dsh-crossterm 0.8.8 → 0.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.8.10 — 2026-10-19
4
+
5
+ - **云端设备的显示规则调整**(User said (m11109):名称(权限)+在线状态不变,版本只在有差异时显示):
6
+ 每台设备从 `名称 · 角色 · 状态 · v版本` 改为 `名称(角色) · 状态`,
7
+ 例如 `YOCE_C(成员) · 3 小时前`;版本号只在该设备与**本机版本不一致**时才追加(` · v0.8.2`),
8
+ 与本机一致的设备不再显示版本,避免每台都挂一串相同版本号。
9
+ - 仅改 `client/client.js` 的 `devicesText()` 渲染逻辑,接口与数据不变。
10
+
11
+ ## 0.8.9 — 2026-10-19
12
+
13
+ ### 第一个组件从「总开关」改成「自更新开关」
14
+
15
+ 来源:User said (m10846):**"把第一个组件总开关改为自更新开关,"**;追问后用户选「自动更新开关,四个业务组件不再受它约束」+「默认关」。
16
+
17
+ - **语义变更**:入口行(裸包名 `dsh-crossterm`,`config.part: ui`)不再 provide `crossterm` 服务,
18
+ 也不再约束另外四个组件。以前关掉它 → 服务消失 → 跨会话/跨终端/跨设备/跨账号的 `inject` 回调根本不执行
19
+ (工具、心跳、云同步、中转站、会话备份一起停);0.8.9 起五行一律 `root.inject(['agents'], …)`,各挂各的,
20
+ 关掉入口行只失去「配置卡片 + 自动更新」。
21
+ - **自动更新**:`lib/web.js` 新增导出 `selfUpdate(term)` —— `profileInfo()` 定位 profile、
22
+ `installMode() === 'link'` 直接跳过(源码版不自动覆盖)、`latestVersion()` 读不到返回失败、
23
+ `compareVersion(latest, VERSION) <= 0` 视为已最新,否则走既有 `updatePlugin()`(dsh → cli → pnpm 三级兜底)。
24
+ 装完**不自动重启**,只回 `needRestart: true`,由卡片/日志提示「重启 DSH 后生效」。
25
+ - **定时器**:`lib/index.js` 新增 `autoUpdateStatus()` / `enableAutoUpdate({ immediate })` / `disableAutoUpdate()` /
26
+ `autoUpdateOnce()`;`start()` 在 `cfg.autoUpdate` 为真时启动(首次 30 s 后查一次,之后按周期),`stop()` 清定时器。
27
+ 结果记 `this.autoLast` 并写 `logs/update.log`。
28
+ - **配置**:`bus.js` DEFAULTS 新增 `autoUpdate: false`、`autoUpdateIntervalMs: 21600000`(6 小时);
29
+ `readConfig()` 把 `autoUpdate` 归一为布尔、`autoUpdateIntervalMs` 夹到 1 分钟–7 天。
30
+ - **接口**:`status` 新增 `autoUpdate: {enabled, running, intervalMs, last}`;
31
+ 新增 `POST /crossterm/api/auto-update/start|stop`(要 `x-crossterm-token`,写 `config.json` 并即时生效)。
32
+ - **界面**:卡片新增「自动更新」一行 + 复用云同步样式的开关;开启时显示周期与上次结果
33
+ (已装 / 已是最新 / 失败原因),关闭时显示「已关闭(打开后自动检查并安装新版本)」。
34
+ - **文案**:`locale/zh.json` 标题改「跨端互通(自更新)」、`en.json` 改 `CrossTerm (self-update)`;
35
+ `cordis.patch.yml` 顶部说明与 ui 行注释同步改写。
36
+
3
37
  ## 0.8.8 — 2026-10-19
4
38
 
5
39
  ### 「维护者」一栏只给维护端看
package/README.md CHANGED
@@ -12,46 +12,43 @@ 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.8)**
31
+ **本版更新(0.8.10)**
32
32
 
33
- - **「维护者」一栏只给维护端看**:以前任何设备装上插件都能看到负责人是谁;现在只在维护端显示 ——
34
- 本机 `nodeId` 必须出现在 `MAINTAINERS.md` 的负责人/助手条目里(两者在同一台机器上时都算),
35
- 或用配置 `maintainerDevices` 显式放行。其他设备上这行直接不渲染。
36
- - **换机器只改一个字段**:负责人换机器时,把 `MAINTAINERS.md` 里 `current.nodeId` 改成新机器,
37
- 旧机器下次刷新卡片自动隐藏,不用改代码。
38
- - 纯展示改动,装上重启即可,不用改配置。
33
+ - **云端设备显示规则调整**:每台设备显示 `名称(权限) · 在线状态`,例如 `YOCE_C(成员) · 3 小时前`;
34
+ 版本号**只在它与本机版本不一致时**才显示(如 ` · v0.8.2`),与本机一致的设备不再挂版本号。
35
+ - 只改客户端渲染,接口与数据不变;升级后刷新卡片即生效。
39
36
 
