Technical Documentation: milanvarady/Applite
βΉοΈ Provenance: Multi-Tier Ingestion Β· CodeWiki Reference
Overview (`README.md`)
Applite
A native macOS app store for software that isn't on the App Store, backed by Homebrew Cask.
Table of Contents
- What Sets Applite Apart
- Comparison
- Key Features
- Screenshots
- Download
- Built With
- Development and AI
- Contact
- Roadmap
- Contributing
- Packages Used
- Credits
- License
- Alternatives
What Sets Applite Apart
Applite brings its own Homebrew. Every other Homebrew GUI expects brew to already be on the machine, which means the user has opened a Terminal and installed the Xcode Command Line Tools first. Applite downloads a Homebrew tarball into its own Application Support directory on first launch and runs it from there, in API mode behind a git shim.
- No Terminal, no Command Line Tools. Applite can be the first app on a fresh Mac.
- Brewfile import and export, so restoring your apps on a new machine is a file and a checklist.
- Casks only, by design. No formulae, no services, no CLI surface. Apps in categories, with icons and a search field.
- Uses your existing Homebrew if you have one. Point it at any prefix in Settings.
Comparison
Three actively developed alternatives worth knowing about. All of them are good software solving a slightly different problem.
| Applite | CaskHub | BrewUI | Cork | |
|---|---|---|---|---|
| Installs Homebrew itself | Yes, no CLT or Terminal needed | No, guided manual setup | No | No |
| Scope | Casks only | Casks only | Formulae and casks | Formulae, casks, services, taps |
| Audience | Non-technical | Non-technical | All Homebrew users | Power users |
| Price | Free | Free | Free | 25 β¬ prebuilt, free if self-compiled |
| License | MIT | MIT | AGPL-3.0 | Commons Clause (source available) |
| Minimum macOS | 14 | 15.6 | 14 | 13 |
| Brewfile import/export | Yes, with a per-app selection sheet | No | Not yet | Yes |
| Telemetry | None | Sentry + TelemetryDeck | None | None |
| Status | Released | Released | Early development | Released |
Comparison drawn in August 2026; check the projects themselves for current state.
Key Features
- Install, update, and uninstall apps in one click
- Self-contained Homebrew installation, no Command Line Tools required
- Works with an existing Homebrew installation if you have one
- Handpicked gallery of apps, plus full-catalog FTS5 search
- Brewfile import and export for migrating to a new Mac
- Casks from custom taps
- System proxy support (HTTP, HTTPS, SOCKS5)
- Localized in 7 languages
- Free and open source, no telemetry
Screenshots
Download
applite.app or a direct
DMG download
or
$ brew install --cask applite
Minimum OS version: macOS 14+. Universal binary, Apple Silicon and Intel.
Built With
Swift and SwiftUI, targeting macOS 14 so @Observable and NavigationSplitView are available without back-deployment shims. No Combine. The cask catalog is a local SQLite database (GRDB.swift, WAL mode) synced from the Homebrew JSON API, with FTS5 and BM25 ranking for full-catalog search. Loading is two-stage: SQLite paints the UI immediately, then brew list --cask and brew outdated --cask fill in installed and outdated state reactively, so nothing blocks on the brew CLI.
Architecture notes are in CLAUDE.md.
Development and AI
I'm the sole maintainer of Applite. Starting with version 1.4, I use Claude Code as part of my development workflow, and I'd rather state that plainly than leave people to guess.
The reason is capacity. This is a side project maintained by one person, and the realistic alternative to AI-assisted development is not hand-written code, it's a much slower release cycle or no releases at all. Several things that shipped recently, the SQLite migration and the six-language localization among them, would have sat in the backlog indefinitely otherwise.
Scope of what that actually means:
- The core of the app was written by hand, before any AI involvement.
- AI assistance is used for refactoring and reorganization work, and for small to medium sized new features.
- Architecture decisions, code review, and what does and doesn't go into the app are mine. Nothing merges that I haven't read.
If you object to AI-assisted code on principle, that's a legitimate position and Cork is explicitly developed without it.
Contact
If you have any questions, feel free to e-mail me: [email protected]
Or join the Official Discord Server.
The FAQ and Troubleshooting pages
answer most questions.
Roadmap
I don't have much time for development, but I release updates periodically.
View the roadmap.
Contributing
The project is open to contributions. See the Contribution Guidelines for more information.
For typos and minor bugs, open a PR directly. For anything larger, open an issue or bring it up on Discord first, so we don't both spend time on something that doesn't fit the project's scope.
Packages Used
- Homebrew - the package manager everything is built on
- GRDB.swift, Gwendal RouΓ© - SQLite database and FTS5 search
- Sparkle - app updates
- Kingfisher, Wei Wang - image downloading and caching
- ButtonKit, Thomas Durand - async buttons
- SwiftUI-Shimmer, Vikram Kriplaney - loading placeholders
Credits
App icons are sourced from CaskFlow by Ali Elsokary (MIT), a metadata pipeline that extracts icons from vendor .icns files. Casks without a CaskFlow icon fall back to a generated monogram tile.
License
Applite is licensed under the MIT License. See LICENSE for more details.
Alternatives
Compared above:
- CaskHub - cask-focused app store with strong discovery features
- BrewUI - Homebrew's official GUI, in early development
- Cork - full-featured Homebrew GUI for power users
Others:
- WailBrew (Go, Wails, React)
- Brewer X (paid)
- BrewMate (Electron based)
- Brewlet (menu bar app)
- App Fair (looks discontinued)
- Cakebrew (discontinued)
Overview (`Testing/README.md`)
Applite test harness
applite_test.py is a guided end-to-end test harness. It doesn't automate the
UI β you perform each action in the real Applite window, and after each step the
script independently verifies the real result via the Homebrew CLI + filesystem
(brew info --json=v2, the Caskroom, /Applications) and prints PASS / FAIL.
Purely-visual outcomes (a green tick clearing, a dialog appearing) are guided y/N
confirmations. The "no Command Line Tools dialog" rule is stated once at the start
of the annex round as an invariant β if that dialog ever appears, you abort and report
it, rather than being asked about it every step.
It exists because Applite's real risk lives in the Homebrew integration β the
CLT-free "annex" flow, FFI quarantine/trash, pty-streamed installs, the annex
refresh (tarball overlay + brew cleanup --prune=all cache reclaim) β which is
impractical to unit-test and needs a real (fresh, CLT-free) machine to exercise.
Run it on a throwaway VM, not your dev machine β it hides/deletes Homebrew and
wipes Applite's data.
Requirements
- A macOS VM, Apple Silicon assumed (
/opt/homebrew). Note: Apple-Silicon VMs have no
native snapshots β see "No snapshots" below for the baseline strategy. - A standalone Python 3 β python.org's installer (gives
/usr/local/bin/python3)
oruv python install. Do not use/usr/bin/python3: it's a Command Line
Tools stub that pops the very install dialog we simulate away, and it stops working
once CLT is hidden. The script refuses to run under it. Stdlib only β nothing topip install.
Invoke it with the standalone interpreter explicitly, e.g. /usr/local/bin/python3 applite_test.py β¦.
Forgot the commands? Run it with no arguments β applite_test.py prints the full
pass in order, the partial-run flags, and every phase id per round.
How it works: rounds + hide/unhide
Applite behaves differently depending on which brew it drives, so we test both:
- Round A β annex brew, CLT-free. Applite installs its own bundled Homebrew and
runs it without the Command Line Tools. This is the risky path. - Round B β external
/opt/homebrew. The user's own full Homebrew (real git,
Swift quarantine, none of the annex env). A shorter core-lifecycle re-run.
Plus two rounds that stand apart from the AβB sequence:
- Round U β upgrade from v1.3.1. Its own pass, from its own
reset, because it
starts from an old install rather than a clean machine. Every existing user arrives
this way and this release moved their storage underneath them (JSON cache β GRDB,
onboarding removed, annexApplite/homebrewβApplite/Homebrewβ a case-only
rename, so the same directory on a default APFS volume and a different one on a
case-sensitive volume). Run it once per release. - Round
finalizeβ self-uninstall. Always last; it removes Applite.
Installing brew + CLT takes ~10 min, so we don't do it every run. Instead:
provisiononce installs Homebrew + CLT (if missing) and then hides them
(renames/opt/homebrewand/Library/Developer/CommandLineToolsaside, andxcode-select --reset). Hidden β the machine genuinely reads as CLT-free with no
system brew, which is exactly Round A's starting condition.resetreturns to a fresh baseline: it (a)brew uninstalls whatever is still
linked, then (b) removes the known test casks by name from the catalog β their.appbundles, uninstall/zap paths, and pkg receipts β independent of brew's
receipts, then wipes Applite's data and hides the prereqs. The name-based step is
essential because the Reinstall Homebrew phase unlinks previously-installed apps,
sobrew uninstallalone can no longer see them.- Round B's first phase unhides them and
brew updates.
No snapshots on Apple-Silicon UTM
macOS guests on Apple Silicon use Apple's Virtualization framework, which has no
snapshot support β so reset is what returns you to a clean state (it uninstalls
all casks rather than assuming a snapshot rollback). If you'd rather roll back the
whole disk, the only option is to duplicate the .utm bundle in UTM before a run
(a full copy) and restore from that. reset --keep-apps skips the uninstall sweep if
you're managing app state yourself.
Full pass
PY=/usr/local/bin/python3 # your standalone interpreter
$PY applite_test.py provision # once per VM β then save a baseline (duplicate the .utm bundle; see below)
$PY applite_test.py reset # hide prereqs + wipe Applite
# β launch Applite, then:
$PY applite_test.py run --round annex
$PY applite_test.py run --round external
$PY applite_test.py run --round finalize # self-uninstall (LAST)
$PY applite_test.py teardown # re-hide prereqs, keep them cachedOnce per release, as a separate pass from its own reset:
$PY applite_test.py reset
$PY applite_test.py run --round upgrade # install v1.3.1 first, then the new build over itEach run prints a PASS/FAIL/SKIP summary and appends to Testing/applite-test.log.
Running part of a round
$PY applite_test.py run --round annex --only 6 # just the update phase
$PY applite_test.py run --round annex --from 9 # phase 9 (catalog) onward
$PY applite_test.py run --round external --only B3Phase ids per round are listed by running the script with no arguments.
Other subcommands
| Command | What it does |
|---|---|
reset [--keep-apps] |
Fresh state: uninstall all casks + wipe Applite + hide prereqs. --keep-apps skips the uninstall sweep. |
preflight [--round ...] |
Validate the configured test casks against the live catalog. Run before a pass. |
verify [--round ...] |
Print current brew health, installed/outdated casks, and the hide/unhide state. |
fake-outdated <token> [--round ...] |
Rename a cask's installed version to 0.0.1 so brew outdated reports it (used by the update phases). |
teardown [--full] |
Default: uninstall all casks, wipe Applite, re-hide prereqs. --full: also delete /opt/homebrew, CLT, and caches for a pristine image. |
Test casks
Set at the top of applite_test.py (one per installer type); preflight re-checks each:
| Var | Default | Role |
|---|---|---|
DMG_CASK |
rectangle |
.app DMG |
PKG_CASK |
zoom |
.pkg installer |
ZAP_CASK |
stats |
has a zap trash: stanza |
UPDATE_CASK |
font-hack |
versioned, auto_updates: false β required so non-greedy brew outdated will report the faked-old version |
WARN_CASK |
aegisub |
deprecated β triggers the download warning dialog (and checks a deprecated cask still installs β deprecation stays a warning, not a hard error) |
CANCEL_CASK |
libreoffice |
large download so there's time to hit Stop mid-download (the harness clears its cache first); reused for the quit-mid-install phase |
FAIL_CASK |
blackhole-2ch |
small pkg cask that always needs admin β cancelling its password dialog is a deterministic, offline-safe way to force a failed install. Must be used by no other phase, or it'd already be installed |
BULK_INSTALL_CASKS |
hiddenbar, mos, font-fira-code |
set imported in one batch (installAll) β proves reliable bulk install + per-cask rings + one summary (just one font: fonts are barely used, and the harness cleans their files so a fresh install works) |
BULK_UPDATE_CASKS |
hiddenbar, mos |
subset of the above (apps, not fonts), fake-outdated then "Update All" β need auto_updates:false and a concrete version (not :latest) |
BULK_STOP_CASKS |
libreoffice, inkscape |
two big downloads so the batch is still running when you exercise the batch Stop |
Cask metadata drifts. If preflight flags one (wrong artifact type, auto_updates
became true, no zap/caveat), swap that variable for a cask that fits the role β the
table it prints tells you what it detected.
The tap fixture (phase 16)
Phase 16 needs a third-party tap containing (a) a token that exists nowhere else and
(b) a token that collides with a core cask. Rather than depend on someone's real
tap, the harness writes one itself into /Library/Taps/applite-harness/ homebrew-fixtures/: no git, no network, no brew tap. Brew enumerates tap directories
directly, and Applite's brew-tap-cask-info.rb loads them through FromPathLoader
(which it un-trusts), so a plain directory of .rb files is enough β which also keeps
the phase runnable on the CLT-free annex, where there is no real git.
Both fixture casks point at an unreachable URL: they exist to be listed, never
installed. reset and teardown remove the fixture from both prefixes; a clean annex
reinstall (phase 20) wipes it too.
It guards two regressions worth naming:
- Tap casks vanishing.
Shell's stream reader used to drop the tail of a command's
output;brew ruby's tap listing was the only thing big enough to hit it, so custom
taps silently disappeared from the catalog. - Token collisions (P2-29). Casks were once keyed by the bare token, so a tap's
rectangleand corerectanglecollapsed into one row. Identity isfullTokennow,
andbrew list --cask --full-nameis matched exactly β a bare-token match let a tap
cask inherit the core cask's installed state.
Safety
- The harness only ever renames (
sudo mv, reversible) a system brew/CLT to
hide/restore them. It deletes/opt/homebrew+ CLT only under the explicitteardown --full. - Outright deletes are a fixed allowlist of Applite's own data + the annex + brew
caches + the specific test-cask apps + the harness's own tap fixture β never/opt/homebrew,/usr/local,/Library/Developer, or anything$HOME-wide. The
fixture cleanup removes onlyLibrary/Taps/applite-harness/, never a real tap. - Hiding/unhiding needs
sudo(you'll be prompted) and Applite to be quit.
Caveats
- Assumes CLT-only (no full Xcode) on the VM. If Xcode.app is installed, the
machine won't read as CLT-free after hiding β also remove/rename Xcode or pointxcode-selectaway first.resetwarns if it detects this. - The assertions deliberately avoid invoking
/usr/bin/git|swift|xcrun(that would
itself pop the dialog); "CLT-free" is checked viaxcode-select -ppointing at a
missing dir. - A few things are guided visual checks, not hard assertions: cancel-mid-download
timing, proxy/mirror, the get-info/terminal windows. Sparkle's toggle plumbing is
checked (phase 17) but an actual self-update is not β that needs a signed build and a
real appcast. - The upgrade round needs you to fetch v1.3.1 from the GitHub releases page by hand; it
restores the CLT first, since an upgrader's machine has them. - Not a CI tool β it's an interactive pre-release checklist meant to be run by hand.
Overview (`Scripts/Localization/README.md`)
Localization tooling
Four stdlib-only Python scripts for maintaining Localizable.xcstrings. Run them from the repo
root. None of them are part of the build; Scripts/ sits outside the app target.
| Script | What it does |
|---|---|
sync_catalog.py <objdir> |
Merge the compiler's extracted keys + comments into the catalog |
apply_translations.py |
Apply a batch of translations or corrections |
glossary_audit.py |
Check the catalog against the glossary in TRANSLATING.md |
apple_terms.py <term>β¦ |
Print how macOS itself renders a term, per language |
Why `sync_catalog.py` exists
xcodebuild never writes back to Localizable.xcstrings. Only building inside Xcode updates
the catalog. Extraction still runs on the command line (SWIFT_EMIT_LOC_STRINGS=YES) β it just
leaves the result in .stringsdata files and stops there. So if you add or change a localized
literal and only ever build from the CLI, the catalog silently falls behind: new keys never appear,
and changed keys are never marked stale.
xcodebuild -project Applite.xcodeproj -scheme Applite -configuration Debug \
-destination 'platform=macOS' -derivedDataPath /tmp/dd build
python3 Scripts/Localization/sync_catalog.py \
/tmp/dd/Build/Intermediates.noindex/Applite.build/Debug/Applite.build/Objects-normal/arm64 \
--applyIt adds new keys, refreshes comments from source, marks vanished keys stale, and never toucheslocalizations β translations are only ever changed by a translator. It preserves the file's key
order, because Xcode sorts with a locale-aware collation (punctuation first) that a naive sort does
not reproduce, and re-sorting churns the whole file.
Batch format for `apply_translations.py`
TRANSLATIONS = {"<source key>": {"hu": "β¦", "fr": "β¦"}} # refuses to overwrite an existing value
FIXES = {"<source key>": {"hu": "β¦"}} # overwrites deliberatelyA value may also be {"one": β¦, "other": β¦} for a language that inflects after a numeral, or{"_raw": {β¦}} to pass a localization object through verbatim (two-count strings needsubstitutions).
It refuses to run if a key appears twice in one dict. That is not hypothetical: a batch grouped
by language put the same key in two sections, Python kept only the last, and 15 corrections were
silently dropped while the applied count still looked right.
`apple_terms.py`
macOS ships its own localizations as .loctable plists β one per string table, keyed by language.
This finds the keys whose en value matches your term exactly, reads those same keys back out ofhu / fr / ja / zh_CN / zh_HK / tr, and counts each rendering.
python3 Scripts/Localization/apple_terms.py "Install" "Uninstall" "Advanced"Frequency is evidence, not a verdict. The same English word is usually several UI concepts: Note
resolves to the Notes app, Utilities most often to Launchpad's "Other" grouping. Always read
the alternates before adopting the top hit β see the deviations section of TRANSLATING.md.
Overview (`Scripts/Release/README.md`)
Release tooling
Everything mechanical about shipping a release, in one command:
Scripts/Release/release.sh run 1.4.0It builds, signs, notarizes, packages, publishes the GitHub release, and updates the Sparkle
appcast. It stops twice, once for you to write the release notes and once for you to publish them
to applite.app, and both stops are conditions rather than prompts: do the thing, run the same
command again, and it carries on from where it stopped.
| Command | What it does |
|---|---|
release.sh preflight [version] |
Check this machine can cut a release. Safe to run any time |
release.sh run <version> |
Run, or resume, the pipeline |
release.sh status <version> |
Which steps have completed |
release.sh notes <version> |
Regenerate the release-note drafts |
release.sh clean <version> |
Delete build/release/v<version>/ |
Flags for run: --dry-run, --skip-notarize, --critical, --build <n>, --from <step>,--only <step>, --force.
One-time setup
Three credentials live in the login keychain and never in the repo. preflight checks all three
and prints the fix for whichever is missing.
1. Developer ID Application certificate
Notarization requires it; Apple Development and Apple Distribution will not do.
Xcode βΈ Settings βΈ Accounts βΈ team 9CLTNBW4Z3 βΈ Manage Certificatesβ¦ βΈ + βΈ
Developer ID Application. Only the Account Holder can create one, and a team is capped at five.
security find-identity -v -p codesigning # verify2. notarytool keychain profile
Create an app-specific password at appleid.apple.com βΈ Sign-In and Security, then:
xcrun notarytool store-credentials "applite-notary" \
--apple-id <your-apple-id> --team-id 9CLTNBW4Z3
xcrun notarytool history --keychain-profile applite-notary # verifyOmit --password and let it prompt, so the app-specific password stays out of your shell history.
Override the profile name with APPLITE_NOTARY_PROFILE if you ever rename it.
3. Sparkle EdDSA signing key
To restore the key on a new machine, from your 1Password backup:
Scripts/Release/release.sh import-key ~/path/to/sparkle-key.txtThat refuses to import anything that isn't the real key. Before touching the keychain it signs an
already-published DMG from ~/Documents/Applite/versions/ and requires a byte-identical match
against the signature in appcast.xml β Ed25519 is deterministic, so only the genuine key can
reproduce it. Afterwards it re-derives the public half and checks it against SUPublicEDKey. It
also refuses to overwrite a different key that is already present.
Delete your copy of the key file afterwards; 1Password is the backup.
The underlying tools, if you need them directly (Scripts/Release/release.sh sparkle-bin prints
the path β the Xcode DerivedData hash is unstable, so never hardcode it):
generate_keys # creates a NEW key β never run this for Applite
generate_keys -x sparkle-key.txt # export for backup
generate_keys -p # print the public halfgenerate_keys -p exits 0 even when no key exists, printing an error to stdout, so compare its
output rather than trusting the exit code β preflight and import-key both do.
If the Sparkle key is ever lost
Read this before doing anything else.
Every installed copy of Applite has SUPublicEDKey baked into its Info.plist and rejects any
update not signed by the matching private key. Sparkle has no key-rotation mechanism. Publishing a
new keypair means every existing install silently stops updating, forever, and each of those users
has to be told to download the app again by hand.
So before concluding it's gone: check 1Password and any other password manager, Time Machine copies
of ~/Library/Keychains/login.keychain-db, iCloud Keychain on another device, and any other Mac
that has ever built Applite.
If you find a candidate, release.sh import-key <file> performs the conclusive test for you β the
right key reproduces a byte-identical signature for an already-published DMG, because Ed25519
signing is deterministic. By hand that is:
"$(Scripts/Release/release.sh sparkle-bin)/sign_update" \
--ed-key-file <key> ~/Documents/Applite/versions/v1.3.1/Applite.dmg
# must print the same edSignature as the 1.3.1 item in appcast.xmlRunning a release
Scripts/Release/preflight.sh # optional, but do it first
Scripts/Release/release.sh run 1.4.0 --skip-notarize --dry-run # ~2 min rehearsal
Scripts/Release/release.sh run 1.4.0 --dry-run # full rehearsal
Scripts/Release/release.sh run 1.4.0 # the real thing| Step | Notes |
|---|---|
preflight |
Credentials, toolchain, clean tree, appcast validity |
bump |
MARKETING_VERSION + CURRENT_PROJECT_VERSION, committed locally, not pushed |
notes-draft |
Writes changelog-raw.md plus seeded release-notes.md and website-notes.md |
notes-gate |
Stops until you delete the draft sentinel from both notes files |
archive |
Clean release build, a few minutes |
export |
Re-signs to Developer ID via ExportOptions.plist |
verify-app |
Signature, hardened runtime, timestamp, universal, versions read back |
notarize-app |
Apple round trip, then staples the .app |
dmg |
Draws the background, runs create-dmg, re-checks the ticket inside the image |
notarize-dmg |
Signs the DMG with a secure timestamp, notarizes, staples |
sign-update |
Sparkle signature + byte length |
notes-website |
Writes website-notes.md into applite-site as the release-notes page |
github-release |
Pushes main, creates the release, uploads Applite.dmg |
appcast |
Inserts one `` and shows you the diff |
site-gate |
Stops until the notes page is live on applite.app |
publish-appcast |
Commits and pushes appcast.xml, then waits for Pages |
archive-artifacts |
Copies app, DMG and dSYMs to ~/Documents/Applite/versions/ |
The two gates
notes-gate. Skim changelog-raw.md, then write two files. The gate holds until theAPPLITE-RELEASE-NOTES-DRAFT comment is gone from both.
| File | Goes to | Shape |
|---|---|---|
release-notes.md |
The GitHub release body | Everything that changed, in full |
website-notes.md |
The Sparkle update panel and applite.app | ~5 one-line bullets, no jargon |
They are separate on purpose. The Sparkle panel is a small window someone skims mid-update β it
needs a glance at the headline changes, not a changelog. GitHub is where the detail belongs, and
nobody reading it minds length.
Both use the same headings (### New Features / ### Improvements / ### Fixes /### Known Issues) because both are parsed the same way; keep them. Delete any section you don't
need β empty ones are dropped, and the model's initialiser defaults them to [].
github-release appends a compare link to the body it uploads:
**Full changelog**: https://github.com/milanvarady/Applite/compare/v1.3.1...v1.4.0so every commit in the range stays one click away without a third document to maintain. It is
assembled into build/release/v<version>/github-release-body.md, never appended torelease-notes.md, so re-running the step can't stack duplicate links onto your source.
site-gate. The notes-website step has already written~/GitHub/applite-site/src/content/releases/<version>.md. Read it, commit it, push it; Cloudflare
rebuilds in a minute or two. The gate then curls the real URL, and a miss there is a genuine 404
rather than a redirect, so a 200 proves this version's page is live and not merely that the site
is up.
website-notes.md ships verbatim. Whatever is in it is what gets published, bold lead-ins
included, so write the copy you want readers to see rather than a summary to be reworked later.
Resuming
Re-run the identical command. Steps that finished are skipped; the gates re-check their condition.
release.sh run 1.4.0 --from export # redo export and everything after it
release.sh run 1.4.0 --only dmg --force # iterate on the DMG window alone
release.sh status 1.4.0- Build failed after the bump commit β
git reset --hard HEAD~1. Nothing was pushed. - Ctrl-C during notarization β the submission id is on disk before anything can fail:
xcrun notarytool wait "$(cat build/release/v1.4.0/logs/notarize-app.submission-id)" --keychain-profile applite-notary - Notarization rejected β resume
--from export, not--from notarize-app. The binary has to
be rebuilt; resubmitting the same bytes cannot help.
Dry runs
--dry-run still builds for real β archive, export, notarize, staple, DMG. A rehearsal that
doesn't produce a signed, notarized DMG isn't a rehearsal. Only publishing is stubbed: no commits,
no pushes, no GitHub release, the appcast is diffed rather than written, and~/Documents/Applite/versions/ is left alone. It does apply the version bump to project.pbxproj
(the built app has to carry the right version), so afterwards:
git checkout Applite.xcodeproj/project.pbxprojThe rehearsal reminds you of this at the end. If you forget, the next real run's preflight stops on
a dirty working tree rather than doing anything strange.
Rehearsals record their progress in state-rehearsal/, not state/. That separation matters: with
a shared directory a --dry-run would mark every step done β including the ones it had stubbed β
and the real release afterwards would skip everything, exit 0 and publish nothing.
--skip-notarize additionally skips Apple's servers for a ~2-minute cosmetic pass, and implies--dry-run. The DMG it produces is not distributable, and github-release runsstapler validate on the artifact itself before publishing, so a leftover rehearsal DMG can't be
uploaded by mistake.
Where things go
build/release/v1.4.0/ gitignored
state/ step markers and values carried between steps
logs/ xcodebuild, notarytool, create-dmg
DerivedData/ also where the Sparkle CLI tools resolve to
export/Applite.app the signed, stapled app
dmgbg/ generated background, 1x + 2x + combined TIFF
Applite.dmg the artifact
notes/ changelog draft, release notes, website notes
~/Documents/Applite/versions/v1.4.0/ app, DMG and dSYMs, kept after the releasedSYMs were not kept before, which is why crash reports from older versions can't be symbolicated.
Version numbers
MARKETING_VERSION is the user-facing 1.4.0; CURRENT_PROJECT_VERSION is the build number
Sparkle actually compares. The build number is chosen as
max(CURRENT_PROJECT_VERSION, highest sparkle:version in appcast.xml + 1)and asserted to be greater than everything published. That max exists because the scheme used to
bump the build number after every archive, so the project could sit one ahead of what shipped β
1.3.1 shipped as build 18 while the project said 19. That archive post-action has been removed;release.sh owns the bump now.
agvtool is not used, and must not be. agvtool what-marketing-version reports findingCFBundleShortVersionString of "" in Applite/Applite-Info.plist β a key that isn't in that
file β because it misreads the GENERATE_INFOPLIST_FILE = YES setup. agvtool new-marketing-version
would then write that key into the plist, permanently shadowing MARKETING_VERSION and
desynchronising the version from the build settings in a way nobody would notice for months.release.sh edits project.pbxproj with sed and verifies the result throughxcodebuild -showBuildSettings for both configurations β a grep can't prove a setting resolves.
The appcast
appcast.xml at the repo root is the live feed: GitHub Pages serves main's root athttps://milanvarady.github.io/Applite/appcast.xml, which is the SUFeedURL in every shipped
build. Pushing it publishes it. There is no other copy β if you find one, it's stale.
generate_appcast is deliberately not used. It rewrites the whole file, so every release would
produce an unreviewable diff; it prunes old items; it points enclosures at a --download-url-prefix
rather than the per-tag GitHub release URL; and it drops <sparkle:releaseNotesLink> entirely. All
four were visible in the stale ~/Documents/Applite/updates/appcast.xml it had left behind.appcast_insert.py instead splices one templated `` in as text and validates the feed as XML
before and after β one item added, everything else byte-identical.
Fields worth knowing about:
pubDateis generated withLC_ALL=C. Without it, the Hungarian system locale emitssze, 05 aug. 2026, which is not RFC 822 and Sparkle cannot parse. The script also regex-checks it.minimumSystemVersionis read from the built app'sLSMinimumSystemVersion, never hardcoded.
Hardcoding is exactly how every item up to 1.3.1 ended up claiming 13.0 after the deployment
target moved to 14.0 β which would offer the update to macOS 13 users who then can't launch it.lengthmust be the byte count of the final, stapled DMG. Sparkle rejects a download whose
size doesn't match exactly. Nothing may touch the DMG aftersign-update.- Critical updates:
--criticalsets both<sparkle:criticalUpdate>in the feed andisCritical: truein the website snippet, so the two can't disagree.
Rebuilding the DMG clears the appcast marker, so the step has to be re-runnable. It passes--replace-existing, which rewrites the item for that version in place. Doing it that way rather
than reverting the file with git matters β a git checkout appcast.xml would also discard any other
uncommitted edit to the feed.
The DMG
The background is generated by make_dmg_background.swift at build time β no image asset in the
repo, nothing to reopen in Affinity. Edit the constants at the top of that file to move things.
create-dmg cannot draw an arrow: its whole visual surface is template.applescript, which sets
window bounds, icon size, icon positions and background picture β nothing else. Every DMG arrow
you've ever seen is pixels in the background image, which is why the background exists at all.
Two things about that file worth not undoing:
- The wash is drawn at 140Γ100 and scaled up. Core Graphics dithers gradients, and that per-pixel
noise is incompressible β drawing it at full size made the background 709 KB and the DMG 17%
bigger than 1.3.1, for the smoothest part of the image. At 1/4 scale it's 317 KB and looks
identical. The arrow and caption are drawn afterwards at full resolution. - The 1x and 2x renders are combined with
tiffutil -cathidpicheck, which marks both as measuring
560Γ400 points. That marking is what makes Finder render it sharply on Retina. Round-tripping
the PNGs through JPEG breaks it β the 2x rep comes back claiming 1120Γ800 points.
--codesign and --notarize are deliberately not passed to create-dmg: it signs with a barecodesign -s and no --timestamp (which notarization requires), and its --notarize never inspects
the submission status, so a rejection surfaces as a confusing stapler error. release.sh does both
itself and parses the result.
Iterate on the window with release.sh run <version> --only dmg --force.
Signing and stapling order
Sign β notarize β staple. Never codesign after stapling. The ticket is stored inside the
bundle; re-signing invalidates it.
Both the .app and the DMG are notarized and stapled, in that order. The DMG alone isn't enough:
Sparkle copies Applite.app out of the disk image, and the DMG's ticket doesn't travel with the
extracted app β so an auto-updated app first launching offline, or behind a proxy that blocks
Apple's notary endpoints, would be rejected by Gatekeeper. 1.3.1 was stapled at both levels; this
keeps that.
Troubleshooting
| Symptom | Fix |
|---|---|
hdiutil: Resource busy |
Already retried 8Γ; close any Finder window showing a mounted Applite volume |
Notarization Invalid |
build/release/v<v>/logs/notarize-*.log.json has the per-file reason |
| Export: "No profiles found" | Set signingStyle to automatic in ExportOptions.plist and add -allowProvisioningUpdates to the export step |
| Sparkle: "update is improperly signed" | length or edSignature doesn't match the served bytes β the DMG changed after sign-update |
| Appcast pushed but no update offered | Pages caches; curl -H 'Cache-Control: no-cache' the feed, and check the build number is higher than the installed one |
generate_keys -p prints nothing |
The key isn't in the keychain. See If the Sparkle key is ever lost |
CONTRIBUTING (`docs/CONTRIBUTING.md`)
Contributing to Applite
Contributions are welcome, and not only code. Bug reports, translations, and well-argued feature
suggestions are all useful.
Table of Contents
Project Goal
Applite aims to be more of an app store for third-party apps than a full-blown Homebrew GUI
wrapper.
The goal is to bring Homebrew casks to people who would never open a Terminal. Simple setup, a UI
that can be understood at a glance, no technical knowledge required.
Applite does have features aimed at experienced users, such as a custom brew path and a custom
installation directory. Those stay out of the main interface by design. This is the standard the
project measures suggestions against, and it is the usual reason something gets turned down.
Reporting a Bug
Open an issue and include:
- What went wrong
- The steps you took before it happened
- Any error message or terminal output (see below)
- App version and hardware, for example "Applite 1.4, MacBook Air M2"
Getting the terminal output
If the problem happened during an install, update, or uninstall, the app card itself will show a red
Error label with two buttons next to it. The terminal icon opens the full Homebrew output in a
new window. That output is the single most useful thing you can paste into an issue, so please
include it rather than only describing the failure.
Getting logs from Console.app
For anything that isn't a failed cask operation, the unified log is the next best source.
- Open Console.app and select your device in the sidebar
- Click Start to begin streaming
- Reproduce the bug, then pause
- Filter for the
dev.aerolite.Applitesubsystem, or search for "applite" - Copy the entries around the failure
If values show up as ``, follow this Stack Exchange
answer
to reveal them.
Suggesting a Feature
- Open an issue, or a discussion if the idea is open-ended
- Describe what you're missing and why
- Suggesting a solution is optional
Check it against the project goal first. Features that would only make sense to
someone who already knows Homebrew are usually declined, however well built.
Contributing Code
Small fixes need no ceremony. If you spot a typo or a minor bug, open a pull request.
For anything larger, open an issue or bring it up on the Discord
server before you write the code. It is a bad experience for
everyone when a well-made PR gets turned down on scope, and a five-minute conversation up front
avoids it.
Building
Open Applite.xcodeproj in Xcode 16 or newer and build. Swift Package Manager resolves the
dependencies on first build; there is nothing else to install. The deployment target is macOS 14.
Project layout
The project uses Xcode 16 file-system synchronized groups, which means the folder structure on
disk is the project structure. To add, remove, move, or rename a source file, just do it on disk.
Xcode picks it up automatically.
Do not hand-edit project.pbxproj to manage files. It is not needed, and it produces merge
conflicts and broken references for no benefit.
The architecture is documented in CLAUDE.md at the repo root. It's written as context
for AI coding agents, which makes it terse, but it's the most current description of how the app fits
together and is worth reading before a larger change.
A few conventions that are easy to miss:
@Observableand@MainActorfor view models and managers. No Combine.- Cask identity is
full_token(tap/token) everywhere. The bare token is not unique across taps,
since two taps can each ship a cask calledfirefox. - Never block the UI on the brew CLI. Catalog data comes from SQLite first; installed and outdated
state arrives afterwards and updates reactively. - One view struct per file.
AI-assisted contributions
I use AI assistance on this project myself, so it would be strange to forbid it here. Use whatever
tools you like.
What I ask is that you can explain and defend every line you submit, and that you have actually run
it. A PR whose author can't answer questions about it costs me more time to review than writing the
change myself, and that is the only kind I will close on sight.
Contributing a Translation
Applite ships in English plus Hungarian, French, Japanese, Simplified Chinese, Traditional Chinese
(Hong Kong), and Turkish. Translation help is very welcome, both for new languages and for fixing
awkward wording in the existing ones.
Everything lives in a single String Catalog, Localizable.xcstrings, with English as the source
language. You can edit it directly in Xcode's catalog editor.
Read TRANSLATING.md first. It is the glossary: the agreed rendering of the
terms that recur across the UI, plus the register each language uses, how buttons are phrased, and
the spacing rules. Consistency matters more than any single elegant sentence. An app feels broken
much faster when the same button is called three different things than it does from one awkward
phrase.
Maintenance scripts live in Scripts/Localization/ and have their own
README. You don't need them to submit a translation. If you're
adding new localized strings to the code, though, note that xcodebuild never writes new keys back
into the catalog. Only building inside Xcode does. sync_catalog.py exists to cover that gap.
One trap worth knowing: in a String Catalog an empty translation is not the same as a missing one. A
missing entry falls back to English, while an empty string renders as blank space in the UI. If you
don't have a translation for something, leave the entry out rather than filling it with "".
--- METRICS ---
- Files Extracted: 5
- Estimated Token Budget: ~11744 tokens
- Canonical Reference: https://codewiki.google/github.com/milanvarady/Applite