ya-webadb v3.0.0-beta.4 ships scrcpy 5.0/5.0.1 aliases and rewrites `bipedal` around `Generator`

ya-webadb v3.0.0-beta.4 was tagged on 2026-10-09 with four commits
on top of v3.0.0-beta.3:

  • d8d1ddc —
    feat(scrcpy): add version 5.0 and 5.0.1 as aliases
  • 53f3b7a —
    fix: use consistent Node.js type names
  • 991335b —
    chore: run prettier --check in CI
  • 7bcec61 —
    chore: release v3.0.0-beta.4

It also pulls the two commits that were already sitting on main and
un-tagged when we wrote up
2026-10-04
(PR #871’s IndexedDB fix and the WebUSB NotFoundError handler) into a
coherent beta release. The headline feature is the new
AdbScrcpyOptions5_0 / 5_0_1 client classes that let consumers talk
to scrcpy server 5.0 and 5.0.1. The interesting infrastructure work
is the rewrite of bipedal in
@yume-chan/struct,
which switched from a hand-rolled { resolved, error } sentinel
protocol to a normal iterator.next(value) / iterator.throw(error)
generator protocol.

This post is the read-through: how the new scrcpy aliases plug into
the existing version-permutations architecture, why the maintainer
went for aliases instead of a real 5_0 implementation (and why that’s
fine), what bipedal actually does under the hood, and what changed
in the rewrite that’s worth noticing even if you never call
bipedal yourself.

The new 5_0 / 5_0_1 aliases are version-permutations, not a rewrite

The repo has had one source file per scrcpy server version since
1.x. Files like 2_1.ts, 3_3_3.ts, 4_0.ts, 4_1.ts each declare
a class extending the matching ScrcpyOptions<X> type from
@yume-chan/scrcpy. The pattern for beta.4 is the same — what changes
is that the “latest” pointer now flips from 4_1 to 5_0_1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// libraries/adb-scrcpy/src/latest.ts (beta.3)
import { AdbScrcpyOptions4_1 } from "./4_1.js";

export class AdbScrcpyOptionsLatest<
TInit extends AdbScrcpyOptions4_1.Init = AdbScrcpyOptions4_1.Init,
> extends AdbScrcpyOptions4_1<TInit> {
constructor(init: TInit, clientOptions?: AdbScrcpyClientOptions) {
super(init, clientOptions);
}
}

// libraries/adb-scrcpy/src/latest.ts (beta.4)
import { AdbScrcpyOptions5_0_1 } from "./5_0_1.js";

export class AdbScrcpyOptionsLatest<
TInit extends AdbScrcpyOptions5_0_1.Init = AdbScrcpyOptions5_0_1.Init,
> extends AdbScrcpyOptions5_0_1<TInit> {
constructor(init: TInit, clientOptions?: AdbScrcpyClientOptions) {
super(init, clientOptions);
}
}

Two new files appear, both doing exactly the same thing as their 4_1
counterparts:

1
2
3
4
5
// libraries/scrcpy/src/5_0.ts (new in beta.4)
export { ScrcpyOptions4_1 as ScrcpyOptions5_0 } from "./4_1/index.js";

// libraries/scrcpy/src/5_0_1.ts (new in beta.4)
export { ScrcpyOptions4_1 as ScrcpyOptions5_0_1 } from "./4_1/index.js";

That is, scrcpy server 5.0 re-exports ScrcpyOptions4_1 under a
new name.
No new options, no new control messages, no new codec
quirks. The maintainer has decided that scrcpy server 5.0 and 5.0.1
are wire-compatible with 4.1 for the purposes of the option-shape
the library models (video codec, audio codec, control socket layout,
video metadata orientation). This matches the pattern used for older
jumps where, for example, 3_0_3 re-exported 3_0_1‘s options. The
opt-set of a scrcpy server release tends to change very little
between patch versions, so re-exporting is the right amount of code.

What does differ is the version string the client sends on the wire:

1
2
3
4
5
6
// libraries/adb-scrcpy/src/5_0_1.ts (new in beta.4)
constructor(init: TInit, clientOptions?: AdbScrcpyClientOptions) {
super(init);
this.version = clientOptions?.version ?? "5.0.1";
this.spawner = clientOptions?.spawner;
}

The server uses that string to decide whether it should accept the
connection (newer servers reject older version strings; older servers
reject newer ones in some cases). By default the client says
"5.0.1", but clientOptions.version lets you say "5.0",
"5.0.1", or whatever variant you actually deployed.

Why aliases instead of a real 5_0 implementation

If you’ve watched the repo over the last year, you might remember
that 4_1 was the first version where a real new file got written
(feat(scrcpy): support server version 4.0 and 4.1 (#854)), with
new ScrcpyOptions4_0 and ScrcpyOptions4_1 types in libraries /scrcpy/src/4_0/ and 4_1/. The non-alias approach makes sense
when a server version adds a real protocol feature — for example, the
video / audio codec options that became first-class in 3_0.

For 5.0 and 5.0.1, nothing in the option shape the library models
has changed. Reading the upstream scrcpy release notes between 4.x
and 5.0 confirms this: the changes are bug fixes in the server
binary, not wire-protocol additions. So writing a new 5_0/ directory
that mirrored 4_1/index.ts line-for-line would have been a copy of
code that would immediately fall behind as soon as 4_1 got bugfixes
ported back. The alias keeps a single source of truth — if the
maintainer adds a new option to ScrcpyOptions4_1, both 4_1 and
5_0 and 5_0_1 get it for free.

The bipedal rewrite is mostly invisible, except where it isn’t

The commit that’s not in the headline but is by far the largest in the
diff is the rewrite of libraries/struct/src/bipedal.ts. bipedal
is the helper that turns a “bipedal generator” — a generator that
yields promise values to ask the host to await them — into an
ordinary function that returns a promise. It’s used by the
IndexedDB-transaction fix we wrote up two weeks ago: that’s how
waitRequest() lets a generator yield store.getAll() and get
back the resolved value.

The old implementation passed an Iterator<unknown, T, unknown> to a
helper called advance, which on each iteration inspected the yielded
value, and if it was a promise, attached .then() handlers that
wrapped the resolved value in { resolved: value } and the rejected
value in { error }
before passing it back into the iterator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// BEFORE (struct/bipedal.ts on beta.3)
function advance<T>(
iterator: Iterator<unknown, T, unknown>,
next: unknown,
): MaybePromiseLike<T> {
while (true) {
const { done, value } = iterator.next(next);
if (done) {
return value;
}
if (isPromiseLike(value)) {
return value.then(
(value) => advance(iterator, { resolved: value }),
(error: unknown) => advance(iterator, { error }),
);
}
next = value;
}
}

The generator helper that yielded the promise had to recognize the
sentinel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// BEFORE
function* <U>(
value: MaybePromiseLike<U>,
): Generator<
PromiseLike<U>,
U,
{ resolved: U } | { error: unknown }
> {
if (isPromiseLike(value)) {
const result = yield value;
if ("resolved" in result) {
return result.resolved;
} else {
throw result.error;
}
}
return value;
}

The new implementation just hands the resolved value back via
iterator.next(value) and the rejected value via
iterator.throw(error), which is the way every other generator
library (Koa, redux-saga, learning Generators) does it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// AFTER (struct/bipedal.ts on beta.4)
function advance<T>(
iterator: Generator<unknown, T, unknown>,
result: IteratorResult<unknown, T>,
): MaybePromiseLike<T> {
do {
if (result.done) {
return result.value;
}
if (isPromiseLike(result.value)) {
return result.value.then(
(value) => advance(iterator, iterator.next(value)),
(error: unknown) =>
advance(iterator, iterator.throw(error)),
);
}
result = iterator.next(result.value);
} while (true);
}

The generator helper gets correspondingly simpler:

1
2
3
4
5
6
7
// AFTER
function* <U>(value: MaybePromiseLike<U>): Generator<PromiseLike<U>, U, U> {
if (isPromiseLike(value)) {
return yield value;
}
return value;
}

Two things are worth noticing here:

  1. do { … } while (true) instead of while (true) { … } —
    the old loop body used next = value to re-assign and fall
    through to the next iteration, which meant the next-iteration
    value was set at the bottom of the loop. The new loop takes an
    IteratorResult argument (so the recursive calls pass
    iterator.next(value) rather than value) and has nowhere clean
    to put that bottom-of-loop assignment. do/while lets the body
    assign into the loop variable and the conditional sits at the
    bottom instead of the top.

  2. The type signature tightened too. The old type
    Iterable<unknown, T, unknown> for the iterator parameter was
    technically wrong — the third type parameter is the value passed
    back into the iterator via .next(), and it should match the
    yield type, not unknown. The new signature is
    Generator<unknown, T, unknown>, which (a) locks in that it’s a
    generator and not just an iterable, and (b) lets iterator.throw
    type-check without an extra cast.

    The old code had as never at the call site (fn.call(...) as never) because of this looseness. The new code drops it.

If you have a code path that does something exotic with the
sentinel — for example, inspecting { resolved, error } from a
generator you wrote yourself — that will break across the upgrade.
The IndexedDB-related call sites in @yume-chan/adb-credential-web
all go through the then: helper, not directly through advance, so
they keep working without changes. We checked libraries/ on the
beta.4 tree and no other file imports advance or the sentinel
shapes.

The dep churn is the boring half

The non-source half of the beta.4 diff is a pnpm-lock.yaml rewrite
(713 lines, ~ half insertions, ~ half deletions) plus package
metadata bumps across all twenty workspace libraries. The lockfile
diff is dominated by eslint-config → lint directory rename and
the prettier CI step:

1
2
3
toolchain/{eslint-config => lint}/eslint.config.js
toolchain/{eslint-config => lint}/package.json
toolchain/lint/run-lint.js

The interesting dep-side change is 991335b — chore: run prettier --check in CI — which pins the project to a single canonical
formatting. If you’re a ya-webadb contributor, expect PRs that fail
CI on a missing semicolon to come back with “run prettier.” This is
the maintainer moving from “please remember to format” to “the
formatter is in CI,” and 53f3b7a (fix: use consistent Node.js type names) is the kind of one-line cleanup that prettier would
have caught automatically — a reminder of why this matters.

What this means for webadb.online

We’re still on [email protected] in
package.json,
pending the v3.0.0 stable cut. The beta.4 changes don’t change that
plan, but they do firm up three things we were holding our breath on:

  1. WebUSB NotFoundError is now in a tagged release. This is the
    five-line fix from a5134b5
    we wrote up on 2026-10-04 — when a USB transfer is interrupted
    by a device unplug, the loop now resolves the promise cleanly
    instead of throwing. Our existing transfer-retry state machine
    already handles an undefined result, but we couldn’t tell anyone
    “this is fixed in upstream” until the tag landed.

  2. PR #871 is in a tagged release. Same story for the IndexedDB
    credential-storage fix. As of beta.4, a fresh ya-webadb install
    will not regress on the “credentials don’t persist” path that
    bit users in beta.3.

  3. scrcpy 5.0 wire compatibility is library-acknowledged. Anyone
    running scrcpy server 5.0 or 5.0.1 against our Screencast
    panel will hit the version-negotiation handshake cleanly; if we
    want to pin our latest to 5_0_1 in a future bump, the option
    shape is identical to 4_1 so the upgrade is a one-line change.

The bipedal rewrite doesn’t affect us at runtime — none of our
panels call into @yume-chan/struct directly — but it is the kind
of cleanup that makes the upstream maintainer likely to cut a stable
v3.0.0 sooner rather than later, which is the thing we’re actually
waiting on to drop the -beta.1 pin.

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.

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.

How to use webadb.online

A quick walkthrough of the seven things you’ll actually use webadb.online
for: pairing the device, dropping into an interactive shell, moving files
around, browsing images, managing installed apps, watching CPU and
memory, and the smaller utilities (text editor, APK install, Wi-Fi ADB)
that round out the dock.

1. Connect your device

Open https://webadb.online in a desktop Chromium
browser — Chrome, Edge, or Opera. WebUSB isn’t available on Safari or
mobile, and Firefox needs the deprecated native-flag workaround, so
stick to one of the three.

Hit the big Connect device button. The browser opens a device picker
— usually anchored to the top-left of the window — listing every USB
device you’ve granted permission to before. Pick your phone.

On the phone, accept the “Allow USB debugging?” prompt that pops up
the first time you plug in (Settings → Developer options → USB
debugging must be enabled). Tick “Always allow from this computer” if
you don’t want to confirm every session.

You’ll land on the desktop — a top bar, a dock at the bottom, and a
workspace in the middle. The Shell window opens by default. From here
on, every session picks up where the previous one left off (the
workspace layout and your pinned items are stored in localStorage,
not on a server).

2. ADB shell

The shell dock icon is the first thing you’ll see. It’s a real PTY:
arrow keys work, Ctrl+C interrupts, the window resizes with the
column-count environment variable, and ANSI colors pass through
verbatim — so ls --color=auto, vim, top, and any tool that uses
24-bit escapes render correctly.

Three things to know:

  • Saved commands. Sidebar shortcuts (commands, scripts, binary
    files you’ve uploaded) persist across sessions in localStorage.
    Switching machines or browsers resets them — there is no cloud sync,
    by design, because the ADB session itself doesn’t go through any
    server.
  • No streaming on shared devices. If you’re using a lab phone, the
    shell can be observed by anyone who reconnects with the same RSA
    key — revoke the key from Developer options when you’re done.
  • adb root requires userdebug build. su won’t help on stock
    builds; if you need root, flash a userdebug image or use
    Magisk/KernelSU and run su from inside the shell.

3. File Manager

Open the File Manager from the dock. It mirrors a desktop file browser:

  • Navigate by clicking folders; the path bar updates.
  • Double-click the path bar to type an absolute path. This is the
    only way to reach /data/local/tmp, /sdcard/Android/data/<pkg>/,
    and other paths you can’t step into one directory at a time.
  • Right-click (or the kebab menu) for Pin to sidebar — pinned
    folders stick across sessions and across reconnects.
  • Drag-and-drop a file onto the window to upload it.
  • Double-click text files to open them in the in-browser text editor;
    double-click images to open them in the image viewer.

4. Browse images

Open any image and the viewer pre-loads the rest of the folder, so
left/right arrow keys flip through the whole album without going back
to the directory listing. This is much faster than the Android gallery
app for triage — especially when you’re picking the one screenshot
out of forty that you actually wanted to upload.

Useful while testing:

  • Save writes to your Downloads folder with the original filename.
  • The viewer’s zoom follows the image’s intrinsic size; on
    wallpaper.png it respects the EXIF orientation flag (so portrait
    phone photos don’t show sideways on the desktop).

5. App Manager

The dock’s Apps icon lists every installed package with its launcher
icon (lazy-fetched from the APK — see the previous post
for why the row list uses a letter avatar). For each app you can:

  • Launch — am start -n <pkg>/<activity> for the resolved launcher.
  • Uninstall — pm uninstall <pkg>, with a confirmation dialog.
  • Force-stop / Clear data / Disable / Re-enable under the
    overflow menu.
  • Permissions — see every granted runtime permission, toggle them
    with the same prompts Android would show.
  • APK info — versionCode, install date, target SDK, signature
    scheme, native libraries — anything you’d otherwise need
    aapt2 dump badging for.

6. System Monitor

The dock’s chart icon opens a four-pane monitor modeled on GNOME’s
System Monitor:

  • CPU — per-core usage with sparklines (1s / 5s / 30s windows).
  • Memory — total / used / available / cached, plus a breakdown by
    category (Java heap, native, graphics, system) when the device
    exposes the right /proc files.
  • Network — per-interface sent/received, on both the cellular and
    Wi-Fi interfaces (when the device surfaces both).
  • Processes — sorted by CPU by default; click a column to sort by
    RSS, PID, or user. The list polls every two seconds.

It’s a faithful translation of top / vmstat into something you can
leave running in a corner while you do something else.

7. Other features

A few smaller dock icons round out the workspace:

  • Text Editor — opens any text file you click in the File
    Manager. Edits buffer locally and save pushes the whole file back
    over ADB; there’s no in-browser file watcher, so save and walk
    away rather than expecting live updates.
  • Install APK — drag an APK onto the workspace and confirm. When
    this fails (it does occasionally — mostly on packages signed with
    v2-only signatures that need pm install -t -r instead of the
    installer flow), the workaround is to push to /data/local/tmp/
    with the File Manager, then run pm install xxx.apk from the
    shell. Both paths end up at the same place on the device.
  • Screenshot — captures the foreground app and offers a download.
    Useful for grabbing an error dialog without unlocking the phone.
  • Wi-Fi ADB — enables adb tcpip 5555 and reports the device’s
    current LAN IP, so you can unplug and keep the session going over
    Wi-Fi. Pair this with a saved-sidebar shortcut to the disconnect
    command when you want to clean up afterwards.

That’s the full tour. If something’s missing from the dock that you
expected to see — Sockets? Reverse port-forwarding? — open an issue
on the GitHub repo and it’ll land in a future release.