dsh-plugin-mobile-link 0.4.0 → 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 +2 -1
- package/cordis.patch.yml +23 -0
- package/lib/cli.js +6 -6
- package/lib/client.js +493 -0
- package/lib/hello.js +15 -2
- package/lib/history/probe.js +195 -0
- package/lib/history/records.js +97 -0
- package/lib/history/shared.js +25 -0
- package/lib/history/window.js +90 -0
- package/lib/history.js +355 -0
- package/lib/index.js +29 -6
- package/lib/link.js +88 -0
- package/lib/qr.js +34 -5
- package/lib/router.js +22 -0
- package/lib/routes.js +58 -55
- package/package.json +5 -1
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
|
|
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
|
|
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('
|
|
114
|
-
+ '
|
|
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
|
|
118
|
-
+ '用手机 App
|
|
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
|
|
45
|
-
*
|
|
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) {
|