Handle Sparkle updater cache and bump version

This commit is contained in:
Lawrence Chen 2026-01-28 23:30:59 -08:00
parent 96941c6dbc
commit e743498fe1
6 changed files with 198 additions and 73 deletions

View file

@ -57,6 +57,7 @@ ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcode
## Ghostty submodule workflow ## Ghostty submodule workflow
Ghostty changes must be committed in the `ghostty` submodule and pushed to the `manaflow-ai/ghostty` fork. 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 ```bash
cd ghostty cd ghostty

View file

@ -510,7 +510,7 @@
CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO; ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -526,7 +526,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 1.5.0;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-lc++", "-lc++",
"-framework", "-framework",
@ -555,7 +555,7 @@
CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO; ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -571,7 +571,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 1.5.0;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-lc++", "-lc++",
"-framework", "-framework",
@ -624,10 +624,10 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 10;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 1.5.0;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -641,10 +641,10 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 10;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 1.5.0;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -4,6 +4,7 @@ struct NotificationsPage: View {
@EnvironmentObject var notificationStore: TerminalNotificationStore @EnvironmentObject var notificationStore: TerminalNotificationStore
@EnvironmentObject var tabManager: TabManager @EnvironmentObject var tabManager: TabManager
@Binding var selection: SidebarSelection @Binding var selection: SidebarSelection
@FocusState private var focusedNotificationId: UUID?
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -26,7 +27,8 @@ struct NotificationsPage: View {
}, },
onClear: { onClear: {
notificationStore.remove(id: notification.id) notificationStore.remove(id: notification.id)
} },
focusedNotificationId: $focusedNotificationId
) )
} }
} }
@ -36,6 +38,20 @@ struct NotificationsPage: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor)) .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 { private var header: some View {
@ -91,44 +107,56 @@ private struct NotificationRow: View {
let tabTitle: String? let tabTitle: String?
let onOpen: () -> Void let onOpen: () -> Void
let onClear: () -> Void let onClear: () -> Void
let focusedNotificationId: FocusState<UUID?>.Binding
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Circle() Button(action: onOpen) {
.fill(notification.isRead ? Color.clear : Color.accentColor) HStack(alignment: .top, spacing: 12) {
.frame(width: 8, height: 8)
.overlay(
Circle() Circle()
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) .fill(notification.isRead ? Color.clear : Color.accentColor)
) .frame(width: 8, height: 8)
.padding(.top, 6) .overlay(
Circle()
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
)
.padding(.top, 6)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack { HStack {
Text(notification.title) Text(notification.title)
.font(.headline) .font(.headline)
.foregroundColor(.primary) .foregroundColor(.primary)
Spacer() Spacer()
Text(notification.createdAt, style: .time) Text(notification.createdAt, style: .time)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
if !notification.body.isEmpty { if !notification.body.isEmpty {
Text(notification.body) Text(notification.body)
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(3) .lineLimit(3)
} }
if let tabTitle { if let tabTitle {
Text(tabTitle) Text(tabTitle)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
}
}
Spacer(minLength: 0)
} }
.padding(.trailing, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
} }
.buttonStyle(.plain)
Spacer(minLength: 0) .focusable()
.focused(focusedNotificationId, equals: notification.id)
.modifier(DefaultActionModifier(isActive: focusedNotificationId.wrappedValue == notification.id))
Button(action: onClear) { Button(action: onClear) {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
@ -141,7 +169,17 @@ private struct NotificationRow: View {
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.fill(Color(nsColor: .controlBackgroundColor)) .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
}
} }
} }

View file

@ -40,6 +40,7 @@ class UpdateController {
/// Start the updater. If startup fails, the error is shown via the custom UI. /// Start the updater. If startup fails, the error is shown via the custom UI.
func startUpdater() { func startUpdater() {
ensureSparkleInstallationCache()
do { do {
try updater.start() try updater.start()
} catch { } catch {
@ -74,6 +75,7 @@ class UpdateController {
/// Check for updates (used by the menu item). /// Check for updates (used by the menu item).
@objc func checkForUpdates() { @objc func checkForUpdates() {
UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))") UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))")
ensureSparkleInstallationCache()
if viewModel.state == .idle { if viewModel.state == .idle {
updater.checkForUpdates() updater.checkForUpdates()
return return
@ -164,4 +166,39 @@ class UpdateController {
} }
#endif #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)")
}
}
} }

View file

@ -428,7 +428,12 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
popover.animates = true popover.animates = true
popover.delegate = self popover.delegate = self
popover.contentViewController = NSHostingController( popover.contentViewController = NSHostingController(
rootView: NotificationsPopoverView(notificationStore: notificationStore) rootView: NotificationsPopoverView(
notificationStore: notificationStore,
onDismiss: { [weak popover] in
popover?.performClose(nil)
}
)
) )
return popover return popover
} }
@ -436,6 +441,8 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
private struct NotificationsPopoverView: View { private struct NotificationsPopoverView: View {
@ObservedObject var notificationStore: TerminalNotificationStore @ObservedObject var notificationStore: TerminalNotificationStore
let onDismiss: () -> Void
@FocusState private var focusedNotificationId: UUID?
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -475,7 +482,8 @@ private struct NotificationsPopoverView: View {
notification: notification, notification: notification,
tabTitle: tabTitle(for: notification.tabId), tabTitle: tabTitle(for: notification.tabId),
onOpen: { open(notification) }, 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)) .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? { private func tabTitle(for tabId: UUID) -> String? {
@ -494,6 +516,7 @@ private struct NotificationsPopoverView: View {
private func open(_ notification: TerminalNotification) { private func open(_ notification: TerminalNotification) {
AppDelegate.shared?.tabManager?.focusTabFromNotification(notification.tabId, surfaceId: notification.surfaceId) AppDelegate.shared?.tabManager?.focusTabFromNotification(notification.tabId, surfaceId: notification.surfaceId)
markReadIfFocused(notification) markReadIfFocused(notification)
onDismiss()
} }
private func markReadIfFocused(_ notification: TerminalNotification) { private func markReadIfFocused(_ notification: TerminalNotification) {
@ -513,44 +536,56 @@ private struct NotificationPopoverRow: View {
let tabTitle: String? let tabTitle: String?
let onOpen: () -> Void let onOpen: () -> Void
let onClear: () -> Void let onClear: () -> Void
let focusedNotificationId: FocusState<UUID?>.Binding
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 10) { HStack(alignment: .top, spacing: 10) {
Circle() Button(action: onOpen) {
.fill(notification.isRead ? Color.clear : Color.accentColor) HStack(alignment: .top, spacing: 10) {
.frame(width: 8, height: 8)
.overlay(
Circle() Circle()
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) .fill(notification.isRead ? Color.clear : Color.accentColor)
) .frame(width: 8, height: 8)
.padding(.top, 6) .overlay(
Circle()
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
)
.padding(.top, 6)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack { HStack {
Text(notification.title) Text(notification.title)
.font(.headline) .font(.headline)
.foregroundColor(.primary) .foregroundColor(.primary)
Spacer() Spacer()
Text(notification.createdAt, style: .time) Text(notification.createdAt, style: .time)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
if !notification.body.isEmpty { if !notification.body.isEmpty {
Text(notification.body) Text(notification.body)
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(3) .lineLimit(3)
} }
if let tabTitle { if let tabTitle {
Text(tabTitle) Text(tabTitle)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
}
}
Spacer(minLength: 0)
} }
.padding(.trailing, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
} }
.buttonStyle(.plain)
Spacer(minLength: 0) .focusable()
.focused(focusedNotificationId, equals: notification.id)
.modifier(DefaultActionModifier(isActive: focusedNotificationId.wrappedValue == notification.id))
Button(action: onClear) { Button(action: onClear) {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
@ -563,8 +598,18 @@ private struct NotificationPopoverRow: View {
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(Color(nsColor: .controlBackgroundColor)) .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
}
} }
} }

View file

@ -192,6 +192,8 @@ class UpdateViewModel: ObservableObject {
} }
if nsError.domain == SUSparkleErrorDomain { if nsError.domain == SUSparkleErrorDomain {
switch nsError.code { switch nsError.code {
case 4005:
return "Updater Permission Error"
case 2001: case 2001:
return "Couldn't Download Update" return "Couldn't Download Update"
case 1000, 1002: case 1000, 1002:
@ -237,6 +239,8 @@ class UpdateViewModel: ObservableObject {
} }
if nsError.domain == SUSparkleErrorDomain { if nsError.domain == SUSparkleErrorDomain {
switch nsError.code { 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: case 2001:
return "cmuxterm couldnt download the update feed. Check your connection and try again." return "cmuxterm couldnt download the update feed. Check your connection and try again."
case 1000, 1002: case 1000, 1002: