feat: updater hardening — timeouts, signature verification, atomic install+rollback

30s/600s URLSession timeouts on the update session (was 15s/15s, too tight for
a real zip download). Every staged and installed payload is now verified with
the Security framework (SecStaticCodeCheckValidityWithErrors, strict + all
architectures + nested code) against bundle id ai.flowmaster.shotdeck and an
allowed-team set (PWMCBMX5M8, L3N9S54CN3; overridable via REDLINE_ALLOWED_TEAMS
for the self-test only) — an unsigned or wrongly-signed update is discarded
before checkNow ever offers it, and installStaged re-verifies what actually
landed on disk as defense in depth.

installStaged is now atomic: ditto into an itemReplacementDirectory, then
FileManager.replaceItemAt swaps it into place, keeping exactly one
Redline.app.previous rollback copy (older ones are dropped first). Added
revertToPrevious(target:) to swap that copy back in (itself reversible — the
replaced version becomes the new .previous), and previousVersion(target:) to
read its CFBundleShortVersionString. Relaunch is now a detached
"wait for this PID to exit, then open -n" shell handoff instead of a
synchronous open+terminate, so there is never a moment with two instances
running. checkNow(manual:) now says "Redline X is up to date." when the user
asked directly, and exposes isCheckingNow/lastCheckedAt for the UI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 10:00:10 +04:00
co-authored by Claude Fable 5.1
parent 380b704f8a
commit a79a569a7d
+224 -21
View File
@@ -1,6 +1,7 @@
import AppKit import AppKit
import CryptoKit import CryptoKit
import Foundation import Foundation
import Security
/// Built-in updater. Checks an appcast, stages a verified payload, and installs /// Built-in updater. Checks an appcast, stages a verified payload, and installs
/// only when the user clicks the menu row never automatically. /// only when the user clicks the menu row never automatically.
@@ -10,22 +11,31 @@ final class UpdateChecker {
static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")! static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")!
static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app") static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app")
/// Required bundle identifier for any staged or installed payload.
static let expectedBundleIdentifier = "ai.flowmaster.shotdeck"
/// Developer team identifiers MMD ships Redline under. Overridable only for the self-test.
static let allowedTeamIdentifiers: Set<String> = ["PWMCBMX5M8", "L3N9S54CN3"]
private(set) var availableUpdate: (version: String, notes: String)? private(set) var availableUpdate: (version: String, notes: String)?
private(set) var stagedAppURL: URL? private(set) var stagedAppURL: URL?
private(set) var statusMessage: String? private(set) var statusMessage: String?
private(set) var lastCheckedAt: Date?
private(set) var isCheckingNow: Bool = false
var onChecked: (() -> Void)? var onChecked: (() -> Void)?
/// Fired whenever `isCheckingNow` flips, so a UI can show "Checking" for the
/// whole duration of a check rather than only after it lands.
var onCheckingChanged: ((Bool) -> Void)?
private let urlSession: URLSession private let urlSession: URLSession
private var repeatingTimer: Timer? private var repeatingTimer: Timer?
private var firstCheckTask: Task<Void, Never>? private var firstCheckTask: Task<Void, Never>?
private var isChecking = false
private var stagingDirectory: URL? private var stagingDirectory: URL?
init() { init() {
let config = URLSessionConfiguration.ephemeral let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 15 config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 15 config.timeoutIntervalForResource = 600
config.httpCookieAcceptPolicy = .never config.httpCookieAcceptPolicy = .never
config.httpShouldSetCookies = false config.httpShouldSetCookies = false
config.httpCookieStorage = nil config.httpCookieStorage = nil
@@ -51,10 +61,18 @@ final class UpdateChecker {
repeatingTimer = timer repeatingTimer = timer
} }
func checkNow() async { /// Checks the appcast and stages a newer, signature-verified payload.
guard !isChecking else { return } /// `manual` only affects the status message shown when already up to date
isChecking = true /// a user-initiated check says so; the silent background check stays quiet.
defer { isChecking = false } func checkNow(manual: Bool = false) async {
guard !isCheckingNow else { return }
isCheckingNow = true
onCheckingChanged?(true)
defer {
isCheckingNow = false
onCheckingChanged?(false)
}
lastCheckedAt = Date()
let appcast: Appcast let appcast: Appcast
do { do {
@@ -67,7 +85,7 @@ final class UpdateChecker {
guard Self.isNewer(appcast.version, than: Self.currentVersion()) else { guard Self.isNewer(appcast.version, than: Self.currentVersion()) else {
clearOffer() clearOffer()
statusMessage = nil statusMessage = manual ? "Redline \(Self.currentVersion()) is up to date." : nil
onChecked?() onChecked?()
return return
} }
@@ -80,6 +98,10 @@ final class UpdateChecker {
discardStaging() discardStaging()
availableUpdate = nil availableUpdate = nil
statusMessage = "Update file failed the checksum — not installed." statusMessage = "Update file failed the checksum — not installed."
} catch UpdateCheckError.signatureInvalid {
discardStaging()
availableUpdate = nil
statusMessage = "Update is not signed by MMD — not installed."
} catch { } catch {
discardStaging() discardStaging()
availableUpdate = nil availableUpdate = nil
@@ -88,9 +110,9 @@ final class UpdateChecker {
onChecked?() onChecked?()
} }
/// Copies the staged app onto `target` with ditto (in place; never deletes the old app). /// Installs the staged app onto `target` atomically, keeping exactly one rollback
/// Relaunches unless `SHOTDECK_UPDATE_SELFTEST` is set, so the in-process self-test /// copy (`Redline.app.previous`), then hands off to a relaunch and quits.
/// can assert the installed Info.plist without killing the process. /// Never deletes the old app before the new one is verified in place.
func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) { func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) {
guard let staged = stagedAppURL else { guard let staged = stagedAppURL else {
statusMessage = "No update is staged." statusMessage = "No update is staged."
@@ -98,29 +120,118 @@ final class UpdateChecker {
return return
} }
let targetDir = target.deletingLastPathComponent()
let previousURL = targetDir.appendingPathComponent("Redline.app.previous")
do { do {
try FileManager.default.createDirectory( try FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true)
at: target.deletingLastPathComponent(),
withIntermediateDirectories: true let replacementDir = try FileManager.default.url(
for: .itemReplacementDirectory,
in: .userDomainMask,
appropriateFor: target,
create: true
) )
try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, target.path]) defer { try? FileManager.default.removeItem(at: replacementDir) }
let newCopy = replacementDir.appendingPathComponent(target.lastPathComponent)
try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, newCopy.path])
// Exactly one rollback copy is kept drop any older one before this install.
if FileManager.default.fileExists(atPath: previousURL.path) {
try FileManager.default.removeItem(at: previousURL)
}
if FileManager.default.fileExists(atPath: target.path) {
_ = try FileManager.default.replaceItemAt(
target,
withItemAt: newCopy,
backupItemName: previousURL.lastPathComponent,
options: [.withoutDeletingBackupItem]
)
} else {
try FileManager.default.moveItem(at: newCopy, to: target)
}
} catch { } catch {
statusMessage = "The update could not be installed." statusMessage = "The update could not be installed."
onChecked?() onChecked?()
return return
} }
let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil // Defense in depth: re-verify what actually landed on disk, not just the staged copy.
if isSelfTest { return }
do { do {
try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path]) try Self.verifySignature(of: target)
} catch { } catch {
statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications." statusMessage = "The update was installed but failed verification."
onChecked?() onChecked?()
return return
} }
NSApp.terminate(nil)
discardStaging()
availableUpdate = nil
relaunch(target: target)
}
/// Swaps `Redline.app.previous` back into place, verifying its signature first.
/// The just-replaced (newer) app becomes the new `.previous` a revert is
/// itself reversible.
func revertToPrevious(target: URL = UpdateChecker.defaultInstallTarget) {
let targetDir = target.deletingLastPathComponent()
let previousURL = targetDir.appendingPathComponent("Redline.app.previous")
guard FileManager.default.fileExists(atPath: previousURL.path) else {
statusMessage = "No previous version to revert to."
onChecked?()
return
}
do {
try Self.verifySignature(of: previousURL)
} catch {
statusMessage = "The previous version failed verification and was not restored."
onChecked?()
return
}
do {
// `previousURL` cannot be handed to replaceItemAt directly: its own path
// IS the requested backup name, so the backup step would clobber it
// before the swap ever reads it. Stage a throwaway copy first, exactly
// like installStaged does for the forward direction.
let replacementDir = try FileManager.default.url(
for: .itemReplacementDirectory,
in: .userDomainMask,
appropriateFor: target,
create: true
)
defer { try? FileManager.default.removeItem(at: replacementDir) }
let newCopy = replacementDir.appendingPathComponent(target.lastPathComponent)
try Self.runProcess(executable: "/usr/bin/ditto", arguments: [previousURL.path, newCopy.path])
try FileManager.default.removeItem(at: previousURL)
_ = try FileManager.default.replaceItemAt(
target,
withItemAt: newCopy,
backupItemName: previousURL.lastPathComponent,
options: [.withoutDeletingBackupItem]
)
} catch {
statusMessage = "Could not revert to the previous version."
onChecked?()
return
}
relaunch(target: target)
}
/// The version recorded in `Redline.app.previous`'s Info.plist, or nil when no
/// rollback copy exists.
func previousVersion(target: URL = UpdateChecker.defaultInstallTarget) -> String? {
let previousURL = target.deletingLastPathComponent().appendingPathComponent("Redline.app.previous")
let plistURL = previousURL.appendingPathComponent("Contents/Info.plist")
guard let plist = NSDictionary(contentsOf: plistURL) as? [String: Any] else { return nil }
return plist["CFBundleShortVersionString"] as? String
} }
static func resolvedAppcastURL() -> URL { static func resolvedAppcastURL() -> URL {
@@ -156,6 +267,67 @@ final class UpdateChecker {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
} }
/// Validates the code signature of the app at `appURL`: strictly, across all
/// architectures and nested code, then checks its bundle identifier and team
/// identifier against `expectedBundleIdentifier` / the allowed-teams set.
/// `REDLINE_ALLOWED_TEAMS` (comma separated) overrides the allowed set for
/// the self-test only, so it can accept a locally re-signed fake bundle.
static func verifySignature(of appURL: URL) throws {
var staticCode: SecStaticCode?
let createStatus = SecStaticCodeCreateWithPath(appURL as CFURL, [], &staticCode)
guard createStatus == errSecSuccess, let code = staticCode else {
throw UpdateCheckError.signatureInvalid(
"could not read a code signature (status \(createStatus))"
)
}
let validityFlags = SecCSFlags(
rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures | kSecCSCheckNestedCode
)
var validityError: Unmanaged<CFError>?
let validityStatus = SecStaticCodeCheckValidityWithErrors(code, validityFlags, nil, &validityError)
guard validityStatus == errSecSuccess else {
let detail = (validityError?.takeRetainedValue()).map { String(describing: $0) } ?? "status \(validityStatus)"
throw UpdateCheckError.signatureInvalid("signature is not valid: \(detail)")
}
var signingInfo: CFDictionary?
let infoStatus = SecCodeCopySigningInformation(
code,
SecCSFlags(rawValue: kSecCSSigningInformation),
&signingInfo
)
guard infoStatus == errSecSuccess, let info = signingInfo as? [String: Any] else {
throw UpdateCheckError.signatureInvalid("could not read signing information (status \(infoStatus))")
}
let identifier = info[kSecCodeInfoIdentifier as String] as? String
guard identifier == expectedBundleIdentifier else {
throw UpdateCheckError.signatureInvalid(
"unexpected bundle identifier: \(identifier ?? "nil")"
)
}
let teamIdentifier = info[kSecCodeInfoTeamIdentifier as String] as? String
guard let teamIdentifier, resolvedAllowedTeamIdentifiers().contains(teamIdentifier) else {
throw UpdateCheckError.signatureInvalid(
"unexpected team identifier: \(teamIdentifier ?? "nil")"
)
}
}
private static func resolvedAllowedTeamIdentifiers() -> Set<String> {
if let env = ProcessInfo.processInfo.environment["REDLINE_ALLOWED_TEAMS"], !env.isEmpty {
let parts = env.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
if !parts.isEmpty {
return Set(parts)
}
}
return allowedTeamIdentifiers
}
// MARK: - Private // MARK: - Private
private struct Appcast: Decodable { private struct Appcast: Decodable {
@@ -170,6 +342,7 @@ final class UpdateChecker {
case invalidPayload case invalidPayload
case httpStatus(Int) case httpStatus(Int)
case processFailed(String) case processFailed(String)
case signatureInvalid(String)
} }
private func fetchAppcast() async throws -> Appcast { private func fetchAppcast() async throws -> Appcast {
@@ -219,6 +392,7 @@ final class UpdateChecker {
guard FileManager.default.fileExists(atPath: executable.path) else { guard FileManager.default.fileExists(atPath: executable.path) else {
throw UpdateCheckError.invalidPayload throw UpdateCheckError.invalidPayload
} }
try Self.verifySignature(of: appURL)
stagedAppURL = appURL stagedAppURL = appURL
} }
@@ -235,6 +409,35 @@ final class UpdateChecker {
stagedAppURL = nil stagedAppURL = nil
} }
/// Spawns a detached watcher that waits for this process to exit, then reopens
/// `target`, and quits. Never called during the self-test, so the in-process
/// assertions after `installStaged`/`revertToPrevious` can still run.
private func relaunch(target: URL) {
guard ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] == nil else { return }
let ownPID = ProcessInfo.processInfo.processIdentifier
let script = "while kill -0 \(ownPID) 2>/dev/null; do sleep 0.2; done; " +
"/usr/bin/open -n \(Self.shellQuoted(target.path))"
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", script]
process.standardInput = FileHandle.nullDevice
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do {
try process.run()
} catch {
statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications."
onChecked?()
return
}
NSApp.terminate(nil)
}
private static func shellQuoted(_ path: String) -> String {
"'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private static func findRedlineApp(in directory: URL) -> URL? { private static func findRedlineApp(in directory: URL) -> URL? {
let fm = FileManager.default let fm = FileManager.default
let direct = directory.appendingPathComponent("Redline.app") let direct = directory.appendingPathComponent("Redline.app")