diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift index 47897ad..8937316 100644 --- a/Sources/Shotdeck/AppModel.swift +++ b/Sources/Shotdeck/AppModel.swift @@ -43,6 +43,8 @@ public final class AppModel { var hotkeyDisplayString: String { captureHotkey.displayString } /// Staged update offered in the menu. Set only after checksum + payload validation. public private(set) var updateAvailable: (version: String, notes: String)? + /// True for the duration of any appcast check (manual or scheduled). + public private(set) var isCheckingForUpdates: Bool = false let paths: AppSupportPaths let spool: SpoolStore @@ -96,8 +98,19 @@ public final class AppModel { self.setStatus(message) } } + self.updateChecker.onCheckingChanged = { [weak self] checking in + self?.isCheckingForUpdates = checking + } } + /// CFBundleShortVersionString of the running app. + public var appVersion: String { UpdateChecker.currentVersion() } + /// Version recorded in the app-managed rollback copy, when one exists. + public var previousVersion: String? { updateChecker.previousVersion() } + /// Most recent status text — shared with the general status line by design + /// (Redline has one status channel, not a separate update-only one). + public var updateStatusMessage: String? { statusLine } + // MARK: Seam mutators — the only way a WP-4b/4c extension changes state. func setStatus(_ text: String?) { statusLine = text } @@ -219,6 +232,19 @@ public final class AppModel { updateChecker.installStaged() } + /// User-initiated appcast check ("Check for updates" menu row). + public func checkForUpdates() { + Task { @MainActor in + await updateChecker.checkNow(manual: true) + } + } + + /// Reverts `/Applications/Redline.app` to the app-managed rollback copy and relaunches. + /// Does nothing unless a `Redline.app.previous` exists and the user clicked the row. + public func revertToPreviousVersion() { + updateChecker.revertToPrevious() + } + /// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new /// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working. func reRegisterHotkey() { diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift index 5b239cb..778e3e5 100644 --- a/Sources/Shotdeck/MenuBarView.swift +++ b/Sources/Shotdeck/MenuBarView.swift @@ -15,6 +15,8 @@ struct MenuBarView: View { Divider() returnsBlock } + Divider() + updateFooter } .padding(10) .frame(width: 320, alignment: .leading) @@ -83,6 +85,21 @@ struct MenuBarView: View { } } + Button { + model.checkForUpdates() + } label: { + actionLabel(model.isCheckingForUpdates ? "Checking…" : "Check for updates") + } + .disabled(model.isCheckingForUpdates) + + if let previous = model.previousVersion { + Button { + model.revertToPreviousVersion() + } label: { + actionLabel("Revert to \(previous)") + } + } + Button { let anchor = NSApp.keyWindow?.contentView if let sender = model as? SendCapable { @@ -193,4 +210,18 @@ struct MenuBarView: View { private var newestReturns: [ReturnedDocument] { model.allReturns.sorted { $0.detectedAt > $1.detectedAt } } + + private var updateFooter: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Redline \(model.appVersion)") + .font(.caption) + .foregroundStyle(.secondary) + if let message = model.updateStatusMessage { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } } diff --git a/Sources/Shotdeck/main.swift b/Sources/Shotdeck/main.swift index 3b61e42..beef734 100644 --- a/Sources/Shotdeck/main.swift +++ b/Sources/Shotdeck/main.swift @@ -21,8 +21,9 @@ struct ShotdeckApp: App { .environment(appDelegate.model) } label: { let state = appDelegate.model.iconState + let hasUpdate = appDelegate.model.updateAvailable != nil HStack(spacing: 4) { - Image(systemName: state.symbolName) + menuBarIcon(for: state, hasUpdate: hasUpdate) if let count = state.countText { Text(count).font(.system(size: 11, weight: .semibold)) } @@ -33,6 +34,29 @@ struct ShotdeckApp: App { } } +/// The menu-bar symbol for `state`, badged while an update is staged. Uses the +/// SF Symbol's own `.badge` variant when one exists; falls back to a small +/// overlaid dot on the plain symbol otherwise. The badge disappears on its own +/// once `updateAvailable` clears, since this reads live model state. +@ViewBuilder +private func menuBarIcon(for state: MenuIconState, hasUpdate: Bool) -> some View { + if hasUpdate { + let badgeName = "\(state.symbolName).badge" + if NSImage(systemSymbolName: badgeName, accessibilityDescription: nil) != nil { + Image(systemName: badgeName) + } else { + ZStack(alignment: .topTrailing) { + Image(systemName: state.symbolName) + Circle() + .frame(width: 6, height: 6) + .offset(x: 3, y: -3) + } + } + } else { + Image(systemName: state.symbolName) + } +} + @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { let model: AppModel