Applite Documentation

A native macOS app store for software that isn't on the App Store, backed by Homebrew Cask

7,089 stars Swift Swift 5 files Β· ~11,825 tokens #homebrew#homebrew-cask#macos#macos-app#swift#swiftui
RAW Doc

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

  1. What Sets Applite Apart
  2. Comparison
  3. Key Features
  4. Screenshots
  5. Download
  6. Built With
  7. Development and AI
  8. Contact
  9. Roadmap
  10. Contributing
  11. Packages Used
  12. Credits
  13. License
  14. 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

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:


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)
    or uv 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 to
    pip 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, annex Applite/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:

  1. provision once installs Homebrew + CLT (if missing) and then hides them
    (renames /opt/homebrew and /Library/Developer/CommandLineTools aside, and
    xcode-select --reset). Hidden β‡’ the machine genuinely reads as CLT-free with no
    system brew, which is exactly Round A's starting condition.
  2. reset returns 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
    .app bundles, 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,
    so brew uninstall alone can no longer see them.
  3. 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

sh
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 cached

Once per release, as a separate pass from its own reset:

sh
$PY applite_test.py reset
$PY applite_test.py run --round upgrade    # install v1.3.1 first, then the new build over it

Each run prints a PASS/FAIL/SKIP summary and appends to Testing/applite-test.log.

Running part of a round

sh
$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 B3

Phase 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
    rectangle and core rectangle collapsed into one row. Identity is fullToken now,
    and brew list --cask --full-name is 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 explicit
    teardown --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 only Library/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 point
    xcode-select away first. reset warns if it detects this.
  • The assertions deliberately avoid invoking /usr/bin/git|swift|xcrun (that would
    itself pop the dialog); "CLT-free" is checked via xcode-select -p pointing 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.

text
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 \
    --apply

It adds new keys, refreshes comments from source, marks vanished keys stale, and never touches
localizations
β€” 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`

python
TRANSLATIONS = {"<source key>": {"hu": "…", "fr": "…"}}   # refuses to overwrite an existing value
FIXES        = {"<source key>": {"hu": "…"}}              # overwrites deliberately

A 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 need
substitutions).

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 of
hu / fr / ja / zh_CN / zh_HK / tr, and counts each rendering.

text
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:

text
Scripts/Release/release.sh run 1.4.0

It 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.

text
security find-identity -v -p codesigning        # verify

2. notarytool keychain profile

Create an app-specific password at appleid.apple.com β–Έ Sign-In and Security, then:

text
xcrun notarytool store-credentials "applite-notary" \
    --apple-id <your-apple-id> --team-id 9CLTNBW4Z3

xcrun notarytool history --keychain-profile applite-notary    # verify

Omit --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:

text
Scripts/Release/release.sh import-key ~/path/to/sparkle-key.txt

That 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):

text
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 half

generate_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:

text
"$(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.xml

Running a release

text
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 the
APPLITE-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:

text
**Full changelog**: https://github.com/milanvarady/Applite/compare/v1.3.1...v1.4.0

so 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 to
release-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.

text
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:

text
git checkout Applite.xcodeproj/project.pbxproj

The 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 runs
stapler validate on the artifact itself before publishing, so a leftover rehearsal DMG can't be
uploaded by mistake.

Where things go

text
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 release

dSYMs 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

text
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 finding
CFBundleShortVersionString 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 through
xcodebuild -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 at
https://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:

  • pubDate is generated with LC_ALL=C. Without it, the Hungarian system locale emits
    sze, 05 aug. 2026, which is not RFC 822 and Sparkle cannot parse. The script also regex-checks it.
  • minimumSystemVersion is read from the built app's LSMinimumSystemVersion, 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.
  • length must be the byte count of the final, stapled DMG. Sparkle rejects a download whose
    size doesn't match exactly. Nothing may touch the DMG after sign-update.
  • Critical updates: --critical sets both <sparkle:criticalUpdate> in the feed and
    isCritical: true in 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 bare
codesign -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

  1. Project goal
  2. Reporting a bug
  3. Suggesting a feature
  4. Contributing code
  5. Contributing a translation

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.

  1. Open Console.app and select your device in the sidebar
  2. Click Start to begin streaming
  3. Reproduce the bug, then pause
  4. Filter for the dev.aerolite.Applite subsystem, or search for "applite"
  5. 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:

  • @Observable and @MainActor for 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 called firefox.
  • 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 ---