| author | |
| committer | |
| log | 823119350ee29cca434b1295aa5c560f4c9ef98a |
| tree | f3fe805130624552f5942f0716229113ba81516f |
| parent | cb3d95c17487f3d834a5537f7c3aa0106bc9b5ec |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 18 | const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | 18 | const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 19 | const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] })); | 19 | const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] })); |
| 20 | const generatedDeviceStreamRoot = path.join(__dirname, ".generated", "device-stream"); | ||
| 20 | const resolveShikiDependencyRoot = (packageName) => { | 21 | const 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 | } |
| 95 | 97 | ||
| 96 | module.exports = generateMobileThirdPartyLicenses().then(() => | 98 | async 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 | |||
| 123 | module.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 @@ | ||
| 1 | import * as NodeFSP from "node:fs/promises"; | ||
| 2 | import * as NodePath from "node:path"; | ||
| 3 | import * as NodeURL from "node:url"; | ||
| 4 | import { build } from "vite-plus"; | ||
| 5 | |||
| 6 | const 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. */ | ||
| 9 | export 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 { |
| 30 | import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet"; | 30 | import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet"; |
| 31 | import { ReviewSheet } from "./features/review/ReviewSheet"; | 31 | import { ReviewSheet } from "./features/review/ReviewSheet"; |
| 32 | import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; | 32 | import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; |
| 33 | import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen"; | ||
| 33 | import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; | 34 | import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; |
| 34 | import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; | 35 | import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; |
| 35 | import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; | 36 | import { 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 | ]); |
| 454 | 456 | ||
| ... | @@ -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"; |
| 60 | import IconGitMerge from "@tabler/icons-react-native/IconGitMerge"; | 60 | import IconGitMerge from "@tabler/icons-react-native/IconGitMerge"; |
| 61 | import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; | 61 | import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; |
| 62 | import IconHammer from "@tabler/icons-react-native/IconHammer"; | 62 | import IconHammer from "@tabler/icons-react-native/IconHammer"; |
| 63 | import IconHome from "@tabler/icons-react-native/IconHome"; | ||
| 63 | import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle"; | 64 | import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle"; |
| 64 | import IconKeyboard from "@tabler/icons-react-native/IconKeyboard"; | 65 | import IconKeyboard from "@tabler/icons-react-native/IconKeyboard"; |
| 65 | import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide"; | 66 | import 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 @@ | ||
| 1 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | ||
| 2 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; | ||
| 3 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | import * as Cause from "effect/Cause"; | ||
| 5 | import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; | ||
| 6 | import { ActivityIndicator, Alert, AppState, Platform, Pressable, View } from "react-native"; | ||
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 8 | |||
| 9 | import { AppText } from "../../components/AppText"; | ||
| 10 | import { ScreenHeader, type ScreenHeaderMenuItem } from "../../components/ScreenHeader"; | ||
| 11 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | ||
| 12 | import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device"; | ||
| 13 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 14 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 15 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 16 | import { DeviceStreamWebView, type DeviceStreamRef } from "./DeviceStreamWebView"; | ||
| 17 | import { | ||
| 18 | selectedThreadDevicePreview, | ||
| 19 | threadDevicePreviews, | ||
| 20 | type ThreadDevicePreview, | ||
| 21 | } from "./threadDevicePreviews"; | ||
| 22 | |||
| 23 | const DevicePreviewStack = createNativeStackNavigator<{ DevicePreview: undefined }>(); | ||
| 24 | |||
| 25 | type DevicePreviewRouteScreenProps = StaticScreenProps<{ | ||
| 26 | readonly environmentId: string; | ||
| 27 | readonly threadId: string; | ||
| 28 | }>; | ||
| 29 | |||
| 30 | /** The nested native stack supplies the navigation bar inside the modal. */ | ||
| 31 | export 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 | |||
| 59 | function 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 | |||
| 249 | function 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 @@ | ||
| 1 | import deviceStreamScript from "@t3tools/mobile-device-stream"; | ||
| 2 | import { useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type Ref } from "react"; | ||
| 3 | import { Platform } from "react-native"; | ||
| 4 | import { WebView } from "react-native-webview"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | deviceStreamDocument, | ||
| 8 | deviceStreamMessage, | ||
| 9 | type DeviceStreamConfiguration, | ||
| 10 | } from "./device-stream-document"; | ||
| 11 | |||
| 12 | export interface DeviceStreamRef { | ||
| 13 | home: () => void; | ||
| 14 | back: () => void; | ||
| 15 | appSwitcher: () => void; | ||
| 16 | rotate: () => void; | ||
| 17 | } | ||
| 18 | |||
| 19 | type NativeStreamBridge = { | ||
| 20 | readonly ref?: Ref<DeviceStreamRef>; | ||
| 21 | readonly onUnauthorized: () => Promise<void>; | ||
| 22 | readonly onInputConnected: (connected: boolean) => Promise<void>; | ||
| 23 | }; | ||
| 24 | |||
| 25 | export 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 | |||
| 49 | function 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 @@ | ||
| 1 | import { Pressable, View } from "react-native"; | ||
| 2 | |||
| 3 | import { AppText } from "../../components/AppText"; | ||
| 4 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 5 | |||
| 6 | export 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 @@ | ||
| 1 | import * as NodeVM from "node:vm"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { deviceStreamDocument, deviceStreamMessage } from "./device-stream-document"; | ||
| 5 | |||
| 6 | describe("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 | |||
| 27 | describe("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 @@ | ||
| 1 | import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access"; | ||
| 2 | import type { DevicePlatform } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | export 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 | |||
| 16 | export 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 | |||
| 23 | export 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 @@ | ||
| 1 | import { | ||
| 2 | createDeviceStreamClient, | ||
| 3 | type DeviceScreenSize, | ||
| 4 | } from "@t3tools/client-runtime/device/stream"; | ||
| 5 | |||
| 6 | import type { DeviceStreamConfiguration } from "./device-stream-document"; | ||
| 7 | |||
| 8 | declare global { | ||
| 9 | interface Window { | ||
| 10 | ReactNativeWebView: { postMessage: (message: string) => void }; | ||
| 11 | } | ||
| 12 | } | ||
| 13 | |||
| 14 | let activeClient: ReturnType<typeof createDeviceStreamClient> | null = null; | ||
| 15 | let activeImage: HTMLImageElement | null = null; | ||
| 16 | |||
| 17 | export function stop() { | ||
| 18 | activeClient?.stop(); | ||
| 19 | activeClient = null; | ||
| 20 | activeImage?.removeAttribute("src"); | ||
| 21 | activeImage = null; | ||
| 22 | } | ||
| 23 | |||
| 24 | export 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. */ | ||
| 30 | export 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 @@ | ||
| 1 | import { ThreadId, type DeviceServiceState } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { selectedThreadDevicePreview, threadDevicePreviews } from "./threadDevicePreviews"; | ||
| 5 | |||
| 6 | const threadId = ThreadId.make("thread"); | ||
| 7 | const 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 | |||
| 77 | describe("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 @@ | ||
| 1 | import type { DeviceServiceState, ThreadId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | /** Host identity is part of the selection because Android serials repeat across hosts. */ | ||
| 4 | export 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 | |||
| 21 | export type ThreadDevicePreview = ReturnType<typeof threadDevicePreviews>[number]; | ||
| 22 | |||
| 23 | export 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 |
| 13 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; | 13 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; |
| 14 | import type { LegendListRef } from "@legendapp/list/react-native"; | 14 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| 15 | import { HeaderHeightContext } from "@react-navigation/elements"; | 15 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 16 | import { useNavigation } from "@react-navigation/native"; | ||
| 16 | import type { | 17 | import 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"; |
| 72 | import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; | 73 | import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; |
| 73 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; | 74 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 74 | import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; | 75 | import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; |
| 76 | import { deviceEnvironment } from "../../state/device"; | ||
| 77 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 78 | import { threadDevicePreviews } from "../devices/threadDevicePreviews"; | ||
| 75 | import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; | 79 | import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; |
| 76 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 80 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 77 | import type { | 81 | import type { |
| ... | @@ -259,6 +263,21 @@ const USER_INPUT_TOGGLE_TIMING = { | ... | @@ -259,6 +263,21 @@ const USER_INPUT_TOGGLE_TIMING = { |
| 259 | }; | 263 | }; |
| 260 | 264 | ||
| 261 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { | 265 | export 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 | <FloatingWorkingControl | 956 | <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"; |
| 24 | import { SymbolView } from "../../components/AppSymbol"; | 24 | import { SymbolView } from "../../components/AppSymbol"; |
| 25 | import { ControlPill } from "../../components/ControlPill"; | 25 | import { ControlPill } from "../../components/ControlPill"; |
| 26 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 26 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 27 | import { DevicePreviewButton } from "../devices/device-preview-button"; | ||
| 27 | import type { FloatingWorkingStatus } from "./floating-working-status"; | 28 | import type { FloatingWorkingStatus } from "./floating-working-status"; |
| 28 | import { ShimmeringWorkContent } from "./thread-work-log"; | 29 | import { ShimmeringWorkContent } from "./thread-work-log"; |
| 29 | 30 | ||
| ... | @@ -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 |
| 63 | export function FloatingWorkingControl(props: { | 64 | export 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); |
| 73 | 80 | ||
| 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 around | 119 | // 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 | })); | ||
| 115 | 124 | ||
| 116 | if (props.status === null && !props.showScrollToEnd) { | 125 | if (!hasCapsule && !props.showScrollToEnd) { |
| 117 | return null; | 126 | return null; |
| 118 | } | 127 | } |
| 119 | 128 | ||
| 120 | // Only the connection label is a button (tap to reconnect); the others | 129 | // 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 constraint | 133 | // 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 | <View | 141 | <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 | <FloatingStatusLabel | 146 | <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; |
| 146 | 170 | ||
| ... | @@ -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 | <UniwindGlassContainer | 181 | <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 | <AnimatedGlassView | 186 | <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> |
| 172 | 196 | ||
| 173 | <AnimatedGlassView | 197 | <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.View | 214 | <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> |
| 197 | 221 | ||
| 198 | <Animated.View | 222 | <Animated.View |
apps/mobile/src/state/device.ts created+44| ... | @@ -0,0 +1,44 @@ | ||
| 1 | import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device"; | ||
| 2 | import { resolveDeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; | ||
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import { Atom } from "effect/unstable/reactivity"; | ||
| 7 | import { useMemo } from "react"; | ||
| 8 | |||
| 9 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 10 | import { appAtomRegistry } from "./atom-registry"; | ||
| 11 | import { useEnvironmentQuery } from "./query"; | ||
| 12 | import { environmentSession, usePreparedConnection } from "./session"; | ||
| 13 | |||
| 14 | export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRuntime); | ||
| 15 | |||
| 16 | const 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 | |||
| 29 | export function refreshDeviceHubAccess(environmentId: EnvironmentId) { | ||
| 30 | appAtomRegistry.refresh(deviceHubAccessAtom(environmentId)); | ||
| 31 | } | ||
| 32 | |||
| 33 | export 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 @@ | ||
| 1 | declare 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 | })); |
| 10 | const access = { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true }; | 10 | const access = { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true }; |
| 11 | vi.mock("./deviceStream", () => ({ | 11 | vi.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"; |
| 15 | 15 | ||
| 16 | const AX_POLL_INTERVAL_MS = 2_000; | 16 | const AX_POLL_INTERVAL_MS = 2_000; |
| 17 | 17 |
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"; |
| 10 | 10 | ||
| 11 | const envelope = (tag: number, payload: number[]) => { | 11 | const 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 | }); |
| 45 | 45 | ||
| 46 | describe("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 | |||
| 46 | describe("serve-emu frames", () => { | 211 | describe("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 | */ | ||
| 19 | import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; | ||
| 20 | import { withDeviceHubQuery } from "@t3tools/client-runtime/state/deviceHubAccess"; | ||
| 21 | import type { DevicePlatform } from "@t3tools/contracts"; | ||
| 22 | |||
| 23 | export type DeviceStreamStatus = "connecting" | "streaming" | "error"; | ||
| 24 | |||
| 25 | export interface DeviceScreenSize { | ||
| 26 | readonly width: number; | ||
| 27 | readonly height: number; | ||
| 28 | readonly orientation: "portrait" | "portrait_upside_down" | "landscape_left" | "landscape_right"; | ||
| 29 | } | ||
| 30 | |||
| 31 | export 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 | |||
| 46 | export interface DeviceStreamTarget { | ||
| 47 | readonly platform: DevicePlatform; | ||
| 48 | readonly deviceId: string; | ||
| 49 | readonly access: DeviceHubAccess; | ||
| 50 | } | ||
| 51 | |||
| 52 | export type DeviceHardwareButton = "home" | "back" | "recents" | "power" | "appSwitcher"; | ||
| 53 | |||
| 54 | const RETRY_DELAY_MS = 1_000; | ||
| 55 | const FRAME_DURATION_US = 16_667; | ||
| 56 | const SEMU_MAGIC = 0x53454d55; | ||
| 57 | const SEMU_HEADER_BYTES = 16; | ||
| 58 | const SEMU_FLAG_KEY = 1; | ||
| 59 | const SOFT_DECODE_QUEUE = 8; | ||
| 60 | |||
| 61 | // serve-sim binary WS message tags (browser -> helper). | ||
| 62 | const IOS_MSG_TOUCH = 0x03; | ||
| 63 | const IOS_MSG_BUTTON = 0x04; | ||
| 64 | const IOS_MSG_KEY = 0x06; | ||
| 65 | const IOS_MSG_ORIENTATION = 0x07; | ||
| 66 | const IOS_MSG_HARDWARE_KEYBOARD = 0x0d; | ||
| 67 | // helper -> browser. | ||
| 68 | const IOS_TAG_SCREEN_CONFIG = 0x82; | ||
| 69 | |||
| 70 | const encoder = new TextEncoder(); | ||
| 71 | const decoder = new TextDecoder(); | ||
| 72 | |||
| 73 | const isWebCodecsSupported = (): boolean => | ||
| 74 | typeof globalThis !== "undefined" && | ||
| 75 | "VideoDecoder" in globalThis && | ||
| 76 | "EncodedVideoChunk" in globalThis; | ||
| 77 | |||
| 78 | function 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. */ | ||
| 87 | export 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. */ | ||
| 94 | export 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 | |||
| 114 | const 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. */ | ||
| 124 | export 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 | |||
| 147 | export type AvccChunk = { | ||
| 148 | readonly type: "description" | "keyframe" | "delta" | "seed"; | ||
| 149 | readonly payload: Uint8Array; | ||
| 150 | }; | ||
| 151 | |||
| 152 | const 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. */ | ||
| 160 | export 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 | |||
| 201 | export 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 | |||
| 211 | const 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 | |||
| 243 | function 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 | |||
| 250 | const 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 | |||
| 265 | const IOS_ORIENTATIONS: ReadonlyArray<DeviceScreenSize["orientation"]> = [ | ||
| 266 | "portrait", | ||
| 267 | "landscape_left", | ||
| 268 | "portrait_upside_down", | ||
| 269 | "landscape_right", | ||
| 270 | ]; | ||
| 271 | |||
| 272 | export 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"; |
| 35 | import { useDeviceState } from "~/state/device"; | 35 | import { useDeviceState } from "~/state/device"; |
| 36 | 36 | ||
| 37 | import { DeviceStreamView } from "../device/DeviceStreamView"; | 37 | import { DeviceStreamView } from "../device/DeviceStreamView"; |
| 38 | import type { DeviceScreenSize } from "../device/deviceStream"; | 38 | import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream"; |
| 39 | import { previewBridge } from "./previewBridge"; | 39 | import { previewBridge } from "./previewBridge"; |
| 40 | import { | 40 | import { |
| 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"; |
| 8 | import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; | 8 | import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; |
| 9 | 9 | ||
| 10 | import type { DeviceScreenSize } from "../device/deviceStream"; | 10 | import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream"; |
| 11 | 11 | ||
| 12 | export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; | 12 | export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; |
| 13 | export const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12; | 13 | export 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. |
| 55 | When an agent opens a device, it floats over the chat in web and desktop clients | 55 | When an agent opens a device, it floats over the chat in web and desktop clients |
| 56 | connected to the thread, the same way an agent-driven browser does. Turn off | 56 | connected to the thread, the same way an agent-driven browser does. Turn off |
| 57 | **Auto-show floating preview** in **Settings → Integrations → Browser** to open a | 57 | **Auto-show floating preview** in **Settings → Integrations → Browser** to open a |
| 58 | right-panel tab instead. Mobile clients show device activity in the thread | 58 | right-panel tab instead. In the mobile app, open the agent's thread and tap the |
| 59 | timeline. Agents drive the device through the `agent-device` command line. T3 | 59 | device button above the composer to watch the live screen and control it. |
| 60 | If the thread has several devices open, choose one in the viewer. Closing the | ||
| 61 | viewer stops streaming and leaves the device available to the agent. Device | ||
| 62 | activity also appears in the thread timeline. | ||
| 63 | |||
| 64 | Agents drive the device through the `agent-device` command line. T3 | ||
| 60 | Code installs and starts it only after **Agent device access** is enabled. iOS | 65 | Code installs and starts it only after **Agent device access** is enabled. iOS |
| 61 | taps build a small test runner on first use, which takes a couple of minutes | 66 | taps build a small test runner on first use, which takes a couple of minutes |
| 62 | once per server. Restart an existing agent session after granting access so it | 67 | once 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. */ | ||
| 2 | export 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 | |||
| 13 | export 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 | */ | ||
| 22 | import { type DeviceHubAccess, withDeviceHubQuery } from "./hubAccess.ts"; | ||
| 23 | import type { DevicePlatform } from "@t3tools/contracts"; | ||
| 24 | |||
| 25 | export type DeviceStreamStatus = "connecting" | "streaming" | "error"; | ||
| 26 | |||
| 27 | export interface DeviceScreenSize { | ||
| 28 | readonly width: number; | ||
| 29 | readonly height: number; | ||
| 30 | readonly orientation: "portrait" | "portrait_upside_down" | "landscape_left" | "landscape_right"; | ||
| 31 | } | ||
| 32 | |||
| 33 | export 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 | |||
| 48 | export 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 | |||
| 56 | export type DeviceHardwareButton = "home" | "back" | "recents" | "power" | "appSwitcher"; | ||
| 57 | |||
| 58 | const RETRY_DELAY_MS = 1_000; | ||
| 59 | const FRAME_DURATION_US = 16_667; | ||
| 60 | const SEMU_MAGIC = 0x53454d55; | ||
| 61 | const SEMU_HEADER_BYTES = 16; | ||
| 62 | const SEMU_FLAG_KEY = 1; | ||
| 63 | const SOFT_DECODE_QUEUE = 8; | ||
| 64 | |||
| 65 | // serve-sim binary WS message tags (browser -> helper). | ||
| 66 | const IOS_MSG_TOUCH = 0x03; | ||
| 67 | const IOS_MSG_BUTTON = 0x04; | ||
| 68 | const IOS_MSG_KEY = 0x06; | ||
| 69 | const IOS_MSG_ORIENTATION = 0x07; | ||
| 70 | const IOS_MSG_HARDWARE_KEYBOARD = 0x0d; | ||
| 71 | // helper -> browser. | ||
| 72 | const IOS_TAG_SCREEN_CONFIG = 0x82; | ||
| 73 | |||
| 74 | const encoder = new TextEncoder(); | ||
| 75 | const decoder = new TextDecoder(); | ||
| 76 | |||
| 77 | const isWebCodecsSupported = (): boolean => | ||
| 78 | typeof globalThis !== "undefined" && | ||
| 79 | "VideoDecoder" in globalThis && | ||
| 80 | "EncodedVideoChunk" in globalThis; | ||
| 81 | |||
| 82 | function 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. */ | ||
| 91 | export 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. */ | ||
| 98 | export 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 | |||
| 118 | const 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. */ | ||
| 128 | export 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 | |||
| 151 | export type AvccChunk = { | ||
| 152 | readonly type: "description" | "keyframe" | "delta" | "seed"; | ||
| 153 | readonly payload: Uint8Array; | ||
| 154 | }; | ||
| 155 | |||
| 156 | const 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. */ | ||
| 164 | export 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 | |||
| 205 | export 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 | |||
| 215 | const 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 | |||
| 247 | function 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 | |||
| 254 | const 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 | |||
| 269 | const IOS_ORIENTATIONS: ReadonlyArray<DeviceScreenSize["orientation"]> = [ | ||
| 270 | "portrait", | ||
| 271 | "landscape_left", | ||
| 272 | "portrait_upside_down", | ||
| 273 | "landscape_right", | ||
| 274 | ]; | ||
| 275 | |||
| 276 | export 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"; |
| 20 | import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; | 20 | import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; |
| 21 | import type { RemoteEnvironmentRequestError } from "../rpc/http.ts"; | 21 | import type { RemoteEnvironmentRequestError } from "../rpc/http.ts"; |
| 22 | import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; | 22 | import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; |
| 23 | import type { DeviceHubAccess } from "../device/hubAccess.ts"; | ||
| 23 | 24 | ||
| 24 | const TICKET_TIMEOUT_MS = 8_000; | 25 | export { type DeviceHubAccess, withDeviceHubQuery } from "../device/hubAccess.ts"; |
| 25 | 26 | ||
| 26 | export interface DeviceHubAccess { | 27 | const 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 | } | ||
| 36 | 28 | ||
| 37 | export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDeviceHubAccess")( | 29 | export 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 | |||
| 68 | export 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 | }; |