device-center 0.2.0-beta.3 → 0.2.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTRIBUTING.md CHANGED
@@ -7,3 +7,5 @@ Use Node.js 24, `npm ci --ignore-scripts`, `npm run dev`, `npm run check`, and `
7
7
  Preserve the compact sidebar, optional MCP setup, truthful device states, and separate memory/disk observations. The local process represents its machine; a container cannot claim its host. SSH configuration discovery must not execute commands or read keys, and association must not imply connectivity or task authority. Do not restore sample projects, hosted quota displays or simulated cloud synchronization.
8
8
 
9
9
  Never add real secrets to prompts, packages, fixture data or logs. SSH observation is an explicit owner action using a fixed script, isolated configuration, existing authentication and strict host trust; GET and MCP must never trigger connections. Tests inject subprocess results and do not contact real hosts. Resource enrollment, Agent installation, network discovery, tunnels, cloud deployment and remote tasks require separately scoped implementation and review. Do not run service installation or Docker deployment merely to validate a UI change. Keep user data and unrelated services intact.
10
+
11
+ Pull requests run the existing checks and tests on GitHub-hosted macOS and Ubuntu. Main pushes additionally validate and build on the repository's dedicated MBM-24 and MBP-16 runners. These runners reject PR jobs and hold no production credentials. After the complete main CI succeeds, a separate GitHub-hosted production job builds the exact SHA, deploys through a restricted receiver, verifies public bytes and login protection, and confirms health or restores the previous code. See [delivery configuration](docs/development/delivery-profile.md) for the authorized channel, activation evidence, credentials boundary and recovery. npm publication remains a separate release.
package/Dockerfile CHANGED
@@ -1,9 +1,10 @@
1
1
  # Optional local-only control plane, not a host/resource Agent.
2
2
  FROM node:24-bookworm-slim
3
3
  WORKDIR /app
4
- COPY --chown=node:node package.json npm-shrinkwrap.json index.html login.html ./
4
+ COPY --chown=node:node package.json npm-shrinkwrap.json index.html login.html start.html favicon.svg ./
5
5
  COPY --chown=node:node server ./server
6
6
  COPY --chown=node:node src ./src
7
+ COPY --chown=node:node docs/agent-install.md docs/friend-trial.md docs/remote-access.md docs/device-profiles.md docs/troubleshooting.md ./docs/
7
8
  RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && mkdir -p /app/data && chown node:node /app/data
8
9
  USER node
9
10
  ENV PORT=5173 DEVICE_CENTER_RUNTIME=container DEVICE_CENTER_DB_PATH=/app/data/device-center.sqlite
package/README.md CHANGED
@@ -8,6 +8,21 @@
8
8
 
9
9
  ## 快速使用
10
10
 
11
+ ### 简化接入(beta.4 开发候选,尚未发布)
12
+
13
+ 一页指南明确提供 **交给 Agent/手动安装**,目标设备不需要先安装 AI Agent:
14
+
15
+ ```text
16
+ 复制安装说明给 Agent(或复制终端脚本)
17
+ → 自动安装并打开本次绑定页
18
+ → 本人登录、核对指纹、批准一次
19
+ → 自动确认资源回报,Agent 继续配置 MCP
20
+ ```
21
+
22
+ 有浏览器的设备无需搬运配对码,也不需要回 Agent 回复“已批准”。无浏览器服务器可选手动安装:目标终端运行脚本,在另一台电脑登录控制面,填入终端码、核对指纹并批准。全新安装脚本会配置用户级后台服务;已有安装时停止并保留设置。AI Agent/MCP 仅是管理电脑的可选能力。身份私钥留在设备;安装绑定只允许资源回报,远程日志仍需另外授权。[手动步骤与排查](docs/agent-install.md#手动安装目标设备没有-ai-agent)
23
+
24
+ 当前公开 npm 仍为 **0.2.0-beta.3**;下面的已发布命令保持可用。新流程需要新版控制面与 CLI 一起交付。
25
+
11
26
  希望交给本机 Agent 安装时,按[Agent 安装指南](docs/agent-install.md)选择本机、官方托管或已有自建服务。“交给 Agent 安装”可直接复制完整说明;登录和设备绑定仍由用户核对。朋友试用从[一页指南](https://devices.owenshen.top/start)开始,无需先登录。
12
27
 
13
28
  0.2.0-beta.3 增加九语言界面与安装说明,语言菜单支持键盘、窄屏与输入保留。长文档和 CLI 仍以中文为主。
@@ -70,4 +85,4 @@ npm run verify:package
70
85
 
71
86
  需要已有受信任 App Services 共享账号服务与同父域会话;不是通用第三方 OAuth。个人部署可选 personal + ownerEmail。旧个人数据切多用户前按[迁移与回退说明](docs/hosted-access.md)核验原所有者映射;不能只切回旧代码而保留多用户数据库。
72
87
 
73
- 当前没有密钥托管、安装时自动联网或远程 shell。安全边界与披露方式见 [SECURITY](SECURITY.md)。npm 包公开不自动改变 GitHub 仓库可见性;发布包按固定源码提交构建;手工发布不等同主线 CI 自动交付。
88
+ 当前没有密钥托管、安装时自动联网或远程 shell。安全边界与披露方式见 [SECURITY](SECURITY.md)。npm 包公开不自动改变 GitHub 仓库可见性;npm 发布独立于 Web 部署。主线验证、自动部署和失败恢复的机制及启用证据见[持续交付](docs/development/delivery-profile.md)。
@@ -34,8 +34,9 @@ export function helpText() {
34
34
 
35
35
  device-center start [--port 5174] 启动本机面板与只读 MCP
36
36
  device-center plan --cloud https://你的域名 预览回报范围,不生成身份或联网
37
- device-center connect --cloud https://你的域名 --confirm
38
- 在本机生成身份,申请配对后等待你核对批准
37
+ device-center connect --cloud https://你的域名 --browser --confirm
38
+ 打开浏览器核对批准一次,自动等待与核验首次回报
39
+ 无浏览器服务器:去掉 --browser,在本机终端手动配对
39
40
  device-center status 查看已有绑定,不显示私钥
40
41
  device-center run [--port 5174] 前台恢复已绑定 Driver
41
42
  device-center disconnect --confirm 通知云端撤销,保留本机身份
@@ -1,8 +1,8 @@
1
- # 让 Agent 安装 Device Center
1
+ # 安装 Device Center:Agent 或手动
2
2
 
3
- 用户选择模式后,把完整安装说明交给**正在目标电脑上运行、具备本机安装能力的 Agent**。网页只复制说明,不会远程替浏览器所在电脑安装软件。
3
+ 目标设备只需要 Device Center Driver,不要求已经安装 AI Agent。一页指南提供 **交给 Agent** 和 **手动安装** 两个入口。网页只复制内容,安装发生在你执行脚本的目标设备上。
4
4
 
5
- `device-center@0.2.0-beta.3` 提供“交给 Agent 安装”、设备说明与手动采集。新用户可在[一页试用指南](https://devices.owenshen.top/start)免登录选择方式并复制完整说明。
5
+ **beta.4 是本地开发候选,尚未发布到 npm。** 当前公开版为 `device-center@0.2.0-beta.3`。新流程需要控制面与 CLI 一起升级;下列 `--browser` 指新版源码能力,不应交给旧客户端执行。
6
6
 
7
7
  ## 用户只需要选择一种方式
8
8
 
@@ -14,6 +14,34 @@
14
14
 
15
15
  托管的是面板、设备登记与中转服务。设备身份私钥和指定日志授权仍在设备上,不上传私钥,不让服务商代为持有设备登录能力。
16
16
 
17
+ ## 手动安装:目标设备没有 AI Agent
18
+
19
+ 新版指南选择使用方式后,点 **手动安装**,复制 **终端安装脚本**。主面板的“安装 Device Center”中也有手动指南入口,会带入当前选择的控制面。
20
+
21
+ 复制按钮下方的三张窗口示意按选择切换:**目标电脑的终端/Agent 对话 → 浏览器绑定(本机模式为安装检查)→ 设备列表**。点“看下一步”可预看后续动作;切图不会执行安装、批准绑定或表示操作成功。无浏览器模式会画出“目标终端 → 另一台电脑的浏览器”;本机面板地址则明确要求在刚安装的那台电脑打开。
22
+
23
+ 1. **准备环境**:macOS / Linux、Node.js 24+ 和 npm。缺少 Node.js 时先从官方来源安装;Windows 原生 Driver 暂未支持。脚本只支持全新安装,发现已有 CLI 会停止,不覆盖身份或配置。
24
+ 2. **在目标设备执行**:Mac 打开“终端”;Linux 使用终端,或你已经建立的 SSH 会话。粘贴整块脚本并执行。不需要把它发给 AI Agent,也不会替你建立 SSH 连接。
25
+ 3. **本机使用**:不登录、不申请云端绑定。安装结束后在这台设备打开 `http://127.0.0.1:5174` 查看资源。
26
+ 4. **托管/自建接入**:复制前选择目标设备有没有浏览器:
27
+
28
+ | 目标设备 | 接下来的操作 |
29
+ | --- | --- |
30
+ | 有浏览器 | 脚本执行 `connect --browser --confirm`,自动打开本次申请。登录自己的账号,对照终端完整公钥指纹并批准一次。程序自动等待结果并核验首次回报。 |
31
+ | 无浏览器/服务器 | 脚本执行不带 `--browser` 的 `connect --confirm`。保持终端打开,在另一台有浏览器的电脑登录同一控制面,点“连接我的电脑”,输入终端的五分钟配对码,对照完整公钥指纹并批准。设备自动继续;云端列表首次收到数据后显示在线。 |
32
+
33
+ 5. **查看结果**:后台用户服务应为运行状态,云端要有新的实际回报时间;“已绑定”与“已收到资源回报”是两件事。无需回到 Agent 回复“已批准”。不要重复绑定来解决未回报。
34
+
35
+ AI Agent 和 MCP 是管理端的可选能力:只有以后想让某台电脑上的 AI Agent 使用 Device Center 时,才在**那台管理电脑**配置本机 MCP。目标设备没有 AI Agent,照样可以常驻回报并作为另行授权的日志读取目标。服务器的 `127.0.0.1` 指服务器自己,不能作为另一台电脑的远程 MCP 地址。
36
+
37
+ ### 手动模式遇到问题
38
+
39
+ - **已有安装**:脚本停止是保留现有设置,不是安装成功。使用已有 CLI 的 `status`、`service status --port 5174` 核对版本、绑定与服务,再明确选择复用或升级。不要直接重装或解绑。
40
+ - **没有浏览器/打开失败**:预先选择“无浏览器/服务器”。若已经运行过浏览器模式且打开失败,可以在目标终端重新运行同一条 `connect` 命令并去掉 `--browser`,以新申请为准。不要把配对码贴进 AI Agent 对话或共享截图。
41
+ - **端口冲突**:保留其他程序,选明确可用的本机端口,并同步脚本里的服务、配对和面板地址。
42
+ - **后台服务失败**:macOS 需要登录用户的 launchd;Linux 需要 `systemd --user`。脚本不会自动改系统权限或启用 linger。无人登录也要常驻的服务器,需管理员单独配置合适的服务生命周期;不满足时可按下面的前台方式使用。
43
+ - **绑定后未回报**:检查 Driver 是否运行、设备是否休眠、到控制面域名的 HTTPS 出站连通性与系统时间。首次回报可能需约 30 秒;在面板核对真实时间,不把仅有本地绑定当作在线。不需要对公网开放设备端口。
44
+
17
45
  ## Agent 执行顺序
