authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 20:36:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 20:36:50-07:00
log823119350ee29cca434b1295aa5c560f4c9ef98a
treef3fe805130624552f5942f0716229113ba81516f
parentcb3d95c17487f3d834a5537f7c3aa0106bc9b5ec
signature Signed by PGP key B5690EEEBB952194

feat(mobile): view and control agent devices (#12531)


28 files changed, 2023 insertions(+), 739 deletions(-)

apps/mobile/metro.config.js+28-1
...@@ -17,6 +17,7 @@ const licenseGeneratorSource = path.join(...@@ -17,6 +17,7 @@ const licenseGeneratorSource = path.join(
17);17);
18const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");18const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] }));19const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] }));
20const generatedDeviceStreamRoot = path.join(__dirname, ".generated", "device-stream");
20const resolveShikiDependencyRoot = (packageName) => {21const resolveShikiDependencyRoot = (packageName) => {
21 const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] });22 const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] });
22 let currentDir = path.dirname(entryPath);23 let currentDir = path.dirname(entryPath);
...@@ -46,6 +47,7 @@ config.resolver = {...@@ -46,6 +47,7 @@ config.resolver = {
46 extraNodeModules: {47 extraNodeModules: {
47 ...config.resolver?.extraNodeModules,48 ...config.resolver?.extraNodeModules,
48 "@t3tools/mobile-third-party-licenses": generatedLicenseModuleRoot,49 "@t3tools/mobile-third-party-licenses": generatedLicenseModuleRoot,
50 "@t3tools/mobile-device-stream": generatedDeviceStreamRoot,
49 shiki: mobileShikiRoot,51 shiki: mobileShikiRoot,
50 "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"),52 "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"),
51 "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"),53 "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"),
...@@ -93,7 +95,32 @@ async function generateMobileThirdPartyLicenses() {...@@ -93,7 +95,32 @@ async function generateMobileThirdPartyLicenses() {
93 ]);95 ]);
94}96}
9597
96module.exports = generateMobileThirdPartyLicenses().then(() =>98async function prepareDeviceStream() {
99 const { generateDeviceStreamScript } = await import(
100 pathToFileURL(path.join(__dirname, "scripts", "generate-device-stream.mts")).href
101 );
102 await generateDeviceStreamScript();
103 if (process.env.NODE_ENV !== "production") {
104 let rebuild = Promise.resolve();
105 for (const [directory, files] of [
106 [path.join(__dirname, "src/features/devices"), ["device-stream.browser.ts"]],
107 [
108 path.join(workspaceRoot, "packages/client-runtime/src/device"),
109 ["stream.ts", "hubAccess.ts"],
110 ],
111 ]) {
112 // The generated module participates in Metro's normal Fast Refresh.
113 fs.watch(directory, { persistent: false }, (_event, filename) => {
114 if (filename && !files.includes(String(filename))) return;
115 rebuild = rebuild.then(generateDeviceStreamScript).catch((error) => {
116 console.error("Could not rebuild the device stream:", error);
117 });
118 });
119 }
120 }
121}
122
123module.exports = Promise.all([generateMobileThirdPartyLicenses(), prepareDeviceStream()]).then(() =>
97 withUniwindConfig(config, {124 withUniwindConfig(config, {
98 cssEntryFile: "./global.css",125 cssEntryFile: "./global.css",
99 extraThemes,126 extraThemes,
apps/mobile/scripts/generate-device-stream.mts created+39
...@@ -0,0 +1,39 @@
1import * as NodeFSP from "node:fs/promises";
2import * as NodePath from "node:path";
3import * as NodeURL from "node:url";
4import { build } from "vite-plus";
5
6const mobileRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "..");
7
8/** Metro embeds the shared browser transport as a small script in the native WebView. */
9export async function generateDeviceStreamScript() {
10 const result = await build({
11 configFile: false,
12 logLevel: "silent",
13 build: {
14 write: false,
15 target: "es2022",
16 minify: true,
17 lib: {
18 entry: NodePath.join(mobileRoot, "src/features/devices/device-stream.browser.ts"),
19 name: "T3DeviceStream",
20 formats: ["iife"],
21 },
22 },
23 });
24 const bundles = Array.isArray(result) ? result : [result];
25 const chunk = bundles
26 .flatMap((bundle) => ("output" in bundle ? bundle.output : []))
27 .find((output) => output.type === "chunk");
28 if (!chunk) throw new Error("Device stream build did not emit a script.");
29 const root = NodePath.join(mobileRoot, ".generated/device-stream");
30 await NodeFSP.mkdir(root, { recursive: true });
31 for (const [name, contents] of [
32 ["index.js", `module.exports = ${JSON.stringify(chunk.code)};\n`],
33 ["package.json", '{"main":"index.js"}\n'],
34 ] as const) {
35 const destination = NodePath.join(root, name);
36 const previous = await NodeFSP.readFile(destination, "utf8").catch(() => null);
37 if (previous !== contents) await NodeFSP.writeFile(destination, contents);
38 }
39}
apps/mobile/src/Stack.tsx+11
...@@ -30,6 +30,7 @@ import {...@@ -30,6 +30,7 @@ import {
30import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";30import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";
31import { ReviewSheet } from "./features/review/ReviewSheet";31import { ReviewSheet } from "./features/review/ReviewSheet";
32import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";32import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
33import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen";
33import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";34import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
34import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";35import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
35import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";36import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
...@@ -449,6 +450,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([...@@ -449,6 +450,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
449 "SettingsLegal",450 "SettingsLegal",
450 "SettingsSheet",451 "SettingsSheet",
451 "ThreadReviewComment",452 "ThreadReviewComment",
453 "ThreadDevicePreview",
452 "ThreadSettingsSheet",454 "ThreadSettingsSheet",
453]);455]);
454456
...@@ -595,6 +597,15 @@ export const RootStack = createNativeStackNavigator({...@@ -595,6 +597,15 @@ export const RootStack = createNativeStackNavigator({
595 linking: `${THREAD_LINKING_PREFIX}/terminal`,597 linking: `${THREAD_LINKING_PREFIX}/terminal`,
596 options: SOLID_HEADER_OPTIONS,598 options: SOLID_HEADER_OPTIONS,
597 }),599 }),
600 ThreadDevicePreview: createNativeStackScreen({
601 screen: DevicePreviewRouteScreen,
602 linking: `${THREAD_LINKING_PREFIX}/devices`,
603 options: {
604 presentation: "fullScreenModal",
605 headerShown: false,
606 gestureEnabled: false,
607 },
608 }),
598 ThreadReview: createNativeStackScreen({609 ThreadReview: createNativeStackScreen({
599 screen: ReviewSheet,610 screen: ReviewSheet,
600 linking: `${THREAD_LINKING_PREFIX}/review`,611 linking: `${THREAD_LINKING_PREFIX}/review`,
apps/mobile/src/components/AppSymbol.tsx+3
...@@ -60,6 +60,7 @@ import IconGitBranch from "@tabler/icons-react-native/IconGitBranch";...@@ -60,6 +60,7 @@ import IconGitBranch from "@tabler/icons-react-native/IconGitBranch";
60import IconGitMerge from "@tabler/icons-react-native/IconGitMerge";60import IconGitMerge from "@tabler/icons-react-native/IconGitMerge";
61import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest";61import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest";
62import IconHammer from "@tabler/icons-react-native/IconHammer";62import IconHammer from "@tabler/icons-react-native/IconHammer";
63import IconHome from "@tabler/icons-react-native/IconHome";
63import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle";64import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle";
64import IconKeyboard from "@tabler/icons-react-native/IconKeyboard";65import IconKeyboard from "@tabler/icons-react-native/IconKeyboard";
65import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide";66import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide";
...@@ -161,6 +162,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {...@@ -161,6 +162,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
161 gearshape: IconSettings,162 gearshape: IconSettings,
162 globe: IconWorld,163 globe: IconWorld,
163 hammer: IconHammer,164 hammer: IconHammer,
165 house: IconHome,
164 "info.circle": IconInfoCircle,166 "info.circle": IconInfoCircle,
165 internaldrive: IconDatabase,167 internaldrive: IconDatabase,
166 keyboard: IconKeyboard,168 keyboard: IconKeyboard,
...@@ -194,6 +196,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {...@@ -194,6 +196,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
194 "sidebar.right": IconLayoutSidebarRight,196 "sidebar.right": IconLayoutSidebarRight,
195 "slider.horizontal.3": IconAdjustmentsHorizontal,197 "slider.horizontal.3": IconAdjustmentsHorizontal,
196 "square.and.pencil": IconEdit,198 "square.and.pencil": IconEdit,
199 "square.on.square": IconCopy,
197 "square.grid.2x2": IconApps,200 "square.grid.2x2": IconApps,
198 "square.split.2x1": IconLayoutColumns,201 "square.split.2x1": IconLayoutColumns,
199 star: IconStar,202 star: IconStar,
apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx created+309
...@@ -0,0 +1,309 @@
1import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { createNativeStackNavigator } from "@react-navigation/native-stack";
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import * as Cause from "effect/Cause";
5import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
6import { ActivityIndicator, Alert, AppState, Platform, Pressable, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8
9import { AppText } from "../../components/AppText";
10import { ScreenHeader, type ScreenHeaderMenuItem } from "../../components/ScreenHeader";
11import { NativeHeaderToolbar } from "../../native/StackHeader";
12import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device";
13import { useEnvironmentQuery } from "../../state/query";
14import { useAtomCommand } from "../../state/use-atom-command";
15import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
16import { DeviceStreamWebView, type DeviceStreamRef } from "./DeviceStreamWebView";
17import {
18 selectedThreadDevicePreview,
19 threadDevicePreviews,
20 type ThreadDevicePreview,
21} from "./threadDevicePreviews";
22
23const DevicePreviewStack = createNativeStackNavigator<{ DevicePreview: undefined }>();
24
25type DevicePreviewRouteScreenProps = StaticScreenProps<{
26 readonly environmentId: string;
27 readonly threadId: string;
28}>;
29
30/** The nested native stack supplies the navigation bar inside the modal. */
31export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) {
32 const navigation = useNavigation();
33 const onClose = useCallback(() => navigation.goBack(), [navigation]);
34 return (
35 <View collapsable={false} className="flex-1 bg-sheet">
36 <DevicePreviewStack.Navigator
37 screenOptions={{
38 headerShown: Platform.OS === "ios",
39 headerBackVisible: false,
40 headerShadowVisible: false,
41 headerTransparent: false,
42 headerTitleStyle: { fontSize: 17, fontWeight: "600" },
43 }}
44 >
45 <DevicePreviewStack.Screen name="DevicePreview">
46 {() => (
47 <DevicePreviewScreen
48 environmentId={EnvironmentId.make(route.params.environmentId)}
49 threadId={ThreadId.make(route.params.threadId)}
50 onClose={onClose}
51 />
52 )}
53 </DevicePreviewStack.Screen>
54 </DevicePreviewStack.Navigator>
55 </View>
56 );
57}
58
59function DevicePreviewScreen({
60 environmentId,
61 threadId,
62 onClose,
63}: {
64 readonly environmentId: EnvironmentId;
65 readonly threadId: ThreadId;
66 readonly onClose: () => void;
67}) {
68 const insets = useSafeAreaInsets();
69 const { themeVariables } = useAppearancePreferences();
70 const focused = useIsFocused();
71 const [foreground, setForeground] = useState(AppState.currentState === "active");
72 const [selectedKey, setSelectedKey] = useState<string | null>(null);
73 const [inputConnected, setInputConnected] = useState(false);
74 const [streamAttempt, setStreamAttempt] = useState(0);
75 const [shuttingDown, setShuttingDown] = useState(false);
76 const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false });
77 const streamRef = useRef<DeviceStreamRef>(null);
78 const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} }));
79 const previews = useMemo(
80 () => threadDevicePreviews(state.data, threadId),
81 [state.data, threadId],
82 );
83 const preview = selectedThreadDevicePreview(previews, selectedKey);
84 const onInputConnected = useCallback(
85 async (connected: boolean) => setInputConnected(connected),
86 [],
87 );
88 useEffect(() => {
89 const subscription = AppState.addEventListener("change", (state) =>
90 setForeground(state === "active"),
91 );
92 return () => subscription.remove();
93 }, []);
94 useEffect(() => {
95 if (focused && state.data !== null && previews.length === 0) onClose();
96 }, [focused, state.data, previews.length, onClose]);
97
98 const shutDownDevice = async () => {
99 if (!preview || shuttingDown) return;
100 setShuttingDown(true);
101 try {
102 const result = await shutdown({
103 environmentId,
104 input: {
105 hostId: preview.session.hostId,
106 deviceId: preview.session.deviceId,
107 platform: preview.session.platform,
108 },
109 });
110 if (result._tag === "Failure") {
111 Alert.alert("Could not shut down device", String(Cause.squash(result.cause)));
112 }
113 } finally {
114 setShuttingDown(false);
115 }
116 };
117
118 const controls: ScreenHeaderMenuItem[] = [
119 {
120 id: "reload",
121 title: "Reload stream",
122 icon: "arrow.clockwise",
123 disabled: !preview || shuttingDown,
124 onPress: () => {
125 setInputConnected(false);
126 setStreamAttempt((attempt) => attempt + 1);
127 },
128 },
129 ...(preview?.session.platform === "android"
130 ? [
131 {
132 id: "back",
133 title: "Back",
134 icon: "arrow.left",
135 disabled: !inputConnected,
136 onPress: () => streamRef.current?.back(),
137 },
138 ]
139 : []),
140 {
141 id: "app-switcher",
142 title: "App switcher",
143 icon: "square.on.square",
144 disabled: !inputConnected,
145 onPress: () => streamRef.current?.appSwitcher(),
146 },
147 ...(preview?.session.platform === "ios"
148 ? [
149 {
150 id: "rotate",
151 title: "Rotate device",
152 icon: "arrow.clockwise",
153 disabled: !inputConnected,
154 onPress: () => streamRef.current?.rotate(),
155 },
156 ]
157 : []),
158 {
159 id: "shutdown",
160 title: shuttingDown ? "Shutting down…" : "Shut down device",
161 icon: "power",
162 disabled: !preview || shuttingDown,
163 onPress: () => void shutDownDevice(),
164 },
165 ];
166 return (
167 <View className="flex-1 bg-sheet" style={{ paddingBottom: insets.bottom }}>
168 <ScreenHeader
169 title={preview?.name ?? "Devices"}
170 sidebar={false}
171 onBack={onClose}
172 options={{ headerBackVisible: false }}
173 actions={[
174 {
175 accessibilityLabel: "Home",
176 icon: "house",
177 disabled: !inputConnected,
178 onPress: () => streamRef.current?.home(),
179 },
180 ]}
181 menus={[
182 {
183 title: "Device options",
184 icon: "ellipsis",
185 items: [
186 ...(previews.length > 1
187 ? [
188 {
189 id: "devices",
190 title: "Devices",
191 inline: true,
192 items: previews.map((device) => ({
193 id: device.key,
194 title: device.name,
195 subtitle: device.description,
196 selected: device.key === preview?.key,
197 onPress: () => setSelectedKey(device.key),
198 })),
199 },
200 ]
201 : []),
202 ...controls,
203 ],
204 },
205 ]}
206 />
207 {Platform.OS === "ios" ? (
208 <NativeHeaderToolbar placement="left">
209 <NativeHeaderToolbar.Button
210 icon="xmark"
211 accessibilityLabel="Close device preview"
212 onPress={onClose}
213 separateBackground
214 />
215 </NativeHeaderToolbar>
216 ) : null}
217 {preview && focused && foreground ? (
218 <OpenDevicePreview
219 key={`${preview.key}:${streamAttempt}`}
220 environmentId={environmentId}
221 preview={preview}
222 streamRef={streamRef}
223 onInputConnected={onInputConnected}
224 />
225 ) : (
226 <View className="flex-1 items-center justify-center gap-4 px-6">
227 {state.error ? (
228 <>
229 <AppText selectable className="text-center text-sm text-foreground-muted">
230 {state.error}
231 </AppText>
232 <Pressable
233 accessibilityRole="button"
234 className="rounded-full bg-subtle px-6 py-3"
235 onPress={state.refresh}
236 >
237 <AppText>Retry</AppText>
238 </Pressable>
239 </>
240 ) : focused && foreground ? (
241 <ActivityIndicator color={themeVariables["--color-icon"]} />
242 ) : null}
243 </View>
244 )}
245 </View>
246 );
247}
248
249function OpenDevicePreview({
250 environmentId,
251 preview,
252 streamRef,
253 onInputConnected,
254}: {
255 readonly environmentId: EnvironmentId;
256 readonly preview: ThreadDevicePreview;
257 readonly streamRef: RefObject<DeviceStreamRef | null>;
258 readonly onInputConnected: (connected: boolean) => Promise<void>;
259}) {
260 const { session } = preview;
261 const { themeVariables } = useAppearancePreferences();
262 const { access, error, refresh } = useDeviceHubAccess(environmentId, session.hostId);
263 const onUnauthorized = useCallback(
264 async () => refreshDeviceHubAccess(environmentId),
265 [environmentId],
266 );
267 useEffect(() => {
268 refreshDeviceHubAccess(environmentId);
269 return () => void onInputConnected(false);
270 }, [environmentId, onInputConnected]);
271 return access ? (
272 <DeviceStreamWebView
273 ref={streamRef}
274 access={access}
275 platform={session.platform}
276 deviceId={session.deviceId}
277 colors={{
278 background: themeVariables["--color-sheet-solid"],
279 foreground: themeVariables["--color-foreground"],
280 muted: themeVariables["--color-foreground-muted"],
281 buttonBackground: themeVariables["--color-subtle"],
282 }}
283 onUnauthorized={onUnauthorized}
284 onInputConnected={onInputConnected}
285 />
286 ) : (
287 <View className="flex-1 items-center justify-center gap-4 px-6">
288 {error ? (
289 <>
290 <AppText selectable className="text-center text-sm text-foreground-muted">
291 {error}
292 </AppText>
293 <Pressable
294 accessibilityRole="button"
295 className="rounded-full bg-subtle px-6 py-3"
296 onPress={refresh}
297 >
298 <AppText>Retry</AppText>
299 </Pressable>
300 </>
301 ) : (
302 <>
303 <ActivityIndicator color={themeVariables["--color-icon"]} />
304 <AppText className="text-sm text-foreground-muted">Connecting to device...</AppText>
305 </>
306 )}
307 </View>
308 );
309}
apps/mobile/src/features/devices/DeviceStreamWebView.tsx created+109
...@@ -0,0 +1,109 @@
1import deviceStreamScript from "@t3tools/mobile-device-stream";
2import { useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type Ref } from "react";
3import { Platform } from "react-native";
4import { WebView } from "react-native-webview";
5
6import {
7 deviceStreamDocument,
8 deviceStreamMessage,
9 type DeviceStreamConfiguration,
10} from "./device-stream-document";
11
12export interface DeviceStreamRef {
13 home: () => void;
14 back: () => void;
15 appSwitcher: () => void;
16 rotate: () => void;
17}
18
19type NativeStreamBridge = {
20 readonly ref?: Ref<DeviceStreamRef>;
21 readonly onUnauthorized: () => Promise<void>;
22 readonly onInputConnected: (connected: boolean) => Promise<void>;
23};
24
25export function DeviceStreamWebView({
26 ref,
27 ...props
28}: DeviceStreamConfiguration & NativeStreamBridge) {
29 const [attempt, setAttempt] = useState(0);
30 const configuration = JSON.stringify({
31 access: props.access,
32 platform: props.platform,
33 deviceId: props.deviceId,
34 colors: props.colors,
35 });
36 return (
37 <DeviceStreamDocumentView
38 key={`${attempt}:${configuration}`}
39 ref={ref}
40 configuration={configuration}
41 background={props.colors.background}
42 onUnauthorized={props.onUnauthorized}
43 onInputConnected={props.onInputConnected}
44 onRetry={() => setAttempt((attempt) => attempt + 1)}
45 />
46 );
47}
48
49function DeviceStreamDocumentView({
50 ref,
51 configuration,
52 background,
53 onUnauthorized,
54 onInputConnected,
55 onRetry,
56}: NativeStreamBridge & {
57 readonly configuration: string;
58 readonly background: string;
59 readonly onRetry: () => void;
60}) {
61 const webView = useRef<WebView>(null);
62 const source = useMemo(
63 () => ({
64 html: deviceStreamDocument(configuration, deviceStreamScript),
65 // Android WebCodecs needs a secure document; streams still use the environment's URLs.
66 baseUrl: Platform.OS === "android" ? "https://localhost/" : "file:///",
67 }),
68 [configuration],
69 );
70 const command = (button: keyof DeviceStreamRef) => {
71 webView.current?.injectJavaScript(
72 `window.T3DeviceStream?.command(${JSON.stringify(button)}); true;`,
73 );
74 };
75 useImperativeHandle(ref, () => ({
76 home: () => command("home"),
77 back: () => command("back"),
78 appSwitcher: () => command("appSwitcher"),
79 rotate: () => command("rotate"),
80 }));
81 useLayoutEffect(() => {
82 const view = webView.current;
83 return () => view?.injectJavaScript("window.T3DeviceStream?.stop(); true;");
84 }, []);
85 return (
86 <WebView
87 ref={webView}
88 source={source}
89 originWhitelist={["*"]}
90 scrollEnabled={false}
91 bounces={false}
92 mixedContentMode="always"
93 allowUniversalAccessFromFileURLs
94 contentInsetAdjustmentBehavior="never"
95 setSupportMultipleWindows={false}
96 style={{ flex: 1, backgroundColor: background }}
97 onLoadStart={() => void onInputConnected(false)}
98 onShouldStartLoadWithRequest={(request) =>
99 request.url === "about:blank" || request.url === source.baseUrl
100 }
101 onMessage={(event) => {
102 const message = deviceStreamMessage(event.nativeEvent.data);
103 if (message?.type === "unauthorized") void onUnauthorized();
104 else if (message?.type === "input") void onInputConnected(message.connected);
105 else if (message?.type === "retry") onRetry();
106 }}
107 />
108 );
109}
apps/mobile/src/features/devices/device-preview-button.tsx created+41
...@@ -0,0 +1,41 @@
1import { Pressable, View } from "react-native";
2
3import { AppText } from "../../components/AppText";
4import { SymbolView } from "../../components/AppSymbol";
5
6export function DevicePreviewButton(props: {
7 readonly count: number;
8 readonly onPress: () => void;
9 readonly compact?: boolean;
10}) {
11 const compact = props.compact ?? true;
12 return (
13 <Pressable
14 accessibilityRole="button"
15 accessibilityLabel={props.count === 1 ? "View device" : `View ${props.count} devices`}
16 accessibilityHint="Watch and control devices open in this thread"
17 onPress={props.onPress}
18 className={
19 compact
20 ? "size-11 shrink-0 items-center justify-center rounded-full active:bg-subtle"
21 : "h-11 shrink-0 flex-row items-center justify-center gap-2 rounded-full px-4 active:bg-subtle"
22 }
23 >
24 <SymbolView
25 name={{ ios: "iphone", android: "smartphone" }}
26 size={20}
27 tintColorClassName="accent-icon"
28 type="monochrome"
29 />
30 {!compact ? (
31 <AppText className="font-t3-medium text-xs text-foreground">
32 {props.count === 1 ? "One device open" : `${props.count} devices open`}
33 </AppText>
34 ) : props.count > 1 ? (
35 <View className="absolute right-0.5 top-0.5 min-w-4 items-center rounded-full bg-primary px-1">
36 <AppText className="text-2xs font-t3-bold text-primary-foreground">{props.count}</AppText>
37 </View>
38 ) : null}
39 </Pressable>
40 );
41}
apps/mobile/src/features/devices/device-stream-document.test.ts created+49
...@@ -0,0 +1,49 @@
1import * as NodeVM from "node:vm";
2import { describe, expect, it } from "vite-plus/test";
3
4import { deviceStreamDocument, deviceStreamMessage } from "./device-stream-document";
5
6describe("native device stream document", () => {
7 it("keeps ticket and device values from terminating the embedded script", () => {
8 const deviceId = '</script><script>alert("device")</script>';
9 const configuration = JSON.stringify({ deviceId, ticket: "<ticket>" });
10 const html = deviceStreamDocument(
11 configuration,
12 "var T3DeviceStream={start(input){return input}};",
13 );
14 const script = html.match(/<script>([\s\S]*)<\/script>/)?.[1];
15 expect(html.match(/<script>/g)).toHaveLength(1);
16 const input: unknown = NodeVM.runInNewContext(script!);
17 expect(input).toEqual({ deviceId, ticket: "<ticket>" });
18 });
19
20 it("preserves script strings containing an HTML closing delimiter", () => {
21 const html = deviceStreamDocument("{}", 'var text="</script>";');
22 expect(html.match(/<\/script>/g)).toHaveLength(1);
23 expect(html).toContain('var text="<\\/script>";');
24 });
25});
26
27describe("native device stream messages", () => {
28 it("accepts authentication renewal and input connection changes", () => {
29 expect(deviceStreamMessage('{"type":"unauthorized"}')).toEqual({ type: "unauthorized" });
30 expect(deviceStreamMessage('{"type":"input","connected":true}')).toEqual({
31 type: "input",
32 connected: true,
33 });
34 expect(deviceStreamMessage('{"type":"input","connected":false}')).toEqual({
35 type: "input",
36 connected: false,
37 });
38 expect(deviceStreamMessage('{"type":"retry"}')).toEqual({ type: "retry" });
39 });
40
41 it.each([
42 "invalid JSON",
43 "null",
44 '"input"',
45 "{}",
46 '{"type":"input","connected":"yes"}',
47 '{"type":"unknown"}',
48 ])("ignores invalid bridge messages: %s", (data) => expect(deviceStreamMessage(data)).toBeNull());
49});
apps/mobile/src/features/devices/device-stream-document.ts created+41
...@@ -0,0 +1,41 @@
1import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access";
2import type { DevicePlatform } from "@t3tools/contracts";
3
4export interface DeviceStreamConfiguration {
5 readonly access: DeviceHubAccess;
6 readonly platform: DevicePlatform;
7 readonly deviceId: string;
8 readonly colors: {
9 readonly background: string;
10 readonly foreground: string;
11 readonly muted: string;
12 readonly buttonBackground: string;
13 };
14}
15
16export function deviceStreamDocument(configuration: string, script: string) {
17 // Tickets and device names are data, including any HTML delimiter characters.
18 const safeConfiguration = configuration.replace(/</g, "\\u003c");
19 const safeScript = script.replace(/<\/script/gi, "<\\/script");
20 return `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"></head><body><script>${safeScript}\nT3DeviceStream.start(${safeConfiguration});</script></body></html>`;
21}
22
23export function deviceStreamMessage(data: string) {
24 try {
25 const message: unknown = JSON.parse(data);
26 if (typeof message !== "object" || message === null || !("type" in message)) return null;
27 if (message.type === "unauthorized" || message.type === "retry") {
28 return { type: message.type } as const;
29 }
30 if (
31 message.type === "input" &&
32 "connected" in message &&
33 typeof message.connected === "boolean"
34 ) {
35 return { type: message.type, connected: message.connected } as const;
36 }
37 } catch {
38 // Ignore messages that are not part of the stream bridge.
39 }
40 return null;
41}
apps/mobile/src/features/devices/device-stream.browser.ts created+223
...@@ -0,0 +1,223 @@
1import {
2 createDeviceStreamClient,
3 type DeviceScreenSize,
4} from "@t3tools/client-runtime/device/stream";
5
6import type { DeviceStreamConfiguration } from "./device-stream-document";
7
8declare global {
9 interface Window {
10 ReactNativeWebView: { postMessage: (message: string) => void };
11 }
12}
13
14let activeClient: ReturnType<typeof createDeviceStreamClient> | null = null;
15let activeImage: HTMLImageElement | null = null;
16
17export function stop() {
18 activeClient?.stop();
19 activeClient = null;
20 activeImage?.removeAttribute("src");
21 activeImage = null;
22}
23
24export function command(button: "home" | "back" | "appSwitcher" | "rotate") {
25 if (button === "rotate") activeClient?.rotate();
26 else activeClient?.pressButton(button);
27}
28
29/** Bundled into the existing native WebView without React or Expo's web runtime. */
30export function start(configuration: DeviceStreamConfiguration) {
31 stop();
32 // oxlint-disable-next-line unicorn/require-post-message-target-origin -- The native WebView bridge takes one string.
33 const post = (message: object) => window.ReactNativeWebView.postMessage(JSON.stringify(message));
34 const { colors, platform } = configuration;
35 Object.assign(document.documentElement.style, { height: "100%", overflow: "hidden" });
36 Object.assign(document.body.style, {
37 margin: "0",
38 height: "100%",
39 overflow: "hidden",
40 background: colors.background,
41 color: colors.foreground,
42 fontFamily: "system-ui",
43 });
44 const container = document.createElement("div");
45 Object.assign(container.style, {
46 position: "fixed",
47 inset: "0",
48 containerType: "size",
49 display: "flex",
50 alignItems: "center",
51 justifyContent: "center",
52 });
53 const frame = document.createElement("div");
54 frame.setAttribute("role", "application");
55 frame.setAttribute(
56 "aria-label",
57 platform === "ios" ? "iOS Simulator screen" : "Android Emulator screen",
58 );
59 frame.tabIndex = 0;
60 Object.assign(frame.style, {
61 position: "relative",
62 touchAction: "none",
63 userSelect: "none",
64 webkitUserSelect: "none",
65 webkitTouchCallout: "none",
66 outline: "none",
67 });
68 const canvas = document.createElement("canvas");
69 const image = document.createElement("img");
70 image.alt = "";
71 image.draggable = false;
72 image.style.display = "none";
73 const overlay = document.createElement("div");
74 overlay.setAttribute("role", "status");
75 Object.assign(overlay.style, {
76 position: "fixed",
77 inset: "0",
78 display: "flex",
79 flexDirection: "column",
80 alignItems: "center",
81 justifyContent: "center",
82 gap: "16px",
83 padding: "24px",
84 textAlign: "center",
85 background: colors.background,
86 });
87 const detail = document.createElement("span");
88 const retry = document.createElement("button");
89 retry.textContent = "Retry";
90 Object.assign(retry.style, {
91 padding: "12px 24px",
92 borderRadius: "20px",
93 border: "0",
94 background: colors.buttonBackground,
95 color: colors.foreground,
96 font: "inherit",
97 display: "none",
98 });
99 retry.addEventListener("click", () => post({ type: "retry" }));
100 overlay.append(detail, retry);
101 const inputStatus = document.createElement("div");
102 inputStatus.setAttribute("role", "status");
103 inputStatus.textContent = "Reconnecting device controls...";
104 Object.assign(inputStatus.style, {
105 position: "fixed",
106 bottom: "12px",
107 left: "0",
108 right: "0",
109 textAlign: "center",
110 pointerEvents: "none",
111 fontSize: "13px",
112 color: colors.muted,
113 background: colors.background,
114 });
115 frame.append(canvas, image);
116 container.append(frame);
117 document.body.replaceChildren(container, overlay, inputStatus);
118
119 let pointerId: number | null = null;
120 let inputConnected = false;
121 let streaming = false;
122 const layout = (screen: DeviceScreenSize | null) => {
123 const landscape =
124 screen?.orientation === "landscape_left" || screen?.orientation === "landscape_right";
125 const aspect = screen
126 ? landscape
127 ? Math.max(screen.width, screen.height) / Math.min(screen.width, screen.height)
128 : Math.min(screen.width, screen.height) / Math.max(screen.width, screen.height)
129 : 9 / 19.5;
130 const rotation =
131 platform === "ios" && screen && screen.width <= screen.height
132 ? screen.orientation === "landscape_left"
133 ? 90
134 : screen.orientation === "landscape_right"
135 ? -90
136 : screen.orientation === "portrait_upside_down"
137 ? 180
138 : 0
139 : 0;
140 const sideways = Math.abs(rotation) === 90;
141 frame.style.width = `min(100cqw, ${aspect * 100}cqh)`;
142 frame.style.height = `min(100cqh, ${100 / aspect}cqw)`;
143 for (const media of [canvas, image]) {
144 Object.assign(media.style, {
145 position: "absolute",
146 width: sideways ? `${100 / aspect}%` : "100%",
147 height: sideways ? `${100 * aspect}%` : "100%",
148 left: "50%",
149 top: "50%",
150 transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
151 pointerEvents: "none",
152 });
153 }
154 };
155 layout(null);
156 const unauthorized = () => {
157 if (activeClient === client) post({ type: "unauthorized" });
158 };
159 const client = createDeviceStreamClient(
160 { ...configuration, preferMjpeg: platform === "ios" },
161 canvas,
162 {
163 onStatus: (status, message) => {
164 streaming = status === "streaming";
165 overlay.style.display = streaming ? "none" : "flex";
166 inputStatus.style.display = streaming && !inputConnected ? "block" : "none";
167 detail.textContent =
168 status === "error" ? (message ?? "Device stream failed.") : "Connecting to device...";
169 retry.style.display = status === "error" ? "block" : "none";
170 },
171 onScreen: layout,
172 onMjpegFallback: (url) => {
173 canvas.style.display = "none";
174 image.style.display = "block";
175 image.src = url;
176 },
177 onUnauthorized: unauthorized,
178 onInputConnected: (connected) => {
179 inputConnected = connected;
180 inputStatus.style.display = streaming && !connected ? "block" : "none";
181 post({ type: "input", connected });
182 },
183 },
184 );
185 activeClient = client;
186 activeImage = image;
187 image.addEventListener("error", unauthorized);
188 const touch = (event: PointerEvent, phase: "begin" | "move" | "end") => {
189 const rect = frame.getBoundingClientRect();
190 client.sendTouch(
191 phase,
192 Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
193 Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)),
194 );
195 };
196 frame.addEventListener("pointerdown", (event) => {
197 if (!inputConnected || pointerId !== null) return;
198 event.preventDefault();
199 pointerId = event.pointerId;
200 frame.setPointerCapture(event.pointerId);
201 frame.focus();
202 touch(event, "begin");
203 });
204 frame.addEventListener("pointermove", (event) => {
205 if (pointerId === event.pointerId) touch(event, "move");
206 });
207 const endTouch = (event: PointerEvent) => {
208 if (pointerId !== event.pointerId) return;
209 pointerId = null;
210 touch(event, "end");
211 };
212 frame.addEventListener("pointerup", endTouch);
213 frame.addEventListener("pointercancel", endTouch);
214 frame.addEventListener("lostpointercapture", endTouch);
215 frame.addEventListener("keydown", (event) => {
216 event.preventDefault();
217 client.sendKey(event, "down");
218 });
219 frame.addEventListener("keyup", (event) => client.sendKey(event, "up"));
220 window.addEventListener("pagehide", stop, { once: true });
221 post({ type: "input", connected: false });
222 client.start();
223}
apps/mobile/src/features/devices/threadDevicePreviews.test.ts created+106
...@@ -0,0 +1,106 @@
1import { ThreadId, type DeviceServiceState } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { selectedThreadDevicePreview, threadDevicePreviews } from "./threadDevicePreviews";
5
6const threadId = ThreadId.make("thread");
7const state: DeviceServiceState = {
8 hosts: [
9 {
10 id: "local",
11 kind: "local",
12 label: "Local",
13 platforms: [],
14 hubInstalled: true,
15 agentDeviceInstalled: true,
16 },
17 {
18 id: "remote",
19 kind: "ssh",
20 label: "Mac mini",
21 platforms: [],
22 hubInstalled: true,
23 agentDeviceInstalled: true,
24 },
25 ],
26 hostStatus: "ready",
27 hostStatuses: {},
28 devices: [
29 {
30 hostId: "local",
31 id: "emulator-5554",
32 platform: "android",
33 name: "Pixel 9",
34 version: "Android 16",
35 booted: true,
36 physical: false,
37 },
38 {
39 hostId: "remote",
40 id: "emulator-5554",
41 platform: "android",
42 name: "Pixel 8",
43 version: "Android 15",
44 booted: true,
45 physical: false,
46 },
47 ],
48 sessions: [
49 {
50 threadId,
51 hostId: "local",
52 deviceId: "emulator-5554",
53 platform: "android",
54 openedAt: "2026-09-18T00:00:00Z",
55 },
56 {
57 threadId,
58 hostId: "remote",
59 deviceId: "emulator-5554",
60 platform: "android",
61 openedAt: "2026-09-18T00:01:00Z",
62 },
63 {
64 threadId: ThreadId.make("another-thread"),
65 hostId: "local",
66 deviceId: "iphone",
67 platform: "ios",
68 openedAt: "2026-09-18T00:02:00Z",
69 },
70 ],
71 onboardingCompleted: true,
72 agentAccessEnabled: true,
73 hubBasePath: "/api/device-hub",
74 revision: 1,
75};
76
77describe("thread device previews", () => {
78 it("keeps device names and selection distinct when hosts have the same Android serial", () => {
79 const previews = threadDevicePreviews(state, threadId);
80 expect(previews.map((preview) => preview.name)).toEqual(["Pixel 9", "Pixel 8"]);
81 expect(new Set(previews.map((preview) => preview.key)).size).toBe(2);
82 expect(selectedThreadDevicePreview(previews, previews[1]!.key)?.description).toBe(
83 "Android 15 · Mac mini",
84 );
85 });
86
87 it("selects another open device when the agent closes the selected session", () => {
88 const previews = threadDevicePreviews(state, threadId);
89 const selectedKey = previews[1]!.key;
90 const afterClose = threadDevicePreviews({ ...state, sessions: [state.sessions[0]!] }, threadId);
91 expect(selectedThreadDevicePreview(afterClose, selectedKey)?.name).toBe("Pixel 9");
92 expect(selectedThreadDevicePreview([], selectedKey)).toBeNull();
93 });
94
95 it("can view an open session before device discovery metadata arrives", () => {
96 const previews = threadDevicePreviews({ ...state, hosts: [], devices: [] }, threadId);
97 expect(previews[0]?.name).toBe("Android Emulator");
98 expect(previews[0]?.session.deviceId).toBe("emulator-5554");
99 expect(previews[0]?.description).toBe("");
100 });
101
102 it("offers no devices before state arrives or in a thread without sessions", () => {
103 expect(threadDevicePreviews(null, threadId)).toEqual([]);
104 expect(threadDevicePreviews(state, ThreadId.make("empty-thread"))).toEqual([]);
105 });
106});
apps/mobile/src/features/devices/threadDevicePreviews.ts created+28
...@@ -0,0 +1,28 @@
1import type { DeviceServiceState, ThreadId } from "@t3tools/contracts";
2
3/** Host identity is part of the selection because Android serials repeat across hosts. */
4export function threadDevicePreviews(state: DeviceServiceState | null, threadId: ThreadId) {
5 return (state?.sessions ?? [])
6 .filter((session) => session.threadId === threadId)
7 .map((session) => {
8 const device = state?.devices.find(
9 (device) => device.hostId === session.hostId && device.id === session.deviceId,
10 );
11 const host = state?.hosts.find((host) => host.id === session.hostId);
12 return {
13 key: JSON.stringify([session.hostId, session.deviceId]),
14 session,
15 name: device?.name ?? (session.platform === "ios" ? "iOS Simulator" : "Android Emulator"),
16 description: [device?.version, host?.label].filter(Boolean).join(" · "),
17 };
18 });
19}
20
21export type ThreadDevicePreview = ReturnType<typeof threadDevicePreviews>[number];
22
23export function selectedThreadDevicePreview(
24 previews: ReadonlyArray<ThreadDevicePreview>,
25 selectedKey: string | null,
26) {
27 return previews.find((preview) => preview.key === selectedKey) ?? previews[0] ?? null;
28}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+25
...@@ -13,6 +13,7 @@ import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp...@@ -13,6 +13,7 @@ import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp
13import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";13import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
14import type { LegendListRef } from "@legendapp/list/react-native";14import type { LegendListRef } from "@legendapp/list/react-native";
15import { HeaderHeightContext } from "@react-navigation/elements";15import { HeaderHeightContext } from "@react-navigation/elements";
16import { useNavigation } from "@react-navigation/native";
16import type {17import type {
17 ApprovalRequestId,18 ApprovalRequestId,
18 EnvironmentId,19 EnvironmentId,
...@@ -72,6 +73,9 @@ import type { DraftComposerAttachment } from "../../lib/composerImages";...@@ -72,6 +73,9 @@ import type { DraftComposerAttachment } from "../../lib/composerImages";
72import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";73import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
73import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";74import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
74import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";75import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";
76import { deviceEnvironment } from "../../state/device";
77import { useEnvironmentQuery } from "../../state/query";
78import { threadDevicePreviews } from "../devices/threadDevicePreviews";
75import type { QueuedThreadMessage } from "../../state/thread-outbox-model";79import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
76import { scopedThreadKey } from "../../lib/scopedEntities";80import { scopedThreadKey } from "../../lib/scopedEntities";
77import type {81import type {
...@@ -259,6 +263,21 @@ const USER_INPUT_TOGGLE_TIMING = {...@@ -259,6 +263,21 @@ const USER_INPUT_TOGGLE_TIMING = {
259};263};
260264
261export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {265export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
266 const navigation = useNavigation();
267 const deviceState = useEnvironmentQuery(
268 deviceEnvironment.state({ environmentId: props.environmentId, input: {} }),
269 );
270 const devicePreviews = useMemo(
271 () => threadDevicePreviews(deviceState.data, props.selectedThread.id),
272 [deviceState.data, props.selectedThread.id],
273 );
274 const openDevicePreview = useCallback(() => {
275 Keyboard.dismiss();
276 navigation.navigate("ThreadDevicePreview", {
277 environmentId: props.environmentId,
278 threadId: props.selectedThread.id,
279 });
280 }, [navigation, props.environmentId, props.selectedThread.id]);
262 const insets = useSafeAreaInsets();281 const insets = useSafeAreaInsets();
263 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);282 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
264 const liveKeyboardHeight = useKeyboardState((state) => state.height);283 const liveKeyboardHeight = useKeyboardState((state) => state.height);
...@@ -390,6 +409,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -390,6 +409,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
390 // stable when reconnecting hands off to syncing and then to a running turn.409 // stable when reconnecting hands off to syncing and then to a running turn.
391 const showFloatingStatus =410 const showFloatingStatus =
392 showWorkingControl ||411 showWorkingControl ||
412 devicePreviews.length > 0 ||
393 props.connectionStateLabel !== "connected" ||413 props.connectionStateLabel !== "connected" ||
394 props.queuedMessages.length > 0 ||414 props.queuedMessages.length > 0 ||
395 props.selectedThreadFeed.some(415 props.selectedThreadFeed.some(
...@@ -936,6 +956,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -936,6 +956,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
936 <FloatingWorkingControl956 <FloatingWorkingControl
937 colorScheme={isDarkMode ? "dark" : "light"}957 colorScheme={isDarkMode ? "dark" : "light"}
938 status={floatingStatus}958 status={floatingStatus}
959 devicePreview={
960 devicePreviews.length > 0
961 ? { count: devicePreviews.length, onPress: openDevicePreview }
962 : null
963 }
939 showScrollToEnd={showScrollToEndButton}964 showScrollToEnd={showScrollToEndButton}
940 onScrollToEnd={handleScrollToEnd}965 onScrollToEnd={handleScrollToEnd}
941 />966 />
apps/mobile/src/features/threads/floating-working-control.tsx+38-14
...@@ -24,6 +24,7 @@ import { AppText as Text } from "../../components/AppText";...@@ -24,6 +24,7 @@ import { AppText as Text } from "../../components/AppText";
24import { SymbolView } from "../../components/AppSymbol";24import { SymbolView } from "../../components/AppSymbol";
25import { ControlPill } from "../../components/ControlPill";25import { ControlPill } from "../../components/ControlPill";
26import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";26import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
27import { DevicePreviewButton } from "../devices/device-preview-button";
27import type { FloatingWorkingStatus } from "./floating-working-status";28import type { FloatingWorkingStatus } from "./floating-working-status";
28import { ShimmeringWorkContent } from "./thread-work-log";29import { ShimmeringWorkContent } from "./thread-work-log";
2930
...@@ -63,12 +64,18 @@ export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTRO...@@ -63,12 +64,18 @@ export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTRO
63export function FloatingWorkingControl(props: {64export function FloatingWorkingControl(props: {
64 readonly colorScheme: "light" | "dark";65 readonly colorScheme: "light" | "dark";
65 readonly status: FloatingWorkingStatus | null;66 readonly status: FloatingWorkingStatus | null;
67 readonly devicePreview: { readonly count: number; readonly onPress: () => void } | null;
66 readonly showScrollToEnd: boolean;68 readonly showScrollToEnd: boolean;
67 readonly onScrollToEnd: () => void;69 readonly onScrollToEnd: () => void;
68}) {70}) {
69 const { width: windowWidth } = useWindowDimensions();71 const { width: windowWidth } = useWindowDimensions();
70 const [overlayWidth, setOverlayWidth] = useState(windowWidth);72 const [overlayWidth, setOverlayWidth] = useState(windowWidth);
71 const labelWidth = Math.max(0, Math.min(overlayWidth, windowWidth) - CONTROL_HEIGHT - 16);73 const deviceControlWidth = props.devicePreview !== null ? CONTROL_HEIGHT : 0;
74 const hasCapsule = props.status !== null || props.devicePreview !== null;
75 const labelWidth = Math.max(
76 0,
77 Math.min(overlayWidth, windowWidth) - CONTROL_HEIGHT - 16 - deviceControlWidth,
78 );
72 const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);79 const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);
7380
74 useEffect(() => {81 useEffect(() => {
...@@ -111,25 +118,30 @@ export function FloatingWorkingControl(props: {...@@ -111,25 +118,30 @@ export function FloatingWorkingControl(props: {
111 }));118 }));
112 // Zero until the first measurement lands, so the capsule never paints around119 // Zero until the first measurement lands, so the capsule never paints around
113 // a label it has not sized to yet.120 // a label it has not sized to yet.
114 const capsuleSizerStyle = useAnimatedStyle(() => ({ width: capsuleWidth.value ?? 0 }));121 const capsuleSizerStyle = useAnimatedStyle(() => ({
122 width: (capsuleWidth.value ?? 0) + deviceControlWidth,
123 }));
115124
116 if (props.status === null && !props.showScrollToEnd) {125 if (!hasCapsule && !props.showScrollToEnd) {
117 return null;126 return null;
118 }127 }
119128
120 // Only the connection label is a button (tap to reconnect); the others129 // Only the connection label is a button (tap to reconnect); the others
121 // pass touches through to the feed like before.130 // pass touches through to the feed like before.
122 const statusInteractive = props.status?.kind === "connection";131 const statusInteractive = props.status?.kind === "connection";
132 const capsuleInteractive = statusInteractive || props.devicePreview !== null;
123 // The host stays centered on the capsule, but its measurement constraint133 // The host stays centered on the capsule, but its measurement constraint
124 // comes from the overlay, independent of the capsule's current width.134 // comes from the overlay, independent of the capsule's current width.
125 const statusContent =135 const capsuleContent =
126 props.status !== null ? (136 props.status === null && props.devicePreview !== null ? (
137 <DevicePreviewButton {...props.devicePreview} compact={false} />
138 ) : props.status !== null ? (
127 <>139 <>
128 <Animated.View className="h-11" style={capsuleSizerStyle} />140 <Animated.View className="h-11" style={capsuleSizerStyle} />
129 <View141 <View
130 pointerEvents="box-none"142 pointerEvents={statusInteractive ? "box-none" : "none"}
131 className="absolute h-11 items-center justify-center"143 className="absolute h-11 items-center justify-center"
132 style={{ width: labelWidth }}144 style={{ width: labelWidth, transform: [{ translateX: -deviceControlWidth / 2 }] }}
133 >145 >
134 <FloatingStatusLabel146 <FloatingStatusLabel
135 key={147 key={
...@@ -141,6 +153,18 @@ export function FloatingWorkingControl(props: {...@@ -141,6 +153,18 @@ export function FloatingWorkingControl(props: {
141 onLayout={handleLabelLayout}153 onLayout={handleLabelLayout}
142 />154 />
143 </View>155 </View>
156 {props.devicePreview !== null ? (
157 <>
158 <View
159 pointerEvents="none"
160 className="absolute h-4 w-px bg-border"
161 style={{ right: deviceControlWidth }}
162 />
163 <View className="absolute right-0">
164 <DevicePreviewButton {...props.devicePreview} />
165 </View>
166 </>
167 ) : null}
144 </>168 </>
145 ) : null;169 ) : null;
146170
...@@ -153,7 +177,7 @@ export function FloatingWorkingControl(props: {...@@ -153,7 +177,7 @@ export function FloatingWorkingControl(props: {
153 entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING}177 entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING}
154 exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING}178 exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING}
155 >179 >
156 {props.status !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? (180 {hasCapsule && NATIVE_LIQUID_GLASS_SUPPORTED ? (
157 <UniwindGlassContainer181 <UniwindGlassContainer
158 spacing={GLASS_MERGE_SPACING}182 spacing={GLASS_MERGE_SPACING}
159 pointerEvents="box-none"183 pointerEvents="box-none"
...@@ -162,12 +186,12 @@ export function FloatingWorkingControl(props: {...@@ -162,12 +186,12 @@ export function FloatingWorkingControl(props: {
162 <AnimatedGlassView186 <AnimatedGlassView
163 colorScheme={props.colorScheme}187 colorScheme={props.colorScheme}
164 glassEffectStyle="regular"188 glassEffectStyle="regular"
165 isInteractive={statusInteractive}189 isInteractive={capsuleInteractive}
166 pointerEvents={statusInteractive ? "box-none" : "none"}190 pointerEvents={capsuleInteractive ? "box-none" : "none"}
167 className="h-11 items-center justify-center overflow-hidden rounded-full"191 className="h-11 items-center justify-center overflow-hidden rounded-full"
168 style={capsuleStyle}192 style={capsuleStyle}
169 >193 >
170 {statusContent}194 {capsuleContent}
171 </AnimatedGlassView>195 </AnimatedGlassView>
172196
173 <AnimatedGlassView197 <AnimatedGlassView
...@@ -185,14 +209,14 @@ export function FloatingWorkingControl(props: {...@@ -185,14 +209,14 @@ export function FloatingWorkingControl(props: {
185 </Animated.View>209 </Animated.View>
186 </AnimatedGlassView>210 </AnimatedGlassView>
187 </UniwindGlassContainer>211 </UniwindGlassContainer>
188 ) : props.status !== null ? (212 ) : hasCapsule ? (
189 <View pointerEvents="box-none" className="flex-row items-center gap-4">213 <View pointerEvents="box-none" className="flex-row items-center gap-4">
190 <Animated.View214 <Animated.View
191 pointerEvents={statusInteractive ? "box-none" : "none"}215 pointerEvents={capsuleInteractive ? "box-none" : "none"}
192 className="h-11 items-center justify-center overflow-hidden rounded-full border border-border bg-card shadow-md shadow-black/10"216 className="h-11 items-center justify-center overflow-hidden rounded-full border border-border bg-card shadow-md shadow-black/10"
193 style={capsuleStyle}217 style={capsuleStyle}
194 >218 >
195 {statusContent}219 {capsuleContent}
196 </Animated.View>220 </Animated.View>
197221
198 <Animated.View222 <Animated.View
apps/mobile/src/state/device.ts created+44
...@@ -0,0 +1,44 @@
1import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device";
2import { resolveDeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
3import type { EnvironmentId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import { Atom } from "effect/unstable/reactivity";
7import { useMemo } from "react";
8
9import { connectionAtomRuntime } from "../connection/runtime";
10import { appAtomRegistry } from "./atom-registry";
11import { useEnvironmentQuery } from "./query";
12import { environmentSession, usePreparedConnection } from "./session";
13
14export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRuntime);
15
16const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) =>
17 connectionAtomRuntime
18 .atom((get) => {
19 const prepared = Option.getOrNull(
20 get(environmentSession.preparedConnectionValueAtom(environmentId)),
21 );
22 return prepared === null
23 ? Effect.never
24 : resolveDeviceHubAccess({ prepared, hubBasePath: "/api/device-hub" });
25 })
26 .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`mobile-device-hub-access:${environmentId}`)),
27);
28
29export function refreshDeviceHubAccess(environmentId: EnvironmentId) {
30 appAtomRegistry.refresh(deviceHubAccessAtom(environmentId));
31}
32
33export function useDeviceHubAccess(environmentId: EnvironmentId, hostId: string) {
34 const prepared = usePreparedConnection(environmentId);
35 const query = useEnvironmentQuery(deviceHubAccessAtom(environmentId));
36 const access = useMemo(
37 () =>
38 query.data && query.error === null && Option.isSome(prepared)
39 ? { ...query.data, query: { ...query.data.query, hostId } }
40 : null,
41 [query.data, query.error, prepared, hostId],
42 );
43 return { access, error: query.error, refresh: query.refresh };
44}
apps/mobile/src/types/mobile-device-stream.d.ts created+4
...@@ -0,0 +1,4 @@
1declare module "@t3tools/mobile-device-stream" {
2 const script: string;
3 export default script;
4}
apps/web/src/components/device/DeviceStreamView.test.tsx+1-1
...@@ -8,7 +8,7 @@ vi.mock("~/state/device", () => ({...@@ -8,7 +8,7 @@ vi.mock("~/state/device", () => ({
8 refreshDeviceHubAccess: vi.fn(),8 refreshDeviceHubAccess: vi.fn(),
9}));9}));
10const access = { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true };10const access = { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true };
11vi.mock("./deviceStream", () => ({11vi.mock("@t3tools/client-runtime/device/stream", () => ({
12 createDeviceStreamClient: (12 createDeviceStreamClient: (
13 _target: unknown,13 _target: unknown,
14 _canvas: unknown,14 _canvas: unknown,
apps/web/src/components/device/DeviceStreamView.tsx+1-1
...@@ -11,7 +11,7 @@ import {...@@ -11,7 +11,7 @@ import {
11 type DeviceScreenSize,11 type DeviceScreenSize,
12 type DeviceStreamClient,12 type DeviceStreamClient,
13 type DeviceStreamStatus,13 type DeviceStreamStatus,
14} from "./deviceStream";14} from "@t3tools/client-runtime/device/stream";
1515
16const AX_POLL_INTERVAL_MS = 2_000;16const AX_POLL_INTERVAL_MS = 2_000;
1717
apps/web/src/components/device/deviceStream.test.ts+166-1
...@@ -6,7 +6,7 @@ import {...@@ -6,7 +6,7 @@ import {
6 avcCodecString,6 avcCodecString,
7 parseSemuPacket,7 parseSemuPacket,
8 scanAccessUnit,8 scanAccessUnit,
9} from "./deviceStream";9} from "@t3tools/client-runtime/device/stream";
1010
11const envelope = (tag: number, payload: number[]) => {11const envelope = (tag: number, payload: number[]) => {
12 const length = 1 + payload.length;12 const length = 1 + payload.length;
...@@ -43,6 +43,171 @@ describe("AvccDemuxer", () => {...@@ -43,6 +43,171 @@ describe("AvccDemuxer", () => {
43 });43 });
44});44});
4545
46describe("native device stream transport", () => {
47 afterEach(() => {
48 vi.useRealTimers();
49 vi.unstubAllGlobals();
50 });
51
52 function setup(platform: "ios" | "android") {
53 vi.useFakeTimers();
54 vi.stubGlobal("VideoDecoder", vi.fn());
55 vi.stubGlobal("EncodedVideoChunk", vi.fn());
56 let resolveOpened!: (socket: FakeSocket) => void;
57 const waitForSocket = () =>
58 new Promise<FakeSocket>((resolve) => {
59 resolveOpened = resolve;
60 });
61 const opened = waitForSocket();
62 class FakeSocket {
63 static OPEN = 1;
64 readyState = 1;
65 binaryType = "";
66 onopen: (() => void) | null = null;
67 onclose: ((event: { code: number; reason: string }) => void) | null = null;
68 send = vi.fn();
69 close = vi.fn();
70 readonly url: string;
71 constructor(url: string) {
72 this.url = url;
73 resolveOpened(this);
74 }
75 }
76 vi.stubGlobal("WebSocket", FakeSocket);
77 const fetch = vi.fn(() => Promise.resolve(new Response("frame")));
78 vi.stubGlobal("fetch", fetch);
79 const events = {
80 onStatus: vi.fn(),
81 onScreen: vi.fn(),
82 onUnauthorized: vi.fn(),
83 onMjpegFallback: vi.fn(),
84 onInputConnected: vi.fn(),
85 };
86 const client = createDeviceStreamClient(
87 {
88 platform,
89 deviceId: "test device",
90 preferMjpeg: platform === "ios",
91 access: {
92 httpBase: "https://environment.test/api/device-hub",
93 wsBase: "wss://environment.test/api/device-hub",
94 credentials: false,
95 query: { wsTicket: "stream-ticket", hostId: "ssh-host" },
96 },
97 },
98 { getContext: () => null } as unknown as HTMLCanvasElement,
99 events,
100 );
101 return { client, opened, waitForSocket, events, fetch };
102 }
103
104 it("uses authenticated iOS MJPEG and forwards controls without a cross-origin video fetch", async () => {
105 const { client, opened, events, fetch } = setup("ios");
106 client.start();
107 const socket = await opened;
108 const imageUrl = new URL(events.onMjpegFallback.mock.calls[0]![0] as string);
109 expect(imageUrl.pathname).toContain("test%20device/stream.mjpeg");
110 expect(imageUrl.searchParams.get("wsTicket")).toBe("stream-ticket");
111 expect(imageUrl.searchParams.get("hostId")).toBe("ssh-host");
112 expect(fetch).toHaveBeenCalledTimes(1);
113 socket.onopen?.();
114 client.pressButton("home");
115 client.sendTouch("begin", 0.25, 0.75);
116 const messages = socket.send.mock.calls.slice(1).map(([bytes]) => {
117 const packet = bytes as Uint8Array;
118 return {
119 tag: packet[0],
120 body: JSON.parse(new TextDecoder().decode(packet.subarray(1))) as unknown,
121 };
122 });
123 expect(messages).toEqual([
124 { tag: 0x04, body: { button: "home" } },
125 { tag: 0x03, body: { type: "begin", x: 0.25, y: 0.75 } },
126 ]);
127 client.stop();
128 expect(socket.close).toHaveBeenCalledTimes(1);
129 expect(vi.getTimerCount()).toBe(0);
130 });
131
132 it("forwards Android gestures and device controls through the ticketed host socket", async () => {
133 const { client, opened } = setup("android");
134 client.start();
135 const socket = await opened;
136 const url = new URL(socket.url);
137 expect(url.searchParams.get("device")).toBe("test device");
138 expect(url.searchParams.get("wsTicket")).toBe("stream-ticket");
139 expect(url.searchParams.get("hostId")).toBe("ssh-host");
140 client.sendTouch("begin", 0.2, 0.8);
141 client.sendTouch("move", 0.2, 0.4);
142 client.sendTouch("end", 0.2, 0.1);
143 client.pressButton("home");
144 client.pressButton("appSwitcher");
145 expect(
146 socket.send.mock.calls.map(([message]) => JSON.parse(message as string) as unknown),
147 ).toEqual([
148 { type: "touch", action: "down", x: 0.2, y: 0.8 },
149 { type: "touch", action: "move", x: 0.2, y: 0.4 },
150 { type: "touch", action: "up", x: 0.2, y: 0.1 },
151 { type: "home" },
152 { type: "recents" },
153 ]);
154 client.stop();
155 expect(socket.close).toHaveBeenCalledTimes(1);
156 });
157
158 it("renews an expired Android ticket when the HTTP upgrade is rejected instead of retrying it forever", async () => {
159 const { client, opened, events } = setup("android");
160 client.start();
161 const socket = await opened;
162 socket.onclose?.({ code: 1006, reason: "" });
163 expect(events.onUnauthorized).toHaveBeenCalledTimes(1);
164 expect(vi.getTimerCount()).toBe(0);
165 client.stop();
166 });
167
168 it.each(["ios", "android"] as const)(
169 "keeps a restarted %s stream connected when the discarded socket closes late",
170 async (platform) => {
171 const { client, opened, waitForSocket, events } = setup(platform);
172 client.start();
173 const discarded = await opened;
174 client.stop();
175 const reopened = waitForSocket();
176 client.start();
177 const replacement = await reopened;
178 replacement.onopen?.();
179 replacement.send.mockClear();
180 events.onInputConnected.mockClear();
181
182 discarded.onclose?.({ code: 1006, reason: "" });
183
184 expect(events.onUnauthorized).not.toHaveBeenCalled();
185 expect(events.onInputConnected).not.toHaveBeenCalled();
186 expect(replacement.close).not.toHaveBeenCalled();
187 client.pressButton("home");
188 expect(replacement.send).toHaveBeenCalledTimes(1);
189 client.stop();
190 expect(replacement.close).toHaveBeenCalledTimes(1);
191 expect(vi.getTimerCount()).toBe(0);
192 },
193 );
194
195 it.each(["ios", "android"] as const)(
196 "does not renew credentials or reconnect a stopped %s stream",
197 async (platform) => {
198 const { client, opened, events } = setup(platform);
199 client.start();
200 const socket = await opened;
201 events.onInputConnected.mockClear();
202 client.stop();
203 socket.onclose?.({ code: 1006, reason: "" });
204 expect(events.onUnauthorized).not.toHaveBeenCalled();
205 expect(events.onInputConnected).not.toHaveBeenCalled();
206 expect(vi.getTimerCount()).toBe(0);
207 },
208 );
209});
210
46describe("serve-emu frames", () => {211describe("serve-emu frames", () => {
47 it("strips the SEMU header and reads the keyframe flag and timestamp", () => {212 it("strips the SEMU header and reads the keyframe flag and timestamp", () => {
48 const buffer = new ArrayBuffer(16 + 3);213 const buffer = new ArrayBuffer(16 + 3);
apps/web/src/components/device/deviceStream.ts deleted-697
...@@ -1,697 +0,0 @@
1/**
2 * Framework-free client for expo-device-hub's per-device streams, reached
3 * through the T3 proxy. One class handles both platforms because the hub
4 * vendors two servers with different wire formats:
5 *
6 * - iOS (serve-sim): video is an HTTP `stream.avcc` body of length-prefixed
7 * envelopes (`u32be length, u8 tag, payload`; tag 1 avcC description,
8 * 2 keyframe, 3 delta, 4 JPEG seed) decoded with WebCodecs; input goes over
9 * `helper/ws?device=<udid>` as `[tag][json]` packets. When WebCodecs is
10 * unavailable (plain-http remote origins) the MJPEG endpoint is used as an
11 * `<img>` source instead.
12 * - Android (serve-emu): one WebSocket at `ws?device=<serial>&frame-meta=1`
13 * carries H.264 access units prefixed with a 16-byte "SEMU" header
14 * (magic, version, key flag, pts) and accepts JSON gestures upstream.
15 *
16 * The decoder only runs while frames arrive and the viewer is attached; a
17 * hidden panel calls `stop()` so an idle device costs nothing on the GPU.
18 */
19import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
20import { withDeviceHubQuery } from "@t3tools/client-runtime/state/deviceHubAccess";
21import type { DevicePlatform } from "@t3tools/contracts";
22
23export type DeviceStreamStatus = "connecting" | "streaming" | "error";
24
25export interface DeviceScreenSize {
26 readonly width: number;
27 readonly height: number;
28 readonly orientation: "portrait" | "portrait_upside_down" | "landscape_left" | "landscape_right";
29}
30
31export interface DeviceStreamEvents {
32 readonly onStatus: (status: DeviceStreamStatus, detail?: string) => void;
33 readonly onScreen: (screen: DeviceScreenSize) => void;
34 /** The proxy rejected the credential; the owner should refresh access and reconnect. */
35 readonly onUnauthorized: () => void;
36 /**
37 * H.264 cannot be decoded here (no WebCodecs, or the simulator's profile is
38 * unsupported); the owner should show this MJPEG URL in an `<img>` instead of
39 * the canvas.
40 */
41 readonly onMjpegFallback: (url: string) => void;
42 /** Whether touches and keys can currently reach the device. */
43 readonly onInputConnected: (connected: boolean, detail?: string) => void;
44}
45
46export interface DeviceStreamTarget {
47 readonly platform: DevicePlatform;
48 readonly deviceId: string;
49 readonly access: DeviceHubAccess;
50}
51
52export type DeviceHardwareButton = "home" | "back" | "recents" | "power" | "appSwitcher";
53
54const RETRY_DELAY_MS = 1_000;
55const FRAME_DURATION_US = 16_667;
56const SEMU_MAGIC = 0x53454d55;
57const SEMU_HEADER_BYTES = 16;
58const SEMU_FLAG_KEY = 1;
59const SOFT_DECODE_QUEUE = 8;
60
61// serve-sim binary WS message tags (browser -> helper).
62const IOS_MSG_TOUCH = 0x03;
63const IOS_MSG_BUTTON = 0x04;
64const IOS_MSG_KEY = 0x06;
65const IOS_MSG_ORIENTATION = 0x07;
66const IOS_MSG_HARDWARE_KEYBOARD = 0x0d;
67// helper -> browser.
68const IOS_TAG_SCREEN_CONFIG = 0x82;
69
70const encoder = new TextEncoder();
71const decoder = new TextDecoder();
72
73const isWebCodecsSupported = (): boolean =>
74 typeof globalThis !== "undefined" &&
75 "VideoDecoder" in globalThis &&
76 "EncodedVideoChunk" in globalThis;
77
78function taggedJson(tag: number, payload: unknown): Uint8Array<ArrayBuffer> {
79 const json = encoder.encode(JSON.stringify(payload));
80 const out = new Uint8Array(1 + json.length);
81 out[0] = tag;
82 out.set(json, 1);
83 return out;
84}
85
86/** Build the WebCodecs `avc1.PPCCLL` string from an avcC record or an SPS NAL. */
87export function avcCodecString(bytes: Uint8Array): string {
88 if (bytes.length < 4) return "avc1.42E01E";
89 const hex = (byte: number) => byte.toString(16).padStart(2, "0");
90 return `avc1.${hex(bytes[1]!)}${hex(bytes[2]!)}${hex(bytes[3]!)}`;
91}
92
93/** Split serve-emu's SEMU-framed message into metadata and the Annex-B payload. */
94export function parseSemuPacket(raw: ArrayBuffer): {
95 readonly data: Uint8Array;
96 readonly isKey: boolean | null;
97 readonly timestamp: number | null;
98} {
99 const bytes = new Uint8Array(raw);
100 if (bytes.byteLength > SEMU_HEADER_BYTES) {
101 const view = new DataView(raw, 0, SEMU_HEADER_BYTES);
102 if (view.getUint32(0, false) === SEMU_MAGIC && view.getUint8(4) === 1) {
103 const pts = view.getBigUint64(8, false);
104 return {
105 data: bytes.subarray(SEMU_HEADER_BYTES),
106 isKey: (view.getUint8(5) & SEMU_FLAG_KEY) !== 0,
107 timestamp: pts <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(pts) : null,
108 };
109 }
110 }
111 return { data: bytes, isKey: null, timestamp: null };
112}
113
114const isVideoSessionMessage = (text: string) => {
115 try {
116 const message = JSON.parse(text) as { type?: unknown };
117 return message.type === "video-session";
118 } catch {
119 return false;
120 }
121};
122
123/** Walk an Annex-B access unit for its keyframe flag and SPS bytes. */
124export function scanAccessUnit(buf: Uint8Array): { isKey: boolean; sps: Uint8Array | null } {
125 let isKey = false;
126 let sps: Uint8Array | null = null;
127 const len = buf.length;
128 let i = 0;
129 while (i + 2 < len) {
130 if (buf[i] === 0 && buf[i + 1] === 0) {
131 let codeLen = 0;
132 if (buf[i + 2] === 1) codeLen = 3;
133 else if (i + 3 < len && buf[i + 2] === 0 && buf[i + 3] === 1) codeLen = 4;
134 if (codeLen) {
135 const nalType = buf[i + codeLen]! & 0x1f;
136 if (nalType === 7 && !sps) sps = buf.subarray(i + codeLen);
137 if (nalType === 5) isKey = true;
138 i += codeLen + 1;
139 continue;
140 }
141 }
142 i++;
143 }
144 return { isKey, sps };
145}
146
147export type AvccChunk = {
148 readonly type: "description" | "keyframe" | "delta" | "seed";
149 readonly payload: Uint8Array;
150};
151
152const AVCC_TAGS: Record<number, AvccChunk["type"] | undefined> = {
153 1: "description",
154 2: "keyframe",
155 3: "delta",
156 4: "seed",
157};
158
159/** Turns a fragmented AVCC byte stream into complete envelopes. */
160export class AvccDemuxer {
161 private buffer = new Uint8Array(64 * 1024);
162 private length = 0;
163
164 push(bytes: Uint8Array): AvccChunk[] {
165 if (this.length + bytes.length > this.buffer.length) {
166 let capacity = this.buffer.length;
167 while (capacity < this.length + bytes.length) capacity *= 2;
168 const grown = new Uint8Array(capacity);
169 grown.set(this.buffer.subarray(0, this.length));
170 this.buffer = grown;
171 }
172 this.buffer.set(bytes, this.length);
173 this.length += bytes.length;
174
175 const chunks: AvccChunk[] = [];
176 let offset = 0;
177 while (this.length - offset >= 4) {
178 const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 4);
179 const frameLength = view.getUint32(0, false);
180 if (this.length - offset - 4 < frameLength) break;
181 if (frameLength >= 1) {
182 const type = AVCC_TAGS[this.buffer[offset + 4]!];
183 if (type) {
184 chunks.push({ type, payload: this.buffer.slice(offset + 5, offset + 4 + frameLength) });
185 }
186 }
187 offset += 4 + frameLength;
188 }
189 if (offset > 0) {
190 this.buffer.copyWithin(0, offset, this.length);
191 this.length -= offset;
192 }
193 return chunks;
194 }
195
196 reset(): void {
197 this.length = 0;
198 }
199}
200
201export interface DeviceStreamClient {
202 readonly start: () => void;
203 readonly stop: () => void;
204 /** Normalized 0..1 coordinates in the displayed frame. */
205 readonly sendTouch: (phase: "begin" | "move" | "end", x: number, y: number) => void;
206 readonly sendKey: (event: KeyboardEvent, phase: "down" | "up") => void;
207 readonly pressButton: (button: DeviceHardwareButton) => void;
208 readonly rotate: () => void;
209}
210
211const HID_USAGE_BY_CODE: Readonly<Record<string, number>> = {
212 Enter: 0x28,
213 Escape: 0x29,
214 Backspace: 0x2a,
215 Tab: 0x2b,
216 Space: 0x2c,
217 Minus: 0x2d,
218 Equal: 0x2e,
219 BracketLeft: 0x2f,
220 BracketRight: 0x30,
221 Backslash: 0x31,
222 Semicolon: 0x33,
223 Quote: 0x34,
224 Backquote: 0x35,
225 Comma: 0x36,
226 Period: 0x37,
227 Slash: 0x38,
228 Delete: 0x4c,
229 ArrowRight: 0x4f,
230 ArrowLeft: 0x50,
231 ArrowDown: 0x51,
232 ArrowUp: 0x52,
233 ControlLeft: 0xe0,
234 ShiftLeft: 0xe1,
235 AltLeft: 0xe2,
236 MetaLeft: 0xe3,
237 ControlRight: 0xe4,
238 ShiftRight: 0xe5,
239 AltRight: 0xe6,
240 MetaRight: 0xe7,
241};
242
243function hidUsageForCode(code: string): number | null {
244 if (/^Key[A-Z]$/.test(code)) return 0x04 + (code.charCodeAt(3) - 65);
245 if (/^Digit[1-9]$/.test(code)) return 0x1e + (code.charCodeAt(5) - 49);
246 if (code === "Digit0") return 0x27;
247 return HID_USAGE_BY_CODE[code] ?? null;
248}
249
250const ANDROID_KEYCODE_BY_KEY: Readonly<Record<string, number>> = {
251 ArrowUp: 19,
252 ArrowDown: 20,
253 ArrowLeft: 21,
254 ArrowRight: 22,
255 Tab: 61,
256 Enter: 66,
257 Backspace: 67,
258 Delete: 112,
259 Home: 122,
260 End: 123,
261 PageUp: 92,
262 PageDown: 93,
263};
264
265const IOS_ORIENTATIONS: ReadonlyArray<DeviceScreenSize["orientation"]> = [
266 "portrait",
267 "landscape_left",
268 "portrait_upside_down",
269 "landscape_right",
270];
271
272export function createDeviceStreamClient(
273 target: DeviceStreamTarget,
274 canvas: HTMLCanvasElement,
275 events: DeviceStreamEvents,
276): DeviceStreamClient {
277 const { access, platform, deviceId } = target;
278 const vendor = platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
279 const device = encodeURIComponent(deviceId);
280 const httpUrl = (path: string) =>
281 withDeviceHubQuery(`${access.httpBase}${vendor}${path}`, access);
282 const wsUrl = (path: string) => withDeviceHubQuery(`${access.wsBase}${vendor}${path}`, access);
283 const useWebCodecs = isWebCodecsSupported();
284
285 let stopped = true;
286 let socket: WebSocket | null = null;
287 let controller: AbortController | null = null;
288 const retryTimers = new Map<"video" | "input", ReturnType<typeof setTimeout>>();
289 let primeController: AbortController | null = null;
290 let videoDecoder: VideoDecoder | null = null;
291 let timestamp = 0;
292 let awaitingKeyframe = true;
293 let screen: DeviceScreenSize | null = null;
294 let firstFrame = false;
295 let configuring = false;
296 let mjpeg = false;
297
298 const mjpegUrl = () => httpUrl(`/helper/${device}/stream.mjpeg`);
299
300 const fallBackToMjpeg = () => {
301 if (stopped || mjpeg) return;
302 mjpeg = true;
303 closeDecoder();
304 events.onMjpegFallback(mjpegUrl());
305 setStatus("streaming");
306 };
307
308 const setStatus = (status: DeviceStreamStatus, detail?: string) => {
309 if (!stopped) events.onStatus(status, detail);
310 };
311
312 const paint = (source: CanvasImageSource, width: number, height: number) => {
313 if (stopped) return;
314 if (canvas.width !== width || canvas.height !== height) {
315 canvas.width = width;
316 canvas.height = height;
317 if (platform === "android") {
318 screen = { width, height, orientation: width > height ? "landscape_left" : "portrait" };
319 events.onScreen(screen);
320 }
321 }
322 canvas.getContext("2d")?.drawImage(source, 0, 0, width, height);
323 if (!firstFrame) {
324 firstFrame = true;
325 setStatus("streaming");
326 }
327 };
328
329 const closeDecoder = () => {
330 try {
331 videoDecoder?.close();
332 } catch {
333 // Already closed.
334 }
335 videoDecoder = null;
336 awaitingKeyframe = true;
337 };
338
339 const makeDecoder = () =>
340 new VideoDecoder({
341 output: (frame) => {
342 try {
343 paint(frame, frame.displayWidth, frame.displayHeight);
344 } finally {
345 frame.close();
346 }
347 },
348 error: () => {
349 closeDecoder();
350 requestKeyframe();
351 },
352 });
353
354 /**
355 * Resolves false when this browser cannot decode the stream's profile
356 * (simulators encode High 5.1, which headless and some hardware decoders
357 * reject). iOS then falls back to MJPEG; Android has no MJPEG.
358 */
359 const configureDecoder = async (config: VideoDecoderConfig): Promise<boolean> => {
360 const full: VideoDecoderConfig = { ...config, optimizeForLatency: true };
361 const support = await VideoDecoder.isConfigSupported(full).catch(() => ({ supported: false }));
362 if (stopped) return false;
363 if (!support.supported) {
364 setStatus("error", `This browser cannot decode ${config.codec}.`);
365 return false;
366 }
367 if (!videoDecoder || videoDecoder.state === "closed") videoDecoder = makeDecoder();
368 try {
369 videoDecoder.configure(full);
370 return true;
371 } catch (cause) {
372 setStatus("error", `Video decoder: ${(cause as Error).message}`);
373 return false;
374 }
375 };
376
377 const decode = (isKey: boolean, data: Uint8Array, pts?: number | null) => {
378 if (!videoDecoder || videoDecoder.state !== "configured") return;
379 if (awaitingKeyframe) {
380 if (!isKey) return;
381 awaitingKeyframe = false;
382 }
383 if (videoDecoder.decodeQueueSize > SOFT_DECODE_QUEUE) {
384 closeDecoder();
385 requestKeyframe();
386 return;
387 }
388 try {
389 videoDecoder.decode(
390 new EncodedVideoChunk({
391 type: isKey ? "key" : "delta",
392 timestamp: pts ?? timestamp,
393 data,
394 }),
395 );
396 timestamp += FRAME_DURATION_US;
397 } catch {
398 closeDecoder();
399 requestKeyframe();
400 }
401 };
402
403 const requestKeyframe = () => {
404 if (platform === "android" && socket?.readyState === WebSocket.OPEN) {
405 socket.send(JSON.stringify({ type: "reset-video", ack: false }));
406 }
407 };
408
409 const scheduleRetry = (channel: "video" | "input", run: () => void) => {
410 if (stopped || retryTimers.has(channel)) return;
411 retryTimers.set(
412 channel,
413 setTimeout(() => {
414 retryTimers.delete(channel);
415 run();
416 }, RETRY_DELAY_MS),
417 );
418 };
419
420 const handleUnauthorized = () => {
421 stop();
422 events.onUnauthorized();
423 };
424
425 // iOS video: fetch the AVCC body and demux into the decoder.
426 const readIosVideo = async () => {
427 const demuxer = new AvccDemuxer();
428 controller = new AbortController();
429 try {
430 const response = await fetch(httpUrl(`/helper/${device}/stream.avcc`), {
431 signal: controller.signal,
432 credentials: access.credentials ? "include" : "same-origin",
433 });
434 if (response.status === 401 || response.status === 403) return handleUnauthorized();
435 if (!response.ok || !response.body) throw new Error(`stream ${response.status}`);
436 const reader = response.body.getReader();
437 for (;;) {
438 const { done, value } = await reader.read();
439 if (done || stopped) break;
440 for (const chunk of demuxer.push(value)) {
441 switch (chunk.type) {
442 case "seed":
443 void createImageBitmap(new Blob([chunk.payload as BlobPart], { type: "image/jpeg" }))
444 .then((bitmap) => {
445 paint(bitmap, bitmap.width, bitmap.height);
446 bitmap.close();
447 })
448 .catch(() => {});
449 break;
450 case "description": {
451 awaitingKeyframe = true;
452 const configured = await configureDecoder({
453 codec: avcCodecString(chunk.payload),
454 description: chunk.payload,
455 });
456 if (!configured) {
457 await reader.cancel().catch(() => {});
458 fallBackToMjpeg();
459 return;
460 }
461 break;
462 }
463 case "keyframe":
464 case "delta":
465 decode(chunk.type === "keyframe", chunk.payload);
466 break;
467 }
468 }
469 }
470 } catch (cause) {
471 if (stopped) return;
472 setStatus("connecting", (cause as Error).message);
473 }
474 if (!stopped) scheduleRetry("video", () => void readIosVideo());
475 };
476
477 /**
478 * serve-sim's helper only accepts HID and pushes its screen config once
479 * screen capture is running, and the AVCC stream does not reliably start
480 * it. Touching the MJPEG endpoint does; one aborted request is enough.
481 */
482 const primeIosHelper = async () => {
483 const controller = new AbortController();
484 primeController = controller;
485 const timeout = setTimeout(() => controller.abort(), 2_000);
486 try {
487 const response = await fetch(httpUrl(`/helper/${device}/stream.mjpeg`), {
488 signal: controller.signal,
489 credentials: access.credentials ? "include" : "same-origin",
490 });
491 if (response.status === 401 || response.status === 403) return handleUnauthorized();
492 await response.body?.getReader().read();
493 } catch {
494 // A failed prime just means the socket may take a retry to come up.
495 } finally {
496 clearTimeout(timeout);
497 controller.abort();
498 if (primeController === controller) primeController = null;
499 }
500 };
501
502 // iOS input socket; also carries the screen config the helper pushes.
503 const connectIosInput = async () => {
504 if (stopped) return;
505 await primeIosHelper();
506 if (stopped) return;
507 const ws = new WebSocket(wsUrl(`/helper/ws?device=${device}`));
508 ws.binaryType = "arraybuffer";
509 socket = ws;
510 ws.onopen = () => {
511 ws.send(taggedJson(IOS_MSG_HARDWARE_KEYBOARD, { enabled: false }));
512 events.onInputConnected(true);
513 };
514 ws.onmessage = (event) => {
515 if (!(event.data instanceof ArrayBuffer)) return;
516 const bytes = new Uint8Array(event.data);
517 if (bytes.length < 1 || bytes[0] !== IOS_TAG_SCREEN_CONFIG) return;
518 try {
519 const config = JSON.parse(decoder.decode(bytes.subarray(1))) as DeviceScreenSize;
520 if (config.width > 0 && config.height > 0) {
521 screen = config;
522 events.onScreen(config);
523 }
524 } catch {
525 // Ignore malformed config frames.
526 }
527 };
528 ws.onclose = (event) => {
529 if (socket === ws) socket = null;
530 if (!stopped) {
531 events.onInputConnected(
532 false,
533 event.reason || (event.code === 1006 ? "input socket refused" : `closed ${event.code}`),
534 );
535 }
536 if (event.code === 1008 || event.code === 4401) return handleUnauthorized();
537 scheduleRetry("input", () => void connectIosInput());
538 };
539 ws.onerror = () => ws.close();
540 };
541
542 // Android: one socket for video and input.
543 const connectAndroid = () => {
544 if (stopped) return;
545 const ws = new WebSocket(wsUrl(`/ws?device=${device}&frame-meta=1`));
546 ws.binaryType = "arraybuffer";
547 socket = ws;
548 ws.onopen = () => {
549 setStatus("connecting");
550 events.onInputConnected(true);
551 };
552 ws.onmessage = (event) => {
553 if (typeof event.data === "string") {
554 // The encoder restarts at a new size when the device rotates; the
555 // next keyframe carries a fresh SPS, so the decoder is rebuilt from it.
556 if (isVideoSessionMessage(event.data)) closeDecoder();
557 return;
558 }
559 if (!(event.data instanceof ArrayBuffer)) return;
560 const packet = parseSemuPacket(event.data);
561 const needsScan =
562 packet.isKey === null ||
563 (packet.isKey && (!videoDecoder || videoDecoder.state !== "configured"));
564 const scanned = needsScan ? scanAccessUnit(packet.data) : null;
565 const isKey = packet.isKey ?? scanned?.isKey ?? false;
566 if (scanned?.sps && (!videoDecoder || videoDecoder.state !== "configured")) {
567 if (configuring) return;
568 configuring = true;
569 void configureDecoder({ codec: avcCodecString(scanned.sps) }).then((configured) => {
570 configuring = false;
571 awaitingKeyframe = true;
572 if (configured) requestKeyframe();
573 });
574 return;
575 }
576 if (!videoDecoder || videoDecoder.state !== "configured") {
577 if (!isKey) requestKeyframe();
578 return;
579 }
580 decode(isKey, packet.data, packet.timestamp);
581 };
582 ws.onclose = (event) => {
583 if (socket === ws) socket = null;
584 closeDecoder();
585 if (!stopped) events.onInputConnected(false, event.reason || `closed ${event.code}`);
586 if (event.code === 1008 || event.code === 4401) return handleUnauthorized();
587 if (!stopped) {
588 setStatus("connecting", event.reason || undefined);
589 scheduleRetry("input", connectAndroid);
590 }
591 };
592 ws.onerror = () => ws.close();
593 };
594
595 const start = () => {
596 if (!stopped) return;
597 stopped = false;
598 firstFrame = false;
599 events.onStatus("connecting");
600 if (platform === "ios") {
601 void connectIosInput();
602 if (useWebCodecs) void readIosVideo();
603 else fallBackToMjpeg();
604 } else if (useWebCodecs) {
605 connectAndroid();
606 } else {
607 setStatus("error", "This browser cannot decode the Android stream (WebCodecs unavailable).");
608 }
609 };
610
611 const stop = () => {
612 if (stopped) return;
613 stopped = true;
614 mjpeg = false;
615 for (const timer of retryTimers.values()) clearTimeout(timer);
616 retryTimers.clear();
617 primeController?.abort();
618 primeController = null;
619 controller?.abort();
620 controller = null;
621 socket?.close();
622 socket = null;
623 closeDecoder();
624 };
625
626 const send = (payload: Uint8Array<ArrayBuffer> | string) => {
627 if (socket?.readyState === WebSocket.OPEN) socket.send(payload);
628 };
629
630 const rawPoint = (x: number, y: number) => {
631 // serve-sim streams the raw framebuffer; rotated devices need input
632 // remapped into that raw space.
633 if (platform !== "ios" || !screen || screen.width > screen.height) return { x, y };
634 switch (screen.orientation) {
635 case "landscape_left":
636 return { x: y, y: 1 - x };
637 case "landscape_right":
638 return { x: 1 - y, y: x };
639 case "portrait_upside_down":
640 return { x: 1 - x, y: 1 - y };
641 default:
642 return { x, y };
643 }
644 };
645
646 return {
647 start,
648 stop,
649 sendTouch: (phase, x, y) => {
650 if (platform === "ios") {
651 send(taggedJson(IOS_MSG_TOUCH, { type: phase, ...rawPoint(x, y) }));
652 return;
653 }
654 const action = phase === "begin" ? "down" : phase === "move" ? "move" : "up";
655 send(JSON.stringify({ type: "touch", action, x, y }));
656 },
657 sendKey: (event, phase) => {
658 if (platform === "ios") {
659 const usage = hidUsageForCode(event.code);
660 if (usage !== null) send(taggedJson(IOS_MSG_KEY, { type: phase, usage }));
661 return;
662 }
663 if (phase !== "down") return;
664 if (event.key === "Escape") return send(JSON.stringify({ type: "back" }));
665 const keycode = ANDROID_KEYCODE_BY_KEY[event.key];
666 if (keycode !== undefined) return send(JSON.stringify({ type: "key", keycode }));
667 if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
668 send(JSON.stringify({ type: "text", text: event.key }));
669 }
670 },
671 pressButton: (button) => {
672 if (platform === "ios") {
673 const name =
674 button === "home"
675 ? "home"
676 : button === "appSwitcher"
677 ? "app_switcher"
678 : button === "power"
679 ? "lock"
680 : null;
681 if (name) send(taggedJson(IOS_MSG_BUTTON, { button: name }));
682 return;
683 }
684 const type = button === "appSwitcher" ? "recents" : button;
685 if (type === "home" || type === "back" || type === "recents" || type === "power") {
686 send(JSON.stringify({ type }));
687 }
688 },
689 rotate: () => {
690 if (platform !== "ios") return;
691 const current = screen?.orientation ?? "portrait";
692 const next =
693 IOS_ORIENTATIONS[(IOS_ORIENTATIONS.indexOf(current) + 1) % IOS_ORIENTATIONS.length]!;
694 send(taggedJson(IOS_MSG_ORIENTATION, { orientation: next }));
695 },
696 };
697}
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+1-1
...@@ -35,7 +35,7 @@ import { useRightPanelStore } from "~/rightPanelStore";...@@ -35,7 +35,7 @@ import { useRightPanelStore } from "~/rightPanelStore";
35import { useDeviceState } from "~/state/device";35import { useDeviceState } from "~/state/device";
3636
37import { DeviceStreamView } from "../device/DeviceStreamView";37import { DeviceStreamView } from "../device/DeviceStreamView";
38import type { DeviceScreenSize } from "../device/deviceStream";38import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream";
39import { previewBridge } from "./previewBridge";39import { previewBridge } from "./previewBridge";
40import {40import {
41 clampPreviewMiniPlayerPosition,41 clampPreviewMiniPlayerPosition,
apps/web/src/components/preview/previewMiniPlayerLayout.ts+1-1
...@@ -7,7 +7,7 @@ import {...@@ -7,7 +7,7 @@ import {
7} from "~/browser/browserViewportLayout";7} from "~/browser/browserViewportLayout";
8import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore";8import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore";
99
10import type { DeviceScreenSize } from "../device/deviceStream";10import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream";
1111
12export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12;12export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12;
13export const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12;13export const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12;
docs/user/devices.md+7-2
...@@ -55,8 +55,13 @@ back from the device after a change....@@ -55,8 +55,13 @@ back from the device after a change.
55When an agent opens a device, it floats over the chat in web and desktop clients55When an agent opens a device, it floats over the chat in web and desktop clients
56connected to the thread, the same way an agent-driven browser does. Turn off56connected to the thread, the same way an agent-driven browser does. Turn off
57**Auto-show floating preview** in **Settings → Integrations → Browser** to open a57**Auto-show floating preview** in **Settings → Integrations → Browser** to open a
58right-panel tab instead. Mobile clients show device activity in the thread58right-panel tab instead. In the mobile app, open the agent's thread and tap the
59timeline. Agents drive the device through the `agent-device` command line. T359device button above the composer to watch the live screen and control it.
60If the thread has several devices open, choose one in the viewer. Closing the
61viewer stops streaming and leaves the device available to the agent. Device
62activity also appears in the thread timeline.
63
64Agents drive the device through the `agent-device` command line. T3
60Code installs and starts it only after **Agent device access** is enabled. iOS65Code installs and starts it only after **Agent device access** is enabled. iOS
61taps build a small test runner on first use, which takes a couple of minutes66taps build a small test runner on first use, which takes a couple of minutes
62once per server. Restart an existing agent session after granting access so it67once per server. Restart an existing agent session after granting access so it
knip.jsonc+7-2
...@@ -58,8 +58,13 @@...@@ -58,8 +58,13 @@
58 "ignoreIssues": { "src/components/ui/*.tsx": ["exports", "nsExports", "duplicates"] },58 "ignoreIssues": { "src/components/ui/*.tsx": ["exports", "nsExports", "duplicates"] },
59 },59 },
60 "apps/mobile": {60 "apps/mobile": {
61 // Expo loads local config plugins by string; Metro handles platform variants.61 // Expo loads plugins by string; Metro embeds the browser entry in the native WebView.
62 "entry": ["index.ts!", "plugins/*.cjs"],62 "entry": [
63 "index.ts!",
64 "plugins/*.cjs",
65 "scripts/generate-device-stream.mts",
66 "src/features/devices/device-stream.browser.ts!",
67 ],
63 // Fonts are configured as asset paths; Expo autolinks the native packages.68 // Fonts are configured as asset paths; Expo autolinks the native packages.
64 "ignoreDependencies": [69 "ignoreDependencies": [
65 "@expo-google-fonts/dm-sans",70 "@expo-google-fonts/dm-sans",
packages/client-runtime/package.json+8
...@@ -3,6 +3,14 @@...@@ -3,6 +3,14 @@
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 "./device/stream": {
7 "types": "./src/device/stream.ts",
8 "default": "./src/device/stream.ts"
9 },
10 "./device/hub-access": {
11 "types": "./src/device/hubAccess.ts",
12 "default": "./src/device/hubAccess.ts"
13 },
6 "./worktree-setup": {14 "./worktree-setup": {
7 "types": "./src/worktreeSetup.ts",15 "types": "./src/worktreeSetup.ts",
8 "default": "./src/worktreeSetup.ts"16 "default": "./src/worktreeSetup.ts"
packages/client-runtime/src/device/hubAccess.ts created+18
...@@ -0,0 +1,18 @@
1/** Credentials for media requests that cannot set bearer or DPoP headers. */
2export interface DeviceHubAccess {
3 /** Absolute environment URL ending in `/api/device-hub`. */
4 readonly httpBase: string;
5 /** Same base with the `ws(s)` scheme. */
6 readonly wsBase: string;
7 /** Empty for cookie sessions; includes a short-lived ticket for bearer and DPoP sessions. */
8 readonly query: Readonly<Record<string, string>>;
9 /** Whether requests must include session cookies. */
10 readonly credentials: boolean;
11}
12
13export const withDeviceHubQuery = (url: string, access: DeviceHubAccess): string => {
14 const entries = Object.entries(access.query);
15 if (entries.length === 0) return url;
16 const separator = url.includes("?") ? "&" : "?";
17 return `${url}${separator}${new URLSearchParams(entries).toString()}`;
18};
packages/client-runtime/src/device/stream.ts created+712
...@@ -0,0 +1,712 @@
1// @effect-diagnostics globalFetch:off globalTimers:off - This browser and WebView transport runs without an Effect runtime.
2/* oxlint-disable unicorn/prefer-add-event-listener -- Each client owns its sockets and their handlers. */
3
4/**
5 * Framework-free client for expo-device-hub's per-device streams, reached
6 * through the T3 proxy. One class handles both platforms because the hub
7 * vendors two servers with different wire formats:
8 *
9 * - iOS (serve-sim): video is an HTTP `stream.avcc` body of length-prefixed
10 * envelopes (`u32be length, u8 tag, payload`; tag 1 avcC description,
11 * 2 keyframe, 3 delta, 4 JPEG seed) decoded with WebCodecs; input goes over
12 * `helper/ws?device=<udid>` as `[tag][json]` packets. When WebCodecs is
13 * unavailable (plain-http remote origins) the MJPEG endpoint is used as an
14 * `<img>` source instead.
15 * - Android (serve-emu): one WebSocket at `ws?device=<serial>&frame-meta=1`
16 * carries H.264 access units prefixed with a 16-byte "SEMU" header
17 * (magic, version, key flag, pts) and accepts JSON gestures upstream.
18 *
19 * The decoder only runs while frames arrive and the viewer is attached; a
20 * hidden panel calls `stop()` so an idle device costs nothing on the GPU.
21 */
22import { type DeviceHubAccess, withDeviceHubQuery } from "./hubAccess.ts";
23import type { DevicePlatform } from "@t3tools/contracts";
24
25export type DeviceStreamStatus = "connecting" | "streaming" | "error";
26
27export interface DeviceScreenSize {
28 readonly width: number;
29 readonly height: number;
30 readonly orientation: "portrait" | "portrait_upside_down" | "landscape_left" | "landscape_right";
31}
32
33export interface DeviceStreamEvents {
34 readonly onStatus: (status: DeviceStreamStatus, detail?: string) => void;
35 readonly onScreen: (screen: DeviceScreenSize) => void;
36 /** The proxy rejected the credential; the owner should refresh access and reconnect. */
37 readonly onUnauthorized: () => void;
38 /**
39 * H.264 cannot be decoded here (no WebCodecs, or the simulator's profile is
40 * unsupported); the owner should show this MJPEG URL in an `<img>` instead of
41 * the canvas.
42 */
43 readonly onMjpegFallback: (url: string) => void;
44 /** Whether touches and keys can currently reach the device. */
45 readonly onInputConnected: (connected: boolean, detail?: string) => void;
46}
47
48export interface DeviceStreamTarget {
49 readonly platform: DevicePlatform;
50 readonly deviceId: string;
51 readonly access: DeviceHubAccess;
52 /** Native iOS WebViews can use MJPEG without cross-origin fetch or secure-context support. */
53 readonly preferMjpeg?: boolean;
54}
55
56export type DeviceHardwareButton = "home" | "back" | "recents" | "power" | "appSwitcher";
57
58const RETRY_DELAY_MS = 1_000;
59const FRAME_DURATION_US = 16_667;
60const SEMU_MAGIC = 0x53454d55;
61const SEMU_HEADER_BYTES = 16;
62const SEMU_FLAG_KEY = 1;
63const SOFT_DECODE_QUEUE = 8;
64
65// serve-sim binary WS message tags (browser -> helper).
66const IOS_MSG_TOUCH = 0x03;
67const IOS_MSG_BUTTON = 0x04;
68const IOS_MSG_KEY = 0x06;
69const IOS_MSG_ORIENTATION = 0x07;
70const IOS_MSG_HARDWARE_KEYBOARD = 0x0d;
71// helper -> browser.
72const IOS_TAG_SCREEN_CONFIG = 0x82;
73
74const encoder = new TextEncoder();
75const decoder = new TextDecoder();
76
77const isWebCodecsSupported = (): boolean =>
78 typeof globalThis !== "undefined" &&
79 "VideoDecoder" in globalThis &&
80 "EncodedVideoChunk" in globalThis;
81
82function taggedJson(tag: number, payload: unknown): Uint8Array<ArrayBuffer> {
83 const json = encoder.encode(JSON.stringify(payload));
84 const out = new Uint8Array(1 + json.length);
85 out[0] = tag;
86 out.set(json, 1);
87 return out;
88}
89
90/** Build the WebCodecs `avc1.PPCCLL` string from an avcC record or an SPS NAL. */
91export function avcCodecString(bytes: Uint8Array): string {
92 if (bytes.length < 4) return "avc1.42E01E";
93 const hex = (byte: number) => byte.toString(16).padStart(2, "0");
94 return `avc1.${hex(bytes[1]!)}${hex(bytes[2]!)}${hex(bytes[3]!)}`;
95}
96
97/** Split serve-emu's SEMU-framed message into metadata and the Annex-B payload. */
98export function parseSemuPacket(raw: ArrayBuffer): {
99 readonly data: Uint8Array;
100 readonly isKey: boolean | null;
101 readonly timestamp: number | null;
102} {
103 const bytes = new Uint8Array(raw);
104 if (bytes.byteLength > SEMU_HEADER_BYTES) {
105 const view = new DataView(raw, 0, SEMU_HEADER_BYTES);
106 if (view.getUint32(0, false) === SEMU_MAGIC && view.getUint8(4) === 1) {
107 const pts = view.getBigUint64(8, false);
108 return {
109 data: bytes.subarray(SEMU_HEADER_BYTES),
110 isKey: (view.getUint8(5) & SEMU_FLAG_KEY) !== 0,
111 timestamp: pts <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(pts) : null,
112 };
113 }
114 }
115 return { data: bytes, isKey: null, timestamp: null };
116}
117
118const isVideoSessionMessage = (text: string) => {
119 try {
120 const message = JSON.parse(text) as { type?: unknown };
121 return message.type === "video-session";
122 } catch {
123 return false;
124 }
125};
126
127/** Walk an Annex-B access unit for its keyframe flag and SPS bytes. */
128export function scanAccessUnit(buf: Uint8Array): { isKey: boolean; sps: Uint8Array | null } {
129 let isKey = false;
130 let sps: Uint8Array | null = null;
131 const len = buf.length;
132 let i = 0;
133 while (i + 2 < len) {
134 if (buf[i] === 0 && buf[i + 1] === 0) {
135 let codeLen = 0;
136 if (buf[i + 2] === 1) codeLen = 3;
137 else if (i + 3 < len && buf[i + 2] === 0 && buf[i + 3] === 1) codeLen = 4;
138 if (codeLen) {
139 const nalType = buf[i + codeLen]! & 0x1f;
140 if (nalType === 7 && !sps) sps = buf.subarray(i + codeLen);
141 if (nalType === 5) isKey = true;
142 i += codeLen + 1;
143 continue;
144 }
145 }
146 i++;
147 }
148 return { isKey, sps };
149}
150
151export type AvccChunk = {
152 readonly type: "description" | "keyframe" | "delta" | "seed";
153 readonly payload: Uint8Array;
154};
155
156const AVCC_TAGS: Record<number, AvccChunk["type"] | undefined> = {
157 1: "description",
158 2: "keyframe",
159 3: "delta",
160 4: "seed",
161};
162
163/** Turns a fragmented AVCC byte stream into complete envelopes. */
164export class AvccDemuxer {
165 private buffer = new Uint8Array(64 * 1024);
166 private length = 0;
167
168 push(bytes: Uint8Array): AvccChunk[] {
169 if (this.length + bytes.length > this.buffer.length) {
170 let capacity = this.buffer.length;
171 while (capacity < this.length + bytes.length) capacity *= 2;
172 const grown = new Uint8Array(capacity);
173 grown.set(this.buffer.subarray(0, this.length));
174 this.buffer = grown;
175 }
176 this.buffer.set(bytes, this.length);
177 this.length += bytes.length;
178
179 const chunks: AvccChunk[] = [];
180 let offset = 0;
181 while (this.length - offset >= 4) {
182 const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 4);
183 const frameLength = view.getUint32(0, false);
184 if (this.length - offset - 4 < frameLength) break;
185 if (frameLength >= 1) {
186 const type = AVCC_TAGS[this.buffer[offset + 4]!];
187 if (type) {
188 chunks.push({ type, payload: this.buffer.slice(offset + 5, offset + 4 + frameLength) });
189 }
190 }
191 offset += 4 + frameLength;
192 }
193 if (offset > 0) {
194 this.buffer.copyWithin(0, offset, this.length);
195 this.length -= offset;
196 }
197 return chunks;
198 }
199
200 reset(): void {
201 this.length = 0;
202 }
203}
204
205export interface DeviceStreamClient {
206 readonly start: () => void;
207 readonly stop: () => void;
208 /** Normalized 0..1 coordinates in the displayed frame. */
209 readonly sendTouch: (phase: "begin" | "move" | "end", x: number, y: number) => void;
210 readonly sendKey: (event: KeyboardEvent, phase: "down" | "up") => void;
211 readonly pressButton: (button: DeviceHardwareButton) => void;
212 readonly rotate: () => void;
213}
214
215const HID_USAGE_BY_CODE: Readonly<Record<string, number>> = {
216 Enter: 0x28,
217 Escape: 0x29,
218 Backspace: 0x2a,
219 Tab: 0x2b,
220 Space: 0x2c,
221 Minus: 0x2d,
222 Equal: 0x2e,
223 BracketLeft: 0x2f,
224 BracketRight: 0x30,
225 Backslash: 0x31,
226 Semicolon: 0x33,
227 Quote: 0x34,
228 Backquote: 0x35,
229 Comma: 0x36,
230 Period: 0x37,
231 Slash: 0x38,
232 Delete: 0x4c,
233 ArrowRight: 0x4f,
234 ArrowLeft: 0x50,
235 ArrowDown: 0x51,
236 ArrowUp: 0x52,
237 ControlLeft: 0xe0,
238 ShiftLeft: 0xe1,
239 AltLeft: 0xe2,
240 MetaLeft: 0xe3,
241 ControlRight: 0xe4,
242 ShiftRight: 0xe5,
243 AltRight: 0xe6,
244 MetaRight: 0xe7,
245};
246
247function hidUsageForCode(code: string): number | null {
248 if (/^Key[A-Z]$/.test(code)) return 0x04 + (code.charCodeAt(3) - 65);
249 if (/^Digit[1-9]$/.test(code)) return 0x1e + (code.charCodeAt(5) - 49);
250 if (code === "Digit0") return 0x27;
251 return HID_USAGE_BY_CODE[code] ?? null;
252}
253
254const ANDROID_KEYCODE_BY_KEY: Readonly<Record<string, number>> = {
255 ArrowUp: 19,
256 ArrowDown: 20,
257 ArrowLeft: 21,
258 ArrowRight: 22,
259 Tab: 61,
260 Enter: 66,
261 Backspace: 67,
262 Delete: 112,
263 Home: 122,
264 End: 123,
265 PageUp: 92,
266 PageDown: 93,
267};
268
269const IOS_ORIENTATIONS: ReadonlyArray<DeviceScreenSize["orientation"]> = [
270 "portrait",
271 "landscape_left",
272 "portrait_upside_down",
273 "landscape_right",
274];
275
276export function createDeviceStreamClient(
277 target: DeviceStreamTarget,
278 canvas: HTMLCanvasElement,
279 events: DeviceStreamEvents,
280): DeviceStreamClient {
281 const { access, platform, deviceId } = target;
282 const vendor = platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
283 const device = encodeURIComponent(deviceId);
284 const httpUrl = (path: string) =>
285 withDeviceHubQuery(`${access.httpBase}${vendor}${path}`, access);
286 const wsUrl = (path: string) => withDeviceHubQuery(`${access.wsBase}${vendor}${path}`, access);
287 const useWebCodecs = isWebCodecsSupported() && !(platform === "ios" && target.preferMjpeg);
288
289 let stopped = true;
290 let socket: WebSocket | null = null;
291 let controller: AbortController | null = null;
292 const retryTimers = new Map<"video" | "input", ReturnType<typeof setTimeout>>();
293 let primeController: AbortController | null = null;
294 let videoDecoder: VideoDecoder | null = null;
295 let timestamp = 0;
296 let awaitingKeyframe = true;
297 let screen: DeviceScreenSize | null = null;
298 let firstFrame = false;
299 let configuring = false;
300 let mjpeg = false;
301
302 const mjpegUrl = () => httpUrl(`/helper/${device}/stream.mjpeg`);
303
304 const fallBackToMjpeg = () => {
305 if (stopped || mjpeg) return;
306 mjpeg = true;
307 closeDecoder();
308 events.onMjpegFallback(mjpegUrl());
309 setStatus("streaming");
310 };
311
312 const setStatus = (status: DeviceStreamStatus, detail?: string) => {
313 if (!stopped) events.onStatus(status, detail);
314 };
315
316 const paint = (source: CanvasImageSource, width: number, height: number) => {
317 if (stopped) return;
318 if (canvas.width !== width || canvas.height !== height) {
319 canvas.width = width;
320 canvas.height = height;
321 if (platform === "android") {
322 screen = { width, height, orientation: width > height ? "landscape_left" : "portrait" };
323 events.onScreen(screen);
324 }
325 }
326 canvas.getContext("2d")?.drawImage(source, 0, 0, width, height);
327 if (!firstFrame) {
328 firstFrame = true;
329 setStatus("streaming");
330 }
331 };
332
333 const closeDecoder = () => {
334 try {
335 videoDecoder?.close();
336 } catch {
337 // Already closed.
338 }
339 videoDecoder = null;
340 awaitingKeyframe = true;
341 };
342
343 const makeDecoder = () =>
344 new VideoDecoder({
345 output: (frame) => {
346 try {
347 paint(frame, frame.displayWidth, frame.displayHeight);
348 } finally {
349 frame.close();
350 }
351 },
352 error: () => {
353 closeDecoder();
354 requestKeyframe();
355 },
356 });
357
358 /**
359 * Resolves false when this browser cannot decode the stream's profile
360 * (simulators encode High 5.1, which headless and some hardware decoders
361 * reject). iOS then falls back to MJPEG; Android has no MJPEG.
362 */
363 const configureDecoder = async (config: VideoDecoderConfig): Promise<boolean> => {
364 const full: VideoDecoderConfig = { ...config, optimizeForLatency: true };
365 const support = await VideoDecoder.isConfigSupported(full).catch(() => ({ supported: false }));
366 if (stopped) return false;
367 if (!support.supported) {
368 setStatus("error", `This browser cannot decode ${config.codec}.`);
369 return false;
370 }
371 if (!videoDecoder || videoDecoder.state === "closed") videoDecoder = makeDecoder();
372 try {
373 videoDecoder.configure(full);
374 return true;
375 } catch (cause) {
376 setStatus("error", `Video decoder: ${(cause as Error).message}`);
377 return false;
378 }
379 };
380
381 const decode = (isKey: boolean, data: Uint8Array, pts?: number | null) => {
382 if (!videoDecoder || videoDecoder.state !== "configured") return;
383 if (awaitingKeyframe) {
384 if (!isKey) return;
385 awaitingKeyframe = false;
386 }
387 if (videoDecoder.decodeQueueSize > SOFT_DECODE_QUEUE) {
388 closeDecoder();
389 requestKeyframe();
390 return;
391 }
392 try {
393 videoDecoder.decode(
394 new EncodedVideoChunk({
395 type: isKey ? "key" : "delta",
396 timestamp: pts ?? timestamp,
397 data,
398 }),
399 );
400 timestamp += FRAME_DURATION_US;
401 } catch {
402 closeDecoder();
403 requestKeyframe();
404 }
405 };
406
407 const requestKeyframe = () => {
408 if (platform === "android" && socket?.readyState === WebSocket.OPEN) {
409 socket.send(JSON.stringify({ type: "reset-video", ack: false }));
410 }
411 };
412
413 const scheduleRetry = (channel: "video" | "input", run: () => void) => {
414 if (stopped || retryTimers.has(channel)) return;
415 retryTimers.set(
416 channel,
417 setTimeout(() => {
418 retryTimers.delete(channel);
419 run();
420 }, RETRY_DELAY_MS),
421 );
422 };
423
424 const handleUnauthorized = () => {
425 stop();
426 events.onUnauthorized();
427 };
428
429 // iOS video: fetch the AVCC body and demux into the decoder.
430 const readIosVideo = async () => {
431 const demuxer = new AvccDemuxer();
432 controller = new AbortController();
433 try {
434 const response = await fetch(httpUrl(`/helper/${device}/stream.avcc`), {
435 signal: controller.signal,
436 credentials: access.credentials ? "include" : "same-origin",
437 });
438 if (response.status === 401 || response.status === 403) return handleUnauthorized();
439 if (!response.ok || !response.body) throw new Error(`stream ${response.status}`);
440 const reader = response.body.getReader();
441 for (;;) {
442 const { done, value } = await reader.read();
443 if (done || stopped) break;
444 for (const chunk of demuxer.push(value)) {
445 switch (chunk.type) {
446 case "seed":
447 void createImageBitmap(new Blob([chunk.payload as BlobPart], { type: "image/jpeg" }))
448 .then((bitmap) => {
449 paint(bitmap, bitmap.width, bitmap.height);
450 bitmap.close();
451 })
452 .catch(() => {});
453 break;
454 case "description": {
455 awaitingKeyframe = true;
456 const configured = await configureDecoder({
457 codec: avcCodecString(chunk.payload),
458 description: chunk.payload,
459 });
460 if (!configured) {
461 await reader.cancel().catch(() => {});
462 fallBackToMjpeg();
463 return;
464 }
465 break;
466 }
467 case "keyframe":
468 case "delta":
469 decode(chunk.type === "keyframe", chunk.payload);
470 break;
471 }
472 }
473 }
474 } catch (cause) {
475 if (stopped) return;
476 setStatus("connecting", (cause as Error).message);
477 }
478 if (!stopped) scheduleRetry("video", () => void readIosVideo());
479 };
480
481 /**
482 * serve-sim's helper only accepts HID and pushes its screen config once
483 * screen capture is running, and the AVCC stream does not reliably start
484 * it. Touching the MJPEG endpoint does; one aborted request is enough.
485 */
486 const primeIosHelper = async () => {
487 const controller = new AbortController();
488 primeController = controller;
489 const timeout = setTimeout(() => controller.abort(), 2_000);
490 try {
491 const response = await fetch(httpUrl(`/helper/${device}/stream.mjpeg`), {
492 signal: controller.signal,
493 credentials: access.credentials ? "include" : "same-origin",
494 });
495 if (response.status === 401 || response.status === 403) return handleUnauthorized();
496 await response.body?.getReader().read();
497 } catch {
498 // A failed prime just means the socket may take a retry to come up.
499 } finally {
500 clearTimeout(timeout);
501 controller.abort();
502 if (primeController === controller) primeController = null;
503 }
504 };
505
506 // iOS input socket; also carries the screen config the helper pushes.
507 const connectIosInput = async () => {
508 if (stopped) return;
509 await primeIosHelper();
510 if (stopped) return;
511 const ws = new WebSocket(wsUrl(`/helper/ws?device=${device}`));
512 ws.binaryType = "arraybuffer";
513 socket = ws;
514 ws.onopen = () => {
515 ws.send(taggedJson(IOS_MSG_HARDWARE_KEYBOARD, { enabled: false }));
516 events.onInputConnected(true);
517 };
518 ws.onmessage = (event) => {
519 if (!(event.data instanceof ArrayBuffer)) return;
520 const bytes = new Uint8Array(event.data);
521 if (bytes.length < 1 || bytes[0] !== IOS_TAG_SCREEN_CONFIG) return;
522 try {
523 const config = JSON.parse(decoder.decode(bytes.subarray(1))) as DeviceScreenSize;
524 if (config.width > 0 && config.height > 0) {
525 screen = config;
526 events.onScreen(config);
527 }
528 } catch {
529 // Ignore malformed config frames.
530 }
531 };
532 ws.onclose = (event) => {
533 if (socket !== ws) return;
534 socket = null;
535 if (stopped) return;
536 events.onInputConnected(
537 false,
538 event.reason || (event.code === 1006 ? "input socket refused" : `closed ${event.code}`),
539 );
540 // A rejected HTTP upgrade surfaces as 1006, including an expired stream ticket.
541 if (
542 event.code === 1008 ||
543 event.code === 4401 ||
544 (event.code === 1006 && access.query.wsTicket)
545 )
546 return handleUnauthorized();
547 scheduleRetry("input", () => void connectIosInput());
548 };
549 ws.onerror = () => ws.close();
550 };
551
552 // Android: one socket for video and input.
553 const connectAndroid = () => {
554 if (stopped) return;
555 const ws = new WebSocket(wsUrl(`/ws?device=${device}&frame-meta=1`));
556 ws.binaryType = "arraybuffer";
557 socket = ws;
558 ws.onopen = () => {
559 setStatus("connecting");
560 events.onInputConnected(true);
561 };
562 ws.onmessage = (event) => {
563 if (typeof event.data === "string") {
564 // The encoder restarts at a new size when the device rotates; the
565 // next keyframe carries a fresh SPS, so the decoder is rebuilt from it.
566 if (isVideoSessionMessage(event.data)) closeDecoder();
567 return;
568 }
569 if (!(event.data instanceof ArrayBuffer)) return;
570 const packet = parseSemuPacket(event.data);
571 const needsScan =
572 packet.isKey === null ||
573 (packet.isKey && (!videoDecoder || videoDecoder.state !== "configured"));
574 const scanned = needsScan ? scanAccessUnit(packet.data) : null;
575 const isKey = packet.isKey ?? scanned?.isKey ?? false;
576 if (scanned?.sps && (!videoDecoder || videoDecoder.state !== "configured")) {
577 if (configuring) return;
578 configuring = true;
579 void configureDecoder({ codec: avcCodecString(scanned.sps) }).then((configured) => {
580 configuring = false;
581 awaitingKeyframe = true;
582 if (configured) requestKeyframe();
583 });
584 return;
585 }
586 if (!videoDecoder || videoDecoder.state !== "configured") {
587 if (!isKey) requestKeyframe();
588 return;
589 }
590 decode(isKey, packet.data, packet.timestamp);
591 };
592 ws.onclose = (event) => {
593 if (socket !== ws) return;
594 socket = null;
595 closeDecoder();
596 if (stopped) return;
597 events.onInputConnected(false, event.reason || `closed ${event.code}`);
598 if (
599 event.code === 1008 ||
600 event.code === 4401 ||
601 (event.code === 1006 && access.query.wsTicket)
602 )
603 return handleUnauthorized();
604 setStatus("connecting", event.reason || undefined);
605 scheduleRetry("input", connectAndroid);
606 };
607 ws.onerror = () => ws.close();
608 };
609
610 const start = () => {
611 if (!stopped) return;
612 stopped = false;
613 firstFrame = false;
614 events.onStatus("connecting");
615 if (platform === "ios") {
616 void connectIosInput();
617 if (useWebCodecs) void readIosVideo();
618 else fallBackToMjpeg();
619 } else if (useWebCodecs) {
620 connectAndroid();
621 } else {
622 setStatus("error", "This browser cannot decode the Android stream (WebCodecs unavailable).");
623 }
624 };
625
626 const stop = () => {
627 if (stopped) return;
628 stopped = true;
629 mjpeg = false;
630 for (const timer of retryTimers.values()) clearTimeout(timer);
631 retryTimers.clear();
632 primeController?.abort();
633 primeController = null;
634 controller?.abort();
635 controller = null;
636 socket?.close();
637 socket = null;
638 closeDecoder();
639 };
640
641 const send = (payload: Uint8Array<ArrayBuffer> | string) => {
642 if (socket?.readyState === WebSocket.OPEN) socket.send(payload);
643 };
644
645 const rawPoint = (x: number, y: number) => {
646 // serve-sim streams the raw framebuffer; rotated devices need input
647 // remapped into that raw space.
648 if (platform !== "ios" || !screen || screen.width > screen.height) return { x, y };
649 switch (screen.orientation) {
650 case "landscape_left":
651 return { x: y, y: 1 - x };
652 case "landscape_right":
653 return { x: 1 - y, y: x };
654 case "portrait_upside_down":
655 return { x: 1 - x, y: 1 - y };
656 default:
657 return { x, y };
658 }
659 };
660
661 return {
662 start,
663 stop,
664 sendTouch: (phase, x, y) => {
665 if (platform === "ios") {
666 send(taggedJson(IOS_MSG_TOUCH, { type: phase, ...rawPoint(x, y) }));
667 return;
668 }
669 const action = phase === "begin" ? "down" : phase === "move" ? "move" : "up";
670 send(JSON.stringify({ type: "touch", action, x, y }));
671 },
672 sendKey: (event, phase) => {
673 if (platform === "ios") {
674 const usage = hidUsageForCode(event.code);
675 if (usage !== null) send(taggedJson(IOS_MSG_KEY, { type: phase, usage }));
676 return;
677 }
678 if (phase !== "down") return;
679 if (event.key === "Escape") return send(JSON.stringify({ type: "back" }));
680 const keycode = ANDROID_KEYCODE_BY_KEY[event.key];
681 if (keycode !== undefined) return send(JSON.stringify({ type: "key", keycode }));
682 if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
683 send(JSON.stringify({ type: "text", text: event.key }));
684 }
685 },
686 pressButton: (button) => {
687 if (platform === "ios") {
688 const name =
689 button === "home"
690 ? "home"
691 : button === "appSwitcher"
692 ? "app_switcher"
693 : button === "power"
694 ? "lock"
695 : null;
696 if (name) send(taggedJson(IOS_MSG_BUTTON, { button: name }));
697 return;
698 }
699 const type = button === "appSwitcher" ? "recents" : button;
700 if (type === "home" || type === "back" || type === "recents" || type === "power") {
701 send(JSON.stringify({ type }));
702 }
703 },
704 rotate: () => {
705 if (platform !== "ios") return;
706 const current = screen?.orientation ?? "portrait";
707 const next =
708 IOS_ORIENTATIONS[(IOS_ORIENTATIONS.indexOf(current) + 1) % IOS_ORIENTATIONS.length]!;
709 send(taggedJson(IOS_MSG_ORIENTATION, { orientation: next }));
710 },
711 };
712}
packages/client-runtime/src/state/deviceHubAccess.ts+3-18
...@@ -20,19 +20,11 @@ import { environmentEndpointUrl } from "../environment/endpoint.ts";...@@ -20,19 +20,11 @@ import { environmentEndpointUrl } from "../environment/endpoint.ts";
20import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";20import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
21import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";21import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";
22import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";22import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
23import type { DeviceHubAccess } from "../device/hubAccess.ts";
2324
24const TICKET_TIMEOUT_MS = 8_000;25export { type DeviceHubAccess, withDeviceHubQuery } from "../device/hubAccess.ts";
2526
26export interface DeviceHubAccess {27const TICKET_TIMEOUT_MS = 8_000;
27 /** Absolute origin-relative base, e.g. `https://env.example/api/device-hub`. */
28 readonly httpBase: string;
29 /** Same base with the `ws(s)` scheme. */
30 readonly wsBase: string;
31 /** Query parameters to append to every hub request; empty for cookie sessions. */
32 readonly query: Readonly<Record<string, string>>;
33 /** Whether requests must include cookies (same-origin session). */
34 readonly credentials: boolean;
35}
3628
37export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDeviceHubAccess")(29export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDeviceHubAccess")(
38 function* (input: {30 function* (input: {
...@@ -64,10 +56,3 @@ export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDevi...@@ -64,10 +56,3 @@ export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDevi
64 };56 };
65 },57 },
66);58);
67
68export const withDeviceHubQuery = (url: string, access: DeviceHubAccess): string => {
69 const entries = Object.entries(access.query);
70 if (entries.length === 0) return url;
71 const separator = url.includes("?") ? "&" : "?";
72 return `${url}${separator}${new URLSearchParams(entries).toString()}`;
73};