Let users on an outdated build know inside the app, with copy and a
button that match how they actually installed OpenQuack — and a hook
for proper auto-update once we have notarisation. Today’s banner
(“Click to download from GitHub Releases”) is identical for brew users
and DMG users, which leads brew users to drag-install a DMG over their
brew-managed .app and silently drift brew upgrade out of sync.
Status: implemented in this PR.
InstallMethodDetector resolves Bundle.main.bundleURL and decides:
.homebrew(prefix:) if the path is under <prefix>/Caskroom/openquack/
(resolved through symlinks), or if <prefix>/Caskroom/openquack/.metadata
exists alongside a manual-looking .app (belt-and-braces)..manual otherwise.AppState.installMethod is set once at launch and never changes.brew upgrade --cask openquack to clipboard,
open Terminal.app. User presses ⌘V + ⏎.⬆ to 🦆 when an update is detected
and the app is idle / ready, so the duck reads as 🦆⬆ until the
user opens the popover and acts.Image(systemName: "arrow.down.circle") in the banner uses
.symbolEffect(.bounce) on macOS 14+ for a one-shot bounce when the
banner first renders. Static fallback on Ventura.Deferred — needs visual iteration with the maintainer.
NSImage-based icon set:
@Sendable SF-Symbol-like proportions, monochrome template image
that adapts to menu-bar dark/light + accent).idle, recording, transcribing, update.update overlays a small badge (⬆ glyph or download arrow) in
the duck’s tail/wing area.CAAnimation on the status item button’s
image layer.playSounds.Open questions for next session:
Status: implemented (see PR that cites this SPEC section).
When the user clicks Upgrade and OpenQuack was installed via Homebrew,
run brew upgrade --cask openquack silently in the background — no
Terminal window, no clipboard dance. The duck disappears for ~30
seconds, then the new version relaunches itself.
Quit-first, relaunch-after. The running binary must exit before
brew swaps the bundle, not after. Keeping the app alive during the
upgrade means brew can move resource files out from under the running
process; any bundle load that happens in that window (icon refresh,
sound, popover open) can crash, and if it does there is no parent
process left to do open -a OpenQuack. The sequence must be:
click Upgrade
└─ write + launch detached /tmp/openquack-upgrade-*.sh
└─ appState.updateStatus = .upgrading (banner → "Installing…")
└─ NSApp.terminate after 1.5 s (old binary exits cleanly)
[old process gone]
detached script (reparented to launchd):
└─ brew upgrade --cask openquack
└─ open -a OpenQuack (new binary starts)
└─ rm -- "$0"
The 1.5 s window is long enough for the “Installing update…” banner to render and give the user a moment to see what’s happening before the duck disappears. It is short enough that the script is already running before the parent exits.
No conflict between old and new instance. The old process exits at
t ≈ 1.5 s; open -a OpenQuack fires at t ≈ 30–60 s (brew network
latency). There is no overlap. macOS single-instance behaviour
(applicationShouldHandleReopen) is not exercised because the old
process is already gone.
Detached script, not an in-process Process. Running brew inside
the live app and quitting on terminationHandler completion is
attractive for live progress but inherits the bundle-swap risk above.
A detached shell script survives the parent’s exit (reparented to
launchd), eliminates the race, and keeps UpgradeAction stateless.
Explicit PATH. GUI-launched processes inherit a sparse PATH
(/usr/bin:/bin:/usr/sbin:/sbin). brew itself is reachable by
absolute path (<prefix>/bin/brew), but brew shells out to curl,
git, and sometimes gpg. The script env sets:
PATH=<prefix>/bin:/usr/bin:/bin:/usr/sbin:/sbin
HOMEBREW_PREFIX=<prefix>
UpdateCheckStatus gains a new case .upgrading (no associated value).
It is active from the moment the script is launched until the process
exits (~1.5 s). hasAvailableUpdate returns false for .upgrading
so the ⬆ status-item badge clears immediately on click.
UI surfaces:
ProgressView + “Installing update… Duck will be back in ~30 seconds.”ProgressView + “Installing…” text..available(release); user can retry./bin/bash exec fails → same; also remove the partially-written script.open -a OpenQuack runs anyway (semicolon, not
&&), so the duck comes back. The new instance will re-poll for
updates on launch; if the version is unchanged it shows .upToDate.About shows the new version string.open -a fires).UNUserNotification is a stretch for a future PR).Deferred to M3 (signed builds).
Sparkle once we have an Apple
Developer ID and notarised builds. Sparkle handles download +
in-place replace + relaunch, which we currently can’t safely offer
without a stable code signature across releases.brew install --cask openquack against a
pre-release tag, run brew tap + cask edit to point at a newer
version → relaunch app, popover banner reads “Upgrade”, clicking it
drops the brew command on the clipboard and opens Terminal.~/Desktop/OpenQuack.app, run, repeat the
cask trick → banner reads “Download”, clicking opens the DMG asset
URL in the browser.🦆⬆ while idle, reverts to 🦆 after popover
open + relaunch (next launch: availableUpdate is nil if cask sha
matched, so duck is plain again).