This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Applite is a native macOS GUI application for Homebrew Casks, designed as an "app store for third-party apps" rather than a full Homebrew wrapper. Target audience is non-technical users who want simple app installation/management.
- Language: Swift with SwiftUI (
@Observable,@MainActor, async/await) - Platform: macOS 14+ (Apple Silicon and Intel)
- Build System: Xcode with Swift Package Manager for dependencies. Uses Xcode 16+ file-system synchronized groups (folder-based project): the on-disk folder structure is the project structure. Adding, removing, moving, or renaming source files needs no
project.pbxprojedits — just change the files on disk and Xcode picks them up automatically. Do not hand-edit the pbxproj for file management. - Database: GRDB.swift (SQLite) at
~/Library/Application Support/Applite/casks.sqlite
Open Applite.xcodeproj in Xcode and build/run (⌘R). Dependencies resolve automatically via SPM.
- App Launch:
ContentView.task(id: bootstrap.attempt)callscaskManager.bootstrapAndLoad() - First Run:
HomebrewBootstrapinstalls/validates brew; whilebootstrap.needsSetupOverlayis true,ContentViewcovers the window with the non-dismissableComponentsInstallViewmodal (Features/Bootstrap/). There is no separate onboarding flow — the app opens straight into the main UI - Main UI:
ContentViewwithNavigationSplitViewsidebar navigation - Data Loading:
CaskManager.loadData()runs in two stages — catalog (DB-only, instant) then brew CLI state (slow). The UI lights up after stage 1; installed/outdated state arrives reactively as stage 2 completes.
The shared cask/brew engine lives under Applite/Core/, split into focused subfolders:
Persistence (Applite/Core/Database/)
AppDatabase- Schema migrations, DatabasePool with WAL mode, FTS5 virtual table oncasksCaskRecord- GRDBFetchableRecord/PersistableRecord, decodes fromCaskDTOCaskDatabaseService- CRUD, FTS5 search (async), API sync
Cask engine (Applite/Core/CaskCore/) — the @Observable runtime layer
CaskViewModel-@Observable @MainActorview model wrappingCaskRecordwith runtime state (isInstalled,isOutdated,progressState)CaskViewModelRegistry- Single-identity store;viewModels(for:)is get-or-create so the same cask shares one VM across views. Identity isfullTokeneverywhere — DB primary key, registry key, brew ops. The baretokenis not unique (two taps can each ship a "firefox"), so it must never key anything; it's indexed for lookup onlyCaskDataLoader- Orchestrates:loadCatalogData()(DB-only),refreshInstalled()/refreshOutdated()(brew CLI),search(query:)(FTS5). DefinesCategoryLoadResultandTapLoadResult.CaskWarning- Warning enum (deprecated/disabled/caveat)CaskProgressState,CaskLoadError- install progress + load error types
Plain models (Applite/Core/Models/)
CaskDTO,CaskAdditionalInfo,BrewAnalytics- decode-only DTOs for the Homebrew API/JSONCategory,CategoryLoadResult+LocalizedName,TapLoadResult,SidebarItem,SortingOptions
Other Core/ subfolders: Core/Brew/ (brew CLI services + BrewPaths, Shell, Installation/), Core/Preferences/, Core/Infrastructure/ (AlertManager, AppPaths, SendNotification, MirrorEnvironment, NetworkProxyManager, …).
CaskManager (Applite/Core/CaskCore/CaskManager.swift)
- Thin
@Observable @MainActorcoordinator owningdataLoader,registry,brewService categories: [CategoryLoadResult]andtaps: [TapLoadResult]populated after stage 1isResolvingInstalledState: Boolis true during stage 2 (brew CLI);isRefreshingCatalog: Boolis true during aforceSyncreload- "Is brew usable" has exactly one owner:
bootstrap.phase(HomebrewBootstrap.Phase).isBrewReady/needsSetupOverlayderive from it, and a broken brew surfaces only as the setup overlay. Don't add a parallel flag —CaskManager.hasBrokenInstallandBrokenInstallViewwere removed for exactly that reason. ABrewServiceop that finds the path invalid callsrecoverBrew(wired tobootstrap.run()) instead of reacting on its own alert: AlertManageris the main window's one alert surface — brew failures, catalog/load failures and view-raised errors all queue in it, andContentViewpresents it once at the window root via.alertManager(_:). Rule: one manager per window, bound at that window's root; never inside a repeated view (binding it per cask card was the F5/P3-5 bug). Alerts carry their own buttons (AppAlert.Action), so nothing hand-rolls.alert. Windows that can't see that root (Settings'UninstallView) own a local oneloadData(forceSync:)is non-throwing — it path-validates, runs stage 1, then stage 2, surfacing any failure throughalert(with Retry/Quit actions). The same entry point powers initial load, the ⌘R menu action, and the "Refresh Catalog" prompt in Settings- Forwards install/uninstall/update to
BrewService
Brew services (Applite/Core/Brew/)
BrewService- Brew CLI operations; tracksactiveTasks: [ActiveBrewTask]InstalledCaskService- Wrapsbrew list --caskandbrew outdated --cask(the slow stage 2)
Views — split across App/ (entry + Commands), Navigation/ (shell), Components/ (generic reusable views), AppViews/ (the shared cask "app card" cluster), Features/<Screen>/ (one folder per screen), and Windows/ (standalone windows)
Navigation/split intoContentView/SidebarViews/DetailView.ContentViewis aNavigationSplitView; the detail closure picksSearchView(whensearchInputis non-empty) orDetailViews(tab-driven), while a broken/installing brew is covered by theComponentsInstallViewoverlay gated onbootstrap.needsSetupOverlay. The.homesidebar tab rendersDiscoverViewdirectly (no wrapper)selection: SidebarItem?is optional. Typing in the search field stashes the current selection intolastSelectionand clearsselectionso a sidebar tap can interrupt the search; tapping a sidebar item while a search is active clearssearchInput; clearing the search (Esc) restoreslastSelection. TwoonChangeguards (!searchInput.isEmpty/selection == nil) keep the watchers from loopingFeatures/Search/SearchView- Owns its own results state. Uses.task(id: query)with a 200msTask.sleepfor debounced live search;ContentUnavailableView.search(text:)for the empty state. Sort/filter are scoped here, not in ContentViewFeatures/Search/SortingOptionsToolbar- Toolbar shared by SearchView (sort + hide-unpopular + hide-disabled toggles)AppViews/- App card display components (split across 8+ files).AppliteAppView(self-card in the installed list) reads the live app icon fromNSApplication.shared.applicationIconImageso the new Icon Composer / Liquid Glass icon renders correctlyFeatures/Settings/SettingsView+BrewSettingsView- Shows a single fixed-height "Refresh Catalog" prompt at the bottom whenever the brew-path option or the "Include Casks from Taps" toggle differs from the baselines captured.onAppear. The button callscaskManager.loadData(forceSync: true)and resets the baselines on success. The old "relaunch app" flow was replacedFeatures/Bootstrap/-ComponentsInstallView(the setup overlay) +SetupStatusIcon. Not an onboarding flow; it's a modal over the main windowApp/Commands.swift- Menu bar commands. "Refresh App Catalog" lives in the Applite menu (⌘R) and invokescaskManager.loadData(forceSync: true)
- Homebrew Cask API:
https://formulae.brew.sh/api/cask.json - Analytics API:
https://formulae.brew.sh/api/analytics/cask-install/365d.json - Custom taps via
brew rubyscript (Applite/Resources/brew-tap-cask-info.rb), invoked byCaskDataLoader.fetchTapDTOs. The script no-opsHomebrew::Trust.require_trusted_cask!so metadata loads from already-tapped repos on Brew 6+ without requiringbrew trust(Applite only reads metadata; realbrew installstill honors trust). It also injectstapandfull_tokeninto each entry becauseFromPathLoader'sto_hleaves themnil
User settings stored via @AppStorage with keys defined in Applite/Core/Preferences/Preferences.swift.
- Sparkle - Auto-updates
- Kingfisher - Async image loading/caching
- GRDB.swift - SQLite database
- ButtonKit, SwiftUI-Shimmer - UI components
@Observable+@MainActorfor view models and managers (macOS 14+)- Async/await throughout; DB I/O uses GRDB's async API (
dbPool.read { ... }/.write { ... }) — never block the main actor on disk - Prefer SwiftUI built-ins over hand-rolled equivalents (e.g.
ContentUnavailableView) - Prefer Swift-native concurrency (e.g.
.task(id:)for debounced cancellable work) over add-on packages where the native primitive suffices - One view struct per file; do not split an owned type across multiple
Type+View.swiftextension files. Genuine extensions on external/stdlib types (Array+,String+,URL+,View+Modify) live inExtensions/and are fine. View helpers tightly coupled to a parent's@Statestay asprivatecomputed properties/methods in the parent's own file (e.g.AppView'sactionsView), not as a struct or a separate extension file - Two-stage data load: never block UI on
brew list --cask/brew outdated --cask; let the registry update those flags reactively
- For typos/minor bugs: PRs welcome directly
- For larger changes: Open issue or discuss on Discord first
- Project goal is simplicity for non-technical users; advanced features should not clutter main UI