The in-app purchase payment sheet is rendered by Apple Media Services (AMSUI), whose daemons lived only in the icloud category. Slimming with --except store therefore disabled them: products still loaded and the buy request succeeded, but the purchase died with AMSErrorDomain "Payment Sheet Failed Presentation failed". Categories may now overlap. A label lives in every category whose feature needs it, and Profile.Desired() keeps a label enabled when any excepted category lists it (previously a label was disabled if any non-excepted category listed it). The amsaccountsd/amsengagementd/amsondevicestoraged trio joins the store category while remaining in icloud. The storekit doctor feature now checks itunesstored and the AMS daemons alongside storekitd, so `doctor --requires storekit` catches this state. The GUI computes its disabled count and per-service kept state from a deduplicated effective set so shared labels are neither double-counted nor shown as disabled when another kept category needs them.
8.4 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
simslim runs many more iOS simulators on one Mac by disabling the background
daemons a simulator doesn't need, cutting each simulator's memory ~4x. It is a Go
CLI plus a SwiftUI macOS app that wraps it. Everything is driven through
xcrun simctl; the tool only ever touches the simulators you point it at, never
the host Mac. macOS-only.
Commands
go build ./cmd/simslim # build the CLI
go test ./... # run all tests (Makefile: make test)
go test -run TestName ./... # run a single test
make check # full CI gate — must pass before a PR
make format # gofmt + swift-format (run before committing)
make app # build build/SimSlim.app (Go + swiftc, macOS only)
open build/SimSlim.app
make check runs go test, go vet, swift-format lint --strict --recursive gui,
zsh -n scripts/build-app.sh, and plutil -lint gui/Info.plist. CI (.github/workflows/ci.yml)
runs exactly this on macos-26 / Xcode 26.6, then builds and verifies the app bundle.
Tests are pure unit tests (parsing, delta logic, allowlist invariants) — they do not boot real simulators, so they run anywhere. Only the app build and manual runs need Xcode + an iOS runtime.
Architecture
Two packages. The repo root is package simslim, an importable library holding
every piece of slimming logic and no external dependencies. cmd/simslim/
is package main, the CLI: main.go dispatches os.Args[1] to a cmd*
function per subcommand and enforces macOS-only up front.
Anything that talks to a terminal — printing, --json encoding via writeJSON,
the interactive wizard in wizard.go, fatal(), usage() — lives in
cmd/simslim/. The library never writes to stdout; it returns values and
reports progress through the Reporter callback the caller supplies.
Exported identifiers in the root package are the public API, so renaming one is a breaking change for importers as well as for the CLI.
The slimming model (the core idea). profiles.go defines Categories, an
allowlist of launchd daemon labels grouped by user-facing feature (siri, search,
icloud, …). Categories may overlap — a label lives in every category whose
feature needs it (e.g. the AMS payment-sheet daemons are in both store and
icloud), and Profile.Desired() keeps a label enabled when any excepted
category lists it. SlimmableSet() is the deduplicated union of every label in
Categories.
managedSet() adds each category's AlwaysEnabled compatibility services,
which simslim may only repair back to enabled; these are the only labels the
tool may ever disable or enable. Anything outside those sets is never touched.
service_descriptions.go supplies the short per-daemon explanations shown by
the GUI; its coverage and length are enforced in profiles_test.go.
profile_file.go loads a committed JSON profile (simslim on --profile <path>)
whose except/keep arrays mirror the flags of the same name, validates it
against the allowlist, and resolves it to a Profile. The dependency-free
profile command's interactive wizard lives in cmd/simslim/wizard.go.
features.go defines Features, a finer-grained catalog than Categories:
each feature (push, storekit, universal-links, …) names just the daemons one
testable capability needs. doctor reads a booted simulator's disabled labels
and reports any required feature whose daemons are down, exiting non-zero — a CI
preflight. features_test.go asserts every feature label is slimmable.
slim.go's ensure() boots the device, reads its currently
disabled labels, computes a delta against the desired set, and applies the
changes with launchctl disable/enable run inside the simulator via
simctl spawn. The disables are written as persistent launchd overrides, so a
slimmed simulator comes up slim on every subsequent boot in one boot. on
disables the profile; off re-enables the whole managed set back to stock.
simctl wrapper. simctl.go is the only place that shells out to
xcrun simctl (list/boot/shutdown/clone/erase/delete/spawn). measure.go sums
phys_footprint across the simulator's launchd process tree (via pgrep/ps/top)
— that's the memory figure that decides how many simulators fit. MeasureProcesses
keeps the per-process detail (footprint + cpu, from the same snapshot) for the
top drill-down. fleet.go's FleetSnapshot composes booted devices + slim
status + MeasureMany into the fleet view; the live TUI is cmd/simslim/top.go
(Bubble Tea), which also has a --json/non-TTY one-shot fallback. disk.go,
disk_cleanup.go, and disk_inventory.go handle disk measurement and the
separate, permanent disk-cleanup feature.
JSON is a contract. Every read-only and management command supports --json.
The structs in output.go (DeviceSummary, StatusOutput,
SimulatorMutationOutput, etc.) plus the category/plan structs are the stable
interface the SwiftUI app decodes. Changing a JSON field breaks the GUI in
gui/*.swift — keep them in sync. gui/Backend.swift invokes the bundled CLI
with --json and decodes these types; there is no other IPC.
The SwiftUI app (gui/) has no Xcode project. scripts/build-app.sh compiles
the Swift sources directly with swiftc, cross-builds the Go CLI, bundles it into
SimSlim.app/Contents/Resources/simslim, generates the icon, and ad-hoc codesigns.
The app is a thin front end that shells out to that bundled binary.
Safety invariants — preserve these
- Never add a deadlock-prone daemon to
Categories. A handful of daemons wedge a simulator when disabled.profiles_test.goholds aforbiddenLabelslist and asserts none appear in any category; if you add labels, keep that test green. - Only managed labels are ever mutated.
delta()is scoped tomanagedSet()on both sides. Compatibility labels are omitted from every desired slim state, so they can only be repaired to enabled. - Destructive/management commands resolve the exact UDID first (
findDevice/shutdownIfBooted) so asimctlalias likeallcan never fan out a boot, erase, delete, or filesystem path across every simulator. - Disk cleanup only removes allowlisted per-device directories, refuses to run
without
--confirm, and never touches the shared, signed iOS runtime (built-in apps, core OS language resources).disk-planis strictly read-only. Durable storage (Documents, app bundles, user media) is measured but never deletable.
Conventions
- CLI parsing uses
github.com/urfave/cli/v3; thetopcommand's live TUI usesgithub.com/charmbracelet/bubbletea+lipgloss. These are the CLI's only dependencies — the root library still has none, so importers never inherit a terminal stack. The command tree lives incmd/simslim/app.go(newApp); each subcommand'sActionis acmd*function incmd/simslim/main.gothat reads flags viacmd.Bool/String(...)and positionals viacmd.Args(). Flags may appear before or after positional args (e.g.simslim on <udid> --except search) — v3 parses flags anywhere by default.--setis a global flag (inherited by every subcommand) registered in its flagAction.main.gostill ownsversion/help/no-args and the macOS-only guard before handing off to the tree, and routes every command error throughfatal()for the stablesimslim: <msg>(exit 1); unknown commands exit 2 with usage. - Two timeouts live in
simctl.go:ShutdownTimeout(30s, a const) andBootTimeout(10min, because a first slim reconfigure boots twice).BootTimeoutis a package var, not a const, so the CLI's global--boot-timeoutflag (envSIMSLIM_BOOT_TIMEOUT, wired inapp.golike--set) can raise it for slow CI runners where the per-daemonlaunchctltransitions would otherwise blow the deadline mid-reconfigure. - Progress for multi-minute operations goes to stderr via the
Reportercallback; machine-readable JSON goes to stdout. Under--json, suppress the stderr chatter so consumers reading a combined stream still get clean JSON. - Memory estimates (
ApproxMemoryMB) are iOS-26.5 clean-boot medians and are not additive. Every category must have a positive measured estimate.
Git
Never add a Co-Authored-By: Claude trailer or any "Generated with Claude" footer
to commit messages.