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 @@ + + +