dsh-plugin-mobile-link 0.4.1 → 0.6.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/README.md CHANGED
@@ -88,7 +88,8 @@ dsh plugin --profile web add ./plugins/mobile-link
88
88
  dsh-mobile-link --mint-pair-code # prints JSON: code + relay + agentId
89
89
  ```
90
90
 
91
- Or open `/mobile-link/qr` on the local DSH (behind DSH's own authentication).
91
+ Or open the **移动端连接** panel at the bottom of the sidebar in the DSH client
92
+ (spec 010) — same code, same QR, with the validity countdown.
92
93
 
93
94
  3. Scan it in the iOS app. After that the phone reconnects on its own — nothing
94
95
  else has to be run by hand.
package/cordis.patch.yml CHANGED
@@ -34,3 +34,26 @@
34
34
  enabled: true
35
35
  heartbeatMs: 20000
36
36
  reconnect: true
37
+
38
+ # 手机端的「新建会话 → 浏览电脑目录」走 RPC `directoryPicker/list`,而这个方法**只有
39
+ # browse 后端提供**。官方 `directory-picker-auto` 在 macOS / Windows + 回环地址下必然
40
+ # 解析成 native(系统弹窗),于是任何 macOS/Windows 电脑上的手机都浏览不了目录——这是
41
+ # 产品级缺陷,不是本机配置问题,所以在这里声明,而不是往某个 profile 的
42
+ # cordis.patch.yml 里手抄(那个文件在 ~/.dsh 下,不在仓库里,换机/重装就会丢)。
43
+ #
44
+ # 官方在 `@deepseek-ai/dsh-web-app` 的 bundle patch 里写明了这种用法:
45
+ # "Mount -native or -browse directly in an overlay to pin the interaction."
46
+ # 一个 context 只能挂一个后端,所以这里停掉 auto、直接挂 browse 的那一对
47
+ # (host 后端 + 客户端界面)。代价:电脑端「选目录」从系统弹窗变成应用内浏览器。
48
+ #
49
+ # 插入按 id 去重:如果某个 profile 的 cordis.patch.yml 里还留着同样一段(本机 004
50
+ # 之前的历史状态),两边同时写不会挂两次——已实测,客户端图里只出现一条。
51
+ - id: directory-picker
52
+ name: '@deepseek-ai/dsh-host-directory-picker-auto'
53
+ disabled: true
54
+
55
+ - insert:
56
+ - id: directory-picker-browse
57
+ name: '@deepseek-ai/dsh-host-directory-picker-browse'
58
+ - id: directory-picker-browse-ui
59
+ name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'
package/lib/cli.js CHANGED
@@ -100,22 +100,22 @@ async function main() {
100
100
  //
101
101
  // 反正人已经坐在终端前、手机就在手边,索性把配对码也申请出来、画成终端二维码:
102
102
  // 扫这一屏等于在 App 里填「中转服务器 + 配对码」,少一次开页面找端口的往返。
103
- // 申请失败不算失败——登记本身已经成功,退回到"去二维码页取"的说法。
103
+ // 申请失败不算失败——登记本身已经成功,退回到"去界面里的连接入口取"的说法。
104
104
  process.stdout.write('这台电脑已登记。接下来还有两步:\n'
105
105
  + ' 1. 重启 DSH,让连接器用上这个身份;\n'
106
- + ' 2. 手机 App 底部「扫码配对」扫下面的二维码(等同于点开二维码页取码)。\n')
106
+ + ' 2. 手机 App 的「扫码配对」扫下面的二维码(与 DSH 界面左下角「移动端连接」里那张等价)。\n')
107
107
  try {
108
108
  const identity = await resolveIdentity({ stateFile: settled.stateFile })
109
109
  const minted = await mintPairCode({ identity, ttlMs: 10 * 60 * 1000 })
110
110
  const minutes = Math.round((minted.expiresAt - Date.now()) / 60000)
111
111
  process.stdout.write('\n' + qrTerminal(minted.qrPayload) + '\n')
112
112
  process.stdout.write(`配对码 ${minted.code}(约 ${minutes} 分钟有效,一次性)\n`)
113
- process.stdout.write('也可以在这台电脑上打开 http://127.0.0.1:<DSH 端口>/mobile-link/qr 取新码;\n'
114
- + '端口在 DSH 启动时打印;连接器还会把带令牌地址写进 ~/.dsh/mobile-link/endpoint.json。\n')
113
+ process.stdout.write('也可以在这台电脑上点 DSH 界面左下角的「移动端连接」取新码;\n'
114
+ + '连接器还会把带令牌地址写进 ~/.dsh/mobile-link/endpoint.json。\n')
115
115
  } catch (error) {
116
116
  process.stdout.write('(这次没能预先申请配对码:' + (error?.message ?? error) + ')\n'
117
- + '重启 DSH 后,在这台电脑的浏览器里打开 http://127.0.0.1:<DSH 端口>/mobile-link/qr,'
118
- + '用手机 App 的「扫码配对」扫它。\n')
117
+ + '重启 DSH 后,在这台电脑的 DSH 界面左下角点「移动端连接」,'
118
+ + '用手机 App 的「扫码配对」扫里面的二维码。\n')
119
119
  }
120
120
  return 0
121
121
  }
package/lib/client.js ADDED
@@ -0,0 +1,493 @@
1
+ /**
2
+ * 客户端半边:DSH 界面里的移动端连接入口(spec 010)。界面只用官方原子组件
3
+ * (`@deepseek-ai/dsh-client-ui-primitives`)拼装:遮罩、顶层 Esc、焦点归位归官方 `Modal`,
4
+ * 视觉跟官方 token 走,这里的 CSS 只管版式。小窗自己持有那张配对码(每次打开新签一张)。
5
+ *
6
+ * 形状抄 `plugins/send-image/lib/client.js`(契约见 specs/002-.../contracts/client-plugin.md):
7
+ * CJS 命名空间、只 require 平台 seed 字、顶层除 load 外无副作用、整份脚本套 IIFE。
8
+ * 热更:改本文件不需要重启 DSH,也不用刷新页面(`dsh-client-hmr`,实测约 3 秒生效)。
9
+ */
10
+ ;(() => {
11
+ /** 路由用相对路径:宿主把插件路由挂在同一个源上。 */
12
+ const STATUS_ROUTE = 'mobile-link/status'
13
+ const PAIR_ROUTE = 'mobile-link/pair-code'
14
+ const SLOT_ENTRY = 'sidebar.footer.action'
15
+ const SLOT_OVERLAY = 'shell.overlay'
16
+ const ENTRY_ID = 'mobile-link-entry'
17
+ const PANEL_ID = 'mobile-link-panel'
18
+ const STYLE_ID = 'dsh-mobile-link-style'
19
+ const LABEL = '移动端连接'
20
+ const TITLE = '移动端连接:二维码、已配对的手机与连接状态'
21
+ const PRIMITIVES = '@deepseek-ai/dsh-client-ui-primitives'
22
+ /** 小窗打开时的状态轮询间隔;关闭时只为让侧栏状态点别停在旧值上。 */
23
+ const OPEN_POLL_MS = 2500
24
+ const IDLE_POLL_MS = 20000
25
+ /** 剩余寿命低于这个值就换一张:只剩几秒的码,扫它失败的原因用户看不见。 */
26
+ const CODE_FLOOR_MS = 60 * 1000
27
+ /** 向宿主请求的码长;宿主与中转各自还有兜底上限。 */
28
+ const CODE_TTL_MS = 10 * 60 * 1000
29
+ /** 「已复制」提示的停留时间。 */
30
+ const COPIED_MS = 2000
31
+ /** 版式。颜色与圆角取官方 token 并带兜底值:插件可能装在没有这些变量的宿主上。 */
32
+ const CSS = [
33
+ '.dsh-mobile-link-entry{display:flex;align-items:center;justify-content:flex-start;gap:8px;width:100%;font-size:13px}',
34
+ '.dsh-mobile-link-entry-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
35
+ '.dsh-mobile-link-dialog{width:min(420px,94vw)}',
36
+ '.dsh-mobile-link-body{display:flex;flex-direction:column;gap:16px}',
37
+ '.dsh-mobile-link-status{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500}',
38
+ '.dsh-mobile-link-section-title{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary,inherit)}',
39
+ '.dsh-mobile-link-kv{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px 16px;font-size:13px}',
40
+ '.dsh-mobile-link-k{color:var(--dsw-alias-label-tertiary,inherit);white-space:nowrap}',
41
+ '.dsh-mobile-link-v{min-width:0;text-align:right;color:var(--dsw-alias-label-primary,inherit);overflow-wrap:anywhere}',
42
+ '.dsh-mobile-link-mono,.dsh-mobile-link-note,.dsh-mobile-link-device-meta{font-size:12px;color:var(--dsw-alias-label-tertiary,inherit)}',
43
+ '.dsh-mobile-link-mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}',
44
+ '.dsh-mobile-link-card{display:flex;flex-direction:column;align-items:center;gap:14px;padding:20px 18px;border-radius:12px;background:var(--dsw-alias-bg-module-platform,rgba(15,17,21,.04))}',
45
+ '.dsh-mobile-link-qr{display:block;width:248px;max-width:100%;height:auto;box-sizing:border-box;padding:10px;border-radius:10px;background:#fff}',
46
+ '.dsh-mobile-link-code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:20px;font-weight:600;letter-spacing:.16em;line-height:1;color:var(--dsw-alias-label-primary,inherit)}',
47
+ '.dsh-mobile-link-count{display:flex;flex-direction:column;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-tertiary,inherit)}',
48
+ '.dsh-mobile-link-bar{width:180px;height:3px;border-radius:2px;overflow:hidden;background:var(--dsw-alias-border-secondary,rgba(15,17,21,.12))}',
49
+ '.dsh-mobile-link-bar i{display:block;height:100%;border-radius:2px;background:var(--dsw-alias-state-business-primary,#4176e6);transition:width .9s linear}',
50
+ '.dsh-mobile-link-actions{display:flex;align-items:center;gap:8px}',
51
+ '.dsh-mobile-link-section{display:flex;flex-direction:column;gap:8px}',
52
+ '.dsh-mobile-link-devices{display:flex;flex-direction:column;gap:6px;max-height:184px;overflow:auto}',
53
+ '.dsh-mobile-link-device{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:10px;background:var(--dsw-alias-bg-module-platform,rgba(15,17,21,.04))}',
54
+ '.dsh-mobile-link-device-name{font-size:13px;font-weight:500}',
55
+ '.dsh-mobile-link-device .dsh-mobile-link-tag{margin-left:auto}',
56
+ '.dsh-mobile-link-error{color:var(--dsw-alias-state-error-primary,#d1242f)}',
57
+ '.dsh-mobile-link-note{font-size:12px;line-height:1.65;color:var(--dsw-alias-label-tertiary,inherit)}',
58
+ ].join('')
59
+
60
+ function report(ctx, message) {
61
+ const text = `mobile-link: ${message}`
62
+ try {
63
+ if (typeof ctx?.logger?.warn === 'function') ctx.logger.warn(text)
64
+ else console.warn(text)
65
+ } catch {
66
+ /* 连日志都写不了就算了,不能因此让界面起不来 */
67
+ }
68
+ }
69
+ function describe(error) {
70
+ return error instanceof Error ? error.message : String(error)
71
+ }
72
+ /** 样式表:页面上只留一份,退场时随插件 fiber 一起清掉。 */
73
+ function ensureStyle() {
74
+ if (typeof document === 'undefined' || !document.head) return null
75
+ const existing = document.getElementById(STYLE_ID)
76
+ if (existing) return existing
77
+ const style = document.createElement('style')
78
+ style.id = STYLE_ID
79
+ style.textContent = CSS
80
+ document.head.appendChild(style)
81
+ return style
82
+ }
83
+ /** 极小的外部状态容器:按钮与小窗是两个独立注册项,需要共享的只有几个字段。 */
84
+ function createStore(initial) {
85
+ let state = { ...initial }
86
+ const listeners = new Set()
87
+ return {
88
+ get: () => state,
89
+ set(patch) {
90
+ state = { ...state, ...patch }
91
+ for (const listener of listeners) listener()
92
+ },
93
+ subscribe(listener) {
94
+ listeners.add(listener)
95
+ return () => listeners.delete(listener)
96
+ },
97
+ }
98
+ }
99
+ function useStore(React, store) {
100
+ return React.useSyncExternalStore(store.subscribe, store.get, store.get)
101
+ }
102
+ /** 一个 GET/POST,把失败统一成带可读 message 的 Error。 */
103
+ async function requestJson(path, init) {
104
+ let response
105
+ try {
106
+ response = await fetch(path, init)
107
+ } catch (error) {
108
+ throw new Error(`连不上本机连接器:${describe(error)}`)
109
+ }
110
+ let body
111
+ try {
112
+ body = await response.json()
113
+ } catch {
114
+ throw new Error(`连接器应答无法解析(HTTP ${response.status})`)
115
+ }
116
+ if (!response.ok || body?.ok === false) {
117
+ throw new Error(body?.message ?? `连接器应答 HTTP ${response.status}`)
118
+ }
119
+ return body
120
+ }
121
+ function needsEnroll(status) {
122
+ if (!status) return false
123
+ if (status.enroll?.needsEnroll !== undefined) return Boolean(status.enroll.needsEnroll)
124
+ return status.enroll?.registered === false
125
+ }
126
+ /** 状态点与状态词的唯一来源:侧栏入口与标题行共用,不会出现"点上是绿的、文字说断开"。 */
127
+ function connectionState(state) {
128
+ if (state.statusError) return { dot: 'error', text: '状态未知' }
129
+ if (!state.status) return { dot: 'ongoing', text: '正在读取连接状态' }
130
+ if (needsEnroll(state.status)) return { dot: 'error', text: '这台电脑还没登记到中转' }
131
+ if (state.status.enabled === false) return { dot: 'idle', text: '连接器已停用' }
132
+ if (state.status.connected) return { dot: 'done', text: '已连接中转' }
133
+ return { dot: 'warning', text: '未连接,正在重连' }
134
+ }
135
+ /** 手机报上来的版本:`hello` 存的是 `{name,version,build,label}`,直接插值会渲染成 [object Object]。 */
136
+ function clientLabel(client) {
137
+ if (!client) return null
138
+ if (typeof client === 'string') return client
139
+ return client.label ?? [client.name, client.version].filter(Boolean).join(' ') ?? null
140
+ }
141
+ /** 在线/总数分开说:`deviceCount` 把"连着但已离线"的记录也算进去了。 */
142
+ function deviceSummary(devices) {
143
+ const list = Array.isArray(devices) ? devices : []
144
+ if (list.length === 0) return '还没有手机配对过'
145
+ const online = list.filter((device) => device.offlineSince === null || device.offlineSince === undefined).length
146
+ return online === list.length ? `在线 ${online} 台` : `在线 ${online} 台 · 共 ${list.length} 台`
147
+ }
148
+ function clockText(expiresAt) {
149
+ const date = new Date(Number(expiresAt))
150
+ const pad = (value) => String(value).padStart(2, '0')
151
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}`
152
+ }
153
+ function remainingText(ms) {
154
+ const total = Math.max(0, Math.floor(Number(ms) / 1000))
155
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
156
+ }
157
+ /** 面板的可变状态:一个插件实例一份。 */
158
+ function createPanel() {
159
+ return {
160
+ store: createStore({
161
+ open: false,
162
+ status: null,
163
+ statusError: null,
164
+ code: null,
165
+ codeError: null,
166
+ busy: false,
167
+ copied: false,
168
+ showError: false,
169
+ now: Date.now(),
170
+ }),
171
+ disposed: false,
172
+ style: null,
173
+ idleTimer: null,
174
+ tickTimer: null,
175
+ copiedTimer: null,
176
+ lastStatusAt: 0,
177
+ }
178
+ }
179
+ /**
180
+ * 这张码是不是已经被兑换掉了:只看"在线手机台数比取码时多了"(问中转要动 relay,spec 010 禁止)。
181
+ * 只用来触发换码,不承担旧实现那种"猜这张码还能不能用"的职责。
182
+ */
183
+ function swapIfSpent(panel) {
184
+ const code = panel.store.get().code
185
+ if (!code || panel.disposed) return
186
+ const count = Number(panel.store.get().status?.deviceCount ?? 0)
187
+ if (count > code.mintedDeviceCount) void refreshCode(panel)
188
+ }
189
+
190
+ async function loadStatus(panel) {
191
+ try {
192
+ const status = await requestJson(STATUS_ROUTE, { method: 'GET' })
193
+ if (panel.disposed) return
194
+ panel.store.set({ status, statusError: null })
195
+ swapIfSpent(panel)
196
+ } catch (error) {
197
+ if (panel.disposed) return
198
+ panel.store.set({ statusError: describe(error) })
199
+ }
200
+ }
201
+ /** 取一张新码。**不复用上一张**:复用正是"扫到已兑换的码"的来源。 */
202
+ async function refreshCode(panel) {
203
+ if (panel.disposed || panel.store.get().busy) return
204
+ if (needsEnroll(panel.store.get().status)) {
205
+ panel.store.set({ code: null, codeError: null })
206
+ return
207
+ }
208
+ panel.store.set({ busy: true, codeError: null })
209
+ try {
210
+ const body = await requestJson(PAIR_ROUTE, {
211
+ method: 'POST',
212
+ headers: { 'content-type': 'application/json' },
213
+ body: JSON.stringify({ ttlMs: CODE_TTL_MS }),
214
+ })
215
+ if (panel.disposed) return
216
+ panel.store.set({
217
+ code: {
218
+ code: body.code,
219
+ qrPayload: body.qrPayload,
220
+ svg: body.svg,
221
+ expiresAt: Number(body.expiresAt),
222
+ ttlMs: Number(body.ttlMs) || CODE_TTL_MS,
223
+ mintedDeviceCount: Number(panel.store.get().status?.deviceCount ?? 0),
224
+ },
225
+ codeError: null,
226
+ })
227
+ } catch (error) {
228
+ if (panel.disposed) return
229
+ // 码撤掉而不是留着:一张取不到的码继续摆在屏幕上,就是在骗人去扫。
230
+ panel.store.set({ code: null, codeError: describe(error) })
231
+ } finally {
232
+ if (!panel.disposed) panel.store.set({ busy: false })
233
+ }
234
+ }
235
+ /** 每秒一拍:倒计时、状态轮询、以及"这张码该换了"。 */
236
+ function tick(panel) {
237
+ if (panel.disposed || !panel.store.get().open) return
238
+ const now = Date.now()
239
+ panel.store.set({ now })
240
+ if (now - panel.lastStatusAt >= OPEN_POLL_MS) {
241
+ panel.lastStatusAt = now
242
+ void loadStatus(panel)
243
+ }
244
+ const code = panel.store.get().code
245
+ if (code && code.expiresAt - now <= CODE_FLOOR_MS) void refreshCode(panel)
246
+ }
247
+ function openPanel(panel) {
248
+ if (panel.disposed) return
249
+ panel.store.set({ open: true, now: Date.now(), copied: false })
250
+ panel.lastStatusAt = Date.now()
251
+ void loadStatus(panel)
252
+ void refreshCode(panel)
253
+ }
254
+ function closePanel(panel) {
255
+ if (panel.disposed) return
256
+ panel.store.set({ open: false, code: null, codeError: null, copied: false })
257
+ }
258
+ function disposePanel(panel) {
259
+ panel.disposed = true
260
+ if (panel.idleTimer) clearInterval(panel.idleTimer)
261
+ if (panel.tickTimer) clearInterval(panel.tickTimer)
262
+ if (panel.copiedTimer) clearTimeout(panel.copiedTimer)
263
+ panel.idleTimer = null
264
+ panel.tickTimer = null
265
+ panel.copiedTimer = null
266
+ panel.style?.remove?.()
267
+ panel.style = null
268
+ }
269
+ /** 插件启用时建立的页面资源;返回退场时用的清理函数。 */
270
+ function startPanel(panel) {
271
+ panel.style = ensureStyle()
272
+ panel.store.set({ now: Date.now() })
273
+ void loadStatus(panel)
274
+ panel.idleTimer = setInterval(() => {
275
+ if (panel.disposed || panel.store.get().open) return
276
+ void loadStatus(panel)
277
+ }, IDLE_POLL_MS)
278
+ panel.tickTimer = setInterval(() => tick(panel), 1000)
279
+ return () => disposePanel(panel)
280
+ }
281
+ /** 复制配对码:选中态两秒后自己退回去。 */
282
+ function copyCode(panel) {
283
+ const code = panel.store.get().code
284
+ if (!code) return
285
+ const done = () => {
286
+ panel.store.set({ copied: true })
287
+ if (panel.copiedTimer) clearTimeout(panel.copiedTimer)
288
+ panel.copiedTimer = setTimeout(() => {
289
+ if (!panel.disposed) panel.store.set({ copied: false })
290
+ }, COPIED_MS)
291
+ }
292
+ try {
293
+ void navigator.clipboard?.writeText(code.code).then(done, done)
294
+ } catch {
295
+ done()
296
+ }
297
+ }
298
+ /** 控制器:视图只读它的 store、只调它的动作;"码什么时候作废"只在 tick/swapIfSpent 定义。 */
299
+ function createController() {
300
+ const panel = createPanel()
301
+ return {
302
+ store: panel.store,
303
+ open: () => openPanel(panel),
304
+ close: () => closePanel(panel),
305
+ refreshCode: () => refreshCode(panel),
306
+ copy: () => copyCode(panel),
307
+ toggleError: () => panel.store.set({ showError: !panel.store.get().showError }),
308
+ start: () => startPanel(panel),
309
+ }
310
+ }
311
+
312
+ // ── 视图(官方原子组件拼装) ───────────────────────────────────────────────
313
+
314
+ function makeEntry(React, controller, ui) {
315
+ return function MobileLinkEntry() {
316
+ const state = useStore(React, controller.store)
317
+ const conn = connectionState(state)
318
+ return React.createElement(ui.Button, {
319
+ variant: 'ghost', type: 'button', id: ENTRY_ID, className: 'dsh-mobile-link-entry',
320
+ title: `${TITLE}(${conn.text})`, 'aria-label': `${LABEL}:${conn.text}`,
321
+ onClick: () => controller.open(),
322
+ }, React.createElement(ui.StateDot, { state: conn.dot }),
323
+ React.createElement('span', { className: 'dsh-mobile-link-entry-label' }, LABEL))
324
+ }
325
+ }
326
+ function row(React, key, value, mono = false) {
327
+ return React.createElement('div', { className: 'dsh-mobile-link-kv', key },
328
+ React.createElement('span', { className: 'dsh-mobile-link-k' }, key),
329
+ React.createElement('span', {
330
+ className: mono ? 'dsh-mobile-link-v dsh-mobile-link-mono' : 'dsh-mobile-link-v',
331
+ }, value))
332
+ }
333
+ /** 标题行 + 连接事实。 */
334
+ function statusSection(React, ui, state) {
335
+ const conn = connectionState(state)
336
+ return React.createElement('div', { className: 'dsh-mobile-link-section' },
337
+ React.createElement('div', { className: 'dsh-mobile-link-status' },
338
+ React.createElement(ui.StateDot, { state: conn.dot }),
339
+ React.createElement('span', null, conn.text)),
340
+ state.statusError ? React.createElement('div', { className: 'dsh-mobile-link-note dsh-mobile-link-error' },
341
+ `${state.statusError}(连接器可能刚重启,或插件没装进当前宿主)`) : null,
342
+ row(React, '中转', state.status?.relayUrl ?? '—', true),
343
+ state.status?.agentName ? row(React, '电脑', state.status.agentName) : null,
344
+ state.status ? row(React, '手机', deviceSummary(state.status.devices)) : null)
345
+ }
346
+ /** 未登记:给命令,不给二维码(FR-008)。 */
347
+ function enrollSection(React, status) {
348
+ return React.createElement('div', { className: 'dsh-mobile-link-section' },
349
+ React.createElement('div', { className: 'dsh-mobile-link-note' },
350
+ '这台电脑还没有登记到中转。登记是一次性的,登记完重启 DSH 就能扫码配对:'),
351
+ React.createElement('div', { className: 'dsh-mobile-link-mono dsh-mobile-link-v', style: { textAlign: 'left' } },
352
+ status?.enroll?.command ?? 'dsh-mobile-link enroll --invite <邀请码> --relay <中转地址>'))
353
+ }
354
+ /** 二维码卡:明文码、图案、倒数条,以及换一张与复制。 */
355
+ function codeSection(React, controller, ui, state) {
356
+ const code = state.code
357
+ const children = []
358
+ if (code) {
359
+ const remaining = Math.max(0, code.expiresAt - state.now)
360
+ const percent = Math.max(0, Math.min(100, (remaining / (code.ttlMs || CODE_TTL_MS)) * 100))
361
+ children.push(React.createElement('div', { className: 'dsh-mobile-link-code', key: 'code' }, code.code))
362
+ // 二维码:宿主给的 SVG 直接进 data URI,界面不碰图案本身。
363
+ children.push(React.createElement('img', {
364
+ key: 'qr', className: 'dsh-mobile-link-qr', alt: '配对二维码',
365
+ src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(String(code.svg))}`,
366
+ }))
367
+ children.push(React.createElement('div', { className: 'dsh-mobile-link-count', key: 'count' },
368
+ React.createElement('div', { className: 'dsh-mobile-link-bar' }, React.createElement('i', { style: { width: `${percent.toFixed(1)}%` } })),
369
+ React.createElement('span', null, `有效期至 ${clockText(code.expiresAt)}(还剩 ${remainingText(remaining)})`)))
370
+ } else if (state.codeError) {
371
+ children.push(React.createElement('div', { className: 'dsh-mobile-link-note dsh-mobile-link-error', key: 'err' }, state.codeError))
372
+ } else {
373
+ children.push(React.createElement('div', { className: 'dsh-mobile-link-note', key: 'loading' },
374
+ state.busy ? '正在向中转取一张新码…' : '还没有可用的配对码。'))
375
+ }
376
+ const buttons = [React.createElement(ui.Button, {
377
+ key: 'swap', variant: 'primary', type: 'button', disabled: Boolean(state.busy),
378
+ onClick: () => void controller.refreshCode(),
379
+ }, state.busy ? '取码中…' : (state.codeError ? '重试' : '换一张'))]
380
+ if (code) {
381
+ buttons.push(React.createElement(ui.Button, {
382
+ key: 'copy', variant: 'outline', type: 'button', onClick: () => controller.copy(),
383
+ }, state.copied ? '已复制' : '复制配对码'))
384
+ }
385
+ children.push(React.createElement('div', { className: 'dsh-mobile-link-actions', key: 'actions' }, buttons))
386
+ return React.createElement('div', { className: 'dsh-mobile-link-card' }, children)
387
+ }
388
+ function deviceRow(React, ui, device) {
389
+ const online = device.offlineSince === null || device.offlineSince === undefined
390
+ const meta = [device.model, clientLabel(device.client)].filter(Boolean).join(' · ')
391
+ return React.createElement('div', { className: 'dsh-mobile-link-device', key: device.deviceId ?? device.name },
392
+ React.createElement(ui.StateDot, { state: online ? 'done' : 'idle' }),
393
+ React.createElement('div', { style: { minWidth: 0 } },
394
+ React.createElement('div', { className: 'dsh-mobile-link-device-name' }, device.name ?? '未命名设备'),
395
+ meta ? React.createElement('div', { className: 'dsh-mobile-link-device-meta' }, meta) : null),
396
+ React.createElement(ui.Tag, { tone: online ? 'success' : 'quiet', className: 'dsh-mobile-link-tag' },
397
+ online ? '在线' : '离线'))
398
+ }
399
+ function devicesSection(React, ui, status) {
400
+ const devices = Array.isArray(status?.devices) ? status.devices : []
401
+ const rows = devices.length === 0
402
+ ? [React.createElement('div', { className: 'dsh-mobile-link-note', key: 'none' }, '还没有手机配对过。')]
403
+ : devices.map((device) => deviceRow(React, ui, device))
404
+ return React.createElement('div', { className: 'dsh-mobile-link-section' },
405
+ React.createElement('div', { className: 'dsh-mobile-link-section-title' }, '已配对的手机'),
406
+ React.createElement('div', { className: 'dsh-mobile-link-devices' }, rows))
407
+ }
408
+ /** 最近一次失败:平时收起来,别让一行红字天天抢注意力。 */
409
+ function errorSection(React, controller, ui, status, open) {
410
+ if (!status?.lastError) return null
411
+ return React.createElement(ui.DisclosureRow, {
412
+ title: '最近一次错误', open: Boolean(open), expandable: true,
413
+ onToggle: () => controller.toggleError(),
414
+ }, React.createElement('div', { className: 'dsh-mobile-link-mono dsh-mobile-link-error' }, status.lastError))
415
+ }
416
+ function panelBody(React, controller, ui, state) {
417
+ const children = [statusSection(React, ui, state)]
418
+ if (needsEnroll(state.status)) children.push(enrollSection(React, state.status))
419
+ else children.push(codeSection(React, controller, ui, state))
420
+ children.push(devicesSection(React, ui, state.status))
421
+ const error = errorSection(React, controller, ui, state.status, state.showError)
422
+ if (error) children.push(error)
423
+ children.push(React.createElement('div', { className: 'dsh-mobile-link-note', key: 'hint' },
424
+ '用 DSH Mobile App 扫码配对:一张码只能配一台手机,被兑换或剩余不足 1 分钟会自动换新。'
425
+ + '相机不方便时,也可以把上面那串配对码手动输进 App 连接页底部的手输入口。'))
426
+ return React.createElement('div', { className: 'dsh-mobile-link-body' }, children)
427
+ }
428
+ /** 小窗:官方 Modal 负责遮罩、顶层 Esc 与焦点归位;内容只在打开时渲染。 */
429
+ function makePanel(React, controller, ui) {
430
+ return function MobileLinkPanel() {
431
+ const state = useStore(React, controller.store)
432
+ return React.createElement(ui.Modal, {
433
+ open: state.open,
434
+ onClose: () => controller.close(),
435
+ title: LABEL,
436
+ closeLabel: '关闭',
437
+ className: 'dsh-mobile-link-dialog',
438
+ }, panelBody(React, controller, ui, state))
439
+ }
440
+ }
441
+
442
+ window.__ModuleLoader__.load({
443
+ id: 'dsh-plugin-mobile-link',
444
+ factory: (require) => {
445
+ const module = { exports: {} }
446
+ const exports = module.exports
447
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
448
+ const React = require('react')
449
+ let ui
450
+ try {
451
+ ui = require(PRIMITIVES)
452
+ } catch (error) {
453
+ ui = undefined
454
+ }
455
+ /** 两个入口各自降级:一个注册不上,另一个仍然可用。 */
456
+ function apply(ctx) {
457
+ if (!ui?.Modal || !ui?.Button || !ui?.StateDot) {
458
+ // 官方原子组件是平台 seed 字(宿主启动时就冻结在模块表里),正常不会缺;缺了说明
459
+ // 宿主形态不对,此时宁可不注册,也不摆一个与官方视觉无关的入口。
460
+ report(ctx, '官方原子组件不可用,移动端连接入口未注册')
461
+ return
462
+ }
463
+ const controller = createController()
464
+ try {
465
+ ctx.effect(() => controller.start(), 'mobile-link: panel state')
466
+ } catch (error) {
467
+ report(ctx, `面板状态层没起来:${describe(error)}`)
468
+ }
469
+ try {
470
+ ctx.slots.inject(SLOT_ENTRY, () => ctx.slots.register(
471
+ { name: SLOT_ENTRY, id: ENTRY_ID, order: 20, label: () => LABEL },
472
+ makeEntry(React, controller, ui),
473
+ ))
474
+ } catch (error) {
475
+ report(ctx, `侧栏入口没注册上:${describe(error)}`)
476
+ }
477
+ try {
478
+ ctx.slots.inject(SLOT_OVERLAY, () => ctx.slots.register(
479
+ { name: SLOT_OVERLAY, id: PANEL_ID },
480
+ makePanel(React, controller, ui),
481
+ ))
482
+ } catch (error) {
483
+ report(ctx, `小窗没注册上:${describe(error)}`)
484
+ }
485
+ }
486
+
487
+ exports.inject = ['slots']
488
+ exports.apply = apply
489
+
490
+ return module.exports
491
+ },
492
+ })
493
+ })()
package/lib/hello.js CHANGED
@@ -41,8 +41,9 @@ function readManifestVersion() {
41
41
  * - `events` the per-device `$events` stream is served
42
42
  * - `session-streams` logical streams (`session/follow`, …) are served
43
43
  * - `pair-code` this connector can mint a pairing code on request
44
- * - `qr-pairing` this connector can render the pairing QR itself
45
- * (`GET /mobile-link/qr`), so the desktop needs no client
44
+ * - `qr-pairing` this connector renders the pairing QR itself (the `svg`
45
+ * field of `POST /mobile-link/pair-code`), so the DSH client
46
+ * needs no QR library of its own
46
47
  * - `self-enroll` this connector can register itself with an invite code
47
48
  * - `git` git status / diff / log / show can be read from the work
48
49
  * tree on this computer (see git.js)
@@ -57,6 +58,18 @@ function readManifestVersion() {
57
58
  export const SERVER_CAPABILITIES = [
58
59
  'file-transfer', 'events', 'session-streams', 'pair-code', 'qr-pairing', 'self-enroll', 'git',
59
60
  'background-transfer',
61
+ // history-window: the connector answers `_link/sessionWindow` (exact seq window);
62
+ // session-search: it answers `_link/sessionFind` (in-session literal search). The
63
+ // phone switches channels on these and falls back to `session/page` without them.
64
+ 'history-window', 'session-search',
65
+ // history-role-filter: `_link/sessionWindow` accepts `roles` (e.g. just the
66
+ // "me" role) and returns only matching records; seq cursor semantics are
67
+ // unchanged (spec 009 T031).
68
+ 'history-role-filter',
69
+ // history-scan-boundary: such a page also carries `scanBoundary` — the seq the
70
+ // scan actually reached — so a caller whose page was filtered empty can still
71
+ // advance instead of asking for the same stretch forever (spec 009 T039).
72
+ 'history-scan-boundary',
60
73
  ]
61
74
 
62
75
  export function isHelloMethod(method) {