dsh-dock 0.12.0 → 0.14.0

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.
@@ -10,12 +10,12 @@
10
10
  // features/runstate/,本模块只管通知(页内卡片 / 提示音 / 系统通知 / 钉钉飞书群机器人推送)。
11
11
  // 模块启停 = 功能坞里的开关(独立菜单项),本页不再有第二个总开关。
12
12
  //
13
- // Host 通信:fetch('/dsh-dock/notify/<method>')(见 features/notify/host.js)。
13
+ // Host 通信:fetch('dsh-dock/notify/<method>')(见 features/notify/host.js)。
14
14
  import { useState, useEffect, useRef, useCallback } from "react";
15
15
 
16
16
  // ---------- Host RPC 桥接 ----------
17
17
  function rpcCall(method, args) {
18
- return fetch("/dsh-dock/notify/" + method, {
18
+ return fetch("dsh-dock/notify/" + method, {
19
19
  method: "POST",
20
20
  headers: { "content-type": "application/json" },
21
21
  body: JSON.stringify(args === undefined ? {} : args),
@@ -8,12 +8,12 @@
8
8
  // 共用同一份实例),本模块不写任何配置——动效在 features/animation/、结束与确认提醒在
9
9
  // features/notify/。没有全局浮层,因此只在面板打开时轮询,关闭面板即零开销。
10
10
  //
11
- // Host 通信:fetch('/dsh-dock/runstate/status')(见 features/runstate/host.js)。
11
+ // Host 通信:fetch('dsh-dock/runstate/status')(见 features/runstate/host.js)。
12
12
  import { useState, useEffect } from "react";
13
13
 
14
14
  // ---------- Host RPC 桥接 ----------
15
15
  function rpcCall(method, args) {
16
- return fetch("/dsh-dock/runstate/" + method, {
16
+ return fetch("dsh-dock/runstate/" + method, {
17
17
  method: "POST",
18
18
  headers: { "content-type": "application/json" },
19
19
  body: JSON.stringify(args === undefined ? {} : args),
@@ -8,13 +8,13 @@
8
8
  // - 挂载即扫描历史+按暂存条件查询;挂载期间每 5s 静默自动刷新
9
9
  // - 查询等待动画:点「查询」后按钮转圈扫光 + 数据区顶部「记账小队清点」banner(轮换俏皮文案,
10
10
  // 等久了换语气),旧数据压暗禁点;结果到位时闪一次「✓ 数据已更新」并让 KPI 卡回弹
11
- // Host 通信:fetch('/dsh-dock/tokenlog/<method>')(见 features/tokenlog/host.js)。
11
+ // Host 通信:fetch('dsh-dock/tokenlog/<method>')(见 features/tokenlog/host.js)。
12
12
  import react, { useState, useEffect, useCallback, useMemo, useRef } from "react";
13
13
  import { openPanel } from "../../src/shared.js";
14
14
 
15
15
  // ---------- Host RPC 桥接 ----------
16
16
  function rpcCall(method, args) {
17
- return fetch("/dsh-dock/tokenlog/" + method, {
17
+ return fetch("dsh-dock/tokenlog/" + method, {
18
18
  method: "POST",
19
19
  headers: { "content-type": "application/json" },
20
20
  body: JSON.stringify(args === undefined ? {} : args),
package/index.js CHANGED
@@ -15,8 +15,10 @@
15
15
  // - animation 任务动画(v0.5.0):会话任务追踪 + 动效配置持久化(参照 @wycto/dsh-task-pulse)
16
16
  // - notify 任务通知:完成/异常/需确认通知 + 提示音/系统通知/钉钉飞书推送(从任务动画拆出)
17
17
  // - runstate 运行状态:进行中任务与最近完成一览(从任务动画拆出;只读,无配置段)
18
- // - mobile-relay 手机接力(未发布):扫码反向代理接力 + 局域网电脑直连(0.0.0.0)
19
- import { DockConfig, sendJson, readBody, migrateNotifyConfig, migrateModelsFeatureId, migrateImportedFeatures, bindDockConfig, readDockRoot, mutateDockSection } from './src/host-core.js'
18
+ // - mobile 手机适配:窄屏排版/Tab 栏/抽屉/遮罩/键盘(宿主经 tapIndex 注入 index.html)
19
+ // - mobile-relay 远程访问:账号密码登录的远程入口(网关 + 账号 + 浏览模式目录选择器)
20
+ import { DockConfig, sendJson, readBody, migrateNotifyConfig, migrateModelsFeatureId, migrateImportedFeatures, migrateImportedSections, bindDockConfig, readDockRoot, mutateDockSection } from './src/host-core.js'
21
+ import { injectWelcomeAck } from './src/welcome-ack.js'
20
22
  import { feature as fModels } from './features/modelconfig/host.js'
21
23
  import { feature as fVisionProxy } from './features/visionproxy/host.js'
22
24
  import { feature as fBalance } from './features/balance/host.js'
@@ -25,6 +27,7 @@ import { feature as fAnimation } from './features/animation/host.js'
25
27
  import { feature as fNotify } from './features/notify/host.js'
26
28
  import { feature as fRunState } from './features/runstate/host.js'
27
29
  import { feature as fMobileRelay } from './features/mobile-relay/host.js'
30
+ import { feature as fMobile } from './features/mobile/host.js'
28
31
 
29
32
  export const name = 'dsh-dock'
30
33
 
@@ -66,6 +69,7 @@ export function apply(ctx, config) {
66
69
  fNotify,
67
70
  fRunState,
68
71
  fMobileRelay,
72
+ fMobile,
69
73
  ]
70
74
 
71
75
  // 绑定 Config 镜像(替代已移除的 settings.get);volatile 字段热更时重绑。
@@ -132,6 +136,10 @@ export function apply(ctx, config) {
132
136
  ctx.inject(['settings'], (sctx) => {
133
137
  // 0.1.7-alpha.1 起 settings.register 已移除——不再调用;导出 Config 即注册。
134
138
  Promise.resolve()
139
+ // settings.yaml.imported 整段恢复(账号 remoteAuth / notify / animation /
140
+ // visionProxy——Config 导出前导入失败停在 imported 文件里的那批)。必须最先跑:
141
+ // 后面的迁移与功能启用都要读到恢复后的配置(远程访问账号丢了网关就开不起来)。
142
+ .then(() => migrateImportedSections(sctx))
135
143
  // 通知配置从 animation 段搬到 notify 段(【任务通知】独立成模块)。
136
144
  // 必须在任何面板保存动作之前跑——animation 模块保存时整段写回,旧字段会被覆盖丢失。
137
145
  .then(() => migrateNotifyConfig(sctx))
@@ -166,6 +174,16 @@ export function apply(ctx, config) {
166
174
  })
167
175
  })
168
176
 
177
+ // 插件级兼容注入(不随功能开关启停):远程(非回环)浏览器的「预览版说明」自动确认。
178
+ // 根因与归属见 src/welcome-ack.js 文件头——关掉远程访问、主实例直接绑 0.0.0.0 的
179
+ // 直连形态同样每次重弹,所以不能挂在【远程访问】名下;回环地址脚本自我短路。
180
+ {
181
+ const webServer = ctx.get('webServer')
182
+ if (webServer && typeof webServer.tapIndex === 'function') {
183
+ ctx.effect(() => webServer.tapIndex(injectWelcomeAck), 'dsh-dock: welcome-ack index tap')
184
+ }
185
+ }
186
+
169
187
  // 功能开关管理路由(无条件注册):GET 查询各功能宿主侧状态;POST 同步开关——
170
188
  // 浏览器面板 toggle 经此写入 settings(跨浏览器/重启保持)并即时 setup/dispose 宿主半部。
171
189
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dock",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计,支持各模型自定义单价、多段分时价与周末/节假日谷价)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景)、任务通知(完成/异常/需确认通知、提示音、系统通知、钉钉/飞书推送)、运行状态(进行中任务与最近完成一览);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -43,7 +43,7 @@
43
43
  "scripts": {
44
44
  "build:client": "node scripts/build-client.mjs",
45
45
  "test:client": "node scripts/test-client-views.mjs",
46
- "test:host": "node scripts/test-task-host.mjs && node scripts/test-tokenlog-host.mjs",
46
+ "test:host": "node scripts/test-task-host.mjs && node scripts/test-tokenlog-host.mjs && node scripts/test-legacy-import.mjs && node scripts/test-mobile-host.mjs && node scripts/test-mobile-relay-host.mjs && node scripts/test-mobile-relay.mjs && node scripts/test-welcome-ack.mjs",
47
47
  "dev:client": "node scripts/watch-client.mjs",
48
48
  "dev:link": "node scripts/dev-link-deps.mjs"
49
49
  },
@@ -64,5 +64,11 @@
64
64
  "@deepseek-ai/dsh-llm": "*",
65
65
  "@deepseek-ai/dsh-settings": "*",
66
66
  "@deepseek-ai/schemastery": "*"
67
+ },
68
+ "allowScripts": {
69
+ "file:../esbuild@0.28.1/node_modules/esbuild": true,
70
+ "file:../../esbuild@0.28.1/node_modules/esbuild": true,
71
+ "file:../../esbuild@0.25.12/node_modules/esbuild": true,
72
+ "file:../../esbuild@0.21.5/node_modules/esbuild": true
67
73
  }
68
- }
74
+ }
Binary file
package/src/client.jsx CHANGED
@@ -28,12 +28,13 @@ import { feature as fNotify } from "../features/notify/view.jsx";
28
28
  import { feature as fRunState } from "../features/runstate/view.jsx";
29
29
  import { feature as fGames } from "../features/games/view.jsx";
30
30
  import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
31
+ import { feature as fMobile } from "../features/mobile/view.js";
31
32
 
32
33
  const name = "dsh-dock";
33
- const DOCK_VERSION = "0.12.0";
34
+ const DOCK_VERSION = "0.14.0";
34
35
 
35
36
  // ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
36
- const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fGames, fMobileRelay];
37
+ const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fGames, fMobileRelay, fMobile];
37
38
  // 规划占位(路线图):接入后移除并建 features/<id>/ 模块
38
39
  const PLANNED_FEATURES = [];
39
40
  const PLANNED_NOTES = {};
package/src/host-core.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFileSync, existsSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import { homedir } from 'node:os'
6
+ import yaml from 'js-yaml'
6
7
  import z from '@deepseek-ai/schemastery'
7
8
 
8
9
  /** dsh-dock 自有 settings 命名空间 = profile 条目 id(cordis.patch.yml 的 id)。 */
@@ -26,6 +27,15 @@ export const DockConfig = z.object({
26
27
  passwordHash: z.string().default(''),
27
28
  salt: z.string().default(''),
28
29
  }).default({}).volatile(),
30
+ /** 远程访问网关的持久化状态:enabled=上次是否在跑;port=自选端口(0=主端口+1)。
31
+ * dsh web 重启后按此自动拉起网关(账号存在才拉),免得每次重启都要进面板点一次。 */
32
+ remoteGateway: z.object({
33
+ enabled: z.boolean().default(false),
34
+ /** 网关端口(0 = 跟随默认的主端口+1);面板「开启远程访问」时回写。 */
35
+ port: z.natural().default(0),
36
+ }).default({}).volatile(),
37
+ /** settings.yaml.imported 全量恢复(migrateImportedSections)是否已执行过的一次性标记。 */
38
+ importedRestored: z.boolean().default(false).volatile(),
29
39
  visionProxy: z.object({
30
40
  enabled: z.boolean().default(false),
31
41
  provider: z.string().default(''),
@@ -296,6 +306,125 @@ export async function migrateImportedFeatures(ctx) {
296
306
  }
297
307
  }
298
308
 
309
+ /** 深比较(JSON 化配置快照之间的相等判断)。 */
310
+ function sameValue(a, b) {
311
+ if (a === b) return true
312
+ if (Array.isArray(a) && Array.isArray(b)) {
313
+ return a.length === b.length && a.every((v, i) => sameValue(v, b[i]))
314
+ }
315
+ if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
316
+ const ka = Object.keys(a)
317
+ const kb = Object.keys(b)
318
+ return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && sameValue(a[k], b[k]))
319
+ }
320
+ return false
321
+ }
322
+
323
+ /** schema 缺省值的普通对象快照(用来判断「现网这一段用户从没写过」)。
324
+ * schemastery 没有 parse():走 Standard Schema validate 取值,volatile 读写器交给 plainify 解开。 */
325
+ function dockDefaults() {
326
+ try {
327
+ const standard = DockConfig['~standard'] || DockConfig[Symbol.for('standard.schema')]
328
+ if (!standard || typeof standard.validate !== 'function') return null
329
+ const result = standard.validate({})
330
+ if (!result || result.issues || !result.value) return null
331
+ return plainifyDockConfig(result.value)
332
+ } catch {
333
+ return null
334
+ }
335
+ }
336
+
337
+ /** 只保留 schema 认识的顶层键:imported 旧文件可能带已废弃字段(如 animation 段里的
338
+ * 旧通知字段),整段原样写会被 mutate 校验拒绝——丢字段会让整次恢复失败。
339
+ * 值原样透传(嵌套结构交 schema 处理),这里只做顶层白名单。 */
340
+ function sanitizeSection(value, template) {
341
+ if (!template || typeof template !== 'object' || Array.isArray(template)) return value
342
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value
343
+ const out = {}
344
+ for (const key of Object.keys(template)) {
345
+ if (Object.hasOwn(value, key)) out[key] = value[key]
346
+ }
347
+ return out
348
+ }
349
+
350
+ /**
351
+ * 一次性全量恢复:`~/.dsh/settings.yaml` 被 dsh 升级改名为 `settings.yaml.imported`
352
+ * 并尝试导入 profile 时,dsh-dock 当时还没导出 Config → 整段导入失败,账号
353
+ * (remoteAuth)、通知、动画、图片代理等配置全部停在 imported 文件里;后来只补迁了
354
+ * features(见 migrateImportedFeatures),于是「远程访问账号丢失、网关开不起来」。
355
+ *
356
+ * 这里把整段补回来,规则保守:
357
+ * - `features`:逐键补缺(现网已有的键绝不覆盖);
358
+ * - 其余 schema 认识的段:仅当现网该段仍等于 schema 缺省值(= 用户从未在此写过)
359
+ * 才整段恢复,现网有任何改动一律以现网为准(不覆盖新配置);
360
+ * - 执行过写入或确认无 imported 文档后置 `importedRestored` 标记,仅跑一次。
361
+ */
362
+ export async function migrateImportedSections(ctx) {
363
+ try {
364
+ const root = readDockRoot(ctx)
365
+ if (root && root.importedRestored === true) return
366
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
367
+ const file = join(home, 'settings.yaml.imported')
368
+ if (!existsSync(file)) return
369
+ let data = null
370
+ try {
371
+ data = yaml.load(readFileSync(file, 'utf8'))
372
+ } catch (error) {
373
+ console.warn('[dsh-dock] settings.yaml.imported 不是合法 YAML,跳过恢复:', (error && error.message) || String(error))
374
+ return
375
+ }
376
+ const section = data && typeof data === 'object' && !Array.isArray(data) ? data[DOCK_NS] : null
377
+ if (!section || typeof section !== 'object' || Array.isArray(section)) {
378
+ await mutateDockSection(ctx, ['importedRestored'], true)
379
+ return
380
+ }
381
+ const defaults = dockDefaults()
382
+ const live = root && typeof root === 'object' ? root : {}
383
+ const writes = []
384
+ const importedFeatures = section.features && typeof section.features === 'object' ? section.features : null
385
+ if (importedFeatures) {
386
+ const merged = live.features && typeof live.features === 'object' ? { ...live.features } : {}
387
+ let added = 0
388
+ for (const [key, value] of Object.entries(importedFeatures)) {
389
+ if (typeof value === 'boolean' && typeof merged[key] !== 'boolean') {
390
+ merged[key] = value
391
+ added++
392
+ }
393
+ }
394
+ if (added > 0) writes.push(['features', merged])
395
+ }
396
+ if (defaults) {
397
+ for (const key of Object.keys(section)) {
398
+ if (key === 'features') continue
399
+ if (!Object.hasOwn(defaults, key)) continue
400
+ // 现网缺该段(= 从没写过)或仍等于 schema 缺省 → 恢复;现网有用户改动一律不覆盖。
401
+ if (live[key] !== undefined && !sameValue(live[key], defaults[key])) continue
402
+ writes.push([key, sanitizeSection(section[key], defaults[key])])
403
+ }
404
+ }
405
+ // 逐段独立落盘:一个段被拒(schema 不认/写盘瞬时失败)不拖死其余段——
406
+ // 尤其 remoteAuth 必须尽量恢复,否则远程访问账号一直起不来。
407
+ const restored = []
408
+ const failed = []
409
+ for (const [path, value] of writes) {
410
+ try {
411
+ await mutateDockSection(ctx, [path], value)
412
+ restored.push(path)
413
+ } catch (error) {
414
+ failed.push(path)
415
+ console.warn(`[dsh-dock] 恢复 ${path} 段失败:`, (error && error.message) || String(error))
416
+ }
417
+ }
418
+ // 全部落盘(或本来就无事可做)才置一次性标记;有失败则下次启动重试。
419
+ if (failed.length === 0) await mutateDockSection(ctx, ['importedRestored'], true)
420
+ if (restored.length > 0) {
421
+ console.log('[dsh-dock] restored from settings.yaml.imported:', restored.join(', '))
422
+ }
423
+ } catch (e) {
424
+ console.warn('[dsh-dock] imported section restore skipped:', (e && e.message) || String(e))
425
+ }
426
+ }
427
+
299
428
  /** 任务动画 effectMode 合法值(客户端动画模式)。 */
300
429
  export const ANIMATION_MODES = [
301
430
  'flow', 'breathe', 'ring', 'orbit', 'robot', 'matrix', 'stars', 'aurora', 'space',
package/src/shared.js CHANGED
@@ -68,11 +68,14 @@ export function initFeatureState(defs) {
68
68
  syncFeatureStateFromHost(defs);
69
69
  }
70
70
 
71
- /** 从宿主 /dsh-dock/features 拉取持久化开关,覆盖尚未本地表态的功能默认值。
71
+ /** 从宿主 dsh-dock/features 拉取持久化开关,覆盖尚未本地表态的功能默认值。
72
+ * 路由一律用文档相对路径(不带前导斜杠):DSH 的 index.html 带 <base href="./">,
73
+ * 子路径反代(如 /ui/)下自动解析为 /ui/dsh-dock/...,根挂载下即 dsh-dock/...。
74
+ * 写死前导斜杠会绕过反代挂载前缀,请求被 302 打回应用入口,宿主接口整片失败。
72
75
  * 拉取失败(宿主旧版本无此路由)静默降级——沿用本地默认。 */
73
76
  function syncFeatureStateFromHost(defs) {
74
77
  if (typeof fetch !== "function") return;
75
- fetch("/dsh-dock/features").then(async (res) => {
78
+ fetch("dsh-dock/features").then(async (res) => {
76
79
  if (!res.ok) return;
77
80
  const data = await res.json().catch(() => null);
78
81
  const persisted = data && data.ok && data.data && data.data.persisted;
@@ -108,7 +111,7 @@ function syncFeatureStateFromHost(defs) {
108
111
  * 开关永远 404 而这里毫无声息,排查了两个版本才发现。) */
109
112
  function pushFeatureEnabledToHost(id, enabled) {
110
113
  if (typeof fetch !== "function") return;
111
- fetch("/dsh-dock/features", {
114
+ fetch("dsh-dock/features", {
112
115
  method: "POST",
113
116
  headers: { "content-type": "application/json" },
114
117
  body: JSON.stringify({ id, enabled }),
@@ -0,0 +1,69 @@
1
+ // dsh-dock · 插件级兼容注入:远程(非回环)浏览器的「预览版说明」自动确认
2
+ //
3
+ // 根因(dsh 0.2.0 ui-settings-models/src/client/welcome-store.ts,文件头注释原文):
4
+ // 回环浏览器的确认走宿主持久化,一次确认永久生效;**远程(非回环)浏览器的设置 scope
5
+ // 是 memory 模式,确认只存页面进程内存,刷新即丢**——所以从局域网/组网 IP 打开时
6
+ // 「预览版说明」每次刷新都重弹,profile patch 里钉 welcomeNoticeVersion 也救不了
7
+ // (memory 分支根本不读持久值)。
8
+ //
9
+ // 归属:这是「非回环浏览器」这一访问形态的兼容问题,与任何一个功能模块的职责都不重合——
10
+ // 【远程访问】只保留远程入口本身(用户要求),而关掉远程访问、主实例经 patch 层绑 0.0.0.0
11
+ // 的直连形态(公司部署)同样会每次重弹。所以挂在插件级(index.js 注册 tapIndex),
12
+ // 不随任何功能开关启停;回环地址自我短路,让 dsh 原生的一次性持久确认自然工作。
13
+ //
14
+ // 与【远程访问】网关的关系:网关代理的页面由网关对连接 bundle 做回环自判改写,
15
+ // 确认可持久化、弹窗首次确认后不再出现,本脚本找不到弹窗即零副作用;直连形态下
16
+ // 本脚本每次页面加载最多替用户点一次「继续」。dsh 未来若让远程确认可持久化,
17
+ // 弹窗不再出现,本脚本自动空转,无需随版本跟进。
18
+ //
19
+ // 注入点在 <head> 之后,body 尚未解析:观察器一律推迟到 DOMContentLoaded 再挂。
20
+ const WELCOME_ACK_JS = [
21
+ ';(function(){',
22
+ 'if (window.__dshDockWelcomeAck) return;',
23
+ 'window.__dshDockWelcomeAck = "host";',
24
+ 'var host = (window.location && window.location.hostname) || "";',
25
+ 'if (host === "127.0.0.1" || host === "localhost" || host === "::1" || host.slice(-10) === ".localhost") return;',
26
+ 'if (typeof MutationObserver === "undefined") return;',
27
+ 'var done = false;',
28
+ 'var observer = null;',
29
+ 'function tryDismiss() {',
30
+ ' if (done || !document.body) return;',
31
+ ' var dialogs = document.querySelectorAll(\'[role="dialog"]\');',
32
+ ' for (var i = 0; i < dialogs.length; i++) {',
33
+ ' if (!/预览版说明|Preview Notice/.test(dialogs[i].textContent || "")) continue;',
34
+ ' var buttons = dialogs[i].querySelectorAll("button");',
35
+ ' for (var j = 0; j < buttons.length; j++) {',
36
+ ' var label = (buttons[j].textContent || "").replace(/^\\s+|\\s+$/g, "");',
37
+ ' if (label === "继续" || label === "Continue") {',
38
+ ' done = true;',
39
+ ' buttons[j].click();',
40
+ ' if (observer) observer.disconnect();',
41
+ ' return;',
42
+ ' }',
43
+ ' }',
44
+ ' break;',
45
+ ' }',
46
+ '}',
47
+ 'function start() {',
48
+ ' observer = new MutationObserver(tryDismiss);',
49
+ ' observer.observe(document.body, { childList: true, subtree: true });',
50
+ ' tryDismiss();',
51
+ // 兜底停止:说明若 60 秒内没出现(未来版本改版/被官方移除),观察器自动退场
52
+ ' setTimeout(function () { if (observer) observer.disconnect(); }, 60000);',
53
+ '}',
54
+ 'if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);',
55
+ 'else start();',
56
+ '})();',
57
+ ].join('\n')
58
+ const WELCOME_ACK_MARKER = 'data-dsh-dock-welcome-ack'
59
+ export { WELCOME_ACK_MARKER }
60
+
61
+ /** 把「预览版说明」自动确认脚本内联注入 index.html 的 <head> 之后(幂等)。 */
62
+ export function injectWelcomeAck(html) {
63
+ if (html.includes(WELCOME_ACK_MARKER)) return html
64
+ const match = /<head(?:\s[^>]*)?>/i.exec(html)
65
+ const script = `<script ${WELCOME_ACK_MARKER}>${WELCOME_ACK_JS}</script>`
66
+ if (!match) return script + html
67
+ const at = match.index + match[0].length
68
+ return html.slice(0, at) + script + html.slice(at)
69
+ }