dsh-crossterm 0.8.7 → 0.8.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.8.9 — 2026-10-19
4
+
5
+ ### 第一个组件从「总开关」改成「自更新开关」
6
+
7
+ 来源:User said (m10846):**"把第一个组件总开关改为自更新开关,"**;追问后用户选「自动更新开关,四个业务组件不再受它约束」+「默认关」。
8
+
9
+ - **语义变更**:入口行(裸包名 `dsh-crossterm`,`config.part: ui`)不再 provide `crossterm` 服务,
10
+ 也不再约束另外四个组件。以前关掉它 → 服务消失 → 跨会话/跨终端/跨设备/跨账号的 `inject` 回调根本不执行
11
+ (工具、心跳、云同步、中转站、会话备份一起停);0.8.9 起五行一律 `root.inject(['agents'], …)`,各挂各的,
12
+ 关掉入口行只失去「配置卡片 + 自动更新」。
13
+ - **自动更新**:`lib/web.js` 新增导出 `selfUpdate(term)` —— `profileInfo()` 定位 profile、
14
+ `installMode() === 'link'` 直接跳过(源码版不自动覆盖)、`latestVersion()` 读不到返回失败、
15
+ `compareVersion(latest, VERSION) <= 0` 视为已最新,否则走既有 `updatePlugin()`(dsh → cli → pnpm 三级兜底)。
16
+ 装完**不自动重启**,只回 `needRestart: true`,由卡片/日志提示「重启 DSH 后生效」。
17
+ - **定时器**:`lib/index.js` 新增 `autoUpdateStatus()` / `enableAutoUpdate({ immediate })` / `disableAutoUpdate()` /
18
+ `autoUpdateOnce()`;`start()` 在 `cfg.autoUpdate` 为真时启动(首次 30 s 后查一次,之后按周期),`stop()` 清定时器。
19
+ 结果记 `this.autoLast` 并写 `logs/update.log`。
20
+ - **配置**:`bus.js` DEFAULTS 新增 `autoUpdate: false`、`autoUpdateIntervalMs: 21600000`(6 小时);
21
+ `readConfig()` 把 `autoUpdate` 归一为布尔、`autoUpdateIntervalMs` 夹到 1 分钟–7 天。
22
+ - **接口**:`status` 新增 `autoUpdate: {enabled, running, intervalMs, last}`;
23
+ 新增 `POST /crossterm/api/auto-update/start|stop`(要 `x-crossterm-token`,写 `config.json` 并即时生效)。
24
+ - **界面**:卡片新增「自动更新」一行 + 复用云同步样式的开关;开启时显示周期与上次结果
25
+ (已装 / 已是最新 / 失败原因),关闭时显示「已关闭(打开后自动检查并安装新版本)」。
26
+ - **文案**:`locale/zh.json` 标题改「跨端互通(自更新)」、`en.json` 改 `CrossTerm (self-update)`;
27
+ `cordis.patch.yml` 顶部说明与 ui 行注释同步改写。
28
+
29
+ ## 0.8.8 — 2026-10-19
30
+
31
+ ### 「维护者」一栏只给维护端看
32
+
33
+ - **问题**:0.8.5 加的「维护者」行任何设备装上插件都会显示负责人是谁,而这是维护端信息,
34
+ 别的机器不需要看到。
35
+ - **判断规则**:新增导出纯函数 `maintainerVisible(term, doc)` —— 本机 `nodeId` 必须命中
36
+ `MAINTAINERS.md` 机器可读注释里的 `current` 或 `assistant` 条目(负责人与助手同机时都算
37
+ 「本机授权端」);配置 `maintainerDevices`(`bus.js` DEFAULTS 新增,默认 `[]`)可额外放行设备。
38
+ - **接口**:`GET /crossterm/api/about` 的 `maintainers` 增加 `visible`(布尔)与 `me`(本机 nodeId),
39
+ 客户端据此决定渲不渲染该行;负责人换机器只改 `MAINTAINERS.md` 的 `current.nodeId`,不用改代码。
40
+ - **测试**:`F:\dsh\001X\crossterm-work\probe-maintainer-visible.mjs` 15 项全过
41
+ (维护端可见、其他设备不可见、助手同机可见、显式授权可见、换机后本机隐藏、无文档/无 nodeId/cfg 为 null 等边界)。
42
+
3
43
  ## 0.8.7 — 2026-10-19
4
44
 
5
45
  ### 修 0.8.6 的回归:云端设备列表整个消失
package/README.md CHANGED
@@ -12,46 +12,53 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
12
12
 
13
13
  ## 零、简介与本版更新(每次发版重写这一节)
14
14
 
