diff --git a/.github/releases/v0.3.0.md b/.github/releases/v0.3.0.md new file mode 100644 index 0000000..f83d2a9 --- /dev/null +++ b/.github/releases/v0.3.0.md @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff333ba..5476296 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index e8a2662..e66a96d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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. @@ -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. diff --git a/bun.lock b/bun.lock index 0edb02a..91f0907 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,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", }, @@ -460,8 +460,6 @@ "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], - "@typescript/old": ["@typescript/typescript6@6.0.2", "", { "dependencies": { "@typescript/old": "npm:typescript@^6" }, "bin": { "tsc6": "bin/tsc6" } }, "sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w=="], - "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -810,7 +808,7 @@ "tuf-js": ["tuf-js@4.1.0", "", { "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", "make-fetch-happen": "^15.0.1" } }, "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ=="], - "typescript": ["@typescript/typescript6@6.0.2", "", { "dependencies": { "@typescript/old": "npm:typescript@^6" }, "bin": { "tsc6": "bin/tsc6" } }, "sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w=="], + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], "undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="], diff --git a/dist/gui.cjs b/dist/gui.cjs index 3b10df1..ee878fd 100644 --- a/dist/gui.cjs +++ b/dist/gui.cjs @@ -1,24 +1,23 @@ -"use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const X=require("solid-js/web"),ie=require("solid-js"),yt=require("@opencode/plugin/gui"),bo=require("@opencode/schema/rpc"),u=require("effect"),_o=8*1024*1024,Fe=u.Schema.optional,_e=u.Schema.String.check(u.Schema.isMaxLength(2048)),zr=u.Schema.String.check(u.Schema.isMaxLength(1e5)),bt=u.Schema.Int.check(u.Schema.isGreaterThanOrEqualTo(0)),Vr=u.Schema.Int.check(u.Schema.isBetween({minimum:0,maximum:1e6})),We=u.Schema.Literals(["ios","android"]).annotate({identifier:"Device.Platform"}),ko=u.Schema.Literals(["shutdown","booting","booted","unavailable"]).annotate({identifier:"Device.DeviceState"}),xo=u.Schema.Literals(["available","owned-by-this-host","owned-by-other","unknown"]).annotate({identifier:"Device.Ownership"}),Gr=u.Schema.Literals(["apps","launch","install","snapshot","press","fill","swipe","key","screenshot","stream"]).annotate({identifier:"Device.Capability"}),Eo=u.Schema.Literals(["home","back","recents","lock","volumeUp","volumeDown"]).annotate({identifier:"Device.NavigationKey"}),kn=bt.annotate({identifier:"Device.Generation"}),xn=u.Schema.String.check(u.Schema.isPattern(/^dh_[a-f0-9-]{36}$/u)).pipe(u.Schema.brand("Device.HandleToken")).annotate({identifier:"Device.HandleToken"}),De=u.Schema.Struct({token:xn,platform:We,deviceId:_e,generation:kn}).annotate({identifier:"Device.DeviceHandle"}),Zr=u.Schema.String.check(u.Schema.isPattern(/^@?e[1-9][0-9]*$/u)).pipe(u.Schema.brand("Device.ElementRef")).annotate({identifier:"Device.ElementRef"}),qr=u.Schema.Struct({token:xn,generation:kn,revision:bt,ref:Zr}).annotate({identifier:"Device.SnapshotReference"}),Do=u.Schema.Struct({ref:Fe(Zr),role:_e,name:Fe(zr),value:Fe(zr),enabled:Fe(u.Schema.Boolean),focused:Fe(u.Schema.Boolean)}).annotate({identifier:"Device.AccessibilityNode"}),Lo=u.Schema.Struct({token:xn,generation:kn,revision:bt,interactiveOnly:u.Schema.Boolean,complete:u.Schema.Boolean,nodes:u.Schema.Array(Do)}).annotate({identifier:"Device.Snapshot"}),wn=u.Schema.Struct({x:Vr,y:Vr}).annotate({identifier:"Device.LogicalPoint"}),Io=u.Schema.Struct({id:_e,name:_e,platform:We,osVersion:Fe(_e),state:ko,ownership:xo}).annotate({identifier:"Device.DeviceInfo"}),Oo=u.Schema.Struct({supported:u.Schema.Array(Gr),unsupported:u.Schema.Array(u.Schema.Struct({capability:Gr,reason:_e}))}).annotate({identifier:"Device.Capabilities"}),To=u.Schema.Struct({id:_e,name:Fe(_e)}).annotate({identifier:"Device.AppInfo"}),Co=u.Schema.Struct({app:_e,state:u.Schema.Literals(["launched","foreground","unknown"])}).annotate({identifier:"Device.LaunchResult"}),Po=u.Schema.Struct({installed:u.Schema.Boolean,app:Fe(_e)}).annotate({identifier:"Device.InstallResult"}),Bo=u.Schema.Struct({data:u.Schema.Uint8ArrayFromBase64.check(u.Schema.isMaxLength(_o)),width:bt,height:bt,deviceId:_e,platform:We}).annotate({identifier:"Device.Screenshot"}),Mo=u.Schema.Literals(["mjpeg","h264"]).annotate({identifier:"Device.StreamProtocol"}),No=u.Schema.Struct({platform:We,deviceId:_e,baseUrl:_e,token:_e,protocol:Mo,loopback:u.Schema.Literal(!0)}).annotate({identifier:"Device.StreamDescriptor"}),Uo=u.Schema.Literals(["missing-prerequisite","permission-denied","device-busy","detached","stale-reference","unsupported","not-found","invalid-artifact","timeout","helper-failed","internal"]).annotate({identifier:"Device.DeviceErrorCode"}),En=u.Schema.Struct({code:Uo,message:_e,mayHaveExecuted:u.Schema.Boolean}).annotate({identifier:"Device.DeviceError"}),st=u.Schema.optional,Ue=u.Schema.String.check(u.Schema.isMaxLength(2048)),ei=Ue.check(u.Schema.isMinLength(1)),_n={sessionID:ei,connectionID:u.Schema.String},Hr={..._n,requestID:u.Schema.String},Jr=u.Schema.Number.check(u.Schema.isInt(),u.Schema.isBetween({minimum:1,maximum:1e6})),Kr=u.Schema.optional(u.Schema.Struct({width:Jr,height:Jr})),ot=u.Schema.Struct({sessionID:ei,serverID:Ue,location:u.Schema.optionalKey(u.Schema.Struct({directory:Ue}))}),At=u.Schema.Struct({handle:De,selection:u.Schema.optionalKey(u.Schema.Struct({platform:We,deviceId:Ue})),capabilities:Oo,agentAccess:u.Schema.Boolean}).annotate({identifier:"DeviceBridge.Attachment"}),ti=u.Schema.Union([u.Schema.Struct({type:u.Schema.Literal("list"),platform:st(We)}),u.Schema.Struct({type:u.Schema.Literal("state")}),u.Schema.Struct({type:u.Schema.Literal("open"),platform:We,deviceId:Ue,boot:st(u.Schema.Boolean)}),u.Schema.Struct({type:u.Schema.Literal("apps"),handle:De}),u.Schema.Struct({type:u.Schema.Literal("launch"),handle:De,app:Ue,deepLink:st(Ue),relaunch:st(u.Schema.Boolean)}),u.Schema.Struct({type:u.Schema.Literal("install"),handle:De,artifactPath:Ue}),u.Schema.Struct({type:u.Schema.Literal("snapshot"),handle:De,interactiveOnly:st(u.Schema.Boolean)}),u.Schema.Struct({type:u.Schema.Literal("press"),handle:De,reference:qr}),u.Schema.Struct({type:u.Schema.Literal("tap"),handle:De,point:wn,viewport:Kr}),u.Schema.Struct({type:u.Schema.Literal("fill"),handle:De,reference:qr,text:Ue}),u.Schema.Struct({type:u.Schema.Literal("swipe"),handle:De,from:wn,to:wn,viewport:Kr}),u.Schema.Struct({type:u.Schema.Literal("key"),handle:De,key:Eo}),u.Schema.Struct({type:u.Schema.Literal("screenshot"),handle:De}),u.Schema.Struct({type:u.Schema.Literal("close"),handle:De,shutdown:st(u.Schema.Boolean)})]).pipe(u.Schema.toTaggedUnion("type")).annotate({identifier:"DeviceBridge.Operation"}),Ro=u.Schema.Union([u.Schema.Struct({type:u.Schema.Literal("devices"),devices:u.Schema.Array(Io)}),u.Schema.Struct({type:u.Schema.Literal("state"),state:u.Schema.NullOr(At)}),u.Schema.Struct({type:u.Schema.Literal("attachment"),attachment:At}),u.Schema.Struct({type:u.Schema.Literal("apps"),apps:u.Schema.Array(To)}),u.Schema.Struct({type:u.Schema.Literal("launch"),result:Co}),u.Schema.Struct({type:u.Schema.Literal("install"),result:Po}),u.Schema.Struct({type:u.Schema.Literal("snapshot"),snapshot:Lo}),u.Schema.Struct({type:u.Schema.Literal("screenshot"),screenshot:Bo}),u.Schema.Struct({type:u.Schema.Literal("done")})]).pipe(u.Schema.toTaggedUnion("type")).annotate({identifier:"DeviceBridge.Value"}),Rt=u.Schema.Union([u.Schema.Struct({type:u.Schema.Literal("success"),value:Ro}),u.Schema.Struct({type:u.Schema.Literal("failure"),error:En})]).pipe(u.Schema.toTaggedUnion("type")).annotate({identifier:"DeviceBridge.Outcome"}),Ao=u.Schema.Union([u.Schema.Struct({type:u.Schema.Literal("success"),stream:No}),u.Schema.Struct({type:u.Schema.Literal("failure"),error:En})]).pipe(u.Schema.toTaggedUnion("type")).annotate({identifier:"DeviceBridge.StreamOutcome"}),$o=u.Schema.Union([u.Schema.Struct({type:u.Schema.Literal("attached"),connectionID:u.Schema.String,version:u.Schema.Literal(1)}),u.Schema.Struct({type:u.Schema.Literal("command"),connectionID:u.Schema.String,requestID:u.Schema.String}),u.Schema.Struct({type:u.Schema.Literal("cancel"),connectionID:u.Schema.String,requestID:u.Schema.String})]).pipe(u.Schema.toTaggedUnion("type")).annotate({identifier:"DeviceBridge.Control"}),Pe=u.Schema.toStandardSchemaV1,Nt={unavailable:Pe(En)};bo.Rpc.define({id:"device.control",methods:{attach:{input:Pe(u.Schema.Struct({..._n,version:u.Schema.Literal(1)})),output:Pe(u.Schema.Literals(["closed","replaced"])),errors:Nt},state:{input:Pe(u.Schema.Struct({..._n,state:u.Schema.NullOr(At)})),output:Pe(u.Schema.Void),errors:Nt},command:{input:Pe(u.Schema.Struct(Hr)),output:Pe(ti),errors:Nt},result:{input:Pe(u.Schema.Struct({...Hr,outcome:Rt})),output:Pe(u.Schema.Void),errors:Nt}},events:{control:{schema:Pe($o)}}});const Xr=u.Schema.String.check(u.Schema.isMaxLength(2048)),jo=yt.Ipc.define({id:"demul.device.bridge.v2",state:u.Schema.Array(u.Schema.Struct({scope:ot,state:u.Schema.NullOr(At)})),methods:{connect:{input:ot},disconnect:{input:ot},list:{input:u.Schema.Struct({platform:u.Schema.optional(We),serverID:Xr,sessionID:u.Schema.optionalKey(Xr)}),output:Rt},access:{input:u.Schema.Struct({scope:ot,enabled:u.Schema.Boolean}),output:Rt},manual:{input:u.Schema.Struct({scope:ot,operation:ti}),output:Rt},stream:{input:u.Schema.Struct({scope:ot,handle:De}),output:Ao}}});function Fo(g,L,_,M){if(!(!g||g!==L||_!=="playing"||!Number.isSafeInteger(M.width)||!Number.isSafeInteger(M.height)||M.width<=0||M.height<=0))return{token:g,width:M.width,height:M.height}}function Wo(g,L){return!!L&&g.token===L.token&&g.width===L.width&&g.height===L.height}const zo=1397050709,ni=16*1024*1024;function Vo(g){let L=!1,_;const M=()=>{const r=L&&document.visibilityState==="visible";if(r&&!_){_=g.stream.protocol==="h264"?Xo(g):Qo(g);return}r||!_||(_(),_=void 0,g.onState(L?"paused":"idle"))},q=()=>M();return document.addEventListener("visibilitychange",q),{active(r){L=r,M()},destroy(){L=!1,document.removeEventListener("visibilitychange",q),_?.(),_=void 0,g.canvas.width=0,g.canvas.height=0,g.onState("idle")}}}function Go(g){const L=new Uint8Array(g),_=new DataView(g);if(L.byteLength<4)return;if(_.getUint32(0)!==zo)return Qr(L)?{bytes:L,key:Ut(L,5)||Ut(L,7),timestamp:performance.now()*1e3}:void 0;if(L.byteLength<16)return;const M=_.getUint8(4),q=M===1?16:M===2?24:0;if(!q||L.byteLength<=q)return;const r=_.getBigUint64(8);if(r>BigInt(Number.MAX_SAFE_INTEGER))return;const ee=L.subarray(q);if(Qr(ee))return{bytes:ee,key:(_.getUint8(5)&1)===1||Ut(ee,5)||Ut(ee,7),timestamp:Number(r),serverTimestamp:M===2?Number(_.getBigUint64(16))/1e3:void 0}}function qo(g){const L=ii(g).find(_=>(_[0]&31)===7);if(!(!L||L.byteLength<4))return`avc1.${[L[1],L[2],L[3]].map(_=>_.toString(16).padStart(2,"0")).join("")}`}function Ho(g,L,_){if(![g.left,g.top,g.width,g.height,L.width,L.height,_.x,_.y].every(Number.isFinite)||g.width<=0||g.height<=0||!Number.isSafeInteger(L.width)||!Number.isSafeInteger(L.height)||L.width<=0||L.height<=0)return;const M=Math.min(g.width/L.width,g.height/L.height),q=L.width*M,r=L.height*M,ee=_.x-g.left-(g.width-q)/2,V=_.y-g.top-(g.height-r)/2;if(!(ee<0||V<0||ee>=q||V>=r))return{x:Math.min(L.width-1,Math.floor(ee/M)),y:Math.min(L.height-1,Math.floor(V/M))}}function Jo(g){if(!g||/[\r\n]/.test(g))return;const L=g.split(";");if(L.shift()?.trim().toLowerCase()!=="multipart/x-mixed-replace")return;const _=L.map(r=>r.trim()).find(r=>r.toLowerCase().startsWith("boundary="))?.slice(9).trim();if(!_)return;const M=_.startsWith('"')&&_.endsWith('"')?_.slice(1,-1):_,q=M.startsWith("--")?M.slice(2):M;if(!(!q||/[\r\n]/.test(q)))return q}function Ko(g,L){const _=new TextEncoder().encode(`--${L}`),M=new Uint8Array([13,10,13,10]),q=[];let r=0,ee=0;for(;rg.byteLength)return{frames:q,rest:g.slice(V)};if(g[W]!==13||g[W+1]!==10){r=W;continue}const se=Yr(g,M,W+2);if(se<0)return{frames:q,rest:g.slice(V)};const $=new TextDecoder("ascii").decode(g.subarray(W+2,se)),de=$.match(/(?:^|\r\n)Content-Length:\s*(\d+)(?:\r\n|$)/i)?.[1],he=$.match(/(?:^|\r\n)Content-Type:\s*([^\r\n]+)(?:\r\n|$)/i)?.[1].trim().toLowerCase(),oe=de?Number(de):0,ae=se+M.byteLength,ce=ae+oe;if(!Number.isSafeInteger(oe)||oe<=0||oe>ni||he!=="image/jpeg"){r=se+M.byteLength;continue}if(ce>g.byteLength)return{frames:q,rest:g.slice(V)};const ue=g.slice(ae,ce);ue.byteLength>=4&&ue[0]===255&&ue[1]===216&&ue.at(-2)===255&&ue.at(-1)===217&&q.push(ue),ee=ce,r=ce}return{frames:q,rest:g.slice(ee)}}function Xo(g){if(typeof VideoDecoder!="function"||typeof EncodedVideoChunk!="function")return g.onState("paused"),g.onError("H.264 device video requires WebCodecs support in this browser"),()=>{};const L=new URL("/video?frame-meta=1",new URL(g.stream.baseUrl).origin);L.protocol=L.protocol==="https:"?"wss:":"ws:";const _=new WebSocket(L,`opencode-device.${g.stream.token}`);let M,q,r=0,ee=!0,V=!1,W=!1;_.binaryType="arraybuffer",g.onState("connecting");const se=()=>{if(_.readyState===WebSocket.OPEN)try{_.send(JSON.stringify({type:"reset-video",ack:!1}))}catch{}},$=(oe=!1)=>{r++;const ae=M,ce=W;if(M=void 0,q=void 0,ee=!0,W=!1,ae&&ae.state!=="closed")try{ae.reset()}finally{ae.close()}oe&&ce&&!V&&g.onState("connecting"),oe&&se()},de=oe=>{V||(g.onError(oe),g.onState("paused"))},he=(oe,ae)=>{if(!(V||M!==ae||ae.state!=="configured"))try{ae.decode(new EncodedVideoChunk({type:oe.key?"key":"delta",timestamp:oe.timestamp,data:oe.bytes})),ee=!1}catch(ce){$(!0),de(`H.264 decode failed: ${ce instanceof Error?ce.message:String(ce)}`)}};return _.addEventListener("open",()=>se()),_.addEventListener("message",oe=>{if(V)return;if(typeof oe.data=="string"){Yo(oe.data)&&$(!0);return}if(!(oe.data instanceof ArrayBuffer))return;const ae=Go(oe.data);if(!ae)return;if(M&&M.decodeQueueSize>2){$(!0);return}if(ee&&!ae.key)return;const ce=qo(ae.bytes)??q;if(M&&ce===q){he(ae,M);return}if(!ce){se();return}$(),q=ce;const ue=r,ze={codec:ce,optimizeForLatency:!0,hardwareAcceleration:"prefer-hardware"};VideoDecoder.isConfigSupported(ze).then(Le=>{if(V||ue!==r)return;if(!Le.supported){de(`H.264 codec ${ce} is not supported by this browser`);return}const pe=new VideoDecoder({output(fe){if(V||M!==pe){fe.close();return}try{ri(g,fe,fe.displayWidth,fe.displayHeight)&&!W&&(W=!0,g.onState("playing"))}catch(Re){$(!0),de(`Device video draw failed: ${Re instanceof Error?Re.message:String(Re)}`)}finally{fe.close()}},error(fe){V||M!==pe||($(!0),de(`H.264 decoder failed: ${fe.message}`))}});try{pe.configure(Le.config??ze)}catch(fe){pe.close(),de(`H.264 decoder configuration failed: ${fe instanceof Error?fe.message:String(fe)}`);return}if(V||ue!==r){pe.close();return}M=pe,he(ae,pe)},Le=>de(`H.264 decoder support check failed: ${Le instanceof Error?Le.message:String(Le)}`))}),_.addEventListener("error",()=>de("Device video connection failed")),_.addEventListener("close",()=>{$(),V||g.onState("paused")}),()=>{V=!0,_.close(),$()}}function Qo(g){const L=new AbortController;let _,M,q=!1,r=!1;return g.onState("connecting"),fetch(new URL("/video",new URL(g.stream.baseUrl).origin),{method:"GET",headers:{Authorization:`Bearer ${g.stream.token}`},signal:L.signal}).then(async ee=>{if(!ee.ok||!ee.body)throw new Error(`Device video returned ${ee.status}`);const V=Jo(ee.headers.get("content-type"));if(!V)throw new Error("Device video returned an invalid MJPEG content type");_=ee.body.getReader();let W=new Uint8Array;for(;!q;){const se=await _.read();if(se.done)break;W=Zo(W,se.value);const $=Ko(W,V);W=$.rest;for(const de of $.frames){const he=await createImageBitmap(new Blob([new Uint8Array(de).buffer],{type:"image/jpeg"}));M=he;try{if(q)continue;ri(g,he,he.width,he.height)&&!r&&(r=!0,g.onState("playing"))}finally{he.close(),M===he&&(M=void 0)}}if(W.byteLength>ni)throw new Error("Device MJPEG stream exceeded 16 MiB without a complete frame")}q||g.onState("paused")}).catch(ee=>{q||(g.onState("paused"),g.onError(ee instanceof Error?ee.message:String(ee)))}).finally(()=>{_?.releaseLock(),_=void 0}),()=>{q=!0,L.abort(),_?.cancel().catch(()=>{}),M?.close(),M=void 0}}function ri(g,L,_,M){if(!Number.isSafeInteger(_)||!Number.isSafeInteger(M)||_<=0||M<=0)return!1;(g.canvas.width!==_||g.canvas.height!==M)&&(g.canvas.width=_,g.canvas.height=M);const q=g.canvas.getContext("2d",{alpha:!1});return q?(q.drawImage(L,0,0,_,M),!0):!1}function Yo(g){try{const L=JSON.parse(g);return!L||typeof L!="object"||!("type"in L)||L.type!=="video-session"||!("size"in L)||!L.size||typeof L.size!="object"||!("width"in L.size)||!("height"in L.size)||typeof L.size.width!="number"||typeof L.size.height!="number"||!Number.isSafeInteger(L.size.width)||!Number.isSafeInteger(L.size.height)||L.size.width<=0||L.size.height<=0?void 0:{width:L.size.width,height:L.size.height}}catch{return}}function ii(g){const L=[];for(let _=0;_g.subarray(_.nal,L[M+1]?.code??g.byteLength))}function Qr(g){return g.byteLength>=4&&g[0]===0&&g[1]===0&&g[2]===1?!0:g.byteLength>=5&&g[0]===0&&g[1]===0&&g[2]===0&&g[3]===1}function Ut(g,L){return ii(g).some(_=>(_[0]&31)===L)}function Zo(g,L){const _=new Uint8Array(g.byteLength+L.byteLength);return _.set(g),_.set(L,g.byteLength),_}function Yr(g,L,_){for(let M=_;M<=g.byteLength-L.byteLength;M++){let q=!0;for(let r=0;r"),na=X.template("