Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ macOS.
- Android SDK Platform-Tools and Emulator, a configured Android Virtual Device, and hardware virtualization enabled.
- ffmpeg with libx264 on the desktop process's PATH.

Set `ANDROID_HOME` or `ANDROID_SDK_ROOT` if your SDK is outside the standard Android Studio location.
Set `ANDROID_HOME` or `ANDROID_SDK_ROOT` if your SDK is outside the standard Android Studio location.
Set `OPENCODE_DEVICE_NODE` to your Node executable if the desktop cannot find it.

## Install
Expand All @@ -23,7 +23,7 @@ Add this entry to your OpenCode configuration.

```json
{
"plugins": ["github:viralcodex/opencode-demul#v0.1.3"]
"plugins": ["github:viralcodex/opencode-demul#v0.1.4"]
}
```

Expand All @@ -37,7 +37,7 @@ The panel opens the first configured emulator and starts it if needed. Use the
picker to switch devices and refresh to rescan.

Agent access starts enabled. Use the switch to revoke access for the current
session. Agents can only use a device you have opened.
session. Agents can only use a device you have opened.
Installing an APK or shutting down an emulator requires a separate permission prompt.

Tools use `device_*` names, or `device.*` in Code Mode. They can inspect the
Expand All @@ -59,5 +59,5 @@ bun run bundle
bun run verify:package
```

MIT license.
MIT license.
Dependency licenses are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
30 changes: 15 additions & 15 deletions dist/gui.cjs

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-device",
"version": "0.1.3",
"version": "0.1.4",
"description": "Local Android emulator viewing and session-bound automation for OpenCode Desktop on Windows, Linux, and macOS",
"keywords": [
"android",
Expand Down Expand Up @@ -37,7 +37,6 @@
"fmt:check": "oxfmt --check --ignore-path .oxfmtignore .",
"bundle": "node node_modules/vite/bin/vite.js build --configLoader runner && bun build src/index.ts --target=node --format=esm --packages=external --outfile=dist/server.js",
"typecheck": "tsc6 --noEmit -p tsconfig.test.json",
"typecheck:tsc": "bun run typecheck",
"test": "bun test",
"check": "bun run lint && bun run fmt:check && bun run typecheck && bun test",
"verify:package": "node script/verify-package.mjs",
Expand Down
12 changes: 6 additions & 6 deletions src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ test("main is lazy, mints connection identity, confines stream tokens to IPC, an
if (input.sessionID === sessionID) expect(options?.location).toEqual(location);
attachment = input;
queueMicrotask(() =>
control?.({ type: "attached", connectionID: input.connectionID, version: 1 }),
control({ type: "attached", connectionID: input.connectionID, version: 1 }),
);
return new Promise((resolve) =>
options?.signal?.addEventListener("abort", () => resolve("closed"), { once: true }),
Expand Down Expand Up @@ -280,7 +280,7 @@ test("main is lazy, mints connection identity, confines stream tokens to IPC, an

await ipc.access({ scope, enabled: false }, { window: 7, signal: new AbortController().signal });
operations.set("denied", { type: "apps", handle: opened.value.attachment.handle });
control?.({ type: "command", connectionID, requestID: "denied" });
control({ type: "command", connectionID, requestID: "denied" });
await eventually(() => outcomes.has("denied"));
expect(outcomes.get("denied")).toMatchObject({
type: "failure",
Expand All @@ -290,14 +290,14 @@ test("main is lazy, mints connection identity, confines stream tokens to IPC, an

await ipc.access({ scope, enabled: true }, { window: 7, signal: new AbortController().signal });
operations.set("apps", { type: "apps", handle: opened.value.attachment.handle });
control?.({ type: "command", connectionID, requestID: "apps" });
control?.({ type: "command", connectionID, requestID: "apps" });
control({ type: "command", connectionID, requestID: "apps" });
control({ type: "command", connectionID, requestID: "apps" });
await eventually(() => outcomes.has("apps"));
expect(commandFetches.filter((requestID) => requestID === "apps")).toHaveLength(1);

operations.set("cancelled", { type: "apps", handle: opened.value.attachment.handle });
control?.({ type: "cancel", connectionID, requestID: "cancelled" });
control?.({ type: "command", connectionID, requestID: "cancelled" });
control({ type: "cancel", connectionID, requestID: "cancelled" });
control({ type: "command", connectionID, requestID: "cancelled" });
await Promise.resolve();
expect(commandFetches).not.toContain("cancelled");

Expand Down
5 changes: 1 addition & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,7 @@ export function createMainSetup(
"agent",
AbortSignal.any([connection.abort.signal, request.signal]),
),
(error) =>
deviceOutcome(() => {
throw error;
}),
(error): DeviceBridge.Outcome => ({ type: "failure", error: failure(error) }),
);
if (connection.closed || request.signal.aborted) return;
await rpc.result(
Expand Down
2 changes: 1 addition & 1 deletion src/native/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function androidHelperEnv(
}

// Find the adb serial of a running emulator whose reported AVD name matches, or null when none is running.
export async function resolveRunningSerial(
async function resolveRunningSerial(
options: AndroidOptions,
avdName: string,
run = runCommand,
Expand Down
52 changes: 2 additions & 50 deletions src/native/discovery.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { Option, Schema } from "effect";
import { Device } from "../rpc.js";
import type { Device } from "../rpc.js";
import { deviceError } from "./errors.js";
import { parseJson } from "./json.js";
import { runCommand } from "./process.js";
import { androidToolPath } from "./android.js";

// Device discovery. Listing reads the SDK tools directly — `adb` and `emulator` for Android, `simctl` for iOS — so it
// reports available devices, their boot state, and ownership without booting anything or starting the hub. Boot and
// attachment stay explicit, in `open`.
// Listing reads `adb` and `emulator` directly, so it never boots a device or starts the hub.

export interface DiscoveryOptions {
androidSdkRoot?: string;
Expand Down Expand Up @@ -88,47 +84,3 @@ export async function androidDevices(options: DiscoveryOptions): Promise<Device.
};
});
}

const SimctlList = Schema.Struct({
devices: Schema.Record(
Schema.String,
Schema.Array(
Schema.Struct({
udid: Schema.String,
name: Schema.String,
state: Schema.String,
isAvailable: Schema.optional(Schema.Boolean),
}),
),
),
});

// Parse `simctl list devices --json` into iOS discovery entries, dropping unavailable devices.
export function parseSimctlDevices(output: string): Device.DeviceInfo[] {
const parsed = parseJson(SimctlList, output);
if (Option.isNone(parsed)) return [];
return Object.entries(parsed.value.devices).flatMap(([runtime, devices]) =>
devices
.filter((device) => device.isAvailable !== false)
.map((device) => ({
id: device.udid,
name: device.name,
platform: "ios" as const,
osVersion: runtime.split(".").pop(),
state: device.state === "Booted" ? ("booted" as const) : ("shutdown" as const),
ownership: "unknown" as const,
})),
);
}

// List iOS simulators via `simctl`. Returns an empty list when full Xcode, and therefore `simctl`, is absent.
export async function iosDevices(options: DiscoveryOptions): Promise<Device.DeviceInfo[]> {
const timeoutMs = options.timeoutMs ?? 20_000;
const result = await runCommand({
command: "xcrun",
args: ["simctl", "list", "devices", "--json"],
timeoutMs,
});
if (result.code !== 0) return [];
return parseSimctlDevices(result.stdout);
}
7 changes: 2 additions & 5 deletions src/native/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,7 @@ export async function ensureHelpers(
};
const result = await runCommand({ command, args, timeoutMs, cwd: projectDir, env });
if (result.code !== 0) {
const reason =
result.spawnError ??
result.stderr.split("\n").slice(-3).join(" ").trim() ??
"npm install failed";
const reason = result.spawnError ?? result.stderr.split("\n").slice(-3).join(" ").trim();
throw deviceError(
"missing-prerequisite",
`Installing the device helpers failed: ${reason || "npm install failed"}`,
Expand Down Expand Up @@ -198,7 +195,7 @@ export async function resolveExternalNode(candidate?: string, timeoutMs = 10_000
if (installed[0]) return resolveExternalNode(installed[0], timeoutMs);
}
if (result.code !== 0 || !version) {
const reason = result.spawnError ?? result.stderr.trim() ?? "not found";
const reason = result.spawnError ?? result.stderr.trim();
throw deviceError(
"missing-prerequisite",
`External Node runtime "${node}" is unavailable: ${reason || "not found"}`,
Expand Down
25 changes: 4 additions & 21 deletions src/native/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,8 @@ export interface HubReady {
// The loopback host the hub binds to. The stream never leaves the machine.
export const LOOPBACK_HOST = "127.0.0.1";

// The wire protocol each backend's video stream actually serves, which is what the viewer must decode. The two backends
// differ and the hub's `--transport` flag (mjpeg|h264|webrtc, default mjpeg) is only a dashboard preference — it does
// not change the codec a direct stream connection receives:
//
// - Android (the vendored `serve-emu`) always serves **H.264** over its video WebSocket. Its only transports are
// `websocket` (H.264) and `webrtc` (H.264); it has no MJPEG path. Frames are raw Annex-B (each starts with the
// `00 00 00 01` start code; a keyframe opens with an SPS NAL `0x67`), or, when the socket is opened with
// `frame-meta=1`, each frame is prefixed with the `SEMU` (`0x53454d55`) metadata header carrying the keyframe flag.
// - iOS (the vendored `serve-sim`) serves **MJPEG** (`multipart/x-mixed-replace` with `image/jpeg` parts). This is read
// from the vendored implementation; it is not verified against a live simulator here.
//
// The descriptor advertises this per-platform codec so the viewer decodes what the backend sends, and the hub's own
// `--transport` preference is pinned to the same value so the dashboard never disagrees with the direct stream.
export function streamProtocolFor(platform: Device.Platform): Device.StreamProtocol {
// Android's serve-emu always streams H.264 and iOS's serve-sim streams MJPEG, whatever `--transport` says.
function streamProtocolFor(platform: Device.Platform): Device.StreamProtocol {
return platform === "ios" ? "mjpeg" : "h264";
}

Expand All @@ -91,9 +79,8 @@ export function hubArgs(options: HubOptions): string[] {
// Parse a hub ready line such as `Local: http://localhost:3400/?token=abc` into its loopback origin and token.
export function parseReady(line: string): HubReady | null {
const match = line.match(/https?:\/\/[^\s]*token=[^\s]+/u);
if (!match) return null;
const parsed = toUrl(match[0]);
if (!parsed) return null;
if (!match || !URL.canParse(match[0])) return null;
const parsed = new URL(match[0]);
const token = parsed.searchParams.get("token");
if (!token) return null;
return { baseUrl: parsed.origin, token };
Expand Down Expand Up @@ -175,7 +162,3 @@ export function startHub(options: HubOptions, spawn = spawnProcess): Promise<Hub
);
});
}

