Skip to content

Commit 6236d1f

Browse files
authored
fix studio expose reliability (#17)
* fix studio expose reliability * Harden remote WebRTC reconnects
1 parent fcc497a commit 6236d1f

22 files changed

Lines changed: 1788 additions & 205 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ profile (`1170` longest edge, dynamic up to `60` fps). Use
8484
`--stream-quality quality|balanced|fast|smooth|economy|ci-software` to override it,
8585
or pass `--video-codec hardware` when a dedicated hardware encoder is preferable.
8686
The remote viewer renders live video with the browser's native video element;
87-
the canvas is only used for input geometry.
87+
the canvas is only used for input geometry. Remote viewers can choose 15, 30,
88+
or 60 fps in the browser stream menu.
8889

8990
CLI commands automatically use the same warm daemon:
9091

client/src/app/AppShell.tsx

Lines changed: 196 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import {
88
type FormEvent,
99
} from "react";
1010

11-
import { ApiError, accessTokenFromLocation, pairBrowser } from "../api/client";
11+
import {
12+
ApiError,
13+
accessTokenFromLocation,
14+
apiRequest,
15+
pairBrowser,
16+
} from "../api/client";
1217
import { apiUrl, configureSimDeckClient } from "../api/config";
1318
import {
1419
bootSimulator,
@@ -98,12 +103,38 @@ const LOCAL_STREAM_DEFAULTS: StreamConfig = {
98103
quality: "quality",
99104
};
100105
const REMOTE_STREAM_DEFAULTS: StreamConfig = {
101-
encoder: "auto",
106+
encoder: "software",
102107
fps: 30,
103108
quality: "balanced",
104109
};
110+
const STREAM_CONFIG_SYNC_INTERVAL_MS = 5000;
111+
const STREAM_CONFIG_USER_CHANGE_GRACE_MS = 1000;
112+
const STREAM_ENCODER_VALUES = new Set<StreamEncoder>([
113+
"auto",
114+
"hardware",
115+
"software",
116+
]);
117+
const STREAM_QUALITY_VALUES = new Set<StreamQualityPreset>([
118+
"balanced",
119+
"ci-software",
120+
"economy",
121+
"fast",
122+
"quality",
123+
"smooth",
124+
]);
105125
clearLegacyVolatileUiState();
106126

127+
interface StreamQualityResponse {
128+
ok?: boolean;
129+
quality?: {
130+
fps?: number;
131+
maxEdge?: number;
132+
profile?: string;
133+
videoCodec?: string;
134+
};
135+
videoCodec?: string;
136+
}
137+
107138
function buildChromeUrl(udid: string, stamp: number): string {
108139
return buildAuthenticatedAssetUrl(
109140
`/api/simulators/${udid}/chrome.png`,
@@ -296,6 +327,8 @@ export function AppShell({
296327
const [streamConfig, setStreamConfig] = useState<StreamConfig>(() =>
297328
remoteStream ? REMOTE_STREAM_DEFAULTS : LOCAL_STREAM_DEFAULTS,
298329
);
330+
const [streamConfigApplyKey, setStreamConfigApplyKey] = useState(0);
331+
const [streamConfigReady, setStreamConfigReady] = useState(false);
299332
const [touchIndicators, setTouchIndicators] = useState<TouchIndicator[]>([]);
300333

301334
const menuRef = useRef<HTMLDivElement | null>(null);
@@ -313,6 +346,8 @@ export function AppShell({
313346
const gestureStartZoomRef = useRef(1);
314347
const accessibilityRequestIdRef = useRef(0);
315348
const accessibilityLoadingRef = useRef(false);
349+
const streamConfigRequestIdRef = useRef(0);
350+
const streamConfigUserChangeAtRef = useRef(0);
316351
const controlSocketRef = useRef<{
317352
udid: string;
318353
socket: WebSocket;
@@ -395,6 +430,52 @@ export function AppShell({
395430
[],
396431
);
397432

433+
const syncStreamConfig = useCallback(async () => {
434+
const requestId = ++streamConfigRequestIdRef.current;
435+
try {
436+
const response = await apiRequest<StreamQualityResponse>(
437+
"/api/stream-quality",
438+
);
439+
if (requestId !== streamConfigRequestIdRef.current) {
440+
return;
441+
}
442+
if (
443+
Date.now() - streamConfigUserChangeAtRef.current <
444+
STREAM_CONFIG_USER_CHANGE_GRACE_MS
445+
) {
446+
return;
447+
}
448+
setStreamConfig((current) =>
449+
mergeStreamQualityResponse(current, response),
450+
);
451+
} catch {
452+
// Keep the existing local/default selection; the stream path will surface
453+
// provider reachability errors separately.
454+
} finally {
455+
if (requestId === streamConfigRequestIdRef.current) {
456+
setStreamConfigReady(true);
457+
}
458+
}
459+
}, []);
460+
461+
useEffect(() => {
462+
let cancelled = false;
463+
setStreamConfigReady(false);
464+
465+
const run = () => {
466+
if (!cancelled) {
467+
void syncStreamConfig();
468+
}
469+
};
470+
471+
run();
472+
const intervalId = window.setInterval(run, STREAM_CONFIG_SYNC_INTERVAL_MS);
473+
return () => {
474+
cancelled = true;
475+
window.clearInterval(intervalId);
476+
};
477+
}, [remoteStream, syncStreamConfig]);
478+
398479
const {
399480
deviceNaturalSize,
400481
error: streamError,
@@ -407,20 +488,31 @@ export function AppShell({
407488
streamCanvasKey,
408489
} = useLiveStream({
409490
canvasElement: streamCanvasElement,
491+
paused: !streamConfigReady,
410492
remote: remoteStream,
411493
simulator: selectedSimulator,
412494
streamConfig,
495+
streamConfigApplyKey,
413496
});
414497

415498
const updateStreamEncoder = useCallback((encoder: StreamEncoder) => {
499+
streamConfigUserChangeAtRef.current = Date.now();
500+
setStreamConfigReady(true);
501+
setStreamConfigApplyKey((current) => current + 1);
416502
setStreamConfig((current) => ({ ...current, encoder }));
417503
}, []);
418504

419505
const updateStreamFps = useCallback((fps: StreamFps) => {
506+
streamConfigUserChangeAtRef.current = Date.now();
507+
setStreamConfigReady(true);
508+
setStreamConfigApplyKey((current) => current + 1);
420509
setStreamConfig((current) => ({ ...current, fps }));
421510
}, []);
422511

423512
const updateStreamQuality = useCallback((quality: StreamQualityPreset) => {
513+
streamConfigUserChangeAtRef.current = Date.now();
514+
setStreamConfigReady(true);
515+
setStreamConfigApplyKey((current) => current + 1);
424516
setStreamConfig((current) => ({ ...current, quality }));
425517
}, []);
426518

@@ -899,27 +991,34 @@ export function AppShell({
899991
});
900992

901993
const pairingRequired =
994+
!remoteStream &&
902995
pairingEnabled &&
903996
listError === AUTH_REQUIRED_MESSAGE &&
904997
!accessTokenFromLocation();
905-
const visibleListError = selectedSimulator
906-
? friendlyClientError(listError)
907-
: listError;
998+
const visibleListError =
999+
remoteStream && listError === AUTH_REQUIRED_MESSAGE
1000+
? ""
1001+
: selectedSimulator
1002+
? friendlyClientError(listError)
1003+
: listError;
9081004
const toolbarError = pairingRequired
9091005
? localError
9101006
: localError || (selectedSimulator ? "" : visibleListError);
911-
const streamStatusMessage = streamStatus.error
1007+
const visibleStreamError = friendlyStreamError(streamStatus.error, {
1008+
remote: remoteStream,
1009+
});
1010+
const streamStatusMessage = visibleStreamError
9121011
? streamStatus.detail
913-
? `${streamStatus.error} ${streamStatus.detail}`
914-
: streamStatus.error
1012+
? `${visibleStreamError} ${streamStatus.detail}`
1013+
: visibleStreamError
9151014
: "";
9161015
const viewportStatusOverlayLabel =
9171016
simulatorStatusOverlayLabel ||
9181017
streamStatusMessage ||
9191018
(selectedSimulator ? visibleListError : "");
9201019
const viewportHasStreamError = Boolean(
9211020
streamStatus.state === "error" ||
922-
streamStatus.error ||
1021+
visibleStreamError ||
9231022
(selectedSimulator && visibleListError),
9241023
);
9251024
const deviceTransform = `translate(${pan.x}px, ${pan.y + autoViewportOffsetY}px) scale(${effectiveZoom})`;
@@ -1061,6 +1160,9 @@ export function AppShell({
10611160
if (sendWebRtcControlMessage(encoded)) {
10621161
return true;
10631162
}
1163+
if (remoteStream) {
1164+
return false;
1165+
}
10641166
const state = ensureControlSocket(udid);
10651167
if (state.socket.readyState === WebSocket.OPEN) {
10661168
state.socket.send(encoded);
@@ -1462,6 +1564,7 @@ export function AppShell({
14621564
onToggleTouchOverlay={() =>
14631565
setTouchOverlayVisible((current) => !current)
14641566
}
1567+
remoteStream={remoteStream}
14651568
search={search}
14661569
selectedSimulator={selectedSimulator}
14671570
selectedSimulatorIdentifier={selectedSimulatorDetail}
@@ -1607,3 +1710,87 @@ function friendlyClientError(message: string): string {
16071710
}
16081711
return message;
16091712
}
1713+
1714+
function friendlyStreamError(
1715+
message: string | undefined,
1716+
options: { remote: boolean },
1717+
): string {
1718+
const normalized = message?.trim() ?? "";
1719+
if (!normalized) {
1720+
return "";
1721+
}
1722+
if (
1723+
options.remote &&
1724+
normalized.toLowerCase().includes(AUTH_REQUIRED_MESSAGE.toLowerCase())
1725+
) {
1726+
return "";
1727+
}
1728+
return friendlyClientError(normalized);
1729+
}
1730+
1731+
function mergeStreamQualityResponse(
1732+
current: StreamConfig,
1733+
response: StreamQualityResponse,
1734+
): StreamConfig {
1735+
const quality = response.quality ?? {};
1736+
const next: StreamConfig = {
1737+
...current,
1738+
encoder: normalizeStreamEncoder(
1739+
quality.videoCodec ?? response.videoCodec,
1740+
current.encoder,
1741+
),
1742+
fps: normalizeStreamFps(quality.fps, current.fps),
1743+
maxEdge: normalizeMaxEdge(quality.maxEdge, current.maxEdge),
1744+
quality: normalizeStreamQuality(quality.profile, current.quality),
1745+
};
1746+
return streamConfigsEqual(current, next) ? current : next;
1747+
}
1748+
1749+
function normalizeStreamEncoder(
1750+
value: string | undefined,
1751+
fallback: StreamEncoder,
1752+
): StreamEncoder {
1753+
const normalized = value?.trim().toLowerCase() as StreamEncoder | undefined;
1754+
return normalized && STREAM_ENCODER_VALUES.has(normalized)
1755+
? normalized
1756+
: fallback;
1757+
}
1758+
1759+
function normalizeStreamQuality(
1760+
value: string | undefined,
1761+
fallback: StreamQualityPreset,
1762+
): StreamQualityPreset {
1763+
const normalized = value?.trim().toLowerCase() as
1764+
| StreamQualityPreset
1765+
| undefined;
1766+
return normalized && STREAM_QUALITY_VALUES.has(normalized)
1767+
? normalized
1768+
: fallback;
1769+
}
1770+
1771+
function normalizeStreamFps(
1772+
value: number | undefined,
1773+
fallback: StreamFps,
1774+
): StreamFps {
1775+
return typeof value === "number" && Number.isFinite(value) && value > 0
1776+
? Math.round(value)
1777+
: fallback;
1778+
}
1779+
1780+
function normalizeMaxEdge(
1781+
value: number | undefined,
1782+
fallback: number | undefined,
1783+
): number | undefined {
1784+
return typeof value === "number" && Number.isFinite(value) && value > 0
1785+
? Math.round(value)
1786+
: fallback;
1787+
}
1788+
1789+
function streamConfigsEqual(left: StreamConfig, right: StreamConfig): boolean {
1790+
return (
1791+
left.encoder === right.encoder &&
1792+
left.fps === right.fps &&
1793+
left.maxEdge === right.maxEdge &&
1794+
left.quality === right.quality
1795+
);
1796+
}

0 commit comments

Comments
 (0)