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
46 changes: 46 additions & 0 deletions .github/releases/v0.3.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
0.3.0 keeps the Device panel usable while a device starts. The platform buttons, device picker, and refresh stay available during discovery and boot. If a device is already attached, you can still use its screen and revoke agent access while another starts. Choosing another target cancels the pending attachment; a failed switch keeps the previous device.

In 0.2.0, Android could show a blank panel without an error. The hub announced that its HTTP listener was ready before it started capture. Its default capture path then tried to launch ffmpeg, which could fail with `spawn ffmpeg ENOENT`. The viewer treated the closed video connection as a pause and showed no error. Android now explicitly uses scrcpy and the device's H.264 encoder, so it no longer needs ffmpeg on the desktop PATH. Startup checks capture readiness, and connection, decoder, and drawing failures appear in the panel. A missing first frame, including after a video reset, fails after 15 seconds instead of waiting indefinitely.

Switching also used to wait for the old attachment to finish its work and close its helpers before showing the new device. Now the new attachment becomes active when it is ready, and the old one cleans up in the background. Already-dispatched work stays bound to the old device; queued input is rejected rather than replayed on the new one. Ownership stays reserved until cleanup completes, so another session cannot take a device that is still being released.

The video path does less work per frame. H.264 packets now share one inspection for the keyframe marker and codec instead of separate scans. A keyframe that begins with an SPS stops inspection at its header. MJPEG parsing returns byte views instead of copying every JPEG, and the viewer decodes only the newest complete frame in each received chunk. If a chunk contains several frames, the older ones no longer cost a bitmap decode and canvas draw.

A local macOS capture-only check against already-running Android emulators and iOS Simulators produced first frames in about 0.8 to 1.9 seconds. This excludes boot, helper downloads, and automation-session setup. It is not a before-and-after benchmark of full device switching, and stopped devices still need time to boot.

## Changed

- **Panel:** Android and iOS have separate platform and device pickers. Each session owns its attachment; tabs and windows showing the same session share that device and its access setting. Android and iOS can run side by side in separate sessions.
- **Startup:** Device selections use cancellation and latest-selection-wins handling. New targets do not wait for cancelled boots on different devices. A return to the same target waits for the old startup or cleanup to release ownership.
- **Discovery:** Android resolves running AVD names in batches of four instead of one at a time. Cancelled refreshes stop their native commands rather than leaving discovery processes running.
- **Video:** Reduce H.264 scans and MJPEG copies, skip outdated frames, and keep the current screen visible while the next device prepares. No extra warm capture processes are kept solely to make switching appear instant.
- **Process output:** Retain up to 4 MiB per command output stream as byte chunks and decode once at completion. This avoids repeatedly copying large strings and preserves UTF-8 across chunk boundaries.

## Fixed

- **Android:** Fix the silent blank screen when ffmpeg is unavailable by selecting scrcpy explicitly and checking the capture session before reporting startup success.
- **Viewer:** Report unexpected connection closure, unsupported codecs, decoder setup errors, and unavailable canvases. Refresh can retry a failed viewer without changing the attachment.
- **Cancellation:** Terminate cancelled command processes and interrupt boot-status and screen-readiness waits. Rollback shuts down only devices started by that operation, never a pre-existing emulator or simulator.
- **Pipes and streams:** Drain unused helper stdout and stderr so full pipes cannot stall a child. Cancel MJPEG readers and upstream fetches on failure or closure, and release their tracking entries.
- **Input:** Keep manual tap and swipe tied to the current rendered frame and native stream capability, rather than depending on the automation engine's capability report.
- **Privacy:** Create private session directories, remove screenshot files on failure as well as success, reject oversized screenshots before reading them, and avoid an extra full-size copy of screenshot bytes.
- **Handles:** Use one identity check for token, generation, platform, and device ID across the controller, registry, runtime, and server. Forged metadata cannot release a valid attachment.
- **Stream proxy:** Redact hub credentials from forwarded text, cap upstream WebSocket messages, and reject redirects from the fixed video route.
- **Development:** Replace the recursive TypeScript shim that exited successfully without checking code with the real pinned TypeScript 6.0.2 compiler. Consolidate overlapping tests while retaining cancellation, ownership, and resource-lifecycle coverage.

## Update

Update the plugin pin in your global OpenCode configuration, preserving your other settings:

```json
{
"$schema": "https://opencode.ai/config.json",
"plugins": ["github:viralcodex/opencode-demul#v0.3.0"]
}
```

