docs: add adb wireless keeper plan
This commit is contained in:
787
docs/superpowers/plans/2026-04-05-adb-wireless-keeper.md
Normal file
787
docs/superpowers/plans/2026-04-05-adb-wireless-keeper.md
Normal file
@@ -0,0 +1,787 @@
|
||||
# ADB Wireless Keeper Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 Boss 本机增加一套多 Android 设备无线 ADB 自动扫描与后台自动重连守护方案,并提供状态、安装和文档入口。
|
||||
|
||||
**Architecture:** 保持轻量本机运维路线,不改 Web 或 Android 业务链路。核心逻辑集中在一个可测试的 Node ESM 脚本里,外层再包一层 `zsh` 脚本和 `launchd` 常驻安装脚本;运行态写入本机 `data/` 日志和状态文件,不把运行态文件提交到仓库。
|
||||
|
||||
**Tech Stack:** Node.js ESM、`node:test`、`node:assert/strict`、`zsh`、macOS `launchd`、本机 `adb`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### New tracked files
|
||||
|
||||
- `scripts/adb-wireless-keeper.mjs`
|
||||
- 核心守护逻辑与 CLI 入口。
|
||||
- 负责配置解析、状态读写、子网推断、候选过滤、ADB 重连、日志落盘、状态摘要。
|
||||
- `tests/adb-wireless-keeper.test.mjs`
|
||||
- Node 侧单测,覆盖纯逻辑和一次扫描/重连编排。
|
||||
- `scripts/adb-wireless-keeper-once.sh`
|
||||
- 单次执行入口,便于人工调试一轮扫描。
|
||||
- `scripts/adb-wireless-keeper-status.sh`
|
||||
- 状态查看入口,读取状态文件并打印当前摘要。
|
||||
- `scripts/install-adb-wireless-keeper.sh`
|
||||
- 安装并启动 `launchd` 常驻服务。
|
||||
- `scripts/uninstall-adb-wireless-keeper.sh`
|
||||
- 卸载并停止 `launchd` 常驻服务。
|
||||
- `deployment/launchd/com.hyzq.boss.adb-wireless-keeper.plist`
|
||||
- `launchd` 模板,带仓库根路径和配置占位符。
|
||||
- `config/adb-wireless-keeper.json`
|
||||
- 默认配置文件,定义扫描周期、白名单和日志/状态路径。
|
||||
- `docs/superpowers/specs/2026-04-05-adb-wireless-keeper-design.md`
|
||||
- 已存在 spec,本计划直接实现它。
|
||||
|
||||
### Modified tracked files
|
||||
|
||||
- `README.md`
|
||||
- 新增脚本用法、安装命令、限制说明。
|
||||
- `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- 把新的守护脚本和运行态约束写进当前运行时文档。
|
||||
|
||||
### Runtime-only files created by script, not committed
|
||||
|
||||
- `data/adb-wireless-keeper-state.json`
|
||||
- `data/adb-wireless-keeper.log.jsonl`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 建立可测试的核心配置与过滤逻辑
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/adb-wireless-keeper.test.mjs`
|
||||
- Create: `scripts/adb-wireless-keeper.mjs`
|
||||
|
||||
- [ ] **Step 1: 写第一个失败测试,覆盖配置默认值、白名单匹配和退避规则**
|
||||
|
||||
```js
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
computeBackoffMs,
|
||||
loadKeeperConfig,
|
||||
shouldManageHost,
|
||||
} from "../scripts/adb-wireless-keeper.mjs";
|
||||
|
||||
let runtimeRoot = "";
|
||||
|
||||
test.before(async () => {
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-adb-keeper-"));
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("loadKeeperConfig fills defaults and preserves allowedDevices", async () => {
|
||||
const configPath = path.join(runtimeRoot, "keeper.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
allowedDevices: [{ label: "boss-oppo-main", serialHint: "SER-001" }],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const config = await loadKeeperConfig(configPath);
|
||||
|
||||
assert.equal(config.scanIntervalSeconds, 30);
|
||||
assert.equal(config.connectTimeoutMs, 4000);
|
||||
assert.equal(config.allowedDevices.length, 1);
|
||||
assert.equal(config.allowedDevices[0].label, "boss-oppo-main");
|
||||
});
|
||||
|
||||
test("shouldManageHost only admits whitelist or known-device hosts", () => {
|
||||
const allowedDevices = [{ label: "boss-oppo-main", serialHint: "SER-001", ipHint: "192.168.31.18" }];
|
||||
const knownDevices = {
|
||||
"boss-oppo-main": {
|
||||
serial: "SER-001",
|
||||
lastKnownIp: "192.168.31.18",
|
||||
failureCount: 0,
|
||||
backoffUntil: "",
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldManageHost({
|
||||
ip: "192.168.31.18",
|
||||
allowedDevices,
|
||||
knownDevices,
|
||||
}).allowed,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldManageHost({
|
||||
ip: "192.168.31.99",
|
||||
allowedDevices,
|
||||
knownDevices,
|
||||
}).allowed,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("computeBackoffMs escalates after repeated failures", () => {
|
||||
assert.equal(computeBackoffMs(1), 30_000);
|
||||
assert.equal(computeBackoffMs(2), 120_000);
|
||||
assert.equal(computeBackoffMs(4), 300_000);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认它先失败**
|
||||
|
||||
Run: `node --test tests/adb-wireless-keeper.test.mjs`
|
||||
|
||||
Expected: FAIL,报 `ERR_MODULE_NOT_FOUND` 或缺失导出,例如 `loadKeeperConfig is not exported`。
|
||||
|
||||
- [ ] **Step 3: 在核心脚本里补最小实现,让这些纯逻辑先可测**
|
||||
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const DEFAULT_CONFIG = Object.freeze({
|
||||
subnets: [],
|
||||
scanIntervalSeconds: 30,
|
||||
connectTimeoutMs: 4000,
|
||||
adbPath: "",
|
||||
logFile: "data/adb-wireless-keeper.log.jsonl",
|
||||
stateFile: "data/adb-wireless-keeper-state.json",
|
||||
allowedDevices: [],
|
||||
});
|
||||
|
||||
function normalizeAllowedDevice(raw = {}) {
|
||||
return {
|
||||
label: typeof raw.label === "string" ? raw.label.trim() : "",
|
||||
serialHint: typeof raw.serialHint === "string" ? raw.serialHint.trim() : "",
|
||||
ipHint: typeof raw.ipHint === "string" ? raw.ipHint.trim() : "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadKeeperConfig(configPath) {
|
||||
const resolvedPath = resolve(configPath);
|
||||
const raw = JSON.parse(await readFile(resolvedPath, "utf8"));
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
...raw,
|
||||
allowedDevices: Array.isArray(raw.allowedDevices)
|
||||
? raw.allowedDevices.map((item) => normalizeAllowedDevice(item))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function computeBackoffMs(failureCount) {
|
||||
if (failureCount >= 4) return 300_000;
|
||||
if (failureCount >= 2) return 120_000;
|
||||
return 30_000;
|
||||
}
|
||||
|
||||
export function shouldManageHost({ ip, allowedDevices, knownDevices }) {
|
||||
const allowedByConfig = allowedDevices.find((device) => device.ipHint === ip);
|
||||
if (allowedByConfig) {
|
||||
return { allowed: true, label: allowedByConfig.label, source: "config" };
|
||||
}
|
||||
|
||||
const knownEntry = Object.entries(knownDevices ?? {}).find(([, value]) => value?.lastKnownIp === ip);
|
||||
if (knownEntry) {
|
||||
return { allowed: true, label: knownEntry[0], source: "known" };
|
||||
}
|
||||
|
||||
return { allowed: false, label: "", source: "unknown" };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 再跑测试确认第一批逻辑通过**
|
||||
|
||||
Run: `node --test tests/adb-wireless-keeper.test.mjs`
|
||||
|
||||
Expected: PASS,3 个测试全部通过。
|
||||
|
||||
- [ ] **Step 5: 提交这一批纯逻辑与测试**
|
||||
|
||||
```bash
|
||||
git add tests/adb-wireless-keeper.test.mjs scripts/adb-wireless-keeper.mjs
|
||||
git commit -m "feat: add adb wireless keeper core helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 实现一次扫描与自动重连编排
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/adb-wireless-keeper.test.mjs`
|
||||
- Modify: `scripts/adb-wireless-keeper.mjs`
|
||||
|
||||
- [ ] **Step 1: 先写失败测试,覆盖“已授权设备自动重连”和“未知设备跳过”**
|
||||
|
||||
```js
|
||||
import {
|
||||
runKeeperOnce,
|
||||
} from "../scripts/adb-wireless-keeper.mjs";
|
||||
|
||||
test("runKeeperOnce reconnects a known offline device discovered on the subnet", async () => {
|
||||
const events = [];
|
||||
const writes = [];
|
||||
const now = new Date("2026-04-05T12:00:00.000Z");
|
||||
const config = {
|
||||
subnets: ["192.168.31.0/24"],
|
||||
scanIntervalSeconds: 30,
|
||||
connectTimeoutMs: 4000,
|
||||
allowedDevices: [{ label: "boss-oppo-main", serialHint: "SER-001", ipHint: "192.168.31.18" }],
|
||||
};
|
||||
|
||||
const summary = await runKeeperOnce(config, {
|
||||
now: () => now,
|
||||
loadState: async () => ({ knownDevices: {} }),
|
||||
saveState: async (state) => writes.push(state),
|
||||
listConnectedDevices: async () => [],
|
||||
scanSubnetHosts: async () => [{ ip: "192.168.31.18", port: 5555 }],
|
||||
adbConnect: async (ip) => `connected to ${ip}:5555`,
|
||||
appendLog: async (event) => events.push(event),
|
||||
});
|
||||
|
||||
assert.equal(summary.connectAttempts, 1);
|
||||
assert.equal(summary.connectSuccesses, 1);
|
||||
assert.equal(events.some((item) => item.event === "connect_success"), true);
|
||||
assert.equal(writes.length > 0, true);
|
||||
});
|
||||
|
||||
test("runKeeperOnce skips unknown hosts and respects active backoff", async () => {
|
||||
const events = [];
|
||||
const now = new Date("2026-04-05T12:00:00.000Z");
|
||||
const config = {
|
||||
subnets: ["192.168.31.0/24"],
|
||||
scanIntervalSeconds: 30,
|
||||
connectTimeoutMs: 4000,
|
||||
allowedDevices: [{ label: "boss-oppo-main", serialHint: "SER-001", ipHint: "192.168.31.18" }],
|
||||
};
|
||||
|
||||
const summary = await runKeeperOnce(config, {
|
||||
now: () => now,
|
||||
loadState: async () => ({
|
||||
knownDevices: {
|
||||
"boss-oppo-main": {
|
||||
serial: "SER-001",
|
||||
lastKnownIp: "192.168.31.18",
|
||||
failureCount: 2,
|
||||
backoffUntil: "2026-04-05T12:01:00.000Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
saveState: async () => {},
|
||||
listConnectedDevices: async () => [],
|
||||
scanSubnetHosts: async () => [
|
||||
{ ip: "192.168.31.18", port: 5555 },
|
||||
{ ip: "192.168.31.77", port: 5555 },
|
||||
],
|
||||
adbConnect: async () => {
|
||||
throw new Error("should not connect during backoff");
|
||||
},
|
||||
appendLog: async (event) => events.push(event),
|
||||
});
|
||||
|
||||
assert.equal(summary.connectAttempts, 0);
|
||||
assert.equal(events.some((item) => item.event === "device_skipped_unknown"), true);
|
||||
assert.equal(events.some((item) => item.event === "backoff_applied"), true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认它们按预期失败**
|
||||
|
||||
Run: `node --test tests/adb-wireless-keeper.test.mjs`
|
||||
|
||||
Expected: FAIL,提示 `runKeeperOnce is not a function` 或摘要字段缺失。
|
||||
|
||||
- [ ] **Step 3: 给核心脚本补一次扫描/重连的最小编排实现**
|
||||
|
||||
```js
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
function toIso(value) {
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
export async function loadState(statePath, fallback = { knownDevices: {} }) {
|
||||
try {
|
||||
return JSON.parse(await readFile(resolve(statePath), "utf8"));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveState(statePath, state) {
|
||||
const resolved = resolve(statePath);
|
||||
await mkdir(dirname(resolved), { recursive: true });
|
||||
await writeFile(resolved, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export async function appendLog(logPath, payload) {
|
||||
const resolved = resolve(logPath);
|
||||
await mkdir(dirname(resolved), { recursive: true });
|
||||
const line = `${JSON.stringify(payload)}\n`;
|
||||
await writeFile(resolved, line, { encoding: "utf8", flag: "a" });
|
||||
}
|
||||
|
||||
function isInBackoff(deviceState, now) {
|
||||
return Boolean(deviceState?.backoffUntil) && new Date(deviceState.backoffUntil).getTime() > now.getTime();
|
||||
}
|
||||
|
||||
export async function runKeeperOnce(config, deps = {}) {
|
||||
const now = deps.now ? deps.now() : new Date();
|
||||
const statePath = config.stateFile ?? DEFAULT_CONFIG.stateFile;
|
||||
const logPath = config.logFile ?? DEFAULT_CONFIG.logFile;
|
||||
const state = deps.loadState
|
||||
? await deps.loadState()
|
||||
: await loadState(statePath);
|
||||
const connected = deps.listConnectedDevices ? await deps.listConnectedDevices() : [];
|
||||
const onlineIps = new Set(connected.map((item) => item.ip).filter(Boolean));
|
||||
const subnets = Array.isArray(config.subnets) && config.subnets.length > 0 ? config.subnets : [];
|
||||
|
||||
let connectAttempts = 0;
|
||||
let connectSuccesses = 0;
|
||||
|
||||
for (const subnet of subnets) {
|
||||
const hosts = deps.scanSubnetHosts ? await deps.scanSubnetHosts(subnet) : [];
|
||||
for (const host of hosts) {
|
||||
const match = shouldManageHost({
|
||||
ip: host.ip,
|
||||
allowedDevices: config.allowedDevices,
|
||||
knownDevices: state.knownDevices ?? {},
|
||||
});
|
||||
if (!match.allowed) {
|
||||
await (deps.appendLog ? deps.appendLog({ ts: toIso(now), event: "device_skipped_unknown", ip: host.ip, subnet }) : appendLog(logPath, { ts: toIso(now), event: "device_skipped_unknown", ip: host.ip, subnet }));
|
||||
continue;
|
||||
}
|
||||
|
||||
const deviceState = state.knownDevices?.[match.label] ?? {};
|
||||
if (onlineIps.has(host.ip)) {
|
||||
await (deps.appendLog ? deps.appendLog({ ts: toIso(now), event: "device_already_online", ip: host.ip, subnet, label: match.label }) : appendLog(logPath, { ts: toIso(now), event: "device_already_online", ip: host.ip, subnet, label: match.label }));
|
||||
continue;
|
||||
}
|
||||
if (isInBackoff(deviceState, now)) {
|
||||
await (deps.appendLog ? deps.appendLog({ ts: toIso(now), event: "backoff_applied", ip: host.ip, subnet, label: match.label }) : appendLog(logPath, { ts: toIso(now), event: "backoff_applied", ip: host.ip, subnet, label: match.label }));
|
||||
continue;
|
||||
}
|
||||
|
||||
connectAttempts += 1;
|
||||
try {
|
||||
await (deps.adbConnect ? deps.adbConnect(host.ip) : Promise.resolve());
|
||||
connectSuccesses += 1;
|
||||
state.knownDevices = state.knownDevices ?? {};
|
||||
state.knownDevices[match.label] = {
|
||||
...(state.knownDevices[match.label] ?? {}),
|
||||
lastKnownIp: host.ip,
|
||||
lastConnectedAt: toIso(now),
|
||||
failureCount: 0,
|
||||
backoffUntil: "",
|
||||
};
|
||||
await (deps.appendLog ? deps.appendLog({ ts: toIso(now), event: "connect_success", ip: host.ip, subnet, label: match.label }) : appendLog(logPath, { ts: toIso(now), event: "connect_success", ip: host.ip, subnet, label: match.label }));
|
||||
} catch (error) {
|
||||
const failureCount = Number(deviceState.failureCount ?? 0) + 1;
|
||||
state.knownDevices = state.knownDevices ?? {};
|
||||
state.knownDevices[match.label] = {
|
||||
...(state.knownDevices[match.label] ?? {}),
|
||||
lastKnownIp: host.ip,
|
||||
lastFailureAt: toIso(now),
|
||||
failureCount,
|
||||
backoffUntil: toIso(now.getTime() + computeBackoffMs(failureCount)),
|
||||
};
|
||||
await (deps.appendLog ? deps.appendLog({ ts: toIso(now), event: "connect_failed", ip: host.ip, subnet, label: match.label, reason: error instanceof Error ? error.message : String(error) }) : appendLog(logPath, { ts: toIso(now), event: "connect_failed", ip: host.ip, subnet, label: match.label, reason: error instanceof Error ? error.message : String(error) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.lastScanAt = toIso(now);
|
||||
state.nextScanAt = toIso(now.getTime() + config.scanIntervalSeconds * 1000);
|
||||
await (deps.saveState ? deps.saveState(state) : saveState(statePath, state));
|
||||
|
||||
return {
|
||||
subnetCount: subnets.length,
|
||||
connectAttempts,
|
||||
connectSuccesses,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 再跑测试确认编排行为通过**
|
||||
|
||||
Run: `node --test tests/adb-wireless-keeper.test.mjs`
|
||||
|
||||
Expected: PASS,新增的编排测试通过,且原有纯逻辑测试不回退。
|
||||
|
||||
- [ ] **Step 5: 提交扫描与重连编排**
|
||||
|
||||
```bash
|
||||
git add tests/adb-wireless-keeper.test.mjs scripts/adb-wireless-keeper.mjs
|
||||
git commit -m "feat: add adb wireless keeper reconnect loop"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 补 CLI 模式、壳脚本与 launchd 安装链
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/adb-wireless-keeper.test.mjs`
|
||||
- Modify: `scripts/adb-wireless-keeper.mjs`
|
||||
- Create: `scripts/adb-wireless-keeper-once.sh`
|
||||
- Create: `scripts/adb-wireless-keeper-status.sh`
|
||||
- Create: `scripts/install-adb-wireless-keeper.sh`
|
||||
- Create: `scripts/uninstall-adb-wireless-keeper.sh`
|
||||
- Create: `deployment/launchd/com.hyzq.boss.adb-wireless-keeper.plist`
|
||||
- Create: `config/adb-wireless-keeper.json`
|
||||
|
||||
- [ ] **Step 1: 先写失败测试,覆盖状态摘要和 launchd 模板渲染**
|
||||
|
||||
```js
|
||||
import {
|
||||
formatStatusSummary,
|
||||
renderLaunchAgentPlist,
|
||||
} from "../scripts/adb-wireless-keeper.mjs";
|
||||
|
||||
test("formatStatusSummary shows subnet, next scan and recent failures", () => {
|
||||
const summary = formatStatusSummary({
|
||||
lastSubnet: "192.168.31.0/24",
|
||||
nextScanAt: "2026-04-05T12:00:30.000Z",
|
||||
knownDevices: {
|
||||
"boss-oppo-main": {
|
||||
lastKnownIp: "192.168.31.18",
|
||||
failureCount: 1,
|
||||
backoffUntil: "2026-04-05T12:01:00.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(summary, /192\\.168\\.31\\.0\\/24/);
|
||||
assert.match(summary, /boss-oppo-main/);
|
||||
assert.match(summary, /2026-04-05T12:00:30\\.000Z/);
|
||||
});
|
||||
|
||||
test("renderLaunchAgentPlist injects repo root and config path", () => {
|
||||
const rendered = renderLaunchAgentPlist({
|
||||
template: `
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>__BOSS_ROOT__</string>
|
||||
<string>__CONFIG_PATH__</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>`,
|
||||
rootDir: "/Users/kris/code/boss",
|
||||
configPath: "/Users/kris/code/boss/config/adb-wireless-keeper.json",
|
||||
});
|
||||
|
||||
assert.match(rendered, /\\/Users\\/kris\\/code\\/boss/);
|
||||
assert.match(rendered, /adb-wireless-keeper\\.json/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认先失败**
|
||||
|
||||
Run: `node --test tests/adb-wireless-keeper.test.mjs`
|
||||
|
||||
Expected: FAIL,提示缺失 `formatStatusSummary` 或 `renderLaunchAgentPlist`。
|
||||
|
||||
- [ ] **Step 3: 在 Node 脚本里补 CLI、状态摘要和 plist 渲染**
|
||||
|
||||
```js
|
||||
export function formatStatusSummary(state) {
|
||||
const lines = [
|
||||
`lastSubnet=${state.lastSubnet ?? ""}`,
|
||||
`lastScanAt=${state.lastScanAt ?? ""}`,
|
||||
`nextScanAt=${state.nextScanAt ?? ""}`,
|
||||
];
|
||||
for (const [label, device] of Object.entries(state.knownDevices ?? {})) {
|
||||
lines.push(
|
||||
`${label} ip=${device.lastKnownIp ?? ""} failures=${device.failureCount ?? 0} backoffUntil=${device.backoffUntil ?? ""}`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function renderLaunchAgentPlist({ template, rootDir, configPath }) {
|
||||
return template
|
||||
.replaceAll("__BOSS_ROOT__", rootDir)
|
||||
.replaceAll("__CONFIG_PATH__", configPath);
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
const mode = argv[0] ?? "once";
|
||||
const configPath = argv[1] ?? "config/adb-wireless-keeper.json";
|
||||
const config = await loadKeeperConfig(configPath);
|
||||
if (mode === "status") {
|
||||
const state = await loadState(config.stateFile);
|
||||
process.stdout.write(`${formatStatusSummary(state)}\n`);
|
||||
return;
|
||||
}
|
||||
if (mode === "once") {
|
||||
const summary = await runKeeperOnce(config);
|
||||
process.stdout.write(`${JSON.stringify(summary)}\n`);
|
||||
return;
|
||||
}
|
||||
throw new Error(`UNKNOWN_MODE: ${mode}`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 新增配置文件、launchd 模板和壳脚本**
|
||||
|
||||
`config/adb-wireless-keeper.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"subnets": [],
|
||||
"scanIntervalSeconds": 30,
|
||||
"connectTimeoutMs": 4000,
|
||||
"adbPath": "",
|
||||
"logFile": "data/adb-wireless-keeper.log.jsonl",
|
||||
"stateFile": "data/adb-wireless-keeper-state.json",
|
||||
"allowedDevices": []
|
||||
}
|
||||
```
|
||||
|
||||
`deployment/launchd/com.hyzq.boss.adb-wireless-keeper.plist`
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.hyzq.boss.adb-wireless-keeper</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/zsh</string>
|
||||
<string>-lc</string>
|
||||
<string>cd __BOSS_ROOT__ && node ./scripts/adb-wireless-keeper.mjs once __CONFIG_PATH__</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/boss-adb-wireless-keeper.out</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/boss-adb-wireless-keeper.err</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
`scripts/adb-wireless-keeper-once.sh`
|
||||
|
||||
```sh
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CONFIG_PATH="${1:-$ROOT_DIR/config/adb-wireless-keeper.json}"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
exec node ./scripts/adb-wireless-keeper.mjs once "$CONFIG_PATH"
|
||||
```
|
||||
|
||||
`scripts/adb-wireless-keeper-status.sh`
|
||||
|
||||
```sh
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CONFIG_PATH="${1:-$ROOT_DIR/config/adb-wireless-keeper.json}"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
exec node ./scripts/adb-wireless-keeper.mjs status "$CONFIG_PATH"
|
||||
```
|
||||
|
||||
`scripts/install-adb-wireless-keeper.sh`
|
||||
|
||||
```sh
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PLIST_SOURCE="$ROOT_DIR/deployment/launchd/com.hyzq.boss.adb-wireless-keeper.plist"
|
||||
PLIST_TARGET="$HOME/Library/LaunchAgents/com.hyzq.boss.adb-wireless-keeper.plist"
|
||||
CONFIG_PATH="${1:-$ROOT_DIR/config/adb-wireless-keeper.json}"
|
||||
|
||||
command -v adb >/dev/null
|
||||
mkdir -p "$HOME/Library/LaunchAgents"
|
||||
python3 - <<'PY' "$PLIST_SOURCE" "$PLIST_TARGET" "$ROOT_DIR" "$CONFIG_PATH"
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
source = Path(sys.argv[1]).read_text()
|
||||
target = Path(sys.argv[2])
|
||||
target.write_text(
|
||||
source.replace("__BOSS_ROOT__", sys.argv[3]).replace("__CONFIG_PATH__", sys.argv[4])
|
||||
)
|
||||
PY
|
||||
plutil -lint "$PLIST_TARGET" >/dev/null
|
||||
launchctl unload "$PLIST_TARGET" >/dev/null 2>&1 || true
|
||||
launchctl load "$PLIST_TARGET"
|
||||
echo "Loaded $PLIST_TARGET"
|
||||
```
|
||||
|
||||
`scripts/uninstall-adb-wireless-keeper.sh`
|
||||
|
||||
```sh
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
PLIST_TARGET="$HOME/Library/LaunchAgents/com.hyzq.boss.adb-wireless-keeper.plist"
|
||||
launchctl unload "$PLIST_TARGET" >/dev/null 2>&1 || true
|
||||
rm -f "$PLIST_TARGET"
|
||||
echo "Removed $PLIST_TARGET"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 跑测试和模板校验**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tests/adb-wireless-keeper.test.mjs
|
||||
plutil -lint deployment/launchd/com.hyzq.boss.adb-wireless-keeper.plist
|
||||
zsh ./scripts/adb-wireless-keeper-status.sh config/adb-wireless-keeper.json
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Node tests PASS
|
||||
- `plutil -lint` 输出 `OK`
|
||||
- `status` 能打印 `lastSubnet=` / `nextScanAt=` 这类摘要字段,即使状态文件还不存在也不报错
|
||||
|
||||
- [ ] **Step 6: 提交 CLI 与守护安装链**
|
||||
|
||||
```bash
|
||||
git add \
|
||||
tests/adb-wireless-keeper.test.mjs \
|
||||
scripts/adb-wireless-keeper.mjs \
|
||||
scripts/adb-wireless-keeper-once.sh \
|
||||
scripts/adb-wireless-keeper-status.sh \
|
||||
scripts/install-adb-wireless-keeper.sh \
|
||||
scripts/uninstall-adb-wireless-keeper.sh \
|
||||
deployment/launchd/com.hyzq.boss.adb-wireless-keeper.plist \
|
||||
config/adb-wireless-keeper.json
|
||||
git commit -m "feat: add adb wireless keeper launchd tooling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 文档收口与整体验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Verify: `tests/adb-wireless-keeper.test.mjs`
|
||||
- Verify: `scripts/adb-wireless-keeper-status.sh`
|
||||
|
||||
- [ ] **Step 1: 把实际脚本名称和使用方式写进 README**
|
||||
|
||||
在 `README.md` 的 Android 段落补实际脚本入口和限制,文本至少包含:
|
||||
|
||||
```md
|
||||
- Android ADB 无线保活脚本:`scripts/adb-wireless-keeper.mjs`
|
||||
- 单次执行:`zsh ./scripts/adb-wireless-keeper-once.sh`
|
||||
- 查看状态:`zsh ./scripts/adb-wireless-keeper-status.sh`
|
||||
- 安装常驻守护:`zsh ./scripts/install-adb-wireless-keeper.sh`
|
||||
- 卸载守护:`zsh ./scripts/uninstall-adb-wireless-keeper.sh`
|
||||
- 该守护只能自动恢复“仍允许 TCP/IP ADB 的设备连接”,不能替 Android 永久保持无线调试开启
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 把当前运行时文档改成和真实交付物一致**
|
||||
|
||||
在 `docs/architecture/current_runtime_and_deploy_status_cn.md` 补一段实际运行说明,文本至少包含:
|
||||
|
||||
```md
|
||||
- 本机当前提供 `adb-wireless-keeper` 守护,用于多 Android 设备在同网段内自动扫描 `5555` 并对白名单/历史已授权设备执行 `adb connect`
|
||||
- 守护配置文件位于 `config/adb-wireless-keeper.json`
|
||||
- 运行态写入 `data/adb-wireless-keeper-state.json` 与 `data/adb-wireless-keeper.log.jsonl`
|
||||
- 安装方式走 `launchd`,由 `scripts/install-adb-wireless-keeper.sh` 管理
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 跑最终验证命令**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tests/adb-wireless-keeper.test.mjs
|
||||
npm run lint
|
||||
npm run build
|
||||
zsh ./scripts/adb-wireless-keeper-status.sh config/adb-wireless-keeper.json
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `node --test` PASS
|
||||
- `npm run lint` 0 errors
|
||||
- `npm run build` success
|
||||
- `status` 命令可运行并输出状态摘要
|
||||
|
||||
- [ ] **Step 4: 做一轮人工无线重连验收**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
adb devices -l
|
||||
adb disconnect 192.168.31.18:5555
|
||||
zsh ./scripts/adb-wireless-keeper-once.sh config/adb-wireless-keeper.json
|
||||
adb devices -l
|
||||
```
|
||||
|
||||
Expected:
|
||||
- 目标设备在断开后被重新连接回 `192.168.31.18:5555`
|
||||
- 如果设备当前已不允许 TCP/IP ADB,脚本写失败日志并进入退避,而不是无限刷 `adb connect`
|
||||
|
||||
- [ ] **Step 5: 提交文档和最终收口**
|
||||
|
||||
```bash
|
||||
git add \
|
||||
README.md \
|
||||
docs/architecture/current_runtime_and_deploy_status_cn.md
|
||||
git commit -m "docs: wire adb wireless keeper into runtime docs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plan Self-Review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
- 多设备:Task 2 的 `runKeeperOnce` 和 `knownDevices`/白名单逻辑覆盖。
|
||||
- 自动扫描同网段:Task 2 负责一次扫描编排,Task 4 做人工验收。
|
||||
- 后台常驻:Task 3 补 `launchd` 模板与安装脚本。
|
||||
- 状态/日志/退避:Task 2 和 Task 3 覆盖。
|
||||
- 文档入口:Task 4 覆盖。
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
- 本计划没有留下未决占位标记。
|
||||
- 每个代码步骤都给了实际文件路径、命令和最小代码。
|
||||
|
||||
### Type consistency
|
||||
|
||||
- 核心导出统一使用:
|
||||
- `loadKeeperConfig`
|
||||
- `computeBackoffMs`
|
||||
- `shouldManageHost`
|
||||
- `loadState`
|
||||
- `saveState`
|
||||
- `appendLog`
|
||||
- `runKeeperOnce`
|
||||
- `formatStatusSummary`
|
||||
- `renderLaunchAgentPlist`
|
||||
Reference in New Issue
Block a user