diff --git a/AGENTS.md b/AGENTS.md index c36f0017..5277529e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,27 +111,31 @@ hydrates root and `packages/client` `node_modules` plus falling back to `npm ci` for missing package installs and ensuring Homebrew `pkgconf`/`x264` are available for native builds. Its Run action executes `npm run codex:run`, which builds the CLI and client, saves fresh caches, and -restarts the workspace-local daemon. +restarts the local service. -Run the local daemon: +Run the local service: ```sh ./build/simdeck -./build/simdeck daemon start --port 4311 +./build/simdeck -p 4311 ``` -Running without a subcommand starts a foreground workspace daemon, prints local and LAN HTTP URLs, prints a six-digit pairing code for LAN browsers, and stops when the command exits, when you press `q`, or when you press Ctrl-C. If the always-on service is active on 4310, running without a subcommand or running `simdeck ui` prints the existing service endpoints instead of starting a project daemon. Pass a simulator name or UDID as the only argument to select it by default in the UI. Use `./build/simdeck -d`, `./build/simdeck -k`, and `./build/simdeck -r` as detached start, kill, and restart shortcuts. +Running without a subcommand starts or reuses the background service, prints +local and LAN HTTP URLs, and prints a six-digit pairing code for LAN browsers. +Pass a simulator name or UDID as the only argument to select it by default in +the UI. Use `./build/simdeck -a` or `./build/simdeck pair` when the service +should be registered as a LaunchAgent. Use software H.264 when macOS screen recording starves the hardware encoder: ```sh -./build/simdeck daemon start --port 4311 --video-codec h264-software +./build/simdeck daemon restart --video-codec h264-software ``` For LAN access: ```sh -./build/simdeck daemon start --port 4311 --bind 0.0.0.0 --advertise-host 192.168.1.50 +./build/simdeck -p 4311 --bind 0.0.0.0 --advertise-host 192.168.1.50 ``` Useful direct commands: diff --git a/README.md b/README.md index 2933d7b5..66f46ec3 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,9 @@ To focus a specific simulator by name or UDID, pass it as the only argument: simdeck "iPhone 17 Pro Max" ``` -`simdeck -d` for detached start, `simdeck -k` to kill the background daemon, and `simdeck -r` to restart it. +Use `simdeck --open` to open the browser automatically, `simdeck -p 4311` to +use a non-default port, and `simdeck -a` to register the service for login +autostart. The served loopback browser UI receives the generated API access token automatically. LAN clients should pair with the printed code before receiving the API cookie. @@ -77,7 +79,7 @@ For pairing with SimDeck iOS app: simdeck pair ``` -This starts or refreshes the global LaunchAgent-backed SimDeck service, prints +This starts or refreshes the LaunchAgent-backed SimDeck service, prints local, LAN, and Tailscale URLs when available, and shows a QR code with a `simdeck://pair` link. The QR contains the pairing code plus all detected non-loopback addresses, so pairing once can save both the LAN and Tailscale @@ -85,12 +87,9 @@ routes with the same service token. Normal service restarts preserve that token so paired clients stay connected. Use `simdeck service reset` only when you want to rotate the service token and restart the LaunchAgent. -The LaunchAgent service uses port 4310. Project daemons start at port 4311 and -probe upward when that port is busy. When the service is active, `simdeck` and -`simdeck ui` print the existing service endpoints instead of starting a project -daemon; use the `daemon` subcommand when you explicitly want a workspace daemon. +The service uses port 4310 unless you pass `-p` or `--port`. -CLI commands automatically use the same warm daemon: +CLI commands automatically use the same warm service: ```sh simdeck list @@ -195,8 +194,8 @@ try { } ``` -`connect()` starts the project daemon when needed, reuses it when it is already -healthy, and only stops daemons it started itself. Pass `udid` to `connect()` +`connect()` starts the SimDeck service when needed and reuses it when it is +already healthy. Pass `udid` to `connect()` to make it the default for session methods; each method still accepts an explicit UDID as the first argument when needed. Query helpers such as `tree()`, `query()`, `waitFor()`, `assert()`, and selector `tapElement()` @@ -236,7 +235,7 @@ import "expo-router/entry"; Import it before `expo-router/entry` or `AppRegistry.registerComponent(...)` so the package can capture React Fiber commits. The auto entrypoint no-ops outside development, reads `EXPO_PUBLIC_SIMDECK_PORT` when present, and -otherwise scans common SimDeck daemon ports. +otherwise scans common SimDeck service ports. ## Flutter Inspector diff --git a/actions/run-android-comment-session/action.yml b/actions/run-android-comment-session/action.yml index 2e4b05ee..aa1a836c 100644 --- a/actions/run-android-comment-session/action.yml +++ b/actions/run-android-comment-session/action.yml @@ -338,8 +338,7 @@ runs: SIMDECK_REALTIME_MIN_BITRATE="${stream_min_bitrate}" \ SIMDECK_REALTIME_BITS_PER_PIXEL="${stream_bits_per_pixel}" \ SIMDECK_LOCAL_STREAM_FPS="${stream_fps}" \ - simdeck daemon run \ - --project-root "${GITHUB_WORKSPACE}" \ + simdeck service run \ --metadata-path "${metadata_path}" \ --port "${SIMDECK_PORT}" \ --bind 127.0.0.1 \ diff --git a/actions/run-ios-comment-session/action.yml b/actions/run-ios-comment-session/action.yml index dabcbe52..2f49765f 100644 --- a/actions/run-ios-comment-session/action.yml +++ b/actions/run-ios-comment-session/action.yml @@ -302,8 +302,7 @@ runs: SIMDECK_REALTIME_MIN_BITRATE="${stream_min_bitrate}" \ SIMDECK_REALTIME_BITS_PER_PIXEL="${stream_bits_per_pixel}" \ SIMDECK_LOCAL_STREAM_FPS="${stream_fps}" \ - simdeck daemon run \ - --project-root "${GITHUB_WORKSPACE}" \ + simdeck service run \ --metadata-path "${metadata_path}" \ --port "${SIMDECK_PORT}" \ --bind 127.0.0.1 \ diff --git a/docs/api/health.md b/docs/api/health.md index 5a44f9c5..d0395c40 100644 --- a/docs/api/health.md +++ b/docs/api/health.md @@ -1,6 +1,6 @@ # Health and metrics -Use these endpoints to check whether a daemon is reachable and to diagnose stream performance. +Use these endpoints to check whether the service is reachable and to diagnose stream performance. ## Health @@ -36,18 +36,18 @@ Example: Important fields: -| Field | Meaning | -| --------------- | --------------------------------------------------------- | -| `ok` | Server is alive | -| `serverId` | Stable identity for the current daemon token | -| `advertiseHost` | Host/IP the daemon advertises for non-local clients | -| `hostId` | Stable hashed identity for the Mac hardware host | -| `hostName` | Local host name for grouping LAN/Tailscale/Bonjour URLs | -| `httpPort` | Port serving UI and API | -| `serverKind` | `launchAgent`, `workspace`, `foreground`, or `standalone` | -| `videoCodec` | Requested codec mode: `auto`, `hardware`, or `software` | -| `streamQuality` | Active stream profile and limits | -| `webRtc` | ICE settings the browser should use | +| Field | Meaning | +| --------------- | ------------------------------------------------------- | +| `ok` | Server is alive | +| `serverId` | Stable identity for the current service token | +| `advertiseHost` | Host/IP the service advertises for non-local clients | +| `hostId` | Stable hashed identity for the Mac hardware host | +| `hostName` | Local host name for grouping LAN/Tailscale/Bonjour URLs | +| `httpPort` | Port serving UI and API | +| `serverKind` | `launchAgent` or `standalone` | +| `videoCodec` | Requested codec mode: `auto`, `hardware`, or `software` | +| `streamQuality` | Active stream profile and limits | +| `webRtc` | ICE settings the browser should use | When auth is required, the `401` JSON body still includes `serverId`, `advertiseHost`, `hostId`, `hostName`, `httpPort`, and `serverKind` so native clients can group endpoints before pairing. @@ -64,6 +64,10 @@ Useful fields: | `latest_first_frame_ms` | First-frame startup time | | `frames_dropped_server` | Server dropping stale frames to stay current | | `keyframe_requests` | Stream refresh or recovery activity | +| `stream_pipeline_resets` | Encoder resets after all viewers disconnect | +| `latest_accessibility_snapshot_ms` | Most recent native accessibility duration | +| `max_accessibility_snapshot_ms` | Slowest native accessibility duration | +| `accessibility_snapshot_timeouts` | Native accessibility calls that timed out | | `active_streams` | Open browser streams | | `encoders[].encoder.overloadState` | `nominal`, `strained`, or `overloaded` | | `client_streams` | Recent browser decoder and render reports | diff --git a/docs/api/rest.md b/docs/api/rest.md index 5b4e7f64..db7a41f1 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -6,7 +6,7 @@ Use the CLI for most automation. Use the API when you are building a custom clie ## Authentication -Browser sessions loaded from the SimDeck server receive auth automatically. Direct callers should send the daemon token from `simdeck daemon status`: +Browser sessions loaded from the SimDeck server receive auth automatically. Direct callers should send the service token from `simdeck daemon status`: ```text X-SimDeck-Token: diff --git a/docs/cli/commands.md b/docs/cli/commands.md index a036ef68..9e6c2452 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -2,40 +2,37 @@ Replace `simdeck` with `./build/simdeck` when running from a source checkout. -## UI and daemon +## UI and service | Command | Purpose | | -------------------------------- | ------------------------------------------- | -| `simdeck` | Start a foreground browser session | +| `simdeck` | Start or reuse the service and print URLs | | `simdeck ` | Start and select a device | -| `simdeck -d` | Start or reuse the detached project daemon | -| `simdeck -k` | Stop the detached project daemon | -| `simdeck -r` | Restart the detached project daemon | -| `simdeck ui --open` | Open the browser UI from a daemon | +| `simdeck --open` | Open the browser UI | +| `simdeck -p 4311` | Use a non-default service port | +| `simdeck -a` | Register the service for login autostart | | `simdeck pair` | Show native iOS pairing code and QR | -| `simdeck daemon status` | Show daemon URL, PID, token, and log path | -| `simdeck daemon stop` | Stop the current project daemon | -| `simdeck daemon killall` | Stop all project daemons | +| `simdeck daemon status` | Show service URL, PID, token, and log path | +| `simdeck daemon stop` | Stop the current background service | +| `simdeck daemon restart` | Restart the current background service | | `simdeck service on/off/restart` | Manage the optional always-on macOS service | Examples: ```sh -simdeck ui --port 4320 --open -simdeck ui --open +simdeck -p 4320 --open +simdeck --open simdeck pair simdeck daemon restart --video-codec software --stream-quality low ``` -`simdeck pair` uses the global LaunchAgent-backed service instead of a -project-local daemon. It binds the service for LAN access, preserves an existing -service token and pairing code when present, detects LAN and Tailscale IPv4 -addresses, and prints a `simdeck://pair` QR for the native iOS app. The service -uses port 4310; workspace daemons start at 4311 and probe upward. +`simdeck` starts or reuses one local service. It uses port 4310 unless you pass +`-p` or `--port`. Normal commands reuse that same warm service. -When the service is active, `simdeck` and `simdeck ui` print the existing -service endpoints instead of launching a project daemon. Use `simdeck daemon -start` or `simdeck daemon restart` when you explicitly want a workspace daemon. +`simdeck pair` installs or refreshes the LaunchAgent-backed service. It binds +the service for LAN access, preserves an existing service token and pairing code +when present, detects LAN and Tailscale IPv4 addresses, and prints a +`simdeck://pair` QR for the native iOS app. `simdeck service restart` also preserves the installed service token so native clients remain paired across service restarts. Use `simdeck service reset` to @@ -158,7 +155,7 @@ Use `wait-for` or `assert` steps instead of fixed sleeps when possible. ## Maestro YAML -Run common Maestro flows through SimDeck's daemon-backed iOS Simulator API: +Run common Maestro flows through SimDeck's service-backed iOS Simulator API: ```sh simdeck maestro test flow.yaml --artifacts-dir artifacts/maestro diff --git a/docs/cli/flags.md b/docs/cli/flags.md index 9d9ce4c3..f3421ec2 100644 --- a/docs/cli/flags.md +++ b/docs/cli/flags.md @@ -5,35 +5,36 @@ Pass `--help` to any command for the generated flag list: ```sh simdeck --help simdeck tap --help -simdeck daemon start --help +simdeck daemon restart --help ``` ## Global -| Flag | Env | Purpose | -| --------------------- | -------------------- | -------------------------------- | -| `--server-url ` | `SIMDECK_SERVER_URL` | Target a specific running daemon | -| `--device ` | `SIMDECK_DEVICE` | One-off simulator override | +| Flag | Env | Purpose | +| --------------------- | -------------------- | --------------------------------- | +| `--server-url ` | `SIMDECK_SERVER_URL` | Target a specific running service | +| `--device ` | `SIMDECK_DEVICE` | One-off simulator override | `SIMDECK_UDID` is also accepted for compatibility. Device commands resolve in this order: positional UDID, `--device`, `SIMDECK_DEVICE`, `SIMDECK_UDID`, the -project default from `simdeck use `, then auto-inference from the daemon. +project default from `simdeck use `, then auto-inference from the service. ## Server options -Used by `simdeck ui`, `daemon start`, `daemon restart`, `service on`, and `service restart`. - -| Flag | Default | Notes | -| ---------------------------- | -------------------------------------- | --------------------------------------------------------------------------------- | -| `--port ` | `4311` for daemons, `4310` for service | HTTP port. Daemons probe upward when busy | -| `--bind ` | `127.0.0.1` | Use `0.0.0.0` or `::` for LAN access | -| `--advertise-host ` | detected | Host printed for remote browsers | -| `--client-root ` | bundled client | Static client directory | -| `--video-codec ` | `auto` | `auto`, `hardware`, or `software` | -| `--stream-quality ` | `full` | `full`, `balanced`, `economy`, `low`, `tiny`, `ci-software`, and related profiles | -| `--local-stream-fps ` | `60` | Local stream frame target | -| `--low-latency` | off | Conservative software H.264 profile | -| `--open` | off | `ui` only | +Used by `simdeck`, `daemon start`, `daemon restart`, `service on`, and `service restart`. + +| Flag | Default | Notes | +| ---------------------------- | -------------- | --------------------------------------------------------------------------------- | +| `--port ` / `-p` | `4310` | HTTP port | +| `--bind ` | `127.0.0.1` | Use `0.0.0.0` or `::` for LAN access | +| `--advertise-host ` | detected | Host printed for remote browsers | +| `--client-root ` | bundled client | Static client directory | +| `--video-codec ` | `auto` | `auto`, `hardware`, or `software` | +| `--stream-quality ` | `full` | `full`, `balanced`, `economy`, `low`, `tiny`, `ci-software`, and related profiles | +| `--local-stream-fps ` | `60` | Local stream frame target | +| `--low-latency` | off | Conservative software H.264 profile | +| `--open` | off | Open the browser after starting the service | +| `--autostart` / `-a` | off | Register the service as a macOS LaunchAgent | ## `describe` diff --git a/docs/cli/index.md b/docs/cli/index.md index bbfaf0cb..49592551 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -1,23 +1,24 @@ # CLI -`simdeck` is the main entrypoint for opening the browser UI, managing the daemon, and scripting simulator actions. +`simdeck` is the main entrypoint for opening the browser UI, managing the local service, and scripting simulator actions. ## Common use ```sh simdeck simdeck "iPhone 17 Pro Max" -simdeck -d -simdeck -k -simdeck -r +simdeck --open +simdeck -p 4311 +simdeck -a ``` -With no subcommand, SimDeck starts a foreground server and prints browser URLs. A single simulator name or UDID selects that device in the UI. The shorthand flags start, stop, and restart the detached project daemon. +With no subcommand, SimDeck starts or reuses the background service and prints browser URLs. A single simulator name or UDID selects that device in the UI. ## Command shape ```sh simdeck [SIMULATOR_NAME_OR_UDID] +simdeck [-p ] [--open] [--autostart] simdeck [--server-url ] [options] ``` @@ -26,7 +27,7 @@ default for later device commands. Most commands accept `[]`; `--device`, `SIMDECK_DEVICE`, and `SIMDECK_UDID` override the saved project default when a one-off target is needed. -Use `--server-url` or `SIMDECK_SERVER_URL` when a script should target a specific daemon: +Use `--server-url` or `SIMDECK_SERVER_URL` when a script should target a specific service: ```sh SIMDECK_SERVER_URL=http://127.0.0.1:4310 simdeck list @@ -70,7 +71,7 @@ Most successful commands print JSON so they can be piped into tools such as `jq` ```sh simdeck --help simdeck tap --help -simdeck daemon start --help +simdeck daemon status ``` ## Next diff --git a/docs/extensions/browser-client.md b/docs/extensions/browser-client.md index 90ac6cb8..6933c95d 100644 --- a/docs/extensions/browser-client.md +++ b/docs/extensions/browser-client.md @@ -10,16 +10,16 @@ simdeck Then open the printed local URL. -Detached flow: +Open directly: ```sh -simdeck ui --open +simdeck --open ``` LAN flow: ```sh -simdeck ui --bind 0.0.0.0 --advertise-host 192.168.1.50 --open +simdeck --bind 0.0.0.0 --advertise-host 192.168.1.50 --open ``` ## What the UI shows @@ -45,10 +45,10 @@ Use the default URL for normal operation. ## Serve a custom client -Point the daemon at another static bundle: +Point the service at another static bundle: ```sh -simdeck ui --client-root /path/to/dist --open +simdeck --client-root /path/to/dist --open ``` Your client should use the [REST API](/api/rest), WebRTC offer endpoint, and control WebSocket documented in the API reference. diff --git a/docs/extensions/vscode.md b/docs/extensions/vscode.md index 4dd1096a..79901859 100644 --- a/docs/extensions/vscode.md +++ b/docs/extensions/vscode.md @@ -1,6 +1,6 @@ # VS Code extension -The VS Code extension opens the SimDeck browser UI in an editor panel and can start the project daemon for you. +The VS Code extension opens the SimDeck browser UI in an editor panel and can start the service for you. ## Install @@ -18,25 +18,25 @@ Run this command from the command palette: SimDeck: Open Simulator View ``` -The extension tries the configured server URL first. If it is not reachable and auto-start is enabled, it runs `simdeck ui` for the current workspace. +The extension tries the configured server URL first. If it is not reachable and auto-start is enabled, it runs `simdeck` for the current workspace. ## Commands | Command | Purpose | | ------------------------------ | ------------------------- | | `SimDeck: Open Simulator View` | Open the webview panel | -| `SimDeck: Stop Project Daemon` | Run `simdeck daemon stop` | +| `SimDeck: Stop Service` | Run `simdeck daemon stop` | | `SimDeck: Show Output` | Open extension logs | ## Settings -| Setting | Default | Purpose | -| ------------------------- | ----------------------- | ------------------------------------- | -| `simdeck.serverUrl` | `http://127.0.0.1:4310` | Preferred daemon URL | -| `simdeck.cliPath` | empty | Explicit path to the CLI | -| `simdeck.port` | `4311` | Port for auto-started project daemons | -| `simdeck.bindAddress` | `127.0.0.1` | Bind address for auto-start | -| `simdeck.autoStartDaemon` | `true` | Start the daemon when needed | +| Setting | Default | Purpose | +| ------------------------- | ----------------------- | ----------------------------- | +| `simdeck.serverUrl` | `http://127.0.0.1:4310` | Preferred service URL | +| `simdeck.cliPath` | empty | Explicit path to the CLI | +| `simdeck.port` | `4310` | Port for auto-started service | +| `simdeck.bindAddress` | `127.0.0.1` | Bind address for auto-start | +| `simdeck.autoStartDaemon` | `true` | Start the service when needed | CLI resolution order: @@ -59,8 +59,8 @@ Pair in the webview if the server asks for the LAN code. ## Troubleshooting -| Symptom | Fix | -| ------------------------ | ------------------------------------------------------------------- | -| Blank panel | Open `SimDeck: Show Output` and check the daemon URL and CLI stderr | -| Auto-start fails | Set `simdeck.cliPath` to the real CLI path | -| Old server keeps loading | Run `SimDeck: Stop Project Daemon`, then reopen | +| Symptom | Fix | +| ------------------------ | -------------------------------------------------------------------- | +| Blank panel | Open `SimDeck: Show Output` and check the service URL and CLI stderr | +| Auto-start fails | Set `simdeck.cliPath` to the real CLI path | +| Old server keeps loading | Run `SimDeck: Stop Service`, then reopen | diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 72092d66..6d164889 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -7,7 +7,7 @@ This page is a short mental model for users and contributors. For daily usage, s | Piece | What it does | | ------------------ | ------------------------------------------------------------------- | | CLI | Starts SimDeck and exposes scriptable commands | -| Local daemon | Serves the browser UI, API, streams, metrics, and inspector routing | +| Local service | Serves the browser UI, API, streams, metrics, and inspector routing | | Browser client | Shows the live device, toolbar, inspector panes, and diagnostics | | Native bridge | Handles simulator-specific work on macOS | | Inspector runtimes | Optional app packages that publish richer UI trees | @@ -17,12 +17,12 @@ This page is a short mental model for users and contributors. For daily usage, s Most user actions follow the same path: -1. Browser, CLI, or test sends a command to the daemon. -2. The daemon checks the selected device and starts a warm session when needed. +1. Browser, CLI, or test sends a command to the service. +2. The service checks the selected device and starts a warm session when needed. 3. SimDeck performs the requested simulator or emulator action. 4. The command returns JSON, a screenshot, a recording, logs, or updated stream state. -This is why a long-lived daemon feels faster than repeatedly calling lower-level simulator tools. +This is why a long-lived service feels faster than repeatedly calling lower-level simulator tools. ## Video flow @@ -48,7 +48,7 @@ The response tells you which source was used and why a requested source fell bac | Folder | Purpose | | ------------------------- | ------------------------------------------------- | -| `packages/server/` | CLI entrypoint, daemon, API, streaming, metrics | +| `packages/server/` | CLI entrypoint, service, API, streaming, metrics | | `packages/server/native/` | macOS simulator bridge | | `packages/client/` | Browser UI | | `packages/` | Inspectors, VS Code extension, and `simdeck/test` | diff --git a/docs/guide/daemon.md b/docs/guide/daemon.md index 0d8d0d76..18263e5c 100644 --- a/docs/guide/daemon.md +++ b/docs/guide/daemon.md @@ -1,84 +1,66 @@ -# Daemon +# Service -SimDeck runs a local server for the current project. The server owns the browser UI, REST API, live stream, inspector connections, and warm device sessions. +SimDeck runs one local service. The service owns the browser UI, REST API, live +stream, inspector connections, and warm device sessions. -Most commands start or reuse the project daemon automatically. Manage it directly only when you need a specific lifecycle. +Most commands start or reuse the service automatically. Use the lifecycle +commands only when you need to inspect, stop, or restart it explicitly. -## Foreground +## Start ```sh simdeck +simdeck --open +simdeck -p 4311 ``` -Use this for normal interactive work. It prints browser URLs and stops when you press `q` or Ctrl-C. +`simdeck` starts or reuses the background service and prints the browser URLs. +`--open` opens the local browser URL. `-p` or `--port` selects a non-default +port; the default is `4310`. -## Detached +## Autostart ```sh -simdeck -d -simdeck daemon start +simdeck -a +simdeck --autostart +simdeck pair ``` -Both start or reuse a background daemon for the current project. Detached mode is useful for tests, editor integrations, and scripts. +Without `-a`, SimDeck starts an ordinary background service for the current user +session. `-a`, `--autostart`, and `simdeck pair` install or refresh the macOS +LaunchAgent so SimDeck starts again after login. + +`simdeck pair` also detects LAN and Tailscale addresses, prints the pairing +code, and renders the QR/deep link for the native iOS app. + +## Manage ```sh simdeck daemon status simdeck daemon stop simdeck daemon restart -simdeck daemon killall -``` - -`daemon killall` stops SimDeck project daemons from every workspace. - -## Open the browser UI - -```sh -simdeck ui --open -``` - -This starts or reuses the daemon, then opens the authenticated local URL. - -## Common server options - -`simdeck ui`, `daemon start`, `daemon restart`, and `service restart` use the same core options: - -| Flag | Default | Use it when | -| ---------------------------- | -------------------------------------- | ------------------------------------------ | -| `--port ` | `4311` for daemons, `4310` for service | The default port is busy | -| `--bind ` | `127.0.0.1` | You need LAN access with `0.0.0.0` or `::` | -| `--advertise-host ` | detected | Remote browsers need a specific host or IP | -| `--video-codec ` | `auto` | You need to force encoder behavior | -| `--stream-quality ` | `full` | You want lower CPU or bandwidth use | -| `--local-stream-fps ` | `60` | You want a different local stream target | -| `--client-root ` | bundled client | You are serving a custom static client | - -Example: - -```sh -simdeck daemon start --port 4320 --video-codec software --stream-quality low -``` - -## Always-on service - -Use the macOS user service when SimDeck should be reachable after login without starting a project daemon first: - -```sh -simdeck service on -simdeck service restart --port 4310 simdeck service reset simdeck service off ``` -The LaunchAgent service uses port 4310. Workspace daemons start at 4311 and -probe upward when the requested daemon port is busy. When the service is active, -`simdeck` and `simdeck ui` report the service endpoints instead of launching a -project daemon. +`daemon status`, `daemon stop`, and `daemon restart` manage the same singleton +service that `simdeck` uses. `service reset` rotates the LaunchAgent token and +pairing code. `service off` removes the LaunchAgent. + +## Options -`service on`, `service restart`, and `simdeck pair` preserve the installed -service token and pairing code. Use `service reset` when you explicitly want to -rotate those credentials and restart the LaunchAgent. +These options are accepted by `simdeck`, `daemon start`, `daemon restart`, +`service on`, and `service restart`: -Prefer the project daemon for normal repository work. Use the service for long-lived agent or editor setups. +| Flag | Default | Use it when | +| ---------------------------- | ----------- | ------------------------------------------ | +| `--port ` / `-p` | `4310` | You want a specific service port | +| `--bind ` | `127.0.0.1` | You need LAN access with `0.0.0.0` or `::` | +| `--advertise-host ` | detected | Remote browsers need a specific host or IP | +| `--video-codec ` | `auto` | You need to force encoder behavior | +| `--stream-quality ` | `full` | You want lower CPU or bandwidth use | +| `--local-stream-fps ` | `60` | You want a different local stream target | +| `--client-root ` | bundled UI | You are serving a custom static client | ## Restart CoreSimulator diff --git a/docs/guide/index.md b/docs/guide/index.md index 30bab93f..82ac5733 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -2,7 +2,7 @@ SimDeck is a local tool for viewing, controlling, inspecting, and automating mobile simulators. -Run `simdeck` from your project. It starts a local server, serves a browser UI, and exposes the same controls through the CLI and HTTP API. +Run `simdeck` from your project. It starts or reuses the local service, serves a browser UI, and exposes the same controls through the CLI and HTTP API. ## Core workflows @@ -36,13 +36,13 @@ simdeck back simdeck describe --format agent --max-depth 3 --interactive ``` -Use `simdeck -d` for a detached background daemon, `simdeck -k` to stop it, and `simdeck -r` to restart it. +Use `simdeck --open` to open the browser, `simdeck -p 4311` for a custom port, and `simdeck -a` to enable login autostart. ## Pick a page - [Install](/guide/installation): requirements and setup. - [Quick start](/guide/quick-start): first browser session. -- [Daemon](/guide/daemon): foreground, detached, and always-on modes. +- [Service](/guide/daemon): local service, autostart, and pairing. - [Video and streaming](/guide/video): stream quality and codec choices. - [LAN access](/guide/lan-access): pairing and remote browser access. - [Testing](/guide/testing): `simdeck/test` and integration tests. diff --git a/docs/guide/lan-access.md b/docs/guide/lan-access.md index 221252b5..dcc86a5f 100644 --- a/docs/guide/lan-access.md +++ b/docs/guide/lan-access.md @@ -5,7 +5,7 @@ SimDeck binds to `127.0.0.1` by default. Bind to a LAN address when another devi ## Start a LAN session ```sh -simdeck ui \ +simdeck \ --bind 0.0.0.0 \ --advertise-host 192.168.1.50 \ --open @@ -24,9 +24,9 @@ Enter the pairing code printed by the CLI. After pairing, the browser receives t Use an IP address or hostname that the remote device can resolve: ```sh -simdeck ui --bind 0.0.0.0 --advertise-host my-mac.local --open -simdeck ui --bind 0.0.0.0 --advertise-host 192.168.1.50 --open -simdeck ui --bind 0.0.0.0 --advertise-host 100.101.102.103 --open +simdeck --bind 0.0.0.0 --advertise-host my-mac.local --open +simdeck --bind 0.0.0.0 --advertise-host 192.168.1.50 --open +simdeck --bind 0.0.0.0 --advertise-host 100.101.102.103 --open ``` If you bind to `0.0.0.0` but advertise `localhost`, remote browsers will try to connect to themselves. @@ -52,9 +52,9 @@ Authorization: Bearer ## Security checklist - Use LAN mode only on networks you trust. -- Treat the daemon token as a secret. +- Treat the service token as a secret. - Restrict the port with macOS Firewall when needed. -- Stop the daemon when the shared session is done: +- Stop the service when the shared session is done: ```sh simdeck daemon stop @@ -65,6 +65,6 @@ Authorization: Bearer | Symptom | Fix | | ----------------------------- | ----------------------------------------------------------------- | | Remote browser cannot connect | Confirm `--bind 0.0.0.0`, firewall rules, and the advertised host | -| Pairing code is rejected | Restart the daemon and use the newly printed code | +| Pairing code is rejected | Restart the service and use the newly printed code | | Stream connects but stutters | Use `--stream-quality low` or `--video-codec software` | | API script gets `401` | Send `X-SimDeck-Token` or `Authorization: Bearer ` | diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index a79d5c4b..c5c51f8b 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -9,14 +9,14 @@ simdeck SimDeck prints a local browser URL, a LAN URL when one is available, and a pairing code for LAN browsers. ```text -SimDeck is ready +SimDeck is running -Local: http://127.0.0.1:4311 -Network: http://192.168.1.50:4311 +Local: http://127.0.0.1:4310 +Network: http://192.168.1.50:4310 Pair: 123 456 ``` -Open the local URL. Press `q` or Ctrl-C in the terminal to stop the foreground server. +Open the local URL. SimDeck keeps the service warm in the background. To open a specific simulator by name or UDID: @@ -81,15 +81,18 @@ one of those elements directly. `snapshot`, `press`, and `wait` are aliases for `describe`, `tap`, and `wait-for`. Add `--expect-*` to a tap when the next screen should be present before the command returns. -## 5. Keep it running in the background +## 5. Keep it available ```sh -simdeck -d -simdeck -k -simdeck -r +simdeck --open +simdeck -p 4311 +simdeck -a +simdeck pair ``` -These are shortcuts for detached start, stop, and restart. See [Daemon](/guide/daemon) for details. +`-a` registers the service as a macOS LaunchAgent. `pair` also enables the +LaunchAgent and prints the native iOS pairing QR. See [Service](/guide/daemon) +for details. ## Next diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 4edf60ab..04bf8a8d 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -25,7 +25,10 @@ try { } ``` -`connect()` starts the project daemon if needed, reuses a healthy daemon, and only stops daemons it started itself. Pass `udid` to `connect()` to make it the default for session methods; methods still accept an explicit UDID as their first argument. Use `sim.device("")` to create a session bound to another simulator. +`connect()` starts the service if needed and reuses a healthy service. Pass +`udid` to `connect()` to make it the default for session methods; methods still +accept an explicit UDID as their first argument. Use `sim.device("")` +to create a session bound to another simulator. State-query helpers default to `source: "native-ax"` so agent loops use the fast universal accessibility path. Pass `source: "auto"` when a test intentionally wants connected NativeScript, React Native, Flutter, SwiftUI, or @@ -121,7 +124,7 @@ Setup/reset work is excluded from action timings. | `SIMDECK_INTEGRATION_BOOT_ANDROID=1` | Let SimDeck boot the Android emulator | | `SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID=1` | Fail instead of skipping when Android is unavailable | -## Stress test a running daemon +## Stress test a running service ```sh npm run test:stress -- --server-url http://127.0.0.1:4310 --iterations 1000 --concurrency 12 @@ -133,14 +136,14 @@ Include simulator refresh traffic: npm run test:stress -- --udid --iterations 2000 --concurrency 16 ``` -## Stress test daemon cleanup +## Stress test service cleanup ```sh npm run build:cli npm run test:stress:daemon -- --iterations 30 --concurrency 3 ``` -This starts isolated temporary project daemons, hits health and metrics, stops -them through the CLI, and verifies the process group, listener port, and daemon +This starts isolated temporary services, hits health and metrics, stops +them through the CLI, and verifies the process group, listener port, and service status are cleaned up. Use `--binary /path/to/simdeck` to test an installed or packaged binary. diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index a22d55ee..070add0e 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -11,7 +11,7 @@ simdeck daemon status simdeck list ``` -If a background daemon may be stale: +If the background service may be stale: ```sh simdeck daemon stop @@ -23,22 +23,22 @@ simdeck ### Port is already in use ```text -bind HTTP listener on 127.0.0.1:4311 +bind HTTP listener on 127.0.0.1:4310 ``` Use another port: ```sh -simdeck ui --port 4320 --open +simdeck -p 4320 --open ``` Or find the listener: ```sh -lsof -nP -iTCP:4311 -sTCP:LISTEN +lsof -nP -iTCP:4310 -sTCP:LISTEN ``` -If it is an old project daemon: +If it is an old service: ```sh simdeck daemon stop @@ -186,7 +186,7 @@ Run a debug build with widget creation tracking. Flutter enables this by default Start SimDeck with a LAN bind and reachable advertised host: ```sh -simdeck ui --bind 0.0.0.0 --advertise-host 192.168.1.50 --open +simdeck --bind 0.0.0.0 --advertise-host 192.168.1.50 --open ``` For native iOS pairing, prefer: @@ -199,8 +199,8 @@ Then check: - The remote browser opens `http://192.168.1.50:4310`. - macOS Firewall allows the port. -- The pairing code matches the current daemon or global service. -- API scripts send the daemon or service token. +- The pairing code matches the current service. +- API scripts send the service token. See [LAN access](/guide/lan-access). @@ -212,5 +212,5 @@ Include: - macOS version - Xcode version - The command you ran -- Foreground daemon output, or `build/cli.log` when using `npm run dev` +- Service output, or `build/cli.log` when using `npm run dev` - `simdeck daemon status` without sharing the token publicly diff --git a/docs/guide/video.md b/docs/guide/video.md index 11928a7e..10717a78 100644 --- a/docs/guide/video.md +++ b/docs/guide/video.md @@ -115,6 +115,7 @@ Signals worth watching: | `latest_first_frame_ms` | How long the most recent viewer waited for the first frame | | `frames_dropped_server` | The server skipped frames to keep the stream fresh | | `keyframe_requests` | The client or server requested stream recovery | +| `stream_pipeline_resets` | Encoder resets after the last viewer disconnects | | `encoders[].encoder.overloadState` | Encoder pressure: `nominal`, `strained`, or `overloaded` | ## Stuck stream checklist diff --git a/docs/inspector/react-native.md b/docs/inspector/react-native.md index acff8899..4951abd4 100644 --- a/docs/inspector/react-native.md +++ b/docs/inspector/react-native.md @@ -67,5 +67,5 @@ Nodes may include: - Import the auto entrypoint before app registration. - Use a development build for source locations. -- Set `EXPO_PUBLIC_SIMDECK_PORT=4310` if auto port scanning cannot find the daemon. +- Set `EXPO_PUBLIC_SIMDECK_PORT=4310` if auto port scanning cannot find the service. - Bring the app to the foreground before inspecting. diff --git a/packages/cli/bin/simdeck.mjs b/packages/cli/bin/simdeck.mjs index baa5a462..b09da05c 100755 --- a/packages/cli/bin/simdeck.mjs +++ b/packages/cli/bin/simdeck.mjs @@ -11,7 +11,9 @@ const launcherDir = path.dirname(fileURLToPath(import.meta.url)); const packageRoot = findPackageRoot(launcherDir); const binaryPath = path.join(packageRoot, "build", "simdeck-bin"); const childArgs = process.argv.slice(2); -const isDaemonRun = childArgs[0] === "daemon" && childArgs[1] === "run"; +const isServiceRun = + (childArgs[0] === "daemon" || childArgs[0] === "service") && + childArgs[1] === "run"; if (process.platform !== "darwin") { console.error("simdeck only supports macOS."); @@ -52,7 +54,7 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { } function spawnChild() { - const env = isDaemonRun + const env = isServiceRun ? { ...process.env, SIMDECK_DAEMON_METADATA_PID: String(process.pid), @@ -72,7 +74,7 @@ function spawnChild() { child.on("exit", (code, signal) => { if ( - isDaemonRun && + isServiceRun && !terminating && (code === RECOVERABLE_RESTART_EXIT_CODE || signal) ) { diff --git a/packages/client/src/app/AppShell.tsx b/packages/client/src/app/AppShell.tsx index 450476e3..ea545d47 100644 --- a/packages/client/src/app/AppShell.tsx +++ b/packages/client/src/app/AppShell.tsx @@ -51,6 +51,7 @@ import { shouldRenderNativeChrome, simulatorHasFixedOrientation, simulatorRuntimeLabel, + simulatorUsesInsetChromeButtons, } from "../features/simulators/simulatorDisplay"; import { useSimulatorList } from "../features/simulators/useSimulatorList"; import { sendWebRtcControlMessage } from "../features/stream/streamWorkerClient"; @@ -147,7 +148,7 @@ const STREAM_TRANSPORT_VALUES = new Set([ "webrtc", ]); const MOBILE_VIEWPORT_MEDIA_QUERY = "(max-width: 600px)"; -const CHROME_RENDERER_ASSET_VERSION = "chrome-renderer-watch-bezel-inset-22"; +const CHROME_RENDERER_ASSET_VERSION = "chrome-renderer-button-overlay-23"; clearLegacyVolatileUiState(); interface StreamQualityResponse { @@ -1015,6 +1016,9 @@ export function AppShell({ const chromeHasInteractiveButtons = Boolean( viewportChromeProfile?.buttons?.length, ); + const chromeUsesButtonOverlay = + chromeHasInteractiveButtons && + simulatorUsesInsetChromeButtons(selectedSimulator); const chromeHasCrown = Boolean( viewportChromeProfile?.buttons?.some( (button) => @@ -1033,14 +1037,23 @@ export function AppShell({ selectedSimulator?.udid, chromeGeometryStamp, CHROME_RENDERER_ASSET_VERSION, - chromeHasInteractiveButtons ? "baked-buttons" : "no-buttons", + chromeUsesButtonOverlay + ? "overlay-buttons" + : chromeHasInteractiveButtons + ? "baked-buttons" + : "no-buttons", chromeHasCrown ? "crown" : "no-crown", ] .filter(Boolean) .join(":"); - const chromeButtonsRenderedInChrome = chromeHasInteractiveButtons; + const chromeButtonsRenderedInChrome = + chromeHasInteractiveButtons && !chromeUsesButtonOverlay; const chromeUrl = selectedSimulator - ? buildChromeUrl(selectedSimulator.udid, chromeAssetStamp, true) + ? buildChromeUrl( + selectedSimulator.udid, + chromeAssetStamp, + chromeButtonsRenderedInChrome, + ) : ""; const chromeButtonUrl = useCallback( (button: string, pressed = false) => diff --git a/packages/client/src/features/simulators/simulatorDisplay.test.ts b/packages/client/src/features/simulators/simulatorDisplay.test.ts index 69aacdf6..4965e490 100644 --- a/packages/client/src/features/simulators/simulatorDisplay.test.ts +++ b/packages/client/src/features/simulators/simulatorDisplay.test.ts @@ -5,6 +5,7 @@ import { shouldRenderNativeChrome, simulatorHasFixedOrientation, simulatorRuntimeLabel, + simulatorUsesInsetChromeButtons, } from "./simulatorDisplay"; function simulator( @@ -94,4 +95,31 @@ describe("simulatorDisplay", () => { ), ).toBe(false); }); + + it("uses inset overlay chrome buttons only for iPhone and iPad simulators", () => { + expect( + simulatorUsesInsetChromeButtons( + simulator({ + deviceTypeIdentifier: + "com.apple.CoreSimulator.SimDeviceType.iPhone-17", + }), + ), + ).toBe(true); + expect( + simulatorUsesInsetChromeButtons( + simulator({ + deviceTypeIdentifier: + "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M4", + }), + ), + ).toBe(true); + expect( + simulatorUsesInsetChromeButtons( + simulator({ + deviceTypeIdentifier: + "com.apple.CoreSimulator.SimDeviceType.Apple-Watch-Ultra-3-49mm", + }), + ), + ).toBe(false); + }); }); diff --git a/packages/client/src/features/simulators/simulatorDisplay.ts b/packages/client/src/features/simulators/simulatorDisplay.ts index 8cc3ad34..d1fa89bf 100644 --- a/packages/client/src/features/simulators/simulatorDisplay.ts +++ b/packages/client/src/features/simulators/simulatorDisplay.ts @@ -47,6 +47,16 @@ export function simulatorHasFixedOrientation( ); } +export function simulatorUsesInsetChromeButtons( + simulator: SimulatorMetadata | null, +): boolean { + if (!simulator || simulator.platform === "android-emulator") { + return false; + } + const metadata = simulatorMetadataText(simulator); + return metadata.includes("iphone") || metadata.includes("ipad"); +} + function simulatorMetadataText(simulator: SimulatorMetadata): string { return [ simulator.name, diff --git a/packages/client/src/features/viewport/DeviceChrome.test.ts b/packages/client/src/features/viewport/DeviceChrome.test.ts new file mode 100644 index 00000000..12a52478 --- /dev/null +++ b/packages/client/src/features/viewport/DeviceChrome.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import type { ChromeButtonProfile } from "../../api/types"; +import { chromeButtonMotionVariables } from "./DeviceChrome"; + +describe("chromeButtonMotionVariables", () => { + it("rests halfway inward, hovers at the original button position, and presses farther inward", () => { + const button: ChromeButtonProfile = { + name: "side-button", + x: 100, + y: 20, + width: 10, + height: 20, + normalOffset: { x: 0, y: 0 }, + rolloverOffset: { x: 4, y: -2 }, + }; + + expect(chromeButtonMotionVariables(button)).toEqual({ + "--button-rest-x": "-20%", + "--button-rest-y": "5%", + "--button-hover-x": "0%", + "--button-hover-y": "0%", + "--button-pressed-x": "-34%", + "--button-pressed-y": "8.5%", + }); + }); + + it("keeps buttons without a rollover offset stationary", () => { + const button: ChromeButtonProfile = { + name: "home", + x: 0, + y: 0, + width: 44, + height: 44, + }; + + expect(chromeButtonMotionVariables(button)).toEqual({ + "--button-rest-x": "0%", + "--button-rest-y": "0%", + "--button-hover-x": "0%", + "--button-hover-y": "0%", + "--button-pressed-x": "0%", + "--button-pressed-y": "0%", + }); + }); +}); diff --git a/packages/client/src/features/viewport/DeviceChrome.tsx b/packages/client/src/features/viewport/DeviceChrome.tsx index 37333df4..f9c13212 100644 --- a/packages/client/src/features/viewport/DeviceChrome.tsx +++ b/packages/client/src/features/viewport/DeviceChrome.tsx @@ -225,6 +225,44 @@ const CHROME_BUTTON_WIRE_NAMES: Record = { "volume-down": "volume-down", "volume-up": "volume-up", }; +const CHROME_BUTTON_REST_INSET_RATIO = 0.5; +const CHROME_BUTTON_PRESSED_INSET_RATIO = 0.85; + +export function chromeButtonMotionVariables(button: ChromeButtonProfile) { + const normalOffset = button.normalOffset ?? { x: 0, y: 0 }; + const rolloverOffset = button.rolloverOffset ?? normalOffset; + const inwardDelta = { + x: normalOffset.x - rolloverOffset.x, + y: normalOffset.y - rolloverOffset.y, + }; + const restOffset = { + x: inwardDelta.x * CHROME_BUTTON_REST_INSET_RATIO, + y: inwardDelta.y * CHROME_BUTTON_REST_INSET_RATIO, + }; + const pressedOffset = { + x: inwardDelta.x * CHROME_BUTTON_PRESSED_INSET_RATIO, + y: inwardDelta.y * CHROME_BUTTON_PRESSED_INSET_RATIO, + }; + const width = Math.max(button.width, 1); + const height = Math.max(button.height, 1); + + return { + "--button-rest-x": `${(restOffset.x / width) * 100}%`, + "--button-rest-y": `${(restOffset.y / height) * 100}%`, + "--button-hover-x": "0%", + "--button-hover-y": "0%", + "--button-pressed-x": `${(pressedOffset.x / width) * 100}%`, + "--button-pressed-y": `${(pressedOffset.y / height) * 100}%`, + } as Record< + | "--button-rest-x" + | "--button-rest-y" + | "--button-hover-x" + | "--button-hover-y" + | "--button-pressed-x" + | "--button-pressed-y", + string + >; +} function ChromeButtonOverlay({ chromeButtonUrl, @@ -305,12 +343,6 @@ function ChromeButtonHitTarget({ const pressedRef = useRef(false); const [pressed, setPressed] = useState(false); const label = button.label || humanizeChromeButtonName(button.name); - const rolloverDelta = button.rolloverOffset - ? { - x: button.rolloverOffset.x - (button.normalOffset?.x ?? 0), - y: button.rolloverOffset.y - (button.normalOffset?.y ?? 0), - } - : { x: 0, y: 0 }; const imageUrl = chromeButtonUrl(button.name, false); const pressedImageUrl = button.imageDownName ? chromeButtonUrl(button.name, true) @@ -324,12 +356,7 @@ function ChromeButtonHitTarget({ left: `${(button.x / totalWidth) * 100}%`, top: `${(button.y / totalHeight) * 100}%`, width: `${(button.width / totalWidth) * 100}%`, - "--button-rest-x": "0%", - "--button-rest-y": "0%", - "--button-hover-x": `${(rolloverDelta.x / Math.max(button.width, 1)) * 100}%`, - "--button-hover-y": `${(rolloverDelta.y / Math.max(button.height, 1)) * 100}%`, - "--button-pressed-x": `${(-rolloverDelta.x / Math.max(button.width, 1)) * 100}%`, - "--button-pressed-y": `${(-rolloverDelta.y / Math.max(button.height, 1)) * 100}%`, + ...chromeButtonMotionVariables(button), } as CSSProperties & Record< | "--button-rest-x" diff --git a/packages/client/src/styles/components.css b/packages/client/src/styles/components.css index 499bbcd6..69dadbde 100644 --- a/packages/client/src/styles/components.css +++ b/packages/client/src/styles/components.css @@ -2414,8 +2414,9 @@ a.hierarchy-node-source:hover, pointer-events: auto; touch-action: none; transform: translate3d(var(--button-rest-x, 0), var(--button-rest-y, 0), 0); - transition: transform 130ms cubic-bezier(0.2, 0.8, 0.2, 1); + transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1); -webkit-tap-highlight-color: transparent; + will-change: transform; z-index: 1; } @@ -2453,6 +2454,7 @@ a.hierarchy-node-source:hover, var(--button-pressed-y, var(--button-rest-y, 0)), 0 ); + transition-duration: 90ms; } .pan-enabled .device-bezel, diff --git a/packages/server/src/api/routes.rs b/packages/server/src/api/routes.rs index deb05faf..d456f987 100644 --- a/packages/server/src/api/routes.rs +++ b/packages/server/src/api/routes.rs @@ -72,6 +72,7 @@ const ACCESSIBILITY_SOURCE_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(2 const ACCESSIBILITY_TREE_CACHE_TTL: Duration = Duration::from_secs(5); const NATIVE_AX_SNAPSHOT_RETRY_ATTEMPTS: usize = 5; const NATIVE_AX_SNAPSHOT_RETRY_DELAY: Duration = Duration::from_millis(100); +const NATIVE_AX_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(8); static FOREGROUND_APP_CACHE: OnceLock>> = OnceLock::new(); @@ -5835,10 +5836,29 @@ async fn accessibility_snapshot_with_options( max_depth: Option, interactive_only: bool, ) -> Result { - run_bridge_action(state, move |bridge| { + let bridge = state.registry.bridge().clone(); + let metrics = state.metrics.clone(); + let started = Instant::now(); + let task = task::spawn_blocking(move || { bridge.accessibility_snapshot_with_options(&udid, point, max_depth, interactive_only) - }) - .await + }); + let result = match timeout(NATIVE_AX_SNAPSHOT_TIMEOUT, task).await { + Ok(Ok(result)) => result, + Ok(Err(error)) => Err(AppError::internal(format!( + "Failed to join native accessibility snapshot task: {error}" + ))), + Err(_) => Err(AppError::native(format!( + "Native accessibility snapshot timed out after {}ms.", + NATIVE_AX_SNAPSHOT_TIMEOUT.as_millis() + ))), + }; + let duration_ms = started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64; + metrics.record_accessibility_snapshot( + duration_ms, + result.is_ok(), + duration_ms >= NATIVE_AX_SNAPSHOT_TIMEOUT.as_millis() as u64, + ); + result } #[cfg(test)] diff --git a/packages/server/src/main.rs b/packages/server/src/main.rs index 06b12226..b75c3941 100644 --- a/packages/server/src/main.rs +++ b/packages/server/src/main.rs @@ -66,22 +66,40 @@ const SERVER_HEALTH_WATCHDOG_STALE_HEARTBEAT: Duration = Duration::from_secs(60) const SERVER_HEALTH_WATCHDOG_FAILURE_THRESHOLD: usize = 12; const SERVER_HEALTH_WATCHDOG_HTTP_FAILURE_THRESHOLD: usize = 3; const SERVICE_PORT: u16 = 4310; -const DAEMON_PORT_START: u16 = 4311; +const DAEMON_PORT_START: u16 = SERVICE_PORT; #[derive(Parser)] #[command(name = "simdeck")] #[command(bin_name = "simdeck")] #[command(about = "Project-local iOS Simulator devtool")] #[command( - override_usage = "simdeck [SIMULATOR_NAME_OR_UDID]\n simdeck [-d|--detached]\n simdeck [-k|--kill]\n simdeck [-r|--restart]\n simdeck [OPTIONS]" + override_usage = "simdeck [OPTIONS] [SIMULATOR_NAME_OR_UDID]\n simdeck [OPTIONS]" )] #[command( - after_help = "Run without a subcommand to start a foreground workspace daemon. Pass a simulator name or UDID as the only argument to select it in the UI. Use -d/--detached, -k/--kill, or -r/--restart for shorthand daemon lifecycle commands." + after_help = "Run without a subcommand to start or reuse the background SimDeck service and print its URLs. Pass a simulator name or UDID as the only argument to select it in the UI." )] #[command(version)] struct Cli { #[arg(long, global = true, hide = true)] server_url: Option, + #[arg( + short = 'p', + long, + value_name = "PORT", + help = "When run without a subcommand, start or reuse the service on this port" + )] + port: Option, + #[arg( + short = 'a', + long, + help = "When run without a subcommand, register the service as a LaunchAgent" + )] + autostart: bool, + #[arg( + long, + help = "When run without a subcommand, open the service URL in the default browser" + )] + open: bool, #[arg( long, global = true, @@ -95,26 +113,6 @@ struct Cli { #[derive(Subcommand)] enum Command { - Ui { - #[arg(long, default_value_t = DAEMON_PORT_START)] - port: u16, - #[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))] - bind: IpAddr, - #[arg(long)] - advertise_host: Option, - #[arg(long)] - client_root: Option, - #[arg(long, value_enum, default_value_t = VideoCodecMode::Auto)] - video_codec: VideoCodecMode, - #[arg(long)] - low_latency: bool, - #[arg(long, value_enum)] - stream_quality: Option, - #[arg(long, value_parser = clap::value_parser!(u32).range(15..=240))] - local_stream_fps: Option, - #[arg(long)] - open: bool, - }, Pair { #[arg( long, @@ -154,31 +152,6 @@ enum Command { #[command(subcommand)] command: MaestroCommand, }, - #[command(hide = true)] - Serve { - #[arg(long, default_value_t = SERVICE_PORT)] - port: u16, - #[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))] - bind: IpAddr, - #[arg(long)] - advertise_host: Option, - #[arg(long)] - client_root: Option, - #[arg(long, value_enum, default_value_t = VideoCodecMode::Auto)] - video_codec: VideoCodecMode, - #[arg(long)] - low_latency: bool, - #[arg(long, value_enum)] - stream_quality: Option, - #[arg(long, value_parser = clap::value_parser!(u32).range(15..=240))] - local_stream_fps: Option, - #[arg(long)] - access_token: Option, - #[arg(long)] - pairing_code: Option, - #[arg(long, hide = true, value_enum, default_value_t = ServerKindArg::Standalone)] - server_kind: ServerKindArg, - }, Service { #[command(subcommand)] command: ServiceCommand, @@ -571,35 +544,6 @@ enum DaemonCommand { Stop, Killall, Status, - #[command(hide = true)] - Run { - #[arg(long)] - project_root: PathBuf, - #[arg(long)] - metadata_path: PathBuf, - #[arg(long)] - port: u16, - #[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))] - bind: IpAddr, - #[arg(long)] - advertise_host: Option, - #[arg(long)] - client_root: Option, - #[arg(long, value_enum, default_value_t = VideoCodecMode::Auto)] - video_codec: VideoCodecMode, - #[arg(long)] - low_latency: bool, - #[arg(long, value_enum)] - stream_quality: Option, - #[arg(long, value_parser = clap::value_parser!(u32).range(15..=240))] - local_stream_fps: Option, - #[arg(long)] - access_token: String, - #[arg(long)] - pairing_code: Option, - #[arg(long, hide = true, value_enum, default_value_t = ServerKindArg::Workspace)] - server_kind: ServerKindArg, - }, } #[derive(Subcommand)] @@ -738,6 +682,33 @@ enum ServiceCommand { access_token: Option, }, Off, + #[command(hide = true)] + Run { + #[arg(long)] + metadata_path: Option, + #[arg(long, default_value_t = SERVICE_PORT)] + port: u16, + #[arg(long, default_value_t = IpAddr::V4(Ipv4Addr::LOCALHOST))] + bind: IpAddr, + #[arg(long)] + advertise_host: Option, + #[arg(long)] + client_root: Option, + #[arg(long, value_enum, default_value_t = VideoCodecMode::Auto)] + video_codec: VideoCodecMode, + #[arg(long)] + low_latency: bool, + #[arg(long, value_enum)] + stream_quality: Option, + #[arg(long, value_parser = clap::value_parser!(u32).range(15..=240))] + local_stream_fps: Option, + #[arg(long)] + access_token: Option, + #[arg(long)] + pairing_code: Option, + #[arg(long, hide = true, value_enum, default_value_t = ServerKindArg::Standalone)] + server_kind: ServerKindArg, + }, } #[derive(Subcommand)] @@ -952,6 +923,30 @@ impl VideoCodecMode { } } +fn parse_video_codec_mode(value: &str) -> Option { + match value { + "auto" => Some(VideoCodecMode::Auto), + "hardware" => Some(VideoCodecMode::Hardware), + "software" | "h264-software" => Some(VideoCodecMode::Software), + _ => None, + } +} + +fn parse_stream_quality_profile(value: &str) -> Option { + match value { + "quality" => Some(StreamQualityProfileArg::Quality), + "full" => Some(StreamQualityProfileArg::Full), + "balanced" => Some(StreamQualityProfileArg::Balanced), + "fast" => Some(StreamQualityProfileArg::Fast), + "smooth" => Some(StreamQualityProfileArg::Smooth), + "economy" => Some(StreamQualityProfileArg::Economy), + "low" => Some(StreamQualityProfileArg::Low), + "tiny" => Some(StreamQualityProfileArg::Tiny), + "ci-software" => Some(StreamQualityProfileArg::CiSoftware), + _ => None, + } +} + struct StreamQualityEnvironment { profile: &'static str, max_edge: u32, @@ -1079,7 +1074,15 @@ fn command_service_url(explicit: Option<&str>) -> anyhow::Result { { return Ok(url); } - Ok(ensure_project_daemon(DaemonLaunchOptions::default())?.http_url) + if let Some(result) = service::active()? { + return Ok(http_url_for_host("127.0.0.1", result.port)); + } + if let Some(metadata) = read_daemon_metadata().ok().flatten() { + if daemon_is_healthy(&metadata) { + return Ok(metadata.http_url); + } + } + Ok(ensure_singleton_service(DaemonLaunchOptions::default())?.http_url) } fn command_service_url_for_udid( @@ -1111,28 +1114,80 @@ impl Default for DaemonLaunchOptions { } fn ensure_project_daemon(options: DaemonLaunchOptions) -> anyhow::Result { - Ok(ensure_project_daemon_with_status(options)?.0) + ensure_singleton_service(options) } fn ensure_project_daemon_with_status( options: DaemonLaunchOptions, ) -> anyhow::Result<(DaemonMetadata, bool)> { + ensure_singleton_service_with_status(options) +} + +fn ensure_singleton_service(options: DaemonLaunchOptions) -> anyhow::Result { + Ok(ensure_singleton_service_with_status(options)?.0) +} + +fn ensure_singleton_service_with_status( + options: DaemonLaunchOptions, +) -> anyhow::Result<(DaemonMetadata, bool)> { + if let Some(result) = service::active()? { + let metadata = metadata_from_launch_agent(result)?; + if metadata.port == options.port { + return Ok((metadata, false)); + } + let result = service::pair(ServiceOptions { + port: options.port, + bind: options.bind, + advertise_host: options.advertise_host.clone(), + client_root: options.client_root.clone(), + video_codec: options.video_codec, + low_latency: options.low_latency, + stream_quality_profile: options.stream_quality_profile.clone(), + local_stream_fps: options.local_stream_fps, + access_token: Some(metadata.access_token.clone()), + pairing_code: metadata.pairing_code.clone(), + })?; + let metadata = metadata_from_launch_agent(result)?; + wait_for_daemon(&metadata, Duration::from_secs(15))?; + return Ok((metadata, true)); + } + if let Some(metadata) = read_daemon_metadata().ok().flatten() { if daemon_is_healthy(&metadata) && daemon_matches_launch_options(&metadata, &options) { - cleanup_orphaned_workspace_daemons_for_root(Some(&metadata.project_root)); return Ok((metadata, false)); } let _ = terminate_daemon_metadata(&metadata); } - let project_root = project_root()?; - cleanup_orphaned_workspace_daemons_for_root(Some(&project_root)); Ok((start_project_daemon(options)?, true)) } +fn ensure_launch_agent_service(options: DaemonLaunchOptions) -> anyhow::Result { + if let Some(metadata) = read_daemon_metadata().ok().flatten() { + if daemon_is_healthy(&metadata) { + let _ = terminate_daemon_metadata(&metadata); + } + } + let result = service::pair(ServiceOptions { + port: options.port, + bind: options.bind, + advertise_host: options.advertise_host.clone(), + client_root: options.client_root.clone(), + video_codec: options.video_codec, + low_latency: options.low_latency, + stream_quality_profile: options.stream_quality_profile.clone(), + local_stream_fps: options.local_stream_fps, + access_token: None, + pairing_code: None, + })?; + let metadata = metadata_from_launch_agent(result)?; + wait_for_daemon(&metadata, Duration::from_secs(15))?; + Ok(metadata) +} + fn start_project_daemon(options: DaemonLaunchOptions) -> anyhow::Result { let project_root = project_root()?; - let metadata_path = daemon_metadata_path_for_root(&project_root)?; - let log_path = daemon_log_path_for_root(&project_root)?; + let metadata_path = daemon_metadata_path()?; + let log_path = daemon_log_path()?; if let Some(parent) = log_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("create daemon log directory {}", parent.display()))?; @@ -1142,10 +1197,8 @@ fn start_project_daemon(options: DaemonLaunchOptions) -> anyhow::Result anyhow::Result anyhow::Result<()> { + if service::active()?.is_some() { + return service::disable(); + } let Some(metadata) = read_daemon_metadata()? else { println_json(&serde_json::json!({ "ok": true, "running": false }))?; return Ok(()); @@ -1304,7 +1360,7 @@ fn stop_project_daemon() -> anyhow::Result<()> { fn terminate_daemon_metadata(metadata: &DaemonMetadata) -> anyhow::Result<()> { terminate_process_group(metadata.pid, Duration::from_secs(5)); - let _ = fs::remove_file(daemon_metadata_path_for_root(&metadata.project_root)?); + let _ = fs::remove_file(daemon_metadata_path()?); Ok(()) } @@ -1512,19 +1568,19 @@ fn process_exists(pid: u32) -> bool { .is_ok_and(|status| status.success()) } -fn remove_daemon_metadata_if_current(root: &Path, pid: u32) -> anyhow::Result<()> { - let path = daemon_metadata_path_for_root(root)?; - let should_remove = fs::read_to_string(&path) - .ok() - .and_then(|data| serde_json::from_str::(&data).ok()) - .is_some_and(|metadata| metadata.pid == pid); - if should_remove { - let _ = fs::remove_file(path); - } - Ok(()) -} - fn daemon_status() -> anyhow::Result<()> { + if let Some(result) = service::active()? { + let metadata = metadata_from_launch_agent(result)?; + let healthy = daemon_is_healthy(&metadata); + println_json(&serde_json::json!({ + "running": healthy, + "healthy": healthy, + "processRunning": true, + "stale": false, + "daemon": metadata, + }))?; + return Ok(()); + } let metadata = read_daemon_metadata()?; let process_running = metadata .as_ref() @@ -1532,9 +1588,7 @@ fn daemon_status() -> anyhow::Result<()> { let healthy = metadata.as_ref().is_some_and(daemon_is_healthy); let stale = metadata.is_some() && !process_running && !healthy; if stale { - if let Some(metadata) = metadata.as_ref() { - let _ = fs::remove_file(daemon_metadata_path_for_root(&metadata.project_root)?); - } + let _ = fs::remove_file(daemon_metadata_path()?); } println_json(&serde_json::json!({ "running": healthy, @@ -1557,41 +1611,45 @@ fn print_daemon_start_result(metadata: &DaemonMetadata, started: bool) -> anyhow })) } -fn print_existing_service_endpoints( - result: service::ServiceInstallResult, +fn print_service_metadata_result( + metadata: &DaemonMetadata, selector: Option<&str>, - open: bool, json: bool, ) -> anyhow::Result<()> { - let target = PairingTarget::from_service(result)?; - let local_url = ui_url("127.0.0.1", target.port, selector); - let addresses: Vec = pairing_addresses(&target) - .into_iter() - .map(|address| PairingAddress { - kind: address.kind, - url: ui_url_from_base(address.url, selector), - }) - .collect(); - - if open { - open_browser(&local_url)?; + let local_url = ui_url_from_base(metadata.http_url.clone(), selector); + let mut addresses = vec![PairingAddress { + kind: "local", + url: local_url.clone(), + }]; + if let Some(host) = metadata + .advertise_host + .as_deref() + .filter(|host| !host.trim().is_empty() && *host != "127.0.0.1") + { + addresses.push(PairingAddress { + kind: if is_tailscale_host(host) { + "tailscale" + } else { + "lan" + }, + url: ui_url(host, metadata.port, selector), + }); } if json { println_json(&serde_json::json!({ "ok": true, - "target": target.target, - "service": target.service, "url": local_url, + "pid": metadata.pid, "started": false, - "serverId": target.server_id, - "pairingCode": target.pairing_code, + "serverId": auth::server_identity_for_token(&metadata.access_token), + "pairingCode": metadata.pairing_code, "addresses": addresses, }))?; return Ok(()); } - println!("SimDeck service is already running"); + println!("SimDeck is running"); println!(); for address in &addresses { let label = match address.kind { @@ -1602,14 +1660,38 @@ fn print_existing_service_endpoints( }; println!("{:>12} {}", label, address.url); } - println!( - "{:>12} {}", - "Pair:", - format_pairing_code(&target.pairing_code) - ); + if let Some(pairing_code) = metadata.pairing_code.as_deref() { + println!("{:>12} {}", "Pair:", format_pairing_code(pairing_code)); + } Ok(()) } +fn metadata_from_launch_agent( + result: service::ServiceInstallResult, +) -> anyhow::Result { + Ok(DaemonMetadata { + project_root: project_root()?, + pid: 0, + http_url: http_url_for_host("127.0.0.1", result.port), + port: result.port, + bind: IpAddr::V4(Ipv4Addr::LOCALHOST), + advertise_host: result.advertise_host, + client_root: None, + access_token: result + .access_token + .context("SimDeck service did not publish an access token")?, + pairing_code: result.pairing_code, + binary_path: env::current_exe().context("resolve simdeck executable")?, + started_at: now_secs(), + log_path: Some(result.stdout_log), + video_codec: None, + low_latency: false, + realtime_stream: true, + stream_quality_profile: None, + local_stream_fps: None, + }) +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct PairingAddress { @@ -1853,8 +1935,7 @@ fn daemon_matches_launch_options(metadata: &DaemonMetadata, options: &DaemonLaun } fn daemon_port_matches_launch_options(actual: u16, preferred: u16) -> bool { - let start = preferred.max(1024); - actual >= start && actual < start.saturating_add(200) + actual == preferred.max(1024) } fn read_daemon_metadata() -> anyhow::Result> { @@ -1869,7 +1950,7 @@ fn read_daemon_metadata() -> anyhow::Result> { } fn write_daemon_metadata(metadata: &DaemonMetadata) -> anyhow::Result<()> { - let path = daemon_metadata_path_for_root(&metadata.project_root)?; + let path = daemon_metadata_path()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } @@ -1878,23 +1959,11 @@ fn write_daemon_metadata(metadata: &DaemonMetadata) -> anyhow::Result<()> { } fn daemon_metadata_path() -> anyhow::Result { - daemon_metadata_path_for_root(&project_root()?) + Ok(simdeck_user_state_dir().join("service.json")) } -fn daemon_metadata_path_for_root(root: &Path) -> anyhow::Result { - let mut hasher = DefaultHasher::new(); - root.to_string_lossy().hash(&mut hasher); - Ok(env::temp_dir() - .join("simdeck") - .join(format!("{:016x}.json", hasher.finish()))) -} - -fn daemon_log_path_for_root(root: &Path) -> anyhow::Result { - let mut hasher = DefaultHasher::new(); - root.to_string_lossy().hash(&mut hasher); - Ok(env::temp_dir() - .join("simdeck") - .join(format!("{:016x}.log", hasher.finish()))) +fn daemon_log_path() -> anyhow::Result { + Ok(simdeck_user_state_dir().join("service.log")) } fn read_project_device_selection() -> anyhow::Result> { @@ -1984,13 +2053,11 @@ fn project_root() -> anyhow::Result { } fn choose_daemon_port_for_bind(preferred: u16, bind: IpAddr) -> anyhow::Result { - let start = preferred.max(1024); - for port in start..start.saturating_add(200) { - if port_available(bind, port) { - return Ok(port); - } + let port = preferred.max(1024); + if port_available(bind, port) { + return Ok(port); } - anyhow::bail!("No available SimDeck daemon port near {preferred}") + anyhow::bail!("SimDeck service port {port} is already in use") } fn port_available(bind: IpAddr, port: u16) -> bool { @@ -2009,24 +2076,130 @@ fn open_browser(url: &str) -> anyhow::Result<()> { } enum NoCommandAction { - Foreground(Option), - Detached, - Kill, - Restart, + Service(DefaultServiceLaunchOptions), +} + +#[derive(Clone, Debug)] +struct DefaultServiceLaunchOptions { + selector: Option, + port: u16, + bind: IpAddr, + advertise_host: Option, + client_root: Option, + video_codec: VideoCodecMode, + low_latency: bool, + stream_quality: Option, + local_stream_fps: Option, + open: bool, + autostart: bool, + port_explicit: bool, +} + +impl Default for DefaultServiceLaunchOptions { + fn default() -> Self { + Self { + selector: None, + port: SERVICE_PORT, + bind: IpAddr::V4(Ipv4Addr::LOCALHOST), + advertise_host: None, + client_root: None, + video_codec: VideoCodecMode::Auto, + low_latency: false, + stream_quality: None, + local_stream_fps: None, + open: false, + autostart: false, + port_explicit: false, + } + } } fn no_command_action_from_args() -> Option { let args: Vec = env::args().skip(1).collect(); - match args.as_slice() { - [] => Some(NoCommandAction::Foreground(None)), - [flag] if flag == "-d" || flag == "--detached" => Some(NoCommandAction::Detached), - [flag] if flag == "-k" || flag == "--kill" => Some(NoCommandAction::Kill), - [flag] if flag == "-r" || flag == "--restart" => Some(NoCommandAction::Restart), - [selector] if !selector.starts_with('-') && !is_known_command(selector) => { - Some(NoCommandAction::Foreground(Some(selector.clone()))) + if args + .first() + .is_some_and(|arg| is_known_command(arg) || arg == "ui" || arg == "serve") + { + return None; + } + let mut options = DefaultServiceLaunchOptions::default(); + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + match arg.as_str() { + "-p" | "--port" => { + i += 1; + options.port = args.get(i)?.parse().ok()?; + options.port_explicit = true; + } + value if value.starts_with("--port=") => { + options.port = value.strip_prefix("--port=")?.parse().ok()?; + options.port_explicit = true; + } + "--bind" => { + i += 1; + options.bind = args.get(i)?.parse().ok()?; + } + value if value.starts_with("--bind=") => { + options.bind = value.strip_prefix("--bind=")?.parse().ok()?; + } + "--advertise-host" => { + i += 1; + options.advertise_host = args.get(i).cloned(); + } + value if value.starts_with("--advertise-host=") => { + options.advertise_host = Some(value.strip_prefix("--advertise-host=")?.to_owned()); + } + "--client-root" => { + i += 1; + options.client_root = args.get(i).map(PathBuf::from); + } + value if value.starts_with("--client-root=") => { + options.client_root = Some(PathBuf::from(value.strip_prefix("--client-root=")?)); + } + "--video-codec" => { + i += 1; + options.video_codec = parse_video_codec_mode(args.get(i)?)?; + } + value if value.starts_with("--video-codec=") => { + options.video_codec = + parse_video_codec_mode(value.strip_prefix("--video-codec=")?)?; + } + "--stream-quality" => { + i += 1; + options.stream_quality = Some(parse_stream_quality_profile(args.get(i)?)?); + } + value if value.starts_with("--stream-quality=") => { + options.stream_quality = Some(parse_stream_quality_profile( + value.strip_prefix("--stream-quality=")?, + )?); + } + "--local-stream-fps" => { + i += 1; + let fps = args.get(i)?.parse().ok()?; + if !(15..=240).contains(&fps) { + return None; + } + options.local_stream_fps = Some(fps); + } + value if value.starts_with("--local-stream-fps=") => { + let fps = value.strip_prefix("--local-stream-fps=")?.parse().ok()?; + if !(15..=240).contains(&fps) { + return None; + } + options.local_stream_fps = Some(fps); + } + "-a" | "--autostart" => options.autostart = true, + "--open" => options.open = true, + "--low-latency" => options.low_latency = true, + selector if !selector.starts_with('-') && options.selector.is_none() => { + options.selector = Some(selector.to_owned()); + } + _ => return None, } - _ => None, + i += 1; } + Some(NoCommandAction::Service(options)) } fn is_known_command(value: &str) -> bool { @@ -2081,19 +2254,55 @@ fn is_known_command(value: &str) -> bool { fn run_no_command_action(action: NoCommandAction) -> anyhow::Result<()> { match action { - NoCommandAction::Foreground(selector) => { - let selector = selector.or_else(|| { - read_project_device_selection() - .ok() - .flatten() - .map(|selection| selection.udid) - }); - run_foreground_ui(selector) + NoCommandAction::Service(options) => run_default_service(options), + } +} + +fn run_default_service(options: DefaultServiceLaunchOptions) -> anyhow::Result<()> { + let selector = options.selector.or_else(|| { + read_project_device_selection() + .ok() + .flatten() + .map(|selection| selection.udid) + }); + let launch_options = DaemonLaunchOptions { + port: options.port, + bind: options.bind, + advertise_host: options.advertise_host, + client_root: options.client_root, + video_codec: options.video_codec, + low_latency: options.low_latency, + realtime_stream: false, + stream_quality_profile: local_stream_quality_profile( + options.low_latency, + options.stream_quality, + ), + local_stream_fps: options.local_stream_fps, + }; + let metadata = if !options.port_explicit && !options.autostart { + if let Some(result) = service::active()? { + metadata_from_launch_agent(result)? + } else if let Some(metadata) = read_daemon_metadata().ok().flatten() { + if daemon_is_healthy(&metadata) { + metadata + } else { + ensure_singleton_service(launch_options)? + } + } else { + ensure_singleton_service(launch_options)? } - NoCommandAction::Detached => start_detached_daemon(DaemonLaunchOptions::default()), - NoCommandAction::Kill => stop_project_daemon(), - NoCommandAction::Restart => restart_detached_daemon(DaemonLaunchOptions::default()), + } else if options.autostart { + ensure_launch_agent_service(launch_options)? + } else { + ensure_singleton_service(launch_options)? + }; + if options.open { + open_browser(&ui_url_from_base( + metadata.http_url.clone(), + selector.as_deref(), + ))?; } + print_service_metadata_result(&metadata, selector.as_deref(), false) } fn start_detached_daemon(options: DaemonLaunchOptions) -> anyhow::Result<()> { @@ -2102,6 +2311,20 @@ fn start_detached_daemon(options: DaemonLaunchOptions) -> anyhow::Result<()> { } fn restart_detached_daemon(options: DaemonLaunchOptions) -> anyhow::Result<()> { + if service::active()?.is_some() { + return service::restart(ServiceOptions { + port: options.port, + bind: options.bind, + advertise_host: options.advertise_host, + client_root: options.client_root, + video_codec: options.video_codec, + low_latency: options.low_latency, + stream_quality_profile: options.stream_quality_profile, + local_stream_fps: options.local_stream_fps, + access_token: None, + pairing_code: None, + }); + } if let Some(metadata) = read_daemon_metadata()? { terminate_daemon_metadata(&metadata)?; } @@ -2193,78 +2416,6 @@ fn pair_global_service(options: PairGlobalServiceOptions) -> anyhow::Result<()> print_pairing_result(&target, !reused, json) } -fn run_foreground_ui(selector: Option) -> anyhow::Result<()> { - if let Some(result) = service::active()? { - return print_existing_service_endpoints(result, selector.as_deref(), false, false); - } - - if let Some(metadata) = read_daemon_metadata().ok().flatten() { - if daemon_is_healthy(&metadata) { - terminate_daemon_metadata(&metadata)?; - } - } - - let project_root = project_root()?; - let bind = IpAddr::V4(Ipv4Addr::UNSPECIFIED); - let port = choose_daemon_port_for_bind(DAEMON_PORT_START, bind)?; - let video_codec = VideoCodecMode::Auto; - let low_latency = false; - let stream_quality_profile = Some(DEFAULT_LOCAL_STREAM_QUALITY_PROFILE.to_owned()); - let advertise_host = detect_lan_ip() - .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)) - .to_string(); - let access_token = auth::generate_access_token(); - let pairing_code = auth::generate_pairing_code(); - let executable = env::current_exe().context("resolve simdeck executable")?; - let metadata = DaemonMetadata { - project_root: project_root.clone(), - pid: std::process::id(), - http_url: format!("http://127.0.0.1:{port}"), - port, - bind, - advertise_host: Some(advertise_host.clone()), - client_root: None, - access_token: access_token.clone(), - pairing_code: Some(pairing_code.clone()), - binary_path: executable, - started_at: now_secs(), - log_path: None, - video_codec: Some(video_codec.as_env_value().to_owned()), - low_latency, - realtime_stream: true, - stream_quality_profile: stream_quality_profile.clone(), - local_stream_fps: None, - }; - write_daemon_metadata(&metadata)?; - - let local_url = ui_url("127.0.0.1", port, selector.as_deref()); - let network_url = ui_url(&advertise_host, port, selector.as_deref()); - println!("🚀 SimDeck is ready"); - println!(); - println!("{:>12} {local_url}", "Local:"); - println!("{:>12} {network_url}", "Network:"); - println!("{:>12} {}", "Pair:", format_pairing_code(&pairing_code)); - println!(); - println!("q or ^C to stop server"); - let _ = io::stdout().flush(); - - let result = serve_with_appkit( - port, - bind, - Some(advertise_host), - None, - video_codec, - low_latency, - stream_quality_profile, - None, - ServerKind::Foreground, - Some(access_token), - Some(pairing_code), - ); - let _ = remove_daemon_metadata_if_current(&project_root, std::process::id()); - result -} - fn supervised_daemon_metadata_pid() -> Option { env::var(SUPERVISED_DAEMON_METADATA_PID_ENV) .ok() @@ -2331,6 +2482,10 @@ fn is_tailscale_ip(ip: IpAddr) -> bool { } } +fn is_tailscale_host(host: &str) -> bool { + host.parse::().is_ok_and(is_tailscale_ip) +} + fn http_url_for_host(host: &str, port: u16) -> String { let host = host.trim(); if host.contains(':') && !host.starts_with('[') && !host.ends_with(']') { @@ -3020,6 +3175,7 @@ fn main() -> anyhow::Result<()> { } let cli = Cli::parse(); + let _default_service_flags = (cli.port, cli.autostart, cli.open); let explicit_server_url = cli.server_url.clone(); let device_selector = cli.device.clone(); let service_url = explicit_server_url @@ -3036,37 +3192,6 @@ fn main() -> anyhow::Result<()> { }; match cli.command { - Command::Ui { - port, - bind, - advertise_host, - client_root, - video_codec, - low_latency, - stream_quality, - local_stream_fps, - open, - } => { - if let Some(result) = service::active()? { - return print_existing_service_endpoints(result, None, open, true); - } - let (metadata, started) = ensure_project_daemon_with_status(DaemonLaunchOptions { - port, - bind, - advertise_host, - client_root, - video_codec, - low_latency, - realtime_stream: false, - stream_quality_profile: local_stream_quality_profile(low_latency, stream_quality), - local_stream_fps, - })?; - if open { - open_browser(&metadata.http_url)?; - } - print_daemon_start_result(&metadata, started)?; - Ok(()) - } Command::Pair { port, bind, @@ -3138,67 +3263,6 @@ fn main() -> anyhow::Result<()> { DaemonCommand::Stop => stop_project_daemon(), DaemonCommand::Killall => kill_all_project_daemons(), DaemonCommand::Status => daemon_status(), - DaemonCommand::Run { - project_root, - metadata_path, - port, - bind, - advertise_host, - client_root, - video_codec, - low_latency, - stream_quality, - local_stream_fps, - access_token, - pairing_code, - server_kind, - } => { - if let Some(local_stream_fps) = local_stream_fps { - env::set_var("SIMDECK_LOCAL_STREAM_FPS", local_stream_fps.to_string()); - } - if let Some(stream_quality) = stream_quality { - apply_stream_quality_environment(stream_quality.as_profile_id())?; - } - env::set_current_dir(&project_root).with_context(|| { - format!("set daemon project root to {}", project_root.display()) - })?; - let log_path = daemon_log_path_for_root(&project_root).ok(); - write_daemon_metadata(&DaemonMetadata { - project_root, - pid: supervised_daemon_metadata_pid().unwrap_or_else(std::process::id), - http_url: format!("http://127.0.0.1:{port}"), - port, - bind, - advertise_host: advertise_host.clone(), - client_root: client_root.clone(), - access_token: access_token.clone(), - pairing_code: pairing_code.clone(), - binary_path: env::current_exe().context("resolve daemon executable")?, - started_at: now_secs(), - log_path, - video_codec: Some(video_codec.as_env_value().to_owned()), - low_latency, - realtime_stream: crate::transport::webrtc::realtime_stream_enabled() - || low_latency, - stream_quality_profile: env::var("SIMDECK_STREAM_QUALITY_PROFILE").ok(), - local_stream_fps, - })?; - let result = serve_with_appkit( - port, - bind, - advertise_host, - client_root, - video_codec, - low_latency, - env::var("SIMDECK_STREAM_QUALITY_PROFILE").ok(), - local_stream_fps, - server_kind.into(), - Some(access_token), - pairing_code, - ); - let _ = fs::remove_file(metadata_path); - result - } }, Command::Studio { command } => match command { StudioCommand::Expose { @@ -3246,31 +3310,6 @@ fn main() -> anyhow::Result<()> { } } }, - Command::Serve { - port, - bind, - advertise_host, - client_root, - video_codec, - low_latency, - stream_quality, - local_stream_fps, - access_token, - pairing_code, - server_kind, - } => serve_with_appkit( - port, - bind, - advertise_host, - client_root, - video_codec, - low_latency, - local_stream_quality_profile(low_latency, stream_quality), - local_stream_fps, - server_kind.into(), - access_token, - pairing_code, - ), Command::Service { command } => match command { ServiceCommand::On { port, @@ -3357,6 +3396,68 @@ fn main() -> anyhow::Result<()> { }) } ServiceCommand::Off => service::disable(), + ServiceCommand::Run { + metadata_path, + port, + bind, + advertise_host, + client_root, + video_codec, + low_latency, + stream_quality, + local_stream_fps, + access_token, + pairing_code, + server_kind, + } => { + if let Some(local_stream_fps) = local_stream_fps { + env::set_var("SIMDECK_LOCAL_STREAM_FPS", local_stream_fps.to_string()); + } + let stream_quality_profile = + local_stream_quality_profile(low_latency, stream_quality); + let access_token = access_token.unwrap_or_else(auth::generate_access_token); + let pairing_code = pairing_code.or_else(|| Some(auth::generate_pairing_code())); + let project_root = project_root()?; + if let Some(path) = metadata_path.as_ref() { + write_daemon_metadata(&DaemonMetadata { + project_root, + pid: supervised_daemon_metadata_pid().unwrap_or_else(std::process::id), + http_url: format!("http://127.0.0.1:{port}"), + port, + bind, + advertise_host: advertise_host.clone(), + client_root: client_root.clone(), + access_token: access_token.clone(), + pairing_code: pairing_code.clone(), + binary_path: env::current_exe().context("resolve service executable")?, + started_at: now_secs(), + log_path: daemon_log_path().ok(), + video_codec: Some(video_codec.as_env_value().to_owned()), + low_latency, + realtime_stream: crate::transport::webrtc::realtime_stream_enabled() + || low_latency + || stream_quality_profile.is_some(), + stream_quality_profile: stream_quality_profile.clone(), + local_stream_fps, + })?; + if path != &daemon_metadata_path()? { + let _ = fs::copy(daemon_metadata_path()?, path); + } + } + serve_with_appkit( + port, + bind, + advertise_host, + client_root, + video_codec, + low_latency, + stream_quality_profile, + local_stream_fps, + server_kind.into(), + Some(access_token), + pairing_code, + ) + } }, Command::CoreSimulator { command } => match command { CoreSimulatorCommand::Start => core_simulator::start(), @@ -5649,6 +5750,12 @@ mod tests { ); } + #[test] + fn removed_public_ui_and_serve_commands_are_rejected() { + assert!(Cli::try_parse_from(["simdeck", "ui"]).is_err()); + assert!(Cli::try_parse_from(["simdeck", "serve"]).is_err()); + } + #[test] fn screenshot_accepts_bezel_capture_flag() { let cli = Cli::parse_from(["simdeck", "screenshot", "SIM-1", "--with-bezel"]); @@ -5884,11 +5991,11 @@ mod tests { } #[test] - fn daemon_launch_options_accept_probed_port() { + fn daemon_launch_options_reject_probed_port() { let metadata = daemon_metadata_for_test(4313, "127.0.0.1", None, None); let options = daemon_launch_options_for_test(4311, "127.0.0.1", None, None); - assert!(daemon_matches_launch_options(&metadata, &options)); + assert!(!daemon_matches_launch_options(&metadata, &options)); } #[test] diff --git a/packages/server/src/metrics/counters.rs b/packages/server/src/metrics/counters.rs index fb78c30a..2ef135b4 100644 --- a/packages/server/src/metrics/counters.rs +++ b/packages/server/src/metrics/counters.rs @@ -18,6 +18,13 @@ pub struct Metrics { pub subscribers_disconnected: AtomicU64, pub max_send_queue_depth: AtomicU64, pub latest_first_frame_ms: AtomicU64, + pub stream_pipeline_resets: AtomicU64, + pub accessibility_snapshots: AtomicU64, + pub accessibility_snapshot_errors: AtomicU64, + pub accessibility_snapshot_timeouts: AtomicU64, + pub accessibility_snapshot_slow: AtomicU64, + pub latest_accessibility_snapshot_ms: AtomicU64, + pub max_accessibility_snapshot_ms: AtomicU64, client_stream_stats: Mutex>, } @@ -34,6 +41,13 @@ pub struct MetricsSnapshot { pub avg_send_queue_depth: f64, pub max_send_queue_depth: u64, pub latest_first_frame_ms: u64, + pub stream_pipeline_resets: u64, + pub accessibility_snapshots: u64, + pub accessibility_snapshot_errors: u64, + pub accessibility_snapshot_timeouts: u64, + pub accessibility_snapshot_slow: u64, + pub latest_accessibility_snapshot_ms: u64, + pub max_accessibility_snapshot_ms: u64, pub client_streams: Vec, } @@ -117,10 +131,44 @@ impl Metrics { avg_send_queue_depth: 1.0, max_send_queue_depth: self.max_send_queue_depth.load(Ordering::Relaxed), latest_first_frame_ms: self.latest_first_frame_ms.load(Ordering::Relaxed), + stream_pipeline_resets: self.stream_pipeline_resets.load(Ordering::Relaxed), + accessibility_snapshots: self.accessibility_snapshots.load(Ordering::Relaxed), + accessibility_snapshot_errors: self + .accessibility_snapshot_errors + .load(Ordering::Relaxed), + accessibility_snapshot_timeouts: self + .accessibility_snapshot_timeouts + .load(Ordering::Relaxed), + accessibility_snapshot_slow: self.accessibility_snapshot_slow.load(Ordering::Relaxed), + latest_accessibility_snapshot_ms: self + .latest_accessibility_snapshot_ms + .load(Ordering::Relaxed), + max_accessibility_snapshot_ms: self + .max_accessibility_snapshot_ms + .load(Ordering::Relaxed), client_streams: self.client_stream_stats_snapshot(), } } + pub fn record_accessibility_snapshot(&self, duration_ms: u64, ok: bool, timed_out: bool) { + self.accessibility_snapshots.fetch_add(1, Ordering::Relaxed); + self.latest_accessibility_snapshot_ms + .store(duration_ms, Ordering::Relaxed); + update_max(&self.max_accessibility_snapshot_ms, duration_ms); + if !ok { + self.accessibility_snapshot_errors + .fetch_add(1, Ordering::Relaxed); + } + if timed_out { + self.accessibility_snapshot_timeouts + .fetch_add(1, Ordering::Relaxed); + } + if duration_ms >= 2_000 { + self.accessibility_snapshot_slow + .fetch_add(1, Ordering::Relaxed); + } + } + pub fn record_client_stream_stats(&self, stats: ClientStreamStats) { let mut snapshots = self .client_stream_stats @@ -153,6 +201,16 @@ impl Metrics { } } +fn update_max(target: &AtomicU64, value: u64) { + let mut current = target.load(Ordering::Relaxed); + while value > current { + match target.compare_exchange(current, value, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => break, + Err(next) => current = next, + } + } +} + fn prune_stale_client_stream_stats(snapshots: &mut VecDeque) { let now_ms = current_time_ms(); snapshots.retain(|stats| { @@ -294,4 +352,20 @@ mod tests { assert_eq!(snapshots[0].client_id, "client-12"); assert_eq!(snapshots[47].client_id, "client-59"); } + + #[test] + fn accessibility_snapshot_metrics_track_slow_errors_and_timeouts() { + let metrics = Metrics::default(); + + metrics.record_accessibility_snapshot(1_250, true, false); + metrics.record_accessibility_snapshot(8_000, false, true); + + let snapshot = metrics.snapshot(); + assert_eq!(snapshot.accessibility_snapshots, 2); + assert_eq!(snapshot.accessibility_snapshot_errors, 1); + assert_eq!(snapshot.accessibility_snapshot_timeouts, 1); + assert_eq!(snapshot.accessibility_snapshot_slow, 1); + assert_eq!(snapshot.latest_accessibility_snapshot_ms, 8_000); + assert_eq!(snapshot.max_accessibility_snapshot_ms, 8_000); + } } diff --git a/packages/server/src/service.rs b/packages/server/src/service.rs index b0c44e53..436387ad 100644 --- a/packages/server/src/service.rs +++ b/packages/server/src/service.rs @@ -1,5 +1,6 @@ use crate::{auth, default_client_root, ServiceOptions}; use anyhow::{anyhow, bail, Context}; +use std::env; use std::fs; use std::net::{IpAddr, Ipv4Addr, TcpListener}; use std::path::{Path, PathBuf}; @@ -316,6 +317,9 @@ fn service_options_match_arguments( && argument_value(arguments, "--video-codec").as_deref() == Some(options.video_codec.as_env_value()) && argument_value(arguments, "--server-kind").as_deref() == Some("launch-agent") + && arguments + .windows(2) + .any(|window| window[0] == "service" && window[1] == "run") && optional_argument_matches( arguments, "--advertise-host", @@ -558,7 +562,8 @@ fn plist_contents( ) -> String { let mut program_arguments = vec![ executable.to_string_lossy().into_owned(), - "serve".to_string(), + "service".to_string(), + "run".to_string(), "--port".to_string(), options.port.to_string(), "--bind".to_string(), @@ -600,6 +605,12 @@ fn plist_contents( .map(|argument| format!(" {}", xml_escape(&argument))) .collect::>() .join("\n"); + let environment_xml = launch_agent_environment_xml(); + let environment_section = if environment_xml.is_empty() { + String::new() + } else { + format!(" EnvironmentVariables\n \n{environment_xml}\n \n") + }; format!( r#" @@ -616,7 +627,7 @@ fn plist_contents( KeepAlive - StandardOutPath +{environment_section} StandardOutPath {stdout_log} StandardErrorPath {stderr_log} @@ -625,11 +636,35 @@ fn plist_contents( "#, label = SERVICE_LABEL, program_arguments = program_arguments_xml, + environment_section = environment_section, stdout_log = xml_escape(&stdout_log.to_string_lossy()), stderr_log = xml_escape(&stderr_log.to_string_lossy()), ) } +fn launch_agent_environment_xml() -> String { + [ + "ANDROID_HOME", + "ANDROID_SDK_ROOT", + "JAVA_HOME", + "DEVELOPER_DIR", + ] + .into_iter() + .filter_map(|key| { + let value = env::var(key).ok()?; + if value.trim().is_empty() { + return None; + } + Some(format!( + " {}\n {}", + xml_escape(key), + xml_escape(&value) + )) + }) + .collect::>() + .join("\n") +} + fn xml_escape(value: &str) -> String { value .replace('&', "&") @@ -661,7 +696,8 @@ mod tests { fn service_arguments_for_test(options: &ServiceOptions) -> Vec { let mut arguments = vec![ "/tmp/simdeck".to_owned(), - "serve".to_owned(), + "service".to_owned(), + "run".to_owned(), "--port".to_owned(), options.port.to_string(), "--bind".to_owned(), diff --git a/packages/server/src/simulators/session.rs b/packages/server/src/simulators/session.rs index 202f0454..83534ff2 100644 --- a/packages/server/src/simulators/session.rs +++ b/packages/server/src/simulators/session.rs @@ -76,6 +76,14 @@ impl Drop for FrameSubscription { .unwrap_or(0); if previous <= 1 { self.inner.native.set_client_foreground(false); + *self.inner.latest_keyframe.write().unwrap() = None; + self.inner.last_keyframe_ms.store(0, Ordering::Relaxed); + self.inner.last_refresh_us.store(0, Ordering::Relaxed); + self.inner.native.reconfigure_video_encoder(); + self.inner + .metrics + .stream_pipeline_resets + .fetch_add(1, Ordering::Relaxed); } } } diff --git a/scripts/build-cli.sh b/scripts/build-cli.sh index 1df248af..716ce7bc 100755 --- a/scripts/build-cli.sh +++ b/scripts/build-cli.sh @@ -47,7 +47,7 @@ cat > "$OUTPUT" < simdeck\.pid/); }); @@ -209,7 +209,7 @@ for (const [platform, action, startStep, waitStep] of [ } darwinTest( - "npm CLI wrapper restarts daemon run after recoverable native exit", + "npm CLI wrapper restarts service run after recoverable native exit", () => { const root = mkdtempSync(join(tmpdir(), "simdeck-wrapper-test-")); try { @@ -243,7 +243,7 @@ exit 0 const result = spawnSync( process.execPath, - [wrapperPath, "daemon", "run", "--port", "4310"], + [wrapperPath, "service", "run", "--port", "4310"], { encoding: "utf8", }, @@ -251,7 +251,7 @@ exit 0 assert.equal(result.status, 0, result.stderr); const logLines = readFileSync(logPath, "utf8").trim().split("\n"); - assert.equal(logLines.length, 2, "daemon run should be retried once"); + assert.equal(logLines.length, 2, "service run should be retried once"); const entries = logLines.map((line) => { const [pid, metadataPid, args] = line.split(":"); @@ -261,7 +261,7 @@ exit 0 assert.match(entries[0].metadataPid, /^\d+$/); assert.equal(entries[0].metadataPid, entries[1].metadataPid); assert.notEqual(entries[0].pid, entries[0].metadataPid); - assert.equal(entries[0].args, "daemon run --port 4310"); + assert.equal(entries[0].args, "service run --port 4310"); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/scripts/integration/cli.mjs b/scripts/integration/cli.mjs index d627221d..ee4a042a 100644 --- a/scripts/integration/cli.mjs +++ b/scripts/integration/cli.mjs @@ -716,10 +716,8 @@ function startServer() { serverProcess = spawn( simdeck, [ - "daemon", + "service", "run", - "--project-root", - root, "--metadata-path", path.join(tempRoot, "daemon.json"), "--port", diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index f4e39503..f7be785a 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -23,10 +23,8 @@ Open the simulator UI: Open a specific simulator: simdeck "iPhone 17 Pro" -Detached daemon shortcuts: - simdeck -d - simdeck -k - simdeck -r +Use a different service port: + simdeck -p 4311 Install the agent skill: npx skills add NativeScript/SimDeck --skill simdeck -g @@ -35,8 +33,8 @@ Recommended VS Code extension: nativescript.simdeck-vscode Recommended for always-on agent/editor access: - simdeck service on - simdeck service off + simdeck -a + simdeck pair `; console.log(message.trimEnd()); diff --git a/skills/simdeck/SKILL.md b/skills/simdeck/SKILL.md index aad4c200..6d57d113 100644 --- a/skills/simdeck/SKILL.md +++ b/skills/simdeck/SKILL.md @@ -7,7 +7,14 @@ description: Use for simulator lifecycle, app install/launch, live viewing, UI i SimDeck automates iOS Simulators and Android emulators. Use the CLI for automation and the browser UI for live human visibility. iOS works with NativeScript, UIKit, SwiftUI, React Native, Expo, and Flutter apps; Android works through ADB, emulator lifecycle, screenshots, logs, and UIAutomator hierarchy dumps. -SimDeck uses one daemon per workspace (CWD). Use `simdeck ui` and it will print JSON with `url` key. If it was already running, it prints the existing daemon URL and `started` is set to `false`. Contains workspace in `projectRoot` key. Example response: +SimDeck uses one long-running local service. Run `simdeck` first; it starts or +reuses the background service and prints the browser URL plus pairing code. +Pass `--open` only when you want SimDeck to open the default browser itself. +Pass `-p ` or `--port ` when the service should use a non-default +port. Pass `-a` or `--autostart` when the service should also be registered as +a macOS LaunchAgent. + +Example response from `simdeck daemon status`: ```json { @@ -21,30 +28,27 @@ SimDeck uses one daemon per workspace (CWD). Use `simdeck ui` and it will print ``` ```bash -simdeck ui +simdeck +simdeck --open +simdeck -p 4311 +simdeck -a simdeck pair # prints LAN/Tailscale pairing URLs, code, and iOS QR -simdeck -k # kills the daemon -simdeck -r # restarts the daemon -simdeck daemon killall # kills all daemons on the machine, use with care +simdeck daemon stop +simdeck daemon restart ``` -Usually `http://127.0.0.1:4311` or `http://127.0.0.1:4311?device=` for -project daemons. The always-on LaunchAgent service stays on -`http://127.0.0.1:4310`. -Daemon ports may increment upward from 4311 if multiple daemons are running. +Usually `http://127.0.0.1:4310` or +`http://127.0.0.1:4310?device=`. Use `simdeck -p 4311` when the default +port is not the right fit. Use `simdeck pair` when a native iOS client needs to pair. It starts or -refreshes the global LaunchAgent-backed service, detects LAN and Tailscale IPv4 -addresses, and prints a QR with a `simdeck://pair` URL that carries the pairing -code plus alternate server addresses. -The LaunchAgent service token is stable across `simdeck pair`, `simdeck service -on`, and `simdeck service restart`; use `simdeck service reset` only when you -need to rotate the token and restart the service. When that service is active, -`simdeck` and `simdeck ui` report the service endpoints instead of starting a -project daemon. +refreshes the LaunchAgent-backed service, detects LAN and Tailscale IPv4 +addresses, and prints a QR with a `simdeck://pair` URL. Normal service restarts +preserve the token and pairing code; use `simdeck service reset` only when you +need to rotate them. -Always first run `simdeck ui` to open the URL reported by the `simdeck ui` in the in-app browser using Browser Use tool if available. +Always first run `simdeck` and open the reported URL in the in-app browser using the Browser tool if available. -If Browser Use is not available, only then use `simdeck ui --open` - it would open the default browser - taking focus away from the app. +If Browser Use is not available, only then use `simdeck --open`; it opens the default browser and may take focus away from the app. ## Device And App @@ -83,7 +87,7 @@ AVDs from the Android SDK. ## Fast Agent Inspection -Use targeted checks for test loops. `describe` is a diagnostic snapshot of the whole hierarchy. For verification, prefer the daemon APIs exposed by `simdeck/test`: `action`, `query`, `waitFor`, `assert`, selector `tap`, and `batch`. +Use targeted checks for test loops. `describe` is a diagnostic snapshot of the whole hierarchy. For verification, prefer the service APIs exposed by `simdeck/test`: `action`, `query`, `waitFor`, `assert`, selector `tap`, and `batch`. ```bash simdeck describe @@ -102,12 +106,12 @@ simdeck wait --label "Welcome" --timeout-ms 5000 simdeck assert --id login.button --source auto --max-depth 8 ``` -The default source is `native-ax`, which is the fastest and most universal path for agents. Use `--source auto` with the project daemon when you want richer NativeScript, React Native, Flutter, SwiftUI, or UIKit inspector data before native accessibility fallback. Use `--direct` or `--source native-ax` for the private CoreSimulator accessibility bridge. Use `--source android-uiautomator` for Android emulator UIAutomator hierarchies. +The default source is `native-ax`, which is the fastest and most universal path for agents. Use `--source auto` when you want richer NativeScript, React Native, Flutter, SwiftUI, or UIKit inspector data before native accessibility fallback. Use `--direct` or `--source native-ax` for the private CoreSimulator accessibility bridge. Use `--source android-uiautomator` for Android emulator UIAutomator hierarchies. For Android IDs, `describe` uses `uiautomator dump`; use `--format agent` or `--format compact-json` the same way as iOS. Use `--interactive` or `-i` when an agent only needs controls and actionable framework nodes; SimDeck keeps ancestor context so the output is still navigable. Agent output labels nodes with refs such as `@e3`; reuse them with `simdeck press @e3`. `snapshot`, `press`, and `wait` are aliases for `describe`, `tap`, and `wait-for`. -Prefer selectors, coordinates only when needed. Selector taps go through the daemon and wait for the element server-side. Use `--expect-id`, `--expect-label`, or another `--expect-*` selector when the tap should also wait for the next screen before returning. +Prefer selectors, coordinates only when needed. Selector taps go through the service and wait for the element server-side. Use `--expect-id`, `--expect-label`, or another `--expect-*` selector when the tap should also wait for the next screen before returning. ```bash simdeck tap --id LoginButton --wait-timeout-ms 5000 @@ -296,5 +300,5 @@ import "expo-router/entry"; Import it before `expo-router/entry` or `AppRegistry.registerComponent(...)`. The auto entrypoint no-ops outside development, reads `EXPO_PUBLIC_SIMDECK_PORT` when present, and otherwise scans common SimDeck -daemon ports. Use the manual `startSimDeckReactNativeInspector(...)` API +service ports. Use the manual `startSimDeckReactNativeInspector(...)` API when you need custom host/path/security options.