Skip to content

feat: seamless background update cycle#11

Open
Copilot wants to merge 2 commits into
mainfrom
copilot/update-seamless-download-mechanism
Open

feat: seamless background update cycle#11
Copilot wants to merge 2 commits into
mainfrom
copilot/update-seamless-download-mechanism

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 25, 2026

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() — Swift Task-based loop that re-checks GitHub Releases every 4 hours while the app is running
  • AppState.setup() calls startPeriodicChecks() after the initial on-launch check

Seamless in-place install (performSeamlessUpdate)

Replaces the old "open DMG → drag to /Applications" path:

  1. Mount DMG silently (hdiutil attach -nobrowse -readonly)
  2. Verify .app code signature (existing logic, extracted to verifyAppSignature)
  3. ditto the .app to a temp staging path (preserves all metadata)
  4. Detach DMG
  5. Strip quarantine (xattr -dr com.apple.quarantine)
  6. Guard: destination must have .app extension before any rm -rf
  7. Write + launch a fixed-name one-shot shell script that waits 1.5 s, swaps the bundle, and reopens it
  8. NSApp.terminate(nil) — current instance quits, script applies update, new version launches

Falls back to opening the DMG for manual install if any step fails.

New state & UI

  • @Published var isInstalling distinguishes the install phase from download
  • Menu shows "Downloading update…""Installing update…" spinners in sequence

Safety / hygiene

  • updaterScriptDelay constant (1.5 s) replaces the hardcoded sleep value
  • Fixed script name (freewispr-updater.sh) overwrites stale scripts instead of accumulating UUID-named files
  • .shellQuoted extension properly single-quote-escapes paths for the updater script
Original prompt

This section details on the original issue you should resolve

<issue_title>update cycle</issue_title>
<issue_description>When the user tries to uddate the app, first of all, they need to relaunch the to check if there are any updates. When they click on the Update button, it downloads the thing and opens a popup where the user has to drop the app into the Applications folder. This approach sounds very cumbersome, so we should check for updates at pre-defined Times. If the update is available, and the user clicks on it, the app should download in the background - And the update cycle should be seamless.</issue_description>

<agent_instructions>Can we just follow the update mechanism of how other famous applications like ghostty are updated?</agent_instructions>

Comments on the Issue (you are @copilot in this section)


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

- 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
Copilot AI changed the title [WIP] Implement seamless update cycle for application feat: seamless background update cycle Mar 25, 2026
Copilot AI requested a review from ygivenx March 25, 2026 17:52
@ygivenx ygivenx marked this pull request as ready for review March 25, 2026 17:55
Copilot AI review requested due to automatic review settings March 25, 2026 17:55
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in UpdateChecker and starts it from AppState.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 attach succeeds 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.

Comment on lines +227 to +236
// 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)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
// 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

Copilot uses AI. Check for mistakes.
Comment on lines +238 to +242
let scriptURL = FileManager.default.temporaryDirectory
.appendingPathComponent("freewispr-updater.sh")
do {
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +57
/// 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()
}
}
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(interval))
guard !Task.isCancelled else { break }
await self?.checkForUpdate()
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
await self?.checkForUpdate()
guard let self = self else { break }
await self.checkForUpdate()

Copilot uses AI. Check for mistakes.
return false
}
} else {
logger.warning("Cannot determine own Team ID — skipping signature verification")
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

update cycle

3 participants