Tinycast checks GitHub Releases once a day, offers the newest release for its own channel in a native window with its release notes, installs it and relaunches. There is no Sparkle and no appcast: the release feed the website already reads is the feed the app reads.
- Tinycast installs its own updates, and Homebrew stays out of the way. Both casks declare
auto_updates true, which is Homebrew's own flag for an app that manages its own version.brew update && brew upgradetherefore skips Tinycast entirely — it is never reported outdated, never re-downloaded, and a self-updated copy is never trashed or rolled back.brew install,brew uninstallandbrew listkeep working unchanged. - The archive is a zip, never the DMG. A zip expands with
ditto; a DMG would have to be mounted, which means a volume, a Spotlight handle and a detach that can fail. A release published without a zip is not installable and is not offered. - The zip is chosen by architecture. A stable release carries a thin arm64 zip and a
-Universal-one. Intel takes the universal zip and is offered nothing if it is missing, since a thin build would install and then refuse to launch; Apple silicon prefers the thin zip and falls back to universal. - Nobody ever runs
xattr. An archive Tinycast fetched itself is not quarantined — macOS sets that flag for sandboxed downloaders and for apps that opt in withLSFileQuarantineEnabled, and Tinycast is neither.Quarantinechecks anyway throughgetxattr/removexattrrather than thexattrtool, and an app that still carries the flag is refused rather than installed. - The signature is the only integrity guarantee. A downloaded bundle is trusted when its seal
validates, nested helper included, and it either satisfies the Developer ID requirement pinned in
BundleSignatureor carries the byte-identical leaf certificate the running app does. The requirement names the team, so a certificate renewal strands nobody; the leaf match is the path a copy installed before the Developer ID switch has, and it goes away once none is left.notarizedis deliberately not in the requirement — it resolves the ticket throughsyspolicydor the network, so an offline Mac would refuse a bundle the chain already proves is ours. - A build only ever updates within its own channel. The channels are separate bundle ids installed
side by side; crossing would mean installing a different app.
com.tinycast.app.devnever updates at all, and does not advertise the command. - Nothing is installed unless every check passes. Bundle id, version and signature are all checked
on the expanded copy before
replaceItemAtruns, and the running app survives any failure untouched. - Relaunching goes through
NSApp.terminate, neverexit. That is what flushes a pending note draft and hands back the Hyper Key's HID-level caps remap, which outlives the process. - An automatic prompt defers to whatever the user is doing, and is never spent unshown.
UpdateReadinesswithholds it while a snippet is expanding, an extension command is running, an uninstall is trashing, a shortcut is being recorded, a prompt or dialog is up, or the palette is open. A withheld prompt is still owed:presentIfAvailableanswersfalse, the version is left unannounced, and the pump re-offers it every two minutes for half an hour before falling back to the daily rhythm. That is what a hand-launched copy depends on — its one announcement falls 30 s in, straight into the palette the user opened the app to use, where a launch-at-login copy would have found the desktop idle. The window itself still appears at most once per version per launch:announcedVersionis set the moment an offer lands, so re-offering can never turn into nagging. Readiness is asked again at the click. - Nothing about updates is persisted in
AppSettings. The feature owns one cache file, so noAppSettingsKeyand noSettingsBackupCoverageentry exist for it. - The window shows the changelog and nothing else. CI writes install instructions below
<!-- tinycast:install -->, andReleaseNotes.summary— the single reader of that marker, called where the feed is parsed so the cache holds the cut text too — drops them. An app that installs its own updates has no use for a Homebrew command, and a body published before the marker existed has none, so it comes back whole. - The notes are laid out by
ReleaseNotesView, which is this feature's own.AttributedStringparses inline styling only; headings and bullets are placed by hand or they arrive as literal##and*.ExtensionMarkdownViewdoes the same job and is deliberately not reused — an extension's views never leaveFeatures/Extensions/. @handleand#304are linked by the app, never by the release body. GitHub autolinks both on the web, and a bare mention is what notifies the contributor, so the published body keeps them plain andReleaseNotesspells them as Markdown links on the way to the window. Both point atReleaseFeed.repository, the one place the repo is named.
ReleaseChannel is derived from the bundle id, and nothing else:
| Bundle id | Channel | Takes |
|---|---|---|
com.tinycast.app |
.stable |
releases |
com.tinycast.app.beta |
.beta |
prereleases |
| anything else | .development |
nothing |
AppVersion parses MAJOR.MINOR.PATCH and MAJOR.MINOR.PATCH-beta.N with semver precedence: a
prerelease sorts below the release it leads to, and beta.10 above beta.9. Everything else parses
to nil, which is deliberate — the repo also publishes v0.9.7-sequoia for the macOS 15 cask, and
rejecting the tag is what keeps a beta install from drifting onto the Sequoia build. A release whose
tag disagrees with its prerelease flag is treated as mis-published and skipped.
The Intel build is not a channel. It shares the stable tag, version, bundle id and signature, so it
resolves to .stable like any other; ReleaseArchitecture picks its asset, and nothing about
identity changes. That is why it needed none of the machinery the Sequoia channel did.
UpdateCheckStore copies CurrencyRateStore: a private .ephemeral, urlCache = nil session, a
self-rescheduling pump, and one atomic JSON file.
~/Library/Caches/<bundle-id>/update-check.json
It holds lastCheckedAt, the newest release seen, and the version the user dismissed. Freshness is
measured from lastCheckedAt, so relaunching never re-asks GitHub; the interval is 24 h, dropping to
2 h after a failed attempt, and the first check waits 30 s so it never lands in the login rush. The
request carries a User-Agent, which the GitHub API rejects requests without.
Later means skip. It records the version, so that release stops asking; a newer one still asks. Check for Updates ignores the record and always offers whatever is newer than what is running.
One route, whatever the install came from:
- Stream the zip into
~/Library/Caches/<bundle-id>/Updates/, with real byte progress and a Cancel that actually aborts the transfer. ditto -x -kit into a staging folder, and take whatever.applands there — the bundle is named for its channel, so it isTinycast Beta.appon beta.- Check quarantine natively; clear it if somehow present, and refuse the update if it survives.
- Verify the bundle id, the version, and that the code signature is valid and proves the bundle is ours — by the pinned Developer ID requirement, or by the running app's own leaf certificate.
FileManager.replaceItemAt. The staging folder is on the same volume as/Applications, which is what lets this be atomic. A non-writable/Applicationsis reported, not worked around; there is no privileged helper.- Offer Relaunch, which spawns a detached waiter that reopens the app once this process exits —
openon a bundle id that is still running would only re-activate the instance on its way out.
Nothing here touches ~/Library/Preferences, ~/Library/Caches or Application Support, so no
setting, clipboard entry, note or snippet is affected by an update, by brew upgrade, or by both.
.github/workflows/release.yml publishes two assets from one build: the DMG people download by hand
and the cask installs, and Tinycast-<version>.zip for the updater. A stable run adds a
Tinycast-Universal-<version> pair from its universal job, uploaded second so the thin zip stays
first in the asset list — builds predating architecture-aware selection take whichever comes first.
The zip is made with
ditto -c -k --keepParent --sequesterRsrc "$APP" "dist/$ZIP_FILE"which is the only zip that leaves the code signature verifiable — plain zip drops symlinks and
breaks the seal, and the signature check above would then reject every update.
The body it publishes is composed by Scripts/release-notes.sh: GitHub's generated changelog first,
then <!-- tinycast:install -->, then the install text. Anything a release wants the update window to
show has to go above that marker — see release.md.
The casks must declare auto_updates true in abue-ammar/homebrew-tinycast. Without it Homebrew
compares its Caskroom receipt against the cask version, sees a self-updated app as outdated forever,
and re-installs over it on the next brew upgrade.