18
46
 
19
47
  只有用户明确要求在当前电脑安装并选择模式后,才能执行以下步骤。阅读本文本身不授予安装、配对或访问其他设备的权限。
@@ -29,7 +57,7 @@
29
57
  全新安装可以使用:
30
58
 
31
59
  ```sh
32
- npm install --global --prefix "$HOME/.local" --ignore-scripts device-center@0.2.0-beta.3
60
+ npm install --global --prefix "$HOME/.local" --ignore-scripts device-center@0.2.0-beta.4
33
61
  "$HOME/.local/bin/device-center" --version
34
62
  ```
35
63
 
@@ -61,12 +89,18 @@ macOS 使用当前登录用户的 launchd;Linux 需要可用的 `systemd --use
61
89
 
62
90
  ```sh
63
91
  "$HOME/.local/bin/device-center" plan --cloud 'https://devices.owenshen.top' --port 5174
64
- "$HOME/.local/bin/device-center" connect --cloud 'https://devices.owenshen.top' --port 5174 --confirm
92
+ "$HOME/.local/bin/device-center" connect --cloud 'https://devices.owenshen.top' --port 5174 --browser --confirm
65
93
  ```
66
94
 
67
95
  自建模式替换为用户明确提供的 HTTPS 控制面域名,不携带路径、账号或参数。不能自动覆盖其他控制面的绑定。
68
96
 
69
- 用户在该控制面登录自己的账号,核对本机终端的完整公钥指纹,再手动批准新增设备。Agent 不代为批准,不读取密码、cookie 或私钥;配对码留在本机终端,不贴进智能体对话、脚本或链接。若 Agent 的执行工具会把终端配对码自动记录进对话,只把这一步交给用户在本机终端执行,Agent 继续其他检查。
97
+ Agent 直接执行带 `--browser` 的命令并保持进程。命令自动打开本机默认浏览器,申请自动带入;未登录时先登录,之后自动回到同一申请。用户只需核对命令输出的完整公钥指纹,并在浏览器批准一次。Agent 不代为批准,不读取密码、cookie、私钥或带凭据的浏览器链接。
98
+
99
+ 浏览器模式不向 stdout/stderr 输出配对码或临时登记票据。票据仅通过系统浏览器打开,放在 URL fragment 中,页面立即移除,暂存在同标签页的 sessionStorage 供登录跳转使用;五分钟有效、一次批准,服务端仅存摘要。不要复制、分享或记录该链接。票据不进入提示词、安装脚本或压缩包。
100
+
101
+ 用户批准后,命令自动继续并等待首次资源回报,返回 `firstReport.verified`。**不要让用户去终端重新执行,也不要要求用户回复“已批准”。** Agent 持续观察自己启动的进程,`firstReport.verified=true` 后继续 MCP 配置;前台模式进程会继续运行,不必等它退出。`false` 表示绑定成功但回报未证实,检查服务、网络与时钟,不要重新绑定。
102
+
103
+ 仅当目标机没有浏览器、打开失败或系统阻止打开时,再让用户在目标机终端运行去掉 `--browser` 的同一条 connect 命令。配对码只留在本机终端与登录后的面板,不进入 Agent 对话。旧控制面需要先升级;命令不会自动降级并打印凭据。
70
104
 
71
105
  前台 `connect` 在批准后启动 Driver;已经运行的用户服务会读取绑定并开始回报。不能仅凭 `configured=true` 宣称云端已收到数据,要核对真实回报和时间。
72
106
 
@@ -98,7 +132,7 @@ http://127.0.0.1:5174/mcp
98
132
 
99
133
  默认不导入或连接 SSH 主机、不分享 SSH、不扫描局域网、不开放设备入站端口、不配置隧道、不授予日志或命令权限。安装与绑定不等于远程操作授权。
100
134
 
101
- ## 给 Agent 的公开文档入口
135
+ ## 公开文档入口
102
136
 
103
137
  当前源码服务提供固定路径 `GET /install/agent.md`,仅返回本文,不需要登录,不含账号、身份或设备清单。官方入口为 https://devices.owenshen.top/install/agent.md;自建服务使用自己的域名与相同路径。
104
138
 
@@ -5,6 +5,16 @@
5
5
 
6
6
  ## 三步接入
7
7
 
8
+ ### beta.4 简化接入候选(尚未发布)
9
+
10
+ 新版一页指南并列提供 Agent 说明和手动安装脚本。目标设备有浏览器时共用 `connect --browser --confirm`:自动打开 `/pair`,本人登录并核对完整公钥指纹、批准一次;CLI 自动检测结果并核验首次回报。无需手动搬运配对码,也不用回 Agent 回复“已批准”。手动模式还可选“无浏览器/服务器”,脚本生成不带 `--browser` 的命令,通过另一台电脑完成下面的填码流程。目标设备不需要 AI Agent 或 MCP。已有安装脚本停止保留设置;Agent 可核对后复用已有绑定,不重复登记。
11
+
12
+ 浏览器模式使用独立的 256 位随机登记票据,五分钟有效,服务端只存 SHA256 摘要。CLI 不输出它,仅通过默认浏览器打开 `/pair#ticket=…&expires=…`。fragment 不发送到 HTTP 服务器或 Referer;页面立即移除,仅在同标签页 sessionStorage 暂存以跨过登录,批准、取消、过期时移除。不把它放进提示词、安装脚本或归档;它不是账号登录或远程任务权限。拥有票据仍需登录并本人核对批准,设备领取结果仍必须证明持有私钥。不要分享绑定链接或批准陌生请求。
13
+
14
+ 自动打开失败时明确转手动,不自动输出凭据。自建控制面必须升级后才支持浏览器交接。`firstReport.verified=false` 会明确区分“已绑定”和“首次回报未确认”,不应重新绑定。
15
+
16
+ ### 手动兼容流程
17
+
8
18
  1. 登录自己的 HTTPS 控制面,点 **连接我的电脑**。下载 Driver 源码包,在管理电脑解压到长期保留的目录;需要 Node.js 24,无第三方运行依赖。已有最新版源码仓库可直接使用。
