Fix auto-updater to safely swap app while running
Instead of replacing /Applications/Pommedoro.app in-place while the process is still running, the updater now stages the new .app to a temp directory, spawns a background shell script that waits for the current process to exit, then swaps the app and relaunches. This ensures applicationWillTerminate fires to save timer state and avoids corrupting the running binary. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5f970abc0c
commit
c14ab51724
|
|
@ -32,6 +32,11 @@ enum AutoUpdater {
|
||||||
FileManager.default.temporaryDirectory.appendingPathComponent("Pommedoro-update.dmg")
|
FileManager.default.temporaryDirectory.appendingPathComponent("Pommedoro-update.dmg")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Staging location for the new .app before swap
|
||||||
|
private static var stagedAppPath: String {
|
||||||
|
NSTemporaryDirectory() + "Pommedoro-staged.app"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
|
|
||||||
/// Check for updates in the background. Optionally show UI feedback.
|
/// Check for updates in the background. Optionally show UI feedback.
|
||||||
|
|
@ -76,17 +81,21 @@ enum AutoUpdater {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Mount, install, relaunch
|
// 7. Extract new app from DMG to staging area
|
||||||
guard installFromDMG() else {
|
guard stageFromDMG() else {
|
||||||
showAlert(title: "Update Failed", message: "Could not install the update.")
|
showAlert(title: "Update Failed", message: "Could not extract the update.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Save new SHA so we don't re-update next launch
|
// 8. Save new SHA so we don't re-update next launch
|
||||||
try? remoteSHA.write(to: localSHAPath, atomically: true, encoding: .utf8)
|
try? remoteSHA.write(to: localSHAPath, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
// 9. Relaunch
|
// 9. Spawn swap script, then terminate so applicationWillTerminate saves state
|
||||||
relaunch()
|
spawnSwapAndRelaunch()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Networking
|
// MARK: - Networking
|
||||||
|
|
@ -166,13 +175,14 @@ enum AutoUpdater {
|
||||||
try? sha.write(to: localSHAPath, atomically: true, encoding: .utf8)
|
try? sha.write(to: localSHAPath, atomically: true, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - DMG Install
|
// MARK: - Stage from DMG
|
||||||
|
|
||||||
private static func installFromDMG() -> Bool {
|
/// Mount the downloaded DMG and copy the .app to a temp staging directory.
|
||||||
|
/// This avoids replacing the running binary in-place.
|
||||||
|
private static func stageFromDMG() -> Bool {
|
||||||
let dmg = downloadedDMGPath.path
|
let dmg = downloadedDMGPath.path
|
||||||
let mountPoint = "/tmp/pommedoro-update-mount"
|
let mountPoint = "/tmp/pommedoro-update-mount"
|
||||||
|
|
||||||
// Mount
|
|
||||||
let mountOK = runShell("hdiutil attach \"\(dmg)\" -mountpoint \"\(mountPoint)\" -nobrowse -quiet")
|
let mountOK = runShell("hdiutil attach \"\(dmg)\" -mountpoint \"\(mountPoint)\" -nobrowse -quiet")
|
||||||
guard mountOK else { return false }
|
guard mountOK else { return false }
|
||||||
|
|
||||||
|
|
@ -182,25 +192,61 @@ enum AutoUpdater {
|
||||||
}
|
}
|
||||||
|
|
||||||
let srcApp = "\(mountPoint)/Pommedoro.app"
|
let srcApp = "\(mountPoint)/Pommedoro.app"
|
||||||
let dstApp = "/Applications/Pommedoro.app"
|
|
||||||
|
|
||||||
guard FileManager.default.fileExists(atPath: srcApp) else { return false }
|
guard FileManager.default.fileExists(atPath: srcApp) else { return false }
|
||||||
|
|
||||||
// Remove old and copy new
|
// Copy to staging
|
||||||
try? FileManager.default.removeItem(atPath: dstApp)
|
try? FileManager.default.removeItem(atPath: stagedAppPath)
|
||||||
do {
|
do {
|
||||||
try FileManager.default.copyItem(atPath: srcApp, toPath: dstApp)
|
try FileManager.default.copyItem(atPath: srcApp, toPath: stagedAppPath)
|
||||||
} catch {
|
} catch {
|
||||||
BugReporting.capture(error)
|
BugReporting.capture(error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip quarantine
|
|
||||||
_ = runShell("xattr -cr \"\(dstApp)\"")
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Swap and Relaunch
|
||||||
|
|
||||||
|
/// Spawns a background shell script that:
|
||||||
|
/// 1. Waits for the current process to exit
|
||||||
|
/// 2. Replaces /Applications/Pommedoro.app with the staged copy
|
||||||
|
/// 3. Strips quarantine
|
||||||
|
/// 4. Relaunches the app
|
||||||
|
/// The script is fire-and-forget so the current process can terminate cleanly.
|
||||||
|
private static func spawnSwapAndRelaunch() {
|
||||||
|
let pid = ProcessInfo.processInfo.processIdentifier
|
||||||
|
let staged = stagedAppPath
|
||||||
|
let dest = "/Applications/Pommedoro.app"
|
||||||
|
|
||||||
|
let script = """
|
||||||
|
#!/bin/bash
|
||||||
|
# Wait for the old process to exit (up to 10 seconds)
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
kill -0 \(pid) 2>/dev/null || break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
# Swap
|
||||||
|
rm -rf "\(dest)"
|
||||||
|
mv "\(staged)" "\(dest)"
|
||||||
|
xattr -cr "\(dest)"
|
||||||
|
# Relaunch
|
||||||
|
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
|
||||||
|
// Detach from our process group so it survives our exit
|
||||||
|
proc.qualityOfService = .utility
|
||||||
|
try? proc.run()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Shell Helper
|
// MARK: - Shell Helper
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|
@ -219,19 +265,6 @@ enum AutoUpdater {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Relaunch
|
|
||||||
|
|
||||||
private static func relaunch() {
|
|
||||||
let appPath = "/Applications/Pommedoro.app"
|
|
||||||
let task = Process()
|
|
||||||
task.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
|
||||||
task.arguments = ["-n", appPath]
|
|
||||||
try? task.run()
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
NSApp.terminate(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - User Prompts
|
// MARK: - User Prompts
|
||||||
|
|
||||||
private static func promptForUpdate() -> Bool {
|
private static func promptForUpdate() -> Bool {
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1 +1 @@
|
||||||
9885a430d643f9f7599a9b9581f9b3d65cec81ffb29a0be8e94489044c520835
|
7e15ab9b42481c28c9566c7d71bad03558bb2cdec7dcceb956443caf5cb1724a
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue