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
112 lines
3.5 KiB
JavaScript
112 lines
3.5 KiB
JavaScript
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 };
|