9
19
  2. 在该目录的终端运行面板复制的命令。下面是占位域名,请替换为自己的 HTTPS 域名。npm 分发候选见 [npm 使用与发布说明](npm.md):
10
20
 
@@ -66,4 +76,4 @@ npm run service -- install --confirm --port 5174
66
76
 
67
77
  ## 账号归属与候选版本
68
78
 
69
- 当前源码 beta.2 增加显式多用户账号隔离,未发布或部署;npm beta.1 客户端的机器配对/签名协议保持兼容。个人模式仍为默认;多用户的面板批准需同时提交码与完整指纹,归属只取服务核验的账号,不接受客户端 tenantId。历史个人记录不自动转移;多账号数据不能交给旧的无隔离服务器读取。详见[托管接入、迁移与回退](hosted-access.md)。
79
+ 显式多用户隔离已在 beta.3 上线;beta.4 浏览器交接仍是本地开发候选,旧客户端的手动配对与签名协议保持兼容。个人模式仍为默认;多用户批准需同时提交配对码或短时浏览器票据,以及完整指纹,归属只取服务核验的账号,不接受客户端 tenantId。历史个人记录不自动转移;多账号数据不能交给旧的无隔离服务器读取。详见[托管接入、迁移与回退](hosted-access.md)。
package/docs/npm.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  MIT,Node.js 24,macOS/Linux。当前版本 `0.2.0-beta.3`,使用 npm `beta` 渠道;是否上架以公开 registry 为准。
4
4
 
