dsh-jiuling-remote-mobile 1.0.6 → 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 +169 -3
- package/lib/index.js +113 -2
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -55,6 +55,7 @@ window.__ModuleLoader__.load({
|
|
|
55
55
|
var primitives = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
56
56
|
var Modal = primitives.Modal;
|
|
57
57
|
var Button = primitives.Button;
|
|
58
|
+
var Input = primitives.Input;
|
|
58
59
|
var SegmentedTabs = primitives.SegmentedTabs;
|
|
59
60
|
var IconFolderCloseRegular = primitives.IconFolderCloseRegular;
|
|
60
61
|
var IconChevronRightOutlineRegular = primitives.IconChevronRightOutlineRegular;
|
|
@@ -123,6 +124,16 @@ window.__ModuleLoader__.load({
|
|
|
123
124
|
'padding:16px 24px;border-top:.5px solid var(--dsw-alias-border-l3);',
|
|
124
125
|
footerGap: 'flex:1 1 0;',
|
|
125
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);',
|
|
126
137
|
};
|
|
127
138
|
|
|
128
139
|
/**
|
|
@@ -295,6 +306,22 @@ window.__ModuleLoader__.load({
|
|
|
295
306
|
var state = statePair[0];
|
|
296
307
|
var setState = statePair[1];
|
|
297
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
|
+
|
|
298
325
|
var patch = react.useCallback(function (next) {
|
|
299
326
|
setState(function (prev) {
|
|
300
327
|
return Object.assign({}, prev, next);
|
|
@@ -317,12 +344,39 @@ window.__ModuleLoader__.load({
|
|
|
317
344
|
[open, patch],
|
|
318
345
|
);
|
|
319
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
|
+
|
|
320
366
|
// 加载当前页签、当前路径的目录列表。
|
|
321
367
|
var tab = state.tab;
|
|
322
368
|
var path = state.path;
|
|
323
369
|
react.useEffect(
|
|
324
370
|
function () {
|
|
325
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
|
+
}
|
|
326
380
|
patch({ loading: true, error: null });
|
|
327
381
|
var load =
|
|
328
382
|
tab === 'desktop'
|
|
@@ -344,7 +398,7 @@ window.__ModuleLoader__.load({
|
|
|
344
398
|
);
|
|
345
399
|
return undefined;
|
|
346
400
|
},
|
|
347
|
-
[open, tab, path, listDirectory, patch],
|
|
401
|
+
[open, tab, path, listDirectory, patch, pairState.paired],
|
|
348
402
|
);
|
|
349
403
|
|
|
350
404
|
if (!open) return null;
|
|
@@ -363,6 +417,107 @@ window.__ModuleLoader__.load({
|
|
|
363
417
|
onPicked(loc.path);
|
|
364
418
|
};
|
|
365
419
|
|
|
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
|
+
|
|
366
521
|
/** 「电脑」页签:在电脑的这个目录上开一个新对话,并跳到它。 */
|
|
367
522
|
var openDesktop = function () {
|
|
368
523
|
if (loc.path === '' || state.creating) return;
|
|
@@ -509,12 +664,23 @@ window.__ModuleLoader__.load({
|
|
|
509
664
|
h(
|
|
510
665
|
'div',
|
|
511
666
|
{ style: CSS.content },
|
|
667
|
+
// 配对条只在「电脑」页签下出现 —— 「手机」页签跟配对无关。
|
|
668
|
+
state.tab === 'desktop' ? pairBar : null,
|
|
669
|
+
state.tab === 'desktop' ? pairMsg : null,
|
|
512
670
|
state.loading
|
|
513
671
|
? h('div', { style: CSS.status, role: 'status' }, '加载中…')
|
|
514
672
|
: h(
|
|
515
673
|
'div',
|
|
516
674
|
{ style: CSS.column },
|
|
517
|
-
rows.length > 0
|
|
675
|
+
rows.length > 0
|
|
676
|
+
? rows
|
|
677
|
+
: h(
|
|
678
|
+
'div',
|
|
679
|
+
{ style: CSS.status },
|
|
680
|
+
state.tab === 'desktop' && pairState.paired !== true
|
|
681
|
+
? '配对后这里会显示电脑上的文件夹'
|
|
682
|
+
: '这里没有子文件夹',
|
|
683
|
+
),
|
|
518
684
|
),
|
|
519
685
|
state.error !== null ? h('div', { style: CSS.error, role: 'alert' }, state.error) : null,
|
|
520
686
|
state.tab === 'desktop'
|
|
@@ -558,7 +724,7 @@ window.__ModuleLoader__.load({
|
|
|
558
724
|
{
|
|
559
725
|
variant: 'primary',
|
|
560
726
|
style: CSS.footerAction,
|
|
561
|
-
disabled: loc.path === '' || state.creating,
|
|
727
|
+
disabled: loc.path === '' || state.creating || pairState.paired !== true,
|
|
562
728
|
onClick: openDesktop,
|
|
563
729
|
},
|
|
564
730
|
state.creating ? '正在电脑上新建…' : '在此新建对话',
|
package/lib/index.js
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
50
|
import { createHash, randomUUID } from 'node:crypto';
|
|
51
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
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
|
*
|
|
@@ -723,9 +812,31 @@ async function handleBridge(ctx, config, io) {
|
|
|
723
812
|
case 'isRemote':
|
|
724
813
|
return { remote: io.remoteSessions.has(params.sessionId) };
|
|
725
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
|
+
|
|
726
837
|
default:
|
|
727
838
|
throw new Error(`unknown method "${String(io.body?.method)}"`);
|
|
728
839
|
}
|
|
729
840
|
}
|
|
730
841
|
|
|
731
|
-
export { callRemote, mirrorEvent, loadConfig, DEFAULT_ENDPOINT, CONFIG_PATH };
|
|
842
|
+
export { callRemote, mirrorEvent, loadConfig, DEFAULT_ENDPOINT, CONFIG_PATH, PAIR_ENDPOINT, claimPairing };
|