dsh-url-router 0.4.0 → 0.4.1

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/AGENTS.md CHANGED
@@ -83,7 +83,7 @@
83
83
  ## 桌面地址栏契约(2026-10-24 起,改之前先读)
84
84
 
85
85
  - **形态**:Windows 桌面版的标题栏是**页面里画的** ✓ —— preload(`app.asar` 的 `lib/preload-app.cjs`)跑 `syncWindowsAppearance()`:给 `<html>` 打 `data-windows-titlebar`、设 `--dsh-windows-titlebar-height: 40px`,并用 shadow DOM 画菜单条(宿主 `[data-windows-menu]`,`position:fixed; top:0; left:var(--dsh-windows-menu-start,48px); z-index:1100; -webkit-app-region:no-drag`)。⇒ 那条 strip 是**页面空间** ✓,插件可以往里画 ✓。
86
- - **门控**:只有 `documentElement.getAttribute('data-windows-titlebar') !== null` 时才画 ✓;Web 版与 macOS 上**一行都不执行** ✓("并进 url-router 但不影响其它平台"的全部机制就是这个 ✓)。
86
+ - **门控**:Windows 那条路径只在 `documentElement.getAttribute('data-windows-titlebar') !== null` 时执行 ✓;Web 版**一行都不执行** ✓。macOS 走另一条分支 ✓(见下节 ✓),两者靠 `macInHeader = !windowsStrip && macDesktopWindow(root)` 互斥 ✓,`place()` 里就是一个 if/else ✓ —— **不是两个各自独立挂载的组件** ✓。
87
87
  - **定位**:`left = 48 + 菜单宿主宽度 + 8` ✓(宽度用宿主 `getBoundingClientRect()` 量 ✓,**不要**读 shadow 内部 ✗);`right = TITLEBAR_CAPTION_WIDTH = 138px` 是 Windows 窗口按钮宽度常量 ✓(改缩放/换系统可能要调 ✓);高度一律走 CSS 变量 ✓,不要写死 40 ✓。
88
88
  - **交互**:`-webkit-app-region: no-drag` 必须留 ✓ —— strip 本身是拖拽区 ✗,去掉它输入框点不动 ✗。
89
89
  - **落地只写 hash** ✓:`window.location.hash = fragment` ✓,绝不自己调路由内部 ✗ —— 浏览器触发 `hashchange` ✓,落地就是"粘贴链接"那条已验证的路 ✓。
@@ -91,6 +91,18 @@
91
91
  - **假 DOM 不支持 `#id` 选择器** ✗ → 地址栏带 `data-dsh-part="url-router-address-bar"` ✓,测试用属性选择器 ✓(行锚点当年同理 ✓)。
92
92
  - **验收**:布局可在 Web 版上用 `document.documentElement.setAttribute('data-windows-titlebar','')` + 那个 CSS 变量预演 ✓(同一套 CSS ✓),但 `dsh-app://` 下的 `replaceState` / `hashchange` 行为**只能真机确认** ✓。
93
93
 
94
+ ## macOS 地址栏契约(2026-10-25 起,改之前先读)
95
+
96
+ - **形态**:macOS 没有页面里画的 strip ✓(标题栏是原生的,红黄绿三个灯在左上 ✓)。借的是会话页顶部那行**常驻标题行** ✓:面包屑 + 模式标签 + 右侧一排按钮 + 电源按钮 ✓。bar 仍是 `position:fixed` 挂在 body 上 ✓ —— 和 Windows 同一套挂载方式 ✓。
97
+ - **判定**:`data-platform=darwin`(或 UA 是 Mac)**且**(UA 有 `Electron/<x>` **或**协议是 `dsh-app:`)✓。**实测桌面版把 UA 里的 `Electron/` 抹掉了** ✓(UA 形如 `… @deepseek-ai/dsh-desktop/0.2.1-rc` ✓),所以真正生效的是 `dsh-app:` 那条 ✓ —— 别以为 UA 分支在兜底 ✓。
98
+ - **定位 = 量空白段,不是探矩形** ✓:`macLeaves()` 走一遍行内**所有真正占位的元素** ✓(有自己文字、或 `button/a/input/[role=button]/[role=tab]/img/svg`、或没有子元素 ✓)→ 只留和电源按钮同一行的 ✓ → 按左边界排序并**合并重叠** ✓ → 求所有空白段 ✓ → 取**最宽**那段 ✓ → `fitBar()` 右对齐(贴着头部按钮)+ 两侧 `MAC_BAR_INSET = 25` 内缩 ✓。
99
+ - **宽度必须封顶** ✓(`MAC_BAR_MAX_WIDTH = 1150`):空白段的左边界就是**会话标题文字的右边界** ✓,不封顶的话每换一个会话框就变一次宽 ✓。
100
+ - **「探测矩形 + 一撞就隐藏」那套别再写** ✗:它把候选框放在电源按钮左侧 ✓,必然横跨头部那排按钮 ✓,于是每次都被判"不空"然后整框 `display:none` ✓ —— 这就是"框怎么都不出来"的历史 bug ✓。
101
+ - **没有电源按钮的界面**(插件页 / 设置页 / 面板页 ✓):沿用上一次量到的空白段 ✓,位置不动 ✓。**但要把窗口宽度一起存** ✓(`{top, viewport, gap}` ✓):头部按钮是**右对齐**的 ✓,窗口一变它们就移动 ✓,旧空白段会把框画到标题甚至侧边栏上 ✓(真机抓到过 `bar.left=122` 而 `row.left=301` ✓)。宽度差超过 `MAC_BAR_RESIZE_SLACK = 8` 就**不猜** ✓,先隐藏,等回会话页重新量 ✓。
102
+ - **只有一个框** ✓:挂载前先 `getElementById` 摘掉同 id 的旧 bar 和旧样式表 ✓ —— effect 重跑(热重载 / 重复 apply)不会多一个 ✓。
103
+ - **调试不在产品里** ✓:`dev/inspect/` 是一个**独立观察器插件** ✓(`private: true`,靠根 `files` 白名单不进 npm ✓),只读公开契约(`#dsh-url-router-titlebar`、`data-dsh-url-router-platform`、`[class*="titleRow"]`、`.dshpm-trigger`)✓。判据三条:`bars` 恒为 1 ✓、`onTop` ✓、`leaves` 有没有被压住 ✓。**别再把调试代码写回 `lib/client.js`** ✗ —— 发布物必须是跑过测试的那一份 ✓。
104
+ - **假 DOM 不支持 `#id` 选择器** ✗:mac 侧的新代码对 `getElementById` / `querySelectorAll` 全部用了可选调用 ✓,测试里缺失即跳过 ✓。
105
+
94
106
  ## 跨机开发日志
