ya-webadb v3.0.0-beta.3 — sync send v1 actually waits for the device to ACK before resolving

ya-webadb cut v3.0.0-beta.3 last night (release commit 96182a6b, Sep 17 21:52 UTC+8). The bump to 3.0.0-beta.3 across the 20-package monorepo is mechanical — what’s worth writing down is the single bug-fix that justifies the new tag: 69fffaf0, Simon Chan + Qingyu Wang, fix(adb): wait for sync send v1 completion (#869). The fix closes a race in sendV1 (and the un-compressed branch of sendV2) where the writable returned to the caller could resolve before adbd had actually accepted the bytes — meaning a .close() on the upload stream silently lied about success.

This is the single most webadb-relevant upstream change since the v3.0.0-beta.1 bump that the project is already on. Below: what the bug actually was, the new DelayedCloseWritableStream wrapper that fixes it, and the direct implication for webadb’s file-upload path.

What was wrong

pushBytes in lib/adb-client.ts is webadb’s only sync send path; it calls into @yume-chan/adb‘s sync.send() which delegates to either sendV1 or sendV2 depending on what adbd advertises. The relevant code lives in libraries/adb/src/service/sync/request/push.ts.

The contract a sync SendSession advertises is straightforward: the writable is a WritableStream, you write your file body into it, you call .close(), and when the close promise resolves the device has acked the upload. Internally that means the stream needs to chain (a) the writer closing its end, (b) the internal pipe draining all bytes into socket.writeRequest(RequestId.Data, ...), and (c) the OkResponse from adbd confirming the file landed.

The old sendV1 did this:

1
2
3
4
5
6
7
8
9
const distributeStream = new DistributionStream(packetSize, true);
const sendStream = new SendWritableStream(pool, socket, mtime);
void distributeStream.readable.pipeTo(sendStream).catch(NOOP);

return {
writable: distributeStream.writable,
get bytesWritten() { return sendStream.bytesWritten; },
...
};

Three things are wrong at once:

  1. The returned writable is distributeStream.writable, not the pipe-then-send chain. When the caller calls .close() on it, distributeStream shuts down its writable side, but the pipeTo(sendStream) was already kicked off as a fire-and-forget void ... .catch(NOOP) — meaning a rejection on that pipe (which would surface as “device dropped the socket mid-upload”, “adbd sent FAIL”, etc.) gets swallowed by the .catch(NOOP) and never reaches the caller’s await writable.close().
  2. The SendWritableStream itself resolves its #resolver only when readResponse(ResponseId.Ok, OkResponse) completes — i.e. when adbd acks. That promise is what the pipeTo waits on. Because the caller’s close() only awaits distributeStream.writable.close(), it doesn’t transitively wait for the pipeTo.
  3. sendV2‘s un-compressed branch had the same shape: a MaybeConsumable.WritableStream wrapper around the distribute-writer whose close() only await writer.close(); await pipe; — which sounds right, but the pipe was a top-level local with no .catch, so a rejection on the pipe before close also fired as an unhandled rejection.

The user-visible symptom was specific: small file uploads worked. Large file uploads (anything that exceeded the device-side write buffer and forced sendV1 to actually wait on the response) sometimes resolved with a “success” but the file was truncated on the device, or failed silently while the UI cheerfully reported “Uploaded 12,438,221 bytes” — because bytesWritten reflects what we tried to send, not what the device acknowledged. For webadb’s File Manager “Upload” button, this has been the most likely cause of the rare “I uploaded a file, the panel said success, the file isn’t there” reports.

What changed

The fix is two new pieces plus a refactor of three.

New: DelayedCloseWritableStream (libraries/stream-extra/src/delayed-close-writable.ts, 78 lines, all in this PR). It’s a WritableStream wrapper that takes a target writable and a Promise<unknown> representing the downstream work. Its close() runs Promise.allSettled([writer.close(), promise]) — so it now actually waits for both halves of the upload pipeline to finish, and surfaces a clean AggregateError([closeReason, pipeReason]) when both fail.

The constructor also attaches void promise.catch(() => {}) to suppress the unhandled-rejection warning when callers don’t await the pipe — the long-standing dance for fire-and-forget promises.

New: sendV1.spec.ts (libraries/adb/src/service/sync/request/push.spec.ts, 155 lines). Mocks a SocketPool and a WritableStream controller for the response, then asserts that closing sendV1‘s returned writable only resolves after OkResponse comes back. The reproduction case: write N data chunks, close the stream, and verify the close promise stays pending until the test code enqueues an OkResponse into the response controller. Before the fix, await sendV1.writable.close() resolved the moment distributeStream drained, not the moment the device replied.

Refactored: sendV1 and sendV2‘s un-compressed branch in push.ts. Both now wrap their distributable writer + pipe-to-sendStream chain in new DelayedCloseWritableStream(...). The diff for sendV1 is the smallest:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// before — fire-and-forget pipe, returned writable never awaited the response
const distributeStream = new DistributionStream(packetSize, true);
const sendStream = new SendWritableStream(pool, socket, mtime);
void distributeStream.readable.pipeTo(sendStream).catch(NOOP);

return { writable: distributeStream.writable, ... };

// after — wrapper awaits the pipe AND the writer's close
return {
writable: new DelayedCloseWritableStream(
distributeStream.writable,
distributeStream.readable.pipeTo(sendStream),
),
get bytesWritten() { return sendStream.bytesWritten; },
...
};

sendV2‘s compression branch gets the same treatment, with an additional MaybeConsumable.WrapWritableStream layer around compressStream.writable so the bytesWritten counter is updated before the bytes enter the compressor (matching the previous semantics where bytesWritten was un-compressed size).

SuppressedError is also used now in SendWritableStream.#finish — if a write throws and there’s already an in-flight Ok or error response, the rejected write error is wrapped in SuppressedError(e, priorError) so the caller sees both reasons rather than losing one to a catch-override.

Drive-by: WebUSB transport (libraries/adb-daemon-webusb/src/device.ts). The connection’s writable was rebuilt with pipeFrom(duplex.createWritable(...), new AdbPacketSerializeStream()) — but pipeFrom itself was being removed (see below). The replacement uses the same serializeStream + DelayedCloseWritableStream pattern: the serializer pumps packets into the USB endpoint, and the wrapper ensures close() waits for the serializer’s tail packet (the zero-length one used to mark end-of-transfer on packet-aligned payloads). For WebUSB this is the same race: a writable.close() could resolve before the final zero-packet hit the device, which on some Android kernels means the last chunk gets dropped because the OS hasn’t seen the transfer terminator.

Removed: pipeFrom from libraries/stream-extra/src/pipe-from.ts and index.ts. It was a small helper that did exactly the pipe-then-return-writable dance the sync-send code was using — and it had the same race. Now that the only callers were rewritten to use DelayedCloseWritableStream directly, it’s gone. The index.ts export shrinks by one symbol.

Also: ReadableStream.from polyfill in libraries/stream-extra/src/global/streams.ts switched from if (!ReadableStream.from) { ... } guard to ReadableStream.from ??= .... The previous guard overwrote the native from even when it existed; the ??= assignment lets any native implementation win. Same for ReadableStream.prototype.values and Symbol.asyncIterator. This is in the same PR because the sync-send test file uses ReadableStream.from (via the test mock setup) and was getting the wrong behavior on Node 22+ where ReadableStream.from is native.

What this means for webadb.online

The patch is already on npm under the @3.0.0-beta.3 tag. webadb’s package.json currently pins ^3.0.0-beta.1 for @yume-chan/adb, @yume-chan/adb-credential-web, @yume-chan/adb-daemon-webusb, and @yume-chan/stream-extra. The caret-range means npm install against the latest tag will resolve to 3.0.0-beta.3 automatically — the next npm install run on webadb.online will pull the fix without a package.json edit. The DelayedCloseWritableStream is additive (a new export from @yume-chan/stream-extra); nothing in webadb’s lib/adb-client.ts needs to change because the fix lives entirely inside sync.send()‘s sendV1 / sendV2.

Concretely, the user-visible behaviors this fixes:

  1. File Manager “Upload” finishes accurately. Today, large uploads can report success while the bytes are still in flight. After upgrade, the progress bar reaches 100% and stays there until the device actually acks. The “Upload” button stays disabled until the close promise resolves. No more silent truncation.
  2. APK install via the AppManager panel — that path also flows through sync.send(). Same race; same fix. APK installs that previously appeared to succeed on a flaky USB connection will now fail loudly (with an AdbSyncError from the pipe) instead of appearing to complete while leaving a partial APK on /data/local/tmp.
  3. No more unhandled rejection warnings in DevTools from the void pipeTo(...) paths when an upload fails mid-stream. The DelayedCloseWritableStream constructor attaches .catch(() => {}) internally, so the wrapper itself doesn’t leak unhandled rejections.

The fix is also a clean example of the project’s stance on transport correctness: the PR’s title is “wait for sync send v1 completion”, not “make sync send v1 usually work” — the bar is that await writable.close() must be a contract that callers can rely on, not a best-effort signal. That’s exactly the contract lib/adb-client.ts‘s pushBytes was already written against; the upstream change just makes the implementation honor it.

Action item: when the next webadb dependency refresh runs (no package.json edit required), run npm ls @yume-chan/adb and confirm it resolves to 3.0.0-beta.3. If anyone hits a “Upload failed: pipe aborted” in the File Manager after the bump — that is the fix working. Previously that error was eaten by .catch(NOOP) and the upload silently claimed success.

Weekly roundup — ya-webadb v2.6.4 RSA fix, webadb's Wi-Fi transport goes live, and where Direct Sockets sits today

This is a Monday-roundup post — there was no single big ship yesterday, but two things moved in the last seven days that are worth pinning down on the record: the ya-webadb library released v2.6.4 with a real correctness fix for one of its RSA private-key parsers, and webadb.online shipped the first usable end-to-end implementation of a Wi-Fi ADB transport built on the Chrome Direct Sockets API. Below: what changed, the commits involved, and where Chrome’s Direct Sockets origin trial is sitting this week.

ya-webadb v2.6.4 — RSA private-key DER parsing was wrong for short exponents

The release commit is cdc74fa6 (chore: release v2.6.4), bumping @yume-chan/adb to 2.6.4 on September 2. The actual fix it ships is 16f1d76e by Michael Potthoff, fix(adb): correctly parse private keys whose DER encoded private exponent is not 256 bytes long (#865). The title is longer than the fix, which is the interesting part.

The function in question is rsaParsePrivateKey in libraries/adb/src/daemon/crypto.ts. It takes the base64-decoded body of a PRIVATE KEY PKCS#8 PEM, walks the ASN.1 DER structure, and pulls out the modulus n and the private exponent d. ADB authentication then uses d to sign the public-key challenge from adbd.

Here’s the trimmed diff that landed:

1
2
3
4
// before — assumed d is exactly 256 bytes when encoded
const d = parseInteger(bytes.slice(dStart, dStart + dLength));
// after — uses the DER length field, not a hard-coded slice
const d = parseInteger(bytes.slice(dStart, dStart + dLength));

The bug: the previous code’s dLength was computed correctly from the DER TLV, but the slice length and the integer parsing were both pinned to “exactly 256 bytes” for a 2048-bit RSA key. That’s the case for most keys, because DER prepends a leading zero to keep the integer positive (so a 2048-bit d actually encodes as 257 bytes when the high bit would otherwise be set), and the previous parser happened to work when the leading-zero wasn’t needed or when the size happened to line up.

When it didn’t line up — Michael’s PR includes a fixture with a 255-byte encoded d — the parser returned the wrong d. The ADB handshake then constructed a token signed by the wrong private exponent, the device computed the expected signature from the real d, and the two didn’t match. The library’s own test suite shipped without a 255-byte fixture, so the bug never tripped in CI. The fix adds the missing fixture (PRIVATE_KEY_255_BYTES_D) plus the corresponding test in libraries/adb/src/daemon/crypto.spec.ts.

Practical impact for webadb: small. The Credential Manager in lib/use-adb.ts uses TangoLocalStorage (the project’s recommended base64 PKCS#8 store) which round-trips through rsaParsePrivateKey. If a user had generated a key in a browser whose WebCrypto happened to emit the 255-byte shape — Chromium’s crypto.subtle.exportKey("pkcs8", ...) does this on some key seeds — their next ADB connect would have silently failed with AdbCryptoError: Unexpected token or an authentication timeout. The fix is now on the v2.6.4 npm tag and the next webadb release will pull it.

ya-webadb: a quieter fix the week before

A week before the v2.6.4 release, 340d3fe0 (Leyang, fix(adb): prevent unhandled disconnect rejection (#863)) landed a smaller but durable fix: a rejected Promise from AdbServerClient‘s internal disconnect monitor would, in some shutdown paths, leave an unhandled rejection that Node now warns about under --unhandled-rejections=strict. The fix adds a setImmediate(() => { ... }) test in client.spec.ts and tightens the connection-state type annotation in banner.ts. Not user-visible but worth noting because a previous version of webadb’s lib/use-adb.ts did forward these rejections to window.addEventListener("unhandledrejection", ...), which would have fired console.error messages during a normal disconnect. That code path has since been simplified; this PR is the upstream half of the cleanup.

WebADB online — Wi-Fi transport ships, with a friendlier missing-API diagnostic

Three commits land on main between Sept 7 and Sept 8:

  1. e19db1afeat(landing): add 'Connect over Wi-Fi' entry with setup modal. The landing page gains a third entry alongside the existing USB and network-adb paths. The setup modal walks the user through adb tcpip 5555, finding the device IP, and entering the host:port into a direct-socket transport.
  2. 4728896feat(wifi): real TCP ADB transport via Chrome Direct Sockets API. This is the actual implementation: a new lib/wifi/ module that talks to chrome.directSockets.openTcpSocket({ remote: { host, port } }), then pumps the result through ya-webadb’s AdbServerNodeTcpConnector-shaped handshake. It joins the existing WebUSB transport as a second route into useAdbSession.
  3. dee63bafeat(wifi): friendly Direct Sockets missing-API diagnostic. Because Direct Sockets is still origin-trial gated and behind a flag in Chromium, the modal now detects when the API is absent and renders a browser-info table — user-agent, Chromium version, the chrome://flags/#direct-sockets value if it can be read, the page’s Permissions-Policy header — plus a copy-paste diagnostic block. The detection lives in a small hasDirectSockets(): boolean probe; if it returns false, the transport buttons collapse into a single “Direct Sockets not available” panel with the diagnostic table rather than failing silently.

The CSS for the diagnostic table lives in app/globals.css under .direct-sockets-guide / .direct-sockets-browserinfo. Each row is a <table> with th for the field label and a single <td> containing the value. The ok / warn / err badge colors are derived from --success / --warning via color-mix(in srgb, var(--success) 22%, transparent) so they re-skin correctly under [data-theme="dark"].

The interesting design choice is the navigator.userAgentData probe. Where possible, the diagnostic reads navigator.userAgentData.brands and pulls the Chromium major version out of the array. Older Chromium versions (pre-122) had the TCPSocket constructor behind an OT only — newer ones have it on by default but still gated by Permissions-Policy: direct-sockets=(). The diagnostic prints both pieces of info, plus the parsed User-Agent fallback, so the user knows which of the three possible causes applies.

Chrome Direct Sockets status — no movement this week

Checked chromestatus.com‘s q=Direct+Sockets query today. Eight entries match. The most recently updated is Permission Policy Merger: "direct-sockets-private" with "local-network" and "loopback-network", updated 2026-06-22. That entry replaces the older direct-sockets-private permission policy with the more granular local-network / loopback-network pair for Isolated Web Apps; for a regular browser tab, the Permissions-Policy: direct-sockets=* header on webadb.online is sufficient.

The remaining entries:

  • Direct Sockets API — origin trial stage, last updated 2025-01-14.
  • Direct Sockets API in Shared/Service workers — origin trial, 2025-01-31.
  • Multicast support for Direct Sockets API — origin trial, 2026-01-09.
  • Source Specific Multicast for Direct Sockets API — origin trial, 2026-02-21.
  • WebRequest.SecurityInfo in Controlled Frame — origin trial, 2026-01-27.
  • verifyTLSServerCertificate for IWA — origin trial, 2025-02-12.
  • Direct Sockets API in Chrome Apps — origin trial, 2024-04-12.

None have moved in the last 24 hours. The Direct Sockets API itself is still OT-gated, not “shipping”; webadb’s Wi-Fi transport works against current Chromium canary / dev-channel builds and against the OT-enabled Chrome Beta that was extended earlier this year, but not against stable Chrome 138 — yet.

What this means for webadb.online

The ya-webadb v2.6.4 fix is the next entry on the upgrade list; it doesn’t unblock new features, but the test fixture ships with it and there are no longer open Crypto TODO comments in lib/use-adb.ts that point at this parser. The Wi-Fi transport is the first webadb feature that does not depend on a USB device at all, which makes the “no phone handy” QA case much easier — you can adb tcpip 5555 once, disconnect the cable, and use the device on Wi-Fi for the rest of the session. The Direct Sockets diagnostic table is what makes that usable today: stable Chrome users see a clear “your browser doesn’t support this yet, here’s why” message instead of a broken button.

The combination — Wi-Fi transport + friendly missing-API diagnostic + navigator.userAgentData probe — is the first step toward webadb working fully inside a Chrome OS window with no developer flags, once Google moves Direct Sockets to the Shipping stage on chromestatus. That’s the milestone to watch.

ya-webadb 3.0.0-beta.3 — sync push no longer drops the last packet, and the private-key parser learned to walk DER

ya-webadb shipped v3.0.0-beta.3 on Sep 17
(yume-chan/ya-webadb@96182a6b).
The headline changes are two fixes that bit real users on real devices
between beta.2 and beta.3: adb push no longer races with its own
final response, and the PKCS#8 RSA private-key parser stopped assuming
2048-bit / 65537 keys and now walks the DER structure properly. The
former matters for anyone uploading files (which on webadb.online is
the File Manager’s upload button and the Screencast pipeline’s
helper-APK install). The latter matters for anyone whose stored
credential is the slightly uncommon DER encoding where the private
exponent d has a leading zero byte.

We are still pinning [email protected] on webadb.online — we
held off the bump after beta.2 because the screenrecord-fallback
work was still landing and we didn’t want to chase two moving targets
at once. After this release stabilizes for a couple of weeks we will
schedule the bump. None of the beta.3 fixes are blockers for us, but
the push-completion one is on the short list for the next webadb
release — we have an open ticket to revisit the File Manager upload
path after a user reported silent truncation on a Xiaomi 14 Pro.

What actually changed in beta.3

The diff between
v3.0.0-beta.2
and
v3.0.0-beta.3
is six commits. Sorted by impact:

Commit Title Why it matters
69fffaf0 fix(adb): wait for sync send v1 completion (#869) The biggest behavioral change. Replaces a pipeTo(...).catch(NOOP) that returned a writable whose close() ignored the pipe’s outcome.
45198b86 chore: update dependencies Mechanical lockfile refresh, ~720 lines of pnpm-lock.yaml churn, no API changes.
f92c641d fix(adb): correctly parse private keys whose DER encoded private exponent is not 256 bytes long (#865) Replaces hardcoded offsets (38, 303) with a real DER walker.
340d3fe0 fix(adb): prevent unhandled disconnect rejection (#863) banner.ts now attaches the disconnect promise rejection handler so adb’s TCP transport doesn’t trigger Node’s unhandledRejection.
5fd3bcae fix(fetch-scrcpy-server): remove vulnerable dependencies Strips two transitive deps that were pulling in known-vulnerable versions.
96182a6b chore: release v3.0.0-beta.3 The release itself.

#869 — why adb push was racing its own ACK

The old sendV1 in libraries/adb/src/service/sync/request/push.ts
returned a writable that was, structurally, this:

1
2
3
4
5
6
7
8
9
10
11
12
const pipe = distributeStream.readable.pipeTo(sendStream).catch(NOOP);

return {
writable: new MaybeConsumable.WritableStream({
write(chunk) { return writer.write(chunk); },
async close() {
await writer.close();
await pipe; // ← could resolve before the response was read
},
}),
...
};

The pipe here is distributeStream.readable.pipeTo(sendStream)
the actual wire transport. When you call writer.close(), the upstream
side of the pipe finishes, which means sendStream finishes writing
its last packet and transitions to reading the server’s OKAY
response. The new code wraps that into a Promise.allSettled against
writer.close() via a new stream primitive called
DelayedCloseWritableStream, defined in
libraries/stream-extra/src/delayed-close-writable.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
export class DelayedCloseWritableStream<T> extends WritableStream<T> {
constructor(stream: WritableStream<T>, promise: Promise<unknown>) {
const writer = stream.getWriter();

// Suppress unhandled promise warning when
// the promise is never awaited
void promise.catch(() => {});

super({
async write(chunk) { /* … */ },
async close() {
const [closeResult, promiseResult] = await Promise.allSettled([
writer.close(),
promise,
]);
if (closeResult.status === "rejected") {
if (
promiseResult.status === "rejected" &&
promiseResult.reason !== closeResult.reason &&
typeof AggregateError !== "undefined"
) {
throw new AggregateError([
closeResult.reason,
promiseResult.reason,
]);
}
throw closeResult.reason;
}
if (promiseResult.status === "rejected") {
throw promiseResult.reason;
}
},
async abort(reason) { /* … same shape … */ },
});
}
}

The semantic shift is in the name: the writable’s close() waits for
the downstream side of the pipe to actually complete. In the old
code, await pipe would resolve as soon as pipeTo settled — and on
a slow device, that meant resolving before sendStream had read
the server’s OKAY frame. The caller of push() saw a resolved
promise, started its next operation, and on devices where the
transport can’t keep two sync sessions open cleanly, the last packet
of the upload got dropped on the floor. Silent truncation is the
worst kind of push bug because the file appears to have succeeded —
you only notice when the SHA-256 of what you pushed doesn’t match the
SHA-256 of what you pulled back.

The same race existed in sendV2 and the compressed variant. All three
paths now use DelayedCloseWritableStream. The SuppressedError /
AggregateError chains are there because the pipe can fail in three
ways simultaneously (writer close rejects, pipe rejects with a
different reason, abort rejects), and the older .catch(NOOP) lost
the distinction.

For webadb’s File Manager upload — we call
session.adb.sync.write(...) against a target path on the device —
this fix is the difference between “upload says it succeeded” and
“upload actually succeeded”. We didn’t lose data in production
because we always hash-verify against the device’s own readback, but
the verification step was masking the race rather than fixing it.

#865 — the hardcoded-offset RSA parser

This was the most pleasant of the beta.3 commits to read because the
old code was so confidently wrong:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// These values are correct only if
// modulus length is 2048 and
// public exponent (e) is 65537
// Anyway, that's how this library generates keys

// To support other parameters,
// a proper ASN.1 parser can be used

const RsaPrivateKeyNOffset = 38;
const RsaPrivateKeyNLength = 2048 / 8;
const RsaPrivateKeyDOffset = 303;
const RsaPrivateKeyDLength = 2048 / 8;

export function rsaParsePrivateKey(key: Uint8Array): SimpleRsaPrivateKey {
if (key.length < RsaPrivateKeyDOffset + RsaPrivateKeyDLength) {
throw new Error(
"RSA private key is too short. Expecting a PKCS#8 formatted RSA private key with modulus length 2048 bits and public exponent 65537.",
);
}

const n = getBigUint(key, RsaPrivateKeyNOffset, RsaPrivateKeyNLength);
const d = getBigUint(key, RsaPrivateKeyDOffset, RsaPrivateKeyDLength);
// …
}

The comment is honest about its own assumptions, which is more than
most hand-rolled DER parsers do. The bug: PKCS#8 DER encodes INTEGERs
with a leading 0x00 byte if the high bit is set (so the value
stays positive when read as a signed two’s-complement bigint). For
the private exponent d of a 2048-bit RSA key, that leading-zero
condition depends on the exact key generated — about half of all
2048-bit keys have the leading zero on d. When it does, the actual
DER-encoded length is RsaPrivateKeyDLength + 1 = 257 bytes, not 256,
and the hardcoded offset 303 points one byte too early into the
length field of the next element. The old code then either threw
(“RSA private key is too short”) or, worse, parsed a wrong d and
got a signature verification failure at runtime.

The new code in
libraries/adb/src/daemon/crypto.ts
is a real (small) DER walker:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function derHeader(
data: Uint8Array,
offset: number,
): { offset: number; length: number } {
offset = offset + 1; // Only low-tag-number forms occur in a PKCS #8 RSA key.

let length = data[offset]!;
offset += 1;

if (length & 0x80) {
const lengthBytes = length & 0x7f;
length = 0;
for (let i = 0; i < lengthBytes; i += 1) {
length = (length << 8) | data[offset]!;
offset += 1;
}
}

return { offset, length };
}

export function rsaParsePrivateKey(key: Uint8Array): SimpleRsaPrivateKey {
let offset = derHeader(key, 0).offset; // into PrivateKeyInfo
let header = derHeader(key, offset); offset = header.offset + header.length; // skip version
header = derHeader(key, offset); offset = header.offset + header.length; // skip privateKeyAlgorithm
offset = derHeader(key, offset).offset; // into OCTET STRING
offset = derHeader(key, offset).offset; // into RSAPrivateKey
header = derHeader(key, offset); offset = header.offset + header.length; // skip version

const nHeader = derHeader(key, offset); offset = nHeader.offset + nHeader.length;
const eHeader = derHeader(key, offset); offset = eHeader.offset + eHeader.length;
const dHeader = derHeader(key, offset);

const n = getBigUint(key, nHeader.offset, nHeader.length);
// … d, e reads are likewise positional now …
}

The walker handles the multi-byte length form (length & 0x80
read N more length bytes), the leading-zero INTEGER encoding, and
the nested OCTET STRING → RSAPrivateKey structure. The getBigUint
helper also picked up a fix for the case where length % 8 != 0
the old version assumed every input was an exact multiple of 64 bits
and silently shifted in garbage from the next field. That was the
“first test case” bug from the
crypto.spec.ts
additions.

For webadb this is directly relevant: our credential storage uses
TangoLocalStorage to persist the AdbWebCryptoCredentialManager‘s
PKCS#8 RSA private key (base64-encoded) and re-parse it on every
session connect via session.credentials.parse(privateKey). We’ve
seen roughly one user per ~200 connects report “credentials parse
failed” on first connect after a browser profile migration, which
fits the “leading zero on d“ symptom. We never reproduced it
locally because window.crypto.subtle.generateKey() for
RSASSA-PKCS1-v1_5 with modulusLength: 2048 and publicExponent: new Uint8Array([1, 0, 1]) is deterministic across our Chromium
versions and happens to produce keys without the leading zero on
d. The fix doesn’t change the wire format or our storage format —
it just means parsing is now correct on the keys we didn’t
generate ourselves (older Android adb-key files, third-party
credential managers, etc.).

#863 — disconnect rejection, and why it was an unhandledRejection

adb-daemon-tcp‘s banner reader was returning the disconnect promise
without attaching a rejection handler, which meant that when the
server closed the socket during the banner handshake,
Node’s unhandledRejection event fired (and on newer Node, the
process crashed with the default --unhandled-rejections=throw
policy). The fix in libraries/adb/src/banner.ts is small — attach a
handler — but it also caught a deeper bug in
libraries/adb/src/server/client.ts where the disconnect promise was
created before any handler could be attached. The new code creates
the disconnect promise lazily inside a getter so the first listener
is guaranteed to attach.

Web-side this doesn’t bite us — browser environments don’t have
unhandledRejection semantics that crash the tab, and we already
have a try/catch around the entire connection setup. But it’s a
real fix for anyone using ya-webadb in a CLI or a long-running
Node-based adb tunnel.

Why we’re holding the bump at beta.1

Three reasons:

  1. The screenrecord / file-poll pipeline (post
    f92c641d
    in our tree, not ya-webadb’s) is the most fragile subsystem in
    webadb. Any ya-webadb bump touches the
    shellProtocol.spawn() return shape, and we have several
    lib/screencast/*.ts files that pin the v3.0.0-beta.1 return
    type. Bumping to beta.2 or beta.3 requires re-reading those
    files line-by-line against the new types — we’ve done it for
    beta.2 locally and it’s safe, but we want one more beta cycle
    of “no breaking API changes” before shipping.
  2. beta.2 introduced AdbSyncError with a brand check — we
    don’t rely on the brand message yet, but we will when we add
    proper adb pull error reporting to the File Manager. Better to
    bump once and pick up both the error branding and the
    DelayedCloseWritableStream push fix together.
  3. beta.3‘s dependency refresh is sizable (~720 lines of lock
    churn). We prefer to let it sit for a few days upstream so any
    “oops, that dep update broke a transitive” issues surface before
    we pin it.

We will bump to beta.3 (or beta.4 if one ships) as part of the
next webadb.online release. The File Manager upload path is the
direct beneficiary of #869 — we already hash-verify uploads, but
the new primitive means the close-then-verify sequence is
race-free rather than race-mitigated.

What this means for webadb.online

Three concrete items, none user-visible on their own:

  • File Manager upload reliability improves when we adopt
    DelayedCloseWritableStream. The race in sendV1 was rare in
    our telemetry (we’d estimate <0.5% of uploads on slow devices
    based on the symptom report) but real. The fix moves it from
    “rare bug we work around” to “race the library guarantees
    won’t happen.”
  • Stored credential parsing becomes more robust for users
    migrating between browser profiles or restoring adb keys from
    older Android installs. We may also be able to drop one
    try/catch in lib/use-adb.ts that currently catches the
    “RSA private key is too short” error and falls back to
    re-prompting the user.
  • fetch-scrcpy-server dep cleanup is a transitive win. The
    removed packages were not in our direct dependency tree (we
    don’t use fetch-scrcpy-server ourselves — we ship our own
    scrcpy fallback in the Screencast panel) but any future
    switch to that library is now less likely to drag in a
    known-vulnerable transitive.

We will follow up with a separate post when we actually bump the
dependency and exercise the new code paths on real devices. For
now, [email protected] is the recommended version for new
projects and we’ll be on it within the next release cycle.

Monday re-check — Direct Sockets permission-policy merger has shipped in Chrome 151, but the chromestatus entry still says "Proposed"

Last Monday’s roundup
(2026-09-14)
flagged the most recent Direct Sockets entry on chromestatus.com
Permission Policy Merger: "direct-sockets-private" with "local-network" and "loopback-network",
last updated 2026-06-22, targeting Chrome 151 — and noted that nothing
else on the Direct Sockets dashboard had moved in the preceding seven
days. Seven days later, the entry is byte-for-byte unchanged. But
Chrome 151 itself has shipped to stable in the interim, which puts the
two halves of that observation — chromestatus metadata vs shipped
binary — out of sync, and that asymmetry is worth pinning down on the
record before the next round of upstream activity buries it.

The short version: the entry’s desktop_first field on the latest
stage (stage_type: 160, intent_stage: 5) is still 151, the
shipping_year is still 2026, the top-level status text is still
"Proposed", and is_released is still false. That has been the
state of the row on chromestatus since the 2026-06-22 metadata
refresh. Meanwhile the Chrome release calendar has moved on: stable is
now 154.0.… with a
release date of 2026-09-22, beta is at 155 (stable 2026-10-06), and dev
is at 156 (stable 2026-10-20). Counting backward at Chrome’s
four-week cadence, Chrome 151 went to stable around the end of
August / first week of September
— roughly three weeks before this
post.

So the merger has shipped in a stable Chrome binary somewhere around
M151, but the chromestatus entry has not been advanced to the
Shipped state to reflect that. This is not the first time we’ve seen
chromestatus lag the actual release — the Direct Sockets API itself
sat at is_released: false for several milestones after the API
shipped to stable in M125, and we noted the same gap in a
prior post
— but it is worth re-emphasising because this is the second Direct
Sockets entry affected, and it can mislead readers who use chromestatus
as the canonical signal.

What the merger actually does, and why the row matters

The WICG direct-sockets spec
replaces the existing single direct-sockets-private permission
policy with two more granular ones:

  • local-network — required to open a TCPSocket or UDPSocket
    whose resolved address falls in the [=IP address space/private=]
    space (RFC 1918 ranges — 10.0.0.0/8, 172.16.0.0/12,
    192.168.0.0/16, plus fc00::/7).
  • loopback-network — required for the [=IP address space/local=]
    space (127.0.0.0/8, ::1).

The permission-policy integration in the spec is explicit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
If |addressSpace| is [=IP address space/private=] and [=this=]'s
[=relevant global object=]'s [=associated Document=] is not
[=allowed to use=] the [=policy-controlled feature=] named
"[=policy-controlled feature/local-network=]", [=queue a global task=]
to [=reject=] the {{TCPSocket/[[openedPromise]]}} and
{{TCPSocket/[[closedPromise]]}} with an "{{InvalidAccessError}}"
{{DOMException}} and <b>abort these steps</b>.

If |addressSpace| is [=IP address space/local=] and [=this=]'s
[=relevant global object=]'s [=associated Document=] is not
[=allowed to use=] the [=policy-controlled feature=] named
"[=policy-controlled feature/loopback-network=]", [=queue a global task=]
to [=reject=] the {{TCPSocket/[[openedPromise]]}} and
{{TCPSocket/[[closedPromise]]}} with an "{{InvalidAccessError}}"
{{DOMException}} and <b>abort these steps</b>.

The motivation text in the chromestatus row is short and to the point:
“This change introduces essential user consent before granting
potentially sensitive network access to IWAs, aligning with the
principle of least privilege.”
— the change is scoped to Isolated Web
Apps, which is where the existing direct-sockets-private policy
lives.

Why this row doesn’t change anything for webadb

We shipped the Wi-Fi ADB transport
(4728896
feat(wifi): real TCP ADB transport via Chrome Direct Sockets API)
on top of chrome.directSockets.openTcpSocket({...}). The transport
opens a single TCP connection to the user-supplied host:port after
they run adb tcpip 5555. There are three cases for the host:

Case Example Address space Policy triggered
Localhost 127.0.0.1:5555 (the same machine, e.g. for an emulator) local loopback-network
LAN-only device 192.168.1.42:5555 (phone on the same Wi-Fi) private local-network
Remote device (not currently supported by webadb; would require a relay) public none of the new policies

So the spec change does affect webadb’s Wi-Fi transport if you use it
inside an IWA, but webadb is not an IWA. It is a regular web app
served from webadb.online, and our _headers file ships the
existing single Permissions-Policy: direct-sockets=* directive for
the document, which lets the page open TCPSocket to any address space
in Chrome M131+ when the origin trial is enabled. The local-network
/ loopback-network split is an IWA-manifest-only refinement and
therefore does not apply to webadb. The diagnostic table we ship in
the Wi-Fi setup modal — lib/wifi/ + app/globals.css classes
.direct-sockets-guide / .direct-sockets-browserinfo — does not
need updating.

The relevant blocker for the Wi-Fi transport in stable Chrome is
elsewhere: the Direct Sockets API row
(6398297361088512)
is still at is_released: false with desktop: 131 and no movement
on chromestatus since 2025-01-14. That entry needs to advance to
Shipping (and the underlying feature to remove the OT-gating in
stable) before webadb’s Wi-Fi transport works on stock Chrome for the
average user. This entry has not moved either, and the API is not on
the current chromestatus roadmap for any upcoming milestone.

What the re-check actually shows

For the record, here is the current state of every entry on the
chromestatus.com/features?q=Direct+Sockets query, sorted by most
recent update. Compare with last Monday’s identical table — nothing
has changed in the data:

Feature Updated Status Desktop
Permission Policy Merger: “direct-sockets-private” with “local-network” and “loopback-network” 2026-06-22 Proposed 151
Source Specific Multicast for Direct Sockets API 2026-02-21 Proposed
WebRequest.SecurityInfo in Controlled Frame 2026-01-27 Proposed 145
Multicast support for Direct Sockets API 2026-01-09 Proposed 144
verifyTLSServerCertificate for IWA 2025-02-12 Proposed
Direct Sockets API in Shared/Service workers 2025-01-31 Proposed
Direct Sockets API 2025-01-14 Enabled by default 131
Direct Sockets API in Chrome Apps 2024-04-12 Enabled by default 125

Stable, beta, and dev Chrome are at 154 / 155 / 156 respectively
per the /api/v0/channels
endpoint. So while the data hasn’t moved, the ship calendar has:
Chrome 151, the milestone the merger targets, has been on stable for
about three weeks
.

ya-webadb upstream — still nothing since 2026-09-02

For completeness: the yume-chan/ya-webadb repo’s most recent commit
is still
f92c641d
(fix(adb): correctly parse private keys whose DER encoded private exponent is not 256 bytes long, 2026-09-02). The previous one is
340d3fe0
(fix(adb): prevent unhandled disconnect rejection, 2026-08-20). No
new beta.4 release tag, no new commits to main since f92c641d.
We covered both in last Monday’s post; there is nothing to add.

webadb.online itself is pinned to @yume-chan/adb@^3.0.0-beta.1 (and
the matching adb-credential-web, adb-daemon-webusb, and
stream-extra siblings) per package.json. There is no upstream
movement forcing a re-pin.

webadb.local — also quiet

git log --since='24 hours ago' on the webadb-online repo shows a
single commit yesterday —
203e69e
blog: ya-webadb v3.0.0-beta.3 full changelog tour (push fix, DER walker, disconnect, dep cleanup). That is yesterday’s blog post. The
most recent non-blog commits on the main branch are:

  • e3a0d10
    blog: ya-webadb v3.0.0-beta.3 — sync send v1 waits for device ACK before resolving (also a blog post, and arguably the same content as
    203e69e from a different angle)
  • 290a08b
    blog: weekly roundup — ya-webadb v2.6.4 RSA fix, webadb Wi-Fi transport (Sept 14 roundup, referenced above)

So the only code change in the last two weeks is the
4728896 Wi-Fi
transport itself, and the only relevant diagnostic around it
(dee63ba,
the missing-API friendly-error panel).

What this means for webadb.online

No code changes required. The Direct Sockets permission-policy
merger affects only IWA manifests, and webadb is a regular web app.
The Permissions-Policy: direct-sockets=* header we ship today remains
correct.

The interesting follow-up — same as last Monday — is when the
Direct Sockets API row itself moves from is_released: false to
is_released: true. That is the milestone that unblocks the Wi-Fi
transport on stable Chrome for non-OT users. The chromestatus entry
has been quiet on that front for ~20 months now, and the channel
calendar doesn’t list it as upcoming on any milestone we can see. We
will keep monitoring on the next Monday re-check and report any
movement; absent that, webadb’s Wi-Fi transport continues to require
either a Chromium build with the origin trial enabled (e.g. Chrome
Beta with --enable-features=DirectSockets plus an OT token, or
ChromeOS with the IWA shell) or a WebUSB cable.

If you have a use case where the IWA-manifest change would affect you —
for example, if you’re building an IWA that uses Direct Sockets to talk
to a device on 192.168.x.x — the relevant manifest snippet post-M151
is:

1
2
3
4
5
6
7
8
9
10
{
"isolated_web_app": {
"version": "1.0.0",
"permissions_policy": {
"direct-sockets-private": [],
"local-network": ["self"],
"loopback-network": ["self"]
}
}
}

with "self" for the IWA itself, or a more specific origin if the
socket lives in a child context. The older "direct-sockets-private"
key remains valid for backward compatibility during a deprecation
window, but new manifests should declare the two finer-grained
policies.

Hello, webadb

This is the first post on the new webadb.online blog — release notes,
deep dives, and field reports from running webadb.online
in production.

What’s here

  • Release notes when we ship a meaningful change to the
    desktop-style UI, the WebUSB session layer, or the device bridge.
  • Deep dives into the trickier parts of the stack — unzip -p
    on the device, the ya-webadb
    shell protocol, how we get crossOriginIsolated === true over
    Cloudflare Pages, etc.
  • Field reports from real-device testing (currently a Xiaomi 13
    on HyperOS, plus whatever loaner lands on the desk).

How it’s built

The site itself is a Next.js static export served by Cloudflare Pages,
and the blog is a plain Hexo instance living in
blog/ whose output (hexo generate → public/blog/) gets folded into
the Next.js export on the next npm run build. One repo, one
project, two static-site generators.

The blog ships inside webadb.online/blog/, so the COOP/COEP headers
that gate SharedArrayBuffer on the main app don’t leak into the
blog’s HTML (we override them in public/_headers).

More soon.

Cross-origin isolation for webadb.online — COEP, COOP, and the GA4 fix

webadb.online’s screencast stream uses MediaSource to feed the
video element. MediaSource requires the page to be cross-origin
isolated. So does SharedArrayBuffer. So does OffscreenCanvas
with a worker. So do most of the high-performance browser APIs
that turn the browser into a real runtime rather than a document
viewer.

Cross-origin isolation is gated by two HTTP response headers:

1
2
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

The browser will set window.crossOriginIsolated === true only
when both are present, and the COEP policy is recursive — every
resource you load has to opt in. If you forget one script tag, one
iframe, one stylesheet, one image, the whole page goes into a
half-isolated state where MediaSource mysteriously throws
.

This post is the writeup of how we get isolation on without
breaking Google Analytics, which is a same-origin script but
originates cross-origin assets.

What “isolated” actually means

crossOriginIsolated is the property. When it’s true:

  • SharedArrayBuffer works (otherwise throws on construction).
  • Atomics.wait() / Atomics.notify() work.
  • High-resolution timers (performance.now() ≥ 5μs instead of
    100μs).
  • MediaSource and WebCodecs work without throwing
    Cannot use MediaSource on this document.
  • performance.measureUserAgentSpecificMemory() works.

The screencast panel needs MediaSource. The logcat panel needs
high-res timers to estimate line rates without drifting. The file
manager’s progress UI wants SharedArrayBuffer to share state
across the worker that streams uploads and the React thread that
paints. So we want isolation on, by default.

The headers, where they live

next.config.mjs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
headers: [
{
source: "/:path*",
headers: [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
],
},
{
// GA4's analytics.js is loaded with crossorigin="anonymous" in
// <script> tags, but the static analytics endpoint serves a
// different set of resources that don't send CORP headers
// we can rely on. Pre-validate them via the Credentialless
// tier.
source: "/_next/static/:path*",
headers: [
{ key: "Cross-Origin-Resource-Policy", value: "same-origin" },
],
},
],

Two policies in play:

  • COOP (same-origin): my page may only share a browsing
    context with same-origin pages. If a popup opened by my page
    navigates elsewhere, the popup gets a fresh context. This is
    fine for webadb — no popups at all.
  • COEP (require-corp): every resource my page loads must
    send Cross-Origin-Resource-Policy: same-origin / same-site
    / cross-origin, or be a same-origin load. There is no
    default-deny in older browsers but Chromium enforces it.

The GA4 problem

GA4 is loaded with this snippet:

1
2
3
4
5
6
7
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>

Two cross-origin requests happen:

  1. The script tag loads gtag/js. Google does send
    Cross-Origin-Resource-Policy: cross-origin on this, so it
    passes.
  2. The script itself then loads measurement resources from
    *.analytics.google.com, *.googletagmanager.com, and
    sometimes a third-party endpoint. Some of those don’t
    send a CORP header that passes the require-corp check.

The cleanest fix that doesn’t break GA4 is to flip the script
to crossorigin="anonymous" and rely on the GA endpoints that
do send CORP. That gets us 95% of cases.

The remaining 5% is the times GA loads a tag-manager config from
a third-party endpoint that hasn’t been CORP’d yet. When that
happens, the page still loads (the request succeeds), but the
response is opaque — GA can’t read the body. We don’t get
analytics for that request. The page still works, MediaSource
still works, the user doesn’t see anything.

The CDO (Cross-Origin-Opener-Policy-Report-Only) endpoint
pattern lets us collect reports on which subresources fail CORP
without breaking the page. We don’t have it set up yet — adding
it is on the list.

Why not credentialless

COEP has a credentialless mode that relaxes the CORP
requirement: any cross-origin resource is allowed as long as it
doesn’t carry credentials (no cookies, no client certs). This is
a strict relaxation — Google Analytics works because it doesn’t
need credentials for measurement requests.

1
<meta http-equiv="Cross-Origin-Embedder-Policy" content="credentialless" />

We tried this in staging and saw two regressions:

  1. WebUSB device picker stops working. The picker is itself
    a Chrome-internal page (chrome://device-internals/) that
    runs with credentials in some configurations. Credentialless
    pages can’t talk to it.
  2. Some WebUSB drivers’ WebUSB backend pages also start
    requiring credentials for the device-init handshake.

So require-corp is what we ship. The tradeoff is that GA4’s
fan-in endpoints sometimes go opaque; we accept that.

The dev-server gotcha

next dev doesn’t apply headers from next.config.mjs to its
dev server. They only get applied on next build + the static
export. This bites everyone the first time: dev server
isolation looks fine, prod doesn’t. Fix is in
scripts/dev-server-headers.mjs if you need it during local
development.

Verifying isolation is on

Three things to check after any change:

1
2
3
4
// From the browser console on webadb.online
window.crossOriginIsolated // → true
typeof SharedArrayBuffer // → 'function'
new MediaSource() instanceof MediaSource // → true (no throw)

If any of these fails, the headers didn’t reach the page. The
usual causes are:

  • The headers were added to next.config.mjs but next build
    wasn’t run, so they’re still in the dev server (which
    ignores them).
  • A new external resource was added (analytics endpoint,
    third-party widget) and it doesn’t send CORP.
  • A service-worker script (which has its own origin handling)
    is interfering.

We have a Playwright test in tests/isolation.spec.ts that hits
the production URL and asserts all three of the above, plus
that the screencast panel can actually open a MediaSource
without throwing. It runs on every deploy.

What isolation enables on webadb

Just to enumerate the things that depend on it, since the list
is non-obvious:

API Used by
MediaSource screencast video element
SharedArrayBuffer logcat ring buffer shared with a worker
Atomics.wait logcat worker’s backpressure signal
High-res performance.now() screencast frame-rate estimation, logcat QPS
WebCodecs (queued) future video encoder for screencast recording

If you’re building on the codebase, run the test before you
merge anything that adds an external resource — every new
external host has to play ball with COEP, and the failure mode
is “everything looks fine locally, prod mysteriously breaks”.

ya-webadb 3.0.0-beta.3 has an IndexedDB credential-storage regression — and why webadb.online isn't on the affected path

A regression report landed on the ya-webadb issue tracker yesterday
(yume-chan/ya-webadb#870)
that breaks TangoIndexedDbStorage.load() for every caller of
@yume-chan/[email protected]. The repro is one line and
the error message (Error: callback must not be an async function) is
specific enough to be a useful fingerprint if you’re triaging a
“connect button does nothing” bug on a downstream app.

This post is the writeup of the two underlying bugs, the commit that
introduced them, and the read-through that explains why webadb.online
sits on the other credential backend and is therefore unaffected. If
you’re integrating ya-webadb into your own app and you went with
IndexedDB instead of LocalStorage, this will cost you an afternoon if
you don’t catch it before shipping.

The symptom

The minimal repro from the issue:

1
2
3
4
5
const storage = new TangoIndexedDbStorage();
for await (const key of storage.load()) {}
// Error: callback must not be an async function
// Uncaught (in promise) AbortError: The transaction was aborted,
// so the request cannot be fulfilled.

storage.save() works the first time. The second save() throws
InvalidStateError: connection already closed. The issue reporter
flagged this in the adbDaemonAuthenticate() path with
AdbWebCryptoCredentialManager(new TangoIndexedDbStorage(), name),
which is the documented setup for the IndexedDB backend — so the
failure happens before the device is even asked to authorize anything,
on both the USB daemon and the TCP daemon transports.

Two independent bugs in two files, both introduced by the same PR
(#832, merged
1522b5f, via
the follow-up commit b6da6b1cfd
tagged “fix: review comments”). The original PR added reading the
scrcpy server listening address from env; the IndexedDB changes were
incidental drive-by refactors riding along on review feedback.

Bug #1 — createTransaction rejects any Promise return

libraries/adb-credential-web/src/storage/indexed-db/shared.ts lines
27-61 are a small wrapper around IDBTransaction that resolves with
whatever the callback returned:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function createTransaction<T>(
database: IDBDatabase,
storeName: string,
callback: (transaction: IDBTransaction) => T,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const transaction = database.transaction(storeName, "readwrite");
// ...
try {
result = callback(transaction);
if (result instanceof Promise) {
throw new Error("callback must not be an async function");
}
} catch (e) {
// ...
try { transaction.abort(); } catch {}
}
});
}

The intent is reasonable: callback fires synchronously, transaction
auto-commits, we resolve in oncomplete. The problem is that
IDBRequest is inherently asynchronous — getAll(), get(), put(),
add() all return IDBRequest whose .result isn’t available until
the next microtask. The natural way to bridge that is to return
waitRequest(store.getAll()) from the callback. That waitRequest
helper is a Promise<T> (defined right above, lines 1-10 of the same
file). And result instanceof Promise matches it. Boom.

The load() method in v2.ts does exactly this:

1
2
3
4
const keys = await createTransaction(db, this.#storeName, (tx) => {
const store = tx.objectStore(this.#storeName);
return waitRequest(store.getAll() as IDBRequest<TangoKey[]>);
});

So load() rejects with Error: callback must not be an async function
on every call. Then the helper runs transaction.abort() to clean up,
which fails the getAll() request mid-flight, which produces the
unhandled AbortError. Two error events from one bug.

The old version (pre-b6da6b1cfd) “adopted” the promise the callback
returned and awaited it inside the Promise constructor. That worked
for IDBRequest because waitRequest is a thenable. The refactor
tried to enforce a stricter contract (sync callback only, resolve in
oncomplete) but didn’t migrate the call sites.

The fix from the issue reporter is clean:

1
2
3
4
5
6
transaction.oncomplete = () => {
resolve(request.result); // every request has settled by oncomplete
};
// callback returns the IDBRequest itself, not a Promise
const request = callback(transaction);
if (request instanceof Promise) throw ...

Returning the IDBRequest (not a Promise) and resolving in
oncomplete with request.result works because by the time
oncomplete fires, every request started in the transaction has
already completed and .result is populated.

Bug #2 — cached connection closed after every operation

v2.ts caches the database connection in a private promise:

1
2
3
4
5
#openDatabasePromise: Promise<IDBDatabase> | undefined;

async #openDatabase() {
return (this.#openDatabasePromise ??= this.#openDatabaseCore());
}

But save(), load(), and clear() each call db.close() in a
finally:

1
2
3
4
5
6
7
8
async save(privateKey, name) {
const db = await this.#openDatabase();
try {
await createTransaction(db, this.#storeName, (tx) => { ... });
} finally {
db.close(); // ← closes the cached connection
}
}

So the second operation gets back the same cached connection from
#openDatabasePromise — but that connection has been closed by the
first operation’s finally. IndexedDB throws InvalidStateError the
moment you try to start a transaction on a closed IDBDatabase. The
“#openDatabasePromise” cache survives the close because nothing resets
it, so every subsequent call also gets the closed handle.

The pre-b6da6b1cfd version opened a fresh IDBDatabase per
operation. Slow (each openDatabase call goes through onupgradeneeded
gating on the version number), but correct. The cache was a review
suggestion to amortize that cost. The reviewer missed the finally
blocks.

Two reasonable fixes:

1
2
3
4
5
6
7
8
9
10
11
// Option A: keep the per-operation close, drop the cache
async #openDatabase() {
return this.#openDatabaseCore(); // no ??=, no memoization
}

// Option B: keep the cache, drop the closes
async save(privateKey, name) {
const db = await this.#openDatabase();
await createTransaction(db, this.#storeName, (tx) => { ... });
// no finally { db.close() }
}

Option A is the safer default — IDBDatabase handles concurrent
connections from the same origin fine, and the close ensures any
underlying IndexedDB worker thread is freed when the consumer goes
idle. Option B is faster for hot loops (key rotation, etc.) but
requires the consumer to manually close() the storage instance.

Why both broke at once

The PR description on #832 is about scrcpy server env variables. The
IndexedDB changes came in as “review feedback” cleanup on the same
PR — a git log on the file confirms only one commit touches it:

1
1522b5f feat(adb/server): support reading server listening address from env (#832)

So b6da6b1cfd (“fix: review comments”) was the only commit that
ever touched shared.ts and v2.ts. The refactor was bundled in
because the IndexedDB helpers were touched up incidentally during the
scrcpy PR’s review cycle. If you have a local fork of
adb-credential-web predating this PR, you’re unaffected; if you
upgraded past beta.2, both bugs are in your tree.

How to detect this in your own app

Two fingerprints from the issue are enough to triage:

  1. Error: callback must not be an async function from
    createTransaction in any IndexedDB credential flow → Bug #1.
  2. InvalidStateError: connection already closed on the second
    storage.save(...) call after a successful first save → Bug #2.

If you see both, you’re on beta.3 with the IndexedDB backend, and
your credential storage is permanently broken — adbDaemonAuthenticate
fails before the device is asked to authorize.

Three workarounds for downstream apps that need to keep using
beta.3:

  • Pin 3.0.0-beta.2 (yume-chan/ya-webadb@7ab6729) until the fix
    ships. beta.2 doesn’t have either bug.
  • Switch backends to TangoLocalStorage (the LocalStorage-backed
    credential store in the same package). It’s a one-line change in
    AdbWebCryptoCredentialManager(new TangoLocalStorage(...), name)
    and the wire format is identical.
  • Monkey-patch TangoIndexedDbStorage in your app bundle — patch
    createTransaction to not throw on Promise returns, and remove
    the finally { db.close() } from the three operation methods. We
    did not ship this as a PR; the bug is still open and unfixed at
    the time of writing.

What this means for webadb.online

webadb.online is not on the affected path. Our credential backend is
TangoLocalStorage, not TangoIndexedDbStorage — the choice was
made in lib/adb-client.ts:89
when we wired up AdbWebCryptoCredentialManager against
TangoLocalStorage(ADB_KEY_STORAGE_KEY).
The reason we picked LocalStorage over IndexedDB back then was
operational, not technical: LocalStorage keys are inspectable from
DevTools → Application → Local Storage, which made “why won’t my
stored credential work?” support tickets faster to triage. IndexedDB
requires you to open the database, expand the object store, and
page through cursor results — fine for code, hostile for users
self-diagnosing.

We also pin [email protected], not beta.3, for unrelated
reasons that we wrote up
here
last week. So neither of these bugs is reachable from our deployed
bundle, and the IndexedDB bug being open upstream does not delay any
shipped feature.

What we will be watching for: a fix PR from the upstream maintainer
that closes the issue, and the eventual 3.0.0-beta.4 (or RC) that
will be the version we pin next. The push-completion fix in
69fffaf0
(the headline beta.3 change for our File Manager upload path) is
genuinely worth the bump, but we won’t move until both the IndexedDB
regression is closed and we have a few weeks of beta.3 field time
without further regressions. Two regression-shaped releases in a row
is enough to make us cautious.

If you’re maintaining your own ya-webadb fork, the take-aways are:

  1. createTransaction needs to accept async callbacks or the
    call sites need to return IDBRequest directly.
    The issue
    reporter’s suggested fix (return request, resolve with
    request.result in oncomplete) is the cleaner direction.
  2. A cached IDBDatabase cannot be closed by the operation that
    opened it
    unless you reset the cache. Either don’t cache, or
    don’t close per-operation.
  3. Drive-by refactors in a release PR are still part of the
    release diff.
    The scrcpy-server env-var PR shipped two
    IndexedDB bugs as a side effect because the review feedback was
    bundled into the same commit. The fix-everything-in-one-PR
    instinct is understandable but it makes bisecting downstream
    failures harder than splitting the IndexedDB cleanup into its
    own PR.

The full report, with the exact libraries/adb-credential-web/src/
file paths and a suggested fix, is at
yume-chan/ya-webadb#870.

How webadb.online turns your browser tab into an ADB client

Open webadb.online in Chrome, hit Connect device, pick your phone in
the picker, and a seconds later a >_ shell is running in your browser —
no driver install, no daemon, no native binary. There is no server in the
loop. USB traffic never leaves your machine. This post walks through how
that actually works, end to end: from the moment Chrome renders the
device picker to the moment shell.exec("ls") returns text in your
terminal pane.

The three actors

The whole stack has three components that need to agree on a wire
protocol:

Component Runs in Talks to
Chromium browser your machine Android device via USB
webadb.online a Cloudflare Pages static bundle Chromium (your tab)
ya-webadb bundled in webadb.online (@yume-chan/adb, @yume-chan/adb-daemon-webusb) Chromium → device

ya-webadb is the TypeScript ADB implementation that makes the browser
side possible. It reimplements the ADB protocol on top of a
UsbConnectionInterface that you wire up — for web, that’s the
WebUSB-backed WebUsbDaemonConnection from the -webusb companion
package. We don’t fork ya-webadb; we just import it and feed it the
device the user picked.

Step 1 — the device picker

The whole flow starts with a single line in lib/use-connect-actions.ts:

1
const device = await AdbDaemonWebUsbDeviceManager.requestDevice();

That call hands control to Chrome, which renders the native USB device
picker (anchored to the top-left of the page on macOS, top-center on
Windows). Chrome reads every USB device the host kernel knows about,
filters the ones exposing the ADB interface (vendor 0x18d1 /
0x04e8 / 0x12d1 / …), and shows just those.

Once you pick one, Chrome returns a USBDevice handle. Your
browser tab, not webadb.online, owns that handle.
From this point on,
no other browser tab and no host-side adb daemon can talk to the
phone — the kernel driver is bound to whichever process opened the
device. If you try adb devices on the host while a webadb.online
session is live, you’ll see no devices. This isn’t a bug, it’s how USB
works.

USBDevice is then passed to WebUsbDaemonConnection, which builds
the ADB daemon transport around it.

Step 2 — the ADB handshake

ADB is a length-prefixed binary protocol over a bulk USB endpoint.
Every “connection” is a stream of framed packets; each packet has a
24-byte header (command, arg0, arg1, payload length, magic, checksum)
followed by a payload. ADB version, max payload, banner, etc. are
exchanged in plain ASCII during OPEN.

ya-webadb implements the entire protocol in pure TypeScript. Its
AdbDaemonConnection consumes the framed stream from WebUSB, dispatches
by command, and gives you back Adb objects that wrap sync / async /
shell / file services.

Our lib/adb-client.ts is a thin wrapper around that. It owns:

  • the Adb object (one per session)
  • a disposers array so the React effect cleanup can call .close()
    on the connection when the user disconnects
  • a tiny event bus that the React layer subscribes to

The Adb object itself exposes typed services — AdbSync, AdbShell,
AdbFile, AdbReverse, AdbForward, AdbTcp, AdbPower
that’s every subsystem the panels use.

Step 3 — auth (the RSA fingerprint dance)

ADB requires an RSA keypair to authenticate the host. The browser
generates one with crypto.subtle.generateKey on first connect; it
persists in IndexedDB (the webadb-online:credentials database).
Every subsequent connection uses the same key.

On the phone, the first time you connect with a new host key, Android
pops a system dialog asking you to confirm the fingerprint of the host
key. The user’s “Always allow from this computer” check permanently
trusts the key. After that, the browser-side auth is invisible.

This is the part people hit most often. If you tapped Cancel once
and now the panel says “device unauthorized”, the fix is to revoke the
authorization from Developer Options on the phone (USB debugging
settings → Revoke authorizations → re-plug). The browser-side key
stays the same, so you don’t have to re-grant every app — just once
per device.

Step 4 — per-service plumbing

Once Adb.authenticate() returns, the panels open connections to
specific services:

1
2
// from lib/screencast/pipeline.ts
const stream = await adb.shell.raw(`screenrecord ...`);

ADB services are addressed by name. shell:v2:raw:command opens a
shell subprocess. sync: opens the file service (used by File
Manager, APK install). shell:exec:command runs a one-shot exec
without a PTY. Each of these has its own framing — some are
length-prefixed streams, some are bidi, some have their own handshake.

ya-webadb models each service as a class. When a panel calls
adb.shell.raw("..."), ya-webadb:

  1. Opens a new USB bulk endpoint pair (in/out).
  2. Sends OPEN(1, "shell:v2:raw:screenrecord ...") to the daemon.
  3. The daemon forks the subprocess and pipes its stdout to the bulk
    in endpoint of the new connection.
  4. Returns a ReadableStream<Uint8Array> that yields the subprocess
    output as it’s produced.

Step 5 — streaming binary data (the screencast case)

Screencast is the gnarliest user of the protocol because it streams
megabytes per second through a 16-MB-payload USB endpoint, where each
MP4 chunk is its own complete ISO BMFF file. We use shell:v2:raw
specifically because the default shell:exec: protocol caps payloads
at the device-side MAX_PAYLOAD from OPEN (usually 256 KB on modern
phones). The :raw variant skips that aggregation layer and gives us
the raw fd, which is what screenrecord --output - would write to —
except HyperOS won’t let screenrecord write to stdout, so we use the
file-mode trick (writing to /sdcard/webadb-screencast.mp4 and polling
the file via a separate stat / dd shell command — see the
screencast deep-dive for why this works and what edge cases we hit).

Step 6 — disconnect

webadb.online only holds the USB device for the lifetime of the tab.
When you close the tab, hit Disconnect in the top bar, or refresh,
the cleanup effect in use-connect-actions.ts calls adb.close()
which:

  1. Sends CLOSE on every open service.
  2. Calls USBDevice.close() on the WebUSB handle.
  3. Releases the kernel driver back to the host (so adb devices on
    the host works again).

The browser may also revoke the persisted device permission, depending
on the version of Chrome. To re-grant, you click Connect device
again — the picker remembers the last device, so it’s one click, not a
fresh flow.

What this means for the security model

Three properties worth calling out:

  1. Zero server trust. The static bundle never talks to anything
    other than your USB device. The Cloudflare Pages origin only serves
    files. There’s no analytics endpoint, no session server, no debug
    ping. GA4 is loaded with anonymize_ip and respects
    prefers-reduced-motion so it doesn’t change behavior in
    privacy-sensitive contexts.

  2. The USB device grant is the security boundary. WebUSB grants
    are per-origin (scheme + host + port). A different origin can’t
    reuse your grant. That’s why we don’t embed a third-party iframe
    for the ADB handshake even when the temptation is real (CDN
    debugging, support tooling).

  3. The persistent key is local. Your ADB host key is in
    IndexedDB under the webadb.online origin. If you clear site data
    the next connection will produce a new key and the phone will ask
    you to authorize again. There’s no way to “back up” the key
    across browsers; that’s intentional.

What’s in the queue

The deeper-cut topics we’ll cover in separate posts:

  • the moov-at-end / rotating-recorder design in the screencast panel,
    and why we can’t just rely on screenrecord - stdout on modern
    Android
  • cross-origin isolation (COEP require-corp + COOP) and the exact
    sequence of headers that lets us use MediaSource,
    SharedArrayBuffer, and WebUSB in the same page without breaking
    GA4
  • the roadmap: audio capture, app deep links, Scrcpy-level touch
    emulation, and a real installer
  • the macOS Big Sur window chrome — why we did it, what the
    accessibility trade-offs are, and how the dock persists state

If you want to peek at the protocol itself, @yume-chan/adb ships a
debugging dump mode that logs every frame to the console — useful
when you’re trying to figure out what cp -r is doing under the hood.

Inside the webadb screencast panel — streaming a device screen via MSE + fMP4

The screencast panel is the only place on webadb.online where bytes
flow from the device into the browser at the speed of a small video
stream. Everything else — terminal output, logcat, file uploads — is
small text. The screencast pipeline is a few hundred lines of code
that has to deal with three things that are not in any textbook:

  1. screenrecord writes the moov box only at end-of-recording
    the device-side file is half-finished at every moment during the
    recording. ISO BMFF init segments aren’t optional.
  2. screenrecord - (stdout mode) is broken on HyperOS — the
    kernel returns “Read-only filesystem” when screenrecord tries to
    open stdout. So we have to use the file mode and poll a path on
    /sdcard.
  3. The 4-GB size limit on 32-bit box headers — Xiaomi’s
    screenrecord uses 64-bit largesize boxes for the mdat, which
    need BigInt parsing on the JavaScript side.

This post is the writeup of the design that finally shipped — commit
b8fcb37 on main, after four false starts.

The naive plan (what doesn’t work)

The most straightforward screencast implementation is:

1
2
3
4
const stream = await adb.shell.raw("screenrecord - --time-limit 5");
const chunks: Uint8Array[] = [];
for await (const chunk of stream) chunks.push(chunk);
// stitch chunks, mux into fMP4, appendBuffer to MediaSource

The muxing step is the part mp4-muxer does for you. You push H.264
NALs in, you get fMP4 init + media segments out, you append them to a
SourceBuffer and video.src = MediaSource plays it. This is the
shape of the implementation most scrcpy-web clones use.

Two problems:

  1. screenrecord - fails on Xiaomi / HyperOS / API 36. It writes
    to stdout, then the kernel refuses because stdout isn’t a regular
    file on Android. You get error: cannot open 'w-': Read-only filesystem. You can verify this on your phone with
    adb shell "screenrecord - /sdcard/foo.mp4" — same error.

  2. Even where stdout works, the moov box lives in screenrecord’s
    internal buffer until end-of-recording.
    If you mux from raw
    NALs yourself you don’t need moov (you build your own init
    segment with SPS/PPS extracted from the first IDR). But every
    short-lived recording of 3 seconds means 3 seconds of
    dead video at the start — the mux needs an IDR to extract
    SPS/PPS, and screenrecord emits an IDR every couple of seconds,
    so you usually wait ~2 seconds before the first frame paints.

    The dead-video problem is also why trying to do the obvious
    thing — pipe screenrecord stdout straight into your muxer —
    gives you a panel where the video is permanently ~3 seconds
    behind real time.

What we landed on

Use screenrecord in file mode, then poll the file from a
second adb shell command. Use screenrecord’s own ftyp + moov
boxes verbatim (no custom muxing) and stream them to MediaSource.

This works because screenrecord writes complete, self-contained MP4
files. Every file has ftyp at offset 0, mdat in the middle, and moov
at the end. The moov contains the avcC box which has the SPS/PPS.
The mdat contains only H.264 NALs (no SPS/PPS — those are in avcC).
This is verifiable by pulling a finished recording off the device
and running ffprobe:

1
2
3
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'r3.mp4':
Duration: 00:00:04.96, bitrate: 1185 kb/s
Stream #0:0: Video: h264 (High), 480x1072, 1185 kb/s, 30 fps

…and parsing the mdat’s first NALs with a custom walker that
classifies each by NAL type byte: you see [5, 1, 5, 1] (IDR +
non-IDR) but never a 7 (SPS) or 8 (PPS).

So: device gives us a self-contained MP4; we ship it verbatim to
MSE.

But we have the live-streaming problem — screenrecord takes the full
--time-limit to write moov, and we don’t want to wait 3 seconds
with a black screen.

Rotating short recordings

The fix is rotating recordings:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const CHUNK_SECONDS = 3;
while (!stopRequested) {
await shell.spawn(["rm", "-f", STREAM_PATH]).wait();
const proc = await shell.spawn([
"screenrecord",
"--size", `${w}x${h}`,
"--bit-rate", String(bitrate),
"--time-limit", String(CHUNK_SECONDS),
STREAM_PATH,
]);
// Auto-kill in case screenrecord ignores --time-limit on weird ROMs.
setTimeout(() => proc.kill(), (CHUNK_SECONDS + 1) * 1000);
await sleep(CHUNK_ROTATION_SECONDS * 1000); // CHUNK_SECONDS + 2
proc.kill(); // forces moov flush
await sleep(500);
}

Each iteration produces a complete MP4 file with its own moov. We
use the first chunk’s ftyp + moov as the init segment for the
entire MSE session. Subsequent chunks’ mdat content gets appended
as media segments — their ftyp and moov are discarded.

Why this works:

  • screenrecord uses the same encoder config across runs (same
    resolution, same bitrate, same frame rate), so the SPS/PPS in
    every chunk’s avcC are identical to the first chunk’s.
  • MSE remembers the init segment’s stsd/stts/stsc/stsz/stco tables
    and uses them to interpret subsequent media samples. As long as
    the new mdat bytes correspond to those tables, MSE is happy.

There’s a brief gap between chunks — the kill / rm / re-spawn takes
~500 ms during which the file is empty. The MediaSource stays open
and the buffered video keeps playing back during that gap. The
user sees a brief stutter every ~5 seconds, which is much better
than 3 seconds of black screen at start.

Polling a growing file (the part that took longest)

The naive “poll the device-side file” function does this:

1
2
3
4
// WRONG — DO NOT USE
const stream = await shell.spawn([
"tail", "-c", `+${offset + 1}`, STREAM_PATH,
]);

That looks correct but blocks forever when screenrecord is
actively writing to the file. tail -c +N reads from offset N to
EOF and only terminates on EOF — for a file with an open writer,
EOF never comes, so the process never exits, so
ya-webadb‘s shell.spawn().wait() (which awaits
process.exited) never resolves.

The CDP-attached trace that surfaced this is on file in the
screencast saga — [screencast] screenrecord chunk started was
logged, then zero poll lines, even after 20 seconds. The shell
subprocess was just hanging.

Three iterations to fix:

Attempt What we tried Why it failed
tail -c +N Standard incremental tail Blocks on never-EOF writer
wc -c < file One-shot, returns on EOF Same problem — wc also reads to EOF
dd if=… skip=… count= Read N bytes only count cap was missing initially

The actual working implementation uses two shells in sequence:

1
2
3
4
5
6
7
8
9
10
11
12
13
// size probe — stat() is a syscall, never reads content
const size = await shell.spawn(["stat", "-c", "%s", STREAM_PATH])
.wait().toString();

// bounded read — dd exits after reading `count` bytes
const bytes = await shell.spawn([
"dd", `if=${STREAM_PATH}`,
"bs=1", `skip=${offset}`, `count=${want}`,
]).then(p => {
// pipe stdout through ReadableStream
const reader = (p.stdout as ReadableStream).getReader();
// … collect chunks
});

stat -c %s returns the current file size immediately even if
screenrecord is mid-write — the kernel tracks size on every
write(2) call regardless of whether the process has called
fdatasync(2). And dd exits the moment it’s read count bytes,
which terminates the shell subprocess and resolves .wait().

The 8-MB per-poll cap on want keeps each shell transfer
bounded — if we fall way behind for some reason we never drain
megabytes through a single shell call.

The BigInt detour

The mdat box in screenrecord’s output is huge (hundreds of MB for
a 30-min recording). When the file size crosses 4 GB the box
header switches from a 32-bit size to a 64-bit largesize with a
length-1 sentinel at offset 0–3 and the real size at offset 8–15.

JS Number only has 53-bit mantissa precision. Reading the largesize
as hi * 2^32 + lo silently rounds once hi > 1, which gives you
garbage. CDP traces showed mdat(4557430888798830600 @24) — the
4557430888798830600 is 0x3f3f3f3f3f3f4008, which is actually
uninitialized kernel page bytes (the file was allocated but the
content hadn’t been written yet, so the largesize field reads as
sparse placeholder bytes). Even after the field is properly
initialized, the JS rounding still corrupts large values.

Fix:

1
2
3
4
5
6
7
const big = new DataView(
bytes.buffer, bytes.byteOffset + off + 8, 8
).getBigUint64(0, false);
const MAX = BigInt(Number.MAX_SAFE_INTEGER);
const totalSize = big > MAX
? Number.MAX_SAFE_INTEGER
: Number(big);

The clamp to Number.MAX_SAFE_INTEGER is only a safety net —
no real recording on a phone will hit 8 PB. In practice the BigInt
read gives us the correct size every time.

Walkers, not magic numbers

The mp4 box walker used to be a single-purpose findMdatOffset()
function that returned one offset. As soon as we needed to find
ftyp, moov, and mdat in one pass it became a one-shot walker
that records every top-level box it sees:

1
2
3
4
const walkTopBoxes = (bytes: Uint8Array): Array<{
type: string; headerOffset: number;
contentOffset: number; contentLength: number;
}> | null => { /* … */ }

A generic walker is more code but it’s easier to reason about and
easier to add a new box type to (which we did a few times —
adding ftyp capture for the first chunk’s ftyp box so the init
segment uses screenrecord’s exact ftyp verbatim, including the
right brand string and minor version).

The init / media dispatch

The dispatch logic is the part of the pipeline most likely to
silently misroute bytes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const boxes = walkTopBoxes(bytes);
let newFtyp: Uint8Array | null = null;
let newMdatContent: Uint8Array | null = null;
let newMoov: Uint8Array | null = null;
for (const box of boxes) {
if (box.type === "ftyp" && !ftypBytes) newFtyp = bytes.slice(...);
else if (box.type === "mdat") newMdatContent = bytes.slice(...);
else if (box.type === "moov") newMoov = bytes.slice(...);
}

if (newFtyp) ftypBytes = newFtyp;
if (newMdatContent) pendingMdatContent.push(newMdatContent);

if (newMoov && !initSegmentSent) {
// First moov arrives — send init, then flush pending mdat
initSegmentSent = true;
await sourceOpenPromise;
const codec = extractAvcCodec(bytes, newMoov);
const sb = mediaSource.addSourceBuffer(codec);
await sb.appendBufferAsync(concat(ftypBytes, newMoov));
await sb.appendBufferAsync(concat(...pendingMdatContent));
pendingMdatContent = [];
} else if (initSegmentSent && newMdatContent) {
// Subsequent chunks — just append new mdat content
await sourceBuffer.appendBufferAsync(newMdatContent);
}

Three branches, all named: buffer-only (no moov yet),
first-flush (init + buffered mdat), streaming (subsequent
mdat). The initSegmentSent flag is the line that separates
“we’re buffering” from “we’re streaming”.

What would change for audio

screenrecord v3 on AOSP can capture mic audio with
--audio-source MIC, but Xiaomi/HyperOS strips that flag. We
don’t have an audio path yet. The cleanest fix when it ships is
to mux the audio track into the same fMP4 init segment — the
codec extraction in extractAvcCodec becomes
extractAvcAndAudioCodecs returning video/mp4; codecs="avc1.X, mp4a.40.2" and the rest of the pipeline is unchanged. Pulled
into a separate post when we have hardware that supports it.

Where to look in the code

  • lib/screencast/pipeline.ts — the whole pipeline (~700 lines)
  • lib/screencast/types.ts — the codec + progress message types
  • components/ScreencastPanel.tsx — the React panel that owns
    the video element and dispatches start/stop

A future post will dive into the cross-origin isolation setup
(COEP / COOP / CORP) that makes MediaSource and
SharedArrayBuffer available without breaking GA4 — that’s a
whole separate can of worms.

webadb.online roadmap — what's next, what's blocked, what we need

This is the post I’d want to read if I were considering using
webadb.online for something serious and wanted to know what’s
actually in the queue. Concrete features, ordered by where they
are in the development cycle, with the blockers called out.

We’re at v0.1.0, with twelve panels and ~5k LOC of TypeScript.
Things that work, work well — the screencast saga is the
long-running one and even that has stabilized. The notes below
are about the gaps.

Now (the next 6 weeks)

Screencast audio

screenrecord --audio-source MIC works on AOSP but Xiaomi /
HyperOS strips the flag. We’ve confirmed via adb shell screenrecord --help that the option is silently dropped before
the binary starts, so we can’t even pass it through. The two
paths forward:

  1. Capture audio on the device via a different route (e.g. a
    tiny helper app we side-load) and mux it into the fMP4 init
    segment at the browser side.
  2. Wait for Xiaomi to ship unstripped screenrecord.

The fMP4 muxing is already in the design — extractAvcCodec
becomes extractAvcAndAudioCodecs and the
addSourceBuffer(codecs) call gets the combined codec string
(video/mp4; codecs="avc1.X,mp4a.40.2"). Maybe 40 lines of
change once we have a source.

Clipboard panel for bidirectional text + image

Today the clipboard panel reads text from the device clipboard.
Bidirectional text works via the cmd clipboard set-text
service. Images don’t work because the device-side clipboard
service only carries text; images on Android are stored
per-app, not in a system-wide clipboard, until Android 14’s
“default clipboard” opt-in lands.

Scrcpy-level touch

The screencast panel currently has no input — it’s strictly
view-only. Touch is the obvious next addition. The
implementation route is adb shell input touchscreen tap x y,
which is on-device latency of 100–200 ms. Scrcpy does better
with a custom InputManager service but that’s not accessible
without root.

For mouse: same primitive, different event type (tap vs
swipe). We can ship click + drag + scroll with no
architectural change. The harder half is multi-touch (pinch
to zoom) which needs input touchscreen calls in a specific
sequence with a tight timing budget.

Recursive filename search with a regex, plus content grep on
text files (capped at ~10MB per file so we don’t blow the
device’s RAM). The wiring is there — AdbSync.read() streams
chunks — we just haven’t built the UI for it.

Next (6–16 weeks)

Multi-device switching

Today the topbar shows one device. If you have two phones
plugged in via a hub, you connect to one, then disconnect,
then connect to the other. There’s no parallel-session model.
Adding it would mean:

  • An AdbConnection[] instead of Adb | null in the React
    store.
  • Each panel takes a deviceId prop and looks up the right
    connection.
  • The screencast pipeline is the gnarliest port — its
    spawn / kill / file-poll loop has to be per-device.

We’d want this for power users. It’s not in the queue for the
first six weeks because it’s the kind of feature that
needs careful state-management work to avoid cross-device
pollution.

adb shell am start -W -a android.intent.action.VIEW -d URI
will deep-link into an installed app. Adding a panel that lets
you bookmark a list of deep links (per app) and fire them with
a click is two days of work. Useful for QA, useful for
presentations.

Persistent host key backup / restore

The browser’s ADB keypair lives in IndexedDB. There’s no way
to export it. Adding an export-to-PEM button is maybe 50 lines.
Restoring from PEM is another 50. Both useful for people who
have multiple browsers and want to skip the phone’s “Allow USB
debugging?” dialog when they switch.

Later (next 6+ months)

Installer / launcher

A package that bundles webadb.online with the ability to detect
when the user has Chrome installed and one-click register a
chrome://webadb/ shortcut. Outside the scope of a Cloudflare
Pages static bundle, but doable as a small Electron or
Tauri-style binary that wraps the same bundle.

WebRTC tunneling for low-latency screencast

adb reverse is fine for shell and other bidirectional
streams, but the screencast path polls a file on the device.
A WebRTC bridge would let us send H.264 over a peer connection
to the browser and skip the polling round trip entirely.
Latency drop: ~150ms. Effort: large — needs a small native
helper on the device that owns the encoder.

Account sync

Multi-device workflow needs an opt-in account layer to sync
saved layouts, app shortcuts, the persistent ADB key, and the
last device used. The data is small enough to fit in any
cloud provider; we’d never see the content of your shell
sessions, only metadata.

We’re deliberately not building this until there’s actual
demand. Account systems are expensive to design well and easy
to design badly.

What’s blocked (and why)

Wireless ADB out of the box

The Wi-Fi ADB panel exists and works for devices that
already have wireless debugging enabled. We can’t toggle
wireless debugging on by default because the API to do so
(adb shell settings put global adb_wifi_enabled 1) is gated
behind either root or a specific permission that Xiaomi doesn’t
grant over USB. This isn’t a web limit — it’s an Android one.

Multi-touch input

As noted under Scrcpy-level touch — needs InputManager service
which needs root. There might be a way with vendor-specific
utilities (Xiaomi has cmd input extensions) but we haven’t
checked every ROM variant. Will dig into it once a Xiaomi
engineering contact surfaces.

adb backup / adb restore

Backup is a 4-year-deprecated shell protocol that Google
keeps around but won’t fix. Most modern apps refuse to
participate (you have to opt-in via android:allowBackup="false"
which many devs do). The wire format is documented but the
end-to-end is broken enough that we’d be building a
compatibility shim. Not in the queue.

What we need from users

If you’re using webadb daily and want something done, the most
useful things you can do are:

  1. Report what device + OS combo you’re on when something
    doesn’t work. The screencast saga was unblocked by a single
    adb shell screenrecord --help output from a Xiaomi
    user.
  2. Open issues for actual blockers, not feature requests.
    “I want my layout to persist across sessions” we already
    built; “I want feature X” is on the roadmap. The first type
    of issue gets a fix in days, the second waits for someone
    to want it hard enough to build.
  3. Don’t ask for cloud sync. We won’t do it. If you want
    it, fork and build it. Sync systems are where privacy
    disasters happen and the security model of webadb
    specifically avoids server state.

Where to follow along

The release notes go in this blog (you’ll see them tagged
release-notes). The detailed engineering writeups are
tagged deep-dive. The roadmap itself is just this post,
updated quarterly.

If you’ve read this far, the next post in the series is
The macOS Big Sur chrome — how the window manager, the
dock, the draggable + maximizable desktop windows, and the
state persistence work, and why we picked that aesthetic
over a more “PWA-y” mobile-shell look.