virlen-remote 0.1.2 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,45 @@
3
3
  本文件记录对外可见的变更(协议 / 导出面 / 行为)。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
4
4
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
5
5
 
6
+ ## [0.3.0] - 2026-10-01
7
+
8
+ ### Added
9
+
10
+ - **配对链接**(`buildPairingUrl` / `PAIRING_URL_BASE` / `PAIRING_URL_PARAM`):二维码内容改为
11
+ `https://virlen.cn/mobile?t=<配对数据>`。这样**系统相机 / 微信 / 任意浏览器**扫码就能直接打开
12
+ 手机端并自动配对,无需先装 App 再扫码;App 内的扫码器则从 URL 里取回 `t`。
13
+ - `parsePairingPayload` **新增识 URL**:输入是 URL 时自动取 `?t=` 再解析。三种输入都吃:
14
+ URL / `vrp1:` 混淆串 / 旧版明文 JSON。
15
+
16
+ ### Notes
17
+
18
+ - 向后兼容:新版能解旧码与旧串;**旧版解不了新码**(消费方需同步升级)。
19
+ - `PAIRING_URL_BASE` 是部署地址,`buildPairingUrl` 的第二个参数 `base` 可覆盖(自建 / 换域名)。
20
+ - 无破坏性变更;0.2.0 的导出面保持不变。
21
+
22
+ ## [0.2.0] - 2026-09-30
23
+
24
+ ### Changed
25
+
26
+ - **配对串(二维码 / 手工输入的"配对码")不再是明文 JSON**:`encodePairingPayload` 现在输出
27
+ `vrp1:` + `Base64URL(UTF-8(JSON) ⊕ 固定盐)`。目的只是让二维码文字 / 界面上的排查文本
28
+ **不是一眼可读**(此前是 `{"v":2,"host":"dk-…","ticket":"…"}` 原文),并避免在复制粘贴 / 日志里
29
+ 直接暴露 ticket、设备 key、信令基址。
30
+ - `parsePairingPayload` **兼容两种输入**:带 `vrp1:` 前缀走解码;否则按旧版明文 JSON 解析 ——
31
+ 已生成 / 已截图的旧二维码、以及旧版本手抄的配对串仍能识别,**升级不会让旧码失效**。
32
+
33
+ ### Added
34
+
35
+ - `PAIRING_OBFUSCATION_PREFIX`(`'vrp1:'`):混淆串前缀,供消费方识别 / 展示。
36
+
37
+ ### Notes
38
+
39
+ - ⚠️ **这是混淆,不是加密**:本包发布到公开 npm,固定盐在源码里可见,拿到源码即可解;
40
+ 且它**挡不住有人对着屏幕拍照**(二维码本就是给人扫的,App 会解回来)。
41
+ 真正的安全边界仍是「一次性 ticket + 电脑端确认弹窗 + 授权凭证」。
42
+ - 向后兼容:新版能解旧码;**旧版解不了新码**(消费方需同步升级到本版或更高)。
43
+ - 零依赖:自带 Base64URL 实现,不依赖 `btoa` / `atob` / `Buffer`。
44
+
6
45
  ## [0.1.2] - 2026-09-28
7
46
 
8
47
  ### Changed
@@ -70,3 +109,5 @@
70
109
  [0.1.0]: https://github.com/WeiChens/virlen-remote/releases/tag/v0.1.0
71
110
  [0.1.1]: https://github.com/WeiChens/virlen-remote/releases/tag/v0.1.1
72
111
  [0.1.2]: https://github.com/WeiChens/virlen-remote/releases/tag/v0.1.2
