diff --git a/Info.plist b/Info.plist
index 8717b5c..e64972c 100644
--- a/Info.plist
+++ b/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.1.0
+ 0.2.0
CFBundleVersion
1
CFBundleIconFile
diff --git a/Sources/Shotdeck/AppModel.swift b/Sources/Shotdeck/AppModel.swift
index 9419436..410d55a 100644
--- a/Sources/Shotdeck/AppModel.swift
+++ b/Sources/Shotdeck/AppModel.swift
@@ -39,6 +39,8 @@ public final class AppModel {
/// Currently bound capture combo (the last one Carbon accepted, or the preferred load).
private(set) var captureHotkey: HotkeyPreference
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)?
let paths: AppSupportPaths
let spool: SpoolStore
@@ -48,6 +50,7 @@ public final class AppModel {
let picker: RegionPickerController
let ledger: ReturnLedger
let watcher: ReturnWatcher
+ let updateChecker: UpdateChecker
public init(
paths: AppSupportPaths,
@@ -83,6 +86,14 @@ public final class AppModel {
self.outboxDisplayName = folders.outbox.lastPathComponent
self.watchFolderDisplayName = folders.watch.lastPathComponent
self.captureHotkey = HotkeyPreference.load()
+ self.updateChecker = UpdateChecker()
+ self.updateChecker.onChecked = { [weak self] in
+ guard let self else { return }
+ self.updateAvailable = self.updateChecker.availableUpdate
+ if let message = self.updateChecker.statusMessage {
+ self.setStatus(message)
+ }
+ }
}
// MARK: Seam mutators — the only way a WP-4b/4c extension changes state.
@@ -154,6 +165,20 @@ public final class AppModel {
if !bindCaptureHotkey(pref) {
setStatus("\(pref.displayString) is already used by another app — capture only works from the menu.")
}
+
+ let skipSchedule =
+ ProcessInfo.processInfo.environment["SHOTDECK_PICKER_SELFTEST"] != nil
+ || ProcessInfo.processInfo.environment["SHOTDECK_SNAPSHOT_DIR"] != nil
+ || ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
+ if !skipSchedule {
+ updateChecker.startSchedule()
+ }
+ }
+
+ /// Installs the staged update over `/Applications/Redline.app` and relaunches.
+ /// Does nothing unless the user clicked the menu row.
+ public func installUpdate() {
+ updateChecker.installStaged()
}
/// Unregisters `capture` and binds `HotkeyPreference.load()`. If Carbon rejects the new
diff --git a/Sources/Shotdeck/MenuBarView.swift b/Sources/Shotdeck/MenuBarView.swift
index 2218cbc..1dbca7c 100644
--- a/Sources/Shotdeck/MenuBarView.swift
+++ b/Sources/Shotdeck/MenuBarView.swift
@@ -74,6 +74,15 @@ struct MenuBarView: View {
private var actionsList: some View {
VStack(alignment: .leading, spacing: 2) {
+ if let update = model.updateAvailable {
+ Button {
+ model.installUpdate()
+ } label: {
+ actionLabel("Update to \(update.version)")
+ .foregroundStyle(Color.accentColor)
+ }
+ }
+
Button {
let anchor = NSApp.keyWindow?.contentView
if let sender = model as? SendCapable {
diff --git a/Sources/Shotdeck/PickerSelfTest.swift b/Sources/Shotdeck/PickerSelfTest.swift
index c1eaede..2f14ffe 100644
--- a/Sources/Shotdeck/PickerSelfTest.swift
+++ b/Sources/Shotdeck/PickerSelfTest.swift
@@ -113,7 +113,10 @@ enum PickerSelfTest {
fflush(stdout)
runRegionPersistPhase()
- exit(0)
+ if !startUpdateSelfTestIfRequested() {
+ exit(0)
+ }
+ // UPDATE-SELFTEST hops to a later main-actor turn and exits itself.
}
/// Phase 2: writes a known region under `CaptureRegion.defaultsKey`, reloads it through
@@ -157,6 +160,179 @@ enum PickerSelfTest {
fflush(stdout)
}
+ /// Phase 3: builds a fake 99.0.0 bundle, serves a local appcast, stages via
+ /// `checkNow`, then `installStaged` into the env dir — never `/Applications`.
+ /// Returns true when the async phase was scheduled (it calls `exit` itself).
+ @discardableResult
+ private static func startUpdateSelfTestIfRequested() -> Bool {
+ guard let raw = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"],
+ !raw.isEmpty
+ else { return false }
+
+ let output = URL(fileURLWithPath: raw, isDirectory: true)
+ Task { @MainActor in
+ do {
+ try await runUpdateSelfTest(outputDirectory: output)
+ print("UPDATE-SELFTEST PASS version=99.0.0")
+ fflush(stdout)
+ exit(0)
+ } catch let error as UpdateSelfTestError {
+ updateFail(error.description)
+ } catch {
+ updateFail(String(describing: error))
+ }
+ }
+ return true
+ }
+
+ private static func runUpdateSelfTest(outputDirectory: URL) async throws {
+ let fm = FileManager.default
+ try fm.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
+
+ guard let sourceApp = ownAppBundleURL() else {
+ throw UpdateSelfTestError.detail("own bundle is not a .app (\(Bundle.main.bundleURL.path))")
+ }
+
+ let payload = outputDirectory.appendingPathComponent("payload", isDirectory: true)
+ if fm.fileExists(atPath: payload.path) {
+ try fm.removeItem(at: payload)
+ }
+ try fm.createDirectory(at: payload, withIntermediateDirectories: true)
+ let fakeApp = payload.appendingPathComponent("Redline.app")
+ try fm.copyItem(at: sourceApp, to: fakeApp)
+
+ let plistURL = fakeApp.appendingPathComponent("Contents/Info.plist")
+ let plistData = try Data(contentsOf: plistURL)
+ guard var plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] else {
+ throw UpdateSelfTestError.detail("could not parse copied Info.plist")
+ }
+ plist["CFBundleShortVersionString"] = "99.0.0"
+ let rewritten = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
+ try rewritten.write(to: plistURL)
+
+ let zipURL = outputDirectory.appendingPathComponent("Redline-99.0.0.zip")
+ if fm.fileExists(atPath: zipURL.path) {
+ try fm.removeItem(at: zipURL)
+ }
+ try runDitto(arguments: ["-c", "-k", payload.path, zipURL.path])
+
+ let zipData = try Data(contentsOf: zipURL)
+ let hex = UpdateChecker.sha256Hex(zipData)
+
+ let appcastURL = outputDirectory.appendingPathComponent("appcast.json")
+ let appcast: [String: String] = [
+ "version": "99.0.0",
+ "zipURL": zipURL.absoluteString,
+ "sha256": hex,
+ "notes": "UPDATE-SELFTEST",
+ ]
+ let appcastData = try JSONSerialization.data(withJSONObject: appcast, options: [.sortedKeys])
+ try appcastData.write(to: appcastURL)
+
+ let defaults = UserDefaults.standard
+ let previous = defaults.string(forKey: UpdateChecker.appcastURLDefaultsKey)
+ defaults.set(appcastURL.absoluteString, forKey: UpdateChecker.appcastURLDefaultsKey)
+ defer {
+ if let previous {
+ defaults.set(previous, forKey: UpdateChecker.appcastURLDefaultsKey)
+ } else {
+ defaults.removeObject(forKey: UpdateChecker.appcastURLDefaultsKey)
+ }
+ }
+
+ let (model, isolatedRoot) = try makeIsolatedUpdateModel()
+ defer { try? fm.removeItem(at: isolatedRoot) }
+
+ await model.updateChecker.checkNow()
+
+ guard model.updateAvailable?.version == "99.0.0" else {
+ throw UpdateSelfTestError.detail(
+ "updateAvailable=\(model.updateAvailable?.version ?? "nil")"
+ )
+ }
+ guard let staged = model.updateChecker.stagedAppURL else {
+ throw UpdateSelfTestError.detail("staged payload missing")
+ }
+ guard staged.lastPathComponent == "Redline.app" else {
+ throw UpdateSelfTestError.detail("staged name \(staged.lastPathComponent)")
+ }
+ let stagedExe = staged.appendingPathComponent("Contents/MacOS/Shotdeck")
+ guard fm.fileExists(atPath: stagedExe.path) else {
+ throw UpdateSelfTestError.detail("staged Contents/MacOS/Shotdeck missing")
+ }
+
+ let targetRoot = outputDirectory.appendingPathComponent("target", isDirectory: true)
+ if fm.fileExists(atPath: targetRoot.path) {
+ try fm.removeItem(at: targetRoot)
+ }
+ let target = targetRoot.appendingPathComponent("Redline.app")
+ model.updateChecker.installStaged(to: target)
+
+ let installedPlist = target.appendingPathComponent("Contents/Info.plist")
+ guard let installed = NSDictionary(contentsOf: installedPlist) as? [String: Any],
+ let installedVersion = installed["CFBundleShortVersionString"] as? String
+ else {
+ throw UpdateSelfTestError.detail("installed Info.plist unreadable")
+ }
+ guard installedVersion == "99.0.0" else {
+ throw UpdateSelfTestError.detail("installed version \(installedVersion)")
+ }
+ }
+
+ private static func ownAppBundleURL() -> URL? {
+ let bundle = Bundle.main.bundleURL
+ if bundle.pathExtension == "app" { return bundle }
+ let up3 = bundle
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ if up3.pathExtension == "app" { return up3 }
+ return nil
+ }
+
+ private static func makeIsolatedUpdateModel() throws -> (AppModel, URL) {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("shotdeck-update-selftest-\(UUID().uuidString)", isDirectory: true)
+ let paths = try AppSupportPaths(
+ root: root,
+ outbox: root.appendingPathComponent("outbox", isDirectory: true),
+ watchFolder: root.appendingPathComponent("watch", isDirectory: true)
+ )
+ let ledger = try ReturnLedger(paths: paths)
+ let model = AppModel(
+ paths: paths,
+ spool: try SpoolStore(paths: paths),
+ composer: PDFComposer(),
+ capturer: ScreenCapturer(),
+ hotkeys: HotkeyCenter(),
+ picker: RegionPickerController(),
+ ledger: ledger,
+ watcher: ReturnWatcher(paths: paths, ledger: ledger)
+ )
+ return (model, root)
+ }
+
+ private static func runDitto(arguments: [String]) throws {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
+ process.arguments = arguments
+ let err = Pipe()
+ process.standardError = err
+ process.standardOutput = Pipe()
+ try process.run()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else {
+ let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
+ throw UpdateSelfTestError.detail("ditto failed: \(message)")
+ }
+ }
+
+ private static func updateFail(_ detail: String) -> Never {
+ print("UPDATE-SELFTEST FAIL \(detail)")
+ fflush(stdout)
+ exit(1)
+ }
+
private static func interpolate(_ step: Int) -> NSPoint {
let t = CGFloat(step) / CGFloat(dragSteps)
return NSPoint(
@@ -213,3 +389,12 @@ enum PickerSelfTest {
exit(1)
}
}
+
+private enum UpdateSelfTestError: Error, CustomStringConvertible {
+ case detail(String)
+ var description: String {
+ switch self {
+ case .detail(let s): return s
+ }
+ }
+}
diff --git a/Sources/Shotdeck/UpdateChecker.swift b/Sources/Shotdeck/UpdateChecker.swift
new file mode 100644
index 0000000..207f51a
--- /dev/null
+++ b/Sources/Shotdeck/UpdateChecker.swift
@@ -0,0 +1,281 @@
+import AppKit
+import CryptoKit
+import Foundation
+
+/// Built-in updater. Checks an appcast, stages a verified payload, and installs
+/// only when the user clicks the menu row — never automatically.
+@MainActor
+final class UpdateChecker {
+ static let appcastURLDefaultsKey = "ai.flowmaster.shotdeck.appcastURL"
+ static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")!
+ static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app")
+
+ private(set) var availableUpdate: (version: String, notes: String)?
+ private(set) var stagedAppURL: URL?
+ private(set) var statusMessage: String?
+
+ var onChecked: (() -> Void)?
+
+ private let urlSession: URLSession
+ private var repeatingTimer: Timer?
+ private var firstCheckTask: Task?
+ private var isChecking = false
+ private var stagingDirectory: URL?
+
+ init() {
+ let config = URLSessionConfiguration.ephemeral
+ config.timeoutIntervalForRequest = 15
+ config.timeoutIntervalForResource = 15
+ config.httpCookieAcceptPolicy = .never
+ config.httpShouldSetCookies = false
+ config.httpCookieStorage = nil
+ config.urlCache = nil
+ urlSession = URLSession(configuration: config)
+ }
+
+ /// First check 10 seconds after start, then every 6 hours. Stages only — never installs.
+ func startSchedule() {
+ firstCheckTask?.cancel()
+ firstCheckTask = Task { [weak self] in
+ try? await Task.sleep(for: .seconds(10))
+ guard !Task.isCancelled else { return }
+ await self?.checkNow()
+ }
+ repeatingTimer?.invalidate()
+ let timer = Timer(timeInterval: 6 * 60 * 60, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ await self?.checkNow()
+ }
+ }
+ RunLoop.main.add(timer, forMode: .common)
+ repeatingTimer = timer
+ }
+
+ func checkNow() async {
+ guard !isChecking else { return }
+ isChecking = true
+ defer { isChecking = false }
+
+ let appcast: Appcast
+ do {
+ appcast = try await fetchAppcast()
+ } catch {
+ statusMessage = "Could not check for updates."
+ onChecked?()
+ return
+ }
+
+ guard Self.isNewer(appcast.version, than: Self.currentVersion()) else {
+ clearOffer()
+ statusMessage = nil
+ onChecked?()
+ return
+ }
+
+ do {
+ try await downloadAndStage(appcast)
+ availableUpdate = (version: appcast.version, notes: appcast.notes ?? "")
+ statusMessage = nil
+ } catch UpdateCheckError.checksumMismatch {
+ discardStaging()
+ availableUpdate = nil
+ statusMessage = "Update file failed the checksum — not installed."
+ } catch {
+ discardStaging()
+ availableUpdate = nil
+ statusMessage = "The update could not be prepared."
+ }
+ onChecked?()
+ }
+
+ /// Copies the staged app onto `target` with ditto (in place; never deletes the old app).
+ /// Relaunches unless `SHOTDECK_UPDATE_SELFTEST` is set, so the in-process self-test
+ /// can assert the installed Info.plist without killing the process.
+ func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) {
+ guard let staged = stagedAppURL else {
+ statusMessage = "No update is staged."
+ onChecked?()
+ return
+ }
+
+ do {
+ try FileManager.default.createDirectory(
+ at: target.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, target.path])
+ } catch {
+ statusMessage = "The update could not be installed."
+ onChecked?()
+ return
+ }
+
+ let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
+ if isSelfTest { return }
+
+ do {
+ try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path])
+ } catch {
+ statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications."
+ onChecked?()
+ return
+ }
+ NSApp.terminate(nil)
+ }
+
+ static func resolvedAppcastURL() -> URL {
+ if let env = ProcessInfo.processInfo.environment["REDLINE_APPCAST_URL"],
+ !env.isEmpty,
+ let url = URL(string: env)
+ {
+ return url
+ }
+ if let stored = UserDefaults.standard.string(forKey: appcastURLDefaultsKey),
+ !stored.isEmpty,
+ let url = URL(string: stored)
+ {
+ return url
+ }
+ return defaultAppcastURL
+ }
+
+ static func currentVersion() -> String {
+ Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
+ }
+
+ static func isNewer(_ candidate: String, than current: String) -> Bool {
+ let a = semverParts(candidate)
+ let b = semverParts(current)
+ for i in 0..<3 {
+ if a[i] != b[i] { return a[i] > b[i] }
+ }
+ return false
+ }
+
+ static func sha256Hex(_ data: Data) -> String {
+ SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ // MARK: - Private
+
+ private struct Appcast: Decodable {
+ var version: String
+ var zipURL: URL
+ var sha256: String
+ var notes: String?
+ }
+
+ private enum UpdateCheckError: Error {
+ case checksumMismatch
+ case invalidPayload
+ case httpStatus(Int)
+ case processFailed(String)
+ }
+
+ private func fetchAppcast() async throws -> Appcast {
+ let data = try await fetchData(from: Self.resolvedAppcastURL())
+ return try JSONDecoder().decode(Appcast.self, from: data)
+ }
+
+ private func fetchData(from url: URL) async throws -> Data {
+ if url.isFileURL {
+ return try Data(contentsOf: url)
+ }
+ let (data, response) = try await urlSession.data(from: url)
+ if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
+ throw UpdateCheckError.httpStatus(http.statusCode)
+ }
+ return data
+ }
+
+ private func downloadAndStage(_ appcast: Appcast) async throws {
+ let zipData = try await fetchData(from: appcast.zipURL)
+ let expected = appcast.sha256.trimmingCharacters(in: .whitespacesAndNewlines)
+ let actual = Self.sha256Hex(zipData)
+ guard actual.caseInsensitiveCompare(expected) == .orderedSame else {
+ throw UpdateCheckError.checksumMismatch
+ }
+
+ discardStaging()
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("shotdeck-update-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ stagingDirectory = root
+
+ let zipURL = root.appendingPathComponent("update.zip")
+ try zipData.write(to: zipURL)
+
+ let extracted = root.appendingPathComponent("extracted", isDirectory: true)
+ try FileManager.default.createDirectory(at: extracted, withIntermediateDirectories: true)
+ try Self.runProcess(
+ executable: "/usr/bin/ditto",
+ arguments: ["-x", "-k", zipURL.path, extracted.path]
+ )
+
+ guard let appURL = Self.findRedlineApp(in: extracted) else {
+ throw UpdateCheckError.invalidPayload
+ }
+ let executable = appURL.appendingPathComponent("Contents/MacOS/Shotdeck")
+ guard FileManager.default.fileExists(atPath: executable.path) else {
+ throw UpdateCheckError.invalidPayload
+ }
+ stagedAppURL = appURL
+ }
+
+ private func clearOffer() {
+ availableUpdate = nil
+ discardStaging()
+ }
+
+ private func discardStaging() {
+ if let stagingDirectory {
+ try? FileManager.default.removeItem(at: stagingDirectory)
+ }
+ stagingDirectory = nil
+ stagedAppURL = nil
+ }
+
+ private static func findRedlineApp(in directory: URL) -> URL? {
+ let fm = FileManager.default
+ let direct = directory.appendingPathComponent("Redline.app")
+ if fm.fileExists(atPath: direct.path) { return direct }
+
+ guard let enumerator = fm.enumerator(
+ at: directory,
+ includingPropertiesForKeys: [.isDirectoryKey],
+ options: [.skipsHiddenFiles]
+ ) else { return nil }
+
+ while let item = enumerator.nextObject() as? URL {
+ if item.lastPathComponent == "Redline.app" {
+ return item
+ }
+ if item.pathExtension == "app" {
+ enumerator.skipDescendants()
+ }
+ }
+ return nil
+ }
+
+ private static func semverParts(_ string: String) -> [Int] {
+ let core = string.split(separator: "-").first.map(String.init) ?? string
+ var parts = core.split(separator: ".").prefix(3).map { Int($0) ?? 0 }
+ while parts.count < 3 { parts.append(0) }
+ return parts
+ }
+
+ private static func runProcess(executable: String, arguments: [String]) throws {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: executable)
+ process.arguments = arguments
+ let err = Pipe()
+ process.standardError = err
+ process.standardOutput = Pipe()
+ try process.run()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else {
+ let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
+ throw UpdateCheckError.processFailed("\(executable) failed: \(message)")
+ }
+ }
+}