Skip to content

Commit 2580537

Browse files
committed
Add LAN pairing and WebRTC fallback support
1 parent 0fdd31e commit 2580537

27 files changed

Lines changed: 834 additions & 199 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Run the local daemon:
9999
./build/simdeck daemon start --port 4310
100100
```
101101

102-
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.
102+
Running without a subcommand starts a foreground workspace daemon, prints local and LAN HTTP URLs, prints a six-digit pairing code for LAN browsers, and stops when the command exits, when you press `q`, or when you press Ctrl-C. 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.
103103

104104
Use software H.264 when macOS screen recording starves the hardware encoder:
105105

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Full documentation lives at [simdeck.nativescript.org](https://simdeck.nativescr
3737
simdeck
3838
```
3939

40-
This starts a workspace-local foreground daemon, prints local and LAN browser URLs, and stops when you press Ctrl-C.
40+
This starts a workspace-local foreground daemon, prints local and LAN HTTP URLs plus a pairing code for LAN browsers, and stops when you press `q` or Ctrl-C.
4141
To focus a specific simulator by name or UDID, pass it as the only argument:
4242

4343
```sh
@@ -46,6 +46,7 @@ simdeck "iPhone 17 Pro Max"
4646

4747
Use `simdeck ui --open` or `simdeck daemon start` when you want a reusable background daemon instead.
4848
The no-subcommand lifecycle shortcuts are `simdeck -d` for detached start, `simdeck -k` to kill the background daemon, and `simdeck -r` to restart it.
49+
The served loopback browser UI receives the generated API access token automatically. LAN browsers pair with the printed code before receiving the API cookie.
4950

5051
CLI commands automatically use the same warm daemon:
5152

cli/DFPrivateSimulatorDisplayBridge.m

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
#import <mach-o/dyld.h>
88
#import <mach-o/loader.h>
99
#import <mach-o/nlist.h>
10+
#import <limits.h>
1011
#import <math.h>
1112
#import <objc/message.h>
1213
#import <objc/runtime.h>
1314
#import <stdarg.h>
15+
#import <stdio.h>
1416

1517
// PurpleWorkspacePort mach message IDs / GSEvent type constants.
1618
// Reverse-engineered from Simulator.app ARM64 (Xcode 26.2) — see idb's
@@ -20,7 +22,6 @@
2022
#define DFGSEventTypeDeviceOrientationChanged 50
2123

2224
static NSString * const DFPrivateSimulatorErrorDomain = @"SimDeck.PrivateSimulator";
23-
static NSString * const DFSimulatorKitPath = @"/Applications/Xcode.app/Contents/Developer/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit";
2425
static NSString * const DFCoreSimulatorPath = @"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator";
2526
static NSString * const DFPrivateSimulatorLogPath = @"/tmp/simdeck-private-bridge.log";
2627
static const void *DFPrivateSimulatorCallbackQueueKey = &DFPrivateSimulatorCallbackQueueKey;
@@ -104,6 +105,29 @@
104105
static const NSUInteger DFKeyboardModifierCommand = 1 << 3;
105106
static const NSUInteger DFKeyboardModifierCapsLock = 1 << 4;
106107

108+
static NSString *DFSimulatorKitExecutablePath(void) {
109+
const char *developerDir = getenv("DEVELOPER_DIR");
110+
if (developerDir != NULL && developerDir[0] != '\0') {
111+
return [[NSString stringWithUTF8String:developerDir] stringByAppendingPathComponent:@"Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"];
112+
}
113+
114+
FILE *pipe = popen("/usr/bin/xcode-select -p 2>/dev/null", "r");
115+
if (pipe != NULL) {
116+
char buffer[PATH_MAX] = {0};
117+
if (fgets(buffer, sizeof(buffer), pipe) != NULL) {
118+
NSString *selected = [[NSString stringWithUTF8String:buffer] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
119+
pclose(pipe);
120+
if (selected.length > 0) {
121+
return [selected stringByAppendingPathComponent:@"Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"];
122+
}
123+
} else {
124+
pclose(pipe);
125+
}
126+
}
127+
128+
return @"/Applications/Xcode.app/Contents/Developer/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit";
129+
}
130+
107131
typedef struct {
108132
__unsafe_unretained id unit;
109133
double value;
@@ -298,6 +322,7 @@ static void DFTrieDescend(DFTrieContext *ctx, const uint8_t *node, size_t nameLe
298322
}
299323

300324
const struct linkedit_data_command *exportsTrie = NULL;
325+
const struct symtab_command *symtab = NULL;
301326
const struct segment_command_64 *linkedit = NULL;
302327
uint64_t dyldInfoExportOff = 0;
303328
uint64_t dyldInfoExportSize = 0;
@@ -315,6 +340,9 @@ static void DFTrieDescend(DFTrieContext *ctx, const uint8_t *node, size_t nameLe
315340
dyldInfoExportSize = info->export_size;
316341
break;
317342
}
343+
case LC_SYMTAB:
344+
symtab = (const struct symtab_command *)lc;
345+
break;
318346
case LC_SEGMENT_64: {
319347
const struct segment_command_64 *seg = (const struct segment_command_64 *)lc;
320348
if (strcmp(seg->segname, "__LINKEDIT") == 0) {
@@ -356,20 +384,51 @@ static void DFTrieDescend(DFTrieContext *ctx, const uint8_t *node, size_t nameLe
356384
return NULL;
357385
}
358386

359-
DFTrieContext ctx = {0};
360-
ctx.trie = trie;
361-
ctx.trieEnd = trie + trieSize;
362-
ctx.prefix = prefixedBuf;
363-
ctx.prefixLen = (size_t)written;
364-
ctx.suffix = suffix ?: "";
365-
ctx.suffixLen = suffix ? strlen(suffix) : 0;
387+
if (trieFileOff > 0 && trieSize > 0) {
388+
DFTrieContext ctx = {0};
389+
ctx.trie = trie;
390+
ctx.trieEnd = trie + trieSize;
391+
ctx.prefix = prefixedBuf;
392+
ctx.prefixLen = (size_t)written;
393+
ctx.suffix = suffix ?: "";
394+
ctx.suffixLen = suffix ? strlen(suffix) : 0;
366395

367-
DFTrieDescend(&ctx, trie, 0);
396+
DFTrieDescend(&ctx, trie, 0);
368397

369-
if (!ctx.found) {
370-
return NULL;
398+
if (ctx.found) {
399+
return (void *)((uintptr_t)gSimulatorKitImage + (uintptr_t)ctx.address);
400+
}
401+
}
402+
403+
// Some SimulatorKit Swift accessors are global symbols but are absent from
404+
// the dyld exports trie on GitHub's hosted Xcode images. Fall back to the
405+
// symbol table so prefix lookups still find those private Swift getters.
406+
if (symtab != NULL && symtab->nsyms > 0 && symtab->symoff > 0 && symtab->stroff > 0) {
407+
const struct nlist_64 *symbols = (const struct nlist_64 *)(linkeditMapped + (uintptr_t)symtab->symoff);
408+
const char *strings = (const char *)(linkeditMapped + (uintptr_t)symtab->stroff);
409+
size_t prefixLen = (size_t)written;
410+
size_t suffixLen = suffix ? strlen(suffix) : 0;
411+
for (uint32_t i = 0; i < symtab->nsyms; i++) {
412+
const struct nlist_64 *entry = &symbols[i];
413+
if ((entry->n_type & N_STAB) != 0 || entry->n_un.n_strx == 0 || entry->n_value == 0) {
414+
continue;
415+
}
416+
const char *name = strings + entry->n_un.n_strx;
417+
size_t nameLen = strlen(name);
418+
if (nameLen < prefixLen + suffixLen) {
419+
continue;
420+
}
421+
if (memcmp(name, prefixedBuf, prefixLen) != 0) {
422+
continue;
423+
}
424+
if (suffixLen > 0 && memcmp(name + nameLen - suffixLen, suffix, suffixLen) != 0) {
425+
continue;
426+
}
427+
return (void *)((uintptr_t)entry->n_value + (uintptr_t)gSimulatorKitSlide);
428+
}
371429
}
372-
return (void *)((uintptr_t)gSimulatorKitImage + (uintptr_t)ctx.address);
430+
431+
return NULL;
373432
}
374433

375434
// Cache resolved function pointers per (prefix, suffix). Logs once per missing
@@ -542,18 +601,34 @@ static id DFInitSimDeviceScreen(Class screenClass, id device, uint32_t screenID,
542601
}
543602

544603
static NSDictionary<NSNumber *, id> * DFReadAdapterScreens(id adapter) {
604+
if (adapter == nil || ![NSStringFromClass([adapter class]) containsString:@"SimDeviceScreenAdapter"]) {
605+
return @{};
606+
}
607+
545608
// The full mangled tail of `SimDeviceScreenAdapter.screens.getter` drifts
546609
// across Xcode releases (Xcode 26.4 retyped it from
547610
// `[UInt32: SimScreen]` (ObjC) to `[UInt32: SimDeviceScreen]` (Swift)).
548611
// Resolve by stable prefix instead. If the values are now SimDeviceScreen
549612
// wrappers, unwrap each via `.screen` so callers keep talking to a
550613
// SimScreen-shaped object.
551-
id screens = DFCallSwiftSelfGetterByPattern(
614+
id screens = DFCallSwiftSelfGetter(
552615
adapter,
553-
"$s12SimulatorKit22SimDeviceScreenAdapterC7screens",
554-
"vg",
555-
"SimDeviceScreenAdapter.screens.getter"
616+
"$s12SimulatorKit22SimDeviceScreenAdapterC7screensSDys6UInt32VSo0cE0_pGvg"
556617
);
618+
if (screens == nil) {
619+
screens = DFCallSwiftSelfGetter(
620+
adapter,
621+
"$s12SimulatorKit22SimDeviceScreenAdapterC7screensSDys6UInt32VAA0cdE0CGvg"
622+
);
623+
}
624+
if (screens == nil) {
625+
screens = DFCallSwiftSelfGetterByPattern(
626+
adapter,
627+
"$s12SimulatorKit22SimDeviceScreenAdapterC7screens",
628+
"vg",
629+
"SimDeviceScreenAdapter.screens.getter"
630+
);
631+
}
557632
if (![screens isKindOfClass:[NSDictionary class]]) {
558633
return @{};
559634
}
@@ -2025,10 +2100,11 @@ + (BOOL)loadPrivateFrameworks:(NSError **)error {
20252100
return;
20262101
}
20272102

2028-
if (!dlopen(DFSimulatorKitPath.fileSystemRepresentation, RTLD_NOW | RTLD_GLOBAL)) {
2103+
NSString *simulatorKitPath = DFSimulatorKitExecutablePath();
2104+
if (!dlopen(simulatorKitPath.fileSystemRepresentation, RTLD_NOW | RTLD_GLOBAL)) {
20292105
loadError = DFMakeError(
20302106
DFPrivateSimulatorErrorCodeFrameworkLoadFailed,
2031-
[NSString stringWithFormat:@"Unable to load SimulatorKit from %@.", DFSimulatorKitPath]
2107+
[NSString stringWithFormat:@"Unable to load SimulatorKit from %@.", simulatorKitPath]
20322108
);
20332109
}
20342110
});

client/src/api/client.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { API_ROOT } from "../shared/constants";
22

3+
export class ApiError extends Error {
4+
constructor(
5+
message: string,
6+
readonly status: number,
7+
) {
8+
super(message);
9+
this.name = "ApiError";
10+
}
11+
}
12+
313
export function accessTokenFromLocation(): string {
414
if (typeof window === "undefined") {
515
return "";
@@ -35,7 +45,7 @@ export async function apiRequest<T>(
3545
} else {
3646
message = await response.text();
3747
}
38-
throw new Error(message);
48+
throw new ApiError(message, response.status);
3949
}
4050

4151
if (response.status === 204) {
@@ -48,3 +58,10 @@ export async function apiRequest<T>(
4858

4959
return (await response.text()) as T;
5060
}
61+
62+
export async function pairBrowser(code: string): Promise<void> {
63+
await apiRequest<{ ok: boolean }>("/api/pair", {
64+
body: JSON.stringify({ code }),
65+
method: "POST",
66+
});
67+
}

client/src/app/AppShell.tsx

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import {
55
useRef,
66
useState,
77
type CSSProperties,
8+
type FormEvent,
89
} from "react";
910

10-
import { accessTokenFromLocation } from "../api/client";
11+
import { ApiError, accessTokenFromLocation, pairBrowser } from "../api/client";
1112
import {
1213
bootSimulator,
1314
dismissKeyboard,
@@ -86,6 +87,7 @@ const ACCESSIBILITY_REFRESH_MS = 1500;
8687
const REACT_NATIVE_ACCESSIBILITY_REFRESH_MS = 500;
8788
const DEFAULT_ACCESSIBILITY_MAX_DEPTH = 10;
8889
const LOGICAL_INSPECTOR_MAX_DEPTH = 80;
90+
const AUTH_REQUIRED_MESSAGE = "SimDeck API access token is required.";
8991

9092
clearLegacyVolatileUiState();
9193

@@ -192,6 +194,9 @@ export function AppShell() {
192194
);
193195
const [menuOpen, setMenuOpen] = useState(false);
194196
const [localError, setLocalError] = useState("");
197+
const [pairingCode, setPairingCode] = useState("");
198+
const [pairingError, setPairingError] = useState("");
199+
const [pairingBusy, setPairingBusy] = useState(false);
195200
const [simulatorTransition, setSimulatorTransition] =
196201
useState<SimulatorTransition | null>(null);
197202
const [rotationQuarterTurns, setRotationQuarterTurns] = useState(
@@ -325,6 +330,8 @@ export function AppShell() {
325330
runtimeInfo,
326331
stats,
327332
status: streamStatus,
333+
streamBackend,
334+
streamCanvasKey,
328335
} = useLiveStream({
329336
canvasElement: streamCanvasElement,
330337
simulator: selectedSimulator,
@@ -749,7 +756,11 @@ export function AppShell() {
749756
setPan,
750757
});
751758

752-
const error = localError || streamError || listError;
759+
const pairingRequired =
760+
listError === AUTH_REQUIRED_MESSAGE && !accessTokenFromLocation();
761+
const error = pairingRequired
762+
? localError || streamError
763+
: localError || streamError || listError;
753764
const deviceTransform = `translate(${pan.x}px, ${pan.y + autoViewportOffsetY}px) scale(${effectiveZoom})`;
754765
const chromeScreenRect = computeChromeScreenRect(
755766
viewportChromeProfile,
@@ -898,7 +909,11 @@ export function AppShell() {
898909
}
899910

900911
useEffect(() => {
901-
if (selectedSimulator?.isBooted && !isWebRtcStreamMode()) {
912+
if (
913+
selectedSimulator?.isBooted &&
914+
!isWebRtcStreamMode() &&
915+
streamBackend !== "webrtc"
916+
) {
902917
ensureControlSocket(selectedSimulator.udid);
903918
} else {
904919
closeControlSocket();
@@ -908,6 +923,7 @@ export function AppShell() {
908923
ensureControlSocket,
909924
selectedSimulator?.isBooted,
910925
selectedSimulator?.udid,
926+
streamBackend,
911927
]);
912928

913929
useEffect(() => closeControlSocket, [closeControlSocket]);
@@ -1117,8 +1133,58 @@ export function AppShell() {
11171133
);
11181134
}
11191135

1136+
async function submitPairing(event: FormEvent<HTMLFormElement>) {
1137+
event.preventDefault();
1138+
const code = pairingCode.trim();
1139+
if (!code) {
1140+
setPairingError("Enter the pairing code from the SimDeck terminal.");
1141+
return;
1142+
}
1143+
setPairingBusy(true);
1144+
setPairingError("");
1145+
try {
1146+
await pairBrowser(code);
1147+
setPairingCode("");
1148+
await refresh();
1149+
} catch (error) {
1150+
setPairingError(
1151+
error instanceof ApiError && error.status === 401
1152+
? "Pairing code did not match."
1153+
: error instanceof Error
1154+
? error.message
1155+
: "Pairing failed.",
1156+
);
1157+
} finally {
1158+
setPairingBusy(false);
1159+
}
1160+
}
1161+
11201162
return (
11211163
<div className="app">
1164+
{pairingRequired ? (
1165+
<div className="pairing-gate" role="dialog" aria-modal="true">
1166+
<form className="pairing-panel" onSubmit={submitPairing}>
1167+
<h2>Pair SimDeck</h2>
1168+
<p>Enter the pairing code shown in the SimDeck terminal.</p>
1169+
<input
1170+
autoComplete="one-time-code"
1171+
autoFocus
1172+
inputMode="numeric"
1173+
onChange={(event) => setPairingCode(event.target.value)}
1174+
placeholder="000 000"
1175+
value={pairingCode}
1176+
/>
1177+
{pairingError ? <span>{pairingError}</span> : null}
1178+
<button
1179+
className="tbtn accent"
1180+
disabled={pairingBusy}
1181+
type="submit"
1182+
>
1183+
Pair
1184+
</button>
1185+
</form>
1186+
</div>
1187+
) : null}
11221188
<Toolbar
11231189
closeMenu={() => setMenuOpen(false)}
11241190
debugVisible={debugVisible}
@@ -1314,6 +1380,8 @@ export function AppShell() {
13141380
selectedSimulator={selectedSimulator}
13151381
shellStyle={shellStyle}
13161382
streamCanvasRef={handleStreamCanvasRef}
1383+
streamBackend={streamBackend}
1384+
streamCanvasKey={streamCanvasKey}
13171385
statusOverlayLabel={simulatorStatusOverlayLabel}
13181386
touchIndicators={touchIndicators}
13191387
touchOverlayVisible={touchOverlayVisible}

0 commit comments

Comments
 (0)