diff --git a/CLAUDE.md b/CLAUDE.md index 14f87b6..fe182f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 @@ -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./disabled.plist`, keyed only by UDID. It is an undocumented CoreSimulator detail, so treat it as diff --git a/README.md b/README.md index 978c60a..bbbc4c1 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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: @@ -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 --no-reboot --profile ci.json ``` @@ -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 diff --git a/cmd/simslim/main.go b/cmd/simslim/main.go index c9a7810..553494e 100644 --- a/cmd/simslim/main.go +++ b/cmd/simslim/main.go @@ -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 diff --git a/gui/AppModel.swift b/gui/AppModel.swift index e0c4e68..a5c9545 100644 --- a/gui/AppModel.swift +++ b/gui/AppModel.swift @@ -93,8 +93,10 @@ final class AppModel: ObservableObject { func disabledDaemonCount(for device: SimulatorDevice) -> Int { let allLabels = categories.reduce(into: Set()) { $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 } diff --git a/gui/ContentView.swift b/gui/ContentView.swift index 17bab53..89d86e8 100644 --- a/gui/ContentView.swift +++ b/gui/ContentView.swift @@ -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) @@ -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) diff --git a/gui/Models.swift b/gui/Models.swift index fc24ae0..2bf3aff 100644 --- a/gui/Models.swift +++ b/gui/Models.swift @@ -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" } } diff --git a/launchd_batch.go b/launchd_batch.go new file mode 100644 index 0000000..67f904c --- /dev/null +++ b/launchd_batch.go @@ -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: "PIDStatusLabel", +// 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 +} diff --git a/launchd_batch_test.go b/launchd_batch_test.go new file mode 100644 index 0000000..78df177 --- /dev/null +++ b/launchd_batch_test.go @@ -0,0 +1,328 @@ +package simslim + +import ( + "context" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + "time" +) + +func TestParseRuntimeRoot(t *testing.T) { + out := []byte(`{"runtimes":[ + {"identifier":"com.apple.CoreSimulator.SimRuntime.iOS-26-5","runtimeRoot":"/r/iOS"}, + {"identifier":"com.apple.CoreSimulator.SimRuntime.tvOS-26-0","runtimeRoot":"/r/tvOS"}]}`) + got, err := parseRuntimeRoot(out, "com.apple.CoreSimulator.SimRuntime.tvOS-26-0") + if err != nil || got != "/r/tvOS" { + t.Errorf("parseRuntimeRoot = %q, %v; want /r/tvOS", got, err) + } + if _, err := parseRuntimeRoot(out, "com.apple.CoreSimulator.SimRuntime.watchOS-26-0"); err == nil { + t.Error("an unknown runtime must not resolve") + } +} + +func TestParseLaunchctlList(t *testing.T) { + got := parseLaunchctlList("PID\tStatus\tLabel\n123\t0\tcom.apple.a\n-\t0\tcom.apple.b\n\ngarbage\n") + want := map[string]bool{"com.apple.a": true, "com.apple.b": true} + if !reflect.DeepEqual(got, want) { + t.Errorf("parseLaunchctlList = %v, want %v", got, want) + } +} + +func TestStillToStop(t *testing.T) { + labels := []string{"a", "b", "c", "d"} + disabled := map[string]bool{"a": true, "b": true, "c": true} + loaded := map[string]bool{"b": true} + if got := stillToStop(labels, disabled, loaded); !reflect.DeepEqual(got, []string{"b", "d"}) { + t.Errorf("stillToStop = %v, want [b d]", got) + } +} + +// writeJobPlist writes a minimal launchd job plist whose Label key is label. +func writeJobPlist(t *testing.T, path, label string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + body := ` + +Label` + label + ` +` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// A plist is only used when its own Label matches: a file named after one +// label but defining another would otherwise unload an unmanaged daemon. +func TestLaunchdJobPlistsMatchesTheLabelInside(t *testing.T) { + root := t.TempDir() + writeJobPlist(t, filepath.Join(root, "System/Library/LaunchDaemons/com.apple.a.plist"), "com.apple.a") + writeJobPlist(t, filepath.Join(root, "System/Library/LaunchAgents/com.apple.b.plist"), "com.apple.b") + writeJobPlist(t, filepath.Join(root, "System/Library/LaunchDaemons/com.apple.c.plist"), "com.apple.unmanaged") + got := launchdJobPlists(context.Background(), root, []string{"com.apple.a", "com.apple.b", "com.apple.c", "com.apple.missing"}, readPlistLabel) + want := map[string]string{ + "com.apple.a": filepath.Join(root, "System/Library/LaunchDaemons/com.apple.a.plist"), + "com.apple.b": filepath.Join(root, "System/Library/LaunchAgents/com.apple.b.plist"), + } + if !reflect.DeepEqual(got, want) { + t.Errorf("launchdJobPlists = %v, want %v", got, want) + } +} + +// fakeBatchXcrun models a booted simulator's launchd with one file per label: +// $FAKE_DIS holds disable overrides, $FAKE_LOADED the loaded jobs. It answers +// `launchctl unload -w ` the way launchd does: override set, job +// gone. Labels listed in $FAKE_STUCK get the override but stay loaded, and +// FAKE_SLOW_UNLOAD makes every unload outlast its spawn timeout. +const fakeBatchXcrun = `#!/bin/sh +printf '%s\n' "$*" >> "$FAKE_LOG" +case "$1 $2 $3" in + "simctl list runtimes") printf '{"runtimes":[{"identifier":"com.apple.CoreSimulator.SimRuntime.%s","runtimeRoot":"%s"}]}\n' "$FAKE_RUNTIME" "$FAKE_ROOT"; exit 0 ;; + "simctl list devices") printf '{"devices":{"com.apple.CoreSimulator.SimRuntime.%s":[{"udid":"%s","name":"Batch","state":"Booted","isAvailable":true,"dataPath":"/tmp/batch"}]}}\n' "$FAKE_RUNTIME" "$FAKE_UDID"; exit 0 ;; +esac +case "$1 $2" in + "simctl boot"|"simctl bootstatus") exit 0 ;; + "simctl spawn") shift 3 ;; + *) exit 99 ;; +esac +verb=$2 +case "$verb" in + print-disabled) for f in "$FAKE_DIS"/*; do [ -e "$f" ] && printf '\t"%s" => disabled\n' "${f##*/}"; done ;; + list) printf 'PID\tStatus\tLabel\n'; for f in "$FAKE_LOADED"/*; do [ -e "$f" ] && printf -- '-\t0\t%s\n' "${f##*/}"; done ;; + unload) + [ -n "$FAKE_SLOW_UNLOAD" ] && exec sleep 5 + shift 3 + for p in "$@"; do + l=${p##*/}; l=${l%.plist}; : > "$FAKE_DIS/$l" + grep -qx "$l" "$FAKE_STUCK" 2>/dev/null || rm -f "$FAKE_LOADED/$l" + done ;; + disable) l=${3#system/}; : > "$FAKE_DIS/$l" ;; + bootout) l=${3#system/}; rm -f "$FAKE_LOADED/$l" ;; + *) exit 99 ;; +esac +exit 0 +` + +// batchFake is a booted simulator whose launchd state lives in files, with a +// RuntimeRoot holding a job plist for every label except withoutPlist. +type batchFake struct { + udid, dis, loaded, stuck, log string +} + +func newBatchFake(t *testing.T, runtime string, labels []string, withoutPlist string) batchFake { + t.Helper() + dir := t.TempDir() + f := batchFake{ + udid: "00000000-0000-0000-0000-0000000000B1", + dis: filepath.Join(dir, "dis"), + loaded: filepath.Join(dir, "loaded"), + stuck: filepath.Join(dir, "stuck"), + log: filepath.Join(dir, "calls.log"), + } + root := filepath.Join(dir, "RuntimeRoot") + for _, d := range []string{f.dis, f.loaded} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + for _, l := range labels { + if err := os.WriteFile(filepath.Join(f.loaded, l), nil, 0o644); err != nil { + t.Fatal(err) + } + if l != withoutPlist { + writeJobPlist(t, filepath.Join(root, "System/Library/LaunchDaemons", l+".plist"), l) + } + } + if err := os.WriteFile(filepath.Join(dir, "xcrun"), []byte(fakeBatchXcrun), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("FAKE_LOG", f.log) + t.Setenv("FAKE_DIS", f.dis) + t.Setenv("FAKE_LOADED", f.loaded) + t.Setenv("FAKE_STUCK", f.stuck) + t.Setenv("FAKE_ROOT", root) + t.Setenv("FAKE_RUNTIME", runtime) + t.Setenv("FAKE_UDID", f.udid) + return f +} + +// calls splits the logged xcrun calls into batch unloads and per-label spawns. +func (f batchFake) calls(t *testing.T) (unloads, perLabel []string) { + t.Helper() + log, err := os.ReadFile(f.log) + if err != nil { + t.Fatal(err) + } + for _, call := range strings.Split(strings.TrimSpace(string(log)), "\n") { + switch { + case strings.Contains(call, " launchctl unload "): + unloads = append(unloads, call) + case strings.Contains(call, " launchctl disable "), strings.Contains(call, " launchctl bootout "): + perLabel = append(perLabel, call) + } + } + return unloads, perLabel +} + +// assertStopped checks that every label ends with an override and unloaded. +func (f batchFake) assertStopped(t *testing.T, labels []string) { + t.Helper() + for _, l := range labels { + if _, err := os.Stat(filepath.Join(f.dis, l)); err != nil { + t.Errorf("%s has no disable override", l) + } + if _, err := os.Stat(filepath.Join(f.loaded, l)); err == nil { + t.Errorf("%s is still loaded", l) + } + } +} + +func sortedLabels(set map[string]bool) []string { + labels := make([]string, 0, len(set)) + for l := range set { + labels = append(labels, l) + } + sort.Strings(labels) + return labels +} + +func TestNoRebootStopsEveryDaemonWithOneBatchSpawn(t *testing.T) { + labels := sortedLabels(Profile{}.Desired()) + // Every job is loaded; all but one ship a plist, so that one must take the + // per-label path. + withoutPlist := labels[0] + f := newBatchFake(t, "iOS-26-5", labels, withoutPlist) + + if _, err := EnableSlimNoReboot(context.Background(), "default", f.udid, Profile{}, nil); err != nil { + t.Fatalf("EnableSlimNoReboot: %v", err) + } + unloads, perLabel := f.calls(t) + wantUnloads := (len(labels) - 1 + unloadChunk - 1) / unloadChunk + if len(unloads) != wantUnloads { + t.Errorf("%d unload spawns for %d plists, want %d", len(unloads), len(labels)-1, wantUnloads) + } + if len(perLabel) != 2 { + t.Errorf("per-label spawns = %v, want only the disable and bootout of %s", perLabel, withoutPlist) + } + f.assertStopped(t, labels) +} + +// A job that keeps its process after `unload -w` is disabled but still loaded; +// it must still get the per-label bootout. +func TestNoRebootBootsOutJobsTheBatchLeftLoaded(t *testing.T) { + labels := sortedLabels(Profile{}.Desired()) + stuck := labels[3] + f := newBatchFake(t, "iOS-26-5", labels, "") + if err := os.WriteFile(f.stuck, []byte(stuck+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := EnableSlimNoReboot(context.Background(), "default", f.udid, Profile{}, nil); err != nil { + t.Fatalf("EnableSlimNoReboot: %v", err) + } + _, perLabel := f.calls(t) + want := []string{ + "simctl spawn " + f.udid + " launchctl disable system/" + stuck, + "simctl spawn " + f.udid + " launchctl bootout system/" + stuck, + } + if !reflect.DeepEqual(perLabel, want) { + t.Errorf("per-label spawns = %v, want %v", perLabel, want) + } + f.assertStopped(t, labels) +} + +// Without a RuntimeRoot there are no plists to batch, so every label takes the +// per-label path and the slim still succeeds. +func TestNoRebootFallsBackWithoutRuntimeRoot(t *testing.T) { + labels := sortedLabels(Profile{}.Desired()) + f := newBatchFake(t, "iOS-26-5", labels, "") + t.Setenv("FAKE_ROOT", "") + + if _, err := EnableSlimNoReboot(context.Background(), "default", f.udid, Profile{}, nil); err != nil { + t.Fatalf("EnableSlimNoReboot: %v", err) + } + unloads, perLabel := f.calls(t) + if len(unloads) != 0 || len(perLabel) != 2*len(labels) { + t.Errorf("%d unload and %d per-label spawns, want 0 and %d", len(unloads), len(perLabel), 2*len(labels)) + } + f.assertStopped(t, labels) +} + +// A chunk that outlasts its spawn timeout ends the batch at once, so a slow +// host keeps its remaining budget for the per-label path. +func TestNoRebootStopsBatchingAfterASlowChunk(t *testing.T) { + labels := sortedLabels(Profile{}.Desired()) + f := newBatchFake(t, "iOS-26-5", labels, "") + t.Setenv("FAKE_SLOW_UNLOAD", "1") + previous := SpawnTimeout + SpawnTimeout = 300 * time.Millisecond + t.Cleanup(func() { SpawnTimeout = previous }) + + if _, err := EnableSlimNoReboot(context.Background(), "default", f.udid, Profile{}, nil); err != nil { + t.Fatalf("EnableSlimNoReboot: %v", err) + } + unloads, perLabel := f.calls(t) + if len(unloads) != 1 { + t.Errorf("%d unload spawns after the first one timed out, want 1", len(unloads)) + } + if len(perLabel) != 2*len(labels) { + t.Errorf("%d per-label spawns, want %d", len(perLabel), 2*len(labels)) + } + f.assertStopped(t, labels) +} + +// A second run on a device that is already slim has nothing to stop: it spawns +// no unload and no per-label transition, and says so instead of claiming a batch. +func TestNoRebootOnASlimDeviceStopsNothing(t *testing.T) { + labels := sortedLabels(Profile{}.Desired()) + f := newBatchFake(t, "iOS-26-5", labels, "") + if _, err := EnableSlimNoReboot(context.Background(), "default", f.udid, Profile{}, nil); err != nil { + t.Fatalf("first EnableSlimNoReboot: %v", err) + } + if err := os.Remove(f.log); err != nil { + t.Fatal(err) + } + + var lines []string + report := Reporter(func(s string) { lines = append(lines, s) }) + if _, err := EnableSlimNoReboot(context.Background(), "default", f.udid, Profile{}, report); err != nil { + t.Fatalf("second EnableSlimNoReboot: %v", err) + } + unloads, perLabel := f.calls(t) + if len(unloads) != 0 || len(perLabel) != 0 { + t.Errorf("%d unload and %d per-label spawns on a slim device, want none", len(unloads), len(perLabel)) + } + out := strings.Join(lines, "\n") + if strings.Contains(out, "stopped in one batch") || !strings.Contains(out, "already stopped") { + t.Errorf("report on a slim device = %q, want the already-stopped line", out) + } + f.assertStopped(t, labels) +} + +// The batch only unloads the device's own desired labels, so visionOS keeps +// mobileassetd (and with it the surroundings) running. +func TestNoRebootBatchKeepsVisionMobileAssetsRunning(t *testing.T) { + const label = "com.apple.mobileassetd" + f := newBatchFake(t, "xrOS-27-0", sortedLabels(Profile{}.Desired()), "") + + if _, err := EnableSlimNoReboot(context.Background(), "default", f.udid, Profile{}, nil); err != nil { + t.Fatalf("EnableSlimNoReboot: %v", err) + } + unloads, perLabel := f.calls(t) + for _, call := range append(unloads, perLabel...) { + if strings.Contains(call, label) { + t.Errorf("visionOS slim passed %s to launchctl", label) + } + } + if _, err := os.Stat(filepath.Join(f.loaded, label)); err != nil { + t.Errorf("%s is no longer loaded on visionOS", label) + } + if _, err := os.Stat(filepath.Join(f.dis, label)); err == nil { + t.Errorf("%s got a disable override on visionOS", label) + } +} diff --git a/packaging/homebrew/simslim.rb.in b/packaging/homebrew/simslim.rb.in index 0399201..cda4a3f 100644 --- a/packaging/homebrew/simslim.rb.in +++ b/packaging/homebrew/simslim.rb.in @@ -1,5 +1,5 @@ class Simslim < Formula - desc "Run more iOS, tvOS, and watchOS simulators on one Mac by disabling unneeded background daemons" + desc "Run more iOS, tvOS, watchOS, and visionOS simulators on one Mac by disabling unneeded background daemons" homepage "https://github.com/mobai-app/simslim" url "@SOURCE_URL@" sha256 "@SOURCE_SHA256@" diff --git a/platform_test.go b/platform_test.go index 7ff8c42..0932275 100644 --- a/platform_test.go +++ b/platform_test.go @@ -15,7 +15,9 @@ func TestParseRuntime(t *testing.T) { {"com.apple.CoreSimulator.SimRuntime.tvOS-18-5", "tvOS", "18.5"}, {"com.apple.CoreSimulator.SimRuntime.watchOS-11-5-1", "watchOS", "11.5.1"}, {"com.apple.CoreSimulator.SimRuntime.watchOS-26-0", "watchOS", "26.0"}, - {"com.apple.CoreSimulator.SimRuntime.xrOS-26-0", "", "?"}, + {"com.apple.CoreSimulator.SimRuntime.xrOS-26-0", "visionOS", "26.0"}, + {"com.apple.CoreSimulator.SimRuntime.xrOS-2-5", "visionOS", "2.5"}, + {"com.apple.CoreSimulator.SimRuntime.visionOS-26-0", "", "?"}, {"com.apple.CoreSimulator.SimRuntime.not-iOS-26-0", "", "?"}, {"com.apple.CoreSimulator.SimRuntime.iOS-", "", "?"}, {"iOS-26-5", "", "?"}, @@ -55,11 +57,12 @@ esac } t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) devices, err := ListDevices(context.Background()) - if err != nil || len(devices) != 4 { - t.Fatalf("ListDevices = (%v, %v), want 4 supported, available devices", devices, err) + if err != nil || len(devices) != 5 { + t.Fatalf("ListDevices = (%v, %v), want 5 supported, available devices", devices, err) } want := map[string]Device{ "PHONE": {Platform: "iOS", OSVersion: "26.5", Set: "default"}, + "VISION": {Platform: "visionOS", OSVersion: "26.0", Set: "default"}, "TV": {Platform: "tvOS", OSVersion: "26.0", Set: "default", DataPath: "/tmp/tv/data"}, "WATCH": {Platform: "watchOS", OSVersion: "11.5", Set: "testing", DataPath: "/tmp/watch/data"}, "CUSTOM": {Platform: "tvOS", OSVersion: "18.5", Set: "/custom/devices"}, @@ -93,6 +96,8 @@ func TestDeviceSupportsPersistentOverrides(t *testing.T) { {"watchOS", "26.0", true}, {"tvOS", "26.0", true}, {"watchOS", "?", false}, {"watchOS", "11", false}, {"watchOS", "26.-1", false}, {"xrOS", "26.0", false}, + {"visionOS", "2.4", false}, {"visionOS", "2.5", true}, + {"visionOS", "26.0", true}, {"visionOS", "27.0", true}, } { d := Device{Platform: tt.platform, OSVersion: tt.version} t.Run(d.RuntimeName(), func(t *testing.T) { @@ -104,7 +109,7 @@ func TestDeviceSupportsPersistentOverrides(t *testing.T) { } func TestSlimAndRestoreAppleTVAndWatch(t *testing.T) { - for _, runtime := range []string{"tvOS-18-5", "watchOS-11-5", "tvOS-26-0", "watchOS-26-5"} { + for _, runtime := range []string{"tvOS-18-5", "watchOS-11-5", "tvOS-26-0", "watchOS-26-5", "xrOS-2-5", "xrOS-27-0"} { t.Run(runtime, func(t *testing.T) { const udid = "00000000-0000-0000-0000-0000000000F1" useTempStoreRoot(t) @@ -254,3 +259,43 @@ func TestWatchProfileKeepsHomedEnabled(t *testing.T) { } } } + +func TestVisionProfileKeepsMobileAssetsEnabled(t *testing.T) { + const label = "com.apple.mobileassetd" + vision := Device{Platform: "visionOS", OSVersion: "27.0"} + for _, platform := range []string{"iOS", "tvOS", "watchOS", ""} { + if !(Profile{}).DesiredForDevice(Device{Platform: platform})[label] { + t.Errorf("mobileassetd unexpectedly excluded on %q", platform) + } + } + if (Profile{}).DesiredForDevice(vision)[label] { + t.Fatal("Vision profile disables mobileassetd, which serves the surroundings") + } + // A device slimmed before this exception existed gets mobileassetd back. + const udid = "00000000-0000-0000-0000-0000000000F5" + useTempStoreRoot(t) + logPath := fakeSimctl(t, udid, "xrOS-27-0", "Shutdown") + if err := writeDisabledStore(udid, []string{label}, nil); err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := EnableSlim(ctx, "default", udid, Profile{}, nil); err != nil { + t.Fatal(err) + } + st, disabled, err := ReadStatus(ctx, udid) + if err != nil || disabled[label] || st.ManagedTotal != len(SlimmableSet())-1 || st.ManagedDisabled != st.ManagedTotal { + t.Fatalf("Vision status = (%+v, %v), mobileassetd disabled = %t", st, err, disabled[label]) + } + verified, err := VerifyProfile(ctx, udid, Profile{}) + if err != nil || !verified.OK { + t.Fatalf("Vision profile verification = (%+v, %v)", verified, err) + } + if _, err := EnableSlimNoReboot(ctx, "default", udid, Profile{}, nil); err != nil { + t.Fatal(err) + } + for _, call := range xcrunCalls(t, logPath) { + if strings.Contains(call, "launchctl disable system/"+label) || strings.Contains(call, "launchctl bootout system/"+label) { + t.Fatalf("Vision slimming tried to disable mobileassetd: %s", call) + } + } +} diff --git a/profiles.go b/profiles.go index cd65556..4532c32 100644 --- a/profiles.go +++ b/profiles.go @@ -423,6 +423,12 @@ func desiredForDevice(d Device, desired map[string]bool) map[string]bool { if d.PlatformName() == "watchOS" && label == "com.apple.homed" { continue } + // visionOS serves the simulated surroundings as mobile assets. + // Without mobileassetd the device boots into a black space: the + // Home View icons float in the dark, and apps still launch. + if d.PlatformName() == "visionOS" && label == "com.apple.mobileassetd" { + continue + } set[label] = disabled } return set diff --git a/service_descriptions.go b/service_descriptions.go index 62f8fd4..26873a4 100644 --- a/service_descriptions.go +++ b/service_descriptions.go @@ -202,7 +202,7 @@ var serviceDescriptionByLabel = map[string]string{ "com.apple.storagedatad": "Calculates storage usage shown by the system.", "com.apple.StatusKitAgent": "Shares Focus, presence, and status between devices.", "com.apple.countryd": "Determines regional availability for system features.", - "com.apple.mobileassetd": "Downloads and manages system asset packages.", + "com.apple.mobileassetd": "Downloads and manages system asset packages. Kept enabled on visionOS.", "com.apple.managedconfiguration.passcodenagd": "Enforces managed passcode requirements.", } diff --git a/simctl.go b/simctl.go index ad563d0..1b74ad9 100644 --- a/simctl.go +++ b/simctl.go @@ -24,10 +24,11 @@ type Device struct { UDID string `json:"udid"` Name string `json:"name"` State string `json:"state"` // "Booted" or "Shutdown" - Platform string `json:"platform"` // "iOS", "tvOS", or "watchOS" + Platform string `json:"platform"` // "iOS", "tvOS", "watchOS", or "visionOS" OSVersion string `json:"osVersion"` Set string `json:"set"` DataPath string `json:"-"` + RuntimeID string `json:"-"` // e.g. com.apple.CoreSimulator.SimRuntime.iOS-26-5 } // PlatformName preserves the iOS default for callers constructing Device values @@ -147,6 +148,18 @@ func listDevicesInSet(ctx context.Context, set deviceSetInfo) ([]Device, error) devices = append(devices, Device{UDID: d.UDID, Name: d.Name, State: d.State, Platform: platform, OSVersion: version, Set: set.name, DataPath: d.DataPath}) } } + + // The runtime identifier leads to the runtime's RuntimeRoot, where the + // launchd job plists live (see unloadInBatch). + runtimeOf := map[string]string{} + for runtime, ds := range parsed.Devices { + for _, d := range ds { + runtimeOf[d.UDID] = runtime + } + } + for i := range devices { + devices[i].RuntimeID = runtimeOf[devices[i].UDID] + } return devices, nil } @@ -205,6 +218,9 @@ func parseRuntime(runtime string) (platform, version string) { switch platform { case "iOS", "tvOS", "watchOS": return platform, strings.ReplaceAll(version, "-", ".") + case "xrOS": + // CoreSimulator still names the visionOS runtime xrOS. + return "visionOS", strings.ReplaceAll(version, "-", ".") default: return "", "?" } @@ -532,3 +548,43 @@ func WaitShutdown(ctx context.Context, set, udid string, timeout time.Duration) } return fmt.Errorf("timed out waiting for %s to shut down", udid) } + +// runtimeRoot returns the host path of a CoreSimulator runtime's RuntimeRoot. +// Simulator processes see the host file system, so launchctl inside the +// simulator can read job plists by these paths. +func runtimeRoot(ctx context.Context, runtimeID string) (string, error) { + out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "runtimes", "-j").Output() + if err != nil { + return "", listError(err) + } + return parseRuntimeRoot(out, runtimeID) +} + +// unloadJobs disables and unloads many jobs with one `launchctl unload -w` +// spawn per chunk of unloadChunk plists, instead of a disable and a bootout +// spawn per label. The exit status is not trusted: launchctl reports any single +// failure for the whole call, so the caller reads the state back and retries +// what is missing label by label. A chunk that runs out of time ends the +// batch, so a slow host keeps the rest of its budget for that per-label path. +func unloadJobs(ctx context.Context, set, udid string, paths []string) { + for start := 0; start < len(paths); start += unloadChunk { + end := min(start+unloadChunk, len(paths)) + args := simctlArgs(set, append([]string{"spawn", udid, "launchctl", "unload", "-w"}, paths[start:end]...)...) + spawnCtx, cancel := context.WithTimeout(ctx, SpawnTimeout) + _ = exec.CommandContext(spawnCtx, "xcrun", args...).Run() + expired := spawnCtx.Err() != nil + cancel() + if expired { + return + } + } +} + +// loadedLabels returns every label currently loaded in the simulator's launchd. +func loadedLabels(ctx context.Context, set, udid string) (map[string]bool, error) { + out, err := exec.CommandContext(ctx, "xcrun", simctlArgs(set, "spawn", udid, "launchctl", "list")...).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("launchctl list: %w: %s", err, strings.TrimSpace(string(out))) + } + return parseLaunchctlList(string(out)), nil +} diff --git a/slim.go b/slim.go index 8fa47c8..8aad8c5 100644 --- a/slim.go +++ b/slim.go @@ -171,22 +171,28 @@ func PersistentOverridesSupported(version string) bool { } func (d Device) minimumPersistentVersion() string { - if d.PlatformName() == "watchOS" { + switch d.PlatformName() { + case "watchOS": return "11.5" + case "visionOS": + return "2.5" } return "18.5" } // SupportsPersistentOverrides applies the launchd persistence version gate to -// the device's platform. tvOS 18.5 and watchOS 11.5 are the release generation -// corresponding to iOS 18.5. This gate is not proof of persistence: ensure -// always reads overrides back after boot and fails if the runtime lost them. +// the device's platform. tvOS 18.5, watchOS 11.5, and visionOS 2.5 are the +// release generation corresponding to iOS 18.5. This gate is not proof of +// persistence: ensure always reads overrides back after boot and fails if the +// runtime lost them. func (d Device) SupportsPersistentOverrides() bool { minimumMajor := 18 switch d.PlatformName() { case "iOS", "tvOS": case "watchOS": minimumMajor = 11 + case "visionOS": + minimumMajor = 2 default: return false } @@ -231,10 +237,10 @@ func EnableSlimNoReboot(ctx context.Context, set, udid string, p Profile, report if len(extra) > 0 { report.report(fmt.Sprintf("Leaving %d services disabled beyond this profile; only `simslim off` re-enables them.", len(extra))) } - // Boot out every profiled label, not just those without an override: an + // Check every profiled label, not just those without an override: an // override says nothing about whether the job is still loaded (an earlier - // run may have disabled it and then failed to boot it out). A bootout of - // an already-missing job is a cheap no-op, so this keeps the command idempotent. + // run may have disabled it and then failed to boot it out). Labels that are + // both disabled and unloaded are skipped, so the command stays idempotent. changed = len(toDisable) > 0 labels := make([]string, 0, len(desired)) for l := range desired { @@ -244,7 +250,8 @@ func EnableSlimNoReboot(ctx context.Context, set, udid string, p Profile, report } sort.Strings(labels) report.report(fmt.Sprintf("Stopping %d background services for this boot session...", len(labels))) - if err := applyDelta(ctx, set, udid, labels, nil, liveDisable, report); err != nil { + remaining := unloadInBatch(ctx, set, udid, labels, readPlistLabel, report) + if err := applyDelta(ctx, set, udid, remaining, nil, liveDisable, report); err != nil { return changed, err } after, err := readDisabled(ctx, set, udid) @@ -257,6 +264,62 @@ func EnableSlimNoReboot(ctx context.Context, set, udid string, p Profile, report return changed, nil } +// unloadInBatch disables and unloads the labels whose job plist it can find with +// one `launchctl unload -w` per chunk, then reads the state back and returns +// what still needs the per-label disable and bootout. Two spawns per label add +// up on a busy host; one spawn per chunk does not. Anything that goes wrong +// here only means more labels take the per-label path. +func unloadInBatch(ctx context.Context, set, udid string, labels []string, plistLabel func(context.Context, string) (string, error), report Reporter) []string { + d, err := FindDevice(ctx, udid, set) + if err != nil || d.RuntimeID == "" { + return labels + } + root, err := runtimeRoot(ctx, d.RuntimeID) + if err != nil { + return labels + } + // Only what is still enabled or loaded needs stopping, so a device that is + // already slim spawns no unload and does not report work it did not do. + pending, err := stopState(ctx, set, udid, labels) + if err != nil { + return labels + } + if len(pending) == 0 { + report.report(fmt.Sprintf(" all %d services were already stopped", len(labels))) + return nil + } + plists := launchdJobPlists(ctx, root, pending, plistLabel) + if len(plists) == 0 { + return pending + } + paths := make([]string, 0, len(plists)) + for _, l := range pending { + if p, ok := plists[l]; ok { + paths = append(paths, p) + } + } + unloadJobs(ctx, set, udid, paths) + rest, err := stopState(ctx, set, udid, pending) + if err != nil { + return pending + } + report.report(fmt.Sprintf(" %d/%d services stopped in one batch", len(pending)-len(rest), len(pending))) + return rest +} + +// stopState returns the labels that are still enabled or still loaded. +func stopState(ctx context.Context, set, udid string, labels []string) ([]string, error) { + disabled, err := readDisabled(ctx, set, udid) + if err != nil { + return nil, err + } + loaded, err := loadedLabels(ctx, set, udid) + if err != nil { + return nil, err + } + return stillToStop(labels, disabled, loaded), nil +} + // disableSlim re-enables every managed daemon, returning the device to stock. func DisableSlim(ctx context.Context, set, udid string, report Reporter) (bool, error) { return ensure(ctx, set, udid, map[string]bool{}, report)