dsh-auto-open-web 0.1.22 → 0.1.27

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/README.en.md CHANGED
@@ -84,23 +84,33 @@ cleanup.
84
84
 
85
85
  Two equivalent ways:
86
86
 
87
- 1. **Settings form** (recommended):
88
- - **dsh 0.1.5 (incl. 0.1.6-alpha)**: open the **Plugins page → select this bundle
89
- (`dsh-auto-open-web`)**; the form renders on the bundle's detail page between its
90
- description and its rows (slot `plugins.bundle.config`, routed by bundle package
91
- name). In this shape only **Save** is offered — leaving the page drops unsaved
92
- staged edits.
93
- - **rc.7 0.1.4**: Settings Plugin configuration the "自动打开网页" collapsible card
94
- (slot `settings.plugin.item`). Both slots are registered at once and whichever
95
- exists wins, so one package serves both generations.
96
-
97
- Both shapes edit
87
+ 1. **Settings form** (recommended) — two supported version lines; the plugin registers for
88
+ both and whichever exists wins:
89
+ - **Newest version (dsh 0.1.6-alpha, incl. 0.1.7-alpha.1)**: **Plugins page
90
+ `dsh-auto-open-web` this plugin's row Configure**. The form opens per **row**
91
+ (slot `plugins.row.config`, key `<package name>#<row id>` =
92
+ `dsh-auto-open-web#auto-open-web`); the page supplies the data
93
+ (`form = { state, mutate }` the host-projected row Config snapshot plus atomic
94
+ writes) and the plugin only draws the controls. Only **Save** is offered — leaving
95
+ the page drops staged edits.
96
+ - **Latest rc line (0.1.5-rc.x)**: Settings → Plugin configuration → the "自动打开网页"
97
+ collapsible card (slot `settings.plugin.item`, keyed by settings namespace). Data goes
98
+ through the host's `settings.register` plus the client `settingsScope`.
99
+
100
+ The two lines' client services **do not coexist** (new `configForms` ↔ rc-line
101
+ `settingsScope`), so the plugin reads both **lazily** and declares only the shared
102
+ services in `inject` (`slots`/`locale`/`connection`/`remote`) — a hard inject would leave
103
+ the plugin pending forever on the other line and the settings UI would never appear.
104
+ Earlier versions (0.1.0-rc.*, 0.1.1-rc.*, 0.1.2-*, 0.1.3-alpha, 0.1.5-alpha,
105
+ 0.1.6-alpha.1) are **no longer guaranteed**.
106
+
107
+ Both entry points edit
98
108
  `appWindow` (independent app window), `windowKind` (WebView2 host / browser app window),
99
109
  `browserPath` (browser executable, with a native "Browse" file dialog; located below the
100
110
  window-type field and enabled only when "Browser app window" is selected),
101
111
  `exitOnWindowClose` (exit DSH when the window closes, off by default).
