diff --git a/Sources/Shotdeck/UpdateChecker.swift b/Sources/Shotdeck/UpdateChecker.swift index 207f51a..18ff2d0 100644 --- a/Sources/Shotdeck/UpdateChecker.swift +++ b/Sources/Shotdeck/UpdateChecker.swift @@ -1,6 +1,7 @@ import AppKit import CryptoKit import Foundation +import Security /// Built-in updater. Checks an appcast, stages a verified payload, and installs /// 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 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 = ["PWMCBMX5M8", "L3N9S54CN3"] + private(set) var availableUpdate: (version: String, notes: String)? private(set) var stagedAppURL: URL? private(set) var statusMessage: String? + private(set) var lastCheckedAt: Date? + private(set) var isCheckingNow: Bool = false 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 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.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 600 config.httpCookieAcceptPolicy = .never config.httpShouldSetCookies = false config.httpCookieStorage = nil @@ -51,10 +61,18 @@ final class UpdateChecker { repeatingTimer = timer } - func checkNow() async { - guard !isChecking else { return } - isChecking = true - defer { isChecking = false } + /// Checks the appcast and stages a newer, signature-verified payload. + /// `manual` only affects the status message shown when already up to date — + /// a user-initiated check says so; the silent background check stays quiet. + func checkNow(manual: Bool = false) async { + guard !isCheckingNow else { return } + isCheckingNow = true + onCheckingChanged?(true) + defer { + isCheckingNow = false + onCheckingChanged?(false) + } + lastCheckedAt = Date() let appcast: Appcast do { @@ -67,7 +85,7 @@ final class UpdateChecker { guard Self.isNewer(appcast.version, than: Self.currentVersion()) else { clearOffer() - statusMessage = nil + statusMessage = manual ? "Redline \(Self.currentVersion()) is up to date." : nil onChecked?() return } @@ -80,6 +98,10 @@ final class UpdateChecker { discardStaging() availableUpdate = nil statusMessage = "Update file failed the checksum — not installed." + } catch UpdateCheckError.signatureInvalid { + discardStaging() + availableUpdate = nil + statusMessage = "Update is not signed by MMD — not installed." } catch { discardStaging() availableUpdate = nil @@ -88,9 +110,9 @@ final class UpdateChecker { 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. + /// Installs the staged app onto `target` atomically, keeping exactly one rollback + /// copy (`Redline.app.previous`), then hands off to a relaunch and quits. + /// Never deletes the old app before the new one is verified in place. func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) { guard let staged = stagedAppURL else { statusMessage = "No update is staged." @@ -98,29 +120,118 @@ final class UpdateChecker { return } + let targetDir = target.deletingLastPathComponent() + let previousURL = targetDir.appendingPathComponent("Redline.app.previous") + do { - try FileManager.default.createDirectory( - at: target.deletingLastPathComponent(), - withIntermediateDirectories: true + try FileManager.default.createDirectory(at: targetDir, 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 { statusMessage = "The update could not be installed." onChecked?() return } - let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil - if isSelfTest { return } - + // Defense in depth: re-verify what actually landed on disk, not just the staged copy. do { - try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path]) + try Self.verifySignature(of: target) } catch { - statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications." + statusMessage = "The update was installed but failed verification." onChecked?() 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 { @@ -156,6 +267,67 @@ final class UpdateChecker { 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? + 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 { + 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 private struct Appcast: Decodable { @@ -170,6 +342,7 @@ final class UpdateChecker { case invalidPayload case httpStatus(Int) case processFailed(String) + case signatureInvalid(String) } private func fetchAppcast() async throws -> Appcast { @@ -219,6 +392,7 @@ final class UpdateChecker { guard FileManager.default.fileExists(atPath: executable.path) else { throw UpdateCheckError.invalidPayload } + try Self.verifySignature(of: appURL) stagedAppURL = appURL } @@ -235,6 +409,35 @@ final class UpdateChecker { 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? { let fm = FileManager.default let direct = directory.appendingPathComponent("Redline.app")