1099 lines
49 KiB
Markdown
1099 lines
49 KiB
Markdown
# 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 时必须同时把备份改成:
|
||
> ```js
|
||
> 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**,不监听端口。
|
||
|
||
**可能原因**
|
||
|
||
1. `.env` 没建、或建了但没被加载;
|
||
2. `JWT_SECRET` 太短(<32)或命中弱值黑名单(`secret` / `changeme` / `todo` / `onlyinone` …);
|
||
3. 全同字符(`aaaaaaaa…`);
|
||
4. `NODE_ENV=production` 下设了 `ALLOW_WEAK_SECRET=1`(生产环境无效)。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
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 # 看第一段报错,它会直接告诉你哪条不满足
|
||
```
|
||
|
||
**解决**
|
||
|
||
```bash
|
||
# 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 上仍标为实验特性。**这不是错误**。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
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。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
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` 即可):
|
||
|
||
```bash
|
||
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` 看不到它,所以毫无察觉。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
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 拒绝。
|
||
|
||
**可能原因**
|
||
|
||
1. 测试里为了验证「含私钥的 PEM 必须被拒绝」,直接写了明文 PEM;
|
||
2. 把假密钥 base64 之后**仍然**命中 —— gitleaks 8.30.1 **会先 base64 解码再匹配**
|
||
(日志里的 `decoded:base64` 就是证据);
|
||
3. 把 base64 切块拼接 —— 切出来的高熵串会命中 `generic-api-key`
|
||
(按「关键字邻近 key/token/secret + 高熵」判定)。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
# 看具体命中位置与规则
|
||
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` 的模式改:
|
||
|
||
1. PEM 的 `BEGIN/END` 标记**运行期拼装**:`'-'.repeat(5)` + 标签 + `'-'.repeat(5)`;
|
||
2. 载荷体 base64 存、运行时 `dec()` 解码;
|
||
3. 每个 base64 串**单独成行**且该行**不含** `key` / `token` / `secret` 关键字
|
||
(连 PEM 标签也 base64 存:`TAG_PKCS8`);
|
||
4. JWT 分隔符用 `String.fromCharCode(46)`;
|
||
5. 加 import 期 `SELF_CHECK` 保证解码结果与原来逐字节一致。
|
||
|
||
> ❌ **不要**加 `.gitleaksignore`、**不要** `git commit --no-verify`。
|
||
> 那等于把整个仓库的密钥扫描关掉 —— 之后真泄漏也发现不了。
|
||
|
||
---
|
||
|
||
## 6. 登录后设备一直显示「离线 / 暂无其他设备」
|
||
|
||
**现象**
|
||
|
||
两台机器都登录了,但设备列表里对方一直是灰点或干脆没有。
|
||
|
||
**可能原因**
|
||
|
||
1. 对方启动器**只登录了、没点「上线」** —— 心跳是 30s 周期,首帧要等第一次 tick;
|
||
2. 服务端 `last_seen` 超过 **90 秒**即判定离线(客户端 `online` 阈值与
|
||
`server.js#ONLINE_THRESHOLD_MS` 同为 90_000);
|
||
3. 两台机器的 `device_name` 相同(都取 `os.hostname()`),被当成同一行设备;
|
||
4. 客户端处于 `degraded`(连续 2 次心跳失败),界面显示的是本地缓存。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
# 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。
|
||
|
||
**可能原因**
|
||
|
||
1. 启动器设置项里存着旧的 `serverUrl`(**设置项优先级最高**,压过环境变量);
|
||
2. 部署在反向代理后,代理缓存了旧的 **308** 重定向(308 是永久重定向,会被缓存);
|
||
3. 旧地址上还跑着一个旧版本服务,看起来「有响应」但其实连错了库。
|
||
|
||
> 说明:本服务的 308 只做**路径**重写(`/api/*` → `/api/v1/*`),`Location` 是
|
||
> **相对路径**,不会把 host 换掉。所以「跳到旧地址」一定是配置或代理缓存问题,
|
||
> 不是重定向逻辑问题。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
# 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"
|
||
```
|
||
|
||
**解决**
|
||
|
||
1. 在启动器界面把「服务器地址」改成新地址并点登录(会持久化到 `settings.json`);
|
||
2. 清代理缓存里 308 的条目,或把代理规则改成直接转发到 `/api/v1`;
|
||
3. 内网调试可用环境变量覆盖:`OIO_API_BASE=http://192.168.1.10:3000`,
|
||
但**设置项一旦有值就压过它** —— 想用环境变量请先把界面上的地址清空。
|
||
|
||
---
|
||
|
||
## 8. 限流把自己锁了 15 分钟
|
||
|
||
**现象**
|
||
|
||
登录一直返回:
|
||
```json
|
||
{ "ok": false, "error": { "code": "RATE_LIMITED", "message": "尝试过于频繁,请 15 分钟后再试", "hint": "...", "details": { "retryAfter": 812 } } }
|
||
```
|
||
连正确密码也进不去。
|
||
|
||
**可能原因**
|
||
|
||
同一 IP **或**同一用户名,在 60s 窗口内失败 ≥5 次即锁定 15 分钟。
|
||
最常见的原因是**自己写脚本连续试密码**。计数落库(`login_failures` 表),
|
||
**重启进程不会解锁**。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
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))})));
|
||
"
|
||
```
|
||
|
||
**解决**
|
||
|
||
```bash
|
||
# 方案 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 份**。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
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());
|
||
"
|
||
```
|
||
|
||
**解决**
|
||
|
||
```bash
|
||
# 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` 只拿到主文件 ⇒ **备份缺最新数据**,而它正是回滚用的那份。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
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()`:
|
||
|
||
```js
|
||
// 推荐:一致性快照,无关 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)` 查找,只更新它找到的那一行。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
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` 过滤自己,不影响连接)。想清理就手工合并:
|
||
|
||
```bash
|
||
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`(系统加密存储) | ✅ | ✅ | ❌ 仅内存 |
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
# 在启动器里看「本机角色与依赖状态」卡片;或直接查能力表
|
||
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`。
|
||
|
||
**可能原因**
|
||
|
||
1. macOS 的 `usbip attach` 需要 `sudo`,我们走 `osascript` 弹系统授权框 —— 用户点了「取消」
|
||
会得到 `execution error: User canceled. (-128)`,映射为 `ELEVATION_REQUIRED`;
|
||
2. 目标机没有 `usbipd`(Windows)或该 busid 未共享;
|
||
3. 网络不通(`usbip` 默认端口 3240)。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
# 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 共享打不开
|
||
|
||
**现象**
|
||
|
||
「打开共享文件夹」失败;或本机共享开启失败。
|
||
|
||
**可能原因**
|
||
|
||
1. 本机共享在 Windows 上依赖 `scripts/setup-smb.ps1`(建共享 + `EncryptData`),
|
||
没执行过 `enableLocal` 就没有共享;
|
||
2. 防火墙拦了 445/tcp;
|
||
3. macOS 的「本机共享」是 `unsupported`(要用系统设置里的「文件共享」);
|
||
4. 共享名不在白名单 `[A-Za-z0-9_$-]{1,64}` 内。
|
||
|
||
**排查步骤**
|
||
|
||
```powershell
|
||
# Windows:共享是否真的存在
|
||
Get-SmbShare | Where-Object Name -eq 'OnlyInOne'
|
||
# 防火墙
|
||
Get-NetFirewallRule -DisplayGroup 'File and Printer Sharing' | Select DisplayName,Enabled
|
||
```
|
||
```bash
|
||
# 端口
|
||
nc -zv <host> 445
|
||
# 共享名白名单(与服务端/客户端同一规则)
|
||
node -e "console.log(/^[A-Za-z0-9_\$-]{1,64}$/.test('OnlyInOne'))"
|
||
```
|
||
|
||
**解决**
|
||
|
||
```powershell
|
||
# 管理员 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 条清单。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
# 确认不是环境问题,而是设计如此
|
||
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 条。
|
||
|
||
**可能原因**(按概率排序)
|
||
|
||
1. **该设备的 `device_id` 为空**(`reason = missing_device_id`)。
|
||
D3 之后证书文件名主体是 `device_id`,没有它就无法生成文件名。为空的唯一情形是
|
||
「这台设备从未成功心跳过」(心跳里 `device_id` 是可选字段,老客户端不带)。
|
||
2. **`device_id` 格式不合法**(`reason = invalid_device_id`)。必须是
|
||
`[A-Za-z0-9-]{8,64}`;带 `.` `_` 空格、或长度不足 8 位的值一律拒收。
|
||
3. **某一轮写入失败,导致整轮清理被跳过**(`reason = expected_missing`)。
|
||
这是有意的保护:只有当「本该落盘的文件」全部确实在目录里时,才允许删除多余文件;
|
||
否则说明本地与服务端快照不一致,删任何一个都可能删掉唯一可用副本。
|
||
4. **凭据目录本身不可写 / 不存在**(`reason = io_error` 带 `code`,
|
||
或 `dir_missing`)。`CRED_DIR_NO_PERMISSION` 常见于 Sunshine 以服务账号运行、
|
||
而启动器以当前用户运行。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
# 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=yyyyyyyy
|
||
```
|
||
服务端把**后到的那台**降级为 `device_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`);
|
||
- 或者覆盖后"数据丢了":登录态没了、设置回到默认。
|
||
|
||
**可能原因**(按概率排序)
|
||
|
||
1. **应用还在跑**。本工具有托盘常驻:**关窗口 ≠ 退出**,进程还在时 Finder 替换会失败
|
||
(或替换的是另一个副本),用户看到的仍是旧进程。
|
||
2. **旧副本被 LaunchServices 记住**。曾直接从 dmg 里双击运行过 → 注册的是
|
||
`/Volumes/Only in One 0.2.0-arm64/...` 那份(**卷名带版本号**);覆盖后双击 dmg 仍可能拉起旧副本。
|
||
3. **`productName` 被改过**(`.app` 改名)→ 新旧两个 app 并存,用户双击到哪个看运气。
|
||
`launcher/test/paths.test.ts` 有守卫断言它必须是 `Only in One`。
|
||
4. **`appId` 被改过** → 系统视为**另一个 App**:Preferences plist 新建、TCC 授权重新要、
|
||
登录项失效。此时数据其实没丢,只是落在"新 App"的目录里(见第 18 条)。
|
||
5. **拖进去的不是新包**(比如拿了 `launcher/release/` 里残留的 8 月旧产物 —— 已在 T-6 删除)。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
# 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 个数据目录 + 1 个 Preferences plist**(见下表)。数量是收敛的,
|
||
因为数据根目录被 `core/paths.ts` 的 `DATA_DIR_NAME` 常量钉死,**不再由 app 名推算**。
|
||
2. 多出来的 `Application Support/Only in One` 只可能来自「`package.json` 被加过
|
||
`productName`」的老包(T-6 之前有 `"productName": "Only in One"` 时会这样)。
|
||
3. `Credentials`/`Keychain` 里的凭据是系统托管的(`safeStorage` → 钥匙串),不在数据目录里。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
# 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
|
||
```
|
||
|
||
```bat
|
||
:: 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)**:
|
||
```bash
|
||
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"` 能正常启动**
|
||
(四进程齐全,功能一切正常)。
|
||
|
||
**可能原因**(按概率排序)
|
||
|
||
1. **不是病毒,是本包没做 Apple 公证**。团队内部分发,没有 Apple 开发者账号($99/年),
|
||
只有 ad-hoc 签名。上面那句话是 macOS 对**所有未经公证的 App** 的固定文案,确切含义只有
|
||
一句:*Apple 手里没有它的公证记录,没法替它背书*。功能极简的内部工具也会收到一模一样的提示。
|
||
2. **照着网上教程去右键找「打开」**。macOS 15(Sequoia)起 Apple **已移除**这条绕过路径,
|
||
右键菜单里现在没有那个「打开」项 —— 怎么点都点不出来,这不代表 Mac 出了问题。
|
||
网上绝大多数教程仍是旧写法。
|
||
3. **刚覆盖安装了新版本**。放行记录绑在 App 的 **cdhash** 上,ad-hoc 签名每次打包 cdhash 都变,
|
||
之前的放行失效 → 又拦一次。属预期行为,重新放行一次即可。
|
||
4. (少数)**签名真的坏了**。这时的提示是「"Only in One" **已损坏**,无法打开。你应该将它移到废纸篓」,
|
||
且 `codesign --verify` 会**失败**。与上面三种(`codesign` 通过)必须分开处理。
|
||
|
||
**排查步骤**
|
||
|
||
```bash
|
||
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 | 已公证(将来买了开发者账号才会出现) |
|
||
|
||
**解决**
|
||
|
||
1. **正规放行(当前唯一路径,一次性)**:
|
||
1. 双击 App 让它被拦 → 点「完成」关掉提示。
|
||
**这一步不能省**:系统要先"看见"一次被拦,设置里才会出现放行入口。
|
||
2. 苹果菜单 → **系统设置 → 隐私与安全性** → 滚到底部「安全性」一节 →
|
||
看到「"Only in One" 已被阻止,以保护 Mac」→ 点 **「仍要打开」**。
|
||
3. 输开机密码 / 触控 ID → 系统再问一次 → 再点一次「仍要打开」。
|
||
4. 之后双击正常,不再拦。
|
||
|
||
> 「仍要打开」按钮**只在不早于被拦后约 1 小时**出现。翻遍设置也找不到 =
|
||
> 隔太久了,回第 1 步再双击一次,然后立刻去系统设置。
|
||
|
||
2. **应急(终端)**:`open -a "/Applications/Only in One.app"`。实测能起来
|
||
(四进程齐全、`settings.json` 正常写入、`--user-data-dir` 仍指向
|
||
`~/Library/Application Support/onlyinone-launcher`、`~/Library` 下与项目相关仍恰好 2 条)。
|
||
但它**不改变**双击的行为 —— 想让双击也正常,仍要走第 1 条一次。
|
||
|
||
3. **每次换新版本又会拦一次**:放行记录绑在 App 的 cdhash 上,ad-hoc 签名没有稳定的
|
||
Team Identifier,每次重新打包 cdhash 都变 → 系统视为"没见过的新 App"。
|
||
**重新走第 1 条即可**:这不是新包退化、不是杀毒变严、更不是中毒。
|
||
彻底消除只有两条路:买 Apple 开发者账号做 Developer ID 签名 + 公证;或关掉 Gatekeeper
|
||
(不推荐,等于对整台机器关掉这道防线)。本轮明确选"不做"(PRD-v2 Q11 裁决 B)。
|
||
|
||
4. **真报「已损坏,无法打开」**(即 `codesign --verify` 失败)才需要补签,**不需要 Apple 账号**:
|
||
```bash
|
||
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 签名。
|
||
|
||
5. **想确认拿到的是原包**:`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(…)` 恒真
|
||
|
||
**反模式**:先过滤再逐条断言,过滤器一下子把元素全滤掉时,循环体一次都不执行 ⇒ 恒真。
|
||
|
||
```ts
|
||
// ❌ 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 也不查类型。
|
||
|
||
**规约**:提交前**必须按顺序**跑四条,缺一不可:
|
||
|
||
```bash
|
||
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. 自证循环(实现同时充当预期)
|
||
|
||
**反模式**:用常量生成期望值,而常量正是被测函数的输入。
|
||
|
||
```ts
|
||
// ❌ 白名单里被误加一条 '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 断言
|
||
「设置文件里真的写进了新值」。漏掉这半,掉一次重启就又回到冲突状态。
|