5
+ 本地 `0.2.0-beta.4` 候选新增 `connect --browser --confirm` 与普通终端安装脚本,尚未发布。必须先发布新 CLI,再将指南切到对应版本并部署新版控制面;只更新网页不能让已安装的旧 CLI 获得新参数。新控制面继续支持旧版手动填码。回退网页与旧 CLI 不会删除既有身份,新增加的登记摘要列可保留。以下仍为当前已发布版命令。
6
+
5
7
  ```sh
6
8
  npx --yes device-center@0.2.0-beta.3 --help
7
9
  npx --yes device-center@0.2.0-beta.3 plan
@@ -13,6 +13,8 @@
13
13
 
14
14
  ## 服务器 / Docker 部署后的接入
15
15
 
16
+ beta.4 本地候选提供并列的 Agent/手动安装入口,目标设备不要求 AI Agent 或 MCP。手动安装可选有浏览器自动交接,或无浏览器终端填码。有浏览器时申请自动带入,本人登录、核对指纹并批准一次;CLI 自动等待与验证首次回报,不要求用户回 Agent 回复“已批准”。无浏览器服务器在另一台电脑完成下列手动流程。此候选尚未发布,当前公开 beta.3 的行为仍如下。
17
+
16
18
  控制面与管理电脑可能是两台机器。当前服务只读取自己所在机器的配置;服务器配置不能代替用户电脑的配置,Docker 也不会取得宿主 SSH 权限。
17
19
 
18
20
  当前支持 macOS/Linux 管理 Driver,需要 Node.js 24,操作收敛为:
@@ -6,6 +6,8 @@
6
6
 
7
7
  App Services 账号由固定 HTTPS `/v1/me` 核验,不解码自报 JWT 作为信任。稳定 issuer/subject 摘要派生租户,所有查询和撤销按服务端已核验租户限定。上游不可用时拒绝;密码认证只支持个人模式。旧个人归属与回退见[托管接入](hosted-access.md)。
8
8
 
9
+ 云端允许从个人站或其它网站打开 `/`、`/index.html`、`/login`、`/start` 和 `/start.html`,仅限浏览器顶层 GET 页面导航(`Sec-Fetch-Mode: navigate`、`Sec-Fetch-Dest: document`)。面板仍需登录,匿名根页面跳转登录;API、脚本、iframe 和写请求不适用此例外。同父域 `same-site` 也不等于 `same-origin`,不能用于跨来源操作设备。Host、HTTPS 代理标记与显式 Origin 校验始终保留,本机模式不变。
10
+
9
11
  远程日志使用 Node/OpenSSL TLS 1.3 双向认证,两端固定经核对的证书/指纹。目标本机 grant 只允许指定管理身份、标签、规范化文件路径和截止时间;每次最多64KiB。禁止任意命令、任意路径、符号链接/多硬链接/非普通文件。直连和云中转都核验当前云端成员,撤销后拒绝新操作。
10
12
 
11
13
  云端中转只见密文与账号/设备/流量元数据。配额每日持久计数,队列、帧、会话、速率和并发均有界。两端本机审计不保存日志内容;超容量拒绝,不自动清理。详见[授权与额度](remote-access.md)。
package/favicon.svg ADDED
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="14" fill="#0c0c0e"/>
3
+ <path d="M12 42V12h30v30H12Zm10 10h30V22H22v30Z" fill="none" stroke="#ff8254" stroke-width="4" stroke-linejoin="round"/>
4
+ </svg>
package/index.html CHANGED
@@ -7,6 +7,7 @@
7
7
  <meta name="description" content="Device Center · 云端控制面与设备 Agent 接入原型" />
8
8
  <link rel="stylesheet" href="/src/styles.css" />
9
9
  <link rel="stylesheet" href="/src/i18n.css" />
10
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
10
11
  <title>Device Center · 设备控制面</title>
11
12
  </head>
12
13
  <body>
package/login.html CHANGED
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
2
  <html lang="zh-CN">
3
- <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title data-i18n="login.title">登录 · Device Center</title><link rel="stylesheet" href="/src/login.css"><link rel="stylesheet" href="/src/i18n.css"></head>
3
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.svg" type="image/svg+xml"><title data-i18n="login.title">登录 · Device Center</title><link rel="stylesheet" href="/src/login.css"><link rel="stylesheet" href="/src/i18n.css"></head>
4
4
  <body class="login-page"><main><div class="language-picker" data-language-picker></div><div class="mark">DC</div><h1>Device Center</h1><p data-i18n="login.subtitle">登录你的设备控制面</p><p id="loading" role="status" data-i18n="login.loading">正在读取登录方式…</p><form id="login" hidden><label><span data-i18n="login.username">用户名</span><input name="username" autocomplete="username" required maxlength="64"></label><label><span data-i18n="login.password">密码</span><input name="password" type="password" autocomplete="current-password" required maxlength="256"></label><button type="submit" data-i18n="login.submit">登录</button><p id="message" role="alert"></p></form><section id="unified" hidden><p data-i18n="login.unified">使用 App Services/个人站的统一账号。</p><a id="unified-login" class="login-link" target="_blank" rel="noopener noreferrer" data-i18n="login.link">使用统一账号登录 ↗</a><p class="help" data-i18n="login.help">在新页面用邮箱或 Google 完成登录,回到这里会自动进入设备面板。</p><button id="continue-login" type="button" data-i18n="login.continue">我已登录,继续</button><p id="unified-message" role="status"></p></section><p class="install-help"><span data-i18n="login.notInstalled">还未安装?</span><a href="/start" target="_blank" rel="noopener noreferrer" data-language-link data-i18n="login.guide">查看一页试用指南 ↗</a><br><span data-i18n="login.installHint">本机使用无需账号;官方托管需登录并核对绑定。</span></p><small id="access-notes" data-i18n="login.multi">账号设备隔离 · HTTPS 保护</small></main><script type="module" src="/src/login.js"></script></body>
5
5
  </html>
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "device-center",
3
- "version": "0.2.0-beta.3",
3
+ "version": "0.2.0-beta.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "device-center",
9
- "version": "0.2.0-beta.3",
9
+ "version": "0.2.0-beta.4",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@peculiar/x509": "2.1.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "device-center",
3
- "version": "0.2.0-beta.3",
3
+ "version": "0.2.0-beta.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Local device connector with account-bound pairing, TLS encrypted relay and explicitly authorized log access",
@@ -37,6 +37,7 @@
37
37
  "docs/remote-access.md",
38
38
  "npm-shrinkwrap.json",
39
39
  "start.html",
40
+ "favicon.svg",
40
41
  "docs/friend-trial.md",
41
42
  "docs/i18n.md"
42
43
  ],
@@ -8,21 +8,23 @@ import { servicePlan } from './service.mjs';
8
8
  import { cloudOrigin, publicIdentity, signedRequest } from '../server/drivers/driverProtocol.mjs';
9
9
  import { cloudPost, defaultLinkFile, readLink, writeLink, linkSummary, ensurePrivateDirectory } from '../server/drivers/cloudLink.mjs';
10
10
  import { issueCertificate } from '../server/drivers/certificates.mjs';
11
+ import { pairingBrowserUrl, openPairingBrowser, waitForFirstReport } from '../server/drivers/browserPairing.mjs';
11
12
 
12
- const usage = 'npm run driver -- plan|connect|run|status|disconnect --cloud https://你的域名 [--share-ssh] [--port 5174] [--confirm]';
13
+ const usage = 'npm run driver -- plan|connect|run|status|disconnect --cloud https://你的域名 [--browser] [--share-ssh] [--port 5174] [--confirm]';
13
14
  export function driverPlan(argv = []) {
14
15
  const command = argv[0] ?? 'plan';
15
- let origin = 'https://devices.owenshen.top', port = 5174, confirm = false, shareSsh = false;
16
+ let origin = 'https://devices.owenshen.top', port = 5174, confirm = false, shareSsh = false, browser = false;
16
17
  for (let i = 1; i < argv.length; i++) {
17
18
  if (argv[i] === '--cloud' && argv[i+1]) origin = argv[++i];
18
19
  else if (argv[i] === '--port' && argv[i+1]) port = Number(argv[++i]);
19
20
  else if (argv[i] === '--confirm') confirm = true;
20
21
  else if (argv[i] === '--share-ssh') shareSsh = true;
22
+ else if (argv[i] === '--browser') browser = true;
21
23
  else throw new Error(usage);
22
24
  }
23
25
  if (!['plan', 'connect', 'run', 'status', 'disconnect'].includes(command) || !Number.isInteger(port) || port < 1024 || port > 65535) throw new Error(usage);
24
26
  cloudOrigin(origin);
25
- return { command, origin, port, confirm, shareSsh, fields: ['管理电脑名称', '系统类型', '内存', '用户目录卷磁盘', ...(shareSsh ? ['至多15个已关联SSH主机的名称和缓存资源状态(不含地址、用户、路径或配置)'] : [])], keyStorage: '仅本机私有目录/0600文件;当前不是Keychain/TPM封装', tasksAuthorized: false, backgroundInstalled: false };
27
+ return { command, origin, port, confirm, shareSsh, browser, fields: ['管理电脑名称', '系统类型', '内存', '用户目录卷磁盘', ...(shareSsh ? ['至多15个已关联SSH主机的名称和缓存资源状态(不含地址、用户、路径或配置)'] : [])], keyStorage: '仅本机私有目录/0600文件;当前不是Keychain/TPM封装', tasksAuthorized: false, backgroundInstalled: false };
26
28
  }
27
29
  export async function runLocal(plan) {
28
30
  // Reuse only a running native Driver that implements this protocol; never stop another listener.
@@ -43,8 +45,8 @@ export async function runLocal(plan) {
43
45
  const shutdown = () => instance.server.close(() => { instance.db.close(); process.exit(0); });
44
46
  process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown);
45
47
  }
46
- export async function main(argv = process.argv.slice(2)) {
47
- const plan = driverPlan(argv), file = defaultLinkFile();
48
+ export async function main(argv = process.argv.slice(2), { file = defaultLinkFile(), post = cloudPost, openBrowser = openPairingBrowser, runDriver = runLocal, waitReport = waitForFirstReport } = {}) {
49
+ const plan = driverPlan(argv);
48
50
  if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('需要 Node.js 24 或更高版本。');
49
51
  if (plan.command === 'plan') return plan; // No files, key generation, network or installation.
50
52
  if (process.platform === 'win32') throw new Error('Windows 身份文件的安全存储尚未实现,当前管理 Driver 只支持 macOS/Linux。');
@@ -66,16 +68,31 @@ export async function main(argv = process.argv.slice(2)) {
66
68
  writeLink(file, state, { create: true });
67
69
  }
68
70
  if (!state.certificate) { state.certificate = await issueCertificate(state); writeLink(file, state); }
69
- const pending = await cloudPost(plan.origin, '/api/driver/pair/start', { publicKey: state.publicKey, certificate: state.certificate, name: hostname().slice(0,80), platform: ({ darwin: 'macOS', linux: 'Linux' })[platform()], shareSsh: plan.shareSsh });
70
- if (!/^[a-f0-9-]{36}$/.test(pending.id) || !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(pending.code) || pending.fingerprint !== state.fingerprint || !Number.isSafeInteger(pending.expiresAt)) throw new Error('云端配对响应无效。');
71
- console.log(`请打开 ${plan.origin},点“连接我的电脑”,在面板单独输入以下配对码(5分钟内有效):\n${pending.code}\n核对本机公钥指纹:${state.fingerprint}\n仅批准你刚在这台电脑发起的请求;配对码不放入智能体prompt或安装脚本。`);
71
+ let pending;
72
+ try { pending = await post(plan.origin, '/api/driver/pair/start', { publicKey: state.publicKey, certificate: state.certificate, name: hostname().slice(0,80), platform: ({ darwin: 'macOS', linux: 'Linux' })[platform()], shareSsh: plan.shareSsh, ...(plan.browser ? { browser: true } : {}) }); }
73
+ catch (error) {
74
+ if (plan.browser && error.statusCode === 400 && error.message === 'invalid_driver_input') throw new Error('控制面尚不支持浏览器接入,请升级控制面;或在本机终端使用不带 --browser 的 connect。未自动输出配对凭据。');
75
+ throw error;
76
+ }
77
+ if (!/^[a-f0-9-]{36}$/.test(pending.id) || (!plan.browser && !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(pending.code)) || pending.fingerprint !== state.fingerprint || !Number.isSafeInteger(pending.expiresAt)) throw new Error('云端配对响应无效。');
78
+ if (plan.browser) {
79
+ const url = pairingBrowserUrl(plan.origin, pending);
80
+ console.log(`正在打开本机浏览器。请本人登录账号,核对设备后批准一次。\n本机公钥指纹:${state.fingerprint}\n无需填写配对码,也无需回复 Agent;程序会自动等待并核验。`);
81
+ await openBrowser(url);
82
+ } else console.log(`请打开 ${plan.origin},点“连接我的电脑”,在面板单独输入以下配对码(5分钟内有效):\n${pending.code}\n核对本机公钥指纹:${state.fingerprint}\n仅批准你刚在这台电脑发起的请求;配对码不放入智能体prompt或安装脚本。`);
72
83
  const deadline = Math.min(pending.expiresAt, Date.now() + 300_000);
73
84
  while (Date.now() < deadline) {
74
- const result = await cloudPost(plan.origin, '/api/driver/pair/poll', signedRequest(state.privateKey, plan.origin, '/api/driver/pair/poll', pending.id, {}));
85
+ const result = await post(plan.origin, '/api/driver/pair/poll', signedRequest(state.privateKey, plan.origin, '/api/driver/pair/poll', pending.id, {}));
75
86
  if (result.state === 'approved') {
76
87
  if (!/^[a-f0-9-]{36}$/.test(result.driverId) || typeof result.shareSsh !== 'boolean' || result.shareSsh && !plan.shareSsh) throw new Error('云端批准范围无效。');
77
88
  state = { ...state, driverId: result.driverId, shareSsh: result.shareSsh, disabled: false }; writeLink(file, state);
78
- console.log('已绑定。私钥留在本机,只回报获准字段;远程任务未授权。'); await runLocal(plan); return linkSummary(state);
89
+ console.log('已绑定。私钥留在本机,只回报获准字段;远程任务未授权。');
90
+ const since = Date.now();
91
+ await runDriver(plan);
92
+ if (!plan.browser) return linkSummary(state);
93
+ const report = await waitReport({ port: plan.port, driverId: state.driverId, origin: state.origin, since });
94
+ console.log(report.verified ? '云端已接收首次资源回报,接入完成。' : '账号绑定已完成,但尚未确认首次资源回报。请检查本机服务与网络,不要重复绑定。');
95
+ return { ...linkSummary(state), firstReport: report };
79
96
  }
80
97
  if (result.state !== 'pending') throw new Error('配对响应无效。');
81
98
  await new Promise(done => setTimeout(done, 5000));
@@ -4,6 +4,7 @@ import { createUnifiedAccess } from './unifiedAccess.mjs';
4
4
 
5
5
  const SESSION_AGE = 8 * 60 * 60 * 1000;
6
6
  const COOKIE = '__Host-device-center';
7
+ const ENTRY_PAGES = new Set(['/', '/index.html', '/login', '/start', '/start.html', '/pair']);
7
8
  const digest = (value) => createHash('sha256').update(value).digest('hex');
8
9
  const reject = (message) => { throw new Error(message); };
9
10
 
@@ -42,9 +43,14 @@ export function createCloudAccess({ publicOrigin, authFile, now = Date.now, fetc
42
43
  origin: publicOrigin,
43
44
  validate(request) {
44
45
  // The process stays on loopback. The proxy preserves Host and overwrites X-Forwarded-Proto.
46
+ const site = request.headers['sec-fetch-site'];
47
+ // External links may open HTML pages; API requests, frames and writes remain isolated.
48
+ const entryNavigation = request.method === 'GET' && ['same-site', 'cross-site'].includes(site) &&
49
+ request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document' &&
50
+ ENTRY_PAGES.has(new URL(request.url ?? '/', publicOrigin).pathname);
45
51
  if (request.headers.host !== url.host || request.headers['x-forwarded-proto'] !== 'https' ||
46
52
  (request.headers.origin && request.headers.origin !== publicOrigin) ||
47
- (request.headers['sec-fetch-site'] && !['same-origin', 'none'].includes(request.headers['sec-fetch-site']))) {
53
+ (site && !['same-origin', 'none'].includes(site) && !entryNavigation)) {
48
54
  throw Object.assign(new Error('cloud_origin_required'), { statusCode: 403 });
49
55
  }
50
56
  return publicOrigin;
@@ -0,0 +1,41 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { cloudOrigin } from './driverProtocol.mjs';
3
+
4
+ export function pairingBrowserUrl(origin, pending) {
5
+ cloudOrigin(origin);
6
+ if (!/^[a-f0-9]{64}$/.test(pending.browserTicket ?? '') || !Number.isSafeInteger(pending.expiresAt)) throw new Error('控制面尚不支持浏览器接入,请升级控制面;或在本机终端使用不带 --browser 的 connect。');
7
+ const url = new URL('/pair', origin);
8
+ // Fragments are excluded from HTTP requests and Referer; the page removes this immediately.
9
+ url.hash = new URLSearchParams({ ticket: pending.browserTicket, expires: String(pending.expiresAt) }).toString();
10
+ return url.href;
11
+ }
12
+
13
+ export async function openPairingBrowser(url, { platform = process.platform, spawnImpl = spawn } = {}) {
14
+ const target = new URL(url);
15
+ cloudOrigin(target.origin);
16
+ if (target.pathname !== '/pair' || target.search || !/^#ticket=[a-f0-9]{64}&expires=\d+$/.test(target.hash)) throw new Error('无效的浏览器交接。');
17
+ const command = platform === 'darwin' ? '/usr/bin/open' : platform === 'linux' ? 'xdg-open' : null;
18
+ if (!command) throw new Error('此系统尚不支持浏览器接入。');
19
+ // No shell, URL echo or opener output in the Agent transcript.
20
+ await new Promise((resolve, reject) => {
21
+ const child = spawnImpl(command, [target.href], { stdio: 'ignore', shell: false });
22
+ const timer = setTimeout(() => { child.kill(); reject(new Error('无法打开本机浏览器。请在本机终端执行不带 --browser 的 connect 手动配对;不会自动输出凭据。')); }, 10_000);
23
+ child.once('error', () => { clearTimeout(timer); reject(new Error('无法打开本机浏览器,请在本机终端使用不带 --browser 的 connect 手动配对。')); });
24
+ child.once('close', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error('浏览器打开失败,请在本机终端使用不带 --browser 的 connect 手动配对。')); });
25
+ });
26
+ }
27
+
28
+ export async function waitForFirstReport({ port, driverId, origin, since, fetchImpl = fetch, now = Date.now, sleep = ms => new Promise(done => setTimeout(done, ms)), timeoutMs = 75_000 }) {
29
+ const deadline = now() + timeoutMs;
30
+ while (now() < deadline) {
31
+ try {
32
+ const reply = await fetchImpl(`http://127.0.0.1:${port}/api/driver/link`, { signal: AbortSignal.timeout(1500), redirect: 'error' });
33
+ if (reply.ok) {
34
+ const link = await reply.json();
35
+ if (link.protocol === 'device-center-driver-v1' && link.driverId === driverId && link.origin === origin && !link.disabled && link.publisher?.state === 'connected' && link.publisher.lastSuccess >= since) return { verified: true, receivedAt: link.publisher.lastSuccess };
36
+ }
37
+ } catch { /* Registration and cloud delivery are separate results. */ }
38
+ await sleep(1500);
39
+ }
40
+ return { verified: false, reason: 'first_report_timeout' };
41
+ }
@@ -20,6 +20,8 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
20
20
  }
