Redline updater hardening: signed-update verification, atomic install + revert, update visibility, notarization pipeline (MMDB-2681) #24
@@ -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,8 +98,19 @@ 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.
|
||||||
|
|
||||||
func setStatus(_ text: String?) { statusLine = text }
|
func setStatus(_ text: String?) { statusLine = text }
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user