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)") } } }