function toUrl(value: string): URL | null {
return URL.canParse(value) ? new URL(value) : null;
}
68 changes: 18 additions & 50 deletions src/native/native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
swipeArgs,
tapArgs,
} from "./automation.js";
import { adbState, androidDevices, parseAdbDevices, parseSimctlDevices } from "./discovery.js";
import { adbState, androidDevices, parseAdbDevices } from "./discovery.js";
import { HandleRegistry } from "./handle.js";
import {
installArgs as helperInstallArgs,
Expand Down Expand Up @@ -515,12 +515,15 @@ test.skipIf(process.platform === "win32")(
test("Windows helper installation uses Node and npm CLI without a shell", () => {
const node = "C:\\Program Files\\nodejs\\node.exe";
const cli = "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js";
expect(
npmCliCandidates(node, { Path: "C:\\Users\\test\\AppData\\Roaming\\npm" }, "win32"),
).toContain(cli);
expect(
npmCliCandidates(node, { Path: "C:\\Users\\test\\AppData\\Roaming\\npm" }, "win32"),
).toContain("C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\npm\\bin\\npm-cli.js");
const candidates = npmCliCandidates(
node,
{ Path: "C:\\Users\\test\\AppData\\Roaming\\npm" },
"win32",
);
expect(candidates).toContain(cli);
expect(candidates).toContain(
"C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\npm\\bin\\npm-cli.js",
);
const invocation = helperInstallArgs("C:\\Device Helpers", node, cli);
expect(invocation.command).toBe(node);
expect(invocation.args[0]).toBe(cli);
Expand Down Expand Up @@ -894,44 +897,8 @@ test("parseAdbDevices and adbState read running emulators", () => {
expect(adbState("no device")).toBe("unavailable");
});

test("parseSimctlDevices reads available booted simulators", () => {
const json = JSON.stringify({
devices: {
"com.apple.CoreSimulator.SimRuntime.iOS-17-5": [
{ udid: "UDID-1", name: "iPhone 15", state: "Booted", isAvailable: true },
{ udid: "UDID-2", name: "iPhone SE", state: "Shutdown", isAvailable: false },
],
},
});
const devices = parseSimctlDevices(json);
expect(devices).toHaveLength(1);
expect(devices[0]).toMatchObject({
id: "UDID-1",
name: "iPhone 15",
platform: "ios",
state: "booted",
});
});

test("parseSimctlDevices reports the selected simulator's own state, not any booted device", () => {
// The iOS boot check finds the selected udid in this parse rather than scanning the raw JSON for any "Booted"
// string, so a different booted simulator never stands in for the shutdown device the caller named.
const json = JSON.stringify({
devices: {
"com.apple.CoreSimulator.SimRuntime.iOS-17-5": [
{ udid: "UDID-OTHER", name: "iPhone 15", state: "Booted", isAvailable: true },
{ udid: "UDID-WANTED", name: "iPhone SE", state: "Shutdown", isAvailable: true },
],
},
});
const devices = parseSimctlDevices(json);
expect(devices.find((device) => device.id === "UDID-WANTED")?.state).toBe("shutdown");
expect(devices.find((device) => device.id === "UDID-OTHER")?.state).toBe("booted");
});

test("HandleRegistry mints handles, bumps revisions, and enforces reference validity", () => {
let counter = 0;
const registry = new HandleRegistry(() => `dh_00000000-0000-4000-8000-00000000000${counter++}`);
const registry = sequentialRegistry();
const handle = registry.create("android", "emulator-5554");
expect(handle.generation).toBe(1);
expect(registry.revision(handle)).toBe(0);
Expand All @@ -951,8 +918,7 @@ test("HandleRegistry mints handles, bumps revisions, and enforces reference vali
});

test("HandleRegistry rejects a reference from an older generation", () => {
let counter = 0;
const registry = new HandleRegistry(() => `dh_00000000-0000-4000-8000-00000000000${counter++}`);
const registry = sequentialRegistry();
const first = registry.create("android", "emulator-5554");
registry.nextSnapshot(first);
const second = registry.create("android", "emulator-5554");
Expand All @@ -966,8 +932,7 @@ test("HandleRegistry rejects a reference from an older generation", () => {
});

test("HandleRegistry rejects a reference that belongs to a different handle than the one named", () => {
let counter = 0;
const registry = new HandleRegistry(() => `dh_00000000-0000-4000-8000-00000000000${counter++}`);
const registry = sequentialRegistry();
const deviceA = registry.create("android", "emulator-5554");
const deviceB = registry.create("android", "emulator-5556");
const revision = registry.nextSnapshot(deviceA);
Expand All @@ -977,8 +942,11 @@ test("HandleRegistry rejects a reference that belongs to a different handle than
revision,
ref: ref("@e3"),
};
// Valid against its own device, so a snapshot ref keeps working for the device it came from.
expect(registry.resolveReference(referenceOnA, deviceA)).toBe(ref("@e3"));
// Rejected when dispatched against another device, so an element can never be tapped on the wrong device.
expect(() => registry.resolveReference(referenceOnA, deviceB)).toThrow();
});

function sequentialRegistry() {
let counter = 0;
return new HandleRegistry(() => `dh_00000000-0000-4000-8000-00000000000${counter++}`);
}
4 changes: 2 additions & 2 deletions src/native/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ export interface LongRunningProcess {
release?(): void;
}

export const STOP_GRACE_MS = 2_000;
export const STOP_KILL_MS = 2_000;
const STOP_GRACE_MS = 2_000;
const STOP_KILL_MS = 2_000;
const stopping = new WeakMap<LongRunningProcess, Promise<void>>();

export function stopProcess(
Expand Down
Loading
Loading