Skip to content

Commit b0df48c

Browse files
committed
Support embedded client expose URLs
1 parent 5bbf2ef commit b0df48c

9 files changed

Lines changed: 145 additions & 49 deletions

File tree

client/src/api/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { API_ROOT } from "../shared/constants";
1+
import { apiUrl } from "./config";
22
import type { HealthResponse } from "./types";
33

44
export class ApiError extends Error {
@@ -32,7 +32,7 @@ export async function apiRequest<T>(
3232
options: RequestInit = {},
3333
): Promise<T> {
3434
const { headers, ...rest } = options;
35-
const response = await fetch(`${API_ROOT}${path}`, {
35+
const response = await fetch(apiUrl(path), {
3636
...rest,
3737
headers: apiHeaders(headers),
3838
});

client/src/api/config.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export interface SimDeckClientConfig {
2+
apiRoot?: string;
3+
}
4+
5+
let clientConfig: Required<SimDeckClientConfig> = {
6+
apiRoot: "",
7+
};
8+
9+
export function configureSimDeckClient(config: SimDeckClientConfig): void {
10+
clientConfig = {
11+
...clientConfig,
12+
...config,
13+
apiRoot: normalizeRoot(config.apiRoot ?? clientConfig.apiRoot),
14+
};
15+
}
16+
17+
export function apiRoot(): string {
18+
return clientConfig.apiRoot;
19+
}
20+
21+
export function apiUrl(path: string): string {
22+
const root = apiRoot();
23+
if (!root) {
24+
return path;
25+
}
26+
return `${root}${path.startsWith("/") ? path : `/${path}`}`;
27+
}
28+
29+
function normalizeRoot(root: string): string {
30+
return root.replace(/\/+$/, "");
31+
}

client/src/api/controls.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { accessTokenFromLocation, apiRequest } from "./client";
2+
import { apiUrl } from "./config";
23
import type {
34
KeyPayload,
45
LaunchPayload,
@@ -58,7 +59,7 @@ export function sendKey(udid: string, payload: KeyPayload) {
5859

5960
export function simulatorControlSocketUrl(udid: string) {
6061
const url = new URL(
61-
`/api/simulators/${encodeURIComponent(udid)}/control`,
62+
apiUrl(`/api/simulators/${encodeURIComponent(udid)}/control`),
6263
window.location.href,
6364
);
6465
const token = accessTokenFromLocation();

client/src/app/AppShell.tsx

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "react";
1010

1111
import { ApiError, accessTokenFromLocation, pairBrowser } from "../api/client";
12+
import { apiUrl, configureSimDeckClient } from "../api/config";
1213
import {
1314
bootSimulator,
1415
dismissKeyboard,
@@ -61,7 +62,6 @@ import {
6162
} from "../features/viewport/viewportMath";
6263
import {
6364
DEVICE_SCREEN_WIDTH,
64-
STREAM_ORIGIN,
6565
ZOOM_ANIMATION_MS,
6666
ZOOM_STEP,
6767
} from "../shared/constants";
@@ -102,7 +102,7 @@ function buildScreenMaskUrl(udid: string, stamp: number): string {
102102
}
103103

104104
function buildAuthenticatedAssetUrl(path: string, stamp: number): string {
105-
const url = new URL(path, `${STREAM_ORIGIN || window.location.origin}/`);
105+
const url = new URL(apiUrl(path), window.location.href);
106106
url.searchParams.set("stamp", String(stamp));
107107
const token = accessTokenFromLocation();
108108
if (token) {
@@ -172,10 +172,26 @@ type SimulatorTransition = {
172172
udid: string;
173173
};
174174

175-
export function AppShell() {
175+
export interface AppShellProps {
176+
apiRoot?: string;
177+
fixedSimulatorUDID?: string | null;
178+
hideSimulatorSelection?: boolean;
179+
pairingEnabled?: boolean;
180+
}
181+
182+
export function AppShell({
183+
apiRoot = "",
184+
fixedSimulatorUDID = null,
185+
hideSimulatorSelection = false,
186+
pairingEnabled = true,
187+
}: AppShellProps = {}) {
188+
configureSimDeckClient({ apiRoot });
176189
const [initialUiState] = useState(readPersistedUiState);
177190
const [initialSelectedUDID] = useState(
178-
() => readDeviceQueryParam() ?? initialUiState.selectedUDID,
191+
() =>
192+
fixedSimulatorUDID ??
193+
readDeviceQueryParam() ??
194+
initialUiState.selectedUDID,
179195
);
180196
const initialViewportState = initialSelectedUDID
181197
? viewportStateForUDID(initialUiState, initialSelectedUDID)
@@ -301,6 +317,14 @@ export function AppShell() {
301317
});
302318

303319
const selectedSimulator =
320+
(fixedSimulatorUDID
321+
? (simulators.find(
322+
(simulator) => simulator.udid === fixedSimulatorUDID,
323+
) ??
324+
simulators.find((simulator) =>
325+
simulatorMatchesIdentifier(simulator, fixedSimulatorUDID),
326+
))
327+
: null) ??
304328
simulators.find((simulator) => simulator.udid === selectedUDID) ??
305329
simulators.find((simulator) =>
306330
simulatorMatchesIdentifier(simulator, selectedUDID),
@@ -354,6 +378,7 @@ export function AppShell() {
354378
!selectedSimulator ||
355379
!streamError ||
356380
readDeviceQueryParam() ||
381+
fixedSimulatorUDID ||
357382
!isStreamAttachFailure(streamError)
358383
) {
359384
return;
@@ -377,7 +402,13 @@ export function AppShell() {
377402
`${selectedSimulator.name} did not expose a live simulator screen. Switched to ${nextSimulator.name}.`,
378403
);
379404
}
380-
}, [failedStreamUDIDs, selectedSimulator, simulators, streamError]);
405+
}, [
406+
failedStreamUDIDs,
407+
fixedSimulatorUDID,
408+
selectedSimulator,
409+
simulators,
410+
streamError,
411+
]);
381412
const shouldRenderChrome =
382413
selectedSimulator != null && shouldRenderNativeChrome(selectedSimulator);
383414
const viewportChromeProfile = shouldRenderChrome ? chromeProfile : null;
@@ -499,10 +530,14 @@ export function AppShell() {
499530
}, [accessibilitySelectedId, selectedSimulator?.udid]);
500531

501532
useEffect(() => {
502-
if (selectedSimulator && selectedSimulator.udid !== selectedUDID) {
533+
if (
534+
!fixedSimulatorUDID &&
535+
selectedSimulator &&
536+
selectedSimulator.udid !== selectedUDID
537+
) {
503538
setSelectedUDID(selectedSimulator.udid);
504539
}
505-
}, [selectedSimulator, selectedUDID]);
540+
}, [fixedSimulatorUDID, selectedSimulator, selectedUDID]);
506541

507542
useEffect(() => {
508543
const nextViewportState = selectedSimulator
@@ -805,7 +840,9 @@ export function AppShell() {
805840
});
806841

807842
const pairingRequired =
808-
listError === AUTH_REQUIRED_MESSAGE && !accessTokenFromLocation();
843+
pairingEnabled &&
844+
listError === AUTH_REQUIRED_MESSAGE &&
845+
!accessTokenFromLocation();
809846
const error = pairingRequired
810847
? localError || streamError
811848
: localError || streamError || listError;
@@ -1221,6 +1258,7 @@ export function AppShell() {
12211258
error={error}
12221259
filteredSimulators={filteredSimulators}
12231260
hierarchyVisible={hierarchyVisible}
1261+
hideSimulatorSelection={hideSimulatorSelection}
12241262
isLoading={isLoading}
12251263
menuOpen={menuOpen}
12261264
menuRef={menuRef}

client/src/embedded.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { AppShell as SimDeckClient } from "./app/AppShell";
2+
export type { AppShellProps as SimDeckClientProps } from "./app/AppShell";
3+
export type { SimulatorMetadata } from "./api/types";

client/src/features/simulators/SimulatorMenu.tsx

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { SimulatorRow } from "./SimulatorRow";
66
interface SimulatorMenuProps {
77
debugVisible: boolean;
88
filteredSimulators: SimulatorMetadata[];
9+
hideSimulatorSelection?: boolean;
910
isLoading: boolean;
1011
menuOpen: boolean;
1112
menuRef: RefObject<HTMLDivElement | null>;
@@ -23,6 +24,7 @@ interface SimulatorMenuProps {
2324
export function SimulatorMenu({
2425
debugVisible,
2526
filteredSimulators,
27+
hideSimulatorSelection = false,
2628
isLoading,
2729
menuOpen,
2830
menuRef,
@@ -53,29 +55,33 @@ export function SimulatorMenu({
5355
className="menu-popover"
5456
onPointerDown={(event) => event.stopPropagation()}
5557
>
56-
<input
57-
className="sidebar-search"
58-
onChange={(event) => onChangeSearch(event.target.value)}
59-
placeholder="Search simulators…"
60-
value={search}
61-
/>
62-
<div className="sim-list">
63-
{isLoading ? <p className="list-empty">Loading…</p> : null}
64-
{!isLoading && filteredSimulators.length === 0 ? (
65-
<p className="list-empty">No matches</p>
66-
) : null}
67-
{filteredSimulators.map((simulator) => (
68-
<SimulatorRow
69-
isSelected={simulator.udid === selectedSimulator?.udid}
70-
key={simulator.udid}
71-
onSelect={() => {
72-
setSelectedUDID(simulator.udid);
73-
onCloseMenu();
74-
}}
75-
simulator={simulator}
58+
{!hideSimulatorSelection ? (
59+
<>
60+
<input
61+
className="sidebar-search"
62+
onChange={(event) => onChangeSearch(event.target.value)}
63+
placeholder="Search simulators..."
64+
value={search}
7665
/>
77-
))}
78-
</div>
66+
<div className="sim-list">
67+
{isLoading ? <p className="list-empty">Loading...</p> : null}
68+
{!isLoading && filteredSimulators.length === 0 ? (
69+
<p className="list-empty">No matches</p>
70+
) : null}
71+
{filteredSimulators.map((simulator) => (
72+
<SimulatorRow
73+
isSelected={simulator.udid === selectedSimulator?.udid}
74+
key={simulator.udid}
75+
onSelect={() => {
76+
setSelectedUDID(simulator.udid);
77+
onCloseMenu();
78+
}}
79+
simulator={simulator}
80+
/>
81+
))}
82+
</div>
83+
</>
84+
) : null}
7985
{selectedSimulator ? (
8086
<>
8187
<div className="menu-divider" />

client/src/features/stream/streamWorkerClient.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { apiHeaders, fetchHealth } from "../../api/client";
2+
import { apiUrl } from "../../api/config";
23
import type { HealthResponse } from "../../api/types";
34
import { createEmptyStreamStats } from "./stats";
45
import type {
@@ -138,7 +139,10 @@ class WebRtcStreamClient implements StreamClientBackend {
138139
(video as HTMLVideoElement & { latencyHint?: string }).latencyHint =
139140
"interactive";
140141
video.srcObject = stream;
141-
canvasElement.after(video);
142+
canvasElement.parentElement?.insertBefore(
143+
video,
144+
canvasElement.nextSibling,
145+
);
142146
this.video = video;
143147
const startPlayback = () => {
144148
if (generation !== this.connectGeneration) {
@@ -459,12 +463,15 @@ class WebRtcStreamClient implements StreamClientBackend {
459463
url: window.location.href,
460464
userAgent: window.navigator.userAgent,
461465
};
462-
void fetch(new URL("/api/client-stream-stats", window.location.href), {
463-
body: JSON.stringify(payload),
464-
cache: "no-store",
465-
headers: apiHeaders(),
466-
method: "POST",
467-
}).catch(() => {
466+
void fetch(
467+
new URL(apiUrl("/api/client-stream-stats"), window.location.href),
468+
{
469+
body: JSON.stringify(payload),
470+
cache: "no-store",
471+
headers: apiHeaders(),
472+
method: "POST",
473+
},
474+
).catch(() => {
468475
// Diagnostics only.
469476
});
470477
}
@@ -583,14 +590,17 @@ function postWebRtcOffer(
583590
udid: string,
584591
localDescription: RTCSessionDescription,
585592
): Promise<Response> {
586-
return fetch(`/api/simulators/${encodeURIComponent(udid)}/webrtc/offer`, {
587-
body: JSON.stringify({
588-
sdp: localDescription.sdp,
589-
type: localDescription.type,
590-
}),
591-
headers: apiHeaders(),
592-
method: "POST",
593-
});
593+
return fetch(
594+
apiUrl(`/api/simulators/${encodeURIComponent(udid)}/webrtc/offer`),
595+
{
596+
body: JSON.stringify({
597+
sdp: localDescription.sdp,
598+
type: localDescription.type,
599+
}),
600+
headers: apiHeaders(),
601+
method: "POST",
602+
},
603+
);
594604
}
595605

596606
function configureLowLatencyReceiver(receiver: RTCRtpReceiver) {

client/src/features/stream/useLiveStream.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useRef, useState } from "react";
22

33
import { apiHeaders } from "../../api/client";
4+
import { apiUrl } from "../../api/config";
45
import type { SimulatorMetadata } from "../../api/types";
56
import type { Size } from "../viewport/types";
67
import { createEmptyStreamStats } from "./stats";
@@ -57,7 +58,10 @@ function createClientTelemetryId(): string {
5758
}
5859

5960
function buildClientTelemetryUrl(): string {
60-
return new URL("/api/client-stream-stats", window.location.href).toString();
61+
return new URL(
62+
apiUrl("/api/client-stream-stats"),
63+
window.location.href,
64+
).toString();
6165
}
6266

6367
export function useLiveStream({

client/src/features/toolbar/Toolbar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface ToolbarProps {
88
error: string;
99
filteredSimulators: SimulatorMetadata[];
1010
hierarchyVisible: boolean;
11+
hideSimulatorSelection?: boolean;
1112
isLoading: boolean;
1213
onBoot: () => void;
1314
onChangeSearch: (value: string) => void;
@@ -40,6 +41,7 @@ export function Toolbar({
4041
error,
4142
filteredSimulators,
4243
hierarchyVisible,
44+
hideSimulatorSelection = false,
4345
isLoading,
4446
menuOpen,
4547
menuRef,
@@ -98,6 +100,7 @@ export function Toolbar({
98100
<SimulatorMenu
99101
debugVisible={debugVisible}
100102
filteredSimulators={filteredSimulators}
103+
hideSimulatorSelection={hideSimulatorSelection}
101104
isLoading={isLoading}
102105
menuOpen={menuOpen}
103106
menuRef={menuRef}

0 commit comments

Comments
 (0)