dsh-notify-yimit 0.2.1 → 0.2.3

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/index.js CHANGED
@@ -96,11 +96,13 @@ const TOAST_LABELS = {
96
96
  zh: {
97
97
  ignore: '忽略',
98
98
  jump: '跳转会话',
99
+ activate: '返回应用',
99
100
  unnamed: '(未命名会话)'
100
101
  },
101
102
  en: {
102
103
  ignore: 'Ignore',
103
104
  jump: 'Open',
105
+ activate: 'Go to app',
104
106
  unnamed: '(Unnamed session)'
105
107
  }
106
108
  }
@@ -114,6 +116,12 @@ const EDIT_TITLES = {
114
116
  en: 'Notification preview'
115
117
  }
116
118
 
119
+ /** 编辑模式基准线说明(中/英):基准线下方画 ↑,表示通知从该位置往上堆叠。 */
120
+ const EDIT_ANCHOR_LABELS = {
121
+ zh: '通知以该位置为基准,均在此线上方',
122
+ en: 'Toasts anchor here and stack upward'
123
+ }
124
+
117
125
  const HEX_RE = /^#[0-9a-fA-F]{3,8}$/
118
126
  const isHex = (v) => typeof v === 'string' && HEX_RE.test(v)
119
127
 
@@ -154,6 +162,14 @@ function normalizeConfig(input) {
154
162
  left: Math.round(pos.left),
155
163
  top: Math.round(pos.top)
156
164
  }
165
+ // 兼容旧版已保存的坐标。旧版语义混乱:position.top 被当作"样板窗窗口顶边",
166
+ // 而通知也按"窗口顶边 = pos.top"落位;新版统一为"可见卡片底边 = pos.top"。
167
+ // 两者相差(卡片高 + GLOW),故对没有新标记的旧数据整体下移这么多。
168
+ // (此处拿不到真实卡片高,用默认值估;用户重新拖动一次即精确。)
169
+ if (srcToast.posAtBaseline !== true) {
170
+ out.toast.position.top = Math.round(out.toast.position.top + DEFAULT_WINDOW_H - TOAST_BOTTOM_GAP)
171
+ }
172
+ out.toast.posAtBaseline = true
157
173
  }
158
174
 
159
175
  const srcColors = src.colors !== null && typeof src.colors === 'object' && !Array.isArray(src.colors) ? src.colors :
