feature: built-in auto-update (appcast + sha256 + staged install), version 0.2.0
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user