dsh-jiuling-remote-mobile 1.0.9 → 1.0.11

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 +106 -7
  2. package/lib/index.js +73 -13
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -135,6 +135,12 @@ window.__ModuleLoader__.load({
135
135
  'flex:1 1 100%;min-width:0;' +
136
136
  'color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;',
137
137
  pairInput: 'width:140px;text-transform:uppercase;letter-spacing:2px;',
138
+ // 解绑二次确认那一行:提示文字占满整行,两个按钮跟在下面(手机上 360px
139
+ // 放不下「文字 + 两个按钮」一行,所以让文字 `flex:1 1 100%` 自己换行)。
140
+ pairConfirm:
141
+ 'display:flex;flex-wrap:wrap;align-items:center;gap:8px;' +
142
+ 'font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);',
143
+ pairConfirmText: 'flex:1 1 100%;',
138
144
  pairMsg: 'padding:0 4px 8px;font-size:12px;line-height:18px;',
139
145
  pairOk: 'color:var(--dsw-alias-state-success-primary);',
140
146
  };
@@ -313,9 +319,17 @@ window.__ModuleLoader__.load({
313
319
  * 配对状态(与目录浏览无关,所以单独一个 state)。
314
320
  *
315
321
  * `endpoint === null` 表示还没查过;`paired === false` 表示
316
- * 手机现在用的还是内置的默认地址与令牌,需要用户去电脑上取一个配对码。
322
+ * 手机还没有任何凭据(令牌为空),需要用户去电脑上取一个配对码。
317
323
  */
318
- var pairPair = react.useState({ endpoint: null, paired: false, code: '', busy: false, message: null });
324
+ var pairPair = react.useState({
325
+ endpoint: null,
326
+ paired: false,
327
+ code: '',
328
+ busy: false,
329
+ // 「重新配对」的两步确认:`true` 时才显示「确定解绑 / 取消」。
330
+ confirming: false,
331
+ message: null,
332
+ });
319
333
  var pairState = pairPair[0];
320
334
  var setPairState = pairPair[1];
321
335
 
@@ -372,10 +386,9 @@ window.__ModuleLoader__.load({
372
386
  react.useEffect(
373
387
  function () {
374
388
  if (!open) return undefined;
375
- // ⚠️ **没配对就别去问电脑要目录**。未配对时 `listDesktop`
376
- // 会拿着内置的默认地址打过去,那台「电脑」并不存在 ——
377
- // 用户看到的是转圈 10 秒后一句「远程请求失败: HTTP 502」,
378
- // 完全不知道要先去配对。这里直接给出可操作的提示。
389
+ // ⚠️ **没配对就别去问电脑要目录**。未配对时手机没有任何凭据,
390
+ // 打过去必然是 401/502 —— 用户看到的是转圈 10 秒后一句
391
+ // 「远程请求失败」,完全不知道要先去配对。这里直接给出可操作的提示。
379
392
  if (tab === 'desktop' && pairState.paired !== true) {
380
393
  patch({ loading: false, listing: null, error: null });
381
394
  return undefined;
@@ -453,6 +466,92 @@ window.__ModuleLoader__.load({
453
466
  );
454
467
  };
455
468
 
469
+ /**
470
+ * 解除配对:清掉本地凭据,退回未配对状态。
471
+ *
472
+ * 成功后 `paired` 翻回 `false`,配对条自己就会换成输入框(同一个
473
+ * `pairState`),所以这里不用手工重挂组件。
474
+ */
475
+ var submitUnpair = function () {
476
+ if (pairState.busy) return;
477
+ patchPair({ busy: true, message: null });
478
+ bridge('unpair', {}).then(
479
+ function (result) {
480
+ if (!aliveRef.current) return;
481
+ patchPair({
482
+ busy: false,
483
+ confirming: false,
484
+ paired: false,
485
+ endpoint: result.endpoint,
486
+ code: '',
487
+ message: { kind: 'ok', text: '已解绑,请到电脑上生成新的配对码' },
488
+ });
489
+ patch({ path: null, listing: null });
490
+ },
491
+ function (reason) {
492
+ if (!aliveRef.current) return;
493
+ patchPair({
494
+ busy: false,
495
+ message: { kind: 'error', text: String((reason && reason.message) || reason) },
496
+ });
497
+ },
498
+ );
499
+ };
500
+
501
+ /**
502
+ * 已配对时的「重新配对」入口。
503
+ *
504
+ * ⚠️ **这是必需的回退路径,不是便利功能。** 电脑端一旦「解绑」,它会换掉
505
+ * 令牌并停掉隧道;而手机这边 `mobile.json` 里还留着旧令牌,`pairStatus`
506
+ * 会一直报「已配对」,于是输入框永远不渲染 —— 用户卡在 502,界面上无路
507
+ * 可走,只能手工删 `mobile.json` 或清 App 数据。补上这个入口,回退才闭环。
508
+ *
509
+ * 两步确认:解绑会停隧道、清远程会话清单,误触代价太大。
510
+ */
511
+ var unpairControls = function () {
512
+ if (pairState.confirming !== true) {
513
+ return h(
514
+ Button,
515
+ {
516
+ variant: 'ghost',
517
+ size: 'sm',
518
+ onClick: function () {
519
+ patchPair({ confirming: true, message: null });
520
+ },
521
+ disabled: pairState.busy,
522
+ },
523
+ '重新配对',
524
+ );
525
+ }
526
+ return h(
527
+ 'span',
528
+ { style: CSS.pairConfirm },
529
+ h('span', { style: CSS.pairConfirmText }, '解绑后这台手机要重新输入配对码。'),
530
+ h(
531
+ Button,
532
+ {
533
+ variant: 'outline',
534
+ size: 'sm',
535
+ onClick: submitUnpair,
536
+ disabled: pairState.busy,
537
+ },
538
+ pairState.busy ? '解绑中…' : '确定解绑',
539
+ ),
540
+ h(
541
+ Button,
542
+ {
543
+ variant: 'ghost',
544
+ size: 'sm',
545
+ onClick: function () {
546
+ patchPair({ confirming: false });
547
+ },
548
+ disabled: pairState.busy,
549
+ },
550
+ '取消',
551
+ ),
552
+ );
553
+ };
554
+
456
555
  /**
457
556
  * 「电脑」页签顶部的配对条。
458
557
  *
@@ -476,7 +575,7 @@ window.__ModuleLoader__.load({
476
575
  '还没配对。请在电脑的「设置 → 手机远程」里生成 6 位配对码,填在这里。',
477
576
  ),
478
577
  pairState.paired === true
479
- ? null
578
+ ? unpairControls()
480
579
  : h(Input, {
481
580
  value: pairState.code,
482
581
  onChange: function (event) {
package/lib/index.js CHANGED
@@ -105,24 +105,41 @@ function saveConfig(endpoint, token) {
105
105
  }
106
106
 
107
107
  /**
108
- * 内置访问令牌 —— **必须与 `dsh-jiuling-remote-desktop` 里的内置值逐字相同**。
108
+ * 令牌为空 —— 表示**尚未配对**。
109
+ *
110
+ * ⚠️ 早期这里硬编码了一个「内置访问令牌」,让两端装完即连。那是真实漏洞:
111
+ * 常量随 npm 包公开,任何未配对的电脑都跑在**同一个公开令牌**上,谁都能连上去
112
+ * 读他的电脑(实测用包内公开令牌打公网隧道 → HTTP 200,读到 177 个目录条目)。
113
+ * 现在**包里不含任何凭据**:令牌只能由配对流程从服务器 claim 得到(见
114
+ * `claimPairing`),未配对时就是空字符串,`callRemote` / `follow` 会直接报
115
+ * 「尚未配对」,不会静默用一个假令牌去连。
116
+ */
117
+ const EMPTY_TOKEN = '';
118
+
119
+ /**
120
+ * **已作废**的旧公开令牌 —— 只用于识别,**不是凭据**。
109
121
  *
110
- * 两端同值 ⇒ 装完即可连通,用户不用手工同步任何东西。想隔离就改这个文件里的
111
- * `token` 字段,并把电脑端 `<DSH_HOME>/dsh-remote/token.txt` 改成一样的值。
122
+ * 1.0.9 及更早的版本把它当默认令牌写进 `mobile.json`。老用户升级后文件里还留着
123
+ * 这个值,若不识别就会被 `pairStatus` 判成「已配对」,而实际上电脑端早已改用随机
124
+ * 令牌 —— 症状是「显示已配对,一发消息就 401」。所以见到它就当成未配对。
112
125
  */
113
- const DEFAULT_TOKEN = 'f6721b0cf82414709df172056193c78a6c374e9a920e51145dd7883923cf16e7';
126
+ const LEGACY_PUBLIC_TOKEN = 'f6721b0cf82414709df172056193c78a6c374e9a920e51145dd7883923cf16e7';
114
127
 
115
128
  /**
116
129
  * 读配置。首次运行写一份模板,用户直接改文件即可。
117
130
  *
118
- * `token` 为空时退回内置值(与电脑端同值),所以**装完就能连**。
131
+ * ⚠️ 这里**没有**任何内置令牌兜底(早期有,是漏洞)。`token` 为空即「未配对」,
132
+ * 调用方据此直接拒绝,而不是拿一个包里公开的常量去连。
119
133
  *
120
134
  * @returns `{ endpoint, token }`。
121
135
  */
122
136
  function loadConfig() {
123
137
  try {
124
138
  const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
125
- const token = typeof parsed.token === 'string' && parsed.token !== '' ? parsed.token : DEFAULT_TOKEN;
139
+ // ⚠️ 旧版写进文件的公开令牌要当成**未配对**,否则界面显示已配对、
140
+ // 实际一调用就 401(电脑端已换随机令牌)。
141
+ const raw = typeof parsed.token === 'string' ? parsed.token : EMPTY_TOKEN;
142
+ const token = raw === LEGACY_PUBLIC_TOKEN ? EMPTY_TOKEN : raw;
126
143
  return {
127
144
  endpoint:
128
145
  typeof parsed.endpoint === 'string' && parsed.endpoint !== ''
@@ -135,13 +152,13 @@ function loadConfig() {
135
152
  mkdirSync(STATE_DIR, { recursive: true });
136
153
  writeFileSync(
137
154
  CONFIG_PATH,
138
- JSON.stringify({ endpoint: DEFAULT_ENDPOINT, token: DEFAULT_TOKEN }, null, 2),
155
+ JSON.stringify({ endpoint: DEFAULT_ENDPOINT, token: EMPTY_TOKEN }, null, 2),
139
156
  { mode: 0o600 },
140
157
  );
141
158
  } catch {
142
159
  /* 只读环境:用默认值继续 */
143
160
  }
144
- return { endpoint: DEFAULT_ENDPOINT, token: DEFAULT_TOKEN };
161
+ return { endpoint: DEFAULT_ENDPOINT, token: EMPTY_TOKEN };
145
162
  }
146
163
  }
147
164
 
@@ -218,7 +235,8 @@ async function remoteFetch(url, init) {
218
235
  */
219
236
  async function callRemote(config, method, params = {}) {
220
237
  if (config.token === '') {
221
- throw new Error(`未配置访问令牌:请在 ${CONFIG_PATH} 里填 token(电脑端启动日志会打印它)`);
238
+ // 未配对 = 没有令牌。**不要**提示用户去手改文件:面向大众的主路径是配对。
239
+ throw new Error('还没配对:请在电脑的「设置 → 手机远程」里生成配对码,再到「添加工作区 → 电脑」填进去');
222
240
  }
223
241
  const res = await remoteFetch(`${config.endpoint}/api/dsh-remote/rpc`, {
224
242
  method: 'POST',
@@ -352,7 +370,7 @@ export function apply(ctx) {
352
370
  const config = loadConfig();
353
371
  ctx.logger?.info?.(`[dsh-remote-mobile] 远端: ${config.endpoint}`);
354
372
  if (config.token === '') {
355
- ctx.logger?.warn?.(`[dsh-remote-mobile] 未配置令牌,请在 ${CONFIG_PATH} 里填 token`);
373
+ ctx.logger?.info?.('[dsh-remote-mobile] 尚未配对:请到「添加工作区 → 电脑」填配对码(配对后自动连接)');
356
374
  }
357
375
 
358
376
  /** 远程会话 id 集合:这些会话的 prompt 转发电脑,不在手机本地跑模型。 */
@@ -675,6 +693,9 @@ export function apply(ctx) {
675
693
  const result = await handleBridge(ctx, config, {
676
694
  remoteSessions,
677
695
  follow,
696
+ // 解绑要能停掉全部旧连接、清掉全部搬运状态(见 `unpair`)。
697
+ follows,
698
+ mirrorStates,
678
699
  persist: () => saveRemoteSessions(remoteSessions, ctx.logger),
679
700
  body,
680
701
  });
@@ -814,7 +835,7 @@ async function ensureDesktopWorkspace(ctx, desktopPath) {
814
835
  *
815
836
  * @param ctx - Cordis 上下文。
816
837
  * @param config - 远程配置。
817
- * @param io - `{ remoteSessions, follow, persist, body }`。
838
+ * @param io - `{ remoteSessions, follow, follows, mirrorStates, persist, body }`。
818
839
  */
819
840
  async function handleBridge(ctx, config, io) {
820
841
  const params = io.body?.params ?? {};
@@ -838,7 +859,7 @@ async function handleBridge(ctx, config, io) {
838
859
  throw new Error('请先在电脑页签里选一个目录');
839
860
  }
840
861
  if (config.token === '') {
841
- throw new Error(`未配置访问令牌:请在 ${CONFIG_PATH} 里填 token`);
862
+ throw new Error('还没配对:请先在「电脑」页签里填配对码');
842
863
  }
843
864
  const sessionId = `session-${randomUUID()}`;
844
865
  // ⚠️ 手机端 cwd 必须是**手机本地**的绝对路径,绝不把电脑路径传进来
@@ -928,7 +949,46 @@ async function handleBridge(ctx, config, io) {
928
949
 
929
950
  /** 当前连的是哪台电脑(前端用来显示状态)。 */
930
951
  case 'pairStatus':
931
- return { endpoint: config.endpoint, paired: config.token !== DEFAULT_TOKEN };
952
+ // 「已配对」的判据是**有令牌**。早期拿「令牌不等于内置常量」当判据,
953
+ // 那个常量已删(它是公开凭据,见 EMPTY_TOKEN 的注释)。
954
+ return { endpoint: config.endpoint, paired: config.token !== EMPTY_TOKEN };
955
+
956
+ /**
957
+ * 解除配对:清掉本地凭据,退回未配对状态,让用户能重新填配对码。
958
+ *
959
+ * ⚠️ **这个方法是必需的回退路径,不是便利功能**。电脑端一旦「解绑」,
960
+ * 它会换掉令牌并停掉隧道;手机这边 `mobile.json` 里还留着旧令牌,
961
+ * `pairStatus` 就一直报「已配对」⇒ 前端那条「已配对」横幅永远挂着、
962
+ * **输入框根本不渲染**(见 client.js 里 `paired === true` 的分支)。
963
+ * 结果就是:电脑解绑后手机永久卡在 502,界面上没有任何重新配对的入口,
964
+ * 只能手工删 `mobile.json` 或清 App 数据。
965
+ *
966
+ * 清理分三部分,缺一不可:
967
+ * ① 凭据 —— `config` 就地清空 + 写盘,否则重启后旧令牌又回来了;
968
+ * ② 连接 —— 停掉所有跟随旧电脑的事件流,否则它们会拿着旧令牌
969
+ * 无限重连(退避到 15s 一次)一直打一个已经不属于自己的域名;
970
+ * ③ 会话归属 —— `remoteSessions` 里的会话原本靠「转发电脑」工作,
971
+ * 解绑后电脑那边可能已经不存在了,继续留着会让这些会话的 prompt
972
+ * 走 `callRemote` 报错。一并清掉,它们就退回普通本地会话。
973
+ *
974
+ * @returns 清空后的 `{ endpoint, paired: false }`。
975
+ */
976
+ case 'unpair': {
977
+ Object.assign(config, saveConfig(DEFAULT_ENDPOINT, EMPTY_TOKEN));
978
+ for (const controller of io.follows.values()) {
979
+ try {
980
+ controller.abort();
981
+ } catch {
982
+ /* 已经结束了 */
983
+ }
984
+ }
985
+ io.follows.clear();
986
+ io.mirrorStates.clear();
987
+ io.remoteSessions.clear();
988
+ io.persist();
989
+ ctx.logger?.info?.('[dsh-remote-mobile] 已解除配对,本地凭据与远程会话清单已清空');
990
+ return { endpoint: config.endpoint, paired: false };
991
+ }
932
992
 
933
993
  default:
934
994
  throw new Error(`unknown method "${String(io.body?.method)}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-jiuling-remote-mobile",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "DSH 远程目录(手机端):在选文件夹流程里多出「电脑」页签,进入电脑目录、在电脑上发起对话,并把电脑的实时消息流同步到手机本地界面(只传文本,不映射桌面)。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",