40
- **上一版(0.8.7)**
37
+ **上一版(0.8.9)**
41
38
 
42
- - **修 0.8.6 的回归**:0.8.6 装上后卡片「云端设备」整块消失(接口报 `layout is not defined`);
43
- 0.8.7 已修,没升 0.8.6 的设备可直接装 0.8.7。
44
- - **合并逻辑变成可测的纯函数**:`mergeDevices({ old, fresh, selfId, ownerDevice })`,配套 20 项单测。
45
- - **离线时间排序口径统一**:`at → lastSeen → updatedAt` 先归一毫秒再排。
39
+ - **第一个组件从「总开关」改成「自更新开关」**:关掉它只失去卡片与自动更新,跨会话 / 跨终端 / 跨设备 / 跨账号 各自独立。
40
+ - **自动更新默认关**:卡片「自动更新」一行 + 开关,默认每 6 小时检查一次并自动安装,装完提示重启、不自动重启。
41
+ - **服务注入拆掉**:五行都只 `inject(['agents'])`,不再 provide/inject 连锁。
46
42
 
47
- **更早(0.8.6 / 0.8.5 / 0.8.2 / 0.8.0)**
43
+ **更早(0.8.8 / 0.8.7 / 0.8.6 / 0.8.5 / 0.8.2 / 0.8.0)**
48
44
 
45
+ - 0.8.7:修 0.8.6 的回归(卡片「云端设备」整块消失,接口报 `layout is not defined`);
46
+ 合并逻辑变成可测纯函数 `mergeDevices({ old, fresh, selfId, ownerDevice })`,配套 20 项单测;
47
+ 离线时间按 `at → lastSeen → updatedAt` 先归一毫秒再排。
49
48
  - **云端设备只列「别人」**:卡片里那行不再显示本机(自己已经在「当前终端」一栏),
50
49
  只列同一账号下的其他设备。
51
50
  - **每台带权限和状态**:逐台显示 `名称 · 管理员/成员 · 在线/离线(x 分钟前)· v版本号`;
52
51
  管理员就是云端 `_account.json` 里 `ownerDevice` 那台,其余都是成员。
53
- - **离线时间不再显示「未知」**:以前只认 `updatedAt`,新布局心跳写的是 `at`,
54
- 离线设备一律「未知」;现在按 `updatedAt → at → lastSeen` 依次取。
55
52
  - `term_list` 的云端行也带角色。
56
53
 
57
54
  - 0.8.5:负责人留痕 `MAINTAINERS.md`、运行日志可轮转(5 MB 压 `logs/archive/` 留 20 份)、
