mirror of
https://github.com/MobAI-App/simslim.git
synced 2026-10-28 18:15:16 +01:00
* feat: enable and disable a simulator's services without a reboot
`simslim enable <udid> icloud,photos` turns categories or individual managed
daemons back on in a running simulator, without a reboot, and records them
as that simulator's exceptions. `simslim disable <udid> <ids>` turns them off
again and drops them; `--all` clears every exception. The pair mirrors
`on`/`off`, which slim or restore the whole simulator, but acts only on the
services named.
Enabling lifts each disable override and loads the job into the current
boot session: one `launchctl load -w` per chunk of RuntimeRoot plists does
most of them, then a per-label `launchctl enable` and `launchctl bootstrap`
handle whatever is left, and the state is read back. A job launchd only
loads at the next boot (cloudsettingssyncagent on iOS 27) is reported in
`notStarted` instead of failing the command. Disabling reuses the
unload/disable/bootout path of `on --no-reboot`; a daemon that another
remaining exception still needs keeps running. On a shutdown simulator both
commands only rewrite the offline override store.
The exceptions are per-simulator state, stored in simslim-exceptions.json
beside the simulator's device.plist, so they go with the simulator and no
global file holds them; an erase keeps them, as it keeps device.plist,
since `simctl erase` only resets the data directory. `on`, `watch`
(through EnableSlimNoReboot), and `verify` merge them into every profile,
and `clone` copies them, so a simulator with iCloud re-enabled keeps it
across reboots under a watch whose profile disables everything;
EnableSlimNoReboot also starts excepted daemons it finds disabled. `status`
and `status --json` list them, and `verify` names them.
A per-simulator lock (an flock on simslim.lock beside the exceptions) is
held by `on`, `enable`, `disable`, and each watch pass from the moment they
read the exceptions to their last transition. Without it, `enable` run right
after a boot raced the watch pass still slimming that boot: the pass had read
the exceptions before enable wrote them and stopped what enable started.
Reads of launchd state are retried with bounded pauses, as are later
transition passes, because launchd_sim answers error 124 ("Domain is tearing
down") for a few seconds around a boot or reboot.
`on --no-reboot` is not the live inverse of `enable`: it applies a whole
profile around the exceptions and leaves them unchanged, while `disable`
stops exactly what it is given and updates the exceptions.
Verified on an iPhone 17 simulator (iOS 27.0) under `watch --profile` with
an all-off profile: slim at boot (168/168); `enable icloud` in 16s with no
reboot, `doctor --requires icloud` ok; after a reboot the watch re-slimmed
147 daemons and iCloud stayed ok; `disable icloud` stopped its 21 daemons
in 14s with no reboot and brought `doctor` back to BROKEN. `verify` matched
the profile at every step. Re-run with the watch agent live: `enable`
straight after a boot now waits for the pass and iCloud stays ok after it,
20s later, and across a reboot.
* refactor: make enable/disable live-only and add --save
Drop the per-simulator exceptions file and its lock, as requested in
review: a profile is again the only description of a slim simulator, so
on, watch, verify, clone and status behave exactly as on main.
enable and disable now act on a booted simulator's running boot session
only, like on --no-reboot, and refuse a simulator that is not booted.
--save <profile.json> also adds the categories to the profile's except
and the daemons to its keep (or removes them), creating the file from the
default profile when missing, keeping order and other fields, skipping
duplicates, and writing atomically; on a shutdown simulator only the file
changes. disable --all is gone: on --no-reboot --profile returns to a
profile.
disable still keeps a daemon shared with another category whose labels
are all enabled right now (iCloud and the store share the AMS daemons),
reported in stillEnabled, now derived from the live state.
* fix: stop named daemons, save only after a live change, bound launchd spawns
- disable always stops a daemon named on its own; only a named category's
daemons are kept for another fully enabled category.
- --save validates the profile first and writes it only once the live change
succeeded, saying so when it did not.
- Launchd reads retry only launchd_sim's error 124, each attempt bounded by
SpawnTimeout; reads and per-label transitions set WaitDelay so a simctl
child holding the pipe cannot hang a timed-out spawn.
- An empty profile gets a clear error; the temporary profile is synced before
the rename; the README says what enable and disable each list.
* fix: retry a launchd read that ran out of time
A launchctl read bounded by SpawnTimeout can take longer than that on a
heavily loaded host; returning at once made enable fail where the same
read without a per-attempt bound used to just wait. Retry it like
launchd_sim's error 124, so a hung spawn still cannot block forever.
* docs: describe the read retry on timeout and rewrap long lines
155 lines
5.6 KiB
Go
155 lines
5.6 KiB
Go
package simslim
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// assertProfileFile compares a saved profile byte for byte.
|
|
func assertProfileFile(t *testing.T, path, want string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
if string(data) != want {
|
|
t.Errorf("%s =\n%s\nwant\n%s", filepath.Base(path), data, want)
|
|
}
|
|
}
|
|
|
|
// A missing file starts from the default profile, the one `on` applies
|
|
// without --profile, which excepts and keeps nothing.
|
|
func TestSaveServicesCreatesTheProfile(t *testing.T) {
|
|
useTestCategories(t)
|
|
path := filepath.Join(t.TempDir(), "dev.json")
|
|
if err := SaveServices(path, []string{"cloud"}, []string{"com.apple.mediad"}, true); err != nil {
|
|
t.Fatalf("SaveServices: %v", err)
|
|
}
|
|
assertProfileFile(t, path, "{\n \"except\": [\n \"cloud\"\n ],\n \"keep\": [\n \"com.apple.mediad\"\n ]\n}\n")
|
|
info, err := os.Stat(path)
|
|
if err != nil || info.Mode().Perm() != 0o644 {
|
|
t.Errorf("profile mode = %v (%v), want 0644", info.Mode().Perm(), err)
|
|
}
|
|
if entries, _ := os.ReadDir(filepath.Dir(path)); len(entries) != 1 {
|
|
t.Errorf("directory holds %d entries, want only the profile (no temporary file left)", len(entries))
|
|
}
|
|
|
|
path = filepath.Join(t.TempDir(), "empty.json")
|
|
if err := SaveServices(path, []string{"cloud"}, nil, false); err != nil {
|
|
t.Fatalf("SaveServices disable on a missing file: %v", err)
|
|
}
|
|
assertProfileFile(t, path, "{}\n")
|
|
}
|
|
|
|
// Adding keeps the existing entries, their order, and the other fields; new
|
|
// entries go at the end, and nothing is listed twice.
|
|
func TestSaveServicesAddsWithoutDuplicates(t *testing.T) {
|
|
useTestCategories(t)
|
|
path := filepath.Join(t.TempDir(), "ci.json")
|
|
if err := os.WriteFile(path, []byte(`{"name":"ci","description":"CI runs","except":["media"],"keep":["com.apple.xpc"]}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// media is already excepted, com.apple.shared already kept by media, and
|
|
// com.apple.xpc already in keep: only com.apple.lazy is new.
|
|
if err := SaveServices(path, []string{"media"}, []string{"com.apple.shared", "com.apple.xpc", "com.apple.lazy"}, true); err != nil {
|
|
t.Fatalf("SaveServices: %v", err)
|
|
}
|
|
if err := SaveServices(path, []string{"cloud"}, nil, true); err != nil {
|
|
t.Fatalf("SaveServices: %v", err)
|
|
}
|
|
want := `{
|
|
"name": "ci",
|
|
"description": "CI runs",
|
|
"except": [
|
|
"media",
|
|
"cloud"
|
|
],
|
|
"keep": [
|
|
"com.apple.xpc",
|
|
"com.apple.lazy"
|
|
]
|
|
}
|
|
`
|
|
assertProfileFile(t, path, want)
|
|
if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
|
|
t.Errorf("profile mode = %v, want the original 0600", info.Mode().Perm())
|
|
}
|
|
if _, err := LoadSlimProfile(path); err != nil {
|
|
t.Errorf("saved profile does not load: %v", err)
|
|
}
|
|
}
|
|
|
|
// Removing drops only what is named and keeps the rest in order.
|
|
func TestSaveServicesRemoves(t *testing.T) {
|
|
useTestCategories(t)
|
|
path := filepath.Join(t.TempDir(), "dev.json")
|
|
if err := os.WriteFile(path, []byte(`{"except":["media","cloud"],"keep":["com.apple.mediad","com.apple.xpc"]}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := SaveServices(path, []string{"media"}, []string{"com.apple.mediad"}, false); err != nil {
|
|
t.Fatalf("SaveServices: %v", err)
|
|
}
|
|
assertProfileFile(t, path, "{\n \"except\": [\n \"cloud\"\n ],\n \"keep\": [\n \"com.apple.xpc\"\n ]\n}\n")
|
|
|
|
// A daemon a still-excepted category keeps cannot be removed through keep.
|
|
err := SaveServices(path, nil, []string{"com.apple.cloudd"}, false)
|
|
if err == nil || !strings.Contains(err.Error(), `through category "cloud"`) {
|
|
t.Errorf("removing a daemon cloud keeps = %v, want an error naming cloud", err)
|
|
}
|
|
}
|
|
|
|
// Saving the same change twice leaves the file untouched the second time.
|
|
func TestSaveServicesIsIdempotent(t *testing.T) {
|
|
useTestCategories(t)
|
|
path := filepath.Join(t.TempDir(), "dev.json")
|
|
const original = `{"except": ["cloud"]}` // hand-formatted: a rewrite would reformat it
|
|
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, enable := range []bool{true, true} {
|
|
if err := SaveServices(path, []string{"cloud"}, []string{"com.apple.lazy"}, enable); err != nil {
|
|
t.Fatalf("SaveServices: %v", err)
|
|
}
|
|
}
|
|
assertProfileFile(t, path, original)
|
|
if err := SaveServices(path, []string{"media"}, nil, false); err != nil {
|
|
t.Fatalf("SaveServices: %v", err)
|
|
}
|
|
assertProfileFile(t, path, original)
|
|
}
|
|
|
|
// A profile that would not load is refused, not rewritten.
|
|
func TestSaveServicesRejectsAnInvalidProfile(t *testing.T) {
|
|
useTestCategories(t)
|
|
for name, contents := range map[string]string{
|
|
"unknown field": `{"exceptt": ["cloud"]}`,
|
|
"unknown category": `{"except": ["nope"]}`,
|
|
"not JSON": `{"except": [`,
|
|
} {
|
|
path := filepath.Join(t.TempDir(), "bad.json")
|
|
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := SaveServices(path, []string{"cloud"}, nil, true); err == nil {
|
|
t.Errorf("%s: SaveServices accepted %s", name, contents)
|
|
}
|
|
assertProfileFile(t, path, contents)
|
|
}
|
|
}
|
|
|
|
// An empty profile file is refused with an error that says so, not "EOF".
|
|
func TestEmptyProfileIsExplained(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "empty.json")
|
|
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := LoadSlimProfile(path); err == nil || !strings.Contains(err.Error(), "is empty") {
|
|
t.Errorf("LoadSlimProfile of an empty file = %v, want an is-empty error", err)
|
|
}
|
|
if err := SaveServices(path, []string{"siri"}, nil, true); err == nil || !strings.Contains(err.Error(), "is empty") {
|
|
t.Errorf("SaveServices on an empty file = %v, want an is-empty error", err)
|
|
}
|
|
}
|