15
- **插件名称**:`dsh-crossterm`(插件页显示 **跨端互通(总开关)**)| **插件简介**:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份。
15
+ **插件名称**:`dsh-crossterm`(插件页显示 **跨端互通(自更新)**)| **插件简介**:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份。
16
16
 
17
- **组件列表(插件页第一个是总开关,后面四个是业务组件,名称与简介来自各自的 manifest)**
17
+ **组件列表(插件页第一个是入口/自更新,后面四个是业务组件,名称与简介来自各自的 manifest)**
18
18
 
19
19
  | 顺序 | 组件名称 | 作用 | 工具 |
20
20
  |---|---|---|---|
21
- | ① | 跨端互通(总开关) | 插件总开关:关掉它,下面四个组件全部停用(工具、心跳、云同步、中转站、备份一起停);开着时四个组件可各自单独开关。裸包名行,承载插件标题/简介与配置卡片,不挂工具 | — |
21
+ | ① | 跨端互通(自更新) | 自更新开关:打开后定期检查 npm 新版本并自动安装(默认关;装完要重启 DSH 生效)。关掉它只失去卡片与自动更新,下面四个组件照常工作。裸包名行,承载插件标题/简介与配置卡片,不挂工具 | — |
22
22
  | ② | 跨会话 | 同一终端里的多个会话之间互通:特征码链、内联邮件、收件箱 | `term_link` `term_unlink` `term_broadcast` `term_inbox` `term_config` |
23
23
  | ③ | 跨终端 | 同一台机器上的多个 DSH 终端/进程之间互发邮件与文件 | `term_whoami` `term_list` `term_send` `term_send_files` |
24
24
  | ④ | 跨设备 | 经云端总线,在多台机器的 DSH 之间互发邮件与文件 | `term_cloud` |
25
25
  | ⑤ | 跨账号 | 预留位:后续用于跨账号互通,当前不挂工具 | — |
26
26
 