21
21
  db.exec('CREATE INDEX IF NOT EXISTS cloud_drivers_tenant_created ON cloud_drivers(tenant_id, created_at)');
22
22
  for (const table of ['driver_pairings', 'cloud_drivers']) if (!db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === 'certificate')) db.exec(`ALTER TABLE ${table} ADD COLUMN certificate TEXT`);
23
+ if (!db.prepare('PRAGMA table_info(driver_pairings)').all().some(c => c.name === 'browser_hash')) db.exec('ALTER TABLE driver_pairings ADD COLUMN browser_hash TEXT');
24
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS driver_pairings_browser ON driver_pairings(browser_hash)');
23
25
  if (!db.prepare('PRAGMA table_info(cloud_drivers)').all().some(c => c.name === 'control_seen_at')) db.exec('ALTER TABLE cloud_drivers ADD COLUMN control_seen_at INTEGER');
24
26
  db.exec(`CREATE TABLE IF NOT EXISTS driver_collection_requests (driver_id TEXT PRIMARY KEY, request_id TEXT NOT NULL,
25
27
  requested_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, completed_at INTEGER)`);
@@ -43,6 +45,13 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
43
45
  if (!row || row.expires_at <= now()) fail('driver_pairing_expired', 410);
44
46
  return row;
45
47
  }
