From e3ee2469309ba234be692b88b2372dd65a22709a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:25:34 -0800 Subject: [PATCH] Add Sparkle auto-update flow and titlebar update UI --- .github/workflows/release.yml | 16 +- CLAUDE.md | 1 + GhosttyTabs.xcodeproj/project.pbxproj | 83 +++- README.md | 31 ++ Sources/AppDelegate.swift | 18 +- Sources/ContentView.swift | 138 +++---- Sources/TabManager.swift | 29 ++ Sources/Update/UpdateBadge.swift | 65 ++++ Sources/Update/UpdateController.swift | 90 +++++ Sources/Update/UpdateDelegate.swift | 28 ++ Sources/Update/UpdateDriver.swift | 181 +++++++++ Sources/Update/UpdatePill.swift | 96 +++++ Sources/Update/UpdatePopoverView.swift | 377 +++++++++++++++++++ Sources/Update/UpdateTitlebarAccessory.swift | 128 +++++++ Sources/Update/UpdateViewModel.swift | 345 +++++++++++++++++ Sources/WindowToolbarController.swift | 135 +++++++ Sources/cmuxApp.swift | 10 +- scripts/sparkle_generate_appcast.sh | 62 +++ scripts/sparkle_generate_keys.sh | 60 +++ 19 files changed, 1813 insertions(+), 80 deletions(-) create mode 100644 Sources/Update/UpdateBadge.swift create mode 100644 Sources/Update/UpdateController.swift create mode 100644 Sources/Update/UpdateDelegate.swift create mode 100644 Sources/Update/UpdateDriver.swift create mode 100644 Sources/Update/UpdatePill.swift create mode 100644 Sources/Update/UpdatePopoverView.swift create mode 100644 Sources/Update/UpdateTitlebarAccessory.swift create mode 100644 Sources/Update/UpdateViewModel.swift create mode 100644 Sources/WindowToolbarController.swift create mode 100755 scripts/sparkle_generate_appcast.sh create mode 100755 scripts/sparkle_generate_keys.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa536d08..b2e3b8c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,8 @@ jobs: cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework - name: Build app (Release) + env: + SPARKLE_PUBLIC_KEY: ${{ secrets.SPARKLE_PUBLIC_KEY }} run: | xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build @@ -117,10 +119,22 @@ jobs: xcrun stapler staple "$DMG_RELEASE" xcrun stapler validate "$DMG_RELEASE" + - name: Generate Sparkle appcast + env: + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + run: | + if [ -z "$SPARKLE_PRIVATE_KEY" ]; then + echo "Missing SPARKLE_PRIVATE_KEY secret" >&2 + exit 1 + fi + ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml + - name: Upload release asset uses: softprops/action-gh-release@v2 with: - files: cmux-macos.dmg + files: | + cmux-macos.dmg + appcast.xml generate_release_notes: true - name: Cleanup keychain diff --git a/CLAUDE.md b/CLAUDE.md index b8573df2..730fc70e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,3 +25,4 @@ Notes: `APPLE_SIGNING_IDENTITY`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`. - The release asset is `cmux-macos.dmg` attached to the tag. - README download button points to `releases/latest/download/cmux-macos.dmg`. +- Versioning: bump the minor version for updates unless explicitly asked otherwise. diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 74d1f57d..24d987a6 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -20,7 +20,17 @@ A50010A4 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50010A0 /* SplitTree.swift */; }; A50010A5 /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50010A1 /* SplitView.swift */; }; A50010A6 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50010A2 /* TerminalSplitTreeView.swift */; }; + A5001201 /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001211 /* UpdateController.swift */; }; + A5001202 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001212 /* UpdateDelegate.swift */; }; + A5001203 /* UpdateDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001213 /* UpdateDriver.swift */; }; + A5001204 /* UpdateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001214 /* UpdateViewModel.swift */; }; + A5001205 /* UpdatePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001215 /* UpdatePill.swift */; }; + A5001206 /* UpdateBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001216 /* UpdateBadge.swift */; }; + A5001207 /* UpdatePopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001217 /* UpdatePopoverView.swift */; }; + A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; }; + A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; + A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; /* End PBXBuildFile section */ @@ -66,6 +76,15 @@ A50010A0 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splits/SplitTree.swift; sourceTree = ""; }; A50010A1 /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splits/SplitView.swift; sourceTree = ""; }; A50010A2 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splits/TerminalSplitTreeView.swift; sourceTree = ""; }; + A5001211 /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateController.swift; sourceTree = ""; }; + A5001212 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDelegate.swift; sourceTree = ""; }; + A5001213 /* UpdateDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDriver.swift; sourceTree = ""; }; + A5001214 /* UpdateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateViewModel.swift; sourceTree = ""; }; + A5001215 /* UpdatePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdatePill.swift; sourceTree = ""; }; + A5001216 /* UpdateBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateBadge.swift; sourceTree = ""; }; + A5001217 /* UpdatePopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdatePopoverView.swift; sourceTree = ""; }; + A5001218 /* UpdateTitlebarAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTitlebarAccessory.swift; sourceTree = ""; }; + A5001219 /* WindowToolbarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowToolbarController.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "terminfo/78/xterm-ghostty"; sourceTree = ""; }; @@ -77,6 +96,7 @@ buildActionMask = 2147483647; files = ( A5001006 /* GhosttyKit.xcframework in Frameworks */, + A5001230 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -138,6 +158,15 @@ A50010A0 /* SplitTree.swift */, A50010A1 /* SplitView.swift */, A50010A2 /* TerminalSplitTreeView.swift */, + A5001211 /* UpdateController.swift */, + A5001212 /* UpdateDelegate.swift */, + A5001213 /* UpdateDriver.swift */, + A5001214 /* UpdateViewModel.swift */, + A5001215 /* UpdatePill.swift */, + A5001216 /* UpdateBadge.swift */, + A5001217 /* UpdatePopoverView.swift */, + A5001218 /* UpdateTitlebarAccessory.swift */, + A5001219 /* WindowToolbarController.swift */, ); path = Sources; sourceTree = ""; @@ -183,6 +212,9 @@ ); dependencies = ( ); + packageProductDependencies = ( + A5001231 /* Sparkle */, + ); name = GhosttyTabs; productName = GhosttyTabs; productReference = A5001000 /* cmux.app */; @@ -225,6 +257,9 @@ Base, ); mainGroup = A5001040; + packageReferences = ( + A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); productRefGroup = A5001042 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -252,6 +287,15 @@ A50010A4 /* SplitTree.swift in Sources */, A50010A5 /* SplitView.swift in Sources */, A50010A6 /* TerminalSplitTreeView.swift in Sources */, + A5001201 /* UpdateController.swift in Sources */, + A5001202 /* UpdateDelegate.swift in Sources */, + A5001203 /* UpdateDriver.swift in Sources */, + A5001204 /* UpdateViewModel.swift in Sources */, + A5001205 /* UpdatePill.swift in Sources */, + A5001206 /* UpdateBadge.swift in Sources */, + A5001207 /* UpdatePopoverView.swift in Sources */, + A5001208 /* UpdateTitlebarAccessory.swift in Sources */, + A5001209 /* WindowToolbarController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -342,7 +386,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -352,11 +396,13 @@ INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSMainStoryboardFile = ""; INFOPLIST_KEY_NSPrincipalClass = NSApplication; + INFOPLIST_KEY_SUFeedURL = "https://github.com/manaflow-ai/GhosttyTabs/releases/latest/download/appcast.xml"; + INFOPLIST_KEY_SUPublicEDKey = "$(SPARKLE_PUBLIC_KEY)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0.4; + MARKETING_VERSION = 1.1.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -385,7 +431,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -395,11 +441,13 @@ INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSMainStoryboardFile = ""; INFOPLIST_KEY_NSPrincipalClass = NSApplication; + INFOPLIST_KEY_SUFeedURL = "https://github.com/manaflow-ai/GhosttyTabs/releases/latest/download/appcast.xml"; + INFOPLIST_KEY_SUPublicEDKey = "$(SPARKLE_PUBLIC_KEY)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0.4; + MARKETING_VERSION = 1.1.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -426,10 +474,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0.4; + MARKETING_VERSION = 1.1.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmux.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -443,10 +491,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0.4; + MARKETING_VERSION = 1.1.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmux.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -457,6 +505,25 @@ }; /* End XCBuildConfiguration section */ +/* Begin XCRemoteSwiftPackageReference section */ + A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.5.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A5001231 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin XCConfigurationList section */ A5001060 /* Build configuration list for PBXNativeTarget "GhosttyTabs" */ = { isa = XCConfigurationList; diff --git a/README.md b/README.md index 5151818d..b8291906 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,35 @@ Tag a version like `v0.1.0` and push it to trigger the GitHub Actions release wo The workflow builds `GhosttyKit.xcframework`, builds the Release app, signs, notarizes, staples, and uploads `cmux-macos.dmg` to the release. +## Auto updates + +cmux uses Sparkle with the same update UI flow as upstream Ghostty. The app looks for +an appcast at: + +``` +https://github.com/manaflow-ai/GhosttyTabs/releases/latest/download/appcast.xml +``` + +To sign updates, set these secrets for release builds: + +- `SPARKLE_PUBLIC_KEY`: Sparkle EdDSA public key (embedded in the app). +- `SPARKLE_PRIVATE_KEY`: Sparkle EdDSA private key (used when generating appcasts). + +You still need to generate and upload `appcast.xml` alongside each release asset. + +To generate keys locally (stores the private key in your Keychain and appends values +to `.env`), run: + +```bash +./scripts/sparkle_generate_keys.sh +``` + +For manual appcast generation (uses `SPARKLE_PRIVATE_KEY`): + +```bash +SPARKLE_PRIVATE_KEY=... ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg vX.Y.Z appcast.xml +``` + ### Required GitHub secrets - `APPLE_CERTIFICATE_BASE64`: Base64-encoded Developer ID Application .p12 @@ -18,3 +47,5 @@ staples, and uploads `cmux-macos.dmg` to the release. - `APPLE_ID`: Apple ID used for notarization - `APPLE_APP_SPECIFIC_PASSWORD`: App-specific password for the Apple ID - `APPLE_TEAM_ID`: Apple Developer Team ID +- `SPARKLE_PUBLIC_KEY`: Sparkle EdDSA public key for update verification +- `SPARKLE_PRIVATE_KEY`: Sparkle EdDSA private key for appcast signing diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b3f04571..1af2ddf6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2,12 +2,18 @@ import AppKit import CoreServices import UserNotifications -final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { static var shared: AppDelegate? weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? private var workspaceObserver: NSObjectProtocol? + private let updateController = UpdateController() + private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) + + var updateViewModel: UpdateViewModel { + updateController.viewModel + } override init() { super.init() @@ -22,6 +28,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ensureApplicationIcon() observeDuplicateLaunches() configureUserNotifications() + updateController.startUpdater() + titlebarAccessoryController.start() } func applicationWillTerminate(_ notification: Notification) { @@ -33,6 +41,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.notificationStore = notificationStore } + @objc func checkForUpdates(_ sender: Any?) { + updateController.checkForUpdates() + } + + func validateMenuItem(_ item: NSMenuItem) -> Bool { + updateController.validateMenuItem(item) + } + private func configureUserNotifications() { let actions = [ UNNotificationAction( diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 21011a3e..54d0f9e2 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2,6 +2,7 @@ import AppKit import SwiftUI struct ContentView: View { + @ObservedObject var updateViewModel: UpdateViewModel @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore @State private var sidebarWidth: CGFloat = 200 @@ -16,85 +17,85 @@ struct ContentView: View { var body: some View { HStack(spacing: 0) { - // Vertical Tabs Sidebar - VerticalTabsSidebar( - sidebarWidth: sidebarWidth, - selection: $sidebarSelection, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex - ) - .frame(width: sidebarWidth) - .background(GeometryReader { proxy in - Color.clear - .preference(key: SidebarFramePreferenceKey.self, value: proxy.frame(in: .global)) - }) - .overlay(alignment: .trailing) { - Color.clear - .frame(width: sidebarHandleWidth) - .contentShape(Rectangle()) - .accessibilityIdentifier("SidebarResizer") - .onHover { hovering in - if hovering { - if !isResizerHovering { - NSCursor.resizeLeftRight.push() - isResizerHovering = true + // Vertical Tabs Sidebar + VerticalTabsSidebar( + sidebarWidth: sidebarWidth, + selection: $sidebarSelection, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex + ) + .frame(width: sidebarWidth) + .background(GeometryReader { proxy in + Color.clear + .preference(key: SidebarFramePreferenceKey.self, value: proxy.frame(in: .global)) + }) + .overlay(alignment: .trailing) { + Color.clear + .frame(width: sidebarHandleWidth) + .contentShape(Rectangle()) + .accessibilityIdentifier("SidebarResizer") + .onHover { hovering in + if hovering { + if !isResizerHovering { + NSCursor.resizeLeftRight.push() + isResizerHovering = true + } + } else if isResizerHovering { + if !isResizerDragging { + NSCursor.pop() + isResizerHovering = false + } } - } else if isResizerHovering { - if !isResizerDragging { + } + .onDisappear { + if isResizerHovering || isResizerDragging { NSCursor.pop() isResizerHovering = false + isResizerDragging = false } } - } - .onDisappear { - if isResizerHovering || isResizerDragging { - NSCursor.pop() - isResizerHovering = false - isResizerDragging = false - } - } - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .global) - .onChanged { value in - if !isResizerDragging { - isResizerDragging = true - if !isResizerHovering { - NSCursor.resizeLeftRight.push() - isResizerHovering = true + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + if !isResizerDragging { + isResizerDragging = true + if !isResizerHovering { + NSCursor.resizeLeftRight.push() + isResizerHovering = true + } + } + let nextWidth = max(140, min(360, value.location.x - sidebarMinX + sidebarHandleWidth / 2)) + withTransaction(Transaction(animation: nil)) { + sidebarWidth = nextWidth } } - let nextWidth = max(140, min(360, value.location.x - sidebarMinX + sidebarHandleWidth / 2)) - withTransaction(Transaction(animation: nil)) { - sidebarWidth = nextWidth - } - } - .onEnded { _ in - if isResizerDragging { - isResizerDragging = false - if !isResizerHovering { - NSCursor.pop() + .onEnded { _ in + if isResizerDragging { + isResizerDragging = false + if !isResizerHovering { + NSCursor.pop() + } } } - } - ) - } - - // Terminal Content - use ZStack to keep all surfaces alive - ZStack { - ZStack { - ForEach(tabManager.tabs) { tab in - let isActive = tabManager.selectedTabId == tab.id - TerminalSplitTreeView(tab: tab, isTabActive: isActive) - .opacity(isActive ? 1 : 0) - .allowsHitTesting(isActive) - .focusable() - .focused($focusedTabId, equals: tab.id) - } + ) } - .opacity(sidebarSelection == .tabs ? 1 : 0) - .allowsHitTesting(sidebarSelection == .tabs) - NotificationsPage(selection: $sidebarSelection) + // Terminal Content - use ZStack to keep all surfaces alive + ZStack { + ZStack { + ForEach(tabManager.tabs) { tab in + let isActive = tabManager.selectedTabId == tab.id + TerminalSplitTreeView(tab: tab, isTabActive: isActive) + .opacity(isActive ? 1 : 0) + .allowsHitTesting(isActive) + .focusable() + .focused($focusedTabId, equals: tab.id) + } + } + .opacity(sidebarSelection == .tabs ? 1 : 0) + .allowsHitTesting(sidebarSelection == .tabs) + + NotificationsPage(selection: $sidebarSelection) .opacity(sidebarSelection == .notifications ? 1 : 0) .allowsHitTesting(sidebarSelection == .notifications) } @@ -139,6 +140,7 @@ struct ContentView: View { sidebarMinX = frame.minX } } + } struct VerticalTabsSidebar: View { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index a7452ab8..fef8e08b 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -220,6 +220,7 @@ class TabManager: ObservableObject { let previousTabId = oldValue DispatchQueue.main.async { [weak self] in self?.focusSelectedTabSurface(previousTabId: previousTabId) + self?.updateWindowTitleForSelectedTab() } } } @@ -399,9 +400,37 @@ class TabManager: ObservableObject { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } if tabs[index].title != title { tabs[index].title = title + if selectedTabId == tabId { + updateWindowTitle(for: tabs[index]) + } } } + private func updateWindowTitleForSelectedTab() { + guard let selectedTabId, + let tab = tabs.first(where: { $0.id == selectedTabId }) else { + updateWindowTitle(for: nil) + return + } + updateWindowTitle(for: tab) + } + + private func updateWindowTitle(for tab: Tab?) { + let title = windowTitle(for: tab) + let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first + targetWindow?.title = title + } + + private func windowTitle(for tab: Tab?) -> String { + guard let tab else { return "cmux" } + let trimmedTitle = tab.title.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedTitle.isEmpty { + return trimmedTitle + } + let trimmedDirectory = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedDirectory.isEmpty ? "cmux" : trimmedDirectory + } + func focusTab(_ tabId: UUID, surfaceId: UUID? = nil) { guard tabs.contains(where: { $0.id == tabId }) else { return } selectedTabId = tabId diff --git a/Sources/Update/UpdateBadge.swift b/Sources/Update/UpdateBadge.swift new file mode 100644 index 00000000..f1837419 --- /dev/null +++ b/Sources/Update/UpdateBadge.swift @@ -0,0 +1,65 @@ +import SwiftUI + +/// A badge view that displays the current state of an update operation. +struct UpdateBadge: View { + @ObservedObject var model: UpdateViewModel + @State private var rotationAngle: Double = 0 + + var body: some View { + badgeContent + .accessibilityLabel(model.text) + } + + @ViewBuilder + private var badgeContent: some View { + switch model.state { + case .downloading(let download): + if let expectedLength = download.expectedLength, expectedLength > 0 { + let progress = min(1, max(0, Double(download.progress) / Double(expectedLength))) + ProgressRingView(progress: progress) + } else { + Image(systemName: "arrow.down.circle") + } + + case .extracting(let extracting): + ProgressRingView(progress: min(1, max(0, extracting.progress))) + + case .checking: + if let iconName = model.iconName { + Image(systemName: iconName) + .rotationEffect(.degrees(rotationAngle)) + .onAppear { + withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) { + rotationAngle = 360 + } + } + .onDisappear { + rotationAngle = 0 + } + } + + default: + if let iconName = model.iconName { + Image(systemName: iconName) + } + } + } +} + +fileprivate struct ProgressRingView: View { + let progress: Double + let lineWidth: CGFloat = 2 + + var body: some View { + ZStack { + Circle() + .stroke(Color.primary.opacity(0.2), lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: progress) + .stroke(Color.primary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: progress) + } + } +} diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift new file mode 100644 index 00000000..e3e21b59 --- /dev/null +++ b/Sources/Update/UpdateController.swift @@ -0,0 +1,90 @@ +import Sparkle +import Cocoa +import Combine + +/// Controller for managing Sparkle updates in cmux. +class UpdateController { + private(set) var updater: SPUUpdater + private let userDriver: UpdateDriver + private var installCancellable: AnyCancellable? + + var viewModel: UpdateViewModel { + userDriver.viewModel + } + + /// True if we're force-installing an update. + var isInstalling: Bool { + installCancellable != nil + } + + init() { + let hostBundle = Bundle.main + self.userDriver = UpdateDriver(viewModel: .init(), hostBundle: hostBundle) + self.updater = SPUUpdater( + hostBundle: hostBundle, + applicationBundle: hostBundle, + userDriver: userDriver, + delegate: userDriver + ) + } + + deinit { + installCancellable?.cancel() + } + + /// Start the updater. If startup fails, the error is shown via the custom UI. + func startUpdater() { + do { + try updater.start() + } catch { + userDriver.viewModel.state = .error(.init( + error: error, + retry: { [weak self] in + self?.userDriver.viewModel.state = .idle + self?.startUpdater() + }, + dismiss: { [weak self] in + self?.userDriver.viewModel.state = .idle + } + )) + } + } + + /// Force install the current update by auto-confirming all installable states. + func installUpdate() { + guard viewModel.state.isInstallable else { return } + guard installCancellable == nil else { return } + + installCancellable = viewModel.$state.sink { [weak self] state in + guard let self else { return } + guard state.isInstallable else { + self.installCancellable = nil + return + } + state.confirm() + } + } + + /// Check for updates (used by the menu item). + @objc func checkForUpdates() { + if viewModel.state == .idle { + updater.checkForUpdates() + return + } + + installCancellable?.cancel() + viewModel.state.cancel() + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in + self?.updater.checkForUpdates() + } + } + + /// Validate the check for updates menu item. + func validateMenuItem(_ item: NSMenuItem) -> Bool { + if item.action == #selector(checkForUpdates) { + return updater.canCheckForUpdates + } + return true + } +} diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift new file mode 100644 index 00000000..35832a4b --- /dev/null +++ b/Sources/Update/UpdateDelegate.swift @@ -0,0 +1,28 @@ +import Sparkle +import Cocoa + +extension UpdateDriver: SPUUpdaterDelegate { + func feedURLString(for updater: SPUUpdater) -> String? { + Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String + } + + /// Called when an update is scheduled to install silently, + /// which occurs when automatic download is enabled. + func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool { + viewModel.state = .installing(.init( + isAutoUpdate: true, + retryTerminatingApplication: immediateInstallHandler, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + } + )) + return true + } + + func updaterWillRelaunchApplication(_ updater: SPUUpdater) { + NSApp.invalidateRestorableState() + for window in NSApp.windows { + window.invalidateRestorableState() + } + } +} diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift new file mode 100644 index 00000000..bf05c4ae --- /dev/null +++ b/Sources/Update/UpdateDriver.swift @@ -0,0 +1,181 @@ +import Cocoa +import Sparkle + +/// SPUUserDriver that updates the view model for custom update UI. +class UpdateDriver: NSObject, SPUUserDriver { + let viewModel: UpdateViewModel + let standard: SPUStandardUserDriver + + init(viewModel: UpdateViewModel, hostBundle: Bundle) { + self.viewModel = viewModel + self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil) + super.init() + } + + func show(_ request: SPUUpdatePermissionRequest, + reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { + viewModel.state = .permissionRequest(.init(request: request, reply: { [weak viewModel] response in + viewModel?.state = .idle + reply(response) + })) + if !hasUnobtrusiveTarget { + standard.show(request, reply: reply) + } + } + + func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { + viewModel.state = .checking(.init(cancel: cancellation)) + if !hasUnobtrusiveTarget { + standard.showUserInitiatedUpdateCheck(cancellation: cancellation) + } + } + + func showUpdateFound(with appcastItem: SUAppcastItem, + state: SPUUserUpdateState, + reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { + viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply)) + if !hasUnobtrusiveTarget { + standard.showUpdateFound(with: appcastItem, state: state, reply: reply) + } + } + + func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { + // cmux uses Sparkle's UI for release notes links instead. + } + + func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) { + // Release notes are handled via link buttons. + } + + func showUpdateNotFoundWithError(_ error: any Error, + acknowledgement: @escaping () -> Void) { + viewModel.state = .notFound(.init(acknowledgement: acknowledgement)) + + if !hasUnobtrusiveTarget { + standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement) + } + } + + func showUpdaterError(_ error: any Error, + acknowledgement: @escaping () -> Void) { + viewModel.state = .error(.init( + error: error, + retry: { [weak viewModel] in + viewModel?.state = .idle + DispatchQueue.main.async { + guard let delegate = NSApp.delegate as? AppDelegate else { return } + delegate.checkForUpdates(nil) + } + }, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + })) + + if !hasUnobtrusiveTarget { + standard.showUpdaterError(error, acknowledgement: acknowledgement) + } else { + acknowledgement() + } + } + + func showDownloadInitiated(cancellation: @escaping () -> Void) { + viewModel.state = .downloading(.init( + cancel: cancellation, + expectedLength: nil, + progress: 0)) + + if !hasUnobtrusiveTarget { + standard.showDownloadInitiated(cancellation: cancellation) + } + } + + func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { + guard case let .downloading(downloading) = viewModel.state else { + return + } + + viewModel.state = .downloading(.init( + cancel: downloading.cancel, + expectedLength: expectedContentLength, + progress: 0)) + + if !hasUnobtrusiveTarget { + standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength) + } + } + + func showDownloadDidReceiveData(ofLength length: UInt64) { + guard case let .downloading(downloading) = viewModel.state else { + return + } + + viewModel.state = .downloading(.init( + cancel: downloading.cancel, + expectedLength: downloading.expectedLength, + progress: downloading.progress + length)) + + if !hasUnobtrusiveTarget { + standard.showDownloadDidReceiveData(ofLength: length) + } + } + + func showDownloadDidStartExtractingUpdate() { + viewModel.state = .extracting(.init(progress: 0)) + + if !hasUnobtrusiveTarget { + standard.showDownloadDidStartExtractingUpdate() + } + } + + func showExtractionReceivedProgress(_ progress: Double) { + viewModel.state = .extracting(.init(progress: progress)) + + if !hasUnobtrusiveTarget { + standard.showExtractionReceivedProgress(progress) + } + } + + func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { + if !hasUnobtrusiveTarget { + standard.showReady(toInstallAndRelaunch: reply) + } else { + reply(.install) + } + } + + func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { + viewModel.state = .installing(.init( + retryTerminatingApplication: retryTerminatingApplication, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + } + )) + + if !hasUnobtrusiveTarget { + standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication) + } + } + + func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { + standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement) + viewModel.state = .idle + } + + func showUpdateInFocus() { + if !hasUnobtrusiveTarget { + standard.showUpdateInFocus() + } + } + + func dismissUpdateInstallation() { + viewModel.state = .idle + standard.dismissUpdateInstallation() + } + + // MARK: No-Window Fallback + + /// True if there is a target that can render our unobtrusive update checker. + var hasUnobtrusiveTarget: Bool { + NSApp.windows.contains { $0.isVisible } + } +} diff --git a/Sources/Update/UpdatePill.swift b/Sources/Update/UpdatePill.swift new file mode 100644 index 00000000..c3f4b72d --- /dev/null +++ b/Sources/Update/UpdatePill.swift @@ -0,0 +1,96 @@ +import AppKit +import SwiftUI + +/// A pill-shaped button that displays update status and provides access to update actions. +struct UpdatePill: View { + @ObservedObject var model: UpdateViewModel + var showWhenIdle: Bool = false + var idleText: String = "Check for Updates" + var onIdleTap: (() -> Void)? + @State private var showPopover = false + @State private var resetTask: Task? + + private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium) + + var body: some View { + if !model.state.isIdle || showWhenIdle { + pillButton + .popover(isPresented: $showPopover, arrowEdge: .bottom) { + UpdatePopoverView(model: model) + } + .transition(.opacity.combined(with: .scale(scale: 0.95))) + .onChange(of: model.state) { newState in + resetTask?.cancel() + if case .notFound(let notFound) = newState { + resetTask = Task { [weak model] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled, case .notFound? = model?.state else { return } + model?.state = .idle + notFound.acknowledgement() + } + } else { + resetTask = nil + } + } + } + } + + @ViewBuilder + private var pillButton: some View { + Button(action: { + if model.state.isIdle && showWhenIdle { + if let onIdleTap { + onIdleTap() + } else { + showPopover.toggle() + } + return + } + if case .notFound(let notFound) = model.state { + model.state = .idle + notFound.acknowledgement() + } else { + showPopover.toggle() + } + }) { + HStack(spacing: 6) { + if model.state.isIdle && showWhenIdle { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundColor(.secondary) + .frame(width: 14, height: 14) + } else { + UpdateBadge(model: model) + .frame(width: 14, height: 14) + } + + Text(displayText) + .font(Font(textFont)) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: textWidth) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(model.backgroundColor) + ) + .foregroundColor(model.foregroundColor) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .help(displayText) + .accessibilityLabel(displayText) + } + + private var textWidth: CGFloat? { + let attributes: [NSAttributedString.Key: Any] = [.font: textFont] + let text = model.state.isIdle && showWhenIdle ? idleText : model.maxWidthText + let size = (text as NSString).size(withAttributes: attributes) + return size.width + } + + private var displayText: String { + model.state.isIdle && showWhenIdle ? idleText : model.text + } +} diff --git a/Sources/Update/UpdatePopoverView.swift b/Sources/Update/UpdatePopoverView.swift new file mode 100644 index 00000000..09831252 --- /dev/null +++ b/Sources/Update/UpdatePopoverView.swift @@ -0,0 +1,377 @@ +import AppKit +import SwiftUI +import Sparkle + +/// Popover view that displays detailed update information and actions. +struct UpdatePopoverView: View { + @ObservedObject var model: UpdateViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + switch model.state { + case .idle: + EmptyView() + + case .permissionRequest(let request): + PermissionRequestView(request: request, dismiss: dismiss) + + case .checking(let checking): + CheckingView(checking: checking, dismiss: dismiss) + + case .updateAvailable(let update): + UpdateAvailableView(update: update, dismiss: dismiss) + + case .downloading(let download): + DownloadingView(download: download, dismiss: dismiss) + + case .extracting(let extracting): + ExtractingView(extracting: extracting) + + case .installing(let installing): + InstallingView(installing: installing, dismiss: dismiss) + + case .notFound(let notFound): + NotFoundView(notFound: notFound, dismiss: dismiss) + + case .error(let error): + UpdateErrorView(error: error, dismiss: dismiss) + } + } + .frame(width: 300) + } +} + +fileprivate struct PermissionRequestView: View { + let request: UpdateState.PermissionRequest + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Enable automatic updates?") + .font(.system(size: 13, weight: .semibold)) + + Text("cmux can automatically check for updates in the background.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 8) { + Button("Not Now") { + request.reply(SUUpdatePermissionResponse( + automaticUpdateChecks: false, + sendSystemProfile: false)) + dismiss() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Allow") { + request.reply(SUUpdatePermissionResponse( + automaticUpdateChecks: true, + sendSystemProfile: false)) + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + } + } + .padding(16) + } +} + +fileprivate struct CheckingView: View { + let checking: UpdateState.Checking + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Checking for updates…") + .font(.system(size: 13)) + } + + HStack { + Spacer() + Button("Cancel") { + checking.cancel() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + } + } + .padding(16) + } +} + +fileprivate struct UpdateAvailableView: View { + let update: UpdateState.UpdateAvailable + let dismiss: DismissAction + + private let labelWidth: CGFloat = 60 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text("Update Available") + .font(.system(size: 13, weight: .semibold)) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text("Version:") + .foregroundColor(.secondary) + .frame(width: labelWidth, alignment: .trailing) + Text(update.appcastItem.displayVersionString) + } + .font(.system(size: 11)) + + if update.appcastItem.contentLength > 0 { + HStack(spacing: 6) { + Text("Size:") + .foregroundColor(.secondary) + .frame(width: labelWidth, alignment: .trailing) + Text(ByteCountFormatter.string(fromByteCount: Int64(update.appcastItem.contentLength), countStyle: .file)) + } + .font(.system(size: 11)) + } + + if let date = update.appcastItem.date { + HStack(spacing: 6) { + Text("Released:") + .foregroundColor(.secondary) + .frame(width: labelWidth, alignment: .trailing) + Text(date.formatted(date: .abbreviated, time: .omitted)) + } + .font(.system(size: 11)) + } + } + .textSelection(.enabled) + } + + HStack(spacing: 8) { + Button("Skip") { + update.reply(.skip) + dismiss() + } + .controlSize(.small) + + Button("Later") { + update.reply(.dismiss) + dismiss() + } + .controlSize(.small) + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Install and Relaunch") { + update.reply(.install) + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(16) + + if let notes = update.releaseNotes { + Divider() + + Link(destination: notes.url) { + HStack { + Image(systemName: "doc.text") + .font(.system(size: 11)) + Text(notes.label) + .font(.system(size: 11, weight: .medium)) + Spacer() + Image(systemName: "arrow.up.right") + .font(.system(size: 10)) + } + .foregroundColor(.primary) + .padding(12) + .frame(maxWidth: .infinity) + .background(Color(nsColor: .controlBackgroundColor)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } +} + +fileprivate struct DownloadingView: View { + let download: UpdateState.Downloading + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Downloading Update") + .font(.system(size: 13, weight: .semibold)) + + if let expectedLength = download.expectedLength, expectedLength > 0 { + let progress = min(1, max(0, Double(download.progress) / Double(expectedLength))) + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: progress) + Text(String(format: "%.0f%%", progress * 100)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } else { + ProgressView() + .controlSize(.small) + } + } + + HStack { + Spacer() + Button("Cancel") { + download.cancel() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + } + } + .padding(16) + } +} + +fileprivate struct ExtractingView: View { + let extracting: UpdateState.Extracting + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Preparing Update") + .font(.system(size: 13, weight: .semibold)) + + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: min(1, max(0, extracting.progress)), total: 1.0) + Text(String(format: "%.0f%%", min(1, max(0, extracting.progress)) * 100)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + .padding(16) + } +} + +fileprivate struct InstallingView: View { + let installing: UpdateState.Installing + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Restart Required") + .font(.system(size: 13, weight: .semibold)) + + Text("The update is ready. Please restart the application to complete the installation.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Button("Restart Later") { + installing.dismiss() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + + Spacer() + + Button("Restart Now") { + installing.retryTerminatingApplication() + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(16) + } +} + +fileprivate struct NotFoundView: View { + let notFound: UpdateState.NotFound + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("No Updates Found") + .font(.system(size: 13, weight: .semibold)) + + Text("You're already running the latest version.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + Button("OK") { + notFound.acknowledgement() + dismiss() + } + .keyboardShortcut(.defaultAction) + .controlSize(.small) + } + } + .padding(16) + } +} + +fileprivate struct UpdateErrorView: View { + let error: UpdateState.Error + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 13)) + Text("Update Failed") + .font(.system(size: 13, weight: .semibold)) + } + + Text(error.error.localizedDescription) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 8) { + Button("OK") { + error.dismiss() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + + Spacer() + + Button("Retry") { + error.retry() + dismiss() + } + .keyboardShortcut(.defaultAction) + .controlSize(.small) + } + } + .padding(16) + } +} diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift new file mode 100644 index 00000000..4e1f4920 --- /dev/null +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -0,0 +1,128 @@ +import AppKit +import Combine +import SwiftUI + +final class NonDraggableHostingView: NSHostingView { + override var mouseDownCanMoveWindow: Bool { false } +} + +private struct TitlebarAccessoryView: View { + @ObservedObject var model: UpdateViewModel + let onIdleTap: () -> Void + + var body: some View { + UpdatePill( + model: model, + showWhenIdle: false, + onIdleTap: onIdleTap + ) + .fixedSize() + .padding(.top, 4) + .padding(.trailing, 8) + } +} + +final class UpdateAccessoryViewController: NSTitlebarAccessoryViewController { + private var sizeCancellable: AnyCancellable? + + init(model: UpdateViewModel, onIdleTap: @escaping () -> Void) { + super.init(nibName: nil, bundle: nil) + + let hostingView = NonDraggableHostingView(rootView: TitlebarAccessoryView( + model: model, + onIdleTap: onIdleTap + )) + hostingView.setFrameSize(hostingView.fittingSize) + view = hostingView + + sizeCancellable = model.$state + .receive(on: DispatchQueue.main) + .sink { [weak hostingView] _ in + guard let hostingView else { return } + hostingView.invalidateIntrinsicContentSize() + hostingView.layoutSubtreeIfNeeded() + hostingView.setFrameSize(hostingView.fittingSize) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +final class UpdateTitlebarAccessoryController { + private weak var updateViewModel: UpdateViewModel? + private var didStart = false + private let attachedWindows = NSHashTable.weakObjects() + private var observers: [NSObjectProtocol] = [] + + init(viewModel: UpdateViewModel) { + self.updateViewModel = viewModel + } + + deinit { + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + } + + func start() { + guard !didStart else { return } + didStart = true + attachToExistingWindows() + installObservers() + } + + private func installObservers() { + let center = NotificationCenter.default + observers.append(center.addObserver( + forName: NSWindow.didBecomeMainNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let window = notification.object as? NSWindow else { return } + self?.attachIfNeeded(to: window) + }) + + observers.append(center.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let window = notification.object as? NSWindow else { return } + self?.attachIfNeeded(to: window) + }) + } + + private func attachToExistingWindows() { + for window in NSApp.windows { + attachIfNeeded(to: window) + } + } + + private func attachIfNeeded(to window: NSWindow) { + guard let updateViewModel else { return } + guard !attachedWindows.contains(window) else { return } + guard window.styleMask.contains(.titled) else { return } + + let identifier = NSUserInterfaceItemIdentifier("cmux.updateAccessory") + if window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == identifier }) { + attachedWindows.add(window) + return + } + + let accessory = UpdateAccessoryViewController( + model: updateViewModel, + onIdleTap: { + guard let delegate = NSApp.delegate as? AppDelegate else { return } + delegate.checkForUpdates(nil) + } + ) + accessory.layoutAttribute = .right + + accessory.view.identifier = identifier + + window.addTitlebarAccessoryViewController(accessory) + attachedWindows.add(window) + } +} diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift new file mode 100644 index 00000000..8d53caa0 --- /dev/null +++ b/Sources/Update/UpdateViewModel.swift @@ -0,0 +1,345 @@ +import Foundation +import AppKit +import SwiftUI +import Sparkle + +class UpdateViewModel: ObservableObject { + @Published var state: UpdateState = .idle + + var text: String { + switch state { + case .idle: + return "" + case .permissionRequest: + return "Enable Automatic Updates?" + case .checking: + return "Checking for Updates…" + case .updateAvailable(let update): + let version = update.appcastItem.displayVersionString + if !version.isEmpty { + return "Update Available: \(version)" + } + return "Update Available" + case .downloading(let download): + if let expectedLength = download.expectedLength, expectedLength > 0 { + let progress = Double(download.progress) / Double(expectedLength) + return String(format: "Downloading: %.0f%%", progress * 100) + } + return "Downloading…" + case .extracting(let extracting): + return String(format: "Preparing: %.0f%%", extracting.progress * 100) + case .installing(let install): + return install.isAutoUpdate ? "Restart to Complete Update" : "Installing…" + case .notFound: + return "No Updates Available" + case .error(let err): + return err.error.localizedDescription + } + } + + var maxWidthText: String { + switch state { + case .downloading: + return "Downloading: 100%" + case .extracting: + return "Preparing: 100%" + default: + return text + } + } + + var iconName: String? { + switch state { + case .idle: + return nil + case .permissionRequest: + return "questionmark.circle" + case .checking: + return "arrow.triangle.2.circlepath" + case .updateAvailable: + return "shippingbox.fill" + case .downloading: + return "arrow.down.circle" + case .extracting: + return "shippingbox" + case .installing: + return "power.circle" + case .notFound: + return "info.circle" + case .error: + return "exclamationmark.triangle.fill" + } + } + + var description: String { + switch state { + case .idle: + return "" + case .permissionRequest: + return "Configure automatic update preferences" + case .checking: + return "Please wait while we check for available updates" + case .updateAvailable(let update): + return update.releaseNotes?.label ?? "Download and install the latest version" + case .downloading: + return "Downloading the update package" + case .extracting: + return "Extracting and preparing the update" + case let .installing(install): + return install.isAutoUpdate ? "Restart to Complete Update" : "Installing update and preparing to restart" + case .notFound: + return "You are running the latest version" + case .error: + return "An error occurred during the update process" + } + } + + var badge: String? { + switch state { + case .updateAvailable(let update): + let version = update.appcastItem.displayVersionString + return version.isEmpty ? nil : version + case .downloading(let download): + if let expectedLength = download.expectedLength, expectedLength > 0 { + let percentage = Double(download.progress) / Double(expectedLength) * 100 + return String(format: "%.0f%%", percentage) + } + return nil + case .extracting(let extracting): + return String(format: "%.0f%%", extracting.progress * 100) + default: + return nil + } + } + + var iconColor: Color { + switch state { + case .idle: + return .secondary + case .permissionRequest: + return .white + case .checking: + return .secondary + case .updateAvailable: + return .accentColor + case .downloading, .extracting, .installing: + return .secondary + case .notFound: + return .secondary + case .error: + return .orange + } + } + + var backgroundColor: Color { + switch state { + case .permissionRequest: + return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue) + case .updateAvailable: + return .accentColor + case .notFound: + return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue) + case .error: + return .orange.opacity(0.2) + default: + return Color(nsColor: .controlBackgroundColor) + } + } + + var foregroundColor: Color { + switch state { + case .permissionRequest: + return .white + case .updateAvailable: + return .white + case .notFound: + return .white + case .error: + return .orange + default: + return .primary + } + } +} + +enum UpdateState: Equatable { + case idle + case permissionRequest(PermissionRequest) + case checking(Checking) + case updateAvailable(UpdateAvailable) + case notFound(NotFound) + case error(Error) + case downloading(Downloading) + case extracting(Extracting) + case installing(Installing) + + var isIdle: Bool { + if case .idle = self { return true } + return false + } + + var isInstallable: Bool { + switch self { + case .checking, + .updateAvailable, + .downloading, + .extracting, + .installing: + return true + default: + return false + } + } + + func cancel() { + switch self { + case .checking(let checking): + checking.cancel() + case .updateAvailable(let available): + available.reply(.dismiss) + case .downloading(let downloading): + downloading.cancel() + case .notFound(let notFound): + notFound.acknowledgement() + case .error(let err): + err.dismiss() + default: + break + } + } + + func confirm() { + switch self { + case .updateAvailable(let available): + available.reply(.install) + default: + break + } + } + + static func == (lhs: UpdateState, rhs: UpdateState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle): + return true + case (.permissionRequest, .permissionRequest): + return true + case (.checking, .checking): + return true + case (.updateAvailable(let lUpdate), .updateAvailable(let rUpdate)): + return lUpdate.appcastItem.displayVersionString == rUpdate.appcastItem.displayVersionString + case (.notFound, .notFound): + return true + case (.error(let lErr), .error(let rErr)): + return lErr.error.localizedDescription == rErr.error.localizedDescription + case (.downloading(let lDown), .downloading(let rDown)): + return lDown.progress == rDown.progress && lDown.expectedLength == rDown.expectedLength + case (.extracting(let lExt), .extracting(let rExt)): + return lExt.progress == rExt.progress + case (.installing(let lInstall), .installing(let rInstall)): + return lInstall.isAutoUpdate == rInstall.isAutoUpdate + default: + return false + } + } + + struct NotFound { + let acknowledgement: () -> Void + } + + struct PermissionRequest { + let request: SPUUpdatePermissionRequest + let reply: @Sendable (SUUpdatePermissionResponse) -> Void + } + + struct Checking { + let cancel: () -> Void + } + + struct UpdateAvailable { + let appcastItem: SUAppcastItem + let reply: @Sendable (SPUUserUpdateChoice) -> Void + + var releaseNotes: ReleaseNotes? { + ReleaseNotes(displayVersionString: appcastItem.displayVersionString) + } + } + + enum ReleaseNotes { + case commit(URL) + case tagged(URL) + + init?(displayVersionString: String) { + let version = displayVersionString + + if let semver = Self.extractSemanticVersion(from: version) { + let tag = semver.hasPrefix("v") ? semver : "v\(semver)" + if let url = URL(string: "https://github.com/manaflow-ai/GhosttyTabs/releases/tag/\(tag)") { + self = .tagged(url) + return + } + } + + guard let newHash = Self.extractGitHash(from: version) else { + return nil + } + + if let url = URL(string: "https://github.com/manaflow-ai/GhosttyTabs/commit/\(newHash)") { + self = .commit(url) + } else { + return nil + } + } + + private static func extractSemanticVersion(from version: String) -> String? { + let pattern = #"v?\d+\.\d+\.\d+"# + if let range = version.range(of: pattern, options: .regularExpression) { + return String(version[range]) + } + return nil + } + + private static func extractGitHash(from version: String) -> String? { + let pattern = #"[0-9a-f]{7,40}"# + if let range = version.range(of: pattern, options: .regularExpression) { + return String(version[range]) + } + return nil + } + + var url: URL { + switch self { + case .commit(let url): return url + case .tagged(let url): return url + } + } + + var label: String { + switch self { + case .commit: return "View GitHub Commit" + case .tagged: return "View Release Notes" + } + } + } + + struct Error { + let error: any Swift.Error + let retry: () -> Void + let dismiss: () -> Void + } + + struct Downloading { + let cancel: () -> Void + let expectedLength: UInt64? + let progress: UInt64 + } + + struct Extracting { + let progress: Double + } + + struct Installing { + var isAutoUpdate = false + let retryTerminatingApplication: () -> Void + let dismiss: () -> Void + } +} diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift new file mode 100644 index 00000000..2817b082 --- /dev/null +++ b/Sources/WindowToolbarController.swift @@ -0,0 +1,135 @@ +import AppKit +import SwiftUI + +final class WindowToolbarController: NSObject, NSToolbarDelegate { + private let commandItemIdentifier = NSToolbarItem.Identifier("cmux.focusedCommand") + private let updateItemIdentifier = NSToolbarItem.Identifier("cmux.updatePill") + + private weak var tabManager: TabManager? + private weak var updateViewModel: UpdateViewModel? + + private var commandLabels: [ObjectIdentifier: NSTextField] = [:] + private var observers: [NSObjectProtocol] = [] + + init(updateViewModel: UpdateViewModel) { + self.updateViewModel = updateViewModel + super.init() + } + + deinit { + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + } + + func start(tabManager: TabManager) { + self.tabManager = tabManager + attachToExistingWindows() + installObservers() + updateFocusedCommandText() + } + + private func installObservers() { + let center = NotificationCenter.default + observers.append(center.addObserver( + forName: .ghosttyDidSetTitle, + object: nil, + queue: .main + ) { [weak self] _ in + self?.updateFocusedCommandText() + }) + + observers.append(center.addObserver( + forName: .ghosttyDidFocusTab, + object: nil, + queue: .main + ) { [weak self] _ in + self?.updateFocusedCommandText() + }) + + observers.append(center.addObserver( + forName: NSWindow.didBecomeMainNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let window = notification.object as? NSWindow else { return } + self?.attach(to: window) + }) + } + + private func attachToExistingWindows() { + for window in NSApp.windows { + attach(to: window) + } + } + + private func attach(to window: NSWindow) { + guard window.toolbar == nil else { return } + let toolbar = NSToolbar(identifier: NSToolbar.Identifier("cmux.toolbar")) + toolbar.delegate = self + toolbar.displayMode = .iconOnly + toolbar.allowsUserCustomization = false + toolbar.autosavesConfiguration = false + toolbar.showsBaselineSeparator = false + window.toolbar = toolbar + window.toolbarStyle = .unified + window.titleVisibility = .visible + } + + private func updateFocusedCommandText() { + guard let tabManager else { return } + let text: String + if let selectedId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == selectedId }) { + let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines) + text = title.isEmpty ? "Cmd: —" : "Cmd: \(title)" + } else { + text = "Cmd: —" + } + + for label in commandLabels.values { + label.stringValue = text + } + } + + // MARK: - NSToolbarDelegate + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [commandItemIdentifier, .flexibleSpace, updateItemIdentifier] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [commandItemIdentifier, .flexibleSpace, updateItemIdentifier] + } + + func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + if itemIdentifier == commandItemIdentifier { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + let label = NSTextField(labelWithString: "Cmd: —") + label.font = NSFont.systemFont(ofSize: 12, weight: .medium) + label.textColor = .secondaryLabelColor + label.lineBreakMode = .byTruncatingMiddle + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) + item.view = label + commandLabels[ObjectIdentifier(toolbar)] = label + updateFocusedCommandText() + return item + } + + if itemIdentifier == updateItemIdentifier, let updateViewModel { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + let view = NonDraggableHostingView(rootView: UpdatePill( + model: updateViewModel, + showWhenIdle: true, + onIdleTap: { + guard let delegate = NSApp.delegate as? AppDelegate else { return } + delegate.checkForUpdates(nil) + } + )) + item.view = view + return item + } + + return nil + } +} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index a17b2a7a..9ec3645b 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -14,7 +14,7 @@ struct cmuxApp: App { var body: some Scene { WindowGroup { - ContentView() + ContentView(updateViewModel: appDelegate.updateViewModel) .environmentObject(tabManager) .environmentObject(notificationStore) .onAppear { @@ -23,7 +23,7 @@ struct cmuxApp: App { appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore) } } - .windowStyle(.hiddenTitleBar) + .windowToolbarStyle(.automatic) .commands { CommandGroup(replacing: .appInfo) { Button("About cmux") { @@ -31,6 +31,12 @@ struct cmuxApp: App { } } + CommandGroup(after: .appInfo) { + Button("Check for Updates…") { + appDelegate.checkForUpdates(nil) + } + } + // New tab commands CommandGroup(replacing: .newItem) { Button("New Tab") { diff --git a/scripts/sparkle_generate_appcast.sh b/scripts/sparkle_generate_appcast.sh new file mode 100755 index 00000000..2fc107a9 --- /dev/null +++ b/scripts/sparkle_generate_appcast.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 [output-path]" >&2 + exit 1 +fi + +DMG_PATH="$1" +TAG="$2" +OUT_PATH="${3:-appcast.xml}" + +if [[ -z "${SPARKLE_PRIVATE_KEY:-}" ]]; then + echo "SPARKLE_PRIVATE_KEY is required (exported from Sparkle generate_keys)." >&2 + exit 1 +fi + +SPARKLE_VERSION="${SPARKLE_VERSION:-2.8.1}" +DOWNLOAD_URL_PREFIX="${DOWNLOAD_URL_PREFIX:-https://github.com/manaflow-ai/GhosttyTabs/releases/download/$TAG/}" +RELEASE_NOTES_URL="${RELEASE_NOTES_URL:-https://github.com/manaflow-ai/GhosttyTabs/releases/tag/$TAG}" + +work_dir="$(mktemp -d)" +cleanup() { + rm -rf "$work_dir" +} +trap cleanup EXIT + +echo "Cloning Sparkle ${SPARKLE_VERSION}..." +git clone --depth 1 --branch "$SPARKLE_VERSION" https://github.com/sparkle-project/Sparkle "$work_dir/Sparkle" + +echo "Building Sparkle generate_appcast tool..." +xcodebuild \ + -project "$work_dir/Sparkle/Sparkle.xcodeproj" \ + -scheme generate_appcast \ + -configuration Release \ + -derivedDataPath "$work_dir/build" \ + CODE_SIGNING_ALLOWED=NO \ + build >/dev/null + +generate_appcast="$work_dir/build/Build/Products/Release/generate_appcast" +if [[ ! -x "$generate_appcast" ]]; then + echo "generate_appcast binary not found at $generate_appcast" >&2 + exit 1 +fi + +archives_dir="$work_dir/archives" +mkdir -p "$archives_dir" +cp "$DMG_PATH" "$archives_dir/$(basename "$DMG_PATH")" + +printf "%s" "$SPARKLE_PRIVATE_KEY" | "$generate_appcast" \ + --ed-key-file - \ + --download-url-prefix "$DOWNLOAD_URL_PREFIX" \ + --full-release-notes-url "$RELEASE_NOTES_URL" \ + "$archives_dir" + +if [[ ! -f "$archives_dir/appcast.xml" ]]; then + echo "appcast.xml not generated." >&2 + exit 1 +fi + +cp "$archives_dir/appcast.xml" "$OUT_PATH" +echo "Generated appcast at $OUT_PATH" diff --git a/scripts/sparkle_generate_keys.sh b/scripts/sparkle_generate_keys.sh new file mode 100755 index 00000000..5829b1d5 --- /dev/null +++ b/scripts/sparkle_generate_keys.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +SPARKLE_VERSION="${SPARKLE_VERSION:-2.8.1}" +SPARKLE_KEYCHAIN_ACCOUNT="${SPARKLE_KEYCHAIN_ACCOUNT:-cmux}" +SPARKLE_ENV_FILE="${SPARKLE_ENV_FILE:-.env}" + +work_dir="$(mktemp -d)" +cleanup() { + rm -rf "$work_dir" +} +trap cleanup EXIT + +echo "Cloning Sparkle ${SPARKLE_VERSION}..." +git clone --depth 1 --branch "$SPARKLE_VERSION" https://github.com/sparkle-project/Sparkle "$work_dir/Sparkle" + +echo "Building Sparkle generate_keys tool..." +xcodebuild \ + -project "$work_dir/Sparkle/Sparkle.xcodeproj" \ + -scheme generate_keys \ + -configuration Release \ + -derivedDataPath "$work_dir/build" \ + CODE_SIGNING_ALLOWED=NO \ + build >/dev/null + +generate_keys="$work_dir/build/Build/Products/Release/generate_keys" +if [[ ! -x "$generate_keys" ]]; then + echo "generate_keys binary not found at $generate_keys" >&2 + exit 1 +fi + +echo "Generating or locating Sparkle keys in keychain (account: $SPARKLE_KEYCHAIN_ACCOUNT)..." +"$generate_keys" --account "$SPARKLE_KEYCHAIN_ACCOUNT" + +public_key="$("$generate_keys" --account "$SPARKLE_KEYCHAIN_ACCOUNT" -p)" +private_key_file="$work_dir/sparkle_private_key.txt" +"$generate_keys" --account "$SPARKLE_KEYCHAIN_ACCOUNT" -x "$private_key_file" +private_key="$(cat "$private_key_file")" + +if [[ -z "$public_key" || -z "$private_key" ]]; then + echo "Failed to generate Sparkle keys." >&2 + exit 1 +fi + +if [[ -f "$SPARKLE_ENV_FILE" ]]; then + tmp_env="$work_dir/env.tmp" + awk -F= 'BEGIN {OFS="="} + $1 == "SPARKLE_PUBLIC_KEY" {next} + $1 == "SPARKLE_PRIVATE_KEY" {next} + {print} + ' "$SPARKLE_ENV_FILE" > "$tmp_env" + mv "$tmp_env" "$SPARKLE_ENV_FILE" +fi + +{ + echo "SPARKLE_PUBLIC_KEY=$public_key" + echo "SPARKLE_PRIVATE_KEY=$private_key" +} >> "$SPARKLE_ENV_FILE" + +echo "Sparkle keys written to $SPARKLE_ENV_FILE"