dsh-jiuling-remote-mobile 1.0.8 → 1.0.10

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 CHANGED
@@ -313,7 +313,7 @@ window.__ModuleLoader__.load({
313
313
  * 配对状态(与目录浏览无关,所以单独一个 state)。
314
314
  *
315
315
  * `endpoint === null` 表示还没查过;`paired === false` 表示
316
- * 手机现在用的还是内置的默认地址与令牌,需要用户去电脑上取一个配对码。
316
+ * 手机还没有任何凭据(令牌为空),需要用户去电脑上取一个配对码。
317
317
  */
318
318
  var pairPair = react.useState({ endpoint: null, paired: false, code: '', busy: false, message: null });
319
319
  var pairState = pairPair[0];
@@ -372,10 +372,9 @@ window.__ModuleLoader__.load({
372
372
  react.useEffect(
373
373
  function () {
374
374
  if (!open) return undefined;
375
- // ⚠️ **没配对就别去问电脑要目录**。未配对时 `listDesktop`
376
- // 会拿着内置的默认地址打过去,那台「电脑」并不存在 ——
377
- // 用户看到的是转圈 10 秒后一句「远程请求失败: HTTP 502」,
378
- // 完全不知道要先去配对。这里直接给出可操作的提示。
375
+ // ⚠️ **没配对就别去问电脑要目录**。未配对时手机没有任何凭据,
376
+ // 打过去必然是 401/502 —— 用户看到的是转圈 10 秒后一句
377
+ // 「远程请求失败」,完全不知道要先去配对。这里直接给出可操作的提示。
379
378
  if (tab === 'desktop' && pairState.paired !== true) {
380
379
  patch({ loading: false, listing: null, error: null });
381
380
  return undefined;
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 转发电脑,不在手机本地跑模型。 */
@@ -554,6 +572,102 @@ export function apply(ctx) {
554
572
  };
555
573
  }, 'dsh-remote-mobile: prompt/cancel 拦截');
556
574
 
575
+ // ---- 屏蔽远程会话在手机本地的标题生成 ----
576
+ //
577
+ // ⚠️ 官方 `@deepseek-ai/dsh-session-title/lib/index.js` 的
578
+ // `ctx.on("session/event")` 对**任何** `source.kind === 'user'` 的
579
+ // `user/message` 都会在**本地**写一条 `session/title`
580
+ //(`onUserMessage()` → `ensureFallback()`,源码 379-403 行),并在首轮再置
581
+ // `state.pending`,让标题 LLM 补一条 `session/title-llm-request`
582
+ //(`onRequestHeader()` 消费,源码 386 行那个条件)。
583
+ //
584
+ // 手机本地日志是电脑日志的**逐条镜像**,`mirrorEvent` 靠 seq 折算对齐
585
+ //(见其注释)。镜像进来的 `user/message` 同样满足那个条件,于是手机端
586
+ // **本地多写 2 条**事件,把电脑端同一 seq 上的 2 条真实事件挤掉 —— 而
587
+ // `mirrorEvent` 对「折算 seq < 本地 seq」的事件是按「重连补发的历史」
588
+ // **静默丢弃**的,被挤掉的那 2 条在手机上永久缺失。
589
+ //
590
+ // 真机实测(探针会话 `session-dc52fa34`,两轮对话,`_test/compare-logs.mjs`):
591
+ // 电脑端 41 条、手机端 41 条,seq 0-40 只有 **seq 9 与 seq 16** 不一致,
592
+ // 丢的是电脑端的 workspace 说明(`system-reminder` 那条 `user/message`)
593
+ // 与电脑端自己的 `session/title`,而手机端那两格是**本地生成**的
594
+ // (phone seq=9 `time=1792088906625` `source:{kind:"fallback"}`,与电脑端
595
+ // 同一条的 `time` 不同 ⇒ 确认不是镜像来的)。本轮丢的是说明文字,
596
+ // 时序一变被挤掉的就会是 `assistant/message` —— 也就是**用户看不到 AI 回复**。
597
+ //
598
+ // 标题的真源在电脑端:电脑端生成后会把 `session/title` 流过来,所以远程
599
+ // 会话在手机本地**一律不生成**标题。这样本地 seq 不再被抢占,两端日志
600
+ // 恢复逐条一致,也省掉一次白烧用户额度的本地标题 LLM 调用。
601
+ ctx.effect(() => {
602
+ /** 已完成的还原函数(插件卸载时逐个执行)。 */
603
+ const undo = [];
604
+ let done = false;
605
+
606
+ /**
607
+ * 给标题服务挂上「远程会话直接短路」的包装。
608
+ *
609
+ * @param titleService - `ctx.sessionTitle` 实例;未就绪时为 undefined。
610
+ * @returns 是否已经完成(含此前已完成)。
611
+ */
612
+ const patchTitleService = (titleService) => {
613
+ if (done) return true;
614
+ if (titleService === undefined || typeof titleService.onUserMessage !== 'function') {
615
+ return false;
616
+ }
617
+ const original = titleService.onUserMessage;
618
+ // ⚠️ 必须用 `function` 而不是箭头函数:官方是 `this.onUserMessage(...)`
619
+ // 这样调的,箭头函数会让 `this` 丢失、`original.call(this, ...)` 里的
620
+ // `this` 落成 undefined,标题服务内部读 `this.registration` 即抛错。
621
+ titleService.onUserMessage = function (session, event) {
622
+ if (remoteSessions.has(session.id)) return undefined;
623
+ return original.call(this, session, event);
624
+ };
625
+ undo.push(() => {
626
+ titleService.onUserMessage = original;
627
+ });
628
+ done = true;
629
+ return true;
630
+ };
631
+
632
+ // ⚠️ 两条路径都各自包 try/catch:屏蔽失败只是退化成「远端事件可能错位」,
633
+ // 但**绝不能拖垮整个插件** —— 那会让远程功能完全不可用,比错位严重得多。
634
+ //(`ctx.get(name)` 在服务未注册时返回 `undefined`,不抛错;这里防的是
635
+ // `ctx.get` / `ctx.inject` 本身在引擎版本变化后不再可用。)
636
+ //
637
+ // ⚠️ **两条路要分开 try**:若合在一个 try 里,`ctx.get` 一旦抛错,
638
+ // `ctx.inject` 这条「服务迟一点才注册」的兜底路径会被一并跳过 ——
639
+ // 守卫就这么**静默失效**了,而且日志里只有一条 warn,很难看出少兜了一次。
640
+ const warn = (error) =>
641
+ ctx.logger?.warn?.(
642
+ `[dsh-remote-mobile] 未能屏蔽本地标题生成,远端事件镜像可能错位: ${String(
643
+ error?.message ?? error,
644
+ )}`,
645
+ );
646
+
647
+ // 路径一:服务通常在本插件加载前就已注册,先同步打一次。
648
+ let patched = false;
649
+ try {
650
+ patched = patchTitleService(ctx.get('sessionTitle'));
651
+ } catch (error) {
652
+ warn(error);
653
+ }
654
+
655
+ // 路径二:还没就绪(或路径一失败)则交给依赖注入,等服务注册后再打。
656
+ if (!patched) {
657
+ try {
658
+ ctx.inject(['sessionTitle'], (scoped) => {
659
+ patchTitleService(scoped.sessionTitle);
660
+ });
661
+ } catch (error) {
662
+ warn(error);
663
+ }
664
+ }
665
+
666
+ return () => {
667
+ for (const fn of undo) fn();
668
+ };
669
+ }, 'dsh-remote-mobile: 屏蔽远程会话的本地标题生成');
670
+
557
671
  // ---- 浏览器桥:目录浏览 / 建会话 ----
558
672
  ctx.effect(
559
673
  () =>
@@ -742,7 +856,7 @@ async function handleBridge(ctx, config, io) {
742
856
  throw new Error('请先在电脑页签里选一个目录');
743
857
  }
744
858
  if (config.token === '') {
745
- throw new Error(`未配置访问令牌:请在 ${CONFIG_PATH} 里填 token`);
859
+ throw new Error('还没配对:请先在「电脑」页签里填配对码');
746
860
  }
747
861
  const sessionId = `session-${randomUUID()}`;
748
862
  // ⚠️ 手机端 cwd 必须是**手机本地**的绝对路径,绝不把电脑路径传进来
@@ -832,7 +946,9 @@ async function handleBridge(ctx, config, io) {
832
946
 
833
947
  /** 当前连的是哪台电脑(前端用来显示状态)。 */
834
948
  case 'pairStatus':
835
- return { endpoint: config.endpoint, paired: config.token !== DEFAULT_TOKEN };
949
+ // 「已配对」的判据是**有令牌**。早期拿「令牌不等于内置常量」当判据,
950
+ // 那个常量已删(它是公开凭据,见 EMPTY_TOKEN 的注释)。
951
+ return { endpoint: config.endpoint, paired: config.token !== EMPTY_TOKEN };
836
952
 
837
953
  default:
838
954
  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.8",
3
+ "version": "1.0.10",
4
4
  "description": "DSH 远程目录(手机端):在选文件夹流程里多出「电脑」页签,进入电脑目录、在电脑上发起对话,并把电脑的实时消息流同步到手机本地界面(只传文本,不映射桌面)。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",