112
+ [0.2.0]: https://github.com/WeiChens/virlen-remote/releases/tag/v0.2.0
113
+ [0.3.0]: https://github.com/WeiChens/virlen-remote/releases/tag/v0.3.0
package/README.md CHANGED
@@ -106,17 +106,26 @@ const hello = await caller.call('host.hello', {
106
106
  ```ts
107
107
  import {
108
108
  buildPairingPayload,
109
- encodePairingPayload,
109
+ buildPairingUrl,
110
110
  parsePairingPayload,
111
111
  roomOfPayload,
112
112
  } from 'virlen-remote'
113
113
 
114
114
  const payload = buildPairingPayload({ host: deviceKey, name: '我的电脑', ticket, signal: signalUrl })
115
- const qrText = encodePairingPayload(payload) // 画进二维码
116
- const back = parsePairingPayload(qrText) // 手机端解析(无法识别时返回 null)
115
+ const qrText = buildPairingUrl(payload) // 画进二维码:'https://virlen.cn/mobile?t=vrp1:xxxx'
116
+ const back = parsePairingPayload(qrText) // 手机端解析(URL / 混淆串 / 旧明文 JSON 都吃;认不出返回 null)
117
117
  if (back) await connect(roomOfPayload(back), back.host, back.ticket)
118
118
  ```
119
119
 
120
+ > **配对链接**:二维码内容 = `https://virlen.cn/mobile?t=<配对数据>`,于是**系统相机 / 微信 / 任意浏览器**
121
+ > 扫码就能直接打开手机端并自动配对。手机端从 `?t=` 取回数据后同样交给 `parsePairingPayload`。
122
+ > 地址常量见 `PAIRING_URL_BASE`(自建 / 换域名给 `buildPairingUrl` 传第二个参数覆盖)。
123
+ >
124
+ > **关于混淆**:配对数据是 `vrp1:` + `Base64URL(JSON ⊕ 固定盐)`,只是让二维码文字 /
125
+ > 界面上的排查文本**不是一眼可读**,**不是加密** —— 固定盐在源码里,拿到源码即可解;它也
126
+ > 挡不住有人对着屏幕拍照。真正的安全边界是「一次性 ticket + 电脑端确认弹窗 + 授权凭证」。
127
+ > `parsePairingPayload` **同时接受** URL、混淆串与旧版明文 JSON(向后兼容)。前缀常量见 `PAIRING_OBFUSCATION_PREFIX`。
128
+
120
129
  ## ICE / TURN 配置
121
130
 
122
131
  **本包与两个客户端里都没有任何 TURN 凭证。** 默认值由**服务端**提供:
package/dist/index.d.ts CHANGED
@@ -18,7 +18,7 @@ export type { ErrorCode, WireError, BridgeErrorOptions } from './protocol/errors
18
18
  export { newRequestId } from './protocol/ids.js';
19
19
  export { HOST_KEY_PREFIX, MOBILE_KEY_PREFIX, GRANT_PREFIX, ROOM_PREFIX, GRANT_TTL_MS, GRANT_MAX_LIFETIME_MS, randomKey, newDeviceKey, newGrantToken, isDeviceKey, roomFor, hostKeyFromRoom, issueGrant, isGrantExpired, renewGrant, checkGrant, describeGrantRemaining, } from './protocol/identity.js';
20
20
  export type { DeviceKind, GrantRecord, CredentialRejectReason, RoomStatus } from './protocol/identity.js';
21
- export { PAIRING_PAYLOAD_VERSION, PAIRING_TICKET_TTL_MS, buildPairingPayload, encodePairingPayload, parsePairingPayload, roomOfPayload, } from './protocol/pairing.js';
21
+ export { PAIRING_PAYLOAD_VERSION, PAIRING_TICKET_TTL_MS, PAIRING_OBFUSCATION_PREFIX, PAIRING_URL_BASE, PAIRING_URL_PARAM, buildPairingPayload, encodePairingPayload, buildPairingUrl, parsePairingPayload, roomOfPayload, } from './protocol/pairing.js';
22
22
  export type { PairingPayload } from './protocol/pairing.js';
23
23
  export { normalizeChoiceAnswer, answerActionError, CHOICE_JOINER } from './protocol/answer.js';
24
24
  export type { ChoiceAnswer } from './protocol/answer.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,yBAAyB,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACtG,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AACnG,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAEnE,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAC9D,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAEjF,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAG7C,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,qBAAqB,EACrB,SAAS,EACT,YAAY,EACZ,aAAa,EACb,WAAW,EACX,OAAO,EACP,eAAe,EACf,UAAU,EACV,cAAc,EACd,UAAU,EACV,UAAU,EACV,sBAAsB,GACvB,MAAM,qBAAqB,CAAA;AAC5B,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,sBAAsB,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAGtG,OAAO,EACL,uBAAuB,EACvB,qBAAqB,EACrB,0BAA0B,EAC1B,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,EACpB,eAAe,EACf,mBAAmB,EACnB,aAAa,GACd,MAAM,oBAAoB,CAAA;AAC3B,YAAY,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAExD,OAAO,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAC3F,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD,OAAO,EAAE,SAAS,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AAEnE,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAC9C,YAAY,EACV,WAAW,EACX,YAAY,EACZ,UAAU,EACV,eAAe,EACf,WAAW,EACX,eAAe,GAChB,MAAM,qBAAqB,CAAA;AAE5B,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACjF,OAAO,EAAE,6BAA6B,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAClF,YAAY,EACV,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,eAAe,EACf,WAAW,EACX,OAAO,EACP,SAAS,EACT,UAAU,EACV,YAAY,EACZ,iBAAiB,EACjB,UAAU,EACV,UAAU,EACV,cAAc,EACd,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,UAAU,EACV,aAAa,EACb,UAAU,EACV,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,kBAAkB,EAClB,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EAChB,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,aAAa,EACb,cAAc,GACf,MAAM,gBAAgB,CAAA;AACvB,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,UAAU,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAE1G,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AACtD,YAAY,EAAE,cAAc,EAAE,QAAQ,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAEtG,YAAY,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AAClE,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACtE,YAAY,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAA;AAChE,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAA;AAC/E,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAC/F,YAAY,EACV,gBAAgB,EAChB,aAAa,EACb,mBAAmB,EACnB,eAAe,EACf,UAAU,EACV,sBAAsB,GACvB,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAC9C,YAAY,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAG1D,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,iBAAiB,EACjB,aAAa,EACb,eAAe,GAChB,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EACV,QAAQ,EACR,cAAc,EACd,eAAe,EACf,aAAa,GACd,MAAM,uBAAuB,CAAA;AAG9B,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,EACtB,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,iBAAiB,CAAA;AACxB,YAAY,EACV,aAAa,EACb,SAAS,EACT,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,eAAe,EACf,iBAAiB,GAClB,MAAM,iBAAiB,CAAA"}
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ export { newRequestId } from './protocol/ids.js';
18
18
  // ── 设备身份与授权凭证(M6,§30)──
19
19
  export { HOST_KEY_PREFIX, MOBILE_KEY_PREFIX, GRANT_PREFIX, ROOM_PREFIX, GRANT_TTL_MS, GRANT_MAX_LIFETIME_MS, randomKey, newDeviceKey, newGrantToken, isDeviceKey, roomFor, hostKeyFromRoom, issueGrant, isGrantExpired, renewGrant, checkGrant, describeGrantRemaining, } from './protocol/identity.js';
20
20
  // ── 配对载荷(二维码内容,两端同一份)──
21
- export { PAIRING_PAYLOAD_VERSION, PAIRING_TICKET_TTL_MS, buildPairingPayload, encodePairingPayload, parsePairingPayload, roomOfPayload, } from './protocol/pairing.js';
21
+ export { PAIRING_PAYLOAD_VERSION, PAIRING_TICKET_TTL_MS, PAIRING_OBFUSCATION_PREFIX, PAIRING_URL_BASE, PAIRING_URL_PARAM, buildPairingPayload, encodePairingPayload, buildPairingUrl, parsePairingPayload, roomOfPayload, } from './protocol/pairing.js';
22
22
  export { normalizeChoiceAnswer, answerActionError, CHOICE_JOINER } from './protocol/answer.js';
23
23
  export { negotiate, intersectCapabilities } from './protocol/hello.js';
24
24
  export { Endpoint } from './protocol/endpoint.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,iBAAiB;AACjB,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,yBAAyB,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACtG,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAGnG,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAG9D,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAE7C,yBAAyB;AACzB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,qBAAqB,EACrB,SAAS,EACT,YAAY,EACZ,aAAa,EACb,WAAW,EACX,OAAO,EACP,eAAe,EACf,UAAU,EACV,cAAc,EACd,UAAU,EACV,UAAU,EACV,sBAAsB,GACvB,MAAM,qBAAqB,CAAA;AAG5B,yBAAyB;AACzB,OAAO,EACL,uBAAuB,EACvB,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,EACnB,aAAa,GACd,MAAM,oBAAoB,CAAA;AAG3B,OAAO,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAG3F,OAAO,EAAE,SAAS,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AAEnE,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAU9C,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACjF,OAAO,EAAE,6BAA6B,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAsClF,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AAItD,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAEtE,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAA;AAC/E,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAS/F,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAG9C,mCAAmC;AACnC,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,iBAAiB,EACjB,aAAa,EACb,eAAe,GAChB,MAAM,uBAAuB,CAAA;AAQ9B,yCAAyC;AACzC,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,EACtB,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,iBAAiB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,iBAAiB;AACjB,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,yBAAyB,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACtG,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAGnG,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAG9D,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAE7C,yBAAyB;AACzB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,qBAAqB,EACrB,SAAS,EACT,YAAY,EACZ,aAAa,EACb,WAAW,EACX,OAAO,EACP,eAAe,EACf,UAAU,EACV,cAAc,EACd,UAAU,EACV,UAAU,EACV,sBAAsB,GACvB,MAAM,qBAAqB,CAAA;AAG5B,yBAAyB;AACzB,OAAO,EACL,uBAAuB,EACvB,qBAAqB,EACrB,0BAA0B,EAC1B,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,EACpB,eAAe,EACf,mBAAmB,EACnB,aAAa,GACd,MAAM,oBAAoB,CAAA;AAG3B,OAAO,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAG3F,OAAO,EAAE,SAAS,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AAEnE,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAU9C,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACjF,OAAO,EAAE,6BAA6B,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAsClF,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AAItD,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAEtE,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAA;AAC/E,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAS/F,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAG9C,mCAAmC;AACnC,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,iBAAiB,EACjB,aAAa,EACb,eAAe,GAChB,MAAM,uBAAuB,CAAA;AAQ9B,yCAAyC;AACzC,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,EACtB,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,iBAAiB,CAAA"}
@@ -7,6 +7,27 @@ export declare const PAIRING_PAYLOAD_VERSION = 2;
7
7
  * 到期自动重生成**(见 `phoneControlStore`)三管齐下,真机上基本不可能再扫到过期码。
8
8
  */
9
9
  export declare const PAIRING_TICKET_TTL_MS: number;
10
+ /**
11
+ * 配对串混淆前缀。
12
+ *
13
+ * - **带此前缀**:内容是 `Base64URL(UTF-8(JSON) ⊕ 固定盐)`,走解码;
14
+ * - **不带**:当作旧版明文 JSON 解析(**向后兼容** —— 线上已生成 / 已截图的旧二维码、
15
+ * 以及老版本手抄的配对串仍能识别,升级后不会突然「无法识别」)。
16
+ *
17
+ * 前缀选 `vrp1:`(virlen pairing v1)—— 明文 JSON 必以 `{` 开头,不可能撞上前缀。
18
+ */
19
+ export declare const PAIRING_OBFUSCATION_PREFIX = "vrp1:";
20
+ /**
21
+ * 配对二维码指向的手机端地址。
22
+ *
23
+ * 二维码内容 = `https://virlen.cn/mobile?t=<配对数据>`:**系统相机 / 微信 / 任意浏览器**扫到后
24
+ * 直接打开手机端,手机端从 `?t=` 取回配对数据即可自动配对 —— 无需先装 App 再扫码。
25
+ *
26
+ * ⚠️ 这是**部署地址**(手机端 PWA 在 `/mobile/`);自建 / 换域名时由调用方传入 `base` 覆盖。
27
+ */
28
+ export declare const PAIRING_URL_BASE = "https://virlen.cn/mobile";
29
+ /** URL 里承载配对数据的查询参数名。 */
30
+ export declare const PAIRING_URL_PARAM = "t";
10
31
  export interface PairingPayload {
11
32
  v: number;
12
33
  /** 电脑设备 key(也是房间号的来源)。 */
@@ -27,12 +48,20 @@ export declare function buildPairingPayload(input: {
27
48
  ticket: string;
28
49
  signal?: string;
29
50
  }): PairingPayload;
30
- /** 序列化为二维码内容。 */
51
+ /** 序列化为二维码内容(**混淆后的串**,不再是明文 JSON)。 */
31
52
  export declare function encodePairingPayload(payload: PairingPayload): string;
32
53
  /**
33
- * 解析二维码 / 配对串。无法识别返回 `null`(调用方给「不是 Virlen 配对码」的文案)。
54
+ * 构造成「可被系统相机 / 微信扫开」的配对链接(二维码内容)。
55
+ *
56
+ * @param payload 配对载荷
57
+ * @param base 手机端地址(缺省 {@link PAIRING_URL_BASE})
58
+ */
59
+ export declare function buildPairingUrl(payload: PairingPayload, base?: string): string;
60
+ /**
61
+ * 解析二维码 / 配对串 / 配对链接。无法识别返回 `null`(调用方给「不是 Virlen 配对码」的文案)。
34
62
  *
35
63
  * 宽容点(都是真机上会遇到的东西):
64
+ * - 输入可以是 `vrp1:…` 串、旧版明文 JSON,或 `…?t=<配对数据>` 的 URL;
36
65
  * - `v` 缺省按 1 处理(旧码);
37
66
  * - `name` 缺省回退 `host`;
38
67
  * - `ticket` 允许是任意非空串(前缀只作可读性,不作校验 —— 电脑端的判定才是权威)。
@@ -1 +1 @@
1
- {"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/protocol/pairing.ts"],"names":[],"mappings":"AAcA,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,QAAgB,CAAA;AAElD,MAAM,WAAW,cAAc;IAC7B,CAAC,EAAE,MAAM,CAAA;IACT,0BAA0B;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,sBAAsB;IACtB,IAAI,EAAE,MAAM,CAAA;IACZ,eAAe;IACf,MAAM,EAAE,MAAM,CAAA;IACd,4CAA4C;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,wCAAwC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,iDAAiD;AACjD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE;IACzC,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,GAAG,cAAc,CAQjB;AAED,iBAAiB;AACjB,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,cAAc,GAAG,MAAM,CAEpE;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAqBvE;AAED,2CAA2C;AAC3C,wBAAgB,aAAa,CAAC,OAAO,EAAE,cAAc,GAAG,MAAM,CAE7D"}
1
+ {"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/protocol/pairing.ts"],"names":[],"mappings":"AA2BA,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,QAAgB,CAAA;AAElD;;;;;;;;GAQG;AACH,eAAO,MAAM,0BAA0B,UAAU,CAAA;AAEjD;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,6BAA6B,CAAA;AAE1D,yBAAyB;AACzB,eAAO,MAAM,iBAAiB,MAAM,CAAA;AAcpC,MAAM,WAAW,cAAc;IAC7B,CAAC,EAAE,MAAM,CAAA;IACT,0BAA0B;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,sBAAsB;IACtB,IAAI,EAAE,MAAM,CAAA;IACZ,eAAe;IACf,MAAM,EAAE,MAAM,CAAA;IACd,4CAA4C;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,wCAAwC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,iDAAiD;AACjD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE;IACzC,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,GAAG,cAAc,CAQjB;AAwED,uCAAuC;AACvC,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,cAAc,GAAG,MAAM,CAGpE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,cAAc,EAAE,IAAI,GAAE,MAAyB,GAAG,MAAM,CAIhG;AAsBD;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CA2BvE;AAED,2CAA2C;AAC3C,wBAAgB,aAAa,CAAC,OAAO,EAAE,cAAc,GAAG,MAAM,CAE7D"}
@@ -9,6 +9,19 @@
9
9
  * - `ticket`:**一次性**配对票据(不是长期凭证!兑换后电脑端签发 `grant` 回传,见 §30.3)。
10
10
  * - `signal`:信令基址;缺省 = 同源 Broadcast 联调(生产必有)。
11
11
  * - `room`:信令房间号;**缺省由 `roomFor(host)` 派生**。保留字段只为兼容旧码。
12
+ *
13
+ * **线上的码是混淆过的**(见 `PAIRING_OBFUSCATION_PREFIX`):`encodePairingPayload` 输出
14
+ * `vrp1:` + Base64URL(UTF-8(JSON) ⊕ 固定盐),使二维码文字 / 界面上的排查文本不再是明文 JSON;
15
+ * `parsePairingPayload` 仍**兼容旧明文码**(无前缀即按原 JSON 解析),已生成 / 已截图的旧码不会失效。
16
+ *
17
+ * **二维码内容是一条 URL**:`https://virlen.cn/mobile?t=<配对数据>`(见 `buildPairingUrl`)。
18
+ * 这样**系统相机 / 微信 / 任意浏览器**扫码都能直接打开手机端并自动配对,无需先装 App 再扫码;
19
+ * App 内的扫码器则用 `parsePairingPayload` 从 URL 里取回 `t`。`parsePairingPayload`
20
+ * **三种输入都吃**:URL、`vrp1:` 混淆串、旧版明文 JSON。
21
+ *
22
+ * ⚠️ 这是**混淆,不是加密**:本包是公开 npm 包、固定盐也在源码里,拿到源码即可解;
23
+ * 且它**挡不住有人对着屏幕拍照**(二维码本就是给人扫的)。真正的安全边界是
24
+ * 「一次性 ticket + 电脑端确认弹窗 + 授权凭证」(见 `identity.ts`)。
12
25
  */
13
26
  import { roomFor } from './identity.js';
14
27
  /** 当前载荷版本。v1 与 v2 形状相同(差异在语义:v2 起 `host` 是设备 key、`ticket` 是一次性票据)。 */
@@ -20,6 +33,37 @@ export const PAIRING_PAYLOAD_VERSION = 2;
20
33
  * 到期自动重生成**(见 `phoneControlStore`)三管齐下,真机上基本不可能再扫到过期码。
21
34
  */
22
35
  export const PAIRING_TICKET_TTL_MS = 5 * 60 * 1000;
36
+ /**
37
+ * 配对串混淆前缀。
38
+ *
39
+ * - **带此前缀**:内容是 `Base64URL(UTF-8(JSON) ⊕ 固定盐)`,走解码;
40
+ * - **不带**:当作旧版明文 JSON 解析(**向后兼容** —— 线上已生成 / 已截图的旧二维码、
41
+ * 以及老版本手抄的配对串仍能识别,升级后不会突然「无法识别」)。
42
+ *
43
+ * 前缀选 `vrp1:`(virlen pairing v1)—— 明文 JSON 必以 `{` 开头,不可能撞上前缀。
44
+ */
45
+ export const PAIRING_OBFUSCATION_PREFIX = 'vrp1:';
46
+ /**
47
+ * 配对二维码指向的手机端地址。
48
+ *
49
+ * 二维码内容 = `https://virlen.cn/mobile?t=<配对数据>`:**系统相机 / 微信 / 任意浏览器**扫到后
50
+ * 直接打开手机端,手机端从 `?t=` 取回配对数据即可自动配对 —— 无需先装 App 再扫码。
51
+ *
52
+ * ⚠️ 这是**部署地址**(手机端 PWA 在 `/mobile/`);自建 / 换域名时由调用方传入 `base` 覆盖。
53
+ */
54
+ export const PAIRING_URL_BASE = 'https://virlen.cn/mobile';
55
+ /** URL 里承载配对数据的查询参数名。 */
56
+ export const PAIRING_URL_PARAM = 't';
57
+ const textEncoder = new TextEncoder();
58
+ const textDecoder = new TextDecoder();
59
+ /**
60
+ * 混淆盐(UTF-8 字节)。
61
+ *
62
+ * ⚠️ **这不是密钥,是固定盐**:本包发布到公开 npm,字符串必然可见、拿到源码即可解。
63
+ * 它只解决「配对串在界面上 / 复制粘贴时不是一眼可读的 JSON」,不提供任何机密性 ——
64
+ * 真正的安全边界是「一次性 ticket + 电脑端确认弹窗 + 授权凭证」(见 `identity.ts`)。
65
+ */
66
+ const OBFUSCATION_SALT = textEncoder.encode('virlen/pairing/obfuscation/v1');
23
67
  /** 构造载荷(只填必要字段;`room` 交给两端各自派生,避免又多一处可漂移的冗余)。 */
24
68
  export function buildPairingPayload(input) {
25
69
  return {
@@ -30,14 +74,121 @@ export function buildPairingPayload(input) {
30
74
  ...(input.signal ? { signal: input.signal } : {}),
31
75
  };
32
76
  }
33
- /** 序列化为二维码内容。 */
77
+ // ── 混淆编解码(零依赖:自带 Base64URL,不依赖 btoa / atob / Buffer —— 两端 + Node 环境一致)──
78
+ const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
79
+ /** Base64URL 反向查表(ASCII 0–127;其余一律视为非法字符)。 */
80
+ const B64_LOOKUP = (() => {
81
+ const table = new Int16Array(128).fill(-1);
82
+ for (let i = 0; i < B64_ALPHABET.length; i += 1)
83
+ table[B64_ALPHABET.charCodeAt(i)] = i;
84
+ return table;
85
+ })();
86
+ /** 逐字节与盐做 XOR(盐循环使用)—— 可逆,用于混淆。 */
87
+ function xorSalt(bytes) {
88
+ const out = new Uint8Array(bytes.length);
89
+ const salt = OBFUSCATION_SALT;
90
+ for (let i = 0; i < bytes.length; i += 1)
91
+ out[i] = bytes[i] ^ salt[i % salt.length];
92
+ return out;
93
+ }
94
+ /** Base64URL 编码(**无填充**,`-` / `_` 代替 `+` / `/`)。 */
95
+ function base64UrlEncode(bytes) {
96
+ let out = '';
97
+ for (let i = 0; i < bytes.length; i += 3) {
98
+ const b0 = bytes[i];
99
+ const b1 = i + 1 < bytes.length ? bytes[i + 1] : undefined;
100
+ const b2 = i + 2 < bytes.length ? bytes[i + 2] : undefined;
101
+ out += B64_ALPHABET[b0 >> 2];
102
+ out += B64_ALPHABET[((b0 & 0x03) << 4) | ((b1 ?? 0) >> 4)];
103
+ if (b1 === undefined)
104
+ break;
105
+ out += B64_ALPHABET[((b1 & 0x0f) << 2) | ((b2 ?? 0) >> 6)];
106
+ if (b2 === undefined)
107
+ break;
108
+ out += B64_ALPHABET[b2 & 0x3f];
109
+ }
110
+ return out;
111
+ }
112
+ /** Base64URL 解码;含非法字符或长度非法(`len % 4 === 1`)时返回 `null`。 */
113
+ function base64UrlDecode(text) {
114
+ if (!text)
115
+ return null;
116
+ const values = [];
117
+ for (const ch of text) {
118
+ const code = ch.charCodeAt(0);
119
+ const v = code < 128 ? B64_LOOKUP[code] : -1;
120
+ if (v < 0)
121
+ return null;
122
+ values.push(v);
123
+ }
124
+ if (values.length % 4 === 1)
125
+ return null;
126
+ const out = new Uint8Array((values.length * 6) >> 3);
127
+ let o = 0;
128
+ for (let i = 0; i < values.length; i += 4) {
129
+ const c0 = values[i];
130
+ const c1 = values[i + 1];
131
+ const c2 = values[i + 2];
132
+ const c3 = values[i + 3];
133
+ out[o++] = (c0 << 2) | ((c1 ?? 0) >> 4);
134
+ if (c2 === undefined)
135
+ break;
136
+ out[o++] = (((c1 ?? 0) & 0x0f) << 4) | (c2 >> 2);
137
+ if (c3 === undefined)
138
+ break;
139
+ out[o++] = ((c2 & 0x03) << 6) | c3;
140
+ }
141
+ return out.subarray(0, o);
142
+ }
143
+ /** 把 `vrp1:` 串还原成 JSON 文本;任何异常(非法字符 / 长度 / 解码失败)都返回 `null`。 */
144
+ function decodeObfuscated(text) {
145
+ const bytes = base64UrlDecode(text.slice(PAIRING_OBFUSCATION_PREFIX.length));
146
+ if (!bytes)
147
+ return null;
148
+ return textDecoder.decode(xorSalt(bytes));
149
+ }
150
+ /** 序列化为二维码内容(**混淆后的串**,不再是明文 JSON)。 */
34
151
  export function encodePairingPayload(payload) {
35
- return JSON.stringify(payload);
152
+ const json = textEncoder.encode(JSON.stringify(payload));
153
+ return PAIRING_OBFUSCATION_PREFIX + base64UrlEncode(xorSalt(json));
154
+ }
155
+ /**
156
+ * 构造成「可被系统相机 / 微信扫开」的配对链接(二维码内容)。
157
+ *
158
+ * @param payload 配对载荷
159
+ * @param base 手机端地址(缺省 {@link PAIRING_URL_BASE})
160
+ */
161
+ export function buildPairingUrl(payload, base = PAIRING_URL_BASE) {
162
+ const url = new URL(base);
163
+ url.searchParams.set(PAIRING_URL_PARAM, encodePairingPayload(payload));
164
+ return url.toString();
165
+ }
166
+ /**
167
+ * 从「扫到 / 粘进来的文本」里取出配对数据本体。
168
+ *
169
+ * 三种输入都吃:
170
+ * - **URL**(`https://virlen.cn/mobile?t=<配对数据>`)—— 系统相机 / 微信扫码打开的就是它;
171
+ * - `vrp1:…` 混淆串;
172
+ * - 旧版明文 JSON。
173
+ *
174
+ * 返回 `null` 表示「这里没有配对数据」(比如是别的网址、或 URL 上没带 `t`)。
175
+ */
176
+ function pairingCodeFromText(text) {
177
+ if (!/^https?:\/\//i.test(text))
178
+ return text;
179
+ try {
180
+ const code = new URL(text).searchParams.get(PAIRING_URL_PARAM);
181
+ return code && code.trim() ? code.trim() : null;
182
+ }
183
+ catch {
184
+ return null;
185
+ }
36
186
  }
37
187
  /**
38
- * 解析二维码 / 配对串。无法识别返回 `null`(调用方给「不是 Virlen 配对码」的文案)。
188
+ * 解析二维码 / 配对串 / 配对链接。无法识别返回 `null`(调用方给「不是 Virlen 配对码」的文案)。
39
189
  *
40
190
  * 宽容点(都是真机上会遇到的东西):
191
+ * - 输入可以是 `vrp1:…` 串、旧版明文 JSON,或 `…?t=<配对数据>` 的 URL;
41
192
  * - `v` 缺省按 1 处理(旧码);
42
193
  * - `name` 缺省回退 `host`;
43
194
  * - `ticket` 允许是任意非空串(前缀只作可读性,不作校验 —— 电脑端的判定才是权威)。
@@ -45,9 +196,17 @@ export function encodePairingPayload(payload) {
45
196
  export function parsePairingPayload(text) {
46
197
  if (typeof text !== 'string' || !text.trim())
47
198
  return null;
199
+ // 先取出配对数据本体(URL → `?t=`;否则原样)
200
+ const code = pairingCodeFromText(text.trim());
201
+ if (code === null)
202
+ return null;
203
+ // 带前缀 → 先解混淆;否则按旧版明文 JSON 解析(向后兼容,见 PAIRING_OBFUSCATION_PREFIX)
204
+ const jsonText = code.startsWith(PAIRING_OBFUSCATION_PREFIX) ? decodeObfuscated(code) : code;
205
+ if (jsonText === null)
206
+ return null;
48
207
  let raw;
49
208
  try {
50
- raw = JSON.parse(text);
209
+ raw = JSON.parse(jsonText);
51
210
  }
52
211
  catch {
53
212
  return null;
@@ -1 +1 @@
1
- {"version":3,"file":"pairing.js","sourceRoot":"","sources":["../../src/protocol/pairing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AAEpC,sEAAsE;AACtE,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAA;AAExC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAA;AAgBlD,iDAAiD;AACjD,MAAM,UAAU,mBAAmB,CAAC,KAKnC;IACC,OAAO;QACL,CAAC,EAAE,uBAAuB;QAC1B,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAClD,CAAA;AACH,CAAC;AAED,iBAAiB;AACjB,MAAM,UAAU,oBAAoB,CAAC,OAAuB;IAC1D,OAAO,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;AAChC,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE;QAAE,OAAO,IAAI,CAAA;IACzD,IAAI,GAAY,CAAA;IAChB,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACxB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;IACD,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAA;IAChD,MAAM,GAAG,GAAG,GAA8B,CAAA;IAC1C,IAAI,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE;QAAE,OAAO,IAAI,CAAA;IACjE,IAAI,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE;QAAE,OAAO,IAAI,CAAA;IACrE,MAAM,OAAO,GAAmB;QAC9B,CAAC,EAAE,OAAO,GAAG,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACxC,IAAI,EAAE,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE;QACrB,IAAI,EAAE,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE;QACzF,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE;KAC1B,CAAA;IACD,IAAI,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ,IAAI,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE;QAAE,OAAO,CAAC,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAA;IAC3F,IAAI,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE;QAAE,OAAO,CAAC,IAAI,GAAG,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAA;IACnF,OAAO,OAAO,CAAA;AAChB,CAAC;AAED,2CAA2C;AAC3C,MAAM,UAAU,aAAa,CAAC,OAAuB;IACnD,OAAO,OAAO,CAAC,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;AAC9C,CAAC"}
1
+ {"version":3,"file":"pairing.js","sourceRoot":"","sources":["../../src/protocol/pairing.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virlen-remote",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Virlen 手机控制 · 协议栈共享包 —— 帧 / RPC / 错误 / 能力协商 / 设备身份与授权凭证 / 配对载荷 / ICE 解析 / Transport(WebRTC / 内存 / Broadcast)",
5
5
  "license": "MIT",
6
6
  "author": "WeiChens",
package/src/index.ts CHANGED
@@ -47,8 +47,12 @@ export type { DeviceKind, GrantRecord, CredentialRejectReason, RoomStatus } from
47
47
  export {
48
48
  PAIRING_PAYLOAD_VERSION,
49
49
  PAIRING_TICKET_TTL_MS,
50
+ PAIRING_OBFUSCATION_PREFIX,
51
+ PAIRING_URL_BASE,
52
+ PAIRING_URL_PARAM,
50
53
  buildPairingPayload,
51
54
  encodePairingPayload,
55
+ buildPairingUrl,
52
56
  parsePairingPayload,
53
57
  roomOfPayload,
54
58
  } from './protocol/pairing'
@@ -9,6 +9,19 @@
9
9
  * - `ticket`:**一次性**配对票据(不是长期凭证!兑换后电脑端签发 `grant` 回传,见 §30.3)。
10
10
  * - `signal`:信令基址;缺省 = 同源 Broadcast 联调(生产必有)。
11
11
  * - `room`:信令房间号;**缺省由 `roomFor(host)` 派生**。保留字段只为兼容旧码。
12
+ *
13
+ * **线上的码是混淆过的**(见 `PAIRING_OBFUSCATION_PREFIX`):`encodePairingPayload` 输出
14
+ * `vrp1:` + Base64URL(UTF-8(JSON) ⊕ 固定盐),使二维码文字 / 界面上的排查文本不再是明文 JSON;
15
+ * `parsePairingPayload` 仍**兼容旧明文码**(无前缀即按原 JSON 解析),已生成 / 已截图的旧码不会失效。
16
+ *
17
+ * **二维码内容是一条 URL**:`https://virlen.cn/mobile?t=<配对数据>`(见 `buildPairingUrl`)。
18
+ * 这样**系统相机 / 微信 / 任意浏览器**扫码都能直接打开手机端并自动配对,无需先装 App 再扫码;
19
+ * App 内的扫码器则用 `parsePairingPayload` 从 URL 里取回 `t`。`parsePairingPayload`
20
+ * **三种输入都吃**:URL、`vrp1:` 混淆串、旧版明文 JSON。
21
+ *
22
+ * ⚠️ 这是**混淆,不是加密**:本包是公开 npm 包、固定盐也在源码里,拿到源码即可解;
23
+ * 且它**挡不住有人对着屏幕拍照**(二维码本就是给人扫的)。真正的安全边界是
24
+ * 「一次性 ticket + 电脑端确认弹窗 + 授权凭证」(见 `identity.ts`)。
12
25
  */
13
26
  import { roomFor } from './identity'
14
27
 
@@ -23,6 +36,42 @@ export const PAIRING_PAYLOAD_VERSION = 2
23
36
  */
24
37
  export const PAIRING_TICKET_TTL_MS = 5 * 60 * 1000
25
38
 
39
+ /**
40
+ * 配对串混淆前缀。
41
+ *
42
+ * - **带此前缀**:内容是 `Base64URL(UTF-8(JSON) ⊕ 固定盐)`,走解码;
43
+ * - **不带**:当作旧版明文 JSON 解析(**向后兼容** —— 线上已生成 / 已截图的旧二维码、
44
+ * 以及老版本手抄的配对串仍能识别,升级后不会突然「无法识别」)。
45
+ *
46
+ * 前缀选 `vrp1:`(virlen pairing v1)—— 明文 JSON 必以 `{` 开头,不可能撞上前缀。
47
+ */
48
+ export const PAIRING_OBFUSCATION_PREFIX = 'vrp1:'
49
+
50
+ /**
51
+ * 配对二维码指向的手机端地址。
52
+ *
53
+ * 二维码内容 = `https://virlen.cn/mobile?t=<配对数据>`:**系统相机 / 微信 / 任意浏览器**扫到后
54
+ * 直接打开手机端,手机端从 `?t=` 取回配对数据即可自动配对 —— 无需先装 App 再扫码。
55
+ *
56
+ * ⚠️ 这是**部署地址**(手机端 PWA 在 `/mobile/`);自建 / 换域名时由调用方传入 `base` 覆盖。
57
+ */
58
+ export const PAIRING_URL_BASE = 'https://virlen.cn/mobile'
59
+
60
+ /** URL 里承载配对数据的查询参数名。 */
61
+ export const PAIRING_URL_PARAM = 't'
62
+
63
+ const textEncoder = new TextEncoder()
64
+ const textDecoder = new TextDecoder()
65
+
66
+ /**
67
+ * 混淆盐(UTF-8 字节)。
68
+ *
69
+ * ⚠️ **这不是密钥,是固定盐**:本包发布到公开 npm,字符串必然可见、拿到源码即可解。
70
+ * 它只解决「配对串在界面上 / 复制粘贴时不是一眼可读的 JSON」,不提供任何机密性 ——
71
+ * 真正的安全边界是「一次性 ticket + 电脑端确认弹窗 + 授权凭证」(见 `identity.ts`)。
72
+ */
73
+ const OBFUSCATION_SALT = textEncoder.encode('virlen/pairing/obfuscation/v1')
74
+
26
75
  export interface PairingPayload {
27
76
  v: number
28
77
  /** 电脑设备 key(也是房间号的来源)。 */
@@ -53,24 +102,134 @@ export function buildPairingPayload(input: {
53
102
  }
54
103
  }
55
104
 
56
- /** 序列化为二维码内容。 */
105
+ // ── 混淆编解码(零依赖:自带 Base64URL,不依赖 btoa / atob / Buffer —— 两端 + Node 环境一致)──
106
+
107
+ const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
108
+
109
+ /** Base64URL 反向查表(ASCII 0–127;其余一律视为非法字符)。 */
110
+ const B64_LOOKUP: Int16Array = (() => {
111
+ const table = new Int16Array(128).fill(-1)
112
+ for (let i = 0; i < B64_ALPHABET.length; i += 1) table[B64_ALPHABET.charCodeAt(i)] = i
113
+ return table
114
+ })()
115
+
116
+ /** 逐字节与盐做 XOR(盐循环使用)—— 可逆,用于混淆。 */
117
+ function xorSalt(bytes: Uint8Array): Uint8Array {
118
+ const out = new Uint8Array(bytes.length)
119
+ const salt = OBFUSCATION_SALT
120
+ for (let i = 0; i < bytes.length; i += 1) out[i] = bytes[i]! ^ salt[i % salt.length]!
121
+ return out
122
+ }
123
+
124
+ /** Base64URL 编码(**无填充**,`-` / `_` 代替 `+` / `/`)。 */
125
+ function base64UrlEncode(bytes: Uint8Array): string {
126
+ let out = ''
127
+ for (let i = 0; i < bytes.length; i += 3) {
128
+ const b0 = bytes[i]!
129
+ const b1 = i + 1 < bytes.length ? bytes[i + 1]! : undefined
130
+ const b2 = i + 2 < bytes.length ? bytes[i + 2]! : undefined
131
+ out += B64_ALPHABET[b0 >> 2]
132
+ out += B64_ALPHABET[((b0 & 0x03) << 4) | ((b1 ?? 0) >> 4)]
133
+ if (b1 === undefined) break
134
+ out += B64_ALPHABET[((b1 & 0x0f) << 2) | ((b2 ?? 0) >> 6)]
135
+ if (b2 === undefined) break
136
+ out += B64_ALPHABET[b2 & 0x3f]
137
+ }
138
+ return out
139
+ }
140
+
141
+ /** Base64URL 解码;含非法字符或长度非法(`len % 4 === 1`)时返回 `null`。 */
142
+ function base64UrlDecode(text: string): Uint8Array | null {
143
+ if (!text) return null
144
+ const values: number[] = []
145
+ for (const ch of text) {
146
+ const code = ch.charCodeAt(0)
147
+ const v = code < 128 ? B64_LOOKUP[code]! : -1
148
+ if (v < 0) return null
149
+ values.push(v)
150
+ }
151
+ if (values.length % 4 === 1) return null
152
+ const out = new Uint8Array((values.length * 6) >> 3)
153
+ let o = 0
154
+ for (let i = 0; i < values.length; i += 4) {
155
+ const c0 = values[i]!
156
+ const c1 = values[i + 1]
157
+ const c2 = values[i + 2]
158
+ const c3 = values[i + 3]
159
+ out[o++] = (c0 << 2) | ((c1 ?? 0) >> 4)
160
+ if (c2 === undefined) break
161
+ out[o++] = (((c1 ?? 0) & 0x0f) << 4) | (c2 >> 2)
162
+ if (c3 === undefined) break
163
+ out[o++] = ((c2 & 0x03) << 6) | c3
164
+ }
165
+ return out.subarray(0, o)
166
+ }
167
+
168
+ /** 把 `vrp1:` 串还原成 JSON 文本;任何异常(非法字符 / 长度 / 解码失败)都返回 `null`。 */
169
+ function decodeObfuscated(text: string): string | null {
170
+ const bytes = base64UrlDecode(text.slice(PAIRING_OBFUSCATION_PREFIX.length))
171
+ if (!bytes) return null
172
+ return textDecoder.decode(xorSalt(bytes))
173
+ }
174
+
175
+ /** 序列化为二维码内容(**混淆后的串**,不再是明文 JSON)。 */
57
176
  export function encodePairingPayload(payload: PairingPayload): string {
58
- return JSON.stringify(payload)
177
+ const json = textEncoder.encode(JSON.stringify(payload))
178
+ return PAIRING_OBFUSCATION_PREFIX + base64UrlEncode(xorSalt(json))
179
+ }
180
+
181
+ /**
182
+ * 构造成「可被系统相机 / 微信扫开」的配对链接(二维码内容)。
183
+ *
184
+ * @param payload 配对载荷
185
+ * @param base 手机端地址(缺省 {@link PAIRING_URL_BASE})
186
+ */
187
+ export function buildPairingUrl(payload: PairingPayload, base: string = PAIRING_URL_BASE): string {
188
+ const url = new URL(base)
189
+ url.searchParams.set(PAIRING_URL_PARAM, encodePairingPayload(payload))
190
+ return url.toString()
191
+ }
192
+
193
+ /**
194
+ * 从「扫到 / 粘进来的文本」里取出配对数据本体。
195
+ *
196
+ * 三种输入都吃:
197
+ * - **URL**(`https://virlen.cn/mobile?t=<配对数据>`)—— 系统相机 / 微信扫码打开的就是它;
198
+ * - `vrp1:…` 混淆串;
199
+ * - 旧版明文 JSON。
200
+ *
201
+ * 返回 `null` 表示「这里没有配对数据」(比如是别的网址、或 URL 上没带 `t`)。
202
+ */
203
+ function pairingCodeFromText(text: string): string | null {
204
+ if (!/^https?:\/\//i.test(text)) return text
205
+ try {
206
+ const code = new URL(text).searchParams.get(PAIRING_URL_PARAM)
207
+ return code && code.trim() ? code.trim() : null
208
+ } catch {
209
+ return null
210
+ }
59
211
  }
60
212
 
61
213
  /**
62
- * 解析二维码 / 配对串。无法识别返回 `null`(调用方给「不是 Virlen 配对码」的文案)。
214
+ * 解析二维码 / 配对串 / 配对链接。无法识别返回 `null`(调用方给「不是 Virlen 配对码」的文案)。
63
215
  *
64
216
  * 宽容点(都是真机上会遇到的东西):
217
+ * - 输入可以是 `vrp1:…` 串、旧版明文 JSON,或 `…?t=<配对数据>` 的 URL;
65
218
  * - `v` 缺省按 1 处理(旧码);
66
219
  * - `name` 缺省回退 `host`;
67
220
  * - `ticket` 允许是任意非空串(前缀只作可读性,不作校验 —— 电脑端的判定才是权威)。
68
221
  */
69
222
  export function parsePairingPayload(text: string): PairingPayload | null {
70
223
  if (typeof text !== 'string' || !text.trim()) return null
224
+ // 先取出配对数据本体(URL → `?t=`;否则原样)
225
+ const code = pairingCodeFromText(text.trim())
226
+ if (code === null) return null
227
+ // 带前缀 → 先解混淆;否则按旧版明文 JSON 解析(向后兼容,见 PAIRING_OBFUSCATION_PREFIX)
228
+ const jsonText = code.startsWith(PAIRING_OBFUSCATION_PREFIX) ? decodeObfuscated(code) : code
229
+ if (jsonText === null) return null
71
230
  let raw: unknown
72
231
  try {
73
- raw = JSON.parse(text)
232
+ raw = JSON.parse(jsonText)
74
233
  } catch {
75
234
  return null
76
235
  }