A top(1) for the simulator fleet: every booted simulator with its OS, slim state, process count, CPU, RAM, and disk, refreshing live. Enter a row (or pass a UDID) to drill into that simulator's per-daemon footprint. Columns sort via n/o/s/p/c/m/d with a version-aware OS comparator; the name column flexes to the terminal width. --json and piped output emit one-shot snapshots, keeping the JSON contract for the GUI. Library side stays dependency-free: MeasureProcesses keeps per-process footprint+cpu from the shared ps/top snapshot, and FleetSnapshot composes booted devices, slim status (fanned out per device), and MeasureMany. The TUI (bubbletea + lipgloss, CLI-only deps) anchors the cursor by UDID across live re-sorts, keeps the last snapshot on transient refresh errors, and guards disk sweeps against overlap. Also adds `simslim list --booted` to filter the listing to booted simulators.
8.1 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, …). SlimmableSet() is the 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.