Files
jkooe 1e9a5ff72e feat(deploy): 服务端 Docker 化 + 本机部署文档 + 修正客户端默认地址
新增(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。
2026-10-27 01:01:26 +08:00

21 KiB
Raw Permalink Blame History

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

覆盖安装三步(升级时不会丢数据):

  1. 先真退出:托盘菜单 →「退出」(关窗口 ≠ 退出,本工具有托盘常驻)。 Windows 可用 Get-Process "Only in One" -EA SilentlyContinue | Stop-Process -Force。
  2. 装上新的:macOS 双击 dmg → 拖进「应用程序」→「替换」;Windows 解压 zip 覆盖原目录。
  3. 打开并确认:/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 也不支持自动安装 —— 将来的分发形态是「应用内检查新版本 + 手动覆盖」。


如何新增一个平台分支

平台层是声明式的:加一个平台 = 加一张能力表 + 一个实现文件,调用方零改动。

  1. 新建 launcher/src/platform/<name>.ts,导出
    • export const <NAME>_CAPABILITIES: PlatformCapabilities(8 个能力位,一个都不能少)
    • export function create<Name>Adapter(deps: AdapterDeps): PlatformAdapter
  2. 不支持的能力必须用 unsupported('中文原因'),原因要写「为什么」而不是「不支持」, 例如「本轮 Windows 端只做 USB 导出,导入请用 macOS 主控端」。 绝不允许在 unsupported 里放原始系统错误。
  3. 共享逻辑放 shared.ts。依赖方向是单向的:
    index.ts ──▶ windows.ts / macos.ts / linux.ts ──▶ shared.ts ──▶ core/runner.ts
    
    平台实现不得 import './index'(会构成运行期循环依赖)。
  4. capabilities() 是唯一事实来源:业务模块(usb.ts / share.ts / discover.ts / main.ts / renderer.js)禁止出现 process.platform 与 'win32' 之类的 字面量比较 —— 这一条有 CI 常驻的单测守卫(test/platform.test.ts 的「架构守卫」)。
  5. 平台判定只允许出现在 platform/*.ts(detectPlatform()); 读环境变量只允许出现在 core/config.ts 与 platform/*.ts(ESLint no-restricted-syntax 强制,违反即 error)。
  6. 在 platform/index.ts#createPlatformAdapter() 里加一行分派,并补 test/platform.test.ts 的 8 位 × N 端断言。
  7. 全部外部命令走 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 组网、代码签名与公证

免责声明:本项目为技术预研与内部工具,所有延迟/兼容性指标需在真实硬件上验证, 不构成任何承诺。