Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/serve-sim/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<json>' [-d udid] Send a touch gesture
serve-sim button [name] [-d udid] Send a button press (default: home)
Expand Down Expand Up @@ -88,6 +89,9 @@ Camera options (used with `serve-sim camera <bundle-id>`):
```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
Expand Down Expand Up @@ -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 <bundle-id>` 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.
Expand Down
131 changes: 128 additions & 3 deletions packages/serve-sim/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<void>,
) {
let targetDevice: string | undefined;

if (devices.length > 0) {
Expand Down Expand Up @@ -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("");
Expand All @@ -1854,6 +1865,109 @@ function bindPreviewServer(port: number, middleware: ReturnType<typeof import(".
return servePreview({ port, middleware, host });
}

function getTailscaleName(): string | null {
try {
const output = execFileSync("tailscale", ["status", "--json"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 5_000,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const status = JSON.parse(output) as {
BackendState?: string;
Self?: { DNSName?: string; HostName?: string };
};
if (status.BackendState && status.BackendState !== "Running") return null;
return status.Self?.DNSName?.replace(/\.$/, "") || status.Self?.HostName || null;
} catch {
return null;
}
}

function installTailscaleShareCleanup(command: "serve" | "funnel", target: string): void {
let cleaned = false;
const cleanup = () => {
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";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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();
Expand Down Expand Up @@ -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 <port>", "Preview port (default: 3200)", (v) => parseInt(v, 10))
.option("--host <addr>", "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 <udid>", "Target a specific simulator (udid or name)"] as const;
Expand Down