新增(server/): - Dockerfile:node:22-alpine、非 root(node uid 1000)、npm ci --omit=dev、 HEALTHCHECK 用 /api/v1/health(alpine 无 curl,改用 node fetch)、exec 形式 CMD。 node:sqlite 是 Node 内置模块,alpine/musl 上实测可用,无需换 slim。 - .dockerignore:排除 .env / data / test / smoke.mjs / coverage / docs, 保证密钥与用户密码哈希不进镜像层。 - docker-compose.yml:3100:3000(避开本机已被占用的 3000)、env_file 读 .env、 bind mount ./data:/app/data(用户要能直接看到/拷走 onlyinone.db)、 restart: unless-stopped、init: true、healthcheck。 docs/deploy.md:TL;DR、首次部署、客户端填什么、局域网、升级/备份、故障表、设计取舍。 修正 DEFAULT_BASE_URL: `https://api.onlyinone.app` 是从不存在的域名,用户不填地址就永远连接失败。 改为 `http://127.0.0.1:3100`(与 compose 宿主机端口一致),本机 Docker 开箱即用。 选「默认可用」而非「留空+强制 UI 提示」:改动面只有一行常量、不碰 UI 逻辑, 而本机试跑正是首要场景;局域网需手填 IP,已在文档写明。 测试:新增默认地址回归防护(不可为空、必须能过 normalizeBaseUrl 且不被改写、 必须是纯 origin、端口须为 3100)。launcher 525/525、server 82/82。
21 KiB
Only in One
跨平台、双向可控、低延迟的游戏串流与设备共享系统。一个软件,统一体验。
- 低延迟:局域网端到端目标 ≤15ms(网络 ≤3ms,需真机验证)
- 连接方便:云账户同设备免 PIN 自动配对 + 局域网自动发现
- 双向控制:Windows 小主机 / MacBook 均可主控或被控
- USB 透传与文件共享:USB 跨设备挂载,目录双向共享
设计原则:数据面(音视频 / USB / 文件)直连局域网或虚拟内网,不经过账户服务器;
控制面(认证 / 设备注册 / 证书同步)走 HTTPS 云服务。账户服务器是「盲协调器」——
它从来看不到你的画面、文件与 USB 流量,只保管公钥证书。详见 docs/security.md。
目录结构
OnlyInOne/
├── server/ # 账户服务器(控制面):Node + Express + 内置 node:sqlite
│ ├── server.js # 9 个端点的组装与路由(认证/设备/证书)
│ ├── config.js # 配置唯一入口(JWT_SECRET 强校验、路径基于模块目录)
│ ├── errors.js # 15 个错误码 + 统一响应信封 + 安全响应头 + 308 重定向
│ ├── validate.js # 白名单校验(纯函数)
│ ├── crypto.js # scrypt 口令哈希 / 公钥证书解析 / SHA-256
│ ├── tokens.js # JWT 签发与 jti 吊销
│ ├── ratelimit.js # 登录失败计数与锁定
│ ├── logger.js # 结构化日志 + 脱敏 + 轮转
│ ├── migrate.js # 迁移执行器(备份→事务→失败回滚)
│ ├── migrations/ # 001-baseline.sql / 002-auth-hardening.sql
│ ├── smoke.mjs # 一键全链路冒烟
│ └── .env.example
├── launcher/ # 统一启动器(Electron + TypeScript)
│ ├── src/
│ │ ├── core/ # 可单测的纯逻辑(config/validate/errors/runner/logger/
│ │ │ # ipc/session/paths/windowState/autostart/readiness/connectSettings)
│ │ ├── platform/ # 三端适配器(win/mac/linux + shared + credDir + credentialStore)
│ │ ├── api.ts # 控制面客户端(8s 超时 / 统一信封 / 会话失效识别)
│ │ ├── certs.ts # 证书双向链路(指纹重算 / 原子写 / 陈旧证书清理)
│ │ ├── connect.ts # 一键连接(非阻塞 spawn)
│ │ ├── discover.ts # 局域网发现(mDNS + 端口扫描,并发 ≤64)
│ │ ├── agent.ts # 心跳状态机(30s±10% / 连续 2 次失败降级)
│ │ ├── devices.ts # 在线三态 + 缓存(HMAC 校验)
│ │ ├── tray.ts # 托盘常驻(内联图标 + 菜单)
│ │ ├── usb.ts / share.ts # USB / SMB 薄分派层
│ │ ├── main.ts # Electron 主进程(单实例/窗口状态/崩溃兜底/IPC/登出)
│ │ ├── preload.ts # contextBridge 白名单桥
│ │ └── index.html + renderer.js # 原生 HTML/JS GUI
│ ├── scripts/ # verify-package.mjs(打包自检)/ after-pack.js(mac ad-hoc 签名)
│ ├── test/ # vitest(31 文件 / 522 例,含 QA 的 6 个对抗/复验测试)
│ ├── eslint.config.mjs / .prettierrc / tsconfig.typecheck.json
│ └── vitest.config.ts # 覆盖率门禁
├── scripts/ # install-sunshine.ps1 / setup-smb.ps1 / usb-share.ps1 / macos-usb-attach.sh
├── config/ # Sunshine 低延迟预设(nvenc / videotoolbox)
├── docs/ # PRD / 架构 / API / 安全 / 排障 / 打包
└── .github/workflows/ci.yml
环境要求
| 项 | 要求 | 说明 |
|---|---|---|
| Node | >= 22.5(推荐 >= 24) | node:sqlite 需要 22.5+;22.x 会打印 ExperimentalWarning(正常现象) |
| npm | >= 10 | 随 Node 附带 |
| 操作系统 | macOS / Windows 可用;Linux 仅登录与设备列表可用(见下) | 真机验证仍需 Windows 小主机 + MacBook |
快速开始
1) 启动账户服务器
账户服务器是控制面,必须先在某个可达的机器上跑起来,启动器才能登录。
cd server
npm install
cp .env.example .env
# 生成并填入 JWT_SECRET(必填,≥32 字符随机串,不满足会拒绝启动)
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
# 把输出填到 .env 的 JWT_SECRET=
npm start # = node --env-file-if-exists=.env server.js,默认 http://0.0.0.0:3000
✅
.env现在真的会被加载:npm start/npm run dev都带--env-file-if-exists=.env(Node 内置能力,无 dotenv 依赖)。 旧 README 说的「把 .env 放好即可」并不准确 —— 当时代码里没有任何加载逻辑,JWT_SECRET会被静默忽略。这一点已在 T-1 修复。日志与数据库默认落在
server/data/(onlyinone.db、logs/、backups/)。
2) 启动启动器
cd launcher
npm install
npm run dev # 构建 + 启动 Electron
- Docker 部署(推荐):服务器地址填
http://127.0.0.1:3100(就是启动器的默认值,不填也能连)。完整步骤见 docs/deploy.md。 - 直接
npm start跑:服务器地址填http://localhost:3000 - 团队/局域网:把服务器跑在局域网一台机器上,地址填
http://<该机局域网IP>:<端口>(Docker 部署是3100,直接跑是3000) - 地址会持久化到
userData/settings.json,无需每次输入
注意:地址只填到端口,不要带
/api或/api/v1—— 请求路径由客户端自己拼。
⚠️ 给自己
npm start起的服务进程会随终端结束而停止。正式使用请用 Docker (restart: unless-stopped,开机自启)或 pm2 / systemd / launchd / Windows 服务。
命令表
server
| 命令 | 作用 |
|---|---|
npm start |
启动服务(自动加载 .env) |
npm run dev |
启动 + --watch 热重启 |
npm test |
单元/集成测试(node --test test/*.test.mjs,82 例) |
npm run test:coverage |
测试 + 覆盖率门禁(行覆盖 ≥60%,低于则非 0 退出)。 |
口径用 --test-coverage-include='**/server/*.js' 只统计本项目源码(见下「覆盖率口径为何用 include」) |
|
npm run smoke |
自起临时端口 + 临时 DB 跑 13 步全链路(不需要先启动服务、不污染真实数据) |
launcher
| 命令 | 作用 |
|---|---|
npm run dev |
tsc 构建 + 启动 Electron |
npm run build |
只构建(tsc + 拷贝 index.html/renderer.js) |
npm test |
vitest(29 文件 / 502 例) |
npm run test:coverage |
测试 + 覆盖率门禁(全局行覆盖 ≥60%;connect/discover/usb/share/agent/platform/* ≥70%) |
npm run typecheck |
tsc --noEmit -p tsconfig.typecheck.json(覆盖 src + test) |
npm run lint |
ESLint 9 flat config(0 error 是门禁) |
npm run lint:fix |
自动修复 |
npm run format |
Prettier 全量重排 |
npm run format:check |
只检查(CI 用) |
实际覆盖率高于门禁(服务端行覆盖 90.18%、启动器 91.90%),门禁值取的是「不许退化的底线」。
平台能力矩阵
以 launcher/src/platform/*.ts 的 *_CAPABILITIES 为准(UI 完全由它驱动,
不含任何业务代码里的平台判断):
| 能力位 | Windows | macOS | Linux |
|---|---|---|---|
streamHost(被控/串流主机) |
✅ | ❌ 需虚拟显示器,V2 | ❌ 未验收 |
streamClient(主控/发起串流) |
✅ | ✅ | ❌ 未验收 |
usbExport(导出本机 USB) |
✅ | ❌ 无 usbipd | ❌ 未验收 |
usbImport(挂载远端 USB) |
❌ 本轮只做导出 | ✅ | ❌ 未验收 |
smbExport(本机共享) |
✅ | ❌ 用系统「文件共享」 | ❌ 未验收 |
smbImport(打开远端共享) |
✅ | ✅ | ❌ 未验收 |
mdns |
❌ 回落为端口扫描 | ✅ | ❌ 未验收 |
credential(系统加密存储) |
✅ | ✅ | ❌ 仅内存(重启需重登) |
推荐组合:Windows 小主机当被控 + 导出 USB;MacBook 当主控 + 挂载 USB。
Linux 支持边界(明确说明)
Linux 本轮不验收:代码分支齐备、单测覆盖,但未在真机验证。
- ✅ 可用:登录、心跳、设备列表、设置页、证书目录探测(候选路径含
~/.config/sunshine/ Flatpak / Snap)、凭据目录手动指定 - ❌ 不可用:串流(主/被控)、USB 导出与挂载、SMB 共享、mDNS 发现
- ⚠️ 凭据存储降级为内存(无系统加密存储),重启后需重新登录
core/autostart.ts的 Linux 分支会写~/.config/autostart/onlyinone.desktop, 代码就绪但未真机验收(GNOME/KDE 处理有差异)
未来纳入 Linux 需补的 9 条清单见 docs/packaging.md。
阶段映射(与代码现状一致)
| 阶段 | 内容 | 状态 | 落点 |
|---|---|---|---|
| 0 | 技术预研与验证 | ✅ | docs/phase0-validation.md |
| 1 | 基础串流打通 | ✅ | scripts/install-sunshine.ps1、config/* |
| 2 | 账户系统 + 启动器 MVP | ✅ | server/、launcher/src/{api,connect,discover,agent}.ts |
| 3 | USB/IP 与文件共享 | ✅ | launcher/src/{usb,share}.ts、scripts/* |
| 4 | 双向控制 | ⏳ 角色展示已上线,切换功能未做(V2) | core/readiness.ts(deriveRole)+ 主界面角色卡 |
| 5 | 公网访问 + 虚拟组网 | ⏳ 未实现(Tailscale 引导未落地) | — |
| 6 | 安全加固 + 发布 | ⏳ 加固已完成,签名未做 | JWT/吊销/限流/脱敏 ✅;electron-builder.yml 的 mac.identity: null(未签名) |
P2 需求池(13 条)
| 需求 | 状态 |
|---|---|
| R-P2-01 陈旧证书清理 | ✅ |
| R-P2-02 凭据目录平台探测 + 设置页手动指定 | ✅ |
R-P2-03 scripts/linux-setup.sh |
⛔ 豁免(Linux 本轮不验收;补齐清单见 docs/packaging.md) |
| R-P2-04 托盘常驻 + 开机自启(默认关闭) | ✅ |
R-P2-05 settings.ts 去环境嗅探改 DI |
✅ |
| R-P2-06 单实例 + 窗口状态 + 崩溃兜底 | ✅ |
| R-P2-07 ESLint + Prettier + typecheck 统一 | ✅ |
R-P2-08 dist/ release/ 不入库 |
✅ |
R-P2-09 npm run smoke |
✅ |
| R-P2-10 README 阶段映射同步 | ✅(本文) |
| R-P2-11 设置页连接参数并持久化 | ✅ |
| R-P2-12 退出登录 + 401 自动登出 | ✅ |
| R-P2-13 角色展示 + 4 个依赖状态灯 | ✅ |
上一版 README 的「TODO」标记已全部替换为与代码一致的状态: 不再存在「写着 TODO 但其实已实现」或「写着已完成但其实没有」的条目。
文档索引
| 文档 | 内容 |
|---|---|
docs/PRD.md / docs/PRD-v2-incremental.md |
需求与增量需求池 |
docs/architecture.md / docs/architecture-v2-review.md |
架构设计与 rev.2 评审(含 P2 全量设计与任务分解) |
docs/api.md |
控制面 API 契约:9 个端点、15 个错误码、curl 示例 |
docs/security.md |
安全落地说明:信任模型、私钥边界、密钥轮换、脱敏、真实踩坑 |
docs/troubleshooting.md |
排障手册:18 条四段式故障处置(含覆盖安装、数据目录在哪) |
docs/packaging.md |
打包与分发:产物清单、verify:pack 八步自检、覆盖安装、数据目录固化、未签名首启、代码规范、Linux 补齐清单 |
docs/phase0-validation.md |
阶段 0 真机验证模板 |
安装包与覆盖安装
当前版本 0.2.0,两个产物(launcher/release/,不入库):
| 平台 | 文件 | 形态 |
|---|---|---|
| macOS(Apple Silicon) | OnlyInOne-0.2.0-mac-arm64.dmg |
拖拽到「应用程序」 |
| Windows x64 | OnlyInOne-0.2.0-win-x64.zip |
解压即用(不用安装器,不写注册表、不建 %LOCALAPPDATA% 目录) |
自带 SHA256SUMS.txt,收到包先核对:shasum -a 256 OnlyInOne-0.2.0-mac-arm64.dmg。
出包与自检(在 launcher/ 下):
npm run dist:all # 打两个包
npm run verify:pack # 八步自检:asar 清单 / Info.plist / 签名 / 真跑 / dmg / zip / 双端一致 / SHA256
覆盖安装三步(升级时不会丢数据):
- 先真退出:托盘菜单 →「退出」(关窗口 ≠ 退出,本工具有托盘常驻)。
Windows 可用
Get-Process "Only in One" -EA SilentlyContinue | Stop-Process -Force。 - 装上新的:macOS 双击 dmg → 拖进「应用程序」→「替换」;Windows 解压 zip 覆盖原目录。
- 打开并确认:
/Applications里只有一个Only in One.app、登录态还在、find ~/Library -maxdepth 2 -iname "*onlyinone*"仍是 2 条。
数据只有一处:macOS
~/Library/Application Support/onlyinone-launcher/, Windows%APPDATA%\onlyinone-launcher\。程序放哪都不影响数据, 目录名是代码里的常量(DATA_DIR_NAME),不随包名/版本变化 —— 细节见docs/packaging.md§5。
首次打开未签名应用的安全提示
当前构建没有 Developer ID 签名、没有公证(electron-builder.yml 里 mac.identity: null,
团队内部分发),只有 ad-hoc 签名。首次打开时 Gatekeeper 会拦截,这是"未公证"的固定提示,
不是中毒:
- 你会看到(macOS 15 及以后的原话): 「未打开"Only in One.app",因其包含恶意软件。此操作未对Mac造成危害。」 这句话的确切含义只是"Apple 没有它的公证记录,没法替它背书",不是检出了病毒。
- 怎么打开(macOS):双击一次让它被拦 → 苹果菜单 → 系统设置 → 隐私与安全性 →
滚到「安全性」→ 点「仍要打开」→ 输密码 → 再点一次「仍要打开」。只需一次,之后双击正常。
- ⚠️ 「右键 → 打开」这条老办法在 macOS 15(Sequoia)起已被 Apple 移除, 右键菜单里现在没有那个「打开」项 —— 网上多数教程仍是旧写法,别照着找。
- ⚠️
xattr -dr com.apple.quarantine对本包无效:本包全树没有com.apple.quarantine(只有com.apple.provenance),没有隔离标记可去。 - 应急:终端
open -a "/Applications/Only in One.app"(能起来,但不改变双击的行为)。
- 每次换新版本会再拦一次:放行记录绑在 App 的代码指纹(cdhash)上,ad-hoc 签名每次 重新打包 cdhash 都变 → 系统视为"没见过的新 App"。这是预期,不是新包退化。
- 自证"包是好的、只是没公证":
codesign --verify --deep --strict "<app>"退出码 0 (没被篡改)+spctl -a -t exec "<app>"→rejected(没公证票据)。spctl不看文件内容,rejected≠ 含恶意软件。 - 兜底:若真报
Killed: 9或「已损坏,无法打开」(说明签名真坏了),跑一次 ad-hoc 签名 (不需要 Apple 账号):codesign --force --deep --sign - "/Applications/Only in One.app"。 正常情况不需要 —— 打包时scripts/after-pack.js已经做过 ad-hoc 签名。 - Windows:SmartScreen 提示「Windows 已保护你的电脑」→ 点「更多信息」→ 「仍要运行」;解压出来的 exe 可右键 → 属性 → 勾「解除锁定」。
- 拿到别人发来的安装包时,先核对 SHA-256 再打开(
shasum -a 256 <file>/certutil -hashfile <file> SHA256),确认与发送方给出的一致。 dmg 卷内自带首次打开必读.txt,上面这些内容它都写了 —— 拿到包不用问人。
完整排查(含 4 条判据命令与实测输出)见
docs/troubleshooting.md第 19 条; 打包侧的说明见docs/packaging.md§6。
正式发布需 Apple 开发者证书 + 公证(macOS)、代码签名证书(Windows),
步骤见 docs/packaging.md。注意:macOS 端即使有了
electron-updater 也跑不了自动更新(Squirrel.Mac 要求签名 + 公证);
Windows zip 也不支持自动安装 —— 将来的分发形态是「应用内检查新版本 + 手动覆盖」。
如何新增一个平台分支
平台层是声明式的:加一个平台 = 加一张能力表 + 一个实现文件,调用方零改动。
- 新建
launcher/src/platform/<name>.ts,导出export const <NAME>_CAPABILITIES: PlatformCapabilities(8 个能力位,一个都不能少)export function create<Name>Adapter(deps: AdapterDeps): PlatformAdapter
- 不支持的能力必须用
unsupported('中文原因'),原因要写「为什么」而不是「不支持」, 例如「本轮 Windows 端只做 USB 导出,导入请用 macOS 主控端」。 绝不允许在unsupported里放原始系统错误。 - 共享逻辑放
shared.ts。依赖方向是单向的:平台实现不得index.ts ──▶ windows.ts / macos.ts / linux.ts ──▶ shared.ts ──▶ core/runner.tsimport './index'(会构成运行期循环依赖)。 capabilities()是唯一事实来源:业务模块(usb.ts/share.ts/discover.ts/main.ts/renderer.js)禁止出现process.platform与'win32'之类的 字面量比较 —— 这一条有 CI 常驻的单测守卫(test/platform.test.ts的「架构守卫」)。- 平台判定只允许出现在
platform/*.ts(detectPlatform()); 读环境变量只允许出现在core/config.ts与platform/*.ts(ESLintno-restricted-syntax强制,违反即 error)。 - 在
platform/index.ts#createPlatformAdapter()里加一行分派,并补test/platform.test.ts的 8 位 × N 端断言。 - 全部外部命令走
core/runner.ts(execFile/spawn+ 参数数组 +shell:false), 不许拼命令行字符串。
PlatformAdapter接口的约定:只增不改,新增字段必须可选。 这样加平台时既有两端的实现不需要跟着改。
测试与覆盖率门禁
cd server && npm test && npm run test:coverage && npm run smoke
cd launcher && npm run typecheck && npm run lint && npm run format:check && npm test
| 项目 | 门禁 | 当前实际 |
|---|---|---|
| server 单测 | 全部通过(82 例) | ✅ 82/82 |
| server 行覆盖 | ≥60% | 90.18%(branch 71.15% / funcs 88.89%) |
| launcher 单测 | 全部通过(502 例) | ✅ 502/502 |
| launcher 行覆盖 | 全局 ≥60%;connect/discover/usb/share/agent/platform/* ≥70% |
全局 91.90%(branch 83.65% / funcs 85.42%) |
| typecheck | 0 错(覆盖 src + test) |
✅ |
| lint | 0 error | ✅ |
| format:check | 全部符合 Prettier | ✅ |
| smoke | 13 步全通过 + 无残留进程 | ✅ |
门禁不是装饰:低于阈值时 vitest / node --test 会以非 0 退出,CI 直接失败。
CI 步骤见 .github/workflows/ci.yml。
覆盖率口径为何用 include 而不是 exclude
server 的 test:coverage 里写的是:
node --test --experimental-test-coverage \
--test-coverage-include='**/server/*.js' \
--test-coverage-exclude='**/node_modules/**' \
--test-coverage-exclude='test/**' \
--test-coverage-lines=60 test/*.test.mjs
理由:某些开发机(IDE / 代理 / 沙箱工具)会在 Node 的 require 链上注入
*.cjs 钩子,这些项目外文件会被覆盖率跟踪器一并统计,把分母稀释 10 个点以上。
后果是双向的:换台机器数字就变(不能作为交付证明),而且 60% 门禁在拿被稀释的分母
判定 —— server.js 真掉了 20 个点也可能被这些 shim 抵消掉而不报警。
改用 include 白名单后:报告里只有本项目 10 个源码文件、数字与机器无关
(本地与 CI 同一口径,连跑三次均为 89.29%)。门禁有效性已实测:把阈值临时调到
99% 复跑,exit=1 并打印 89.29% line coverage does not meet threshold of 99%。
已知测试盲区:main.ts / preload.ts / renderer.js / index.html 依赖 Electron
运行时,无自动化测试;能抽成纯函数的逻辑都已抽到 core/ 与 tray.ts 并单测。
状态
- ✅ 账户服务器(认证 / 设备注册 / 证书同步 / 限流 / 审计日志)
- ✅ 启动器(登录 / 在线设备 / 一键连接 / USB / 文件共享 / 设置页 / 托盘 / 角色与依赖灯)
- ✅ 三端平台适配(Linux 代码就绪、未验收)
- ✅ 安全地基(JWT 强校验、jti 吊销、登录限流、日志脱敏、命令注入面清零)
- ✅ 工程质量(ESLint + Prettier + typecheck 覆盖测试、覆盖率门禁、CI)
- ⏳ 真机验证(Windows 小主机 + MacBook + USB 设备)
- ⏳ V2:角色切换、双向证书认证、Tailscale 组网、代码签名与公证
免责声明:本项目为技术预研与内部工具,所有延迟/兼容性指标需在真实硬件上验证, 不构成任何承诺。