AppModel exposes appVersion, previousVersion, isCheckingForUpdates and updateStatusMessage (an alias for the existing statusLine plumbing — one status channel, not a new one), plus checkForUpdates() and revertToPreviousVersion() wired to the hardened UpdateChecker. Menu gains, in order: "Update to X" (unchanged, staged-only), "Check for updates" (labelled "Checking…" and disabled mid-check), "Revert to <version>" (only when a rollback copy exists), then the existing rows unchanged, then a non-interactive footer "Redline <version>" with the status line under it — same caption/secondary styles already used elsewhere in the file, no new tokens. Menu-bar icon gets a small badge while an update is staged: uses the SF Symbol's own ".badge" variant when one exists for the current icon, otherwise overlays a small dot on the plain symbol. Reads live model state, so the badge disappears on its own once the offer clears. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
106 lines
3.8 KiB
Swift
106 lines
3.8 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import ShotdeckCore
|
|
|
|
// SwiftPM treats a file named main.swift as top-level code, which forbids `@main`.
|
|
// App.main() is the equivalent entry point.
|
|
if ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil {
|
|
MainActor.assumeIsolated { PanelSnapshot.runIfRequested() }
|
|
}
|
|
if ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil {
|
|
MainActor.assumeIsolated { PickerSelfTest.runIfRequested() }
|
|
}
|
|
ShotdeckApp.main()
|
|
|
|
struct ShotdeckApp: App {
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
|
|
var body: some Scene {
|
|
MenuBarExtra {
|
|
MenuBarView()
|
|
.environment(appDelegate.model)
|
|
} label: {
|
|
let state = appDelegate.model.iconState
|
|
let hasUpdate = appDelegate.model.updateAvailable != nil
|
|
HStack(spacing: 4) {
|
|
menuBarIcon(for: state, hasUpdate: hasUpdate)
|
|
if let count = state.countText {
|
|
Text(count).font(.system(size: 11, weight: .semibold))
|
|
}
|
|
}
|
|
.accessibilityLabel("Redline")
|
|
}
|
|
.menuBarExtraStyle(.window)
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
|
|
override init() {
|
|
NSApplication.shared.setActivationPolicy(.accessory)
|
|
model = AppDelegate.makeLaunchModel()
|
|
super.init()
|
|
}
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
Task { await model.bootstrap() }
|
|
}
|
|
|
|
private static func makeLaunchModel() -> AppModel {
|
|
do {
|
|
let paths = try FolderSettings.resolvedAppSupportPaths()
|
|
return try makeModel(paths: paths)
|
|
} catch {
|
|
Log.ui.critical(
|
|
"AppModel init failed: \(String(describing: error), privacy: .public)"
|
|
)
|
|
let tmp = FileManager.default.temporaryDirectory
|
|
let fallbackRoot = tmp.appendingPathComponent("Shotdeck-fallback", isDirectory: true)
|
|
// Safe: temp-dir creation for a path this process controls cannot legitimately fail.
|
|
let fallback = try! AppSupportPaths(root: fallbackRoot, outbox: tmp, watchFolder: tmp)
|
|
let model = try! makeModel(paths: fallback)
|
|
model.setStatus("Redline could not access its storage folder. Captures will not persist.")
|
|
return model
|
|
}
|
|
}
|
|
|
|
private static func makeModel(paths: AppSupportPaths) throws -> AppModel {
|
|
let ledger = try ReturnLedger(paths: paths)
|
|
return AppModel(
|
|
paths: paths,
|
|
spool: try SpoolStore(paths: paths),
|
|
composer: PDFComposer(),
|
|
capturer: ScreenCapturer(),
|
|
hotkeys: HotkeyCenter(),
|
|
picker: RegionPickerController(),
|
|
ledger: ledger,
|
|
watcher: ReturnWatcher(paths: paths, ledger: ledger)
|
|
)
|
|
}
|
|
}
|