Files
InterlapandCihat Gündüz 867e276c14 feat: support Apple TV and Apple Watch simulator slimming (#54)
* feat: support Apple TV and Apple Watch simulator slimming

* feat: support Apple Vision Pro simulator slimming and speed up no-reboot slims (#56)

* perf: stop a no-reboot slim's daemons with one launchctl spawn

`on --no-reboot` runs a `launchctl disable` and a `launchctl bootout`
spawn per label, 340 spawns for the full 170-label profile. A spawn is
cheap on a quiet Mac but can take seconds on a host busy with other
simulators and builds, and a live slim then needs minutes.

`launchctl unload -w` takes many job plists at once: it writes the same
persistent disable override as `disable` and removes the loaded job
like `bootout`. Simulator processes see the host file system, so the
plists under the runtime's RuntimeRoot can be passed by path, in chunks
of 64. `Device` now carries its `RuntimeID` to find that RuntimeRoot.

A plist is only used when its own `Label` key matches the label, so a
misnamed file can never unload a daemon outside the allowlist. The state
is read back afterwards (`print-disabled` and `launchctl list`), and
anything still enabled or loaded takes the existing per-label path. A
chunk that outlasts its spawn timeout ends the batch, so a slow host
keeps its remaining budget for that path.

* feat: support Apple Vision Pro simulator slimming

Builds on the Apple TV and Apple Watch support: CoreSimulator names the
visionOS runtime `xrOS`, so `parseRuntime` now maps it to the `visionOS`
platform, and every command, the JSON output, and the macOS app pick it
up through the existing platform plumbing.

**visionOS keeps `com.apple.mobileassetd` enabled.** The simulated
surroundings and the Home View are mobile assets. With mobileassetd
disabled a visionOS 27.0 simulator boots to a black space with no Home
View, while apps still launch, so a boot check alone does not notice it.
`desiredForDevice` skips it like homed on watchOS, so slimming,
verification, status totals, and the app's count agree, and a device
slimmed before this exception gets the service back on its next `on`.

**Persistence gate.** visionOS 2.5 is the release generation of iOS
18.5, following the tvOS and watchOS cutoffs. Like those, it is not an
independent measurement; every platform is still read back after boot.

* fix: reuse the resolved device for batch unloads and note the visionOS no-reboot cutoff

EnableSlimNoReboot already resolves the device, so unloadInBatch takes its
RuntimeID instead of running a second simctl list. listDevicesInSet sets
RuntimeID in its main loop, and the --no-reboot help names visionOS < 2.5.

---------

Co-authored-by: Cihat Gündüz <github@cihatguenduez.de>
2026-10-08 12:23:03 +02:00

101 lines
3.2 KiB
Go

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
}