feat: seamless background update cycle#11
Conversation
- Add periodic update checks every 4 hours via startPeriodicChecks() - Replace DMG drag-to-install with in-place seamless update: mount DMG → verify signature → ditto to staging → detach → strip quarantine → run one-shot updater script → NSApp.terminate - Fall back to opening DMG if any step fails - Add isInstalling published state for distinct UI feedback - Separate UI spinners for downloading vs installing phases - Add updaterScriptDelay constant (1.5s) and .shellQuoted helper - Validate destination is a .app bundle before rm -rf - Use fixed script name to overwrite stale updater scripts Co-authored-by: ygivenx <31292443+ygivenx@users.noreply.github.com> Agent-Logs-Url: https://github.com/ygivenx/freeWispr/sessions/6e6bc10e-3532-44c9-a6ba-b118a22a0d83
There was a problem hiding this comment.
Pull request overview
Adds an automatic update lifecycle for the macOS app: periodic background update discovery plus a “seamless” in-place install flow that attempts to replace the running bundle via a one-shot updater script, with fallback to the existing manual DMG install UX.
Changes:
- Introduces a
Task-driven periodic update check loop inUpdateCheckerand starts it fromAppState.setup(). - Replaces the DMG “open + drag to /Applications” flow with a staged copy + updater-script swap approach (with signature verification and fallback).
- Updates the menu UI to show separate “Downloading…” vs “Installing…” states.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| FreeWispr/Sources/FreeWispr/UpdateChecker.swift | Adds periodic checks, new install state, and implements the seamless in-place update workflow + shell quoting helper. |
| FreeWispr/Sources/FreeWispr/AppState.swift | Starts periodic update checks after the initial on-launch update check. |
| FreeWispr/Sources/FreeWispr/FreeWisprApp.swift | Updates menu UI to reflect install-vs-download progress. |
Comments suppressed due to low confidence (1)
FreeWispr/Sources/FreeWispr/UpdateChecker.swift:162
- If
hdiutil attachsucceeds but the plist output can’t be parsed, the function returns without detaching the DMG, leaving it mounted. Consider providing an explicit-mountpoint(so you can always detach), or extracting a disk identifier/mountpoint robustly and ensuring detach happens on every early-return path.
let outputData = pipe.fileHandleForReading.readDataToEndOfFile()
guard let plist = try? PropertyListSerialization.propertyList(from: outputData, format: nil) as? [String: Any],
let entities = plist["system-entities"] as? [[String: Any]],
let mountPoint = entities.first(where: { $0["mount-point"] != nil })?["mount-point"] as? String
else {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Write a one-shot shell script: wait briefly, replace the app, reopen it. | ||
| // A fixed name is used so stale scripts from previous failed updates are overwritten. | ||
| let stagedPath = stagingURL.path.shellQuoted | ||
| let destPath = destinationURL.path.shellQuoted | ||
| let script = """ | ||
| #!/bin/sh | ||
| sleep \(updaterScriptDelay) | ||
| rm -rf \(destPath) | ||
| mv \(stagedPath) \(destPath) | ||
| open \(destPath) |
There was a problem hiding this comment.
The updater script deletes the existing app (rm -rf) and then does a non-atomic mv after a fixed sleep. If mv fails (permissions, disk full, cross-volume move, etc.) the user can be left without a working app, and the fixed delay may race with the app still terminating. Consider a safer swap strategy (e.g., wait for the process to exit, move the existing bundle to a backup first, then move/copy the staged bundle into place and roll back on failure).
| // Write a one-shot shell script: wait briefly, replace the app, reopen it. | |
| // A fixed name is used so stale scripts from previous failed updates are overwritten. | |
| let stagedPath = stagingURL.path.shellQuoted | |
| let destPath = destinationURL.path.shellQuoted | |
| let script = """ | |
| #!/bin/sh | |
| sleep \(updaterScriptDelay) | |
| rm -rf \(destPath) | |
| mv \(stagedPath) \(destPath) | |
| open \(destPath) | |
| // Write a one-shot shell script: wait briefly, safely swap the app bundle, then reopen it. | |
| // A fixed name is used so stale scripts from previous failed updates are overwritten. | |
| let stagedPath = stagingURL.path.shellQuoted | |
| let destPath = destinationURL.path.shellQuoted | |
| let backupPath = (destinationURL.path + ".backup").shellQuoted | |
| let originatingPID = ProcessInfo.processInfo.processIdentifier | |
| let script = """ | |
| #!/bin/sh | |
| set -u | |
| STAGED_PATH=\(stagedPath) | |
| DEST_PATH=\(destPath) | |
| BACKUP_PATH=\(backupPath) | |
| TARGET_PID=\(originatingPID) | |
| # Give the app a brief head start on shutdown | |
| sleep \(updaterScriptDelay) | |
| # Wait until the originating process has fully exited to avoid races | |
| if [ -n "$TARGET_PID" ]; then | |
| while kill -0 "$TARGET_PID" 2>/dev/null; do | |
| sleep 0.2 | |
| done | |
| fi | |
| # Move existing app to a backup location first | |
| if mv "$DEST_PATH" "$BACKUP_PATH"; then | |
| # Try to put the new app in place | |
| if mv "$STAGED_PATH" "$DEST_PATH"; then | |
| # New app successfully installed; remove backup and reopen | |
| rm -rf "$BACKUP_PATH" | |
| open "$DEST_PATH" | |
| exit 0 | |
| else | |
| # Failed to move new app into place — roll back original | |
| mv "$BACKUP_PATH" "$DEST_PATH" | |
| exit 1 | |
| fi | |
| else | |
| # Could not move existing app out of the way; try to reopen it as-is | |
| open "$DEST_PATH" || true | |
| exit 1 | |
| fi |
| let scriptURL = FileManager.default.temporaryDirectory | ||
| .appendingPathComponent("freewispr-updater.sh") | ||
| do { | ||
| try script.write(to: scriptURL, atomically: true, encoding: .utf8) | ||
| try FileManager.default.setAttributes( |
There was a problem hiding this comment.
Writing and executing a fixed-name updater script in the temp directory is vulnerable to symlink/TOCTOU issues (another process could replace the path between write/permission-set/run). Consider creating the script securely (unique temp file with exclusive create, verify it’s a regular file, then execute), even if you still want to clean up older scripts separately.
| /// Start periodic background update checks at the given interval (default: every 4 hours). | ||
| func startPeriodicChecks(interval: TimeInterval = updateCheckInterval) { | ||
| periodicCheckTask?.cancel() | ||
| periodicCheckTask = Task { [weak self] in | ||
| while !Task.isCancelled { | ||
| try? await Task.sleep(for: .seconds(interval)) | ||
| guard !Task.isCancelled else { break } | ||
| await self?.checkForUpdate() | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
There are existing UpdateCheckerTests, but the new periodic-check loop and seamless-install path aren’t covered. Consider adding tests around task start/stop/cancellation behavior (and, if feasible, factoring clock/network/process execution behind injectable dependencies so the seamless install flow can be unit tested without invoking system tools).
| while !Task.isCancelled { | ||
| try? await Task.sleep(for: .seconds(interval)) | ||
| guard !Task.isCancelled else { break } | ||
| await self?.checkForUpdate() |
There was a problem hiding this comment.
The periodic background task captures self weakly but never exits when self becomes nil, so if UpdateChecker is ever deallocated the Task can keep waking forever (doing nothing) until the process exits. Consider breaking the loop when self is nil (or canceling the task in deinit).
| await self?.checkForUpdate() | |
| guard let self = self else { break } | |
| await self.checkForUpdate() |
| return false | ||
| } | ||
| } else { | ||
| logger.warning("Cannot determine own Team ID — skipping signature verification") |
There was a problem hiding this comment.
If expectedTeamID cannot be determined, the code currently proceeds with a seamless install and also strips quarantine, which allows installing an unverified bundle. For safety, treat a missing Team ID as a hard failure for seamless updates (return false and fall back), or at least require a validity check that still binds the update to an expected signer.
| logger.warning("Cannot determine own Team ID — skipping signature verification") | |
| logger.error("Cannot determine own Team ID — aborting seamless update for safety") | |
| detachDMG(mountPoint: mountPoint) | |
| return false |
The update flow required a manual relaunch to discover updates, then forced users through a drag-to-Applications DMG window. Updates should be discovered automatically and installed without user friction.
Changes
Periodic background checks (
UpdateChecker)startPeriodicChecks(interval:)/stopPeriodicChecks()— SwiftTask-based loop that re-checks GitHub Releases every 4 hours while the app is runningAppState.setup()callsstartPeriodicChecks()after the initial on-launch checkSeamless in-place install (
performSeamlessUpdate)Replaces the old "open DMG → drag to /Applications" path:
hdiutil attach -nobrowse -readonly).appcode signature (existing logic, extracted toverifyAppSignature)dittothe.appto a temp staging path (preserves all metadata)xattr -dr com.apple.quarantine).appextension before anyrm -rfNSApp.terminate(nil)— current instance quits, script applies update, new version launchesFalls back to opening the DMG for manual install if any step fails.
New state & UI
@Published var isInstallingdistinguishes the install phase from downloadSafety / hygiene
updaterScriptDelayconstant (1.5 s) replaces the hardcoded sleep valuefreewispr-updater.sh) overwrites stale scripts instead of accumulating UUID-named files.shellQuotedextension properly single-quote-escapes paths for the updater scriptOriginal prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.