48
+ function credential(input) {
49
+ if (input.browserTicket !== undefined) {
50
+ if (input.code !== undefined || typeof input.browserTicket !== 'string' || !/^[a-f0-9]{64}$/.test(input.browserTicket)) fail('invalid_pairing_code');
51
+ return { column: 'browser_hash', hash: publicCodeHash(input.browserTicket) };
52
+ }
53
+ return { column: 'code_hash', hash: hashCode(input.code) };
54
+ }
46
55
  function proof(row, path, input) {
47
56
  verifyRequest(row.public_key, origin, path, input, now());
48
57
  db.prepare('DELETE FROM driver_nonces WHERE expires_at<=?').run(now());
@@ -76,7 +85,8 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
76
85
  const registry = {
77
86
  start(input) {
78
87
  starts = limited(starts, 8);
79
- object(input, ['publicKey', 'name', 'platform', 'shareSsh', 'certificate']);
88
+ object(input, ['publicKey', 'name', 'platform', 'shareSsh', 'certificate', 'browser']);
89
+ if (input.browser !== undefined && typeof input.browser !== 'boolean') fail('invalid_driver_scope');
80
90
  const identity = publicIdentity(input.publicKey);
81
91
  const certificate = input.certificate == null ? null : validateCertificate(input.certificate, identity.fingerprint, now());
82
92
  const name = text(input.name), platform = text(input.platform, 64);
@@ -87,6 +97,11 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
87
97
  if (db.prepare('SELECT count(*) AS n FROM driver_pairings').get().n >= 32) fail('driver_pairing_busy', 429);
88
98
  const id = randomUUID(), raw = randomBytes(4).toString('hex').toUpperCase(), code = `${raw.slice(0,4)}-${raw.slice(4)}`;
89
99
  db.prepare('INSERT INTO driver_pairings (id,code_hash,public_key,fingerprint,name,platform,requested_ssh,created_at,expires_at,certificate) VALUES (?,?,?,?,?,?,?,?,?,?)').run(id, hashCode(code), identity.publicKey, identity.fingerprint, name, platform, Number(input.shareSsh), now(), now() + PAIR_AGE, certificate);
100
+ if (input.browser) {
101
+ const browserTicket = randomBytes(32).toString('hex');
102
+ db.prepare('UPDATE driver_pairings SET browser_hash=? WHERE id=?').run(publicCodeHash(browserTicket), id);
103
+ return { id, browserTicket, fingerprint: identity.fingerprint, expiresAt: now() + PAIR_AGE, pollIntervalMs: 5000 };
104
+ }
90
105
  return { id, code, fingerprint: identity.fingerprint, expiresAt: now() + PAIR_AGE, verificationUrl: origin, pollIntervalMs: 5000 };
91
106
  },
92
107
  poll(input) {
@@ -102,24 +117,26 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
102
117
  },
103
118
  lookup(input, tenantId) {
104
119
  globalLookups = limited(globalLookups, 64);
105
- limitAccount(lookups, tenantId); object(input, ['code']);
106
- const row = db.prepare('SELECT * FROM driver_pairings WHERE code_hash=?').get(hashCode(input.code));
120
+ limitAccount(lookups, tenantId); object(input, ['code', 'browserTicket']);
121
+ const proof = credential(input);
122
+ const row = db.prepare(`SELECT * FROM driver_pairings WHERE ${proof.column}=?`).get(proof.hash);
107
123
  if (!row || row.expires_at <= now() || row.driver_id) fail('driver_pairing_unavailable', 404);
108
124
  return { id: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint, requestedSsh: Boolean(row.requested_ssh), expiresAt: row.expires_at };
109
125
  },
110
126
  approve(input, tenantId) {
111
127
  globalApprovals = limited(globalApprovals, 64); limitAccount(approvals, tenantId);
112
- object(input, ['id', 'code', 'fingerprint', 'shareSsh']);
128
+ object(input, ['id', 'code', 'browserTicket', 'fingerprint', 'shareSsh']);
113
129
  if (typeof input.id !== 'string' || typeof input.shareSsh !== 'boolean') fail('invalid_driver_approval');
114
- if (multiUser && (typeof input.code !== 'string' || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
130
+ if ((multiUser || input.browserTicket !== undefined) && ((typeof input.code !== 'string' && typeof input.browserTicket !== 'string') || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
115
131
  db.exec('BEGIN IMMEDIATE');
116
132
  try {
117
133
  const row = pairing(input.id);
134
+ if (row.browser_hash != null && ((input.code === undefined && input.browserTicket === undefined) || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
118
135
  if (row.driver_id) {
119
136
  if (!db.prepare('SELECT id FROM cloud_drivers WHERE id=? AND tenant_id=?').get(row.driver_id, tenantId)) fail('driver_pairing_unavailable', 404);
120
137
  fail('driver_pairing_consumed', 409);
121
138
  }
122
- if (input.code !== undefined && hashCode(input.code) !== row.code_hash) fail('driver_pairing_unavailable', 404);
139
+ if (input.code !== undefined || input.browserTicket !== undefined) { const proof = credential(input); if (proof.hash !== row[proof.column]) fail('driver_pairing_unavailable', 404); }
123
140
  if (input.fingerprint !== undefined && input.fingerprint !== row.fingerprint) fail('driver_pairing_confirmation_required');
124
141
  if (input.shareSsh && !row.requested_ssh) fail('driver_scope_violation');
125
142
  if (db.prepare('SELECT count(*) AS n FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL').get(tenantId).n >= 16) fail('driver_limit', 409);
package/server/index.mjs CHANGED
@@ -42,7 +42,9 @@ async function readJson(request) {
42
42
  async function serveStatic(pathname, response) {
43
43
  let filePath;
44
44
  if (pathname === '/' || pathname === '/index.html') filePath = resolve(PROJECT_ROOT, 'index.html');
45
+ else if (pathname === '/favicon.svg') filePath = resolve(PROJECT_ROOT, 'favicon.svg');
45
46
  else if (pathname === '/login') filePath = resolve(PROJECT_ROOT, 'login.html');
47
+ else if (pathname === '/pair') filePath = resolve(PROJECT_ROOT, 'login.html');
46
48
  else if (pathname === '/start' || pathname === '/start.html') filePath = resolve(PROJECT_ROOT, 'start.html');
47
49
  else if (pathname.startsWith('/src/')) {
48
50
  filePath = resolve(PROJECT_ROOT, `.${pathname}`);
@@ -81,8 +83,9 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
81
83
  const origin = cloudAccess ? cloudAccess.validate(request) : validateLocalRequest(request);
82
84
  const url = new URL(request.url ?? '/', origin);
83
85
  const path = url.pathname;
86
+ if (['/pair', '/src/pair.js', '/src/pair.css', '/src/pairing-handoff.js', '/src/api.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
84
87
  // Public static installation reference only. No identity, configuration or owner API.
85
- if (['/start', '/start.html', '/src/start.js', '/src/start.css', '/src/onboarding.js', '/src/i18n.js', '/src/i18n.css', '/src/messages.js', '/src/install-prompt.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
88
+ if (['/start', '/start.html', '/favicon.svg', '/src/start.js', '/src/start.css', '/src/onboarding.js', '/src/i18n.js', '/src/i18n.css', '/src/messages.js', '/src/install-prompt.js', '/src/install-walkthrough.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
86
89
  if (Object.hasOwn(PUBLIC_GUIDES, path) && request.method === 'GET') {
87
90
  const body = await readFile(resolve(PROJECT_ROOT, PUBLIC_GUIDES[path]));
88
91
  response.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; frame-ancestors 'none'", 'referrer-policy': 'no-referrer' });
package/src/api.js CHANGED
@@ -7,6 +7,8 @@ async function request(path, { timeoutMs = 12000, ...options } = {}) {
7
7
  return payload;
8
8
  }
9
9
  export const api = {
10
+ lookupBrowserPairing: browserTicket => request('/api/drivers/pairing/lookup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ browserTicket }) }),
11
+ approveBrowserPairing: input => request('/api/drivers/pairing/approve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) }),
10
12
  saveDeviceProfile: (profile) => request('/api/devices/profile', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(profile) }),
11
13
  collectDevice: (deviceId) => request('/api/devices/collect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ deviceId }), timeoutMs: 30000 }),
12
14
  remotePeers: () => request('/api/remote/peers'),
@@ -0,0 +1,49 @@
1
+ import { translate } from './i18n.js';
2
+
3
+ // Instruction illustrations only. Navigation never installs software or confirms a binding.
4
+ export function installationSteps({ method = 'agent', mode = 'local', pairingMethod = 'browser' } = {}) {
5
+ if (!['agent', 'manual'].includes(method) || !['local', 'official', 'self-hosted'].includes(mode) || !['browser', 'headless'].includes(pairingMethod)) throw new Error('invalid_installation_walkthrough');
6
+ const manual = method === 'manual', cloud = mode !== 'local', headless = manual && pairingMethod === 'headless';
7
+ return [
8
+ { tab: 'walk.pasteTab', title: manual ? 'walk.terminalTitle' : 'walk.agentTitle', body: manual ? 'walk.terminalBody' : 'walk.agentBody', picture: manual ? 'terminal' : 'chat' },
9
+ { tab: cloud ? 'walk.bindTab' : 'walk.installTab', title: cloud ? headless ? 'walk.headlessTitle' : 'walk.browserTitle' : 'walk.installTitle', body: cloud ? headless ? 'walk.headlessBody' : 'walk.browserBody' : manual ? 'walk.installBody' : 'walk.agentInstallBody', picture: cloud ? headless ? 'headless' : 'pair' : 'install' },
10
+ { tab: 'walk.checkTab', title: 'walk.checkTitle', body: cloud ? 'walk.cloudCheckBody' : 'walk.localCheckBody', picture: 'dashboard' },
11
+ ];
12
+ }
13
+
14
+ const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
15
+ const arrows = '<svg viewBox="0 0 32 20" aria-hidden="true"><path d="M2 10h26M20 3l8 7-8 7"/></svg>';
16
+ const clipboard = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5H5v16h14V5h-3M8 3h8v5H8zM8 12h8m-8 4h5"/></svg>';
17
+ const pointer = '<svg class="picture-pointer" viewBox="0 0 30 36" aria-hidden="true"><path d="m4 3 21 18-10 1-5 10z"/></svg>';
18
+
19
+ export function installWalkthroughMarkup({ method = 'agent', mode = 'local', pairingMethod = 'browser', step = 0, copied = false, controlOrigin = '', localUrl = 'http://127.0.0.1:5174', locale = 'zh-CN' } = {}) {
20
+ const steps = installationSteps({ method, mode, pairingMethod });
21
+ if (!Number.isInteger(step) || step < 0 || step >= steps.length) throw new Error('invalid_installation_step');
22
+ const tr = (key, values) => escape(translate(locale, key, values));
23
+ const active = steps[step], cloud = mode !== 'local';
24
+ const window = (title, contents, type = '') => `<div class="picture-window ${type}"><div class="picture-titlebar"><i></i><i></i><i></i><span>${title}</span></div>${contents}</div>`;
25
+ const paste = type => `<div class="picture-transfer"><span>${clipboard}${tr('walk.copiedContent')}</span>${arrows}<span>${tr(type === 'terminal' ? 'walk.terminal' : 'walk.chat')}</span></div>`;
26
+ const fingerprint = `<div class="picture-fingerprints"><span>${tr('walk.terminal')}</span><code>${tr('walk.fullFingerprint')}</code><b>=</b><span>${tr('walk.browser')}</span><code>${tr('walk.fullFingerprint')}</code></div>`;
27
+ const approval = `<div class="picture-approval">${tr('walk.approve')}${pointer}</div>`;
28
+ const browserTitle = `<span class="picture-address">${escape(controlOrigin || 'https://devices.example.com')}</span>`;
29
+ let picture;
30
+ if (active.picture === 'terminal') {
31
+ picture = `${paste('terminal')}${window(tr('walk.targetTerminal'), `<div class="picture-terminal"><div class="picture-paste-line"><span>$</span><div>${tr('walk.pasteScript')}<i class="picture-caret"></i></div>${pointer}</div><div class="picture-keyline"><span>macOS <kbd>⌘ V</kbd> · Linux <kbd>Ctrl Shift V</kbd></span><strong>${tr('walk.pressEnter')} <kbd>Enter ↵</kbd></strong></div></div>`, 'terminal-picture')}<div class="picture-launch"><span>${tr('walk.openMac')}</span><span>${tr('walk.openLinux')}</span></div>`;
32
+ } else if (active.picture === 'chat') {
33
+ picture = `${paste('chat')}${window(tr('walk.targetChat'), `<div class="picture-chat"><div class="picture-chat-message">${tr('walk.pastePrompt')}<i class="picture-caret"></i></div><div class="picture-send"><span>${tr('walk.send')}</span><b>↑</b>${pointer}</div></div>`, 'chat-picture')}`;
34
+ } else if (active.picture === 'pair' || active.picture === 'headless') {
35
+ const headless = active.picture === 'headless';
36
+ const transfer = headless ? `<div class="picture-transfer headless-transfer"><span>${tr('walk.targetTerminal')}<code>${tr('walk.pairCode')}</code></span>${arrows}<span>${tr('walk.otherBrowser')}</span></div>` : `<div class="picture-auto-open">${tr('walk.autoBrowser')}</div>`;
37
+ picture = `${transfer}${window(browserTitle, `<div class="picture-pair"><div class="picture-signin"><span>01</span>${tr('walk.signIn')}</div>${headless ? `<div class="picture-code-entry"><span>${tr('walk.openPairing', { action: translate(locale, 'panel.434') })}</span><div>${tr('walk.pasteCode')}${pointer}</div></div>` : ''}<div class="picture-signin"><span>02</span>${tr('walk.compare')}</div>${fingerprint}${approval}</div>`, 'browser-picture')}`;
38
+ } else if (active.picture === 'install') {
39
+ picture = window(tr('walk.targetTerminal'), `<div class="picture-install"><div class="picture-install-command"><span>$</span> device-center</div><div class="picture-install-row"><span>01</span>${tr('walk.driver')}</div><div class="picture-install-row"><span>02</span>${tr('walk.service')}</div>${method === 'agent' ? `<div class="picture-install-row"><span>03</span>${tr('walk.mcp')}</div>` : ''}<div class="picture-install-track"><i></i><i></i><i></i></div></div>`, 'terminal-picture');
40
+ } else {
41
+ picture = `${!cloud ? `<div class="picture-auto-open">${tr('walk.sameComputer')}</div>` : ''}${window(`<span class="picture-address">${escape(cloud ? controlOrigin || 'https://devices.example.com' : localUrl)}</span>`, `<div class="picture-dashboard"><div class="picture-device"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M1 21h22M8 16l-1 5m9-5 1 5"/></svg><strong>${tr('walk.yourDevice')}</strong><span>${tr('walk.awaitReport')}</span></div><div class="picture-metrics"><div><span>${tr('panel.8')}</span><b>— / —</b><i></i></div><div><span>${tr('panel.9')}</span><b>— / —</b><i></i></div></div><div class="picture-report-time">${tr('walk.reportTime')}${pointer}</div></div>`, 'browser-picture')}`;
42
+ }
43
+ return `<ol class="walkthrough-tabs" aria-label="${tr('walk.steps')}">${steps.map((item, index) => `<li><button type="button" data-walk-step="${index}" ${index === step ? 'aria-current="step"' : ''}><span>0${index + 1}</span>${tr(item.tab)}</button></li>`).join('')}</ol>
44
+ <div class="walkthrough-caption" aria-live="polite"><span>${tr(active.title)}</span><small>${tr('walk.counter', { current: step + 1, total: steps.length })}</small></div>
45
+ <div class="walkthrough-picture" role="img" aria-label="${tr(active.title)}. ${tr(active.body)}">${picture}</div>
46
+ <p class="walkthrough-description">${tr(active.body)}</p>
47
+ ${step === 0 && !copied ? `<p class="walkthrough-copy-first">${tr('walk.copyFirst')}</p>` : ''}
48
+ <div class="walkthrough-navigation"><button type="button" data-walk-step="${step - 1}" ${step === 0 ? 'disabled' : ''}>${tr('walk.previous')}</button><button type="button" data-walk-step="${step === 2 ? 0 : step + 1}">${tr(step === 2 ? 'walk.restart' : 'walk.next')}</button></div>`;
49
+ }