From 109c6d99afc776c03df849d5708f35d458e22783 Mon Sep 17 00:00:00 2001 From: shaowei <466995322@qq.com> Date: Sat, 25 Jul 2026 13:19:51 +0800 Subject: [PATCH] feat: add local phone pairing QR --- README.md | 2 +- docs/changelog.zh-CN.md | 1 + .../new-user-install-review-20260725.zh-CN.md | 10 +- scripts/server-module-smoke.ts | 31 +++++ scripts/verify-quick-tunnel.ps1 | 21 ++- src/server/httpServer.ts | 58 +------- src/server/localPairingPage.ts | 125 ++++++++++++++++++ tests.md | 19 +++ 8 files changed, 206 insertions(+), 61 deletions(-) create mode 100644 src/server/localPairingPage.ts diff --git a/README.md b/README.md index 6a4d168..e522622 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ http://127.0.0.1:7420 默认安装最新正式 Release,并校验 Release 包的 SHA-256;只有参与源码预览时才应显式使用 `-UseBranchArchive`。 -安装完成后,电脑本机打开 `http://127.0.0.1:7420/local-setup` 查看访问密码;该页面不允许通过公网域名访问。 +安装完成后,电脑本机打开 `http://127.0.0.1:7420/local-setup`,用手机扫描二维码打开临时地址,再输入页面显示的访问密码。二维码只在本机生成且只包含地址,不包含密码;配对页不允许通过公网域名访问。 `-JsonOutput` 的 stdout 固定为单行 JSON,构建进度和诊断写入 stderr。成功结果包含 `schemaVersion`、`operation`、`version`、`started`、`healthReady`、本机/公网地址和结构化告警;失败结果返回 `BOOTSTRAP_FAILED` 与失败阶段,不输出密码、Cookie 或 Token。bootstrap 还会读取归档内的 `release-capabilities.json`,拒绝把新版参数交给不支持它们的旧 Release。 diff --git a/docs/changelog.zh-CN.md b/docs/changelog.zh-CN.md index 248ec5e..6788536 100644 --- a/docs/changelog.zh-CN.md +++ b/docs/changelog.zh-CN.md @@ -4,6 +4,7 @@ - 长时稳定性浸泡现在持续验证本机事件回放结构与 `latestSeq` 单调性,并在配置公网地址时逐样本确认未登录 Codex API 始终返回 HTTP 401;JSON 报告新增回放失败、鉴权失败和事件序号倒退汇总,避免只看 `/health` 而漏掉消息接收链路或远程鉴权回归。 - 设置中的“手机访问”前置到基础设置之后,新人无需先滚过套餐余量和权限控制即可生成、复制、打开或停止临时地址;功能、安全验证和视觉样式保持不变。 +- 仅限本机的手机配对页新增本地生成二维码,手机相机可直接打开已验证的临时地址;二维码不包含访问密码、不调用第三方二维码服务,配对页继续对公网请求返回 404。 ## 2.5.5 - 2026-07-25 diff --git a/docs/new-user-install-review-20260725.zh-CN.md b/docs/new-user-install-review-20260725.zh-CN.md index 2daf2da..53b5835 100644 --- a/docs/new-user-install-review-20260725.zh-CN.md +++ b/docs/new-user-install-review-20260725.zh-CN.md @@ -4,7 +4,7 @@ 从没有 CX-Codex 运行环境的 Windows 状态出发,按 README 的一行命令已经能够完成安装、构建、启动、免费临时公网访问、密码登录、发送消息和接收回复。 -首次源码预览安装最终成功用时 153.5 秒。正式 2.5.4 的公开资产、五进程无警告卸载和无安装状态重装均已通过;首次升级时 Cloudflare 临时地址一度完全不可达,手动再次开启约 20 秒即成功,2.5.5 候选已只对这种 `HTTP unreachable` 自动重试一次。当前仍不能称为真正“傻瓜式”,主要差距变为:下载阶段缺少连续进度、首次工作区选择和二维码配对仍不够明确。 +首次源码预览安装最终成功用时 153.5 秒。正式 2.5.4 的公开资产、五进程无警告卸载和无安装状态重装均已通过;首次升级时 Cloudflare 临时地址一度完全不可达,手动再次开启约 20 秒即成功,2.5.5 已只对这种 `HTTP unreachable` 自动重试一次。当前 `main` 还补上了仅限本机的二维码配对页。项目仍不能称为真正“傻瓜式”,主要差距变为:下载阶段缺少连续进度、首次工作区选择不够明确,以及手机扫码后仍需手动输入密码。 ## 测试边界 @@ -34,8 +34,8 @@ ``` 2. 等待源码下载、依赖安装、前端/CLI 构建和 cloudflared 下载。 -3. 本机打开 `http://127.0.0.1:7420/local-setup`,只在本机查看访问密码。 -4. 手机打开安装结果中的临时 HTTPS 地址并输入密码。 +3. 本机打开 `http://127.0.0.1:7420/local-setup`,用手机相机扫描本机生成的二维码。 +4. 手机打开临时 HTTPS 地址并输入配对页显示的密码;二维码本身不包含密码。 5. 浏览器中新建或选择工作区,本次选择 `CodexWorkspace`。 6. 发送一条测试消息并等待回复。 7. 不再需要手机入口时,在设置的“手机访问”卡片中停止临时地址;退出 CX-Codex 后地址也会失效。 @@ -100,7 +100,7 @@ - 临时地址会变化,无 SLA,退出进程后失效。 - 安全模式默认不创建自启动任务;重启电脑后需要重新启动并获得新地址。 -- 需要先在本机打开配对页,再到手机输入地址和密码;还没有二维码配对闭环。 +- 仍需先在本机打开配对页并在手机输入密码;地址已可扫码打开,密码刻意不写入二维码或 URL。 ### P2:可维护性 @@ -121,7 +121,7 @@ ### 第二阶段:首次引导 1. 安装完成自动打开本机引导页,按“Codex 登录 → 选择工作区 → 本机健康 → 手机访问”显示四步状态。 -2. 配对页同时显示二维码、可复制地址、可复制密码、有效期和停止按钮。 +2. 部分完成:配对页已显示本地生成的二维码、地址和密码,二维码不含密码且公网无法打开配对页;后续再补有效期和停止按钮。 3. 首次消息提供“发送测试消息”按钮,自动验证发送、实时进度、回复和断线恢复。 4. 明确提供“安全临时访问”和“长期固定访问”两个模式,后者引导 Tailscale 或命名 Cloudflare Tunnel。 diff --git a/scripts/server-module-smoke.ts b/scripts/server-module-smoke.ts index a0a84e9..2bc1bda 100644 --- a/scripts/server-module-smoke.ts +++ b/scripts/server-module-smoke.ts @@ -435,6 +435,10 @@ import { CODEX_BRIDGE_SHARED_STATE_KEY, getCodexBridgeSharedState, } from '../src/server/codexBridgeSharedState.js' +import { + renderLocalSetupHtml, + renderPairingQrSvg, +} from '../src/server/localPairingPage.js' const originalNow = Date.now @@ -456,6 +460,7 @@ try { smokeAppServerLaunch() smokeAppServerHealth() await smokeAuthMiddleware() + smokeLocalPairingPage() await smokeAppServerMethodCatalog() smokeAppServerNotificationDiagnostics() smokeAppServerNotificationListeners() @@ -1512,6 +1517,32 @@ async function smokeAuthMiddleware(): Promise { } } +function smokeLocalPairingPage(): void { + const publicUrl = 'https://pairing.example.test/connect?a=1&b=2' + const qrSvg = renderPairingQrSvg(publicUrl) + assert.match(qrSvg, /^/gu, '>') - .replace(/"/gu, '"') - .replace(/'/gu, ''') -} - -function renderLocalSetupHtml(password: string): string { - const tunnel = getQuickTunnelSnapshot() - const publicUrl = tunnel.active ? tunnel.publicUrl : '' - const publicLink = publicUrl - ? `打开手机访问地址` - : '

