49 KiB
Only in One · 故障排查手册
19 条真实踩过的坑,每条按 现象 / 可能原因 / 排查步骤 / 解决 四段写。 命令默认在仓库根目录执行;涉及服务端的在
server/下。第 16 条(对端证书不落盘)是 D3/D4 把证书文件名键从
device_name改成device_id之后新增的;升级后「凭据目录里同时存在新旧两种文件名」是预期行为, 见该条末尾的说明。第 17 / 18 条是 T-6 打包交付新增的:覆盖安装后"还是旧版本/两个 app", 以及「数据目录到底在哪、能不能搬、怎么彻底卸载」。
第 19 条是用户在真机上第一次撞到之后补的:双击提示「包含恶意软件」打不开。 它把「未公证 ≠ 中毒」「macOS 15 起『右键 → 打开』已失效」「每次重新打包要再放行一次」 三件事一次说清 —— 这条最容易被误判成"包中毒了",先看它再决定要不要删包。
文末附录 A 是本项目真实踩过的「测试反模式」清单(6 条),写用例前值得扫一眼。
⚠️ 先看这条:迁移备份只复制主 db 文件
server/migrate.js#createBackup()用的是fs.copyFileSync(dbPath, target)—— 只复制主数据库文件。当前实现没开 WAL(journal_mode保持 SQLite 默认), 所以「冷启动时备份」是完整的。但一旦将来把
journal_mode改成WAL(并发读性能更好,很常见的改动), 最新事务可能还在-wal文件里没 checkpoint 回主文件,此时copyFileSync出来的备份会缺最后一部分已提交数据,而它恰恰是「迁移失败后用来回滚」的那份。改 WAL 时必须同时把备份改成:
db.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`); // 推荐:一致性快照 // 或先 db.exec('PRAGMA wal_checkpoint(TRUNCATE)') 再 copyFileSync详见第 10 条。
1. 服务端起不来:JWT_SECRET 被拒绝
现象
配置校验失败:
- JWT_SECRET 未设置。生成方式:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
或
- JWT_SECRET 长度必须 ≥32 字符(当前 12 字符),否则可被暴力穷举
- JWT_SECRET 命中了内置默认值黑名单(如 dev-only-change-me),必须替换为随机串
进程 exit code 1,不监听端口。
可能原因
.env没建、或建了但没被加载;JWT_SECRET太短(<32)或命中弱值黑名单(secret/changeme/todo/onlyinone…);- 全同字符(
aaaaaaaa…); NODE_ENV=production下设了ALLOW_WEAK_SECRET=1(生产环境无效)。
排查步骤
cd server
ls -la .env # 存在吗?
node -e "console.log(JSON.stringify(process.env.JWT_SECRET||'(unset)'))" # 别直接 echo,避免进 shell history
node -e "console.log(require('fs').readFileSync('.env','utf8').split('\n').filter(l=>l.startsWith('JWT')).map(l=>l.split('=')[0]))"
npm start # 看第一段报错,它会直接告诉你哪条不满足
解决
# 1) 生成强密钥
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
# 或: openssl rand -hex 48
# 2) 写进 server/.env(不要提交进仓库)
echo "JWT_SECRET=<上一步的输出>" >> server/.env
# 3) 用带 env 加载的方式启动(package.json 的 start 脚本已内置 --env-file-if-exists)
cd server && npm start
校验逻辑落点:
server/config.js#inspectJwtSecret()。npm start=node --env-file-if-exists=.env server.js,所以.env会被真正加载。
2. ExperimentalWarning: SQLite is an experimental feature
现象
(node:11395) ExperimentalWarning: SQLite is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
服务照常启动、测试照常通过。
可能原因
用的是 node:sqlite(Node 内置模块),在 Node 22 上仍标为实验特性。这不是错误。
排查步骤
node -v # 需要 >= 22.5;推荐 >= 24
node -e "require('node:sqlite'); console.log('sqlite ok')"
解决
无需处理。想彻底消掉告警就升级到 Node ≥24(server/package.json 的
engines.node 是 >=22.5)。服务启动时也会打一条 info 日志提醒这一点
(server.js 里检测到 22.x 才打)。
3. node --test <目录> 报 Cannot find module .../test
现象
Error: Cannot find module '/path/to/server/test'
或测试数变成 0。
可能原因
node --test 早期版本的目录参数解析与 glob 支持在不同小版本间不一致;
传目录在部分版本上会被当成模块路径去 require。
排查步骤
cd server
cat package.json | node -e "process.stdin.on('data',d=>console.log(JSON.parse(d).scripts.test))"
# 必须是: node --test test/*.test.mjs
解决
用 glob 形式(package.json 里已经是这样,直接 npm test 即可):
cd server && npm test
# 等价于: node --test test/*.test.mjs
# 覆盖率门禁
cd server && npm run test:coverage
# 等价于: node --test --experimental-test-coverage --test-coverage-lines=60 test/*.test.mjs
4. clone 之后 npm test 报 fixture ENOENT
现象
Error: ENOENT: no such file or directory, open '.../server/test/fixtures/test-cert.pem'
而在原作者机器上一切正常。
可能原因
机器的全局 gitignore(~/.gitignore_global)里常有 *.pem,导致 .pem fixture
从来没进过版本库。原作者 git status 看不到它,所以毫无察觉。
排查步骤
git ls-files | grep -i fixtures
# 期望能看到:server/test/fixtures/fakeSecrets.mjs、server/test/fixtures/testCert.mjs
# 如果只看到 .mjs、看不到任何 .pem —— 说明设计如此(见下)
git check-ignore -v server/test/fixtures/test-cert.pem
# 若输出命中 ~/.gitignore_global 的 *.pem,就是本条的根因
解决
不要用 git add -f 强塞 .pem(下一个人加新 fixture 会再踩一次)。
正确做法是 fixture 用 .mjs 模块导出字符串 —— 现成实现:
server/test/fixtures/testCert.mjs:export const TEST_CERT_PEM = [...].join('\n')server/test/fixtures/fakeSecrets.mjs:dec()+ 运行期拼装(见第 5 条)
5. git commit 被 gitleaks 阻断
现象
WRN leaks found: 6
Finding: <PEM 私钥块起始标记> ← 命中的就是这段标记本身
Secret: <同上>
RuleID: private-key
Tags: [decoded:base64 decode-depth:1]
本文也遵守同样的规矩:不写 PEM 字面量(连示例都不写),否则本文件自己就会被 pre-commit 拦下 —— 写这份文档时已实测撞了一次。完整输出请直接跑
gitleaks protect --staged -v看。
提交被 pre-commit hook 拒绝。
可能原因
- 测试里为了验证「含私钥的 PEM 必须被拒绝」,直接写了明文 PEM;
- 把假密钥 base64 之后仍然命中 —— gitleaks 8.30.1 会先 base64 解码再匹配
(日志里的
decoded:base64就是证据); - 把 base64 切块拼接 —— 切出来的高熵串会命中
generic-api-key(按「关键字邻近 key/token/secret + 高熵」判定)。
排查步骤
# 看具体命中位置与规则
gitleaks protect --staged --verbose 2>&1 | head -40
# 确认 hook 是全局的(不是仓库里的)
git config --get core.hooksPath
ls -la "$(git config --get core.hooksPath 2>/dev/null || echo .git/hooks)" | head
解决
按 server/test/fixtures/fakeSecrets.mjs 的模式改:
- PEM 的
BEGIN/END标记运行期拼装:'-'.repeat(5)+ 标签 +'-'.repeat(5); - 载荷体 base64 存、运行时
dec()解码; - 每个 base64 串单独成行且该行不含
key/token/secret关键字 (连 PEM 标签也 base64 存:TAG_PKCS8); - JWT 分隔符用
String.fromCharCode(46); - 加 import 期
SELF_CHECK保证解码结果与原来逐字节一致。
❌ 不要加
.gitleaksignore、不要git commit --no-verify。 那等于把整个仓库的密钥扫描关掉 —— 之后真泄漏也发现不了。
6. 登录后设备一直显示「离线 / 暂无其他设备」
现象
两台机器都登录了,但设备列表里对方一直是灰点或干脆没有。
可能原因
- 对方启动器只登录了、没点「上线」 —— 心跳是 30s 周期,首帧要等第一次 tick;
- 服务端
last_seen超过 90 秒即判定离线(客户端online阈值与server.js#ONLINE_THRESHOLD_MS同为 90_000); - 两台机器的
device_name相同(都取os.hostname()),被当成同一行设备; - 客户端处于
degraded(连续 2 次心跳失败),界面显示的是本地缓存。
排查步骤
# 1) 服务端看设备行与 last_seen(毫秒时间戳)
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log(db.prepare('SELECT id,user_id,device_id,device_name,platform,last_seen FROM devices').all());
console.log('now=',Date.now());
"
# 2) 直接打心跳看返回
curl -s -X POST http://127.0.0.1:3000/api/v1/devices/heartbeat \
-H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \
-d '{"device_name":"manual-test"}'
# 3) 看启动器日志
tail -50 ~/Library/Application\ Support/onlyinone-launcher/logs/launcher.log
解决
- 点一次「上线(心跳 + 同步证书 + 发现设备)」,等 ≥30s 再看;
- 若两台机器 hostname 相同:改其中一台的系统主机名(
scutil --set HostName/ Windows 设置里改); degraded态下先确认服务器可达(curl /api/v1/health),心跳会自动重试、无需重启。
7. 换了服务器地址/端口后,请求还打到旧地址
现象
服务器已迁到新机器/新端口,启动器仍报 NETWORK_UNREACHABLE,或抓包看到请求发往旧 IP。
可能原因
- 启动器设置项里存着旧的
serverUrl(设置项优先级最高,压过环境变量); - 部署在反向代理后,代理缓存了旧的 308 重定向(308 是永久重定向,会被缓存);
- 旧地址上还跑着一个旧版本服务,看起来「有响应」但其实连错了库。
说明:本服务的 308 只做路径重写(
/api/*→/api/v1/*),Location是 相对路径,不会把 host 换掉。所以「跳到旧地址」一定是配置或代理缓存问题, 不是重定向逻辑问题。
排查步骤
# 1) 启动器实际用的是哪个地址(设置项 > 环境变量 > 默认值)
# 界面上「服务器地址」输入框即设置项值;查看落盘:
cat ~/Library/Application\ Support/onlyinone-launcher/settings.json
# 2) 服务端自己监听的地址
curl -s http://127.0.0.1:3000/api/v1/health
# 3) 跟随重定向看完整链路
curl -sv http://<新地址>:3000/api/devices 2>&1 | grep -E "^< HTTP|^< Location|Connected"
解决
- 在启动器界面把「服务器地址」改成新地址并点登录(会持久化到
settings.json); - 清代理缓存里 308 的条目,或把代理规则改成直接转发到
/api/v1; - 内网调试可用环境变量覆盖:
OIO_API_BASE=http://192.168.1.10:3000, 但设置项一旦有值就压过它 —— 想用环境变量请先把界面上的地址清空。
8. 限流把自己锁了 15 分钟
现象
登录一直返回:
{ "ok": false, "error": { "code": "RATE_LIMITED", "message": "尝试过于频繁,请 15 分钟后再试", "hint": "...", "details": { "retryAfter": 812 } } }
连正确密码也进不去。
可能原因
同一 IP 或同一用户名,在 60s 窗口内失败 ≥5 次即锁定 15 分钟。
最常见的原因是自己写脚本连续试密码。计数落库(login_failures 表),
重启进程不会解锁。
排查步骤
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
const rows=db.prepare('SELECT subject,count,locked_until FROM login_failures ORDER BY locked_until DESC').all();
console.log(rows.map(r=>({...r, remainSec: Math.max(0, Math.round((r.locked_until-Date.now())/1000))})));
"
解决
# 方案 A:等(最小 15 分钟)
# 方案 B:清掉某个主体
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log(db.prepare(\"DELETE FROM login_failures WHERE subject=?\").run('ip:127.0.0.1'));
"
# 方案 C:全清(演示环境/本地开发用)
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log(db.prepare('DELETE FROM login_failures').run().changes,'rows cleared');
"
部署在反向代理后务必设
TRUST_PROXY=1,否则所有人共享一个 IP 计数器, 一个人失败 5 次就把全团队锁住(见docs/security.md§7)。
9. 迁移失败后数据库被回滚了,想确认/手工恢复
现象
启动时报 数据库迁移失败,拒绝启动,进程 exit 1。再启动时数据「回到迁移前」。
可能原因
这是设计行为:runMigrations() 在每个迁移文件前都先备份,任一语句失败会
ROLLBACK → 关连接 → 用备份覆盖 db 文件 → 抛 MigrationError。
备份在 server/data/backups/onlyinone-<ISO时间戳>.db,只保留最近 3 份。
排查步骤
cd server
ls -la data/backups/ # 应该有 1~3 个 onlyinone-*.db
tail -30 data/logs/server.log | grep -i migration
node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log('applied:',db.prepare('SELECT version,name FROM schema_migrations ORDER BY version').all());
console.log('users:',db.prepare('SELECT COUNT(*) c FROM users').get());
"
解决
# 0) 先停服务,确认没有进程占用 db
pgrep -f "server.js" && echo "还有进程,先停" || echo clean
# 1) 备份当前(已经回滚过的)库,便于事后对比
cp data/onlyinone.db /tmp/broken-$(date +%s).db
# 2) 用指定备份手工恢复(先选好时间戳)
cp data/backups/onlyinone-2025-01-01T12-00-00-000Z.db data/onlyinone.db
# 3) 若目录里存在 -wal / -shm(WAL 模式才有),一并删掉,否则旧快照会与新主文件不一致
rm -f data/onlyinone.db-wal data/onlyinone.db-shm
# 4) 修掉那个坏迁移 SQL,再启动
npm start
备份只有 3 份:如果连着失败 3 次以上,真正的「迁移前」备份可能已经被轮转删掉了。 所以修 SQL 之前不要反复重启 —— 每失败一次就消耗一代备份。
10. 迁移备份只复制主 db 文件(将来启用 WAL 必须改)
现象
目前没有现象 —— 这是给未来埋的雷。
可能原因
createBackup() 用 fs.copyFileSync(dbPath, target)。默认 journal 模式下冷启动复制
是完整的;但如果有人把 journal_mode 改成 WAL(为并发读):最新事务在
-wal 文件里,copyFileSync 只拿到主文件 ⇒ 备份缺最新数据,而它正是回滚用的那份。
排查步骤
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log('journal_mode =',db.prepare('PRAGMA journal_mode').get());
"
# 若输出 wal,就命中本条的适用范围
解决
启用 WAL 时必须同步改 server/migrate.js#createBackup():
// 推荐:一致性快照,无关 journal 模式
db.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
// 或者先 checkpoint 再复制文件
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
fs.copyFileSync(dbPath, target);
并且回滚时一并处理 -wal/-shm(见第 9 条第 3 步)。
11. 首次升级后,同一台机器出现两行设备
现象
GET /api/v1/devices 里同一台机器出现两条:一条 device_id 为 null(旧行,只有
device_name),一条 device_id 有值(新行)。
可能原因
这是预期行为。 迁移 002 给 devices 加了 device_id 列;老数据填 null,
而唯一索引是 UNIQUE(user_id, device_id) —— SQLite 里 NULL 互不相等,所以
老行 + 新行可以共存。心跳按 (user_id, device_name) 查找,只更新它找到的那一行。
排查步骤
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log(db.prepare('SELECT id,user_id,device_id,device_name,last_seen FROM devices ORDER BY device_name,id').all());
"
解决
一般无需处理(客户端按 device_name 过滤自己,不影响连接)。想清理就手工合并:
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
// 1) 把旧行的证书迁到有 device_id 的那行(若旧行有证书)
// 2) 删掉 device_id 为 NULL 且 device_name 与某新行重名的旧行
const dupes = db.prepare(\`
SELECT old.id AS old_id FROM devices old
JOIN devices nw ON nw.user_id=old.user_id AND nw.device_name=old.device_name AND nw.device_id IS NOT NULL
WHERE old.device_id IS NULL\`).all();
for (const {old_id} of dupes) {
db.prepare('DELETE FROM certs WHERE device_id = ?').run(old_id);
db.prepare('DELETE FROM devices WHERE id = ?').run(old_id);
}
console.log('removed', dupes.length);
"
12. USB 挂载报 UNSUPPORTED_ON_PLATFORM
现象
点「挂载」后提示「当前系统不支持该操作(错误码 UNSUPPORTED_ON_PLATFORM)」。
可能原因
该方向的能力位在平台表里是 unsupported —— 不是缺依赖,是本轮没做。
三端能力矩阵(launcher/src/platform/*.ts 的 *_CAPABILITIES):
| 能力位 | Windows | macOS | Linux |
|---|---|---|---|
streamHost(被控) |
✅ | ❌ 需虚拟显示器,V2 | ❌ 未验收 |
streamClient(主控) |
✅ | ✅ | ❌ 未验收 |
usbExport(导出本机 USB) |
✅ | ❌ 无 usbipd | ❌ 未验收 |
usbImport(挂载远端 USB) |
❌ 本轮只做导出 | ✅ | ❌ 未验收 |
smbExport(本机共享) |
✅ | ❌ 用系统「文件共享」 | ❌ 未验收 |
smbImport(打开远端共享) |
✅ | ✅ | ❌ 未验收 |
mdns |
❌ 回落端口扫描 | ✅ | ❌ 未验收 |
credential(系统加密存储) |
✅ | ✅ | ❌ 仅内存 |
排查步骤
# 在启动器里看「本机角色与依赖状态」卡片;或直接查能力表
cd launcher && npx vitest run test/platform.test.ts # 含 8 位 × 3 端逐项断言
解决
按矩阵换机器:Windows 当被控 + 导出 USB,macOS 当主控 + 挂载 USB。 Linux 见第 15 条。UI 会显示能力位自带的中文原因(例如 「本轮 Windows 端只做 USB 导出,导入请用 macOS 主控端」),不会显示成"未安装"。
13. usbip attach 提权失败 / macOS 弹授权框后没反应
现象
挂载长时间无响应;或返回 ELEVATION_REQUIRED / COMMAND_FAILED。
可能原因
- macOS 的
usbip attach需要sudo,我们走osascript弹系统授权框 —— 用户点了「取消」 会得到execution error: User canceled. (-128),映射为ELEVATION_REQUIRED; - 目标机没有
usbipd(Windows)或该 busid 未共享; - 网络不通(
usbip默认端口 3240)。
排查步骤
# 1) 目标端是否在共享
usbipd list # Windows(管理员)
# 2) 网络是否可达
nc -zv <host> 3240
# 3) 手动跑一次,看真实报错(这条最能定位问题)
sudo usbip attach -r <host> -b 1-2
# 4) 启动器日志里有结构化错误码
tail -50 ~/Library/Application\ Support/onlyinone-launcher/logs/launcher.log | grep -i usb
解决
- macOS 上请在弹窗里点「允许」(
osascript需要「辅助功能」或管理员口令); - 目标端先
usbipd bind --busid 1-2,再在自己的启动器里点「共享」; - 跨网段时确认 3240/tcp 放行;USB over IP 不建议跨公网(延迟与稳定性)。
14. SMB 共享打不开
现象
「打开共享文件夹」失败;或本机共享开启失败。
可能原因
- 本机共享在 Windows 上依赖
scripts/setup-smb.ps1(建共享 +EncryptData), 没执行过enableLocal就没有共享; - 防火墙拦了 445/tcp;
- macOS 的「本机共享」是
unsupported(要用系统设置里的「文件共享」); - 共享名不在白名单
[A-Za-z0-9_$-]{1,64}内。
排查步骤
# Windows:共享是否真的存在
Get-SmbShare | Where-Object Name -eq 'OnlyInOne'
# 防火墙
Get-NetFirewallRule -DisplayGroup 'File and Printer Sharing' | Select DisplayName,Enabled
# 端口
nc -zv <host> 445
# 共享名白名单(与服务端/客户端同一规则)
node -e "console.log(/^[A-Za-z0-9_\$-]{1,64}$/.test('OnlyInOne'))"
解决
# 管理员 PowerShell,在目标目录执行(脚本内置路径/共享名校验 + 幂等)
powershell -ExecutionPolicy Bypass -File scripts\setup-smb.ps1 -SharePath "D:\Share" -ShareName OnlyInOne
macOS 作为共享方:系统设置 → 通用 → 共享 → 文件共享,手动添加目录;
客户端侧仍可用「打开共享文件夹」(smbImport 在 macOS 是支持的)。
15. Linux 端为什么大部分功能不可用
现象
Linux 上启动器能登录、能看设备列表,但 USB / 共享 / 串流全部报
UNSUPPORTED_ON_PLATFORM,且提示「本轮未做 Linux 真机验证,暂不支持」。
可能原因
这是本轮明确的范围边界,不是 bug:Linux 只做了「代码分支就绪、不纳入验收」。
所有 8 个能力位在 LINUX_CAPABILITIES 里都是 unsupported(credential 为
「仅内存」),usb === null(显式降级实现类,不是 else 兜底)。
docs/packaging.md 里列了「未来纳入 Linux 需补什么」的 9 条清单。
排查步骤
# 确认不是环境问题,而是设计如此
cd launcher && npx vitest run test/platform.test.ts
# 断言包含:linux 的 usb === null、8 位能力全 unsupported、原因文案为中文
# 想验证 Linux 分支代码本身是否可用(不真机):
cd launcher && npx vitest run test/... # 各平台纯函数测试都可在任意宿主跑
解决
- 需要 Linux 串流/USB:本轮请改用 Windows(被控 + USB 导出)+ macOS(主控 + USB 挂载);
- 想自己接 Linux:按
docs/packaging.md的 9 条清单逐条补(platform/linux.ts把state从unsupported改成supported并补实现,调用方零改动); - 登录/设备列表/心跳在 Linux 上本来就能用(
credential降级为内存,重启需重登)。
16. 对端证书不落盘 / 凭据目录里的文件「只增不减」
现象
- 连对端时仍然被要求输 PIN(说明 Sunshine 没拿到对方公钥);
- 或者凭据目录里躺着一堆
moonlight-*.pem,把服务端已经没有的设备删掉后它们还在; - 启动器日志里反复出现
peer 证书缺少 device_id,跳过写入/peer 证书 device_id 不符合 [A-Za-z0-9-]{8,64}/受管证书未全部落盘; - 或者出现
本机 device_id 已被他人占用,已换新 id—— 这是自愈动作, 不是故障,见下方「解决」第 1 条。
可能原因(按概率排序)
- 该设备的
device_id为空(reason = missing_device_id)。 D3 之后证书文件名主体是device_id,没有它就无法生成文件名。为空的唯一情形是 「这台设备从未成功心跳过」(心跳里device_id是可选字段,老客户端不带)。 device_id格式不合法(reason = invalid_device_id)。必须是[A-Za-z0-9-]{8,64};带._空格、或长度不足 8 位的值一律拒收。- 某一轮写入失败,导致整轮清理被跳过(
reason = expected_missing)。 这是有意的保护:只有当「本该落盘的文件」全部确实在目录里时,才允许删除多余文件; 否则说明本地与服务端快照不一致,删任何一个都可能删掉唯一可用副本。 - 凭据目录本身不可写 / 不存在(
reason = io_error带code, 或dir_missing)。CRED_DIR_NO_PERMISSION常见于 Sunshine 以服务账号运行、 而启动器以当前用户运行。
排查步骤
# 1) 看服务端记的 device_id(为空/过短的设备就是「写不进去」的那台)
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log(db.prepare('SELECT device_name,device_id,last_seen FROM devices ORDER BY id').all());
"
# 2) 看凭据目录里到底有什么(只列文件名,不要 cat 内容)
# macOS / Linux
ls -l ~/Library/Application\ Support/Sunshine/credentials/ 2>/dev/null || ls -l ~/.config/sunshine/credentials/
# Windows
dir "%APPDATA%\Sunshine\credentials"
# 3) 看启动器日志里的 reason(时间戳对齐一次心跳周期 30s)
grep -E "missing_device_id|invalid_device_id|受管证书未全部落盘|peer 证书" \
~/Library/Application\ Support/onlyinone-launcher/logs/launcher.log | tail -20
# 3b) 「凭据目录只增不减」专门看这一条:expected_missing 的 warn 带
# missingCount(缺几个)+ sample(缺哪几个文件名,最多 3 个)。
# 它意味着「本该落盘却没落盘」⇒ prune 整轮静默跳过,不是清理本身有 bug。
grep -A2 "受管证书未全部落盘" ~/Library/Application\ Support/onlyinone-launcher/logs/launcher.log | tail -20
# missingCount > 0 且持续多轮不降 = L5 在长期拦住 prune:
# 先按上面第 1 步查那几台设备的 device_id,再按第 4 步查目录权限。
# 4) 确认目标设备的 device_id 是否真的上报成功(正常应回显非 null)
curl -sS -X POST http://127.0.0.1:3000/api/v1/devices/heartbeat \
-H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \
-d '{"device_name":"windesk","device_id":"9f1c2b7e-2a4d-4c6b-9f0e-1a2b3c4d5e6f"}'
解决
- 两台机器共用同一个
device_id(克隆虚拟机 / 复制了settings.json)⇒ 一个心跳周期内自动恢复。 现象是启动器日志里出现两条 warn:服务端把后到的那台降级为服务端:新设备 device_id 与既有记录冲突,已降级为 null 插入(保住在线状态) deviceId8=xxxxxxxx 客户端:本机 device_id 已被他人占用,已换新 id(下一轮心跳生效) previous8=xxxxxxxx next8=yyyyyyyydevice_id = null(不返回 500,在线状态保住); 客户端读到响应里的device_id: null后立即生成并持久化一个新 UUID,下一轮心跳 (≤30s)就带上新 id ⇒ 证书随之落进对端 Sunshine 目录 ⇒ 不需要手输 PIN、不需要 重启、不需要人工干预。 判据:等一个心跳周期后看设备列表里那台设备的device_id,已经从null变成新值。 若一直是null,说明跑的是老客户端(不会自愈),升级该客户端即可。 missing_device_id/invalid_device_id:先让那台设备成功心跳一次 (在它的启动器上点「上线」)。心跳写入device_id后,本轮 ≤30s 即自愈, 不需要重启、不需要手工建文件。若它跑的是很老的客户端(心跳不带device_id), 升级该客户端即可 —— 服务端不会替它编一个 id。expected_missing:先修掉同一轮里的io_error(权限/磁盘满),其余不动。 这条不需要任何「清理」动作:受管文件补齐后,陈旧文件会在下一轮正常被清。CRED_DIR_NO_PERMISSION:让启动器与 Sunshine 用同一个用户运行, 或在设置页把「凭据目录」指到一个当前用户可写的目录。- 升级后凭据目录里同时存在新旧两种文件名是正常的、无需处理:
新文件形如
moonlight-<device_id>.pem,旧文件形如moonlight-<device_name>.pem。旧文件对本工具来说属于「命名不合规」 (not_managed_naming),既不参与清理也不会被删除;它们只是几 KB 的公钥副本, 可以手工删。本轮不提供、也不需要迁移脚本 —— 证书文件是纯缓存 (内容完全来自GET /api/v1/certs,本机私钥从不写在这里),下一次成功同步即重建。 - 仍然不行:按第 6 条的「排查步骤」导出启动器日志,并把
GET /api/v1/certs的响应(去掉cert_pem)一起贴出来。
已知边界(不阻塞、不需要改设计):理论上两个
device_id若仅大小写不同 (如Aaaa1111/aaaa1111),在大小写不敏感的文件系统上仍会落到同一个文件。 现实里不会触发:device_id由ensureDeviceId()用crypto.randomUUID()生成, 全小写且随机,两台机器撞到「仅大小写不同」的概率可忽略。即便真撞上,后果也只是 其中一台的证书本轮没写成功 —— prune 会因 L5 走expected_missing零删除, 不会误删,不会变成「连接突然要求输 PIN」。 这也是 D3 相比 D1 的实质改进:旧方案(用device_name当文件名)在大小写不敏感 文件系统上必然覆盖,而这里只是「理论上可能」,且被 L5 兜住。
17. 覆盖安装后还是旧版本 / /Applications 里出现两个 app
现象
- 把新
OnlyInOne-0.2.0-mac-arm64.dmg拖进「应用程序」替换后,行为/界面还是旧的 (本项目 UI 不显示版本号,所以"看起来一样"本身不能当判据); - 或者
/Applications里出现两个Only in One(Finder 会叫Only in One 2.app); - 或者覆盖后"数据丢了":登录态没了、设置回到默认。
可能原因(按概率排序)
- 应用还在跑。本工具有托盘常驻:关窗口 ≠ 退出,进程还在时 Finder 替换会失败 (或替换的是另一个副本),用户看到的仍是旧进程。
- 旧副本被 LaunchServices 记住。曾直接从 dmg 里双击运行过 → 注册的是
/Volumes/Only in One 0.2.0-arm64/...那份(卷名带版本号);覆盖后双击 dmg 仍可能拉起旧副本。 productName被改过(.app改名)→ 新旧两个 app 并存,用户双击到哪个看运气。launcher/test/paths.test.ts有守卫断言它必须是Only in One。appId被改过 → 系统视为另一个 App:Preferences plist 新建、TCC 授权重新要、 登录项失效。此时数据其实没丢,只是落在"新 App"的目录里(见第 18 条)。- 拖进去的不是新包(比如拿了
launcher/release/里残留的 8 月旧产物 —— 已在 T-6 删除)。
排查步骤
# 1) 还有没有实例在跑(有输出 = 没真退出,先退出再装)
pgrep -af "Only in One.app/Contents/MacOS/Only in One"
# 2) /Applications 里到底几个
ls -d /Applications/Only*
# 3) 版本与身份(期望 0.2.0 / com.onlyinone.launcher)
plutil -p "/Applications/Only in One.app/Contents/Info.plist" \
| grep -E "CFBundleShortVersionString|CFBundleIdentifier|CFBundleName"
# 4) app.asar 的时间戳应是本次构建时间(不是 8 月)
ls -l "/Applications/Only in One.app/Contents/Resources/app.asar"
# 5) LaunchServices 里登记了几份(同一 appId 出现多条 = 第 2/3 条原因)
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
-dump | grep -i "Only in One" | grep -i "\.app" | head
解决
- 先真退出:托盘菜单 →「退出」(或
osascript -e 'quit app "Only in One"'), 确认pgrep无输出后再拖拽替换。 - 重注册新位置:
lsregister -f "/Applications/Only in One.app"; 若记录里还有/Volumes/...,把 dmg「推出」后重跑一次。 - 删掉多出来的副本:删前先看它的 appId(
plutil -p "…/Contents/Info.plist" | grep CFBundleIdentifier)。 appId 相同 = 两份共用同一个数据目录,删哪份都不丢数据;appId 不同 = 跑的是改过配置的包, 数据目录也不同(见第 18 条迁移办法)。 - 数据"消失"先别慌:先按第 18 条确认目录落在哪,再决定是搬回来还是本来就没丢。
- 校验你拿到的是新包:
shasum -a 256 OnlyInOne-0.2.0-mac-arm64.dmg,与同目录SHA256SUMS.txt(或发送方给出的值)逐字符对比。
18. 应用到底建了哪些文件夹 / 数据能不能搬 / 怎么彻底卸载
现象
- 想知道「这个工具会不会在系统里到处建文件夹」;
- 换机器或重装前想确认哪些文件是"数据"、能不能整体搬走;
- 或者反过来:
~/Library/Application Support里同时出现onlyinone-launcher与Only in One(带空格)两个目录。
可能原因
- 正常形态就是 1 个数据目录 + 1 个 Preferences plist(见下表)。数量是收敛的,
因为数据根目录被
core/paths.ts的DATA_DIR_NAME常量钉死,不再由 app 名推算。 - 多出来的
Application Support/Only in One只可能来自「package.json被加过productName」的老包(T-6 之前有"productName": "Only in One"时会这样)。 Credentials/Keychain里的凭据是系统托管的(safeStorage→ 钥匙串),不在数据目录里。
排查步骤
# macOS:期望**恰好 2 条**(1 个数据目录 + 1 个 plist)
find ~/Library -maxdepth 2 -iname "*onlyinone*"
# 数据目录里应该有什么(settings.json / credential.bin / logs/ / app-cache/ + Chromium profile)
ls ~/Library/Application\ Support/onlyinone-launcher
du -sh ~/Library/Application\ Support/onlyinone-launcher
# 应用日志(排障第一现场)
tail -50 ~/Library/Application\ Support/onlyinone-launcher/logs/launcher.log
:: Windows:数据目录唯一,程序本体在你自己选的解压目录
dir "%APPDATA%\onlyinone-launcher"
dir "%LOCALAPPDATA%" | findstr /i onlyinone :: 期望无输出(zip 形态不建 LocalAppData 目录)
这张表就是全部(macOS)
| 路径 | 由什么决定 | 内容 | 能否不建 |
|---|---|---|---|
~/Library/Application Support/onlyinone-launcher/ |
core/paths.ts#DATA_DIR_NAME(硬编码常量) |
唯一数据目录:settings.json、credential.bin、logs/、app-cache/,外加 Chromium 自己的 profile(Cache/、Code Cache/、GPUCache/、Cookies、Local Storage/ …) |
❌ 必须有 |
~/Library/Preferences/com.onlyinone.launcher.plist |
appId(CFBundleIdentifier) |
Electron 首启写入的 4 条 macOS 外观默认值,约 190 B | ❌ 系统托管,无法重定向 |
/Applications/Only in One.app |
productName |
程序本体(约 225 MB 未压缩) | ❌ |
/Volumes/Only in One 0.2.0-arm64 |
dmg 挂载点(卷名带版本号,不是 Only in One) |
装机时的临时卷 | ✅ 推出即消失 |
~/Library/Caches/onlyinone-launcher/、~/Library/Logs/…、~/Library/Saved Application State/… |
— | 不存在(Chromium 缓存在数据目录内部;日志写 数据目录/logs/) |
✅ 本来就不会建 |
Windows 侧同理:
%APPDATA%\onlyinone-launcher\是唯一数据目录;程序本体在用户自选的 解压目录里,不写注册表卸载项、不建%LOCALAPPDATA%目录。开机自启(默认关闭) 由app.setLoginItemSettings写HKCU\…\Run,关掉自启即消失。⚠️ 我们的子目录叫
app-cache而不是cache:Chromium 在同一目录建的是Cache/, 而 APFS 与 NTFS 都大小写不敏感,cache与Cache会是同一个目录, 我们的devices.json就会被 Chromium 的缓存清理逻辑波及(表现为设备列表缓存莫名失效)。 这条有单测守卫(test/paths.test.ts)。
解决
- 搬数据到另一台机器:退出应用 → 把整个
onlyinone-launcher目录拷到新机器同位置 → 启动。settings.json会原样生效;但credential.bin解不开(safeStorage用系统钥匙串 加密,换机器/换用户后密钥不同)→ 表现是"需要重新登录一次"。这是设计,不是故障。 - 程序挪目录不影响数据:Windows 上把 zip 从
D:\Apps\OnlyInOne换到E:\Tools\OiO, 数据仍在%APPDATA%\onlyinone-launcher(这正是 T-6 固化目录的直接收益)。 - 彻底卸载(macOS):
osascript -e 'quit app "Only in One"' # 先真退出(托盘常驻) rm -rf "/Applications/Only in One.app" # 程序 rm -rf ~/Library/Application\ Support/onlyinone-launcher # 数据(含登录态) defaults delete com.onlyinone.launcher # Preferences plist - 彻底卸载(Windows):退出 → 删掉解压目录 →
rmdir /s /q "%APPDATA%\onlyinone-launcher"→ 若开过自启,删HKCU\Software\Microsoft\Windows\CurrentVersion\Run下的 OnlyInOne 值。 - 只想清缓存(不动登录态):删数据目录里的
app-cache/、Cache/、Code Cache/、GPUCache/、Dawn*Cache/;不要碰settings.json/credential.bin。
19. 双击提示「包含恶意软件」打不开(访达这条路被 Gatekeeper 拦)
现象
在「访达」里双击 /Applications/Only in One.app,弹出:
未打开"Only in One.app",因其包含恶意软件。此操作未对Mac造成危害。
(更早的系统措辞是:「"Only in One" 无法打开,因为 Apple 无法检查其是否包含恶意软件。」)
点掉提示后再双击,还是一样。但终端 open -a "/Applications/Only in One.app" 能正常启动
(四进程齐全,功能一切正常)。
可能原因(按概率排序)
- 不是病毒,是本包没做 Apple 公证。团队内部分发,没有 Apple 开发者账号($99/年), 只有 ad-hoc 签名。上面那句话是 macOS 对所有未经公证的 App 的固定文案,确切含义只有 一句:Apple 手里没有它的公证记录,没法替它背书。功能极简的内部工具也会收到一模一样的提示。
- 照着网上教程去右键找「打开」。macOS 15(Sequoia)起 Apple 已移除这条绕过路径, 右键菜单里现在没有那个「打开」项 —— 怎么点都点不出来,这不代表 Mac 出了问题。 网上绝大多数教程仍是旧写法。
- 刚覆盖安装了新版本。放行记录绑在 App 的 cdhash 上,ad-hoc 签名每次打包 cdhash 都变, 之前的放行失效 → 又拦一次。属预期行为,重新放行一次即可。
- (少数)签名真的坏了。这时的提示是「"Only in One" 已损坏,无法打开。你应该将它移到废纸篓」,
且
codesign --verify会失败。与上面三种(codesign通过)必须分开处理。
排查步骤
APP="/Applications/Only in One.app"
# 1) 三条命令定性:包是"好的但没公证",还是"真的坏了"
codesign --verify --deep --strict "$APP"; echo "exit=$?"
# 无输出 + exit=0 → 每个文件与签名记录逐字节一致,**没被篡改**(下面两条都是好消息)
codesign -dv --verbose=4 "$APP" 2>&1 | grep -E "Identifier|flags|Signature|TeamIdentifier"
# Identifier=com.onlyinone.launcher / flags=0x2(adhoc) / Signature=adhoc /
# TeamIdentifier=not set → 本机临时签名,没有开发者身份可追溯
spctl -a -t exec -v "$APP"; echo "exit=$?"
# rejected + exit=3 → 没有 Apple 公证票据。**必然**如此,不等于含恶意软件
# (spctl 根本不看文件内容,它只查 Apple 的公证票据)
# 2) 确认它不是"下载来的、带隔离标记"的(顺带否掉一条流传很广的错方子)
xattr -l "$APP" # 期望只有 com.apple.provenance
xattr -r -l "$APP" | grep -c quarantine # 期望 0
# → 所以 xattr -dr com.apple.quarantine 对本包是**空操作**,跑了也不会改变任何事
# 3) 确认进程侧是干净的(能跑,只是访达这条路被拦)
open -a "$APP" && sleep 3 && pgrep -fl "Only in One.app/Contents/MacOS"
# 期望:main + GPU Helper + NetworkService Helper + Renderer Helper 四进程齐全
# 4) 换新版本后又被拦?看一眼构建时间(每次构建 cdhash 都不同,放行记录随之失效)
ls -l "$APP/Contents/Resources/app.asar"
判据口诀
codesign --verify --deep --strict |
spctl -a -t exec |
结论 |
|---|---|---|
| exit 0 | rejected | 包是好的,只是没公证 → 按「解决」第 1 条放行 |
| 非 0 | rejected | 签名真坏了 → 重新拿包,或按「解决」第 4 条本地补签 |
| exit 0 | accepted | 已公证(将来买了开发者账号才会出现) |
解决
-
正规放行(当前唯一路径,一次性):
- 双击 App 让它被拦 → 点「完成」关掉提示。 这一步不能省:系统要先"看见"一次被拦,设置里才会出现放行入口。
- 苹果菜单 → 系统设置 → 隐私与安全性 → 滚到底部「安全性」一节 → 看到「"Only in One" 已被阻止,以保护 Mac」→ 点 「仍要打开」。
- 输开机密码 / 触控 ID → 系统再问一次 → 再点一次「仍要打开」。
- 之后双击正常,不再拦。
「仍要打开」按钮只在不早于被拦后约 1 小时出现。翻遍设置也找不到 = 隔太久了,回第 1 步再双击一次,然后立刻去系统设置。
-
应急(终端):
open -a "/Applications/Only in One.app"。实测能起来 (四进程齐全、settings.json正常写入、--user-data-dir仍指向~/Library/Application Support/onlyinone-launcher、~/Library下与项目相关仍恰好 2 条)。 但它不改变双击的行为 —— 想让双击也正常,仍要走第 1 条一次。 -
每次换新版本又会拦一次:放行记录绑在 App 的 cdhash 上,ad-hoc 签名没有稳定的 Team Identifier,每次重新打包 cdhash 都变 → 系统视为"没见过的新 App"。 重新走第 1 条即可:这不是新包退化、不是杀毒变严、更不是中毒。 彻底消除只有两条路:买 Apple 开发者账号做 Developer ID 签名 + 公证;或关掉 Gatekeeper (不推荐,等于对整台机器关掉这道防线)。本轮明确选"不做"(PRD-v2 Q11 裁决 B)。
-
真报「已损坏,无法打开」(即
codesign --verify失败)才需要补签,不需要 Apple 账号:codesign --force --deep --sign - "/Applications/Only in One.app" codesign --verify --deep --strict "/Applications/Only in One.app" && echo "补签成功"正常情况用不到 —— 打包时
scripts/after-pack.js已经做过 ad-hoc 签名。 -
想确认拿到的是原包:
shasum -a 256 -c SHA256SUMS.txt(与 dmg 放在同一目录)。 dmg 卷内还自带一份首次打开必读.txt,上面这些步骤都写在里面 —— 拿到包不用问人。
⚠️ 三条不要做的事:
- 不要
sudo spctl --master-disable:macOS 15 起已不接受这种非交互式全局策略改动 (要走 MDM 配置描述符),而且它是对整台机器关掉这道防线,为一个内部工具不值。- 不要
xattr -cr整个 App「试试看」:本包根本没有 quarantine 属性,跑了只是把com.apple.provenance也一起抹掉,把线索掩盖掉。- 不要把「右键 → 打开」当成"再试一次就好" —— 它在 macOS 15+ 已经不存在了。
打包侧的同源说明见
docs/packaging.md§6(放行步骤)与 §1(dmg 卷内的 《首次打开必读.txt》)。
附录 A · 测试反模式清单
本清单只收本项目真实踩过的坑,每条都附当时的现场。目的不是讲测试理论, 而是让「下次写用例时能立刻认出来」。
底线一句话:测试绿 ≠ 能编译 ≠ 可交付(第 3 条就是活证据)。
A1. upsert 只覆盖一条路径(本轮 R10 的根因)
反模式:只测「行已存在时冲突」,测不到「行不存在 + 目标键已被他人占用」。
真实案例:heartbeat 是 按 (user_id, device_name) 查 → 有则 UPDATE / 无则 INSERT。
我写的用例顺序是「先让 devB 用自己的 id 建行 → 再改成 idA」,第二次必然走 UPDATE,
于是 INSERT 分支永远没被触达。结果 devices 上的
UNIQUE INDEX idx_devices_uuid 在 INSERT 路径上未兜底 → 500,
且因为行没建成,之后每一次心跳都 500(永久性),那台设备永远进不了设备列表。
规约:
凡「按 key 查 → 有则 UPDATE / 无则 INSERT」的 upsert 逻辑,必须同时覆盖两条路径: ① 行已存在时冲突(UPDATE);② 行不存在 + 目标键已被他人占用(INSERT)。 只覆盖 ① 的用例看起来是绿的,但它跳过了「行根本建不出来」这类更重的失败形态。
断言要点:不要只断状态码。要断「那一行到底存不存在」(GET /devices 列表里能看到)、
last_seen 有没有刷新、以及「下一轮能否自愈」。
A2. for (… of 空数组) expect(…) 恒真
反模式:先过滤再逐条断言,过滤器一下子把元素全滤掉时,循环体一次都不执行 ⇒ 恒真。
// ❌ skipped 为空时,这条断言永远不会失败
for (const item of result.skipped) expect(item.reason).toBe('not_managed_naming');
// ✅ 先钉住「数量/名单完全等于预期」,再断字段
expect(result.skipped.map((i) => i.file).sort()).toEqual(untouched.slice().sort());
expect(result.skipped.every((i) => i.reason === 'not_managed_naming')).toBe(true);
真实案例:qa-certs-adversarial.test.ts 的 C5 —— 我写的版本在 skipped 为空时恒绿,
由 QA 复验时改成上面的强断言。
A3. 测试绿 ≠ 类型正确(esbuild 只剥类型)
反模式:把 npm test 绿当成「可以提交」。
真实案例:客户端自愈那个中间态里
vitest 493/493 全绿、eslint 0 error,但 tsc 报 3 个 TS2322
(readDeviceId() 的推断返回类型仍是 string | (() => string))。
原因:vitest 用 esbuild 只剥类型不做类型检查,eslint 也不查类型。
规约:提交前必须按顺序跑四条,缺一不可:
npx tsc --noEmit -p tsconfig.typecheck.json # 类型
npm run lint # 规则
npm run format:check # 格式
npm test # 行为
顺序有意义:CI 里若 typecheck 排在 test 之后,同样的类型错误会被 test 的绿掩盖。
A4. 夹具形状失效 ⇒ 用例空转
反模式:正则/白名单改了,用例还在用「旧规则下合法、新规则下不合法」的夹具。 此时不是「断言太弱」,而是被测代码根本没进那个分支。
真实案例:证书文件名键从 device_name 改成 device_id(长度 8–64)后,
QA 夹具 moonlight-peer-a.pem 里的 peer-a 只有 6 个字符 ⇒ 在新正则下
不是受管文件 ⇒ L1/L2/L3/L4 的保护用例全部走「命名不合规」提前返回,表面通过。
修法是把夹具换成 moonlight-peer-0001.pem 这种 8 位以上 id,并逐条重核期望值。
自检问法:改完规则先问「现有夹具在新规则下还落在原来那条分支吗?」
A5. 自证循环(实现同时充当预期)
反模式:用常量生成期望值,而常量正是被测函数的输入。
// ❌ 白名单里被误加一条 '1920x1080;rm -rf /',isResolution(它) 仍返回 true,用例照绿
for (const r of ALLOWED_RESOLUTIONS) expect(isResolution(r)).toBe(true);
// ✅ 独立手写结构不变式(正则不复用实现)+ 显式负例
for (const r of ALLOWED_RESOLUTIONS) expect(r).toMatch(/^\d{3,5}x\d{3,5}$/);
expect(ALLOWED_RESOLUTIONS).toHaveLength(12); // 内容被改会红
同族问题:双端契约靠「两份 ACCEPT/REJECT 字面量清单 + 注释提醒同步」。
只要有人只往一边加值,另一边不会红。补法:两端都再钉一条
DEVICE_ID_PATTERN.source === '^[A-Za-z0-9-]{8,64}$' —— 任何一边改字符集都会红。
A6. 只断「函数被调用」不断「调用之后世界变成什么样」
反模式:expect(regenerateDeviceId).toHaveBeenCalled() 就收工。
真实案例:客户端自愈里「生成了新 id」和「新 id 已持久化」是两回事 —— 前者注入 mock 就能验,后者必须在校验层用内存 store 断言 「设置文件里真的写进了新值」。漏掉这半,掉一次重启就又回到冲突状态。