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.
- package/lib/client.js +462 -151
- package/lib/index.js +175 -34
- 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
|
-
/**
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
|
|
54
|
-
'
|
|
55
|
-
'
|
|
56
|
-
|
|
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
|
-
|
|
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:
|
|
77
|
-
'border
|
|
78
|
-
'
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
'
|
|
82
|
-
'
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
'
|
|
86
|
-
'
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
628
|
+
var body = h(
|
|
359
629
|
'div',
|
|
360
|
-
{
|
|
361
|
-
|
|
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.
|
|
369
|
-
h(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
{
|
|
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.
|
|
647
|
+
{ style: CSS.crumbBar },
|
|
382
648
|
h(
|
|
383
|
-
|
|
649
|
+
Button,
|
|
384
650
|
{
|
|
385
|
-
|
|
386
|
-
|
|
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('
|
|
660
|
+
h('span', { style: CSS.pathText, title: loc.path }, loc.path || '(根)'),
|
|
421
661
|
),
|
|
422
|
-
|
|
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.
|
|
671
|
+
? h('div', { style: CSS.status, role: 'status' }, '加载中…')
|
|
425
672
|
: h(
|
|
426
673
|
'div',
|
|
427
|
-
{ style: CSS.
|
|
428
|
-
rows.length > 0
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
*
|
|
652
|
+
* 只用于镜像目录的命名(用户看不到),所以要求是「同一个电脑路径永远得到
|
|
653
|
+
* 同一个名字,不同路径几乎不可能撞」。`createHash` 那 8 位十六进制就是防撞的:
|
|
654
|
+
* 纯字符替换会把 `C:\a-b` 与 `C:\a\b` 压成同一个名字。
|
|
564
655
|
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
687
|
+
* 前端把会话归到哪个工作区,是**按 cwd 路径精确匹配**的
|
|
688
|
+
* (`dsh-workspace/lib/index.js:102` 的 `sessionIds` getter)。
|
|
689
|
+
* 早先的实现把远程会话的 cwd 设成手机默认工作区的路径,于是**所有**远程会话
|
|
690
|
+
* 都堆在「默认工作区」底下 —— 用户在电脑页签里选了 `C:\Users\XIAOPAN`,
|
|
691
|
+
* 侧边栏却显示「默认工作区」,看起来就像「建完又回到默认工作去了」。
|
|
574
692
|
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
693
|
+
* 正解是给每个电脑目录配一个**独立的手机本地镜像目录**,用它注册一个独立
|
|
694
|
+
* 工作区,标题取电脑目录的末段(`XIAOPAN`)。镜像目录是空的、永远不会被写入
|
|
695
|
+
* —— 真正干活的是电脑那台机器,这里只是给前端一个可以挂载的 cwd 锚点。
|
|
578
696
|
*
|
|
579
|
-
* ⚠️
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
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
|
-
* @
|
|
703
|
+
* @param desktopPath - 电脑上的绝对路径(仅用于取名字,不会成为 cwd)。
|
|
704
|
+
* @returns 工作区实体(含 `.id` / `.path` / `.title`)。
|
|
587
705
|
*/
|
|
588
|
-
function
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
637
|
-
|
|
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 };
|