95
107
 
96
108
  改动记录在私有库 [DDDMUC/repo-devlogs](https://github.com/DDDMUC/repo-devlogs) 的 **`dsh-url-router/`** 文件夹(`HANDOFF.md` 最新一轮在最上面;macOS 端写 `WORKLOG-macos.md`,条目以 `[macOS]` 开头)。按该库规矩,每条先写 `**运行环境**`(设备 / 应用 / 服务商与模型),再写做了什么、动了哪些文件、怎么验证、遗留问题。
package/README.md CHANGED
@@ -55,8 +55,21 @@ dsh plugin --profile web add "link:/path/to/dsh-url-router"
55
55
 
56
56
  DSH 桌面版(Windows)的标题栏是**画在网页里的**:preload 给 `<html>` 打上 `data-windows-titlebar`、给出 `--dsh-windows-titlebar-height`,菜单条本身是页面里的一个 shadow DOM 固定元素(`[data-windows-menu]`)。那条 strip 里的空白是**页面空间**,而桌面版**自己没有地址栏** —— 于是本插件把地址栏放在那儿:
57
57
 
58
- - 只在 `html[data-windows-titlebar]` 存在时出现 ✓(Web 版与 macOS 上什么都不画 ✓)。
58
+ - 只在 `html[data-windows-titlebar]` 存在时出现 ✓(纯 Web 版什么都不画 ✓)。
59
59
  - 位置:紧跟 shell 自己的菜单条右侧 ✓、右端避开窗口按钮 ✓;高度取 `--dsh-windows-titlebar-height` ✓;`-webkit-app-region: no-drag` 保证能点 ✓。
60
+
61
+ ### macOS 桌面的地址栏
62
+
63
+ macOS 的标题栏是**原生**的(左边红黄绿三个灯),页面里没有 Windows 那条 strip 可以借。但会话页顶部有一行**常驻的标题行**(面包屑 + 模式标签 + 右侧按钮 + 电源按钮),地址栏就落在那行里:
64
+
65
+ - 判定:shell 给 `<html>` 打上 `data-platform=darwin`(或 UA 是 Mac)**并且**(UA 里有 `Electron/<x>` **或**页面跑在 `dsh-app:` 协议上)→ 只有桌面 App 满足,浏览器里开 Web 版不画 ✓。(实测桌面版会把 UA 里的 `Electron/<x>` 抹掉,所以真正生效的是 `dsh-app:` 那条 ✓。)
66
+ - 位置:先量这一行里**所有真正占位的东西**(有自己文字的元素、按钮、图标),按左边界排序并合并重叠 → 得到若干空白段 → 取**最宽的那一段**,右端贴着头部按钮、左端跟着走,两边各留 25px 内缩 ✓。
67
+ - 宽度封顶 **1150px**:空白段的左边界是跟着**会话标题长度**跑的 ✓,不封顶的话换个会话框就变一次宽 ✓;放不下时按实际空白缩 ✓。
68
+ - 和电源按钮**同一行**(垂直居中于它 ✓),`position: fixed` —— 所以既不遮挡也不挤压任何东西 ✓。
69
+ - 没有电源按钮的界面(插件页、设置页、面板页):沿用上一次量到的空白段,**位置不动** ✓。但如果窗口尺寸变过(> 8px),旧空白段已经不可信 ✓(头部按钮是靠右对齐的,窗口一变它们就移动了),此时**不猜** —— 先隐藏,等回到会话页重新量 ✓。
70
+ - **只有一个框** ✓:挂载前先按 id 摘掉旧的 bar 和样式表 ✓,所以 effect 重跑多少次(热重载、重复 apply)都只有一个 ✓。
71
+ - Windows 那条路径完全不变,`data-windows-titlebar` 优先,两种形态互斥 ✓。
72
+ - 输入、反射、拒识、Escape 恢复的逻辑与 Windows 版共用同一份代码 ✓。
60
73
  - 输入什么 ✓:任何 `#/…` 路由(会话 / 工作区 / 视图 / 轮次 / 行 / 面板 / 设置三层 / 右栏 pane)、**裸会话 id**、或**整条链接**(只取它的 fragment)✓;回车写 hash → 浏览器触发 `hashchange` → 落地走的就是"粘贴链接"那条已验证的路 ✓。
61
74
  - 它同时是**反映** ✓:本插件每次写地址栏都会同步进这个框 ✓,所以它显示的就是"现在在哪" ✓。Escape 还原 ✓;认不出的文本不动地址栏 ✓。
62
75
 
@@ -130,7 +143,8 @@ Conversation rows and both plugin-list groups carry a transparent full-row ancho
130
143
 
131
144
  The Windows desktop window draws its own title bar **inside the page**: the preload marks `<html data-windows-titlebar>` and publishes `--dsh-windows-titlebar-height`, and its menu is a fixed shadow-DOM element in the page (`[data-windows-menu]`). That strip is page space, and the desktop window has **no address bar of its own** — so this plugin puts one there:
132
145
 
133
- - Drawn only when `html[data-windows-titlebar]` is present (nothing at all on the web app or macOS).
146
+ - Drawn only when `html[data-windows-titlebar]` is present (nothing at all on the plain web app).
147
+ - On the **macOS desktop** build there is no in-page strip to borrow, so the bar becomes a floating pill instead: pinned just under the native title bar at the window's top-right, 380×30, rounded, blurred and shadowed. It is recognised by the shell's `<html data-platform="darwin">` marker (or a Mac user agent) together with the window being the application — Electron in the user agent or the `dsh-app:` scheme — so the web app in a browser never draws it. It sits inside the 48px the shell reserves above its frame (`--dsh-frame-top-clearance`), clear of the traffic lights. Both shapes share one code path; the Windows marker wins when present.
134
148
  - Placed right after the shell's own menu and before the window buttons, at `--dsh-windows-titlebar-height`, with `-webkit-app-region: no-drag` so it stays clickable.
135
149
  - Accepts any `#/…` route, a bare conversation id, or a whole link (only its fragment is used); Enter writes the hash, the browser fires `hashchange`, and the link lands through the very same path a pasted link takes.
136
150
  - It also **reflects**: every write this plugin makes to the address bar is mirrored into the box, so it always names what is on screen. Escape restores it; text that names no route is refused.
package/lib/client.js CHANGED
@@ -131,14 +131,14 @@ window.__ModuleLoader__.load({
131
131
  * The router's own version, for the report a human files. A test keeps it equal to the
132
132
  * package version, so a release cannot forget to bump it.
133
133
  */
134
- const ROUTER_VERSION = '0.4.0'
134
+ const ROUTER_VERSION = '0.4.1'
135
135
  /**
136
136
  * Hand-maintained build marker, reported alongside the version. A working copy that is
137
137
  * linked into a profile serves this file straight from disk, so the package version
138
138
  * cannot say *which* copy a live window loaded — this can, and `report().build` is how a
139
139
  * real-machine check answers that. Bump it whenever a live window must be told apart.
140
140
  */
141
- const ROUTER_BUILD = 'desktop-bar+settings-marker+modal-dismiss'
141
+ const ROUTER_BUILD = 'desktop-bar+settings-marker+modal-dismiss+mac-header-bar.2'
142
142
 
143
143
  const TAG = '[url-router]'
144
144
 
@@ -155,10 +155,173 @@ window.__ModuleLoader__.load({
155
155
  const TITLEBAR_STYLE_ID = 'dsh-url-router-titlebar-style'
156
156
  /** Width the window's caption buttons take at the right end of the strip. */
157
157
  const TITLEBAR_CAPTION_WIDTH = 138
158
- /** Gap between the shell's menu and the bar. */
159
- const TITLEBAR_GAP = 8
160
- /** How long the bar waits for a handed-over link to land before it says it did not. */
161
- const TITLEBAR_SETTLE_MS = 900
158
+ /** Gap between the shell's menu and the bar. */
159
+ const TITLEBAR_GAP = 8
160
+ /**
161
+ * macOS has no in-page title-bar strip, so the bar cannot borrow window chrome
162
+ * the way the Windows build does. It borrows the next best thing: the shell's
163
+ * resident header row, which exists on every screen. The bar stays mounted in
164
+ * the body with fixed positioning — exactly the Windows approach — and `place`
165
+ * measures the title cluster and the right-hand cluster to get its insets.
166
+ */
167
+ const MAC_BAR_HEIGHT = 26
168
+ /** The least room the bar will draw itself into; below this it stays hidden. */
169
+ const MAC_BAR_MIN_WIDTH = 120
170
+ /** The breathing space the bar keeps from whatever stands next to it. */
171
+ const MAC_BAR_GAP = 8
172
+ /** The margin the bar leaves inside the free stretch, on each side. */
173
+ const MAC_BAR_INSET = 25
174
+ /**
175
+ * The bar's own length.
176
+ *
177
+ * The free stretch begins wherever the conversation's title happens to end, so
178
+ * without a length of its own the bar would grow and shrink as titles change.
179
+ * It stops here instead, and only gets shorter when the row cannot spare this
180
+ * much.
181
+ */
182
+ const MAC_BAR_MAX_WIDTH = 1150
183
+ /**
184
+ * How much the window may change before a remembered stretch stops counting.
185
+ *
186
+ * The stretch was measured against one window; a different one moves the
187
+ * header's buttons, and there is nothing to re-measure from on a screen that
188
+ * has no power button. A couple of pixels is rounding, more is a resize.
189
+ */
190
+ const MAC_BAR_RESIZE_SLACK = 8
191
+ const MAC_STYLE_TEXT = [
192
+ `#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"]{position:fixed;display:flex;align-items:center;height:${MAC_BAR_HEIGHT}px;z-index:1200;-webkit-app-region:no-drag}`,
193
+ `#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"] input{height:${MAC_BAR_HEIGHT}px;width:100%;box-sizing:border-box;border:1px solid rgba(127,127,127,.34);border-radius:8px;padding:0 10px;font:inherit;font-size:12px;color:inherit;background:rgba(127,127,127,.16);outline:none;-webkit-app-region:no-drag}`,
194
+ `#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"] input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
195
+ `#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"][data-dsh-url-router-state="refused"] input{border-color:rgba(214,69,69,.85);color:rgba(214,69,69,1)}`,
196
+ ].join('')
197
+ /** Where the bar's last spot is kept, so a screen without a power button still gets it. */
198
+ const MAC_PLACEMENT_KEY = 'dsh-url-router-placement'
199
+ /**
200
+ * Describe one element: what it is, and where it sits.
201
+ * @param {object} el - the element to describe.
202
+ * @returns {object|null} the description, or null when there is no element.
203
+ */
204
+ function macDescribe(el) {
205
+ if (el === null || el === undefined) return null
206
+ const rect = el.getBoundingClientRect?.()
207
+ const cls = el.className?.baseVal ?? el.className ?? ''
208
+ return {
209
+ tag: String(el.tagName ?? '?').toLowerCase(),
210
+ cls: typeof cls === 'string' ? cls.slice(0, 70) : '',
211
+ rect: rect === undefined || rect === null
212
+ ? null
213
+ : { top: Math.round(rect.top), left: Math.round(rect.left), width: Math.round(rect.width), height: Math.round(rect.height) },
214
+ }
215
+ }
216
+ /**
217
+ * Every descendant of an element that carries text of its own or answers a
218
+ * click — the header's real contents, so the bar can find the gap between
219
+ * them instead of guessing where it is.
220
+ * @param {object} el - the element to walk.
221
+ * @param {number} depth - how far down to walk.
222
+ * @returns {Array<object>} the leaves, with their boxes.
223
+ */
224
+ function macLeaves(el, depth) {
225
+ const leaves = []
226
+ if (el === null || el === undefined || depth > 6) return leaves
227
+ for (const child of el.children ?? []) {
228
+ const own = ownText(child)
229
+ const interactive = typeof child.closest === 'function'
230
+ && child.matches?.('button, a, input, [role="button"], [role="tab"], img, svg') === true
231
+ if (own !== '' || interactive || child.children.length === 0) {
232
+ leaves.push({ ...macDescribe(child), own: own.slice(0, 40) })
233
+ } else {
234
+ leaves.push(...macLeaves(child, depth + 1))
235
+ }
236
+ }
237
+ return leaves
238
+ }
239
+ /**
240
+ * The header row the bar has to live in: the row that carries the
241
+ * conversation's title on the left and the header's own buttons on the right.
242
+ *
243
+ * The shell names the row in its stylesheet, so that name is tried first; when
244
+ * it has changed, the row is taken from the power button's own corner, whose
245
+ * parent is the row the button was mounted on.
246
+ *
247
+ * @param {object} trigger - the power-menu trigger.
248
+ * @returns {object|undefined} the row element, or undefined when there is none.
249
+ */
250
+ function macHeaderRow(trigger) {
251
+ const corner = document.querySelector?.('[data-conversation-header-corner]')
252
+ const named = document.querySelector?.('[class*="titleRow"]')
253
+ if (named !== undefined && named !== null) {
254
+ // Only the row that actually holds the button is the row to measure.
255
+ if (trigger === undefined || trigger === null) return named
256
+ if (typeof named.contains !== 'function' || named.contains(trigger)) return named
257
+ }
258
+ return corner?.parentElement ?? undefined
259
+ }
260
+ /**
261
+ * The free stretch of the header row the bar can live in.
262
+ *
263
+ * The row is measured rather than guessed: every leaf that shares the power
264
+ * button's row — the title text, the mode label, the header's own buttons — is
265
+ * turned into an interval, overlapping intervals are merged, and the widest
266
+ * stretch between them is the one the bar goes into. Nothing is covered,
267
+ * because that stretch holds nothing; nothing is pushed, because the bar is a
268
+ * fixed-position layer of its own.
269
+ *
270
+ * @param {Array<object>} leaves - the row's measured contents.
271
+ * @param {object} rect - the power button's rectangle, which ends the search.
272
+ * @param {object} [bounds] - the row's own rectangle, which begins it.
273
+ * @returns {{left: number, right: number}|null} the stretch, or null when the row has no room.
274
+ */
275
+ function macFreeGap(leaves, rect, bounds) {
276
+ if (rect === undefined || rect === null) return null
277
+ const rowTop = rect.top
278
+ const rowBottom = rect.top + rect.height
279
+ // The power button is the anchor: the bar never reaches past its left edge.
280
+ const limit = Math.round(rect.left)
281
+ const occupied = []
282
+ for (const leaf of leaves ?? []) {
283
+ const box = leaf?.rect
284
+ if (box === undefined || box === null) continue
285
+ if (!(box.width > 0) || !(box.height > 0)) continue
286
+ // Only what stands on the power button's row can be in the way.
287
+ if (box.top >= rowBottom || box.top + box.height <= rowTop) continue
288
+ const left = Math.round(box.left)
289
+ const right = Math.round(box.left + box.width)
290
+ if (right <= 0 || left >= limit) continue
291
+ occupied.push({ left, right })
292
+ }
293
+ occupied.sort((a, b) => a.left - b.left)
294
+ // Merge what overlaps, so a box inside another cannot split a stretch.
295
+ const merged = []
296
+ for (const span of occupied) {
297
+ const last = merged[merged.length - 1]
298
+ if (last !== undefined && span.left <= last.right) {
299
+ if (span.right > last.right) last.right = span.right
300
+ continue
301
+ }
302
+ merged.push({ left: span.left, right: span.right })
303
+ }
304
+ // The row begins at its own left edge when that is known, and at the first
305
+ // thing standing on it otherwise.
306
+ const start = bounds !== undefined && bounds !== null && bounds.left > 0
307
+ ? Math.round(bounds.left)
308
+ : (merged[0]?.left ?? limit)
309
+ const runs = []
310
+ let cursor = start
311
+ for (const span of merged) {
312
+ if (span.left > cursor) runs.push({ left: cursor, right: span.left })
313
+ if (span.right > cursor) cursor = span.right
314
+ }
315
+ if (limit > cursor) runs.push({ left: cursor, right: limit })
316
+ let best = null
317
+ for (const run of runs) {
318
+ if (run.right - run.left < MAC_BAR_MIN_WIDTH) continue
319
+ if (best === null || run.right - run.left > best.right - best.left) best = run
320
+ }
321
+ return best
322
+ }
323
+ /** How long the bar waits for a handed-over link to land before it says it did not. */
324
+ const TITLEBAR_SETTLE_MS = 900
162
325
  /** What the address bar accepts, said in its placeholder. */
163
326
  const TITLEBAR_PLACEHOLDER = '#/session/… · #/panel/… · #/settings · 会话 id · 链接'
164
327
  /** The strip belongs to the shell, so only the bar itself is styled. */
@@ -284,12 +447,6 @@ window.__ModuleLoader__.load({
284
447
  return undefined
285
448
  }
286
449
 
287
- /**
288
- * The kind inside a tab id: the bar builds one as `sidebar://<kind>` and appends a uuid for
289
- * kinds that may be opened more than once, so the kind is right there in the address.
290
- * @param {string} value - a tab id.
291
- * @returns {string|undefined} the kind, when the value is one of these addresses.
292
- */
293
450
  /**
294
451
  * The right bar's registry, normalised: one row per page type with its kind, the label
295
452
  * the tab shows, whether more than one may be open, and its empty-state card (measured
@@ -297,36 +454,6 @@ window.__ModuleLoader__.load({
297
454
  * @param {object} faces - the probed host faces.
298
455
  * @returns {{kind: string, label: string|undefined, multiple: boolean}[]|undefined} the rows.
299
456
  */
300
- /**
301
- * The seq of a turn's start event, from the `turnOutline` projection.
302
- *
303
- * The projection is a public read on the session controller (the core reads it the same
304
- * way: `session.projections.faceOf("turnOutline").getSnapshot()`), and each entry carries
305
- * the `seq` of that turn's `turn/start` — which is exactly what `loadThrough` pages back
306
- * to. Reading it is what makes a turn link work for a turn that is not loaded yet.
307
- * @param {object} faces - the probed host faces.
308
- * @param {string|undefined} sessionId - the session the turn belongs to.
309
- * @param {string} turn - the turn number.
310
- * @returns {number|undefined} the seq, when the projection names that turn.
311
- */
312
- function turnSeqOf(faces, sessionId, turn) {
313
- if (typeof sessionId !== 'string' || sessionId === '') return undefined
314
- // Reached by retaining the session's scope: `sessions.binding(id)` answers undefined
315
- // for a session nobody materialised yet (measured), while `retainScope(id, source)`
316
- // materialises it and returns a reference whose `binding` is the live one. Released here
317
- // because only the projection is read.
318
- const reference = attemptValue(() => faces.sessions?.retainScope?.(sessionId, 'url-router'))
319
- const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
320
- attempt(() => reference?.release?.())
321
- if (!Array.isArray(outline)) return undefined
322
- const wanted = String(turn)
323
- for (const entry of outline) {
324
- if (entry === null || typeof entry !== 'object') continue
325
- if (String(entry.turn) !== wanted) continue
326
- return typeof entry.seq === 'number' ? entry.seq : undefined
327
- }
328
- return undefined
329
- }
330
457
 
331
458
  function paneRegistryOf(faces) {
332
459
  const rows = attemptValue(() => faces.sidebarRightTabs?.entries?.())
@@ -2383,6 +2510,55 @@ window.__ModuleLoader__.load({
2383
2510
  return `打不开 ${name}(当前 ${shown},侧栏 ${rows} 行)`
2384
2511
  }
2385
2512
 
2513
+ /**
2514
+ * Whether this window is the macOS desktop build.
2515
+ *
2516
+ * The macOS desktop shell draws a native title bar and publishes no in-page
2517
+ * strip, so the Windows marker is absent there — which used to mean "draw
2518
+ * nothing". Two facts together identify a macOS desktop window: the shell marks
2519
+ * `<html data-platform="darwin">` (or the user agent says Mac), and the window
2520
+ * is the application rather than a browser tab (Electron in the user agent, or
2521
+ * the page served over the app's own `dsh-app:` scheme). The plain web app in a
2522
+ * browser satisfies neither and stays empty, as before.
2523
+ *
2524
+ * @param {object} root - `document.documentElement`.
2525
+ * @returns {boolean} true when the address bar belongs on screen.
2526
+ */
2527
+ function macDesktopWindow(root) {
2528
+ const ua = typeof navigator === 'object' && navigator !== null ? String(navigator.userAgent ?? '') : ''
2529
+ const platform = typeof root?.getAttribute === 'function' ? root.getAttribute('data-platform') : null
2530
+ const isMac = platform === 'darwin' || /Mac OS X|Macintosh/u.test(ua)
2531
+ if (!isMac) return false
2532
+ const protocol = String(globalThis.location?.protocol ?? '')
2533
+ return /Electron\/\d/u.test(ua) || protocol.startsWith('dsh-app')
2534
+ }
2535
+
2536
+
2537
+ /**
2538
+ * The power-menu trigger, the one element that is guaranteed to sit on the
2539
+ * same row on every screen. The address bar anchors to it.
2540
+ *
2541
+ * @returns {object|undefined} the trigger element, or undefined when absent.
2542
+ */
2543
+ function powerTrigger() {
2544
+ if (typeof document?.querySelector !== 'function') return undefined
2545
+ return document.querySelector('[data-dshpm-mount] .dshpm-trigger')
2546
+ ?? document.querySelector('.dshpm-trigger')
2547
+ ?? document.querySelector('[aria-label="电源菜单"]')
2548
+ ?? undefined
2549
+ }
2550
+
2551
+ /** An element's own text, ignoring the text of its children. */
2552
+ function ownText(el) {
2553
+ if (el === null || el === undefined || typeof el.childNodes !== 'object') return ''
2554
+ let text = ''
2555
+ for (const node of el.childNodes) {
2556
+ if (node !== null && node.nodeType === 3) text += node.textContent ?? ''
2557
+ }
2558
+ return text.trim()
2559
+ }
2560
+
2561
+
2386
2562
  /**
2387
2563
  * Draw the address bar into the desktop window's title bar strip.
2388
2564
  * @param {object} document - the document to draw into.
@@ -2393,14 +2569,32 @@ window.__ModuleLoader__.load({
2393
2569
  if (document === undefined || document === null) return () => {}
2394
2570
  const root = document.documentElement
2395
2571
  // The marker is the shell's own statement that this page has a title bar drawn
2396
- // inside it. Without it there is no strip, and nothing is drawn.
2397
- if (typeof root?.getAttribute !== 'function' || root.getAttribute(WINDOWS_TITLEBAR_ATTRIBUTE) === null) return () => {}
2572
+ // inside it. Without it there is no strip — but the macOS desktop build still
2573
+ // gets a bar, drawn into the session header row instead.
2574
+ const windowsStrip = typeof root?.getAttribute === 'function' && root.getAttribute(WINDOWS_TITLEBAR_ATTRIBUTE) !== null
2575
+ const macInHeader = !windowsStrip && macDesktopWindow(root)
2576
+ if (!windowsStrip && !macInHeader) return () => {}
2398
2577
  if (typeof document.createElement !== 'function' || typeof document.body?.appendChild !== 'function') return () => {}
2578
+ // One bar, one stylesheet — however many times this runs.
2579
+ //
2580
+ // The effect that draws the bar can run again in a live window (a hot
2581
+ // reload of this plugin re-applies it), and a second mount would leave a
2582
+ // second box on screen. Both are identified by id, so anything already
2583
+ // there is taken down before the new one goes up: the platform split
2584
+ // decides *where* the one bar lives, never how many there are.
2585
+ try {
2586
+ const staleBar = document.getElementById?.(TITLEBAR_BAR_ID)
2587
+ if (staleBar !== null && staleBar !== undefined) staleBar.remove?.()
2588
+ const staleStyle = document.getElementById?.(TITLEBAR_STYLE_ID)
2589
+ if (staleStyle !== null && staleStyle !== undefined) staleStyle.remove?.()
2590
+ } catch {
2591
+ // A document without getElementById keeps the disposer as its only guard.
2592
+ }
2399
2593
  let style
2400
2594
  try {
2401
2595
  style = document.createElement('style')
2402
2596
  style.id = TITLEBAR_STYLE_ID
2403
- style.textContent = TITLEBAR_STYLE_TEXT
2597
+ style.textContent = macInHeader ? `${TITLEBAR_STYLE_TEXT}\n${MAC_STYLE_TEXT}` : TITLEBAR_STYLE_TEXT
2404
2598
  const parent = document.head ?? document.body
2405
2599
  if (parent !== undefined && parent !== null && typeof parent.appendChild === 'function') parent.appendChild(style)
2406
2600
  } catch {
@@ -2408,26 +2602,156 @@ window.__ModuleLoader__.load({
2408
2602
  }
2409
2603
  const bar = document.createElement('div')
2410
2604
  bar.id = TITLEBAR_BAR_ID
2411
- if (typeof bar.setAttribute === 'function') bar.setAttribute('data-dsh-part', TITLEBAR_PART)
2605
+ if (typeof bar.setAttribute === 'function') {
2606
+ bar.setAttribute('data-dsh-part', TITLEBAR_PART)
2607
+ bar.setAttribute('data-dsh-url-router-platform', macInHeader ? 'mac' : 'windows')
2608
+ }
2412
2609
  const input = document.createElement('input')
2413
2610
  input.type = 'text'
2414
2611
  input.spellcheck = false
2415
2612
  input.placeholder = TITLEBAR_PLACEHOLDER
2416
2613
  if (typeof input.setAttribute === 'function') input.setAttribute('aria-label', '地址栏 / address bar')
2417
2614
  bar.appendChild(input)
2418
- /** Start after the shell's menu, stop before its window buttons. */
2419
- const place = () => {
2420
- let menuWidth = 0
2421
- try {
2422
- const menu = typeof document.querySelector === 'function' ? document.querySelector(`[${WINDOWS_MENU_ATTRIBUTE}]`) : null
2423
- if (menu !== null && menu !== undefined && typeof menu.getBoundingClientRect === 'function') menuWidth = menu.getBoundingClientRect().width || 0
2424
- } catch {
2425
- menuWidth = 0
2615
+ /**
2616
+ * The free stretch the bar was last fitted into.
2617
+ *
2618
+ * A screen with no power button — a panel, the plugins page, settings — has
2619
+ * nothing to align to. The bar keeps this stretch there, so changing views
2620
+ * neither moves it nor makes it vanish. The stretch is also kept on disk, so
2621
+ * a window that starts on such a screen still gets the bar where it belongs;
2622
+ * and because the box is fitted from the stretch each time, a change to the
2623
+ * bar's margins applies there too, without waiting for a conversation.
2624
+ */
2625
+ let lastPlacement = attemptValue(() => {
2626
+ const raw = globalThis.localStorage?.getItem?.(MAC_PLACEMENT_KEY)
2627
+ if (raw === null || raw === undefined || raw === '') return null
2628
+ const parsed = JSON.parse(raw)
2629
+ const gap = parsed?.gap
2630
+ if (typeof parsed?.top !== 'number' || typeof parsed?.viewport !== 'number') return null
2631
+ if (typeof gap?.left !== 'number' || typeof gap?.right !== 'number') return null
2632
+ return { top: parsed.top, viewport: parsed.viewport, gap: { left: gap.left, right: gap.right } }
2633
+ }) ?? null
2634
+ /**
2635
+ * Fit the bar into a free stretch of the header row.
2636
+ *
2637
+ * The bar takes the whole stretch bar a margin on each side, so it is as
2638
+ * long as the row can spare without touching what stands beside it. A
2639
+ * narrow row keeps the whole stretch rather than a sliver of it.
2640
+ *
2641
+ * @param {{left: number, right: number}} gap - the free stretch.
2642
+ * @param {number} top - the row's line, already centred on the power button.
2643
+ * @returns {{left: number, top: number, width: number}} the box to draw.
2644
+ */
2645
+ const fitBar = (gap, top) => {
2646
+ const right = gap.right - MAC_BAR_GAP - MAC_BAR_INSET
2647
+ const room = gap.left + MAC_BAR_INSET
2648
+ const available = right - room
2649
+ const width = available >= MAC_BAR_MIN_WIDTH
2650
+ ? Math.round(Math.min(MAC_BAR_MAX_WIDTH, available))
2651
+ : Math.round(gap.right - MAC_BAR_GAP - gap.left)
2652
+ return { left: Math.round(right - width), top, width }
2653
+ }
2654
+ /**
2655
+ * Remember the stretch the bar went into, and put it on disk when it moved.
2656
+ * @param {object} placement - the line and the stretch just used.
2657
+ */
2658
+ const remember = placement => {
2659
+ const previous = lastPlacement
2660
+ lastPlacement = placement
2661
+ if (previous !== null
2662
+ && previous.top === placement.top
2663
+ && previous.viewport === placement.viewport
2664
+ && previous.gap.left === placement.gap.left
2665
+ && previous.gap.right === placement.gap.right) return
2666
+ attemptValue(() => globalThis.localStorage?.setItem?.(MAC_PLACEMENT_KEY, JSON.stringify(placement)))
2667
+ }
2668
+ /**
2669
+ * Put the bar where it belongs.
2670
+ *
2671
+ * Windows: start after the shell's menu, stop before its window buttons.
2672
+ * macOS: sit in the free stretch of the header row, on the power button's
2673
+ * own line.
2674
+ */
2675
+ const place = () => {
2676
+ if (macInHeader) {
2677
+ // The bar lives on the header row, on the same line as the power
2678
+ // button, in the free stretch between the title text and the
2679
+ // header's own buttons. The row's real contents are measured and the
2680
+ // widest free stretch is chosen, so the bar covers nothing — and,
2681
+ // being a fixed-position layer, it pushes nothing either.
2682
+ const trigger = powerTrigger()
2683
+ const rect = trigger?.getBoundingClientRect?.()
2684
+ const row = macHeaderRow(trigger)
2685
+ const rowRect = row?.getBoundingClientRect?.()
2686
+ const anchored = rect !== undefined && rect !== null && rect.width > 0
2687
+ // The row is only walked when there is a button to measure from.
2688
+ const gap = anchored ? macFreeGap(macLeaves(row, 0), rect, rowRect) : null
2689
+ if (gap === null) {
2690
+ // Nothing on this screen to align to. Keep the stretch the
2691
+ // conversation gave the bar, so switching views does not move it
2692
+ // and does not make it disappear.
2693
+ //
2694
+ // Only while the window is still the one that stretch was measured
2695
+ // in: the header's buttons sit at the right end of the window, so a
2696
+ // resize moves them, and reusing an old stretch would draw the bar
2697
+ // straight across the title and the buttons. With no button to
2698
+ // measure from there is nothing to re-derive it from either, so a
2699
+ // resized window hides the bar until a conversation places it again.
2700
+ const viewport = Math.round(globalThis.innerWidth ?? 0)
2701
+ if (lastPlacement === null || viewport <= 0
2702
+ || Math.abs(viewport - lastPlacement.viewport) > MAC_BAR_RESIZE_SLACK) {
2703
+ bar.style.display = 'none'
2704
+ return
2705
+ }
2706
+ const box = fitBar(lastPlacement.gap, lastPlacement.top)
2707
+ const left = Math.max(MAC_BAR_GAP, Math.min(box.left, viewport - box.width - MAC_BAR_GAP))
2708
+ bar.style.display = ''
2709
+ bar.style.top = `${box.top}px`
2710
+ bar.style.left = `${left}px`
2711
+ bar.style.right = 'auto'
2712
+ bar.style.width = `${box.width}px`
2713
+ return
2426
2714
  }
2427
- bar.style.left = `${WINDOWS_MENU_START + menuWidth + TITLEBAR_GAP}px`
2428
- bar.style.right = `${TITLEBAR_CAPTION_WIDTH}px`
2715
+ const top = Math.round(rect.top + (rect.height - MAC_BAR_HEIGHT) / 2)
2716
+ const box = fitBar(gap, top)
2717
+ bar.style.display = ''
2718
+ bar.style.top = `${box.top}px`
2719
+ bar.style.left = `${box.left}px`
2720
+ bar.style.right = 'auto'
2721
+ bar.style.width = `${box.width}px`
2722
+ remember({ top, viewport: Math.round(globalThis.innerWidth ?? 0), gap })
2723
+ return
2429
2724
  }
2430
- place()
2725
+ let menuWidth = 0
2726
+ try {
2727
+ const menu = typeof document.querySelector === 'function' ? document.querySelector(`[${WINDOWS_MENU_ATTRIBUTE}]`) : null
2728
+ if (menu !== null && menu !== undefined && typeof menu.getBoundingClientRect === 'function') menuWidth = menu.getBoundingClientRect().width || 0
2729
+ } catch {
2730
+ menuWidth = 0
2731
+ }
2732
+ bar.style.left = `${WINDOWS_MENU_START + menuWidth + TITLEBAR_GAP}px`
2733
+ bar.style.right = `${TITLEBAR_CAPTION_WIDTH}px`
2734
+ bar.style.width = 'auto'
2735
+ }
2736
+ /**
2737
+ * Place the bar at most once per frame.
2738
+ *
2739
+ * A streaming answer mutates the document constantly, and every mutation
2740
+ * would otherwise re-measure the header row. Coalescing the runs into one
2741
+ * frame keeps the bar where it belongs without measuring the header dozens
2742
+ * of times a second.
2743
+ */
2744
+ let placeFrame
2745
+ const schedulePlace = () => {
2746
+ if (placeFrame !== undefined) return
2747
+ placeFrame = attemptValue(() => globalThis.requestAnimationFrame?.(() => {
2748
+ placeFrame = undefined
2749
+ place()
2750
+ }))
2751
+ // No animation frames in this host: place it straight away.
2752
+ if (placeFrame === undefined) place()
2753
+ }
2754
+ place()
2431
2755
  /** How the bar looks right now: idle, waiting for a link to land, or refused. */
2432
2756
  const mark = (state, hint) => {
2433
2757
  if (typeof bar.setAttribute === 'function') bar.setAttribute('data-dsh-url-router-state', state)
@@ -2510,11 +2834,31 @@ window.__ModuleLoader__.load({
2510
2834
  settle(fragment, false)
2511
2835
  }
2512
2836
  input.addEventListener('keydown', onKeyDown)
2837
+ // Both platforms mount the bar in the body: Windows pins it into the
2838
+ // title-bar strip, macOS pins it over the resident header row.
2513
2839
  document.body.appendChild(bar)
2840
+ // The first placement measured the header before the bar was in the
2841
+ // document. Place it again now that it is, so the bar's own box is laid
2842
+ // out and can be checked, and so the first paint is already correct.
2843
+ place()
2844
+ /**
2845
+ * The shell re-renders the session header whenever the view changes, which
2846
+ * takes the bar with it. A watcher puts it back where it belongs — the same
2847
+ * node, so what was typed into it survives.
2848
+ */
2849
+ let headerWatcher
2850
+ if (macInHeader && typeof MutationObserver === 'function') {
2851
+ try {
2852
+ headerWatcher = new MutationObserver(() => schedulePlace())
2853
+ headerWatcher.observe(document.body, { childList: true, subtree: true })
2854
+ } catch {
2855
+ headerWatcher = undefined
2856
+ }
2857
+ }
2514
2858
  const offRoute = subscribe(show)
2515
2859
  let onResize
2516
2860
  try {
2517
- onResize = () => place()
2861
+ onResize = () => schedulePlace()
2518
2862
  window.addEventListener('resize', onResize)
2519
2863
  } catch {
2520
2864
  onResize = undefined
@@ -2524,6 +2868,7 @@ window.__ModuleLoader__.load({
2524
2868
  stopSettle()
2525
2869
  input.removeEventListener?.('keydown', onKeyDown)
2526
2870
  if (onResize !== undefined) window.removeEventListener?.('resize', onResize)
2871
+ headerWatcher?.disconnect?.()
2527
2872
  bar.remove?.()
2528
2873
  style?.remove?.()
2529
2874
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-url-router",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "DSH 地址栏路由插件:每个界面都有自己的链接(会话 / 主面板 / 插件页与组件页 / 任务看板 / 设置),整行即真链接,只写 fragment;Windows 桌面版把地址栏画在标题栏里。",
5
5
  "repository": {
6
6
  "type": "git",