The published plugin exports and tool names are unchanged. Android still needs SDK Platform-Tools, Emulator, and a configured AVD. iOS still needs Xcode and an iOS Simulator runtime on macOS. Both need a supported Node installation with npm; ffmpeg is no longer required for Android video.

Validation includes lint, formatting, the real typecheck, 192 passing tests, bundle generation, and installed-package verification.

Full changelog: https://github.com/viralcodex/opencode-demul/compare/v0.2.0...v0.3.0
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ jobs:
assert.match(version, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/);
const manifest = JSON.parse(readFileSync('package.json', 'utf8'));
assert.equal(version, manifest.version, 'Commit the package version before releasing.');
assert.ok(readFileSync(`.github/releases/v${version}.md`, 'utf8').trim(), 'Commit nonempty release notes before releasing.');
appendFileSync(process.env.GITHUB_OUTPUT, `tag=v${version}\n`);
NODE
- uses: actions/github-script@v7
Expand Down Expand Up @@ -80,6 +81,10 @@ jobs:
actions: read
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/download-artifact@v4
with:
name: release-package
Expand All @@ -92,7 +97,7 @@ jobs:
RELEASE_COMMIT: ${{ github.sha }}
PRERELEASE: ${{ inputs.prerelease }}
run: |
args=(--target "$RELEASE_COMMIT" --title "$RELEASE_TAG" --generate-notes)
args=(--target "$RELEASE_COMMIT" --title "$RELEASE_TAG" --notes-file ".github/releases/$RELEASE_TAG.md")
if [ "$PRERELEASE" = true ]; then
args+=(--prerelease)
fi
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ simulators on macOS.
- OpenCode Desktop compatible with plugin SDK 2.2.2, using a local server.
- Node 22.19+ on the 22.x line, or Node 24+, with npm.
- Android SDK Platform-Tools and Emulator, a configured Android Virtual Device, and hardware virtualization enabled.
- ffmpeg with libx264 on the desktop process's PATH.
- For iOS (macOS only): Xcode with an iOS Simulator runtime, selected with `xcode-select`.