临时地址尚未生成,可在 CX-Codex 设置的“手机访问”中开启。

' - return ` - - - - - -CX-Codex 本机配对 - - - -
-

仅限本机

-

CX-Codex 手机配对

-

在手机打开临时地址后,输入下面的访问密码。密码不会写入公网链接。

-
-
手机访问地址
${escapeHtml(publicUrl || '尚未生成')}
-
访问密码
${escapeHtml(password || '当前未启用密码')}
-
-${publicLink} -

只在你自己的电脑上打开本页,不要截图或转发访问密码。临时地址停止后会失效。

-
- -` -} - function normalizeLocalImagePath(rawPath: string): string { const trimmed = rawPath.trim() if (!trimmed) return '' @@ -223,9 +173,13 @@ export function createServer(options: ServerOptions = {}): ServerInstance { res.status(404).end() return } + const tunnel = getQuickTunnelSnapshot() res.setHeader('Cache-Control', 'no-store') res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'") - res.status(200).type('text/html; charset=utf-8').send(renderLocalSetupHtml(options.password ?? '')) + res.status(200).type('text/html; charset=utf-8').send(renderLocalSetupHtml({ + password: options.password ?? '', + publicUrl: tunnel.active ? tunnel.publicUrl : '', + })) }) // 1. Auth middleware (if password is set) diff --git a/src/server/localPairingPage.ts b/src/server/localPairingPage.ts new file mode 100644 index 0000000..f39324f --- /dev/null +++ b/src/server/localPairingPage.ts @@ -0,0 +1,125 @@ +import { createRequire } from 'node:module' + +type QrCodeInstance = { + addData: (value: string) => void + getModuleCount: () => number + isDark: (row: number, column: number) => boolean + make: () => void +} + +type QrCodeConstructor = new (typeNumber: number, errorCorrectLevel: number) => QrCodeInstance + +const require = createRequire(import.meta.url) +const QrCode = require('qrcode-terminal/vendor/QRCode') as QrCodeConstructor +const qrErrorCorrectLevel = require('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel') as { + M: number +} +const QR_QUIET_ZONE_MODULES = 4 + +function escapeHtml(value: string): string { + return value + .replace(/&/gu, '&') + .replace(//gu, '>') + .replace(/"/gu, '"') + .replace(/'/gu, ''') +} + +export function renderPairingQrSvg(value: string): string { + const normalizedValue = value.trim() + if (!normalizedValue) return '' + + const qrCode = new QrCode(-1, qrErrorCorrectLevel.M) + qrCode.addData(normalizedValue) + qrCode.make() + + const moduleCount = qrCode.getModuleCount() + const viewBoxSize = moduleCount + QR_QUIET_ZONE_MODULES * 2 + const darkModules: string[] = [] + for (let row = 0; row < moduleCount; row += 1) { + for (let column = 0; column < moduleCount; column += 1) { + if (!qrCode.isDark(row, column)) continue + darkModules.push( + `M${String(column + QR_QUIET_ZONE_MODULES)} ${String(row + QR_QUIET_ZONE_MODULES)}h1v1h-1z`, + ) + } + } + + return [ + `', + '手机访问地址二维码', + ``, + ``, + '', + ].join('') +} + +export function renderLocalSetupHtml(options: { + password: string + publicUrl: string +}): string { + const publicUrl = options.publicUrl.trim() + const qrCode = renderPairingQrSvg(publicUrl) + const pairingGuide = publicUrl + ? [ + '
', + `
${qrCode}
`, + '
', + '

手机连接

', + '

扫描二维码打开地址

', + '
    ', + '
  1. 用手机相机扫描二维码。
  2. ', + '
  3. 在打开的页面输入下方访问密码。
  4. ', + '
  5. 看到聊天界面后即可开始使用。
  6. ', + '
', + '
', + '
', + ].join('') + : '

临时地址尚未生成,可在 CX-Codex 设置的“手机访问”中开启。

' + const publicLink = publicUrl + ? `在电脑上测试手机地址` + : '' + + return ` + + + + + +CX-Codex 本机配对 + + + +
+

