Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c8b145b
feat(devices): first-class iOS Simulator and Android Emulator support
juliusmarminge Sep 8, 2026
4c54771
fix(devices): proxy hub WebSocket upgrades in dev and scope the state…
juliusmarminge Sep 8, 2026
ba87513
fix(devices): probe decoder support and fall back to MJPEG on iOS
juliusmarminge Sep 8, 2026
65e1fd0
fix(devices): give device_list an object schema so providers keep the…
juliusmarminge Sep 8, 2026
7e2e758
fix(devices): start the device host when a device-capable session begins
juliusmarminge Sep 8, 2026
499295f
fix(devices): reap a device hub left behind by a killed server
juliusmarminge Sep 8, 2026
5071913
fix(devices): show input connectivity and disable controls while the …
juliusmarminge Sep 8, 2026
4c5c9da
fix(devices): prime the iOS helper before opening the input socket an…
juliusmarminge Sep 8, 2026
034b186
fix(devices): keep the device aspect ratio and shrink height when the…
juliusmarminge Sep 8, 2026
02c5928
feat(devices): add a Tools drawer for simulator and emulator settings
juliusmarminge Sep 8, 2026
87b2dc7
fix(devices): flatten the nested iOS accessibility tree for the overlay
juliusmarminge Sep 8, 2026
bea0c88
fix(devices): read the Android foreground app from the unfiltered win…
juliusmarminge Sep 8, 2026
39180ae
fix(devices): rebuild the Android decoder when the emulator rotates
juliusmarminge Sep 8, 2026
65b422f
fix(devices): rotate Android emulators through the accelerometer
juliusmarminge Sep 8, 2026
613e365
fix(devices): only overlay pointable Android accessibility nodes
juliusmarminge Sep 8, 2026
07bb3b5
fix(devices): preserve typed errors through screenshot registration
juliusmarminge Sep 8, 2026
a9aecd1
fix(devices): preserve snapshot handling after main rebase
juliusmarminge Sep 8, 2026
f352c2c
fix(devices): keep implementation-only symbols private
juliusmarminge Sep 8, 2026
3a6eb45
feat(devices): add consent-based device onboarding and setup
juliusmarminge Sep 8, 2026
ac7501a
refactor(devices): compose onboarding with shared wizard layout
juliusmarminge Sep 8, 2026
4613fbf
fix(devices): close streams and recover device discovery reliably
juliusmarminge Sep 8, 2026
ecdec3e
fix(devices): pass CLI access through all local providers
juliusmarminge Sep 8, 2026
c0e4bf0
fix(devices): show device launch and shutdown failures
juliusmarminge Sep 8, 2026
5c399f8
refactor(devices): expose host dependency and normalize setup failures
juliusmarminge Sep 8, 2026
eb1b675
refactor(devices): compose the local host at the service layer
juliusmarminge Sep 8, 2026
abeab2c
fix(devices): normalize public failures and preserve error causes
juliusmarminge Sep 8, 2026
45761fc
fix(devices): preserve shutdown success when discovery is unavailable
juliusmarminge Sep 8, 2026
0ea0eb7
refactor(devices): keep local host construction and layer together
juliusmarminge Sep 8, 2026
5554f6c
fix(devices): preserve pull request capabilities after rebase
juliusmarminge Sep 9, 2026
08a7c26
fix(devices): correct iOS rotation and summarize device activity
juliusmarminge Sep 10, 2026
3ed624a
fix(devices): defer platform icons until the multi-host layer
juliusmarminge Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
type WorkContentIcon = AppSymbolName | "browser" | "t3-code" | "pull-request";
type WorkContentIcon = AppSymbolName | "browser" | "device" | "t3-code" | "pull-request";

