diff --git a/CLAUDE.md b/CLAUDE.md index 21d339ba..e4588af6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,7 @@ ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcode ## Ghostty submodule workflow Ghostty changes must be committed in the `ghostty` submodule and pushed to the `manaflow-ai/ghostty` fork. +Keep `docs/ghostty-fork.md` up to date with any fork changes and conflict notes. ```bash cd ghostty diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d0c548f0..a9748fe3 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -510,7 +510,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -526,7 +526,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -555,7 +555,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -571,7 +571,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -624,10 +624,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -641,10 +641,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 745980a5..f460bdab 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -4,6 +4,7 @@ struct NotificationsPage: View { @EnvironmentObject var notificationStore: TerminalNotificationStore @EnvironmentObject var tabManager: TabManager @Binding var selection: SidebarSelection + @FocusState private var focusedNotificationId: UUID? var body: some View { VStack(spacing: 0) { @@ -26,7 +27,8 @@ struct NotificationsPage: View { }, onClear: { notificationStore.remove(id: notification.id) - } + }, + focusedNotificationId: $focusedNotificationId ) } } @@ -36,6 +38,20 @@ struct NotificationsPage: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) + .onAppear(perform: setInitialFocus) + .onChange(of: notificationStore.notifications.first?.id) { _ in + setInitialFocus() + } + } + + private func setInitialFocus() { + guard let firstId = notificationStore.notifications.first?.id else { + focusedNotificationId = nil + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + focusedNotificationId = firstId + } } private var header: some View { @@ -91,44 +107,56 @@ private struct NotificationRow: View { let tabTitle: String? let onOpen: () -> Void let onClear: () -> Void + let focusedNotificationId: FocusState.Binding var body: some View { HStack(alignment: .top, spacing: 12) { - Circle() - .fill(notification.isRead ? Color.clear : Color.accentColor) - .frame(width: 8, height: 8) - .overlay( + Button(action: onOpen) { + HStack(alignment: .top, spacing: 12) { Circle() - .stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) - ) - .padding(.top, 6) + .fill(notification.isRead ? Color.clear : Color.accentColor) + .frame(width: 8, height: 8) + .overlay( + Circle() + .stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) + ) + .padding(.top, 6) - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(notification.title) - .font(.headline) - .foregroundColor(.primary) - Spacer() - Text(notification.createdAt, style: .time) - .font(.caption) - .foregroundColor(.secondary) - } + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(notification.title) + .font(.headline) + .foregroundColor(.primary) + Spacer() + Text(notification.createdAt, style: .time) + .font(.caption) + .foregroundColor(.secondary) + } - if !notification.body.isEmpty { - Text(notification.body) - .font(.subheadline) - .foregroundColor(.secondary) - .lineLimit(3) - } + if !notification.body.isEmpty { + Text(notification.body) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(3) + } - if let tabTitle { - Text(tabTitle) - .font(.caption) - .foregroundColor(.secondary) + if let tabTitle { + Text(tabTitle) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer(minLength: 0) } + .padding(.trailing, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } - - Spacer(minLength: 0) + .buttonStyle(.plain) + .focusable() + .focused(focusedNotificationId, equals: notification.id) + .modifier(DefaultActionModifier(isActive: focusedNotificationId.wrappedValue == notification.id)) Button(action: onClear) { Image(systemName: "xmark.circle.fill") @@ -141,7 +169,17 @@ private struct NotificationRow: View { RoundedRectangle(cornerRadius: 10) .fill(Color(nsColor: .controlBackgroundColor)) ) - .contentShape(Rectangle()) - .onTapGesture(perform: onOpen) + } +} + +private struct DefaultActionModifier: ViewModifier { + let isActive: Bool + + func body(content: Content) -> some View { + if isActive { + content.keyboardShortcut(.defaultAction) + } else { + content + } } } diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index e8f19c6a..ff91225f 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -40,6 +40,7 @@ class UpdateController { /// Start the updater. If startup fails, the error is shown via the custom UI. func startUpdater() { + ensureSparkleInstallationCache() do { try updater.start() } catch { @@ -74,6 +75,7 @@ class UpdateController { /// Check for updates (used by the menu item). @objc func checkForUpdates() { UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))") + ensureSparkleInstallationCache() if viewModel.state == .idle { updater.checkForUpdates() return @@ -164,4 +166,39 @@ class UpdateController { } #endif } + + private func ensureSparkleInstallationCache() { + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return } + guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return } + + let baseURL = cachesURL + .appendingPathComponent(bundleIdentifier) + .appendingPathComponent("org.sparkle-project.Sparkle") + let installURL = baseURL.appendingPathComponent("Installation") + + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: installURL.path, isDirectory: &isDirectory) { + if !isDirectory.boolValue { + do { + try FileManager.default.removeItem(at: installURL) + } catch { + UpdateLogStore.shared.append("Failed removing Sparkle installation cache file: \(error)") + return + } + } else { + return + } + } + + do { + try FileManager.default.createDirectory( + at: installURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + UpdateLogStore.shared.append("Ensured Sparkle installation cache at \(installURL.path)") + } catch { + UpdateLogStore.shared.append("Failed creating Sparkle installation cache: \(error)") + } + } } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 9d46f1a7..a3351dd3 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -428,7 +428,12 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont popover.animates = true popover.delegate = self popover.contentViewController = NSHostingController( - rootView: NotificationsPopoverView(notificationStore: notificationStore) + rootView: NotificationsPopoverView( + notificationStore: notificationStore, + onDismiss: { [weak popover] in + popover?.performClose(nil) + } + ) ) return popover } @@ -436,6 +441,8 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private struct NotificationsPopoverView: View { @ObservedObject var notificationStore: TerminalNotificationStore + let onDismiss: () -> Void + @FocusState private var focusedNotificationId: UUID? var body: some View { VStack(spacing: 0) { @@ -475,7 +482,8 @@ private struct NotificationsPopoverView: View { notification: notification, tabTitle: tabTitle(for: notification.tabId), onOpen: { open(notification) }, - onClear: { notificationStore.remove(id: notification.id) } + onClear: { notificationStore.remove(id: notification.id) }, + focusedNotificationId: $focusedNotificationId ) } } @@ -485,6 +493,20 @@ private struct NotificationsPopoverView: View { } } .background(Color(nsColor: .windowBackgroundColor)) + .onAppear(perform: setInitialFocus) + .onChange(of: notificationStore.notifications.first?.id) { _ in + setInitialFocus() + } + } + + private func setInitialFocus() { + guard let firstId = notificationStore.notifications.first?.id else { + focusedNotificationId = nil + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + focusedNotificationId = firstId + } } private func tabTitle(for tabId: UUID) -> String? { @@ -494,6 +516,7 @@ private struct NotificationsPopoverView: View { private func open(_ notification: TerminalNotification) { AppDelegate.shared?.tabManager?.focusTabFromNotification(notification.tabId, surfaceId: notification.surfaceId) markReadIfFocused(notification) + onDismiss() } private func markReadIfFocused(_ notification: TerminalNotification) { @@ -513,44 +536,56 @@ private struct NotificationPopoverRow: View { let tabTitle: String? let onOpen: () -> Void let onClear: () -> Void + let focusedNotificationId: FocusState.Binding var body: some View { HStack(alignment: .top, spacing: 10) { - Circle() - .fill(notification.isRead ? Color.clear : Color.accentColor) - .frame(width: 8, height: 8) - .overlay( + Button(action: onOpen) { + HStack(alignment: .top, spacing: 10) { Circle() - .stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) - ) - .padding(.top, 6) + .fill(notification.isRead ? Color.clear : Color.accentColor) + .frame(width: 8, height: 8) + .overlay( + Circle() + .stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) + ) + .padding(.top, 6) - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(notification.title) - .font(.headline) - .foregroundColor(.primary) - Spacer() - Text(notification.createdAt, style: .time) - .font(.caption) - .foregroundColor(.secondary) - } + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(notification.title) + .font(.headline) + .foregroundColor(.primary) + Spacer() + Text(notification.createdAt, style: .time) + .font(.caption) + .foregroundColor(.secondary) + } - if !notification.body.isEmpty { - Text(notification.body) - .font(.subheadline) - .foregroundColor(.secondary) - .lineLimit(3) - } + if !notification.body.isEmpty { + Text(notification.body) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(3) + } - if let tabTitle { - Text(tabTitle) - .font(.caption) - .foregroundColor(.secondary) + if let tabTitle { + Text(tabTitle) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer(minLength: 0) } + .padding(.trailing, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } - - Spacer(minLength: 0) + .buttonStyle(.plain) + .focusable() + .focused(focusedNotificationId, equals: notification.id) + .modifier(DefaultActionModifier(isActive: focusedNotificationId.wrappedValue == notification.id)) Button(action: onClear) { Image(systemName: "xmark.circle.fill") @@ -563,8 +598,18 @@ private struct NotificationPopoverRow: View { RoundedRectangle(cornerRadius: 8) .fill(Color(nsColor: .controlBackgroundColor)) ) - .contentShape(Rectangle()) - .onTapGesture(perform: onOpen) + } +} + +private struct DefaultActionModifier: ViewModifier { + let isActive: Bool + + func body(content: Content) -> some View { + if isActive { + content.keyboardShortcut(.defaultAction) + } else { + content + } } } diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift index a41b17a3..a31e0321 100644 --- a/Sources/Update/UpdateViewModel.swift +++ b/Sources/Update/UpdateViewModel.swift @@ -192,6 +192,8 @@ class UpdateViewModel: ObservableObject { } if nsError.domain == SUSparkleErrorDomain { switch nsError.code { + case 4005: + return "Updater Permission Error" case 2001: return "Couldn't Download Update" case 1000, 1002: @@ -237,6 +239,8 @@ class UpdateViewModel: ObservableObject { } if nsError.domain == SUSparkleErrorDomain { switch nsError.code { + case 4005: + return "macOS blocked cmuxterm from updating apps. Open System Settings → Privacy & Security → App Management, allow cmuxterm, then try again." case 2001: return "cmuxterm couldn’t download the update feed. Check your connection and try again." case 1000, 1002: