Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
aa5e9d9
feat(devices): first-class iOS Simulator and Android Emulator support
juliusmarminge Sep 8, 2026
173b8c8
fix(devices): proxy hub WebSocket upgrades in dev and scope the state…
juliusmarminge Sep 8, 2026
9b1e2e9
fix(devices): probe decoder support and fall back to MJPEG on iOS
juliusmarminge Sep 8, 2026
a6a204c
fix(devices): give device_list an object schema so providers keep the…
juliusmarminge Sep 8, 2026
89f132a
fix(devices): start the device host when a device-capable session begins
juliusmarminge Sep 8, 2026
afaf7be
fix(devices): reap a device hub left behind by a killed server
juliusmarminge Sep 8, 2026
a87ece2
fix(devices): show input connectivity and disable controls while the …
juliusmarminge Sep 8, 2026
8b045a9
fix(devices): prime the iOS helper before opening the input socket an…
juliusmarminge Sep 8, 2026
28669ac
fix(devices): keep the device aspect ratio and shrink height when the…
juliusmarminge Sep 8, 2026
db49a5e
feat(devices): add a Tools drawer for simulator and emulator settings
juliusmarminge Sep 8, 2026
9a4e391
fix(devices): flatten the nested iOS accessibility tree for the overlay
juliusmarminge Sep 8, 2026
f36f421
fix(devices): read the Android foreground app from the unfiltered win…
juliusmarminge Sep 8, 2026
0e51f87
fix(devices): rebuild the Android decoder when the emulator rotates
juliusmarminge Sep 8, 2026
fe89109
fix(devices): rotate Android emulators through the accelerometer
juliusmarminge Sep 8, 2026
b743d72
fix(devices): only overlay pointable Android accessibility nodes
juliusmarminge Sep 8, 2026
a7feca5
fix(devices): preserve typed errors through screenshot registration
juliusmarminge Sep 8, 2026
ca5f8a0
fix(devices): preserve snapshot handling after main rebase
juliusmarminge Sep 8, 2026
d3039fd
fix(devices): keep implementation-only symbols private
juliusmarminge Sep 8, 2026
ff148f9
feat(devices): add consent-based device onboarding and setup
juliusmarminge Sep 8, 2026
1841952
refactor(devices): compose onboarding with shared wizard layout
juliusmarminge Sep 8, 2026
a47370f
fix(devices): close streams and recover device discovery reliably
juliusmarminge Sep 8, 2026
7c637d2
fix(devices): pass CLI access through all local providers
juliusmarminge Sep 8, 2026
e9d881a
fix(devices): show device launch and shutdown failures
juliusmarminge Sep 8, 2026
ab733a2
refactor(devices): expose host dependency and normalize setup failures
juliusmarminge Sep 8, 2026
7ecbdbd
refactor(devices): compose the local host at the service layer
juliusmarminge Sep 8, 2026
064f3c9
fix(devices): normalize public failures and preserve error causes
juliusmarminge Sep 8, 2026
2b221af
fix(devices): preserve shutdown success when discovery is unavailable
juliusmarminge Sep 8, 2026
834988b
refactor(devices): keep local host construction and layer together
juliusmarminge Sep 8, 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
12 changes: 9 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";
type WorkContentIcon = AppSymbolName | "browser" | "device" | "t3-code";

function WorkLogIcon(props: {
readonly icon: WorkContentIcon;
Expand All @@ -92,7 +92,13 @@ function WorkLogIcon(props: {
}
return (
<SymbolView
name={props.icon === "browser" ? { ios: "globe", android: "public" } : props.icon}
name={
props.icon === "browser"
? { ios: "globe", android: "public" }
: props.icon === "device"
? { ios: "iphone", android: "smartphone" }
: props.icon
}
size={14}
weight="medium"
{...(colorClassName ? { tintColorClassName: colorClassName } : { tintColor: props.color })}
Expand Down Expand Up @@ -893,7 +899,7 @@ export function ThreadWorkGroupToggle(props: {
readonly iconSubtleColor: import("react-native").ColorValue;
readonly summary: string;
readonly summaryKind: ToolGroupSummaryKind;
readonly summaryToolIcon?: "browser" | "t3-code";
readonly summaryToolIcon?: "browser" | "device" | "t3-code";
readonly themeAppearance: "light" | "dark";
readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
readonly toolIcon?: ToolActivityIcon;
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";
readonly summaryToolIcon?: "browser" | "device" | "t3-code";
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 @@ -142,6 +142,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