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
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Add this entry to your OpenCode configuration.

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

Expand All @@ -34,9 +34,17 @@ Android SDK tools, Node and ffmpeg must already be installed.

## Use

The panel opens the first running device, or else the first listed one, and starts it if
needed. Use the picker to switch between Android emulators and iOS simulators and refresh to
rescan. Either platform's toolchain may be missing; the picker lists what the host can run.
The panel opens the first available running device, or else the first available listed one,
and starts it if needed. Use the platform and device pickers to switch between Android
emulators and iOS simulators, and refresh to rescan. Either platform's toolchain may be
missing; the picker lists what the host can run.

Each session controls its own device. Run iOS and Android side by side in separate sessions;
a device already controlled by another session cannot be attached. Tabs and windows showing
the same session share its device and agent-access setting. Closing a viewer stops its video
without detaching the device, so agents can keep working. Detach the device in the panel to
release it; deleting or moving the session also releases it. A failed device switch keeps the
previous attachment.

Agent access starts enabled. Use the switch to revoke access for the current
session. Agents can only use a device you have opened.
Expand Down
38 changes: 19 additions & 19 deletions dist/gui.cjs

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ var Scope = Schema2.Struct({
});
var Attachment = Schema2.Struct({
handle: exports_rpc.DeviceHandle,
selection: Schema2.optionalKey(Schema2.Struct({ platform: exports_rpc.Platform, deviceId: short2 })),
capabilities: exports_rpc.Capabilities,
agentAccess: Schema2.Boolean
}).annotate({ identifier: "DeviceBridge.Attachment" });
Expand Down Expand Up @@ -424,12 +425,13 @@ var make = Effect.fn("DeviceServer.make")(function* (ctx) {
command: (input, call) => Effect.gen(function* () {
const attachment = attachments.get(input.sessionID);
const pending = attachment?.connectionID === input.connectionID ? attachment.pending.get(input.requestID) : undefined;
if (!pending)
if (!pending || pending.claimed)
return yield* Effect.fail(call.error("unavailable", "Device request is no longer available.", {
code: "detached",
message: "Device request is no longer available.",
mayHaveExecuted: false
}));
pending.claimed = true;
return pending.operation;
}),
result: (input, call) => Effect.gen(function* () {
Expand All @@ -454,13 +456,17 @@ var make = Effect.fn("DeviceServer.make")(function* (ctx) {
const forward = Effect.fn("DeviceServer.forward")(function* (attachment, operation) {
const requestID = crypto.randomUUID();
const pending = yield* Deferred.make();
attachment.pending.set(requestID, { operation, result: pending });
attachment.pending.set(requestID, { operation, result: pending, claimed: false });
return yield* rpc.events.emit("control", { type: "command", connectionID: attachment.connectionID, requestID }).pipe(Effect.mapError((error) => new Tool.Error({
message: `Could not dispatch device.${operation.type}. Check the desktop connection, then decide whether to retry.`,
error
})), Effect.andThen(Deferred.await(pending)), Effect.raceFirst(Deferred.await(attachment.closed).pipe(Effect.andThen(new Tool.Error({
message: "[device.disconnected] The device connection closed; the action may already have run. Reconnect this session in the desktop app and re-read device state before repeating a mutating action."
})))), Effect.onInterrupt(() => rpc.events.emit("control", { type: "cancel", connectionID: attachment.connectionID, requestID }).pipe(Effect.ignore)), Effect.timeoutOrElse({
})))), Effect.onInterrupt(() => Effect.sync(() => attachment.pending.delete(requestID)).pipe(Effect.andThen(rpc.events.emit("control", {
type: "cancel",
connectionID: attachment.connectionID,
requestID
})), Effect.ignore)), Effect.timeoutOrElse({
duration: "60 seconds",
orElse: () => new Tool.Error({
message: `[device.timeout] device.${operation.type} did not finish within 60 seconds; its outcome is unknown. Check the desktop connection and re-read device state before repeating a mutating action.`
Expand Down
2 changes: 1 addition & 1 deletion 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.2.0",
"version": "0.3.0",
"description": "Local Android emulator and iOS simulator viewing and session-bound automation for OpenCode Desktop",
"keywords": [
"android",
Expand Down
1 change: 1 addition & 0 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type Scope = typeof Scope.Type;

export const Attachment = Schema.Struct({
handle: Device.DeviceHandle,
selection: Schema.optionalKey(Schema.Struct({ platform: Device.Platform, deviceId: short })),
capabilities: Device.Capabilities,
agentAccess: Schema.Boolean,
}).annotate({ identifier: "DeviceBridge.Attachment" });
Expand Down
81 changes: 46 additions & 35 deletions src/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,26 +219,21 @@ test("handles are bound to both server and session and agents cannot attach a di
).rejects.toMatchObject({ code: "permission-denied" });
await expect(
item.controller.open(caller, { ...selection, deviceId: "other" }, "agent"),
).rejects.toMatchObject({ code: "device-busy" });
).rejects.toMatchObject({ code: "permission-denied" });
expect(item.calls).toEqual(["open"]);
await item.controller.dispose();
});

test("concurrent sessions share one physical device with separate handles and access grants", async () => {
test("concurrent sessions cannot control the same physical device", async () => {
const item = fixture();
const other = { ...caller, sessionID: "session-b" };
const opened = await Promise.all([
const opened = await Promise.allSettled([
item.controller.open(caller, selection, "manual"),
item.controller.open(other, selection, "manual"),
]);
expect(opened[0].handle.token).not.toBe(opened[1].handle.token);
expect(item.controller.state(caller)?.handle).toEqual(opened[0].handle);
expect(item.controller.state(other)?.handle).toEqual(opened[1].handle);
item.controller.access(caller, false);
expect(item.controller.state(other)?.agentAccess).toBe(true);
await expect(
item.controller.run(other, { type: "screenshot", handle: opened[0].handle }, "agent"),
).rejects.toMatchObject({ code: "permission-denied" });
expect(opened[0].status).toBe("fulfilled");
expect(opened[1]).toMatchObject({ status: "rejected", reason: { code: "device-busy" } });
expect(item.controller.state(other)).toBeNull();
expect(item.calls).toEqual(["open"]);
await item.controller.dispose();
});
Expand Down Expand Up @@ -270,35 +265,43 @@ test("revoking agent access rejects a queued action without replaying it", async
await item.controller.dispose();
});

test("closing one session preserves the shared device until the last session detaches", async () => {
test("closing one session preserves another session's device", async () => {
const item = fixture();
const other = { ...caller, sessionID: "session-b" };
const first = await item.controller.open(caller, selection, "manual");
const second = await item.controller.open(other, selection, "manual");
const second = await item.controller.open(
other,
{ platform: "ios", deviceId: "iphone" },
"manual",
);
await item.controller.close(caller, { handle: first.handle }, "manual");
expect(item.controller.state(caller)).toBeNull();
expect(item.controller.state(other)?.handle).toEqual(second.handle);
expect(item.calls).toEqual(["open"]);
expect(item.calls).toEqual(["open", "open", "close"]);
expect(await item.controller.stream(other, second.handle)).toMatchObject({
deviceId: selection.deviceId,
deviceId: "iphone",
});
expect(
await item.controller.run(other, { type: "apps", handle: second.handle }, "agent"),
).toEqual({ type: "apps", apps: [] });
const reopened = await item.controller.open(caller, selection, "manual");
expect(reopened.handle.token).not.toBe(first.handle.token);
await item.controller.close(other, { handle: second.handle }, "manual");
expect(item.calls).toEqual(["open", "apps"]);
expect(item.calls).toEqual(["open", "open", "close", "apps", "open", "close"]);
await item.controller.close(caller, { handle: reopened.handle }, "manual");
expect(item.calls).toEqual(["open", "apps", "close"]);
expect(item.calls).toEqual(["open", "open", "close", "apps", "open", "close", "close"]);
await item.controller.dispose();
});

test("actions from different sessions share the device queue without sharing access grants", async () => {
test("actions on different session devices execute independently", async () => {
const item = fixture();
const other = { ...caller, sessionID: "session-b" };
const first = await item.controller.open(caller, selection, "manual");
const second = await item.controller.open(other, selection, "manual");
const second = await item.controller.open(
other,
{ platform: "ios", deviceId: "iphone" },
"manual",
);
const running = item.controller.run(
caller,
{ type: "key", handle: first.handle, key: "home" },
Expand All @@ -307,19 +310,23 @@ test("actions from different sessions share the device queue without sharing acc
await item.started.promise;
const queued = item.controller.run(other, { type: "apps", handle: second.handle }, "agent");
item.controller.access(caller, false);
expect(item.calls).toEqual(["open", "key"]);
expect(await queued).toEqual({ type: "apps", apps: [] });
expect(item.calls).toEqual(["open", "open", "key", "apps"]);
item.release.resolve();
await running;
expect(await queued).toEqual({ type: "apps", apps: [] });
expect(item.calls).toEqual(["open", "key", "apps"]);
expect(item.calls).toEqual(["open", "open", "key", "apps"]);
await item.controller.dispose();
});

test("shared snapshots keep session references isolated and invalidate after another session interacts", async () => {
test("snapshots remain isolated when another session interacts with its device", async () => {
const item = fixture();
const other = { ...caller, sessionID: "session-b" };
const first = await item.controller.open(caller, selection, "manual");
const second = await item.controller.open(other, selection, "manual");
const second = await item.controller.open(
other,
{ platform: "ios", deviceId: "iphone" },
"manual",
);
const value = await item.controller.run(
other,
{ type: "snapshot", handle: second.handle },
Expand Down Expand Up @@ -351,8 +358,8 @@ test("shared snapshots keep session references isolated and invalidate after ano
{ type: "tap", handle: first.handle, point: { x: 1, y: 1 } },
"manual",
);
await expect(
item.controller.run(
expect(
await item.controller.run(
other,
{
type: "press",
Expand All @@ -361,23 +368,27 @@ test("shared snapshots keep session references isolated and invalidate after ano
},
"agent",
),
).rejects.toMatchObject({ code: "stale-reference" });
).toEqual({ type: "done" });
await item.controller.dispose();
});

test("shutdown cannot close a device still attached by another session", async () => {
test("shutdown cannot target another session's device", async () => {
const item = fixture();
const opened = await item.controller.open(caller, selection, "manual");
const other = { ...caller, sessionID: "session-b" };
const shared = await item.controller.open(other, selection, "manual");
const separate = await item.controller.open(
other,
{ platform: "ios", deviceId: "iphone" },
"manual",
);
await expect(
item.controller.close(caller, { handle: opened.handle, shutdown: true }, "manual"),
).rejects.toMatchObject({ code: "device-busy" });
expect(item.controller.state(other)?.handle).toEqual(shared.handle);
expect(item.calls).toEqual(["open"]);
await item.controller.close(other, { handle: shared.handle }, "manual");
item.controller.close(caller, { handle: separate.handle, shutdown: true }, "manual"),
).rejects.toMatchObject({ code: "permission-denied" });
expect(item.controller.state(other)?.handle).toEqual(separate.handle);
expect(item.calls).toEqual(["open", "open"]);
await item.controller.close(other, { handle: separate.handle }, "manual");
await item.controller.close(caller, { handle: opened.handle, shutdown: true }, "manual");
expect(item.calls).toEqual(["open", "close"]);
expect(item.calls).toEqual(["open", "open", "close", "close"]);
await item.controller.dispose();
});

Expand Down
Loading
Loading