function WorkLogIcon(props: {
readonly icon: WorkContentIcon;
Expand All @@ -97,7 +97,9 @@ function WorkLogIcon(props: {
? "arrow.triangle.pull"
: props.icon === "browser"
? { ios: "globe", android: "public" }
: props.icon
: props.icon === "device"
? { ios: "iphone", android: "smartphone" }
: props.icon
}
size={14}
weight="medium"
Expand Down Expand Up @@ -899,7 +901,7 @@ export function ThreadWorkGroupToggle(props: {
readonly iconSubtleColor: import("react-native").ColorValue;
readonly summary: string;
readonly summaryKind: ToolGroupSummaryKind;
readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request";
readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
readonly themeAppearance: "light" | "dark";
readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
readonly toolIcon?: ToolActivityIcon;
Expand Down Expand Up @@ -1248,6 +1250,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
return { ios: "square.and.pencil", android: "edit" };
case "command":
return { ios: "terminal", android: "terminal" };
case "device":
return { ios: "iphone", android: "smartphone" };
case "browser":
case "search":
return { ios: "globe", android: "public" };
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export type ThreadFeedEntry =
readonly summaryKind: ToolGroupSummaryKind;
readonly toolSurface?: WorkLogEntry["toolSurface"];
readonly toolIcon?: WorkLogEntry["toolIcon"];
readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request";
readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
readonly hasFailure: boolean;
readonly live: boolean;
readonly shimmer: boolean;
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
[WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceList]: AuthOrchestrationReadScope,
[WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceShutdown]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceDetail]: AuthOrchestrationReadScope,
[WS_METHODS.deviceAction]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope,
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/device/AgentDeviceShim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* A directory holding an `agent-device` launcher that runs the pinned install
* with the server's Node. Prepended to provider subprocess PATHs so the agent
* types `agent-device …` and gets the version the injected instructions were
* written for, regardless of what is or is not globally installed.
*/
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

const SHIM_DIR = "device/bin";

export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(function* (input: {
readonly entryPath: string;
readonly stateDir: string;
}) {
const { entryPath } = input;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const platform = yield* HostProcessPlatform;
const shimDir = path.join(input.stateDir, SHIM_DIR);
yield* fs.makeDirectory(shimDir, { recursive: true });
const node = process.execPath;
if (platform === "win32") {
const script = `@echo off\r\n"${node}" "${entryPath}" %*\r\n`;
yield* fs.writeFileString(path.join(shimDir, "agent-device.cmd"), script);
} else {
const script = `#!/bin/sh\nexec "${node}" "${entryPath}" "$@"\n`;
const shimPath = path.join(shimDir, "agent-device");
yield* fs.writeFileString(shimPath, script);
yield* fs.chmod(shimPath, 0o755);
}
return shimDir;
});
283 changes: 283 additions & 0 deletions apps/server/src/device/DeviceActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import { describe, expect, it } from "@effect/vitest";
import type { DeviceActionInput } from "@t3tools/contracts";
import * as Effect from "effect/Effect";

import { readDeviceDetail, runDeviceAction, supportsAction } from "./DeviceActions.ts";
import type { DeviceHostReady } from "./DeviceHost.ts";

type Call = { command: string; args: ReadonlyArray<string>; stdin?: string };

const makeReady = (
respond: (call: Call) => { stdout?: string; stderr?: string; code?: number } = () => ({}),
helpers: DeviceHostReady["helpers"] = {
serveSimAxSettings: "/hub/simax/serve-sim-ax-settings",
serveSimCli: "/hub/serve-sim.js",
},
) => {
const calls: Call[] = [];
const ready: DeviceHostReady = {
hub: { origin: "http://127.0.0.1:1" },
helpers,
run: (command, args, options) => {
const call = { command, args, ...(options?.stdin ? { stdin: options.stdin } : {}) };
calls.push(call);
const result = respond(call);
return Effect.succeed({
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
code: result.code ?? 0,
});
},
};
return { ready, calls };
};

const udid = "SIM-1";

describe("supportsAction", () => {
it("advertises platform-specific toggles", () => {
const toggle = (setting: Extract<DeviceActionInput, { type: "setToggle" }>["setting"]) =>
({ type: "setToggle", deviceId: udid, setting, value: true }) as const;
expect(supportsAction("ios", toggle("voiceOver"))).toBe(true);
expect(supportsAction("android", toggle("voiceOver"))).toBe(false);
expect(supportsAction("android", toggle("networkEnabled"))).toBe(true);
expect(supportsAction("ios", toggle("networkEnabled"))).toBe(false);
expect(supportsAction("ios", { type: "setLiquidGlass", deviceId: udid, value: "clear" })).toBe(
true,
);
expect(
supportsAction("android", { type: "setLiquidGlass", deviceId: udid, value: "clear" }),
).toBe(false);
expect(
supportsAction("android", { type: "setOrientation", deviceId: udid, value: "portrait" }),
).toBe(true);
expect(
supportsAction("ios", { type: "setOrientation", deviceId: udid, value: "portrait" }),
).toBe(false);
});
});

describe("runDeviceAction", () => {
it.effect("maps shared text sizes onto simctl content-size categories", () =>
Effect.gen(function* () {
const { ready, calls } = makeReady();
yield* runDeviceAction(ready, "ios", {
type: "setTextSize",
deviceId: udid,
value: "extra-large",
});
expect(calls).toEqual([
{ command: "xcrun", args: ["simctl", "ui", udid, "content_size", "accessibility-large"] },
]);
}),
);

it.effect("maps shared text sizes onto Android font_scale", () =>
Effect.gen(function* () {
const { ready, calls } = makeReady();
yield* runDeviceAction(ready, "android", {
type: "setTextSize",
deviceId: "emulator-5554",
value: "large",
});
expect(calls[0]?.args).toEqual([
"-s",
"emulator-5554",
"shell",
"settings",
"put",
"system",
"font_scale",
"1.15",
]);
}),
);

it.effect(
"rotates emulators through the accelerometer and physical devices through the lock",
() =>
Effect.gen(function* () {
const emulator = makeReady();
yield* runDeviceAction(emulator.ready, "android", {
type: "setOrientation",
deviceId: "emulator-5554",
value: "landscape_left",
});
expect(emulator.calls.at(-1)?.args).toEqual([
"-s",
"emulator-5554",
"emu",
"sensor",
"set",
"acceleration",
"9.81:0:0",
]);
const phone = makeReady();
yield* runDeviceAction(phone.ready, "android", {
type: "setOrientation",
deviceId: "R5CT1234",
value: "landscape_right",
});
expect(phone.calls).toEqual([
{
command: "adb",
args: ["-s", "R5CT1234", "shell", "cmd", "window", "user-rotation", "lock", "3"],
},
]);
}),
);

it.effect("runs accessibility toggles through the bundled helper via simctl spawn", () =>
Effect.gen(function* () {
const { ready, calls } = makeReady();
yield* runDeviceAction(ready, "ios", {
type: "setToggle",
deviceId: udid,
setting: "voiceOver",
value: true,
});
expect(calls).toEqual([
{
command: "xcrun",
args: [
"simctl",
"spawn",
udid,
"/hub/simax/serve-sim-ax-settings",
"set",
"voiceover",
"on",
],
},
]);
}),
);

it.effect("fails clearly when the helper is missing", () =>
Effect.gen(function* () {
const { ready } = makeReady(() => ({}), { serveSimAxSettings: null, serveSimCli: null });
const error = yield* Effect.flip(
runDeviceAction(ready, "ios", {
type: "setColorFilter",
deviceId: udid,
value: "grayscale",
}),
);
expect(error._tag).toBe("DeviceActionUnavailableError");
expect(error.message).toContain("requires a helper");
}),
);

it.effect("rejects actions the platform does not support without running anything", () =>
Effect.gen(function* () {
const { ready, calls } = makeReady();
const error = yield* Effect.flip(
runDeviceAction(ready, "android", {
type: "sendPush",
deviceId: "emulator-5554",
appId: "com.example",
payload: "hi",
}),
);
expect(error.message).toContain("not supported on android");
expect(calls).toEqual([]);
}),
);

it.effect("surfaces non-zero exit codes as operation errors", () =>
Effect.gen(function* () {
const { ready } = makeReady(() => ({ code: 1, stderr: "Invalid device: SIM-1" }));
const error = yield* Effect.flip(
runDeviceAction(ready, "ios", { type: "setAppearance", deviceId: udid, value: "dark" }),
);
expect(error.operation).toBe("appearance");
expect(error.message).toContain("exit code 1");
expect(error.message).not.toContain("Invalid device: SIM-1");
expect(error._tag === "DeviceOperationError" && error.cause).toMatchObject({
stderr: "Invalid device: SIM-1",
});
}),
);

it.effect("wraps a bare push string in an APNs alert and feeds it on stdin", () =>
Effect.gen(function* () {
const { ready, calls } = makeReady();
yield* runDeviceAction(ready, "ios", {
type: "sendPush",
deviceId: udid,
appId: "com.example.app",
payload: "Hello",
});
expect(calls[0]?.args).toEqual(["simctl", "push", udid, "com.example.app", "-"]);
expect(calls[0]?.stdin).toBe('{"aps":{"alert":"Hello"}}');
}),
);
});

describe("readDeviceDetail", () => {
it.effect("reads iOS settings from simctl and the accessibility helper", () =>
Effect.gen(function* () {
const { ready } = makeReady((call) => {
const key = call.args.join(" ");
if (key.endsWith("ui SIM-1 appearance")) return { stdout: "dark\n" };
if (key.endsWith("ui SIM-1 content_size")) return { stdout: "extra-extra-large\n" };
if (key.endsWith("ui SIM-1 increase_contrast")) return { stdout: "enabled\n" };
if (key.includes("serve-sim-ax-settings status")) {
return {
stdout:
'{"reduce-motion":"on","reduce-transparency":"off","show-borders":"off","voiceover":"off","liquid-glass":"tinted","color-filter":"grayscale"}',
};
}
return { code: 1 };
});
const detail = yield* readDeviceDetail(ready, "ios", udid);
expect(detail.settings).toEqual({
appearance: "dark",
textSize: "large",
increaseContrast: true,
reduceMotion: true,
reduceTransparency: false,
showBorders: false,
voiceOver: false,
liquidGlass: "tinted",
colorFilter: "grayscale",
});
}),
);

it.effect("degrades unreadable values to unknown instead of failing", () =>
Effect.gen(function* () {
const { ready } = makeReady(() => ({ code: 1, stderr: "boom" }));
const detail = yield* readDeviceDetail(ready, "ios", udid);
expect(detail.settings).toEqual({});
expect(detail.foregroundApp).toBeNull();
}),
);

it.effect("reads Android settings and the focused package", () =>
Effect.gen(function* () {
const { ready } = makeReady((call) => {
const key = call.args.join(" ");
if (key.endsWith("cmd uimode night")) return { stdout: "Night mode: yes\n" };
if (key.endsWith("font_scale")) return { stdout: "0.85\n" };
if (key.endsWith("animator_duration_scale")) return { stdout: "0\n" };
if (key.endsWith("wifi_on")) return { stdout: "1\n" };
if (key.endsWith("dumpsys window")) {
return {
stdout:
" mFocusedApp=ActivityRecord{155579877 u0 com.example.app/.MainActivity t15}\n mCurrentFocus=Window{1a2b u0 com.example.app/com.example.app.MainActivity}\n",
};
}
return { code: 1 };
});
const detail = yield* readDeviceDetail(ready, "android", "emulator-5554");
expect(detail.settings).toEqual({
appearance: "dark",
textSize: "small",
reduceMotion: true,
networkEnabled: true,
});
expect(detail.foregroundApp).toEqual({ id: "com.example.app" });
}),
);
});
Loading
Loading