129 lines
5.2 KiB
Swift
129 lines
5.2 KiB
Swift
import Foundation
|
|
import AppKit
|
|
|
|
/// Update Local: downloads DMG from remote and swaps app. No version or integrity checks.
|
|
enum AutoUpdater {
|
|
|
|
private static let remoteDMGURL = URL(
|
|
string: "https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg"
|
|
)!
|
|
|
|
private static var supportDir: URL {
|
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
|
let dir = support.appendingPathComponent("Pommedoro", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir
|
|
}
|
|
|
|
private static var statePath: URL { supportDir.appendingPathComponent("current.sha256") }
|
|
private static var downloadedDMGPath: URL {
|
|
FileManager.default.temporaryDirectory.appendingPathComponent("Pommedoro-update.dmg")
|
|
}
|
|
private static var stagedAppPath: String { NSTemporaryDirectory() + "Pommedoro-staged.app" }
|
|
private static var pendingStatePath: String { NSTemporaryDirectory() + "pommedoro-pending-state.txt" }
|
|
|
|
static func updateLocal(silent: Bool = false) {
|
|
DispatchQueue.global(qos: .utility).async { performUpdateLocal(silent: silent) }
|
|
}
|
|
|
|
private static func performUpdateLocal(silent: Bool) {
|
|
if !silent { showAlert(title: "Update Local", message: "Getting latest version...") }
|
|
let sem = DispatchSemaphore(value: 0)
|
|
var downloadOk = false
|
|
let task = URLSession.shared.downloadTask(with: remoteDMGURL) { url, response, _ in
|
|
defer { sem.signal() }
|
|
guard let url = url, (response as? HTTPURLResponse)?.statusCode == 200 else { return }
|
|
do {
|
|
try? FileManager.default.removeItem(at: downloadedDMGPath)
|
|
try FileManager.default.moveItem(at: url, to: downloadedDMGPath)
|
|
downloadOk = true
|
|
} catch {}
|
|
}
|
|
task.resume()
|
|
sem.wait()
|
|
guard downloadOk else {
|
|
if !silent { showAlert(title: "Update Failed", message: "Download error.") }
|
|
return
|
|
}
|
|
guard stageFromDMG() else {
|
|
if !silent { showAlert(title: "Update Failed", message: "Install error.") }
|
|
return
|
|
}
|
|
try? "local\n0".write(toFile: pendingStatePath, atomically: true, encoding: .utf8)
|
|
spawnSwapAndRelaunch()
|
|
DispatchQueue.main.async {
|
|
showAlert(title: "Update complete", message: "Reinstalled from remote. Restarting now.")
|
|
NSApp.terminate(nil)
|
|
}
|
|
}
|
|
|
|
private static func stageFromDMG() -> Bool {
|
|
let dmg = downloadedDMGPath.path
|
|
let mountPoint = "/tmp/pommedoro-update-mount"
|
|
guard runShell("hdiutil attach \"\(dmg)\" -mountpoint \"\(mountPoint)\" -nobrowse -quiet") else { return false }
|
|
defer {
|
|
_ = runShell("hdiutil detach \"\(mountPoint)\" -quiet")
|
|
try? FileManager.default.removeItem(at: downloadedDMGPath)
|
|
}
|
|
let srcApp = "\(mountPoint)/Pommedoro.app"
|
|
guard FileManager.default.fileExists(atPath: srcApp) else { return false }
|
|
try? FileManager.default.removeItem(atPath: stagedAppPath)
|
|
do { try FileManager.default.copyItem(atPath: srcApp, toPath: stagedAppPath) }
|
|
catch { return false }
|
|
return true
|
|
}
|
|
|
|
private static func spawnSwapAndRelaunch() {
|
|
let pid = ProcessInfo.processInfo.processIdentifier
|
|
let staged = stagedAppPath
|
|
let dest = "/Applications/Pommedoro.app"
|
|
let stateFile = statePath.path
|
|
let pending = pendingStatePath
|
|
|
|
let script = """
|
|
#!/bin/bash
|
|
for i in $(seq 1 20); do kill -0 \(pid) 2>/dev/null || break; sleep 0.5; done
|
|
rm -rf "\(dest)"
|
|
mv "\(staged)" "\(dest)"
|
|
xattr -cr "\(dest)"
|
|
if [ -f "\(pending)" ]; then
|
|
mkdir -p "$(dirname "\(stateFile)")"
|
|
cp "\(pending)" "\(stateFile)"
|
|
rm -f "\(pending)"
|
|
fi
|
|
open "\(dest)"
|
|
"""
|
|
let scriptPath = NSTemporaryDirectory() + "pommedoro-swap.sh"
|
|
try? script.write(toFile: scriptPath, atomically: true, encoding: .utf8)
|
|
let proc = Process()
|
|
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
proc.arguments = [scriptPath]
|
|
proc.standardOutput = FileHandle.nullDevice
|
|
proc.standardError = FileHandle.nullDevice
|
|
proc.qualityOfService = .utility
|
|
try? proc.run()
|
|
}
|
|
|
|
private static func runShell(_ command: String) -> Bool {
|
|
let proc = Process()
|
|
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
proc.arguments = ["-c", command]
|
|
proc.standardOutput = FileHandle.nullDevice
|
|
proc.standardError = FileHandle.nullDevice
|
|
guard (try? proc.run()) != nil else { return false }
|
|
proc.waitUntilExit()
|
|
return proc.terminationStatus == 0
|
|
}
|
|
|
|
private static func showAlert(title: String, message: String) {
|
|
DispatchQueue.main.async {
|
|
let a = NSAlert()
|
|
a.messageText = title
|
|
a.informativeText = message
|
|
a.alertStyle = .informational
|
|
a.addButton(withTitle: "OK")
|
|
a.runModal()
|
|
}
|
|
}
|
|
}
|