@@ -287,8 +303,45 @@ function openConfig() {
287
303
  const MAX_QUEUE = 500
288
304
  const RUNNING_THROTTLE_MS = 400
289
305
  const KIND_DEBOUNCE_MS = 2000
290
- /** 通知间距默认值(px);实际使用 config.toast.gap(0–60,可调)。 */
306
+ /** 通知间距默认值(px,指"可见卡片边到边"的间距);实际使用 config.toast.gap(0–60,可调)。 */
291
307
  const TOAST_GAP = 12
308
+ /**
309
+ * 每个通知窗口四周的透明辉光边距(px),必须与 toast-host.ps1 里
310
+ * New-ToastWindow 的 `$border.Margin = Thickness(12)` 保持一致。
311
+ *
312
+ * 实测(两条通知同锚点堆叠):卡片可见矩形 = 窗口内缩 GLOW,
313
+ * 即 窗口高 = 可见卡片高 + 2×GLOW。因此
314
+ * · 某条的"可见卡片高" = 窗口高 − 2×GLOW;
315
+ * · 堆叠间距直接用用户设置的 gap 即可 —— 因为相邻两条的"窗口间距离"
316
+ * 里已经含了 2×GLOW 的透明边距,而它们是被计入 offsetY 的可见高度吸收的。
317
+ * 关键是 gap 绝不能再减去 2×GLOW:那样 gap ≤ 24 时会算出 0 间距,
318
+ * 用户看到的默认 12px 就变成"卡片贴在一起"(实测复现过)。
319
+ */
320
+ const TOAST_GLOW = 12
321
+ /** 默认右下角排布时,最新那条"可见卡片底边"距工作区底边的留白(px);须与宿主一致。 */
322
+ const TOAST_EDGE_PAD = 20
323
+ /**
324
+ * 通知窗口内"可见卡片底边"到**窗口底边**的距离(px),须与 toast-host.ps1 的
325
+ * $script:TOAST_BOTTOM_GAP 一致(实测 = 12,即窗口四周的辉光边距)。
326
+ * 底部锚定公式(注意含"窗口高",不能用固定值):
327
+ * 窗口顶边 = 锚点Y − (窗口高 − TOAST_BOTTOM_GAP) − offsetY
328
+ * ⇒ 卡片可见底边 = 锚点Y − offsetY,与内容高度无关。
329
+ */
330
+ const TOAST_BOTTOM_GAP = 12
331
+ /**
332
+ * 迁移旧配置时估算用的"通知窗口总高"(px),≈ 卡片高 110 + 2×辉光 24。
333
+ * 真实高度由宿主回传,这里只在读旧配置时用作回落值。
334
+ */
335
+ const DEFAULT_WINDOW_H = 134
336
+ /**
337
+ * 堆叠时相邻两条之间的"窗口间"间距。
338
+ *
339
+ * 直接用用户设置的 gap —— 不要再减去 2×TOAST_GLOW:
340
+ * offsetY 累加的是"可见卡片高 + 间距",可见卡片高已经把两侧透明辉光吸收掉了,
341
+ * 所以 gap 就是肉眼看到的卡片间距。若在此处再减 2×GLOW,gap ≤ 24 时会被夹成 0,
342
+ * 默认的 12px 就变成"卡片严丝合缝贴在一起"(实测复现过)。
343
+ */
344
+ const stackGap = (gap) => Math.max(0, gap)
292
345
  const TEXT_MAX = 300
293
346
 
294
347
  function clip(text) {
@@ -466,10 +519,41 @@ export function apply(ctx) {
466
519
  let toastSeq = 1
467
520
  let host = null
468
521
  let hostAlive = false
469
- let hostRestartAttempts = 0
470
522
  let hostRestartUntil = 0
523
+ // ② 早退退避:spawn 成功 ≠ 进程活着。旧代码在 spawn 后同步置 hostAlive = true,
524
+ // 于是 ensureHost() 里的 `if (!hostAlive)` 永不成立、退避计数恒被清零,退避形同虚设。
525
+ // 宿主秒退时(例如 BOM 丢失),每条通知都会重拉一个 powershell.exe
526
+ // (冷启动 200–300ms + WPF 初始化),而 running 是高频事件 → 进程风暴。
527
+ // 因此把"早退"记在 exit 回调里(那里有真实存活时长),按连续早退次数退避。
528
+ let hostEarlyExits = 0
529
+ // ③ 一个故障周期内只告警一次,避免 600 字符 stderr 刷屏把关键信息淹掉。
530
+ let hostWarned = false
531
+ const HOST_EARLY_EXIT_MS = 3000
532
+ // ④ 设置页可见的宿主故障状态。null = 正常;否则描述最近一次故障。
533
+ // 客户端把它渲染成设置页上的横幅 —— 否则宿主失败时用户只看到"没有通知",
534
+ // 完全不知道发生了什么(报告者的核心抱怨就是"没有任何报错")。
535
+ // 形状:{ code, signal, at, count, retryAt }
536
+ let hostError = null
471
537
 
472
538
  function spawnHost() {
539
+ // ① 运行时自检:toast-host.ps1 必须是「UTF-8 with BOM」。
540
+ // 宿主是 Windows PowerShell 5.1 拉起的,它对无 BOM 的 .ps1 会按系统 ANSI
541
+ // 代码页解码;在中文 Windows(936/932/950)下中文会吃掉字符串收尾引号 →
542
+ // 整段脚本语法错误 → 宿主秒退 → 所有自定义通知不显示且无任何报错。
543
+ // prepublishOnly 里也有同样的断言,但那只在发布时拦得住;这里是给
544
+ // "开发期误丢 BOM 后本地重载插件"这条路径兜底。
545
+ try {
546
+ const head = fs.readFileSync(TOAST_HOST_SCRIPT)
547
+ if (!(head[0] === 0xEF && head[1] === 0xBB && head[2] === 0xBF)) {
548
+ console.warn(
549
+ '[dsh-notify-yimit] lib/toast-host.ps1 缺少 UTF-8 BOM!中文 Windows 下 ' +
550
+ 'PowerShell 5.1 会按 ANSI 解码导致脚本语法错误、宿主秒退,自定义通知将全部不显示。' +
551
+ '请把该文件另存为「UTF-8 with BOM」。'
552
+ )
553
+ }
554
+ } catch (error) {
555
+ if (process.env.DSH_DEBUG) console.warn(`[dsh-notify-yimit] BOM 自检失败: ${String(error)}`)
556
+ }
473
557
  try {
474
558
  const spawnFn = typeof globalThis.__DSH_NOTIFY_TEST_SPAWN__ === 'function' ? globalThis
475
559
  .__DSH_NOTIFY_TEST_SPAWN__ : spawn
@@ -484,6 +568,9 @@ export function apply(ctx) {
484
568
  return
485
569
  }
486
570
  hostAlive = true
571
+ // ④ 新宿主已拉起:清掉上一次的故障状态(真正的恢复以"活过阈值"为准,
572
+ // 但拉起成功即可先收起横幅,若又秒退会立刻重新置上)。
573
+ hostError = null
487
574
  host.stdin.on('error', (err) => {
488
575
  if (process.env.DSH_DEBUG) console.warn(`[dsh-notify-yimit] stdin error: ${err}`)
489
576
  })
@@ -493,6 +580,64 @@ export function apply(ctx) {
493
580
  })
494
581
  host.stdout.setEncoding('utf8')
495
582
 
583
+ // ── 宿主 stderr 与早退诊断 ──────────────────────────────────────────
584
+ // toast-host.ps1 是「UTF-8 with BOM」文件:Windows PowerShell 5.1 对无 BOM 的
585
+ // .ps1 会按系统 ANSI 代码页解码,在中文 Windows(936/932/950)下中文会吃掉
586
+ // 字符串引号 → 整段脚本语法错误 → 宿主**秒退**。
587
+ // 而发通知失败原本是"静默入队",用户既看不到浮窗、也拿不到任何报错。
588
+ // 所以这里把宿主的 stderr 留住,并在它启动后很快以非 0 退出时明确告警。
589
+ let hostStderr = ''
590
+ let spawnedAt = Date.now()
591
+ host.stderr.on('data', (d) => {
592
+ if (hostStderr.length < 8192) hostStderr += String(d)
593
+ })
594
+ host.stderr.on('error', () => {})
595
+ // exit 报告里带上"存活时长"与 stderr,便于直接定位。
596
+ // ③ 条件用 `code !== 0 || signal != null`:被信号杀死时 code 为 null、signal 才有值,
597
+ // 旧写法 `code !== 0 && code !== null` 会正好漏掉这种情况而不告警。
598
+ host.on('exit', (code, signal) => {
599
+ const livedMs = Date.now() - spawnedAt
600
+ hostAlive = false
601
+ activeToasts.clear()
602
+ toastOrder.length = 0
603
+ pendingClose.clear()
604
+ if (livedMs < HOST_EARLY_EXIT_MS && (code !== 0 || signal !== null)) {
605
+ // ② 记一次早退,据此退避,避免进程风暴
606
+ hostEarlyExits++
607
+ hostRestartUntil = Date.now() + Math.min(30000, 1000 * (2 ** hostEarlyExits))
608
+ // ④ 暴露给设置页(客户端据此显示横幅)
609
+ hostError = {
610
+ code: code === null ? null : Number(code),
611
+ signal: signal === null ? null : String(signal),
612
+ at: Date.now(),
613
+ count: hostEarlyExits,
614
+ retryAt: hostRestartUntil,
615
+ }
616
+ // ③ 一个故障周期内只告警一次
617
+ if (!hostWarned) {
618
+ hostWarned = true
619
+ const detail = hostStderr.trim().slice(0, 600)
620
+ console.warn(
621
+ `[dsh-notify-yimit] 通知宿主进程启动后 ${livedMs}ms 即退出` +
622
+ `(code=${code}${signal !== null ? `, signal=${signal}` : ''}),自定义通知将无法显示。` +
623
+ `最可能的原因:lib/toast-host.ps1 丢失了 UTF-8 BOM` +
624
+ `(中文 Windows 下 PS 5.1 会按 ANSI 解码导致语法错误)。` +
625
+ `已进入退避,${Math.round((hostRestartUntil - Date.now()) / 1000)}s 内不再重启。` +
626
+ (detail.length > 0 ? `\n--- 宿主 stderr ---\n${detail}` : '\n(宿主未输出 stderr)')
627
+ )
628
+ }
629
+ } else if (livedMs >= HOST_EARLY_EXIT_MS) {
630
+ // ② 活过阈值 = 健康,清零计数与告警状态(并可提示已恢复)。
631
+ if (hostEarlyExits > 0) {
632
+ console.warn('[dsh-notify-yimit] 通知宿主已恢复正常运行。')
633
+ }
634
+ hostEarlyExits = 0
635
+ hostRestartUntil = 0
636
+ hostWarned = false
637
+ // ④ 清除故障状态,设置页横幅随之收起
638
+ hostError = null
639
+ }
640
+ })
496
641
  let buf = ''
497
642
  host.stdout.on('data', (d) => {
498
643
  buf += String(d)
@@ -532,12 +677,21 @@ export function apply(ctx) {
532
677
  } else if (msg.type === 'geometry' && typeof msg.left === 'number' && typeof msg.top ===
533
678
  'number') {
534
679
  // 编辑模式:样板窗拖拽/缩放结束 → 写回配置,并应用到在屏浮窗。
680
+ //
681
+ // 坐标口径:宿主回传的 msg.top 就是"基准线(卡片可见底边)的屏幕 Y"。
682
+ // 宿主定位时用的是同一个口径($win.Top = pos.top − lineInWin),
683
+ // 所以"存下的值"与"重开时的落点"必然一致,不会漂移。
684
+ // 注意不要再自己加 msg.baselineTop:那是"基准线从窗口顶边算起的偏移",
685
+ // 只为调试/换算提供,加进去会重复计入一个卡片高。
535
686
  const width = Math.max(260, Math.min(520, Math.round(Number(msg.width) || 340)))
536
687
  const left = Math.round(Number(msg.left))
688
+ // position.top 一律存"基准线的屏幕 Y";posAtBaseline 标记供
689
+ // normalizeConfig 区分新旧坐标(旧数据语义不同,需迁移)。
537
690
  const top = Math.round(Number(msg.top))
538
691
  store.set({
539
692
  toast: {
540
693
  width,
694
+ posAtBaseline: true,
541
695
  position: {
542
696
  left,
543
697
  top
@@ -555,13 +709,6 @@ export function apply(ctx) {
555
709
  }
556
710
  }
557
711
  })
558
-
559
- host.on('exit', () => {
560
- hostAlive = false
561
- activeToasts.clear()
562
- toastOrder.length = 0
563
- pendingClose.clear()
564
- })
565
712
  }
566
713
 
567
714
  function sendCmd(payload) {
@@ -576,14 +723,11 @@ export function apply(ctx) {
576
723
 
577
724
  function ensureHost() {
578
725
  if (host !== null && hostAlive) return true
726
+ // ② 退避窗口内直接放弃:否则宿主秒退时每条通知都会重拉一个 powershell.exe。
727
+ // 退避时长由 exit 回调按"连续早退次数"设置(那里才有真实存活时长),
728
+ // 不能像旧代码那样在 spawn 之后同步判断 hostAlive —— 那是恒为 true 的。
579
729
  if (Date.now() < hostRestartUntil) return false
580
730
  spawnHost()
581
- if (!hostAlive) {
582
- hostRestartAttempts++
583
- hostRestartUntil = Date.now() + Math.min(30000, 1000 * (2 ** hostRestartAttempts))
584
- } else {
585
- hostRestartAttempts = 0
586
- }
587
731
  return host !== null && hostAlive
588
732
  }
589
733
 
@@ -617,19 +761,27 @@ export function apply(ctx) {
617
761
  const cfg = store.get()
618
762
  const pos = cfg.toast?.position ?? null
619
763
  const gap = typeof cfg.toast?.gap === 'number' ? cfg.toast.gap : TOAST_GAP
764
+ // 窗口排布间距:扣除两侧透明辉光边距,使"可见卡片间距"真正等于用户设置的 gap。
765
+ const boxGap = stackGap(gap)
620
766
  if (pos !== null) {
621
- // 自定义位置:position.top 是"第一个(最新,最底部)浮窗的顶边",向上堆叠。
622
- // 与宿主 Loaded 的 targetTop = pos.top - offsetY 语义一致,否则重排会把
623
- // 浮窗再上移一个自身高度(先正确、随即上移的 bug)。横向同步 position.left,
624
- // 编辑调整位置后,在屏浮窗立即左右移动到位。
625
- let prevTop = pos.top
626
- let first = true
767
+ // 自定义位置:锚点(pos.top,即编辑模式那条基准线)对应"最新那条的可见卡片底边"。
768
+ // 这是底部锚定 —— 无论通知内容多长、卡片多高,底边都恒定落在基准线上,
769
+ // 只有顶边随内容浮动。
770
+ //
771
+ // 定位公式必须与宿主 New-ToastWindow 的 Loaded 完全一致,否则宿主先落位、
772
+ // 这里再校正,两者会互相打架(表现为浮窗抖动或被推错位):
773
+ // 窗口顶边 = pos.top − (窗口高 − TOAST_BOTTOM_GAP) − offsetY
774
+ // "窗口高"用每条自己回传的 meta.height —— 卡片高度随内容变化,写死会让
775
+ // 长内容的底边偏离锚点。offsetY = 该条之上已叠的高度(第 0 位为 0)。
776
+ // 横向同步 position.left,编辑调整位置后,在屏浮窗立即左右移动到位。
777
+ let offset = 0
627
778
  for (let i = 0; i < toastOrder.length; i += 1) {
628
779
  const rec = activeToasts.get(toastOrder[i])
629
780
  if (rec === undefined) continue
630
- const height = rec.meta?.height ?? 130
631
- const newTop = first ? prevTop : Math.round(prevTop - gap - height)
632
- first = false
781
+ const height = rec.meta?.height ?? DEFAULT_WINDOW_H
782
+ const newTop = Math.round(pos.top - (height - TOAST_BOTTOM_GAP) - offset)
783
+ // 累积"本条之上"的高度:可见卡片高(= 窗口高 − 2×辉光)+ gap
784
+ offset += Math.max(0, height - 2 * TOAST_GLOW) + boxGap
633
785
  const needsTop = rec.meta !== null && newTop !== rec.meta.top
634
786
  const needsLeft = rec.meta !== null && rec.meta.left !== pos.left
635
787
  if (needsTop || needsLeft) {
@@ -645,19 +797,19 @@ export function apply(ctx) {
645
797
  }
646
798
  sendCmd(move)
647
799
  }
648
- prevTop = newTop
649
800
  }
650
801
  return
651
802
  }
652
- // 默认右下:底边对齐(工作区底边 - 20),向上堆叠;横向回默认贴边(left 'default'),
653
- // 覆盖"恢复默认位置/改宽度后需回到右下角"的场景。
803
+ // 默认右下:最新那条的"可见卡片底边"距工作区底边 TOAST_EDGE_PAD,向上堆叠;
804
+ // 横向回默认贴边(left 'default'),覆盖"恢复默认位置/改宽度后需回到右下角"的场景。
805
+ // 与宿主默认分支同式:窗口顶边 = 工作区底边 − TOAST_EDGE_PAD − (窗口高 − TOAST_BOTTOM_GAP) − offsetY。
654
806
  if (globalWorkBottom === null) return
655
- let cursor = globalWorkBottom - 20
807
+ let offset = 0
656
808
  for (let i = 0; i < toastOrder.length; i += 1) {
657
809
  const rec = activeToasts.get(toastOrder[i])
658
810
  if (rec === undefined) continue
659
- const height = rec.meta?.height ?? 130
660
- const newTop = Math.round(cursor - height)
811
+ const height = rec.meta?.height ?? DEFAULT_WINDOW_H
812
+ const newTop = Math.round(globalWorkBottom - TOAST_EDGE_PAD - (height - TOAST_BOTTOM_GAP) - offset)
661
813
  if (rec.meta !== null && newTop !== rec.meta.top) {
662
814
  rec.meta.top = newTop
663
815
  sendCmd({
@@ -667,7 +819,7 @@ export function apply(ctx) {
667
819
  left: 'default'
668
820
  })
669
821
  }
670
- cursor = newTop - gap
822
+ offset += Math.max(0, height - 2 * TOAST_GLOW) + boxGap
671
823
  }
672
824
  }
673
825
 
@@ -745,10 +897,18 @@ export function apply(ctx) {
745
897
  }
746
898
  const insertIndex = replaceIndex !== -1 ? replaceIndex : 0
747
899
  const gap = typeof cfg.toast?.gap === 'number' ? cfg.toast.gap : TOAST_GAP
900
+ const boxGap = stackGap(gap)
901
+ // 宿主首帧落位用的偏移量,语义 = "该条之上已经叠了多少高度(px)"。
902
+ // 宿主按底部锚定定位:窗口顶边 = 锚点Y − 卡片可见高 − offsetY,
903
+ // 于是该条"可见卡片底边 = 锚点Y − offsetY"。所以:
904
+ // · 第 0 位(最下方那条)上方没有任何东西 → offsetY 必须为 0;
905
+ // · 其余位置 = Σ(上方各条的可见卡片高 + gap)。
906
+ // 每条只吃自己的高度,永远不需要预测新条自己的高度。
748
907
  let offsetY = 0
749
908
  for (let i = 0; i < insertIndex; i += 1) {
750
909
  const r = activeToasts.get(toastOrder[i])
751
- offsetY += (r?.meta?.height ?? 130) + gap
910
+ // 上方每一条贡献"可见卡片高 + gap";可见卡片高 = 窗口高 − 2×辉光。
911
+ offsetY += Math.max(0, (r?.meta?.height ?? 130) - 2 * TOAST_GLOW) + boxGap
752
912
  }
753
913
 
754
914
  const lang = cfg.locale === 'zh' ? 'zh' : 'en'
@@ -774,6 +934,13 @@ export function apply(ctx) {
774
934
  jumpLabel: l.jump,
775
935
  // 桌面端隐藏「跳转会话」按钮(该按钮会打开浏览器,在桌面端内多余)
776
936
  jumpEnabled: isDesktopRuntime !== true,
937
+ // 「返回应用」只在桌面端有意义:把已在运行的桌面窗口切到前台。
938
+ // 实现是宿主侧枚举窗口 + SetForegroundWindow —— 不能用 dsh://open:
939
+ // 应用只在 macOS 的 open-url 事件里处理该 URL,Windows 上协议 URL 是以
940
+ // argv 交给新进程的,而 second-instance 处理器不读 argv,那条路是死代码。
941
+ // 因此把应用可执行文件路径一并下发,供宿主按进程定位窗口。
942
+ activateLabel: isDesktopRuntime === true ? l.activate : '',
943
+ appPath: isDesktopRuntime === true ? String(process.execPath ?? '') : '',
777
944
  unnamedLabel: l.unnamed,
778
945
  // 通知窗口样式:宽度 + 自定义位置(null = 默认右下贴边)
779
946
  width: cfg.toast?.width ?? 340,
@@ -1110,6 +1277,14 @@ export function apply(ctx) {
1110
1277
  config: store.get(),
1111
1278
  // 运行环境:桌面端(=Electron 宿主)还是 web 端。客户端据此隐藏只对浏览器有意义的设置项。
1112
1279
  desktop: isDesktopRuntime === true,
1280
+ // ④ 宿主故障状态(null = 正常)。客户端在设置页渲染成横幅,让"通知不显示"
1281
+ // 这件事从"静默失败"变成可见、可解释。
1282
+ hostError: hostError === null ? null : {
1283
+ ...hostError
1284
+ },
1285
+ // 宿主不可用时,通知会堆在插件侧的 queue 里(见 showDesktopToast 的失败分支)。
1286
+ // 给客户端一个条数,用于告诉用户"积压了多少条"。
1287
+ pendingCount: Math.max(0, queue.length - queueHead),
1113
1288
  events: queue.slice(queueHead).map((item) => ({
1114
1289
  ...item
1115
1290
  }))
@@ -1162,6 +1337,8 @@ export function apply(ctx) {
1162
1337
  sendCmd({
1163
1338
  cmd: 'edit',
1164
1339
  title: EDIT_TITLES[lang],
1340
+ // 基准线提示文案:告诉用户"通知以该位置为基准、均在此线上方"
1341
+ anchorLabel: EDIT_ANCHOR_LABELS[lang],
1165
1342
  width: cfg.toast?.width ?? 340,
1166
1343
  position: cfg.toast?.position ?? null,
1167
1344
  // 新增:类型色卡数组,样板窗内做预览
@@ -1185,6 +1362,22 @@ export function apply(ctx) {
1185
1362
  ok: true
1186
1363
  }
1187
1364
  },
1365
+ /**
1366
+ * ④ 立即重试拉起通知宿主(设置页横幅上的「重新拉起宿主」)。
1367
+ * 清掉退避窗口与故障状态后立刻 spawn;若又秒退,exit 回调会重新置上
1368
+ * hostError 并按新的连续早退次数退避。
1369
+ */
1370
+ retryHost() {
1371
+ hostEarlyExits = 0
1372
+ hostRestartUntil = 0
1373
+ hostWarned = false
1374
+ hostError = null
1375
+ hostAlive = false
1376
+ spawnHost()
1377
+ return {
1378
+ ok: hostAlive
1379
+ }
1380
+ },
1188
1381
  }
1189
1382
 
1190
1383
  Object.defineProperty(service, 'typertRemote', {