DroidSock v2.0.0 Changelog
Release Status: Major release (breaking API changes)
Overview
v2.0.0 redesigns how connected devices are represented, looked up, and disconnected, completes bidirectional port forwarding, adds Wi-Fi pairing, and adds a modern streaming APK install path alongside the newer 64-bit SYNC V2 file-transfer commands. Each device is now a real, persistent composed API leaf rather than a plain object that disappears when its socket dies, split across a device module (single-target connect/disconnect/remove) and a devices module (collection-wide list/disconnect/remove/lookup) - and both the subnet sweep and mDNS discovery now support IPv6 alongside IPv4.
🚨 Breaking Changes
api.device.* / api.devices.* split; a device leaf now persists across a disconnect
The flat device module (functions taking a connection as their first argument) has been replaced by two modules with a clear single-vs-collection split, and disconnecting no longer forgets the device:
device.connect(host, port, options)/device.disconnect(host, port)/device.remove(host, port)- single-target operations.devices.list()/devices.disconnect()(no arguments - disconnects every device) /devices.remove()(no arguments - forgets every device) /devices.get(idOrLeaf)- collection-wide operations.
Every device is mounted as its own composed API leaf at api.devices.<sanitized host_port> - the same connection returned by connect() - giving every device leaf's methods working self/context access exactly like any other module in the api tree, instead of being called against a plain object holding connection state. The leaf is now a persistent handle: disconnect() only tears down the current socket, it does not unmount anything, so connect() on the same host:port later reconnects that exact same leaf - reusing whatever options it was created with - instead of building a new one. remove() is the new, separate "forget this device" operation.
// v1.2.0 and earlier
const device = await api.device.connect(host, port);
device.disconnect(); // synchronous, and the only teardown - there was nothing to unmount
// v2.0.0
const device = await api.device.connect(host, port);
device.disconnect(); // still synchronous - tears down the socket, leaf stays mounted
const same = await api.device.connect(host, port); // reconnects the SAME leaf, no options needed again
same === device; // true
await device.remove(); // the new, explicit "forget this device" call - unmounts the leaf
await api.devices.remove(); // forgets EVERY device - no arguments
const gone = api.devices.get(device); // undefined - actually removed, not just disconnecteddevices.disconnect()/devices.remove() both take no arguments on purpose - the module name already means "all", and each throws if called with any argument, catching a mix-up with its single-target device.* counterpart. remove() is the one genuinely async step in this lifecycle (it's real api-tree surgery); disconnect() needs no await at all. Everything else on a device - shell(), file operations, reboot, forwarding, install, and the convenience shortcuts (getModel(), logcat(), etc.) - keeps its existing call shape.
engines.node raised to >=20.19.0
The Wi-Fi pairing implementation's SPAKE2-over-Ed25519 key exchange depends on @noble/curves@^2.4.0, which itself requires Node 20.19 or newer. The previous floor (>=18.12) no longer reflects what the package actually needs to run.
✨ Added
IPv6 support (device.connect, discover.subnet, discover.mdns)
device.connect(host, port) accepts an IPv6 host exactly like an IPv4 one. discover.subnet(cidr, ...) accepts an IPv6 CIDR (e.g. "2001:db8::/120") alongside IPv4 - since IPv6 has no broadcast concept, an IPv6 sweep covers its full address range rather than excluding network/broadcast addresses the way IPv4 does, and the existing maxHosts safety guard is what keeps a /64-or-larger sweep from ever being attempted. discover.mdns(options) gains options.family (4 or 6) and dual-stack AAAA-record support, defaulting to the IPv4 mDNS group (224.0.0.251) exactly as before and targeting the IPv6 group (ff02::fb) when family: 6 is requested. EXPERIMENTAL - verified against real loopback IPv4/IPv6 sockets and cross-checked against the platform's own IPv6 address canonicalization, but not yet validated against a real device or a real IPv6 network. See #1.
devices.get(idOrLeaf) and device.remove()/devices.remove()
devices.get(idOrLeaf) looks up a device leaf - connected or currently disconnected - by "host:port" string (bracket an IPv6 host when attaching a port, e.g. "[2001:db8::1]:5555") or by the leaf object itself, returning undefined only for a device that was never connected or has since been remove()d. device.remove(host, port) / devices.remove() (all) are the new explicit "forget this device" operations, separate from disconnect() - see the breaking-change section above for the full reasoning.
Reverse port forwarding (device.reverse)
device.reverse(devicePort, hostPort, options) - the adb reverse tcp:<devicePort> tcp:<hostPort> equivalent - completes the port-forwarding direction device.forward() (v1.1.0) didn't cover. Registering the tunnel is a normal client-initiated stream (reverse:forward:tcp:<devicePort>;tcp:<hostPort>); each subsequent connection to that device port arrives as a device-initiated OPEN the stream layer now handles directly (recognized by arg1 == 0, since the device has no local id of ours to reference yet), emitted as a remoteOpen event and bridged to a real local TCP connection. EXPERIMENTAL - built from the protocol spec and covered by tests against real loopback TCP sockets, not yet validated against a real device. See #1.
Wi-Fi pairing (pairing.pair)
pairing.pair(host, port, pairingCode, options) implements the PIN-based pairing flow Android 11+ uses for wireless debugging (adb pair host:port pairing-code) - a completely separate protocol from the rest of droidsock, existing solely to get the persistent RSA identity's public key into the device's trust store, authenticated by the 6-digit code shown on the device instead of a manual "Allow this computer?" tap. The session is a raw TLS 1.3 connection (peer certificate verification disabled - trust comes from the pairing code via a SPAKE2 key exchange, channel-bound to the TLS session so it can't be relayed) carrying a SPAKE2 exchange followed by an AES-128-GCM-encrypted PEER_INFO exchange. The SPAKE2 construction itself is BoringSSL's bespoke SPAKE2-over-edwards25519 (not RFC 9382's NIST-curve SPAKE2), built on @noble/curves's Ed25519 primitives. EXPERIMENTAL - every wire constant is confirmed against AOSP/BoringSSL source and covered by tests against real loopback TLS servers, but not yet validated against a real device. See #1.
Streaming APK install (install.streaming, wired into device.install)
install.streaming(socket, streamManager, localPath, options) installs an APK via exec:cmd package install -S <size>, writing the local file's raw bytes directly as the command's stdin - no on-device temp file at all, unlike the classic push-then-install flow. device.install() now tries this path automatically when the device advertises the cmd CNXN feature, falling back to the classic flow (and surfacing both failures together, via { cause }, if the fallback also fails) when it doesn't or when streaming itself fails. EXPERIMENTAL - built from the protocol spec and covered by mocked tests, not yet validated against a real device. See #1 and #7.
SYNC V2 (64-bit) file operations (pushV2 / pullV2 / statV2 / listV2)
The legacy SEND/RECV/LIST commands (v1.1.0) use 32-bit size fields, capping a single file or directory entry around ~2.14GB. The new _V2 variants use 64-bit fields (size/atime/mtime/ctime/dev/ino all returned as BigInt), gated on the device advertising the matching CNXN feature (sendrecv_v2 / stat_v2 / ls_v2 - droidsock declares all three in its own outgoing banner). pushV2/pullV2 also support opt-in per-chunk brotli compression (options.compression: "brotli", off by default) via Node's built-in zlib, and both stream through disk in bounded chunks rather than buffering a whole file in memory - the entire point of the 64-bit path is large files. EXPERIMENTAL - implemented from AOSP packages/modules/adb/file_sync_protocol.h and covered by mocked tests, not yet validated against a real device. See #1 and #8.
📦 Dependencies
- Added
@noble/curves@^2.4.0- Ed25519 primitives for the Wi-Fi pairing SPAKE2 key exchange.
🐛 Fixed
This release also went through several rounds of automated code review against every new code path, which found and fixed a number of genuine bugs beyond the features themselves:
- The device-key sanitizer collapsed
.and:to the same character -sanitizeKey()mapped both a dotted-quad's separators and an IPv6 literal's colons to a single_, making the two indistinguishable in the resultingapi.devices.<key>path segment. A.now maps to a single_and a:to a double__. parseHostPort()mis-parsed a bare IPv6 literal - it always treated the text after the last:as a port, soparseHostPort("::1")returned{host: ":", port: 1}instead of recognizing a complete address with no port attached. It now supports bracketed[addr]:portnotation (the standard URL/curl convention) and accepts a complete, unbracketed IPv6 literal as a bare host. The bracketed form itself wasn't validated either -"[]:5555"or"[not-ip]:5555"parsed successfully instead of being rejected; the bracketed host is now checked againstnet.isIPv6().- Sweeping an IPv6 CIDR could throw or silently sort out of order - the subnet sweep's result sort compared parsed IP integers, which either throws (
Array.sort()cannot use a BigInt comparator result) or silently fails (NaN) once a host could be IPv6. Reachable hosts are now sorted by sweep position, which is already address order for both families. - Shell-metacharacter injection in
devices.mjs's andshell.mjs's convenience shortcuts -ls(),getprop(),screenshot(),keypress(),launchApp(),killApp(),installApk(), anduninstallApp()interpolated caller-supplied values into shell commands with double quotes or no quoting at all, the same class of gap v1.2.0 closed infiles.mjs.shell.mjs's copies of these shortcuts had carried the same gap since the very first release - found while adding this module's first dedicated test file. All of them now go through the sharedutils.quoteShellArg()helper. shell.mjs'sinstallApk()had no flags validation - unlikeinstall.streaming(), which already rejected a whitespace-containing flag string viaassertValidFlags(),shell.mjs'sinstallApk()(and thereforeinstall.classic(), which delegates to it) had no equivalent check; a flag like"--user 0"passed as one array element would now be single-quoted into one argv token instead of the twopm installexpects.assertValidFlags()moved to the sharedutils.mjsand is now called from both install paths.- Unbounded memory allocation from device-supplied lengths -
listV2()'s directory-entry name length andFAIL-frame length, andpullV2()'s brotli decompression output, were all read from the wire with no upper bound; a malformed or hostile device could force unbounded buffering. All three are now capped before the corresponding read/decompress happens. - A TOCTOU race in
install.streaming()(flagged by CodeQL) - the file was separatelystat()'d for its-S <size>value and then opened by path, leaving a window where the file could change between the two calls. Fixed by opening the file once and reading its size from the open handle. - Several mid-transfer disconnect/failure paths that previously hung or silently succeeded -
createSyncFrameReader()(shared bypush/pull/pushV2/pullV2) never rejected a pending read if the stream ended before a terminal frame arrived;install.streaming()could resolve successfully on a premature disconnect because the underlying stream only emits"close", never"error", for a normal disconnect; andpullV2()wrote decompressed chunks straight to the caller's destination path, leaving a corrupted partial file behind on failure instead of none (now written to a temp file, renamed onto the destination only on success). isConnected()could report a dead connection as live - it only checked a flag thatdisconnect()itself sets, so an unexpected socket teardown (device unplugged, network drop) left it stuck reportingtrueforever; it now also checks the socket's owndestroyedstate.- A temporal-dead-zone crash in
pairing.pair()- a synchronous throw fromtls.connect()could leave a pending timeout that later read aconstbefore it was ever assigned. reconnect()silently discarded options passed while already connected - it mergedoptionsinto what a device remembers for its next reconnect only after the already-connected short-circuit, sodevice.connect(host, port, { keyDir })called on a still-connected device never took effect on a later disconnect/reconnect cycle. The merge now happens unconditionally, before that check.devices.disconnect()'s returned count included already-disconnected devices - it incremented once per known device leaf regardless of whetherdisconnect()on it actually did anything, so calling it twice in a row reported the same count both times even though the second call disconnected nothing. It now only counts (and disconnects) devices that were actually connected.discover.mdns()didn't validateoptions.address/options.multicastInterfaceas strings - a non-string value surfaced as a confusingTypeErrordeep insideisMulticastAddress()or adgramcall instead of a clear validation error; both are now checked up front.
📋 Known Limitations
- Every capability added in this release - the IPv6 support,
device.reverse(),pairing.pair(),install.streaming(), and the SYNC V2 file operations - is markedEXPERIMENTALin code and has not been exercised against a real device, a real IPv6 network, or a real Android 11+ pairing flow yet. #1 tracks the real-device session needed to validate the protocol stack they depend on. - SYNC V2's compression option only implements brotli;
lz4/zstdwould need new dependencies and are tracked separately in #25.