diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml
index fb76f2ba0..590fd8a5a 100644
--- a/.github/workflows/release-tip.yml
+++ b/.github/workflows/release-tip.yml
@@ -48,6 +48,17 @@ jobs:
with:
nix_path: nixpkgs=channel:nixos-unstable
+ # Setup Sparkle
+ - name: Setup Sparkle
+ env:
+ SPARKLE_VERSION: 2.5.1
+ run: |
+ mkdir -p .action/sparkle
+ cd .action/sparkle
+ curl -L https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-for-Swift-Package-Manager.zip > sparkle.zip
+ unzip sparkle.zip
+ echo "$(pwd)/bin" >> $GITHUB_PATH
+
# Setup our S3 client
- name: Setup s3cmd
uses: s3-actions/s3cmd@v1.5.0
@@ -55,7 +66,7 @@ jobs:
provider: cloudflare
account_id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }}
access_key: ${{ secrets.CF_R2_TIP_AWS_KEY }}
- secreT_key: ${{ secrets.CF_R2_TIP_SECRET_KEY }}
+ secret_key: ${{ secrets.CF_R2_TIP_SECRET_KEY }}
# Load Build Number
- name: Build Number
@@ -76,12 +87,18 @@ jobs:
# We inject the "build number" as simply the number of commits since HEAD.
# This will be a monotonically always increasing build number that we use.
- - name: Inject Build Number
+ - name: Update Info.plist
+ env:
+ SPARKLE_KEY_PUB: ${{ secrets.PROD_MACOS_SPARKLE_KEY_PUB }}
run: |
+ # Version Info
/usr/libexec/PlistBuddy -c "Set :GhosttyCommit $GHOSTTY_COMMIT" "macos/build/Release/Ghostty.app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $GHOSTTY_BUILD" "macos/build/Release/Ghostty.app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $GHOSTTY_COMMIT" "macos/build/Release/Ghostty.app/Contents/Info.plist"
+ # Updater
+ /usr/libexec/PlistBuddy -c "Set :SUPublicEDKey $SPARKLE_KEY_PUB" "macos/build/Release/Ghostty.app/Contents/Info.plist"
+
- name: Zip Unsigned App
run: nix develop -c sh -c 'cd macos/build/Release && zip -9 -r --symlinks ../../../ghostty-macos-universal-unsigned.zip Ghostty.app'
@@ -115,8 +132,19 @@ jobs:
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain
- # We finally codesign our app bundle, specifying the Hardened runtime option
- /usr/bin/codesign --force -s "$MACOS_CERTIFICATE_NAME" --options runtime macos/build/Release/Ghostty.app -v
+ # Codesign Sparkle. Some notes here:
+ # - The XPC services aren't used since we don't sandbox Ghostty,
+ # but since they're part of the build, they still need to be
+ # codesigned.
+ # - The binaries in the "Versions" folders need to NOT be symlinks.
+ /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpc"
+ /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc"
+ /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate"
+ /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app"
+ /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework"
+
+ # Codesign the app bundle
+ /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime macos/build/Release/Ghostty.app
- name: "Notarize app bundle"
env:
@@ -162,7 +190,19 @@ jobs:
files: ghostty-macos-universal.zip
token: ${{ secrets.GH_RELEASE_TOKEN }}
+ # Create our appcast for Sparkle
+ - name: Generate Appcast
+ env:
+ SPARKLE_KEY: ${{ secrets.PROD_MACOS_SPARKLE_KEY }}
+ run: |
+ echo $SPARKLE_KEY > signing.key
+ sign_update -f signing.key ghostty-macos-universal.zip > sign_update.txt
+ curl -L https://tip.files.ghostty.dev/appcast.xml > appcast.xml
+ python3 ./dist/macos/update_appcast_tip.py
+ test -f appcast_new.xml
+
# Update Blob Storage
- name: Upload to Blob Storage
run: |
s3cmd put ghostty-macos-universal.zip s3://ghostty-tip/${GHOSTTY_BUILD}/ghostty-macos-universal.zip
+ s3cmd put appcast_new.xml s3://ghostty-tip/appcast.xml
diff --git a/dist/macos/update_appcast_tip.py b/dist/macos/update_appcast_tip.py
new file mode 100644
index 000000000..200583e8a
--- /dev/null
+++ b/dist/macos/update_appcast_tip.py
@@ -0,0 +1,75 @@
+"""
+This script is used to update the appcast.xml file for Ghostty releases.
+The script is currently hardcoded to only work for tip releases and therefore
+doesn't have rich release notes, hardcodes the URL to the tip bucket, etc.
+
+This expects the following files in the current directory:
+ - sign_update.txt - contains the output from "sign_update" in the Sparkle
+ framework for the current build.
+ - appcast.xml - the existing appcast file.
+
+And the following environment variables to be set:
+ - GHOSTTY_BUILD - the build number
+ - GHOSTTY_COMMIT - the commit hash
+
+The script will output a new appcast file called appcast_new.xml.
+"""
+
+import os
+import xml.etree.ElementTree as ET
+from datetime import datetime, timezone
+
+now = datetime.now(timezone.utc)
+build = os.environ["GHOSTTY_BUILD"]
+commit = os.environ["GHOSTTY_COMMIT"]
+
+# Read our sign_update output
+with open("sign_update.txt", "r") as f:
+ # format is a=b b=c etc. create a map of this. values may contain equal
+ # signs, so we can't just split on equal signs.
+ attrs = {}
+ for pair in f.read().split(" "):
+ key, value = pair.split("=", 1)
+ value = value.strip()
+ if value[0] == '"':
+ value = value[1:-1]
+ attrs[key] = value
+
+# We need to register our namespaces before reading or writing any files.
+ET.register_namespace("sparkle", "http://www.andymatuschak.org/xml-namespaces/sparkle")
+
+# Open our existing appcast and find the channel element. This is where
+# we'll add our new item.
+et = ET.parse('appcast.xml')
+channel = et.find("channel")
+
+# Create the item using some absoultely terrible XML manipulation.
+item = ET.SubElement(channel, "item")
+elem = ET.SubElement(item, "title")
+elem.text = f"Build {build}"
+elem = ET.SubElement(item, "pubDate")
+elem.text = now.strftime("%a, %d %b %Y %H:%M:%S %z")
+elem = ET.SubElement(item, "sparkle:version")
+elem.text = build
+elem = ET.SubElement(item, "sparkle:shortVersionString")
+elem.text = commit
+elem = ET.SubElement(item, "sparkle:minimumSystemVersion")
+elem.text = "12.0.0"
+elem = ET.SubElement(item, "description")
+elem.text = f"""
+
Automated build from commit {commit}
.
+
+These are automatic per-commit builds generated from the main Git branch.
+We do not generate any release notes for these builds. You can view the full
+commit history
+on GitHub for all changes.
+
+"""
+elem = ET.SubElement(item, "enclosure")
+elem.set("url", f"https://tip.files.ghostty.dev/{build}/ghostty-macos-universal.zip")
+elem.set("type", "application/octet-stream")
+for key, value in attrs.items():
+ elem.set(key, value)
+
+# Output the new appcast.
+et.write("appcast_new.xml", xml_declaration=True, encoding="utf-8")
diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist
index f0311e217..a2066680c 100644
--- a/macos/Ghostty-Info.plist
+++ b/macos/Ghostty-Info.plist
@@ -91,6 +91,8 @@
A program in Ghostty wants to use speech recognition.
NSSystemAdministrationUsageDescription
A program in Ghostty requires elevated privileges.
+ SUPublicEDKey
+ wsNcGf5hirwtdXMVnYoxRIX/SqZQLMOsYlD3q3imeok=
CFBundleVersion
CFBundleShortVersionString
diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj
index 87033b409..e43e98993 100644
--- a/macos/Ghostty.xcodeproj/project.pbxproj
+++ b/macos/Ghostty.xcodeproj/project.pbxproj
@@ -13,6 +13,8 @@
A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51BFC1D2B2FB5CE00E92F16 /* About.xib */; };
A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */; };
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC212B2FB6B400E92F16 /* AboutView.swift */; };
+ A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A51BFC262B30F1B800E92F16 /* Sparkle */; };
+ A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC2A2B30F6BE00E92F16 /* UpdateDelegate.swift */; };
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */; };
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */; };
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; };
@@ -57,6 +59,8 @@
A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; };
A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = ""; };
A51BFC212B2FB6B400E92F16 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; };
+ A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = ""; };
+ A51BFC2A2B30F6BE00E92F16 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateDelegate.swift; sourceTree = ""; };
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Input.swift; sourceTree = ""; };
A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; };
@@ -102,6 +106,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */,
A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */,
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */,
);
@@ -120,6 +125,14 @@
path = About;
sourceTree = "";
};
+ A51BFC292B30F69F00E92F16 /* Update */ = {
+ isa = PBXGroup;
+ children = (
+ A51BFC2A2B30F6BE00E92F16 /* UpdateDelegate.swift */,
+ );
+ path = Update;
+ sourceTree = "";
+ };
A53426362A7DC53000EBB7A2 /* Features */ = {
isa = PBXGroup;
children = (
@@ -128,6 +141,7 @@
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
A534263E2A7DCC5800EBB7A2 /* Settings */,
A51BFC1C2B2FB5AB00E92F16 /* About */,
+ A51BFC292B30F69F00E92F16 /* Update */,
);
path = Features;
sourceTree = "";
@@ -221,6 +235,7 @@
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */,
A5B30538299BEAAB0047F10C /* Assets.xcassets */,
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */,
+ A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */,
A54CD6ED299BEB14008C95BB /* Sources */,
A5D495A3299BECBA00DD1313 /* Frameworks */,
A5A1F8862A489D7400D1E8BC /* Resources */,
@@ -280,6 +295,9 @@
dependencies = (
);
name = Ghostty;
+ packageProductDependencies = (
+ A51BFC262B30F1B800E92F16 /* Sparkle */,
+ );
productName = Ghostty;
productReference = A5B30531299BEAAA0047F10C /* Ghostty.app */;
productType = "com.apple.product-type.application";
@@ -308,6 +326,9 @@
Base,
);
mainGroup = A5B30528299BEAAA0047F10C;
+ packageReferences = (
+ A51BFC252B30F1B700E92F16 /* XCRemoteSwiftPackageReference "Sparkle" */,
+ );
productRefGroup = A5B30532299BEAAA0047F10C /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -349,6 +370,7 @@
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
+ A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */,
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
@@ -496,7 +518,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
- CODE_SIGN_ENTITLEMENTS = Ghostty.entitlements;
+ CODE_SIGN_ENTITLEMENTS = GhosttyDebug.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
@@ -583,6 +605,25 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+ A51BFC252B30F1B700E92F16 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/sparkle-project/Sparkle";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 2.5.1;
+ };
+ };
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ A51BFC262B30F1B800E92F16 /* Sparkle */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = A51BFC252B30F1B700E92F16 /* XCRemoteSwiftPackageReference "Sparkle" */;
+ productName = Sparkle;
+ };
+/* End XCSwiftPackageProductDependency section */
};
rootObject = A5B30529299BEAAA0047F10C /* Project object */;
}
diff --git a/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 000000000..70af83f79
--- /dev/null
+++ b/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,14 @@
+{
+ "pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "1f07f4096e52f19b5e7abaa697b7fc592b7ff57c",
+ "version" : "2.5.1"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/macos/GhosttyDebug.entitlements b/macos/GhosttyDebug.entitlements
new file mode 100644
index 000000000..12b429c28
--- /dev/null
+++ b/macos/GhosttyDebug.entitlements
@@ -0,0 +1,22 @@
+
+
+
+
+ com.apple.security.automation.apple-events
+
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.device.camera
+
+ com.apple.security.personal-information.addressbook
+
+ com.apple.security.personal-information.calendars
+
+ com.apple.security.personal-information.location
+
+ com.apple.security.personal-information.photos-library
+
+
+
diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift
index d1b16d176..41c6ea5de 100644
--- a/macos/Sources/AppDelegate.swift
+++ b/macos/Sources/AppDelegate.swift
@@ -1,9 +1,15 @@
import AppKit
import UserNotifications
import OSLog
+import Sparkle
import GhosttyKit
-class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, GhosttyAppStateDelegate {
+class AppDelegate: NSObject,
+ ObservableObject,
+ NSApplicationDelegate,
+ UNUserNotificationCenterDelegate,
+ GhosttyAppStateDelegate
+{
// The application logger. We should probably move this at some point to a dedicated
// class/struct but for now it lives here! 🤷‍♂️
static let logger = Logger(
@@ -12,6 +18,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, UNUserNoti
)
/// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config.
+ @IBOutlet private var menuCheckForUpdates: NSMenuItem?
@IBOutlet private var menuOpenConfig: NSMenuItem?
@IBOutlet private var menuReloadConfig: NSMenuItem?
@IBOutlet private var menuQuit: NSMenuItem?
@@ -56,8 +63,18 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, UNUserNoti
/// Manages our terminal windows.
let terminalManager: TerminalManager
+ /// Manages updates
+ let updaterController: SPUStandardUpdaterController
+ let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
+
override init() {
- self.terminalManager = TerminalManager(ghostty)
+ terminalManager = TerminalManager(ghostty)
+ updaterController = SPUStandardUpdaterController(
+ startingUpdater: true,
+ updaterDelegate: updaterDelegate,
+ userDriverDelegate: nil
+ )
+
super.init()
ghostty.delegate = self
@@ -80,6 +97,10 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, UNUserNoti
"ApplePressAndHoldEnabled": false,
])
+ // Hook up updater menu
+ menuCheckForUpdates?.target = updaterController
+ menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:))
+
// Let's launch our first window. We only do this if we have no other windows. It
// is possible to have other windows if we're opening a URL since `application(_:openFile:)`
// is called before this.
diff --git a/macos/Sources/Features/Update/UpdateDelegate.swift b/macos/Sources/Features/Update/UpdateDelegate.swift
new file mode 100644
index 000000000..c116432df
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdateDelegate.swift
@@ -0,0 +1,11 @@
+import Sparkle
+
+class UpdaterDelegate: NSObject, SPUUpdaterDelegate {
+ func feedURLString(for updater: SPUUpdater) -> String? {
+ // Eventually w want to support multiple channels. Sparkle itself supports
+ // channels but we probably don't want some appcasts in the same file (i.e.
+ // tip) so this would be the place to change that. For now, we hardcode the
+ // tip appcast URL since it is all we support.
+ return "https://tip.files.ghostty.dev/appcast.xml"
+ }
+}
diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib
index df7635f9a..87b197e6b 100644
--- a/macos/Sources/MainMenu.xib
+++ b/macos/Sources/MainMenu.xib
@@ -14,6 +14,7 @@
+
@@ -58,6 +59,9 @@
+