27
- > 总开关为什么必须用裸包名:浏览器 client 半端只对裸包名行生效(`dsh-client-modules` 的
27
+ > 入口行为什么必须用裸包名:浏览器 client 半端只对裸包名行生效(`dsh-client-modules` 的
28
28
  > `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
29
- > 所以它单独做总开关,标题带「(总开关)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
29
+ > 所以它单独做入口(配置卡片 + 自动更新开关),标题带「(自更新)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
30
30
 
31
- **本版更新(0.8.7)**
31
+ **本版更新(0.8.9)**
32
32
 
33
- - **修 0.8.6 的回归**:0.8.6 装上后卡片「云端设备」整块消失,接口报
34
- `layout is not defined`(路由里把 `layout` 声明在 `try` 块内、块外又用了它)。
35
- 0.8.7 已修;本来就还没升 0.8.6 的设备可以直接装 0.8.7。
36
- - **合并逻辑变成可测的纯函数**:`mergeDevices({ old, fresh, selfId, ownerDevice })` 负责
37
- 「老总线镜像 + 新布局心跳」的合并、排除本机、补管理员/成员角色、按心跳倒序,
38
- 路由不再自己拼装,配套 20 项单测。
39
- - **离线时间排序口径统一**:`annotateDevices()` 把 `at → lastSeen → updatedAt` 归一成毫秒,
40
- 列表按它倒序,不会再把 ISO 字符串和毫秒时间戳混着比。
41
- - 纯展示与拼装修复,装上重启即可,不用改配置。
33
+ - **第一个组件从「总开关」改成「自更新开关」**:它只管「有没有新版本、要不要自动装」,
34
+ 不再管另外四个组件。以前关掉它会连坐停掉工具、心跳、云同步、中转站与备份;现在关掉它只失去卡片与自动更新,
35
+ 跨会话 / 跨终端 / 跨设备 / 跨账号 各自独立开关、照常工作。
36
+ - **自动更新默认关**:卡片新增「自动更新」一行与开关,打开后按 `autoUpdateIntervalMs`(默认 6 小时)检查
37
+ npm 最新版并自动安装,结果写进 `logs/update.log`。装完**不会自己重启**,卡片提示「重启 DSH 后生效」;
38
+ 关掉后仍可手动点「检查更新 / 更新」。
39
+ - **服务注入拆掉**:0.7.x–0.8.8 是 `ui` 行 `root.provide('crossterm')`、其余四行 `inject` 它;
40
+ 0.8.9 起五行都只 `inject(['agents'])`,各挂各的。
41
+ - 新增配置:`autoUpdate`(默认 `false`)、`autoUpdateIntervalMs`(默认 `21600000`,即 6 小时,范围 1 分钟–7 天)。
42
42
 
43
- **上一版(0.8.6)**
43
+ **上一版(0.8.8)**
44
44
 
45
+ - **「维护者」一栏只给维护端看**:以前任何设备装上插件都能看到负责人是谁;现在只在维护端显示 ——
46
+ 本机 `nodeId` 必须出现在 `MAINTAINERS.md` 的负责人/助手条目里(两者在同一台机器上时都算),
47
+ 或用配置 `maintainerDevices` 显式放行。其他设备上这行直接不渲染。
48
+ - **换机器只改一个字段**:负责人换机器时,把 `MAINTAINERS.md` 里 `current.nodeId` 改成新机器,
49
+ 旧机器下次刷新卡片自动隐藏,不用改代码。
50
+
51
+ **更早(0.8.7 / 0.8.6 / 0.8.5 / 0.8.2 / 0.8.0)**
52
+
53
+ - 0.8.7:修 0.8.6 的回归(卡片「云端设备」整块消失,接口报 `layout is not defined`);
54
+ 合并逻辑变成可测纯函数 `mergeDevices({ old, fresh, selfId, ownerDevice })`,配套 20 项单测;
55
+ 离线时间按 `at → lastSeen → updatedAt` 先归一毫秒再排。
45
56
  - **云端设备只列「别人」**:卡片里那行不再显示本机(自己已经在「当前终端」一栏),
46
57
  只列同一账号下的其他设备。
47
58
  - **每台带权限和状态**:逐台显示 `名称 · 管理员/成员 · 在线/离线(x 分钟前)· v版本号`;
48
59
  管理员就是云端 `_account.json` 里 `ownerDevice` 那台,其余都是成员。
49
- - **离线时间不再显示「未知」**:以前只认 `updatedAt`,新布局心跳写的是 `at`,
50
- 离线设备一律「未知」;现在按 `updatedAt → at → lastSeen` 依次取。
51
60
  - `term_list` 的云端行也带角色。
52
61
 
53
- **更早(0.8.5 / 0.8.2 / 0.8.0)**
54
-
55
62
  - 0.8.5:负责人留痕 `MAINTAINERS.md`、运行日志可轮转(5 MB 压 `logs/archive/` 留 20 份)、
56
63
  卡片「维护者」行与「运行日志」按钮、`GET /crossterm/api/logs`。
57
64
  - 0.8.2:死信按 20s/1m/5m/15m/1h 退避,8 次后进 `dead/`;收件人写设备名会回退「最近活跃会话」;
package/client/client.js CHANGED
@@ -356,6 +356,21 @@ window.__ModuleLoader__.load({
356
356
  }
357
357
  };
358
358
 
359
+ const onToggleAutoUpdate = async () => {
360
+ const enabled = Boolean(status && status.autoUpdate && status.autoUpdate.enabled);
361
+ setBusy("auto");
362
+ setMsg(enabled ? "正在关闭自动更新…" : "正在开启自动更新…");
363
+ try {
364
+ const data = await call(enabled ? "/crossterm/api/auto-update/stop" : "/crossterm/api/auto-update/start", "POST");
365
+ setMsg((data && data.message) || (data && data.ok ? "已切换" : "未能切换"));
366
+ if (data && data.autoUpdate && status) setStatus({ ...status, autoUpdate: data.autoUpdate });
367
+ } catch (error) {
368
+ setMsg("切换失败:" + String((error && error.message) || error));
369
+ } finally {
370
+ setBusy("");
371
+ }
372
+ };
373
+
359
374
  const onSetupCloud = async () => {
360
375
  setBusy("setup");
361
376
  setMsg("正在打开记事本…把 AppKey / AppSecret 填进引号里,保存并关闭记事本");
@@ -428,6 +443,7 @@ window.__ModuleLoader__.load({
428
443
  const version = status ? "v" + status.version : "";
429
444
  const mode = status && status.install ? status.install.mode : "";
430
445
  const cloud = (status && status.cloud) || {};
446
+ const autoUpdate = (status && status.autoUpdate) || {};
431
447
  const home = (status && status.home) || null;
432
448
  const subtitle = status
433
449
  ? `${status.node || "本机"} · ${status.chains && status.chains.length ? status.chains.join(" / ") : "未入链"}`
@@ -482,7 +498,7 @@ window.__ModuleLoader__.load({
482
498
  label: "当前终端",
483
499
  value: status ? `${status.node}(${status.nodeId})· ${status.hostname}` : "",
484
500
  }),
485
- about && about.maintainers
501
+ about && about.maintainers && about.maintainers.visible === true
486
502
  ? h(FriendlyRow, {
487
503
  label: "维护者",
488
504
  value: maintainersText(about),
@@ -557,6 +573,38 @@ window.__ModuleLoader__.load({
557
573
  home && home.backup
558
574
  ? h(FriendlyRow, { label: "会话备份", value: backupText(home.backup) })
559
575
  : null,
576
+ h(
577
+ "div",
578
+ { style: S.grid },
579
+ h("div", { style: S.key }, "自动更新"),
580
+ h(
581
+ "div",
582
+ { style: S.val },
583
+ h(
584
+ "span",
585
+ null,
586
+ autoUpdate.enabled
587
+ ? `已开启 · 每 ${Math.max(1, Math.round((autoUpdate.intervalMs || 0) / 3600000))} 小时检查一次${autoUpdate.last
588
+ ? `;上次:${autoUpdate.last.updated ? `已装 ${autoUpdate.last.installed || ""}` : autoUpdate.last.ok ? "已是最新" : `失败(${autoUpdate.last.message || "未知"})`}`
589
+ : ""}`
590
+ : "已关闭(打开后自动检查并安装新版本)",
591
+ ),
592
+ h(
593
+ "button",
594
+ {
595
+ style: autoUpdate.enabled ? S.switchOn : S.switchOff,
596
+ onClick: (event) => {
597
+ event.stopPropagation();
598
+ void onToggleAutoUpdate();
599
+ },
600
+ disabled: busy !== "",
601
+ title: autoUpdate.enabled ? "点击关闭自动更新" : "点击开启自动更新",
602
+ "aria-label": autoUpdate.enabled ? "自动更新开启中" : "自动更新已关闭",
603
+ },
604
+ h("span", { style: autoUpdate.enabled ? S.knobOn : S.knobOff }),
605
+ ),
606
+ ),
607
+ ),
560
608
  h(
561
609
  "div",
562
610
  { style: S.actions },
package/cordis.patch.yml CHANGED
@@ -10,10 +10,10 @@
10
10
  # 每个 insert 行在 DSH「插件 → 跨端互通 → 包含的组件」里是一行组件,可单独开关。
11
11
  # 五行指向同一个包,只是走不同子路径 / 不同 config.part:
12
12
  #
13
- # ① dsh-crossterm 总开关(入口):提供 crossterm 服务,插件页标题/简介/配置卡片,
14
- # 不挂任何工具(config.part: ui)。
15
- # 关掉它 → 服务消失 → 下面四个组件全部停用
16
- # (工具、心跳、云同步、中转站、会话备份一起停)。
13
+ # ① dsh-crossterm 自更新(入口):插件页标题/简介/配置卡片,不挂任何工具
14
+ # (config.part: ui)。0.8.9 起它不再是总开关 —— 关掉它只失去
15
+ # 卡片与「自动更新」,下面四个组件照常跑。
16
+ # 自动更新默认关,开关在卡片里,值落 config.json 的 autoUpdate。
17
17
  # ② dsh-crossterm/parts/session 跨会话:term_link / term_unlink / term_broadcast / term_inbox / term_config
18
18
  # ③ dsh-crossterm/parts/terminal 跨终端:term_whoami / term_list / term_send / term_send_files
19
19
  # ④ dsh-crossterm/parts/device 跨设备:term_cloud(云端总线同步)
@@ -23,13 +23,14 @@
23
23
  # "./parts/session/package.json" -> "./parts/session.manifest.json"
24
24
  # 每个组件显示自己的中文名,实际加载的仍是 lib/index.js,靠 config.part 决定挂哪组工具。
25
25
  #
26
- # 为什么总开关必须用「裸包名」:浏览器的 client 半端只对裸包名行生效
26
+ # 为什么入口行必须用「裸包名」:浏览器的 client 半端只对裸包名行生效
27
27
  # (dsh-client-modules 的 locatePkgJson 对子路径行直接返回 undefined),而插件页顶部标题
28
- # 与裸包名行共用同一份 locale/zh.json 的 meta —— 所以裸包名行单独做总开关,
29
- # 标题写「跨端互通(总开关)」,四个业务组件排在它后面、名字仍是跨会话/跨终端/跨设备/跨账号。
28
+ # 与裸包名行共用同一份 locale/zh.json 的 meta —— 所以裸包名行单独做入口(卡片 + 自动更新),
29
+ # 标题写「跨端互通(自更新)」,四个业务组件排在它后面、名字仍是跨会话/跨终端/跨设备/跨账号。
30
30
  #
31
- # 总开关是「服务注入」实现的:ui 行 root.provide('crossterm'),其余四行 root.inject 它。
32
- # 谁先加载不影响结果(cordis 的 inject 会等服务出现),顺序只为让列表好看。
31
+ # 0.8.9 起五个组件互不依赖:谁都不用 provide/inject 别人,各挂各的工具与开销。
32
+ # 关掉入口行只失去「配置卡片 + 自动更新」,四个业务组件照常工作、各自仍可单独关。
33
+ # 之前(0.7.x–0.8.8)是 ui 行 provide('crossterm')、其余四行 inject 它,关入口会连坐——已废弃。
33
34
  #
34
35
  # 五个组件在同一 DSH 进程里共用一份总线实例:心跳只起一次,云同步只在 device 组件启用
35
36
  # 且 config.json 里 cloudEnabled=true 时才跑。安装/改动后需重启对应的 DSH 进程。
@@ -37,8 +38,8 @@
37
38
 
38
39
  - insert:
39
40
  - id: crossterm-ui
40
- # 总开关(入口):裸包名,提供 crossterm 服务 + 浏览器半端的配置卡片 + 插件页标题/简介。
41
- # 关掉这一行等于整插件停用;它自己不挂工具。
41
+ # 入口(自更新):裸包名,浏览器半端的配置卡片 + 插件页标题/简介 + 「自动更新」开关。
42
+ # 关掉这一行只失去卡片与自动更新,四个业务组件不受影响;它自己不挂工具。
42
43
  name: dsh-crossterm
43
44
  config:
44
45
  part: ui
package/lib/bus.js CHANGED
@@ -23,7 +23,7 @@ import path from 'node:path'
23
23
  import crypto from 'node:crypto'
24
24
  import { rotateIfNeeded } from './logs.js'
25
25
 
26
- export const VERSION = '0.8.7'
26
+ export const VERSION = '0.8.9'
27
27
 
28
28
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
29
29
  export function dshHome() {
@@ -79,6 +79,13 @@ const DEFAULTS = {
79
79
  backupSessionsPerRun: 20,
80
80
  backupAttachmentsPerRun: 20,
81
81
  attachmentBackupEnabled: true,
82
+ // 卡片「维护者」一栏只在维护端显示:默认按 MAINTAINERS.md 里的 nodeId 判断,
83
+ // 这里可以额外放行别的设备(例如用户授权过的本机另装环境)。
84
+ maintainerDevices: [],
85
+ // 自动更新(0.8.9):入口组件不再当总开关,只负责这件事 —— 打开后每 autoUpdateIntervalMs
86
+ // 检查一次 npm 上的新版本,有更新就装;装完要重启 DSH 才生效,绝不自动重启。
87
+ autoUpdate: false,
88
+ autoUpdateIntervalMs: 6 * 60 * 60 * 1000,
82
89
  baiduAppFile: '',
83
90
  baiduAuthFile: '',
84
91
  cloudStateFile: '',
@@ -139,6 +146,13 @@ export function readConfig() {
139
146
  cfg.maxFileBytes = clampNum(cfg.maxFileBytes, 1024, 2 * 1024 * 1024 * 1024, DEFAULTS.maxFileBytes)
140
147
  cfg.maxFiles = clampNum(cfg.maxFiles, 1, 50, DEFAULTS.maxFiles)
141
148
  cfg.deliverAttempts = clampNum(cfg.deliverAttempts, 1, 20, DEFAULTS.deliverAttempts)
149
+ cfg.autoUpdate = cfg.autoUpdate === true
150
+ cfg.autoUpdateIntervalMs = clampNum(
151
+ cfg.autoUpdateIntervalMs,
152
+ 60000,
153
+ 7 * 24 * 60 * 60 * 1000,
154
+ DEFAULTS.autoUpdateIntervalMs,
155
+ )
142
156
  return cfg
143
157
  }
144
158
 
package/lib/index.js CHANGED
@@ -40,7 +40,7 @@ import { createCloud } from './cloud/sync.js'
40
40
  import { createCloudHome } from './cloud/home.js'
41
41
  import { runSetup } from './cloud/setup.js'
42
42
  import { deviceRow, matchDevice } from './cloud/match.js'
43
- import { registerWeb } from './web.js'
43
+ import { registerWeb, selfUpdate } from './web.js'
44
44
  import { recordOwnership } from './logs.js'
45
45
 
46
46
  const PLUGIN = 'dsh-crossterm'
@@ -1396,6 +1396,59 @@ class CrossTerm {
1396
1396
  return true
1397
1397
  }
1398
1398
 
1399
+ // ── 自动更新(0.8.9)──────────────────────────────────────────────────
1400
+ // 入口组件不再是总开关,它只负责「有没有新版本」。开关落在 cfg.autoUpdate,
1401
+ // 卡片上的开关走 /crossterm/api/auto-update/start|stop 写进 config.json。
1402
+ autoUpdateStatus() {
1403
+ const cfg = this.cfg || {}
1404
+ return {
1405
+ enabled: cfg.autoUpdate === true,
1406
+ running: Boolean(this.autoTimer),
1407
+ intervalMs: Number(cfg.autoUpdateIntervalMs) || 0,
1408
+ last: this.autoLast || null,
1409
+ }
1410
+ }
1411
+
1412
+ enableAutoUpdate({ immediate = false } = {}) {
1413
+ if (this.autoTimer) return false
1414
+ const every = Math.max(60000, Number(this.cfg && this.cfg.autoUpdateIntervalMs) || 6 * 60 * 60 * 1000)
1415
+ const tick = () => { void this.autoUpdateOnce() }
1416
+ this.autoTimer = setInterval(tick, every)
1417
+ if (typeof this.autoTimer.unref === 'function') this.autoTimer.unref()
1418
+ // 首次不用等满一个周期:30 秒后先查一次,之后按周期走
1419
+ const first = setTimeout(tick, immediate ? 5000 : 30000)
1420
+ if (typeof first.unref === 'function') first.unref()
1421
+ void this.note(`auto-update: 已开启,每 ${Math.max(1, Math.round(every / 3600000))} 小时检查一次`)
1422
+ return true
1423
+ }
1424
+
1425
+ disableAutoUpdate() {
1426
+ if (!this.autoTimer) return false
1427
+ clearInterval(this.autoTimer)
1428
+ this.autoTimer = undefined
1429
+ void this.note('auto-update: 已关闭')
1430
+ return true
1431
+ }
1432
+
1433
+ async autoUpdateOnce() {
1434
+ let result
1435
+ try {
1436
+ result = await selfUpdate(this)
1437
+ } catch (e) {
1438
+ result = { ok: false, message: (e && e.message) || String(e) }
1439
+ }
1440
+ this.autoLast = { at: Date.now(), ...result }
1441
+ try {
1442
+ const line = result.updated
1443
+ ? `已装 ${result.installed || ''}(重启后生效)`
1444
+ : result.ok
1445
+ ? `${result.skipped ? '跳过' : '已是最新'} ${result.latest || VERSION}`
1446
+ : `失败 ${result.message || ''}`
1447
+ await this.note(`auto-update: ${line}`)
1448
+ } catch { /* ignore */ }
1449
+ return result
1450
+ }
1451
+
1399
1452
  async start() {
1400
1453
  if (this.started) return
1401
1454
  this.started = true
@@ -1409,6 +1462,9 @@ class CrossTerm {
1409
1462
  if (this.cfg.cloudEnabled && wantsCloud) {
1410
1463
  try { this.enableCloud() } catch (e) { void this.warn('cloud sync 启动失败:' + ((e && e.message) || String(e))) }
1411
1464
  }
1465
+ if (this.cfg.autoUpdate) {
1466
+ try { this.enableAutoUpdate() } catch (e) { void this.warn('auto-update 启动失败:' + ((e && e.message) || String(e))) }
1467
+ }
1412
1468
  await this.beat()
1413
1469
  await this.note(`started node=${this.nodeId} name=${this.cfg.name} bus=${this.bus.root}`)
1414
1470
  this.timer = setInterval(() => { void this.tick() }, this.cfg.pollMs)
@@ -1422,6 +1478,7 @@ class CrossTerm {
1422
1478
  if (this.timer) clearInterval(this.timer)
1423
1479
  this.timer = undefined
1424
1480
  if (this.cloud) { this.cloud.stop(); this.cloud = null }
1481
+ if (this.autoTimer) { clearInterval(this.autoTimer); this.autoTimer = undefined }
1425
1482
  if (this.home) { this.home.stop(); this.home = null }
1426
1483
  await this.bus.leave(this.nodeId).catch(() => {})
1427
1484
  await this.note('stopped')
@@ -2017,24 +2074,10 @@ export default {
2017
2074
  name: PLUGIN,
2018
2075
  apply(root, config) {
2019
2076
  const part = normalizePart(config?.part)
2020
- // 「入口」这一行就是总开关:它提供 crossterm 服务,其余四个组件都 inject 它。
2021
- // 关掉这一行 → 服务不存在 → 另外四个组件的 inject 回调根本不会执行,
2022
- // 工具、心跳、云同步、中转站、会话备份一起停(不是只关卡片)。
2023
- // 反过来,只要总开关开着,四个组件各自还能单独关。
2024
- const isMaster = part === 'ui'
2025
- if (isMaster) {
2026
- root.provide('crossterm', { master: true, part, since: Date.now() })
2027
- } else {
2028
- const timer = setTimeout(() => {
2029
- let alive = true
2030
- try {
2031
- alive = Boolean(root.get?.('crossterm'))
2032
- } catch { alive = true }
2033
- if (!alive) console.warn(`[dsh-crossterm] 总开关(入口组件)未启用,组件「${PART_LABEL[part]}」已跳过挂载`)
2034
- }, 3000)
2035
- if (timer && typeof timer.unref === 'function') timer.unref()
2036
- }
2037
- root.inject(isMaster ? ['agents'] : ['agents', 'crossterm'], (ctx) => {
2077
+ // 0.8.9 起「入口」组件不再当总开关:它只负责插件卡片与「自动更新」,
2078
+ // 四个业务组件(跨会话/跨终端/跨设备/跨账号)各挂各的 —— 关掉入口行不会连坐。
2079
+ // 自动更新默认关(cfg.autoUpdate=false),开关在卡片里,改了落 config.json。
2080
+ root.inject(['agents'], (ctx) => {
2038
2081
  // 四个组件跑在同一个进程里,共用一份总线实例:各自只挂自己那组工具,
2039
2082
  // 心跳/云同步只在第一个组件加载时启动一次,最后一个组件卸载时才停。
2040
2083
  const term = acquireTerm(ctx)
package/lib/web.js CHANGED
@@ -429,6 +429,26 @@ async function updatePlugin(term, info, latest) {
429
429
  }
430
430
  }
431
431
 
432
+ /**
433
+ * 自动更新(0.8.9):给定时器用的一步式「检查 + 安装」。
434
+ * 只在 registry 安装模式下有意义;link: 源码版直接跳过(代码就在工作区里)。
435
+ * 装完不重启,由调用方提示「重启 DSH 后生效」。
436
+ */
437
+ export async function selfUpdate(term) {
438
+ const info = profileInfo(term.cfg && term.cfg.busDir)
439
+ if (!info) return { ok: false, updated: false, current: VERSION, latest: '', message: '找不到安装本插件的 profile' }
440
+ if (installMode(info.spec) === 'link') {
441
+ return { ok: true, updated: false, skipped: 'link', current: VERSION, latest: '' }
442
+ }
443
+ const latest = await latestVersion().catch(() => '')
444
+ if (!latest) return { ok: false, updated: false, current: VERSION, latest: '', message: '读不到 npm 最新版本' }
445
+ if (compareVersion(latest, VERSION) <= 0) {
446
+ return { ok: true, updated: false, current: VERSION, latest }
447
+ }
448
+ const result = await updatePlugin(term, info, latest)
449
+ return { ...result, current: VERSION, latest, needRestart: Boolean(result.ok && result.updated) }
450
+ }
451
+
432
452
  function statusOf(term) {
433
453
  const cfg = term.cfg || {}
434
454
  const info = profileInfo(cfg.busDir)
@@ -468,6 +488,11 @@ function statusOf(term) {
468
488
  provider,
469
489
  },
470
490
  chains,
491
+ // 自动更新(0.8.9):入口组件不再是总开关,开关与状态都走这里
492
+ autoUpdate:
493
+ typeof term.autoUpdateStatus === 'function'
494
+ ? term.autoUpdateStatus()
495
+ : { enabled: cfg.autoUpdate === true, running: false, intervalMs: 0, last: null },
471
496
  // 云端目录(0.3.39):账号/角色/设备目录/中转站/会话备份/设备心跳
472
497
  home: homeStatus,
473
498
  }
@@ -506,7 +531,28 @@ function readMaintainers() {
506
531
  return readMaintainersDoc(ROOT)
507
532
  }
508
533
 
509
- function aboutOf() {
534
+ /**
535
+ * 「维护者」只给维护端看:本机 nodeId 必须出现在 MAINTAINERS.md 的
536
+ * 负责人/助手条目里,或用配置 `maintainerDevices` 额外授权;其他设备一律不给这行。
537
+ */
538
+ export function maintainerVisible(term, doc) {
539
+ if (!doc) return false
540
+ const self = String((term && term.nodeId) || '')
541
+ if (!self) return false
542
+ const allow = new Set()
543
+ const add = (item) => {
544
+ if (item && item.nodeId) allow.add(String(item.nodeId))
545
+ }
546
+ add(doc.current)
547
+ add(doc.assistant)
548
+ try {
549
+ const extra = term && term.cfg && Array.isArray(term.cfg.maintainerDevices) ? term.cfg.maintainerDevices : []
550
+ for (const item of extra) allow.add(String(item))
551
+ } catch { /* ignore */ }
552
+ return allow.has(self)
553
+ }
554
+
555
+ function aboutOf(term) {
510
556
  if (aboutCache && aboutCache.version === VERSION) return aboutCache
511
557
  let meta = {}
512
558
  try {
@@ -524,6 +570,7 @@ function aboutOf() {
524
570
  description: doc.description || '',
525
571
  }
526
572
  })
573
+ const maintainers = readMaintainers()
527
574
  aboutCache = {
528
575
  name: PKG,
529
576
  version: VERSION,
@@ -532,7 +579,9 @@ function aboutOf() {
532
579
  shortTitle: meta.shortTitle || meta.title || PKG,
533
580
  description: meta.description || '',
534
581
  components,
535
- maintainers: readMaintainers(),
582
+ maintainers: maintainers
583
+ ? { ...maintainers, visible: maintainerVisible(term, maintainers), me: String((term && term.nodeId) || '') }
584
+ : null,
536
585
  changelog: readChangelog(),
537
586
  }
538
587
  return aboutCache
@@ -578,7 +627,7 @@ export function registerWeb(ctx, term, log) {
578
627
  path: '/crossterm/api/about',
579
628
  handler: (req, res) => {
580
629
  try {
581
- json(res, 200, { ok: true, ...aboutOf() })
630
+ json(res, 200, { ok: true, ...aboutOf(term) })
582
631
  } catch (error) {
583
632
  json(res, 200, { ok: false, message: String((error && error.message) || error) })
584
633
  }
@@ -797,6 +846,48 @@ export function registerWeb(ctx, term, log) {
797
846
  },
798
847
  }),
799
848
  )
849
+ disposers.push(
850
+ webCtx.webServer.register({
851
+ kind: 'exact',
852
+ path: '/crossterm/api/auto-update/start',
853
+ handler: async (req, res) => {
854
+ if (!checkHttp(req, res, term.cfg)) return
855
+ try {
856
+ await writeConfig({ autoUpdate: true })
857
+ term.cfg = readConfig()
858
+ term.enableAutoUpdate()
859
+ json(res, 200, {
860
+ ok: true,
861
+ message: '自动更新已开启:发现新版本会自动安装,装完重启 DSH 生效',
862
+ autoUpdate: statusOf(term).autoUpdate,
863
+ })
864
+ } catch (error) {
865
+ json(res, 200, { ok: false, message: `开启失败:${String((error && error.message) || error)}` })
866
+ }
867
+ },
868
+ }),
869
+ )
870
+ disposers.push(
871
+ webCtx.webServer.register({
872
+ kind: 'exact',
873
+ path: '/crossterm/api/auto-update/stop',
874
+ handler: async (req, res) => {
875
+ if (!checkHttp(req, res, term.cfg)) return
876
+ try {
877
+ await writeConfig({ autoUpdate: false })
878
+ term.cfg = readConfig()
879
+ term.disableAutoUpdate()
880
+ json(res, 200, {
881
+ ok: true,
882
+ message: '自动更新已关闭(仍可手动点「检查更新 / 更新」)',
883
+ autoUpdate: statusOf(term).autoUpdate,
884
+ })
885
+ } catch (error) {
886
+ json(res, 200, { ok: false, message: `关闭失败:${String((error && error.message) || error)}` })
887
+ }
888
+ },
889
+ }),
890
+ )
800
891
  disposers.push(
801
892
  webCtx.webServer.register({
802
893
  kind: 'exact',
package/locale/en.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
- "title": "CrossTerm (master switch)",
3
+ "title": "CrossTerm (self-update)",
4
4
  "shortTitle": "CrossTerm",
5
- "description": "Master switch: turning this off stops everything below — tools, heartbeat, cloud sync, cloud relay and session backups for all four components (cross-session / cross-terminal / cross-device / cross-account). While it is on, each component can still be switched off individually."
5
+ "description": "Self-update switch: when on, the plugin periodically checks npm for a newer version and installs it (off by default; restart DSH afterwards to take effect — it never restarts on its own). Turning it off does not affect the four components below — cross-session / cross-terminal / cross-device / cross-account each have their own switch. About: synchronize sessions, terminals and devices under one DSH account, with feature-code chains, inline mail, a cloud relay and automatic session backups."
6
6
  }
7
7
  }
package/locale/zh.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
- "title": "跨端互通(总开关)",
3
+ "title": "跨端互通(自更新)",
4
4
  "shortTitle": "跨端互通",
5
- "description": "总开关:关掉这一项,下面四个组件(跨会话 / 跨终端 / 跨设备 / 跨账号)全部停用 —— 工具、心跳、云同步、云端中转站与会话备份一起停。开着它时,四个组件可各自单独开关。插件简介:同一 DSH 账号下的会话、终端、设备之间互通。"
5
+ "description": "自更新开关:打开后插件会定期检查 npm 上的新版本并自动安装(默认关;装完要重启 DSH 才生效,不会自己重启)。关掉它不影响下面四个组件 —— 跨会话 / 跨终端 / 跨设备 / 跨账号 各自独立开关。插件简介:同一 DSH 账号下的会话、终端、设备之间互通,支持特征码链、内联邮件、云端中转站与会话自动备份。"
6
6
  }
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "description": "跨端互通:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",