Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## What this is

simslim runs many more iOS, tvOS, and watchOS simulators on one Mac by disabling the background
simslim runs many more iOS, tvOS, watchOS, and visionOS 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
Expand Down Expand Up @@ -67,20 +67,28 @@ 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()` rejects a non-empty slim profile on runtimes older than
iOS/tvOS 18.5 or watchOS 11.5 before booting or mutating the device, then reads the currently disabled
iOS/tvOS 18.5, watchOS 11.5, or visionOS 2.5 before booting or mutating the device, then reads the 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`, a pool of
`spawnWorkers` (8) at a time. It reboots
and reads the state back before reporting persistence. `on` disables the profile;
`off` remains available on every runtime and re-enables the whole managed set.
`Profile.DesiredForDevice` keeps `com.apple.homed` enabled on watchOS, which
re-enables that service after startup. Slimming, verification, and per-device
re-enables that service after startup, and `com.apple.mobileassetd` on visionOS,
whose simulated surroundings are mobile assets. Slimming, verification, and per-device
status totals use this platform-specific profile; `Profile.Desired` remains the
platform-independent catalog for profile editors. `Device.Platform` identifies
iOS, tvOS, or watchOS; an empty platform defaults to iOS for library callers.
iOS, tvOS, watchOS, or visionOS (CoreSimulator's `xrOS` runtimes); an empty platform defaults to iOS for library callers.
`EnableSlimNoReboot` (`on --no-reboot`) skips the reboot: it runs `launchctl
disable` + `launchctl bootout` per label so the daemon stops in the current boot
session, which is the only slimming possible on runtimes older than iOS 18.5.
Before that per-label pass, `unloadInBatch` stops most labels at once: it finds
each label's job plist under the runtime's RuntimeRoot (`Device.RuntimeID` →
`runtimeRoot`, used only when the plist's own `Label` matches) and passes them to
one `launchctl unload -w` per 64 plists, which writes the same override as
`disable` and removes the job like `bootout`. It then reads `print-disabled` and
`launchctl list` back, and only labels still enabled or loaded take the
per-label path; any failure in the batch just means more labels do.

**The offline fast path.** On a runtime with persistent overrides `ensureOffline`
takes over: `disabled_store.go` writes the overrides directly and boots the
Expand All @@ -90,7 +98,8 @@ there too — an override only takes effect at the next boot, so `ensure` reads
live state, returns early when the profile already matches, and otherwise spends
the shutdown the device already owed before handing off. There is no shell inside
any iOS runtime (`RuntimeRoot/bin` holds only `df` and `launchctl`), so batching
transitions through a spawned `/bin/sh` is not an option; the pool is. `launchd_sim` is a *host* process, so that store is not in the
transitions through a spawned `/bin/sh` is not an option; the offline path uses
the pool. `launchd_sim` is a *host* process, so that store is not in the
device's data directory — it sits beside the device's `launchd.log` in
`/private/var/tmp/com.apple.CoreSimulator.SimDevice.<UDID>/disabled.plist`, keyed
only by UDID. It is an undocumented CoreSimulator detail, so treat it as
Expand Down
34 changes: 23 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# simslim

Run a lot more iOS, Apple TV (tvOS), and Apple Watch (watchOS) simulators on one Mac by turning off the background daemons a simulator doesn't need.
Run a lot more iOS, Apple TV (tvOS), Apple Watch (watchOS), and Apple Vision Pro (visionOS) simulators on one Mac by turning off the background daemons a simulator doesn't need.

A freshly booted iOS simulator starts around 180 background services: Siri, Spotlight indexing, photo analysis, News, wallpaper posters, iCloud sync, and so on. None of it matters when you're using the simulator for development, testing, or CI. simslim switches those services off, which cuts each simulator's memory roughly 4x. On the same laptop you go from a handful of simulators to a screenful.

Expand Down Expand Up @@ -38,18 +38,24 @@ or
go install github.com/mobai-app/simslim/cmd/simslim@latest
```

macOS only, and you need Xcode with an iOS, tvOS, or watchOS Simulator runtime,
since simslim drives simulators through `xcrun simctl`.
macOS only, and you need Xcode with an iOS, tvOS, watchOS, or visionOS Simulator
runtime, since simslim drives simulators through `xcrun simctl`.

All three platforms use the same commands and service profiles. `list`, `top`,
All four platforms use the same commands and service profiles. `list`, `top`,
and the macOS app show each device's platform; JSON device records include
`platform` (`iOS`, `tvOS`, or `watchOS`) alongside the numeric `osVersion`.
The existing memory figures and category estimates were measured on iOS;
savings on Apple TV and Apple Watch depend on their runtime and workload.
`platform` (`iOS`, `tvOS`, `watchOS`, or `visionOS`) alongside the numeric
`osVersion`. The existing memory figures and category estimates were measured on
iOS; savings on Apple TV, Apple Watch, and Apple Vision Pro depend on their
runtime and workload.
The profile is an allowlist of disable overrides, so labels absent from a
particular runtime do not represent running processes or additional savings.
watchOS keeps `com.apple.homed` enabled because it re-enables the service after
startup; profile verification and managed totals account for this exception.
visionOS keeps `com.apple.mobileassetd` enabled: the simulated surroundings
are mobile assets, and without it the simulator boots into a black space
around the Home View, although apps still launch. A visionOS simulator stays
expensive even when slim, because rendering its surroundings keeps
`backboardd` busy.
For Watch connectivity tests, keep `connectivity,messaging`; for HealthKit or
fitness tests, also keep `health`, for example:

Expand Down Expand Up @@ -247,6 +253,12 @@ each daemon and then boots it out of launchd, so the process stops now and
cannot respawn, with no shutdown/boot cycle. It takes the same
`--profile`/`--except`/`--keep` selection as `on`.

Most daemons are stopped together: one `launchctl unload -w` with the job plists
from the runtime writes the same disable override and removes the running job,
so a busy host does not pay for two `launchctl` spawns per daemon. The result is
read back, and any daemon still enabled or loaded is then disabled and booted out
on its own.

```sh
simslim on <udid> --no-reboot --profile ci.json
```
Expand Down Expand Up @@ -364,10 +376,10 @@ Applying a profile normally costs no `launchctl` call at all: the overrides are

The earliest runtime with verified persistence is iOS 18.5. iOS 17.x and 18.3
accept each `launchctl disable`, but the simulator comes back stock after a
reboot. The persistent-slimming version gate is iOS/tvOS 18.5 or watchOS 11.5
and newer. The tvOS and watchOS cutoffs follow the corresponding iOS release
generation; they are not independent measurements of the earliest persistent
runtime. `simslim on` rejects older versions before booting or changing launchd
reboot. The persistent-slimming version gate is iOS/tvOS 18.5, watchOS 11.5, or
visionOS 2.5 and newer. The tvOS, watchOS, and visionOS cutoffs follow the
corresponding iOS release generation; they are not independent measurements of
the earliest persistent runtime. `simslim on` rejects older versions before booting or changing launchd
state and points at `--no-reboot`, which slims the current boot session only
(see [Slimming without a reboot](#slimming-without-a-reboot)). Every platform is
read back after boot, and the command fails instead of claiming success if any
Expand Down
2 changes: 1 addition & 1 deletion cmd/simslim/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,7 @@ func fatal(msg string) {
}

func usage() {
fmt.Print(`simslim runs more iOS, tvOS, and watchOS simulators on the same Mac by disabling the
fmt.Print(`simslim runs more iOS, tvOS, watchOS, and visionOS simulators on the same Mac by disabling the
background daemons a simulator does not need.

USAGE
Expand Down
4 changes: 3 additions & 1 deletion gui/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,10 @@ final class AppModel: ObservableObject {
func disabledDaemonCount(for device: SimulatorDevice) -> Int {
let allLabels = categories.reduce(into: Set<String>()) { $0.formUnion($1.labels) }
var disabled = allLabels.subtracting(effectiveKeptLabels)
// Keep in sync with Profile.DesiredForDevice: watchOS re-enables homed.
// Keep in sync with Profile.DesiredForDevice: watchOS re-enables homed, and
// visionOS needs mobileassetd for its surroundings.
if device.platform == "watchOS" { disabled.remove("com.apple.homed") }
if device.platform == "visionOS" { disabled.remove("com.apple.mobileassetd") }
return disabled.count
}

Expand Down
4 changes: 2 additions & 2 deletions gui/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ struct ContentView: View {
systemImage: "rectangle.slash",
description: Text(
searchText.isEmpty
? "Install an iOS, tvOS, or watchOS Simulator runtime in Xcode."
? "Install an iOS, tvOS, watchOS, or visionOS Simulator runtime in Xcode."
: "Try a different name, UDID, or OS version.")
)
.frame(width: geometry.size.width, height: geometry.size.height)
Expand Down Expand Up @@ -791,7 +791,7 @@ private struct ProfileSidebar: View {
.font(.subheadline.weight(.semibold))
.foregroundStyle(.green)
Text(
"Core workflow and deadlock-prone daemons are never disabled. HomeKit’s homed service stays enabled on watchOS."
"Core workflow and deadlock-prone daemons are never disabled. HomeKit’s homed stays enabled on watchOS, and the asset service mobileassetd on visionOS."
)
.font(.caption)
.foregroundStyle(.secondary)
Expand Down
1 change: 1 addition & 0 deletions gui/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct SimulatorDevice: Decodable, Identifiable, Equatable {
switch platform {
case "tvOS": return "appletv"
case "watchOS": return "applewatch"
case "visionOS": return "visionpro"
default: return "iphone"
}
}
Expand Down
100 changes: 100 additions & 0 deletions launchd_batch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package simslim

import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)

// launchdJobDirs are the RuntimeRoot directories holding the job plists that
// launchd_sim bootstraps into the simulator's system domain.
var launchdJobDirs = []string{"System/Library/LaunchDaemons", "System/Library/LaunchAgents"}

// unloadChunk bounds how many plist paths go into one `launchctl unload`, so
// the argument list stays far below ARG_MAX however long the profile grows.
const unloadChunk = 64

func parseRuntimeRoot(out []byte, runtimeID string) (string, error) {
var parsed struct {
Runtimes []struct {
Identifier string `json:"identifier"`
RuntimeRoot string `json:"runtimeRoot"`
} `json:"runtimes"`
}
if err := json.Unmarshal(out, &parsed); err != nil {
return "", fmt.Errorf("parse simctl list runtimes: %w", err)
}
for _, r := range parsed.Runtimes {
if r.Identifier == runtimeID && r.RuntimeRoot != "" {
return r.RuntimeRoot, nil
}
}
return "", fmt.Errorf("runtime %s has no runtimeRoot", runtimeID)
}

// launchdJobPlists maps each label to the job plist that defines exactly that
// label. A plist is only used when its own Label key matches, so a renamed or
// shared file can never unload a daemon outside the allowlist; labels without
// a matching plist are left for the per-label path.
func launchdJobPlists(ctx context.Context, root string, labels []string, plistLabel func(context.Context, string) (string, error)) map[string]string {
paths := make(map[string]string, len(labels))
for _, label := range labels {
if ctx.Err() != nil {
break
}
for _, dir := range launchdJobDirs {
path := filepath.Join(root, dir, label+".plist")
if _, err := os.Stat(path); err != nil {
continue
}
if got, err := plistLabel(ctx, path); err == nil && got == label {
paths[label] = path
break
}
}
}
return paths
}

// readPlistLabel reads a job plist's Label with the host's plutil, which
// handles both XML and binary plists.
func readPlistLabel(ctx context.Context, path string) (string, error) {
out, err := exec.CommandContext(ctx, "plutil", "-extract", "Label", "raw", "-o", "-", path).Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}

// parseLaunchctlList reads `launchctl list` rows: "PID<TAB>Status<TAB>Label",
// with "-" as the PID of a loaded job that is not running.
func parseLaunchctlList(output string) map[string]bool {
loaded := map[string]bool{}
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
if len(fields) != 3 || fields[2] == "Label" {
continue
}
loaded[fields[2]] = true
}
return loaded
}

// stillToStop keeps the labels the batch did not finish: no disable override
// yet, or still loaded. A few jobs stay listed even with their override in
// place (on a slim visionOS 27.0 simulator, for example siri.context.service
// and TrustedPeersHelper), so they always take the per-label bootout; the old
// path booted out every label, so this is never more work than before.
func stillToStop(labels []string, disabled, loaded map[string]bool) []string {
var rest []string
for _, l := range labels {
if !disabled[l] || loaded[l] {
rest = append(rest, l)
}
}
return rest
}
Loading
Loading