仅限本机

+

CX-Codex 手机配对

+

二维码只包含手机访问地址,不包含访问密码,也不会发送到第三方二维码服务。

+${pairingGuide} +
+
手机访问地址
${escapeHtml(publicUrl || '尚未生成')}
+
访问密码
${escapeHtml(options.password || '当前未启用密码')}
+
+${publicLink} +

只在你自己的电脑上打开本页,不要截图或转发访问密码。临时地址停止后会失效。

+
+ +` +} diff --git a/tests.md b/tests.md index 0e1ca13..a586c24 100644 --- a/tests.md +++ b/tests.md @@ -2,6 +2,25 @@ This file tracks manual regression and feature verification steps. +## Local phone-pairing QR (2026-07-25) + +### Expected behavior + +1. Opening `http://127.0.0.1:7420/local-setup` while Quick Tunnel is ready shows a scannable QR code for the active public URL. +2. The QR code is generated locally, contains only the public URL, and never includes the CX-Codex password. +3. The page keeps `Cache-Control: no-store`, a script-blocking Content Security Policy, and HTTP 404 for requests made through a non-loopback/public host. +4. When no tunnel is active, the page shows the existing start-from-settings guidance and renders no QR SVG. +5. At 393 × 852 the card, QR code, address, password, and warning remain readable without horizontal overflow. + +### Verification + +- Run `npm.cmd run build:cli`. +- Run `npm.cmd run build:frontend`. +- Run `npm.cmd run verify:server-modules`. +- Run `node .\scripts\run-powershell-script.mjs .\scripts\verify-quick-tunnel.ps1`. +- Start an isolated password-protected server, enable a temporary Quick Tunnel, and open `/local-setup` at 393 × 852. +- Decode the rendered screenshot with a QR decoder and require it to match the server's active `publicUrl`; then stop the tunnel and isolated server. + ## Phone-access settings priority (2026-07-25) ### Expected behavior