package/client/client.js CHANGED
@@ -177,13 +177,17 @@ window.__ModuleLoader__.load({
177
177
  if (!devices.ok) return devices.message || "读取失败";
178
178
  const list = (devices.devices || []).filter((d) => d && (!devices.me || d.nodeId !== devices.me));
179
179
  if (!list.length) return "云端还没有其他设备";
180
+ const local = String((status && status.version) || "");
180
181
  return list
181
182
  .map((d) => {
182
183
  const label = d.name || d.nodeId || "未知设备";
183
184
  const role = d.role || "成员";
184
185
  const at = d.updatedAt || d.at || (d.lastSeen ? Date.parse(d.lastSeen) : 0);
185
186
  const state = d.online ? "在线" : agoText(at);
186
- return label + " · " + role + " · " + state + (d.version ? " · v" + d.version : "");
187
+ const ver = String(d.version || "");
188
+ // 版本只在「与本机不一致」时显示,避免每台都挂一串相同的 vX
189
+ const verText = ver && ver !== local ? " · v" + ver : "";
190
+ return label + "(" + role + ") · " + state + verText;
187
191
  })
188
192
  .join(";");
189
193
  }
@@ -356,6 +360,21 @@ window.__ModuleLoader__.load({
356
360
  }
357
361
  };
358
362
 
363
+ const onToggleAutoUpdate = async () => {
364
+ const enabled = Boolean(status && status.autoUpdate && status.autoUpdate.enabled);
365
+ setBusy("auto");
366
+ setMsg(enabled ? "正在关闭自动更新…" : "正在开启自动更新…");
367
+ try {
368
+ const data = await call(enabled ? "/crossterm/api/auto-update/stop" : "/crossterm/api/auto-update/start", "POST");
369
+ setMsg((data && data.message) || (data && data.ok ? "已切换" : "未能切换"));
370
+ if (data && data.autoUpdate && status) setStatus({ ...status, autoUpdate: data.autoUpdate });
371
+ } catch (error) {
372
+ setMsg("切换失败:" + String((error && error.message) || error));
373
+ } finally {
374
+ setBusy("");
375
+ }
376
+ };
377
+
359
378
  const onSetupCloud = async () => {
360
379
  setBusy("setup");
361
380
  setMsg("正在打开记事本…把 AppKey / AppSecret 填进引号里,保存并关闭记事本");
@@ -428,6 +447,7 @@ window.__ModuleLoader__.load({
428
447
  const version = status ? "v" + status.version : "";
429
448
  const mode = status && status.install ? status.install.mode : "";
430
449
  const cloud = (status && status.cloud) || {};
450
+ const autoUpdate = (status && status.autoUpdate) || {};
431
451
  const home = (status && status.home) || null;
432
452
  const subtitle = status
433
453
  ? `${status.node || "本机"} · ${status.chains && status.chains.length ? status.chains.join(" / ") : "未入链"}`
@@ -557,6 +577,38 @@ window.__ModuleLoader__.load({
557
577
  home && home.backup
558
578
  ? h(FriendlyRow, { label: "会话备份", value: backupText(home.backup) })
559
579
  : null,
580
+ h(
581
+ "div",
582
+ { style: S.grid },
583
+ h("div", { style: S.key }, "自动更新"),
584
+ h(
585
+ "div",
586
+ { style: S.val },
587
+ h(
588
+ "span",
589
+ null,
590
+ autoUpdate.enabled
591
+ ? `已开启 · 每 ${Math.max(1, Math.round((autoUpdate.intervalMs || 0) / 3600000))} 小时检查一次${autoUpdate.last
592
+ ? `;上次:${autoUpdate.last.updated ? `已装 ${autoUpdate.last.installed || ""}` : autoUpdate.last.ok ? "已是最新" : `失败(${autoUpdate.last.message || "未知"})`}`
593
+ : ""}`
594
+ : "已关闭(打开后自动检查并安装新版本)",
595
+ ),
596
+ h(
597
+ "button",
598
+ {
599
+ style: autoUpdate.enabled ? S.switchOn : S.switchOff,
600
+ onClick: (event) => {
601
+ event.stopPropagation();
602
+ void onToggleAutoUpdate();
603
+ },
604
+ disabled: busy !== "",
605
+ title: autoUpdate.enabled ? "点击关闭自动更新" : "点击开启自动更新",
606
+ "aria-label": autoUpdate.enabled ? "自动更新开启中" : "自动更新已关闭",
607
+ },
608
+ h("span", { style: autoUpdate.enabled ? S.knobOn : S.knobOff }),
609
+ ),
610
+ ),
611
+ ),
560
612
  h(
561
613
  "div",
562
614
  { 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.8'
26
+ export const VERSION = '0.8.10'
27
27
 
28
28
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
29
29
  export function dshHome() {
@@ -82,6 +82,10 @@ const DEFAULTS = {
82
82
  // 卡片「维护者」一栏只在维护端显示:默认按 MAINTAINERS.md 里的 nodeId 判断,
83
83
  // 这里可以额外放行别的设备(例如用户授权过的本机另装环境)。
84
84
  maintainerDevices: [],
85
+ // 自动更新(0.8.9):入口组件不再当总开关,只负责这件事 —— 打开后每 autoUpdateIntervalMs
86
+ // 检查一次 npm 上的新版本,有更新就装;装完要重启 DSH 才生效,绝不自动重启。
87
+ autoUpdate: false,
88
+ autoUpdateIntervalMs: 6 * 60 * 60 * 1000,
85
89
  baiduAppFile: '',
86
90
  baiduAuthFile: '',
87
91
  cloudStateFile: '',
@@ -142,6 +146,13 @@ export function readConfig() {
142
146
  cfg.maxFileBytes = clampNum(cfg.maxFileBytes, 1024, 2 * 1024 * 1024 * 1024, DEFAULTS.maxFileBytes)
143
147
  cfg.maxFiles = clampNum(cfg.maxFiles, 1, 50, DEFAULTS.maxFiles)
144
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
+ )
145
156
  return cfg
146
157
  }
147
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
  }
@@ -821,6 +846,48 @@ export function registerWeb(ctx, term, log) {
821
846
  },
822
847
  }),
823
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
+ )
824
891
  disposers.push(
825
892
  webCtx.webServer.register({
826
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.8",
3
+ "version": "0.8.10",
4
4
  "description": "跨端互通:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",