Releases: CLDMV/droidsock
Release list
v2.0.0
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 brac...
v1.2.0
v1.1.1
release: v1.1.1 - pad slashes between adjacent code spans (#16)
🚀 What's Changed
💥 Breaking Changes
No breaking changes
✨ Features
No new features
🐛 Bug Fixes
No bug fixes
📦 Dependencies
No dependency updates
🔧 Other Changes
- #15
- chore: retrigger CI (prior push compared across a history rewrite
boundary) (cddad81)- docs: pad slashes between adjacent code spans (e84362b)
👥 Contributors
release: v1.1.0 - fix files.list/stat, add SYNC-based list, reboot, port forwarding, and APK install
DroidSock v1.1.0 Changelog
Release Status: Minor release
Overview
v1.1.0 fixes the list/stat regression from v1.0.0 - previously written up as a planned v1.0.1, which was never actually published; this release supersedes that draft - and adds four new capabilities on top of the fix: a binary-safe SYNC-based list() with automatic shell fallback, real ADB reboot: service support, TCP port forwarding, and local APK installation.
🐛 Fixed
files.list(remotePath)andfiles.stat(remotePath)now actually work. Both threw in v1.0.0 due to a leftover reference to a module (../src.backup/sync.mjs) that never existed in this repository - not something removed during cleanup, but a dangling reference present since the very first commit.stat()shells out tostatand returns the raw output;list()goes further than a plain fix - see below.
✨ Added
Binary-safe directory listing (files.listSync / files.listShell)
files.list(remotePath) now prefers the ADB SYNC sub-protocol's LIST command - binary-safe, immune to the shell-metacharacter/whitespace edge cases ls -la text parsing can hit - and falls back to the previous shell-based approach (now files.listShell()) only when the SYNC service itself isn't usable. A real LIST failure (e.g. "No such file or directory") is never masked by the fallback; it propagates as-is. listSync() and listShell() also remain directly callable to force one path. listSync() is EXPERIMENTAL - implemented from the public protocol spec (AOSP SYNC.TXT), covered by mocked tests, not yet validated against a real device. See #1.
Real binary file transfer (files.push / files.pull)
Implements the ADB SYNC sub-protocol's SEND/RECV commands for actual binary file transfer, built directly from the public protocol spec and cross-checked against Google's own reference client (google/python-adb). Files are chunked at the protocol's 64KB DATA ceiling; options.onProgress reports bytes transferred; options.mode sets the pushed file's permissions. EXPERIMENTAL - covered by mocked unit tests (including a chunk-boundary-split reassembly case) but not yet exercised against a real device. See #1.
Real reboot service (device.reboot)
device.reboot(mode) opens the real ADB reboot: service directly - a distinct top-level service like shell:/sync:, not a shell command - supporting the standard mode variants ("", "bootloader", "recovery", "sideload", "sideload-auto-reboot") plus any vendor-specific target as a raw string. Convenience shortcuts device.rebootBootloader(), device.rebootRecovery(), and device.rebootSideload() are also available. The existing device.shell("reboot") path stays available unchanged as a fallback for a plain reboot - bootloader/recovery/sideload were never reachable through it, since they aren't real shell commands. EXPERIMENTAL - built from the protocol spec, not yet validated against a real device. See #1.
Port forwarding (device.forward)
device.forward(devicePort, options) - the adb forward tcp:<localPort> tcp:<devicePort> equivalent - listens on a local TCP port and, for each accepted connection, opens an ADB stream to the device's tcp:<devicePort> service, bridging bytes bidirectionally until either side closes. Returns { localPort, close() }; options.localPort (default 0, letting the OS pick a free port), options.host, and options.onError are all optional. Only the forward direction (host -> device) is implemented - reverse (device -> host) needs inbound-OPEN handling this doesn't have yet; tracked in #4. EXPERIMENTAL - built from the protocol spec, not yet validated against a real device. See #1.
Local APK install (device.install)
device.install(localPath, options) - the adb install <local.apk> equivalent - pushes a local APK to a device temp directory, runs pm install, then removes the temp file regardless of outcome. Pure composition of the existing push/shell-command primitives, no new protocol work of its own. options.flags passes flags to pm install (e.g. ["-r"] to reinstall), options.remoteDir overrides the push destination (default /data/local/tmp), options.onProgress reports push progress. Only the classic push-then-install flow is implemented - the modern streaming install path (exec:cmd package install, no on-device file at all) is separate follow-up work; tracked in #7. EXPERIMENTAL - depends on the experimental push(), so it's unvalidated against a real device too even without new protocol work of its own. See #1.
📋 Known Limitations
files.push/files.pull/files.listSync/device.reboot/device.forward/device.installare all markedEXPERIMENTALin code and have not been exercised against a real device. #1 tracks the real-device session needed to validate the protocol stack they depend on.- Port forwarding is outbound-only (#4 tracks
reverse); APK install is push-then-install only (#7 tracks the streaming path); SYNCLIST/SEND/RECVare the legacy 32-bit variants only (#8 tracks the 64-bit_V2variants needed for files/listings beyond ~2.14GB).
release: v1.0.0 - make the default export a callable quick path, drop connect/listDevices
DroidSock v1.0.0 Changelog
Release Status: First stable release
Overview
v1.0.0 is DroidSock's first tagged release. The library itself - a from-scratch Node.js implementation of the Android Debug Bridge (ADB) wire protocol, built on @cldmv/slothlet - already had a working protocol implementation (connection handshake, RSA authentication, shell execution, stream multiplexing, shell-based file operations); this release is what turns that into a real package: a proper test suite with measured coverage, a full CI/release pipeline, a real dist/ build, and an API surface that's been reviewed and cleaned up rather than just organically grown.
🚨 Breaking Changes
Default export is now a callable quick path; connect()/listDevices() are gone
The previous top-level connect() and listDevices() exports were removed - their names risked colliding with other modules a consumer might already have in scope, and connect() returned a raw device rather than the full api. The default export is now itself the quick path:
// v0.x - removed
import { connect } from "@cldmv/droidsock";
const device = await connect(host, port);
// v1.0.0 - the default export is the quick path
import droidsock from "@cldmv/droidsock";
const api = await droidsock(); // same function as createDroidSock(options)
const device = await api.device.connect(host, port);droidsock(options) and createDroidSock(options) are the exact same function - droidsock is just the friendlier name for the common case of not needing to name the factory explicitly. Both are available from the default export, and createDroidSock is also available as a named export for callers who prefer it.
✨ Highlights
- Real test suite and measured coverage - a from-scratch Vitest suite covering
auth,utils,config,log, and the shell-basedfilesoperations, wired through@cldmv/vitest-runnerwith v8 coverage reporting and a live coverage badge. Found and fixed a genuine coverage-attribution bug along the way: slothlet's per-instance dynamic-import URLs were making some code read as uncovered even though it executed (fixed by inlining@cldmv/slothletinto Vitest's module graph). - Full CLDMV v4 CI/release automation - the complete workflow set (CI, CodeQL, Dependency Review, Scorecard, coverage badge, lint/format autofix, the
next/hotfixesstaging-branch release flow), a local pre-commit hook, and npm Trusted Publishing. @cldmv/slothletbumped to 3.x, including switching off the experimental"live"runtime mode to the stable"async"default.- A real
dist/build pipeline (build.mjs) - copiessrc/todist/, optionally strips comments/whitespace via esbuild, and re-prepends the Apache license header - replacing a build script that previously pointed at a file that didn't exist. - The leaked ADB RSA keypair was purged from the repository's history entirely (not just the working tree) before this repository went public. A freshly installed copy now generates its own key on first use instead of shipping a real one.
🐛 Notable Fixes
- Two
auth.mjscoverage-ignore comments were removed after empirically verifying they were wrong: the code paths they guarded are reachable on current Node via any non-RSA key type (DSA, Ed25519, and others), not just "older Node versions" as originally assumed. Real tests using actual DSA and Ed25519 keys now cover them. auth.mjswas missing theselfruntime import it needed for its own debug logging - eighteen call sites were silently broken.utils.retry()previously rejected with a bareundefinedand never called the wrapped function at all when given a negativemaxRetries- it now clamps to0(still runs the function once; a real failure now propagates as a realError) and warns.devcheck.mjs's development-environment check could previously report success fromNODE_ENV=developmentalone, without theNODE_OPTIONS=--conditions=droidsock-devflag that actually controls which build the package resolves to - it now checks the flag that matters.
📋 Known Limitations
push/pull (real binary file transfer via the ADB SYNC sub-protocol) are not implemented yet - they throw explicitly rather than silently failing. list/stat also threw at this version due to a leftover reference to a nonexistent module; see v1.1.0 for the fix - both are implemented via existing shell commands and needed no SYNC protocol work at all. #1 tracks capturing a real-device session to build accurate protocol-level mocks for connection/device/stream/shell - it was never about push/pull/list/stat.