Skip to content

Add update checks and in-place auto-update from GitHub Releases - #40

Merged
tsvb merged 3 commits into
mainfrom
claude/auto-update
Sep 14, 2026
Merged

tsvb merged 3 commits into
mainfrom
claude/auto-update

Conversation

@tsvb

@tsvb tsvb commented Sep 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

Built-in update checks and auto-update with no third-party code: the feed is this repo's GitHub Releases, the install is a signature-verified swap of the bundle.

  • Check. Once a day at launch, quietly: it only speaks when something is newer than the running version and not a version the user skipped, and it swallows network errors. LUTzy ▸ Check for Updates… (and Settings ▸ Check Now) always reports: newer, up to date, or the error.
  • Install and Relaunch. Downloads the DMG, mounts it read-only, and verifies the app inside against a SecRequirement derived from the running copy: Apple-anchored Developer ID chain, same Team ID, same bundle identifier, strict and nested. Then copies it next to the current app, swaps with two renames, removes the old bundle, detaches, and hands open to a shell that waits for this process to exit before relaunching.
  • What a dev build does. swift run has no Info.plist, so no version: the automatic check never runs, and a manual check offers the release page instead of an install. An unsigned bundle is refused the same way.
  • Settings. An Updates section with the automatic-check toggle, the current version, and Check Now. Skip This Version lives on the sheet.
  • Release convention (README): tag vX.Y.Z with the DMG attached, which is what v0.1.0 already is.

Verification

  • swift test: 356 tests, 0 failures. UpdateTests covers version parsing and ordering, feed decoding (with and without a DMG, bad tag), both check manners, skipped versions, the 24h interval, the preference, install failure surfacing, and the on-disk swap including a read-only destination.
  • The requirement string was run by hand with codesign --verify -R= against the app inside the published LUTzy-0.1.0.dmg: it passes, and a wrong Team ID fails.
  • End-to-end runs on 2026-09-13. v0.1.1 was built with scripts/release-dmg.sh from this branch, notarized, and published. A copy of the same build stamped 0.1.0 and re-signed was installed in /Applications; on launch the daily check found 0.1.1 and put up the sheet; Install and Relaunch downloaded, verified, and swapped the bundle (/Applications/LUTzy.app at 0.1.1, signature valid, nothing mounted, no staged/retired bundle or temp directory left). First run: the old process did not quit — NSApp.terminate under the presented sheet was ignored — and the relaunch helper sat waiting; ending the process by hand made it open the new build. Fixed in the second commit (sheet down first, hard exit if terminate is ignored). Second and third runs, from a 0.1.0 stand-in built from the fixed source (LUTZY_SKIP_NOTARIZE=1), read through the new os.Logger stream: check → available → Install and Relaunch → downloaded, mounted, signature verified, swapped, helper started, process gone within a second, new process up as 0.1.1. No mount, no staged or retired bundle, no temp directory left.

Test plan

  • Release 0.1.1 with scripts/release-dmg.sh 0.1.1 and publish it as v0.1.1 with the DMG.
  • Launch the installed 0.1.0 (this branch built into it), open LUTzy ▸ Check for Updates…, click Install and Relaunch. Expect the app to relaunch as 0.1.1 with no Gatekeeper prompt.
  • Check again: "LUTzy is up to date". (Not driven from here: accessibility cannot invoke the menu item or read the SwiftUI sheet; one manual check on the installed 0.1.1 is the remaining hand step.)
  • v0.1.1 asset rebuilt from the final branch, notarized, and replaced (23:48 UTC). A fourth run from a 0.1.0 stand-in installed that published asset: swapped, relaunched as 0.1.1 within four seconds of the click, stapled ticket validates, nothing left behind.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ViSYWttguWwFQm1PEksQ7K

Tim and others added 3 commits September 13, 2026 17:55
No Sparkle — zero third-party dependencies — so the mechanism is written
out in full and kept small.

- `AppVersion`: the `major.minor.patch` in Info.plist and in a `vX.Y.Z` tag.
  Nil for a `swift run` build, which has no Info.plist.
- `ReleaseFeed`: `releases/latest` from the GitHub API, decoded to a
  `Release` with the first `.dmg` asset. A release without one is still
  reported and offered as a page.
- `UpdateInstaller`: download, `hdiutil attach` read-only, verify the new
  bundle with a `SecRequirement` built from the running app (Developer ID
  chain, same Team ID, same bundle identifier, strict + nested), copy next to
  the current app, two renames, delete the old bundle, detach, then hand
  `open` to a shell that waits for this PID and terminate. An unsigned or
  unbundled running copy cannot install and is sent to the release page.
- `UpdateCoordinator`: a quiet daily check at launch (skipping a version the
  user skipped, swallowing network errors) and a talkative manual one from
  LUTzy ▸ Check for Updates…. Feed and installer are injected for tests.
- `UpdateSheet`: one sheet for checking / up to date / available with notes /
  downloading / installing / failed. Settings gains an Updates section.
- `UpdateTests`: 15 tests over versions, feed decoding, both check manners,
  skip, the interval, install failure, and the on-disk swap. The requirement
  string was checked by hand against the published v0.1.0 DMG: it passes
  the real app and fails a wrong team.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViSYWttguWwFQm1PEksQ7K
…update

The first end-to-end run swapped the bundle and started the relaunch
helper, but the old process stayed up behind its "Installing…" sheet:
`NSApp.terminate` sent while a SwiftUI sheet was presented did not end
the app. The coordinator now brings the sheet down first, and `relaunch`
follows `terminate` with a hard `exit(0)` a second later.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViSYWttguWwFQm1PEksQ7K
…for local tests

`log stream --level info --predicate 'subsystem == "com.timvbs.LUTzy"'`
now shows a check's outcome and each install stage — download, mount,
verify, swap, relaunch — which is the only way to see what the updater
did on a machine you are not sitting at. It is also how the end-to-end
run was read: accessibility could not see the SwiftUI sheet.

`LUTZY_SKIP_NOTARIZE=1 scripts/release-dmg.sh <version>` builds and signs
without the two notarization rounds, for a local update test; never for
something shipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViSYWttguWwFQm1PEksQ7K
@tsvb
tsvb merged commit 8be2577 into main Sep 14, 2026
2 checks passed
@tsvb
tsvb deleted the claude/auto-update branch September 14, 2026 00:04
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.

1 participant