authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-10 11:59:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 11:59:32-07:00
logdca7b59bea7e8a4a24e75b290027496a523b0ca7
tree97d1609e5c29267cb248704d506b4df2d7a3e09c
parent502131adff7494d1a1797b4fe518870f7e8431fd
signature Signed by PGP key B5690EEEBB952194

feat(devices): add simulator and emulator support (#10677)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

77 files changed, 9041 insertions(+), 98 deletions(-)

apps/mobile/src/features/threads/thread-work-log.tsx+7-3
...@@ -76,7 +76,7 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;...@@ -76,7 +76,7 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
76const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);76const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
77const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);77const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
78const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);78const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
79type WorkContentIcon = AppSymbolName | "browser" | "t3-code" | "pull-request";79type WorkContentIcon = AppSymbolName | "browser" | "device" | "t3-code" | "pull-request";
8080
81function WorkLogIcon(props: {81function WorkLogIcon(props: {
82 readonly icon: WorkContentIcon;82 readonly icon: WorkContentIcon;
...@@ -97,7 +97,9 @@ function WorkLogIcon(props: {...@@ -97,7 +97,9 @@ function WorkLogIcon(props: {
97 ? "arrow.triangle.pull"97 ? "arrow.triangle.pull"
98 : props.icon === "browser"98 : props.icon === "browser"
99 ? { ios: "globe", android: "public" }99 ? { ios: "globe", android: "public" }
100 : props.icon100 : props.icon === "device"
101 ? { ios: "iphone", android: "smartphone" }
102 : props.icon
101 }103 }
102 size={14}104 size={14}
103 weight="medium"105 weight="medium"
...@@ -899,7 +901,7 @@ export function ThreadWorkGroupToggle(props: {...@@ -899,7 +901,7 @@ export function ThreadWorkGroupToggle(props: {
899 readonly iconSubtleColor: import("react-native").ColorValue;901 readonly iconSubtleColor: import("react-native").ColorValue;
900 readonly summary: string;902 readonly summary: string;
901 readonly summaryKind: ToolGroupSummaryKind;903 readonly summaryKind: ToolGroupSummaryKind;
902 readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request";904 readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
903 readonly themeAppearance: "light" | "dark";905 readonly themeAppearance: "light" | "dark";
904 readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;906 readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
905 readonly toolIcon?: ToolActivityIcon;907 readonly toolIcon?: ToolActivityIcon;
...@@ -1248,6 +1250,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {...@@ -1248,6 +1250,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
1248 return { ios: "square.and.pencil", android: "edit" };1250 return { ios: "square.and.pencil", android: "edit" };
1249 case "command":1251 case "command":
1250 return { ios: "terminal", android: "terminal" };1252 return { ios: "terminal", android: "terminal" };
1253 case "device":
1254 return { ios: "iphone", android: "smartphone" };
1251 case "browser":1255 case "browser":
1252 case "search":1256 case "search":
1253 return { ios: "globe", android: "public" };1257 return { ios: "globe", android: "public" };
apps/mobile/src/lib/threadActivity.ts+1-1
...@@ -167,7 +167,7 @@ export type ThreadFeedEntry =...@@ -167,7 +167,7 @@ export type ThreadFeedEntry =
167 readonly summaryKind: ToolGroupSummaryKind;167 readonly summaryKind: ToolGroupSummaryKind;
168 readonly toolSurface?: WorkLogEntry["toolSurface"];168 readonly toolSurface?: WorkLogEntry["toolSurface"];
169 readonly toolIcon?: WorkLogEntry["toolIcon"];169 readonly toolIcon?: WorkLogEntry["toolIcon"];
170 readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request";170 readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
171 readonly hasFailure: boolean;171 readonly hasFailure: boolean;
172 readonly live: boolean;172 readonly live: boolean;
173 readonly shimmer: boolean;173 readonly shimmer: boolean;
apps/server/src/auth/RpcAuthorization.ts+8
...@@ -144,6 +144,14 @@ export const RPC_REQUIRED_SCOPES = {...@@ -144,6 +144,14 @@ export const RPC_REQUIRED_SCOPES = {
144 [WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope,144 [WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope,
145 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,145 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
146 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,146 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
147 [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,
148 [WS_METHODS.deviceList]: AuthOrchestrationReadScope,
149 [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,
150 [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,
151 [WS_METHODS.deviceShutdown]: AuthOrchestrationOperateScope,
152 [WS_METHODS.deviceDetail]: AuthOrchestrationReadScope,
153 [WS_METHODS.deviceAction]: AuthOrchestrationOperateScope,
154 [WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope,
147 [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope,155 [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope,
148 [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope,156 [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope,
149 [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope,157 [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope,
apps/server/src/device/AgentDeviceShim.ts created+35
...@@ -0,0 +1,35 @@
1/**
2 * A directory holding an `agent-device` launcher that runs the pinned install
3 * with the server's Node. Prepended to provider subprocess PATHs so the agent
4 * types `agent-device …` and gets the version the injected instructions were
5 * written for, regardless of what is or is not globally installed.
6 */
7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11
12const SHIM_DIR = "device/bin";
13
14export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(function* (input: {
15 readonly entryPath: string;
16 readonly stateDir: string;
17}) {
18 const { entryPath } = input;
19 const fs = yield* FileSystem.FileSystem;
20 const path = yield* Path.Path;
21 const platform = yield* HostProcessPlatform;
22 const shimDir = path.join(input.stateDir, SHIM_DIR);
23 yield* fs.makeDirectory(shimDir, { recursive: true });
24 const node = process.execPath;
25 if (platform === "win32") {
26 const script = `@echo off\r\n"${node}" "${entryPath}" %*\r\n`;
27 yield* fs.writeFileString(path.join(shimDir, "agent-device.cmd"), script);
28 } else {
29 const script = `#!/bin/sh\nexec "${node}" "${entryPath}" "$@"\n`;
30 const shimPath = path.join(shimDir, "agent-device");
31 yield* fs.writeFileString(shimPath, script);
32 yield* fs.chmod(shimPath, 0o755);
33 }
34 return shimDir;
35});
apps/server/src/device/DeviceActions.test.ts created+283
...@@ -0,0 +1,283 @@
1import { describe, expect, it } from "@effect/vitest";
2import type { DeviceActionInput } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4
5import { readDeviceDetail, runDeviceAction, supportsAction } from "./DeviceActions.ts";
6import type { DeviceHostReady } from "./DeviceHost.ts";
7
8type Call = { command: string; args: ReadonlyArray<string>; stdin?: string };
9
10const makeReady = (
11 respond: (call: Call) => { stdout?: string; stderr?: string; code?: number } = () => ({}),
12 helpers: DeviceHostReady["helpers"] = {
13 serveSimAxSettings: "/hub/simax/serve-sim-ax-settings",
14 serveSimCli: "/hub/serve-sim.js",
15 },
16) => {
17 const calls: Call[] = [];
18 const ready: DeviceHostReady = {
19 hub: { origin: "http://127.0.0.1:1" },
20 helpers,
21 run: (command, args, options) => {
22 const call = { command, args, ...(options?.stdin ? { stdin: options.stdin } : {}) };
23 calls.push(call);
24 const result = respond(call);
25 return Effect.succeed({
26 stdout: result.stdout ?? "",
27 stderr: result.stderr ?? "",
28 code: result.code ?? 0,
29 });
30 },
31 };
32 return { ready, calls };
33};
34
35const udid = "SIM-1";
36
37describe("supportsAction", () => {
38 it("advertises platform-specific toggles", () => {
39 const toggle = (setting: Extract<DeviceActionInput, { type: "setToggle" }>["setting"]) =>
40 ({ type: "setToggle", deviceId: udid, setting, value: true }) as const;
41 expect(supportsAction("ios", toggle("voiceOver"))).toBe(true);
42 expect(supportsAction("android", toggle("voiceOver"))).toBe(false);
43 expect(supportsAction("android", toggle("networkEnabled"))).toBe(true);
44 expect(supportsAction("ios", toggle("networkEnabled"))).toBe(false);
45 expect(supportsAction("ios", { type: "setLiquidGlass", deviceId: udid, value: "clear" })).toBe(
46 true,
47 );
48 expect(
49 supportsAction("android", { type: "setLiquidGlass", deviceId: udid, value: "clear" }),
50 ).toBe(false);
51 expect(
52 supportsAction("android", { type: "setOrientation", deviceId: udid, value: "portrait" }),
53 ).toBe(true);
54 expect(
55 supportsAction("ios", { type: "setOrientation", deviceId: udid, value: "portrait" }),
56 ).toBe(false);
57 });
58});
59
60describe("runDeviceAction", () => {
61 it.effect("maps shared text sizes onto simctl content-size categories", () =>
62 Effect.gen(function* () {
63 const { ready, calls } = makeReady();
64 yield* runDeviceAction(ready, "ios", {
65 type: "setTextSize",
66 deviceId: udid,
67 value: "extra-large",
68 });
69 expect(calls).toEqual([
70 { command: "xcrun", args: ["simctl", "ui", udid, "content_size", "accessibility-large"] },
71 ]);
72 }),
73 );
74
75 it.effect("maps shared text sizes onto Android font_scale", () =>
76 Effect.gen(function* () {
77 const { ready, calls } = makeReady();
78 yield* runDeviceAction(ready, "android", {
79 type: "setTextSize",
80 deviceId: "emulator-5554",
81 value: "large",
82 });
83 expect(calls[0]?.args).toEqual([
84 "-s",
85 "emulator-5554",
86 "shell",
87 "settings",
88 "put",
89 "system",
90 "font_scale",
91 "1.15",
92 ]);
93 }),
94 );
95
96 it.effect(
97 "rotates emulators through the accelerometer and physical devices through the lock",
98 () =>
99 Effect.gen(function* () {
100 const emulator = makeReady();
101 yield* runDeviceAction(emulator.ready, "android", {
102 type: "setOrientation",
103 deviceId: "emulator-5554",
104 value: "landscape_left",
105 });
106 expect(emulator.calls.at(-1)?.args).toEqual([
107 "-s",
108 "emulator-5554",
109 "emu",
110 "sensor",
111 "set",
112 "acceleration",
113 "9.81:0:0",
114 ]);
115 const phone = makeReady();
116 yield* runDeviceAction(phone.ready, "android", {
117 type: "setOrientation",
118 deviceId: "R5CT1234",
119 value: "landscape_right",
120 });
121 expect(phone.calls).toEqual([
122 {
123 command: "adb",
124 args: ["-s", "R5CT1234", "shell", "cmd", "window", "user-rotation", "lock", "3"],
125 },
126 ]);
127 }),
128 );
129
130 it.effect("runs accessibility toggles through the bundled helper via simctl spawn", () =>
131 Effect.gen(function* () {
132 const { ready, calls } = makeReady();
133 yield* runDeviceAction(ready, "ios", {
134 type: "setToggle",
135 deviceId: udid,
136 setting: "voiceOver",
137 value: true,
138 });
139 expect(calls).toEqual([
140 {
141 command: "xcrun",
142 args: [
143 "simctl",
144 "spawn",
145 udid,
146 "/hub/simax/serve-sim-ax-settings",
147 "set",
148 "voiceover",
149 "on",
150 ],
151 },
152 ]);
153 }),
154 );
155
156 it.effect("fails clearly when the helper is missing", () =>
157 Effect.gen(function* () {
158 const { ready } = makeReady(() => ({}), { serveSimAxSettings: null, serveSimCli: null });
159 const error = yield* Effect.flip(
160 runDeviceAction(ready, "ios", {
161 type: "setColorFilter",
162 deviceId: udid,
163 value: "grayscale",
164 }),
165 );
166 expect(error._tag).toBe("DeviceActionUnavailableError");
167 expect(error.message).toContain("requires a helper");
168 }),
169 );
170
171 it.effect("rejects actions the platform does not support without running anything", () =>
172 Effect.gen(function* () {
173 const { ready, calls } = makeReady();
174 const error = yield* Effect.flip(
175 runDeviceAction(ready, "android", {
176 type: "sendPush",
177 deviceId: "emulator-5554",
178 appId: "com.example",
179 payload: "hi",
180 }),
181 );
182 expect(error.message).toContain("not supported on android");
183 expect(calls).toEqual([]);
184 }),
185 );
186
187 it.effect("surfaces non-zero exit codes as operation errors", () =>
188 Effect.gen(function* () {
189 const { ready } = makeReady(() => ({ code: 1, stderr: "Invalid device: SIM-1" }));
190 const error = yield* Effect.flip(
191 runDeviceAction(ready, "ios", { type: "setAppearance", deviceId: udid, value: "dark" }),
192 );
193 expect(error.operation).toBe("appearance");
194 expect(error.message).toContain("exit code 1");
195 expect(error.message).not.toContain("Invalid device: SIM-1");
196 expect(error._tag === "DeviceOperationError" && error.cause).toMatchObject({
197 stderr: "Invalid device: SIM-1",
198 });
199 }),
200 );
201
202 it.effect("wraps a bare push string in an APNs alert and feeds it on stdin", () =>
203 Effect.gen(function* () {
204 const { ready, calls } = makeReady();
205 yield* runDeviceAction(ready, "ios", {
206 type: "sendPush",
207 deviceId: udid,
208 appId: "com.example.app",
209 payload: "Hello",
210 });
211 expect(calls[0]?.args).toEqual(["simctl", "push", udid, "com.example.app", "-"]);
212 expect(calls[0]?.stdin).toBe('{"aps":{"alert":"Hello"}}');
213 }),
214 );
215});
216
217describe("readDeviceDetail", () => {
218 it.effect("reads iOS settings from simctl and the accessibility helper", () =>
219 Effect.gen(function* () {
220 const { ready } = makeReady((call) => {
221 const key = call.args.join(" ");
222 if (key.endsWith("ui SIM-1 appearance")) return { stdout: "dark\n" };
223 if (key.endsWith("ui SIM-1 content_size")) return { stdout: "extra-extra-large\n" };
224 if (key.endsWith("ui SIM-1 increase_contrast")) return { stdout: "enabled\n" };
225 if (key.includes("serve-sim-ax-settings status")) {
226 return {
227 stdout:
228 '{"reduce-motion":"on","reduce-transparency":"off","show-borders":"off","voiceover":"off","liquid-glass":"tinted","color-filter":"grayscale"}',
229 };
230 }
231 return { code: 1 };
232 });
233 const detail = yield* readDeviceDetail(ready, "ios", udid);
234 expect(detail.settings).toEqual({
235 appearance: "dark",
236 textSize: "large",
237 increaseContrast: true,
238 reduceMotion: true,
239 reduceTransparency: false,
240 showBorders: false,
241 voiceOver: false,
242 liquidGlass: "tinted",
243 colorFilter: "grayscale",
244 });
245 }),
246 );
247
248 it.effect("degrades unreadable values to unknown instead of failing", () =>
249 Effect.gen(function* () {
250 const { ready } = makeReady(() => ({ code: 1, stderr: "boom" }));
251 const detail = yield* readDeviceDetail(ready, "ios", udid);
252 expect(detail.settings).toEqual({});
253 expect(detail.foregroundApp).toBeNull();
254 }),
255 );
256
257 it.effect("reads Android settings and the focused package", () =>
258 Effect.gen(function* () {
259 const { ready } = makeReady((call) => {
260 const key = call.args.join(" ");
261 if (key.endsWith("cmd uimode night")) return { stdout: "Night mode: yes\n" };
262 if (key.endsWith("font_scale")) return { stdout: "0.85\n" };
263 if (key.endsWith("animator_duration_scale")) return { stdout: "0\n" };
264 if (key.endsWith("wifi_on")) return { stdout: "1\n" };
265 if (key.endsWith("dumpsys window")) {
266 return {
267 stdout:
268 " mFocusedApp=ActivityRecord{155579877 u0 com.example.app/.MainActivity t15}\n mCurrentFocus=Window{1a2b u0 com.example.app/com.example.app.MainActivity}\n",
269 };
270 }
271 return { code: 1 };
272 });
273 const detail = yield* readDeviceDetail(ready, "android", "emulator-5554");
274 expect(detail.settings).toEqual({
275 appearance: "dark",
276 textSize: "small",
277 reduceMotion: true,
278 networkEnabled: true,
279 });
280 expect(detail.foregroundApp).toEqual({ id: "com.example.app" });
281 }),
282 );
283});
apps/server/src/device/DeviceActions.ts created+568
...@@ -0,0 +1,568 @@
1/**
2 * Device settings and one-shot actions, run by the server against the host's
3 * toolchain instead of through serve-sim's shell-exec channel.
4 *
5 * serve-sim's preview drives its "Simulator" panel by sending shell commands
6 * over a token-gated socket. Proxying that would hand any environment
7 * session arbitrary command execution on the host, so T3 runs the same
8 * underlying commands itself, typed per action: `xcrun simctl ui` and
9 * `simctl privacy` for iOS, the `serve-sim-ax-settings` helper that serve-sim
10 * bundles for the accessibility toggles, and `adb shell` for Android.
11 *
12 * Each platform advertises which actions it supports; the panel hides the
13 * rest rather than showing controls that cannot work.
14 */
15import {
16 type DeviceActionInput,
17 type DeviceActionType,
18 type DeviceForegroundApp,
19 DeviceOperationError,
20 DeviceActionUnavailableError,
21 type DeviceOrientation,
22 type DevicePlatform,
23 type DeviceSettings,
24 type DeviceTextSize,
25} from "@t3tools/contracts";
26import * as Effect from "effect/Effect";
27import * as Schema from "effect/Schema";
28
29import type { DeviceHostReady } from "./DeviceHost.ts";
30
31type Runner = DeviceHostReady["run"];
32
33const decodeAxStatus = Schema.decodeUnknownEffect(
34 Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)),
35);
36const encodePushPayload = Schema.encodeUnknownEffect(
37 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
38);
39
40const IOS_ACTIONS: ReadonlySet<DeviceActionType> = new Set([
41 "setAppearance",
42 "setTextSize",
43 "setToggle",
44 "setLiquidGlass",
45 "setColorFilter",
46 "setLocation",
47 "clearLocation",
48 "setPermission",
49 "openUrl",
50 "launchApp",
51 "terminateApp",
52 "sendPush",
53]);
54
55const ANDROID_ACTIONS: ReadonlySet<DeviceActionType> = new Set([
56 "setAppearance",
57 "setTextSize",
58 "setToggle",
59 "setOrientation",
60 "setLocation",
61 "clearLocation",
62 "setPermission",
63 "openUrl",
64 "launchApp",
65 "terminateApp",
66]);
67
68/** Toggle settings each platform can actually flip. */
69const IOS_TOGGLES = new Set([
70 "reduceMotion",
71 "increaseContrast",
72 "reduceTransparency",
73 "showBorders",
74 "voiceOver",
75]);
76const ANDROID_TOGGLES = new Set(["reduceMotion", "networkEnabled"]);
77
78export const supportsAction = (platform: DevicePlatform, input: DeviceActionInput): boolean => {
79 const actions = platform === "ios" ? IOS_ACTIONS : ANDROID_ACTIONS;
80 if (!actions.has(input.type)) return false;
81 if (input.type === "setToggle") {
82 return (platform === "ios" ? IOS_TOGGLES : ANDROID_TOGGLES).has(input.setting);
83 }
84 return true;
85};
86
87const ok = (operation: string) => (result: { code: number; stderr: string; stdout: string }) =>
88 result.code === 0
89 ? Effect.succeed(result.stdout)
90 : Effect.fail(
91 new DeviceOperationError({
92 operation,
93 reason: "command_failed",
94 exitCode: result.code,
95 cause: result,
96 }),
97 );
98
99// iOS text-size categories in ascending order; the four shared steps index
100// into it. `default` is what a fresh simulator reports ("large").
101const IOS_TEXT_SIZES: Record<DeviceTextSize, string> = {
102 small: "small",
103 default: "large",
104 large: "extra-extra-large",
105 "extra-large": "accessibility-large",
106};
107const ANDROID_TEXT_SIZES: Record<DeviceTextSize, string> = {
108 small: "0.85",
109 default: "1.0",
110 large: "1.15",
111 "extra-large": "1.3",
112};
113
114const textSizeFromIos = (category: string): DeviceTextSize | undefined => {
115 const entry = (Object.entries(IOS_TEXT_SIZES) as Array<[DeviceTextSize, string]>).find(
116 ([, value]) => value === category,
117 );
118 if (entry) return entry[0];
119 if (category.startsWith("accessibility")) return "extra-large";
120 if (category.includes("extra")) return "large";
121 return category === "extra-small" || category === "small" || category === "medium"
122 ? "small"
123 : "default";
124};
125
126const textSizeFromAndroid = (scale: number): DeviceTextSize => {
127 if (scale <= 0.9) return "small";
128 if (scale >= 1.25) return "extra-large";
129 if (scale >= 1.1) return "large";
130 return "default";
131};
132
133const IOS_TOGGLE_OPTIONS: Record<string, string> = {
134 reduceMotion: "reduce-motion",
135 increaseContrast: "increase-contrast",
136 reduceTransparency: "reduce-transparency",
137 showBorders: "show-borders",
138 voiceOver: "voiceover",
139};
140
141// serve-sim permission names -> the TCC service or simctl privacy service.
142const IOS_TCC_SERVICES: Record<string, string> = {
143 camera: "camera",
144 microphone: "microphone",
145 photos: "photos",
146 contacts: "contacts",
147 calendar: "calendar",
148 reminders: "reminders",
149 motion: "motion",
150 "media-library": "media-library",
151 faceid: "faceid",
152};
153
154const ANDROID_PERMISSIONS: Record<string, ReadonlyArray<string>> = {
155 camera: ["android.permission.CAMERA"],
156 microphone: ["android.permission.RECORD_AUDIO"],
157 photos: ["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"],
158 contacts: ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"],
159 calendar: ["android.permission.READ_CALENDAR", "android.permission.WRITE_CALENDAR"],
160 location: [
161 "android.permission.ACCESS_FINE_LOCATION",
162 "android.permission.ACCESS_COARSE_LOCATION",
163 ],
164 notifications: ["android.permission.POST_NOTIFICATIONS"],
165 motion: ["android.permission.ACTIVITY_RECOGNITION"],
166};
167
168// Gravity vector (x:y:z) that makes the emulator report each orientation,
169// and the window-manager rotation index for the same.
170const ANDROID_GRAVITY: Record<DeviceOrientation, string> = {
171 portrait: "0:9.81:0",
172 landscape_left: "9.81:0:0",
173 portrait_upside_down: "0:-9.81:0",
174 landscape_right: "-9.81:0:0",
175};
176const ANDROID_ROTATION: Record<DeviceOrientation, string> = {
177 portrait: "0",
178 landscape_left: "1",
179 portrait_upside_down: "2",
180 landscape_right: "3",
181};
182
183export const runDeviceAction = Effect.fn("DeviceActions.run")(function* (
184 ready: DeviceHostReady,
185 platform: DevicePlatform,
186 input: DeviceActionInput,
187) {
188 if (!supportsAction(platform, input)) {
189 return yield* new DeviceActionUnavailableError({
190 operation: input.type,
191 platform,
192 reason: "unsupported",
193 });
194 }
195 if (platform === "ios") return yield* runIos(ready, input);
196 return yield* runAndroid(ready.run, input);
197});
198
199const simctl = (run: Runner, udid: string, args: ReadonlyArray<string>, operation: string) =>
200 run("xcrun", ["simctl", ...args.slice(0, 1), udid, ...args.slice(1)]).pipe(
201 Effect.flatMap(ok(operation)),
202 );
203
204const axSettings = (ready: DeviceHostReady, udid: string, args: ReadonlyArray<string>) =>
205 Effect.gen(function* () {
206 const helper = ready.helpers.serveSimAxSettings;
207 if (!helper) {
208 return yield* new DeviceActionUnavailableError({
209 operation: "accessibility",
210 platform: "ios",
211 reason: "helper_missing",
212 });
213 }
214 return yield* ready
215 .run("xcrun", ["simctl", "spawn", udid, helper, ...args])
216 .pipe(Effect.flatMap(ok("accessibility")));
217 });
218
219const runIos = Effect.fn("DeviceActions.runIos")(function* (
220 ready: DeviceHostReady,
221 input: DeviceActionInput,
222) {
223 const udid = input.deviceId;
224 const { run } = ready;
225 switch (input.type) {
226 case "setAppearance":
227 yield* simctl(run, udid, ["ui", "appearance", input.value], "appearance");
228 return;
229 case "setTextSize":
230 yield* simctl(run, udid, ["ui", "content_size", IOS_TEXT_SIZES[input.value]], "text size");
231 return;
232 case "setToggle":
233 if (input.setting === "increaseContrast") {
234 yield* simctl(
235 run,
236 udid,
237 ["ui", "increase_contrast", input.value ? "enabled" : "disabled"],
238 "increase contrast",
239 );
240 return;
241 }
242 yield* axSettings(ready, udid, [
243 "set",
244 IOS_TOGGLE_OPTIONS[input.setting]!,
245 input.value ? "on" : "off",
246 ]);
247 return;
248 case "setLiquidGlass":
249 yield* axSettings(ready, udid, ["set", "liquid-glass", input.value]);
250 return;
251 case "setColorFilter":
252 yield* axSettings(ready, udid, ["set", "color-filter", input.value]);
253 return;
254 case "setLocation":
255 yield* simctl(
256 run,
257 udid,
258 ["location", "set", `${input.latitude},${input.longitude}`],
259 "location",
260 );
261 return;
262 case "clearLocation":
263 yield* simctl(run, udid, ["location", "clear"], "location");
264 return;
265 case "setPermission": {
266 if (input.permission === "notifications") {
267 // simctl has no notification permission verb; serve-sim's CLI edits
268 // the BulletinBoard plist for it.
269 yield* serveSimPermissions(ready, udid, input);
270 return;
271 }
272 const service =
273 input.permission === "location" ? "location" : IOS_TCC_SERVICES[input.permission];
274 if (!service)
275 return yield* new DeviceActionUnavailableError({
276 operation: "permission",
277 platform: "ios",
278 reason: "unsupported",
279 });
280 yield* simctl(run, udid, ["privacy", input.decision, service, input.appId], "permission");
281 return;
282 }
283 case "openUrl":
284 yield* simctl(run, udid, ["openurl", input.url], "open url");
285 return;
286 case "launchApp":
287 yield* simctl(run, udid, ["launch", input.appId], "launch");
288 return;
289 case "terminateApp":
290 yield* simctl(run, udid, ["terminate", input.appId], "terminate");
291 return;
292 case "sendPush": {
293 const payload =
294 typeof input.payload === "string" ? { aps: { alert: input.payload } } : input.payload;
295 const encoded = yield* encodePushPayload(payload).pipe(
296 Effect.mapError(
297 (cause) =>
298 new DeviceOperationError({ operation: "push", reason: "invalid_payload", cause }),
299 ),
300 );
301 yield* run("xcrun", ["simctl", "push", udid, input.appId, "-"], { stdin: encoded }).pipe(
302 Effect.flatMap(ok("push")),
303 );
304 return;
305 }
306 case "shake":
307 case "setOrientation":
308 return yield* new DeviceActionUnavailableError({
309 operation: input.type,
310 platform: "ios",
311 reason: "unsupported",
312 });
313 }
314});
315
316const serveSimPermissions = (
317 ready: DeviceHostReady,
318 udid: string,
319 input: Extract<DeviceActionInput, { type: "setPermission" }>,
320) =>
321 Effect.gen(function* () {
322 const cli = ready.helpers.serveSimCli;
323 if (!cli)
324 return yield* new DeviceActionUnavailableError({
325 operation: "permission",
326 platform: "ios",
327 reason: "helper_missing",
328 });
329 yield* ready
330 .run(process.execPath, [
331 cli,
332 "permissions",
333 input.decision,
334 input.permission,
335 input.appId,
336 "-d",
337 udid,
338 ])
339 .pipe(Effect.flatMap(ok("permission")));
340 });
341
342const adb = (run: Runner, serial: string, args: ReadonlyArray<string>, operation: string) =>
343 run("adb", ["-s", serial, ...args]).pipe(Effect.flatMap(ok(operation)));
344
345const runAndroid = Effect.fn("DeviceActions.runAndroid")(function* (
346 run: Runner,
347 input: DeviceActionInput,
348) {
349 const serial = input.deviceId;
350 const shell = (args: ReadonlyArray<string>, operation: string) =>
351 adb(run, serial, ["shell", ...args], operation);
352 switch (input.type) {
353 case "setAppearance":
354 yield* shell(["cmd", "uimode", "night", input.value === "dark" ? "yes" : "no"], "appearance");
355 return;
356 case "setTextSize":
357 yield* shell(
358 ["settings", "put", "system", "font_scale", ANDROID_TEXT_SIZES[input.value]],
359 "text size",
360 );
361 return;
362 case "setToggle":
363 if (input.setting === "networkEnabled") {
364 const state = input.value ? "enable" : "disable";
365 yield* shell(["svc", "wifi", state], "network");
366 yield* shell(["svc", "data", state], "network");
367 return;
368 }
369 if (input.setting === "reduceMotion") {
370 const scale = input.value ? "0" : "1";
371 for (const key of [
372 "animator_duration_scale",
373 "transition_animation_scale",
374 "window_animation_scale",
375 ]) {
376 yield* shell(["settings", "put", "global", key, scale], "reduce motion");
377 }
378 return;
379 }
380 return yield* new DeviceActionUnavailableError({
381 operation: input.type,
382 platform: "android",
383 reason: "unsupported",
384 });
385 case "setOrientation": {
386 // `user-rotation lock` only rotates window content on recent images;
387 // the display the encoder captures stays put. Tilting the emulator's
388 // accelerometer rotates it for real, so that is used whenever the
389 // target is an emulator. Physical devices get the lock.
390 if (serial.startsWith("emulator-")) {
391 yield* shell(["settings", "put", "system", "accelerometer_rotation", "1"], "orientation");
392 yield* shell(["cmd", "window", "user-rotation", "free"], "orientation");
393 yield* adb(
394 run,
395 serial,
396 ["emu", "sensor", "set", "acceleration", ANDROID_GRAVITY[input.value]],
397 "orientation",
398 );
399 return;
400 }
401 yield* shell(
402 ["cmd", "window", "user-rotation", "lock", ANDROID_ROTATION[input.value]],
403 "orientation",
404 );
405 return;
406 }
407 case "setLocation":
408 yield* adb(
409 run,
410 serial,
411 ["emu", "geo", "fix", String(input.longitude), String(input.latitude)],
412 "location",
413 );
414 return;
415 case "clearLocation":
416 // The emulator has no "clear"; leaving the fix in place is the closest
417 // behavior, so this is a no-op that still refreshes the reading.
418 return;
419 case "setPermission": {
420 const permissions = ANDROID_PERMISSIONS[input.permission];
421 if (!permissions) {
422 return yield* new DeviceActionUnavailableError({
423 operation: "permission",
424 platform: "android",
425 reason: "unsupported",
426 });
427 }
428 const verb = input.decision === "grant" ? "grant" : "revoke";
429 for (const permission of permissions) {
430 // Not every app declares every permission in a group; ignore those.
431 yield* shell(["pm", verb, input.appId, permission], "permission").pipe(Effect.ignore);
432 }
433 return;
434 }
435 case "openUrl":
436 yield* shell(
437 ["am", "start", "-a", "android.intent.action.VIEW", "-d", input.url],
438 "open url",
439 );
440 return;
441 case "launchApp":
442 yield* shell(
443 ["monkey", "-p", input.appId, "-c", "android.intent.category.LAUNCHER", "1"],
444 "launch",
445 );
446 return;
447 case "terminateApp":
448 yield* shell(["am", "force-stop", input.appId], "terminate");
449 return;
450 case "setLiquidGlass":
451 case "setColorFilter":
452 case "shake":
453 case "sendPush":
454 return yield* new DeviceActionUnavailableError({
455 operation: input.type,
456 platform: "android",
457 reason: "unsupported",
458 });
459 }
460});
461
462/** Read the current settings and foreground app. Errors degrade to unknowns. */
463export const readDeviceDetail = Effect.fn("DeviceActions.readDetail")(function* (
464 ready: DeviceHostReady,
465 platform: DevicePlatform,
466 deviceId: string,
467): Effect.fn.Return<{ settings: DeviceSettings; foregroundApp: DeviceForegroundApp | null }> {
468 return platform === "ios"
469 ? yield* readIos(ready, deviceId)
470 : yield* readAndroid(ready.run, deviceId);
471});
472
473const quiet = <A, E>(effect: Effect.Effect<A, E>) =>
474 effect.pipe(Effect.orElseSucceed((): A | undefined => undefined));
475
476const readIos = Effect.fn("DeviceActions.readIos")(function* (
477 ready: DeviceHostReady,
478 udid: string,
479) {
480 const { run } = ready;
481 const uiValue = (option: string) =>
482 quiet(
483 simctl(run, udid, ["ui", option], option).pipe(Effect.map((out) => out.trim().toLowerCase())),
484 );
485 const [appearance, contentSize, contrast, axStatus] = yield* Effect.all(
486 [
487 uiValue("appearance"),
488 uiValue("content_size"),
489 uiValue("increase_contrast"),
490 quiet(axSettings(ready, udid, ["status"]).pipe(Effect.flatMap(decodeAxStatus))),
491 ],
492 { concurrency: 4 },
493 );
494 const onOff = (value: string | undefined) =>
495 value === "on" ? true : value === "off" ? false : undefined;
496 const settings: DeviceSettings = {
497 ...(appearance === "light" || appearance === "dark" ? { appearance } : {}),
498 ...(contentSize ? { textSize: textSizeFromIos(contentSize) } : {}),
499 ...(contrast ? { increaseContrast: contrast === "enabled" } : {}),
500 ...(axStatus
501 ? {
502 ...(onOff(axStatus["reduce-motion"]) === undefined
503 ? {}
504 : { reduceMotion: onOff(axStatus["reduce-motion"]) }),
505 ...(onOff(axStatus["reduce-transparency"]) === undefined
506 ? {}
507 : { reduceTransparency: onOff(axStatus["reduce-transparency"]) }),
508 ...(onOff(axStatus["show-borders"]) === undefined
509 ? {}
510 : { showBorders: onOff(axStatus["show-borders"]) }),
511 ...(onOff(axStatus.voiceover) === undefined
512 ? {}
513 : { voiceOver: onOff(axStatus.voiceover) }),
514 ...(axStatus["liquid-glass"] === "clear" || axStatus["liquid-glass"] === "tinted"
515 ? { liquidGlass: axStatus["liquid-glass"] }
516 : {}),
517 ...(isColorFilter(axStatus["color-filter"])
518 ? { colorFilter: axStatus["color-filter"] }
519 : {}),
520 }
521 : {}),
522 };
523 return { settings, foregroundApp: null };
524});
525
526const isColorFilter = (value: unknown): value is DeviceSettings["colorFilter"] & string =>
527 value === "none" ||
528 value === "grayscale" ||
529 value === "red-green" ||
530 value === "green-red" ||
531 value === "blue-yellow";
532
533const readAndroid = Effect.fn("DeviceActions.readAndroid")(function* (run: Runner, serial: string) {
534 const shell = (args: ReadonlyArray<string>) =>
535 quiet(
536 adb(run, serial, ["shell", ...args], args[0] ?? "shell").pipe(Effect.map((s) => s.trim())),
537 );
538 const [night, fontScale, animator, wifi, focus] = yield* Effect.all(
539 [
540 shell(["cmd", "uimode", "night"]),
541 shell(["settings", "get", "system", "font_scale"]),
542 shell(["settings", "get", "global", "animator_duration_scale"]),
543 shell(["settings", "get", "global", "wifi_on"]),
544 // `dumpsys window windows` stopped printing the focus on API 36; the
545 // unfiltered dump still does.
546 shell(["dumpsys", "window"]),
547 ],
548 { concurrency: 5 },
549 );
550 const scale = fontScale && fontScale !== "null" ? Number(fontScale) : Number.NaN;
551 const focused = focus?.match(/m(?:CurrentFocus|FocusedApp)=\w+\{[^ ]+ u\d+ ([^/ ]+)\//);
552 const settings: DeviceSettings = {
553 ...(night?.includes("yes")
554 ? { appearance: "dark" }
555 : night?.includes("no")
556 ? { appearance: "light" }
557 : {}),
558 ...(Number.isFinite(scale) ? { textSize: textSizeFromAndroid(scale) } : {}),
559 ...(animator !== undefined && animator !== "null"
560 ? { reduceMotion: Number(animator) === 0 }
561 : {}),
562 ...(wifi === "1" || wifi === "0" ? { networkEnabled: wifi === "1" } : {}),
563 };
564 return {
565 settings,
566 foregroundApp: focused ? { id: focused[1]! } : null,
567 };
568});
apps/server/src/device/DeviceHost.ts created+102
...@@ -0,0 +1,102 @@
1/**
2 * A device host is a machine with simulators or emulators on it. The service
3 * layer only ever talks to this interface, so a future SSH or cloud host slots
4 * in beside `LocalDeviceHost` without touching discovery, the proxy, or the
5 * MCP tools.
6 *
7 * Every ready host presents a loopback origin where expo-device-hub answers.
8 * Hosts add an agent-device daemon endpoint only after agent access is granted.
9 * For the local host both run on this machine; a remote host would forward
10 * them here.
11 */
12import type {
13 DeviceHostId,
14 DeviceHostSummary,
15 DevicePlatform,
16 DevicePlatformAvailability,
17} from "@t3tools/contracts";
18import * as Context from "effect/Context";
19import * as Effect from "effect/Effect";
20import * as Schema from "effect/Schema";
21
22export class DeviceHostError extends Schema.TaggedError<DeviceHostError>()("DeviceHostError", {
23 hostId: Schema.String,
24 step: Schema.String,
25 cause: Schema.Defect(),
26}) {
27 override get message(): string {
28 return `Device host ${this.hostId} failed while ${this.step}.`;
29 }
30}
31
32export class DeviceHostTimeoutError extends Schema.TaggedError<DeviceHostTimeoutError>()(
33 "DeviceHostTimeoutError",
34 { hostId: Schema.String, timeoutMs: Schema.Number },
35) {
36 override get message(): string {
37 return `Device host ${this.hostId} did not start agent tools within ${this.timeoutMs} ms.`;
38 }
39}
40
41export interface DeviceHubEndpoint {
42 /** Loopback origin of expo-device-hub, e.g. `http://127.0.0.1:3400`. */
43 readonly origin: string;
44}
45
46export interface AgentDeviceEndpoint {
47 readonly baseUrl: string;
48 readonly token: string;
49 /** Absolute path of the agent-device entry script for the provider PATH shim. */
50 readonly entryPath: string;
51}
52
53export interface DeviceHostReady {
54 readonly hub: DeviceHubEndpoint;
55 /**
56 * Runs a host command (`xcrun`, `adb`, or a helper bundled with the hub)
57 * where the devices live. On the local host this is a plain spawn; a
58 * remote host would run it over its transport.
59 */
60 readonly run: (
61 command: string,
62 args: ReadonlyArray<string>,
63 options?: { readonly timeoutMs?: number; readonly stdin?: string },
64 ) => Effect.Effect<{ readonly stdout: string; readonly stderr: string; readonly code: number }>;
65 /** Absolute paths of helper binaries vendored with the hub, when present. */
66 readonly helpers: {
67 readonly serveSimAxSettings: string | null;
68 readonly serveSimCli: string | null;
69 };
70}
71
72export interface DeviceHostAgentReady extends DeviceHostReady {
73 readonly agentDevice: AgentDeviceEndpoint;
74}
75
76export class DeviceHost extends Context.Service<
77 DeviceHost,
78 {
79 readonly id: DeviceHostId;
80 readonly summary: Effect.Effect<DeviceHostSummary>;
81 readonly platformAvailability: (
82 platform: DevicePlatform,
83 ) => Effect.Effect<DevicePlatformAvailability>;
84 /**
85 * Installs tools on first use and starts the helper processes. Idempotent:
86 * concurrent callers share one start, and a ready host returns immediately.
87 */
88 readonly ensureReady: (
89 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
90 ) => Effect.Effect<DeviceHostReady, DeviceHostError>;
91 /** Installs and starts agent-device after the user grants agent access. */
92 readonly ensureAgentReady: (
93 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
94 ) => Effect.Effect<DeviceHostAgentReady, DeviceHostError | DeviceHostTimeoutError>;
95 /** Current endpoints when already running, without starting anything. */
96 readonly current: Effect.Effect<DeviceHostReady | null>;
97 /** Stops only agent-device. Manual viewing through the hub stays available. */
98 readonly stopAgent: Effect.Effect<void>;
99 /** Stops helpers. Devices themselves keep running; the user owns those. */
100 readonly stop: Effect.Effect<void>;
101 }
102>()("t3/device/DeviceHost") {}
apps/server/src/device/DeviceHubProxy.test.ts created+141
...@@ -0,0 +1,141 @@
1import { afterEach, describe, expect, it } from "vite-plus/test";
2import {
3 AuthOrchestrationReadScope,
4 AuthOrchestrationOperateScope,
5 AuthSessionId,
6 LOCAL_DEVICE_HOST_ID,
7 type AuthEnvironmentScope,
8} from "@t3tools/contracts";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import { HttpClient, HttpClientResponse, HttpRouter } from "effect/unstable/http";
12import {
13 EnvironmentAuth,
14 ServerAuthMissingCredentialError,
15 ServerAuthSessionCredentialValidationError,
16 type ServerAuthCredentialError,
17 type ServerAuthInternalError,
18} from "../auth/EnvironmentAuth.ts";
19import { DeviceService } from "./DeviceService.ts";
20import { deviceHubProxyRouteLayer } from "./DeviceHubProxy.ts";
21
22const disposers: Array<() => Promise<void>> = [];
23afterEach(async () => {
24 for (const dispose of disposers.splice(0)) await dispose();
25});
26
27const fixture = (
28 scopes: ReadonlyArray<AuthEnvironmentScope>,
29 fail = false,
30 authError?: ServerAuthCredentialError | ServerAuthInternalError,
31) => {
32 let finalized = 0;
33 const requests: string[] = [];
34 const client = HttpClient.make((request, _url, signal) =>
35 Effect.gen(function* () {
36 requests.push(request.url);
37 signal.addEventListener("abort", () => {
38 finalized++;
39 });
40 if (fail) return yield* Effect.die(new Error("upstream failed"));
41 return HttpClientResponse.fromWeb(request, new Response("frame"));
42 }),
43 );
44 const { handler, dispose } = HttpRouter.toWebHandler(
45 deviceHubProxyRouteLayer.pipe(
46 Layer.provideMerge(
47 Layer.succeed(EnvironmentAuth, {
48 authenticateWebSocketUpgrade: () =>
49 authError
50 ? Effect.fail(authError)
51 : Effect.succeed({
52 sessionId: AuthSessionId.make("test"),
53 subject: "test",
54 method: "bearer-access-token",
55 scopes,
56 }),
57 } as unknown as EnvironmentAuth["Service"]),
58 ),
59 Layer.provideMerge(
60 Layer.succeed(DeviceService, {
61 currentReadiness: () =>
62 Effect.succeed({ hostId: LOCAL_DEVICE_HOST_ID, hub: { origin: "http://hub.test" } }),
63 } as DeviceService["Service"]),
64 ),
65 Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client)),
66 ),
67 { disableLogger: true },
68 );
69 disposers.push(dispose);
70 return { handler, requests, finalized: () => finalized };
71};
72
73describe("device hub proxy", () => {
74 it("releases the upstream response after forwarding its body and strips tickets", async () => {
75 const { handler, requests, finalized } = fixture([AuthOrchestrationReadScope]);
76 const response = await handler(
77 new Request("http://t3.test/api/device-hub/api/devices?wsTicket=secret"),
78 );
79 expect(response.status).toBe(200);
80 expect(await response.text()).toBe("frame");
81 expect(requests).toEqual(["http://hub.test/api/devices"]);
82 expect(finalized()).toBe(1);
83 });
84
85 it("releases resources when upstream acquisition fails", async () => {
86 const { handler, finalized } = fixture([AuthOrchestrationReadScope], true);
87 const response = await handler(new Request("http://t3.test/api/device-hub/api/devices"));
88 expect(response.status).toBe(500);
89 expect(finalized()).toBe(1);
90 });
91
92 it.each(["/vendor/serve-sim/helper/ws", "/vendor/serve-emu/ws"])(
93 "rejects input socket %s for a read-only session",
94 async (path) => {
95 const { handler, requests } = fixture([AuthOrchestrationReadScope]);
96 const response = await handler(
97 new Request(`http://t3.test/api/device-hub${path}`, { headers: { upgrade: "websocket" } }),
98 );
99 expect(response.status).toBe(403);
100 expect(requests).toEqual([]);
101 },
102 );
103
104 it("requires operate scope for stream tuning", async () => {
105 const readOnly = fixture([AuthOrchestrationReadScope]);
106 const path = "http://t3.test/api/device-hub/vendor/serve-emu/api/stream-settings";
107 expect((await readOnly.handler(new Request(path, { method: "POST" }))).status).toBe(403);
108 const operator = fixture([AuthOrchestrationOperateScope]);
109 const response = await operator.handler(new Request(path, { method: "POST" }));
110 expect(response.status).toBe(200);
111 await response.text();
112 });
113
114 it("never forwards the vendor shell endpoint", async () => {
115 const { handler, requests } = fixture([AuthOrchestrationOperateScope]);
116 expect(
117 (
118 await handler(
119 new Request("http://t3.test/api/device-hub/vendor/serve-sim/exec", { method: "POST" }),
120 )
121 ).status,
122 ).toBe(404);
123 expect(requests).toEqual([]);
124 });
125});
126
127it.each([
128 [new ServerAuthMissingCredentialError({}), 401],
129 [
130 new ServerAuthSessionCredentialValidationError({
131 cause: new Error("private credential diagnostic"),
132 }),
133 500,
134 ],
135] as const)("translates authentication failure to HTTP %s", async (error, status) => {
136 const { handler, requests } = fixture([], false, error);
137 const response = await handler(new Request("http://t3.test/api/device-hub/api/devices"));
138 expect(response.status).toBe(status);
139 expect(await response.text()).not.toContain("private credential diagnostic");
140 expect(requests).toEqual([]);
141});
apps/server/src/device/DeviceHubProxy.ts created+224
...@@ -0,0 +1,224 @@
1/**
2 * Same-origin proxy in front of expo-device-hub.
3 *
4 * The hub binds loopback and is never reachable directly: serve-sim exposes a
5 * shell-exec route and serve-emu's action routes are unauthenticated, so the
6 * only way to a device stream is through this route, which requires an
7 * environment session with read scope (operate scope for input and tuning). Reusing the T3
8 * origin is also what makes remote connections work unchanged — Tailscale and
9 * T3 Connect already carry `/api/*` and WebSocket upgrades for the app itself.
10 *
11 * Only the routes the Device panel needs are forwarded. Anything under the
12 * hub's dashboard, exec, or WebRTC surface is rejected here.
13 */
14import {
15 AuthOrchestrationReadScope,
16 AuthOrchestrationOperateScope,
17 type AuthEnvironmentScope,
18} from "@t3tools/contracts";
19import * as Effect from "effect/Effect";
20import * as Option from "effect/Option";
21import {
22 HttpClient,
23 HttpClientRequest,
24 HttpRouter,
25 HttpServerRequest,
26 HttpServerResponse,
27} from "effect/unstable/http";
28import * as Socket from "effect/unstable/socket/Socket";
29import * as NodeSocket from "@effect/platform-node/NodeSocket";
30
31import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
32import {
33 failEnvironmentAuthInvalid,
34 failEnvironmentInternal,
35 failEnvironmentScopeRequired,
36} from "../auth/http.ts";
37import * as DeviceService from "./DeviceService.ts";
38
39const ALLOWED_PATHS: ReadonlyArray<RegExp> = [
40 /^\/api\/devices$/,
41 /^\/vendor\/serve-sim\/api$/,
42 /^\/vendor\/serve-sim\/api\/screenshot$/,
43 /^\/vendor\/serve-sim\/api\/event-log(\/events)?$/,
44 /^\/vendor\/serve-sim\/helper\/[^/]+\/(stream\.mjpeg|stream\.avcc|config|health|ax|foreground)$/,
45 /^\/vendor\/serve-sim\/appstate$/,
46 /^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility)$/,
47 /^\/vendor\/serve-emu\/health$/,
48];
49
50/** Read paths are GET-only; only these accept other methods (screenshot captures, stream tuning). */
51const MUTABLE_PATHS: ReadonlyArray<RegExp> = [
52 /^\/vendor\/serve-sim\/api\/screenshot$/,
53 /^\/vendor\/serve-emu\/api\/(screenshot|stream-mode|stream-settings)$/,
54];
55
56const ALLOWED_WS_PATHS: ReadonlyArray<RegExp> = [
57 /^\/api\/devices\/ws$/,
58 /^\/vendor\/serve-sim\/helper\/ws$/,
59 /^\/vendor\/serve-emu\/ws$/,
60];
61
62/** Hop-by-hop and origin headers that must not cross the proxy. */
63const DROPPED_REQUEST_HEADERS = new Set([
64 "host",
65 "connection",
66 "upgrade",
67 "sec-websocket-key",
68 "sec-websocket-version",
69 "sec-websocket-extensions",
70 "sec-websocket-protocol",
71 "cookie",
72 "authorization",
73 "dpop",
74 "content-length",
75 "accept-encoding",
76]);
77
78const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) =>
79 request.headers.upgrade?.toLowerCase() === "websocket";
80
81/**
82 * `<img>` and WebSocket cannot set headers, so every proxied request
83 * authenticates the way the `/ws` upgrade does: a cookie for browser
84 * sessions, or a short-lived `wsTicket` minted over authenticated HTTP for
85 * bearer and DPoP clients. The upgrade authenticator already implements that
86 * fallback order, so it is used for plain requests as well.
87 */
88const authenticate = (requiredScope: AuthEnvironmentScope) =>
89 Effect.gen(function* () {
90 const request = yield* HttpServerRequest.HttpServerRequest;
91 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
92 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
93 Effect.catch((error) =>
94 Effect.gen(function* () {
95 if (EnvironmentAuth.isServerAuthCredentialError(error)) {
96 return yield* failEnvironmentAuthInvalid(
97 EnvironmentAuth.serverAuthCredentialReason(error),
98 EnvironmentAuth.serverAuthDpopFailureReason(error),
99 );
100 }
101 return yield* failEnvironmentInternal("internal_error", error);
102 }),
103 ),
104 );
105 if (!session.scopes.includes(requiredScope)) {
106 return yield* failEnvironmentScopeRequired(requiredScope);
107 }
108 });
109
110const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, origin: string) => {
111 const headers: Record<string, string> = {};
112 for (const [name, value] of Object.entries(request.headers)) {
113 if (DROPPED_REQUEST_HEADERS.has(name) || value === undefined) continue;
114 headers[name] = value;
115 }
116 // serve-emu refuses mutations whose Origin differs from the request origin.
117 if (request.headers.origin !== undefined) headers.origin = origin;
118 return headers;
119};
120
121/**
122 * Pipe a client WebSocket to the hub's with no framing changes. Frames are
123 * opaque: H.264 access units one way, input packets the other.
124 */
125const proxyWebSocket = Effect.fn("DeviceHubProxy.proxyWebSocket")(function* (
126 request: HttpServerRequest.HttpServerRequest,
127 upstreamUrl: string,
128) {
129 const client = yield* request.upgrade;
130 const upstream = yield* Socket.makeWebSocket(upstreamUrl, {
131 openTimeout: "10 seconds",
132 }).pipe(Effect.provide(NodeSocket.layerWebSocketConstructor));
133 yield* Effect.scoped(
134 Effect.gen(function* () {
135 const writeToClient = yield* client.writer;
136 const writeToUpstream = yield* upstream.writer;
137 const downstream = upstream.runRaw((data) => writeToClient(data));
138 const upstreamPump = client.runRaw((data) => writeToUpstream(data));
139 // Whichever side closes first ends the other via scope teardown.
140 yield* Effect.raceFirst(downstream, upstreamPump);
141 }),
142 ).pipe(Effect.catchCause(() => Effect.void));
143 return HttpServerResponse.empty();
144});
145
146const proxyHttp = Effect.fn("DeviceHubProxy.proxyHttp")(function* (
147 request: HttpServerRequest.HttpServerRequest,
148 upstreamUrl: string,
149 hubOrigin: string,
150) {
151 const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient);
152 const method = request.method;
153 const upstreamRequest = HttpClientRequest.make(method)(upstreamUrl).pipe(
154 HttpClientRequest.setHeaders(forwardHeaders(request, hubOrigin)),
155 method === "GET" || method === "HEAD"
156 ? (self) => self
157 : HttpClientRequest.bodyStream(request.stream),
158 );
159 const response = yield* httpClient.execute(upstreamRequest);
160 const headers: Record<string, string> = {};
161 for (const [name, value] of Object.entries(response.headers)) {
162 if (name === "content-encoding" || name === "transfer-encoding" || name === "connection") {
163 continue;
164 }
165 if (value !== undefined) headers[name] = value;
166 }
167 // Long-lived MJPEG and AVCC responses must not be buffered by compression.
168 headers["cache-control"] = "no-store, no-transform";
169 return HttpServerResponse.stream(response.stream, {
170 status: response.status,
171 headers,
172 ...(headers["content-type"] ? { contentType: headers["content-type"] } : {}),
173 });
174});
175
176const handler = Effect.gen(function* () {
177 const request = yield* HttpServerRequest.HttpServerRequest;
178 const url = HttpServerRequest.toURL(request);
179 if (Option.isNone(url)) {
180 return HttpServerResponse.text("Bad Request", { status: 400 });
181 }
182 const hubPath = url.value.pathname.slice(DeviceService.DEVICE_HUB_ROUTE_PREFIX.length) || "/";
183 const upgrade = isWebSocketUpgrade(request);
184 const allowed = (upgrade ? ALLOWED_WS_PATHS : ALLOWED_PATHS).some((pattern) =>
185 pattern.test(hubPath),
186 );
187 if (!allowed) {
188 return HttpServerResponse.text("Not Found", { status: 404 });
189 }
190 const readOnly = request.method === "GET" || request.method === "HEAD";
191 if (!upgrade && !readOnly && !MUTABLE_PATHS.some((pattern) => pattern.test(hubPath))) {
192 return HttpServerResponse.text("Method Not Allowed", { status: 405 });
193 }
194 const controlsDevice =
195 (upgrade && hubPath !== "/api/devices/ws") ||
196 (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath));
197 yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope);
198 const devices = yield* DeviceService.DeviceService;
199 const ready = yield* devices.currentReadiness();
200 if (!ready) {
201 return HttpServerResponse.text("Device hub is not running", { status: 503 });
202 }
203 // The hub runs in standalone mode at its origin root; the panel builds every
204 // stream and socket URL itself, so nothing depends on the hub knowing the
205 // T3 prefix.
206 // The ticket authenticates here and must not travel on to the hub.
207 const upstreamSearch = new URLSearchParams(url.value.search);
208 upstreamSearch.delete("wsTicket");
209 const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : "";
210 const upstreamPath = `${hubPath}${search}`;
211 if (upgrade) {
212 return yield* proxyWebSocket(
213 request,
214 `${ready.hub.origin.replace(/^http/, "ws")}${upstreamPath}`,
215 );
216 }
217 return yield* proxyHttp(request, `${ready.hub.origin}${upstreamPath}`, ready.hub.origin);
218});
219
220export const deviceHubProxyRouteLayer = HttpRouter.add(
221 "*",
222 `${DeviceService.DEVICE_HUB_ROUTE_PREFIX}/*`,
223 handler,
224);
apps/server/src/device/DeviceService.test.ts created+337
...@@ -0,0 +1,337 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 DEFAULT_SERVER_SETTINGS,
4 DeviceId,
5 LOCAL_DEVICE_HOST_ID,
6 ThreadId,
7 type DeviceServiceState,
8} from "@t3tools/contracts";
9import * as Effect from "effect/Effect";
10import * as Deferred from "effect/Deferred";
11import * as Fiber from "effect/Fiber";
12import * as PubSub from "effect/PubSub";
13import * as Ref from "effect/Ref";
14import * as Stream from "effect/Stream";
15import { HttpClient, HttpClientResponse } from "effect/unstable/http";
16import { ServerSettingsService } from "../serverSettings.ts";
17import * as DeviceHost from "./DeviceHost.ts";
18
19import { type DeviceService, make, stateStream } from "./DeviceService.ts";
20
21const baseState: DeviceServiceState = {
22 hosts: [],
23 hostStatus: "idle",
24 devices: [],
25 sessions: [],
26 onboardingCompleted: false,
27 agentAccessEnabled: false,
28 hubBasePath: "/api/device-hub",
29 revision: 0,
30};
31
32describe("DeviceService.stateStream", () => {
33 it.effect("emits the current snapshot and then every published change", () =>
34 Effect.gen(function* () {
35 const pubsub = yield* PubSub.unbounded<DeviceServiceState>();
36 const current = yield* Ref.make(baseState);
37 const service: Pick<DeviceService["Service"], "state" | "subscribe"> = {
38 state: Ref.get(current),
39 subscribe: PubSub.subscribe(pubsub),
40 };
41
42 const collected = yield* stateStream(service as DeviceService["Service"]).pipe(
43 Stream.take(3),
44 Stream.runCollect,
45 Effect.forkChild,
46 );
47 yield* Effect.yieldNow;
48 for (const revision of [1, 2]) {
49 const next = { ...baseState, revision, hostStatus: "ready" as const };
50 yield* Ref.set(current, next);
51 yield* PubSub.publish(pubsub, next);
52 }
53 const seen = yield* Fiber.join(collected);
54 expect(seen.map((state) => state.revision)).toEqual([0, 1, 2]);
55 }),
56 );
57});
58
59const fixture = Effect.fn("fixture")(function* (
60 onBoot: Effect.Effect<void> = Effect.void,
61 bootError?: string,
62 failListAfterShutdown = false,
63) {
64 const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS);
65 const starts: string[] = [];
66 const agentStarts: string[] = [];
67 const agentStops: string[] = [];
68 const requests: string[] = [];
69 let booted = false;
70 let shutDown = false;
71 const ready: DeviceHost.DeviceHostReady = {
72 hub: { origin: "http://device.test" },
73 helpers: { serveSimAxSettings: null, serveSimCli: null },
74 run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }),
75 };
76 const host: DeviceHost.DeviceHost["Service"] = {
77 id: LOCAL_DEVICE_HOST_ID,
78 summary: Effect.succeed({
79 id: LOCAL_DEVICE_HOST_ID,
80 kind: "local",
81 label: "Test server",
82 platforms: [{ platform: "android", available: true }],
83 hubInstalled: true,
84 agentDeviceInstalled: false,
85 }),
86 platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
87 ensureReady: (onPhase) =>
88 Effect.gen(function* () {
89 starts.push("start");
90 yield* onPhase("starting");
91 return ready;
92 }),
93 ensureAgentReady: (onPhase) =>
94 Effect.gen(function* () {
95 agentStarts.push("start");
96 yield* onPhase("starting");
97 return {
98 ...ready,
99 agentDevice: { baseUrl: "http://agent.test", token: "test", entryPath: "/agent" },
100 };
101 }),
102 current: Effect.succeed(null),
103 stopAgent: Effect.sync(() => {
104 agentStops.push("stop");
105 }),
106 stop: Effect.sync(() => {
107 starts.push("stop");
108 }),
109 };
110 const service = yield* make.pipe(
111 Effect.provideService(DeviceHost.DeviceHost, host),
112 Effect.provideService(
113 ServerSettingsService,
114 ServerSettingsService.of({
115 start: Effect.void,
116 ready: Effect.void,
117 getSettings: Ref.get(settings),
118 updateSettings: (patch) =>
119 Ref.updateAndGet(settings, (current) => ({
120 ...current,
121 enableDeviceSupport: patch.enableDeviceSupport ?? current.enableDeviceSupport,
122 enableAgentDeviceAccess:
123 patch.enableAgentDeviceAccess ?? current.enableAgentDeviceAccess,
124 deviceOnboardingCompleted:
125 patch.deviceOnboardingCompleted ?? current.deviceOnboardingCompleted,
126 })),
127 streamChanges: Stream.empty,
128 subscribeChanges: Effect.succeed(Stream.empty),
129 }),
130 ),
131 Effect.provideService(
132 HttpClient.HttpClient,
133 HttpClient.make((request) =>
134 Effect.gen(function* () {
135 requests.push(request.url);
136 if (request.url.includes("/api/screenshot")) {
137 return HttpClientResponse.fromWeb(
138 request,
139 new Response(new Uint8Array([137, 80, 78, 71])),
140 );
141 }
142 if (request.url.endsWith("/shutdown")) {
143 shutDown = true;
144 booted = false;
145 return HttpClientResponse.fromWeb(request, Response.json({ ok: true }));
146 }
147 if (shutDown && failListAfterShutdown) {
148 return HttpClientResponse.fromWeb(
149 request,
150 new Response("Discovery busy", { status: 503 }),
151 );
152 }
153 if (request.url.endsWith("/boot")) {
154 yield* onBoot;
155 booted = true;
156 return HttpClientResponse.fromWeb(
157 request,
158 Response.json(
159 bootError ? { ok: false, error: bootError } : { ok: true, serial: "emulator-5554" },
160 ),
161 );
162 }
163 return HttpClientResponse.fromWeb(
164 request,
165 Response.json({
166 simulators: [],
167 emulators: booted
168 ? [
169 {
170 id: "emulator-5554",
171 name: "Pixel_API_35",
172 platform: "android",
173 version: "Android 15",
174 booted: true,
175 physical: false,
176 },
177 ]
178 : [],
179 }),
180 );
181 }),
182 ),
183 ),
184 );
185 return { service, starts, agentStarts, agentStops, requests, settings };
186});
187
188describe("device setup consent", () => {
189 it.effect("listing and provider startup do not start helpers before consent", () =>
190 Effect.gen(function* () {
191 const { service, starts, requests } = yield* fixture();
192 expect((yield* service.list).hostStatus).toBe("disabled");
193 expect(yield* service.readinessIfSupported()).toBeNull();
194 const readiness = yield* service.readiness().pipe(Effect.result);
195 expect(readiness._tag).toBe("Failure");
196 expect(starts).toEqual([]);
197 expect(requests).toEqual([]);
198 }).pipe(Effect.scoped),
199 );
200
201 it.effect(
202 "explicit setup discovers never-booted AVDs; disabling stops helpers and blocks agents",
203 () =>
204 Effect.gen(function* () {
205 const { service, starts, settings } = yield* fixture();
206 const state = yield* service.configure({ enabled: true });
207 expect((yield* Ref.get(settings)).enableDeviceSupport).toBe(true);
208 expect(state.devices.map((device) => [device.id, device.booted])).toEqual([
209 ["Pixel_API_35", false],
210 ]);
211 expect(starts).toEqual(["start"]);
212 const disabled = yield* service.configure({ enabled: false });
213 expect(disabled.hostStatus).toBe("disabled");
214 expect(disabled.devices).toEqual([]);
215 expect((yield* Ref.get(settings)).enableDeviceSupport).toBe(false);
216 expect(yield* service.readinessIfSupported()).toBeNull();
217 expect(starts).toEqual(["start", "stop"]);
218 }).pipe(Effect.scoped),
219 );
220
221 it.effect("boots a stopped Android AVD and uses its emulator serial without duplicating it", () =>
222 Effect.gen(function* () {
223 const { service, requests } = yield* fixture();
224 yield* service.configure({ enabled: true });
225 const session = yield* service.open({
226 threadId: ThreadId.make("thread-1"),
227 deviceId: "Pixel_API_35",
228 platform: "android",
229 });
230 expect(session.deviceId).toBe("emulator-5554");
231 expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(1);
232 const state = yield* service.state;
233 expect(state.devices.map((device) => device.id)).toEqual(["emulator-5554"]);
234 expect(state.bootingDevices).toEqual([]);
235 }).pipe(Effect.scoped),
236 );
237
238 it.effect("installs agent support only after the separate agent permission", () =>
239 Effect.gen(function* () {
240 const { service, agentStarts, agentStops, settings } = yield* fixture();
241 yield* service.configure({ enabled: true });
242 expect(agentStarts).toEqual([]);
243 expect(yield* service.agentReadinessIfSupported()).toBeNull();
244
245 yield* service.configure({ agentAccessEnabled: true });
246 expect(agentStarts).toEqual(["start"]);
247 expect((yield* Ref.get(settings)).enableAgentDeviceAccess).toBe(true);
248 expect((yield* service.state).agentAccessEnabled).toBe(true);
249
250 yield* service.configure({ agentAccessEnabled: false, onboardingCompleted: true });
251 expect(agentStops).toEqual(["stop"]);
252 expect((yield* service.state).onboardingCompleted).toBe(true);
253 expect((yield* Ref.get(settings)).deviceOnboardingCompleted).toBe(true);
254 }).pipe(Effect.scoped),
255 );
256});
257
258it.effect("publishes boot progress and does not restore sessions after support is disabled", () =>
259 Effect.gen(function* () {
260 const started = yield* Deferred.make<void>();
261 const finish = yield* Deferred.make<void>();
262 const { service } = yield* fixture(
263 Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(finish))),
264 );
265 yield* service.configure({ enabled: true });
266 const opening = yield* service
267 .open({ threadId: ThreadId.make("thread-1"), deviceId: "Pixel_API_35", platform: "android" })
268 .pipe(Effect.result, Effect.forkChild);
269 yield* Deferred.await(started);
270 expect((yield* service.state).bootingDevices?.map((device) => device.name)).toEqual([
271 "Pixel_API_35",
272 ]);
273 yield* service.configure({ enabled: false });
274 yield* Deferred.succeed(finish, undefined);
275 expect((yield* Fiber.join(opening))._tag).toBe("Failure");
276 const state = yield* service.state;
277 expect(state.hostStatus).toBe("disabled");
278 expect(state.devices).toEqual([]);
279 expect(state.sessions).toEqual([]);
280 expect(state.bootingDevices).toEqual([]);
281 }).pipe(Effect.scoped),
282);
283
284describe("device discovery after server restart", () => {
285 it.effect("captures an explicit device before any client lists devices", () =>
286 Effect.gen(function* () {
287 const { service, settings, requests } = yield* fixture();
288 yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true }));
289 expect((yield* service.state).devices).toEqual([]);
290 const capture = yield* service.screenshot({ deviceId: DeviceId.make("Pixel_API_35") });
291 expect(capture.device.id).toBe("Pixel_API_35");
292 expect(Array.from(capture.png)).toEqual([137, 80, 78, 71]);
293 expect(requests.some((url) => url.endsWith("/api/devices"))).toBe(true);
294 }).pipe(Effect.scoped),
295 );
296});
297
298for (const [diagnostic, reason, message] of [
299 ["Insufficient disk space at /private/user/path", "disk_space", "not enough free disk space"],
300 ["Timed out spawning /private/user/command", "timeout", "did not become ready in time"],
301 ["Unexpected failure: secret-token", "launch_failed", "could not start"],
302] as const) {
303 it.effect(`normalizes boot failure: ${reason}`, () =>
304 Effect.gen(function* () {
305 const { service } = yield* fixture(Effect.void, diagnostic);
306 yield* service.configure({ enabled: true });
307 const error = yield* service
308 .open({
309 threadId: ThreadId.make("boot-failure"),
310 deviceId: "Pixel_API_35",
311 platform: "android",
312 })
313 .pipe(Effect.flip);
314 expect(error._tag).toBe("DeviceBootError");
315 expect(error.message).toContain(message);
316 expect(error.message).not.toContain(diagnostic);
317 expect((yield* service.state).bootingDevices).toEqual([]);
318 }).pipe(Effect.scoped),
319 );
320}
321
322it.effect("keeps shutdown successful when subsequent discovery fails", () =>
323 Effect.gen(function* () {
324 const { service } = yield* fixture(Effect.void, undefined, true);
325 yield* service.configure({ enabled: true });
326 const threadId = ThreadId.make("shutdown-refresh");
327 const session = yield* service.open({
328 threadId,
329 deviceId: "Pixel_API_35",
330 platform: "android",
331 });
332 yield* service.close({ threadId, deviceId: session.deviceId, shutdown: true });
333 const state = yield* service.state;
334 expect(state.sessions).toEqual([]);
335 expect(state.devices.find((device) => device.id === session.deviceId)?.booted).toBe(false);
336 }).pipe(Effect.scoped),
337);
apps/server/src/device/DeviceService.ts created+764
...@@ -0,0 +1,764 @@
1/**
2 * Device discovery, per-thread device sessions, and the state stream clients
3 * render the Device panel from.
4 *
5 * Discovery and boot go through expo-device-hub's JSON API rather than
6 * shelling out to simctl and adb here: the hub already normalizes both
7 * platforms into one device shape and is the process that has to know a
8 * device is booted before it can stream it. Sessions are the server's own
9 * bookkeeping — which thread is looking at which device — so the panel and
10 * the `device_*` tools agree, and so a `device_open` from an agent surfaces in
11 * every connected client the way `preview_open` does.
12 */
13import {
14 type DeviceActionInput,
15 type DeviceCloseInput,
16 type DeviceConfigureInput,
17 type DeviceDetail,
18 type DeviceDetailInput,
19 type DeviceError,
20 type DeviceHostId,
21 type DeviceId,
22 DeviceBootError,
23 DeviceHostUnavailableError,
24 DeviceNotFoundError,
25 DeviceOperationError,
26 type DeviceOpenInput,
27 type DevicePlatform,
28 DevicePlatformUnavailableError,
29 type DeviceServiceState,
30 type DeviceSession,
31 type DeviceShutdownInput,
32 type DeviceSummary,
33 LOCAL_DEVICE_HOST_ID,
34 type ThreadId,
35} from "@t3tools/contracts";
36import * as Context from "effect/Context";
37import * as DateTime from "effect/DateTime";
38import * as Duration from "effect/Duration";
39import * as Effect from "effect/Effect";
40import * as Layer from "effect/Layer";
41import * as PubSub from "effect/PubSub";
42import * as Schema from "effect/Schema";
43import * as Scope from "effect/Scope";
44import * as Semaphore from "effect/Semaphore";
45import * as Stream from "effect/Stream";
46import * as SynchronizedRef from "effect/SynchronizedRef";
47import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
48
49import * as ServerSettings from "../serverSettings.ts";
50
51import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
52import * as DeviceHost from "./DeviceHost.ts";
53import * as LocalDeviceHost from "./LocalDeviceHost.ts";
54
55/** Origin-relative prefix the hub is proxied under. See DeviceHubProxy. */
56export const DEVICE_HUB_ROUTE_PREFIX = "/api/device-hub";
57
58const BOOT_TIMEOUT = Duration.minutes(3);
59const SCREENSHOT_TIMEOUT = Duration.seconds(20);
60
61const HubDevice = Schema.Struct({
62 id: Schema.String,
63 name: Schema.String,
64 version: Schema.String,
65 platform: Schema.Literals(["ios", "android"]),
66 booted: Schema.Boolean,
67 physical: Schema.Boolean,
68});
69const HubDeviceList = Schema.Struct({
70 simulators: Schema.Array(HubDevice),
71 emulators: Schema.Array(HubDevice),
72 errors: Schema.optional(Schema.Array(Schema.Struct({ message: Schema.String }))),
73});
74const HubActionResult = Schema.Struct({
75 ok: Schema.Boolean,
76 id: Schema.optional(Schema.String),
77 serial: Schema.optional(Schema.String),
78 error: Schema.optional(Schema.String),
79});
80
81export interface DeviceScreenshot {
82 readonly device: DeviceSummary;
83 readonly png: Uint8Array;
84}
85
86export interface DeviceReadiness extends DeviceHost.DeviceHostReady {
87 readonly hostId: DeviceHostId;
88}
89
90export interface DeviceAgentReadiness extends DeviceReadiness {
91 readonly agentDevice: DeviceHost.AgentDeviceEndpoint;
92}
93
94export class DeviceService extends Context.Service<
95 DeviceService,
96 {
97 readonly state: Effect.Effect<DeviceServiceState>;
98 readonly subscribe: Effect.Effect<PubSub.Subscription<DeviceServiceState>, never, Scope.Scope>;
99 readonly configure: (
100 input: DeviceConfigureInput,
101 ) => Effect.Effect<DeviceServiceState, DeviceError>;
102 /** Refreshes devices only after device support has been enabled. */
103 readonly list: Effect.Effect<DeviceServiceState, DeviceError>;
104 readonly open: (input: DeviceOpenInput) => Effect.Effect<DeviceSession, DeviceError>;
105 readonly close: (input: DeviceCloseInput) => Effect.Effect<void, DeviceError>;
106 readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect<void, DeviceError>;
107 /** Current settings and foreground app for one device. */
108 readonly detail: (input: DeviceDetailInput) => Effect.Effect<DeviceDetail, DeviceError>;
109 /** Runs one action, then returns the refreshed detail. */
110 readonly action: (input: DeviceActionInput) => Effect.Effect<DeviceDetail, DeviceError>;
111 readonly screenshot: (input: {
112 readonly hostId?: DeviceHostId | undefined;
113 readonly deviceId: DeviceId;
114 }) => Effect.Effect<DeviceScreenshot, DeviceError>;
115 /** Host endpoints for the proxy and the provider environment. */
116 readonly readiness: (hostId?: DeviceHostId) => Effect.Effect<DeviceReadiness, DeviceError>;
117 /**
118 * `readiness` only when the host can run at least one platform; a machine
119 * with no simulator toolchain never installs or starts anything.
120 */
121 readonly readinessIfSupported: (
122 hostId?: DeviceHostId,
123 ) => Effect.Effect<DeviceReadiness | null, DeviceError>;
124 readonly agentReadinessIfSupported: (
125 hostId?: DeviceHostId,
126 ) => Effect.Effect<DeviceAgentReadiness | null, DeviceError>;
127 readonly currentReadiness: (hostId?: DeviceHostId) => Effect.Effect<DeviceReadiness | null>;
128 readonly sessionsForThread: (threadId: ThreadId) => Effect.Effect<ReadonlyArray<DeviceSession>>;
129 }
130>()("t3/device/DeviceService") {}
131
132interface ServiceState {
133 readonly state: DeviceServiceState;
134}
135
136const vendorPrefix = (platform: DevicePlatform) =>
137 platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
138
139export const make = Effect.gen(function* () {
140 const localHost = yield* DeviceHost.DeviceHost;
141 const settings = yield* ServerSettings.ServerSettingsService;
142 const lifecycleLock = yield* Semaphore.make(1);
143 const readDeviceSettings = settings.getSettings.pipe(
144 Effect.map((value) => ({
145 enabled: value.enableDeviceSupport,
146 agentAccessEnabled: value.enableAgentDeviceAccess,
147 onboardingCompleted: value.deviceOnboardingCompleted,
148 })),
149 Effect.mapError(
150 (cause) =>
151 new DeviceOperationError({ operation: "settings", reason: "settings_failed", cause }),
152 ),
153 );
154 const initialSettings = yield* readDeviceSettings;
155 const hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]> = new Map([
156 [localHost.id, localHost],
157 ]);
158 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
159 const statePubSub = yield* PubSub.unbounded<DeviceServiceState>();
160 const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);
161 const stateRef = yield* SynchronizedRef.make<ServiceState>({
162 state: {
163 hosts: initialHosts,
164 hostStatus: initialSettings.enabled ? "idle" : "disabled",
165 devices: [],
166 sessions: [],
167 onboardingCompleted: initialSettings.onboardingCompleted,
168 agentAccessEnabled: initialSettings.agentAccessEnabled,
169 hubBasePath: DEVICE_HUB_ROUTE_PREFIX,
170 revision: 0,
171 },
172 });
173
174 const publish = (update: (state: DeviceServiceState) => DeviceServiceState) =>
175 SynchronizedRef.updateAndGetEffect(stateRef, ({ state }) => {
176 const next = { ...update(state), revision: state.revision + 1 };
177 return PubSub.publish(statePubSub, next).pipe(Effect.as({ state: next }));
178 }).pipe(Effect.map(({ state }) => state));
179
180 const resolveHost = (hostId: DeviceHostId | undefined) =>
181 Effect.gen(function* () {
182 const id = hostId ?? LOCAL_DEVICE_HOST_ID;
183 const host = hosts.get(id);
184 if (!host) {
185 return yield* new DeviceHostUnavailableError({ hostId: id, reason: "Unknown host." });
186 }
187 return host;
188 });
189
190 const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(
191 function* (hostId) {
192 const host = yield* resolveHost(hostId);
193 if (!(yield* readDeviceSettings).enabled) {
194 return yield* new DeviceHostUnavailableError({
195 hostId: host.id,
196 reason:
197 "Device support is off. Enable it in the Device panel before installing or starting device tools.",
198 });
199 }
200 const ready = yield* host
201 .ensureReady((phase) =>
202 publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe(
203 Effect.asVoid,
204 ),
205 )
206 .pipe(
207 Effect.tapError((error) =>
208 publish((state) => ({
209 ...state,
210 hostStatus: "failed",
211 hostStatusDetail: error.message,
212 })),
213 ),
214 Effect.mapError(
215 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
216 ),
217 );
218 yield* SynchronizedRef.get(stateRef).pipe(
219 Effect.flatMap(({ state }) =>
220 state.hostStatus === "ready"
221 ? Effect.void
222 : publish((current) => ({
223 ...current,
224 hostStatus: "ready",
225 hostStatusDetail: undefined,
226 })),
227 ),
228 );
229 return { hostId: host.id, ...ready };
230 },
231 lifecycleLock.withPermit,
232 );
233
234 const readinessIfSupported: DeviceService["Service"]["readinessIfSupported"] = Effect.fn(
235 "DeviceService.readinessIfSupported",
236 )(function* (hostId) {
237 if (!(yield* readDeviceSettings).enabled) return null;
238 const host = yield* resolveHost(hostId);
239 const summary = yield* host.summary;
240 if (!summary.platforms.some((platform) => platform.available)) return null;
241 return yield* readiness(host.id);
242 });
243
244 const agentReadinessIfSupported: DeviceService["Service"]["agentReadinessIfSupported"] =
245 Effect.fn("DeviceService.agentReadinessIfSupported")(function* (hostId) {
246 const deviceSettings = yield* readDeviceSettings;
247 if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null;
248 const host = yield* resolveHost(hostId);
249 const summary = yield* host.summary;
250 if (!summary.platforms.some((platform) => platform.available)) return null;
251 const ready = yield* host
252 .ensureAgentReady((phase) =>
253 publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe(
254 Effect.asVoid,
255 ),
256 )
257 .pipe(
258 Effect.tapError((error) =>
259 publish((state) => ({
260 ...state,
261 hostStatus: "failed",
262 hostStatusDetail: error.message,
263 })),
264 ),
265 Effect.mapError(
266 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
267 ),
268 );
269 const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);
270 yield* publish((state) => ({ ...state, hosts: hostSummaries, hostStatus: "ready" }));
271 return { hostId: host.id, ...ready };
272 }, lifecycleLock.withPermit);
273
274 const currentReadiness: DeviceService["Service"]["currentReadiness"] = (hostId) =>
275 resolveHost(hostId).pipe(
276 Effect.flatMap((host) =>
277 host.current.pipe(Effect.map((ready) => (ready ? { hostId: host.id, ...ready } : null))),
278 ),
279 Effect.orElseSucceed(() => null),
280 );
281
282 const hubJson = <A, I>(
283 request: HttpClientRequest.HttpClientRequest,
284 schema: Schema.Codec<A, I>,
285 operation: string,
286 timeout: Duration.Input = Duration.seconds(15),
287 ) =>
288 httpClient.execute(request).pipe(
289 Effect.flatMap(HttpClientResponse.filterStatusOk),
290 Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
291 Effect.scoped,
292 Effect.timeout(timeout),
293 Effect.mapError(
294 (cause) =>
295 new DeviceOperationError({
296 operation,
297 reason: "request_failed",
298 cause,
299 }),
300 ),
301 );
302
303 const fetchDevices = Effect.fn("DeviceService.fetchDevices")(function* (ready: DeviceReadiness) {
304 const list = yield* hubJson(
305 HttpClientRequest.get(`${ready.hub.origin}/api/devices`),
306 HubDeviceList,
307 "list",
308 );
309 const toSummary = (device: typeof HubDevice.Type): DeviceSummary => ({
310 hostId: ready.hostId,
311 id: device.id,
312 platform: device.platform,
313 name: device.name,
314 version: device.version,
315 booted: device.booted,
316 physical: device.physical,
317 });
318 const devices = [...list.simulators, ...list.emulators].map(toSummary);
319 const host = yield* resolveHost(ready.hostId);
320 if ((yield* host.platformAvailability("android")).available) {
321 const avds = yield* ready.run("emulator", ["-list-avds"]);
322 if (avds.code !== 0) {
323 return yield* new DeviceOperationError({
324 operation: "list",
325 reason: "command_failed",
326 exitCode: avds.code,
327 cause: avds,
328 });
329 }
330 for (const name of avds.stdout
331 .split(/\r?\n/)
332 .map((line) => line.trim())
333 .filter(Boolean)) {
334 if (!devices.some((device) => device.platform === "android" && device.name === name)) {
335 devices.push({
336 hostId: ready.hostId,
337 id: name,
338 name,
339 platform: "android",
340 version: "Android",
341 booted: false,
342 physical: false,
343 });
344 }
345 }
346 }
347 return { devices, detail: list.errors?.map((error) => error.message).join("\n") || undefined };
348 });
349
350 const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) {
351 const { devices, detail } = yield* fetchDevices(ready);
352 const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary);
353 return yield* lifecycleLock.withPermit(
354 Effect.gen(function* () {
355 if (!(yield* readDeviceSettings).enabled)
356 return (yield* SynchronizedRef.get(stateRef)).state;
357 return yield* publish((state) => ({
358 ...state,
359 hosts: hostSummaries,
360 devices,
361 hostStatusDetail: detail,
362 }));
363 }),
364 );
365 });
366
367 const list: DeviceService["Service"]["list"] = Effect.gen(function* () {
368 if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state;
369 const ready = yield* readiness();
370 return yield* refresh(ready);
371 }).pipe(
372 Effect.tapError((error) =>
373 publish((state) =>
374 state.hostStatus === "disabled"
375 ? state
376 : { ...state, hostStatus: "failed", hostStatusDetail: error.message },
377 ),
378 ),
379 Effect.withSpan("DeviceService.list"),
380 );
381
382 const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")(
383 function* (input) {
384 const currentSettings = yield* readDeviceSettings;
385 const nextEnabled = input.enabled ?? currentSettings.enabled;
386 const nextAgentAccess = input.agentAccessEnabled ?? currentSettings.agentAccessEnabled;
387 yield* lifecycleLock.withPermit(
388 Effect.gen(function* () {
389 yield* settings
390 .updateSettings({
391 ...(input.enabled === undefined ? {} : { enableDeviceSupport: input.enabled }),
392 ...(input.agentAccessEnabled === undefined
393 ? {}
394 : { enableAgentDeviceAccess: input.agentAccessEnabled }),
395 ...(input.onboardingCompleted === undefined
396 ? {}
397 : { deviceOnboardingCompleted: input.onboardingCompleted }),
398 })
399 .pipe(
400 Effect.mapError(
401 (cause) =>
402 new DeviceOperationError({
403 operation: "configure",
404 reason: "settings_failed",
405 cause,
406 }),
407 ),
408 );
409 if (!nextEnabled) {
410 yield* Effect.forEach(hosts.values(), (host) => host.stop, { discard: true });
411 } else if (input.agentAccessEnabled === false) {
412 yield* Effect.forEach(hosts.values(), (host) => host.stopAgent, { discard: true });
413 }
414 yield* publish((state) => ({
415 ...state,
416 hostStatus: nextEnabled ? "idle" : "disabled",
417 hostStatusDetail: undefined,
418 devices: nextEnabled ? state.devices : [],
419 sessions: nextEnabled ? state.sessions : [],
420 bootingDevices: nextEnabled ? state.bootingDevices : [],
421 agentAccessEnabled: nextAgentAccess,
422 onboardingCompleted: input.onboardingCompleted ?? state.onboardingCompleted,
423 }));
424 }),
425 );
426 if (nextEnabled && nextAgentAccess && input.agentAccessEnabled === true) {
427 yield* agentReadinessIfSupported();
428 }
429 return yield* list;
430 },
431 );
432
433 const findDevice = (
434 state: DeviceServiceState,
435 hostId: DeviceHostId,
436 deviceId: DeviceId,
437 ): DeviceSummary | undefined =>
438 state.devices.find((device) => device.hostId === hostId && device.id === deviceId);
439
440 const ensurePlatform = Effect.fn("DeviceService.ensurePlatform")(function* (
441 host: DeviceHost.DeviceHost["Service"],
442 platform: DevicePlatform,
443 ) {
444 const availability = yield* host.platformAvailability(platform);
445 if (!availability.available) {
446 return yield* new DevicePlatformUnavailableError({
447 hostId: host.id,
448 platform,
449 reason: availability.reason ?? "Platform toolchain missing.",
450 });
451 }
452 });
453
454 /**
455 * Boot through the hub so its device list and the streaming helper both see
456 * the device come up. Android AVDs change id when they boot (AVD name to
457 * emulator serial), so the returned id is authoritative.
458 */
459 const boot = Effect.fn("DeviceService.boot")(function* (
460 ready: DeviceReadiness,
461 device: DeviceSummary,
462 ) {
463 const result = yield* HttpClientRequest.post(`${ready.hub.origin}/api/devices/boot`).pipe(
464 HttpClientRequest.bodyJson({ platform: device.platform, id: device.id, name: device.name }),
465 Effect.mapError(
466 (cause) =>
467 new DeviceOperationError({ operation: "boot", reason: "invalid_payload", cause }),
468 ),
469 Effect.flatMap((request) => hubJson(request, HubActionResult, "boot", BOOT_TIMEOUT)),
470 );
471 if (!result.ok) {
472 return yield* new DeviceBootError({
473 hostId: ready.hostId,
474 deviceId: device.id,
475 reason: /insufficient.*(?:disk|space)|not enough.*(?:disk|space)|no space left/i.test(
476 result.error ?? "",
477 )
478 ? "disk_space"
479 : /timed? out|timeout/i.test(result.error ?? "")
480 ? "timeout"
481 : "launch_failed",
482 cause: result,
483 });
484 }
485 if (device.platform === "ios") {
486 // Booting alone does not attach a serve-sim helper; the grid start
487 // does both and is idempotent for a booted simulator.
488 yield* HttpClientRequest.post(
489 `${ready.hub.origin}${vendorPrefix("ios")}/grid/api/start`,
490 ).pipe(
491 HttpClientRequest.bodyJson({ udid: device.id }),
492 Effect.mapError(
493 (cause) =>
494 new DeviceOperationError({ operation: "boot", reason: "invalid_payload", cause }),
495 ),
496 Effect.flatMap((request) =>
497 hubJson(request, HubActionResult, "attach stream", BOOT_TIMEOUT),
498 ),
499 );
500 }
501 return result.serial ?? result.id ?? device.id;
502 });
503
504 const open: DeviceService["Service"]["open"] = Effect.fn("DeviceService.open")(function* (input) {
505 const host = yield* resolveHost(input.hostId);
506 yield* ensurePlatform(host, input.platform);
507 const ready = yield* readiness(host.id);
508 let state = yield* refresh(ready);
509 let device = findDevice(state, host.id, input.deviceId);
510 if (!device) {
511 return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: input.deviceId });
512 }
513 if (!device.booted && input.boot !== false) {
514 const booting = { ...device, threadId: input.threadId };
515 yield* publish((current) => ({
516 ...current,
517 bootingDevices: [
518 ...(current.bootingDevices ?? []).filter(
519 (entry) => entry.hostId !== booting.hostId || entry.id !== booting.id,
520 ),
521 booting,
522 ],
523 }));
524 const bootedId = yield* boot(ready, device).pipe(
525 Effect.ensuring(
526 publish((current) => ({
527 ...current,
528 bootingDevices: (current.bootingDevices ?? []).filter(
529 (entry) => entry.hostId !== booting.hostId || entry.id !== booting.id,
530 ),
531 })),
532 ),
533 );
534 state = yield* refresh(ready);
535 device = findDevice(state, host.id, bootedId) ?? findDevice(state, host.id, device.id);
536 if (!device) {
537 return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: bootedId });
538 }
539 } else if (device.platform === "ios" && device.booted) {
540 // A simulator booted outside T3 has no helper attached yet.
541 yield* HttpClientRequest.post(
542 `${ready.hub.origin}${vendorPrefix("ios")}/grid/api/start`,
543 ).pipe(
544 HttpClientRequest.bodyJson({ udid: device.id }),
545 Effect.mapError(
546 (cause) =>
547 new DeviceOperationError({ operation: "open", reason: "invalid_payload", cause }),
548 ),
549 Effect.flatMap((request) =>
550 hubJson(request, HubActionResult, "attach stream", BOOT_TIMEOUT),
551 ),
552 );
553 }
554 const openedAt = DateTime.formatIso(yield* DateTime.now);
555 const session: DeviceSession = {
556 threadId: input.threadId,
557 hostId: host.id,
558 deviceId: device.id,
559 platform: device.platform,
560 openedAt,
561 };
562 yield* lifecycleLock.withPermit(
563 Effect.gen(function* () {
564 if (!(yield* readDeviceSettings).enabled)
565 return yield* new DeviceHostUnavailableError({
566 hostId: host.id,
567 reason: "Device support was turned off while the device was opening.",
568 });
569 yield* publish((current) => ({
570 ...current,
571 sessions: [
572 ...current.sessions.filter(
573 (existing) =>
574 !(
575 existing.threadId === session.threadId &&
576 existing.hostId === session.hostId &&
577 existing.deviceId === session.deviceId
578 ),
579 ),
580 session,
581 ],
582 }));
583 }),
584 );
585 return session;
586 });
587
588 const shutdownDevice = Effect.fn("DeviceService.shutdownDevice")(function* (
589 hostId: DeviceHostId,
590 deviceId: DeviceId,
591 platform: DevicePlatform,
592 ) {
593 const ready = yield* readiness(hostId);
594 yield* HttpClientRequest.post(`${ready.hub.origin}/api/devices/shutdown`).pipe(
595 HttpClientRequest.bodyJson({ platform, id: deviceId }),
596 Effect.mapError(
597 (cause) =>
598 new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }),
599 ),
600 Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")),
601 Effect.flatMap((result) =>
602 result.ok
603 ? Effect.void
604 : Effect.fail(
605 new DeviceOperationError({
606 operation: "shutdown",
607 reason: "hub_rejected",
608 cause: result,
609 }),
610 ),
611 ),
612 );
613 yield* publish((state) => ({
614 ...state,
615 devices: state.devices.map((device) =>
616 device.hostId === ready.hostId && device.id === deviceId
617 ? { ...device, booted: false }
618 : device,
619 ),
620 sessions: state.sessions.filter(
621 (session) => !(session.hostId === ready.hostId && session.deviceId === deviceId),
622 ),
623 }));
624 // Discovery can stall while an emulator saves its snapshot. A failed
625 // refresh must not turn an accepted shutdown into an action failure.
626 yield* refresh(ready).pipe(
627 Effect.catch((cause) =>
628 Effect.logWarning("Device discovery unavailable after shutdown", { cause }),
629 ),
630 );
631 });
632
633 const close: DeviceService["Service"]["close"] = Effect.fn("DeviceService.close")(
634 function* (input) {
635 const { state } = yield* SynchronizedRef.get(stateRef);
636 const closing = state.sessions.filter(
637 (session) =>
638 session.threadId === input.threadId &&
639 (input.deviceId === undefined || session.deviceId === input.deviceId),
640 );
641 if (closing.length === 0) return;
642 yield* publish((current) => ({
643 ...current,
644 sessions: current.sessions.filter((session) => !closing.includes(session)),
645 }));
646 if (input.shutdown) {
647 yield* Effect.forEach(
648 closing,
649 (session) => shutdownDevice(session.hostId, session.deviceId, session.platform),
650 { discard: true },
651 );
652 }
653 },
654 );
655
656 const shutdown: DeviceService["Service"]["shutdown"] = Effect.fn("DeviceService.shutdown")(
657 function* (input) {
658 const host = yield* resolveHost(input.hostId);
659 yield* shutdownDevice(host.id, input.deviceId, input.platform);
660 // Sessions on a powered-off device are stale in every thread.
661 yield* publish((current) => ({
662 ...current,
663 sessions: current.sessions.filter(
664 (session) => !(session.hostId === host.id && session.deviceId === input.deviceId),
665 ),
666 }));
667 },
668 );
669
670 const screenshot: DeviceService["Service"]["screenshot"] = Effect.fn("DeviceService.screenshot")(
671 function* (input) {
672 const { ready, device } = yield* resolveDevice(input.hostId, input.deviceId);
673 const url = `${ready.hub.origin}${vendorPrefix(device.platform)}/api/screenshot?device=${encodeURIComponent(device.id)}`;
674 const png = yield* httpClient.execute(HttpClientRequest.post(url)).pipe(
675 Effect.flatMap(HttpClientResponse.filterStatusOk),
676 Effect.flatMap((response) => response.arrayBuffer),
677 Effect.map((buffer) => new Uint8Array(buffer)),
678 Effect.scoped,
679 Effect.timeout(SCREENSHOT_TIMEOUT),
680 Effect.mapError(
681 (cause) =>
682 new DeviceOperationError({
683 operation: "screenshot",
684 reason: "request_failed",
685 cause,
686 }),
687 ),
688 );
689 return { device, png };
690 },
691 );
692
693 const resolveDevice = Effect.fn("DeviceService.resolveDevice")(function* (
694 hostId: DeviceHostId | undefined,
695 deviceId: DeviceId,
696 ) {
697 const host = yield* resolveHost(hostId);
698 const ready = yield* readiness(host.id);
699 const { state } = yield* SynchronizedRef.get(stateRef);
700 const device =
701 findDevice(state, host.id, deviceId) ?? findDevice(yield* refresh(ready), host.id, deviceId);
702 if (!device) return yield* new DeviceNotFoundError({ hostId: host.id, deviceId });
703 return { ready, device };
704 });
705
706 const detail: DeviceService["Service"]["detail"] = Effect.fn("DeviceService.detail")(
707 function* (input) {
708 const { ready, device } = yield* resolveDevice(input.hostId, input.deviceId);
709 const read = yield* readDeviceDetail(ready, device.platform, device.id);
710 return {
711 hostId: ready.hostId,
712 deviceId: device.id,
713 settings: read.settings,
714 foregroundApp: read.foregroundApp,
715 readAt: DateTime.formatIso(yield* DateTime.now),
716 };
717 },
718 );
719
720 const action: DeviceService["Service"]["action"] = Effect.fn("DeviceService.action")(
721 function* (input) {
722 const { ready, device } = yield* resolveDevice(input.hostId, input.deviceId);
723 yield* runDeviceAction(ready, device.platform, input);
724 return yield* detail({ hostId: ready.hostId, deviceId: device.id });
725 },
726 );
727
728 const sessionsForThread: DeviceService["Service"]["sessionsForThread"] = (threadId) =>
729 SynchronizedRef.get(stateRef).pipe(
730 Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)),
731 );
732
733 return DeviceService.of({
734 state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
735 subscribe: PubSub.subscribe(statePubSub),
736 configure,
737 list,
738 open,
739 close,
740 shutdown,
741 detail,
742 action,
743 screenshot,
744 readiness,
745 readinessIfSupported,
746 agentReadinessIfSupported,
747 currentReadiness,
748 sessionsForThread,
749 });
750});
751
752export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer));
753
754/** State stream for WS subscribers: current snapshot first, then every change. */
755export const stateStream = (service: DeviceService["Service"]): Stream.Stream<DeviceServiceState> =>
756 Stream.unwrap(
757 Effect.gen(function* () {
758 // Subscribe before reading the snapshot so no change between the two
759 // is lost; the scope lives as long as the stream does.
760 const subscription = yield* service.subscribe;
761 const initial = yield* service.state;
762 return Stream.concat(Stream.make(initial), Stream.fromSubscription(subscription));
763 }),
764 ).pipe(Stream.scoped);
apps/server/src/device/DeviceToolchain.test.ts created+39
...@@ -0,0 +1,39 @@
1import { expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
7
8import * as ProcessRunner from "../processRunner.ts";
9import { ensureDeviceHub, isDeviceHubInstalled } from "./DeviceToolchain.ts";
10
11it.effect("failed installation cleans staging and exposes only a safe failure message", () =>
12 Effect.gen(function* () {
13 const fs = yield* FileSystem.FileSystem;
14 const path = yield* Path.Path;
15 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-install-" });
16 const result = {
17 code: ChildProcessSpawner.ExitCode(1),
18 stdout: "",
19 stderr: "registry rejected https://private:credential@example.test/package",
20 timedOut: false,
21 stdoutTruncated: false,
22 stderrTruncated: false,
23 stdoutInvalidUtf8: false,
24 stderrInvalidUtf8: false,
25 };
26 const error = yield* ensureDeviceHub(baseDir).pipe(
27 Effect.provideService(ProcessRunner.ProcessRunner, {
28 run: () => Effect.succeed(result),
29 }),
30 Effect.flip,
31 );
32 expect(error.message).toBe(
33 "Installing expo-device-hub failed while running npm install (exit code 1).",
34 );
35 expect(error.cause).toBe(result);
36 expect(yield* isDeviceHubInstalled(baseDir)).toBe(false);
37 expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]);
38 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
39);
apps/server/src/device/DeviceToolchain.ts created+223
...@@ -0,0 +1,223 @@
1/**
2 * Pinned installs of the two external tools device support is built on.
3 *
4 * `expo-device-hub` streams simulator and emulator screens and `agent-device`
5 * drives them. Each is npm-installed separately after its matching consent
6 * step into `<baseDir>/tools/<name>/<version>` and executed from there with the
7 * server's own Node, never `npx`: an ephemeral
8 * npx cache would make every first `device_open` after a reboot depend on the
9 * registry, and the pinned versions are part of the contract the injected
10 * agent instructions describe.
11 *
12 * Install follows the pinned-runtime recipe: stage into a temp sibling, write a
13 * sentinel only after npm exits 0, then rename into place. npm extracts files
14 * before it finishes, so an entry file alone does not prove a usable tree.
15 */
16import * as Duration from "effect/Duration";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Option from "effect/Option";
20import * as Path from "effect/Path";
21import * as PlatformError from "effect/PlatformError";
22import * as Schema from "effect/Schema";
23import * as Semaphore from "effect/Semaphore";
24
25import * as ProcessRunner from "../processRunner.ts";
26
27const DEVICE_HUB_PACKAGE = "expo-device-hub";
28const DEVICE_HUB_VERSION = "0.9.0";
29const AGENT_DEVICE_PACKAGE = "agent-device";
30const AGENT_DEVICE_VERSION = "0.20.10";
31
32const INSTALL_TIMEOUT = Duration.minutes(10);
33const installLock = Semaphore.makeUnsafe(1);
34
35export interface DeviceToolPaths {
36 readonly installDir: string;
37 /** Absolute path of the tool's entry script, run with the server's Node. */
38 readonly entryPath: string;
39 readonly sentinelPath: string;
40}
41
42export interface DeviceToolchainPaths {
43 readonly hub: DeviceToolPaths;
44 readonly agentDevice: DeviceToolPaths;
45}
46
47export class DeviceToolchainInstallError extends Schema.TaggedError<DeviceToolchainInstallError>()(
48 "DeviceToolchainInstallError",
49 {
50 tool: Schema.String,
51 step: Schema.String,
52 exitCode: Schema.optional(Schema.Number),
53 cause: Schema.optional(Schema.Defect()),
54 },
55) {
56 override get message(): string {
57 const suffix = this.exitCode === undefined ? "" : ` (exit code ${this.exitCode})`;
58 return `Installing ${this.tool} failed while ${this.step}${suffix}.`;
59 }
60}
61
62interface ToolSpec {
63 readonly name: string;
64 readonly version: string;
65 readonly entry: ReadonlyArray<string>;
66}
67
68const HUB_SPEC: ToolSpec = {
69 name: DEVICE_HUB_PACKAGE,
70 version: DEVICE_HUB_VERSION,
71 entry: ["dist", "server", "cli.mjs"],
72};
73
74const AGENT_DEVICE_SPEC: ToolSpec = {
75 name: AGENT_DEVICE_PACKAGE,
76 version: AGENT_DEVICE_VERSION,
77 entry: ["bin", "agent-device.mjs"],
78};
79
80const toolPaths = (path: Path.Path, baseDir: string, spec: ToolSpec): DeviceToolPaths => {
81 const installDir = path.join(baseDir, "tools", spec.name, spec.version);
82 return {
83 installDir,
84 entryPath: path.join(installDir, "node_modules", spec.name, ...spec.entry),
85 sentinelPath: path.join(installDir, ".install-complete"),
86 };
87};
88
89const deviceToolchainPaths = (path: Path.Path, baseDir: string): DeviceToolchainPaths => ({
90 hub: toolPaths(path, baseDir, HUB_SPEC),
91 agentDevice: toolPaths(path, baseDir, AGENT_DEVICE_SPEC),
92});
93
94/** Keep daemon state (daemon.json, sessions) in userdata, separate from tool installs. */
95export const agentDeviceStateDir = (path: Path.Path, stateDir: string): string =>
96 path.join(stateDir, "device", "agent-device");
97
98const isInstalled = Effect.fn("DeviceToolchain.isInstalled")(function* (
99 fs: FileSystem.FileSystem,
100 paths: DeviceToolPaths,
101 version: string,
102) {
103 const [entryExists, sentinel] = yield* Effect.all([
104 fs.exists(paths.entryPath),
105 fs.readFileString(paths.sentinelPath).pipe(Effect.option),
106 ]).pipe(Effect.orElseSucceed(() => [false, Option.none<string>()] as const));
107 return entryExists && Option.isSome(sentinel) && sentinel.value.trim() === version;
108});
109
110const installTool = Effect.fn("DeviceToolchain.installTool")(function* (
111 spec: ToolSpec,
112 paths: DeviceToolPaths,
113) {
114 const fs = yield* FileSystem.FileSystem;
115 const path = yield* Path.Path;
116 const runner = yield* ProcessRunner.ProcessRunner;
117 const fail = (step: string) => (cause: unknown) =>
118 new DeviceToolchainInstallError({ tool: spec.name, step, cause });
119
120 if (yield* isInstalled(fs, paths, spec.version)) return paths;
121
122 const parentDir = path.dirname(paths.installDir);
123 yield* fs
124 .remove(paths.installDir, { recursive: true, force: true })
125 .pipe(Effect.mapError(fail("removing an incomplete install")));
126 yield* fs
127 .makeDirectory(parentDir, { recursive: true })
128 .pipe(Effect.mapError(fail("preparing the install directory")));
129 const stagingDir = yield* fs
130 .makeTempDirectory({ directory: parentDir, prefix: ".staging-" })
131 .pipe(Effect.mapError(fail("preparing the install directory")));
132
133 return yield* Effect.gen(function* () {
134 const installArgs = [
135 "install",
136 "--prefix",
137 stagingDir,
138 "--no-fund",
139 "--no-audit",
140 `${spec.name}@${spec.version}`,
141 ];
142 const result = yield* runner
143 .run({ command: "npm", args: installArgs, timeout: INSTALL_TIMEOUT })
144 .pipe(
145 Effect.catchTags({
146 ProcessSpawnError: (error) =>
147 error.cause instanceof PlatformError.PlatformError &&
148 error.cause.reason._tag === "NotFound"
149 ? runner.run({
150 command: "pnpm",
151 args: ["--package=npm@11", "dlx", "npm", ...installArgs],
152 timeout: INSTALL_TIMEOUT,
153 })
154 : Effect.fail(error),
155 }),
156 Effect.mapError(fail("running npm install")),
157 );
158 if (result.code !== 0) {
159 return yield* new DeviceToolchainInstallError({
160 tool: spec.name,
161 step: "running npm install",
162 exitCode: Number(result.code),
163 cause: result,
164 });
165 }
166 const stagedEntry = path.join(stagingDir, "node_modules", spec.name, ...spec.entry);
167 if (!(yield* fs.exists(stagedEntry).pipe(Effect.orElseSucceed(() => false)))) {
168 return yield* new DeviceToolchainInstallError({
169 tool: spec.name,
170 step: "verifying the installed entry point",
171 });
172 }
173 yield* fs
174 .writeFileString(path.join(stagingDir, ".install-complete"), `${spec.version}\n`)
175 .pipe(Effect.mapError(fail("recording the completed install")));
176 yield* fs.rename(stagingDir, paths.installDir).pipe(
177 Effect.catch((cause) =>
178 // A concurrent server may have published the same version first.
179 isInstalled(fs, paths, spec.version).pipe(
180 Effect.flatMap((published) =>
181 published ? Effect.void : Effect.fail(fail("publishing the install")(cause)),
182 ),
183 ),
184 ),
185 );
186 return paths;
187 }).pipe(
188 Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)),
189 );
190});
191
192const ensureTool = Effect.fn("DeviceToolchain.ensureTool")(function* (
193 baseDir: string,
194 spec: ToolSpec,
195 select: (paths: DeviceToolchainPaths) => DeviceToolPaths,
196) {
197 const path = yield* Path.Path;
198 const paths = deviceToolchainPaths(path, baseDir);
199 return yield* installLock.withPermit(installTool(spec, select(paths)));
200});
201
202export const ensureDeviceHub = (baseDir: string) =>
203 ensureTool(baseDir, HUB_SPEC, (paths) => paths.hub);
204
205export const ensureAgentDevice = (baseDir: string) =>
206 ensureTool(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice);
207
208const isToolInstalled = Effect.fn("DeviceToolchain.isToolInstalled")(function* (
209 baseDir: string,
210 spec: ToolSpec,
211 select: (paths: DeviceToolchainPaths) => DeviceToolPaths,
212) {
213 const fs = yield* FileSystem.FileSystem;
214 const path = yield* Path.Path;
215 const paths = deviceToolchainPaths(path, baseDir);
216 return yield* isInstalled(fs, select(paths), spec.version);
217});
218
219export const isDeviceHubInstalled = (baseDir: string) =>
220 isToolInstalled(baseDir, HUB_SPEC, (paths) => paths.hub);
221
222export const isAgentDeviceInstalled = (baseDir: string) =>
223 isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice);
apps/server/src/device/LocalDeviceHost.test.ts created+114
...@@ -0,0 +1,114 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as NodePath from "@effect/platform-node/NodePath";
3import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7import * as FileSystem from "effect/FileSystem";
8
9import * as LocalDeviceHost from "./LocalDeviceHost.ts";
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
12import { HttpClient } from "effect/unstable/http";
13import * as NetService from "@t3tools/shared/Net";
14import * as ServerConfig from "../config.ts";
15import * as ProcessRunner from "../processRunner.ts";
16
17const diagnose = (files: ReadonlyArray<string>, environment: NodeJS.ProcessEnv) =>
18 LocalDeviceHost.__testing.platformReason("android").pipe(
19 Effect.provideService(HostProcessEnvironment, environment),
20 Effect.provideService(HostProcessPlatform, "darwin"),
21 Effect.provideService(
22 FileSystem.FileSystem,
23 FileSystem.makeNoop({
24 exists: (file) => Effect.succeed(files.includes(file)),
25 }),
26 ),
27 Effect.provide(NodePath.layer),
28 );
29
30describe("Android SDK availability", () => {
31 it.effect("explains that adb alone is insufficient to launch an emulator", () =>
32 Effect.gen(function* () {
33 const reason = yield* diagnose(["/sdk/platform-tools/adb"], { ANDROID_HOME: "/sdk" });
34 expect(reason).toContain("Android Emulator is missing");
35 }),
36 );
37
38 it.effect("identifies command-line tools required by the device hub", () =>
39 Effect.gen(function* () {
40 const reason = yield* diagnose(["/sdk/platform-tools/adb", "/sdk/emulator/emulator"], {
41 ANDROID_HOME: "/sdk",
42 });
43 expect(reason).toContain("Command-line Tools (latest)");
44 }),
45 );
46
47 it.effect("discovers the standard macOS SDK without ANDROID_HOME", () =>
48 Effect.gen(function* () {
49 const root = "/test/home/Library/Android/sdk";
50 const reason = yield* diagnose(
51 [
52 `${root}/platform-tools/adb`,
53 `${root}/emulator/emulator`,
54 `${root}/cmdline-tools/latest/bin/avdmanager`,
55 ],
56 { HOME: "/test/home" },
57 );
58 expect(reason).toBeNull();
59 }),
60 );
61
62 it.effect("reports an absent SDK without running or installing tools", () =>
63 Effect.gen(function* () {
64 expect(yield* diagnose([], { HOME: "/test/home" })).toContain("Android SDK was not found");
65 }),
66 );
67});
68
69it.effect("puts detected Android tools on the helper PATH without losing existing commands", () =>
70 Effect.gen(function* () {
71 const path = yield* Path.Path;
72 const environment = LocalDeviceHost.__testing.deviceHostEnvironment(
73 { PATH: "/usr/bin", HOME: "/test/home" },
74 "/sdk",
75 "darwin",
76 path,
77 );
78 expect(environment.PATH).toBe("/sdk/platform-tools:/sdk/emulator:/usr/bin");
79 expect(environment.ANDROID_HOME).toBe("/sdk");
80 expect(environment.HOME).toBe("/test/home");
81 }).pipe(Effect.provide(NodePath.layer)),
82);
83
84it.effect(
85 "constructs and inspects an unconfigured host without installing or starting helpers",
86 () =>
87 Effect.gen(function* () {
88 const fs = yield* FileSystem.FileSystem;
89 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-consent-" });
90 const host = yield* LocalDeviceHost.make().pipe(
91 Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)),
92 Effect.provideService(HostProcessEnvironment, { HOME: baseDir, PATH: "" }),
93 Effect.provideService(HostProcessPlatform, "linux"),
94 Effect.provideService(
95 ChildProcessSpawner.ChildProcessSpawner,
96 ChildProcessSpawner.make(() =>
97 Effect.die(new Error("Host construction must not spawn processes")),
98 ),
99 ),
100 Effect.provideService(ProcessRunner.ProcessRunner, {
101 run: () => Effect.die(new Error("Host construction must not run commands")),
102 }),
103 Effect.provideService(
104 HttpClient.HttpClient,
105 HttpClient.make(() =>
106 Effect.die(new Error("Host construction must not make network requests")),
107 ),
108 ),
109 );
110 expect(yield* host.current).toBeNull();
111 yield* host.stop;
112 expect(yield* fs.exists(`${baseDir}/tools`)).toBe(false);
113 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
114);
apps/server/src/device/LocalDeviceHost.ts created+697
...@@ -0,0 +1,697 @@
1/**
2 * The device host that is this machine.
3 *
4 * Runs expo-device-hub as a supervised child on a loopback port and starts the
5 * agent-device daemon in HTTP mode under a T3-owned state directory. Both are
6 * lazy: the device service requires explicit setup consent before it calls
7 * ensureReady to install tools or start helper processes.
8 *
9 * The hub runs in its standalone mode (origin root). The T3 proxy strips its
10 * own prefix, and the Device panel derives stream and socket URLs from the
11 * prefix itself rather than from anything the hub prints.
12 */
13import {
14 type DeviceHostSummary,
15 type DevicePlatform,
16 type DevicePlatformAvailability,
17 LOCAL_DEVICE_HOST_ID,
18} from "@t3tools/contracts";
19import { waitForHttpReady } from "@t3tools/shared/httpReadiness";
20import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
21import * as NetService from "@t3tools/shared/Net";
22import { isCommandAvailable } from "@t3tools/shared/shell";
23import * as Clock from "effect/Clock";
24import * as Duration from "effect/Duration";
25import * as Effect from "effect/Effect";
26import * as Exit from "effect/Exit";
27import * as FileSystem from "effect/FileSystem";
28import * as Layer from "effect/Layer";
29import * as Path from "effect/Path";
30import * as Ref from "effect/Ref";
31import * as Schema from "effect/Schema";
32import * as Scope from "effect/Scope";
33import * as Semaphore from "effect/Semaphore";
34import * as Stream from "effect/Stream";
35import { HttpClient } from "effect/unstable/http";
36import * as ChildProcess from "effect/unstable/process/ChildProcess";
37import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
38
39import * as ServerConfig from "../config.ts";
40import * as ProcessRunner from "../processRunner.ts";
41import * as DeviceHost from "./DeviceHost.ts";
42import {
43 agentDeviceStateDir,
44 type DeviceToolPaths,
45 ensureAgentDevice,
46 ensureDeviceHub,
47 isAgentDeviceInstalled,
48 isDeviceHubInstalled,
49} from "./DeviceToolchain.ts";
50
51const HUB_READY_TIMEOUT_MS = 30_000;
52const DAEMON_READY_TIMEOUT_MS = 30_000;
53const DAEMON_POLL_MS = 100;
54const HUB_RESTART_STABLE_UPTIME_MS = 60_000;
55const HUB_RESTART_MAX_DELAY_MS = 30_000;
56
57/**
58 * Written beside the agent-device state so a server that dies without running
59 * its finalizers (SIGKILL, dev-runner restarts) does not leave a hub bound to
60 * a loopback port forever. The next start reads it, kills only a process that
61 * is still that hub, and replaces the file.
62 */
63const HubStateFile = Schema.Struct({
64 pid: Schema.Int,
65 port: Schema.Int,
66 entryPath: Schema.String,
67});
68const decodeHubStateFile = Schema.decodeUnknownEffect(Schema.fromJsonString(HubStateFile));
69const encodeHubStateFile = Schema.encodeUnknownEffect(Schema.fromJsonString(HubStateFile));
70
71const AgentDeviceDaemonFile = Schema.Struct({
72 httpPort: Schema.Int,
73 token: Schema.String,
74 pid: Schema.optional(Schema.Int),
75});
76const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile));
77
78interface HubProcess {
79 readonly child: ChildProcessSpawner.ChildProcessHandle;
80 readonly scope: Scope.Closeable;
81 readonly origin: string;
82 readonly startedAtMillis: number;
83}
84
85interface RunningHost {
86 readonly hub: HubProcess;
87 readonly agentDevice: DeviceHost.AgentDeviceEndpoint | null;
88 readonly helpers: DeviceHost.DeviceHostReady["helpers"];
89}
90
91const platformReason = Effect.fn("LocalDeviceHost.platformReason")(function* (
92 platform: DevicePlatform,
93): Effect.fn.Return<string | null, never, FileSystem.FileSystem | Path.Path> {
94 const hostPlatform = yield* HostProcessPlatform;
95 if (platform === "ios") {
96 if (hostPlatform !== "darwin") return "iOS Simulators need macOS with Xcode.";
97 if (!(yield* isCommandAvailable("xcrun"))) return "Xcode command line tools were not found.";
98 return null;
99 }
100 const sdk = yield* androidSdk;
101 if (!sdk.root)
102 return "Android SDK was not found. Install it with Android Studio or set ANDROID_HOME to your SDK directory.";
103 if (!sdk.adb)
104 return `Android SDK Platform-Tools are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`;
105 if (!sdk.emulator)
106 return `Android Emulator is missing from ${sdk.root}. Install it in Android Studio's SDK Manager.`;
107 if (!sdk.avdmanager)
108 return `Android SDK Command-line Tools (latest) are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`;
109 return null;
110});
111
112/** Resolve the SDK once for both diagnostics and the environment passed to helpers. */
113const androidSdk = Effect.gen(function* () {
114 const fs = yield* FileSystem.FileSystem;
115 const path = yield* Path.Path;
116 const environment = yield* HostProcessEnvironment;
117 const platform = yield* HostProcessPlatform;
118 const home = environment.HOME ?? environment.USERPROFILE ?? "";
119 const explicit = environment.ANDROID_HOME?.trim() || environment.ANDROID_SDK_ROOT?.trim();
120 const candidates = explicit
121 ? [explicit]
122 : [
123 path.join(home, "Library", "Android", "sdk"),
124 path.join(home, "Android", "Sdk"),
125 path.join(
126 environment.LOCALAPPDATA ?? path.join(home, "AppData", "Local"),
127 "Android",
128 "Sdk",
129 ),
130 ];
131 if (!explicit) {
132 for (const directory of (environment.PATH ?? "").split(platform === "win32" ? ";" : ":")) {
133 if (!directory) continue;
134 const resolved = yield* fs
135 .realPath(path.join(directory, platform === "win32" ? "adb.exe" : "adb"))
136 .pipe(Effect.option);
137 if (resolved._tag === "Some") candidates.push(path.dirname(path.dirname(resolved.value)));
138 }
139 }
140 const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false));
141 for (const root of candidates) {
142 const adb = yield* exists(
143 path.join(root, "platform-tools", platform === "win32" ? "adb.exe" : "adb"),
144 );
145 const emulator = yield* exists(
146 path.join(root, "emulator", platform === "win32" ? "emulator.exe" : "emulator"),
147 );
148 if (explicit || adb || emulator) {
149 const avdmanager = yield* exists(
150 path.join(
151 root,
152 "cmdline-tools",
153 "latest",
154 "bin",
155 platform === "win32" ? "avdmanager.bat" : "avdmanager",
156 ),
157 );
158 return { root, adb, emulator, avdmanager };
159 }
160 }
161 return { root: null, adb: false, emulator: false, avdmanager: false };
162});
163
164const deviceHostEnvironment = (
165 environment: NodeJS.ProcessEnv,
166 sdkRoot: string | null,
167 hostPlatform: NodeJS.Platform,
168 path: Path.Path,
169): NodeJS.ProcessEnv => {
170 return sdkRoot
171 ? {
172 ...environment,
173 ANDROID_HOME: sdkRoot,
174 PATH: [
175 path.join(sdkRoot, "platform-tools"),
176 path.join(sdkRoot, "emulator"),
177 environment.PATH ?? environment.Path ?? "",
178 ].join(hostPlatform === "win32" ? ";" : ":"),
179 }
180 : environment;
181};
182
183export const make = Effect.fn("LocalDeviceHost.make")(function* () {
184 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
185 const config = yield* ServerConfig.ServerConfig;
186 const path = yield* Path.Path;
187 const fs = yield* FileSystem.FileSystem;
188 const net = yield* NetService.NetService;
189 const runner = yield* ProcessRunner.ProcessRunner;
190 const httpClient = yield* HttpClient.HttpClient;
191 const environment = yield* HostProcessEnvironment;
192 const hostPlatform = yield* HostProcessPlatform;
193 const sdk = yield* androidSdk;
194 const hostEnvironment = deviceHostEnvironment(environment, sdk.root, hostPlatform, path);
195 const startLock = yield* Semaphore.make(1);
196 const runningRef = yield* Ref.make<RunningHost | null>(null);
197 const restartDelayRef = yield* Ref.make(0);
198 const hostId = LOCAL_DEVICE_HOST_ID;
199
200 const platformAvailability = Effect.fn("LocalDeviceHost.platformAvailability")(function* (
201 platform: DevicePlatform,
202 ): Effect.fn.Return<DevicePlatformAvailability> {
203 const reason = yield* platformReason(platform).pipe(
204 Effect.provideService(FileSystem.FileSystem, fs),
205 Effect.provideService(Path.Path, path),
206 );
207 return reason === null ? { platform, available: true } : { platform, available: false, reason };
208 });
209
210 const summary: Effect.Effect<DeviceHostSummary> = Effect.gen(function* () {
211 const [platforms, hubInstalled, agentDeviceInstalled] = yield* Effect.all([
212 Effect.all([platformAvailability("ios"), platformAvailability("android")]),
213 isDeviceHubInstalled(config.baseDir).pipe(
214 Effect.provideService(FileSystem.FileSystem, fs),
215 Effect.provideService(Path.Path, path),
216 ),
217 isAgentDeviceInstalled(config.baseDir).pipe(
218 Effect.provideService(FileSystem.FileSystem, fs),
219 Effect.provideService(Path.Path, path),
220 ),
221 ]);
222 return {
223 id: hostId,
224 kind: "local",
225 label: "This machine",
226 platforms,
227 hubInstalled,
228 agentDeviceInstalled,
229 };
230 });
231
232 const hubEnvironment = (): NodeJS.ProcessEnv => ({
233 ...hostEnvironment,
234 FORCE_COLOR: "0",
235 NO_COLOR: "1",
236 });
237
238 const stopHub = (hub: HubProcess | undefined) =>
239 hub ? Scope.close(hub.scope, Exit.void).pipe(Effect.ignore) : Effect.void;
240
241 const hubStatePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "hub.json");
242
243 const isProcessAlive = (pid: number) =>
244 Effect.sync(() => {
245 try {
246 process.kill(pid, 0);
247 return true;
248 } catch {
249 return false;
250 }
251 });
252
253 /**
254 * A hub left behind by a previous server is identified by pid plus the
255 * command line's entry path, so a recycled pid belonging to something else
256 * is never touched.
257 */
258 const reapStaleHub = Effect.gen(function* () {
259 const previous = yield* fs
260 .readFileString(hubStatePath())
261 .pipe(Effect.flatMap(decodeHubStateFile), Effect.option);
262 if (previous._tag === "None") return;
263 const alive = yield* isProcessAlive(previous.value.pid);
264 if (alive) {
265 const commandLine = yield* runner
266 .run({
267 command: "ps",
268 args: ["-o", "command=", "-p", String(previous.value.pid)],
269 timeout: Duration.seconds(5),
270 timeoutBehavior: "timedOutResult",
271 })
272 .pipe(
273 Effect.map((result) => result.stdout),
274 Effect.orElseSucceed(() => ""),
275 );
276 if (commandLine.includes(previous.value.entryPath)) {
277 yield* Effect.logWarning("Stopping a device hub left behind by a previous server", {
278 pid: previous.value.pid,
279 port: previous.value.port,
280 });
281 yield* Effect.sync(() => {
282 try {
283 process.kill(previous.value.pid, "SIGTERM");
284 } catch {
285 // Already gone.
286 }
287 });
288 }
289 }
290 yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore);
291 }).pipe(Effect.catchCause(() => Effect.void));
292
293 const recordHub = (hub: HubProcess, hubTool: DeviceToolPaths) =>
294 encodeHubStateFile({
295 pid: Number(hub.child.pid),
296 port: Number(new URL(hub.origin).port),
297 entryPath: hubTool.entryPath,
298 }).pipe(
299 Effect.flatMap((json) => fs.writeFileString(hubStatePath(), json)),
300 Effect.ignore,
301 );
302
303 const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* (
304 hubTool: DeviceToolPaths,
305 ): Effect.fn.Return<HubProcess, DeviceHost.DeviceHostError> {
306 yield* reapStaleHub;
307 yield* fs
308 .makeDirectory(agentDeviceStateDir(path, config.stateDir), { recursive: true })
309 .pipe(Effect.ignore);
310 const port = yield* net.reserveLoopbackPort("127.0.0.1").pipe(
311 Effect.mapError(
312 (cause) =>
313 new DeviceHost.DeviceHostError({
314 hostId,
315 step: "reserving a port for the device hub",
316 cause,
317 }),
318 ),
319 );
320 const origin = `http://127.0.0.1:${port}`;
321 const scope = yield* Scope.make("sequential");
322 const child = yield* spawner
323 .spawn(
324 ChildProcess.make(
325 process.execPath,
326 [
327 hubTool.entryPath,
328 "--port",
329 String(port),
330 "--host",
331 "127.0.0.1",
332 "--hide-sidebar",
333 "--hide-boot-device",
334 ],
335 {
336 detached: false,
337 shell: false,
338 stdout: "pipe",
339 stderr: "pipe",
340 env: hubEnvironment(),
341 },
342 ),
343 )
344 .pipe(
345 Effect.provideService(Scope.Scope, scope),
346 Effect.mapError(
347 (cause) =>
348 new DeviceHost.DeviceHostError({
349 hostId,
350 step: "starting the device hub",
351 cause,
352 }),
353 ),
354 );
355 const startedAtMillis = yield* Clock.currentTimeMillis;
356 const hub: HubProcess = { child, scope, origin, startedAtMillis };
357 yield* Effect.forkIn(observeHubOutput(hub), scope);
358 yield* waitForHttpReady({
359 baseUrl: origin,
360 path: "/readyz",
361 timeoutMs: HUB_READY_TIMEOUT_MS,
362 makeError: (info) =>
363 new DeviceHost.DeviceHostError({
364 hostId,
365 step: "waiting for the device hub to answer",
366 cause: info.cause,
367 }),
368 }).pipe(
369 Effect.provideService(HttpClient.HttpClient, httpClient),
370 Effect.tapError(() => stopHub(hub)),
371 );
372 yield* recordHub(hub, hubTool);
373 yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port });
374 return hub;
375 });
376
377 const observeHubOutput = (hub: HubProcess) =>
378 hub.child.all.pipe(
379 Stream.decodeText(),
380 Stream.splitLines,
381 Stream.map((line) => line.trim()),
382 Stream.filter((line) => line.length > 0),
383 Stream.runForEach((line) =>
384 Effect.logDebug("Device hub output", { pid: Number(hub.child.pid), output: line }),
385 ),
386 Effect.catchCause(() => Effect.void),
387 );
388
389 /**
390 * Restart the hub when it dies under us, with the same doubling backoff the
391 * relay connector uses so a hub that crashes on boot cannot spin.
392 */
393 const superviseHub = (hub: HubProcess, hubTool: DeviceToolPaths): Effect.Effect<void> =>
394 Effect.gen(function* () {
395 yield* Effect.result(hub.child.exitCode);
396 const running = yield* Ref.get(runningRef);
397 if (running?.hub.child.pid !== hub.child.pid) return;
398 const uptime = (yield* Clock.currentTimeMillis) - hub.startedAtMillis;
399 const delay = yield* Ref.modify(restartDelayRef, (current) => {
400 if (uptime >= HUB_RESTART_STABLE_UPTIME_MS) return [0, 0];
401 const next = current === 0 ? 1_000 : Math.min(current * 2, HUB_RESTART_MAX_DELAY_MS);
402 return [current, next];
403 });
404 yield* Effect.logWarning("Device hub exited; restarting", {
405 pid: Number(hub.child.pid),
406 delayMs: delay,
407 });
408 yield* Effect.sleep(Duration.millis(delay));
409 yield* startLock.withPermits(1)(
410 Effect.gen(function* () {
411 const current = yield* Ref.get(runningRef);
412 if (current?.hub.child.pid !== hub.child.pid) return;
413 const replacement = yield* spawnHub(hubTool);
414 yield* Ref.set(runningRef, { ...current, hub: replacement });
415 yield* Effect.forkDetach(superviseHub(replacement, hubTool));
416 }),
417 );
418 }).pipe(
419 Effect.catchCause((cause) => Effect.logWarning("Device hub supervisor failed", { cause })),
420 );
421
422 const daemonFilePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "daemon.json");
423
424 const readDaemonFile = Effect.fn("LocalDeviceHost.readDaemonFile")(function* () {
425 const raw = yield* fs.readFileString(daemonFilePath());
426 return yield* decodeDaemonFile(raw);
427 });
428
429 /**
430 * agent-device auto-starts its daemon on any command. A trivial `devices`
431 * call in HTTP mode is the documented way to bring it up; its output is the
432 * daemon.json this reads back.
433 */
434 const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* (
435 agentTool: DeviceToolPaths,
436 ): Effect.fn.Return<DeviceHost.AgentDeviceEndpoint, DeviceHost.DeviceHostTimeoutError> {
437 const stateDir = agentDeviceStateDir(path, config.stateDir);
438 yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore);
439 const existing = yield* readDaemonFile().pipe(Effect.option);
440 const daemonEnvironment: NodeJS.ProcessEnv = {
441 ...hostEnvironment,
442 AGENT_DEVICE_STATE_DIR: stateDir,
443 AGENT_DEVICE_DAEMON_SERVER_MODE: "http",
444 // The daemon idles out after five minutes by default; the server owns
445 // its lifetime here and stops it explicitly.
446 AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: "0",
447 AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1",
448 FORCE_COLOR: "0",
449 NO_COLOR: "1",
450 };
451 const toEndpoint = (
452 file: typeof AgentDeviceDaemonFile.Type,
453 ): DeviceHost.AgentDeviceEndpoint => ({
454 baseUrl: `http://127.0.0.1:${file.httpPort}`,
455 token: file.token,
456 entryPath: agentTool.entryPath,
457 });
458 if (existing._tag === "Some") {
459 const alive = yield* HttpClient.withScope(httpClient)
460 .get(`http://127.0.0.1:${existing.value.httpPort}/health`)
461 .pipe(
462 Effect.timeout(Duration.seconds(2)),
463 Effect.flatMap((response) =>
464 response.arrayBuffer.pipe(Effect.as(response.status === 200)),
465 ),
466 Effect.scoped,
467 Effect.orElseSucceed(() => false),
468 );
469 if (alive) return toEndpoint(existing.value);
470 yield* fs.remove(daemonFilePath(), { force: true }).pipe(Effect.ignore);
471 }
472 // There is no `daemon start`; the first command in a state dir spawns the
473 // daemon and blocks until it answers. `devices` is the cheapest one.
474 yield* runner
475 .run({
476 command: process.execPath,
477 args: [agentTool.entryPath, "devices", "--json"],
478 env: daemonEnvironment,
479 timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS),
480 timeoutBehavior: "timedOutResult",
481 })
482 .pipe(Effect.ignore);
483 const deadline = (yield* Clock.currentTimeMillis) + DAEMON_READY_TIMEOUT_MS;
484 while (true) {
485 const file = yield* readDaemonFile().pipe(Effect.option);
486 if (file._tag === "Some") return toEndpoint(file.value);
487 if ((yield* Clock.currentTimeMillis) > deadline) {
488 return yield* new DeviceHost.DeviceHostTimeoutError({
489 hostId,
490 timeoutMs: DAEMON_READY_TIMEOUT_MS,
491 });
492 }
493 yield* Effect.sleep(Duration.millis(DAEMON_POLL_MS));
494 }
495 });
496
497 const stopAgentDeviceDaemon = (agentTool: DeviceToolPaths | null) =>
498 agentTool
499 ? runner
500 .run({
501 command: process.execPath,
502 args: [
503 agentTool.entryPath,
504 "daemon",
505 "stop",
506 "--state-dir",
507 agentDeviceStateDir(path, config.stateDir),
508 ],
509 env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" },
510 timeout: Duration.seconds(10),
511 timeoutBehavior: "timedOutResult",
512 })
513 .pipe(Effect.ignore)
514 : Effect.void;
515
516 let agentToolRef: DeviceToolPaths | null = null;
517
518 const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* (
519 onPhase: (phase: "installing" | "starting") => Effect.Effect<void>,
520 ): Effect.fn.Return<RunningHost, DeviceHost.DeviceHostError> {
521 const running = yield* Ref.get(runningRef);
522 if (running) {
523 const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false));
524 if (alive) return running;
525 yield* Ref.set(runningRef, null);
526 }
527 const installed = yield* isDeviceHubInstalled(config.baseDir).pipe(
528 Effect.provideService(FileSystem.FileSystem, fs),
529 Effect.provideService(Path.Path, path),
530 );
531 if (!installed) yield* onPhase("installing");
532 const hubTool = yield* ensureDeviceHub(config.baseDir).pipe(
533 Effect.provideService(FileSystem.FileSystem, fs),
534 Effect.provideService(Path.Path, path),
535 Effect.provideService(ProcessRunner.ProcessRunner, runner),
536 Effect.mapError(
537 (cause) =>
538 new DeviceHost.DeviceHostError({
539 hostId,
540 step: "installing device support",
541 cause,
542 }),
543 ),
544 );
545 yield* onPhase("starting");
546 const hub = yield* spawnHub(hubTool);
547 const candidate = helperPaths(hubTool);
548 const [axExists, cliExists] = yield* Effect.all([
549 fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)),
550 fs.exists(candidate.serveSimCli).pipe(Effect.orElseSucceed(() => false)),
551 ]);
552 const next: RunningHost = {
553 hub,
554 agentDevice: null,
555 helpers: {
556 serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null,
557 serveSimCli: cliExists ? candidate.serveSimCli : null,
558 },
559 };
560 yield* Ref.set(runningRef, next);
561 yield* Ref.set(restartDelayRef, 0);
562 yield* Effect.forkDetach(superviseHub(hub, hubTool));
563 return next;
564 });
565
566 const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) =>
567 startLock.withPermits(1)(ensureHubReady(onPhase).pipe(Effect.map(toReady)));
568
569 const ensureAgentReady: DeviceHost.DeviceHost["Service"]["ensureAgentReady"] = (onPhase) =>
570 startLock.withPermits(1)(
571 Effect.gen(function* (): Generator<
572 Effect.Effect<unknown, DeviceHost.DeviceHostError | DeviceHost.DeviceHostTimeoutError>,
573 DeviceHost.DeviceHostAgentReady
574 > {
575 const running = yield* ensureHubReady(onPhase);
576 if (running.agentDevice) return { ...toReady(running), agentDevice: running.agentDevice };
577 const installed = yield* isAgentDeviceInstalled(config.baseDir).pipe(
578 Effect.provideService(FileSystem.FileSystem, fs),
579 Effect.provideService(Path.Path, path),
580 );
581 if (!installed) yield* onPhase("installing");
582 const agentTool = yield* ensureAgentDevice(config.baseDir).pipe(
583 Effect.provideService(FileSystem.FileSystem, fs),
584 Effect.provideService(Path.Path, path),
585 Effect.provideService(ProcessRunner.ProcessRunner, runner),
586 Effect.mapError(
587 (cause) =>
588 new DeviceHost.DeviceHostError({
589 hostId,
590 step: "installing agent tools",
591 cause,
592 }),
593 ),
594 );
595 agentToolRef = agentTool;
596 yield* onPhase("starting");
597 const agentDevice = yield* startAgentDeviceDaemon(agentTool);
598 const next = { ...running, agentDevice };
599 yield* Ref.set(runningRef, next);
600 return { ...toReady(next), agentDevice };
601 }),
602 );
603
604 const helperPaths = (hubTool: DeviceToolPaths) => {
605 const serveSimDist = path.join(
606 hubTool.installDir,
607 "node_modules",
608 "expo-device-hub",
609 "vendor",
610 "serve-sim",
611 "dist",
612 );
613 return {
614 serveSimAxSettings: path.join(serveSimDist, "simax", "serve-sim-ax-settings"),
615 serveSimCli: path.join(serveSimDist, "serve-sim.js"),
616 };
617 };
618
619 const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) =>
620 runner
621 .run({
622 command:
623 command === "emulator" && sdk.root
624 ? path.join(
625 sdk.root,
626 "emulator",
627 hostPlatform === "win32" ? "emulator.exe" : "emulator",
628 )
629 : command,
630 args,
631 env: hostEnvironment,
632 timeout: Duration.millis(options?.timeoutMs ?? 20_000),
633 timeoutBehavior: "timedOutResult",
634 ...(options?.stdin === undefined ? {} : { stdin: options.stdin }),
635 })
636 .pipe(
637 Effect.map((result) => ({
638 stdout: result.stdout,
639 stderr: result.stderr,
640 code: Number(result.code),
641 })),
642 Effect.catch((cause) => Effect.succeed({ stdout: "", stderr: String(cause), code: 127 })),
643 );
644
645 const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({
646 hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint,
647 run,
648 helpers: running.helpers,
649 });
650
651 const current: DeviceHost.DeviceHost["Service"]["current"] = Ref.get(runningRef).pipe(
652 Effect.map((running) => (running ? toReady(running) : null)),
653 );
654
655 const stopAgent: DeviceHost.DeviceHost["Service"]["stopAgent"] = startLock.withPermits(1)(
656 Effect.gen(function* () {
657 yield* stopAgentDeviceDaemon(agentToolRef);
658 yield* Ref.update(runningRef, (running) =>
659 running ? { ...running, agentDevice: null } : running,
660 );
661 }),
662 );
663
664 const stop: DeviceHost.DeviceHost["Service"]["stop"] = startLock.withPermits(1)(
665 Effect.gen(function* () {
666 const running = yield* Ref.getAndSet(runningRef, null);
667 yield* stopHub(running?.hub);
668 yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore);
669 yield* stopAgentDeviceDaemon(agentToolRef);
670 }),
671 );
672
673 // Never leave the hub or daemon behind when the server's scope closes.
674 yield* Effect.addFinalizer(() => stop);
675
676 const host: DeviceHost.DeviceHost["Service"] = {
677 id: hostId,
678 summary,
679 platformAvailability,
680 ensureReady,
681 ensureAgentReady,
682 current,
683 stopAgent,
684 stop,
685 };
686 return host;
687});
688
689export const layer = Layer.effect(DeviceHost.DeviceHost, make());
690
691/** Exposed for tests. */
692export const __testing = {
693 AgentDeviceDaemonFile,
694 androidSdk,
695 platformReason,
696 deviceHostEnvironment,
697};
apps/server/src/mcp/McpDeviceToolkit.test.ts created+127
...@@ -0,0 +1,127 @@
1import { expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { McpSchema, McpServer } from "effect/unstable/ai";
7
8import * as DeviceService from "../device/DeviceService.ts";
9import * as McpHttpServer from "./McpHttpServer.ts";
10import * as McpInvocationContext from "./McpInvocationContext.ts";
11
12const environmentId = EnvironmentId.make("environment-device-test");
13const threadId = ThreadId.make("thread-device-test");
14const invocation = (capabilities: ReadonlyArray<McpInvocationContext.McpCapability>) => ({
15 environmentId,
16 threadId,
17 providerSessionId: "provider-session-device-test",
18 providerInstanceId: ProviderInstanceId.make("codex"),
19 capabilities: new Set(capabilities),
20 issuedAt: 1,
21});
22const client = McpSchema.McpServerClient.of({
23 clientId: 1,
24 clientCapabilities: {},
25 clientInfo: { name: "mcp-test", version: "1.0.0" },
26 protocolVersion: "2025-06-18",
27 initializePayload: {
28 protocolVersion: "2025-06-18",
29 capabilities: {},
30 clientInfo: { name: "mcp-test", version: "1.0.0" },
31 },
32 getClient: Effect.die("unused"),
33});
34
35const device = {
36 hostId: "local",
37 id: "UDID-1",
38 platform: "ios" as const,
39 name: "iPhone 17 Pro",
40 version: "iOS 27.0",
41 booted: true,
42 physical: false,
43};
44const state = {
45 hosts: [
46 {
47 id: "local",
48 kind: "local" as const,
49 label: "This machine",
50 platforms: [
51 { platform: "ios" as const, available: true },
52 { platform: "android" as const, available: false, reason: "No SDK" },
53 ],
54 hubInstalled: true,
55 agentDeviceInstalled: true,
56 },
57 ],
58 hostStatus: "ready" as const,
59 devices: [device],
60 sessions: [],
61 onboardingCompleted: true,
62 agentAccessEnabled: true,
63 hubBasePath: "/api/device-hub",
64 revision: 1,
65};
66const png = new Uint8Array(24);
67new DataView(png.buffer).setUint32(0, 0x89504e47);
68new DataView(png.buffer).setUint32(4, 0x0d0a1a0a);
69new DataView(png.buffer).setUint32(12, 0x49484452);
70new DataView(png.buffer).setUint32(16, 1206);
71new DataView(png.buffer).setUint32(20, 2622);
72
73const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({
74 state: Effect.succeed(state),
75 list: Effect.succeed(state),
76 open: (input) =>
77 Effect.succeed({
78 threadId: input.threadId,
79 hostId: "local",
80 deviceId: input.deviceId,
81 platform: input.platform,
82 openedAt: "2026-09-08T00:00:00.000Z",
83 }),
84 sessionsForThread: () => Effect.succeed([]),
85 screenshot: () => Effect.succeed({ device, png }),
86 close: () => Effect.void,
87});
88
89const TestLayer = McpHttpServer.DeviceToolkitRegistrationLive.pipe(
90 Layer.provideMerge(McpServer.McpServer.layer),
91 Layer.provideMerge(DeviceServiceMock),
92 Layer.provide(NodeServices.layer),
93);
94
95it.effect("registers the device tools and returns the screenshot as image content", () =>
96 Effect.scoped(
97 Effect.gen(function* () {
98 const server = yield* McpServer.McpServer;
99 const names = server.tools.map(({ tool }) => tool.name).toSorted();
100 expect(names).toEqual(["device_close", "device_list", "device_open", "device_screenshot"]);
101
102 const callWith = (capabilities: ReadonlyArray<McpInvocationContext.McpCapability>) =>
103 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities));
104
105 const opened = yield* server
106 .callTool({ name: "device_open", arguments: { platform: "ios" } })
107 .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client));
108 expect(opened.isError).toBe(false);
109 const openedContent = opened.structuredContent as { quickStart: string };
110 expect(openedContent.quickStart).toContain("--udid UDID-1");
111
112 const shot = yield* server
113 .callTool({ name: "device_screenshot", arguments: { deviceId: "UDID-1" } })
114 .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client));
115 expect(shot.isError).toBe(false);
116 expect(shot.content.map((entry) => entry.type)).toEqual(["text", "image"]);
117 expect(shot.structuredContent).toMatchObject({
118 screenshot: { mimeType: "image/png", width: 1206, height: 2622 },
119 });
120
121 const denied = yield* server
122 .callTool({ name: "device_list", arguments: {} })
123 .pipe(callWith(["preview"]), Effect.provideService(McpSchema.McpServerClient, client));
124 expect(denied.isError).toBe(true);
125 }),
126 ).pipe(Effect.provide(TestLayer)),
127);
apps/server/src/mcp/McpHttpServer.ts+172
...@@ -16,6 +16,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab...@@ -16,6 +16,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
1616
17import packageJson from "../../package.json" with { type: "json" };17import packageJson from "../../package.json" with { type: "json" };
18import * as ServerConfig from "../config.ts";18import * as ServerConfig from "../config.ts";
19import * as DeviceService from "../device/DeviceService.ts";
19import * as McpInvocationContext from "./McpInvocationContext.ts";20import * as McpInvocationContext from "./McpInvocationContext.ts";
20import * as McpSessionRegistry from "./McpSessionRegistry.ts";21import * as McpSessionRegistry from "./McpSessionRegistry.ts";
21import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";22import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
...@@ -30,6 +31,15 @@ import {...@@ -30,6 +31,15 @@ import {
30} from "./toolkits/preview/tools.ts";31} from "./toolkits/preview/tools.ts";
31import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts";32import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts";
32import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";33import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
34import {
35 DeviceScreenshotToolkitHandlersLive,
36 DeviceStandardToolkitHandlersLive,
37} from "./toolkits/device/handlers.ts";
38import {
39 DeviceScreenshotTool,
40 DeviceScreenshotToolkit,
41 DeviceStandardToolkit,
42} from "./toolkits/device/tools.ts";
3343
34const unauthorized = HttpServerResponse.jsonUnsafe(44const unauthorized = HttpServerResponse.jsonUnsafe(
35 {45 {
...@@ -426,6 +436,154 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot...@@ -426,6 +436,154 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot
426 });436 });
427});437});
428438
439interface ImageToolResult {
440 readonly screenshot: {
441 readonly mimeType: "image/png";
442 readonly data: string;
443 readonly width: number;
444 readonly height: number;
445 };
446 readonly [key: string]: unknown;
447}
448
449/**
450 * Failures surface only their tag: the remote message may carry renderer or
451 * device output the agent should not see, and the tag is what it can act on.
452 */
453const imageToolFailure =
454 (toolName: string, operation: string, failureText: string) =>
455 <E>(cause: Cause.Cause<E>) => {
456 if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) {
457 return Effect.failCause(cause).pipe(Effect.orDie);
458 }
459 const failures = cause.reasons.filter(Cause.isFailReason);
460 const firstFailure = failures[0]?.error;
461 const errorTag =
462 typeof firstFailure === "object" &&
463 firstFailure !== null &&
464 "_tag" in firstFailure &&
465 typeof firstFailure._tag === "string"
466 ? firstFailure._tag
467 : `${toolName}Error`;
468 const result = new McpSchema.CallToolResult({
469 isError: true,
470 structuredContent: {
471 error: {
472 _tag: errorTag,
473 operation,
474 failureCount: failures.length,
475 },
476 },
477 content: [{ type: "text", text: failureText }],
478 });
479 return Effect.logWarning(`${toolName} failed`, {
480 operation,
481 errorTag,
482 failureCount: failures.length,
483 }).pipe(Effect.as(result));
484 };
485
486/**
487 * `McpServer.toolkit` serializes every result as JSON text, which is the
488 * wrong shape for a screenshot: the model needs image content. Tools whose
489 * result carries a `screenshot` field are registered by hand so the PNG goes
490 * out as an image block and the rest of the payload as JSON metadata.
491 */
492const registerImageTool = <T extends Tool.Any, E, R>(
493 tool: T,
494 handle: (payload: Tool.Parameters<T>) => Effect.Effect<{ readonly encodedResult: unknown }, E, R>,
495 provide: (
496 effect: Effect.Effect<{ readonly encodedResult: unknown }, E, R>,
497 ) => Effect.Effect<
498 { readonly encodedResult: unknown },
499 E,
500 McpInvocationContext.McpInvocationContext
501 >,
502 operation: string,
503 failureText: string,
504) =>
505 Effect.gen(function* () {
506 const server = yield* McpServer.McpServer;
507 yield* server.addTool({
508 tool: new McpSchema.Tool({
509 name: tool.name,
510 description: Tool.getDescription(tool),
511 inputSchema: Tool.getJsonSchema(tool),
512 annotations: {
513 ...Context.getOption(tool.annotations, Tool.Title).pipe(
514 Option.map((title) => ({ title })),
515 Option.getOrUndefined,
516 ),
517 readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
518 destructiveHint: Context.get(tool.annotations, Tool.Destructive),
519 idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
520 openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
521 },
522 }),
523 annotations: tool.annotations,
524 handle: (payload) =>
525 Effect.withFiber((fiber) => {
526 const invocation = Context.getUnsafe(
527 fiber.context,
528 McpInvocationContext.McpInvocationContext,
529 );
530 return provide(handle(payload as Tool.Parameters<T>)).pipe(
531 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
532 Effect.matchCauseEffect({
533 onFailure: imageToolFailure(tool.name, operation, failureText),
534 onSuccess: ({ encodedResult }) => {
535 const { screenshot, ...rest } = encodedResult as ImageToolResult;
536 const includeImage =
537 (payload as { readonly includeImage?: boolean } | undefined)?.includeImage !==
538 false;
539 const metadata = {
540 ...rest,
541 screenshot: {
542 mimeType: screenshot.mimeType,
543 width: screenshot.width,
544 height: screenshot.height,
545 },
546 };
547 return Effect.succeed(
548 new McpSchema.CallToolResult({
549 isError: false,
550 structuredContent: metadata,
551 content: [
552 { type: "text", text: JSON.stringify(metadata) },
553 ...(includeImage
554 ? [
555 {
556 type: "image" as const,
557 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
558 mimeType: screenshot.mimeType,
559 },
560 ]
561 : []),
562 ],
563 }),
564 );
565 },
566 }),
567 );
568 }),
569 });
570 });
571
572const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreenshot")(function* () {
573 const devices = yield* DeviceService.DeviceService;
574 const built = yield* DeviceScreenshotToolkit;
575 yield* registerImageTool(
576 DeviceScreenshotTool,
577 (payload) =>
578 built
579 .handle("device_screenshot", payload)
580 .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)),
581 (effect) => effect.pipe(Effect.provideService(DeviceService.DeviceService, devices)),
582 "screenshot",
583 "Device screenshot failed.",
584 );
585});
586
429const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandardToolkit).pipe(587const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandardToolkit).pipe(
430 Layer.provide(PreviewStandardToolkitHandlersLive),588 Layer.provide(PreviewStandardToolkitHandlersLive),
431);589);
...@@ -443,6 +601,19 @@ export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequest...@@ -443,6 +601,19 @@ export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequest
443 Layer.provide(PullRequestsToolkitHandlersLive),601 Layer.provide(PullRequestsToolkitHandlersLive),
444);602);
445603
604const DeviceStandardToolkitRegistrationLive = McpServer.toolkit(DeviceStandardToolkit).pipe(
605 Layer.provide(DeviceStandardToolkitHandlersLive),
606);
607
608const DeviceScreenshotRegistrationLive = Layer.effectDiscard(registerDeviceScreenshot()).pipe(
609 Layer.provide(DeviceScreenshotToolkitHandlersLive),
610);
611
612export const DeviceToolkitRegistrationLive = Layer.mergeAll(
613 DeviceStandardToolkitRegistrationLive,
614 DeviceScreenshotRegistrationLive,
615);
616
446const McpTransportLive = McpServer.layerHttp({617const McpTransportLive = McpServer.layerHttp({
447 name: "T3 Code",618 name: "T3 Code",
448 version: packageJson.version,619 version: packageJson.version,
...@@ -453,4 +624,5 @@ const McpTransportLive = McpServer.layerHttp({...@@ -453,4 +624,5 @@ const McpTransportLive = McpServer.layerHttp({
453export const layer = Layer.mergeAll(624export const layer = Layer.mergeAll(
454 PreviewToolkitRegistrationLive,625 PreviewToolkitRegistrationLive,
455 PullRequestsToolkitRegistrationLive,626 PullRequestsToolkitRegistrationLive,
627 DeviceToolkitRegistrationLive,
456).pipe(Layer.provideMerge(McpTransportLive));628).pipe(Layer.provideMerge(McpTransportLive));
apps/server/src/mcp/McpInvocationContext.ts+1-1
...@@ -8,7 +8,7 @@ import {...@@ -8,7 +8,7 @@ import {
8import * as Context from "effect/Context";8import * as Context from "effect/Context";
9import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
1010
11export type McpCapability = "preview" | "pull-requests";11export type McpCapability = "preview" | "device" | "pull-requests";
1212
13export interface McpInvocationScope {13export interface McpInvocationScope {
14 readonly environmentId: EnvironmentId;14 readonly environmentId: EnvironmentId;
apps/server/src/mcp/McpProviderSession.test.ts created+30
...@@ -0,0 +1,30 @@
1import { describe, expect, it } from "vite-plus/test";
2import { withAgentDeviceEnvironment } from "./McpProviderSession.ts";
3
4describe("device CLI environment", () => {
5 it("preserves provider credentials and commands while routing devices to the owned daemon", () => {
6 const environment = withAgentDeviceEnvironment(
7 { PATH: "/provider/bin:/usr/bin", PROVIDER_KEY: "fixture" },
8 {
9 agentDeviceEnvironment: {
10 PATH: "/t3/device/bin",
11 PATH_SEPARATOR: ":",
12 AGENT_DEVICE_DAEMON_BASE_URL: "http://127.0.0.1:9000",
13 AGENT_DEVICE_DAEMON_AUTH_TOKEN: "fixture-device",
14 },
15 },
16 );
17 expect(environment).toEqual({
18 PATH: "/t3/device/bin:/provider/bin:/usr/bin",
19 PROVIDER_KEY: "fixture",
20 AGENT_DEVICE_DAEMON_BASE_URL: "http://127.0.0.1:9000",
21 AGENT_DEVICE_DAEMON_AUTH_TOKEN: "fixture-device",
22 });
23 });
24
25 it("does not grant CLI access when device access was not supplied", () => {
26 const environment = { PATH: "/usr/bin", PROVIDER_KEY: "fixture" };
27 expect(withAgentDeviceEnvironment(environment, undefined)).toBe(environment);
28 expect(withAgentDeviceEnvironment(environment, {})).toBe(environment);
29 });
30});
apps/server/src/mcp/McpProviderSession.ts+25-2
...@@ -7,8 +7,31 @@ export interface McpProviderSessionConfig {...@@ -7,8 +7,31 @@ export interface McpProviderSessionConfig {
7 readonly providerInstanceId: ProviderInstanceId;7 readonly providerInstanceId: ProviderInstanceId;
8 readonly endpoint: string;8 readonly endpoint: string;
9 readonly authorizationHeader: string;9 readonly authorizationHeader: string;
10 /** Whether the credential grants the preview (browser) toolkit; the pull request toolkit always is. */10 /** Capabilities the credential grants ("preview", "device"). */
11 readonly preview: boolean;11 readonly capabilities: ReadonlySet<string>;
12 /**
13 * Set when the session may drive devices. Adapters spread this into the
14 * provider subprocess environment so the `agent-device` CLI is on PATH and
15 * already pointed at the server's daemon; the agent never handles a token.
16 */
17 readonly agentDeviceEnvironment?: Readonly<Record<string, string>>;
18}
19
20/** Provider env with the device variables applied over `base`, or `base` untouched. */
21export function withAgentDeviceEnvironment(
22 base: NodeJS.ProcessEnv,
23 config: Pick<McpProviderSessionConfig, "agentDeviceEnvironment"> | undefined,
24): NodeJS.ProcessEnv {
25 const extra = config?.agentDeviceEnvironment;
26 if (!extra) return base;
27 const separator = extra.PATH_SEPARATOR ?? ":";
28 const basePath = base.PATH ?? base.Path;
29 const { PATH: shimDir, PATH_SEPARATOR: _separator, ...rest } = extra;
30 return {
31 ...base,
32 ...rest,
33 ...(shimDir ? { PATH: basePath ? `${shimDir}${separator}${basePath}` : shimDir } : {}),
34 };
12}35}
1336
14const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>();37const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>();
apps/server/src/mcp/McpSessionRegistry.test.ts+14-8
...@@ -39,7 +39,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t...@@ -39,7 +39,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t
39 const issued = yield* registry.issue({39 const issued = yield* registry.issue({
40 threadId,40 threadId,
41 providerInstanceId: ProviderInstanceId.make("codex"),41 providerInstanceId: ProviderInstanceId.make("codex"),
42 preview: true,42 capabilities: new Set(["preview"]),
43 });43 });
44 expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp");44 expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp");
45 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");45 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
...@@ -55,18 +55,23 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t...@@ -55,18 +55,23 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t
55 }),55 }),
56);56);
5757
58it.effect("always grants pull-requests and gates preview on the request", () =>58it.effect("always grants pull-requests and gates browser and device access independently", () =>
59 Effect.gen(function* () {59 Effect.gen(function* () {
60 const registry = yield* makeRegistry(() => 1_000);60 const registry = yield* makeRegistry(() => 1_000);
61 const withPreview = yield* registry.issue({61 const withPreview = yield* registry.issue({
62 threadId: ThreadId.make("thread-preview"),62 threadId: ThreadId.make("thread-preview"),
63 providerInstanceId: ProviderInstanceId.make("codex"),63 providerInstanceId: ProviderInstanceId.make("codex"),
64 preview: true,64 capabilities: new Set(["preview"]),
65 });65 });
66 const withoutPreview = yield* registry.issue({66 const withoutPreview = yield* registry.issue({
67 threadId: ThreadId.make("thread-no-preview"),67 threadId: ThreadId.make("thread-no-preview"),
68 providerInstanceId: ProviderInstanceId.make("codex"),68 providerInstanceId: ProviderInstanceId.make("codex"),
69 preview: false,69 capabilities: new Set(),
70 });
71 const withDevice = yield* registry.issue({
72 threadId: ThreadId.make("thread-device"),
73 providerInstanceId: ProviderInstanceId.make("codex"),
74 capabilities: new Set(["device"]),
70 });75 });
71 const capabilitiesOf = (issued: typeof withPreview) =>76 const capabilitiesOf = (issued: typeof withPreview) =>
72 registry77 registry
...@@ -75,6 +80,7 @@ it.effect("always grants pull-requests and gates preview on the request", () =>...@@ -75,6 +80,7 @@ it.effect("always grants pull-requests and gates preview on the request", () =>
7580
76 expect(yield* capabilitiesOf(withPreview)).toEqual(["preview", "pull-requests"]);81 expect(yield* capabilitiesOf(withPreview)).toEqual(["preview", "pull-requests"]);
77 expect(yield* capabilitiesOf(withoutPreview)).toEqual(["pull-requests"]);82 expect(yield* capabilitiesOf(withoutPreview)).toEqual(["pull-requests"]);
83 expect(yield* capabilitiesOf(withDevice)).toEqual(["device", "pull-requests"]);
78 }),84 }),
79);85);
8086
...@@ -92,7 +98,7 @@ it.effect("builds MCP endpoints from the bound server host", () =>...@@ -92,7 +98,7 @@ it.effect("builds MCP endpoints from the bound server host", () =>
92 const issued = yield* registry.issue({98 const issued = yield* registry.issue({
93 threadId: ThreadId.make(`thread-${hostname}`),99 threadId: ThreadId.make(`thread-${hostname}`),
94 providerInstanceId: ProviderInstanceId.make("codex"),100 providerInstanceId: ProviderInstanceId.make("codex"),
95 preview: true,101 capabilities: new Set(["preview"]),
96 });102 });
97 expect(issued.config.endpoint).toBe(expectedEndpoint);103 expect(issued.config.endpoint).toBe(expectedEndpoint);
98 }104 }
...@@ -106,7 +112,7 @@ it.effect("expires credentials once their session stops showing signs of life",...@@ -106,7 +112,7 @@ it.effect("expires credentials once their session stops showing signs of life",
106 const issued = yield* registry.issue({112 const issued = yield* registry.issue({
107 threadId: ThreadId.make("thread-2"),113 threadId: ThreadId.make("thread-2"),
108 providerInstanceId: ProviderInstanceId.make("claude"),114 providerInstanceId: ProviderInstanceId.make("claude"),
109 preview: true,115 capabilities: new Set(["preview"]),
110 });116 });
111 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");117 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
112 timestamp += 101;118 timestamp += 101;
...@@ -122,7 +128,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool",...@@ -122,7 +128,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool",
122 const issued = yield* registry.issue({128 const issued = yield* registry.issue({
123 threadId,129 threadId,
124 providerInstanceId: ProviderInstanceId.make("claude"),130 providerInstanceId: ProviderInstanceId.make("claude"),
125 preview: true,131 capabilities: new Set(["preview"]),
126 });132 });
127 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");133 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
128134
...@@ -144,7 +150,7 @@ it.effect("does not keep credentials of other threads alive", () =>...@@ -144,7 +150,7 @@ it.effect("does not keep credentials of other threads alive", () =>
144 const issued = yield* registry.issue({150 const issued = yield* registry.issue({
145 threadId: ThreadId.make("thread-4"),151 threadId: ThreadId.make("thread-4"),
146 providerInstanceId: ProviderInstanceId.make("codex"),152 providerInstanceId: ProviderInstanceId.make("codex"),
147 preview: true,153 capabilities: new Set(["preview"]),
148 });154 });
149 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");155 const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
150156
apps/server/src/mcp/McpSessionRegistry.ts+6-9
...@@ -14,11 +14,7 @@ import * as McpProviderSession from "./McpProviderSession.ts";...@@ -14,11 +14,7 @@ import * as McpProviderSession from "./McpProviderSession.ts";
14export interface McpCredentialRequest {14export interface McpCredentialRequest {
15 readonly threadId: ThreadId;15 readonly threadId: ThreadId;
16 readonly providerInstanceId: ProviderInstanceId;16 readonly providerInstanceId: ProviderInstanceId;
17 /**17 readonly capabilities: ReadonlySet<McpInvocationContext.McpCapability>;
18 * Whether the credential may drive the user's browser. The pull request
19 * toolkit is always granted: it only touches the thread's own links.
20 */
21 readonly preview: boolean;
22}18}
2319
24export interface McpIssuedCredential {20export interface McpIssuedCredential {
...@@ -133,9 +129,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -133,9 +129,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
133 threadId: ThreadId.make(request.threadId),129 threadId: ThreadId.make(request.threadId),
134 providerSessionId,130 providerSessionId,
135 providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),131 providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
136 capabilities: new Set<McpInvocationContext.McpCapability>(132 capabilities: new Set<McpInvocationContext.McpCapability>([
137 request.preview ? ["pull-requests", "preview"] : ["pull-requests"],133 "pull-requests",
138 ),134 ...request.capabilities,
135 ]),
139 issuedAt,136 issuedAt,
140 };137 };
141 yield* SynchronizedRef.update(state, ({ records }) => {138 yield* SynchronizedRef.update(state, ({ records }) => {
...@@ -151,7 +148,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (...@@ -151,7 +148,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
151 providerInstanceId: scope.providerInstanceId,148 providerInstanceId: scope.providerInstanceId,
152 endpoint,149 endpoint,
153 authorizationHeader: `Bearer ${rawToken}`,150 authorizationHeader: `Bearer ${rawToken}`,
154 preview: request.preview,151 capabilities: scope.capabilities,
155 },152 },
156 };153 };
157 },154 },
apps/server/src/mcp/toolkits/device/handlers.test.ts created+43
...@@ -0,0 +1,43 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { agentDeviceQuickStart, agentDeviceTargetArgs, pngDimensions } from "./handlers.ts";
4
5const device = {
6 hostId: "local",
7 id: "ABCD-1234",
8 platform: "ios" as const,
9 name: "iPhone 17 Pro",
10 version: "iOS 27.0",
11 booted: true,
12 physical: false,
13};
14
15describe("device tool helpers", () => {
16 it("pins agent-device commands to the device by platform-specific flag", () => {
17 expect(agentDeviceTargetArgs(device)).toEqual(["--platform", "ios", "--udid", "ABCD-1234"]);
18 expect(agentDeviceTargetArgs({ ...device, platform: "android", id: "emulator-5554" })).toEqual([
19 "--platform",
20 "android",
21 "--serial",
22 "emulator-5554",
23 ]);
24 });
25
26 it("writes the quick start around the pinned target", () => {
27 const text = agentDeviceQuickStart(device);
28 expect(text).toContain("agent-device snapshot -i --platform ios --udid ABCD-1234");
29 expect(text).toContain("iPhone 17 Pro (iOS 27.0)");
30 expect(text).toContain("XCTest runner");
31 });
32
33 it("reads PNG dimensions from the IHDR chunk", () => {
34 const png = new Uint8Array(24);
35 new DataView(png.buffer).setUint32(0, 0x89504e47);
36 new DataView(png.buffer).setUint32(4, 0x0d0a1a0a);
37 new DataView(png.buffer).setUint32(12, 0x49484452);
38 new DataView(png.buffer).setUint32(16, 1179);
39 new DataView(png.buffer).setUint32(20, 2556);
40 expect(pngDimensions(png)).toEqual({ width: 1179, height: 2556 });
41 expect(pngDimensions(new Uint8Array([1, 2, 3]))).toEqual({ width: 0, height: 0 });
42 });
43});
apps/server/src/mcp/toolkits/device/handlers.ts created+212
...@@ -0,0 +1,212 @@
1import {
2 type DeviceError,
3 type DeviceHostId,
4 type DeviceId,
5 type DevicePlatform,
6 type DeviceSummary,
7 DeviceToolUnavailableError,
8 LOCAL_DEVICE_HOST_ID,
9} from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11
12import * as DeviceService from "../../../device/DeviceService.ts";
13import * as McpInvocationContext from "../../McpInvocationContext.ts";
14import { DeviceScreenshotToolkit, DeviceStandardToolkit, DeviceToolkit } from "./tools.ts";
15
16/** The flags that pin every agent-device command to one device. */
17export function agentDeviceTargetArgs(device: DeviceSummary): ReadonlyArray<string> {
18 return device.platform === "ios"
19 ? ["--platform", "ios", "--udid", device.id]
20 : ["--platform", "android", "--serial", device.id];
21}
22
23/**
24 * Just-in-time guidance returned from `device_open`. This is the one place
25 * the agent learns how to drive the device, so it lives with the tool result
26 * rather than in the always-on prompt block; threads that never open a device
27 * never pay for it.
28 */
29export function agentDeviceQuickStart(device: DeviceSummary): string {
30 const target = agentDeviceTargetArgs(device).join(" ");
31 const platformNotes =
32 device.platform === "ios"
33 ? "First use builds an XCTest runner and can take a couple of minutes; later commands are fast."
34 : "The Android snapshot helper installs itself on first use.";
35 return [
36 `The user is watching ${device.name} (${device.version}) in the Device panel.`,
37 `Drive it with the agent-device CLI, which is on PATH and already connected to this environment. Always pass ${target}.`,
38 "Typical loop:",
39 ` agent-device open <bundle-or-package-id> ${target} # or: open <app> <deep-link-url>`,
40 ` agent-device snapshot -i ${target} # accessibility tree with @eN refs`,
41 ` agent-device click @e3 ${target}`,
42 ` agent-device fill @e5 "text" ${target}`,
43 ` agent-device screenshot /tmp/shot.png ${target} # or call device_screenshot`,
44 ` agent-device install <app> <path-to-.app-or-.apk> ${target}`,
45 "Prefer snapshot refs over coordinates. Run `agent-device help` for workflow guides and `agent-device <command> --help` for flags.",
46 "Do not call simctl, adb, xcrun, or serve-sim directly while these tools are attached; use agent-device.",
47 platformNotes,
48 ].join("\n");
49}
50
51const requireDeviceAccess = McpInvocationContext.requireMcpCapability("device").pipe(
52 Effect.mapError(
53 () =>
54 new DeviceToolUnavailableError({
55 reason: "Agent device access is turned off for this environment.",
56 }),
57 ),
58);
59
60const pickDevice = (
61 devices: ReadonlyArray<DeviceSummary>,
62 input: {
63 readonly deviceId?: DeviceId | undefined;
64 readonly platform?: DevicePlatform | undefined;
65 readonly hostId?: DeviceHostId | undefined;
66 },
67): Effect.Effect<DeviceSummary, DeviceToolUnavailableError> =>
68 Effect.gen(function* () {
69 const hostId = input.hostId ?? LOCAL_DEVICE_HOST_ID;
70 if (input.deviceId !== undefined) {
71 const match = devices.find(
72 (device) => device.hostId === hostId && device.id === input.deviceId,
73 );
74 if (match) return match;
75 return yield* new DeviceToolUnavailableError({
76 reason: `No device ${input.deviceId} on host ${hostId}. Call device_list for current ids.`,
77 });
78 }
79 const candidates = devices.filter(
80 (device) =>
81 device.hostId === hostId &&
82 (input.platform === undefined || device.platform === input.platform),
83 );
84 if (candidates.length === 0) {
85 return yield* new DeviceToolUnavailableError({
86 reason:
87 input.platform === undefined
88 ? "No simulators or emulators were found. Call device_list to see why."
89 : `No ${input.platform} devices were found on host ${hostId}. Call device_list to see why.`,
90 });
91 }
92 const platforms = new Set(candidates.map((device) => device.platform));
93 if (input.platform === undefined && platforms.size > 1) {
94 return yield* new DeviceToolUnavailableError({
95 reason: "Both iOS and Android devices are available; pass platform or deviceId.",
96 });
97 }
98 return candidates.find((device) => device.booted) ?? candidates[0]!;
99 });
100
101const toolError = (error: DeviceError | DeviceToolUnavailableError) => error;
102
103const handlers = {
104 device_list: (input) =>
105 Effect.gen(function* () {
106 const scope = yield* requireDeviceAccess;
107 const devices = yield* DeviceService.DeviceService;
108 const state = yield* devices.list;
109 if (state.hostStatus === "disabled") {
110 return yield* new DeviceToolUnavailableError({
111 reason:
112 "Device support is off. Ask the user to enable it in the Device panel before installing or starting device tools.",
113 });
114 }
115 const hostId = input?.hostId;
116 const open = state.sessions
117 .filter((session) => session.threadId === scope.threadId)
118 .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId }));
119 return {
120 hosts: hostId ? state.hosts.filter((host) => host.id === hostId) : state.hosts,
121 devices: hostId
122 ? state.devices.filter((device) => device.hostId === hostId)
123 : state.devices,
124 open,
125 };
126 }).pipe(Effect.mapError(toolError)),
127 device_open: (input) =>
128 Effect.gen(function* () {
129 const scope = yield* requireDeviceAccess;
130 const devices = yield* DeviceService.DeviceService;
131 const state = yield* devices.list;
132 if (state.hostStatus === "disabled") {
133 return yield* new DeviceToolUnavailableError({
134 reason:
135 "Device support is off. Ask the user to enable it in the Device panel before installing or starting device tools.",
136 });
137 }
138 const target = yield* pickDevice(state.devices, input);
139 const session = yield* devices.open({
140 threadId: scope.threadId,
141 hostId: target.hostId,
142 deviceId: target.id,
143 platform: target.platform,
144 });
145 const after = yield* devices.state;
146 const device =
147 after.devices.find(
148 (candidate) => candidate.hostId === session.hostId && candidate.id === session.deviceId,
149 ) ?? target;
150 return {
151 device,
152 agentDevice: { command: "agent-device", targetArgs: agentDeviceTargetArgs(device) },
153 quickStart: agentDeviceQuickStart(device),
154 };
155 }).pipe(Effect.mapError(toolError)),
156 device_screenshot: (input) =>
157 Effect.gen(function* () {
158 const scope = yield* requireDeviceAccess;
159 const devices = yield* DeviceService.DeviceService;
160 const sessions = yield* devices.sessionsForThread(scope.threadId);
161 const target =
162 input.deviceId !== undefined
163 ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId }
164 : sessions.at(-1);
165 if (!target) {
166 return yield* new DeviceToolUnavailableError({
167 reason: "No device is open in this thread. Call device_open first.",
168 });
169 }
170 const shot = yield* devices.screenshot(target);
171 return {
172 device: shot.device,
173 screenshot: {
174 mimeType: "image/png" as const,
175 data: Buffer.from(shot.png).toString("base64"),
176 ...pngDimensions(shot.png),
177 },
178 };
179 }).pipe(Effect.mapError(toolError)),
180 device_close: (input) =>
181 Effect.gen(function* () {
182 const scope = yield* requireDeviceAccess;
183 const devices = yield* DeviceService.DeviceService;
184 yield* devices.close({
185 threadId: scope.threadId,
186 ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }),
187 ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }),
188 });
189 return {};
190 }).pipe(Effect.mapError(toolError)),
191} satisfies Parameters<typeof DeviceToolkit.toLayer>[0];
192
193/** Width and height from the IHDR chunk; a PNG that lacks one reports 0×0. */
194export function pngDimensions(png: Uint8Array): { width: number; height: number } {
195 if (png.length < 24) return { width: 0, height: 0 };
196 const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
197 const isPng =
198 view.getUint32(0) === 0x89504e47 &&
199 view.getUint32(4) === 0x0d0a1a0a &&
200 view.getUint32(12) === 0x49484452;
201 return isPng
202 ? { width: view.getUint32(16), height: view.getUint32(20) }
203 : { width: 0, height: 0 };
204}
205
206const { device_screenshot, ...standardHandlers } = handlers;
207
208export const DeviceStandardToolkitHandlersLive = DeviceStandardToolkit.toLayer(standardHandlers);
209
210export const DeviceScreenshotToolkitHandlersLive = DeviceScreenshotToolkit.toLayer({
211 device_screenshot,
212});
apps/server/src/mcp/toolkits/device/tools.ts created+98
...@@ -0,0 +1,98 @@
1import {
2 DeviceToolCloseInput,
3 DeviceToolError,
4 DeviceToolListResult,
5 DeviceToolOpenInput,
6 DeviceToolOpenResult,
7 DeviceToolScreenshotResult,
8 DeviceToolTargetInput,
9} from "@t3tools/contracts";
10import * as Schema from "effect/Schema";
11import { Tool, Toolkit } from "effect/unstable/ai";
12
13import * as McpInvocationContext from "../../McpInvocationContext.ts";
14import * as DeviceService from "../../../device/DeviceService.ts";
15
16const dependencies = [McpInvocationContext.McpInvocationContext, DeviceService.DeviceService];
17
18/**
19 * Deliberately a small surface: lifecycle, visibility for the user, and one
20 * image-returning verb. Driving the device (taps, typing, install, logs)
21 * happens through the preconfigured `agent-device` CLI, which has the
22 * semantic snapshot model agents need and stays current with its own
23 * releases. Wrapping its commands here would only lag behind it.
24 */
25const DeviceListTool = Tool.make("device_list", {
26 description:
27 "List iOS Simulators and Android Emulators on this environment's device hosts, which platforms each host can run, and which devices are already open in this thread's Device panel. Call this before device_open when you do not know a device id.",
28 // An empty struct serializes as `anyOf [object, array]`, which some
29 // providers reject and then drop every tool on the server with it.
30 parameters: Schema.Struct({
31 hostId: Schema.optional(
32 Schema.String.annotate({ description: "Limit to one device host. Defaults to all hosts." }),
33 ),
34 }),
35 success: DeviceToolListResult,
36 failure: DeviceToolError,
37 dependencies,
38})
39 .annotate(Tool.Title, "List devices")
40 .annotate(Tool.Readonly, true)
41 .annotate(Tool.Destructive, false)
42 .annotate(Tool.Idempotent, true)
43 .annotate(Tool.OpenWorld, false);
44
45const DeviceOpenTool = Tool.make("device_open", {
46 description:
47 "Open a simulator or emulator for this thread: boots it if needed, starts its live stream, and shows it in the user's Device panel so they can watch. Returns the agent-device CLI invocation pinned to the device; drive the device with that CLI afterwards.",
48 parameters: DeviceToolOpenInput,
49 success: DeviceToolOpenResult,
50 failure: DeviceToolError,
51 dependencies,
52})
53 .annotate(Tool.Title, "Open device")
54 .annotate(Tool.Readonly, false)
55 .annotate(Tool.Destructive, false)
56 .annotate(Tool.Idempotent, true)
57 .annotate(Tool.OpenWorld, true);
58
59export const DeviceScreenshotTool = Tool.make("device_screenshot", {
60 description:
61 "Capture the current screen of an open device as a PNG image. Use it to see what the user sees; for taps and text use the agent-device CLI.",
62 parameters: DeviceToolTargetInput,
63 success: DeviceToolScreenshotResult,
64 failure: DeviceToolError,
65 dependencies,
66})
67 .annotate(Tool.Title, "Screenshot device")
68 .annotate(Tool.Readonly, true)
69 .annotate(Tool.Destructive, false)
70 .annotate(Tool.Idempotent, true)
71 .annotate(Tool.OpenWorld, true);
72
73const DeviceCloseTool = Tool.make("device_close", {
74 description:
75 "Remove a device from this thread's Device panel. Pass shutdown=true to also power the simulator or emulator off.",
76 parameters: DeviceToolCloseInput,
77 success: Schema.Record(Schema.String, Schema.Never).annotate({
78 description: "The device was closed.",
79 }),
80 failure: DeviceToolError,
81 dependencies,
82})
83 .annotate(Tool.Title, "Close device")
84 .annotate(Tool.Readonly, false)
85 .annotate(Tool.Destructive, true)
86 .annotate(Tool.Idempotent, true)
87 .annotate(Tool.OpenWorld, true);
88
89export const DeviceStandardToolkit = Toolkit.make(DeviceListTool, DeviceOpenTool, DeviceCloseTool);
90
91export const DeviceScreenshotToolkit = Toolkit.make(DeviceScreenshotTool);
92
93export const DeviceToolkit = Toolkit.make(
94 DeviceListTool,
95 DeviceOpenTool,
96 DeviceScreenshotTool,
97 DeviceCloseTool,
98);
apps/server/src/provider/CodexDeveloperInstructions.ts+30-9
...@@ -12,18 +12,39 @@ For browser work, first call \`preview_status\`. If no automation-capable previe...@@ -12,18 +12,39 @@ For browser work, first call \`preview_status\`. If no automation-capable previe
12Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable.12Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable.
13`;13`;
1414
15const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `
16
17## T3 Code devices
18
19The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH and already connected: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route.
20`;
21
22export interface T3CodeToolAvailability {
23 readonly browser: boolean;
24 readonly device: boolean;
25}
26
27const normalizeAvailability = (
28 availability: boolean | T3CodeToolAvailability,
29): T3CodeToolAvailability =>
30 typeof availability === "boolean" ? { browser: availability, device: false } : availability;
31
15/**32/**
16 * The browser block is omitted entirely when the preview tools aren't attached.33 * Each block is omitted entirely when its tools aren't attached. Describing
17 * Describing `preview_*` tools that aren't in the turn's tool list would be34 * `preview_*` or `device_*` tools that aren't in the turn's tool list would be
18 * worse than saying nothing: the instructions actively steer the model away35 * worse than saying nothing: the instructions actively steer the model away
19 * from Playwright and agent-browser, so leaving them in would talk it out of36 * from Playwright, agent-browser, and raw simctl/adb, so leaving them in would
20 * the only browser automation it still has.37 * talk it out of the only automation it still has.
21 */38 */
22const browserToolInstructions = (browserToolsAvailable: boolean): string =>39const browserToolInstructions = (availability: boolean | T3CodeToolAvailability): string => {
23 browserToolsAvailable ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : "";40 const tools = normalizeAvailability(availability);
41 return `${tools.browser ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : ""}${
42 tools.device ? T3_CODE_DEVICE_TOOL_INSTRUCTIONS : ""
43 }`;
44};
2445
25const codexPlanModeDeveloperInstructions = (46const codexPlanModeDeveloperInstructions = (
26 browserToolsAvailable: boolean,47 browserToolsAvailable: boolean | T3CodeToolAvailability,
27): string => `<collaboration_mode># Plan Mode (Conversational)48): string => `<collaboration_mode># Plan Mode (Conversational)
2849
29You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions.50You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions.
...@@ -156,7 +177,7 @@ ${browserToolInstructions(browserToolsAvailable)}...@@ -156,7 +177,7 @@ ${browserToolInstructions(browserToolsAvailable)}
156</collaboration_mode>`;177</collaboration_mode>`;
157178
158const codexDefaultModeDeveloperInstructions = (179const codexDefaultModeDeveloperInstructions = (
159 browserToolsAvailable: boolean,180 browserToolsAvailable: boolean | T3CodeToolAvailability,
160): string => `<collaboration_mode># Collaboration Mode: Default181): string => `<collaboration_mode># Collaboration Mode: Default
161182
162You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.183You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.
...@@ -184,7 +205,7 @@ export function buildCodexDeveloperInstructions(...@@ -184,7 +205,7 @@ export function buildCodexDeveloperInstructions(
184 * it from the session's actual MCP configuration rather than re-reading the205 * it from the session's actual MCP configuration rather than re-reading the
185 * setting, so the prompt cannot claim tools the turn doesn't have.206 * setting, so the prompt cannot claim tools the turn doesn't have.
186 */207 */
187 browserToolsAvailable = true,208 browserToolsAvailable: boolean | T3CodeToolAvailability = true,
188): string {209): string {
189 const base =210 const base =
190 interactionMode === "plan"211 interactionMode === "plan"
apps/server/src/provider/Drivers/AntigravityDriver.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { withAgentDeviceEnvironment } from "../../mcp/McpProviderSession.ts";
1import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";2import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Crypto from "effect/Crypto";4import * as Crypto from "effect/Crypto";
...@@ -162,7 +163,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD...@@ -162,7 +163,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
162 installation: executable,163 installation: executable,
163 profile,164 profile,
164 cwd: input.cwd,165 cwd: input.cwd,
165 baseEnv: processEnvironment,166 baseEnv: withAgentDeviceEnvironment(processEnvironment, input),
166 auth,167 auth,
167 }),168 }),
168 }).pipe(Effect.provideService(Crypto.Crypto, crypto));169 }).pipe(Effect.provideService(Crypto.Crypto, crypto));
apps/server/src/provider/Layers/AntigravityAdapter.ts+3
...@@ -793,6 +793,9 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi...@@ -793,6 +793,9 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
793 cwd,793 cwd,
794 clientInfo: { name: "t3-code", version: "0.0.0" },794 clientInfo: { name: "t3-code", version: "0.0.0" },
795 clientFileSystem: true,795 clientFileSystem: true,
796 ...(mcp?.agentDeviceEnvironment
797 ? { agentDeviceEnvironment: mcp.agentDeviceEnvironment }
798 : {}),
796 additionalDirectories: [serverConfig.attachmentsDir],799 additionalDirectories: [serverConfig.attachmentsDir],
797 ...(Option.isSome(cursor) ? { resumeSessionId: cursor.value.sessionId } : {}),800 ...(Option.isSome(cursor) ? { resumeSessionId: cursor.value.sessionId } : {}),
798 mcpServers: mcp801 mcpServers: mcp
apps/server/src/provider/Layers/ClaudeAdapter.ts+1-1
...@@ -4696,7 +4696,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4696,7 +4696,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4696 canUseTool,4696 canUseTool,
4697 onUserDialog,4697 onUserDialog,
4698 supportedDialogKinds: ["resume_return"],4698 supportedDialogKinds: ["resume_return"],
4699 env: claudeEnvironment,4699 env: McpProviderSession.withAgentDeviceEnvironment(claudeEnvironment, mcpSession),
4700 additionalDirectories,4700 additionalDirectories,
4701 ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),4701 ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
4702 ...(mcpSession4702 ...(mcpSession
apps/server/src/provider/Layers/CodexAdapter.ts+5-2
...@@ -2274,7 +2274,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2274,7 +2274,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2274 ...(mcpSession2274 ...(mcpSession
2275 ? {2275 ? {
2276 environment: {2276 environment: {
2277 ...(options?.environment ?? process.env),2277 ...McpProviderSession.withAgentDeviceEnvironment(
2278 options?.environment ?? process.env,
2279 mcpSession,
2280 ),
2278 T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""),2281 T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""),
2279 },2282 },
2280 appServerArgs: [2283 appServerArgs: [
...@@ -2283,7 +2286,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2283,7 +2286,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2283 "-c",2286 "-c",
2284 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"',2287 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"',
2285 ],2288 ],
2286 browserToolsAvailable: mcpSession.preview,2289 mcpCapabilities: mcpSession.capabilities,
2287 }2290 }
2288 : {}),2291 : {}),
2289 };2292 };
apps/server/src/provider/Layers/CodexSessionRuntime.ts+22-13
...@@ -39,7 +39,10 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema";...@@ -39,7 +39,10 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema";
39import { buildCodexInitializeParams } from "./CodexProvider.ts";39import { buildCodexInitializeParams } from "./CodexProvider.ts";
40import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";40import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
41import { expandHomePath } from "../../pathExpansion.ts";41import { expandHomePath } from "../../pathExpansion.ts";
42import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts";42import {
43 buildCodexDeveloperInstructions,
44 type T3CodeToolAvailability,
45} from "../CodexDeveloperInstructions.ts";
43const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse);46const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse);
4447
45const PROVIDER = ProviderDriverKind.make("codex");48const PROVIDER = ProviderDriverKind.make("codex");
...@@ -66,6 +69,16 @@ export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray<string> | un...@@ -66,6 +69,16 @@ export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray<string> | un
66 return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true;69 return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true;
67}70}
6871
72function configuredMcpToolAvailability(
73 appServerArgs: ReadonlyArray<string> | undefined,
74 mcpCapabilities: ReadonlySet<string> | undefined,
75): T3CodeToolAvailability {
76 if (!hasConfiguredMcpServer(appServerArgs)) return { browser: false, device: false };
77 // Callers predating the capability set attached the browser toolkit only.
78 if (mcpCapabilities === undefined) return { browser: true, device: false };
79 return { browser: mcpCapabilities.has("preview"), device: mcpCapabilities.has("device") };
80}
81
69export const CodexResumeCursorSchema = Schema.Struct({82export const CodexResumeCursorSchema = Schema.Struct({
70 threadId: Schema.String,83 threadId: Schema.String,
71});84});
...@@ -166,13 +179,8 @@ export interface CodexSessionRuntimeOptions {...@@ -166,13 +179,8 @@ export interface CodexSessionRuntimeOptions {
166 readonly serviceTier?: CodexServiceTier | undefined;179 readonly serviceTier?: CodexServiceTier | undefined;
167 readonly resumeCursor?: CodexResumeCursor;180 readonly resumeCursor?: CodexResumeCursor;
168 readonly appServerArgs?: ReadonlyArray<string>;181 readonly appServerArgs?: ReadonlyArray<string>;
169 /**182 /** Capabilities the session's `t3-code` MCP credential grants; drives the prompt blocks. */
170 * Whether the attached `t3-code` MCP server exposes the preview tools. The183 readonly mcpCapabilities?: ReadonlySet<string>;
171 * server is attached for every session now (the pull request toolkit is
172 * always on), so its presence in `appServerArgs` no longer implies browser
173 * access; the credential's own capability decides the developer prompt.
174 */
175 readonly browserToolsAvailable?: boolean;
176}184}
177185
178export interface CodexSessionRuntimeSendTurnInput {186export interface CodexSessionRuntimeSendTurnInput {
...@@ -576,7 +584,7 @@ function buildCodexCollaborationMode(input: {...@@ -576,7 +584,7 @@ function buildCodexCollaborationMode(input: {
576 readonly interactionMode?: ProviderInteractionMode;584 readonly interactionMode?: ProviderInteractionMode;
577 readonly model?: string;585 readonly model?: string;
578 readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;586 readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
579 readonly browserToolsAvailable?: boolean;587 readonly browserToolsAvailable?: boolean | T3CodeToolAvailability;
580}): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined {588}): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined {
581 if (input.interactionMode === undefined) {589 if (input.interactionMode === undefined) {
582 return undefined;590 return undefined;
...@@ -610,7 +618,7 @@ export function buildTurnStartParams(input: {...@@ -610,7 +618,7 @@ export function buildTurnStartParams(input: {
610 readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;618 readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
611 readonly interactionMode?: ProviderInteractionMode;619 readonly interactionMode?: ProviderInteractionMode;
612 /** Defaults to true so callers that predate the agent-access gate are unchanged. */620 /** Defaults to true so callers that predate the agent-access gate are unchanged. */
613 readonly browserToolsAvailable?: boolean;621 readonly browserToolsAvailable?: boolean | T3CodeToolAvailability;
614}): Effect.Effect<622}): Effect.Effect<
615 CodexTurnStartParamsWithCollaborationMode,623 CodexTurnStartParamsWithCollaborationMode,
616 CodexErrors.CodexAppServerProtocolParseError624 CodexErrors.CodexAppServerProtocolParseError
...@@ -2359,9 +2367,10 @@ export const makeCodexSessionRuntime = (...@@ -2359,9 +2367,10 @@ export const makeCodexSessionRuntime = (
2359 // Derived from the session's own credential rather than the2367 // Derived from the session's own credential rather than the
2360 // setting, so the prompt describes the tools this turn actually2368 // setting, so the prompt describes the tools this turn actually
2361 // has even if the setting changed after the session started.2369 // has even if the setting changed after the session started.
2362 browserToolsAvailable:2370 browserToolsAvailable: configuredMcpToolAvailability(
2363 hasConfiguredMcpServer(options.appServerArgs) &&2371 options.appServerArgs,
2364 (options.browserToolsAvailable ?? true),2372 options.mcpCapabilities,
2373 ),
2365 });2374 });
2366 const rawResponse = yield* client.raw.request("turn/start", params);2375 const rawResponse = yield* client.raw.request("turn/start", params);
2367 const response = yield* decodeV2TurnStartResponse(rawResponse).pipe(2376 const response = yield* decodeV2TurnStartResponse(rawResponse).pipe(
apps/server/src/provider/Layers/CursorAdapter.ts+8-1
...@@ -543,7 +543,14 @@ export function makeCursorAdapter(...@@ -543,7 +543,14 @@ export function makeCursorAdapter(
543 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);543 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
544 const acp = yield* makeCursorAcpRuntime({544 const acp = yield* makeCursorAcpRuntime({
545 cursorSettings: effectiveCursorSettings,545 cursorSettings: effectiveCursorSettings,
546 ...(options?.environment ? { environment: options.environment } : {}),546 ...(options?.environment || mcpSession?.agentDeviceEnvironment
547 ? {
548 environment: McpProviderSession.withAgentDeviceEnvironment(
549 options?.environment ?? process.env,
550 mcpSession,
551 ),
552 }
553 : {}),
547 childProcessSpawner,554 childProcessSpawner,
548 cwd,555 cwd,
549 runtimeMode: input.runtimeMode,556 runtimeMode: input.runtimeMode,
apps/server/src/provider/Layers/GrokAdapter.ts+8-1
...@@ -989,7 +989,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -989,7 +989,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
989 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);989 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
990 const acp = yield* makeGrokAcpRuntime({990 const acp = yield* makeGrokAcpRuntime({
991 grokSettings,991 grokSettings,
992 ...(options?.environment ? { environment: options.environment } : {}),992 ...(options?.environment || mcpSession?.agentDeviceEnvironment
993 ? {
994 environment: McpProviderSession.withAgentDeviceEnvironment(
995 options?.environment ?? process.env,
996 mcpSession,
997 ),
998 }
999 : {}),
993 childProcessSpawner,1000 childProcessSpawner,
994 cwd,1001 cwd,
995 runtimeMode: input.runtimeMode,1002 runtimeMode: input.runtimeMode,
apps/server/src/provider/Layers/OpenCodeAdapter.ts+5-2
...@@ -2827,19 +2827,22 @@ export function makeOpenCodeAdapter(...@@ -2827,19 +2827,22 @@ export function makeOpenCodeAdapter(
2827 // The runtime binds the server's lifetime to the Scope.Scope2827 // The runtime binds the server's lifetime to the Scope.Scope
2828 // we provide below — closing `sessionScope` kills the child2828 // we provide below — closing `sessionScope` kills the child
2829 // process automatically. No manual `server.close()` needed.2829 // process automatically. No manual `server.close()` needed.
2830 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
2830 const server = yield* openCodeRuntime.connectToOpenCodeServer({2831 const server = yield* openCodeRuntime.connectToOpenCodeServer({
2831 binaryPath,2832 binaryPath,
2832 directory,2833 directory,
2833 serverUrl,2834 serverUrl,
2834 ...(serverPassword ? { serverPassword } : {}),2835 ...(serverPassword ? { serverPassword } : {}),
2835 ...(options?.environment ? { environment: options.environment } : {}),2836 environment: McpProviderSession.withAgentDeviceEnvironment(
2837 options?.environment ?? process.env,
2838 mcpSession,
2839 ),
2836 });2840 });
2837 const client = openCodeRuntime.createOpenCodeSdkClient({2841 const client = openCodeRuntime.createOpenCodeSdkClient({
2838 baseUrl: server.url,2842 baseUrl: server.url,
2839 directory,2843 directory,
2840 ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}),2844 ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}),
2841 });2845 });
2842 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
2843 if (mcpSession && !server.external) {2846 if (mcpSession && !server.external) {
2844 yield* runOpenCodeSdk("mcp.add", () =>2847 yield* runOpenCodeSdk("mcp.add", () =>
2845 client.mcp.add({2848 client.mcp.add({
apps/server/src/provider/Layers/ProviderService.test.ts+34-8
...@@ -4800,12 +4800,14 @@ describe("agent browser access", () => {...@@ -4800,12 +4800,14 @@ describe("agent browser access", () => {
4800 const projectId = ProjectId.make("project-browser-access");4800 const projectId = ProjectId.make("project-browser-access");
48014801
4802 const startSessionWith = (4802 const startSessionWith = (
4803 enableAgentBrowserAccess: boolean,4803 access: boolean | { readonly browser: boolean; readonly device: boolean },
4804 threadId: ThreadId,4804 threadId: ThreadId,
4805 projectOverride?: boolean,4805 projectOverride?: boolean,
4806 ) =>4806 ) =>
4807 Effect.gen(function* () {4807 Effect.gen(function* () {
4808 const issued: Array<{ readonly threadId: ThreadId; readonly preview: boolean }> = [];4808 const enableAgentBrowserAccess = typeof access === "boolean" ? access : access.browser;
4809 const enableAgentDeviceAccess = typeof access === "boolean" ? access : access.device;
4810 const issued: Array<{ threadId: ThreadId; capabilities: ReadonlyArray<string> }> = [];
4809 const codex = makeFakeCodexAdapter();4811 const codex = makeFakeCodexAdapter();
4810 const providerAdapterLayer = Layer.succeed(4812 const providerAdapterLayer = Layer.succeed(
4811 ProviderAdapterRegistry.ProviderAdapterRegistry,4813 ProviderAdapterRegistry.ProviderAdapterRegistry,
...@@ -4864,7 +4866,10 @@ describe("agent browser access", () => {...@@ -4864,7 +4866,10 @@ describe("agent browser access", () => {
4864 const providerLayer = makeProviderServiceLive({4866 const providerLayer = makeProviderServiceLive({
4865 issueMcpCredential: (request) =>4867 issueMcpCredential: (request) =>
4866 Effect.sync(() => {4868 Effect.sync(() => {
4867 issued.push({ threadId: request.threadId, preview: request.preview });4869 issued.push({
4870 threadId: request.threadId,
4871 capabilities: [...request.capabilities].toSorted(),
4872 });
4868 return undefined;4873 return undefined;
4869 }),4874 }),
4870 }).pipe(4875 }).pipe(
...@@ -4874,6 +4879,7 @@ describe("agent browser access", () => {...@@ -4874,6 +4879,7 @@ describe("agent browser access", () => {
4874 Layer.provide(4879 Layer.provide(
4875 ServerSettings.ServerSettingsService.layerTest({4880 ServerSettings.ServerSettingsService.layerTest({
4876 enableAgentBrowserAccess,4881 enableAgentBrowserAccess,
4882 enableAgentDeviceAccess,
4877 projectAgentBrowserAccessOverrides:4883 projectAgentBrowserAccessOverrides:
4878 projectOverride === undefined ? {} : { [projectId]: projectOverride },4884 projectOverride === undefined ? {} : { [projectId]: projectOverride },
4879 }),4885 }),
...@@ -4910,7 +4916,7 @@ describe("agent browser access", () => {...@@ -4910,7 +4916,7 @@ describe("agent browser access", () => {
49104916
4911 const issued = yield* startSessionWith(false, threadId);4917 const issued = yield* startSessionWith(false, threadId);
49124918
4913 assert.deepEqual(issued, [{ threadId, preview: false }]);4919 assert.deepEqual(issued, [{ threadId, capabilities: ["pull-requests"] }]);
4914 }).pipe(Effect.provide(NodeServices.layer)),4920 }).pipe(Effect.provide(NodeServices.layer)),
4915 );4921 );
49164922
...@@ -4920,23 +4926,43 @@ describe("agent browser access", () => {...@@ -4920,23 +4926,43 @@ describe("agent browser access", () => {
49204926
4921 const issued = yield* startSessionWith(true, threadId);4927 const issued = yield* startSessionWith(true, threadId);
49224928
4923 assert.deepEqual(issued, [{ threadId, preview: true }]);4929 assert.deepEqual(issued, [
4930 { threadId, capabilities: ["device", "preview", "pull-requests"] },
4931 ]);
4932 }).pipe(Effect.provide(NodeServices.layer)),
4933 );
4934
4935 it.effect("drops only the preview capability when browser access alone is off", () =>
4936 Effect.gen(function* () {
4937 const threadId = asThreadId("thread-browser-off-device-on");
4938
4939 const issued = yield* startSessionWith({ browser: false, device: true }, threadId);
4940
4941 assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]);
4924 }).pipe(Effect.provide(NodeServices.layer)),4942 }).pipe(Effect.provide(NodeServices.layer)),
4925 );4943 );
49264944
4927 it.effect("issues a credential without preview when the project disables browser access", () =>4945 it.effect("issues a credential without preview when the project disables browser access", () =>
4928 Effect.gen(function* () {4946 Effect.gen(function* () {
4929 const threadId = asThreadId("thread-project-browser-off");4947 const threadId = asThreadId("thread-project-browser-off");
4948 const issued = yield* startSessionWith({ browser: true, device: false }, threadId, false);
4949 assert.deepEqual(issued, [{ threadId, capabilities: ["pull-requests"] }]);
4950 }).pipe(Effect.provide(NodeServices.layer)),
4951 );
4952
4953 it.effect("a project browser override leaves device access alone", () =>
4954 Effect.gen(function* () {
4955 const threadId = asThreadId("thread-project-browser-off-device-on");
4930 const issued = yield* startSessionWith(true, threadId, false);4956 const issued = yield* startSessionWith(true, threadId, false);
4931 assert.deepEqual(issued, [{ threadId, preview: false }]);4957 assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]);
4932 }).pipe(Effect.provide(NodeServices.layer)),4958 }).pipe(Effect.provide(NodeServices.layer)),
4933 );4959 );
49344960
4935 it.effect("requests an MCP credential when the project overrides browser access to on", () =>4961 it.effect("requests an MCP credential when the project overrides browser access to on", () =>
4936 Effect.gen(function* () {4962 Effect.gen(function* () {
4937 const threadId = asThreadId("thread-project-browser-on");4963 const threadId = asThreadId("thread-project-browser-on");
4938 const issued = yield* startSessionWith(false, threadId, true);4964 const issued = yield* startSessionWith({ browser: false, device: false }, threadId, true);
4939 assert.deepEqual(issued, [{ threadId, preview: true }]);4965 assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]);
4940 }).pipe(Effect.provide(NodeServices.layer)),4966 }).pipe(Effect.provide(NodeServices.layer)),
4941 );4967 );
4942});4968});
apps/server/src/provider/Layers/ProviderService.ts+80-11
...@@ -34,6 +34,7 @@ import {...@@ -34,6 +34,7 @@ import {
34 type ProviderSession,34 type ProviderSession,
35} from "@t3tools/contracts";35} from "@t3tools/contracts";
36import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";36import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
37import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
37import { causeErrorTag } from "@t3tools/shared/observability";38import { causeErrorTag } from "@t3tools/shared/observability";
38import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";39import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
39import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings";40import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings";
...@@ -43,6 +44,7 @@ import * as Effect from "effect/Effect";...@@ -43,6 +44,7 @@ import * as Effect from "effect/Effect";
43import * as FileSystem from "effect/FileSystem";44import * as FileSystem from "effect/FileSystem";
44import * as Layer from "effect/Layer";45import * as Layer from "effect/Layer";
45import * as Option from "effect/Option";46import * as Option from "effect/Option";
47import * as Path from "effect/Path";
46import * as PubSub from "effect/PubSub";48import * as PubSub from "effect/PubSub";
47import * as Ref from "effect/Ref";49import * as Ref from "effect/Ref";
48import * as Schema from "effect/Schema";50import * as Schema from "effect/Schema";
...@@ -52,6 +54,9 @@ import * as Stream from "effect/Stream";...@@ -52,6 +54,9 @@ import * as Stream from "effect/Stream";
52import { appendUserInputAttachmentPaths } from "../userInputAttachments.ts";54import { appendUserInputAttachmentPaths } from "../userInputAttachments.ts";
53import { resolveAttachmentPath } from "../../attachmentStore.ts";55import { resolveAttachmentPath } from "../../attachmentStore.ts";
54import * as ServerConfig from "../../config.ts";56import * as ServerConfig from "../../config.ts";
57import { ensureAgentDeviceShim } from "../../device/AgentDeviceShim.ts";
58import * as DeviceService from "../../device/DeviceService.ts";
59import type * as McpInvocationContext from "../../mcp/McpInvocationContext.ts";
55import {60import {
56 increment,61 increment,
57 providerMetricAttributes,62 providerMetricAttributes,
...@@ -249,6 +254,8 @@ export interface ProviderServiceLiveOptions {...@@ -249,6 +254,8 @@ export interface ProviderServiceLiveOptions {
249 * test see whether a credential was requested at all.254 * test see whether a credential was requested at all.
250 */255 */
251 readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential;256 readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential;
257 /** Overrides the device host lookup used to build the agent-device environment. */
258 readonly deviceReadiness?: DeviceService.DeviceService["Service"]["agentReadinessIfSupported"];
252}259}
253260
254interface TurnAnalyticsMetadata {261interface TurnAnalyticsMetadata {
...@@ -477,7 +484,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -477,7 +484,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
477 );484 );
478 const issueMcpCredential =485 const issueMcpCredential =
479 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;486 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;
487 const deviceReadiness =
488 options?.deviceReadiness ??
489 (() =>
490 Effect.serviceOption(DeviceService.DeviceService).pipe(
491 Effect.flatMap((service) =>
492 Option.isSome(service) ? service.value.agentReadinessIfSupported() : Effect.succeed(null),
493 ),
494 ));
480 const fileSystem = yield* FileSystem.FileSystem;495 const fileSystem = yield* FileSystem.FileSystem;
496 const pathService = yield* Path.Path;
481 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();497 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
482 const pendingCompactions = new Map<ThreadId, PendingCompaction>();498 const pendingCompactions = new Map<ThreadId, PendingCompaction>();
483 const timedOutNativeCompactions = new Set<ThreadId>();499 const timedOutNativeCompactions = new Set<ThreadId>();
...@@ -879,22 +895,75 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -879,22 +895,75 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
879 ),895 ),
880 );896 );
881897
898 const agentDeviceAccessEnabled = serverSettings.getSettings.pipe(
899 Effect.map((settings) => settings.enableAgentDeviceAccess),
900 Effect.catch((cause) =>
901 Effect.logWarning(
902 "Could not read server settings; withholding agent device access for this session.",
903 { cause },
904 ).pipe(Effect.as(false)),
905 ),
906 );
907
908 const agentAccessCapabilities = Effect.fn("ProviderService.agentAccessCapabilities")(function* (
909 threadId: ThreadId,
910 ) {
911 const capabilities = new Set<McpInvocationContext.McpCapability>(["pull-requests"]);
912 if (yield* agentBrowserAccessEnabled(threadId)) capabilities.add("preview");
913 if (yield* agentDeviceAccessEnabled) capabilities.add("device");
914 return capabilities;
915 });
916
882 /**917 /**
883 * Attach the `t3-code` MCP server to the session that is about to start.918 * Starting a session with device access also brings the device host up, so
884 *919 * the `agent-device` CLI is on the provider's PATH from its first turn. The
885 * Every session gets a credential: the pull request toolkit is always on,920 * environment is fixed at spawn time, so a host started later by
886 * since it only registers links on the session's own thread. Browser access921 * `device_open` could not reach an already-running agent. Tools install once
887 * is a capability on that credential, so turning the setting off withholds922 * and the host is idempotent, so this is cheap after the first session;
888 * the preview tools without taking the server away. `issueActiveMcpCredential`923 * a host that fails to start withholds only the CLI, not the MCP tools.
889 * revokes the thread's previous token first, which matters because a session
890 * restart (runtime mode, cwd, model) re-prepares without stopping.
891 */924 */
925 const hostPlatform = yield* HostProcessPlatform;
926 const agentDeviceEnvironment = Effect.gen(function* () {
927 const readiness = yield* deviceReadiness().pipe(
928 Effect.catch((cause) =>
929 Effect.logWarning("Device host unavailable; starting session without agent-device", {
930 cause,
931 }).pipe(Effect.as(null)),
932 ),
933 );
934 if (!readiness) return undefined;
935 const shimDir = yield* ensureAgentDeviceShim({
936 entryPath: readiness.agentDevice.entryPath,
937 stateDir: serverConfig.stateDir,
938 }).pipe(
939 Effect.provideService(FileSystem.FileSystem, fileSystem),
940 Effect.provideService(Path.Path, pathService),
941 Effect.orElseSucceed(() => undefined),
942 );
943 if (!shimDir) return undefined;
944 return {
945 PATH: shimDir,
946 PATH_SEPARATOR: hostPlatform === "win32" ? ";" : ":",
947 AGENT_DEVICE_DAEMON_BASE_URL: readiness.agentDevice.baseUrl,
948 AGENT_DEVICE_DAEMON_AUTH_TOKEN: readiness.agentDevice.token,
949 AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1",
950 } satisfies Record<string, string>;
951 });
952
892 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>953 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
893 Effect.gen(function* () {954 Effect.gen(function* () {
894 const preview = yield* agentBrowserAccessEnabled(threadId);955 const capabilities = yield* agentAccessCapabilities(threadId);
895 const credential = yield* issueMcpCredential({ threadId, providerInstanceId, preview });956 const credential = yield* issueMcpCredential({ threadId, providerInstanceId, capabilities });
896 if (credential) {957 if (credential) {
897 yield* Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config));958 const deviceEnvironment = capabilities.has("device")
959 ? yield* agentDeviceEnvironment
960 : undefined;
961 yield* Effect.sync(() =>
962 McpProviderSession.setMcpProviderSession({
963 ...credential.config,
964 ...(deviceEnvironment ? { agentDeviceEnvironment: deviceEnvironment } : {}),
965 }),
966 );
898 }967 }
899 return credential;968 return credential;
900 });969 });
apps/server/src/provider/acp/AntigravityAcpSupport.ts+2
...@@ -35,6 +35,8 @@ export interface AntigravityAcpRuntimeInput extends Omit<...@@ -35,6 +35,8 @@ export interface AntigravityAcpRuntimeInput extends Omit<
35 | "transformSessionUpdate"35 | "transformSessionUpdate"
36 | "transformStdout"36 | "transformStdout"
37> {37> {
38 /** Device CLI environment supplied for this provider session. */
39 readonly agentDeviceEnvironment?: Readonly<Record<string, string>>;
38 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];40 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
39 readonly onAuthorizationUrl?: (url: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;41 readonly onAuthorizationUrl?: (url: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
40 /**42 /**
apps/server/src/server.test.ts+18
...@@ -5,6 +5,7 @@ import * as NodeCrypto from "node:crypto";...@@ -5,6 +5,7 @@ import * as NodeCrypto from "node:crypto";
5import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";5import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
66
7import {7import {
8 type DeviceServiceState,
8 AuthAccessTokenType,9 AuthAccessTokenType,
9 AuthStandardClientScopes,10 AuthStandardClientScopes,
10 AuthEnvironmentBootstrapTokenType,11 AuthEnvironmentBootstrapTokenType,
...@@ -97,6 +98,7 @@ const encodeTestJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unk...@@ -97,6 +98,7 @@ const encodeTestJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unk
9798
98import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";99import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
99import * as ServerConfig from "./config.ts";100import * as ServerConfig from "./config.ts";
101import * as DeviceService from "./device/DeviceService.ts";
100import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts";102import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts";
101import {103import {
102 isThreadDetailEvent,104 isThreadDetailEvent,
...@@ -805,6 +807,11 @@ const buildAppUnderTest = (options?: {...@@ -805,6 +807,11 @@ const buildAppUnderTest = (options?: {
805 listBindings: () => Effect.succeed([]),807 listBindings: () => Effect.succeed([]),
806 ...options?.layers?.providerSessionDirectory,808 ...options?.layers?.providerSessionDirectory,
807 }),809 }),
810 Layer.mock(DeviceService.DeviceService)({
811 state: Effect.succeed(EMPTY_DEVICE_STATE),
812 currentReadiness: () => Effect.succeed(null),
813 sessionsForThread: () => Effect.succeed([]),
814 }),
808 ),815 ),
809 ),816 ),
810 Layer.provide(817 Layer.provide(
...@@ -1660,6 +1667,17 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe(...@@ -1660,6 +1667,17 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe(
1660 ),1667 ),
1661);1668);
16621669
1670const EMPTY_DEVICE_STATE: DeviceServiceState = {
1671 hosts: [],
1672 hostStatus: "idle",
1673 devices: [],
1674 sessions: [],
1675 onboardingCompleted: false,
1676 agentAccessEnabled: false,
1677 hubBasePath: DeviceService.DEVICE_HUB_ROUTE_PREFIX,
1678 revision: 0,
1679};
1680
1663it.layer(NodeServices.layer)("server router seam", (it) => {1681it.layer(NodeServices.layer)("server router seam", (it) => {
1664 it.effect("parks HTTP ingress until command readiness", () =>1682 it.effect("parks HTTP ingress until command readiness", () =>
1665 Effect.gen(function* () {1683 Effect.gen(function* () {
apps/server/src/server.ts+10-1
...@@ -57,6 +57,8 @@ import * as TerminalManager from "./terminal/Manager.ts";...@@ -57,6 +57,8 @@ import * as TerminalManager from "./terminal/Manager.ts";
57import * as McpHttpServer from "./mcp/McpHttpServer.ts";57import * as McpHttpServer from "./mcp/McpHttpServer.ts";
58import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";58import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";
59import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";59import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
60import * as DeviceService from "./device/DeviceService.ts";
61import { deviceHubProxyRouteLayer } from "./device/DeviceHubProxy.ts";
60import * as PreviewManager from "./preview/Manager.ts";62import * as PreviewManager from "./preview/Manager.ts";
61import * as PortScanner from "./preview/PortScanner.ts";63import * as PortScanner from "./preview/PortScanner.ts";
62import * as ProcessRunner from "./processRunner.ts";64import * as ProcessRunner from "./processRunner.ts";
...@@ -386,6 +388,12 @@ const PreviewLayerLive = Layer.empty.pipe(...@@ -386,6 +388,12 @@ const PreviewLayerLive = Layer.empty.pipe(
386 Layer.provideMerge(PortScannerLayerLive),388 Layer.provideMerge(PortScannerLayerLive),
387);389);
388390
391const DeviceLayerLive = DeviceService.layer.pipe(
392 Layer.provide(ServerSettingsLayerLive),
393 Layer.provide(ProcessRunner.layer),
394 Layer.provide(NetService.layer),
395);
396
389const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer));397const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer));
390398
391const WorkspaceFileSystemLayerLive = WorkspaceFileSystem.layer.pipe(399const WorkspaceFileSystemLayerLive = WorkspaceFileSystem.layer.pipe(
...@@ -469,7 +477,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -469,7 +477,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
469 Layer.provideMerge(GitLayerLive),477 Layer.provideMerge(GitLayerLive),
470 Layer.provideMerge(VcsLayerLive),478 Layer.provideMerge(VcsLayerLive),
471 Layer.provideMerge(ProviderRuntimeLayerLive),479 Layer.provideMerge(ProviderRuntimeLayerLive),
472 Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)),480 Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive, DeviceLayerLive)),
473 Layer.provideMerge(PersistenceLayerLive),481 Layer.provideMerge(PersistenceLayerLive),
474 // Both read a user-owned file out of the state directory and stream changes482 // Both read a user-owned file out of the state directory and stream changes
475 // to clients; neither depends on the other.483 // to clients; neither depends on the other.
...@@ -553,6 +561,7 @@ export const makeRoutesLayer = Layer.mergeAll(...@@ -553,6 +561,7 @@ export const makeRoutesLayer = Layer.mergeAll(
553 otlpTracesProxyRouteLayer,561 otlpTracesProxyRouteLayer,
554 assetRouteLayer,562 assetRouteLayer,
555 attachmentUploadRouteLayer,563 attachmentUploadRouteLayer,
564 deviceHubProxyRouteLayer,
556 staticAndDevRouteLayer,565 staticAndDevRouteLayer,
557 websocketRpcRouteLayer,566 websocketRpcRouteLayer,
558 ),567 ),
apps/server/src/ws.ts+36
...@@ -114,6 +114,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";...@@ -114,6 +114,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
114import * as ServerSettings from "./serverSettings.ts";114import * as ServerSettings from "./serverSettings.ts";
115import * as TerminalManager from "./terminal/Manager.ts";115import * as TerminalManager from "./terminal/Manager.ts";
116import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";116import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
117import * as DeviceService from "./device/DeviceService.ts";
117import * as PreviewManager from "./preview/Manager.ts";118import * as PreviewManager from "./preview/Manager.ts";
118import { issueAssetUrl } from "./assets/AssetAccess.ts";119import { issueAssetUrl } from "./assets/AssetAccess.ts";
119import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts";120import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts";
...@@ -536,6 +537,7 @@ const makeWsRpcLayer = (...@@ -536,6 +537,7 @@ const makeWsRpcLayer = (
536 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;537 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
537 const terminalManager = yield* TerminalManager.TerminalManager;538 const terminalManager = yield* TerminalManager.TerminalManager;
538 const previewManager = yield* PreviewManager.PreviewManager;539 const previewManager = yield* PreviewManager.PreviewManager;
540 const deviceService = yield* DeviceService.DeviceService;
539 const portDiscovery = yield* PortScanner.PortDiscovery;541 const portDiscovery = yield* PortScanner.PortDiscovery;
540 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;542 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
541 const providerService = yield* ProviderService.ProviderService;543 const providerService = yield* ProviderService.ProviderService;
...@@ -2764,6 +2766,40 @@ const makeWsRpcLayer = (...@@ -2764,6 +2766,40 @@ const makeWsRpcLayer = (
2764 observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, {2766 observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, {
2765 "rpc.aggregate": "preview",2767 "rpc.aggregate": "preview",
2766 }),2768 }),
2769 [WS_METHODS.deviceConfigure]: (input) =>
2770 observeRpcEffect(WS_METHODS.deviceConfigure, deviceService.configure(input), {
2771 "rpc.aggregate": "device",
2772 }),
2773 [WS_METHODS.deviceList]: (_input) =>
2774 observeRpcEffect(WS_METHODS.deviceList, deviceService.list, {
2775 "rpc.aggregate": "device",
2776 }),
2777 [WS_METHODS.deviceOpen]: (input) =>
2778 observeRpcEffect(WS_METHODS.deviceOpen, deviceService.open(input), {
2779 "rpc.aggregate": "device",
2780 }),
2781 [WS_METHODS.deviceClose]: (input) =>
2782 observeRpcEffect(WS_METHODS.deviceClose, deviceService.close(input), {
2783 "rpc.aggregate": "device",
2784 }),
2785 [WS_METHODS.deviceShutdown]: (input) =>
2786 observeRpcEffect(WS_METHODS.deviceShutdown, deviceService.shutdown(input), {
2787 "rpc.aggregate": "device",
2788 }),
2789 [WS_METHODS.deviceDetail]: (input) =>
2790 observeRpcEffect(WS_METHODS.deviceDetail, deviceService.detail(input), {
2791 "rpc.aggregate": "device",
2792 }),
2793 [WS_METHODS.deviceAction]: (input) =>
2794 observeRpcEffect(WS_METHODS.deviceAction, deviceService.action(input), {
2795 "rpc.aggregate": "device",
2796 }),
2797 [WS_METHODS.subscribeDeviceState]: (_input) =>
2798 observeRpcStream(
2799 WS_METHODS.subscribeDeviceState,
2800 DeviceService.stateStream(deviceService),
2801 { "rpc.aggregate": "device" },
2802 ),
2767 [WS_METHODS.subscribeDiscoveredLocalServers]: (input) =>2803 [WS_METHODS.subscribeDiscoveredLocalServers]: (input) =>
2768 observeRpcStream(2804 observeRpcStream(
2769 WS_METHODS.subscribeDiscoveredLocalServers,2805 WS_METHODS.subscribeDiscoveredLocalServers,
apps/web/src/components/ChatView.tsx+75
...@@ -205,6 +205,10 @@ import { RightPanelTabs } from "./RightPanelTabs";...@@ -205,6 +205,10 @@ import { RightPanelTabs } from "./RightPanelTabs";
205import { AgentsPanel } from "./AgentsPanel";205import { AgentsPanel } from "./AgentsPanel";
206import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog";206import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog";
207import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel";207import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel";
208import { useDeviceState } from "~/state/device";
209import { DeviceSetup } from "./device/DeviceSetup";
210import { Dialog } from "./ui/dialog";
211import { WizardPopup } from "./ui/wizard";
208import {212import {
209 deriveAgentPanelModel,213 deriveAgentPanelModel,
210 foldSubagentActivities,214 foldSubagentActivities,
...@@ -556,6 +560,9 @@ const PreviewPanel = lazy(() =>...@@ -556,6 +560,9 @@ const PreviewPanel = lazy(() =>
556 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),560 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
557);561);
558const DiffPanel = lazy(() => import("./DiffPanel"));562const DiffPanel = lazy(() => import("./DiffPanel"));
563const DevicePanel = lazy(() =>
564 import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })),
565);
559const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel"));566const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel"));
560const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet<string> = new Set();567const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet<string> = new Set();
561const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [568const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [
...@@ -4136,6 +4143,34 @@ export default function ChatView(props: ChatViewProps) {...@@ -4136,6 +4143,34 @@ export default function ChatView(props: ChatViewProps) {
4136 if (!activeThreadRef || !supportsThreadPullRequests) return;4143 if (!activeThreadRef || !supportsThreadPullRequests) return;
4137 useRightPanelStore.getState().open(activeThreadRef, "pull-requests");4144 useRightPanelStore.getState().open(activeThreadRef, "pull-requests");
4138 }, [activeThreadRef, supportsThreadPullRequests]);4145 }, [activeThreadRef, supportsThreadPullRequests]);
4146 const { state: deviceState } = useDeviceState(activeThreadRef?.environmentId ?? null);
4147 const [deviceSetupThread, setDeviceSetupThread] = useState<ScopedThreadRef | null>(null);
4148 const addDeviceSurface = useCallback(() => {
4149 if (!activeThreadRef) return;
4150 if (!deviceState.onboardingCompleted || deviceState.hostStatus === "disabled") {
4151 setDeviceSetupThread(activeThreadRef);
4152 return;
4153 }
4154 useRightPanelStore.getState().open(activeThreadRef, "device");
4155 }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]);
4156 // An agent's `device_open` surfaces in every client the same way a
4157 // `preview_open` does: the thread starts a device or gains a session and the panel
4158 // opens on it. Closing the last session leaves the tab in place so the
4159 // user keeps their picker; only new sessions raise the panel.
4160 const threadDeviceSessionCount = activeThreadRef
4161 ? deviceState.sessions.filter((session) => session.threadId === activeThreadRef.threadId)
4162 .length +
4163 (deviceState.bootingDevices?.filter((device) => device.threadId === activeThreadRef.threadId)
4164 .length ?? 0)
4165 : 0;
4166 const previousDeviceSessionCount = useRef(threadDeviceSessionCount);
4167 useEffect(() => {
4168 const previous = previousDeviceSessionCount.current;
4169 previousDeviceSessionCount.current = threadDeviceSessionCount;
4170 if (!activeThreadRef || threadDeviceSessionCount <= previous) return;
4171 if (shouldUseRightPanelSheet) return;
4172 useRightPanelStore.getState().open(activeThreadRef, "device");
4173 }, [activeThreadRef, shouldUseRightPanelSheet, threadDeviceSessionCount]);
4139 const openFileSurface = useCallback(4174 const openFileSurface = useCallback(
4140 (relativePath: string) => {4175 (relativePath: string) => {
4141 if (!activeThreadRef || !activeProject) return;4176 if (!activeThreadRef || !activeProject) return;
...@@ -8113,6 +8148,19 @@ export default function ChatView(props: ChatViewProps) {...@@ -8113,6 +8148,19 @@ export default function ChatView(props: ChatViewProps) {
8113 environmentId={activeThreadRef?.environmentId ?? null}8148 environmentId={activeThreadRef?.environmentId ?? null}
8114 threadId={activeThreadRef?.threadId ?? null}8149 threadId={activeThreadRef?.threadId ?? null}
8115 />8150 />
8151 ) : renderedRightPanelSurface?.kind === "device" ? (
8152 <Suspense fallback={null}>
8153 <DevicePanel
8154 mode="embedded"
8155 threadRef={activeThreadRef}
8156 deviceId={null}
8157 visible={rightPanelOpen}
8158 onDismissSetup={() => {
8159 closeRightPanelSurface(renderedRightPanelSurface);
8160 useRightPanelStore.getState().show(activeThreadRef);
8161 }}
8162 />
8163 </Suspense>
8116 ) : (renderedRightPanelSurface?.kind === "files" ||8164 ) : (renderedRightPanelSurface?.kind === "files" ||
8117 renderedRightPanelSurface?.kind === "file") &&8165 renderedRightPanelSurface?.kind === "file") &&
8118 ((activeProject && activeWorkspaceRoot) ||8166 ((activeProject && activeWorkspaceRoot) ||
...@@ -8168,6 +8216,29 @@ export default function ChatView(props: ChatViewProps) {...@@ -8168,6 +8216,29 @@ export default function ChatView(props: ChatViewProps) {
81688216
8169 return (8217 return (
8170 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">8218 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
8219 <Dialog
8220 open={
8221 deviceSetupThread !== null &&
8222 deviceSetupThread.environmentId === activeThreadRef?.environmentId &&
8223 deviceSetupThread.threadId === activeThreadRef?.threadId
8224 }
8225 onOpenChange={(open) => {
8226 if (!open) setDeviceSetupThread(null);
8227 }}
8228 >
8229 <WizardPopup>
8230 {activeThreadRef ? (
8231 <DeviceSetup
8232 environmentId={activeThreadRef.environmentId}
8233 state={deviceState}
8234 onComplete={() => {
8235 useRightPanelStore.getState().open(activeThreadRef, "device");
8236 setDeviceSetupThread(null);
8237 }}
8238 />
8239 ) : null}
8240 </WizardPopup>
8241 </Dialog>
8171 {rightPanelControlsAtRoot ? panelLayoutControls : null}8242 {rightPanelControlsAtRoot ? panelLayoutControls : null}
8172 <div8243 <div
8173 className={cn(8244 className={cn(
...@@ -8666,6 +8737,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8666,6 +8737,7 @@ export default function ChatView(props: ChatViewProps) {
8666 onAddPullRequest={addPullRequestSurface}8737 onAddPullRequest={addPullRequestSurface}
8667 onAddPullRequests={addPullRequestsSurface}8738 onAddPullRequests={addPullRequestsSurface}
8668 onAddAgents={addAgentsSurface}8739 onAddAgents={addAgentsSurface}
8740 onAddDevice={addDeviceSurface}
8669 browserAvailable={isPreviewSupportedInRuntime()}8741 browserAvailable={isPreviewSupportedInRuntime()}
8670 terminalAvailable={activeProject !== null}8742 terminalAvailable={activeProject !== null}
8671 diffAvailable={isServerThread && isGitRepo}8743 diffAvailable={isServerThread && isGitRepo}
...@@ -8673,6 +8745,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8673,6 +8745,7 @@ export default function ChatView(props: ChatViewProps) {
8673 pullRequestAvailable={pullRequestSurfaceAvailable}8745 pullRequestAvailable={pullRequestSurfaceAvailable}
8674 pullRequestsAvailable={isServerThread && supportsThreadPullRequests}8746 pullRequestsAvailable={isServerThread && supportsThreadPullRequests}
8675 agentsAvailable8747 agentsAvailable
8748 deviceAvailable={activeThreadRef !== null}
8676 liveAgentCount={agentPanelModel.liveCount}8749 liveAgentCount={agentPanelModel.liveCount}
8677 >8750 >
8678 {rightPanelContent}8751 {rightPanelContent}
...@@ -8718,6 +8791,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8718,6 +8791,7 @@ export default function ChatView(props: ChatViewProps) {
8718 onAddPullRequest={addPullRequestSurface}8791 onAddPullRequest={addPullRequestSurface}
8719 onAddPullRequests={addPullRequestsSurface}8792 onAddPullRequests={addPullRequestsSurface}
8720 onAddAgents={addAgentsSurface}8793 onAddAgents={addAgentsSurface}
8794 onAddDevice={addDeviceSurface}
8721 browserAvailable={isPreviewSupportedInRuntime()}8795 browserAvailable={isPreviewSupportedInRuntime()}
8722 terminalAvailable={activeProject !== null}8796 terminalAvailable={activeProject !== null}
8723 diffAvailable={isServerThread && isGitRepo}8797 diffAvailable={isServerThread && isGitRepo}
...@@ -8725,6 +8799,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8725,6 +8799,7 @@ export default function ChatView(props: ChatViewProps) {
8725 pullRequestAvailable={pullRequestSurfaceAvailable}8799 pullRequestAvailable={pullRequestSurfaceAvailable}
8726 pullRequestsAvailable={isServerThread && supportsThreadPullRequests}8800 pullRequestsAvailable={isServerThread && supportsThreadPullRequests}
8727 agentsAvailable8801 agentsAvailable
8802 deviceAvailable={activeThreadRef !== null}
8728 liveAgentCount={agentPanelModel.liveCount}8803 liveAgentCount={agentPanelModel.liveCount}
8729 >8804 >
8730 {rightPanelContent}8805 {rightPanelContent}
apps/web/src/components/RightPanelTabs.test.tsx+2
...@@ -123,6 +123,7 @@ function renderTabs(...@@ -123,6 +123,7 @@ function renderTabs(
123 onAddDiff={() => undefined}123 onAddDiff={() => undefined}
124 onAddFiles={() => undefined}124 onAddFiles={() => undefined}
125 onAddAgents={() => undefined}125 onAddAgents={() => undefined}
126 onAddDevice={() => undefined}
126 liveAgentCount={0}127 liveAgentCount={0}
127 browserAvailable128 browserAvailable
128 terminalAvailable={false}129 terminalAvailable={false}
...@@ -131,6 +132,7 @@ function renderTabs(...@@ -131,6 +132,7 @@ function renderTabs(
131 pullRequestAvailable={false}132 pullRequestAvailable={false}
132 pullRequestsAvailable={false}133 pullRequestsAvailable={false}
133 agentsAvailable={false}134 agentsAvailable={false}
135 deviceAvailable={false}
134 >136 >
135 <div>content</div>137 <div>content</div>
136 </RightPanelTabs>,138 </RightPanelTabs>,
apps/web/src/components/RightPanelTabs.tsx+31
...@@ -15,6 +15,7 @@ import type {...@@ -15,6 +15,7 @@ import type {
15import { getTerminalLabel } from "@t3tools/shared/terminalLabels";15import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
16import {16import {
17 Bot,17 Bot,
18 Smartphone,
18 ChevronDown,19 ChevronDown,
19 ChevronLeft,20 ChevronLeft,
20 ChevronRight,21 ChevronRight,
...@@ -114,6 +115,7 @@ interface RightPanelTabsProps {...@@ -114,6 +115,7 @@ interface RightPanelTabsProps {
114 onAddPullRequest: () => void;115 onAddPullRequest: () => void;
115 onAddPullRequests: () => void;116 onAddPullRequests: () => void;
116 onAddAgents: () => void;117 onAddAgents: () => void;
118 onAddDevice: () => void;
117 browserAvailable: boolean;119 browserAvailable: boolean;
118 terminalAvailable: boolean;120 terminalAvailable: boolean;
119 diffAvailable: boolean;121 diffAvailable: boolean;
...@@ -121,6 +123,7 @@ interface RightPanelTabsProps {...@@ -121,6 +123,7 @@ interface RightPanelTabsProps {
121 pullRequestAvailable: boolean;123 pullRequestAvailable: boolean;
122 pullRequestsAvailable: boolean;124 pullRequestsAvailable: boolean;
123 agentsAvailable: boolean;125 agentsAvailable: boolean;
126 deviceAvailable: boolean;
124 pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>;127 pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>;
125 /** Running + waiting subagents; badges the Agents card in the empty state. */128 /** Running + waiting subagents; badges the Agents card in the empty state. */
126 liveAgentCount: number;129 liveAgentCount: number;
...@@ -151,6 +154,7 @@ const SURFACE_DISABLED_REASONS = {...@@ -151,6 +154,7 @@ const SURFACE_DISABLED_REASONS = {
151 pullRequest: "This thread's branch has no pull request yet.",154 pullRequest: "This thread's branch has no pull request yet.",
152 pullRequests: "Linked pull requests are only available for server threads.",155 pullRequests: "Linked pull requests are only available for server threads.",
153 agents: "Agents are only available from a thread.",156 agents: "Agents are only available from a thread.",
157 device: "Devices are only available from a thread.",
154} as const;158} as const;
155159
156/** Overlays that must win over the launcher's letter shortcuts. */160/** Overlays that must win over the launcher's letter shortcuts. */
...@@ -174,6 +178,7 @@ const SURFACE_UNAVAILABLE_HINTS = {...@@ -174,6 +178,7 @@ const SURFACE_UNAVAILABLE_HINTS = {
174 pullRequest: "No pull request on this branch yet.",178 pullRequest: "No pull request on this branch yet.",
175 pullRequests: "Available for server threads.",179 pullRequests: "Available for server threads.",
176 agents: "Available from a thread.",180 agents: "Available from a thread.",
181 device: "Available from a thread.",
177} as const;182} as const;
178183
179type TabContextMenuAction =184type TabContextMenuAction =
...@@ -312,6 +317,7 @@ function RightPanelEmptyState(props: {...@@ -312,6 +317,7 @@ function RightPanelEmptyState(props: {
312 onAddPullRequest: () => void;317 onAddPullRequest: () => void;
313 onAddPullRequests: () => void;318 onAddPullRequests: () => void;
314 onAddAgents: () => void;319 onAddAgents: () => void;
320 onAddDevice: () => void;
315 browserAvailable: boolean;321 browserAvailable: boolean;
316 terminalAvailable: boolean;322 terminalAvailable: boolean;
317 diffAvailable: boolean;323 diffAvailable: boolean;
...@@ -319,6 +325,7 @@ function RightPanelEmptyState(props: {...@@ -319,6 +325,7 @@ function RightPanelEmptyState(props: {
319 pullRequestAvailable: boolean;325 pullRequestAvailable: boolean;
320 pullRequestsAvailable: boolean;326 pullRequestsAvailable: boolean;
321 agentsAvailable: boolean;327 agentsAvailable: boolean;
328 deviceAvailable: boolean;
322 liveAgentCount: number;329 liveAgentCount: number;
323}) {330}) {
324 // -1 means no highlight: it only appears on hover or arrow use.331 // -1 means no highlight: it only appears on hover or arrow use.
...@@ -395,6 +402,16 @@ function RightPanelEmptyState(props: {...@@ -395,6 +402,16 @@ function RightPanelEmptyState(props: {
395 onClick: props.onAddAgents,402 onClick: props.onAddAgents,
396 badgeCount: props.liveAgentCount,403 badgeCount: props.liveAgentCount,
397 },404 },
405 {
406 label: "Device",
407 description: "Watch an iOS Simulator or Android Emulator.",
408 icon: Smartphone,
409 shortcut: "M",
410 available: props.deviceAvailable,
411 disabledReason: SURFACE_UNAVAILABLE_HINTS.device,
412 onClick: props.onAddDevice,
413 badgeCount: 0,
414 },
398 ] as const;415 ] as const;
399416
400 type SurfaceAction = (typeof actions)[number];417 type SurfaceAction = (typeof actions)[number];
...@@ -630,6 +647,8 @@ function surfaceTitle(...@@ -630,6 +647,8 @@ function surfaceTitle(
630 return "Pull requests";647 return "Pull requests";
631 case "agents":648 case "agents":
632 return "Agents";649 return "Agents";
650 case "device":
651 return "Device";
633 case "preview": {652 case "preview": {
634 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;653 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;
635 if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser";654 if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser";
...@@ -713,6 +732,8 @@ function SurfaceIcon({...@@ -713,6 +732,8 @@ function SurfaceIcon({
713 return <GitPullRequestArrow className="size-3 shrink-0" />;732 return <GitPullRequestArrow className="size-3 shrink-0" />;
714 case "agents":733 case "agents":
715 return <Bot className="size-3 shrink-0" />;734 return <Bot className="size-3 shrink-0" />;
735 case "device":
736 return <Smartphone className="size-3 shrink-0" />;
716 }737 }
717}738}
718739
...@@ -906,6 +927,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -906,6 +927,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
906 disabledReason: SURFACE_DISABLED_REASONS.agents,927 disabledReason: SURFACE_DISABLED_REASONS.agents,
907 onClick: props.onAddAgents,928 onClick: props.onAddAgents,
908 },929 },
930 {
931 label: "Device",
932 icon: Smartphone,
933 shortcut: "M",
934 available: props.deviceAvailable,
935 disabledReason: SURFACE_DISABLED_REASONS.device,
936 onClick: props.onAddDevice,
937 },
909 ] as const;938 ] as const;
910939
911 const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {940 const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
...@@ -1344,6 +1373,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -1344,6 +1373,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
1344 onAddPullRequest={props.onAddPullRequest}1373 onAddPullRequest={props.onAddPullRequest}
1345 onAddPullRequests={props.onAddPullRequests}1374 onAddPullRequests={props.onAddPullRequests}
1346 onAddAgents={props.onAddAgents}1375 onAddAgents={props.onAddAgents}
1376 onAddDevice={props.onAddDevice}
1347 browserAvailable={props.browserAvailable}1377 browserAvailable={props.browserAvailable}
1348 terminalAvailable={props.terminalAvailable}1378 terminalAvailable={props.terminalAvailable}
1349 diffAvailable={props.diffAvailable}1379 diffAvailable={props.diffAvailable}
...@@ -1351,6 +1381,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -1351,6 +1381,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
1351 pullRequestAvailable={props.pullRequestAvailable}1381 pullRequestAvailable={props.pullRequestAvailable}
1352 pullRequestsAvailable={props.pullRequestsAvailable}1382 pullRequestsAvailable={props.pullRequestsAvailable}
1353 agentsAvailable={props.agentsAvailable}1383 agentsAvailable={props.agentsAvailable}
1384 deviceAvailable={props.deviceAvailable}
1354 liveAgentCount={props.liveAgentCount}1385 liveAgentCount={props.liveAgentCount}
1355 />1386 />
1356 ) : (1387 ) : (
apps/web/src/components/chat/MessagesTimeline.logic.ts+1-1
...@@ -337,7 +337,7 @@ export type MessagesTimelineRow =...@@ -337,7 +337,7 @@ export type MessagesTimelineRow =
337 summaryKind: ToolGroupSummaryKind;337 summaryKind: ToolGroupSummaryKind;
338 toolSurface?: WorkLogEntry["toolSurface"];338 toolSurface?: WorkLogEntry["toolSurface"];
339 toolIcon?: WorkLogEntry["toolIcon"];339 toolIcon?: WorkLogEntry["toolIcon"];
340 summaryToolIcon?: "browser" | "t3-code" | "pull-request";340 summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
341 hasFailure: boolean;341 hasFailure: boolean;
342 }342 }
343 | {343 | {
apps/web/src/components/chat/MessagesTimeline.tsx+6
...@@ -97,6 +97,7 @@ import {...@@ -97,6 +97,7 @@ import {
97 MousePointerClickIcon,97 MousePointerClickIcon,
98 PaintbrushIcon,98 PaintbrushIcon,
99 SearchIcon,99 SearchIcon,
100 SmartphoneIcon,
100 SquarePenIcon,101 SquarePenIcon,
101 TerminalIcon,102 TerminalIcon,
102 Undo2Icon,103 Undo2Icon,
...@@ -2239,6 +2240,8 @@ function toolGroupSummaryIconName(...@@ -2239,6 +2240,8 @@ function toolGroupSummaryIconName(
2239 return "terminal";2240 return "terminal";
2240 case "browser":2241 case "browser":
2241 return "browser";2242 return "browser";
2243 case "device":
2244 return "device";
2242 case "search":2245 case "search":
2243 return "globe";2246 return "globe";
2244 case "code-search":2247 case "code-search":
...@@ -2800,6 +2803,7 @@ type WorkEntryIconName =...@@ -2800,6 +2803,7 @@ type WorkEntryIconName =
2800 | "check"2803 | "check"
2801 | "circle-alert"2804 | "circle-alert"
2802 | "computer"2805 | "computer"
2806 | "device"
2803 | "eye"2807 | "eye"
2804 | "globe"2808 | "globe"
2805 | "hammer"2809 | "hammer"
...@@ -3022,6 +3026,8 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className...@@ -3022,6 +3026,8 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className
3022 return <BrowserAppIcon className={className} />;3026 return <BrowserAppIcon className={className} />;
3023 case "computer":3027 case "computer":
3024 return <ComputerUseAppIcon className={className} />;3028 return <ComputerUseAppIcon className={className} />;
3029 case "device":
3030 return <SmartphoneIcon className={className} aria-hidden />;
3025 case "t3-code":3031 case "t3-code":
3026 return <T3Wordmark className={className} aria-hidden />;3032 return <T3Wordmark className={className} aria-hidden />;
3027 case "check":3033 case "check":
apps/web/src/components/device/DevicePanel.tsx created+473
...@@ -0,0 +1,473 @@
1import type {
2 DevicePlatform,
3 DeviceServiceState,
4 DeviceSummary,
5 ScopedThreadRef,
6} from "@t3tools/contracts";
7import {
8 ChevronLeft,
9 Circle,
10 Home,
11 Power,
12 RotateCcw,
13 SlidersHorizontal,
14 Smartphone,
15 Square,
16 X,
17} from "lucide-react";
18import { useCallback, useEffect, useMemo, useState } from "react";
19
20import { Button } from "~/components/ui/button";
21import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list";
22import { Dialog } from "~/components/ui/dialog";
23import { WizardPopup } from "~/components/ui/wizard";
24import {
25 Select,
26 SelectGroup,
27 SelectGroupLabel,
28 SelectItem,
29 SelectPopup,
30 SelectTrigger,
31 SelectValue,
32} from "~/components/ui/select";
33import { Spinner } from "~/components/ui/spinner";
34import { Toggle } from "~/components/ui/toggle";
35import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
36import { cn } from "~/lib/utils";
37import { deviceEnvironment, useDeviceHubAccess, useDeviceState } from "~/state/device";
38import { formatEnvironmentQueryError } from "~/state/query";
39import { useAtomCommand } from "~/state/use-atom-command";
40import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView";
41import { DeviceSetup } from "./DeviceSetup";
42import { DeviceToolsPanel } from "./DeviceToolsPanel";
43import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell";
44
45const NEW_DEVICE_VALUE = "__new__";
46
47const platformLabel = (platform: DevicePlatform) =>
48 platform === "ios" ? "iOS Simulators" : "Android Emulators";
49
50const deviceKey = (device: Pick<DeviceSummary, "hostId" | "id">) =>
51 `${device.hostId}\u0000${device.id}`;
52
53/**
54 * The Device right-panel surface: one open device (from the thread's device
55 * sessions) with a picker to switch or boot another. Booting and streaming are
56 * server-owned; this panel only asks and renders.
57 */
58export function DevicePanel(props: {
59 readonly mode: PreviewPanelMode;
60 readonly threadRef: ScopedThreadRef;
61 /** `null` renders the picker with nothing open. */
62 readonly deviceId: string | null;
63 readonly visible: boolean;
64 readonly onDismissSetup: () => void;
65}) {
66 const { environmentId, threadId } = props.threadRef;
67 const { state, loaded } = useDeviceState(environmentId);
68 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
69 const open = useAtomCommand(deviceEnvironment.open);
70 const close = useAtomCommand(deviceEnvironment.close);
71 const [operationError, setOperationError] = useState<string | null>(null);
72 const [pendingDeviceKey, setPendingDeviceKey] = useState<string | null>(null);
73 const [handle, setHandle] = useState<DeviceStreamHandle | null>(null);
74 const [toolsOpen, setToolsOpen] = useState(false);
75 const [axOverlay, setAxOverlay] = useState(false);
76 const access = useDeviceHubAccess(environmentId);
77
78 const hostDisabled = state.hostStatus === "disabled";
79
80 // Opening setup never grants permission to install or start helpers.
81 useEffect(() => {
82 if (!props.visible || !loaded || hostDisabled) return;
83 void list({ environmentId, input: {} });
84 }, [environmentId, list, loaded, props.visible, hostDisabled]);
85
86 const sessions = useMemo(
87 () => state.sessions.filter((session) => session.threadId === threadId),
88 [state.sessions, threadId],
89 );
90 const activeSession =
91 (props.deviceId
92 ? sessions.find((session) => session.deviceId === props.deviceId)
93 : undefined) ?? sessions.at(-1);
94 const activeDevice = activeSession
95 ? state.devices.find(
96 (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId,
97 )
98 : undefined;
99
100 const grouped = useMemo(() => groupDevices(state), [state]);
101
102 const selectDevice = useCallback(
103 async (value: string) => {
104 if (value === NEW_DEVICE_VALUE) return;
105 const device = state.devices.find((candidate) => deviceKey(candidate) === value);
106 if (!device) return;
107 setOperationError(null);
108 setPendingDeviceKey(value);
109 try {
110 const result = await open({
111 environmentId,
112 input: {
113 threadId,
114 hostId: device.hostId,
115 deviceId: device.id,
116 platform: device.platform,
117 },
118 });
119 if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
120 } finally {
121 setPendingDeviceKey(null);
122 }
123 },
124 [environmentId, open, state.devices, threadId],
125 );
126
127 const closeActive = useCallback(
128 (powerOff: boolean) => {
129 if (!activeSession) return;
130 setOperationError(null);
131 void close({
132 environmentId,
133 input: { threadId, deviceId: activeSession.deviceId, shutdown: powerOff },
134 }).then((result) => {
135 if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
136 });
137 },
138 [activeSession, close, environmentId, threadId],
139 );
140
141 const bootingDevices =
142 state.bootingDevices?.filter((device) => device.threadId === threadId) ?? [];
143 const hostReady = state.hostStatus === "ready";
144 const hostBusy = state.hostStatus === "installing" || state.hostStatus === "starting";
145 const unavailablePlatforms = state.hosts.flatMap((host) =>
146 host.platforms.filter((platform) => !platform.available),
147 );
148
149 if (loaded && (!state.onboardingCompleted || hostDisabled)) {
150 return (
151 <Dialog
152 open={props.visible}
153 onOpenChange={(isOpen) => {
154 if (!isOpen) props.onDismissSetup();
155 }}
156 >
157 <WizardPopup>
158 <DeviceSetup environmentId={environmentId} state={state} />
159 </WizardPopup>
160 </Dialog>
161 );
162 }
163
164 return (
165 <PreviewPanelShell mode={props.mode}>
166 <div className="flex h-9 shrink-0 items-center gap-1.5 border-b px-2">
167 <Select
168 value={activeDevice ? deviceKey(activeDevice) : NEW_DEVICE_VALUE}
169 onValueChange={(value) => {
170 if (value !== null) void selectDevice(value);
171 }}
172 disabled={!loaded || hostBusy || hostDisabled || pendingDeviceKey !== null}
173 >
174 <SelectTrigger size="sm" className="min-w-0 flex-1" aria-label="Device">
175 <SelectValue>
176 {activeDevice ? (
177 <span className="flex items-center gap-1.5 truncate">
178 <Smartphone className="size-3.5 shrink-0" />
179 <span className="truncate">{activeDevice.name}</span>
180 <span className="shrink-0 text-muted-foreground">{activeDevice.version}</span>
181 </span>
182 ) : (
183 <span className="text-muted-foreground">
184 {hostBusy
185 ? state.hostStatus === "installing"
186 ? "Installing device tools…"
187 : "Starting device hub…"
188 : "Choose a device"}
189 </span>
190 )}
191 </SelectValue>
192 </SelectTrigger>
193 <SelectPopup align="start" alignItemWithTrigger={false} className="min-w-72">
194 {grouped.map((group) => (
195 <SelectGroup key={group.platform}>
196 <SelectGroupLabel>{platformLabel(group.platform)}</SelectGroupLabel>
197 {group.devices.map((device) => (
198 <SelectItem key={deviceKey(device)} value={deviceKey(device)}>
199 <span className="flex w-full items-center gap-2">
200 <Circle
201 className={cn(
202 "size-2 shrink-0",
203 device.booted ? "fill-success text-success" : "text-muted-foreground/50",
204 )}
205 />
206 <span className="truncate">
207 {device.booted ? device.name : `Start ${device.name}`}
208 </span>
209 <span className="ml-auto shrink-0 text-xs text-muted-foreground">
210 {device.version}
211 </span>
212 </span>
213 </SelectItem>
214 ))}
215 </SelectGroup>
216 ))}
217 {grouped.length === 0 ? (
218 <SelectItem value={NEW_DEVICE_VALUE} disabled>
219 {loaded ? "No devices found" : "Loading…"}
220 </SelectItem>
221 ) : null}
222 </SelectPopup>
223 </Select>
224 {activeDevice ? (
225 <>
226 <DeviceButton
227 label="Home"
228 onClick={() => handle?.pressButton("home")}
229 disabled={!handle?.inputConnected}
230 >
231 <Home />
232 </DeviceButton>
233 {activeDevice.platform === "android" ? (
234 <>
235 <DeviceButton
236 label="Back"
237 onClick={() => handle?.pressButton("back")}
238 disabled={!handle?.inputConnected}
239 >
240 <ChevronLeft />
241 </DeviceButton>
242 <DeviceButton
243 label="Recents"
244 onClick={() => handle?.pressButton("recents")}
245 disabled={!handle?.inputConnected}
246 >
247 <Square />
248 </DeviceButton>
249 </>
250 ) : (
251 <DeviceButton
252 label="Rotate"
253 onClick={() => handle?.rotate()}
254 disabled={!handle?.inputConnected}
255 >
256 <RotateCcw />
257 </DeviceButton>
258 )}
259 <Toggle
260 aria-label="Tools"
261 variant="ghost"
262 size="xs"
263 pressed={toolsOpen}
264 onPressedChange={(pressed) => setToolsOpen(Boolean(pressed))}
265 >
266 <SlidersHorizontal />
267 </Toggle>
268 <DeviceButton label="Power off" onClick={() => closeActive(true)}>
269 <Power />
270 </DeviceButton>
271 <DeviceButton label="Close" onClick={() => closeActive(false)}>
272 <X />
273 </DeviceButton>
274 </>
275 ) : null}
276 </div>
277 {hostReady && state.hostStatusDetail ? (
278 <div
279 role="status"
280 className="whitespace-pre-line border-b px-3 py-2 text-xs text-muted-foreground"
281 >
282 {state.hostStatusDetail}
283 </div>
284 ) : null}
285 {bootingDevices.length > 0 ? (
286 <div role="status" className="border-b px-3 py-2 text-xs text-muted-foreground">
287 Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute.
288 </div>
289 ) : null}
290 {operationError ? (
291 <div
292 role="alert"
293 className="flex items-start gap-2 border-b bg-destructive/5 px-3 py-2 text-xs text-destructive"
294 >
295 <p className="min-w-0 flex-1 whitespace-pre-wrap break-words">{operationError}</p>
296 <Button
297 size="icon-xs"
298 variant="ghost"
299 aria-label="Dismiss device error"
300 onClick={() => setOperationError(null)}
301 >
302 <X className="size-3" />
303 </Button>
304 </div>
305 ) : null}
306 <div className="@container relative flex min-h-0 flex-1">
307 {activeDevice && activeSession ? (
308 <>
309 <div className="relative min-h-0 min-w-0 flex-1">
310 <DeviceStreamView
311 key={deviceKey(activeDevice)}
312 environmentId={environmentId}
313 platform={activeDevice.platform}
314 deviceId={activeDevice.id}
315 visible={props.visible}
316 axOverlay={axOverlay}
317 onHandle={setHandle}
318 />
319 </div>
320 {toolsOpen ? (
321 <DeviceToolsPanel
322 key={deviceKey(activeDevice)}
323 environmentId={environmentId}
324 device={activeDevice}
325 access={access}
326 axOverlay={axOverlay}
327 onAxOverlayChange={setAxOverlay}
328 onClose={() => setToolsOpen(false)}
329 className="absolute inset-y-0 right-0 z-10 w-full max-w-72 border-l shadow-lg @[560px]:static @[560px]:w-72 @[560px]:shrink-0 @[560px]:shadow-none"
330 />
331 ) : null}
332 </>
333 ) : (
334 <div className="flex size-full flex-col overflow-y-auto px-5 py-8 text-sm text-muted-foreground">
335 <div
336 className={cn(
337 "mx-auto flex w-full max-w-xl flex-col gap-6",
338 grouped.length === 0 && "my-auto items-center text-center",
339 )}
340 >
341 {grouped.length === 0 || hostBusy || pendingDeviceKey ? (
342 <>
343 {hostBusy || pendingDeviceKey ? (
344 <Spinner />
345 ) : (
346 <Smartphone className="size-6 opacity-60" />
347 )}
348 <p className="max-w-sm">
349 {state.hostStatus === "failed"
350 ? (state.hostStatusDetail ?? "The device hub failed to start.")
351 : pendingDeviceKey
352 ? "Booting device… this can take a minute."
353 : hostBusy
354 ? state.hostStatus === "installing"
355 ? "Installing device tools…"
356 : "Starting the device hub…"
357 : !loaded
358 ? "Connecting…"
359 : grouped.length === 0
360 ? "No simulators or emulators were found on this environment."
361 : "Choose a device to open."}
362 </p>
363 </>
364 ) : null}
365 {hostReady && grouped.length > 0 ? (
366 <div className="w-full space-y-6 text-left">
367 {grouped.map((group) => (
368 <section key={group.platform} className="space-y-3">
369 <div className="flex items-center gap-2 text-sm text-muted-foreground">
370 <Smartphone className="size-4 shrink-0" />
371 <h3 className="font-medium">{platformLabel(group.platform)}</h3>
372 </div>
373 <DiscoveryList>
374 {group.devices.map((device) => (
375 <DiscoveryListRow
376 key={deviceKey(device)}
377 icon={
378 <span className="grid size-8 shrink-0 place-items-center rounded-md border border-border/60">
379 <Smartphone className="size-4" />
380 </span>
381 }
382 title={device.name}
383 description={`${device.version} · ${device.booted ? "Running" : "Stopped"}`}
384 disabled={pendingDeviceKey !== null}
385 aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`}
386 onClick={() => void selectDevice(deviceKey(device))}
387 action={
388 pendingDeviceKey === deviceKey(device) ? (
389 <Spinner className="size-3" />
390 ) : (
391 <span className="text-xs text-muted-foreground">
392 {device.booted ? "Open" : "Start"}
393 </span>
394 )
395 }
396 />
397 ))}
398 </DiscoveryList>
399 </section>
400 ))}
401 </div>
402 ) : null}
403 {hostReady &&
404 !state.devices.some((device) => device.platform === "android") &&
405 !unavailablePlatforms.some((platform) => platform.platform === "android") ? (
406 <p className="max-w-sm text-xs">
407 No Android virtual devices found. Create one in Android Studio's Device Manager,
408 then refresh.
409 </p>
410 ) : null}
411 {loaded && !hostBusy ? (
412 <Button
413 className="self-start"
414 variant={grouped.length > 0 ? "ghost" : "outline"}
415 size="sm"
416 onClick={() => void list({ environmentId, input: {} })}
417 >
418 Refresh devices
419 </Button>
420 ) : null}
421 {unavailablePlatforms.length > 0 && hostReady ? (
422 <ul className="max-w-sm space-y-1 text-xs opacity-70">
423 {unavailablePlatforms.map((platform) => (
424 <li key={platform.platform}>
425 {platform.platform === "ios" ? "iOS" : "Android"}: {platform.reason}
426 </li>
427 ))}
428 </ul>
429 ) : null}
430 </div>
431 </div>
432 )}
433 </div>
434 </PreviewPanelShell>
435 );
436}
437
438function DeviceButton(props: {
439 readonly label: string;
440 readonly onClick: () => void;
441 readonly disabled?: boolean;
442 readonly children: React.ReactNode;
443}) {
444 return (
445 <Tooltip>
446 <TooltipTrigger
447 render={
448 <Button
449 size="icon-xs"
450 variant="ghost-muted"
451 aria-label={props.label}
452 onClick={props.onClick}
453 disabled={props.disabled ?? false}
454 />
455 }
456 >
457 {props.children}
458 </TooltipTrigger>
459 <TooltipPopup>{props.label}</TooltipPopup>
460 </Tooltip>
461 );
462}
463
464function groupDevices(state: DeviceServiceState) {
465 const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = [];
466 for (const platform of ["ios", "android"] as const) {
467 const devices = state.devices
468 .filter((device) => device.platform === platform)
469 .toSorted((a, b) => Number(b.booted) - Number(a.booted) || a.name.localeCompare(b.name));
470 if (devices.length > 0) groups.push({ platform, devices });
471 }
472 return groups;
473}
apps/web/src/components/device/DeviceSetup.tsx created+307
...@@ -0,0 +1,307 @@
1import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts";
2import { Check, CircleAlert } from "lucide-react";
3import { useState } from "react";
4
5import { Button } from "~/components/ui/button";
6import { DialogClose } from "~/components/ui/dialog";
7import { WizardHeader, WizardPanel, WizardSteps, WizardFooter } from "~/components/ui/wizard";
8import { Spinner } from "~/components/ui/spinner";
9import { Switch } from "~/components/ui/switch";
10import { deviceEnvironment } from "~/state/device";
11import { useAtomCommand } from "~/state/use-atom-command";
12import { cn } from "~/lib/utils";
13
14const platformName = (platform: DevicePlatform) => (platform === "ios" ? "iOS" : "Android");
15
16export const deviceHubDescription =
17 "Enable this environment to open simulators and emulators, whether they run here or on a remote device host.";
18export const agentDeviceDescription =
19 "Allow new agent sessions in this environment to start and control local and remote devices, with required tools set up automatically.";
20
21export function platformSetupStatus(state: DeviceServiceState, platform: DevicePlatform) {
22 const availability = state.hosts
23 .flatMap((host) => host.platforms)
24 .find((candidate) => candidate.platform === platform);
25 if (!availability?.available) {
26 return {
27 ready: false,
28 message: availability?.reason ?? `${platformName(platform)} support was not detected.`,
29 };
30 }
31 if (
32 state.hostStatus === "ready" &&
33 !state.devices.some((device) => device.platform === platform)
34 ) {
35 return {
36 ready: false,
37 message:
38 platform === "ios"
39 ? "Xcode is installed, but no iOS Simulator is available. Install a runtime in Xcode Settings → Components."
40 : "The Android SDK is installed, but no virtual device exists. Create one in Android Studio → Device Manager.",
41 };
42 }
43 return {
44 ready: true,
45 message:
46 platform === "ios"
47 ? "Xcode and iOS Simulator are available."
48 : "The Android SDK and Emulator are available.",
49 };
50}
51
52export function DeviceSetup(props: {
53 readonly environmentId: EnvironmentId;
54 readonly state: DeviceServiceState;
55 readonly onComplete?: () => void;
56}) {
57 const configure = useAtomCommand(deviceEnvironment.configure);
58 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
59 const [pending, setPending] = useState<"hub" | "check" | "agent" | "complete" | null>(null);
60 const [step, setStep] = useState(0);
61 const enabled = props.state.hostStatus !== "disabled";
62 const busy = props.state.hostStatus === "installing" || props.state.hostStatus === "starting";
63
64 const update = async (
65 kind: NonNullable<typeof pending>,
66 input: { enabled?: boolean; agentAccessEnabled?: boolean; onboardingCompleted?: boolean },
67 ) => {
68 setPending(kind);
69 try {
70 const result = await configure({ environmentId: props.environmentId, input });
71 if (kind === "complete" && result._tag === "Success") props.onComplete?.();
72 } finally {
73 setPending(null);
74 }
75 };
76
77 return (
78 <>
79 <WizardHeader
80 title="Set up devices"
81 description="Review what runs on this environment before using simulators and emulators."
82 >
83 <WizardSteps
84 steps={["Device hub", "Simulators", "Agent access"]}
85 currentStep={step}
86 onStepChange={setStep}
87 isStepDisabled={(requested) => busy || pending !== null || requested > step}
88 />
89 </WizardHeader>
90
91 <WizardPanel>
92 {step === 0 ? (
93 <section className="space-y-3 text-sm">
94 <h3 className="font-medium">Enable the device hub</h3>
95 <div className="flex items-start justify-between gap-4">
96 <p className="text-muted-foreground">{deviceHubDescription}</p>
97 <Switch
98 checked={enabled}
99 disabled={busy || pending !== null}
100 aria-label="Enable device hub"
101 onCheckedChange={(checked) =>
102 void update("hub", {
103 enabled: Boolean(checked),
104 ...(checked ? {} : { agentAccessEnabled: false }),
105 })
106 }
107 />
108 </div>
109 <DeviceHubSetupStatus
110 state={props.state}
111 pending={pending === "hub" || (busy && pending !== "agent")}
112 />
113 </section>
114 ) : null}
115
116 {step === 1 ? (
117 <section className="space-y-3 text-sm">
118 <h3 className="font-medium">Check simulator support</h3>
119 <DevicePlatformSetup
120 state={props.state}
121 checking={pending === "check"}
122 disabled={!enabled || busy || pending !== null}
123 onCheck={() => {
124 setPending("check");
125 void list({ environmentId: props.environmentId, input: {} }).finally(() =>
126 setPending(null),
127 );
128 }}
129 />
130 </section>
131 ) : null}
132
133 {step === 2 ? (
134 <section className="space-y-3 text-sm">
135 <h3 className="font-medium">Allow agent control</h3>
136 <div className="flex items-start justify-between gap-4">
137 <p className="text-muted-foreground">{agentDeviceDescription}</p>
138 <Switch
139 checked={props.state.agentAccessEnabled}
140 disabled={!enabled || busy || pending !== null}
141 aria-label="Allow agents to control devices"
142 onCheckedChange={(checked) =>
143 void update("agent", { agentAccessEnabled: Boolean(checked) })
144 }
145 />
146 </div>
147 <AgentDeviceSetupStatus state={props.state} pending={pending === "agent"} />
148 <p className="text-xs text-muted-foreground">
149 Leave this off to keep manual device controls without giving agents access.
150 </p>
151 </section>
152 ) : null}
153 {props.state.hostStatus === "failed" && props.state.hostStatusDetail ? (
154 <p role="alert" className="mt-3 text-xs text-destructive">
155 {props.state.hostStatusDetail}
156 </p>
157 ) : null}
158 </WizardPanel>
159
160 <WizardFooter>
161 {step === 0 ? (
162 <DialogClose render={<Button variant="outline" />}>Cancel</DialogClose>
163 ) : (
164 <Button
165 variant="outline"
166 disabled={busy || pending !== null}
167 onClick={() => setStep(step - 1)}
168 >
169 Back
170 </Button>
171 )}
172 {step < 2 ? (
173 <Button
174 disabled={props.state.hostStatus !== "ready" || pending !== null}
175 onClick={() => setStep(step + 1)}
176 >
177 Continue
178 </Button>
179 ) : (
180 <Button
181 disabled={props.state.hostStatus !== "ready" || pending !== null}
182 onClick={() => void update("complete", { onboardingCompleted: true })}
183 >
184 {pending === "complete" ? "Saving…" : "Done"}
185 </Button>
186 )}
187 </WizardFooter>
188 </>
189 );
190}
191
192export function DeviceHubSetupStatus({
193 state,
194 pending,
195 compact = false,
196}: {
197 readonly state: DeviceServiceState;
198 readonly pending: boolean;
199 readonly compact?: boolean;
200}) {
201 if (!pending && state.hostStatus !== "ready") return null;
202 return (
203 <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
204 {pending ? <Spinner className="size-3" /> : <Check className="size-3 text-success" />}
205 {pending
206 ? state.hostStatus === "installing"
207 ? compact
208 ? "Installing…"
209 : "Installing device hub…"
210 : state.hostStatus === "starting"
211 ? compact
212 ? "Starting…"
213 : "Starting device hub…"
214 : compact
215 ? "Updating…"
216 : "Updating device hub…"
217 : "Device hub is ready."}
218 </p>
219 );
220}
221
222function DevicePlatformSetup(props: {
223 readonly state: DeviceServiceState;
224 readonly checking: boolean;
225 readonly disabled: boolean;
226 readonly onCheck: () => void;
227}) {
228 return (
229 <div className="space-y-3">
230 <PlatformStatus platform="iOS" status={platformSetupStatus(props.state, "ios")} />
231 <PlatformStatus platform="Android" status={platformSetupStatus(props.state, "android")} />
232 <p className="text-xs text-muted-foreground">
233 You can use either platform. Fixing a missing platform does not block the other one.
234 </p>
235 <Button size="compact" variant="outline" disabled={props.disabled} onClick={props.onCheck}>
236 {props.checking ? <Spinner className="size-3" /> : null}
237 {props.checking ? "Checking…" : "Check again"}
238 </Button>
239 </div>
240 );
241}
242
243export function AgentDeviceSetupStatus(props: {
244 readonly state: DeviceServiceState;
245 readonly pending: boolean;
246 readonly compact?: boolean;
247}) {
248 if (props.pending) {
249 const label =
250 props.state.hostStatus === "installing"
251 ? props.compact
252 ? "Installing…"
253 : "Installing agent tools…"
254 : props.state.hostStatus === "starting"
255 ? props.compact
256 ? "Starting…"
257 : "Starting agent tools…"
258 : props.compact
259 ? "Updating…"
260 : "Updating agent access…";
261 return (
262 <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
263 <Spinner className="size-3" />
264 {label}
265 </p>
266 );
267 }
268 if (
269 props.state.agentAccessEnabled &&
270 props.state.hostStatus === "ready" &&
271 props.state.hosts.some((host) => host.agentDeviceInstalled)
272 ) {
273 return (
274 <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
275 <Check className="size-3 text-success" />
276 Agent tools are ready.
277 </p>
278 );
279 }
280 return null;
281}
282
283export function PlatformStatus(props: {
284 readonly platform: string;
285 readonly status: { readonly ready: boolean; readonly message: string };
286 readonly compact?: boolean;
287}) {
288 const Icon = props.status.ready ? Check : CircleAlert;
289 return (
290 <div
291 className={cn("flex gap-2", !props.compact && "rounded-md border border-border/60 px-3 py-2")}
292 >
293 <Icon
294 className={cn(
295 "mt-0.5 size-4 shrink-0",
296 props.status.ready ? "text-success" : "text-muted-foreground",
297 )}
298 />
299 <div className={cn(props.compact && props.status.ready && "flex items-center gap-2")}>
300 <p className="font-medium">{props.platform}</p>
301 <p className="text-xs text-muted-foreground">
302 {props.compact && props.status.ready ? "Ready" : props.status.message}
303 </p>
304 </div>
305 </div>
306 );
307}
apps/web/src/components/device/DeviceStreamView.test.tsx created+58
...@@ -0,0 +1,58 @@
1import { act } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { EnvironmentId } from "@t3tools/contracts";
4import { afterEach, expect, it, vi } from "vite-plus/test";
5
6vi.mock("~/state/device", () => ({
7 useDeviceHubAccess: () => access,
8 refreshDeviceHubAccess: vi.fn(),
9}));
10const access = { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true };
11vi.mock("./deviceStream", () => ({
12 createDeviceStreamClient: (
13 _target: unknown,
14 _canvas: unknown,
15 events: { onMjpegFallback: (url: string) => void },
16 ) => ({
17 start: () => events.onMjpegFallback("http://test/stream.mjpeg"),
18 stop: vi.fn(),
19 }),
20}));
21import { DeviceStreamView } from "./DeviceStreamView";
22let renderer: ReactTestRenderer | undefined;
23afterEach(async () => {
24 await act(async () => renderer?.unmount());
25 vi.unstubAllGlobals();
26});
27
28it("removes MJPEG requests while hidden and reconnects when shown", async () => {
29 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
30 vi.stubGlobal(
31 "ResizeObserver",
32 class {
33 observe() {}
34 disconnect() {}
35 },
36 );
37 const view = (visible: boolean) => (
38 <DeviceStreamView
39 environmentId={EnvironmentId.make("test")}
40 deviceId="test"
41 platform="ios"
42 visible={visible}
43 />
44 );
45 await act(async () => {
46 renderer = create(view(true), {
47 createNodeMock: () => ({
48 style: { setProperty() {} },
49 getBoundingClientRect: () => ({ width: 400, height: 800 }),
50 }),
51 });
52 });
53 expect(renderer!.root.findAllByType("img")).toHaveLength(1);
54 await act(async () => renderer!.update(view(false)));
55 expect(renderer!.root.findAllByType("img")).toHaveLength(0);
56 await act(async () => renderer!.update(view(true)));
57 expect(renderer!.root.findAllByType("img")).toHaveLength(1);
58});
apps/web/src/components/device/DeviceStreamView.tsx created+327
...@@ -0,0 +1,327 @@
1import type { DevicePlatform, EnvironmentId } from "@t3tools/contracts";
2import { useEffect, useMemo, useRef, useState } from "react";
3
4import { cn } from "~/lib/utils";
5import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device";
6import { Spinner } from "~/components/ui/spinner";
7import { type DeviceAxElement, fetchDeviceAxTree } from "./deviceHubApi";
8import {
9 createDeviceStreamClient,
10 type DeviceHardwareButton,
11 type DeviceScreenSize,
12 type DeviceStreamClient,
13 type DeviceStreamStatus,
14} from "./deviceStream";
15
16const AX_POLL_INTERVAL_MS = 2_000;
17
18export interface DeviceStreamHandle {
19 readonly pressButton: (button: DeviceHardwareButton) => void;
20 readonly rotate: () => void;
21 /** False while the input socket is down; controls should disable. */
22 readonly inputConnected: boolean;
23}
24
25/**
26 * The live device screen. Pointer events map onto normalized coordinates in
27 * the displayed frame and go to the device; keyboard input is forwarded while
28 * the surface is focused. `visible=false` tears the stream down so a hidden
29 * panel decodes nothing.
30 */
31export function DeviceStreamView(props: {
32 readonly environmentId: EnvironmentId;
33 readonly platform: DevicePlatform;
34 readonly deviceId: string;
35 readonly visible: boolean;
36 /** Draw accessibility element frames over the screen. */
37 readonly axOverlay?: boolean;
38 readonly onHandle?: (handle: DeviceStreamHandle | null) => void;
39 readonly onScreen?: (screen: DeviceScreenSize | null) => void;
40}) {
41 const access = useDeviceHubAccess(props.environmentId);
42 const canvasRef = useRef<HTMLCanvasElement | null>(null);
43 const clientRef = useRef<DeviceStreamClient | null>(null);
44 const [status, setStatus] = useState<DeviceStreamStatus>("connecting");
45 const [detail, setDetail] = useState<string | undefined>(undefined);
46 const [screen, setScreen] = useState<DeviceScreenSize | null>(null);
47 const [mjpegUrl, setMjpegUrl] = useState<string | null>(null);
48 const [mjpegGeneration, setMjpegGeneration] = useState(0);
49 const [inputState, setInputState] = useState<{ connected: boolean; detail?: string }>({
50 connected: false,
51 });
52 const { onHandle, onScreen } = props;
53
54 useEffect(() => {
55 const canvas = canvasRef.current;
56 if (!access || !canvas || !props.visible) {
57 setStatus("connecting");
58 onHandle?.(null);
59 return;
60 }
61 const client = createDeviceStreamClient(
62 { platform: props.platform, deviceId: props.deviceId, access },
63 canvas,
64 {
65 onStatus: (next, nextDetail) => {
66 setStatus(next);
67 setDetail(nextDetail);
68 },
69 onScreen: (next) => {
70 setScreen(next);
71 onScreen?.(next);
72 },
73 onUnauthorized: () => {
74 // A fresh ticket re-runs this effect through the access dependency.
75 refreshDeviceHubAccess(props.environmentId);
76 },
77 onMjpegFallback: (url) => {
78 setMjpegUrl(url);
79 setMjpegGeneration((generation) => generation + 1);
80 },
81 onInputConnected: (connected, detail) => {
82 setInputState({ connected, ...(detail ? { detail } : {}) });
83 onHandle?.({
84 pressButton: client.pressButton,
85 rotate: client.rotate,
86 inputConnected: connected,
87 });
88 },
89 },
90 );
91 clientRef.current = client;
92 setMjpegUrl(null);
93 setInputState({ connected: false });
94 client.start();
95 onHandle?.({ pressButton: client.pressButton, rotate: client.rotate, inputConnected: false });
96 return () => {
97 client.stop();
98 clientRef.current = null;
99 onHandle?.(null);
100 onScreen?.(null);
101 setScreen(null);
102 };
103 }, [
104 access,
105 onHandle,
106 onScreen,
107 props.deviceId,
108 props.environmentId,
109 props.platform,
110 props.visible,
111 ]);
112
113 // Displayed aspect ratio (width / height) of the device as the user sees it.
114 const aspect = useMemo(() => {
115 if (!screen) return props.platform === "ios" ? 9 / 19.5 : 9 / 20;
116 const landscape =
117 screen.orientation === "landscape_left" || screen.orientation === "landscape_right";
118 const w = landscape
119 ? Math.max(screen.width, screen.height)
120 : Math.min(screen.width, screen.height);
121 const h = landscape
122 ? Math.min(screen.width, screen.height)
123 : Math.max(screen.width, screen.height);
124 return w / h;
125 }, [props.platform, screen]);
126
127 // The frame is the largest box at `aspect` that fits the container, so a
128 // narrow panel shows a shorter phone rather than a squeezed one. CSS
129 // `aspect-ratio` alone cannot do this: with the height pinned to 100% the
130 // width clamp wins and distorts the drawn frame.
131 const hostRef = useRef<HTMLDivElement | null>(null);
132 const [host, setHost] = useState({ width: 0, height: 0 });
133 useEffect(() => {
134 const element = hostRef.current;
135 if (!element) return;
136 const update = () => {
137 const rect = element.getBoundingClientRect();
138 setHost((current) =>
139 current.width === rect.width && current.height === rect.height
140 ? current
141 : { width: rect.width, height: rect.height },
142 );
143 };
144 update();
145 const observer = new ResizeObserver(update);
146 observer.observe(element);
147 return () => observer.disconnect();
148 }, []);
149 const frame = useMemo(() => {
150 if (host.width === 0 || host.height === 0) return { width: 0, height: 0 };
151 const byHeight = { width: host.height * aspect, height: host.height };
152 return byHeight.width <= host.width
153 ? byHeight
154 : { width: host.width, height: host.width / aspect };
155 }, [aspect, host]);
156
157 // serve-sim streams the raw framebuffer; rotate the display for a device
158 // that reports landscape while its frames stay portrait.
159 const rotation = useMemo(() => {
160 if (props.platform !== "ios" || !screen || screen.width > screen.height) return 0;
161 switch (screen.orientation) {
162 case "landscape_left":
163 return 90;
164 case "landscape_right":
165 return -90;
166 case "portrait_upside_down":
167 return 180;
168 default:
169 return 0;
170 }
171 }, [props.platform, screen]);
172
173 // A sideways rotation draws the raw portrait frame into a landscape box:
174 // the media element takes the transposed size and is rotated about the
175 // box's center.
176 const sideways = rotation === 90 || rotation === -90;
177 const mediaStyle: React.CSSProperties = sideways
178 ? {
179 width: frame.height,
180 height: frame.width,
181 left: (frame.width - frame.height) / 2,
182 top: (frame.height - frame.width) / 2,
183 transform: `rotate(${rotation}deg)`,
184 }
185 : {
186 width: frame.width,
187 height: frame.height,
188 ...(rotation ? { transform: `rotate(${rotation}deg)` } : {}),
189 };
190
191 // The accessibility tree is polled while the overlay is on; each poll is
192 // one JSON fetch, so there is nothing to repaint between polls.
193 const [axElements, setAxElements] = useState<ReadonlyArray<DeviceAxElement>>([]);
194 useEffect(() => {
195 if (!props.axOverlay || !access || !props.visible) return;
196 const target = { access, platform: props.platform, deviceId: props.deviceId };
197 let controller: AbortController | null = null;
198 let timer: ReturnType<typeof setTimeout> | null = null;
199 let stopped = false;
200 const poll = async () => {
201 controller = new AbortController();
202 try {
203 const tree = await fetchDeviceAxTree(target, controller.signal);
204 if (!stopped) setAxElements(tree.elements);
205 } catch {
206 // Keep the last good tree; the next poll retries.
207 }
208 if (!stopped) timer = setTimeout(() => void poll(), AX_POLL_INTERVAL_MS);
209 };
210 void poll();
211 return () => {
212 stopped = true;
213 controller?.abort();
214 if (timer) clearTimeout(timer);
215 setAxElements([]);
216 };
217 }, [access, props.axOverlay, props.deviceId, props.platform, props.visible]);
218
219 const pointerActive = useRef(false);
220 const normalizedPoint = (event: React.PointerEvent<HTMLElement>) => {
221 const rect = event.currentTarget.getBoundingClientRect();
222 const x = (event.clientX - rect.left) / rect.width;
223 const y = (event.clientY - rect.top) / rect.height;
224 return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) };
225 };
226
227 return (
228 <div
229 ref={hostRef}
230 className="relative flex size-full items-center justify-center overflow-hidden bg-black/90 outline-none"
231 tabIndex={0}
232 role="application"
233 aria-label={`${props.platform === "ios" ? "iOS Simulator" : "Android Emulator"} screen`}
234 onKeyDown={(event) => {
235 if (event.metaKey && !["r", "R"].includes(event.key)) return;
236 event.preventDefault();
237 clientRef.current?.sendKey(event.nativeEvent, "down");
238 }}
239 onKeyUp={(event) => {
240 clientRef.current?.sendKey(event.nativeEvent, "up");
241 }}
242 >
243 <div
244 className="relative select-none"
245 style={{ width: frame.width, height: frame.height }}
246 onPointerDown={(event) => {
247 event.currentTarget.setPointerCapture(event.pointerId);
248 (event.currentTarget.parentElement as HTMLElement | null)?.focus();
249 pointerActive.current = true;
250 const { x, y } = normalizedPoint(event);
251 clientRef.current?.sendTouch("begin", x, y);
252 }}
253 onPointerMove={(event) => {
254 if (!pointerActive.current) return;
255 const { x, y } = normalizedPoint(event);
256 clientRef.current?.sendTouch("move", x, y);
257 }}
258 onPointerUp={(event) => {
259 if (!pointerActive.current) return;
260 pointerActive.current = false;
261 const { x, y } = normalizedPoint(event);
262 clientRef.current?.sendTouch("end", x, y);
263 }}
264 onPointerCancel={(event) => {
265 if (!pointerActive.current) return;
266 pointerActive.current = false;
267 const { x, y } = normalizedPoint(event);
268 clientRef.current?.sendTouch("end", x, y);
269 }}
270 >
271 <canvas
272 ref={canvasRef}
273 className={cn("absolute top-0 left-0", mjpegUrl && "hidden")}
274 style={mediaStyle}
275 />
276 {props.visible && access && mjpegUrl ? (
277 <img
278 key={mjpegGeneration}
279 src={mjpegUrl}
280 alt=""
281 draggable={false}
282 className="absolute top-0 left-0 object-contain"
283 style={mediaStyle}
284 />
285 ) : null}
286 {axElements.length > 0 ? (
287 <div className="pointer-events-none absolute inset-0" aria-hidden>
288 {axElements.map((element) => (
289 <div
290 key={element.id}
291 className="absolute border border-sky-400/80 bg-sky-400/10"
292 style={{
293 left: `${element.x * 100}%`,
294 top: `${element.y * 100}%`,
295 width: `${element.width * 100}%`,
296 height: `${element.height * 100}%`,
297 }}
298 >
299 {element.label ? (
300 <span className="absolute -top-3.5 left-0 max-w-full truncate rounded-sm bg-sky-500 px-1 text-[9px] leading-3.5 text-white">
301 {element.label}
302 </span>
303 ) : null}
304 </div>
305 ))}
306 </div>
307 ) : null}
308 </div>
309 {status === "streaming" && !inputState.connected ? (
310 <div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-2">
311 <span className="rounded-md bg-background/85 px-2 py-1 text-xs text-muted-foreground">
312 Input disconnected{inputState.detail ? ` (${inputState.detail})` : ""}, reconnecting…
313 </span>
314 </div>
315 ) : null}
316 {status !== "streaming" ? (
317 <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background/70 text-sm text-muted-foreground">
318 {status === "connecting" ? <Spinner /> : null}
319 <span>{status === "error" ? (detail ?? "Stream failed.") : "Connecting to device…"}</span>
320 {status === "connecting" && detail ? (
321 <span className="max-w-xs text-center text-xs opacity-70">{detail}</span>
322 ) : null}
323 </div>
324 ) : null}
325 </div>
326 );
327}
apps/web/src/components/device/DeviceToolsPanel.tsx created+722
...@@ -0,0 +1,722 @@
1import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
2import type {
3 DeviceActionInput,
4 DeviceDetail,
5 DevicePermission,
6 DeviceSummary,
7 DeviceTextSize,
8 EnvironmentId,
9} from "@t3tools/contracts";
10import { ChevronDown, X } from "lucide-react";
11import { useCallback, useEffect, useMemo, useState } from "react";
12
13import { Button } from "~/components/ui/button";
14import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible";
15import { Input } from "~/components/ui/input";
16import {
17 Select,
18 SelectItem,
19 SelectPopup,
20 SelectTrigger,
21 SelectValue,
22} from "~/components/ui/select";
23import { Spinner } from "~/components/ui/spinner";
24import { Switch } from "~/components/ui/switch";
25import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";
26import { cn } from "~/lib/utils";
27import { deviceEnvironment } from "~/state/device";
28import { formatEnvironmentQueryError } from "~/state/query";
29import { useAtomCommand } from "~/state/use-atom-command";
30import {
31 type DeviceEventLogEntry,
32 type DeviceForegroundInfo,
33 subscribeDeviceEventLog,
34 subscribeDeviceForeground,
35} from "./deviceHubApi";
36
37type ActionBody = DeviceActionInput extends infer A
38 ? A extends { readonly type: string }
39 ? Omit<A, "hostId" | "deviceId">
40 : never
41 : never;
42
43const TEXT_SIZES: ReadonlyArray<{ value: DeviceTextSize; label: string }> = [
44 { value: "small", label: "Small" },
45 { value: "default", label: "Default" },
46 { value: "large", label: "Large" },
47 { value: "extra-large", label: "Extra large" },
48];
49
50const COLOR_FILTERS = [
51 { value: "none", label: "None" },
52 { value: "grayscale", label: "Grayscale" },
53 { value: "red-green", label: "Red / green (protanopia)" },
54 { value: "green-red", label: "Green / red (deuteranopia)" },
55 { value: "blue-yellow", label: "Blue / yellow (tritanopia)" },
56] as const;
57
58const ORIENTATIONS = [
59 { value: "portrait", label: "Portrait" },
60 { value: "landscape_left", label: "Landscape left" },
61 { value: "portrait_upside_down", label: "Upside down" },
62 { value: "landscape_right", label: "Landscape right" },
63] as const;
64
65const IOS_PERMISSIONS: ReadonlyArray<{ value: DevicePermission; label: string }> = [
66 { value: "camera", label: "Camera" },
67 { value: "microphone", label: "Microphone" },
68 { value: "photos", label: "Photos" },
69 { value: "contacts", label: "Contacts" },
70 { value: "calendar", label: "Calendar" },
71 { value: "reminders", label: "Reminders" },
72 { value: "location", label: "Location" },
73 { value: "notifications", label: "Notifications" },
74 { value: "motion", label: "Motion" },
75 { value: "media-library", label: "Media library" },
76 { value: "faceid", label: "Face ID" },
77];
78
79const ANDROID_PERMISSIONS: ReadonlyArray<{ value: DevicePermission; label: string }> = [
80 { value: "camera", label: "Camera" },
81 { value: "microphone", label: "Microphone" },
82 { value: "photos", label: "Photos" },
83 { value: "contacts", label: "Contacts" },
84 { value: "calendar", label: "Calendar" },
85 { value: "location", label: "Location" },
86 { value: "notifications", label: "Notifications" },
87 { value: "motion", label: "Physical activity" },
88];
89
90const LOCATION_PRESETS = [
91 { label: "San Francisco", latitude: 37.7749, longitude: -122.4194 },
92 { label: "New York", latitude: 40.7128, longitude: -74.006 },
93 { label: "London", latitude: 51.5074, longitude: -0.1278 },
94 { label: "Stockholm", latitude: 59.3293, longitude: 18.0686 },
95 { label: "Tokyo", latitude: 35.6762, longitude: 139.6503 },
96] as const;
97
98/**
99 * The Tools drawer for one open device: current settings read from the device,
100 * one control per supported action, and the read-only feeds the hub exposes.
101 * Every change is a `device.action` round trip; the returned detail replaces
102 * local state so the controls never show a value the device did not confirm.
103 */
104export function DeviceToolsPanel(props: {
105 readonly environmentId: EnvironmentId;
106 readonly device: DeviceSummary;
107 readonly access: DeviceHubAccess | null;
108 readonly axOverlay: boolean;
109 readonly onAxOverlayChange: (enabled: boolean) => void;
110 readonly onClose: () => void;
111 readonly className?: string;
112}) {
113 const { environmentId, device } = props;
114 const readDetail = useAtomCommand(deviceEnvironment.detail, { reportFailure: false });
115 const runAction = useAtomCommand(deviceEnvironment.action, { reportFailure: false });
116 const [detail, setDetail] = useState<DeviceDetail | null>(null);
117 const [pending, setPending] = useState(false);
118 const [error, setError] = useState<string | null>(null);
119 const [foreground, setForeground] = useState<DeviceForegroundInfo | null | undefined>(undefined);
120 const isIos = device.platform === "ios";
121
122 const target = useMemo(
123 () => ({ hostId: device.hostId, deviceId: device.id }),
124 [device.hostId, device.id],
125 );
126
127 // The panel is keyed by device, so a mount is always a fresh device.
128 useEffect(() => {
129 let cancelled = false;
130 void readDetail({ environmentId, input: target }).then((result) => {
131 if (cancelled) return;
132 if (result._tag === "Success") setDetail(result.value);
133 else setError(formatEnvironmentQueryError(result.cause));
134 });
135 return () => {
136 cancelled = true;
137 };
138 }, [environmentId, readDetail, target]);
139
140 useEffect(() => {
141 if (!props.access) return;
142 return subscribeDeviceForeground(
143 { access: props.access, platform: device.platform, deviceId: device.id },
144 setForeground,
145 );
146 }, [device.id, device.platform, props.access]);
147
148 const act = useCallback(
149 async (body: ActionBody) => {
150 setPending(true);
151 setError(null);
152 try {
153 const result = await runAction({
154 environmentId,
155 input: { ...target, ...body } as DeviceActionInput,
156 });
157 if (result._tag === "Success") setDetail(result.value);
158 else setError(formatEnvironmentQueryError(result.cause));
159 } finally {
160 setPending(false);
161 }
162 },
163 [environmentId, runAction, target],
164 );
165
166 const settings = detail?.settings;
167 const foregroundApp = foreground === undefined ? (detail?.foregroundApp ?? null) : foreground;
168 const disabled = pending || detail === null;
169
170 return (
171 <div
172 className={cn("flex min-h-0 flex-col border-border bg-background text-sm", props.className)}
173 >
174 <div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
175 <span className="font-medium">Tools</span>
176 {pending ? <Spinner className="size-3.5" /> : null}
177 <Button
178 size="icon-xs"
179 variant="ghost-muted"
180 aria-label="Close tools"
181 className="ml-auto"
182 onClick={props.onClose}
183 >
184 <X />
185 </Button>
186 </div>
187 <div className="min-h-0 flex-1 overflow-y-auto">
188 {error ? (
189 <p className="border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{error}</p>
190 ) : null}
191 {detail === null && !error ? (
192 <div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground">
193 <Spinner className="size-3.5" /> Reading device settings…
194 </div>
195 ) : null}
196
197 <Section title="App">
198 <Row label="Foreground">
199 <span className="truncate font-mono text-xs">{foregroundApp?.id ?? "—"}</span>
200 </Row>
201 {foregroundApp ? (
202 <div className="flex gap-1.5">
203 <Button
204 size="xs"
205 variant="outline"
206 disabled={disabled}
207 onClick={() => void act({ type: "terminateApp", appId: foregroundApp.id })}
208 >
209 Terminate
210 </Button>
211 <Button
212 size="xs"
213 variant="outline"
214 disabled={disabled}
215 onClick={() => void act({ type: "launchApp", appId: foregroundApp.id })}
216 >
217 Relaunch
218 </Button>
219 </div>
220 ) : null}
221 <SubmitRow
222 placeholder="https://… or myapp://"
223 action="Open"
224 disabled={disabled}
225 onSubmit={(url) => act({ type: "openUrl", url })}
226 />
227 <SubmitRow
228 placeholder={isIos ? "Bundle ID to launch" : "Package name to launch"}
229 action="Launch"
230 disabled={disabled}
231 onSubmit={(appId) => act({ type: "launchApp", appId })}
232 />
233 </Section>
234
235 <Section title={isIos ? "Simulator" : "Emulator"}>
236 <Row label="Appearance">
237 <ToggleGroup
238 aria-label="Appearance"
239 value={settings?.appearance ? [settings.appearance] : []}
240 disabled={disabled}
241 onValueChange={(value) => {
242 const next = value[0];
243 if (next === "light" || next === "dark")
244 void act({ type: "setAppearance", value: next });
245 }}
246 >
247 <Toggle value="light">Light</Toggle>
248 <Toggle value="dark">Dark</Toggle>
249 </ToggleGroup>
250 </Row>
251 <Row label="Text size">
252 <ChoiceSelect
253 ariaLabel="Text size"
254 value={settings?.textSize ?? null}
255 options={TEXT_SIZES}
256 disabled={disabled}
257 onChange={(value) => act({ type: "setTextSize", value })}
258 />
259 </Row>
260 {isIos ? (
261 <>
262 <Row label="Liquid Glass">
263 <ToggleGroup
264 aria-label="Liquid Glass"
265 value={settings?.liquidGlass ? [settings.liquidGlass] : []}
266 disabled={disabled || settings?.liquidGlass === undefined}
267 onValueChange={(value) => {
268 const next = value[0];
269 if (next === "clear" || next === "tinted") {
270 void act({ type: "setLiquidGlass", value: next });
271 }
272 }}
273 >
274 <Toggle value="clear">Clear</Toggle>
275 <Toggle value="tinted">Tinted</Toggle>
276 </ToggleGroup>
277 </Row>
278 <Row label="Color filter">
279 <ChoiceSelect
280 ariaLabel="Color filter"
281 value={settings?.colorFilter ?? null}
282 options={COLOR_FILTERS}
283 disabled={disabled}
284 onChange={(value) => act({ type: "setColorFilter", value })}
285 />
286 </Row>
287 </>
288 ) : (
289 <Row label="Orientation">
290 <ChoiceSelect
291 ariaLabel="Orientation"
292 value={null}
293 placeholder="Rotate to…"
294 options={ORIENTATIONS}
295 disabled={disabled}
296 onChange={(value) => act({ type: "setOrientation", value })}
297 />
298 </Row>
299 )}
300 <SwitchRow
301 label="Reduce Motion"
302 checked={settings?.reduceMotion}
303 disabled={disabled}
304 onChange={(value) => act({ type: "setToggle", setting: "reduceMotion", value })}
305 />
306 {isIos ? (
307 <>
308 <SwitchRow
309 label="Increase Contrast"
310 checked={settings?.increaseContrast}
311 disabled={disabled}
312 onChange={(value) => act({ type: "setToggle", setting: "increaseContrast", value })}
313 />
314 <SwitchRow
315 label="Reduce Transparency"
316 checked={settings?.reduceTransparency}
317 disabled={disabled}
318 onChange={(value) =>
319 act({ type: "setToggle", setting: "reduceTransparency", value })
320 }
321 />
322 <SwitchRow
323 label="Show Borders"
324 checked={settings?.showBorders}
325 disabled={disabled}
326 onChange={(value) => act({ type: "setToggle", setting: "showBorders", value })}
327 />
328 <SwitchRow
329 label="VoiceOver"
330 checked={settings?.voiceOver}
331 disabled={disabled}
332 onChange={(value) => act({ type: "setToggle", setting: "voiceOver", value })}
333 />
334 </>
335 ) : (
336 <SwitchRow
337 label="Network"
338 checked={settings?.networkEnabled}
339 disabled={disabled}
340 onChange={(value) => act({ type: "setToggle", setting: "networkEnabled", value })}
341 />
342 )}
343 </Section>
344
345 <Section title="Accessibility">
346 <SwitchRow
347 label="Overlay element frames"
348 checked={props.axOverlay}
349 disabled={props.access === null}
350 onChange={(value) => {
351 props.onAxOverlayChange(value);
352 return Promise.resolve();
353 }}
354 />
355 </Section>
356
357 <LocationSection
358 disabled={disabled}
359 canClear={isIos}
360 onSet={(latitude, longitude) => act({ type: "setLocation", latitude, longitude })}
361 onClear={() => act({ type: "clearLocation" })}
362 />
363
364 <PermissionsSection
365 permissions={isIos ? IOS_PERMISSIONS : ANDROID_PERMISSIONS}
366 canReset={isIos}
367 defaultAppId={foregroundApp?.id ?? ""}
368 disabled={disabled}
369 onDecide={(appId, permission, decision) =>
370 act({ type: "setPermission", appId, permission, decision })
371 }
372 />
373
374 {isIos ? (
375 <Section title="Push notification">
376 <SubmitRow
377 placeholder="Alert text"
378 action="Send"
379 disabled={disabled || !foregroundApp}
380 onSubmit={(payload) =>
381 foregroundApp
382 ? act({ type: "sendPush", appId: foregroundApp.id, payload })
383 : Promise.resolve()
384 }
385 />
386 {!foregroundApp ? (
387 <p className="text-xs text-muted-foreground">Open an app first.</p>
388 ) : null}
389 </Section>
390 ) : null}
391
392 {isIos && props.access ? <EventLogSection access={props.access} device={device} /> : null}
393 </div>
394 </div>
395 );
396}
397
398function Section(props: { readonly title: string; readonly children: React.ReactNode }) {
399 return (
400 <section className="flex flex-col gap-2 border-b px-3 py-2.5 last:border-b-0">
401 <h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
402 {props.title}
403 </h3>
404 {props.children}
405 </section>
406 );
407}
408
409function Row(props: { readonly label: string; readonly children: React.ReactNode }) {
410 return (
411 <div className="flex min-h-7 items-center justify-between gap-3">
412 <span className="shrink-0 text-xs text-muted-foreground">{props.label}</span>
413 <div className="flex min-w-0 items-center justify-end">{props.children}</div>
414 </div>
415 );
416}
417
418function SwitchRow(props: {
419 readonly label: string;
420 readonly checked: boolean | undefined;
421 readonly disabled: boolean;
422 readonly onChange: (value: boolean) => Promise<void>;
423}) {
424 return (
425 <Row label={props.label}>
426 <Switch
427 size="sm"
428 aria-label={props.label}
429 checked={props.checked ?? false}
430 disabled={props.disabled || props.checked === undefined}
431 onCheckedChange={(checked) => void props.onChange(checked)}
432 />
433 </Row>
434 );
435}
436
437function ChoiceSelect<V extends string>(props: {
438 readonly ariaLabel: string;
439 readonly value: V | null;
440 readonly options: ReadonlyArray<{ readonly value: V; readonly label: string }>;
441 readonly disabled: boolean;
442 readonly placeholder?: string;
443 readonly onChange: (value: V) => Promise<void>;
444}) {
445 const current = props.options.find((option) => option.value === props.value);
446 return (
447 <Select
448 value={props.value}
449 disabled={props.disabled}
450 onValueChange={(value) => {
451 if (value !== null && value !== props.value) void props.onChange(value as V);
452 }}
453 >
454 <SelectTrigger size="xs" className="w-40" aria-label={props.ariaLabel}>
455 <SelectValue>
456 {current ? (
457 current.label
458 ) : (
459 <span className="text-muted-foreground">{props.placeholder ?? "Unknown"}</span>
460 )}
461 </SelectValue>
462 </SelectTrigger>
463 <SelectPopup align="end" alignItemWithTrigger={false}>
464 {props.options.map((option) => (
465 <SelectItem key={option.value} value={option.value}>
466 {option.label}
467 </SelectItem>
468 ))}
469 </SelectPopup>
470 </Select>
471 );
472}
473
474function SubmitRow(props: {
475 readonly placeholder: string;
476 readonly action: string;
477 readonly disabled: boolean;
478 readonly onSubmit: (value: string) => Promise<void>;
479}) {
480 const [value, setValue] = useState("");
481 const submit = () => {
482 const trimmed = value.trim();
483 if (!trimmed) return;
484 void props.onSubmit(trimmed).then(() => setValue(""));
485 };
486 return (
487 <form
488 className="flex gap-1.5"
489 onSubmit={(event) => {
490 event.preventDefault();
491 submit();
492 }}
493 >
494 <Input
495 size="compact"
496 className="min-w-0 flex-1 font-mono"
497 placeholder={props.placeholder}
498 value={value}
499 disabled={props.disabled}
500 onChange={(event) => setValue(event.target.value)}
501 />
502 <Button
503 type="submit"
504 size="xs"
505 variant="outline"
506 disabled={props.disabled || value.trim().length === 0}
507 >
508 {props.action}
509 </Button>
510 </form>
511 );
512}
513
514function LocationSection(props: {
515 readonly disabled: boolean;
516 readonly canClear: boolean;
517 readonly onSet: (latitude: number, longitude: number) => Promise<void>;
518 readonly onClear: () => Promise<void>;
519}) {
520 const [latitude, setLatitude] = useState("");
521 const [longitude, setLongitude] = useState("");
522 const parsed = { latitude: Number(latitude), longitude: Number(longitude) };
523 const valid =
524 latitude.trim() !== "" &&
525 longitude.trim() !== "" &&
526 Math.abs(parsed.latitude) <= 90 &&
527 Math.abs(parsed.longitude) <= 180;
528 return (
529 <Section title="Location">
530 <div className="flex gap-1.5">
531 <Input
532 size="compact"
533 className="min-w-0 flex-1 font-mono"
534 placeholder="Latitude"
535 inputMode="decimal"
536 value={latitude}
537 disabled={props.disabled}
538 onChange={(event) => setLatitude(event.target.value)}
539 />
540 <Input
541 size="compact"
542 className="min-w-0 flex-1 font-mono"
543 placeholder="Longitude"
544 inputMode="decimal"
545 value={longitude}
546 disabled={props.disabled}
547 onChange={(event) => setLongitude(event.target.value)}
548 />
549 </div>
550 <div className="flex flex-wrap items-center gap-1.5">
551 <Select<string | null>
552 value={null}
553 disabled={props.disabled}
554 onValueChange={(value) => {
555 const preset = LOCATION_PRESETS.find((candidate) => candidate.label === value);
556 if (!preset) return;
557 setLatitude(String(preset.latitude));
558 setLongitude(String(preset.longitude));
559 void props.onSet(preset.latitude, preset.longitude);
560 }}
561 >
562 <SelectTrigger size="xs" className="w-32" aria-label="Location preset">
563 <SelectValue>
564 <span className="text-muted-foreground">Preset…</span>
565 </SelectValue>
566 </SelectTrigger>
567 <SelectPopup align="start" alignItemWithTrigger={false}>
568 {LOCATION_PRESETS.map((preset) => (
569 <SelectItem key={preset.label} value={preset.label}>
570 {preset.label}
571 </SelectItem>
572 ))}
573 </SelectPopup>
574 </Select>
575 <Button
576 size="xs"
577 variant="outline"
578 disabled={props.disabled || !valid}
579 onClick={() => void props.onSet(parsed.latitude, parsed.longitude)}
580 >
581 Set
582 </Button>
583 {props.canClear ? (
584 <Button
585 size="xs"
586 variant="ghost"
587 disabled={props.disabled}
588 onClick={() => {
589 setLatitude("");
590 setLongitude("");
591 void props.onClear();
592 }}
593 >
594 Clear
595 </Button>
596 ) : null}
597 </div>
598 </Section>
599 );
600}
601
602function PermissionsSection(props: {
603 readonly permissions: ReadonlyArray<{ value: DevicePermission; label: string }>;
604 readonly canReset: boolean;
605 readonly defaultAppId: string;
606 readonly disabled: boolean;
607 readonly onDecide: (
608 appId: string,
609 permission: DevicePermission,
610 decision: "grant" | "revoke" | "reset",
611 ) => Promise<void>;
612}) {
613 const [appId, setAppId] = useState("");
614 const [permission, setPermission] = useState<DevicePermission>("camera");
615 const resolvedAppId = appId.trim() || props.defaultAppId;
616 const decide = (decision: "grant" | "revoke" | "reset") =>
617 void props.onDecide(resolvedAppId, permission, decision);
618 return (
619 <Section title="Permissions">
620 <Input
621 size="compact"
622 className="font-mono"
623 placeholder={props.defaultAppId || "App ID"}
624 value={appId}
625 disabled={props.disabled}
626 onChange={(event) => setAppId(event.target.value)}
627 />
628 <div className="flex flex-wrap items-center gap-1.5">
629 <ChoiceSelect
630 ariaLabel="Permission"
631 value={permission}
632 options={props.permissions}
633 disabled={props.disabled}
634 onChange={(value) => {
635 setPermission(value);
636 return Promise.resolve();
637 }}
638 />
639 <Button
640 size="xs"
641 variant="outline"
642 disabled={props.disabled || !resolvedAppId}
643 onClick={() => decide("grant")}
644 >
645 Grant
646 </Button>
647 <Button
648 size="xs"
649 variant="outline"
650 disabled={props.disabled || !resolvedAppId}
651 onClick={() => decide("revoke")}
652 >
653 Revoke
654 </Button>
655 {props.canReset ? (
656 <Button
657 size="xs"
658 variant="ghost"
659 disabled={props.disabled || !resolvedAppId}
660 onClick={() => decide("reset")}
661 >
662 Reset
663 </Button>
664 ) : null}
665 </div>
666 </Section>
667 );
668}
669
670const EVENT_LOG_LIMIT = 100;
671
672function EventLogSection(props: {
673 readonly access: DeviceHubAccess;
674 readonly device: DeviceSummary;
675}) {
676 const [open, setOpen] = useState(false);
677 const [entries, setEntries] = useState<ReadonlyArray<DeviceEventLogEntry>>([]);
678
679 useEffect(() => {
680 if (!open) return;
681 const unsubscribe = subscribeDeviceEventLog(
682 { access: props.access, platform: props.device.platform, deviceId: props.device.id },
683 (incoming, reset) => {
684 setEntries((current) => {
685 const merged = reset ? [...incoming] : [...current, ...incoming];
686 return merged.length > EVENT_LOG_LIMIT ? merged.slice(-EVENT_LOG_LIMIT) : merged;
687 });
688 },
689 );
690 return () => {
691 unsubscribe();
692 setEntries([]);
693 };
694 }, [open, props.access, props.device.id, props.device.platform]);
695
696 return (
697 <Collapsible open={open} onOpenChange={setOpen}>
698 <CollapsibleTrigger className="flex w-full items-center gap-1.5 border-b px-3 py-2.5 text-left text-xs font-medium tracking-wide text-muted-foreground uppercase">
699 Event log
700 <ChevronDown
701 className={cn("ml-auto size-3.5 transition-transform", open && "rotate-180")}
702 />
703 </CollapsibleTrigger>
704 <CollapsiblePanel>
705 <ol className="max-h-64 overflow-y-auto px-3 py-2 font-mono text-[11px] leading-relaxed">
706 {entries.length === 0 ? (
707 <li className="text-muted-foreground">No events yet.</li>
708 ) : (
709 entries.map((entry) => (
710 <li key={entry.id} className="flex gap-2">
711 <span className="shrink-0 text-muted-foreground">
712 {entry.timestamp.slice(11, 19)}
713 </span>
714 <span className="truncate">{entry.summary}</span>
715 </li>
716 ))
717 )}
718 </ol>
719 </CollapsiblePanel>
720 </Collapsible>
721 );
722}
apps/web/src/components/device/deviceHubApi.test.ts created+44
...@@ -0,0 +1,44 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2import { subscribeDeviceForeground } from "./deviceHubApi";
3
4describe("foreground app events", () => {
5 afterEach(() => vi.unstubAllGlobals());
6
7 it("clears the last app when it exits and ignores malformed events", () => {
8 let source: FakeEventSource;
9 class FakeEventSource {
10 onmessage: ((event: { data: string }) => void) | null = null;
11 addEventListener(_type: string, listener: (event: { data: string }) => void) {
12 this.onmessage = listener;
13 }
14 close = vi.fn();
15 constructor() {
16 source = this;
17 }
18 }
19 vi.stubGlobal("EventSource", FakeEventSource);
20 const onChange = vi.fn();
21 const stop = subscribeDeviceForeground(
22 {
23 platform: "ios",
24 deviceId: "test",
25 access: { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true },
26 },
27 onChange,
28 );
29 const emit = (data: unknown) => source.onmessage?.({ data: JSON.stringify(data) });
30 emit({ bundleId: "com.example.app", pid: 123 });
31 emit({ bundleId: null });
32 emit({ bundleId: "com.example.other" });
33 emit({ bundleId: "" });
34 emit({ other: "not app state" });
35 expect(onChange.mock.calls.map(([app]) => app)).toEqual([
36 { id: "com.example.app", pid: 123 },
37 null,
38 { id: "com.example.other" },
39 null,
40 ]);
41 stop();
42 expect(source!.close).toHaveBeenCalledOnce();
43 });
44});
apps/web/src/components/device/deviceHubApi.ts created+248
...@@ -0,0 +1,248 @@
1import { withDeviceHubQuery } from "@t3tools/client-runtime/state/deviceHubAccess";
2import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
3import type { DevicePlatform } from "@t3tools/contracts";
4
5/**
6 * Read-only hub endpoints the Tools drawer consumes directly: the accessibility
7 * tree, the foreground app, and the event log. Everything that changes device
8 * state goes through the `device.action` RPC instead, so this file never POSTs.
9 */
10
11export interface DeviceAxElement {
12 readonly id: string;
13 readonly label: string;
14 readonly role: string;
15 /** Normalized to the displayed screen: 0..1 on both axes. */
16 readonly x: number;
17 readonly y: number;
18 readonly width: number;
19 readonly height: number;
20}
21
22export interface DeviceAxTree {
23 readonly elements: ReadonlyArray<DeviceAxElement>;
24 readonly errors: ReadonlyArray<string>;
25}
26
27export interface DeviceEventLogEntry {
28 readonly id: number;
29 readonly timestamp: string;
30 readonly kind: string;
31 readonly summary: string;
32}
33
34export interface DeviceForegroundInfo {
35 readonly id: string;
36 readonly label?: string;
37 readonly pid?: number;
38 readonly isReactNative?: boolean;
39}
40
41interface Target {
42 readonly access: DeviceHubAccess;
43 readonly platform: DevicePlatform;
44 readonly deviceId: string;
45}
46
47const vendorBase = (target: Target) =>
48 `${target.access.httpBase}${target.platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"}`;
49
50const hubUrl = (target: Target, path: string, params?: Record<string, string>) => {
51 const search = params ? `?${new URLSearchParams(params).toString()}` : "";
52 return withDeviceHubQuery(`${vendorBase(target)}${path}${search}`, target.access);
53};
54
55const fetchJson = async (target: Target, url: string, signal?: AbortSignal): Promise<unknown> => {
56 const response = await fetch(url, {
57 cache: "no-store",
58 credentials: target.access.credentials ? "include" : "same-origin",
59 ...(signal ? { signal } : {}),
60 });
61 if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
62 return response.json();
63};
64
65const isRecord = (value: unknown): value is Record<string, unknown> =>
66 typeof value === "object" && value !== null;
67
68const numberOr = (value: unknown, fallback: number) =>
69 typeof value === "number" && Number.isFinite(value) ? value : fallback;
70
71const AX_ELEMENT_LIMIT = 500;
72
73/**
74 * serve-sim's helper returns the native nested tree; the root node is the
75 * application covering the whole screen. Flatten it the way serve-sim's own
76 * overlay does: skip nodes with the root's frame, cap the count.
77 */
78const flattenIosAxTree = (roots: ReadonlyArray<unknown>): ReadonlyArray<DeviceAxElement> => {
79 const first = roots[0];
80 const rootFrame = isRecord(first) && isRecord(first.frame) ? first.frame : null;
81 const screenWidth = Math.max(1, numberOr(rootFrame?.width, 1));
82 const screenHeight = Math.max(1, numberOr(rootFrame?.height, 1));
83 const elements: DeviceAxElement[] = [];
84 const visit = (node: unknown, path: string) => {
85 if (elements.length >= AX_ELEMENT_LIMIT || !isRecord(node) || !isRecord(node.frame)) return;
86 const frame = node.frame;
87 const width = numberOr(frame.width, 0);
88 const height = numberOr(frame.height, 0);
89 const coversScreen =
90 Math.abs(width - screenWidth) < 0.5 && Math.abs(height - screenHeight) < 0.5;
91 if (!coversScreen && width > 0 && height > 0) {
92 elements.push({
93 id: typeof node.AXUniqueId === "string" ? node.AXUniqueId : path,
94 label: typeof node.AXLabel === "string" ? node.AXLabel : "",
95 role: typeof node.type === "string" ? node.type : "",
96 x: numberOr(frame.x, 0) / screenWidth,
97 y: numberOr(frame.y, 0) / screenHeight,
98 width: width / screenWidth,
99 height: height / screenHeight,
100 });
101 }
102 const children = Array.isArray(node.children) ? node.children : [];
103 children.forEach((child, index) => visit(child, `${path}.${index}`));
104 };
105 roots.forEach((root, index) => visit(root, String(index)));
106 return elements;
107};
108
109export async function fetchDeviceAxTree(
110 target: Target,
111 signal?: AbortSignal,
112): Promise<DeviceAxTree> {
113 if (target.platform === "ios") {
114 const payload = await fetchJson(
115 target,
116 hubUrl(target, `/helper/${encodeURIComponent(target.deviceId)}/ax`),
117 signal,
118 );
119 if (!Array.isArray(payload)) {
120 const error = isRecord(payload) && typeof payload.error === "string" ? payload.error : null;
121 return { elements: [], errors: [error ?? "Unexpected accessibility payload."] };
122 }
123 return { elements: flattenIosAxTree(payload), errors: [] };
124 }
125 const payload = await fetchJson(
126 target,
127 hubUrl(target, "/api/accessibility", { device: target.deviceId }),
128 signal,
129 );
130 if (!isRecord(payload) || !Array.isArray(payload.nodes)) {
131 const error = isRecord(payload) && typeof payload.error === "string" ? payload.error : null;
132 return { elements: [], errors: [error ?? "Unexpected accessibility payload."] };
133 }
134 // uiautomator reports pixel bounds; the first node is the full window.
135 const nodes = payload.nodes.filter(
136 (node): node is Record<string, unknown> => isRecord(node) && isRecord(node.bounds),
137 );
138 const root = nodes[0]?.bounds as Record<string, unknown> | undefined;
139 const screenWidth = Math.max(1, numberOr(root?.right, 1));
140 const screenHeight = Math.max(1, numberOr(root?.bottom, 1));
141 // Layout containers span the whole window and would tint the entire
142 // screen; only nodes a user could point at are worth drawing.
143 const elements = nodes.slice(1).flatMap((node): DeviceAxElement[] => {
144 const bounds = node.bounds as Record<string, unknown>;
145 const left = numberOr(bounds.left, 0);
146 const top = numberOr(bounds.top, 0);
147 const width = (numberOr(bounds.right, left) - left) / screenWidth;
148 const height = (numberOr(bounds.bottom, top) - top) / screenHeight;
149 const text = typeof node.text === "string" ? node.text : "";
150 const description = typeof node.contentDescription === "string" ? node.contentDescription : "";
151 const label = text || description;
152 if (width >= 0.95 && height >= 0.9) return [];
153 if (!label && node.clickable !== true) return [];
154 const className = typeof node.className === "string" ? node.className : "";
155 return [
156 {
157 id: String(node.id ?? ""),
158 label,
159 role: className.split(".").at(-1) ?? "",
160 x: left / screenWidth,
161 y: top / screenHeight,
162 width,
163 height,
164 },
165 ];
166 });
167 return { elements, errors: [] };
168}
169
170const openEventSource = (
171 target: Target,
172 url: string,
173 onMessage: (data: unknown) => void,
174): (() => void) => {
175 const source = new EventSource(url, { withCredentials: target.access.credentials });
176 source.addEventListener("message", (event) => {
177 try {
178 onMessage(JSON.parse(String(event.data)));
179 } catch {
180 // Keep-alive comments and malformed frames carry nothing to render.
181 }
182 });
183 return () => source.close();
184};
185
186/** iOS only: the frontmost app, pushed by serve-sim whenever it changes. */
187export function subscribeDeviceForeground(
188 target: Target,
189 onChange: (app: DeviceForegroundInfo | null) => void,
190): () => void {
191 if (target.platform !== "ios") return () => {};
192 return openEventSource(
193 target,
194 hubUrl(target, "/appstate", { device: target.deviceId }),
195 (data) => {
196 if (!isRecord(data)) return;
197 if (data.bundleId === null || data.bundleId === "") {
198 onChange(null);
199 return;
200 }
201 if (typeof data.bundleId !== "string") return;
202 onChange({
203 id: data.bundleId,
204 ...(typeof data.pid === "number" ? { pid: data.pid } : {}),
205 ...(typeof data.isReactNative === "boolean" ? { isReactNative: data.isReactNative } : {}),
206 });
207 },
208 );
209}
210
211const toEventLogEntry = (raw: unknown): DeviceEventLogEntry | null => {
212 if (!isRecord(raw) || typeof raw.id !== "number") return null;
213 return {
214 id: raw.id,
215 timestamp: typeof raw.timestamp === "string" ? raw.timestamp : "",
216 kind: typeof raw.kind === "string" ? raw.kind : "",
217 summary:
218 typeof raw.summary === "string" ? raw.summary : typeof raw.msg === "string" ? raw.msg : "",
219 };
220};
221
222/**
223 * iOS only: serve-sim's event log, seeded with recent history and then pushed
224 * live. Android's session recorder only tracks replayable gestures, which the
225 * user already sees themselves, so it is not surfaced.
226 */
227export function subscribeDeviceEventLog(
228 target: Target,
229 onEvents: (entries: ReadonlyArray<DeviceEventLogEntry>, reset: boolean) => void,
230): () => void {
231 if (target.platform !== "ios") return () => {};
232 return openEventSource(
233 target,
234 hubUrl(target, "/api/event-log/events", { device: target.deviceId, limit: "100" }),
235 (data) => {
236 if (!isRecord(data)) return;
237 if (Array.isArray(data.events)) {
238 onEvents(
239 data.events.flatMap((raw) => toEventLogEntry(raw) ?? []),
240 true,
241 );
242 return;
243 }
244 const entry = toEventLogEntry(data.event);
245 if (entry) onEvents([entry], false);
246 },
247 );
248}
apps/web/src/components/device/deviceStream.test.ts created+180
...@@ -0,0 +1,180 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 createDeviceStreamClient,
5 AvccDemuxer,
6 avcCodecString,
7 parseSemuPacket,
8 scanAccessUnit,
9} from "./deviceStream";
10
11const envelope = (tag: number, payload: number[]) => {
12 const length = 1 + payload.length;
13 return [
14 (length >>> 24) & 0xff,
15 (length >>> 16) & 0xff,
16 (length >>> 8) & 0xff,
17 length & 0xff,
18 tag,
19 ...payload,
20 ];
21};
22
23describe("AvccDemuxer", () => {
24 it("reassembles envelopes split across reads", () => {
25 const demuxer = new AvccDemuxer();
26 const bytes = new Uint8Array([
27 ...envelope(1, [1, 0x64, 0x00, 0x1f]),
28 ...envelope(2, [9, 9, 9]),
29 ...envelope(0x7f, [0]),
30 ...envelope(3, [4]),
31 ]);
32 const first = demuxer.push(bytes.subarray(0, 7));
33 const rest = demuxer.push(bytes.subarray(7));
34 const chunks = [...first, ...rest];
35 expect(chunks.map((chunk) => chunk.type)).toEqual(["description", "keyframe", "delta"]);
36 expect(Array.from(chunks[0]!.payload)).toEqual([1, 0x64, 0x00, 0x1f]);
37 expect(Array.from(chunks[1]!.payload)).toEqual([9, 9, 9]);
38 });
39
40 it("derives the WebCodecs codec string from the avcC record", () => {
41 expect(avcCodecString(new Uint8Array([1, 0x64, 0x00, 0x1f]))).toBe("avc1.64001f");
42 expect(avcCodecString(new Uint8Array([1]))).toBe("avc1.42E01E");
43 });
44});
45
46describe("serve-emu frames", () => {
47 it("strips the SEMU header and reads the keyframe flag and timestamp", () => {
48 const buffer = new ArrayBuffer(16 + 3);
49 const view = new DataView(buffer);
50 view.setUint32(0, 0x53454d55);
51 view.setUint8(4, 1);
52 view.setUint8(5, 1);
53 view.setBigUint64(8, 123456n);
54 new Uint8Array(buffer).set([7, 8, 9], 16);
55 const packet = parseSemuPacket(buffer);
56 expect(packet.isKey).toBe(true);
57 expect(packet.timestamp).toBe(123456);
58 expect(Array.from(packet.data)).toEqual([7, 8, 9]);
59 });
60
61 it("treats a frame without the header as raw data", () => {
62 const packet = parseSemuPacket(new Uint8Array([0, 0, 1, 0x65]).buffer);
63 expect(packet.isKey).toBeNull();
64 expect(packet.data.length).toBe(4);
65 });
66
67 it("finds the SPS and IDR NAL units in an Annex-B access unit", () => {
68 const unit = new Uint8Array([0, 0, 0, 1, 0x67, 0x64, 0x00, 0x1f, 0, 0, 1, 0x65, 0xaa]);
69 const scanned = scanAccessUnit(unit);
70 expect(scanned.isKey).toBe(true);
71 expect(scanned.sps && avcCodecString(scanned.sps)).toBe("avc1.64001f");
72 expect(scanAccessUnit(new Uint8Array([0, 0, 1, 0x41, 0x00])).isKey).toBe(false);
73 });
74});
75
76describe("iOS input startup", () => {
77 afterEach(() => {
78 vi.useRealTimers();
79 vi.unstubAllGlobals();
80 });
81
82 const setup = () => {
83 vi.useFakeTimers();
84 const sockets: FakeSocket[] = [];
85 class FakeSocket {
86 static OPEN = 1;
87 readyState = 1;
88 binaryType = "";
89 onopen: (() => void) | null = null;
90 onmessage: ((event: { data: ArrayBuffer }) => void) | null = null;
91 onclose: ((event: { code: number; reason: string }) => void) | null = null;
92 send = vi.fn();
93 close = vi.fn();
94 constructor() {
95 sockets.push(this);
96 }
97 }
98 vi.stubGlobal("WebSocket", FakeSocket);
99 const signals: AbortSignal[] = [];
100 vi.stubGlobal(
101 "fetch",
102 vi.fn((_url, init: RequestInit) => {
103 const signal = init.signal!;
104 signals.push(signal);
105 return Promise.resolve(
106 new Response(
107 new ReadableStream({
108 start(controller) {
109 signal.addEventListener("abort", () => controller.error(new Error("aborted")));
110 },
111 }),
112 ),
113 );
114 }),
115 );
116 const client = createDeviceStreamClient(
117 {
118 platform: "ios",
119 deviceId: "test-device",
120 access: {
121 httpBase: "http://test/api/device-hub",
122 wsBase: "ws://test/api/device-hub",
123 credentials: true,
124 query: {},
125 },
126 },
127 { getContext: () => null } as unknown as HTMLCanvasElement,
128 {
129 onStatus: vi.fn(),
130 onScreen: vi.fn(),
131 onUnauthorized: vi.fn(),
132 onMjpegFallback: vi.fn(),
133 onInputConnected: vi.fn(),
134 },
135 );
136 return { client, sockets, signals };
137 };
138
139 it("connects input when the MJPEG prime never produces a frame", async () => {
140 const { client, sockets, signals } = setup();
141 client.start();
142 await vi.advanceTimersByTimeAsync(2_000);
143 expect(signals[0]?.aborted).toBe(true);
144 expect(sockets).toHaveLength(1);
145 client.stop();
146 });
147
148 it("aborts priming immediately when hidden without opening a socket later", async () => {
149 const { client, sockets, signals } = setup();
150 client.start();
151 await vi.advanceTimersByTimeAsync(0);
152 client.stop();
153 expect(signals[0]?.aborted).toBe(true);
154 await vi.advanceTimersByTimeAsync(2_000);
155 expect(sockets).toHaveLength(0);
156 });
157
158 it.each([
159 ["landscape_left", 0.7, 1 - 0.2],
160 ["landscape_right", 1 - 0.7, 0.2],
161 ])("maps %s touches back to the raw iOS framebuffer", async (orientation, x, y) => {
162 const { client, sockets } = setup();
163 client.start();
164 await vi.advanceTimersByTimeAsync(2_000);
165 const socket = sockets[0]!;
166 const json = new TextEncoder().encode(JSON.stringify({ width: 400, height: 800, orientation }));
167 const packet = new Uint8Array(1 + json.length);
168 packet[0] = 0x82;
169 packet.set(json, 1);
170 socket.onmessage?.({ data: packet.buffer });
171 client.sendTouch("begin", 0.2, 0.7);
172 const sent = socket.send.mock.calls[0]![0] as Uint8Array;
173 expect(JSON.parse(new TextDecoder().decode(sent.subarray(1)))).toEqual({
174 type: "begin",
175 x,
176 y,
177 });
178 client.stop();
179 });
180});
apps/web/src/components/device/deviceStream.ts created+697
...@@ -0,0 +1,697 @@
1/**
2 * Framework-free client for expo-device-hub's per-device streams, reached
3 * through the T3 proxy. One class handles both platforms because the hub
4 * vendors two servers with different wire formats:
5 *
6 * - iOS (serve-sim): video is an HTTP `stream.avcc` body of length-prefixed
7 * envelopes (`u32be length, u8 tag, payload`; tag 1 avcC description,
8 * 2 keyframe, 3 delta, 4 JPEG seed) decoded with WebCodecs; input goes over
9 * `helper/ws?device=<udid>` as `[tag][json]` packets. When WebCodecs is
10 * unavailable (plain-http remote origins) the MJPEG endpoint is used as an
11 * `<img>` source instead.
12 * - Android (serve-emu): one WebSocket at `ws?device=<serial>&frame-meta=1`
13 * carries H.264 access units prefixed with a 16-byte "SEMU" header
14 * (magic, version, key flag, pts) and accepts JSON gestures upstream.
15 *
16 * The decoder only runs while frames arrive and the viewer is attached; a
17 * hidden panel calls `stop()` so an idle device costs nothing on the GPU.
18 */
19import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
20import { withDeviceHubQuery } from "@t3tools/client-runtime/state/deviceHubAccess";
21import type { DevicePlatform } from "@t3tools/contracts";
22
23export type DeviceStreamStatus = "connecting" | "streaming" | "error";
24
25export interface DeviceScreenSize {
26 readonly width: number;
27 readonly height: number;
28 readonly orientation: "portrait" | "portrait_upside_down" | "landscape_left" | "landscape_right";
29}
30
31export interface DeviceStreamEvents {
32 readonly onStatus: (status: DeviceStreamStatus, detail?: string) => void;
33 readonly onScreen: (screen: DeviceScreenSize) => void;
34 /** The proxy rejected the credential; the owner should refresh access and reconnect. */
35 readonly onUnauthorized: () => void;
36 /**
37 * H.264 cannot be decoded here (no WebCodecs, or the simulator's profile is
38 * unsupported); the owner should show this MJPEG URL in an `<img>` instead of
39 * the canvas.
40 */
41 readonly onMjpegFallback: (url: string) => void;
42 /** Whether touches and keys can currently reach the device. */
43 readonly onInputConnected: (connected: boolean, detail?: string) => void;
44}
45
46export interface DeviceStreamTarget {
47 readonly platform: DevicePlatform;
48 readonly deviceId: string;
49 readonly access: DeviceHubAccess;
50}
51
52export type DeviceHardwareButton = "home" | "back" | "recents" | "power" | "appSwitcher";
53
54const RETRY_DELAY_MS = 1_000;
55const FRAME_DURATION_US = 16_667;
56const SEMU_MAGIC = 0x53454d55;
57const SEMU_HEADER_BYTES = 16;
58const SEMU_FLAG_KEY = 1;
59const SOFT_DECODE_QUEUE = 8;
60
61// serve-sim binary WS message tags (browser -> helper).
62const IOS_MSG_TOUCH = 0x03;
63const IOS_MSG_BUTTON = 0x04;
64const IOS_MSG_KEY = 0x06;
65const IOS_MSG_ORIENTATION = 0x07;
66const IOS_MSG_HARDWARE_KEYBOARD = 0x0d;
67// helper -> browser.
68const IOS_TAG_SCREEN_CONFIG = 0x82;
69
70const encoder = new TextEncoder();
71const decoder = new TextDecoder();
72
73const isWebCodecsSupported = (): boolean =>
74 typeof globalThis !== "undefined" &&
75 "VideoDecoder" in globalThis &&
76 "EncodedVideoChunk" in globalThis;
77
78function taggedJson(tag: number, payload: unknown): Uint8Array<ArrayBuffer> {
79 const json = encoder.encode(JSON.stringify(payload));
80 const out = new Uint8Array(1 + json.length);
81 out[0] = tag;
82 out.set(json, 1);
83 return out;
84}
85
86/** Build the WebCodecs `avc1.PPCCLL` string from an avcC record or an SPS NAL. */
87export function avcCodecString(bytes: Uint8Array): string {
88 if (bytes.length < 4) return "avc1.42E01E";
89 const hex = (byte: number) => byte.toString(16).padStart(2, "0");
90 return `avc1.${hex(bytes[1]!)}${hex(bytes[2]!)}${hex(bytes[3]!)}`;
91}
92
93/** Split serve-emu's SEMU-framed message into metadata and the Annex-B payload. */
94export function parseSemuPacket(raw: ArrayBuffer): {
95 readonly data: Uint8Array;
96 readonly isKey: boolean | null;
97 readonly timestamp: number | null;
98} {
99 const bytes = new Uint8Array(raw);
100 if (bytes.byteLength > SEMU_HEADER_BYTES) {
101 const view = new DataView(raw, 0, SEMU_HEADER_BYTES);
102 if (view.getUint32(0, false) === SEMU_MAGIC && view.getUint8(4) === 1) {
103 const pts = view.getBigUint64(8, false);
104 return {
105 data: bytes.subarray(SEMU_HEADER_BYTES),
106 isKey: (view.getUint8(5) & SEMU_FLAG_KEY) !== 0,
107 timestamp: pts <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(pts) : null,
108 };
109 }
110 }
111 return { data: bytes, isKey: null, timestamp: null };
112}
113
114const isVideoSessionMessage = (text: string) => {
115 try {
116 const message = JSON.parse(text) as { type?: unknown };
117 return message.type === "video-session";
118 } catch {
119 return false;
120 }
121};
122
123/** Walk an Annex-B access unit for its keyframe flag and SPS bytes. */
124export function scanAccessUnit(buf: Uint8Array): { isKey: boolean; sps: Uint8Array | null } {
125 let isKey = false;
126 let sps: Uint8Array | null = null;
127 const len = buf.length;
128 let i = 0;
129 while (i + 2 < len) {
130 if (buf[i] === 0 && buf[i + 1] === 0) {
131 let codeLen = 0;
132 if (buf[i + 2] === 1) codeLen = 3;
133 else if (i + 3 < len && buf[i + 2] === 0 && buf[i + 3] === 1) codeLen = 4;
134 if (codeLen) {
135 const nalType = buf[i + codeLen]! & 0x1f;
136 if (nalType === 7 && !sps) sps = buf.subarray(i + codeLen);
137 if (nalType === 5) isKey = true;
138 i += codeLen + 1;
139 continue;
140 }
141 }
142 i++;
143 }
144 return { isKey, sps };
145}
146
147export type AvccChunk = {
148 readonly type: "description" | "keyframe" | "delta" | "seed";
149 readonly payload: Uint8Array;
150};
151
152const AVCC_TAGS: Record<number, AvccChunk["type"] | undefined> = {
153 1: "description",
154 2: "keyframe",
155 3: "delta",
156 4: "seed",
157};
158
159/** Turns a fragmented AVCC byte stream into complete envelopes. */
160export class AvccDemuxer {
161 private buffer = new Uint8Array(64 * 1024);
162 private length = 0;
163
164 push(bytes: Uint8Array): AvccChunk[] {
165 if (this.length + bytes.length > this.buffer.length) {
166 let capacity = this.buffer.length;
167 while (capacity < this.length + bytes.length) capacity *= 2;
168 const grown = new Uint8Array(capacity);
169 grown.set(this.buffer.subarray(0, this.length));
170 this.buffer = grown;
171 }
172 this.buffer.set(bytes, this.length);
173 this.length += bytes.length;
174
175 const chunks: AvccChunk[] = [];
176 let offset = 0;
177 while (this.length - offset >= 4) {
178 const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 4);
179 const frameLength = view.getUint32(0, false);
180 if (this.length - offset - 4 < frameLength) break;
181 if (frameLength >= 1) {
182 const type = AVCC_TAGS[this.buffer[offset + 4]!];
183 if (type) {
184 chunks.push({ type, payload: this.buffer.slice(offset + 5, offset + 4 + frameLength) });
185 }
186 }
187 offset += 4 + frameLength;
188 }
189 if (offset > 0) {
190 this.buffer.copyWithin(0, offset, this.length);
191 this.length -= offset;
192 }
193 return chunks;
194 }
195
196 reset(): void {
197 this.length = 0;
198 }
199}
200
201export interface DeviceStreamClient {
202 readonly start: () => void;
203 readonly stop: () => void;
204 /** Normalized 0..1 coordinates in the displayed frame. */
205 readonly sendTouch: (phase: "begin" | "move" | "end", x: number, y: number) => void;
206 readonly sendKey: (event: KeyboardEvent, phase: "down" | "up") => void;
207 readonly pressButton: (button: DeviceHardwareButton) => void;
208 readonly rotate: () => void;
209}
210
211const HID_USAGE_BY_CODE: Readonly<Record<string, number>> = {
212 Enter: 0x28,
213 Escape: 0x29,
214 Backspace: 0x2a,
215 Tab: 0x2b,
216 Space: 0x2c,
217 Minus: 0x2d,
218 Equal: 0x2e,
219 BracketLeft: 0x2f,
220 BracketRight: 0x30,
221 Backslash: 0x31,
222 Semicolon: 0x33,
223 Quote: 0x34,
224 Backquote: 0x35,
225 Comma: 0x36,
226 Period: 0x37,
227 Slash: 0x38,
228 Delete: 0x4c,
229 ArrowRight: 0x4f,
230 ArrowLeft: 0x50,
231 ArrowDown: 0x51,
232 ArrowUp: 0x52,
233 ControlLeft: 0xe0,
234 ShiftLeft: 0xe1,
235 AltLeft: 0xe2,
236 MetaLeft: 0xe3,
237 ControlRight: 0xe4,
238 ShiftRight: 0xe5,
239 AltRight: 0xe6,
240 MetaRight: 0xe7,
241};
242
243function hidUsageForCode(code: string): number | null {
244 if (/^Key[A-Z]$/.test(code)) return 0x04 + (code.charCodeAt(3) - 65);
245 if (/^Digit[1-9]$/.test(code)) return 0x1e + (code.charCodeAt(5) - 49);
246 if (code === "Digit0") return 0x27;
247 return HID_USAGE_BY_CODE[code] ?? null;
248}
249
250const ANDROID_KEYCODE_BY_KEY: Readonly<Record<string, number>> = {
251 ArrowUp: 19,
252 ArrowDown: 20,
253 ArrowLeft: 21,
254 ArrowRight: 22,
255 Tab: 61,
256 Enter: 66,
257 Backspace: 67,
258 Delete: 112,
259 Home: 122,
260 End: 123,
261 PageUp: 92,
262 PageDown: 93,
263};
264
265const IOS_ORIENTATIONS: ReadonlyArray<DeviceScreenSize["orientation"]> = [
266 "portrait",
267 "landscape_left",
268 "portrait_upside_down",
269 "landscape_right",
270];
271
272export function createDeviceStreamClient(
273 target: DeviceStreamTarget,
274 canvas: HTMLCanvasElement,
275 events: DeviceStreamEvents,
276): DeviceStreamClient {
277 const { access, platform, deviceId } = target;
278 const vendor = platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
279 const device = encodeURIComponent(deviceId);
280 const httpUrl = (path: string) =>
281 withDeviceHubQuery(`${access.httpBase}${vendor}${path}`, access);
282 const wsUrl = (path: string) => withDeviceHubQuery(`${access.wsBase}${vendor}${path}`, access);
283 const useWebCodecs = isWebCodecsSupported();
284
285 let stopped = true;
286 let socket: WebSocket | null = null;
287 let controller: AbortController | null = null;
288 const retryTimers = new Map<"video" | "input", ReturnType<typeof setTimeout>>();
289 let primeController: AbortController | null = null;
290 let videoDecoder: VideoDecoder | null = null;
291 let timestamp = 0;
292 let awaitingKeyframe = true;
293 let screen: DeviceScreenSize | null = null;
294 let firstFrame = false;
295 let configuring = false;
296 let mjpeg = false;
297
298 const mjpegUrl = () => httpUrl(`/helper/${device}/stream.mjpeg`);
299
300 const fallBackToMjpeg = () => {
301 if (stopped || mjpeg) return;
302 mjpeg = true;
303 closeDecoder();
304 events.onMjpegFallback(mjpegUrl());
305 setStatus("streaming");
306 };
307
308 const setStatus = (status: DeviceStreamStatus, detail?: string) => {
309 if (!stopped) events.onStatus(status, detail);
310 };
311
312 const paint = (source: CanvasImageSource, width: number, height: number) => {
313 if (stopped) return;
314 if (canvas.width !== width || canvas.height !== height) {
315 canvas.width = width;
316 canvas.height = height;
317 if (platform === "android") {
318 screen = { width, height, orientation: width > height ? "landscape_left" : "portrait" };
319 events.onScreen(screen);
320 }
321 }
322 canvas.getContext("2d")?.drawImage(source, 0, 0, width, height);
323 if (!firstFrame) {
324 firstFrame = true;
325 setStatus("streaming");
326 }
327 };
328
329 const closeDecoder = () => {
330 try {
331 videoDecoder?.close();
332 } catch {
333 // Already closed.
334 }
335 videoDecoder = null;
336 awaitingKeyframe = true;
337 };
338
339 const makeDecoder = () =>
340 new VideoDecoder({
341 output: (frame) => {
342 try {
343 paint(frame, frame.displayWidth, frame.displayHeight);
344 } finally {
345 frame.close();
346 }
347 },
348 error: () => {
349 closeDecoder();
350 requestKeyframe();
351 },
352 });
353
354 /**
355 * Resolves false when this browser cannot decode the stream's profile
356 * (simulators encode High 5.1, which headless and some hardware decoders
357 * reject). iOS then falls back to MJPEG; Android has no MJPEG.
358 */
359 const configureDecoder = async (config: VideoDecoderConfig): Promise<boolean> => {
360 const full: VideoDecoderConfig = { ...config, optimizeForLatency: true };
361 const support = await VideoDecoder.isConfigSupported(full).catch(() => ({ supported: false }));
362 if (stopped) return false;
363 if (!support.supported) {
364 setStatus("error", `This browser cannot decode ${config.codec}.`);
365 return false;
366 }
367 if (!videoDecoder || videoDecoder.state === "closed") videoDecoder = makeDecoder();
368 try {
369 videoDecoder.configure(full);
370 return true;
371 } catch (cause) {
372 setStatus("error", `Video decoder: ${(cause as Error).message}`);
373 return false;
374 }
375 };
376
377 const decode = (isKey: boolean, data: Uint8Array, pts?: number | null) => {
378 if (!videoDecoder || videoDecoder.state !== "configured") return;
379 if (awaitingKeyframe) {
380 if (!isKey) return;
381 awaitingKeyframe = false;
382 }
383 if (videoDecoder.decodeQueueSize > SOFT_DECODE_QUEUE) {
384 closeDecoder();
385 requestKeyframe();
386 return;
387 }
388 try {
389 videoDecoder.decode(
390 new EncodedVideoChunk({
391 type: isKey ? "key" : "delta",
392 timestamp: pts ?? timestamp,
393 data,
394 }),
395 );
396 timestamp += FRAME_DURATION_US;
397 } catch {
398 closeDecoder();
399 requestKeyframe();
400 }
401 };
402
403 const requestKeyframe = () => {
404 if (platform === "android" && socket?.readyState === WebSocket.OPEN) {
405 socket.send(JSON.stringify({ type: "reset-video", ack: false }));
406 }
407 };
408
409 const scheduleRetry = (channel: "video" | "input", run: () => void) => {
410 if (stopped || retryTimers.has(channel)) return;
411 retryTimers.set(
412 channel,
413 setTimeout(() => {
414 retryTimers.delete(channel);
415 run();
416 }, RETRY_DELAY_MS),
417 );
418 };
419
420 const handleUnauthorized = () => {
421 stop();
422 events.onUnauthorized();
423 };
424
425 // iOS video: fetch the AVCC body and demux into the decoder.
426 const readIosVideo = async () => {
427 const demuxer = new AvccDemuxer();
428 controller = new AbortController();
429 try {
430 const response = await fetch(httpUrl(`/helper/${device}/stream.avcc`), {
431 signal: controller.signal,
432 credentials: access.credentials ? "include" : "same-origin",
433 });
434 if (response.status === 401 || response.status === 403) return handleUnauthorized();
435 if (!response.ok || !response.body) throw new Error(`stream ${response.status}`);
436 const reader = response.body.getReader();
437 for (;;) {
438 const { done, value } = await reader.read();
439 if (done || stopped) break;
440 for (const chunk of demuxer.push(value)) {
441 switch (chunk.type) {
442 case "seed":
443 void createImageBitmap(new Blob([chunk.payload as BlobPart], { type: "image/jpeg" }))
444 .then((bitmap) => {
445 paint(bitmap, bitmap.width, bitmap.height);
446 bitmap.close();
447 })
448 .catch(() => {});
449 break;
450 case "description": {
451 awaitingKeyframe = true;
452 const configured = await configureDecoder({
453 codec: avcCodecString(chunk.payload),
454 description: chunk.payload,
455 });
456 if (!configured) {
457 await reader.cancel().catch(() => {});
458 fallBackToMjpeg();
459 return;
460 }
461 break;
462 }
463 case "keyframe":
464 case "delta":
465 decode(chunk.type === "keyframe", chunk.payload);
466 break;
467 }
468 }
469 }
470 } catch (cause) {
471 if (stopped) return;
472 setStatus("connecting", (cause as Error).message);
473 }
474 if (!stopped) scheduleRetry("video", () => void readIosVideo());
475 };
476
477 /**
478 * serve-sim's helper only accepts HID and pushes its screen config once
479 * screen capture is running, and the AVCC stream does not reliably start
480 * it. Touching the MJPEG endpoint does; one aborted request is enough.
481 */
482 const primeIosHelper = async () => {
483 const controller = new AbortController();
484 primeController = controller;
485 const timeout = setTimeout(() => controller.abort(), 2_000);
486 try {
487 const response = await fetch(httpUrl(`/helper/${device}/stream.mjpeg`), {
488 signal: controller.signal,
489 credentials: access.credentials ? "include" : "same-origin",
490 });
491 if (response.status === 401 || response.status === 403) return handleUnauthorized();
492 await response.body?.getReader().read();
493 } catch {
494 // A failed prime just means the socket may take a retry to come up.
495 } finally {
496 clearTimeout(timeout);
497 controller.abort();
498 if (primeController === controller) primeController = null;
499 }
500 };
501
502 // iOS input socket; also carries the screen config the helper pushes.
503 const connectIosInput = async () => {
504 if (stopped) return;
505 await primeIosHelper();
506 if (stopped) return;
507 const ws = new WebSocket(wsUrl(`/helper/ws?device=${device}`));
508 ws.binaryType = "arraybuffer";
509 socket = ws;
510 ws.onopen = () => {
511 ws.send(taggedJson(IOS_MSG_HARDWARE_KEYBOARD, { enabled: false }));
512 events.onInputConnected(true);
513 };
514 ws.onmessage = (event) => {
515 if (!(event.data instanceof ArrayBuffer)) return;
516 const bytes = new Uint8Array(event.data);
517 if (bytes.length < 1 || bytes[0] !== IOS_TAG_SCREEN_CONFIG) return;
518 try {
519 const config = JSON.parse(decoder.decode(bytes.subarray(1))) as DeviceScreenSize;
520 if (config.width > 0 && config.height > 0) {
521 screen = config;
522 events.onScreen(config);
523 }
524 } catch {
525 // Ignore malformed config frames.
526 }
527 };
528 ws.onclose = (event) => {
529 if (socket === ws) socket = null;
530 if (!stopped) {
531 events.onInputConnected(
532 false,
533 event.reason || (event.code === 1006 ? "input socket refused" : `closed ${event.code}`),
534 );
535 }
536 if (event.code === 1008 || event.code === 4401) return handleUnauthorized();
537 scheduleRetry("input", () => void connectIosInput());
538 };
539 ws.onerror = () => ws.close();
540 };
541
542 // Android: one socket for video and input.
543 const connectAndroid = () => {
544 if (stopped) return;
545 const ws = new WebSocket(wsUrl(`/ws?device=${device}&frame-meta=1`));
546 ws.binaryType = "arraybuffer";
547 socket = ws;
548 ws.onopen = () => {
549 setStatus("connecting");
550 events.onInputConnected(true);
551 };
552 ws.onmessage = (event) => {
553 if (typeof event.data === "string") {
554 // The encoder restarts at a new size when the device rotates; the
555 // next keyframe carries a fresh SPS, so the decoder is rebuilt from it.
556 if (isVideoSessionMessage(event.data)) closeDecoder();
557 return;
558 }
559 if (!(event.data instanceof ArrayBuffer)) return;
560 const packet = parseSemuPacket(event.data);
561 const needsScan =
562 packet.isKey === null ||
563 (packet.isKey && (!videoDecoder || videoDecoder.state !== "configured"));
564 const scanned = needsScan ? scanAccessUnit(packet.data) : null;
565 const isKey = packet.isKey ?? scanned?.isKey ?? false;
566 if (scanned?.sps && (!videoDecoder || videoDecoder.state !== "configured")) {
567 if (configuring) return;
568 configuring = true;
569 void configureDecoder({ codec: avcCodecString(scanned.sps) }).then((configured) => {
570 configuring = false;
571 awaitingKeyframe = true;
572 if (configured) requestKeyframe();
573 });
574 return;
575 }
576 if (!videoDecoder || videoDecoder.state !== "configured") {
577 if (!isKey) requestKeyframe();
578 return;
579 }
580 decode(isKey, packet.data, packet.timestamp);
581 };
582 ws.onclose = (event) => {
583 if (socket === ws) socket = null;
584 closeDecoder();
585 if (!stopped) events.onInputConnected(false, event.reason || `closed ${event.code}`);
586 if (event.code === 1008 || event.code === 4401) return handleUnauthorized();
587 if (!stopped) {
588 setStatus("connecting", event.reason || undefined);
589 scheduleRetry("input", connectAndroid);
590 }
591 };
592 ws.onerror = () => ws.close();
593 };
594
595 const start = () => {
596 if (!stopped) return;
597 stopped = false;
598 firstFrame = false;
599 events.onStatus("connecting");
600 if (platform === "ios") {
601 void connectIosInput();
602 if (useWebCodecs) void readIosVideo();
603 else fallBackToMjpeg();
604 } else if (useWebCodecs) {
605 connectAndroid();
606 } else {
607 setStatus("error", "This browser cannot decode the Android stream (WebCodecs unavailable).");
608 }
609 };
610
611 const stop = () => {
612 if (stopped) return;
613 stopped = true;
614 mjpeg = false;
615 for (const timer of retryTimers.values()) clearTimeout(timer);
616 retryTimers.clear();
617 primeController?.abort();
618 primeController = null;
619 controller?.abort();
620 controller = null;
621 socket?.close();
622 socket = null;
623 closeDecoder();
624 };
625
626 const send = (payload: Uint8Array<ArrayBuffer> | string) => {
627 if (socket?.readyState === WebSocket.OPEN) socket.send(payload);
628 };
629
630 const rawPoint = (x: number, y: number) => {
631 // serve-sim streams the raw framebuffer; rotated devices need input
632 // remapped into that raw space.
633 if (platform !== "ios" || !screen || screen.width > screen.height) return { x, y };
634 switch (screen.orientation) {
635 case "landscape_left":
636 return { x: y, y: 1 - x };
637 case "landscape_right":
638 return { x: 1 - y, y: x };
639 case "portrait_upside_down":
640 return { x: 1 - x, y: 1 - y };
641 default:
642 return { x, y };
643 }
644 };
645
646 return {
647 start,
648 stop,
649 sendTouch: (phase, x, y) => {
650 if (platform === "ios") {
651 send(taggedJson(IOS_MSG_TOUCH, { type: phase, ...rawPoint(x, y) }));
652 return;
653 }
654 const action = phase === "begin" ? "down" : phase === "move" ? "move" : "up";
655 send(JSON.stringify({ type: "touch", action, x, y }));
656 },
657 sendKey: (event, phase) => {
658 if (platform === "ios") {
659 const usage = hidUsageForCode(event.code);
660 if (usage !== null) send(taggedJson(IOS_MSG_KEY, { type: phase, usage }));
661 return;
662 }
663 if (phase !== "down") return;
664 if (event.key === "Escape") return send(JSON.stringify({ type: "back" }));
665 const keycode = ANDROID_KEYCODE_BY_KEY[event.key];
666 if (keycode !== undefined) return send(JSON.stringify({ type: "key", keycode }));
667 if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
668 send(JSON.stringify({ type: "text", text: event.key }));
669 }
670 },
671 pressButton: (button) => {
672 if (platform === "ios") {
673 const name =
674 button === "home"
675 ? "home"
676 : button === "appSwitcher"
677 ? "app_switcher"
678 : button === "power"
679 ? "lock"
680 : null;
681 if (name) send(taggedJson(IOS_MSG_BUTTON, { button: name }));
682 return;
683 }
684 const type = button === "appSwitcher" ? "recents" : button;
685 if (type === "home" || type === "back" || type === "recents" || type === "power") {
686 send(JSON.stringify({ type }));
687 }
688 },
689 rotate: () => {
690 if (platform !== "ios") return;
691 const current = screen?.orientation ?? "portrait";
692 const next =
693 IOS_ORIENTATIONS[(IOS_ORIENTATIONS.indexOf(current) + 1) % IOS_ORIENTATIONS.length]!;
694 send(taggedJson(IOS_MSG_ORIENTATION, { orientation: next }));
695 },
696 };
697}
apps/web/src/components/preview/PreviewEmptyState.tsx+5-4
...@@ -3,6 +3,7 @@ import { Globe, History, RadioTower } from "lucide-react";...@@ -3,6 +3,7 @@ import { Globe, History, RadioTower } from "lucide-react";
33
4import type { BrowserHistoryEntry } from "~/browserHistoryStore";4import type { BrowserHistoryEntry } from "~/browserHistoryStore";
5import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty";5import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty";
6import { DiscoveryList } from "../ui/discovery-list";
67
7import { PreviewLocalServerCard } from "./PreviewLocalServerCard";8import { PreviewLocalServerCard } from "./PreviewLocalServerCard";
8import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard";9import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard";
...@@ -55,7 +56,7 @@ export function PreviewEmptyState({...@@ -55,7 +56,7 @@ export function PreviewEmptyState({
55 <History className="size-4 shrink-0" />56 <History className="size-4 shrink-0" />
56 <h2 className="font-medium">Recently used</h2>57 <h2 className="font-medium">Recently used</h2>
57 </div>58 </div>
58 <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background">59 <DiscoveryList>
59 {recents.map((entry) => (60 {recents.map((entry) => (
60 <PreviewRecentUrlCard61 <PreviewRecentUrlCard
61 key={entry.url}62 key={entry.url}
...@@ -65,7 +66,7 @@ export function PreviewEmptyState({...@@ -65,7 +66,7 @@ export function PreviewEmptyState({
65 onRemove={() => onRemoveRecent(entry.url)}66 onRemove={() => onRemoveRecent(entry.url)}
66 />67 />
67 ))}68 ))}
68 </div>69 </DiscoveryList>
69 </div>70 </div>
70 ) : null}71 ) : null}
71 {servers.length > 0 ? (72 {servers.length > 0 ? (
...@@ -74,7 +75,7 @@ export function PreviewEmptyState({...@@ -74,7 +75,7 @@ export function PreviewEmptyState({
74 <RadioTower className="size-4 shrink-0" />75 <RadioTower className="size-4 shrink-0" />
75 <h2 className="font-medium">Local servers</h2>76 <h2 className="font-medium">Local servers</h2>
76 </div>77 </div>
77 <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background">78 <DiscoveryList>
78 {servers.map((server) => (79 {servers.map((server) => (
79 <PreviewLocalServerCard80 <PreviewLocalServerCard
80 key={`${server.host}:${server.port}`}81 key={`${server.host}:${server.port}`}
...@@ -83,7 +84,7 @@ export function PreviewEmptyState({...@@ -83,7 +84,7 @@ export function PreviewEmptyState({
83 onOpen={() => onOpenUrl(server.requestedUrl)}84 onOpen={() => onOpenUrl(server.requestedUrl)}
84 />85 />
85 ))}86 ))}
86 </div>87 </DiscoveryList>
87 <p className="px-1 text-xs text-muted-foreground">88 <p className="px-1 text-xs text-muted-foreground">
88 Select a live local server to open it in this browser tab.89 Select a live local server to open it in this browser tab.
89 </p>90 </p>
apps/web/src/components/preview/PreviewLocalServerCard.tsx+6-12
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";1import type { ScopedThreadRef } from "@t3tools/contracts";
2import { DiscoveryListRow } from "../ui/discovery-list";
23
3import { PreviewFaviconIcon } from "./PreviewFaviconIcon";4import { PreviewFaviconIcon } from "./PreviewFaviconIcon";
4import type { PreviewableServer } from "./useDiscoveredLocalServers";5import type { PreviewableServer } from "./useDiscoveredLocalServers";
...@@ -12,19 +13,12 @@ interface Props {...@@ -12,19 +13,12 @@ interface Props {
12export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) {13export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) {
13 const subtitle = describeServer(server);14 const subtitle = describeServer(server);
14 return (15 return (
15 <button16 <DiscoveryListRow
16 type="button"
17 onClick={onOpen}17 onClick={onOpen}
18 className="group flex w-full items-center gap-3 px-3 py-3 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"18 icon={<PreviewFaviconIcon threadRef={threadRef} url={server.requestedUrl} />}
19 >19 title={subtitle}
20 <PreviewFaviconIcon threadRef={threadRef} url={server.requestedUrl} />20 description={`${server.host}:${server.port}`}
21 <div className="flex min-w-0 flex-1 flex-col">21 />
22 <span className="truncate text-sm font-medium text-foreground">{subtitle}</span>
23 <span className="truncate text-xs text-muted-foreground">
24 {server.host}:{server.port}
25 </span>
26 </div>
27 </button>
28 );22 );
29}23}
3024
apps/web/src/components/settings/IntegrationsSettings.test.tsx+61-1
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts";1import {
2 DEFAULT_CLIENT_SETTINGS,
3 DEFAULT_UNIFIED_SETTINGS,
4 type DeviceServiceState,
5} from "@t3tools/contracts";
2import {6import {
3 createMemoryHistory,7 createMemoryHistory,
4 createRootRoute,8 createRootRoute,
...@@ -36,6 +40,7 @@ vi.mock("./settingsLayout", async (importOriginal) => ({...@@ -36,6 +40,7 @@ vi.mock("./settingsLayout", async (importOriginal) => ({
36}));40}));
3741
38import { IntegrationsSettingsPanel } from "./IntegrationsSettings";42import { IntegrationsSettingsPanel } from "./IntegrationsSettings";
43import { platformSetupStatus } from "../device/DeviceSetup";
3944
40let renderer: ReactTestRenderer | undefined;45let renderer: ReactTestRenderer | undefined;
4146
...@@ -74,4 +79,59 @@ describe("Integrations browser discovery", () => {...@@ -74,4 +79,59 @@ describe("Integrations browser discovery", () => {
74 await openSettings();79 await openSettings();
75 expect(listBrowserImportSources).not.toHaveBeenCalled();80 expect(listBrowserImportSources).not.toHaveBeenCalled();
76 });81 });
82
83 it("places device settings directly after browser settings", async () => {
84 await openSettings();
85 const sections = renderer!.root
86 .findAll((node) => node.type === "section")
87 .map((node) => node.props.id)
88 .filter(Boolean);
89 expect(sections.indexOf("devices")).toBeGreaterThan(sections.indexOf("browser"));
90 });
91});
92
93const deviceState = (overrides: Partial<DeviceServiceState> = {}): DeviceServiceState => ({
94 hosts: [
95 {
96 id: "local",
97 kind: "local",
98 label: "This machine",
99 hubInstalled: false,
100 agentDeviceInstalled: false,
101 platforms: [
102 { platform: "ios", available: true },
103 { platform: "android", available: true },
104 ],
105 },
106 ],
107 hostStatus: "ready",
108 devices: [],
109 sessions: [],
110 onboardingCompleted: false,
111 agentAccessEnabled: false,
112 hubBasePath: "/api/device-hub",
113 revision: 0,
114 ...overrides,
115});
116
117describe("device setup guidance", () => {
118 it("directs users to install an iOS runtime and create an Android virtual device", () => {
119 expect(platformSetupStatus(deviceState(), "ios").message).toContain("Xcode Settings");
120 expect(platformSetupStatus(deviceState(), "android").message).toContain("Device Manager");
121 });
122
123 it("preserves a specific missing-tool explanation from the server", () => {
124 const state = deviceState({
125 hosts: [
126 {
127 ...deviceState().hosts[0]!,
128 platforms: [
129 { platform: "ios", available: true },
130 { platform: "android", available: false, reason: "Android Emulator is missing." },
131 ],
132 },
133 ],
134 });
135 expect(platformSetupStatus(state, "android").message).toBe("Android Emulator is missing.");
136 });
77});137});
apps/web/src/components/settings/IntegrationsSettings.tsx+123
...@@ -39,10 +39,21 @@ import { MoreVertical, Plus as PlusIcon } from "lucide-react";...@@ -39,10 +39,21 @@ import { MoreVertical, Plus as PlusIcon } from "lucide-react";
39import { useCallback, useRef, useState } from "react";39import { useCallback, useRef, useState } from "react";
4040
41import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";41import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";
42import { AnimatedHeight } from "~/components/AnimatedHeight";
42import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic";43import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic";
43import { previewBridge } from "~/components/preview/previewBridge";44import { previewBridge } from "~/components/preview/previewBridge";
44import { cn, randomUUID } from "~/lib/utils";45import { cn, randomUUID } from "~/lib/utils";
45import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";46import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
47import { deviceEnvironment, useDeviceState } from "~/state/device";
48import { useAtomCommand } from "~/state/use-atom-command";
49import {
50 AgentDeviceSetupStatus,
51 DeviceHubSetupStatus,
52 PlatformStatus,
53 platformSetupStatus,
54 deviceHubDescription,
55 agentDeviceDescription,
56} from "~/components/device/DeviceSetup";
46import { isElectron } from "../../env";57import { isElectron } from "../../env";
4758
48import { Badge } from "../ui/badge";59import { Badge } from "../ui/badge";
...@@ -571,6 +582,117 @@ function AgentBrowserAccessSetting() {...@@ -571,6 +582,117 @@ function AgentBrowserAccessSetting() {
571 );582 );
572}583}
573584
585function DeviceIntegrationSettings() {
586 const primaryEnvironment = usePrimaryEnvironment();
587 const environmentId = primaryEnvironment?.environmentId ?? null;
588 const { state, loaded } = useDeviceState(environmentId);
589 const configure = useAtomCommand(deviceEnvironment.configure);
590 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
591 const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null);
592 const enabled = state.hostStatus !== "disabled";
593 const busy = state.hostStatus === "installing" || state.hostStatus === "starting";
594 const [platformsRevealed, setPlatformsRevealed] = useState(false);
595 // Keep diagnostics visible through subsequent agent setup and refresh phases.
596 if (platformsRevealed && !enabled) setPlatformsRevealed(false);
597 if (!platformsRevealed && state.hostStatus === "ready" && pending !== "hub") {
598 setPlatformsRevealed(true);
599 }
600
601 const update = async (
602 kind: NonNullable<typeof pending>,
603 input: { enabled?: boolean; agentAccessEnabled?: boolean },
604 ) => {
605 if (!environmentId) return;
606 setPending(kind);
607 try {
608 const result = await configure({ environmentId, input });
609 if (result._tag === "Success" && input.enabled === true && !state.onboardingCompleted) {
610 await configure({ environmentId, input: { onboardingCompleted: true } });
611 }
612 } finally {
613 setPending(null);
614 }
615 };
616
617 return (
618 <SettingsSection id="devices" title="Devices">
619 <SettingsRow
620 {...searchableSetting("device-hub")}
621 description={deviceHubDescription}
622 control={
623 <>
624 {pending === "hub" ? <DeviceHubSetupStatus state={state} pending compact /> : null}
625 <Switch
626 checked={enabled}
627 disabled={!loaded || !environmentId || busy || pending !== null}
628 aria-label="Device hub"
629 onCheckedChange={(checked) =>
630 void update("hub", {
631 enabled: Boolean(checked),
632 ...(checked ? {} : { agentAccessEnabled: false }),
633 })
634 }
635 />
636 </>
637 }
638 />
639 <AnimatedHeight>
640 {platformsRevealed ? (
641 <SettingsRow
642 {...searchableSetting("device-platform-support")}
643 status={
644 <div className="flex flex-wrap gap-x-5 gap-y-2">
645 <PlatformStatus compact platform="iOS" status={platformSetupStatus(state, "ios")} />
646 <PlatformStatus
647 compact
648 platform="Android"
649 status={platformSetupStatus(state, "android")}
650 />
651 </div>
652 }
653 control={
654 <Button
655 size="sm"
656 variant="outline"
657 disabled={!environmentId || !enabled || busy || pending !== null}
658 onClick={() => {
659 if (!environmentId) return;
660 setPending("check");
661 void list({ environmentId, input: {} }).finally(() => setPending(null));
662 }}
663 >
664 {pending === "check" ? "Checking…" : "Refresh"}
665 </Button>
666 }
667 />
668 ) : null}
669 </AnimatedHeight>
670 <SettingsRow
671 {...searchableSetting("agent-device-access")}
672 description={agentDeviceDescription}
673 control={
674 <>
675 {pending === "agent" ? <AgentDeviceSetupStatus state={state} pending compact /> : null}
676 <Switch
677 checked={state.agentAccessEnabled}
678 disabled={!loaded || !environmentId || !enabled || busy || pending !== null}
679 aria-label="Agent device access"
680 onCheckedChange={(checked) =>
681 void update("agent", { agentAccessEnabled: Boolean(checked) })
682 }
683 />
684 </>
685 }
686 />
687 {state.hostStatus === "failed" && state.hostStatusDetail ? (
688 <p role="alert" className="px-4 py-3 text-xs text-destructive">
689 {state.hostStatusDetail}
690 </p>
691 ) : null}
692 </SettingsSection>
693 );
694}
695
574function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled: boolean }) {696function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled: boolean }) {
575 const autoShow = useClientSettings((settings) => settings.browserAutoShowFloatingPreview);697 const autoShow = useClientSettings((settings) => settings.browserAutoShowFloatingPreview);
576 const updateSettings = useUpdatePrimarySettings();698 const updateSettings = useUpdatePrimarySettings();
...@@ -1170,6 +1292,7 @@ export function IntegrationsSettingsPanel() {...@@ -1170,6 +1292,7 @@ export function IntegrationsSettingsPanel() {
1170 previewDefaults1292 previewDefaults
1171 )}1293 )}
1172 </SettingsSection>1294 </SettingsSection>
1295 <DeviceIntegrationSettings />
1173 </SettingsPageContainer>1296 </SettingsPageContainer>
1174 );1297 );
1175}1298}
apps/web/src/components/settings/settingsSearch.ts+21
...@@ -402,6 +402,27 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -402,6 +402,27 @@ export const SETTINGS_SEARCH_ITEMS = [
402 to: "/settings/projects",402 to: "/settings/projects",
403 searchTerms: ["allow open drive preview tools sessions"],403 searchTerms: ["allow open drive preview tools sessions"],
404 },404 },
405 {
406 id: "agent-device-access",
407 title: "Agent device access",
408 to: "/settings/integrations",
409 targetId: "devices",
410 searchTerms: ["allow simulator emulator ios android drive tools sessions"],
411 },
412 {
413 id: "device-hub",
414 title: "Device hub",
415 to: "/settings/integrations",
416 targetId: "devices",
417 searchTerms: ["simulator emulator ios android install start"],
418 },
419 {
420 id: "device-platform-support",
421 title: "Simulator support",
422 to: "/settings/integrations",
423 targetId: "devices",
424 searchTerms: ["xcode android studio sdk avd runtime"],
425 },
405 {426 {
406 id: "browser-profiles",427 id: "browser-profiles",
407 title: "Browser profiles",428 title: "Browser profiles",
apps/web/src/components/ui/discovery-list.tsx created+37
...@@ -0,0 +1,37 @@
1import type { ComponentProps, ReactNode } from "react";
2
3export function DiscoveryList({ children }: { readonly children: ReactNode }) {
4 return (
5 <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background">
6 {children}
7 </div>
8 );
9}
10
11export function DiscoveryListRow({
12 icon,
13 title,
14 description,
15 action,
16 ...props
17}: Omit<ComponentProps<"button">, "title" | "children" | "className"> & {
18 readonly icon: ReactNode;
19 readonly title: ReactNode;
20 readonly description: ReactNode;
21 readonly action?: ReactNode;
22}) {
23 return (
24 <button
25 type="button"
26 {...props}
27 className="group flex w-full items-center gap-3 px-3 py-3 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
28 >
29 {icon}
30 <div className="flex min-w-0 flex-1 flex-col">
31 <span className="truncate text-sm font-medium text-foreground">{title}</span>
32 <span className="truncate text-xs text-muted-foreground">{description}</span>
33 </div>
34 {action}
35 </button>
36 );
37}
apps/web/src/rightPanelStore.ts+11-1
...@@ -24,6 +24,7 @@ const RIGHT_PANEL_KINDS = [...@@ -24,6 +24,7 @@ const RIGHT_PANEL_KINDS = [
24 "files",24 "files",
25 "file",25 "file",
26 "preview",26 "preview",
27 "device",
27 "terminal",28 "terminal",
28 "pull-request",29 "pull-request",
29 "pull-requests",30 "pull-requests",
...@@ -34,6 +35,12 @@ export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];...@@ -34,6 +35,12 @@ export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
34export type RightPanelSurface =35export type RightPanelSurface =
35 | { id: `browser:${string}`; kind: "preview"; resourceId: string }36 | { id: `browser:${string}`; kind: "preview"; resourceId: string }
36 | { id: "browser:new"; kind: "preview"; resourceId: null }37 | { id: "browser:new"; kind: "preview"; resourceId: null }
38 /**
39 * One Device tab per thread. The tab is the surface; which device it shows
40 * comes from the thread's server-side device sessions, so an agent opening a
41 * device from another client lands in the same tab.
42 */
43 | { id: "device"; kind: "device" }
37 | {44 | {
38 id: `terminal:${string}`;45 id: `terminal:${string}`;
39 kind: "terminal";46 kind: "terminal";
...@@ -82,7 +89,8 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";...@@ -82,7 +89,8 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
82// v9 removed the "plan" surface kind (plans render inline in the transcript).89// v9 removed the "plan" surface kind (plans render inline in the transcript).
83// v10 keys pull-request surfaces by reference instead of a singleton tab.90// v10 keys pull-request surfaces by reference instead of a singleton tab.
84// v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh.91// v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh.
85const RIGHT_PANEL_STORAGE_VERSION = 11;92// v12 adds the device surface.
93const RIGHT_PANEL_STORAGE_VERSION = 12;
8694
87/** A fixed workspace-level ref: each PR surface carries its own real environment. */95/** A fixed workspace-level ref: each PR surface carries its own real environment. */
88export const PULL_REQUESTS_PANEL_REF = scopeThreadRef(96export const PULL_REQUESTS_PANEL_REF = scopeThreadRef(
...@@ -178,6 +186,8 @@ const singletonSurface = (...@@ -178,6 +186,8 @@ const singletonSurface = (
178 return { id: "pull-requests", kind };186 return { id: "pull-requests", kind };
179 case "agents":187 case "agents":
180 return { id: "agents", kind };188 return { id: "agents", kind };
189 case "device":
190 return { id: "device", kind };
181 }191 }
182};192};
183193
apps/web/src/routes/_chat.pull-requests.tsx+2
...@@ -1971,6 +1971,7 @@ function PullRequestsRouteView() {...@@ -1971,6 +1971,7 @@ function PullRequestsRouteView() {
1971 onAddPullRequest={() => undefined}1971 onAddPullRequest={() => undefined}
1972 onAddPullRequests={() => undefined}1972 onAddPullRequests={() => undefined}
1973 onAddAgents={() => undefined}1973 onAddAgents={() => undefined}
1974 onAddDevice={() => undefined}
1974 browserAvailable={false}1975 browserAvailable={false}
1975 terminalAvailable={false}1976 terminalAvailable={false}
1976 diffAvailable={false}1977 diffAvailable={false}
...@@ -1978,6 +1979,7 @@ function PullRequestsRouteView() {...@@ -1978,6 +1979,7 @@ function PullRequestsRouteView() {
1978 pullRequestAvailable={false}1979 pullRequestAvailable={false}
1979 pullRequestsAvailable={false}1980 pullRequestsAvailable={false}
1980 agentsAvailable={false}1981 agentsAvailable={false}
1982 deviceAvailable={false}
1981 liveAgentCount={0}1983 liveAgentCount={0}
1982 pullRequestStatusSeeds={listedPullRequestTabStatuses}1984 pullRequestStatusSeeds={listedPullRequestTabStatuses}
1983 >1985 >
apps/web/src/state/device.ts created+70
...@@ -0,0 +1,70 @@
1import { useAtomValue } from "@effect/atom-react";
2import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device";
3import {
4 type DeviceHubAccess,
5 resolveDeviceHubAccess,
6} from "@t3tools/client-runtime/state/deviceHubAccess";
7import type { DeviceServiceState, EnvironmentId } from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10import { AsyncResult, Atom } from "effect/unstable/reactivity";
11
12import { connectionAtomRuntime } from "../connection/runtime";
13import { appAtomRegistry } from "../rpc/atomRegistry";
14import { environmentSession } from "./session";
15import { useEnvironmentQuery } from "./query";
16
17export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRuntime);
18
19const EMPTY_DEVICE_STATE: DeviceServiceState = {
20 hosts: [],
21 hostStatus: "idle",
22 devices: [],
23 sessions: [],
24 onboardingCompleted: false,
25 agentAccessEnabled: false,
26 hubBasePath: "/api/device-hub",
27 revision: 0,
28};
29
30export function useDeviceState(environmentId: EnvironmentId | null): {
31 readonly state: DeviceServiceState;
32 readonly loaded: boolean;
33} {
34 const query = useEnvironmentQuery(
35 environmentId === null ? null : deviceEnvironment.state({ environmentId, input: {} }),
36 );
37 return { state: query.data ?? EMPTY_DEVICE_STATE, loaded: query.data !== undefined };
38}
39
40/**
41 * Hub access for one environment. Bearer and DPoP connections mint a ticket
42 * here; a stream that gets a 401 back refreshes this atom and reconnects.
43 * Keyed on the prepared connection so a re-pair produces new credentials.
44 */
45const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) =>
46 connectionAtomRuntime
47 .atom((get) => {
48 const prepared = Option.getOrNull(
49 get(environmentSession.preparedConnectionValueAtom(environmentId)),
50 );
51 if (prepared === null) return Effect.never;
52 return resolveDeviceHubAccess({ prepared, hubBasePath: EMPTY_DEVICE_STATE.hubBasePath });
53 })
54 .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`device-hub-access:${environmentId}`)),
55);
56
57export function useDeviceHubAccess(environmentId: EnvironmentId | null): DeviceHubAccess | null {
58 const result = useAtomValue(
59 environmentId === null ? EMPTY_ACCESS_ATOM : deviceHubAccessAtom(environmentId),
60 );
61 return AsyncResult.isSuccess(result) ? result.value : null;
62}
63
64const EMPTY_ACCESS_ATOM = Atom.make(AsyncResult.initial<DeviceHubAccess, never>()).pipe(
65 Atom.withLabel("device-hub-access:empty"),
66);
67
68export function refreshDeviceHubAccess(environmentId: EnvironmentId): void {
69 appAtomRegistry.refresh(deviceHubAccessAtom(environmentId));
70}
apps/web/vite.config.ts+5-4
...@@ -230,16 +230,17 @@ export default defineConfig(() => {...@@ -230,16 +230,17 @@ export default defineConfig(() => {
230 ? {230 ? {
231 // One entry per shared prefix; the server's dev catch-all 404s the231 // One entry per shared prefix; the server's dev catch-all 404s the
232 // same list, so the two sides cannot drift. `/ws` is the app's own232 // same list, so the two sides cannot drift. `/ws` is the app's own
233 // socket — Vite's HMR socket is matched separately and exactly233 // socket and `/api` carries the device hub's stream sockets —
234 // (path "/" plus a vite-hmr subprotocol), so the two upgrade234 // Vite's HMR socket is matched separately and exactly (path "/"
235 // handlers don't collide.235 // plus a vite-hmr subprotocol), so the upgrade handlers don't
236 // collide.
236 proxy: Object.fromEntries(237 proxy: Object.fromEntries(
237 DEV_PROXIED_PATH_PREFIXES.map((prefix) => [238 DEV_PROXIED_PATH_PREFIXES.map((prefix) => [
238 prefix,239 prefix,
239 {240 {
240 target: devProxyTarget,241 target: devProxyTarget,
241 changeOrigin: true,242 changeOrigin: true,
242 ...(prefix === "/ws" ? { ws: true } : {}),243 ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}),
243 },244 },
244 ]),245 ]),
245 ),246 ),
docs/README.md+2
...@@ -13,6 +13,7 @@...@@ -13,6 +13,7 @@
13- [Keyboard shortcuts](./user/keybindings.md)13- [Keyboard shortcuts](./user/keybindings.md)
14- [SnapShots](./user/snap-shot.md)14- [SnapShots](./user/snap-shot.md)
15- [Import browser sessions](./user/browser-import.md)15- [Import browser sessions](./user/browser-import.md)
16- [Devices](./user/devices.md)
16- [Usage and limits](./user/usage.md)17- [Usage and limits](./user/usage.md)
17- [Product usage data](./user/telemetry.md)18- [Product usage data](./user/telemetry.md)
18- [Remote access](./user/remote-access.md)19- [Remote access](./user/remote-access.md)
...@@ -46,6 +47,7 @@ source alone does not explain. Most code changes do not need an internal documen...@@ -46,6 +47,7 @@ source alone does not explain. Most code changes do not need an internal documen
46- [Mobile navigation](./internals/mobile-navigation.md)47- [Mobile navigation](./internals/mobile-navigation.md)
47- [Mobile development lifecycle](./internals/mobile-development.md)48- [Mobile development lifecycle](./internals/mobile-development.md)
48- [Terminal runtime](./internals/terminal-runtime.md)49- [Terminal runtime](./internals/terminal-runtime.md)
50- [Devices](./internals/devices.md)
49- [Voice input](./internals/voice-input.md)51- [Voice input](./internals/voice-input.md)
5052
51### Runbooks53### Runbooks
docs/internals/devices.md created+87
...@@ -0,0 +1,87 @@
1# Devices
2
3The environment server owns simulators and emulators the way it owns
4terminals: discovery, streaming, and agent access all run there, and every
5client reaches them through the environment connection. This is what makes the
6Device panel work over Tailscale and T3 Connect, and what will let a device
7host on another machine slot in later.
8
9## Two external tools, one seam
10
11[expo-device-hub](../../apps/server/src/device/LocalDeviceHost.ts) streams and
12[agent-device](../../apps/server/src/device/AgentDeviceShim.ts) drives. Each is
13npm-installed at a pinned version into the T3 home after its matching Device
14panel consent step. Manual setup installs and starts only expo-device-hub;
15agent-device remains absent and stopped until agent access is granted. Both run
16with the server's Node; `npx` would make the first `device_open` after a reboot
17depend on the registry. The hub is a supervised child rather than an imported
18middleware because serve-sim loads private CoreSimulator frameworks through a
19native addon, and a crash there must not take the server down.
20
21Everything platform-specific sits behind
22[`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the
23proxy, and the MCP tools only see a hub origin and an agent-device endpoint.
24An SSH or cloud host would forward those two things to the server and change
25nothing above it.
26
27## The hub is never exposed
28
29serve-sim has a shell-exec route whose token is readable from its own
30unauthenticated `/api`, and serve-emu's action routes have no auth at all. The
31hub binds loopback and the only way in is the
32[proxy](../../apps/server/src/device/DeviceHubProxy.ts), which allowlists the
33stream, config, and screenshot routes and authenticates every request as an
34environment session. `<img>` and `WebSocket` cannot carry headers, so the proxy
35authenticates like the `/ws` upgrade: cookie, or a short-lived `wsTicket` that
36bearer and DPoP clients mint over authenticated HTTP. The ticket is stripped
37before the request reaches the hub.
38
39Stream responses carry `Cache-Control: no-transform`; the compression
40middleware would otherwise buffer an MJPEG body that never ends. In browser dev,
41the Vite proxy must forward WebSocket upgrades for `/api`, not only `/ws`.
42
43## Device settings never go through the hub
44
45serve-sim's preview drives its Tools panel by sending shell commands over that
46same exec channel. Proxying it, even allowlisted, would hand any environment
47session arbitrary command execution on the host, so T3 does not. The
48[`device.action`](../../apps/server/src/device/DeviceActions.ts) RPC runs the
49underlying `simctl`, `adb`, and serve-sim helper binaries itself through
50`DeviceHostReady.run`, one typed action per control, and returns the settings
51it reads back. The proxy allowlist grows only with read routes (accessibility
52tree, foreground app, event log) and refuses non-GET methods everywhere except
53screenshot capture and stream tuning.
54
55## Agents drive through the CLI
56
57The `device_*` toolkit is deliberately four tools: list, open, screenshot, and
58close. Driving happens through the `agent-device` CLI, which has the semantic
59snapshot model agents need and stays current with its own releases. T3 prepends
60a shim directory to the provider's PATH and sets
61`AGENT_DEVICE_DAEMON_BASE_URL` and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` so the
62agent never handles the endpoint or token.
63
64That environment is fixed when the provider subprocess spawns, so
65[`prepareMcpSession`](../../apps/server/src/provider/Layers/ProviderService.ts)
66starts agent-device only when device support and agent access have both been
67enabled, the session has the `device` capability, and the machine can run at
68least one platform. Starting it later from `device_open` would leave the
69already-running agent without the CLI.
70
71How to drive a device is returned from `device_open`, not kept in an
72always-loaded prompt or skill: it costs nothing in threads that never open a
73device and cannot drift from the pinned CLI version. The always-on prompt block
74is a few lines that point at the tools and forbid raw `simctl` and `adb`.
75
76## The viewer decodes both vendored protocols
77
78The hub vendors two streaming servers with different wire formats. iOS video is
79an HTTP body of AVCC envelopes decoded with WebCodecs, with input on a separate
80binary WebSocket; Android multiplexes SEMU-framed H.264 and JSON gestures over
81one WebSocket. [`deviceStream.ts`](../../apps/web/src/components/device/deviceStream.ts)
82speaks both so one panel covers both platforms.
83
84Simulators encode H.264 High 5.1. Hardware decoders on some machines and all
85headless browsers reject that profile, and WebCodecs is secure-context only, so
86the viewer probes `isConfigSupported` and falls back to the MJPEG endpoint on
87iOS. Android has no MJPEG; there the panel reports that it cannot decode.
docs/user/devices.md created+63
...@@ -0,0 +1,63 @@
1# Devices
2
3The Device panel shows a live iOS Simulator or Android Emulator next to a
4thread, so you can watch an agent verify mobile work and tap the device
5yourself. Agents get the same device through `device_*` tools and the
6`agent-device` command line, which T3 Code sets up for them.
7
8## Open a device
9
10Open the right panel in a project thread and choose **Device**. On first use,
11the panel walks through three steps: starting the device hub, checking iOS and
12Android support, and choosing whether agents may control devices. Opening the
13panel alone does not download or start anything. If the hub is already
14installed, the setup screen says so and reuses it.
15
16Choose a running device to watch it, or choose **Start** next to a stopped
17device to boot it. The panel shows when you or an agent starts a device.
18Turn off the device hub in **Settings → Integrations → Devices** to stop the
19helper processes; simulators and emulators keep running until you power them
20off.
21
22Simulators run on the machine that hosts the environment server. iOS needs
23macOS with Xcode. Android needs the SDK Platform-Tools, Android Emulator,
24and Command-line Tools (latest), plus a virtual device created in Android
25Studio's Device Manager. T3 Code detects standard SDK locations; set
26`ANDROID_HOME` for a custom location. The panel explains missing dependencies.
27After installing them, restart the environment server and refresh devices.
28
29The screen is interactive: click and drag to touch, type while the screen is
30focused, and use the toolbar for Home, Back, and Recents on Android, rotate on
31iOS, and power off. Close the tab to stop watching; the device keeps running
32unless you power it off.
33
34## Tools
35
36The toolbar's **Tools** button opens a drawer for the open device. It shows the
37foreground app, and lets you switch light and dark mode, change text size,
38flip accessibility settings, overlay the accessibility element frames on the
39screen, set a fake location, and grant or revoke app permissions. iOS also
40exposes Liquid Glass, color filters, VoiceOver, and sending a test push
41notification; Android adds orientation and toggling the network. The drawer
42only shows what the platform can do, and every control reflects the value read
43back from the device after a change.
44
45## Agents and devices
46
47When an agent opens a device, the panel opens in web and desktop clients connected
48to the thread. Mobile clients show device activity in the thread timeline. Agents drive the device through the `agent-device` command line. T3
49Code installs and starts it only after **Agent device access** is enabled. iOS
50taps build a small test runner on first use, which takes a couple of minutes
51once per server. Restart an existing agent session after granting access so it
52receives the device CLI environment.
53
54To keep agents away from simulators, turn off **Agent device access** in
55**Settings → Integrations → Devices**. This hides the device tools from agents
56started from then on; your own Device panel is unaffected.
57
58## Remote connections
59
60The device stream goes through the environment server, so it works over the
61local network, Tailscale, and T3 Connect. Live video needs a secure page
62(HTTPS or localhost); on a plain-HTTP remote origin iOS falls back to a slower
63still-image stream and Android cannot show video.
packages/client-runtime/package.json+8
...@@ -131,6 +131,14 @@...@@ -131,6 +131,14 @@
131 "types": "./src/state/presentation.ts",131 "types": "./src/state/presentation.ts",
132 "default": "./src/state/presentation.ts"132 "default": "./src/state/presentation.ts"
133 },133 },
134 "./state/device": {
135 "types": "./src/state/device.ts",
136 "default": "./src/state/device.ts"
137 },
138 "./state/deviceHubAccess": {
139 "types": "./src/state/deviceHubAccess.ts",
140 "default": "./src/state/deviceHubAccess.ts"
141 },
134 "./state/preview": {142 "./state/preview": {
135 "types": "./src/state/preview.ts",143 "types": "./src/state/preview.ts",
136 "default": "./src/state/preview.ts"144 "default": "./src/state/preview.ts"
packages/client-runtime/src/rpc/client.ts+1
...@@ -51,6 +51,7 @@ export type EnvironmentSubscriptionRpcTag =...@@ -51,6 +51,7 @@ export type EnvironmentSubscriptionRpcTag =
51 | typeof WS_METHODS.subscribeTerminalMetadata51 | typeof WS_METHODS.subscribeTerminalMetadata
52 | typeof WS_METHODS.subscribePreviewEvents52 | typeof WS_METHODS.subscribePreviewEvents
53 | typeof WS_METHODS.subscribeDiscoveredLocalServers53 | typeof WS_METHODS.subscribeDiscoveredLocalServers
54 | typeof WS_METHODS.subscribeDeviceState
54 | typeof WS_METHODS.subscribeResourceTelemetry55 | typeof WS_METHODS.subscribeResourceTelemetry
55 | typeof WS_METHODS.pullRequestsSubscribeRefreshes56 | typeof WS_METHODS.pullRequestsSubscribeRefreshes
56 | typeof WS_METHODS.previewAutomationConnect57 | typeof WS_METHODS.previewAutomationConnect
packages/client-runtime/src/state/device.ts created+68
...@@ -0,0 +1,68 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";
3
4import type { EnvironmentRegistry } from "../connection/registry.ts";
5import {
6 createAtomCommandScheduler,
7 createEnvironmentRpcCommand,
8 createEnvironmentRpcSubscriptionAtomFamily,
9} from "./runtime.ts";
10
11export function createDeviceEnvironmentAtoms<R, E>(
12 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
13) {
14 const scheduler = createAtomCommandScheduler();
15 const concurrency = {
16 mode: "serial" as const,
17 key: ({ environmentId }: { environmentId: string }) => environmentId,
18 };
19 return {
20 /** Server-pushed device hosts, devices, and open sessions for one environment. */
21 state: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
22 label: "environment-data:device:state",
23 tag: WS_METHODS.subscribeDeviceState,
24 }),
25 configure: createEnvironmentRpcCommand(runtime, {
26 label: "environment-data:device:configure",
27 tag: WS_METHODS.deviceConfigure,
28 scheduler,
29 concurrency,
30 }),
31 list: createEnvironmentRpcCommand(runtime, {
32 label: "environment-data:device:list",
33 tag: WS_METHODS.deviceList,
34 scheduler,
35 concurrency,
36 }),
37 open: createEnvironmentRpcCommand(runtime, {
38 label: "environment-data:device:open",
39 tag: WS_METHODS.deviceOpen,
40 scheduler,
41 concurrency,
42 }),
43 close: createEnvironmentRpcCommand(runtime, {
44 label: "environment-data:device:close",
45 tag: WS_METHODS.deviceClose,
46 scheduler,
47 concurrency,
48 }),
49 shutdown: createEnvironmentRpcCommand(runtime, {
50 label: "environment-data:device:shutdown",
51 tag: WS_METHODS.deviceShutdown,
52 scheduler,
53 concurrency,
54 }),
55 detail: createEnvironmentRpcCommand(runtime, {
56 label: "environment-data:device:detail",
57 tag: WS_METHODS.deviceDetail,
58 scheduler,
59 concurrency,
60 }),
61 action: createEnvironmentRpcCommand(runtime, {
62 label: "environment-data:device:action",
63 tag: WS_METHODS.deviceAction,
64 scheduler,
65 concurrency,
66 }),
67 };
68}
packages/client-runtime/src/state/deviceHubAccess.ts created+72
...@@ -0,0 +1,72 @@
1/**
2 * Credentials for the Device panel's media requests.
3 *
4 * The panel reaches simulator streams through `/api/device-hub/*` on the
5 * environment origin. `<img>`, `EventSource`, and `WebSocket` cannot set
6 * bearer or DPoP headers, so bearer and DPoP connections mint a
7 * short-lived WebSocket ticket and pass it as `wsTicket`, the same way the
8 * app's own `/ws` upgrade authenticates. Cookie sessions send the cookie.
9 *
10 * A ticket lives five minutes server-side and is bound to the session, not
11 * to one request, so one ticket covers everything a panel opens at once.
12 * Callers fetch a fresh one each time they (re)connect a stream.
13 */
14import * as Effect from "effect/Effect";
15import type { HttpClient } from "effect/unstable/http";
16
17import { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
18import type { PreparedConnection } from "../connection/model.ts";
19import { environmentEndpointUrl } from "../environment/endpoint.ts";
20import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
21import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";
22import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
23
24const TICKET_TIMEOUT_MS = 8_000;
25
26export interface DeviceHubAccess {
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
37export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDeviceHubAccess")(
38 function* (input: {
39 readonly prepared: PreparedConnection;
40 readonly hubBasePath: string;
41 }): Effect.fn.Return<DeviceHubAccess, RemoteEnvironmentRequestError, HttpClient.HttpClient> {
42 const httpBase = environmentEndpointUrl(input.prepared.httpBaseUrl, input.hubBasePath);
43 const wsBase = httpBase.replace(/^http/, "ws");
44 if (input.prepared.httpAuthorization === null) {
45 return { httpBase, wsBase, query: {}, credentials: true };
46 }
47 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
48 const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization);
49 const ticket = yield* executeAuthenticatedEnvironmentHttpRequest({
50 prepared: input.prepared,
51 signer,
52 remoteAuthorization,
53 method: "POST",
54 url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/websocket-ticket"),
55 timeoutMs: TICKET_TIMEOUT_MS,
56 request: ({ client, headers }) => client.auth.webSocketTicket({ headers }),
57 });
58 return {
59 httpBase,
60 wsBase,
61 query: { wsTicket: ticket.ticket },
62 credentials: false,
63 };
64 },
65);
66
67export const withDeviceHubQuery = (url: string, access: DeviceHubAccess): string => {
68 const entries = Object.entries(access.query);
69 if (entries.length === 0) return url;
70 const separator = url.includes("?") ? "&" : "?";
71 return `${url}${separator}${new URLSearchParams(entries).toString()}`;
72};
packages/client-runtime/src/work-log/presentation.test.ts+63
...@@ -221,6 +221,19 @@ describe("resolveWorkEntryToolPresentation", () => {...@@ -221,6 +221,19 @@ describe("resolveWorkEntryToolPresentation", () => {
221 });221 });
222 });222 });
223223
224 it("labels device tools with the device icon", () => {
225 expect(
226 resolveWorkEntryToolPresentation({
227 label: "mcp__t3-code__device_open",
228 toolLifecycleStatus: "completed",
229 }),
230 ).toEqual({ displayName: "Opened a device in the Device panel", icon: "device" });
231 expect(resolveWorkEntryToolPresentation({ label: "t3-code · device_screenshot" })).toEqual({
232 displayName: "Taking a screenshot of the device",
233 icon: "device",
234 });
235 });
236
224 it("uses structured MCP identity when the provider supplies a custom title", () => {237 it("uses structured MCP identity when the provider supplies a custom title", () => {
225 expect(238 expect(
226 resolveWorkEntryToolPresentation({239 resolveWorkEntryToolPresentation({
...@@ -647,3 +660,53 @@ describe("pull request tool presentation", () => {...@@ -647,3 +660,53 @@ describe("pull request tool presentation", () => {
647 ).toBeNull();660 ).toBeNull();
648 });661 });
649});662});
663
664describe("device group summaries", () => {
665 const deviceEntry = (tool: string): WorkLogPresentationEntry => ({
666 label: "MCP tool call",
667 toolData: { server: "t3-code", tool },
668 itemType: "mcp_tool_call",
669 toolLifecycleStatus: "completed",
670 tone: "tool",
671 });
672
673 it.each(["device_list", "device_open", "device_screenshot", "device_close"])(
674 "recognizes %s as device controls",
675 (tool) => {
676 const entry = deviceEntry(tool);
677 expect(summarizeToolGroup([entry])).toBe("Used device controls 1 time");
678 expect(toolGroupSummaryKind([entry])).toBe("device");
679 },
680 );
681
682 it("summarizes device calls alongside shell commands", () => {
683 expect(
684 summarizeToolGroup([
685 { label: "Ran command", itemType: "command_execution", command: "pwd", tone: "tool" },
686 deviceEntry("device_list"),
687 deviceEntry("device_open"),
688 ]),
689 ).toBe("Ran 1 command and used device controls 2 times");
690 });
691
692 it("recognizes Claude tool names and preserves screenshot previews", () => {
693 const entry = {
694 ...deviceEntry("device_screenshot"),
695 toolData: { toolName: "mcp__t3_code__device_screenshot" },
696 viewedImagePath: "/workspace/device.png",
697 };
698 expect(summarizeToolGroup([entry])).toBe("Used device controls 1 time");
699 expect(workEntryViewedImagePath(entry)).toBe("/workspace/device.png");
700 });
701
702 it("does not classify another server's tools as T3 device controls", () => {
703 expect(
704 summarizeToolGroup([
705 {
706 ...deviceEntry("device_open"),
707 toolData: { server: "another-server", tool: "device_open" },
708 },
709 ]),
710 ).toBe("Used 1 tool");
711 });
712});
packages/client-runtime/src/work-log/presentation.ts+16-1
...@@ -44,6 +44,7 @@ export type ToolGroupAction =...@@ -44,6 +44,7 @@ export type ToolGroupAction =
44 | "edit"44 | "edit"
45 | "command"45 | "command"
46 | "browser"46 | "browser"
47 | "device"
47 | "code-search"48 | "code-search"
48 | "search"49 | "search"
49 | "other"50 | "other"
...@@ -104,6 +105,15 @@ const T3_MCP_TOOL_LABELS: Record<...@@ -104,6 +105,15 @@ const T3_MCP_TOOL_LABELS: Record<
104 preview_set_appearance: ["Set", "Setting", "Set", "preview browser appearance"],105 preview_set_appearance: ["Set", "Setting", "Set", "preview browser appearance"],
105 preview_recording_start: ["Start", "Starting", "Started", "recording the preview browser"],106 preview_recording_start: ["Start", "Starting", "Started", "recording the preview browser"],
106 preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"],107 preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"],
108 device_list: ["List", "Listing", "Listed", "simulators and emulators"],
109 device_open: ["Open", "Opening", "Opened", "a device in the Device panel"],
110 device_screenshot: [
111 "Take a screenshot of",
112 "Taking a screenshot of",
113 "Took a screenshot of",
114 "the device",
115 ],
116 device_close: ["Close", "Closing", "Closed", "a device"],
107};117};
108118
109const PR_TOOL_ACTIONS: Readonly<Record<string, ToolGroupAction>> = {119const PR_TOOL_ACTIONS: Readonly<Record<string, ToolGroupAction>> = {
...@@ -159,7 +169,9 @@ function resolveT3McpToolPresentation(...@@ -159,7 +169,9 @@ function resolveT3McpToolPresentation(
159 ? ("pull-request" as const)169 ? ("pull-request" as const)
160 : name.startsWith("preview_")170 : name.startsWith("preview_")
161 ? ("browser" as const)171 ? ("browser" as const)
162 : ("t3-code" as const),172 : name.startsWith("device_")
173 ? ("device" as const)
174 : ("t3-code" as const),
163 ...(actionKind === undefined ? {} : { action: actionKind }),175 ...(actionKind === undefined ? {} : { action: actionKind }),
164 };176 };
165}177}
...@@ -451,6 +463,7 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio...@@ -451,6 +463,7 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio
451 const presentation = resolveWorkEntryToolPresentation(entry);463 const presentation = resolveWorkEntryToolPresentation(entry);
452 if (presentation?.action !== undefined) return presentation.action;464 if (presentation?.action !== undefined) return presentation.action;
453 if (presentation?.icon === "browser") return "browser";465 if (presentation?.icon === "browser") return "browser";
466 if (presentation?.icon === "device") return "device";
454 if (467 if (
455 entry.requestKind === "file-read" ||468 entry.requestKind === "file-read" ||
456 entry.itemType === "image_view" ||469 entry.itemType === "image_view" ||
...@@ -558,6 +571,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {...@@ -558,6 +571,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
558 return `Changed ${count} ${count === 1 ? "file" : "files"}`;571 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
559 case "command":572 case "command":
560 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;573 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
574 case "device":
575 return `Used device controls ${count} ${count === 1 ? "time" : "times"}`;
561 case "browser":576 case "browser":
562 return `Used browser ${count} ${count === 1 ? "time" : "times"}`;577 return `Used browser ${count} ${count === 1 ? "time" : "times"}`;
563 case "search":578 case "search":
packages/contracts/src/device.ts created+516
...@@ -0,0 +1,516 @@
1/**
2 * Device - Schemas for first-class iOS Simulator and Android Emulator support.
3 *
4 * The server owns device discovery, the streaming helper (expo-device-hub),
5 * and the agent driver (agent-device). Clients render the live screen from the
6 * server-proxied stream, and agents reach devices through the `device_*` MCP
7 * tools plus the `agent-device` CLI the server preconfigures for them.
8 *
9 * Devices live on a *host*. Only the local host (the machine the server runs
10 * on) exists today; the host id is carried everywhere so SSH and cloud hosts
11 * can be added without changing the client contract.
12 *
13 * @module Device
14 */
15import { Schema } from "effect";
16
17import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
18
19export const DevicePlatform = Schema.Literals(["ios", "android"]);
20export type DevicePlatform = typeof DevicePlatform.Type;
21
22export const DeviceHostId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
23export type DeviceHostId = typeof DeviceHostId.Type;
24
25/** The server machine. Always present; other host kinds are future work. */
26export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId;
27
28/** Simulator udid or adb serial (an AVD name while it is not running). */
29export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256));
30export type DeviceId = typeof DeviceId.Type;
31
32export const DeviceSummary = Schema.Struct({
33 hostId: DeviceHostId,
34 id: DeviceId,
35 platform: DevicePlatform,
36 name: TrimmedNonEmptyString,
37 /** OS label such as "iOS 18.0" or "Android 15.0". */
38 version: Schema.String,
39 booted: Schema.Boolean,
40 physical: Schema.Boolean,
41});
42export type DeviceSummary = typeof DeviceSummary.Type;
43
44/**
45 * What the host can do right now. Platforms missing their toolchain are
46 * reported rather than hidden so the picker and the agent can explain why a
47 * platform is absent instead of showing an empty list.
48 */
49export const DevicePlatformAvailability = Schema.Struct({
50 platform: DevicePlatform,
51 available: Schema.Boolean,
52 reason: Schema.optional(Schema.String),
53});
54export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type;
55
56export const DeviceHostSummary = Schema.Struct({
57 id: DeviceHostId,
58 kind: Schema.Literals(["local"]),
59 label: TrimmedNonEmptyString,
60 platforms: Schema.Array(DevicePlatformAvailability),
61 hubInstalled: Schema.Boolean,
62 agentDeviceInstalled: Schema.Boolean,
63});
64export type DeviceHostSummary = typeof DeviceHostSummary.Type;
65
66/**
67 * Lifecycle of the helper processes on a host. Tools are installed on first
68 * use, so a fresh install spends a while in `installing` before any device can
69 * stream; the UI shows that instead of an empty picker.
70 */
71export const DeviceHostStatus = Schema.Literals([
72 "disabled",
73 "idle",
74 "installing",
75 "starting",
76 "ready",
77 "failed",
78]);
79export type DeviceHostStatus = typeof DeviceHostStatus.Type;
80
81/**
82 * A device a thread is looking at. One session per (thread, device); the same
83 * device may be open in several threads, since the stream is shared.
84 */
85export const DeviceSession = Schema.Struct({
86 threadId: ThreadId,
87 hostId: DeviceHostId,
88 deviceId: DeviceId,
89 platform: DevicePlatform,
90 openedAt: Schema.String,
91});
92export type DeviceSession = typeof DeviceSession.Type;
93
94export const DeviceServiceState = Schema.Struct({
95 hosts: Schema.Array(DeviceHostSummary),
96 hostStatus: DeviceHostStatus,
97 hostStatusDetail: Schema.optional(Schema.String),
98 devices: Schema.Array(DeviceSummary),
99 sessions: Schema.Array(DeviceSession),
100 bootingDevices: Schema.optional(
101 Schema.Array(Schema.Struct({ ...DeviceSummary.fields, threadId: ThreadId })),
102 ),
103 onboardingCompleted: Schema.Boolean,
104 agentAccessEnabled: Schema.Boolean,
105 /** Origin-relative path the client prefixes to hub routes. */
106 hubBasePath: Schema.String,
107 revision: Schema.Int,
108});
109export type DeviceServiceState = typeof DeviceServiceState.Type;
110
111export const DeviceListInput = Schema.Struct({});
112export type DeviceListInput = typeof DeviceListInput.Type;
113
114export const DeviceConfigureInput = Schema.Struct({
115 enabled: Schema.optional(Schema.Boolean),
116 agentAccessEnabled: Schema.optional(Schema.Boolean),
117 onboardingCompleted: Schema.optional(Schema.Boolean),
118});
119export type DeviceConfigureInput = typeof DeviceConfigureInput.Type;
120
121export const DeviceOpenInput = Schema.Struct({
122 threadId: ThreadId,
123 hostId: Schema.optional(DeviceHostId),
124 deviceId: DeviceId,
125 platform: DevicePlatform,
126 /** Boot the simulator or emulator when it is not running. Defaults to true. */
127 boot: Schema.optional(Schema.Boolean),
128});
129export type DeviceOpenInput = typeof DeviceOpenInput.Type;
130
131export const DeviceCloseInput = Schema.Struct({
132 threadId: ThreadId,
133 /** Omit to close every device session for the thread. */
134 deviceId: Schema.optional(DeviceId),
135 /** Also shut the simulator or emulator down. Defaults to false. */
136 shutdown: Schema.optional(Schema.Boolean),
137});
138export type DeviceCloseInput = typeof DeviceCloseInput.Type;
139
140export const DeviceShutdownInput = Schema.Struct({
141 hostId: Schema.optional(DeviceHostId),
142 deviceId: DeviceId,
143 platform: DevicePlatform,
144});
145export type DeviceShutdownInput = typeof DeviceShutdownInput.Type;
146
147// Device settings and actions. Each setting names the platforms that support
148// it; the panel hides the rest. Values are normalized across platforms where
149// both have the concept (appearance, text size) and platform-specific where
150// only one does.
151
152export const DeviceAppearance = Schema.Literals(["light", "dark"]);
153export type DeviceAppearance = typeof DeviceAppearance.Type;
154
155/**
156 * iOS content-size categories map onto twelve steps; Android `font_scale`
157 * is continuous. Four shared steps cover what people actually reach for.
158 */
159export const DeviceTextSize = Schema.Literals(["small", "default", "large", "extra-large"]);
160export type DeviceTextSize = typeof DeviceTextSize.Type;
161
162export const DeviceColorFilter = Schema.Literals([
163 "none",
164 "grayscale",
165 "red-green",
166 "green-red",
167 "blue-yellow",
168]);
169export type DeviceColorFilter = typeof DeviceColorFilter.Type;
170
171export const DeviceOrientation = Schema.Literals([
172 "portrait",
173 "landscape_left",
174 "portrait_upside_down",
175 "landscape_right",
176]);
177export type DeviceOrientation = typeof DeviceOrientation.Type;
178
179/** Current values as read from the device; `undefined` means unsupported or unread. */
180export const DeviceSettings = Schema.Struct({
181 appearance: Schema.optional(DeviceAppearance),
182 textSize: Schema.optional(DeviceTextSize),
183 reduceMotion: Schema.optional(Schema.Boolean),
184 increaseContrast: Schema.optional(Schema.Boolean),
185 reduceTransparency: Schema.optional(Schema.Boolean),
186 showBorders: Schema.optional(Schema.Boolean),
187 voiceOver: Schema.optional(Schema.Boolean),
188 liquidGlass: Schema.optional(Schema.Literals(["clear", "tinted"])),
189 colorFilter: Schema.optional(DeviceColorFilter),
190 networkEnabled: Schema.optional(Schema.Boolean),
191 location: Schema.optional(
192 Schema.NullOr(Schema.Struct({ latitude: Schema.Number, longitude: Schema.Number })),
193 ),
194});
195export type DeviceSettings = typeof DeviceSettings.Type;
196
197/** The app in the foreground, when the platform can tell us. */
198export const DeviceForegroundApp = Schema.Struct({
199 id: Schema.String,
200 name: Schema.optional(Schema.String),
201 version: Schema.optional(Schema.String),
202});
203export type DeviceForegroundApp = typeof DeviceForegroundApp.Type;
204
205export const DeviceDetail = Schema.Struct({
206 hostId: DeviceHostId,
207 deviceId: DeviceId,
208 settings: DeviceSettings,
209 foregroundApp: Schema.NullOr(DeviceForegroundApp),
210 readAt: Schema.String,
211});
212export type DeviceDetail = typeof DeviceDetail.Type;
213
214export const DevicePermission = Schema.Literals([
215 "camera",
216 "microphone",
217 "photos",
218 "contacts",
219 "calendar",
220 "reminders",
221 "location",
222 "notifications",
223 "motion",
224 "media-library",
225 "faceid",
226]);
227export type DevicePermission = typeof DevicePermission.Type;
228
229const DeviceTarget = {
230 hostId: Schema.optional(DeviceHostId),
231 deviceId: DeviceId,
232};
233
234export const DeviceActionInput = Schema.Union([
235 Schema.Struct({
236 ...DeviceTarget,
237 type: Schema.Literal("setAppearance"),
238 value: DeviceAppearance,
239 }),
240 Schema.Struct({ ...DeviceTarget, type: Schema.Literal("setTextSize"), value: DeviceTextSize }),
241 Schema.Struct({
242 ...DeviceTarget,
243 type: Schema.Literal("setToggle"),
244 setting: Schema.Literals([
245 "reduceMotion",
246 "increaseContrast",
247 "reduceTransparency",
248 "showBorders",
249 "voiceOver",
250 "networkEnabled",
251 ]),
252 value: Schema.Boolean,
253 }),
254 Schema.Struct({
255 ...DeviceTarget,
256 type: Schema.Literal("setLiquidGlass"),
257 value: Schema.Literals(["clear", "tinted"]),
258 }),
259 Schema.Struct({
260 ...DeviceTarget,
261 type: Schema.Literal("setColorFilter"),
262 value: DeviceColorFilter,
263 }),
264 Schema.Struct({
265 ...DeviceTarget,
266 type: Schema.Literal("setOrientation"),
267 value: DeviceOrientation,
268 }),
269 Schema.Struct({
270 ...DeviceTarget,
271 type: Schema.Literal("setLocation"),
272 latitude: Schema.Number.check(Schema.isBetween({ minimum: -90, maximum: 90 })),
273 longitude: Schema.Number.check(Schema.isBetween({ minimum: -180, maximum: 180 })),
274 }),
275 Schema.Struct({ ...DeviceTarget, type: Schema.Literal("clearLocation") }),
276 Schema.Struct({
277 ...DeviceTarget,
278 type: Schema.Literal("setPermission"),
279 appId: TrimmedNonEmptyString,
280 permission: DevicePermission,
281 decision: Schema.Literals(["grant", "revoke", "reset"]),
282 }),
283 Schema.Struct({ ...DeviceTarget, type: Schema.Literal("openUrl"), url: TrimmedNonEmptyString }),
284 Schema.Struct({
285 ...DeviceTarget,
286 type: Schema.Literal("launchApp"),
287 appId: TrimmedNonEmptyString,
288 }),
289 Schema.Struct({
290 ...DeviceTarget,
291 type: Schema.Literal("terminateApp"),
292 appId: TrimmedNonEmptyString,
293 }),
294 Schema.Struct({ ...DeviceTarget, type: Schema.Literal("shake") }),
295 Schema.Struct({
296 ...DeviceTarget,
297 type: Schema.Literal("sendPush"),
298 appId: TrimmedNonEmptyString,
299 /** APNs-style payload; a bare string becomes the alert body. */
300 payload: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.Unknown)]),
301 }),
302]);
303export type DeviceActionInput = typeof DeviceActionInput.Type;
304export type DeviceActionType = DeviceActionInput["type"];
305
306export const DeviceDetailInput = Schema.Struct(DeviceTarget);
307export type DeviceDetailInput = typeof DeviceDetailInput.Type;
308
309export class DeviceHostUnavailableError extends Schema.TaggedError<DeviceHostUnavailableError>()(
310 "DeviceHostUnavailableError",
311 {
312 hostId: DeviceHostId,
313 reason: Schema.String,
314 },
315) {
316 override get message(): string {
317 return `Device host ${this.hostId} is unavailable: ${this.reason}`;
318 }
319}
320
321export class DevicePlatformUnavailableError extends Schema.TaggedError<DevicePlatformUnavailableError>()(
322 "DevicePlatformUnavailableError",
323 {
324 hostId: DeviceHostId,
325 platform: DevicePlatform,
326 reason: Schema.String,
327 },
328) {
329 override get message(): string {
330 return `${this.platform} devices are unavailable on host ${this.hostId}: ${this.reason}`;
331 }
332}
333
334export class DeviceNotFoundError extends Schema.TaggedError<DeviceNotFoundError>()(
335 "DeviceNotFoundError",
336 {
337 hostId: DeviceHostId,
338 deviceId: DeviceId,
339 },
340) {
341 override get message(): string {
342 return `Device ${this.deviceId} was not found on host ${this.hostId}.`;
343 }
344}
345
346export class DeviceBootError extends Schema.TaggedError<DeviceBootError>()("DeviceBootError", {
347 hostId: DeviceHostId,
348 deviceId: DeviceId,
349 reason: Schema.Literals(["disk_space", "timeout", "launch_failed"]),
350 cause: Schema.Defect(),
351}) {
352 override get message(): string {
353 const explanation = {
354 disk_space: "There is not enough free disk space on the environment server.",
355 timeout: "The device did not become ready in time.",
356 launch_failed:
357 "The simulator or emulator could not start. Check its configuration on the environment server.",
358 }[this.reason];
359 return `Device ${this.deviceId} failed to boot: ${explanation}`;
360 }
361}
362
363export class DeviceOperationError extends Schema.TaggedError<DeviceOperationError>()(
364 "DeviceOperationError",
365 {
366 operation: Schema.String,
367 reason: Schema.Literals([
368 "command_failed",
369 "request_failed",
370 "invalid_payload",
371 "settings_failed",
372 "hub_rejected",
373 ]),
374 exitCode: Schema.optional(Schema.Number),
375 cause: Schema.Defect(),
376 },
377) {
378 override get message(): string {
379 const explanation = {
380 command_failed: `The device command failed${this.exitCode === undefined ? "" : ` (exit code ${this.exitCode})`}.`,
381 request_failed: "Could not communicate with device support. Try refreshing devices.",
382 invalid_payload: "The device request could not be encoded.",
383 settings_failed: "Could not read or save device settings.",
384 hub_rejected: "The device hub could not complete the request.",
385 }[this.reason];
386 return `Device ${this.operation} failed: ${explanation}`;
387 }
388}
389
390export class DeviceActionUnavailableError extends Schema.TaggedError<DeviceActionUnavailableError>()(
391 "DeviceActionUnavailableError",
392 {
393 operation: Schema.String,
394 platform: DevicePlatform,
395 reason: Schema.Literals(["unsupported", "helper_missing"]),
396 },
397) {
398 override get message(): string {
399 return this.reason === "helper_missing"
400 ? `Device ${this.operation} requires a helper missing from this install. Set up device support again.`
401 : `Device ${this.operation} is not supported on ${this.platform}.`;
402 }
403}
404
405export const DeviceError = Schema.Union([
406 DeviceHostUnavailableError,
407 DevicePlatformUnavailableError,
408 DeviceNotFoundError,
409 DeviceBootError,
410 DeviceOperationError,
411 DeviceActionUnavailableError,
412]);
413export type DeviceError = typeof DeviceError.Type;
414
415// MCP tool shapes. Kept next to the RPC shapes so the tool surface and the
416// panel describe devices the same way.
417
418export const DeviceToolListResult = Schema.Struct({
419 hosts: Schema.Array(DeviceHostSummary),
420 devices: Schema.Array(DeviceSummary),
421 /** Devices already open in this thread's Device panel. */
422 open: Schema.Array(Schema.Struct({ hostId: DeviceHostId, deviceId: DeviceId })),
423});
424export type DeviceToolListResult = typeof DeviceToolListResult.Type;
425
426export const DeviceToolOpenInput = Schema.Struct({
427 deviceId: Schema.optional(
428 DeviceId.annotate({
429 description:
430 "Simulator udid or emulator serial from device_list. Omit to use the booted device for the platform, or the most recently used one.",
431 }),
432 ),
433 platform: Schema.optional(
434 DevicePlatform.annotate({
435 description: "Required when deviceId is omitted and both platforms are available.",
436 }),
437 ),
438 hostId: Schema.optional(
439 DeviceHostId.annotate({ description: "Device host from device_list. Defaults to local." }),
440 ),
441}).annotate({
442 description:
443 "Boots the device if needed, starts its live stream, and opens the Device panel so the user can watch. Returns how to drive it with the agent-device CLI.",
444});
445export type DeviceToolOpenInput = typeof DeviceToolOpenInput.Type;
446
447export const DeviceToolOpenResult = Schema.Struct({
448 device: DeviceSummary,
449 /** Ready-to-run agent-device invocation pinned to this device. */
450 agentDevice: Schema.Struct({
451 command: Schema.String,
452 /** Flags that pin every command to this device, e.g. `--udid <id>`. */
453 targetArgs: Schema.Array(Schema.String),
454 }),
455 quickStart: Schema.String,
456});
457export type DeviceToolOpenResult = typeof DeviceToolOpenResult.Type;
458
459export const DeviceToolTargetInput = Schema.Struct({
460 deviceId: Schema.optional(
461 DeviceId.annotate({
462 description:
463 "Device from device_list. Omit to use the device most recently opened in this thread.",
464 }),
465 ),
466 hostId: Schema.optional(DeviceHostId),
467});
468export type DeviceToolTargetInput = typeof DeviceToolTargetInput.Type;
469
470export const DeviceToolScreenshotResult = Schema.Struct({
471 device: DeviceSummary,
472 screenshot: Schema.Struct({
473 mimeType: Schema.Literal("image/png"),
474 data: Schema.String,
475 width: Schema.Int,
476 height: Schema.Int,
477 }),
478});
479export type DeviceToolScreenshotResult = typeof DeviceToolScreenshotResult.Type;
480
481export const DeviceToolCloseInput = Schema.Struct({
482 deviceId: Schema.optional(
483 DeviceId.annotate({
484 description: "Device to close. Omit to close every device in this thread.",
485 }),
486 ),
487 hostId: Schema.optional(DeviceHostId),
488 shutdown: Schema.optional(
489 Schema.Boolean.annotate({
490 description: "Also power the simulator or emulator off. Defaults to false.",
491 }),
492 ),
493});
494export type DeviceToolCloseInput = typeof DeviceToolCloseInput.Type;
495
496export class DeviceToolUnavailableError extends Schema.TaggedError<DeviceToolUnavailableError>()(
497 "DeviceToolUnavailableError",
498 {
499 reason: Schema.String,
500 },
501) {
502 override get message(): string {
503 return this.reason;
504 }
505}
506
507export const DeviceToolError = Schema.Union([
508 DeviceToolUnavailableError,
509 DeviceHostUnavailableError,
510 DevicePlatformUnavailableError,
511 DeviceNotFoundError,
512 DeviceBootError,
513 DeviceOperationError,
514 DeviceActionUnavailableError,
515]);
516export type DeviceToolError = typeof DeviceToolError.Type;
packages/contracts/src/index.ts+1
...@@ -34,6 +34,7 @@ export * from "./assets.ts";...@@ -34,6 +34,7 @@ export * from "./assets.ts";
34export * from "./review.ts";34export * from "./review.ts";
35export * from "./browserImport.ts";35export * from "./browserImport.ts";
36export * from "./browserProfile.ts";36export * from "./browserProfile.ts";
37export * from "./device.ts";
37export * from "./preview.ts";38export * from "./preview.ts";
38export * from "./previewAutomation.ts";39export * from "./previewAutomation.ts";
39export * from "./resourceTelemetry.ts";40export * from "./resourceTelemetry.ts";
packages/contracts/src/rpc.ts+78
...@@ -181,6 +181,19 @@ import {...@@ -181,6 +181,19 @@ import {
181 PreviewResizeInput,181 PreviewResizeInput,
182 PreviewSessionSnapshot,182 PreviewSessionSnapshot,
183} from "./preview.ts";183} from "./preview.ts";
184import {
185 DeviceActionInput,
186 DeviceCloseInput,
187 DeviceConfigureInput,
188 DeviceDetail,
189 DeviceDetailInput,
190 DeviceError,
191 DeviceListInput,
192 DeviceOpenInput,
193 DeviceServiceState,
194 DeviceSession,
195 DeviceShutdownInput,
196} from "./device.ts";
184import {197import {
185 PreviewAutomationError,198 PreviewAutomationError,
186 PreviewAutomationHost,199 PreviewAutomationHost,
...@@ -312,6 +325,15 @@ export const WS_METHODS = {...@@ -312,6 +325,15 @@ export const WS_METHODS = {
312 previewAutomationRespond: "previewAutomation.respond",325 previewAutomationRespond: "previewAutomation.respond",
313 previewAutomationFocusHost: "previewAutomation.focusHost",326 previewAutomationFocusHost: "previewAutomation.focusHost",
314327
328 // Device methods
329 deviceConfigure: "device.configure",
330 deviceList: "device.list",
331 deviceOpen: "device.open",
332 deviceClose: "device.close",
333 deviceShutdown: "device.shutdown",
334 deviceDetail: "device.detail",
335 deviceAction: "device.action",
336
315 // Server meta337 // Server meta
316 serverProbe: "server.probe",338 serverProbe: "server.probe",
317 serverGetConfig: "server.getConfig",339 serverGetConfig: "server.getConfig",
...@@ -378,6 +400,7 @@ export const WS_METHODS = {...@@ -378,6 +400,7 @@ export const WS_METHODS = {
378 subscribeTerminalMetadata: "subscribeTerminalMetadata",400 subscribeTerminalMetadata: "subscribeTerminalMetadata",
379 subscribePreviewEvents: "subscribePreviewEvents",401 subscribePreviewEvents: "subscribePreviewEvents",
380 subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers",402 subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers",
403 subscribeDeviceState: "subscribeDeviceState",
381 subscribeServerConfig: "subscribeServerConfig",404 subscribeServerConfig: "subscribeServerConfig",
382 subscribeServerLifecycle: "subscribeServerLifecycle",405 subscribeServerLifecycle: "subscribeServerLifecycle",
383 subscribeAuthAccess: "subscribeAuthAccess",406 subscribeAuthAccess: "subscribeAuthAccess",
...@@ -1078,6 +1101,53 @@ const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscov...@@ -1078,6 +1101,53 @@ const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscov
1078 stream: true,1101 stream: true,
1079});1102});
10801103
1104const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, {
1105 payload: DeviceListInput,
1106 success: DeviceServiceState,
1107 error: Schema.Union([DeviceError, EnvironmentAuthorizationError]),
1108});
1109
1110const WsDeviceConfigureRpc = Rpc.make(WS_METHODS.deviceConfigure, {
1111 payload: DeviceConfigureInput,
1112 success: DeviceServiceState,
1113 error: Schema.Union([DeviceError, EnvironmentAuthorizationError]),
1114});
1115
1116const WsDeviceOpenRpc = Rpc.make(WS_METHODS.deviceOpen, {
1117 payload: DeviceOpenInput,
1118 success: DeviceSession,
1119 error: Schema.Union([DeviceError, EnvironmentAuthorizationError]),
1120});
1121
1122const WsDeviceCloseRpc = Rpc.make(WS_METHODS.deviceClose, {
1123 payload: DeviceCloseInput,
1124 error: Schema.Union([DeviceError, EnvironmentAuthorizationError]),
1125});
1126
1127const WsDeviceShutdownRpc = Rpc.make(WS_METHODS.deviceShutdown, {
1128 payload: DeviceShutdownInput,
1129 error: Schema.Union([DeviceError, EnvironmentAuthorizationError]),
1130});
1131
1132const WsDeviceDetailRpc = Rpc.make(WS_METHODS.deviceDetail, {
1133 payload: DeviceDetailInput,
1134 success: DeviceDetail,
1135 error: Schema.Union([DeviceError, EnvironmentAuthorizationError]),
1136});
1137
1138const WsDeviceActionRpc = Rpc.make(WS_METHODS.deviceAction, {
1139 payload: DeviceActionInput,
1140 success: DeviceDetail,
1141 error: Schema.Union([DeviceError, EnvironmentAuthorizationError]),
1142});
1143
1144const WsSubscribeDeviceStateRpc = Rpc.make(WS_METHODS.subscribeDeviceState, {
1145 payload: Schema.Struct({}),
1146 success: DeviceServiceState,
1147 error: EnvironmentAuthorizationError,
1148 stream: true,
1149});
1150
1081const WsOrchestrationDispatchCommandRpc = Rpc.make(ORCHESTRATION_WS_METHODS.dispatchCommand, {1151const WsOrchestrationDispatchCommandRpc = Rpc.make(ORCHESTRATION_WS_METHODS.dispatchCommand, {
1082 payload: ClientOrchestrationCommand,1152 payload: ClientOrchestrationCommand,
1083 success: OrchestrationRpcSchemas.dispatchCommand.output,1153 success: OrchestrationRpcSchemas.dispatchCommand.output,
...@@ -1308,6 +1378,14 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1308,6 +1378,14 @@ export const WsRpcGroup = RpcGroup.make(
1308 WsPreviewAutomationFocusHostRpc,1378 WsPreviewAutomationFocusHostRpc,
1309 WsSubscribePreviewEventsRpc,1379 WsSubscribePreviewEventsRpc,
1310 WsSubscribeDiscoveredLocalServersRpc,1380 WsSubscribeDiscoveredLocalServersRpc,
1381 WsDeviceConfigureRpc,
1382 WsDeviceListRpc,
1383 WsDeviceOpenRpc,
1384 WsDeviceCloseRpc,
1385 WsDeviceShutdownRpc,
1386 WsDeviceDetailRpc,
1387 WsDeviceActionRpc,
1388 WsSubscribeDeviceStateRpc,
1311 WsSubscribeServerConfigRpc,1389 WsSubscribeServerConfigRpc,
1312 WsSubscribeServerLifecycleRpc,1390 WsSubscribeServerLifecycleRpc,
1313 WsSubscribeAuthAccessRpc,1391 WsSubscribeAuthAccessRpc,
packages/contracts/src/settings.ts+19
...@@ -967,6 +967,22 @@ export const ServerSettings = Schema.Struct({...@@ -967,6 +967,22 @@ export const ServerSettings = Schema.Struct({
967 defaultModelSelection: Schema.NullOr(ModelSelection).pipe(967 defaultModelSelection: Schema.NullOr(ModelSelection).pipe(
968 Schema.withDecodingDefault(Effect.succeed(null)),968 Schema.withDecodingDefault(Effect.succeed(null)),
969 ),969 ),
970 /**
971 * Whether agents may drive simulators and emulators. Gates the `device_*`
972 * MCP tools and the preconfigured `agent-device` CLI the same way
973 * `enableAgentBrowserAccess` gates the browser: server-authoritative, applied
974 * when the provider session is prepared. The user's own Device panel is
975 * unaffected.
976 */
977 enableAgentDeviceAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
978 /**
979 * Whether this server may install and run T3's device helper processes.
980 * Kept separate from agent access so enabling the user's Device panel does
981 * not also grant providers control of simulators and emulators.
982 */
983 enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
984 /** Whether the server-local Device panel setup flow has been completed. */
985 deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
970 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(986 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
971 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),987 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
972 ),988 ),
...@@ -1239,6 +1255,9 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1239,6 +1255,9 @@ export const ServerSettingsPatch = Schema.Struct({
1239 Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),1255 Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),
1240 ),1256 ),
1241 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),1257 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
1258 enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean),
1259 enableDeviceSupport: Schema.optionalKey(Schema.Boolean),
1260 deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),
1242 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),1261 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
1243 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),1262 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
1244 backgroundActivity: Schema.optionalKey(1263 backgroundActivity: Schema.optionalKey(