diff --git a/Sources/Pommedoro/AutoUpdater.swift b/Sources/Pommedoro/AutoUpdater.swift index 3cfd374..cb787ec 100644 --- a/Sources/Pommedoro/AutoUpdater.swift +++ b/Sources/Pommedoro/AutoUpdater.swift @@ -32,6 +32,11 @@ enum AutoUpdater { 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 /// Check for updates in the background. Optionally show UI feedback. @@ -76,17 +81,21 @@ enum AutoUpdater { return } - // 7. Mount, install, relaunch - guard installFromDMG() else { - showAlert(title: "Update Failed", message: "Could not install the update.") + // 7. Extract new app from DMG to staging area + guard stageFromDMG() else { + showAlert(title: "Update Failed", message: "Could not extract the update.") return } // 8. Save new SHA so we don't re-update next launch try? remoteSHA.write(to: localSHAPath, atomically: true, encoding: .utf8) - // 9. Relaunch - relaunch() + // 9. Spawn swap script, then terminate so applicationWillTerminate saves state + spawnSwapAndRelaunch() + + DispatchQueue.main.async { + NSApp.terminate(nil) + } } // MARK: - Networking @@ -166,13 +175,14 @@ enum AutoUpdater { 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 mountPoint = "/tmp/pommedoro-update-mount" - // Mount let mountOK = runShell("hdiutil attach \"\(dmg)\" -mountpoint \"\(mountPoint)\" -nobrowse -quiet") guard mountOK else { return false } @@ -182,25 +192,61 @@ enum AutoUpdater { } let srcApp = "\(mountPoint)/Pommedoro.app" - let dstApp = "/Applications/Pommedoro.app" - guard FileManager.default.fileExists(atPath: srcApp) else { return false } - // Remove old and copy new - try? FileManager.default.removeItem(atPath: dstApp) + // Copy to staging + try? FileManager.default.removeItem(atPath: stagedAppPath) do { - try FileManager.default.copyItem(atPath: srcApp, toPath: dstApp) + try FileManager.default.copyItem(atPath: srcApp, toPath: stagedAppPath) } catch { BugReporting.capture(error) return false } - // Strip quarantine - _ = runShell("xattr -cr \"\(dstApp)\"") - 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 @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 private static func promptForUpdate() -> Bool { diff --git a/releases/Pommedoro.dmg b/releases/Pommedoro.dmg index 820a08e..bfb63c8 100644 Binary files a/releases/Pommedoro.dmg and b/releases/Pommedoro.dmg differ diff --git a/releases/Pommedoro.dmg.sha256 b/releases/Pommedoro.dmg.sha256 index e07997b..8ac8a02 100644 --- a/releases/Pommedoro.dmg.sha256 +++ b/releases/Pommedoro.dmg.sha256 @@ -1 +1 @@ -9885a430d643f9f7599a9b9581f9b3d65cec81ffb29a0be8e94489044c520835 +7e15ab9b42481c28c9566c7d71bad03558bb2cdec7dcceb956443caf5cb1724a