Set `ANDROID_HOME` or `ANDROID_SDK_ROOT` if your SDK is outside the standard Android Studio location.
Expand All @@ -28,9 +27,9 @@ Add this entry to your OpenCode configuration.
}
```

Restart OpenCode Desktop, then open **Add tab > Device** in a session.
Open **Add tab > Device** in a session.
First use downloads the pinned device helpers from npm.
Android SDK tools, Node and ffmpeg must already be installed.
Android SDK tools and Node must already be installed. Android video uses the device's H.264 encoder through scrcpy.

## Use

Expand All @@ -39,10 +38,13 @@ and starts it if needed. Use the platform and device pickers to switch between A
emulators and iOS simulators, and refresh to rescan. Either platform's toolchain may be
missing; the picker lists what the host can run.

Discovery and startup run in the background. The controls remain available while a device
boots; selecting another device cancels the pending attachment.

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
without detaching the device, so agents can keep working. Use `device.close` to
release it; deleting or moving the session also releases it. A failed device switch keeps the
previous attachment.

Expand All @@ -58,6 +60,8 @@ The first iOS automation command builds the agent's XCTest runner, which can tak

Prebuilt plugin files are included.

[Release notes for v0.3.0](https://github.com/viralcodex/opencode-demul/releases/tag/v0.3.0).

## Development

Use Bun 1.3.6 and a supported Node version to build and test it.
Expand Down
6 changes: 2 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 18 additions & 19 deletions dist/gui.cjs

Large diffs are not rendered by default.

49 changes: 13 additions & 36 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { Schema as Schema2 } from "effect";
// src/rpc.ts
var exports_rpc = {};
__export(exports_rpc, {
sameHandle: () => sameHandle,
StreamProtocol: () => StreamProtocol,
StreamDescriptor: () => StreamDescriptor,
SnapshotReference: () => SnapshotReference,
Expand Down Expand Up @@ -206,6 +207,9 @@ var DeviceErrorCode = Schema.Literals([
"helper-failed",
"internal"
]).annotate({ identifier: "Device.DeviceErrorCode" });
function sameHandle(expected, actual) {
return expected.token === actual.token && expected.generation === actual.generation && expected.platform === actual.platform && expected.deviceId === actual.deviceId;
}
var DeviceError = Schema.Struct({
code: DeviceErrorCode,
message: short,
Expand Down Expand Up @@ -356,6 +360,7 @@ var ControlRpc = Rpc.define({
});

// src/server.ts
var unavailable = (call, code, message) => call.error("unavailable", message, { code, message, mayHaveExecuted: false });
var make = Effect.fn("DeviceServer.make")(function* (ctx) {
const attachments = new Map;
let active = true;
Expand All @@ -374,30 +379,14 @@ var make = Effect.fn("DeviceServer.make")(function* (ctx) {
attach: (input, call) => Effect.gen(function* () {
const decoded = Schema3.decodeUnknownOption(Session.ID)(input.sessionID);
if (Option.isNone(decoded))
return yield* Effect.fail(call.error("unavailable", "Invalid session id.", {
code: "not-found",
message: "Invalid session id.",
mayHaveExecuted: false
}));
return yield* Effect.fail(unavailable(call, "not-found", "Invalid session id."));
const sessionID2 = decoded.value;
const session = yield* ctx.session.get({ sessionID: sessionID2 }).pipe(Effect.mapError(() => call.error("unavailable", "Session not found.", {
code: "not-found",
message: "Session not found.",
mayHaveExecuted: false
})));
const session = yield* ctx.session.get({ sessionID: sessionID2 }).pipe(Effect.mapError(() => unavailable(call, "not-found", "Session not found.")));
if (session.location.directory !== ctx.location.directory)
return yield* Effect.fail(call.error("unavailable", "Session belongs to another location.", {
code: "not-found",
message: "Session belongs to another location.",
mayHaveExecuted: false
}));
return yield* Effect.fail(unavailable(call, "not-found", "Session belongs to another location."));
const attachment = yield* Effect.acquireRelease(Effect.gen(function* () {
if (!active)
return yield* Effect.fail(call.error("unavailable", "Device server is shutting down.", {
code: "internal",
message: "Device server is shutting down.",
mayHaveExecuted: false
}));
return yield* Effect.fail(unavailable(call, "internal", "Device server is shutting down."));
yield* close(input.sessionID, "replaced");
const attachment2 = {
connectionID: input.connectionID,
Expand All @@ -415,33 +404,21 @@ var make = Effect.fn("DeviceServer.make")(function* (ctx) {
state: (input, call) => Effect.gen(function* () {
const attachment = attachments.get(input.sessionID);
if (!attachment || attachment.connectionID !== input.connectionID)
return yield* Effect.fail(call.error("unavailable", "Device connection is unavailable.", {
code: "detached",
message: "Device connection is unavailable.",
mayHaveExecuted: false
}));
return yield* Effect.fail(unavailable(call, "detached", "Device connection is unavailable."));
attachment.state = input.state;
}),
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 || 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
}));
return yield* Effect.fail(unavailable(call, "detached", "Device request is no longer available."));
pending.claimed = true;
return pending.operation;
}),
result: (input, call) => Effect.gen(function* () {
const attachment = attachments.get(input.sessionID);
if (!attachment || attachment.connectionID !== input.connectionID)
return yield* Effect.fail(call.error("unavailable", "Device connection is unavailable.", {
code: "detached",
message: "Device connection is unavailable.",
mayHaveExecuted: false
}));
return yield* Effect.fail(unavailable(call, "detached", "Device connection is unavailable."));
const pending = attachment.pending.get(input.requestID);
if (!pending)
return;
Expand Down Expand Up @@ -497,7 +474,7 @@ var make = Effect.fn("DeviceServer.make")(function* (ctx) {
});
return { type: "attachment", attachment: device };
}
if (device.handle.token !== operation.handle.token || device.handle.generation !== operation.handle.generation)
if (!exports_rpc.sameHandle(device.handle, operation.handle))
return yield* new Tool.Error({
message: "[device.detached] This device handle no longer matches the attached device. Call the state tool to read the current handle, then retry with it."
});
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"fmt": "oxfmt --write --ignore-path .oxfmtignore .",
"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 --noEmit -p tsconfig.test.json",
"test": "bun test",
"check": "bun run lint && bun run fmt:check && bun run typecheck && bun test",
"verify:package": "node script/verify-package.mjs",
Expand All @@ -61,7 +61,7 @@
"oxfmt": "0.75.0",
"oxlint": "1.90.0",
"solid-js": "1.9.17",
"typescript": "npm:@typescript/typescript6@6.0.2",
"typescript": "6.0.2",
"vite": "7.3.7",
"vite-plugin-solid": "2.11.10"
},
Expand Down
Loading
Loading