virlen-remote 0.1.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 +40 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol/answer.d.ts +48 -0
- package/dist/protocol/answer.d.ts.map +1 -0
- package/dist/protocol/answer.js +65 -0
- package/dist/protocol/answer.js.map +1 -0
- package/dist/protocol/api.d.ts +576 -0
- package/dist/protocol/api.d.ts.map +1 -0
- package/dist/protocol/api.js +38 -0
- package/dist/protocol/api.js.map +1 -0
- package/dist/protocol/endpoint.d.ts +76 -0
- package/dist/protocol/endpoint.d.ts.map +1 -0
- package/dist/protocol/endpoint.js +307 -0
- package/dist/protocol/endpoint.js.map +1 -0
- package/dist/protocol/errors.d.ts +32 -0
- package/dist/protocol/errors.d.ts.map +1 -0
- package/dist/protocol/errors.js +56 -0
- package/dist/protocol/errors.js.map +1 -0
- package/dist/protocol/frame.d.ts +51 -0
- package/dist/protocol/frame.d.ts.map +1 -0
- package/dist/protocol/frame.js +154 -0
- package/dist/protocol/frame.js.map +1 -0
- package/dist/protocol/hello.d.ts +73 -0
- package/dist/protocol/hello.d.ts.map +1 -0
- package/dist/protocol/hello.js +37 -0
- package/dist/protocol/hello.js.map +1 -0
- package/dist/protocol/host.d.ts +116 -0
- package/dist/protocol/host.d.ts.map +1 -0
- package/dist/protocol/host.js +52 -0
- package/dist/protocol/host.js.map +1 -0
- package/dist/protocol/identity.d.ts +107 -0
- package/dist/protocol/identity.d.ts.map +1 -0
- package/dist/protocol/identity.js +143 -0
- package/dist/protocol/identity.js.map +1 -0
- package/dist/protocol/ids.d.ts +2 -0
- package/dist/protocol/ids.d.ts.map +1 -0
- package/dist/protocol/ids.js +20 -0
- package/dist/protocol/ids.js.map +1 -0
- package/dist/protocol/pairing.d.ts +43 -0
- package/dist/protocol/pairing.d.ts.map +1 -0
- package/dist/protocol/pairing.js +78 -0
- package/dist/protocol/pairing.js.map +1 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/mock-host.d.ts +66 -0
- package/dist/testing/mock-host.d.ts.map +1 -0
- package/dist/testing/mock-host.js +573 -0
- package/dist/testing/mock-host.js.map +1 -0
- package/dist/transport/broadcast.d.ts +26 -0
- package/dist/transport/broadcast.d.ts.map +1 -0
- package/dist/transport/broadcast.js +71 -0
- package/dist/transport/broadcast.js.map +1 -0
- package/dist/transport/ice.d.ts +130 -0
- package/dist/transport/ice.d.ts.map +1 -0
- package/dist/transport/ice.js +338 -0
- package/dist/transport/ice.js.map +1 -0
- package/dist/transport/memory.d.ts +52 -0
- package/dist/transport/memory.d.ts.map +1 -0
- package/dist/transport/memory.js +135 -0
- package/dist/transport/memory.js.map +1 -0
- package/dist/transport/rtc.d.ts +76 -0
- package/dist/transport/rtc.d.ts.map +1 -0
- package/dist/transport/rtc.js +310 -0
- package/dist/transport/rtc.js.map +1 -0
- package/dist/transport/signaling.d.ts +101 -0
- package/dist/transport/signaling.d.ts.map +1 -0
- package/dist/transport/signaling.js +235 -0
- package/dist/transport/signaling.js.map +1 -0
- package/dist/transport/types.d.ts +41 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +11 -0
- package/dist/transport/types.js.map +1 -0
- package/package.json +69 -0
- package/src/index.ts +155 -0
- package/src/protocol/answer.ts +89 -0
- package/src/protocol/api.ts +517 -0
- package/src/protocol/endpoint.ts +405 -0
- package/src/protocol/errors.ts +90 -0
- package/src/protocol/frame.ts +196 -0
- package/src/protocol/hello.ts +112 -0
- package/src/protocol/host.ts +150 -0
- package/src/protocol/identity.ts +184 -0
- package/src/protocol/ids.ts +20 -0
- package/src/protocol/pairing.ts +95 -0
- package/src/testing/index.ts +5 -0
- package/src/testing/mock-host.ts +691 -0
- package/src/transport/broadcast.ts +81 -0
- package/src/transport/ice.ts +402 -0
- package/src/transport/memory.ts +164 -0
- package/src/transport/rtc.ts +344 -0
- package/src/transport/signaling.ts +317 -0
- package/src/transport/types.ts +50 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 版本与能力协商(见 docs/phone-control-bridge.md §3.5)。
|
|
3
|
+
*
|
|
4
|
+
* 规则:
|
|
5
|
+
* - 双方各自报能力集,**取交集**驱动 UI 显隐;
|
|
6
|
+
* - 主版本不匹配 → 拒绝并提示升级(**不要试图兼容**);
|
|
7
|
+
* - 新方法只增不改;破坏性变更才升 major。
|
|
8
|
+
*
|
|
9
|
+
* 兼容性靠 **`E_UNSUPPORTED` 错误**而不是版本号 if-else —— 老端调新方法时明确报错,UI 隐藏该功能。
|
|
10
|
+
*/
|
|
11
|
+
import { BridgeError } from './errors'
|
|
12
|
+
import type { StreamMode } from './api'
|
|
13
|
+
import type { GrantRecord } from './identity'
|
|
14
|
+
|
|
15
|
+
export interface ClientInfo {
|
|
16
|
+
platform: string
|
|
17
|
+
appVersion: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 发起方(手机)hello 参数。 */
|
|
21
|
+
export interface HelloParams {
|
|
22
|
+
protocolVersion: number
|
|
23
|
+
client: ClientInfo
|
|
24
|
+
capabilities: string[]
|
|
25
|
+
/**
|
|
26
|
+
* 配对令牌。
|
|
27
|
+
*
|
|
28
|
+
* - **首次配对**:二维码里的**一次性票据**(兑换后电脑端签发 `grant` 回传,见 §30.3);
|
|
29
|
+
* - **已配对设备**:本地存储的**授权凭证**(`grant`,电脑端滑动续期)。
|
|
30
|
+
*/
|
|
31
|
+
token?: string
|
|
32
|
+
/**
|
|
33
|
+
* 手机设备 key(`mk-…`,M6 新增,**可选**)。
|
|
34
|
+
*
|
|
35
|
+
* 为什么可选:已装机的旧版 PWA 不会带它。电脑端把它当作「临时设备」放行(凭证仍要有效),
|
|
36
|
+
* 等手机端更新后再回填绑定(§30.5 的渐进迁移)。
|
|
37
|
+
*/
|
|
38
|
+
mobileKey?: string
|
|
39
|
+
/** 手机显示名(电脑端列表里显示;缺省由电脑端给「Virlen 手机」)。 */
|
|
40
|
+
mobileName?: string
|
|
41
|
+
/**
|
|
42
|
+
* 流式正文的接收偏好(§32,可选)。
|
|
43
|
+
*
|
|
44
|
+
* - 不传 / `'full'`:每帧发整段正文(旧行为,带宽 O(n²));
|
|
45
|
+
* - `'delta'`:只发新增后缀(带宽 O(n))—— 客户端必须能按 `offset` 重基准。
|
|
46
|
+
*
|
|
47
|
+
* 为何用参数而不是能力标记:`capabilities` 是「我会什么」的集合,
|
|
48
|
+
* 而这里要的是「我这次要什么」—— 且 `stream.delta` 这个能力名在 M3 就被两端写进能力表
|
|
49
|
+
* 却始终没实现,已部署的旧客户端会「声明了但不会处理」。用一个**明确的一次性声明**
|
|
50
|
+
* 才能同时满足:老客户端不发它(继续收整帧)、新客户端发了才收到增量。
|
|
51
|
+
*/
|
|
52
|
+
streamMode?: StreamMode
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 应答方(电脑)hello 结果。 */
|
|
56
|
+
export interface HelloResult {
|
|
57
|
+
protocolVersion: number
|
|
58
|
+
host: ClientInfo
|
|
59
|
+
capabilities: string[]
|
|
60
|
+
paired: boolean
|
|
61
|
+
deviceName: string
|
|
62
|
+
/** 电脑设备 key,手机据以保存设备记录(同时也是房间号的来源)。 */
|
|
63
|
+
deviceId?: string
|
|
64
|
+
/**
|
|
65
|
+
* 当前有效的授权凭证(**含首次配对新签发的那条**)。
|
|
66
|
+
*
|
|
67
|
+
* 手机端必须用它覆盖本地记录:首次配对时手机手上只有一次性票据,真正的长期凭证在这里;
|
|
68
|
+
* 已配对设备每次连接也会拿到(滑动续期后的到期时间)。
|
|
69
|
+
*/
|
|
70
|
+
grant?: GrantRecord
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface NegotiationInput {
|
|
74
|
+
protocolVersion: number
|
|
75
|
+
capabilities: readonly string[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface Negotiated {
|
|
79
|
+
protocolVersion: number
|
|
80
|
+
/** 双方能力的交集(有序、去重,保持 local 顺序)。 */
|
|
81
|
+
capabilities: string[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 取交集:结果保持 `local` 的顺序、去重。 */
|
|
85
|
+
export function intersectCapabilities(local: readonly string[], remote: readonly string[]): string[] {
|
|
86
|
+
const remoteSet = new Set(remote)
|
|
87
|
+
const seen = new Set<string>()
|
|
88
|
+
const result: string[] = []
|
|
89
|
+
for (const cap of local) {
|
|
90
|
+
if (remoteSet.has(cap) && !seen.has(cap)) {
|
|
91
|
+
seen.add(cap)
|
|
92
|
+
result.push(cap)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 协商。主版本不匹配抛 `E_UNSUPPORTED`(不可重试)——调用方应提示「请升级客户端」。
|
|
100
|
+
*/
|
|
101
|
+
export function negotiate(local: NegotiationInput, remote: NegotiationInput): Negotiated {
|
|
102
|
+
if (local.protocolVersion !== remote.protocolVersion) {
|
|
103
|
+
throw new BridgeError(
|
|
104
|
+
'E_UNSUPPORTED',
|
|
105
|
+
`protocol version mismatch: local=${local.protocolVersion}, remote=${remote.protocolVersion}`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
protocolVersion: local.protocolVersion,
|
|
110
|
+
capabilities: intersectCapabilities(local.capabilities, remote.capabilities),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host 侧通用胶水(电脑端「接口层」的与传输无关的部分)。
|
|
3
|
+
*
|
|
4
|
+
* 设计:**把「方法分发」与「数据来源」解耦**,用 `HostDataSource` 端口隔离。
|
|
5
|
+
* 于是同一份 bridge 代码有两个数据源实现:
|
|
6
|
+
* - 真实:`virlen-app/src/bridge/`,接 `sessionStore` / `chat-service`(含 ACL / 审计)
|
|
7
|
+
* - 演示/测试:mock 数据源(`virlen-remote/testing`)
|
|
8
|
+
* 这样 M2 的浏览器 harness(mock)与 M3 的真实桌面端(真实源)复用同一分发逻辑,
|
|
9
|
+
* 且 mock 让「不依赖 WebRTC 的端到端联调」成为可能。
|
|
10
|
+
*
|
|
11
|
+
* 事件用 `HostEmit` 回填:host 侧(store-bridge / mock)通过它把 `HostEvents` 推给手机。
|
|
12
|
+
*/
|
|
13
|
+
import type { Endpoint } from './endpoint'
|
|
14
|
+
import type {
|
|
15
|
+
AnswerParams,
|
|
16
|
+
AnswerResult,
|
|
17
|
+
CompressParams,
|
|
18
|
+
ContextInfoDTO,
|
|
19
|
+
ContextParams,
|
|
20
|
+
CreateSessionParams,
|
|
21
|
+
DeleteSessionParams,
|
|
22
|
+
HostEvents,
|
|
23
|
+
InteractionDTO,
|
|
24
|
+
MessageDTO,
|
|
25
|
+
ModelProviderDTO,
|
|
26
|
+
MsgPageDTO,
|
|
27
|
+
MsgPageParams,
|
|
28
|
+
PinSessionParams,
|
|
29
|
+
RenameSessionParams,
|
|
30
|
+
SendParams,
|
|
31
|
+
SessionSummaryDTO,
|
|
32
|
+
SetModelParams,
|
|
33
|
+
WorkspaceOptionDTO,
|
|
34
|
+
} from './api'
|
|
35
|
+
import type { ClientInfo, HelloParams, HelloResult } from './hello'
|
|
36
|
+
|
|
37
|
+
/** 电脑端接口层的数据来源(返回已投影好的 DTO,投影逻辑在实现侧)。 */
|
|
38
|
+
export interface HostDataSource {
|
|
39
|
+
listSessions(): SessionSummaryDTO[] | Promise<SessionSummaryDTO[]>
|
|
40
|
+
getMessages(params: MsgPageParams): MsgPageDTO | Promise<MsgPageDTO>
|
|
41
|
+
getMessage(params: { sessionId: string; messageId: string }): MessageDTO | Promise<MessageDTO>
|
|
42
|
+
send(params: SendParams): { messageId: string } | Promise<{ messageId: string }>
|
|
43
|
+
cancel(params: { sessionId: string }): { ok: true } | Promise<{ ok: true }>
|
|
44
|
+
/** 从暂停的 run 快照恢复执行(M5,与手机端「暂存」配对)。 */
|
|
45
|
+
resume(params: { sessionId: string }): { ok: true } | Promise<{ ok: true }>
|
|
46
|
+
subscribe(params: { sessionId: string; fromRowid?: number }): { ok: true } | Promise<{ ok: true }>
|
|
47
|
+
answer(params: AnswerParams): AnswerResult | Promise<AnswerResult>
|
|
48
|
+
/** 当前待应答交互(拉取式;手机每次链路就绪后调一次,补齐错过的 `requested` 事件)。 */
|
|
49
|
+
listInteractions(): InteractionDTO[] | Promise<InteractionDTO[]>
|
|
50
|
+
// ── M4 写操作(§16.1)──
|
|
51
|
+
createSession(params: CreateSessionParams): { sessionId: string } | Promise<{ sessionId: string }>
|
|
52
|
+
renameSession(params: RenameSessionParams): { ok: true } | Promise<{ ok: true }>
|
|
53
|
+
setPinned(params: PinSessionParams): { ok: true } | Promise<{ ok: true }>
|
|
54
|
+
/** ⚠️ 不可逆;实现侧必须校验 `confirm === true`(不得依赖手机 UI)。 */
|
|
55
|
+
deleteSession(params: DeleteSessionParams): { ok: true } | Promise<{ ok: true }>
|
|
56
|
+
/** 可选:覆盖默认 hello 应答。 */
|
|
57
|
+
hello?(params: HelloParams): HelloResult | Promise<HelloResult>
|
|
58
|
+
// ── §22:模型 / 工作目录 / 上下文 ──
|
|
59
|
+
/** 已启用的模型服务与模型(白名单:不含 apiKey / baseUrl)。 */
|
|
60
|
+
listModels(): ModelProviderDTO[] | Promise<ModelProviderDTO[]>
|
|
61
|
+
/** 切换会话模型;实现侧必须校验「服务已启用且模型存在」。 */
|
|
62
|
+
setModel(params: SetModelParams): { ok: true } | Promise<{ ok: true }>
|
|
63
|
+
/** 新建会话可选的工作目录候选集(**只用于新建**,已有会话不可改)。 */
|
|
64
|
+
listWorkspaces(): WorkspaceOptionDTO[] | Promise<WorkspaceOptionDTO[]>
|
|
65
|
+
/** 上下文占用快照(口径与桌面 token 环一致)。 */
|
|
66
|
+
getContext(params: ContextParams): ContextInfoDTO | Promise<ContextInfoDTO>
|
|
67
|
+
/**
|
|
68
|
+
* 压缩上下文(fire-and-forget)。
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ 实现侧必须校验 `confirm === true`(不得依赖手机 UI),并自行保证「正在回复 / 正在压缩」不被并发触发。
|
|
71
|
+
*/
|
|
72
|
+
compress(params: CompressParams): { ok: true } | Promise<{ ok: true }>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type HostEmit = <E extends keyof HostEvents & string>(topic: E, payload: HostEvents[E]) => void
|
|
76
|
+
|
|
77
|
+
export interface HostRegistration {
|
|
78
|
+
/** 把一条 `HostEvents` 推给对端(手机)。 */
|
|
79
|
+
emit: HostEmit
|
|
80
|
+
/** 注销全部 handler 与订阅。 */
|
|
81
|
+
dispose(): void
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface RegisterHostOptions {
|
|
85
|
+
hostInfo?: ClientInfo
|
|
86
|
+
/** 本机声明的能力集(会与手机 hello 的能力取交集,见 hello.ts)。 */
|
|
87
|
+
capabilities?: string[]
|
|
88
|
+
paired?: boolean
|
|
89
|
+
deviceName?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const DEFAULT_CAPABILITIES = ['session.list', 'session.send', 'interaction.answer', 'stream.delta']
|
|
93
|
+
|
|
94
|
+
export function registerHostHandlers(
|
|
95
|
+
endpoint: Endpoint,
|
|
96
|
+
source: HostDataSource,
|
|
97
|
+
options: RegisterHostOptions = {},
|
|
98
|
+
): HostRegistration {
|
|
99
|
+
const disposers: Array<() => void> = []
|
|
100
|
+
|
|
101
|
+
const emit: HostEmit = (topic, payload) => {
|
|
102
|
+
endpoint.emit(topic, payload)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const on = (method: string, handler: (params: unknown) => unknown | Promise<unknown>) => {
|
|
106
|
+
disposers.push(endpoint.handle(method, handler))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
on('host.hello', async (params) => {
|
|
110
|
+
if (source.hello) return source.hello(params as HelloParams)
|
|
111
|
+
const hello = params as Partial<HelloParams>
|
|
112
|
+
return {
|
|
113
|
+
protocolVersion: hello.protocolVersion ?? 1,
|
|
114
|
+
host: options.hostInfo ?? { platform: 'web-harness', appVersion: '0.0.0' },
|
|
115
|
+
capabilities: options.capabilities ?? DEFAULT_CAPABILITIES,
|
|
116
|
+
paired: options.paired ?? true,
|
|
117
|
+
deviceName: options.deviceName ?? 'Virlen 电脑(演示)',
|
|
118
|
+
} satisfies HelloResult
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
on('host.session.list', async () => ({ sessions: await source.listSessions() }))
|
|
122
|
+
on('host.session.messages', async (params) => source.getMessages(params as MsgPageParams))
|
|
123
|
+
on('host.session.message.get', async (params) => {
|
|
124
|
+
const p = params as { sessionId: string; messageId: string }
|
|
125
|
+
return { message: await source.getMessage(p) }
|
|
126
|
+
})
|
|
127
|
+
on('host.session.send', async (params) => source.send(params as SendParams))
|
|
128
|
+
on('host.session.cancel', async (params) => source.cancel(params as { sessionId: string }))
|
|
129
|
+
on('host.session.resume', async (params) => source.resume(params as { sessionId: string }))
|
|
130
|
+
on('host.session.subscribe', async (params) => source.subscribe(params as { sessionId: string; fromRowid?: number }))
|
|
131
|
+
on('host.session.create', async (params) => source.createSession(params as CreateSessionParams))
|
|
132
|
+
on('host.session.rename', async (params) => source.renameSession(params as RenameSessionParams))
|
|
133
|
+
on('host.session.pin', async (params) => source.setPinned(params as PinSessionParams))
|
|
134
|
+
on('host.session.delete', async (params) => source.deleteSession(params as DeleteSessionParams))
|
|
135
|
+
on('host.interaction.answer', async (params) => source.answer(params as AnswerParams))
|
|
136
|
+
on('host.interaction.list', async () => ({ interactions: await source.listInteractions() }))
|
|
137
|
+
// ── §22:模型 / 工作目录 / 上下文 ──
|
|
138
|
+
on('host.model.list', async () => ({ providers: await source.listModels() }))
|
|
139
|
+
on('host.session.setModel', async (params) => source.setModel(params as SetModelParams))
|
|
140
|
+
on('host.workspace.list', async () => ({ workspaces: await source.listWorkspaces() }))
|
|
141
|
+
on('host.session.context', async (params) => source.getContext(params as ContextParams))
|
|
142
|
+
on('host.session.compress', async (params) => source.compress(params as CompressParams))
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
emit,
|
|
146
|
+
dispose() {
|
|
147
|
+
for (const dispose of disposers) dispose()
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设备身份与授权凭证(M6,见 docs/phone-control-bridge.md §30)。
|
|
3
|
+
*
|
|
4
|
+
* 一句话:**设备 key 是「谁」,授权凭证是「凭什么是它」。**
|
|
5
|
+
*
|
|
6
|
+
* - **设备 key**:首次运行生成一次,之后**持久化不变**(手机写 localStorage,电脑写
|
|
7
|
+
* `<data_dir>/phone-device.json`)。它不是硬件指纹 —— 见 §30.1 的取舍说明:
|
|
8
|
+
* PWA 拿不到可靠的硬件指纹(浏览器升级 / 隐私模式 / 清数据即变,同型号还会撞),
|
|
9
|
+
* 所以唯一能同时满足「不重复」与「重新获取还是同一个」的做法就是随机生成 + 落盘。
|
|
10
|
+
* - **房间号**由电脑设备 key 派生(`roomFor`):于是「第二次连接」只需要拿电脑 key 去问
|
|
11
|
+
* 信令服务「这台电脑在不在线」,不必再扫码。
|
|
12
|
+
* - **授权凭证**(grant):由**电脑端**签发,绑定手机设备 key,默认 30 天有效、
|
|
13
|
+
* 每次成功连接**滑动续期**,但单次签发最长 `GRANT_MAX_LIFETIME_MS`(90 天)——
|
|
14
|
+
* 到顶后必须重新扫码授权(用户 2026-09-27 拍板)。
|
|
15
|
+
*
|
|
16
|
+
* 本模块**零依赖、两端同一份**:电脑端与手机端都必须用这里的常量与判定函数,
|
|
17
|
+
* 否则必然出现「一端认为有效、另一端认为过期」的分叉(§18.5 的教训)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** 电脑设备 key 前缀(desktop key)。 */
|
|
21
|
+
export const HOST_KEY_PREFIX = 'dk-'
|
|
22
|
+
/** 手机设备 key 前缀(mobile key)。 */
|
|
23
|
+
export const MOBILE_KEY_PREFIX = 'mk-'
|
|
24
|
+
/** 授权凭证前缀(grant)。 */
|
|
25
|
+
export const GRANT_PREFIX = 'gt-'
|
|
26
|
+
|
|
27
|
+
/** 授权凭证的单次续期时长(30 天)。 */
|
|
28
|
+
export const GRANT_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 单次签发的**最长寿命**(90 天,从 `issuedAt` 起算)。
|
|
32
|
+
*
|
|
33
|
+
* 为什么要有硬上限:滑动续期若没有上限,一个凭证就等于永久授权 —— 手机丢失/被借用时
|
|
34
|
+
* 电脑端的「移除」是唯一刹车,而用户未必想得起来去看那张列表。
|
|
35
|
+
*/
|
|
36
|
+
export const GRANT_MAX_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000
|
|
37
|
+
|
|
38
|
+
/** 房间名前缀(信令层)。与 M3 起的旧房间名同构:`virlen:<电脑标识>`。 */
|
|
39
|
+
export const ROOM_PREFIX = 'virlen:'
|
|
40
|
+
|
|
41
|
+
export type DeviceKind = 'host' | 'mobile'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 一条授权凭证(电脑侧是**真源**,手机侧存副本)。
|
|
45
|
+
*
|
|
46
|
+
* `token` 在续期时**保持不变**(只推后 `expiresAt`)—— 凭证字符串是「这次配对」的身份,
|
|
47
|
+
* 每次连接都换新串会让重连路径(内存里的 `lastOptions` / 本地存储的写入时序)出现
|
|
48
|
+
* 「用旧串连、被判无效」的窗口。要换串只有一种场景:**重新扫码**(新签发)。
|
|
49
|
+
*/
|
|
50
|
+
export interface GrantRecord {
|
|
51
|
+
token: string
|
|
52
|
+
/** 首次签发时刻(硬上限 `GRANT_MAX_LIFETIME_MS` 据此起算)。 */
|
|
53
|
+
issuedAt: number
|
|
54
|
+
/** 当前到期时刻(滑动续期会推后,但不超过 `issuedAt + 硬上限`)。 */
|
|
55
|
+
expiresAt: number
|
|
56
|
+
/** 最近一次成功连接时刻(电脑端列表展示用)。 */
|
|
57
|
+
lastSeenAt?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 凭证被拒的原因(线上随 `E_DENIED` 的 `data.reason` 回传,手机据此给不同文案)。 */
|
|
61
|
+
export type CredentialRejectReason = 'invalid' | 'expired' | 'revoked'
|
|
62
|
+
|
|
63
|
+
/** 房间在线状态(信令服务 `POST /status` 的应答元素)。 */
|
|
64
|
+
export interface RoomStatus {
|
|
65
|
+
room: string
|
|
66
|
+
/** 电脑端是否在线(有 host 角色的活跃连接)。 */
|
|
67
|
+
hostOnline: boolean
|
|
68
|
+
/** 是否已有手机占着 guest 位(第二台手机会顶掉它,见 §30.3)。 */
|
|
69
|
+
guestOnline: boolean
|
|
70
|
+
/** host / guest 的加入时刻(毫秒;不在线则缺省)。 */
|
|
71
|
+
hostSince?: number
|
|
72
|
+
guestSince?: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 生成 `prefix + 十六进制随机串`(默认 16 字节 = 32 hex)。 */
|
|
76
|
+
export function randomKey(prefix: string, bytes = 16): string {
|
|
77
|
+
const buf = new Uint8Array(bytes)
|
|
78
|
+
const c = (globalThis as { crypto?: Crypto }).crypto
|
|
79
|
+
if (c && typeof c.getRandomValues === 'function') {
|
|
80
|
+
c.getRandomValues(buf)
|
|
81
|
+
} else {
|
|
82
|
+
// 无 WebCrypto 的环境(老 Node / 极端降级):Math.random 兜底。
|
|
83
|
+
// ⚠️ 只用于「本地身份标识」,不是加密码 —— 真正的安全边界是授权凭证与电脑端确认弹窗。
|
|
84
|
+
for (let i = 0; i < buf.length; i += 1) buf[i] = Math.floor(Math.random() * 256)
|
|
85
|
+
}
|
|
86
|
+
let out = prefix
|
|
87
|
+
for (const b of buf) out += b.toString(16).padStart(2, '0')
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 生成一个设备 key(`host` → `dk-…`,`mobile` → `mk-…`)。 */
|
|
92
|
+
export function newDeviceKey(kind: DeviceKind): string {
|
|
93
|
+
return randomKey(kind === 'host' ? HOST_KEY_PREFIX : MOBILE_KEY_PREFIX)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 生成一个授权凭证串。 */
|
|
97
|
+
export function newGrantToken(): string {
|
|
98
|
+
return randomKey(GRANT_PREFIX, 24)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const KEY_RE = /^(dk|mk)-[0-9a-f]{8,64}$/
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 是否为合法设备 key。`kind` 不传则不校验前缀归属。
|
|
105
|
+
*
|
|
106
|
+
* 只用于**早期拒掉明显是垃圾的输入**(日志可读、列表可渲染),不是安全校验 ——
|
|
107
|
+
* 真正的授权判定在电脑端的凭证表。
|
|
108
|
+
*/
|
|
109
|
+
export function isDeviceKey(value: unknown, kind?: DeviceKind): value is string {
|
|
110
|
+
if (typeof value !== 'string' || !KEY_RE.test(value)) return false
|
|
111
|
+
if (!kind) return true
|
|
112
|
+
return value.startsWith(kind === 'host' ? HOST_KEY_PREFIX : MOBILE_KEY_PREFIX)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 电脑设备 key → 信令房间号。
|
|
117
|
+
*
|
|
118
|
+
* ⚠️ 兼容 M3 的旧房间名:旧 `hostId`(如 `host-ab12cd34`,无前缀)传进来同样得到
|
|
119
|
+
* `virlen:host-ab12cd34` —— 与旧实现逐字一致,于是**旧二维码仍然可用**(§30.6)。
|
|
120
|
+
*/
|
|
121
|
+
export function roomFor(hostKey: string): string {
|
|
122
|
+
return ROOM_PREFIX + hostKey
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 房间号 → 电脑设备 key(非本前缀派生时返回 `null`)。 */
|
|
126
|
+
export function hostKeyFromRoom(room: string): string | null {
|
|
127
|
+
if (typeof room !== 'string' || !room.startsWith(ROOM_PREFIX)) return null
|
|
128
|
+
const key = room.slice(ROOM_PREFIX.length)
|
|
129
|
+
return key ? key : null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 签发一条新凭证(`now` 可注入,便于测试)。 */
|
|
133
|
+
export function issueGrant(now: number = Date.now()): GrantRecord {
|
|
134
|
+
return {
|
|
135
|
+
token: newGrantToken(),
|
|
136
|
+
issuedAt: now,
|
|
137
|
+
expiresAt: now + GRANT_TTL_MS,
|
|
138
|
+
lastSeenAt: now,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 凭证是否已过期。 */
|
|
143
|
+
export function isGrantExpired(grant: GrantRecord, now: number = Date.now()): boolean {
|
|
144
|
+
return now >= grant.expiresAt
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 滑动续期:把到期时间推后到 `now + 30 天`,但**不超过** `issuedAt + 90 天`。
|
|
149
|
+
*
|
|
150
|
+
* 返回新对象(不原地改),调用方负责持久化 —— 「算」与「存」分开,便于测试与审计。
|
|
151
|
+
*/
|
|
152
|
+
export function renewGrant(grant: GrantRecord, now: number = Date.now()): GrantRecord {
|
|
153
|
+
const cap = grant.issuedAt + GRANT_MAX_LIFETIME_MS
|
|
154
|
+
return {
|
|
155
|
+
...grant,
|
|
156
|
+
expiresAt: Math.min(now + GRANT_TTL_MS, cap),
|
|
157
|
+
lastSeenAt: now,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 凭证可用性判定(**两端共用**)。
|
|
163
|
+
* 返回 `null` = 可用;否则是拒绝原因。
|
|
164
|
+
*/
|
|
165
|
+
export function checkGrant(
|
|
166
|
+
grant: GrantRecord | null | undefined,
|
|
167
|
+
now: number = Date.now(),
|
|
168
|
+
): CredentialRejectReason | null {
|
|
169
|
+
if (!grant || typeof grant.token !== 'string' || !grant.token) return 'invalid'
|
|
170
|
+
if (isGrantExpired(grant, now)) return 'expired'
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 剩余有效期的自然语言(两端 UI 共用同一套口径,避免「还剩 3 天」与「2.9 天」打架)。 */
|
|
175
|
+
export function describeGrantRemaining(grant: { expiresAt: number }, now: number = Date.now()): string {
|
|
176
|
+
const left = grant.expiresAt - now
|
|
177
|
+
if (left <= 0) return '已过期'
|
|
178
|
+
const days = Math.floor(left / (24 * 60 * 60 * 1000))
|
|
179
|
+
if (days >= 1) return `剩余 ${days} 天`
|
|
180
|
+
const hours = Math.floor(left / (60 * 60 * 1000))
|
|
181
|
+
if (hours >= 1) return `剩余 ${hours} 小时`
|
|
182
|
+
const minutes = Math.max(1, Math.floor(left / 60000))
|
|
183
|
+
return `剩余 ${minutes} 分钟`
|
|
184
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* requestId 生成 —— 零依赖。
|
|
3
|
+
*
|
|
4
|
+
* 用于 RPC 幂等(见 docs/phone-control-bridge.md §3.2):手机网络会闪断,重连后必然重发,
|
|
5
|
+
* 电脑侧靠 requestId 去重、**返回上次结果而非重放**。
|
|
6
|
+
*
|
|
7
|
+
* 优先用 `crypto.randomUUID()`(浏览器安全上下文 / Node ≥ 19 均可用);
|
|
8
|
+
* 退化为「时间戳 + 单调计数 + 随机」——**不用于安全用途**,仅需高概率唯一。
|
|
9
|
+
*/
|
|
10
|
+
let fallbackCounter = 0
|
|
11
|
+
|
|
12
|
+
export function newRequestId(): string {
|
|
13
|
+
const c = (globalThis as { crypto?: Crypto }).crypto
|
|
14
|
+
if (c && typeof c.randomUUID === 'function') {
|
|
15
|
+
return c.randomUUID()
|
|
16
|
+
}
|
|
17
|
+
fallbackCounter = (fallbackCounter + 1) >>> 0
|
|
18
|
+
const rand = Math.random().toString(36).slice(2, 10)
|
|
19
|
+
return `${Date.now().toString(36)}-${fallbackCounter.toString(36)}-${rand}`
|
|
20
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配对载荷(二维码 / 配对串的内容)—— **两端同一份**(M6,见 docs/phone-control-bridge.md §30.2)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么搬到共享包:此前电脑端 `pairingPayload()` 与手机端 `parsePayload()` 各写一份 interface,
|
|
5
|
+
* 字段含义靠注释对齐 —— 这类「契约写两份」的分叉只会在真机上暴露(§26/§29 都是这个形状的缺陷)。
|
|
6
|
+
*
|
|
7
|
+
* 字段:
|
|
8
|
+
* - `host`:**电脑设备 key**(`dk-…`;M3 的旧码是 `host-…`,同样能解析)。
|
|
9
|
+
* - `ticket`:**一次性**配对票据(不是长期凭证!兑换后电脑端签发 `grant` 回传,见 §30.3)。
|
|
10
|
+
* - `signal`:信令基址;缺省 = 同源 Broadcast 联调(生产必有)。
|
|
11
|
+
* - `room`:信令房间号;**缺省由 `roomFor(host)` 派生**。保留字段只为兼容旧码。
|
|
12
|
+
*/
|
|
13
|
+
import { roomFor } from './identity'
|
|
14
|
+
|
|
15
|
+
/** 当前载荷版本。v1 与 v2 形状相同(差异在语义:v2 起 `host` 是设备 key、`ticket` 是一次性票据)。 */
|
|
16
|
+
export const PAIRING_PAYLOAD_VERSION = 2
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 配对票据有效期(默认 5 分钟)。
|
|
20
|
+
*
|
|
21
|
+
* 用户反馈「扫码后提示二维码失效」时的值曾是 2 分钟;现在 **5 分钟 + 面板打开即重生成 +
|
|
22
|
+
* 到期自动重生成**(见 `phoneControlStore`)三管齐下,真机上基本不可能再扫到过期码。
|
|
23
|
+
*/
|
|
24
|
+
export const PAIRING_TICKET_TTL_MS = 5 * 60 * 1000
|
|
25
|
+
|
|
26
|
+
export interface PairingPayload {
|
|
27
|
+
v: number
|
|
28
|
+
/** 电脑设备 key(也是房间号的来源)。 */
|
|
29
|
+
host: string
|
|
30
|
+
/** 电脑显示名(手机列表里显示)。 */
|
|
31
|
+
name: string
|
|
32
|
+
/** 一次性配对票据。 */
|
|
33
|
+
ticket: string
|
|
34
|
+
/** 信令基址(如 `https://virlen.cn/api/rtc/`)。 */
|
|
35
|
+
signal?: string
|
|
36
|
+
/** 信令房间号(缺省 `roomFor(host)`;旧码里带着它)。 */
|
|
37
|
+
room?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 构造载荷(只填必要字段;`room` 交给两端各自派生,避免又多一处可漂移的冗余)。 */
|
|
41
|
+
export function buildPairingPayload(input: {
|
|
42
|
+
host: string
|
|
43
|
+
name: string
|
|
44
|
+
ticket: string
|
|
45
|
+
signal?: string
|
|
46
|
+
}): PairingPayload {
|
|
47
|
+
return {
|
|
48
|
+
v: PAIRING_PAYLOAD_VERSION,
|
|
49
|
+
host: input.host,
|
|
50
|
+
name: input.name,
|
|
51
|
+
ticket: input.ticket,
|
|
52
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 序列化为二维码内容。 */
|
|
57
|
+
export function encodePairingPayload(payload: PairingPayload): string {
|
|
58
|
+
return JSON.stringify(payload)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 解析二维码 / 配对串。无法识别返回 `null`(调用方给「不是 Virlen 配对码」的文案)。
|
|
63
|
+
*
|
|
64
|
+
* 宽容点(都是真机上会遇到的东西):
|
|
65
|
+
* - `v` 缺省按 1 处理(旧码);
|
|
66
|
+
* - `name` 缺省回退 `host`;
|
|
67
|
+
* - `ticket` 允许是任意非空串(前缀只作可读性,不作校验 —— 电脑端的判定才是权威)。
|
|
68
|
+
*/
|
|
69
|
+
export function parsePairingPayload(text: string): PairingPayload | null {
|
|
70
|
+
if (typeof text !== 'string' || !text.trim()) return null
|
|
71
|
+
let raw: unknown
|
|
72
|
+
try {
|
|
73
|
+
raw = JSON.parse(text)
|
|
74
|
+
} catch {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
if (!raw || typeof raw !== 'object') return null
|
|
78
|
+
const obj = raw as Partial<PairingPayload>
|
|
79
|
+
if (typeof obj.host !== 'string' || !obj.host.trim()) return null
|
|
80
|
+
if (typeof obj.ticket !== 'string' || !obj.ticket.trim()) return null
|
|
81
|
+
const payload: PairingPayload = {
|
|
82
|
+
v: typeof obj.v === 'number' ? obj.v : 1,
|
|
83
|
+
host: obj.host.trim(),
|
|
84
|
+
name: typeof obj.name === 'string' && obj.name.trim() ? obj.name.trim() : obj.host.trim(),
|
|
85
|
+
ticket: obj.ticket.trim(),
|
|
86
|
+
}
|
|
87
|
+
if (typeof obj.signal === 'string' && obj.signal.trim()) payload.signal = obj.signal.trim()
|
|
88
|
+
if (typeof obj.room === 'string' && obj.room.trim()) payload.room = obj.room.trim()
|
|
89
|
+
return payload
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 载荷 → 房间号(显式 `room` 优先,否则由电脑 key 派生)。 */
|
|
93
|
+
export function roomOfPayload(payload: PairingPayload): string {
|
|
94
|
+
return payload.room ?? roomFor(payload.host)
|
|
95
|
+
}
|