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:
Leopere 2026-02-08 15:48:56 -05:00
parent 5f970abc0c
commit c14ab51724
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
3 changed files with 63 additions and 30 deletions

View File

@ -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.

View File

@ -1 +1 @@
9885a430d643f9f7599a9b9581f9b3d65cec81ffb29a0be8e94489044c520835 7e15ab9b42481c28c9566c7d71bad03558bb2cdec7dcceb956443caf5cb1724a