Skip to content

Commit c5b08d2

Browse files
committed
Improve SimDeck stream readiness signals
1 parent 4778e42 commit c5b08d2

12 files changed

Lines changed: 370 additions & 25 deletions

File tree

client/src/api/simulators.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
InspectorRequestResponse,
88
SimulatorLogsResponse,
99
SimulatorMetadata,
10+
SimulatorStateResponse,
1011
SimulatorsResponse,
1112
WebKitTargetDiscovery,
1213
} from "./types";
@@ -22,6 +23,16 @@ export async function fetchChromeProfile(udid: string): Promise<ChromeProfile> {
2223
return apiRequest<ChromeProfile>(`/api/simulators/${udid}/chrome-profile`);
2324
}
2425

26+
export async function fetchSimulatorState(
27+
udid: string,
28+
options: RequestInit = {},
29+
): Promise<SimulatorStateResponse> {
30+
return apiRequest<SimulatorStateResponse>(
31+
`/api/simulators/${encodeURIComponent(udid)}/state`,
32+
options,
33+
);
34+
}
35+
2536
export async function fetchAccessibilityTree(
2637
udid: string,
2738
source: AccessibilitySourcePreference = "auto",

client/src/api/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface PrivateDisplayInfo {
2222
displayHeight: number;
2323
encoder?: EncoderStats;
2424
frameSequence: number;
25+
lastFrameAt?: number;
2526
rotationQuarterTurns?: number;
2627
}
2728

@@ -110,6 +111,24 @@ export interface SimulatorResponse {
110111
simulator: SimulatorMetadata;
111112
}
112113

114+
export interface SimulatorForegroundApp {
115+
appName?: string | null;
116+
bundleIdentifier?: string | null;
117+
processIdentifier: number;
118+
}
119+
120+
export interface SimulatorStateResponse {
121+
booted: boolean;
122+
displayReady: boolean;
123+
displayStatus: string;
124+
foregroundApp?: SimulatorForegroundApp | null;
125+
frameSequence: number;
126+
lastFrameAgeMs?: number | null;
127+
lastFrameAt: number;
128+
simulator: SimulatorMetadata;
129+
udid: string;
130+
}
131+
113132
export interface ChromeProfile {
114133
totalWidth: number;
115134
totalHeight: number;

client/src/app/AppShell.tsx

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,19 @@ import {
3030
toggleAppearance,
3131
type ControlMessage,
3232
} from "../api/controls";
33-
import { fetchAccessibilityTree, fetchChromeProfile } from "../api/simulators";
33+
import {
34+
fetchAccessibilityTree,
35+
fetchChromeProfile,
36+
fetchSimulatorState,
37+
} from "../api/simulators";
3438
import type {
3539
AccessibilityNode,
3640
AccessibilitySource,
3741
AccessibilitySourcePreference,
3842
AccessibilityTreeResponse,
3943
ChromeProfile,
4044
SimulatorMetadata,
45+
SimulatorStateResponse,
4146
TouchPhase,
4247
} from "../api/types";
4348
import { AccessibilityInspector } from "../features/accessibility/AccessibilityInspector";
@@ -430,6 +435,8 @@ export function AppShell({
430435
const [streamConfigApplyKey, setStreamConfigApplyKey] = useState(0);
431436
const [streamConfigReady, setStreamConfigReady] = useState(false);
432437
const [touchIndicators, setTouchIndicators] = useState<TouchIndicator[]>([]);
438+
const [selectedSimulatorState, setSelectedSimulatorState] =
439+
useState<SimulatorStateResponse | null>(null);
433440

434441
const menuRef = useRef<HTMLDivElement | null>(null);
435442
const outerCanvasRef = useRef<HTMLDivElement | null>(null);
@@ -494,7 +501,7 @@ export function AppShell({
494501
.includes(searchNeedle);
495502
});
496503

497-
const selectedSimulator =
504+
const baseSelectedSimulator =
498505
(fixedSimulatorUDID
499506
? (simulators.find(
500507
(simulator) => simulator.udid === fixedSimulatorUDID,
@@ -511,6 +518,14 @@ export function AppShell({
511518
filteredSimulators.find((simulator) => simulator.isBooted) ??
512519
filteredSimulators[0] ??
513520
null;
521+
const selectedSimulator =
522+
baseSelectedSimulator &&
523+
selectedSimulatorState?.udid === baseSelectedSimulator.udid
524+
? {
525+
...baseSelectedSimulator,
526+
...selectedSimulatorState.simulator,
527+
}
528+
: baseSelectedSimulator;
514529
const selectedSimulatorTransitionKind =
515530
selectedSimulator != null &&
516531
simulatorTransition?.udid === selectedSimulator.udid
@@ -532,6 +547,59 @@ export function AppShell({
532547
: "Stopping..."
533548
: "";
534549

550+
useEffect(() => {
551+
const udid = baseSelectedSimulator?.udid;
552+
if (!udid) {
553+
setSelectedSimulatorState(null);
554+
return;
555+
}
556+
557+
let cancelled = false;
558+
let timeoutId = 0;
559+
let controller: AbortController | null = null;
560+
561+
const loadState = () => {
562+
controller?.abort();
563+
controller =
564+
typeof AbortController !== "undefined" ? new AbortController() : null;
565+
void fetchSimulatorState(
566+
udid,
567+
controller ? { signal: controller.signal } : {},
568+
)
569+
.then((state) => {
570+
if (!cancelled) {
571+
setSelectedSimulatorState(state);
572+
}
573+
})
574+
.catch((error) => {
575+
if (
576+
!cancelled &&
577+
!(error instanceof DOMException && error.name === "AbortError")
578+
) {
579+
setSelectedSimulatorState(null);
580+
}
581+
})
582+
.finally(() => {
583+
if (!cancelled) {
584+
timeoutId = window.setTimeout(
585+
loadState,
586+
baseSelectedSimulator?.isBooted ? 1000 : 3000,
587+
);
588+
}
589+
});
590+
};
591+
592+
setSelectedSimulatorState(null);
593+
loadState();
594+
return () => {
595+
cancelled = true;
596+
if (timeoutId) {
597+
window.clearTimeout(timeoutId);
598+
}
599+
controller?.abort();
600+
};
601+
}, [baseSelectedSimulator?.isBooted, baseSelectedSimulator?.udid]);
602+
535603
const handleStreamCanvasRef = useCallback(
536604
(node: HTMLCanvasElement | null) => {
537605
streamCanvasRef.current = node;
@@ -1354,9 +1422,29 @@ export function AppShell({
13541422
? `${visibleStreamError} ${streamStatus.detail}`
13551423
: visibleStreamError
13561424
: "";
1425+
const serverFrameSequence =
1426+
selectedSimulatorState?.frameSequence ??
1427+
selectedSimulator?.privateDisplay?.frameSequence ??
1428+
0;
1429+
const browserFramePending = Boolean(
1430+
selectedSimulator?.isBooted &&
1431+
serverFrameSequence > 0 &&
1432+
!hasFrame &&
1433+
streamStatus.state !== "error" &&
1434+
!visibleStreamError,
1435+
);
1436+
const streamStatusLabel = streamAgentStatusLabel({
1437+
hasFrame,
1438+
simulator: selectedSimulator,
1439+
simulatorState: selectedSimulatorState,
1440+
stats,
1441+
streamStatusDetail: streamStatus.detail,
1442+
streamStatusState: streamStatus.state,
1443+
});
13571444
const viewportStatusOverlayLabel =
13581445
simulatorStatusOverlayLabel ||
13591446
streamStatusMessage ||
1447+
(browserFramePending ? "Stream connected, browser frame pending" : "") ||
13601448
(selectedSimulator ? visibleListError : "");
13611449
const viewportHasStreamError = Boolean(
13621450
streamStatus.state === "error" ||
@@ -2117,6 +2205,7 @@ export function AppShell({
21172205
chromeScreenStyle={viewportScreenStyle}
21182206
chromeUrl={chromeUrl}
21192207
chromeButtonUrl={chromeButtonUrl}
2208+
browserFramePending={browserFramePending}
21202209
debugPanel={
21212210
debugVisible ? (
21222211
<DebugPanel
@@ -2180,6 +2269,7 @@ export function AppShell({
21802269
streamCanvasRef={handleStreamCanvasRef}
21812270
streamBackend={streamBackend}
21822271
streamCanvasKey={streamCanvasKey}
2272+
streamStatusLabel={streamStatusLabel}
21832273
statusOverlayLabel={viewportStatusOverlayLabel}
21842274
touchIndicators={touchIndicators}
21852275
touchOverlayVisible={touchOverlayVisible}
@@ -2315,6 +2405,76 @@ function friendlyStreamError(
23152405
return friendlyClientError(normalized);
23162406
}
23172407

2408+
function streamAgentStatusLabel({
2409+
hasFrame,
2410+
simulator,
2411+
simulatorState,
2412+
stats,
2413+
streamStatusDetail,
2414+
streamStatusState,
2415+
}: {
2416+
hasFrame: boolean;
2417+
simulator: SimulatorMetadata | null;
2418+
simulatorState: SimulatorStateResponse | null;
2419+
stats: {
2420+
frameSequence: number;
2421+
latestFrameGapMs: number;
2422+
renderedFrames: number;
2423+
};
2424+
streamStatusDetail?: string;
2425+
streamStatusState: string;
2426+
}): string {
2427+
if (!simulator) {
2428+
return "No simulator selected";
2429+
}
2430+
2431+
const display = simulator.privateDisplay;
2432+
const serverFrameSequence =
2433+
simulatorState?.frameSequence ?? display?.frameSequence ?? 0;
2434+
const serverFrameAgeMs =
2435+
simulatorState?.lastFrameAgeMs ??
2436+
(display?.lastFrameAt
2437+
? Math.max(0, Date.now() - display.lastFrameAt)
2438+
: null);
2439+
const foreground =
2440+
simulatorState?.foregroundApp?.appName ??
2441+
simulatorState?.foregroundApp?.bundleIdentifier ??
2442+
"unknown foreground app";
2443+
const parts = [
2444+
simulator.isBooted ? "Booted" : "Shutdown",
2445+
display?.displayReady || simulatorState?.displayReady
2446+
? "display ready"
2447+
: "display not ready",
2448+
`server ${display?.displayStatus ?? simulatorState?.displayStatus ?? "Unknown"}`,
2449+
`server frame ${serverFrameSequence}`,
2450+
serverFrameAgeMs == null
2451+
? "server frame age unknown"
2452+
: `server frame ${formatMilliseconds(serverFrameAgeMs)} ago`,
2453+
hasFrame
2454+
? stats.frameSequence > 0
2455+
? `browser frame ${stats.frameSequence}`
2456+
: `browser rendered ${stats.renderedFrames}`
2457+
: "browser frame pending",
2458+
`browser gap ${formatMilliseconds(stats.latestFrameGapMs)}`,
2459+
`foreground ${foreground}`,
2460+
`client ${streamStatusState}`,
2461+
];
2462+
if (streamStatusDetail) {
2463+
parts.push(streamStatusDetail);
2464+
}
2465+
return parts.join(" · ");
2466+
}
2467+
2468+
function formatMilliseconds(value: number): string {
2469+
if (!Number.isFinite(value) || value <= 0) {
2470+
return "0ms";
2471+
}
2472+
if (value < 1000) {
2473+
return `${Math.round(value)}ms`;
2474+
}
2475+
return `${(value / 1000).toFixed(1)}s`;
2476+
}
2477+
23182478
function userFacingAccessibilityError(message: string): string {
23192479
const normalized = message.trim();
23202480
if (!normalized) {

client/src/features/stream/streamWorkerClient.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,7 @@ class WebRtcStreamClient implements StreamClientBackend {
13141314
private reportedVideoWidth = 0;
13151315
private receiverStatsInterval = 0;
13161316
private receiverStatsSeen = false;
1317+
private streamingReported = false;
13171318
private shouldReconnect = false;
13181319
private latestRgbaSequence = -1;
13191320
private rgbaAssemblies = new Map<number, WebRtcRgbaAssembly>();
@@ -1413,6 +1414,7 @@ class WebRtcStreamClient implements StreamClientBackend {
14131414
this.reportedVideoHeight = 0;
14141415
this.reportedVideoWidth = 0;
14151416
this.receiverStatsSeen = false;
1417+
this.streamingReported = false;
14161418
this.onMessage({
14171419
type: "status",
14181420
status: { detail: "Creating WebRTC offer", state: "connecting" },
@@ -1544,17 +1546,7 @@ class WebRtcStreamClient implements StreamClientBackend {
15441546
this.clearIceRestartTimeout();
15451547
this.iceRestartInFlight = false;
15461548
this.reconnectDelayMs = WEBRTC_RECONNECT_BASE_DELAY_MS;
1547-
if (this.reportedVideoWidth > 0 && this.reportedVideoHeight > 0) {
1548-
this.onMessage({
1549-
type: "status",
1550-
status: {
1551-
detail: useRgbaTransport
1552-
? "WebRTC RGBA stream connected"
1553-
: "WebRTC media connected",
1554-
state: "streaming",
1555-
},
1556-
});
1557-
}
1549+
this.reportWebRtcStreaming();
15581550
return;
15591551
}
15601552
if (peerConnection.connectionState === "disconnected") {
@@ -1818,6 +1810,7 @@ class WebRtcStreamClient implements StreamClientBackend {
18181810
const reconnects = this.stats.reconnects;
18191811
this.hasRenderedFrame = false;
18201812
this.lastVideoFrameAt = 0;
1813+
this.streamingReported = false;
18211814
this.stats = createEmptyStreamStats();
18221815
this.stats.iceRestartReason = iceRestartReason;
18231816
this.stats.iceRestarts = iceRestarts;
@@ -2290,6 +2283,7 @@ class WebRtcStreamClient implements StreamClientBackend {
22902283
previousFrameAt > 0 ? finishedAt - previousFrameAt : 0;
22912284
this.reportVideoConfig(frame.width, frame.height);
22922285
this.onMessage({ type: "stats", stats: { ...this.stats } });
2286+
this.reportWebRtcStreaming();
22932287
}
22942288

22952289
private drawVideoFrame = () => {
@@ -2320,6 +2314,7 @@ class WebRtcStreamClient implements StreamClientBackend {
23202314
}
23212315
this.lastVideoFrameAt = now;
23222316
this.onMessage({ type: "stats", stats: { ...this.stats } });
2317+
this.reportWebRtcStreaming();
23232318
}
23242319
this.scheduleVideoFrame();
23252320
};
@@ -2355,12 +2350,19 @@ class WebRtcStreamClient implements StreamClientBackend {
23552350
type: "video-config",
23562351
size: { height, width },
23572352
});
2353+
}
2354+
2355+
private reportWebRtcStreaming() {
2356+
if (!this.hasRenderedFrame || this.streamingReported) {
2357+
return;
2358+
}
2359+
this.streamingReported = true;
23582360
this.onMessage({
23592361
type: "status",
23602362
status: {
23612363
detail: this.rgbaMode
2362-
? "WebRTC RGBA stream connected"
2363-
: "WebRTC media connected",
2364+
? "WebRTC RGBA first frame rendered"
2365+
: "WebRTC first video frame rendered",
23642366
state: "streaming",
23652367
},
23662368
});

client/src/features/toolbar/Toolbar.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Half2Icon as AppearanceIcon,
55
HomeIcon,
66
LayersIcon as HierarchyIcon,
7+
Link2Icon as OpenUrlIcon,
78
PlayIcon,
89
ReloadIcon as RotateRightIcon,
910
StopIcon,
@@ -208,6 +209,14 @@ export function Toolbar({
208209
<StopIcon />
209210
</button>
210211
) : null}
212+
<button
213+
aria-label="Open URL"
214+
className="tbtn icon-btn"
215+
onClick={onOpenUrlPrompt}
216+
title="Open URL"
217+
>
218+
<OpenUrlIcon />
219+
</button>
211220
<button
212221
aria-label="Home"
213222
className="tbtn icon-btn"

0 commit comments

Comments
 (0)