Add local ADB web remote for Android TV

Node/Express server shells out to adb (connect/pair/keyevent/text/app
launch) and serves a mobile-first installable PWA remote: power, mute,
input switch, volume/channel rockers, D-pad, media transport, text
entry, and one-tap streaming app launch. Supports multiple saved TVs
and the Android 11+ wireless-debugging pairing flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSVyYcrjrbmrMKT85G4fyn
This commit is contained in:
Claude
2026-09-26 12:40:01 +00:00
commit 6db1db70a0
18 changed files with 2246 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const ADB_BIN = process.env.ADB_BIN || "adb";
const EXEC_TIMEOUT_MS = 8000;
class AdbError extends Error {
constructor(message, { code, stderr } = {}) {
super(message);
this.name = "AdbError";
this.code = code;
this.stderr = stderr;
}
}
function run(args) {
return execFileAsync(ADB_BIN, args, { timeout: EXEC_TIMEOUT_MS }).catch((err) => {
if (err.code === "ENOENT") {
throw new AdbError(
"adb binary not found on this machine. Install Android platform-tools and make sure `adb` is on PATH.",
{ code: "ADB_NOT_FOUND" }
);
}
throw new AdbError(err.stderr?.trim() || err.message, {
code: "ADB_COMMAND_FAILED",
stderr: err.stderr,
});
});
}
// Accepts "host" or "host:port" and rejects anything that isn't a plausible
// address, since this value is passed straight to execFile as a single arg
// (no shell involved, but we still don't want to hand adb garbage).
const HOST_PORT_RE = /^[a-zA-Z0-9.\-]+(:\d{1,5})?$/;
export function normalizeTarget(hostOrHostPort) {
const trimmed = String(hostOrHostPort || "").trim();
if (!HOST_PORT_RE.test(trimmed)) {
throw new AdbError(`"${hostOrHostPort}" doesn't look like a valid host or host:port`, {
code: "INVALID_TARGET",
});
}
return trimmed.includes(":") ? trimmed : `${trimmed}:5555`;
}
export async function connect(hostOrHostPort) {
const target = normalizeTarget(hostOrHostPort);
const { stdout } = await run(["connect", target]);
const text = stdout.trim();
// adb prints "failed to connect..." on stdout with exit code 0, so we
// have to sniff the message instead of relying on the exit status.
if (/failed|unable|cannot/i.test(text)) {
throw new AdbError(text, { code: "CONNECT_FAILED" });
}
return { target, message: text };
}
export async function disconnect(hostOrHostPort) {
const target = normalizeTarget(hostOrHostPort);
const { stdout } = await run(["disconnect", target]);
return { target, message: stdout.trim() };
}
export async function pair(hostAndPort, code) {
if (!/^\d{6}$/.test(String(code))) {
throw new AdbError("Pairing code must be exactly 6 digits", { code: "INVALID_PAIR_CODE" });
}
const target = normalizeTarget(hostAndPort);
const { stdout } = await run(["pair", target, String(code)]);
const text = stdout.trim();
if (/failed|unable|cannot/i.test(text)) {
throw new AdbError(text, { code: "PAIR_FAILED" });
}
return { target, message: text };
}
export async function listDevices() {
const { stdout } = await run(["devices", "-l"]);
return stdout
.split("\n")
.slice(1)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [serial, state, ...rest] = line.split(/\s+/);
return { serial, state, details: rest.join(" ") };
});
}
export async function shell(serial, ...shellArgs) {
const target = normalizeTarget(serial);
return run(["-s", target, "shell", ...shellArgs]);
}
export async function keyevent(serial, code) {
await shell(serial, "input", "keyevent", String(code));
}
export async function sendText(serial, text) {
// Android's `input text` treats spaces specially; %s is the documented escape.
const escaped = String(text).replace(/ /g, "%s");
await shell(serial, "input", "text", escaped);
}
export async function launchApp(serial, packageName) {
await shell(serial, "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1");
}
export { AdbError };