102
- After saving, values persist through the **official settings domain** (client `settingsScope`)
103
- to the settings document (namespace `auto-open-web`); once saved,
112
+ After saving, values take precedence over row configuration (on the newest version they land
113
+ in that row's user layer; on the rc line in the `auto-open-web` settings namespace); once saved,
104
114
  settings take precedence over row configuration. The host keeps only the "Browse" / "Test"
105
115
  helper routes (capabilities the official channel cannot cover).
106
116
  2. **Row configuration** (`cordis.patch.yml`): acts as the startup seed, effective until the
@@ -193,12 +203,14 @@ without touching the package.
193
203
 
194
204
  - **Zero dependencies**: every runtime dependency is provided by the **DSH deployment** and
195
205
  declared as an optional peer (no install warnings):
196
- - `@deepseek-ai/dsh` (the host; declares the compatibility range `>=0.1.0-rc.8 <0.2.0`
197
- the client half resolves the snapshot store across generations
198
- (`@deepseek-ai/dsh-client-store` `@deepseek-ai/dsh-client-runtime` → built-in
199
- fallback) and registers into both the current plugin-manager slot
200
- (`plugins.bundle.config`) and the earlier settings slot (`settings.plugin.item`);
201
- no runtime version check is performed)
206
+ - `@deepseek-ai/dsh` (the host; declares the compatibility range **`>=0.1.5-rc.1 <0.2.0`**
207
+ covering the **latest rc line (0.1.5-rc.x)** and the **newest version (≥0.1.6-alpha,
208
+ incl. 0.1.7-alpha.1)**; older 0.1.0-rc.*/0.1.1-rc.*/0.1.2-*/0.1.3-alpha/0.1.5-alpha
209
+ releases are no longer guaranteed. The snapshot store uses
210
+ `@deepseek-ai/dsh-client-store` only the rc.8-era `dsh-client-runtime` fallback was
211
+ removed; the client registers the newest version's row slot (`plugins.row.config`) and
212
+ the rc line's settings slot (`settings.plugin.item`); no runtime version check is
213
+ performed)
202
214
  - `@deepseek-ai/schemastery` (config schema validator; resolved at runtime: normal import
203
215
  first, then the DSH deployment copy under the Windows global npm layout)
204
216
  - `koffi` (Windows only: Job Object / process verification / native file dialog; same runtime
package/README.md CHANGED
@@ -3,8 +3,9 @@
3
3
  > English: [README.en.md](README.en.md)
4
4
 
5
5
  dsh web profile 启动后自动打开独立应用窗口(或网页标签页)的常驻插件,并在
6
- 设置界面提供配置表单(手动维护浏览器位置等)。dsh ≥ 0.1.5 的配置入口是
7
- **「插件管理」页里本 bundle 的详情页**;rc.7–0.1.4 为**设置 → 插件配置**卡片。
6
+ 设置界面提供配置表单(手动维护浏览器位置等)。**最新版**的配置入口是
7
+ 「插件管理」页里本插件**那一行**的配置页;**最新 rc 线(0.1.5-rc.x)**为
8
+ **设置 → 插件配置**卡片。
8
9
 
9
10
  ## 行为
10
11
 
@@ -79,22 +80,30 @@ Job Object(强杀也生效)+ 退出清理结束进程树。
79
80
 
80
81
  两种途径,等价:
81
82
 
82
- 1. **设置表单**(推荐):
83
- - **dsh ≥ 0.1.5(含 0.1.6-alpha)**:**「插件管理」页 → 选中本插件(bundle
84
- `dsh-auto-open-web`)**,配置表单显示在该 bundle 详情页的说明与插件行之间
85
- (插槽 `plugins.bundle.config`,按 bundle 包名路由)。此形态下只有「保存」,
86
- 离开页面即丢弃未保存的暂存编辑。
87
- - **rc.7 0.1.4**:设置 插件配置 → 「自动打开网页」折叠卡片
88
- (插槽 `settings.plugin.item`)。两代插槽**同时注册、谁存在谁生效**,
89
- 无需按版本切换插件。
90
-
91
- 两代都可编辑 `appWindow`(独立应用窗口)、`windowKind`(WebView2 宿主 /
83
+ 1. **设置表单**(推荐)——两条受支持的版本线,插件**同时**注册、哪条存在就哪条生效:
84
+ - **最新版(dsh ≥ 0.1.6-alpha,含 0.1.7-alpha.1)**:**「插件管理」页 →
85
+ `dsh-auto-open-web`(bundle 详情页)**,表单直接显示在该页说明与组件行之间
86
+ (插槽 `plugins.bundle.config`,key = **bundle 包名**)—— 与 `dsh-harness-tags`
87
+ 同款席位。页面只传 `{ view: 'page' }`,**数据由插件自绑**:命名空间 = 本条目 id,
88
+ 经客户端服务 `configForms` ConfigForm(快照 + `set`/`unset`/订阅)。
89
+ 此形态只有「保存」,**离开页面即丢弃暂存**。
90
+ - **最新 rc 线(0.1.5-rc.x)**:设置 → 插件配置 → 「自动打开网页」折叠卡片
91
+ (插槽 `settings.plugin.item`,按设置命名空间 keyed)。数据走宿主
92
+ `settings.register` + 客户端 `settingsScope`。
93
+
94
+ 两条线的**客户端服务互不存在**(新版的 `configForms` ↔ rc 线的
95
+ `settingsScope`),因此插件把这两个服务都改为**惰性读取**、`inject` 只声明
96
+ 两线共有的服务(`slots`/`locale`/`connection`/`remote`)——否则硬注入会让插件
97
+ 在另一条线上永远 pending、设置界面完全不出现。更早的版本(0.1.0-rc.*、
98
+ 0.1.1-rc.*、0.1.2-*/0.1.3-alpha/0.1.5-alpha/0.1.6-alpha.1)**不再保证**。
99
+
100
+ 两种入口都可编辑 `appWindow`(独立应用窗口)、`windowKind`(WebView2 宿主 /
92
101
  浏览器应用窗口)、`browserPath`(浏览器可执行文件,支持「浏览」原生对话框
93
102
  选择;位于窗口类型下方,仅选择「浏览器应用窗口」时使能)、
94
103
  `exitOnWindowClose`(窗口关闭时退出 DSH,默认关闭)。
95
- 保存后经**官方 settings 域**(客户端 settingsScope)持久化到
96
- settings 文档(命名空间 `auto-open-web`),首次保存后设置值优先于行配置;
97
- 宿主仅保留「浏览」「测试」辅助路由(官方通道无法覆盖的能力)。
104
+ 保存后设置值优先于行配置(新版落在该行的用户层覆盖上,rc 线落在
105
+ `auto-open-web` 设置命名空间);宿主仅保留「浏览」「测试」辅助路由
106
+ (官方通道无法覆盖的能力)。
98
107
  2. **行配置**(cordis.patch.yml):作为启动种子,设置表单保存前生效。
99
108
 
100
109
  | 字段 | 默认 | 说明 |
@@ -166,8 +175,8 @@ dsh plugin --profile web remove dsh-auto-open-web # 同时移除依赖与对
166
175
  安装后:pnpm 将包加入 `profiles/web/node_modules`,`dsh` 把
167
176
  `dsh-auto-open-web` 追加到 `dsh.profile.bundles`;启动时 bundle 的
168
177
  `cordis.patch.yml` 插入插件行(`name: auto-open-web`,按包名解析)。
169
- 重启 `dsh web` 后,配置表单出现在对应入口(dsh ≥ 0.1.5:「插件管理」页里
170
- `dsh-auto-open-web` bundle 的详情页;更早版本:设置 → 插件配置卡片)。
178
+ 重启 `dsh web` 后,配置表单出现在对应入口(最新版:「插件管理」页里
179
+ `dsh-auto-open-web` 那一行的配置页;最新 rc 线:设置 → 插件配置卡片)。
171
180
  客户端 bundle 由 modules 行按 `dsh.client` 声明在启动时扫描进浏览器清单;
172
181
  插件在客户端**同时**向 `plugins.bundle.config`(新)与 `settings.plugin.item`
173
182
  (旧)两个插槽注册,由客户端插槽机制决定哪个生效(不存在者保持 pending,
@@ -182,10 +191,11 @@ dsh plugin --profile web remove dsh-auto-open-web # 同时移除依赖与对
182
191
 
183
192
  - **本包零 dependencies**:所有运行时依赖均由 **DSH 部署提供**,以 optional
184
193
  peer 声明(安装无警告):
185
- - `@deepseek-ai/dsh`(宿主,声明兼容范围 `>=0.1.0-rc.8 <0.2.0`:
186
- rc.8 起客户端冻结表移除 `dsh-client-schema-form`、引入
187
- `dsh-client-runtime`,本插件的设置卡片与打开行为均依赖该版本;
188
- 不做运行时版本检测)
194
+ - `@deepseek-ai/dsh`(宿主,声明兼容范围 **`>=0.1.5-rc.1 <0.2.0`**:
195
+ 覆盖**最新 rc 线(0.1.5-rc.x)**与**最新版(≥0.1.6-alpha,含 0.1.7-alpha.1)**;
196
+ 更早的 0.1.0-rc.*/0.1.1-rc.*/0.1.2-*/0.1.3-alpha/0.1.5-alpha 不再保证。
197
+ 快照 store 统一走 `@deepseek-ai/dsh-client-store`(自 0.1.2-alpha.2 起,
198
+ rc.8 时代的 `dsh-client-runtime` 回退已移除);不做运行时版本检测)
189
199
  - `@deepseek-ai/schemastery`(配置 schema 校验器;运行时解析:常规 import
190
200
  优先,其次 Windows 全局 npm 布局下的 DSH 部署副本)
191
201
  - `koffi`(仅 Windows 的 Job Object / 进程校验 / 原生对话框;同样运行时
@@ -197,6 +207,16 @@ dsh plugin --profile web remove dsh-auto-open-web # 同时移除依赖与对
197
207
  (不 require `dsh-client-ui-primitives`,不复刻官方样式表),卡片外壳、
198
208
  字段、按钮、输入框、图标全部手写;观感经官方设计令牌变量
199
209
  (`--dsw-alias-*` / `--dsw-static-*`)对齐,浅/深色自动跟随主题。
210
+ **维护提示(0.1.26,新版设置投影)**:dsh 0.1.7 起宿主只把**标记为
211
+ `volatile` 的 Config 字段**投影成设置表单(`dsh-settings` 的 `volatileForm()`
212
+ 对非 volatile 字段直接返回 undefined → `describe()` 里没有本条目 → 页面显示
213
+ "本部署未提供该设置命名空间")。因此 `lib/index.js` 的 `Config` 四个字段都必须
214
+ 链上 schemastery 的 **`.volatile()`**(官方 `dsh-agent-loop` 同款写法);
215
+ **不要**写成 `.set('volatile', true)` —— `Schema.prototype.set` 是写 dict 子字段,
216
+ 会把 schema 弄坏、条目 import 失败。
217
+ 0.1.7 把设置命名空间改为"**每 profile 条目一个**"(= 本条目 id),与 bundle
218
+ 席位配套:页面只给 `view`,插件自己经惰性读取的 `configForms` 绑定该命名空间
219
+ (rc 线同理,用惰性读取的 `settingsScope`)。
200
220
  **维护提示(0.1.22)**:主按钮(保存)必须用**随主题反转**的令牌对
201
221
  `background:var(--dsw-alias-label-primary)` +
202
222
  `color:var(--dsw-alias-bg-layer-3)`(与官方 `PluginConfigForm` 的 save 同款)。
@@ -205,8 +225,9 @@ dsh plugin --profile web remove dsh-auto-open-web # 同时移除依赖与对
205
225
  同理不要引用并不存在的 `--dsw-alias-label-error`(真名是
206
226
  `--dsw-alias-state-error-primary`)。这两条已由 `smoke-client-slots.mjs`
207
227
  断言守护。
208
- 仅使用官方公开的数据/机制接口:`settingsScope`(settings 域)、`slots`
209
- (插槽)、`locale`(文案)、快照 store(跨版本解析)。
228
+ 仅使用官方公开的数据/机制接口:`slots`(插槽)、`locale`(文案)、
229
+ 行 Config 表单(新版,页面下发 `form`)、rc 线的 `settingsScope`
230
+ (惰性读取)、快照 store。
210
231
  - **npm 包已含 WebView2 宿主编译产物**(prepack 编译后发布);**GitHub
211
232
  main 分支与源码 checkout 方式不含** `host-publish/`(构建产物被
212
233
  .gitignore 忽略):webview2 模式需先在 `node_modules/dsh-auto-open-web`
package/lib/client.js CHANGED
@@ -4,16 +4,24 @@
4
4
  // **不** require @deepseek-ai/dsh-client-ui-primitives 等官方 UI 包。
5
5
  //
6
6
  // 依赖边界(与官方数据的对接面,均为公开接口):
7
- // - settingsScope:官方 settings 域(命名空间 `auto-open-web`)
8
- // - slots:**两代插槽各自注册,谁存在谁生效**(都用 slots.inject 挂载,
9
- // 不存在的插槽只会保持 pending,不会报错):
10
- // · dsh ≥0.1.5:插件设置搬到「插件管理」页,bundle 配置槽
11
- // `plugins.bundle.config`(按 **bundle 包名** keyed,只渲染 view:'page')
12
- // · rc.7–0.1.4:设置页插件配置卡片 `settings.plugin.item`
13
- // (按设置命名空间 keyed,label 供列表显示)
7
+ // 支持两条版本线(0.1.26 起;更早的 alpha/rc 不再保证):
8
+ // · 最新版(dsh ≥0.1.6-alpha,含 0.1.7-alpha.1):插件设置位于「插件管理」页,
9
+ // 配置注册在 **bundle 详情页** —— 槽 `plugins.bundle.config`,key = bundle
10
+ // 包名(`dsh-auto-open-web`),与 dsh-harness-tags 同款席位。页面只传
11
+ // `{ view:'page' }`,没有 form,故组件自己经客户端服务 `configForms` 绑命名
12
+ // 空间(条目 id `auto-open-web`);宿主只在 Config 字段标了 `.volatile()`
13
+ // 时才投影该命名空间。
14
+ // · 最新 rc 线(0.1.5-rc.x):设置页插件配置卡片 —— 槽 `settings.plugin.item`
15
+ // (按设置命名空间 keyed),数据走宿主 `settings.register` + 客户端
16
+ // `settingsScope`。
17
+ // 两条线的客户端服务互不存在(settingsScope ↔ configForms),因此:
18
+ // - `exports.inject` 只声明两线共有的服务(slots/locale/connection/remote);
19
+ // - settingsScope / configForms 都用 `ctx.get` **惰性读取**;
20
+ // - 两个槽都用 slots.inject 挂载,不存在的插槽只保持 pending(不报错)。
14
21
  // - locale:文案注册(zh/en/ja/ko/fr/de/ru,跟随界面语言)
15
- // - store:createSnapshotStore 快照 store(解析顺序 dsh-client-store
16
- // dsh-client-runtime 内置最小实现)
22
+ // - store:createSnapshotStore 快照 store(dsh-client-store,缺失时内置最小实现)
23
+ // —— rc.8 时代的 dsh-client-runtime 回退已按兼容策略移除(0.1.2-alpha.2
24
+ // 统一为 dsh-client-store)。
17
25
  //
18
26
  // 视觉(全部自产):
19
27
  // - 卡片外壳、字段、按钮、输入框、复选/单选、徽章、图标均为手写实现;
@@ -103,23 +111,15 @@ window.__ModuleLoader__.load({
103
111
  injectCss(CONTROLS_TAG, CONTROLS_CSS)
104
112
 
105
113
  // ── 共享模块 ─────────────────────────────────────────────────────────
106
- // 快照 store 解析顺序( gowin 插件同款,双版本兼容):
107
- // @deepseek-ai/dsh-client-store(当前壳层静态模块,dsh >= 0.1.2-alpha.1)
108
- // @deepseek-ai/dsh-client-runtime(rc8 动态包) → 内置最小实现。
109
- // 缺失或版本不含 createSnapshotStore 时退化为内置实现,绝不抛错。
114
+ // 快照 store:@deepseek-ai/dsh-client-store(壳层静态模块,自 dsh 0.1.2-alpha.2
115
+ // 起提供;最新 rc 线与最新版都包含它)。rc.8 时代的 dsh-client-runtime 回退
116
+ // 已按兼容策略移除;若该模块缺失(异常部署)退化为内置最小实现,绝不抛错。
110
117
  var createSnapshotStore = null
111
118
  try {
112
119
  createSnapshotStore = require('@deepseek-ai/dsh-client-store').createSnapshotStore
113
120
  } catch (e) {
114
121
  console.error('[auto-open-web] dsh-client-store require failed:', e !== null && e !== undefined && e.message !== undefined ? e.message : String(e))
115
122
  }
116
- if (createSnapshotStore === undefined || createSnapshotStore === null) {
117
- try {
118
- createSnapshotStore = require('@deepseek-ai/dsh-client-runtime').createSnapshotStore
119
- } catch (e) {
120
- console.error('[auto-open-web] dsh-client-runtime require failed:', e !== null && e !== undefined && e.message !== undefined ? e.message : String(e))
121
- }
122
- }
123
123
  if (createSnapshotStore === undefined || createSnapshotStore === null) {
124
124
  createSnapshotStore = fallbackSnapshotStore
125
125
  }
@@ -467,99 +467,56 @@ window.__ModuleLoader__.load({
467
467
  }
468
468
  }
469
469
 
470
- // ── 设置表单(同一组件服务两代插槽) ──────────────────────────────────
471
- // · 新版 plugins.bundle.config:页面传 view:'page',只出表单本体
472
- // (标题/面包屑/图标由页面自绘),无折叠外壳、无"放弃"按钮,
473
- // 离开页面即丢弃暂存编辑 —— 与官方 PluginConfigForm 的约定一致。
474
- // · 旧版 settings.plugin.item:无 view,渲染自带折叠外壳的卡片
475
- // (标题/描述/未保存标记/放弃/保存)
476
- function AutoOpenSettingsPage(props) {
477
- const { t } = props
478
- const state = props.useAutoOpenCard((snapshot) => snapshot)
479
- const [picking, setPicking] = React.useState(false)
480
- const [testing, setTesting] = React.useState(false)
481
- const [testResult, setTestResult] = React.useState(null) // { ok, text } | null
482
- const isPage = props.view === 'page'
483
- // 新版页面表单:卸载即丢弃暂存编辑(官方同约定);用 ref 持有最新 discard。
484
- const discardRef = React.useRef(props.discard)
485
- discardRef.current = props.discard
486
- React.useEffect(() => () => {
487
- if (isPage) discardRef.current()
488
- }, [])
489
-
490
- const browse = () => {
491
- if (!state.writable || picking) return
492
- setPicking(true)
493
- fetch('/auto-open-web/pick-browser', { method: 'POST' })
494
- .then((response) => response.json())
495
- .then((result) => {
496
- if (result !== null && result !== undefined && result.ok === true && typeof result.path === 'string') {
497
- props.edit('browserPath', result.path)
498
- }
499
- })
500
- .catch(() => { /* 选择失败静默;用户可重试 */ })
501
- .then(() => { setPicking(false) })
502
- }
503
-
504
- const testBrowser = () => {
505
- if (testing) return
506
- setTesting(true)
507
- setTestResult(null)
508
- fetch('/auto-open-web/test-browser', {
509
- method: 'POST',
510
- headers: { 'content-type': 'application/json' },
511
- body: JSON.stringify({ browserPath: state.browserPath.text }),
512
- })
513
- .then((response) => response.json())
514
- .then((result) => {
515
- if (result !== null && result !== undefined && result.ok === true) {
516
- setTestResult({ ok: true, text: typeof result.message === 'string' ? result.message : t('testOk') })
517
- } else {
518
- setTestResult({ ok: false, text: t('testFail') + ': ' + (result !== null && result !== undefined && typeof result.error === 'string' ? result.error : 'unknown') })
519
- }
520
- })
521
- .catch((error) => { setTestResult({ ok: false, text: t('testFail') + ': ' + String(error !== null && error !== undefined && error.message !== undefined ? error.message : error) }) })
522
- .then(() => { setTesting(false) })
523
- }
524
-
525
- const browserDisabled = !state.writable || state.windowKind.text !== 'browser'
526
-
527
- const badgesFor = (field) => React.createElement(FieldBadges, {
470
+ // ── 两代插槽共用的字段构件 ───────────────────────────────────────────
471
+ /** 字段规格(两代共用:schema 字段名 文本格式化/解析规则)。 */
472
+ function fieldSpecs() {
473
+ return [
474
+ boolField('appWindow'),
475
+ windowKindField('windowKind'),
476
+ textField('browserPath'),
477
+ boolField('exitOnWindowClose')
478
+ ]
479
+ }
480
+ /** 字段头部"已覆盖 + 恢复默认"徽章(静态徽章可选)。 */
481
+ function badgesFor(t, state, field, resetField, staticLabel) {
482
+ return React.createElement(FieldBadges, {
483
+ static: staticLabel,
528
484
  overridden: state[field].overridden,
529
485
  disabled: !state.writable,
530
486
  overriddenLabel: t('overridden'),
531
487
  resetLabel: t('reset'),
532
- onReset: () => props.resetField(field)
488
+ onReset: () => resetField(field)
533
489
  })
534
-
535
- // 插件管理页要一行摘要时(summary 视图;bundle 配置通常只取 page,
536
- // 此处按官方卡片同约定兜底)。
537
- if (props.view === 'summary') return t('description')
538
-
539
- // ---- 字段(两代插槽共用;key 供列表渲染) ----
540
- const fields = [
541
- // 独立应用窗口
490
+ }
491
+ /**
492
+ * 四个字段的节点(两代插槽共用同一份 UI 与文案)。
493
+ * @param t - 文案
494
+ * @param state - 每字段 { text, overridden, invalid } + writable
495
+ * @param edit - (field, value) 暂存编辑
496
+ * @param resetField - (field) 恢复默认(清除用户层)
497
+ * @param ui - useBrowserTools 的返回值(浏览/测试按钮与结果)
498
+ */
499
+ function buildFieldNodes(t, state, edit, resetField, ui) {
500
+ return [
542
501
  React.createElement(Field, {
543
502
  key: 'appWindow',
544
503
  label: t('appWindow'),
545
- badges: badgesFor('appWindow'),
546
- control: React.createElement(Checkbox, { checked: state.appWindow.text === true, disabled: !state.writable, onChange: (v) => props.edit('appWindow', v) }, t('appWindowHint'))
504
+ badges: badgesFor(t, state, 'appWindow', resetField),
505
+ control: React.createElement(Checkbox, { checked: state.appWindow.text === true, disabled: !state.writable, onChange: (v) => edit('appWindow', v) }, t('appWindowHint'))
547
506
  }),
548
- // 窗口类型
549
507
  React.createElement(Field, {
550
508
  key: 'windowKind',
551
509
  label: t('windowKind'),
552
- badges: badgesFor('windowKind'),
510
+ badges: badgesFor(t, state, 'windowKind', resetField),
553
511
  control: React.createElement(React.Fragment, null,
554
- React.createElement(Radio, { checked: state.windowKind.text === 'webview2', disabled: !state.writable, value: 'webview2', onChange: (v) => props.edit('windowKind', v) }, t('windowKindWebview2')),
555
- React.createElement(Radio, { checked: state.windowKind.text === 'browser', disabled: !state.writable, value: 'browser', onChange: (v) => props.edit('windowKind', v) }, t('windowKindBrowser'))
512
+ React.createElement(Radio, { checked: state.windowKind.text === 'webview2', disabled: !state.writable, value: 'webview2', onChange: (v) => edit('windowKind', v) }, t('windowKindWebview2')),
513
+ React.createElement(Radio, { checked: state.windowKind.text === 'browser', disabled: !state.writable, value: 'browser', onChange: (v) => edit('windowKind', v) }, t('windowKindBrowser'))
556
514
  )
557
515
  }),
558
- // 浏览器可执行文件(输入 + 浏览/测试按钮)
559
516
  React.createElement(Field, {
560
517
  key: 'browserPath',
561
518
  label: t('browserPath'),
562
- badges: badgesFor('browserPath'),
519
+ badges: badgesFor(t, state, 'browserPath', resetField),
563
520
  hint: t('browserPathHint'),
564
521
  invalid: state.browserPath.invalid,
565
522
  invalidLabel: t('browserPathInvalid'),
@@ -568,54 +525,241 @@ window.__ModuleLoader__.load({
568
525
  id: 'auto-open-web-browser-path',
569
526
  value: state.browserPath.text,
570
527
  placeholder: t('browserPathPlaceholder'),
571
- disabled: browserDisabled,
528
+ disabled: ui.browserDisabled,
572
529
  invalid: state.browserPath.invalid,
573
- onChange: (text) => props.edit('browserPath', text)
530
+ onChange: (text) => edit('browserPath', text)
574
531
  }),
575
- React.createElement(Btn, { disabled: !state.writable || picking, onClick: browse }, picking ? t('browsePicking') : t('browse')),
576
- React.createElement(Btn, { disabled: testing || state.windowKind.text !== 'browser', onClick: testBrowser }, testing ? t('testRunning') : t('test'))
532
+ React.createElement(Btn, { disabled: !state.writable || ui.picking, onClick: ui.browse }, ui.picking ? t('browsePicking') : t('browse')),
533
+ React.createElement(Btn, { disabled: ui.testing || state.windowKind.text !== 'browser', onClick: ui.testBrowser }, ui.testing ? t('testRunning') : t('test'))
577
534
  ),
578
- footer: testResult !== null
579
- ? React.createElement('p', { className: testResult.ok ? 'aow-test-ok' : 'aow-test-fail', role: 'status' },
580
- (testResult.ok ? '✓ ' : '✗ ') + testResult.text
535
+ footer: ui.testResult !== null
536
+ ? React.createElement('p', { className: ui.testResult.ok ? 'aow-test-ok' : 'aow-test-fail', role: 'status' },
537
+ (ui.testResult.ok ? '✓ ' : '✗ ') + ui.testResult.text
581
538
  )
582
539
  : null
583
540
  }),
584
- // 窗口关闭时退出 DSH(实验性)
585
541
  React.createElement(Field, {
586
542
  key: 'exitOnWindowClose',
587
543
  label: t('exitOnWindowClose'),
588
- badges: React.createElement(FieldBadges, {
589
- static: t('experimental'),
590
- overridden: state.exitOnWindowClose.overridden,
591
- disabled: !state.writable,
592
- overriddenLabel: t('overridden'),
593
- resetLabel: t('reset'),
594
- onReset: () => props.resetField('exitOnWindowClose')
595
- }),
596
- control: React.createElement(Checkbox, { checked: state.exitOnWindowClose.text === true, disabled: !state.writable, onChange: (v) => props.edit('exitOnWindowClose', v) }, t('exitOnWindowCloseHint'))
544
+ badges: badgesFor(t, state, 'exitOnWindowClose', resetField, t('experimental')),
545
+ control: React.createElement(Checkbox, { checked: state.exitOnWindowClose.text === true, disabled: !state.writable, onChange: (v) => edit('exitOnWindowClose', v) }, t('exitOnWindowCloseHint'))
597
546
  })
598
547
  ]
548
+ }
549
+ /** 宿主辅助路由(原生浏览对话框 / 拉起测试):两代插槽共用。 */
550
+ function useBrowserTools(t, state, edit) {
551
+ const [picking, setPicking] = React.useState(false)
552
+ const [testing, setTesting] = React.useState(false)
553
+ const [testResult, setTestResult] = React.useState(null) // { ok, text } | null
554
+ const browse = () => {
555
+ if (!state.writable || picking) return
556
+ setPicking(true)
557
+ fetch('/auto-open-web/pick-browser', { method: 'POST' })
558
+ .then((response) => response.json())
559
+ .then((result) => {
560
+ if (result !== null && result !== undefined && result.ok === true && typeof result.path === 'string') {
561
+ edit('browserPath', result.path)
562
+ }
563
+ })
564
+ .catch(() => { /* 选择失败静默;用户可重试 */ })
565
+ .then(() => { setPicking(false) })
566
+ }
567
+ const testBrowser = () => {
568
+ if (testing) return
569
+ setTesting(true)
570
+ setTestResult(null)
571
+ fetch('/auto-open-web/test-browser', {
572
+ method: 'POST',
573
+ headers: { 'content-type': 'application/json' },
574
+ body: JSON.stringify({ browserPath: state.browserPath.text })
575
+ })
576
+ .then((response) => response.json())
577
+ .then((result) => {
578
+ if (result !== null && result !== undefined && result.ok === true) {
579
+ setTestResult({ ok: true, text: typeof result.message === 'string' ? result.message : t('testOk') })
580
+ } else {
581
+ setTestResult({ ok: false, text: t('testFail') + ': ' + (result !== null && result !== undefined && typeof result.error === 'string' ? result.error : 'unknown') })
582
+ }
583
+ })
584
+ .catch((error) => { setTestResult({ ok: false, text: t('testFail') + ': ' + String(error !== null && error !== undefined && error.message !== undefined ? error.message : error) }) })
585
+ .then(() => { setTesting(false) })
586
+ }
587
+ return {
588
+ picking,
589
+ testing,
590
+ testResult,
591
+ browse,
592
+ testBrowser,
593
+ browserDisabled: !state.writable || state.windowKind.text !== 'browser'
594
+ }
595
+ }
599
596
 
600
- // ---- 新版:插件管理页的 bundle 配置表单(view:'page') ----
601
- // 页面自绘标题/面包屑,这里只出表单本体:只读提示 + 控件 + 保存
602
- // (失败提示),无折叠外壳、无"放弃"按钮(离开页面自动丢弃)
603
- if (isPage) {
604
- if (!state.available) {
605
- return React.createElement('p', { className: 'aow-hint', role: 'status' }, t('unavailable'))
597
+ // ── 新版(dsh 0.1.6-alpha):插件管理页 → 行配置页 ───────────────────
598
+ // 页面下发 `form = { state, mutate }`:state 是宿主投影的行 Config 快照
599
+ // (value/base/user/status/writable/revision),mutate(ops, revision) 原子写入。
600
+ // 与官方同一约定:只有保存会写入,离开页面即丢弃暂存(暂存只在本组件 state)
601
+ function rowFieldStored(snapshot, field) {
602
+ const user = snapshot.user
603
+ return user !== undefined && user !== null && Object.prototype.hasOwnProperty.call(user, field)
604
+ }
605
+ /** 单字段视图(staged 优先,否则取快照值);与 FormModel.field 同语义。 */
606
+ function rowFieldView(snapshot, staged, spec) {
607
+ const edit = staged.get(spec.field)
608
+ const value = snapshot.value !== undefined && snapshot.value !== null ? snapshot.value[spec.field] : undefined
609
+ if (edit === undefined) {
610
+ return { text: spec.format(value), overridden: rowFieldStored(snapshot, spec.field), invalid: false }
611
+ }
612
+ const write = edit.clear ? { kind: 'clear' } : spec.parse(edit.text)
613
+ return { text: edit.text, overridden: write !== undefined && write.kind === 'set', invalid: write === undefined }
614
+ }
615
+ /** 暂存草稿 → 写入计划(与 FormModel.plan 同语义,产物是 path op)。 */
616
+ function rowPlan(snapshot, staged, specs) {
617
+ const plan = []
618
+ for (const [field, edit] of staged) {
619
+ const spec = specs.find((item) => item.field === field)
620
+ const value = snapshot.value !== undefined && snapshot.value !== null ? snapshot.value[field] : undefined
621
+ if (edit.clear) {
622
+ if (rowFieldStored(snapshot, field)) plan.push({ field, op: { op: 'unset', path: [field] } })
623
+ continue
606
624
  }
607
- const blockedPage = !state.dirty || state.invalid || state.saving
608
- return React.createElement('div', null,
609
- !state.writable ? React.createElement('p', { className: 'aow-cardReadOnly', role: 'status' }, t('readOnly')) : null,
610
- ...fields,
611
- React.createElement('div', { className: 'aow-cardFooter' },
612
- state.failed ? React.createElement('p', { className: 'aow-cardFailed', role: 'status' }, t('saveFailed')) : null,
613
- React.createElement(Btn, { variant: 'primary', disabled: blockedPage, onClick: props.save }, state.saving ? t('saving') : t('save'))
614
- )
615
- )
625
+ if (edit.text === spec.format(value)) continue
626
+ const write = spec.parse(edit.text)
627
+ if (write === undefined) plan.push({ field, op: undefined })
628
+ else if (write.kind === 'clear') plan.push({ field, op: { op: 'unset', path: [field] } })
629
+ else plan.push({ field, op: { op: 'set', path: [field], value: write.value } })
630
+ }
631
+ return plan
632
+ }
633
+ /**
634
+ * bundle 配置页的数据源解析(按优先级):
635
+ * 1. 页面下发的 `props.form`(官方路径:页面把宿主投影的 Config 作为
636
+ * `{ state, mutate }` 传入——`plugins.item`/`plugins.row.config` 会这样传);
637
+ * 2. 页面**只传 view**(0.1.7-alpha.1 的 `plugins.bundle.config` 正是如此)时,
638
+ * 自行经客户端服务 `configForms` 按命名空间(= 本条目 id)解析,拿到
639
+ * ConfigForm(getSnapshot/subscribe/set/unset,与本插件 FormModel 的 scope
640
+ * 接口一致);候选顺序:行 id → 包名。
641
+ * 两者都没有才返回 null(组件显示"该命名空间未提供"——通常意味着 Config
642
+ * 字段没标 `.volatile()`,宿主压根没投影出命名空间)。
643
+ * @param props - 插槽 owner props(可能带 form)
644
+ * @param forms - configForms 服务(惰性读取,rc 线上为 undefined)
645
+ * @returns {{ state, mutate, subscribe }} 或 null
646
+ */
647
+ function resolveRowForm(props, forms) {
648
+ const supplied = props.form
649
+ if (supplied !== undefined && supplied !== null && supplied.state !== undefined && supplied.state !== null) {
650
+ return {
651
+ state: supplied.state,
652
+ mutate: (ops, revision) => supplied.mutate(ops, revision),
653
+ subscribe: null
654
+ }
655
+ }
656
+ if (forms === undefined || forms === null || typeof forms.get !== 'function') return null
657
+ for (const ns of ROW_FORM_NS_CANDIDATES) {
658
+ let bound = null
659
+ try {
660
+ bound = forms.get(ns)
661
+ } catch (e) {
662
+ bound = null
663
+ }
664
+ if (bound === undefined || bound === null || typeof bound.getSnapshot !== 'function') continue
665
+ const snapshot = bound.getSnapshot()
666
+ if (snapshot === undefined || snapshot === null || snapshot.status !== 'ready') continue
667
+ return {
668
+ state: snapshot,
669
+ mutate: (ops, revision) => bound.mutate(ops, revision),
670
+ subscribe: typeof bound.subscribe === 'function' ? (listener) => bound.subscribe(listener) : null
671
+ }
672
+ }
673
+ return null
674
+ }
675
+ function AutoOpenRowForm(props) {
676
+ const { t } = props
677
+ const form = resolveRowForm(props, props.configForms)
678
+ const [, forceTick] = React.useState(0)
679
+ const subscribe = form !== null ? form.subscribe : null
680
+ // 命名空间后续才就绪 / 值被外部改动时跟新(revision 变化即重渲染)。
681
+ React.useEffect(() => {
682
+ if (subscribe === null || subscribe === undefined) return undefined
683
+ return subscribe(() => forceTick((n) => n + 1))
684
+ }, [subscribe])
685
+ const snapshot = form !== null && form.state !== undefined && form.state !== null ? form.state : null
686
+ const [staged, setStaged] = React.useState(() => new Map())
687
+ const [saving, setSaving] = React.useState(false)
688
+ const [failed, setFailed] = React.useState(false)
689
+ const stagedRef = React.useRef(staged)
690
+ stagedRef.current = staged
691
+ const specs = React.useMemo(() => fieldSpecs(), [])
692
+ const available = snapshot !== null && snapshot.status === 'ready'
693
+ const writable = snapshot !== null && snapshot.writable === true
694
+ const plan = available ? rowPlan(snapshot, staged, specs) : []
695
+ const invalid = plan.some((item) => item.op === undefined)
696
+ const dirty = plan.length > 0
697
+ const state = {
698
+ available,
699
+ writable,
700
+ dirty,
701
+ invalid,
702
+ saving,
703
+ failed,
704
+ appWindow: { text: true, overridden: false, invalid: false },
705
+ windowKind: { text: 'webview2', overridden: false, invalid: false },
706
+ browserPath: { text: '', overridden: false, invalid: false },
707
+ exitOnWindowClose: { text: false, overridden: false, invalid: false }
708
+ }
709
+ if (snapshot !== null) {
710
+ for (const spec of specs) state[spec.field] = rowFieldView(snapshot, staged, spec)
711
+ }
712
+ const edit = (field, text) => {
713
+ const next = new Map(stagedRef.current)
714
+ next.set(field, { text, clear: false })
715
+ setStaged(next)
716
+ setFailed(false)
616
717
  }
718
+ const resetField = (field) => {
719
+ const spec = specs.find((item) => item.field === field)
720
+ const base = snapshot !== null && snapshot.base !== undefined && snapshot.base !== null ? snapshot.base[field] : undefined
721
+ const next = new Map(stagedRef.current)
722
+ next.set(field, { text: spec.format(base), clear: true })
723
+ setStaged(next)
724
+ setFailed(false)
725
+ }
726
+ const save = () => {
727
+ if (!available || saving) return
728
+ const ops = plan.filter((item) => item.op !== undefined).map((item) => item.op)
729
+ if (plan.length === 0 || ops.length !== plan.length) return
730
+ setSaving(true)
731
+ setFailed(false)
732
+ Promise.resolve(form.mutate(ops, snapshot.revision))
733
+ .then((accepted) => {
734
+ if (accepted === true) setStaged(new Map())
735
+ setFailed(accepted !== true)
736
+ })
737
+ .catch(() => { setFailed(true) })
738
+ .then(() => { setSaving(false) })
739
+ }
740
+ const ui = useBrowserTools(t, state, edit)
741
+ // 行缺描述时页面取一行摘要。
742
+ if (props.view === 'summary') return t('description')
743
+ if (!available) return React.createElement('p', { className: 'aow-hint', role: 'status' }, t('unavailable'))
744
+ return React.createElement('div', null,
745
+ !writable ? React.createElement('p', { className: 'aow-cardReadOnly', role: 'status' }, t('readOnly')) : null,
746
+ ...buildFieldNodes(t, state, edit, resetField, ui),
747
+ React.createElement('div', { className: 'aow-cardFooter' },
748
+ failed ? React.createElement('p', { className: 'aow-cardFailed', role: 'status' }, t('saveFailed')) : null,
749
+ React.createElement(Btn, { variant: 'primary', disabled: !dirty || invalid || saving, onClick: save }, saving ? t('saving') : t('save'))
750
+ )
751
+ )
752
+ }
617
753
 
618
- // ---- 旧版:设置页插件配置卡片(折叠外壳 + 放弃/保存) ----
754
+ // ── 最新 rc 线(0.1.5-rc.x):设置页 → 插件配置卡片 ─────────────────────
755
+ // 宿主用 settings.register(ns, schema) 注册命名空间,客户端经 settingsScope
756
+ // 读写(该服务在新版已被 configForms 取代,故在 apply 里惰性读取)。
757
+ function AutoOpenSettingsPage(props) {
758
+ const { t } = props
759
+ const state = props.useAutoOpenCard((snapshot) => snapshot)
760
+ const ui = useBrowserTools(t, state, props.edit)
761
+ if (props.view === 'summary') return t('description')
762
+ const fields = buildFieldNodes(t, state, props.edit, props.resetField, ui)
619
763
  return React.createElement(Card, {
620
764
  t,
621
765
  titleKey: 'title',
@@ -628,8 +772,14 @@ window.__ModuleLoader__.load({
628
772
 
629
773
  // ---- 文案(locale 机制,zh/en/ja/ko/fr/de/ru) ----
630
774
  const NS = 'auto-open-web'
631
- /** bundle 包名:新版插件管理页按包名路由 bundle 配置,即 plugins.bundle.config 的 key。 */
775
+ /** bundle 包名(manifest 名,与安装名一致)。 */
632
776
  const BUNDLE_NAME = 'dsh-auto-open-web'
777
+ /** 本插件在 cordis.patch.yml 里声明的行 id;新版 settings 命名空间 = 本条目 id。 */
778
+ const ROW_ID = 'auto-open-web'
779
+ /** rc 线的设置命名空间(设置页卡片按它派发)。 */
780
+ const SETTINGS_NS = 'auto-open-web'
781
+ /** 自行解析 ConfigForm 时的命名空间候选(条目 id 通常是行 id,兜底包名)。 */
782
+ const ROW_FORM_NS_CANDIDATES = [ROW_ID, BUNDLE_NAME]
633
783
  const en = {
634
784
  title: 'Auto-open web',
635
785
  description: 'Open the DSH Web GUI in an app-style window on profile start; the browser path can be configured manually.',
@@ -802,30 +952,42 @@ window.__ModuleLoader__.load({
802
952
  function apply(ctx) {
803
953
  // 文案(locale 机制,跟随界面语言)
804
954
  ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, ko, fr, de, ru }), 'auto-open-web: card dictionary')
805
- // 数据通道:settingsScope(官方 settings 域)
806
- const scope = ctx.settingsScope.bind({ namespace: 'auto-open-web' })
807
- const controller = new AutoOpenCardController(scope)
808
- // ── 新版(dsh ≥0.1.5):插件管理页 bundle 配置槽 ──────────────────
809
- // 页面按包名路由(ledger.bundles.has(pkg.name)),故 key 必须是 **bundle 包名**;
810
- // 只渲染 view:'page'(标题/图标/面包屑由页面自绘),组件见 AutoOpenSettingsPage。
955
+ // ── 新版(dsh ≥0.1.6-alpha):插件管理页 → **bundle 详情页**配置 ────────
956
+ // dsh-harness-tags 同款席位:插槽 `plugins.bundle.config`,key = **bundle 包名**
957
+ // (页面按 ledger.bundles.has(pkg.name) 判定是否渲染该区块,位置在说明与组件行之间)。
958
+ // 页面只传 `{ view: 'page' }` —— 数据要**自己绑**:命名空间 = 本条目 id,
959
+ // 由客户端服务 configForms 提供 ConfigForm(getSnapshot/subscribe/set/unset,
960
+ // FormModel 的 scope 接口一致);页面若哪天也下发 form,则优先用它。
961
+ // 注意 configForms 必须**惰性读取**且不在 exports.inject 里:rc 线没有这个服务,
962
+ // 硬注入会让插件在那条线上永远 pending(harness-tags 只在 0.1.7+ 上跑,所以它
963
+ // 可以直接在 manifest 的 inject 里点名 plugin-manager,我们不能)。
964
+ const configForms = typeof ctx.get === 'function' ? ctx.get('configForms') : undefined
811
965
  ctx.slots.inject('plugins.bundle.config', () =>
812
966
  ctx.slots.register(
813
- { name: 'plugins.bundle.config', key: BUNDLE_NAME, locale: NS, inject: () => controller.inject() },
814
- AutoOpenSettingsPage
967
+ { name: 'plugins.bundle.config', key: BUNDLE_NAME, locale: NS },
968
+ (props) => AutoOpenRowForm(Object.assign({}, props, { configForms }))
815
969
  )
816
970
  )
817
- // ── 旧版(rc.7–0.1.4):设置页 → 插件配置卡片 ───────────────────────
818
- // 新版已移除该插槽,此处 inject 永不触发(两代互不干扰)
819
- ctx.slots.inject('settings.plugin.item', () =>
820
- ctx.slots.register(
821
- { name: 'settings.plugin.item', key: 'auto-open-web', label: '自动打开网页', locale: NS, inject: () => controller.inject() },
822
- AutoOpenSettingsPage
971
+ // ── 最新 rc 线(0.1.5-rc.x):设置页 → 插件配置卡片 ───────────────────
972
+ // 该线仍走旧的 settings 域:宿主 settings.register(ns, schema) 注册命名空间,
973
+ // 客户端经 settingsScope 读写。该服务在新版已被 configForms 取代,故惰性读取:
974
+ // 缺失时只跳过这条注册,不影响新版路径(硬注入会让插件在新版永远 pending)。
975
+ const settingsScope = typeof ctx.get === 'function' ? ctx.get('settingsScope') : undefined
976
+ if (settingsScope !== undefined && settingsScope !== null && typeof settingsScope.bind === 'function') {
977
+ const controller = new AutoOpenCardController(settingsScope.bind({ namespace: SETTINGS_NS }))
978
+ ctx.slots.inject('settings.plugin.item', () =>
979
+ ctx.slots.register(
980
+ { name: 'settings.plugin.item', key: SETTINGS_NS, label: '自动打开网页', locale: NS, inject: () => controller.inject() },
981
+ AutoOpenSettingsPage
982
+ )
823
983
  )
824
- )
984
+ }
825
985
  }
826
986
 
827
- // 代码级服务依赖(cordis):数据/机制服务,不含任何 UI
828
- var clientInject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
987
+ // 代码级服务依赖(cordis):两代共有的服务,不含任何 UI 包。
988
+ // 注意:settingsScope(rc 线)/ configForms(新版)互不存在,二者都必须惰性读取,
989
+ // 否则硬注入会让插件在另一条线上永远 pending。
990
+ var clientInject = ['slots', 'locale', 'connection', 'remote']
829
991
 
830
992
  exports.apply = apply
831
993
  exports.inject = clientInject
package/lib/index.js CHANGED
@@ -74,17 +74,28 @@ export const name = 'auto-open-web';
74
74
  */
75
75
  export const inject = ['webServer', 'settings'];
76
76
 
77
+ /**
78
+ * 配置 schema。
79
+ *
80
+ * **每个字段都带 `meta.volatile`**(schemastery `.volatile()`):
81
+ * dsh 0.1.7 起,宿主只把**标记为 volatile** 的 Config 字段投影成设置表单
82
+ * (`dsh-settings` 的 `volatileForm()`:非 volatile 字段直接返回 undefined,
83
+ * 于是 `describe()` 里根本没有本条目 → 客户端拿不到 form,页面显示
84
+ * "本部署未提供该设置命名空间")。volatile 的语义是"改动无需重挂载即可生效",
85
+ * 与本插件的四个字段一致(它们都在下次打开/窗口关闭时才起作用,见下)。
86
+ * 宿主据此把用户层覆盖写回本条目,`config` 在 apply 时即含最终值。
87
+ */
77
88
  export const Config = z.object({
78
89
  /** 启动时自动打开独立应用窗口;false 时与官方相同:默认浏览器打开(普通标签页)。 */
79
- appWindow: z.boolean().default(true),
90
+ appWindow: z.boolean().default(true).volatile(),
80
91
  /** 窗口类型:webview2 = WebView2 宿主(独立进程,任务栏 DSH 图标,随 DSH 退出);
81
92
  * browser = 浏览器 --app 专用实例(--user-data-dir 隔离)。所选类型不可用时
82
93
  * 降级为最后的兜底:默认浏览器打开(官方 dsh web 同款,普通标签页)。 */
83
- windowKind: z.union([z.const('webview2'), z.const('browser')]).default('webview2'),
94
+ windowKind: z.union([z.const('webview2'), z.const('browser')]).default('webview2').volatile(),
84
95
  /** 手动维护的浏览器可执行文件路径(单条;优先于内置候选 Edge → Chrome;仅 browser 模式使用)。 */
85
- browserPath: z.string().default(''),
96
+ browserPath: z.string().default('').volatile(),
86
97
  /** 关闭自动打开的窗口时随之退出 DSH(默认关闭;仅 appWindow 开启时生效)。 */
87
- exitOnWindowClose: z.boolean().default(false),
98
+ exitOnWindowClose: z.boolean().default(false).volatile(),
88
99
  });
89
100
 
90
101
  const PICK_API_PATH = '/auto-open-web/pick-browser';
@@ -336,6 +347,13 @@ function removeLegacyState() {
336
347
  }
337
348
  }
338
349
 
350
+ /**
351
+ * 本进程已为哪个 GUI URL 打开过窗口。
352
+ * 新版设置是 live 应用的(行 Config 投影 + settings 文档),用户保存配置会让
353
+ * 本条目重新 apply;没有这道守卫就会重复弹窗。进程重启后自然重置。
354
+ */
355
+ let openedForUrl = null;
356
+
339
357
  /** 把任意输入规范化为完整配置(类型收紧 + 默认值;兼容旧 browsers 列表迁移)。 */
340
358
  function normalizeState(next) {
341
359
  const obj = next !== null && typeof next === 'object' ? next : {};
@@ -619,6 +637,14 @@ export function apply(ctx, config) {
619
637
  return;
620
638
  }
621
639
 
640
+ // 每进程每端口只打开一次:新版设置是 live 应用的(行 Config 投影),
641
+ // 用户保存配置会让本条目重新 apply —— 没有这道守卫就会再弹一个窗口。
642
+ if (openedForUrl === url) {
643
+ console.log('[auto-open-web] already opened for this process; skipping re-open (entry re-applied by a config change)');
644
+ return;
645
+ }
646
+ openedForUrl = url;
647
+
622
648
  // 独立应用窗口:按 windowKind 显式选择(webview2 | browser),直接加载 GUI 根地址。
623
649
  // 所选类型不可用时降级到最后兜底:默认浏览器打开(官方 dsh web 同款,
624
650
  // 普通标签页),保证用户至少能打开 GUI。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-auto-open-web",
3
- "version": "0.1.22",
3
+ "version": "0.1.27",
4
4
  "description": "Open the DSH Web GUI in an app-style WebView2 window on profile start, with a settings card for browser paths",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,7 +45,7 @@
45
45
  "README.en.md"
46
46
  ],
47
47
  "peerDependencies": {
48
- "@deepseek-ai/dsh": ">=0.1.0-rc.8 <0.2.0",
48
+ "@deepseek-ai/dsh": ">=0.1.5-rc.1 <0.2.0",
49
49
  "@deepseek-ai/schemastery": "^3.18.1"
50
50
  },
51
51
  "peerDependenciesMeta": {