dsh-jiuling-remote-mobile 1.0.5 → 1.0.7

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.
Files changed (3) hide show
  1. package/lib/client.js +462 -151
  2. package/lib/index.js +175 -34
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -38,54 +38,102 @@ window.__ModuleLoader__.load({
38
38
  var react = require('react');
39
39
  var h = react.createElement;
40
40
 
41
+ /**
42
+ * 官方 UI 原子组件(对话框、按钮、分段页签、图标)。
43
+ *
44
+ * ⚠️ **这是「长得像官方」的唯一正解**:官方目录选择器
45
+ * (`dsh-client-ui-directory-picker-browse`)自己也是这么 `require` 的,
46
+ * 该模块已在页面模块注册表里,手机与桌面两端都有。
47
+ *
48
+ * 自己手搓 `div` + `style` 的代价是实打实的:先前那版写死了
49
+ * `background:var(--dsw-alias-bg-primary,#1b1b1f)` —— 而
50
+ * **`--dsw-alias-bg-primary` 这个令牌根本不存在**(官方真实名字是
51
+ * `--dsw-alias-bg-layer-1` / `bg-l1` / `bg-base`),于是整块卡片永远走
52
+ * 那个深色 fallback;`--dsw-alias-border-secondary` 同样不存在。
53
+ * 表现就是手机上「两大坨黑色、跟官方 UI 完全脱钩」。
54
+ */
55
+ var primitives = require('@deepseek-ai/dsh-client-ui-primitives');
56
+ var Modal = primitives.Modal;
57
+ var Button = primitives.Button;
58
+ var Input = primitives.Input;
59
+ var SegmentedTabs = primitives.SegmentedTabs;
60
+ var IconFolderCloseRegular = primitives.IconFolderCloseRegular;
61
+ var IconChevronRightOutlineRegular = primitives.IconChevronRightOutlineRegular;
62
+ var IconPlusOutlineRegular = primitives.IconPlusOutlineRegular;
63
+
41
64
  /** 宿主桥的路由(同源,直接相对路径)。 */
42
65
  var ROUTE_RPC = '/api/dsh-remote/rpc';
43
66
  var ROUTE_STREAM = '/api/dsh-remote/stream';
44
67
 
45
- /** 配色取自官方设计令牌,拿不到时回退到中性值,避免与官方 UI 撞色。 */
68
+ /**
69
+ * 对话框卡片的 className(Modal 的 `className` 收字符串、不是 style 对象)。
70
+ *
71
+ * 尺寸与留白**逐条照抄**官方目录选择器对 Modal 的覆盖
72
+ * (`.../dsh-client-ui-directory-picker-browse/lib/client.js` 里
73
+ * `dialog{...gap:0;width:min(680px,100%);height:min(500px,100dvh - 32px);padding:0}`),
74
+ * 只把宽度收窄到手机合适的 560px。背景、圆角、阴影、遮罩一律由官方
75
+ * Modal 自己的 CSS 提供 —— 本插件**不再声明任何颜色**。
76
+ */
77
+ var DIALOG_CLASS = 'jlm_dialog';
78
+
79
+ /**
80
+ * 布局样式:取值全部照抄官方目录选择器的同义规则,令牌全部是
81
+ * **真实存在**的官方设计令牌(`--dsw-alias-border-l3`、`bg-layer-*`、
82
+ * `interactive-bg-hover`、`label-*` …),不写死任何颜色值。
83
+ */
46
84
  var CSS = {
47
- overlay:
48
- 'position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;' +
49
- 'background:rgba(0,0,0,.42);padding:16px;',
50
- panel:
51
- 'width:100%;max-width:520px;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;' +
52
- 'border-radius:14px;background:var(--dsw-alias-bg-primary,#1b1b1f);' +
53
- 'color:var(--dsw-alias-label-primary,#e8e8ea);' +
54
- 'box-shadow:0 18px 48px rgba(0,0,0,.38);' +
55
- 'border:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.1));',
56
- head: 'display:flex;align-items:center;gap:10px;padding:14px 16px 10px;flex:none;',
57
- title: 'font-size:15px;font-weight:600;letter-spacing:.01em;flex:1;min-width:0;',
58
- tabs: 'display:flex;gap:6px;padding:0 16px 10px;flex:none;',
59
- tab:
60
- 'flex:1;height:32px;border-radius:8px;border:1px solid transparent;cursor:pointer;' +
61
- 'font-size:13px;background:transparent;color:var(--dsw-alias-label-secondary,#9a9aa2);',
62
- tabOn:
63
- 'flex:1;height:32px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;' +
64
- 'border:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.14));' +
65
- 'background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.07));' +
66
- 'color:var(--dsw-alias-label-primary,#e8e8ea);',
67
- path:
68
- 'display:flex;align-items:center;gap:8px;padding:8px 16px;flex:none;font-size:12px;' +
69
- 'color:var(--dsw-alias-label-tertiary,#7d7d85);' +
70
- 'border-top:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.08));' +
71
- 'border-bottom:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.08));',
85
+ dialog: 'gap:0;width:min(560px,100%);height:min(560px,100dvh - 32px);padding:0;',
86
+ // 官方用它把 header / content / footer 抬进 Modal 卡片的 flex 流里。
87
+ editorScope: 'display:contents;',
88
+ header:
89
+ 'display:flex;flex-direction:column;flex:none;gap:8px;' +
90
+ 'padding:16px 14px 8px 24px;border-bottom:.5px solid var(--dsw-alias-border-l3);',
91
+ title:
92
+ 'display:flex;align-items:flex-end;min-height:28px;margin:0;' +
93
+ 'font-size:16px;font-weight:510;line-height:24px;color:var(--dsw-alias-label-primary);',
94
+ crumbBar: 'display:flex;align-items:center;gap:8px;min-height:24px;',
72
95
  pathText:
73
- 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
74
- list: 'flex:1;min-height:120px;overflow:auto;padding:6px 8px;',
96
+ 'flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' +
97
+ 'color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;',
98
+ content:
99
+ 'display:flex;flex-direction:column;flex:1 1 auto;min-height:0;' +
100
+ 'padding:16px 16px 16px 24px;position:relative;overflow:hidden;',
101
+ column:
102
+ 'display:flex;flex-direction:column;flex:1 1 auto;gap:2px;min-height:0;' +
103
+ 'padding-right:8px;overflow-y:auto;',
75
104
  row:
76
- 'display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:9px 10px;' +
77
- 'border-radius:8px;border:none;background:transparent;cursor:pointer;font-size:14px;' +
78
- 'color:var(--dsw-alias-label-primary,#e8e8ea);',
79
- foot: 'display:flex;gap:8px;padding:12px 16px;flex:none;justify-content:flex-end;',
80
- btn:
81
- 'height:34px;padding:0 16px;border-radius:8px;cursor:pointer;font-size:13px;' +
82
- 'border:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.16));' +
83
- 'background:transparent;color:var(--dsw-alias-label-primary,#e8e8ea);',
84
- btnPrimary:
85
- 'height:34px;padding:0 16px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;' +
86
- 'border:none;background:var(--dsw-alias-state-business-primary,#4d6bfe);color:#fff;',
87
- note: 'padding:10px 16px;font-size:12px;color:var(--dsw-alias-label-tertiary,#7d7d85);',
88
- err: 'padding:10px 16px;font-size:12px;color:var(--dsw-alias-state-error-primary,#ff6b6b);word-break:break-all;',
105
+ 'display:flex;align-items:center;gap:4px;width:100%;height:28px;padding:4px;' +
106
+ 'border:none;border-radius:var(--dsw-radius-sm);background:0 0;' +
107
+ 'text-align:left;cursor:pointer;flex:0 0 auto;',
108
+ rowIcon: 'flex:none;color:var(--dsw-alias-label-secondary);',
109
+ rowName:
110
+ 'flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' +
111
+ 'color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:20px;',
112
+ rowChevron: 'flex:none;color:var(--dsw-alias-label-tertiary);',
113
+ status:
114
+ 'padding:4px 120px 4px 4px;font-size:12px;line-height:18px;' +
115
+ 'color:var(--dsw-alias-label-secondary);',
116
+ error:
117
+ 'padding:4px 120px 4px 4px;font-size:12px;line-height:18px;' +
118
+ 'color:var(--dsw-alias-state-error-primary);word-break:break-all;',
119
+ note:
120
+ 'padding:8px 4px 0;font-size:12px;line-height:18px;' +
121
+ 'color:var(--dsw-alias-label-tertiary);',
122
+ footerBar:
123
+ 'display:flex;flex-wrap:wrap;flex:none;align-items:center;gap:8px;' +
124
+ 'padding:16px 24px;border-top:.5px solid var(--dsw-alias-border-l3);',
125
+ footerGap: 'flex:1 1 0;',
126
+ footerAction: 'min-width:72px;',
127
+ // —— 配对条(只在「电脑」页签下显示)——
128
+ pairBar:
129
+ 'display:flex;flex-wrap:wrap;align-items:center;gap:8px;flex:none;' +
130
+ 'padding:0 0 8px;',
131
+ pairWho:
132
+ 'flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' +
133
+ 'color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;',
134
+ pairInput: 'width:140px;text-transform:uppercase;letter-spacing:2px;',
135
+ pairMsg: 'padding:0 4px 8px;font-size:12px;line-height:18px;',
136
+ pairOk: 'color:var(--dsw-alias-state-success-primary);',
89
137
  };
90
138
 
91
139
  /**
@@ -121,9 +169,48 @@ window.__ModuleLoader__.load({
121
169
  return out;
122
170
  }
123
171
 
172
+ // ⚠️ **先把文本留一份副本再转对象**:下面那个 `<style>` 标签拼的是 CSS
173
+ // **文本**,而第 162 行会把 `CSS` 就地改写成对象 —— 顺序反了就会拼出
174
+ // `[object Object]`,卡片尺寸规则整条失效(列表被 flex 压成 0 高、只剩空白)。
175
+ var DIALOG_CSS_TEXT = CSS.dialog;
176
+
124
177
  // 就地转成对象:下游一律写 `style: CSS.xxx`,不会再碰到字符串形式。
125
178
  for (var styleKey in CSS) CSS[styleKey] = toStyle(CSS[styleKey]);
126
179
 
180
+ /**
181
+ * 注入官方风格的样式表。
182
+ *
183
+ * ⚠️ 卡片那层(背景/圆角/阴影/遮罩)**全部由官方 Modal 自己的 CSS 提供**,
184
+ * 这里只覆盖尺寸与留白 —— 与官方目录选择器覆盖
185
+ * `dialog` 类的方式完全一致。所以本插件**不声明任何颜色**,
186
+ * 亮色/暗色主题都随官方走,不会再出现「一大坨黑」。
187
+ *
188
+ * 用 `<style data-plugin-css>` 注入(官方各 client 包的标准做法),
189
+ * 而不是把样式塞进 React 的 `style` prop —— Modal 只接受 `className`。
190
+ */
191
+ var STYLE_TAG_ID = 'dsh-jiuling-remote-mobile/DirectoryTabs.css';
192
+ if (
193
+ typeof document !== 'undefined' &&
194
+ document.querySelector('style[data-plugin-css=' + JSON.stringify(STYLE_TAG_ID) + ']') === null
195
+ ) {
196
+ var styleTag = document.createElement('style');
197
+ styleTag.dataset.plugin = 'dsh-jiuling-remote-mobile';
198
+ styleTag.dataset.pluginCss = STYLE_TAG_ID;
199
+ styleTag.textContent =
200
+ '.' +
201
+ DIALOG_CLASS +
202
+ '{' +
203
+ DIALOG_CSS_TEXT +
204
+ '}' +
205
+ '.' +
206
+ DIALOG_CLASS +
207
+ ' [role="tablist"]{flex:none;}' +
208
+ '.' +
209
+ DIALOG_CLASS +
210
+ ' button:hover{background:var(--dsw-alias-interactive-bg-hover);}';
211
+ document.head.appendChild(styleTag);
212
+ }
213
+
127
214
  /**
128
215
  * 调宿主桥的一条 RPC(令牌只在引擎进程里,浏览器侧不持有)。
129
216
  *
@@ -203,6 +290,10 @@ window.__ModuleLoader__.load({
203
290
  var listDirectory = props.listDirectory;
204
291
  var createDirectory = props.createDirectory;
205
292
  var openSession = props.openSession;
293
+ /** 把宿主刚建好的会话登记进本地列表(见 `settleLocal`)。 */
294
+ var registerLocal = props.registerLocal;
295
+ /** 兜底:拉一次全量会话列表(登记失败时用)。 */
296
+ var refreshSessions = props.refreshSessions;
206
297
 
207
298
  var statePair = react.useState({
208
299
  tab: 'local',
@@ -215,6 +306,22 @@ window.__ModuleLoader__.load({
215
306
  var state = statePair[0];
216
307
  var setState = statePair[1];
217
308
 
309
+ /**
310
+ * 配对状态(与目录浏览无关,所以单独一个 state)。
311
+ *
312
+ * `endpoint === null` 表示还没查过;`paired === false` 表示
313
+ * 手机现在用的还是内置的默认地址与令牌,需要用户去电脑上取一个配对码。
314
+ */
315
+ var pairPair = react.useState({ endpoint: null, paired: false, code: '', busy: false, message: null });
316
+ var pairState = pairPair[0];
317
+ var setPairState = pairPair[1];
318
+
319
+ var patchPair = react.useCallback(function (next) {
320
+ setPairState(function (prev) {
321
+ return Object.assign({}, prev, next);
322
+ });
323
+ }, []);
324
+
218
325
  var patch = react.useCallback(function (next) {
219
326
  setState(function (prev) {
220
327
  return Object.assign({}, prev, next);
@@ -237,12 +344,39 @@ window.__ModuleLoader__.load({
237
344
  [open, patch],
238
345
  );
239
346
 
347
+ // 打开时查一次配对状态(只查一次,失败也不打扰用户 —— 未配对
348
+ // 本来就是这个流程的正常起点)。
349
+ react.useEffect(
350
+ function () {
351
+ if (!open) return undefined;
352
+ bridge('pairStatus', {}).then(
353
+ function (result) {
354
+ if (!aliveRef.current) return;
355
+ patchPair({ endpoint: result.endpoint, paired: result.paired === true });
356
+ },
357
+ function () {
358
+ /* 查不到就不显示那一条,用户仍然可以输码 */
359
+ },
360
+ );
361
+ return undefined;
362
+ },
363
+ [open, patchPair],
364
+ );
365
+
240
366
  // 加载当前页签、当前路径的目录列表。
241
367
  var tab = state.tab;
242
368
  var path = state.path;
243
369
  react.useEffect(
244
370
  function () {
245
371
  if (!open) return undefined;
372
+ // ⚠️ **没配对就别去问电脑要目录**。未配对时 `listDesktop`
373
+ // 会拿着内置的默认地址打过去,那台「电脑」并不存在 ——
374
+ // 用户看到的是转圈 10 秒后一句「远程请求失败: HTTP 502」,
375
+ // 完全不知道要先去配对。这里直接给出可操作的提示。
376
+ if (tab === 'desktop' && pairState.paired !== true) {
377
+ patch({ loading: false, listing: null, error: null });
378
+ return undefined;
379
+ }
246
380
  patch({ loading: true, error: null });
247
381
  var load =
248
382
  tab === 'desktop'
@@ -264,7 +398,7 @@ window.__ModuleLoader__.load({
264
398
  );
265
399
  return undefined;
266
400
  },
267
- [open, tab, path, listDirectory, patch],
401
+ [open, tab, path, listDirectory, patch, pairState.paired],
268
402
  );
269
403
 
270
404
  if (!open) return null;
@@ -283,24 +417,176 @@ window.__ModuleLoader__.load({
283
417
  onPicked(loc.path);
284
418
  };
285
419
 
286
- /** 「电脑」页签:在电脑的这个目录上开一个新会话,并跳到它。 */
420
+ /**
421
+ * 提交配对码。
422
+ *
423
+ * 成功后**必须重拉一次目录列表**:配对的当下 `listDesktop` 是失败的
424
+ * (那时还没配对),而上面那个 effect 的依赖里有 `paired`,
425
+ * 状态一变它自己就会重跑 —— 这里只需要把 `paired` 翻过来。
426
+ */
427
+ var submitPair = function () {
428
+ var code = pairState.code.trim();
429
+ if (code === '' || pairState.busy) return;
430
+ patchPair({ busy: true, message: null });
431
+ bridge('pair', { code: code }).then(
432
+ function (result) {
433
+ if (!aliveRef.current) return;
434
+ patchPair({
435
+ busy: false,
436
+ paired: true,
437
+ endpoint: result.endpoint,
438
+ code: '',
439
+ message: { kind: 'ok', text: '配对成功,正在连接电脑…' },
440
+ });
441
+ patch({ path: null, listing: null });
442
+ },
443
+ function (reason) {
444
+ if (!aliveRef.current) return;
445
+ patchPair({
446
+ busy: false,
447
+ message: { kind: 'error', text: String((reason && reason.message) || reason) },
448
+ });
449
+ },
450
+ );
451
+ };
452
+
453
+ /**
454
+ * 「电脑」页签顶部的配对条。
455
+ *
456
+ * 已配对时只显示「连的是哪台电脑」,不显示令牌(令牌只在引擎进程里)。
457
+ * 未配对时给一个输入框 —— **手输是主路径**:手机 App 的
458
+ * `onPermissionRequest` 只放行录音权限,摄像头一律拒绝,
459
+ * 页面里拿不到 `getUserMedia`,扫二维码这条路走不通。
460
+ */
461
+ var pairBar = h(
462
+ 'div',
463
+ { style: CSS.pairBar },
464
+ pairState.paired === true
465
+ ? h(
466
+ 'span',
467
+ { style: CSS.pairWho, title: pairState.endpoint || '' },
468
+ '已配对:' + (pairState.endpoint || ''),
469
+ )
470
+ : h(
471
+ 'span',
472
+ { style: CSS.pairWho },
473
+ '还没配对。请在电脑的「设置 → 手机远程」里生成 6 位配对码,填在这里。',
474
+ ),
475
+ pairState.paired === true
476
+ ? null
477
+ : h(Input, {
478
+ value: pairState.code,
479
+ onChange: function (event) {
480
+ patchPair({ code: event.target.value.toUpperCase() });
481
+ },
482
+ onKeyDown: function (event) {
483
+ if (event.key === 'Enter') submitPair();
484
+ },
485
+ placeholder: '6 位配对码',
486
+ 'aria-label': '配对码',
487
+ maxLength: 8,
488
+ // ⚠️ 直接给对象:`CSS` 在模块加载时已就地转成对象了
489
+ //(见上面那个 `for (var styleKey in CSS)`),
490
+ // 再套一层 `toStyle()` 会得到 `[object Object]`。
491
+ style: CSS.pairInput,
492
+ }),
493
+ pairState.paired === true
494
+ ? null
495
+ : h(
496
+ Button,
497
+ {
498
+ variant: 'outline',
499
+ size: 'sm',
500
+ onClick: submitPair,
501
+ disabled: pairState.busy || pairState.code.trim() === '',
502
+ },
503
+ pairState.busy ? '配对中…' : '配对',
504
+ ),
505
+ );
506
+
507
+ var pairMsg = pairState.message === null
508
+ ? null
509
+ : h(
510
+ 'div',
511
+ {
512
+ // 同上:`CSS` 已是对象,直接拼对象即可。
513
+ style: pairState.message.kind === 'ok'
514
+ ? Object.assign({}, CSS.pairMsg, CSS.pairOk)
515
+ : CSS.pairMsg,
516
+ role: pairState.message.kind === 'ok' ? undefined : 'alert',
517
+ },
518
+ pairState.message.text,
519
+ );
520
+
521
+ /** 「电脑」页签:在电脑的这个目录上开一个新对话,并跳到它。 */
287
522
  var openDesktop = function () {
288
523
  if (loc.path === '' || state.creating) return;
289
524
  patch({ creating: true, error: null });
290
525
  bridge('createSession', { path: loc.path }).then(
291
526
  function (result) {
292
527
  if (!aliveRef.current) return;
293
- patch({ creating: false });
294
- onCancel();
295
- if (openSession !== undefined && result && result.sessionId) {
296
- openSession(result.sessionId);
297
- }
528
+ // ⚠️ **先登记、再打开**,顺序反了就会静默失败(见 `registerLocal`)。
529
+ return settleLocal(result).then(function () {
530
+ if (!aliveRef.current) return;
531
+ patch({ creating: false });
532
+ onCancel();
533
+ if (openSession !== undefined && result && result.sessionId) {
534
+ openSession(result.sessionId);
535
+ }
536
+ });
298
537
  },
299
538
  function (reason) {
300
539
  if (!aliveRef.current) return;
301
540
  patch({ creating: false, error: String((reason && reason.message) || reason) });
302
541
  },
303
- );
542
+ ).then(null, function (reason) {
543
+ if (!aliveRef.current) return;
544
+ patch({ creating: false, error: String((reason && reason.message) || reason) });
545
+ });
546
+ };
547
+
548
+ /**
549
+ * 把刚在电脑上建好的会话**登记进本地会话列表**。
550
+ *
551
+ * ⚠️ **这一步不能省,而且必须在 `openSession` 之前完成。**
552
+ *
553
+ * `uiWorkspace.openSession(id)` 最终走
554
+ * `SessionManager.resolveTarget(id)`,它有**三道**身份检查,全不过就抛
555
+ * `sessions.retain: unknown session <id>`:
556
+ *
557
+ * 1. `sessions.has(id)` —— 已物化的客户端实例;
558
+ * 2. `summaries.some(...)` —— 客户端列表快照;
559
+ * 3. `addresses` —— 子 agent 地址。
560
+ *
561
+ * 而会话是**宿主**建的,客户端只能靠 `api-session/added` 这条**异步推送**
562
+ * 知道它 —— RPC 的响应会先到。所以直接在 RPC 回执里 `openSession()`
563
+ * 是**必然的竞态**:抛出的异常还被 `openSession()` 同步吞掉,界面
564
+ * 只表现为「点完没反应、又回到默认工作区」,控制台一句错都没有。
565
+ *
566
+ * 正解是走官方 client 自己的 `sessions.create({sessionId, cwd})`:
567
+ * `SessionManager.create()` 在 RPC 返回后立刻
568
+ * `recordMutation({kind:'placeholder'})` 把这一行写进列表快照,
569
+ * 于是 `openSession()` 当场就能解析到它。
570
+ *
571
+ * 幂等性是安全的:宿主侧已经建过这个 id,`createOrAdopt` 看到
572
+ * `agents.get(sessionId)` 活着就直接返回它,不会重复建、也不会撞 cwd。
573
+ *
574
+ * `cwd` 由宿主回执带回(手机本地的工作区路径,不是电脑路径)——
575
+ * 两端各自的 cwd 互不相通,只在 `sessionId` 上对齐。
576
+ *
577
+ * @param result - 宿主 `createSession` 的回执 `{sessionId, cwd}`。
578
+ * @returns 登记完成的 Promise(失败也 resolve,交由调用方决定下一步)。
579
+ */
580
+ var settleLocal = function (result) {
581
+ if (!result || typeof result.sessionId !== 'string') return Promise.resolve();
582
+ var local = registerLocal === undefined
583
+ ? Promise.resolve()
584
+ : Promise.resolve(registerLocal(result.sessionId, result.cwd)).then(null, function () {
585
+ // 登记失败(例如 `session/writer-held`):退一步拉全量列表,
586
+ // 宿主那一行仍然会进来,`openSession` 同样能解析。
587
+ return refreshSessions === undefined ? null : Promise.resolve(refreshSessions()).then(null, function () { return null; });
588
+ });
589
+ return local;
304
590
  };
305
591
 
306
592
  var makeFolder = function () {
@@ -327,89 +613,43 @@ window.__ModuleLoader__.load({
327
613
  key: full,
328
614
  type: 'button',
329
615
  style: CSS.row,
330
- onMouseEnter: function (event) {
331
- event.currentTarget.style.background =
332
- 'var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.07))';
333
- },
334
- onMouseLeave: function (event) {
335
- event.currentTarget.style.background = 'transparent';
336
- },
337
616
  onClick: function () {
338
617
  patch({ path: full });
339
618
  },
340
619
  },
341
- h('span', { style: { opacity: 0.55, flex: 'none' } }, '\u25B8'),
342
- h(
343
- 'span',
344
- {
345
- style: {
346
- flex: '1',
347
- minWidth: 0,
348
- overflow: 'hidden',
349
- textOverflow: 'ellipsis',
350
- whiteSpace: 'nowrap',
351
- },
352
- },
353
- nameOf(entry),
354
- ),
620
+ // ⚠️ 图标组件只解构 `size/className/strokeWidth`,**不透传 `style`**
621
+ // —— 上色要靠外层 span(官方图标本来就是 currentColor 描边)。
622
+ h('span', { style: CSS.rowIcon }, h(IconFolderCloseRegular, { size: 16 })),
623
+ h('span', { style: CSS.rowName }, nameOf(entry)),
624
+ h('span', { style: CSS.rowChevron }, h(IconChevronRightOutlineRegular, { size: 12 })),
355
625
  );
356
626
  });
357
627
 
358
- return h(
628
+ var body = h(
359
629
  'div',
360
- {
361
- style: CSS.overlay,
362
- onClick: function (event) {
363
- if (event.target === event.currentTarget) onCancel();
364
- },
365
- },
630
+ { style: CSS.editorScope },
631
+ // —— 头部:标题 + 页签 + 路径行 ——
366
632
  h(
367
633
  'div',
368
- { style: CSS.panel, role: 'dialog', 'aria-label': '选择工作区目录' },
369
- h(
370
- 'div',
371
- { style: CSS.head },
372
- h('div', { style: CSS.title }, '选择工作区目录'),
373
- h(
374
- 'button',
375
- { type: 'button', 'aria-label': '关闭', style: CSS.btn, onClick: onCancel },
376
- '关闭',
377
- ),
378
- ),
634
+ { style: CSS.header },
635
+ h('h2', { style: CSS.title }, '选择工作区目录'),
636
+ h(SegmentedTabs, {
637
+ label: '目录来源',
638
+ value: state.tab,
639
+ onChange: switchTab,
640
+ items: [
641
+ { value: 'local', label: '手机' },
642
+ { value: 'desktop', label: '电脑' },
643
+ ],
644
+ }),
379
645
  h(
380
646
  'div',
381
- { style: CSS.tabs },
647
+ { style: CSS.crumbBar },
382
648
  h(
383
- 'button',
649
+ Button,
384
650
  {
385
- type: 'button',
386
- style: state.tab === 'local' ? CSS.tabOn : CSS.tab,
387
- onClick: function () {
388
- switchTab('local');
389
- },
390
- },
391
- '手机',
392
- ),
393
- h(
394
- 'button',
395
- {
396
- type: 'button',
397
- style: state.tab === 'desktop' ? CSS.tabOn : CSS.tab,
398
- onClick: function () {
399
- switchTab('desktop');
400
- },
401
- },
402
- '电脑',
403
- ),
404
- ),
405
- h(
406
- 'div',
407
- { style: CSS.path },
408
- h(
409
- 'button',
410
- {
411
- type: 'button',
412
- style: CSS.btn,
651
+ variant: 'outline',
652
+ size: 'sm',
413
653
  disabled: loc.parent === '',
414
654
  onClick: function () {
415
655
  patch({ path: loc.parent === '' ? null : loc.parent });
@@ -417,16 +657,32 @@ window.__ModuleLoader__.load({
417
657
  },
418
658
  '上一级',
419
659
  ),
420
- h('div', { style: CSS.pathText, title: loc.path }, loc.path || '(根)'),
660
+ h('span', { style: CSS.pathText, title: loc.path }, loc.path || '(根)'),
421
661
  ),
422
- state.error !== null ? h('div', { style: CSS.err, role: 'alert' }, state.error) : null,
662
+ ),
663
+ // —— 主体:目录列表 ——
664
+ h(
665
+ 'div',
666
+ { style: CSS.content },
667
+ // 配对条只在「电脑」页签下出现 —— 「手机」页签跟配对无关。
668
+ state.tab === 'desktop' ? pairBar : null,
669
+ state.tab === 'desktop' ? pairMsg : null,
423
670
  state.loading
424
- ? h('div', { style: CSS.note }, '加载中…')
671
+ ? h('div', { style: CSS.status, role: 'status' }, '加载中…')
425
672
  : h(
426
673
  'div',
427
- { style: CSS.list },
428
- rows.length > 0 ? rows : h('div', { style: CSS.note }, '这里没有子文件夹'),
674
+ { style: CSS.column },
675
+ rows.length > 0
676
+ ? rows
677
+ : h(
678
+ 'div',
679
+ { style: CSS.status },
680
+ state.tab === 'desktop' && pairState.paired !== true
681
+ ? '配对后这里会显示电脑上的文件夹'
682
+ : '这里没有子文件夹',
683
+ ),
429
684
  ),
685
+ state.error !== null ? h('div', { style: CSS.error, role: 'alert' }, state.error) : null,
430
686
  state.tab === 'desktop'
431
687
  ? h(
432
688
  'div',
@@ -434,36 +690,63 @@ window.__ModuleLoader__.load({
434
690
  '在电脑的这个目录上开一个新对话,手机上会同步显示它的全部过程。',
435
691
  )
436
692
  : null,
437
- h(
438
- 'div',
439
- { style: CSS.foot },
440
- state.tab === 'local' && createDirectory !== undefined
441
- ? h('button', { type: 'button', style: CSS.btn, onClick: makeFolder }, '新建文件夹')
442
- : null,
443
- state.tab === 'local'
444
- ? h(
445
- 'button',
446
- {
447
- type: 'button',
448
- style: CSS.btnPrimary,
449
- disabled: loc.path === '' || props.busy,
450
- onClick: pickLocal,
451
- },
452
- props.busy ? '处理中…' : '选择此文件夹',
453
- )
454
- : h(
455
- 'button',
456
- {
457
- type: 'button',
458
- style: CSS.btnPrimary,
459
- disabled: loc.path === '' || state.creating,
460
- onClick: openDesktop,
461
- },
462
- state.creating ? '正在电脑上新建…' : '在此新建对话',
463
- ),
464
- ),
693
+ ),
694
+ // —— 底部动作栏 ——
695
+ h(
696
+ 'div',
697
+ { style: CSS.footerBar },
698
+ state.tab === 'local' && createDirectory !== undefined
699
+ ? h(
700
+ Button,
701
+ {
702
+ variant: 'outline',
703
+ icon: h(IconPlusOutlineRegular, { size: 14 }),
704
+ onClick: makeFolder,
705
+ },
706
+ '新建文件夹',
707
+ )
708
+ : null,
709
+ h('span', { style: CSS.footerGap }),
710
+ h(Button, { variant: 'outline', style: CSS.footerAction, onClick: onCancel }, '取消'),
711
+ state.tab === 'local'
712
+ ? h(
713
+ Button,
714
+ {
715
+ variant: 'primary',
716
+ style: CSS.footerAction,
717
+ disabled: loc.path === '' || props.busy,
718
+ onClick: pickLocal,
719
+ },
720
+ props.busy ? '处理中…' : '选择此文件夹',
721
+ )
722
+ : h(
723
+ Button,
724
+ {
725
+ variant: 'primary',
726
+ style: CSS.footerAction,
727
+ disabled: loc.path === '' || state.creating || pairState.paired !== true,
728
+ onClick: openDesktop,
729
+ },
730
+ state.creating ? '正在电脑上新建…' : '在此新建对话',
731
+ ),
465
732
  ),
466
733
  );
734
+
735
+ // 卡片本身(背景/圆角/阴影/遮罩/关闭按钮)全部交给官方 Modal;
736
+ // 我们只覆盖尺寸与留白(见 DIALOG_CLASS)。`headless` 才不会
737
+ // 再套一层官方自带的标题栏 —— 标题由我们放进 header 里。
738
+ return h(
739
+ Modal,
740
+ {
741
+ open: open,
742
+ onClose: onCancel,
743
+ title: '选择工作区目录',
744
+ closeLabel: '关闭',
745
+ className: DIALOG_CLASS,
746
+ headless: true,
747
+ },
748
+ body,
749
+ );
467
750
  }
468
751
 
469
752
  //#region 电脑流式帧 → 本地 transient
@@ -642,6 +925,34 @@ window.__ModuleLoader__.load({
642
925
  listDirectory: (path, signal) => ctx.uiWorkspace.listDirectory(path, signal),
643
926
  createDirectory: (path, name) => ctx.uiWorkspace.createDirectory(path, name),
644
927
  openSession: (sessionId) => ctx.uiWorkspace.openSession(sessionId),
928
+ /**
929
+ * 把宿主刚建好的会话登记进本地列表。
930
+ *
931
+ * 走官方 client 的 `sessions.create({sessionId, cwd})`:
932
+ * `SessionManager.create()` 在 RPC 回执后立刻
933
+ * `recordMutation({kind:'placeholder'})` 把这一行写进列表快照,
934
+ * 于是 `openSession()` 当场就能解析到这个 id。
935
+ * **不登记就直接 `openSession()` 是竞态**,会抛
936
+ * `sessions.retain: unknown session` 并被静默吞掉
937
+ * (详见组件里 `settleLocal` 的注释)。
938
+ *
939
+ * 传 `cwd`(宿主回执里的**手机本地**路径)而不是 `workspaceId`:
940
+ * 宿主侧已经把这个会话挂进工作区了,这里只需要让客户端列表
941
+ * 认识它;带上 cwd 才能让列表把它分进正确的工作区分组。
942
+ *
943
+ * 宿主侧已建过该 id ⇒ `createOrAdopt` 看到活着的 agent 直接返回,
944
+ * 不会重复建。
945
+ *
946
+ * @param sessionId - 会话 id。
947
+ * @param cwd - 宿主回执里的本地 cwd(两端只在 id 上对齐)。
948
+ */
949
+ registerLocal: (sessionId, cwd) => {
950
+ const options = { sessionId };
951
+ if (cwd !== undefined && cwd !== '') options.cwd = cwd;
952
+ return ctx.sessions.create(options);
953
+ },
954
+ /** 兜底:拉一次全量会话列表。 */
955
+ refreshSessions: () => ctx.sessions.refresh(),
645
956
  });
646
957
 
647
958
  // 负 priority:single slot 取 entries[0],entries 按 priority 升序 ⇒ 最先渲染。
package/lib/index.js CHANGED
@@ -47,8 +47,8 @@
47
47
  * @module dsh-remote-mobile
48
48
  */
49
49
 
50
- import { randomUUID } from 'node:crypto';
51
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
50
+ import { createHash, randomUUID } from 'node:crypto';
51
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
52
52
  import { homedir } from 'node:os';
53
53
  import { join } from 'node:path';
54
54
 
@@ -73,6 +73,37 @@ const SESSIONS_PATH = join(STATE_DIR, 'sessions.json');
73
73
  /** 默认隧道地址。 */
74
74
  const DEFAULT_ENDPOINT = 'https://dsh-remote.jiulingssh.xyz';
75
75
 
76
+ /**
77
+ * 配对服务地址。
78
+ *
79
+ * 与电脑端 `dsh-jiuling-remote-desktop/lib/pair.js` 的 `PAIR_ENDPOINT`
80
+ * **必须逐字相同** —— 两端各写一份是因为这两个包要能独立安装,
81
+ * 不能互相 import(一个是 host 插件、一个是另一台机器上的 host 插件)。
82
+ */
83
+ const PAIR_ENDPOINT = 'https://pair.jiulingssh.xyz';
84
+
85
+ /**
86
+ * 保存连接配置(配对成功后写)。
87
+ *
88
+ * ⚠️ 走「临时文件 + rename」:这个文件是手机端**唯一一份**凭据,
89
+ * 写一半断电就得重新配对一次。
90
+ *
91
+ * @param endpoint - 电脑的隧道地址。
92
+ * @param token - 访问令牌。
93
+ * @returns 落盘后的 `{ endpoint, token }`。
94
+ */
95
+ function saveConfig(endpoint, token) {
96
+ const record = {
97
+ endpoint: String(endpoint).replace(/\/+$/, ''),
98
+ token: String(token),
99
+ };
100
+ mkdirSync(STATE_DIR, { recursive: true });
101
+ const tmp = `${CONFIG_PATH}.${process.pid}.tmp`;
102
+ writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
103
+ renameSync(tmp, CONFIG_PATH);
104
+ return record;
105
+ }
106
+
76
107
  /**
77
108
  * 内置访问令牌 —— **必须与 `dsh-jiuling-remote-desktop` 里的内置值逐字相同**。
78
109
  *
@@ -203,6 +234,64 @@ async function callRemote(config, method, params = {}) {
203
234
  return payload.result;
204
235
  }
205
236
 
237
+ /**
238
+ * 调配对服务器的一条接口。
239
+ *
240
+ * ⚠️ 同样走 `remoteFetch`(专用 undici 池):配对服务器也在公网上,
241
+ * 用全局 `fetch` 会撞上 Android 上那个 12 秒超时的连接策略问题(见 `remoteFetch`)。
242
+ *
243
+ * @param path - 例如 `/pair/claim`。
244
+ * @param body - 请求体;不传就发 GET。
245
+ * @returns 解析后的 JSON(失败抛错,错误文案直接可读)。
246
+ */
247
+ async function pairFetch(path, body) {
248
+ const init = body === undefined
249
+ ? { method: 'GET' }
250
+ : {
251
+ method: 'POST',
252
+ headers: { 'content-type': 'application/json' },
253
+ body: JSON.stringify(body),
254
+ };
255
+ const res = await remoteFetch(`${PAIR_ENDPOINT}${path}`, init);
256
+ let payload;
257
+ try {
258
+ payload = await res.json();
259
+ } catch {
260
+ throw new Error(`配对服务器返回了非 JSON(HTTP ${res.status})`);
261
+ }
262
+ if (!res.ok) {
263
+ throw new Error(payload.message ?? payload.error ?? `配对失败: HTTP ${res.status}`);
264
+ }
265
+ // ⚠️ **不能要求 `payload.ok === true`**:配对服务器 `claim_handshake()` 的
266
+ // 成功响应体只有 `{hostname, accessToken, subdomain}`,**没有 `ok` 字段**
267
+ //(失败时才是 400 + `{error, message}`)。按 `ok` 判会让每一次正常配对
268
+ // 都报「配对失败: HTTP 200」,而 HTTP 状态码明明是对的。
269
+ if (payload.ok === false) {
270
+ throw new Error(payload.message ?? payload.error ?? '配对失败');
271
+ }
272
+ return payload;
273
+ }
274
+
275
+ /**
276
+ * 用 6 位配对码领取这台电脑的连接信息。
277
+ *
278
+ * ⚠️ **配对码是一次性的**:服务器在 `claim` 成功后立刻删掉它。所以这里的顺序
279
+ * 必须是「先领、后写盘」—— 反过来(先写盘再领)一旦写盘失败,码已经作废,
280
+ * 用户得回电脑上重新生成一个。
281
+ *
282
+ * @param code - 用户输入的 6 位码(大小写、空格、连字符都由服务器容错)。
283
+ * @returns 落盘后的 `{ endpoint, token }`。
284
+ */
285
+ async function claimPairing(code) {
286
+ const payload = await pairFetch('/pair/claim', { pairCode: code });
287
+ const endpoint = `https://${payload.hostname}`;
288
+ const token = payload.accessToken;
289
+ if (typeof endpoint !== 'string' || typeof token !== 'string' || token === '') {
290
+ throw new Error('配对服务器没有返回完整的连接信息');
291
+ }
292
+ return saveConfig(endpoint, token);
293
+ }
294
+
206
295
  /**
207
296
  * 把一个电脑事件写进手机本地会话。
208
297
  *
@@ -558,44 +647,70 @@ export function apply(ctx) {
558
647
  }
559
648
 
560
649
  /**
561
- * 挑一个**手机本地**的工作区给新建的远程会话用。
650
+ * 把电脑路径压成一个**文件名安全**的稳定 slug。
562
651
  *
563
- * ## ⚠️ 为什么不能直接用 `process.cwd()`
652
+ * 只用于镜像目录的命名(用户看不到),所以要求是「同一个电脑路径永远得到
653
+ * 同一个名字,不同路径几乎不可能撞」。`createHash` 那 8 位十六进制就是防撞的:
654
+ * 纯字符替换会把 `C:\a-b` 与 `C:\a\b` 压成同一个名字。
564
655
  *
565
- * 前端把会话归到哪个工作区,是**按 cwd 路径精确匹配**的
566
- * (`dsh-workspace/lib/index.js:102` 的 `sessionIds` getter:
567
- * `record.sessionIds.filter(id => host.sessionPath(id) === record.path)`,
568
- * 而 `sessionPath` 是 header.cwd 的 `realpathNormalize`)。
656
+ * @param desktopPath - 电脑上的绝对路径(win32 语义)。
657
+ * @returns 形如 `c-users-xiaopan-1a2b3c4d` 的 slug。
658
+ */
659
+ function desktopSlug(desktopPath) {
660
+ const flat = desktopPath.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
661
+ const digest = createHash('sha256').update(desktopPath).digest('hex').slice(0, 8);
662
+ return `${flat === '' ? 'root' : flat}-${digest}`;
663
+ }
664
+
665
+ /**
666
+ * 取电脑路径的末段当工作区标题。
667
+ *
668
+ * ⚠️ **不能直接用 `node:path` 的 `basename`**:手机是 posix 语义,
669
+ * `basename('C:\\Users\\XIAOPAN')` 会把整串原样返回(反斜杠不是分隔符),
670
+ * 侧边栏就会显示一长条 `C:\Users\XIAOPAN`。这里两种分隔符都切。
671
+ *
672
+ * @param desktopPath - 电脑上的绝对路径。
673
+ * @returns 末段;没有末段(如 `C:\`)时退回去掉尾部斜杠的原串。
674
+ */
675
+ function desktopTitle(desktopPath) {
676
+ const trimmed = desktopPath.replace(/[/\\]+$/, '');
677
+ const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
678
+ const name = cut < 0 ? trimmed : trimmed.slice(cut + 1);
679
+ return name === '' ? desktopPath : name;
680
+ }
681
+
682
+ /**
683
+ * 为电脑上的一个目录,在手机本地建(或复用)一个**同名镜像工作区**。
684
+ *
685
+ * ## 为什么要建工作区,而不是塞进「默认工作区」
569
686
  *
570
- * 所以 cwd 落在任何已有工作区之外时,这个会话**不属于任何工作区**:
571
- * 侧边栏没有它、composer 也被判成「没选工作区」而禁用 —— 界面表现是停在
572
- * hero 页的「选择一个工作区开始」,看着像新会话没建成功,其实会话已经建好了
573
- * (磁盘上有日志、两端 sessionId 一致)。
687
+ * 前端把会话归到哪个工作区,是**按 cwd 路径精确匹配**的
688
+ * (`dsh-workspace/lib/index.js:102` 的 `sessionIds` getter)。
689
+ * 早先的实现把远程会话的 cwd 设成手机默认工作区的路径,于是**所有**远程会话
690
+ * 都堆在「默认工作区」底下 —— 用户在电脑页签里选了 `C:\Users\XIAOPAN`,
691
+ * 侧边栏却显示「默认工作区」,看起来就像「建完又回到默认工作去了」。
574
692
  *
575
- * 引擎的 `process.cwd()` 是 `files/workspace`,而手机默认工作区是
576
- * `files/workspace/deepseek-harness/default-workspace`(**更深一层**),
577
- * 两者不相等,正好踩中这个坑。
693
+ * 正解是给每个电脑目录配一个**独立的手机本地镜像目录**,用它注册一个独立
694
+ * 工作区,标题取电脑目录的末段(`XIAOPAN`)。镜像目录是空的、永远不会被写入
695
+ * —— 真正干活的是电脑那台机器,这里只是给前端一个可以挂载的 cwd 锚点。
578
696
  *
579
- * ⚠️ **光把 cwd 设对还不够**:`sessionPaths` 索引只在该注册表启动
580
- * (`indexHeader`)时建一次,新建的会话**不在索引里** ⇒ `sessionPath(id)`
581
- * 返回 `undefined` ≠ `record.path`,照样被 getter 过滤掉。
582
- * 所以创建完必须再 `attachSession()` 一次(见 `createSession`),
583
- * 它会 `rememberSessionPath()` 把这条补进索引。
697
+ * ⚠️ cwd 依然**不能**是电脑路径:Android 是 posix 语义,
698
+ * `path.posix.isAbsolute('C:/Users/...') === false`,而
699
+ * `createOrAdopt()` 里有 `await mkdir(cwd, {recursive:true})` —— 传电脑路径
700
+ * 会真在手机上建出一个叫 `C:\Users\...` 的怪目录。
584
701
  *
585
702
  * @param ctx - 宿主 Cordis 上下文。
586
- * @returns 工作区实体;没有可用工作区时 `undefined`。
703
+ * @param desktopPath - 电脑上的绝对路径(仅用于取名字,不会成为 cwd)。
704
+ * @returns 工作区实体(含 `.id` / `.path` / `.title`)。
587
705
  */
588
- function localWorkspaceFor(ctx) {
589
- try {
590
- const registry = ctx.get?.('workspaceRegistry');
591
- const all = registry?.list?.() ?? [];
592
- if (all.length === 0) return undefined;
593
- const preferredId = registry?.state?.defaultWorkspaceId;
594
- const chosen = all.find((entity) => entity.id === preferredId) ?? all[0];
595
- return typeof chosen?.path === 'string' && chosen.path !== '' ? chosen : undefined;
596
- } catch {
597
- return undefined;
706
+ async function ensureDesktopWorkspace(ctx, desktopPath) {
707
+ const registry = ctx.get?.('workspaceRegistry');
708
+ if (registry === undefined || typeof registry.create !== 'function') {
709
+ throw new Error('workspaceRegistry 不可用,无法为电脑目录建工作区');
598
710
  }
711
+ const dir = join(STATE_DIR, 'workspaces', desktopSlug(desktopPath));
712
+ mkdirSync(dir, { recursive: true });
713
+ return await registry.create(dir, desktopTitle(desktopPath));
599
714
  }
600
715
 
601
716
  /**
@@ -633,8 +748,12 @@ async function handleBridge(ctx, config, io) {
633
748
  // ⚠️ 手机端 cwd 必须是**手机本地**的绝对路径,绝不把电脑路径传进来
634
749
  // (Android 是 posix 语义,`path.posix.isAbsolute('C:/...') === false`,
635
750
  // 且官方 createOrAdopt 会 `mkdir(cwd, {recursive:true})` 真去建目录)。
636
- const workspace = localWorkspaceFor(ctx);
637
- const cwd = workspace?.path ?? process.cwd();
751
+ //
752
+ // 这里给它一个**电脑目录专属的镜像工作区**(标题 = 电脑目录末段),
753
+ // 而不是手机的「默认工作区」—— 否则所有远程会话都堆在「默认工作区」
754
+ // 底下,看起来就像「建完又回到默认工作去了」(见 `ensureDesktopWorkspace`)。
755
+ const workspace = await ensureDesktopWorkspace(ctx, params.path);
756
+ const cwd = workspace.path;
638
757
  // ⚠️ **必须走 `sessionController.create`,不能走 `ctx.sessions.create`。**
639
758
  //
640
759
  // `ctx.sessions.create()` 只把会话塞进内存 store:持久化层既没
@@ -693,9 +812,31 @@ async function handleBridge(ctx, config, io) {
693
812
  case 'isRemote':
694
813
  return { remote: io.remoteSessions.has(params.sessionId) };
695
814
 
815
+ /**
816
+ * 配对:用电脑上生成的 6 位码换回这台电脑的隧道地址与令牌。
817
+ *
818
+ * ⚠️ **必须就地改写 `config`**(`Object.assign` 而不是重新赋值):
819
+ * `config` 是 `apply()` 里 `loadConfig()` 的产物,被闭包里的 `prompt`
820
+ * 拦截、`follow`、以及本函数共享。如果这里只写盘不更新对象,
821
+ * 配对的**当下**仍然拿着旧的默认地址与令牌 —— 表现是「配对提示成功、
822
+ * 一发消息就 401」,而重启 App 后又好了(重启才会重新 `loadConfig`)。
823
+ */
824
+ case 'pair': {
825
+ if (typeof params.code !== 'string' || params.code.trim() === '') {
826
+ throw new Error('请输入电脑上显示的 6 位配对码');
827
+ }
828
+ const saved = await claimPairing(params.code.trim());
829
+ Object.assign(config, saved);
830
+ return { endpoint: saved.endpoint, tokenSet: saved.token !== '' };
831
+ }
832
+
833
+ /** 当前连的是哪台电脑(前端用来显示状态)。 */
834
+ case 'pairStatus':
835
+ return { endpoint: config.endpoint, paired: config.token !== DEFAULT_TOKEN };
836
+
696
837
  default:
697
838
  throw new Error(`unknown method "${String(io.body?.method)}"`);
698
839
  }
699
840
  }
700
841
 
701
- export { callRemote, mirrorEvent, loadConfig, DEFAULT_ENDPOINT, CONFIG_PATH };
842
+ export { callRemote, mirrorEvent, loadConfig, DEFAULT_ENDPOINT, CONFIG_PATH, PAIR_ENDPOINT, claimPairing };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-jiuling-remote-mobile",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "DSH 远程目录(手机端):在选文件夹流程里多出「电脑」页签,进入电脑目录、在电脑上发起对话,并把电脑的实时消息流同步到手机本地界面(只传文本,不映射桌面)。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",