diff --git a/packages/serve-sim/README.md b/packages/serve-sim/README.md index 62a1d027..8e918588 100644 --- a/packages/serve-sim/README.md +++ b/packages/serve-sim/README.md @@ -39,6 +39,7 @@ Requires macOS with Xcode command line tools (`xcrun simctl`) and Node.js 18+. ` ``` serve-sim [device...] Start preview server (default: localhost:3200) +serve-sim share [device...] Start preview and expose it with Tailscale Serve serve-sim --no-preview [device...] Stream in foreground without a preview server serve-sim gesture '' [-d udid] Send a touch gesture serve-sim button [name] [-d udid] Send a button press (default: home) @@ -88,6 +89,9 @@ Camera options (used with `serve-sim camera `): ```sh serve-sim # auto-detect booted sim, open preview serve-sim "iPhone 16 Pro" # target a specific device +serve-sim share # private tailnet URL via Tailscale Serve +serve-sim share "iPhone 16 Pro" --public + # public URL via Tailscale Funnel serve-sim --detach # start a background helper, return JSON serve-sim --list # show running streams serve-sim --kill # stop all helpers @@ -118,6 +122,12 @@ serve-sim camera --stop-webcam Multiple booted simulators are supported — pass several device names, or leave it empty to attach to all of them. +### Remote access with Tailscale + +`serve-sim share [devices...]` starts the normal preview server and publishes it with [Tailscale Serve](https://tailscale.com/kb/1247/funnel-serve-use-cases) so other devices in your private tailnet can open it. Private tailnet Serve is the default. Use `--public` only when you explicitly want Tailscale Funnel. + +> **Security:** the shared URL exposes live simulator video and browser controls. Anyone who can open it can interact with the simulator. Share only with trusted users and stop the server when done. + ### Camera `serve-sim camera ` replaces the simulator's camera feed for a single app. A small host-side helper writes BGRA frames into a POSIX shared-memory region; an injected dylib (`DYLD_INSERT_LIBRARIES`) swizzles AVFoundation inside the simulator process so the app reads from that region instead of the simulator's stub camera. diff --git a/packages/serve-sim/src/index.ts b/packages/serve-sim/src/index.ts index cbd65529..875caf08 100755 --- a/packages/serve-sim/src/index.ts +++ b/packages/serve-sim/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { Command } from "commander"; -import { execSync, spawn as nodeSpawn, type ChildProcess } from "child_process"; +import { execFileSync, execSync, spawn as nodeSpawn, type ChildProcess } from "child_process"; import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readSync, readFileSync, unlinkSync, writeFileSync } from "fs"; import { createHash } from "crypto"; import { homedir, networkInterfaces } from "os"; @@ -1780,7 +1780,15 @@ Examples: // ─── Serve preview ─── -async function serve(servePort: number, devices: string[], portExplicit: boolean, host: string) { +type PreviewReadyInfo = { host: string; port: number; localUrl: string }; + +async function serve( + servePort: number, + devices: string[], + portExplicit: boolean, + host: string, + onReady?: (info: PreviewReadyInfo) => void | Promise, +) { let targetDevice: string | undefined; if (devices.length > 0) { @@ -1831,6 +1839,9 @@ async function serve(servePort: number, devices: string[], portExplicit: boolean process.exit(1); } + const localUrl = `http://localhost:${boundPort}`; + await onReady?.({ host, port: boundPort, localUrl }); + const exposedToLan = host !== "127.0.0.1" && host !== "localhost" && host !== "::1"; const networkIP = getLocalNetworkIP(); console.log(""); @@ -1854,6 +1865,109 @@ function bindPreviewServer(port: number, middleware: ReturnType { + if (cleaned) return; + cleaned = true; + try { + if (tailscaleTargets(command, target)) { + execFileSync("tailscale", [command, "--bg", target, "off"], { + stdio: "ignore", + timeout: 5_000, + }); + } + } catch {} + }; + + process.once("SIGINT", () => { + cleanup(); + process.exit(0); + }); + process.once("SIGTERM", () => { + cleanup(); + process.exit(0); + }); + process.once("exit", cleanup); +} + +function tailscaleTargets(command: "serve" | "funnel", target: string): boolean { + const output = execFileSync("tailscale", [command, "status", "--json"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5_000, + }); + return collectStrings(JSON.parse(output)).some((value) => matchesServeTarget(value, target)); +} + +function collectStrings(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap(collectStrings); + if (value && typeof value === "object") { + return Object.entries(value).flatMap(([key, child]) => [key, ...collectStrings(child)]); + } + return []; +} + +function matchesServeTarget(value: string, target: string): boolean { + if (value === target || value === target.replace("http://", "")) return true; + try { + const url = new URL(value); + return url.protocol === "http:" && url.origin === target && (url.pathname === "" || url.pathname === "/"); + } catch { + return false; + } +} + +function formatHostForUrl(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + +async function share( + servePort: number, + devices: string[], + portExplicit: boolean, + host: string, + publicFunnel: boolean, +) { + const shareHost = host === "0.0.0.0" ? "127.0.0.1" : host; + await serve(servePort, devices, portExplicit, host, ({ port }) => { + const target = `http://${formatHostForUrl(shareHost)}:${port}`; + const command = publicFunnel ? "funnel" : "serve"; + execFileSync("tailscale", [command, "--bg", target], { + stdio: "ignore", + timeout: 10_000, + }); + installTailscaleShareCleanup(command, target); + + const name = getTailscaleName(); + const url = name ? `https://${name}` : "(run `tailscale serve status` for URL)"; + const mode = publicFunnel ? "public Funnel" : "private tailnet"; + console.log(`\nTailscale ${mode}: ${url}`); + console.log( + "Warning: this URL exposes simulator video and controls. Share only with trusted users.", + ); + }); +} + // ─── Main ─── const program = new Command(); @@ -1907,7 +2021,18 @@ Examples: await follow(devices, startPort ?? 3100, !!opts.quiet); } else { await serve(startPort ?? 3200, devices, startPort !== undefined, opts.host); - } + } +}); + +program + .command("share") + .description("Expose preview over Tailscale Serve (private tailnet by default)") + .argument("[devices...]", "Simulator(s) target (udid or name; default: booted)") + .option("-p, --port ", "Preview port (default: 3200)", (v) => parseInt(v, 10)) + .option("--host ", "Interface bind preview server to", "127.0.0.1") + .option("--public", "Use Tailscale Funnel instead of private tailnet Serve") + .action(async (devices: string[], opts) => { + await share(opts.port ?? 3200, devices, opts.port !== undefined, opts.host, !!opts.public); }); const deviceOpt = ["-d, --device ", "Target a specific simulator (udid or name)"] as const;