diff --git a/AGENTS.md b/AGENTS.md index 42251385..766dff37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,16 +75,19 @@ The current repo uses the private boot path, private display bridge, and private ## Build and Run -Build the browser bundle: +Build the native CLI and browser bundle: ```sh -./scripts/build-client.sh +npm run build ``` -Build the native CLI: +Build individual pieces when needed: ```sh -./scripts/build-cli.sh +npm run build:cli +npm run build:client +npm run build:all +npm run package:vscode ``` This now builds the Rust server in `server/` and copies the resulting binary to `build/simdeck`. @@ -92,9 +95,12 @@ This now builds the Rust server in `server/` and copies the resulting binary to Run the local daemon: ```sh +./build/simdeck ./build/simdeck daemon start --port 4310 ``` +Running without a subcommand starts a foreground workspace daemon, prints local and LAN browser URLs, and stops when the command exits. 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. + Use software H.264 when macOS screen recording starves the hardware encoder: ```sh diff --git a/README.md b/README.md index 41f51bf5..77a37532 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,11 @@ view inside the editor. ## Build ```sh -./scripts/build-client.sh -./scripts/build-cli.sh +npm run build ``` +This builds the native CLI and browser client. To build companion packages too, run `npm run build:all`. + Requirements: - macOS @@ -71,11 +72,18 @@ Full documentation lives at [simdeck.nativescript.org](https://simdeck.nativescr ## Run ```sh -simdeck ui --open +simdeck +``` + +This starts a workspace-local foreground daemon, prints local and LAN browser URLs, and stops when you press Ctrl-C. +To focus a specific simulator by name or UDID, pass it as the only argument: + +```sh +simdeck "iPhone 17 Pro Max" ``` -This starts or reuses the project daemon, enables the browser UI, and opens the authenticated local URL. -To focus a specific simulator, add `?device=UDID` to the opened URL. +Use `simdeck ui --open` or `simdeck daemon start` when you want a reusable background daemon instead. +The no-subcommand lifecycle shortcuts are `simdeck -d` for detached start, `simdeck -k` to kill the background daemon, and `simdeck -r` to restart it. SimDeck Cloud uses the same server binary as its GitHub Actions provider. The provider workflow starts `simdeck serve` on the runner, exposes it through a tunnel, and lets the hosted control plane connect to the simulator with a @@ -257,6 +265,7 @@ npm run package:vscode-extension ``` This writes `build/vscode/simdeck-vscode.vsix`. +The shorter aliases `npm run package:vscode` and `npm run package:vsix` do the same thing. Install that local package into VS Code: diff --git a/bin/simdeck.mjs b/bin/simdeck.mjs index eb0e19e7..0415375b 100755 --- a/bin/simdeck.mjs +++ b/bin/simdeck.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -23,14 +23,28 @@ if (!existsSync(binaryPath)) { process.exit(1); } -const result = spawnSync(binaryPath, process.argv.slice(2), { +const child = spawn(binaryPath, process.argv.slice(2), { cwd: process.cwd(), stdio: "inherit", }); -if (result.error) { - console.error(result.error.message); +child.on("error", (error) => { + console.error(error.message); process.exit(1); +}); + +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.once(signal, () => { + if (!child.killed) { + child.kill(signal); + } + }); } -process.exit(result.status ?? 1); +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/cli/DFPrivateSimulatorDisplayBridge.m b/cli/DFPrivateSimulatorDisplayBridge.m index 6593bb7e..3963dc0f 100644 --- a/cli/DFPrivateSimulatorDisplayBridge.m +++ b/cli/DFPrivateSimulatorDisplayBridge.m @@ -577,6 +577,14 @@ static id DFInitSimDeviceScreen(Class screenClass, id device, uint32_t screenID, return unwrapped; } +static NSDictionary * DFReadAvailableAdapterScreens(id adapterHost, id adapter) { + NSDictionary *screens = DFReadAdapterScreens(adapterHost); + if (screens.count == 0) { + screens = DFReadAdapterScreens(adapter); + } + return screens; +} + static uint32_t __attribute__((unused)) DFCallSwiftSelfGetterU32(id selfObject, const char *symbolName) { if (selfObject == nil) { return 0; @@ -2268,11 +2276,11 @@ - (nullable instancetype)initWithUDID:(NSString *)udid // the bootstrap SimDeviceScreen is allocated — they trickle in over a few // seconds (or only after Simulator.app primes the device). Poll instead of // relying on a fixed 0.5s sleep. - NSDictionary *screens = DFReadAdapterScreens(_screenAdapterHost); + NSDictionary *screens = DFReadAvailableAdapterScreens(_screenAdapterHost, _screenAdapter); NSDate *screenDeadline = [NSDate dateWithTimeIntervalSinceNow:10.0]; while (screens.count == 0 && [screenDeadline timeIntervalSinceNow] > 0) { DFSpinRunLoop(0.1); - screens = DFReadAdapterScreens(_screenAdapterHost); + screens = DFReadAvailableAdapterScreens(_screenAdapterHost, _screenAdapter); } if (screens.count == 0) { DFLog(@"SimulatorKit screen adapter exposed no live screens for %@", udid); @@ -2958,7 +2966,7 @@ - (void)disconnect { ((void(*)(id, SEL, id))objc_msgSend)(self->_screenAdapter, sel_registerName("unregisterScreenAdapterCallbacksWithUUID:"), self->_screenAdapterCallbackUUID); } - NSDictionary *screens = DFReadAdapterScreens(self->_screenAdapterHost); + NSDictionary *screens = DFReadAvailableAdapterScreens(self->_screenAdapterHost, self->_screenAdapter); id rawScreen = screens[@(self->_activeScreenID)]; if (rawScreen != nil && self->_screenCallbackUUID != nil && [rawScreen respondsToSelector:sel_registerName("unregisterScreenCallbacksWithUUID:")]) { ((void(*)(id, SEL, id))objc_msgSend)(rawScreen, sel_registerName("unregisterScreenCallbacksWithUUID:"), self->_screenCallbackUUID); diff --git a/cli/XCWChromeRenderer.m b/cli/XCWChromeRenderer.m index 32be6cb5..dc9e7232 100644 --- a/cli/XCWChromeRenderer.m +++ b/cli/XCWChromeRenderer.m @@ -268,7 +268,7 @@ + (nullable NSData *)screenMaskPNGDataForDeviceName:(NSString *)deviceName CGFloat cornerRadius = chromeCornerRadius; CGFloat maskCornerRadius = [self framebufferMaskCornerRadiusForChromeInfo:chromeInfo pointScreenWidth:pointScreenWidth]; - if (maskCornerRadius > 0.0) { + if (!phoneProfile && maskCornerRadius > 0.0) { cornerRadius = maskCornerRadius * radiusScale; } diff --git a/client/src/api/types.ts b/client/src/api/types.ts index ed2df21b..830d10f6 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -11,6 +11,7 @@ export interface SimulatorMetadata { name: string; runtimeName?: string; runtimeIdentifier?: string; + deviceTypeName?: string; deviceTypeIdentifier?: string; isBooted: boolean; privateDisplay?: PrivateDisplayInfo; diff --git a/client/src/app/AppShell.tsx b/client/src/app/AppShell.tsx index f5771b7a..473047a9 100644 --- a/client/src/app/AppShell.tsx +++ b/client/src/app/AppShell.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useMemo, useRef, useState, type CSSProperties, @@ -46,6 +47,7 @@ import { Toolbar } from "../features/toolbar/Toolbar"; import { SimulatorViewport } from "../features/viewport/SimulatorViewport"; import type { Point, + Size, TouchIndicator, ViewMode, } from "../features/viewport/types"; @@ -60,6 +62,7 @@ import { shellSize, } from "../features/viewport/viewportMath"; import { + DEVICE_SCREEN_WIDTH, STREAM_ORIGIN, ZOOM_ANIMATION_MS, ZOOM_STEP, @@ -121,12 +124,41 @@ function shouldRenderNativeChrome(simulator: SimulatorMetadata): boolean { ); } +function simulatorDisplaySize( + simulator: SimulatorMetadata | null, +): Size | null { + const display = simulator?.privateDisplay; + if (!display || display.displayWidth <= 0 || display.displayHeight <= 0) { + return null; + } + return { + width: display.displayWidth, + height: display.displayHeight, + }; +} + function mergeAccessibilitySources( ...sources: unknown[] ): AccessibilitySource[] { return sanitizeAccessibilitySources(sources.flat()); } +function simulatorMatchesIdentifier( + simulator: SimulatorMetadata, + identifier: string, +): boolean { + const normalized = identifier.trim().toLowerCase(); + if (!normalized) { + return false; + } + return [ + simulator.udid, + simulator.name, + simulator.deviceTypeName, + simulator.deviceTypeIdentifier, + ].some((value) => value?.toLowerCase() === normalized); +} + type SimulatorTransition = { kind: "boot" | "shutdown"; udid: string; @@ -255,6 +287,9 @@ export function AppShell() { const selectedSimulator = simulators.find((simulator) => simulator.udid === selectedUDID) ?? + simulators.find((simulator) => + simulatorMatchesIdentifier(simulator, selectedUDID), + ) ?? filteredSimulators[0] ?? null; const selectedSimulatorDetail = @@ -294,6 +329,20 @@ export function AppShell() { canvasElement: streamCanvasElement, simulator: selectedSimulator, }); + const shouldRenderChrome = + selectedSimulator != null && shouldRenderNativeChrome(selectedSimulator); + const viewportChromeProfile = shouldRenderChrome ? chromeProfile : null; + const effectiveDeviceNaturalSize = useMemo( + () => + deviceNaturalSize ?? + (!shouldRenderChrome && chromeProfile + ? { + width: chromeProfile.screenWidth, + height: chromeProfile.screenHeight, + } + : simulatorDisplaySize(selectedSimulator)), + [chromeProfile, deviceNaturalSize, selectedSimulator, shouldRenderChrome], + ); const zoomDockReservedHeight = zoomDockElement && typeof window !== "undefined" @@ -305,8 +354,8 @@ export function AppShell() { const { fitScale, effectiveZoom } = useViewportLayout({ canvasSize, - chromeProfile, - deviceNaturalSize, + chromeProfile: viewportChromeProfile, + deviceNaturalSize: effectiveDeviceNaturalSize, pan, rotationQuarterTurns, reservedBottomInset: zoomDockReservedHeight, @@ -317,11 +366,11 @@ export function AppShell() { const isBooted = Boolean(selectedSimulator?.isBooted); const autoViewportOffsetY = viewMode === "manual" ? 0 : -zoomDockReservedHeight / 2; - const screenAspect = screenAspectRatio(deviceNaturalSize); + const screenAspect = screenAspectRatio(effectiveDeviceNaturalSize); const chromeUrl = selectedSimulator ? buildChromeUrl(selectedSimulator.udid, streamStamp) : ""; - const chromeRequired = Boolean(chromeProfile && chromeUrl); + const chromeRequired = Boolean(viewportChromeProfile && chromeUrl); const viewportReady = hasFrame && (!chromeRequired || chromeLoaded); useEffect(() => { @@ -584,11 +633,6 @@ export function AppShell() { setChromeProfile(null); return; } - if (!shouldRenderNativeChrome(selectedSimulator)) { - setChromeProfile(null); - return; - } - try { const profile = await fetchChromeProfile(selectedSimulator.udid); if (!cancelled) { @@ -639,8 +683,8 @@ export function AppShell() { currentPan, effectiveZoom, canvasSize, - deviceNaturalSize, - chromeProfile, + effectiveDeviceNaturalSize, + viewportChromeProfile, rotationQuarterTurns, ); return nextPan.x === currentPan.x && nextPan.y === currentPan.y @@ -649,10 +693,10 @@ export function AppShell() { }); }, [ canvasSize, - chromeProfile, - deviceNaturalSize, + effectiveDeviceNaturalSize, effectiveZoom, rotationQuarterTurns, + viewportChromeProfile, ]); useEffect(() => { @@ -684,8 +728,8 @@ export function AppShell() { const pointerInput = usePointerInput({ canvasSize, - chromeProfile, - deviceNaturalSize, + chromeProfile: viewportChromeProfile, + deviceNaturalSize: effectiveDeviceNaturalSize, effectiveZoom, fitScale, isBooted, @@ -708,22 +752,22 @@ export function AppShell() { const error = localError || streamError || listError; const deviceTransform = `translate(${pan.x}px, ${pan.y + autoViewportOffsetY}px) scale(${effectiveZoom})`; const chromeScreenRect = computeChromeScreenRect( - chromeProfile, - deviceNaturalSize, + viewportChromeProfile, + effectiveDeviceNaturalSize, ); const chromeScreenBorderRadius = computeChromeScreenBorderRadius( - chromeProfile, + viewportChromeProfile, chromeScreenRect, ); const chromeScreenStyle = - chromeProfile && chromeScreenRect + viewportChromeProfile && chromeScreenRect ? ({ - left: `${(chromeScreenRect.x / chromeProfile.totalWidth) * 100}%`, - top: `${(chromeScreenRect.y / chromeProfile.totalHeight) * 100}%`, - width: `${(chromeScreenRect.width / chromeProfile.totalWidth) * 100}%`, - height: `${(chromeScreenRect.height / chromeProfile.totalHeight) * 100}%`, + left: `${(chromeScreenRect.x / viewportChromeProfile.totalWidth) * 100}%`, + top: `${(chromeScreenRect.y / viewportChromeProfile.totalHeight) * 100}%`, + width: `${(chromeScreenRect.width / viewportChromeProfile.totalWidth) * 100}%`, + height: `${(chromeScreenRect.height / viewportChromeProfile.totalHeight) * 100}%`, borderRadius: chromeScreenBorderRadius ?? "0", - ...(chromeProfile.hasScreenMask && selectedSimulator + ...(viewportChromeProfile.hasScreenMask && selectedSimulator ? { maskImage: `url("${buildScreenMaskUrl( selectedSimulator.udid, @@ -742,18 +786,32 @@ export function AppShell() { : {}), } satisfies CSSProperties) : null; - const shellStyle = chromeProfile + const screenOnlyStyle = + !viewportChromeProfile && chromeProfile && chromeProfile.screenWidth > 0 + ? ({ + borderRadius: `${Math.min( + chromeProfile.cornerRadius * + (DEVICE_SCREEN_WIDTH / chromeProfile.screenWidth), + DEVICE_SCREEN_WIDTH / 2, + )}px`, + } satisfies CSSProperties) + : null; + const viewportScreenStyle = chromeScreenStyle ?? screenOnlyStyle; + const shellStyle = viewportChromeProfile ? { - width: `${chromeProfile.totalWidth}px`, - height: `${chromeProfile.totalHeight}px`, + width: `${viewportChromeProfile.totalWidth}px`, + height: `${viewportChromeProfile.totalHeight}px`, } : null; const deviceFrameSize = shellSize( - deviceNaturalSize, - chromeProfile, + effectiveDeviceNaturalSize, + viewportChromeProfile, rotationQuarterTurns, ); - const naturalShellSize = shellSize(deviceNaturalSize, chromeProfile); + const naturalShellSize = shellSize( + effectiveDeviceNaturalSize, + viewportChromeProfile, + ); const deviceFrameStyle = { width: `${deviceFrameSize.width}px`, height: `${deviceFrameSize.height}px`, @@ -762,8 +820,8 @@ export function AppShell() { width: `${naturalShellSize.width}px`, height: `${naturalShellSize.height}px`, transform: buildShellRotationTransform( - deviceNaturalSize, - chromeProfile, + effectiveDeviceNaturalSize, + viewportChromeProfile, rotationQuarterTurns, ), }; @@ -877,8 +935,8 @@ export function AppShell() { nextPan, clampedScale, canvasSize, - deviceNaturalSize, - chromeProfile, + effectiveDeviceNaturalSize, + viewportChromeProfile, rotationQuarterTurns, ), ); @@ -950,8 +1008,8 @@ export function AppShell() { }, effectiveZoom, canvasSize, - deviceNaturalSize, - chromeProfile, + effectiveDeviceNaturalSize, + viewportChromeProfile, rotationQuarterTurns, ), ); @@ -1197,8 +1255,8 @@ export function AppShell() { accessibilityPickerActive={accessibilityPickerActive} accessibilityRoots={accessibilityRoots} accessibilitySelectedId={accessibilitySelectedId} - chromeProfile={chromeProfile} - chromeScreenStyle={chromeScreenStyle} + chromeProfile={viewportChromeProfile} + chromeScreenStyle={viewportScreenStyle} chromeUrl={chromeUrl} debugPanel={ debugVisible ? ( diff --git a/client/src/features/viewport/DeviceChrome.tsx b/client/src/features/viewport/DeviceChrome.tsx index 3912f4fa..d7a27e4a 100644 --- a/client/src/features/viewport/DeviceChrome.tsx +++ b/client/src/features/viewport/DeviceChrome.tsx @@ -116,7 +116,7 @@ export function DeviceChrome({ return (
] [OPTIONS] ``` +With no subcommand, `simdeck` starts a foreground workspace daemon and prints local/LAN browser URLs. A single argument selects that simulator by name or UDID in the UI. Use `-d`, `-k`, and `-r` as short aliases for detached start, stop, and restart. + Most commands automatically start or reuse the project daemon when that is the fastest path. Set `SIMDECK_SERVER_URL=http://127.0.0.1:4310` or pass `--server-url` to target a specific already-running daemon. ## Top-level commands | Command | Purpose | | ------------------------------------------------------- | ----------------------------------------------------------- | +| _(none)_ | Start a foreground UI daemon until Ctrl-C. | | `ui` | Start or reuse the project daemon and serve the browser UI. | | `daemon start/status/stop` | Manage the project daemon explicitly. | | `core-simulator ...` | Restart or manage Apple's CoreSimulator service layer. | diff --git a/docs/contributing.md b/docs/contributing.md index 6b9d6212..b608c728 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -18,7 +18,7 @@ Optional: ## First-time setup -Clone, install dependencies, and build everything: +Clone, install dependencies, and build the CLI plus browser client: ```sh git clone https://github.com/NativeScript/SimDeck.git @@ -27,7 +27,7 @@ npm install npm run build ``` -`npm install` installs JavaScript tooling only. `npm run build` rebuilds everything top-to-bottom: Rust binary, React bundle, NativeScript inspector, and test package. +`npm install` installs JavaScript tooling only. `npm run build` rebuilds the Rust binary and React bundle. Use `npm run build:all` when you also need the NativeScript inspector, React Native inspector, and `simdeck/test` outputs. ## Running locally @@ -40,7 +40,7 @@ This starts the Rust server in the background and runs the Vite dev server for t To run only the production server: ```sh -./build/simdeck ui --open +./build/simdeck ``` ## Layout @@ -156,7 +156,7 @@ npm run ci This is the normal local CI script: 1. `npm run lint` — formatting and lint checks. -2. `npm run build` — Rust + Objective-C, React client, NativeScript inspector. +2. `npm run build:all` — Rust + Objective-C, React client, NativeScript inspector, React Native inspector, and `simdeck/test`. 3. `npm run test` — Rust and TypeScript tests. 4. `npm run package:vscode-extension` — VS Code `.vsix`. diff --git a/docs/extensions/vscode.md b/docs/extensions/vscode.md index 2151576c..20298745 100644 --- a/docs/extensions/vscode.md +++ b/docs/extensions/vscode.md @@ -11,6 +11,13 @@ npm run package:vscode-extension npm run install:vscode-extension ``` +Short aliases are available too: + +```sh +npm run package:vscode +npm run install:vscode +``` + This: 1. Builds a `.vsix` at `build/vscode/simdeck-vscode.vsix`. diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 7321ac42..626e4ff2 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -16,7 +16,7 @@ Underneath all of that is the iOS Simulator itself — `CoreSimulator` for lifec ### `server/` — Rust HTTP and stream transports -Owns the public CLI shape (`simdeck ui`, `daemon`, `boot`, `shutdown`, …), daemon metadata, the HTTP API, WebTransport/WebRTC streaming, the inspector hub, log streaming, and metrics. +Owns the public CLI shape (`simdeck`, `simdeck ui`, `daemon`, `boot`, `shutdown`, …), daemon metadata, the HTTP API, WebTransport/WebRTC streaming, the inspector hub, log streaming, and metrics. Key modules: diff --git a/docs/guide/daemon.md b/docs/guide/daemon.md index c7b92ba2..a07047ac 100644 --- a/docs/guide/daemon.md +++ b/docs/guide/daemon.md @@ -4,6 +4,18 @@ 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. +Running `simdeck` with no subcommand starts a foreground workspace daemon, prints local and LAN browser URLs, and stops when the command exits. Pass a simulator name or UDID as the only argument to select it by default: + +```sh +simdeck +simdeck "iPhone 17 Pro Max" +simdeck -d +simdeck -k +simdeck -r +``` + +The shorthand flags map to detached start, kill, and restart respectively. `simdeck -k` reports the PID that was killed, and `simdeck -r` returns the same JSON shape as `simdeck daemon start`. + `simdeck daemon` is project-scoped. `simdeck service` is the optional macOS LaunchAgent wrapper for users who want an always-on daemon after login. diff --git a/docs/guide/index.md b/docs/guide/index.md index a16ece79..cf3bce21 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -21,7 +21,7 @@ SimDeck addresses all of those with one CLI, one HTTP API, one WebTransport endp SimDeck ships as a single npm package (`simdeck`) that installs: 1. **A native CLI and project daemon.** Rust + Objective-C, compiled on install. It serves the HTTP API and a self-signed WebTransport endpoint for live video frames. -2. **A bundled React client.** `simdeck ui --open` starts or reuses the daemon, renders a live Simulator surface, and ships the inspector UI. +2. **A bundled React client.** `simdeck` starts a foreground daemon and prints browser URLs; `simdeck ui --open` starts or reuses a background daemon. 3. **A JS/TS testing package.** `simdeck/test` gives app tests a small API for launching, tapping, querying accessibility state, batching actions, and taking screenshots. Optional companion packages: diff --git a/docs/guide/installation.md b/docs/guide/installation.md index a64895eb..7fcd6519 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -26,13 +26,15 @@ npm install -g simdeck This installs the launcher and bundled native binary to your global `node_modules`. After it finishes: ```sh -simdeck --help +simdeck ``` +The foreground command prints local and LAN browser URLs and stops its workspace daemon when you press Ctrl-C. You can select a simulator by name or UDID with `simdeck "iPhone 17 Pro Max"`. + The global install prints the next setup steps: ```sh -simdeck ui --open +simdeck npx skills add NativeScript/SimDeck --skill simdeck -a codex -g simdeck service on ``` @@ -82,7 +84,7 @@ build/simdeck-bin You can then run the local checkout directly: ```sh -./build/simdeck ui --open +./build/simdeck ``` Or install the local checkout globally: @@ -103,9 +105,9 @@ The client bundle ships pre-built when installed from npm. When working from sou This calls `npm install` and `npm run build` inside the `client/` workspace and writes the production bundle to `client/dist`. The Rust server serves that bundle at the HTTP root. -## Build everything +## Build from source -The root `package.json` exposes a one-shot build that compiles every component: +The root `package.json` exposes a one-shot app build for the native CLI and browser client: ```sh npm run build @@ -115,11 +117,20 @@ This runs: - `npm run build:cli` — Rust server + Objective-C bridge → `build/simdeck` - `npm run build:client` — Vite production build → `client/dist` + +Use `npm run build:all` when you also need the companion package outputs: + +```sh +npm run build:all +``` + +That adds: + - `npm run build:nativescript-inspector` — TypeScript build of the NativeScript inspector - `npm run build:react-native-inspector` — TypeScript build of the React Native inspector - `npm run build:simdeck-test` — TypeScript build of `simdeck/test` -You can also run any one of those scripts on its own. +Other convenience scripts include `npm run build:docs`, `npm run build:vscode-extension`, `npm run package:vscode`, and `npm run package:all`. You can also run any one of the component scripts on its own. ## Update or uninstall diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index d962ff70..e618aaa1 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -4,22 +4,29 @@ This guide walks you from a fresh install to a Simulator streaming in your brows ## 1. Open The UI -After [installing](/guide/installation), start or reuse the project daemon and open the browser client: +After [installing](/guide/installation), start a foreground SimDeck daemon and open one of the printed browser URLs: ```sh -simdeck ui --open +simdeck ``` -The command prints JSON: +The command prints local and LAN URLs: -```json -{ - "ok": true, - "projectRoot": "/path/to/app", - "pid": 12345, - "url": "http://127.0.0.1:4310", - "started": true -} +```text +SimDeck is running for /path/to/app +Local: http://127.0.0.1:4310/?simdeckToken=... +Network: http://192.168.1.50:4310/?simdeckToken=... +Press Ctrl-C to stop. +``` + +This foreground daemon is scoped to the current workspace and exits when the command exits. Use `simdeck ui --open` or `simdeck daemon start` when you want a reusable background daemon. + +For shorthand background lifecycle commands: + +```sh +simdeck -d # detached start +simdeck -k # kill background daemon +simdeck -r # restart background daemon ``` Two listeners run inside the daemon: @@ -36,7 +43,12 @@ simdeck list simdeck boot ``` -To focus a specific simulator in the browser, add `?device=` to the UI URL. +To focus a specific simulator by name or UDID at launch: + +```sh +simdeck "iPhone 17 Pro Max" +simdeck 9750DF52-0471-48FF-B49A-B184C4BD3A3D +``` ::: tip First-frame delay On a cold boot the daemon has to launch the Simulator, attach the private display bridge, and wait for a keyframe before video flows. The first frame typically shows up within a second; subsequent reloads of the same Simulator are near-instant. diff --git a/docs/index.md b/docs/index.md index 8c353734..6ee871a0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,7 @@ features: width: 28 height: 28 title: Browser-first simulator - details: "`simdeck ui --open` starts or reuses a project daemon and opens a React UI with live WebTransport video, touch, keyboard, hardware-button, and rotation input." + details: "`simdeck` starts a foreground project daemon and prints local/LAN URLs for a React UI with live WebTransport video, touch, keyboard, hardware-button, and rotation input." - icon: src: /icons/zap.svg width: 28 @@ -61,7 +61,7 @@ features: SimDeck packages a full simulator workflow into one cross-tool surface: -- **Stream a Simulator into a browser tab.** Run `simdeck ui --open` and use the same warm project daemon from the browser and CLI. +- **Stream a Simulator into a browser tab.** Run `simdeck` and open one of the printed URLs, or use `simdeck ui --open` for a reusable background daemon. - **Drive Simulators from JavaScript.** `simdeck/test` can launch apps, tap, wait for accessibility state, batch steps, and capture screenshots. - **Embed a Simulator in your editor.** The bundled VS Code extension opens the same surface inside a panel. - **Run Simulators on your LAN.** Bind to `0.0.0.0`, advertise a host, and connect from any other Mac, iPad, or laptop on the network. diff --git a/package.json b/package.json index 3b840ca7..71e24147 100644 --- a/package.json +++ b/package.json @@ -34,13 +34,24 @@ }, "scripts": { "build:cli": "scripts/build-cli.sh", - "build:client": "npm run --prefix client build", + "build:client": "scripts/build-client.sh", + "build:app": "npm run build:cli && npm run build:client", + "build:inspectors": "npm run build:nativescript-inspector && npm run build:react-native-inspector", "build:nativescript-inspector": "npm run --prefix packages/nativescript-inspector build", "build:react-native-inspector": "npm run --prefix packages/react-native-inspector build", "build:simdeck-test": "tsc -p packages/simdeck-test/tsconfig.json && prettier --write packages/simdeck-test/dist", - "build": "npm run build:cli && npm run build:client && npm run build:nativescript-inspector && npm run build:react-native-inspector && npm run build:simdeck-test", + "build:packages": "npm run build:inspectors && npm run build:simdeck-test", + "build:docs": "npm run docs:build", + "build:vscode-extension": "npm run package:vscode-extension", + "build:all": "npm run build:app && npm run build:packages", + "build": "npm run build:app", "package:vscode-extension": "node scripts/package-vscode-extension.mjs", + "package:vscode": "npm run package:vscode-extension", + "package:vsix": "npm run package:vscode-extension", + "package:npm": "npm pack", + "package:all": "npm run build:all && npm run package:vscode-extension && npm run package:npm", "install:vscode-extension": "node scripts/install-vscode-extension.mjs", + "install:vscode": "npm run install:vscode-extension", "format": "prettier --write . && cargo fmt --manifest-path server/Cargo.toml", "format:check": "prettier --check . && cargo fmt --manifest-path server/Cargo.toml --check", "lint": "npm run format:check && cargo clippy --manifest-path server/Cargo.toml --all-targets -- -D warnings && npm run --prefix client typecheck", @@ -49,7 +60,7 @@ "test:integration:cli:verbose": "SIMDECK_INTEGRATION_VERBOSE=1 SIMDECK_INTEGRATION_SHOW_SIMULATOR=1 node scripts/integration/cli.mjs", "test:integration:js-api": "node scripts/integration/js-api.mjs", "test:integration:js-api:verbose": "SIMDECK_INTEGRATION_SHOW_SIMULATOR=1 node scripts/integration/js-api.mjs", - "ci": "npm run lint && npm run build && npm run test && npm run package:vscode-extension", + "ci": "npm run lint && npm run build:all && npm run test && npm run package:vscode-extension", "dev": "npm run build:cli && node scripts/dev.mjs", "preview:swiftui": "node scripts/experimental/swiftui-preview.mjs", "docs:dev": "vitepress dev docs", diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index 7e7fc33e..248ad704 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -18,7 +18,15 @@ const message = ` SimDeck is installed. Open the simulator UI: - simdeck ui --open + simdeck + +Open a specific simulator: + simdeck "iPhone 17 Pro" + +Detached daemon shortcuts: + simdeck -d + simdeck -k + simdeck -r Install the Codex skill: npx skills add NativeScript/SimDeck --skill simdeck -a codex -g diff --git a/server/src/main.rs b/server/src/main.rs index e92b1b91..9aa7e9b1 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -30,7 +30,7 @@ use std::env; use std::fs; use std::hash::{Hash, Hasher}; use std::io::{self, Read, Write}; -use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, UdpSocket}; use std::path::{Path, PathBuf}; use std::process::{Command as ProcessCommand, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -52,6 +52,12 @@ const SERVER_HEALTH_WATCHDOG_FAILURE_THRESHOLD: usize = 3; #[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]" +)] +#[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." +)] #[command(version)] struct Cli { #[arg(long, global = true, hide = true)] @@ -586,11 +592,56 @@ fn stop_project_daemon() -> anyhow::Result<()> { println_json(&serde_json::json!({ "ok": true, "running": false }))?; return Ok(()); }; + terminate_daemon_metadata(&metadata)?; + println_json(&serde_json::json!({ + "ok": true, + "running": false, + "pid": metadata.pid, + "killedPid": metadata.pid + })) +} + +fn terminate_daemon_metadata(metadata: &DaemonMetadata) -> anyhow::Result<()> { let _ = ProcessCommand::new("kill") .arg(metadata.pid.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .status(); + wait_for_process_exit(metadata.pid, Duration::from_secs(3)); let _ = fs::remove_file(daemon_metadata_path_for_root(&metadata.project_root)?); - println_json(&serde_json::json!({ "ok": true, "running": false, "pid": metadata.pid })) + Ok(()) +} + +fn wait_for_process_exit(pid: u32, timeout: Duration) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if !process_exists(pid) { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn process_exists(pid: u32) -> bool { + ProcessCommand::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .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<()> { @@ -599,6 +650,16 @@ fn daemon_status() -> anyhow::Result<()> { println_json(&serde_json::json!({ "running": running, "daemon": metadata })) } +fn print_daemon_start_result(metadata: &DaemonMetadata, started: bool) -> anyhow::Result<()> { + println_json(&serde_json::json!({ + "ok": true, + "projectRoot": metadata.project_root, + "pid": metadata.pid, + "url": metadata.http_url, + "started": started + })) +} + fn wait_for_daemon(metadata: &DaemonMetadata, timeout: Duration) -> anyhow::Result<()> { let deadline = Instant::now() + timeout; while Instant::now() < deadline { @@ -687,8 +748,175 @@ fn open_browser(url: &str) -> anyhow::Result<()> { Ok(()) } +enum NoCommandAction { + Foreground(Option), + Detached, + Kill, + Restart, +} + +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()))) + } + _ => None, + } +} + +fn is_known_command(value: &str) -> bool { + matches!( + value, + "ui" | "daemon" + | "service" + | "core-simulator" + | "simctl-service" + | "list" + | "boot" + | "shutdown" + | "open-url" + | "launch" + | "toggle-appearance" + | "erase" + | "install" + | "uninstall" + | "pasteboard" + | "logs" + | "screenshot" + | "describe" + | "touch" + | "tap" + | "swipe" + | "gesture" + | "pinch" + | "rotate-gesture" + | "key" + | "key-sequence" + | "key-combo" + | "type" + | "button" + | "batch" + | "dismiss-keyboard" + | "home" + | "app-switcher" + | "rotate-left" + | "rotate-right" + | "chrome-profile" + | "help" + ) +} + +fn run_no_command_action(action: NoCommandAction) -> anyhow::Result<()> { + match action { + NoCommandAction::Foreground(selector) => run_foreground_ui(selector), + NoCommandAction::Detached => start_detached_daemon(DaemonLaunchOptions::default()), + NoCommandAction::Kill => stop_project_daemon(), + NoCommandAction::Restart => restart_detached_daemon(DaemonLaunchOptions::default()), + } +} + +fn start_detached_daemon(options: DaemonLaunchOptions) -> anyhow::Result<()> { + let (metadata, started) = ensure_project_daemon_with_status(options)?; + print_daemon_start_result(&metadata, started) +} + +fn restart_detached_daemon(options: DaemonLaunchOptions) -> anyhow::Result<()> { + if let Some(metadata) = read_daemon_metadata()? { + terminate_daemon_metadata(&metadata)?; + } + start_detached_daemon(options) +} + +fn run_foreground_ui(selector: Option) -> anyhow::Result<()> { + if let Some(metadata) = read_daemon_metadata().ok().flatten() { + if daemon_is_healthy(&metadata) { + terminate_daemon_metadata(&metadata)?; + } + } + + let project_root = project_root()?; + let port = choose_daemon_port(4310)?; + let bind = IpAddr::V4(Ipv4Addr::UNSPECIFIED); + let advertise_host = detect_lan_ip() + .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)) + .to_string(); + let access_token = auth::generate_access_token(); + 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}"), + access_token: access_token.clone(), + binary_path: executable, + started_at: now_secs(), + }; + write_daemon_metadata(&metadata)?; + + let local_url = ui_url("127.0.0.1", port, &access_token, selector.as_deref()); + let network_url = ui_url(&advertise_host, port, &access_token, selector.as_deref()); + println!("SimDeck is running for {}", project_root.display()); + println!("Local: {local_url}"); + println!("Network: {network_url}"); + println!("Press Ctrl-C to stop."); + + let result = serve_with_appkit( + port, + bind, + Some(advertise_host), + None, + VideoCodecMode::Hevc, + Some(access_token), + ); + let _ = remove_daemon_metadata_if_current(&project_root, std::process::id()); + result +} + +fn detect_lan_ip() -> Option { + for target in ["8.8.8.8:80", "1.1.1.1:80"] { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?; + if socket.connect(target).is_err() { + continue; + } + let ip = socket.local_addr().ok()?.ip(); + if !ip.is_loopback() && !ip.is_unspecified() { + return Some(ip); + } + } + None +} + +fn ui_url(host: &str, port: u16, access_token: &str, selector: Option<&str>) -> String { + let mut query = vec![format!("simdeckToken={}", percent_encode(access_token))]; + if let Some(selector) = selector.filter(|value| !value.trim().is_empty()) { + query.push(format!("device={}", percent_encode(selector.trim()))); + } + format!("http://{host}:{port}/?{}", query.join("&")) +} + +fn percent_encode(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + encoded.push(*byte as char); + } + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + fn main() -> anyhow::Result<()> { logging::init(); + if let Some(action) = no_command_action_from_args() { + return run_no_command_action(action); + } + let cli = Cli::parse(); let explicit_server_url = cli.server_url.clone(); let service_url = explicit_server_url @@ -716,13 +944,7 @@ fn main() -> anyhow::Result<()> { if open { open_browser(&metadata.http_url)?; } - println_json(&serde_json::json!({ - "ok": true, - "projectRoot": metadata.project_root, - "pid": metadata.pid, - "url": metadata.http_url, - "started": started - }))?; + print_daemon_start_result(&metadata, started)?; Ok(()) } Command::Daemon { command } => match command { @@ -740,13 +962,7 @@ fn main() -> anyhow::Result<()> { client_root, video_codec, })?; - println_json(&serde_json::json!({ - "ok": true, - "projectRoot": metadata.project_root, - "pid": metadata.pid, - "url": metadata.http_url, - "started": started - })) + print_daemon_start_result(&metadata, started) } DaemonCommand::Stop => stop_project_daemon(), DaemonCommand::Status => daemon_status(), diff --git a/skills/simdeck/SKILL.md b/skills/simdeck/SKILL.md index fa28beae..e2af4cc8 100644 --- a/skills/simdeck/SKILL.md +++ b/skills/simdeck/SKILL.md @@ -12,13 +12,20 @@ SimDeck automates iOS Simulators. Use the CLI for automation and the browser UI SimDeck uses one warm daemon per project. Check it with `simdeck daemon status`; start it or open the browser UI when needed: ```bash +simdeck +simdeck "iPhone 17 Pro Max" +simdeck -d +simdeck -k +simdeck -r simdeck daemon start simdeck ui --open -./scripts/build-cli.sh && ./build/simdeck ui --open +npm run build:cli && ./build/simdeck ui --open simdeck daemon start --video-codec h264-software simdeck ui --bind 0.0.0.0 --advertise-host 192.168.1.50 --open ``` +`simdeck` without a subcommand starts a foreground workspace daemon, prints local and LAN browser URLs, and stops on Ctrl-C. The optional single argument is a simulator name or UDID to select by default. Use `-d` for detached start, `-k` to kill the background daemon, and `-r` to restart it. + Viewer: `http://127.0.0.1:4310` or `http://127.0.0.1:4310?device=`. The viewer gets the API token automatically. Direct HTTP calls need `X-SimDeck-Token` or `Authorization: Bearer `.