From 14a5e814ccc9c25450bf659ce8ec574f3ff9c3fc Mon Sep 17 00:00:00 2001 From: Brahim Hamichan Date: Sun, 14 Jun 2026 15:31:01 -0400 Subject: [PATCH 1/2] Add Tailscale share command --- packages/serve-sim/README.md | 10 +++ packages/serve-sim/src/index.ts | 119 +++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/packages/serve-sim/README.md b/packages/serve-sim/README.md index 62a1d027..7dfa6733 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 [device]` 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..8a1a4faf 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,97 @@ function bindPreviewServer(port: number, middleware: ReturnType { + if (cleaned) return; + cleaned = true; + try { + if (tailscaleServeTargets(target)) { + execFileSync("tailscale", ["serve", "clear", "https:443"], { stdio: "ignore" }); + } + } catch {} + }; + + process.once("SIGINT", () => { + cleanup(); + process.exit(0); + }); + process.once("SIGTERM", () => { + cleanup(); + process.exit(0); + }); + process.once("exit", cleanup); +} + +function tailscaleServeTargets(target: string): boolean { + const output = execFileSync("tailscale", ["serve", "status", "--json"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }); + 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; + } +} + +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://${shareHost}:${port}`; + const command = publicFunnel ? "funnel" : "serve"; + execFileSync("tailscale", [command, "--bg", target], { stdio: "ignore" }); + installTailscaleShareCleanup(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 +2009,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; From cfb507da09f848a7244e487fca3740479a60068b Mon Sep 17 00:00:00 2001 From: Brahim Hamichan Date: Sun, 14 Jun 2026 17:18:23 -0400 Subject: [PATCH 2/2] Address PR review feedback --- packages/serve-sim/README.md | 2 +- packages/serve-sim/src/index.ts | 28 ++++++++++++++++++++-------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/serve-sim/README.md b/packages/serve-sim/README.md index 7dfa6733..8e918588 100644 --- a/packages/serve-sim/README.md +++ b/packages/serve-sim/README.md @@ -124,7 +124,7 @@ Multiple booted simulators are supported — pass several device names, or leave ### Remote access with Tailscale -`serve-sim share [device]` 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. +`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. diff --git a/packages/serve-sim/src/index.ts b/packages/serve-sim/src/index.ts index 8a1a4faf..875caf08 100755 --- a/packages/serve-sim/src/index.ts +++ b/packages/serve-sim/src/index.ts @@ -1870,6 +1870,7 @@ function getTailscaleName(): string | null { const output = execFileSync("tailscale", ["status", "--json"], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], + timeout: 5_000, }); const status = JSON.parse(output) as { BackendState?: string; @@ -1882,14 +1883,17 @@ function getTailscaleName(): string | null { } } -function installTailscaleShareCleanup(target: string): void { +function installTailscaleShareCleanup(command: "serve" | "funnel", target: string): void { let cleaned = false; const cleanup = () => { if (cleaned) return; cleaned = true; try { - if (tailscaleServeTargets(target)) { - execFileSync("tailscale", ["serve", "clear", "https:443"], { stdio: "ignore" }); + if (tailscaleTargets(command, target)) { + execFileSync("tailscale", [command, "--bg", target, "off"], { + stdio: "ignore", + timeout: 5_000, + }); } } catch {} }; @@ -1905,10 +1909,11 @@ function installTailscaleShareCleanup(target: string): void { process.once("exit", cleanup); } -function tailscaleServeTargets(target: string): boolean { - const output = execFileSync("tailscale", ["serve", "status", "--json"], { +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)); } @@ -1932,6 +1937,10 @@ function matchesServeTarget(value: string, target: string): boolean { } } +function formatHostForUrl(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + async function share( servePort: number, devices: string[], @@ -1941,10 +1950,13 @@ async function share( ) { const shareHost = host === "0.0.0.0" ? "127.0.0.1" : host; await serve(servePort, devices, portExplicit, host, ({ port }) => { - const target = `http://${shareHost}:${port}`; + const target = `http://${formatHostForUrl(shareHost)}:${port}`; const command = publicFunnel ? "funnel" : "serve"; - execFileSync("tailscale", [command, "--bg", target], { stdio: "ignore" }); - installTailscaleShareCleanup(target); + 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)";