282 lines
9.7 KiB
Swift
282 lines
9.7 KiB
Swift
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<Void, Never>?
|
|
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)")
|
|
}
|
|
}
|
|
}
|