Redline updater hardening: signed-update verification, atomic install + revert, update visibility, notarization pipeline (MMDB-2681) #24

Merged
kua-agent merged 7 commits from feat/updater-hardening-20260905 into main 2026-09-05 06:07:06 +00:00
3 changed files with 82 additions and 1 deletions
Showing only changes of commit 064e410e30 - Show all commits
+26
View File
@@ -43,6 +43,8 @@ public final class AppModel {
var hotkeyDisplayString: String { captureHotkey.displayString } var hotkeyDisplayString: String { captureHotkey.displayString }
/// Staged update offered in the menu. Set only after checksum + payload validation. /// Staged update offered in the menu. Set only after checksum + payload validation.
public private(set) var updateAvailable: (version: String, notes: String)? 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 paths: AppSupportPaths
let spool: SpoolStore let spool: SpoolStore
@@ -96,7 +98,18 @@ public final class AppModel {
self.setStatus(message) 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. // MARK: Seam mutators the only way a WP-4b/4c extension changes state.
@@ -219,6 +232,19 @@ public final class AppModel {
updateChecker.installStaged() 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 /// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new
/// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working. /// combo, restores the previous preference (UserDefaults + Carbon) so the old one keeps working.
func reRegisterHotkey() { func reRegisterHotkey() {
+31
View File
@@ -15,6 +15,8 @@ struct MenuBarView: View {
Divider() Divider()
returnsBlock returnsBlock
} }
Divider()
updateFooter
} }
.padding(10) .padding(10)
.frame(width: 320, alignment: .leading) .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 { Button {
let anchor = NSApp.keyWindow?.contentView let anchor = NSApp.keyWindow?.contentView
if let sender = model as? SendCapable { if let sender = model as? SendCapable {
@@ -193,4 +210,18 @@ struct MenuBarView: View {
private var newestReturns: [ReturnedDocument] { private var newestReturns: [ReturnedDocument] {
model.allReturns.sorted { $0.detectedAt > $1.detectedAt } 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)
}
}
}
} }
+25 -1
View File
@@ -21,8 +21,9 @@ struct ShotdeckApp: App {
.environment(appDelegate.model) .environment(appDelegate.model)
} label: { } label: {
let state = appDelegate.model.iconState let state = appDelegate.model.iconState
let hasUpdate = appDelegate.model.updateAvailable != nil
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: state.symbolName) menuBarIcon(for: state, hasUpdate: hasUpdate)
if let count = state.countText { if let count = state.countText {
Text(count).font(.system(size: 11, weight: .semibold)) 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 @MainActor
final class AppDelegate: NSObject, NSApplicationDelegate { final class AppDelegate: NSObject, NSApplicationDelegate {
let model: AppModel let model: AppModel