UpdateCheckService

Opt-in update check for the manually distributed Mac build (direct download from /downloads/ or the Homebrew cask). There is no App Store channel to deliver updates, so the app itself checks for a newer version — but only after the user explicitly turns it on.

Source: Minuta/Sources/Services/UpdateCheckService.swift.

The no-network guarantee

The project rule is “the app should not load anything from web ever”. This feature is the single carve-out, and it is strictly opt-in:

  • The only entry points to a fetch (setEnabled(true) and checkForUpdatesIfNeeded()) are guarded on the updateCheckEnabled setting, which defaults to off.
  • Usage age for the discovery pill is tracked locally (first-launch date in UserDefaults) — no network is involved before opt-in.
  • UI-test launches (--test-storage=) get a NullManifestFetcher that always throws, so tests can never reach the network even if they enable the setting.

Build gating

The whole service and all its UI are compiled only under the MANUAL_DISTRIBUTION Swift compilation condition, set in Minuta/project.yml (SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) MANUAL_DISTRIBUTION" on the app and both test targets — $(inherited) keeps the project-level DEBUG condition in Debug builds). A future App Store build would drop this flag, removing every trace of update UI. The flag is currently on in every configuration since all existing builds are manually distributed.

The UI is additionally gated to targetEnvironment(macCatalyst): there is no manual iOS distribution, so iOS gets no update UI at all. The service still compiles on iOS so the unit tests run on both platforms.

State machine

UserDefaults keys:

KeyMeaning
updateCheckEnabledThe Settings toggle. Default false.
updatePromptDismissedUser dismissed the discovery pill — never prompt again.
firstLaunchDateSet once on first init; drives the 1-week discovery delay.
lastUpdateCheckDateThrottles fetches to one per 24 h (set on attempt, so a down server is not hammered).
latestKnownAppVersionLast manifest result; “update available” survives relaunch offline.

Derived state (toolbarState, priority order):

  1. .updateAvailable(version) — enabled and latestKnownAppVersion is numerically newer than the running CFBundleShortVersionString.
  2. .enablePrompt — not enabled, not dismissed, and 7+ days since first launch.
  3. .hidden — everything else.

Checks run on every scenePhase .active transition (wired in RootView), throttled to one fetch per 24 hours, plus an immediate unthrottled check when the user turns the setting on.

UI

  • Settings > Updates: “Check for updates” toggle (off by default) with a footer explaining what it does. Settings > About shows an “Update available” row when a newer version is known; it opens the downloads page.
  • Toolbar pill (SceneDelegate in MinutaApp.swift): an NSToolbarItem inserted next to the pin button. In the .enablePrompt state it shows Enable updates | x — enable turns the setting on, x dismisses forever. In .updateAvailable it shows “Update available” and opens the downloads page. The pill view uses manual frame layout: Auto Layout inside an NSToolbarItem custom view misplaces subviews on the Catalyst bridge, making them unhittable.
  • The pill is inserted/removed at runtime by observing the .updateToolbarStateChanged notification, which the service posts on every state-affecting action.

Nothing is ever downloaded or installed in-app; the notice links to the downloads page, and Homebrew users run brew upgrade --cask minuta.

Version manifest

https://minuta.tools/downloads/version.json:

{
  "appVersion": "1.0.0",
  "downloadsURL": "https://minuta.tools/downloads/"
}

Generated at site build time by src/routes/downloads/version.json/+server.js (prerendered), which reads MARKETING_VERSION from Minuta/project.yml via src/lib/appVersions.js — the same reader the downloads page uses, so the manifest, the download links, and the artifacts from scripts/release-brew.sh cannot diverge in value.

Release ordering: upload artifacts first (scripts/upload-brew-artifacts.sh), deploy the site second (cd minuta.tools && ./deploy.sh) — the manifest must not advertise a version whose download links 404.

The fetcher uses an ephemeral URLSession with reloadIgnoringLocalCacheData and a 15 s timeout, so client-side caching never serves a stale manifest. If the web server applies long cache headers to .json, a Cache-Control: max-age=300 rule for /downloads/version.json is a nice-to-have, not a requirement (checks are daily).

Version comparison

Numeric dotted compare (isVersion(_:newerThan:)): components compared as integers, missing or non-numeric components count as 0. 1.10.0 > 1.9.0, 2.0 > 1.9.9, 1.0 == 1.0.0.

Testing

  • Unit tests (Minuta/Tests/UpdateCheckServiceTests.swift): state machine, throttling, persistence, version comparison, and launch-argument seeding, with injected UserDefaults suite, manifest fetcher, clock, and current version. Run on both iPhone simulator and Catalyst.
  • UI tests (Minuta/UITests/Flows/UpdatePromptTests.swift, Catalyst-only): pill hidden by default, enable/dismiss flows, update-available pill and Settings row, toggle flip.
  • Test hook: --update-check-state= launch argument (only honored together with --test-storage=) seeds hidden (default), prompt, enabled, or update-available:<version> into a volatile defaults suite.
  • CI runs the unit tests on the iPhone simulator only; the Catalyst-gated pill tests run locally via ./scripts/run-uitests.sh --mac.

Related files

  • Minuta/Sources/Services/UpdateCheckService.swift — service, manifest model, fetchers.
  • Minuta/Sources/MinutaApp.swift — toolbar pill (SceneDelegate), scene-activation check wiring (RootView).
  • Minuta/Sources/Views/Settings/SettingsSheet.swift — Updates section, About row.
  • minuta.tools/src/routes/downloads/version.json/+server.js — prerendered manifest endpoint.
  • minuta.tools/src/lib/appVersions.js — build-time version reader shared with the downloads page.