diff --git a/README.md b/README.md index a2f5f078..5864add3 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,16 @@ Drive iOS Simulator apps from the CLI, browser, and automated tests on macOS. npm i -g simdeck@latest ``` +After installing the CLI, install the Codex skill so agents know the stable +SimDeck workflow: + +```sh +npx skills add NativeScript/SimDeck --skill simdeck -a codex -g +``` + +For VS Code, install the `nativescript.simdeck` extension to open the simulator +view inside the editor. + ## Features - WebTransport based streaming server in Rust, hardware encoded HVEC/H264 video stream @@ -14,6 +24,7 @@ npm i -g simdeck@latest - CoreSimulator chrome asset rendering for device bezels - NativeScript and React Native runtime inspector plugins, plus a native UIKit inspector framework for other apps - Project daemon reuse: normal CLI commands automatically start and reuse one warm native host per project. +- Optional macOS LaunchAgent service for an always-on local SimDeck daemon. - `simdeck/test` for fast JS/TS app tests that can query accessibility state and drive simulator controls. - Agent [`SKILL.md`](./skills/simdeck/SKILL.md) reference @@ -43,6 +54,16 @@ npm install -g . After a global install, use the `simdeck` command directly. From a local checkout, you can also run `./build/simdeck`. +Install the agent skill with [skills.sh](https://skills.sh/): + +```sh +npx skills add NativeScript/SimDeck --skill simdeck -a codex -g +``` + +The npm postinstall message also prints this command after a global install. +It also recommends `simdeck service on` for always-on local access from agents +and editor integrations. + ## Documentation Full documentation lives at [simdeck.nativescript.org](https://simdeck.nativescript.org/), with guides, the CLI reference, the REST API, the WebTransport video pipeline, and the inspector protocols. The source for the site lives in [`docs/`](docs/) — preview it locally with `npm run docs:dev`. @@ -79,6 +100,18 @@ simdeck daemon status simdeck daemon stop ``` +`simdeck daemon` manages the normal per-project warm process. For an always-on +daemon that is available after login, use the macOS user service commands: + +```sh +simdeck service on +simdeck service off +``` + +This uses a LaunchAgent, keeps the server bound to localhost by default, and is +best for agents or editor integrations that should be able to open SimDeck +without first starting a project daemon. + Use software H.264 when macOS screen recording starves the hardware encoder: ```sh diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index c7dbbc5b..7a663404 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,7 +1,7 @@ import { defineConfig } from "vitepress"; const repoName = "SimDeck"; -const githubUrl = `https://github.com/DjDeveloperr/${repoName}`; +const githubUrl = `https://github.com/NativeScript/${repoName}`; const siteUrl = "https://simdeck.nativescript.org"; export default defineConfig({ diff --git a/docs/api/health.md b/docs/api/health.md index 8e51bfeb..8fdf64fd 100644 --- a/docs/api/health.md +++ b/docs/api/health.md @@ -91,7 +91,7 @@ Returns a snapshot of every server-side counter and the rolling buffer of client `client_streams` is a rolling buffer of the most recent reports a client posted to `POST /api/client-stream-stats`. The server keeps the last 48 entries per `(clientId, kind)` pair. -The browser client uses these to render its in-app diagnostics overlay and to size its decoder workers. Every field is optional except `clientId` and `kind`; see [`ClientStreamStats`](https://github.com/DjDeveloperr/SimDeck/blob/main/server/src/metrics/counters.rs) for the full schema. +The browser client uses these to render its in-app diagnostics overlay and to size its decoder workers. Every field is optional except `clientId` and `kind`; see [`ClientStreamStats`](https://github.com/NativeScript/SimDeck/blob/main/server/src/metrics/counters.rs) for the full schema. ## Submitting client stats diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 8c46ba39..713b011c 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -54,6 +54,26 @@ Stop the daemon for the current project: simdeck daemon stop ``` +### `service` + +Manage the optional always-on macOS user service. Use `simdeck daemon` for the +normal per-project process; use `simdeck service` when you want a LaunchAgent +that starts after login and stays available. + +```sh +simdeck service on [--port 4310] [--bind 127.0.0.1] + [--advertise-host ] [--client-root ] + [--video-codec hevc|h264|h264-software] + [--access-token ] +simdeck service restart [same options as service on] +simdeck service off +``` + +`service on` installs `~/Library/LaunchAgents/dev.nativescript.simdeck.plist` +and starts a LaunchAgent that serves SimDeck after login. It is intended for +agents and editor integrations that should be able to open the UI without first +starting a project daemon. + ### `core-simulator` Manage Apple's CoreSimulator service layer: diff --git a/docs/contributing.md b/docs/contributing.md index 30eb247e..948db618 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -21,7 +21,7 @@ Optional: Clone, install dependencies, and build everything: ```sh -git clone https://github.com/DjDeveloperr/SimDeck.git +git clone https://github.com/NativeScript/SimDeck.git cd simdeck npm install npm run build diff --git a/docs/guide/daemon.md b/docs/guide/daemon.md index e56307d7..c7b92ba2 100644 --- a/docs/guide/daemon.md +++ b/docs/guide/daemon.md @@ -4,6 +4,9 @@ SimDeck runs one warm native host per project. The daemon owns the HTTP API, the Normal CLI commands start the daemon automatically when they need it. Use `simdeck daemon` only when you want to manage it explicitly. +`simdeck daemon` is project-scoped. `simdeck service` is the optional macOS +LaunchAgent wrapper for users who want an always-on daemon after login. + ## Start ```sh @@ -69,6 +72,35 @@ simdeck daemon stop This terminates the daemon for the current project and removes its metadata file from the system temp directory. The next CLI command that needs the daemon starts a fresh one. +## Always-On Service + +For agents and editor integrations that should be able to reach SimDeck at any +time after login, use `simdeck service` to install the macOS user service: + +```sh +simdeck service on +``` + +This writes `~/Library/LaunchAgents/dev.nativescript.simdeck.plist`, starts the +server with `launchctl`, and keeps it alive. It binds to `127.0.0.1:4310` by +default and serves the bundled browser client. + +Restart it after changing options: + +```sh +simdeck service restart --port 4310 --video-codec h264-software +``` + +Disable it when you do not want a persistent daemon: + +```sh +simdeck service off +``` + +Prefer the project daemon for project-scoped metadata and automatic lifecycle. +Use the service when the priority is easy access from Codex, VS Code, or a +browser at any time. + ## CoreSimulator Service Layer The project daemon is different from Apple's CoreSimulator service. If `simctl` reports stale service state or the live display never produces a first frame, restart Apple's service layer: diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 8af10517..a64895eb 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -29,12 +29,39 @@ This installs the launcher and bundled native binary to your global `node_module simdeck --help ``` +The global install prints the next setup steps: + +```sh +simdeck ui --open +npx skills add NativeScript/SimDeck --skill simdeck -a codex -g +simdeck service on +``` + +Install the `nativescript.simdeck` VS Code extension if you want the simulator +view inside VS Code. + +`simdeck service on` is recommended when agents or editor integrations should be +able to reach SimDeck any time after login. It installs a localhost macOS +LaunchAgent and can be removed with `simdeck service off`. + +## Install the Codex skill + +SimDeck includes an agent skill at `skills/simdeck/SKILL.md`. Install it with +[skills.sh](https://skills.sh/) so Codex can choose the right commands and +inspection loops automatically: + +```sh +npx skills add NativeScript/SimDeck --skill simdeck -a codex -g +``` + +Restart Codex after installing the skill. + ## Install from source Clone the repo and install dependencies: ```sh -git clone https://github.com/DjDeveloperr/SimDeck.git +git clone https://github.com/NativeScript/SimDeck.git cd simdeck npm install ``` diff --git a/docs/index.md b/docs/index.md index f7ea16ae..8c353734 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,7 +14,7 @@ hero: link: /guide/ - theme: alt text: View on GitHub - link: https://github.com/DjDeveloperr/SimDeck + link: https://github.com/NativeScript/SimDeck features: - icon: diff --git a/package-lock.json b/package-lock.json index 59b28638..ad077360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "simdeck", "version": "0.1.0", + "hasInstallScript": true, "license": "Apache-2.0", "os": [ "darwin" diff --git a/package.json b/package.json index 7372d6b0..f5b59c71 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "LICENSE", "README.md", "bin/", + "scripts/postinstall.mjs", "build/simdeck-bin", "client/dist/", "packages/simdeck-test/dist/" @@ -52,6 +53,7 @@ "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs", + "postinstall": "node scripts/postinstall.mjs", "prepack": "npm run build:cli && npm run build:client" }, "devDependencies": { diff --git a/packages/simdeck-test/dist/index.js b/packages/simdeck-test/dist/index.js index ec2a57a7..e76e527f 100644 --- a/packages/simdeck-test/dist/index.js +++ b/packages/simdeck-test/dist/index.js @@ -175,7 +175,7 @@ export async function connect(options = {}) { return session; } async function startIsolatedDaemon(cliPath, options) { - const port = options.port ?? (await freePort()); + const port = options.port ?? (await freePortPair()); const projectRoot = fs.mkdtempSync( path.join(os.tmpdir(), "simdeck-test-project-"), ); @@ -204,12 +204,13 @@ async function startIsolatedDaemon(cliPath, options) { ], { cwd: options.projectRoot, - stdio: "ignore", + stdio: ["ignore", "pipe", "pipe"], }, ); + const output = captureChildOutput(child); const url = `http://127.0.0.1:${port}`; try { - await waitForHealth(url, child); + await waitForHealth(url, child, output); } catch (error) { child.kill(); fs.rmSync(projectRoot, { recursive: true, force: true }); @@ -225,12 +226,14 @@ async function startIsolatedDaemon(cliPath, options) { isolatedRoot: projectRoot, }; } -async function waitForHealth(endpoint, child) { - const deadline = Date.now() + 15_000; +async function waitForHealth(endpoint, child, output) { + const deadline = Date.now() + 60_000; let lastError; while (Date.now() < deadline) { if (child.exitCode !== null) { - throw new Error(`SimDeck isolated daemon exited with ${child.exitCode}`); + throw new Error( + `SimDeck isolated daemon exited with ${child.exitCode}.\n${output()}`, + ); } try { await requestJson(endpoint, "GET", "/api/health"); @@ -241,9 +244,30 @@ async function waitForHealth(endpoint, child) { } } throw new Error( - `Timed out waiting for isolated SimDeck daemon: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + `Timed out waiting for isolated SimDeck daemon: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${output()}`, ); } +function captureChildOutput(child) { + const chunks = []; + const append = (source, chunk) => { + chunks.push(`[${source}] ${chunk.toString("utf8")}`); + while (chunks.join("").length > 16_384) { + chunks.shift(); + } + }; + child.stdout?.on("data", (chunk) => append("stdout", chunk)); + child.stderr?.on("data", (chunk) => append("stderr", chunk)); + return () => chunks.join("").trim(); +} +async function freePortPair() { + for (let attempt = 0; attempt < 100; attempt += 1) { + const port = await freePort(); + if (port < 65535 && (await portAvailable(port + 1))) { + return port; + } + } + throw new Error("Unable to allocate adjacent free TCP ports."); +} function freePort() { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -260,6 +284,15 @@ function freePort() { server.on("error", reject); }); } +function portAvailable(port) { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); +} function runJson(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd, diff --git a/packages/simdeck-test/src/index.ts b/packages/simdeck-test/src/index.ts index 1446ddc0..a92628bc 100644 --- a/packages/simdeck-test/src/index.ts +++ b/packages/simdeck-test/src/index.ts @@ -269,7 +269,7 @@ async function startIsolatedDaemon( cliPath: string, options: SimDeckLaunchOptions, ): Promise { - const port = options.port ?? (await freePort()); + const port = options.port ?? (await freePortPair()); const projectRoot = fs.mkdtempSync( path.join(os.tmpdir(), "simdeck-test-project-"), ); @@ -298,12 +298,13 @@ async function startIsolatedDaemon( ], { cwd: options.projectRoot, - stdio: "ignore", + stdio: ["ignore", "pipe", "pipe"], }, ); + const output = captureChildOutput(child); const url = `http://127.0.0.1:${port}`; try { - await waitForHealth(url, child); + await waitForHealth(url, child, output); } catch (error) { child.kill(); fs.rmSync(projectRoot, { recursive: true, force: true }); @@ -323,12 +324,15 @@ async function startIsolatedDaemon( async function waitForHealth( endpoint: string, child: ChildProcess, + output: () => string, ): Promise { - const deadline = Date.now() + 15_000; + const deadline = Date.now() + 60_000; let lastError: unknown; while (Date.now() < deadline) { if (child.exitCode !== null) { - throw new Error(`SimDeck isolated daemon exited with ${child.exitCode}`); + throw new Error( + `SimDeck isolated daemon exited with ${child.exitCode}.\n${output()}`, + ); } try { await requestJson(endpoint, "GET", "/api/health"); @@ -339,10 +343,35 @@ async function waitForHealth( } } throw new Error( - `Timed out waiting for isolated SimDeck daemon: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + `Timed out waiting for isolated SimDeck daemon: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }\n${output()}`, ); } +function captureChildOutput(child: ChildProcess): () => string { + const chunks: string[] = []; + const append = (source: string, chunk: Buffer) => { + chunks.push(`[${source}] ${chunk.toString("utf8")}`); + while (chunks.join("").length > 16_384) { + chunks.shift(); + } + }; + child.stdout?.on("data", (chunk: Buffer) => append("stdout", chunk)); + child.stderr?.on("data", (chunk: Buffer) => append("stderr", chunk)); + return () => chunks.join("").trim(); +} + +async function freePortPair(): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + const port = await freePort(); + if (port < 65535 && (await portAvailable(port + 1))) { + return port; + } + } + throw new Error("Unable to allocate adjacent free TCP ports."); +} + function freePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -360,6 +389,16 @@ function freePort(): Promise { }); } +function portAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); +} + function runJson( command: string, args: string[], diff --git a/scripts/integration/js-api.mjs b/scripts/integration/js-api.mjs index a385980e..1cfc2a01 100644 --- a/scripts/integration/js-api.mjs +++ b/scripts/integration/js-api.mjs @@ -276,6 +276,9 @@ async function main() { ), { phase: phaseSimulatorLifecycle }, ); + await measuredStep("close JS API session", () => closeSession(), { + phase: phaseSimulatorLifecycle, + }); await measuredStep( "shutdown simulator", () => shutdownSimulatorIfNeeded(simulatorUDID), @@ -283,7 +286,7 @@ async function main() { ); await measuredStep( "erase simulator", - () => runJson(simdeck, ["erase", simulatorUDID]), + () => eraseSimulatorReliably(simulatorUDID), { phase: phaseSimulatorLifecycle, }, @@ -542,7 +545,10 @@ function preapproveFixtureUrlScheme() { function shutdownSimulatorIfNeeded(udid) { try { - return runJson(simdeck, ["shutdown", udid]); + runText("xcrun", ["simctl", "shutdown", udid], { + timeoutMs: 180_000, + }); + return { ok: true, udid, action: "shutdown" }; } catch (error) { if (String(error?.message ?? error).includes("current state: Shutdown")) { return { ok: true, udid, alreadyShutdown: true }; @@ -551,6 +557,27 @@ function shutdownSimulatorIfNeeded(udid) { } } +function eraseSimulatorReliably(udid) { + return retrySync( + () => { + shutdownSimulatorIfNeeded(udid); + runText("xcrun", ["simctl", "erase", udid], { + timeoutMs: 180_000, + }); + return { ok: true, udid, action: "erase" }; + }, + "erase simulator", + 3, + 3_000, + ); +} + +function closeSession() { + session?.close(); + session = null; + return { ok: true }; +} + function assertRoots(payload, label) { assertJson(payload, label); if (!Array.isArray(payload.roots) || payload.roots.length === 0) { @@ -720,7 +747,7 @@ function openSimulatorApp(udid) { function cleanup() { try { - session?.close(); + closeSession(); } catch {} if (simulatorUDID && !keepSimulator) { spawnSync("xcrun", ["simctl", "shutdown", simulatorUDID], { diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs new file mode 100644 index 00000000..7e7fc33e --- /dev/null +++ b/scripts/postinstall.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +const isGlobalInstall = + process.env.npm_config_global === "true" || + process.env.npm_config_location === "global" || + process.env.npm_config_global_style === "true"; + +const isCi = + process.env.CI === "true" || + process.env.npm_config_loglevel === "silent" || + process.env.npm_config_loglevel === "error"; + +if (!isGlobalInstall || isCi) { + process.exit(0); +} + +const message = ` +SimDeck is installed. + +Open the simulator UI: + simdeck ui --open + +Install the Codex skill: + npx skills add NativeScript/SimDeck --skill simdeck -a codex -g + +Recommended VS Code extension: + nativescript.simdeck + +Recommended for always-on agent/editor access: + simdeck service on + simdeck service off +`; + +console.log(message.trimEnd()); diff --git a/server/src/main.rs b/server/src/main.rs index 9edb014a..e92b1b91 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -95,7 +95,6 @@ enum Command { #[arg(long)] access_token: Option, }, - #[command(hide = true)] Service { #[command(subcommand)] command: ServiceCommand,