# 双壳适配说明 本插件要改外壳布局(壁纸纱层、观测台浮层、气泡重绘),所以必须**稳定地 找到外壳元素**。本机实测发现:**桌面端与网页端跑的是两套不同的壳**,类名哈希 完全不同。这份文档记录核实结果与应对策略,避免以后重新踩坑。 --- ## 一、两套壳的实测差异 | 维度 | 桌面端(本机实际运行) | 网页端 | |---|---|---| | 版本 | `0.2.0-rc.2` | `0.1.0-rc.7` | | 来源 | `app.asar` 内自带 | profile 的 `node_modules` | | AppFrame 类名 | `BynINW_frame` / `_sidebarCol` / `_centerCol` / **`_rightbarCol`** | `pI_x6G_frame` / `_sidebarCol` / `_centerCol` / **`_detailsCol`** | | 右栏折叠属性 | `data-rightbar-collapsed` | `data-details-collapsed` | | `shell.overlay` | 有(`[data-shell-overlay]`) | 有 | | `shell.leading` | 有渲染方(`[data-shell-leading]`,仅 macOS) | 只声明、**无渲染方** | | `sidebar.brand.mark` / `conversation.hero.brand.mark` | **有渲染方**(带 `FishLogo` fallback) | 只声明、无渲染方 | | 桌面专属包 | `dsh-client-ui-sidebar-browser` / `-right` / `-files` / `-terminal` / `-documentpreview` | 无 | | `data-*` 语义锚点 | **456 个** | 139 个 | ### 怎么确认的 ```powershell # 1. 桌面端的壳在 app.asar 里 python -c "..." # 解析 asar header,列出 /dsh/node_modules/@deepseek-ai/* # 2. 关键判据:类名哈希 # app.asar 内: BynINW ✓ pI_x6G ✗ rightbarCol ✓ detailsCol ✗ # node_modules 内: BynINW ✗ pI_x6G ✓ rightbarCol ✗ detailsCol ✓ ``` **哈希由 CSS 内容决定**,所以同一份源码编译出的哈希是稳定的 —— `pI_x6G` 在 profile 的 `node_modules` 与 `app.asar` 之外的位置一致,但两套壳的 CSS 不同, 哈希就不同。这也意味着**不能把哈希写进代码**。 ### 这个发现修正了什么 - 我原先记录的 `_rightbarCol` 在**桌面端有效**、在**网页端失效**; - 参考实现 Mornye 用的 `detailsCol` 在**网页端有效**、在**桌面端失效**。 双方各错一半。所以右栏选择器必须两个都写。 --- ## 二、三层定位策略 按可靠性从高到低,三层同时用,互为兜底。 ### 第 1 层:语义 `data-*` 锚点(最稳) 桌面壳暴露 **456 个** `data-*`,都是外壳给 React 组件打的语义标记,跨版本稳定: | 锚点 | 用途 | |---|---| | `[data-rightbar-col]` | 右栏列 | | `[data-shell-overlay]` | 浮动层(观测台浮层挂这里) | | `[data-chat-flow-kind]` | 聊天行类型(`user` / `assistant-step` / `tool-call` / `turn-tail` / `turn-error`) | | `[data-composer-card]` / `[data-composer-seat]` | 输入框卡片与座位 | | `[data-composer-stats]` | 统计行(轮次/步数/缓存命中) | | `[data-sidebar-collapsed]` | 侧栏折叠 | | `[data-streaming="true"]` / `[data-state="running"]` | 运行中标记 | | `[data-window-drag]` | 窗口拖拽区 | **优先用这些**,不碰类名。 ### 第 2 层:`data-plugin-css` 反查哈希(次稳) 外壳为每个 CSS Module 插入一个样式标签: ```html ``` 标签的 `textContent` 里就是该模块**当前生效**的真实类名。从中正则提取即可, 不用猜哈希。这一招学自 [Mornye-Observation-Skin](https://github.com/is-limo/Mornye-Observation-Skin)。 ```js const tag = document.querySelector('style[data-plugin-css$="/AppFrame.module.css"]') const text = tag.textContent.replace(/\\([0-9a-f]{1,6})\s?|\\(.)/gi, (_, hex, ch) => hex ? String.fromCodePoint(Math.min(parseInt(hex, 16), 0x10ffff)) : ch) // 反转义 const cls = text.match(/\.([\w-]+_sidebarCol)(?=[\s{.:\[>,#])/)?.[1] // -> "BynINW_sidebarCol" ``` **两个必须处理的坑**: 1. **转义类名** —— 以数字开头的类名在 CSS 里写作 `\37 yHdaG_row`,必须先反转义 (`QueueDock.module.css` 就有)。不处理会匹配失败。 2. **标签可能还不存在** —— 插件可能先于外壳渲染。此时返回 `null`,下次再试, **不要抛错**。 ### 第 3 层:`[class*="_后缀"]` 兜底 语义后缀(`_sidebarCol`)比哈希前缀稳定,两套壳都命中。样式表里用这一条, 保证**首帧**(JS 还没跑)就有正确样式。右栏两个后缀都要写: ```css [class*="_rightbarCol"], /* 桌面端 */ [class*="_detailsCol"] /* 网页端 */ ``` --- ## 三、打标:把定位结果固化下来 定位到元素后,客户端给它们打上**本插件自己的** `data-zf-*`: | 属性 | 打在 | |---|---| | `data-zf-frame` | AppFrame 外框 | | `data-zf-sidebar` / `data-zf-center` / `data-zf-rightbar` | 三列 | | `data-zf-rail="on"\|"off"` | body(观测台浮层显隐) | | `data-zf-avatar` | body(头像气泡开关) | | `data-zf-session-state` | body(会话状态,驱动状态点颜色) | CSS 优先认这些属性。好处:**外壳换版本、换哈希时,只要定位逻辑能自愈, 样式表一个字都不用改。** ### 生命周期要点 - **幂等**:已有该属性就跳过(`mark()` 返回 `false`)。 - **可还原**:`tagged: Map>` 记录「谁被打了什么」,卸载时逐条 移除。不做记录就只能 `querySelectorAll` 盲删,会误伤别人打的属性。 - **清理断开节点**:React 卸载子树后,打标会跟着元素一起消失,但 `Map` 里的 引用还在 —— 会泄漏。所以每次 `refresh()` 先 `prune()` 掉 `!isConnected` 的。 ### 观察器要收窄 ```js observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: [ 'data-rightbar-collapsed', 'data-details-collapsed', 'data-sidebar-collapsed', 'data-ds-dark-theme', 'data-chat-flow-kind', 'data-streaming', 'data-state', 'hidden' ] }) ``` **必须给 `attributeFilter`**:聊天区在流式输出时每帧都在变 DOM,不收窄的话 观察器每帧触发一次,白白烧 CPU。 --- ## 四、顶栏已移除(历史教训:定位基准要选确定的) 曾经的顶栏设计(现**已整体删除**,原因见下)只留下两条仍然有效的结论。 **背景**:Windows 桌面壳给 frame 加了标题栏带: ```css [data-windows-titlebar] .BynINW_frame{ padding-top: var(--dsh-windows-titlebar-height); /* 40px */ } [data-windows-titlebar] .BynINW_frame::before{ content:""; height: var(--dsh-windows-titlebar-height); background: var(--dsw-specific-sidebar-fill); -webkit-app-region: drag; /* 窗口拖拽区 */ } ``` **为什么删掉顶栏**:用户实测反馈两条 —— ① 它 `position:fixed` 浮在中栏上方, **压住了会话标题那一行**(截图可见标题被切掉半边);② 它显示的信息全是冗余 (头像在侧栏品牌位与助手消息旁都有,预设与会话状态在观测台里都有)。 右栏观测台因此改为**顶格**显示(`top:0`,仅 Windows 下让开 40px 标题栏)。 ### 留下的有效结论 1. **不要给顶栏类控件放按钮** —— Windows 标题栏那条 40px 带子被系统占满 (左 0–40px 窗口图标、48px 起「应用/编辑」菜单 Shadow DOM `z-index:1100`、 右侧 ~138px 窗口按钮)。任何浮在那里的东西都会与原生 chrome 抢位置。 2. **定位基准选「确定的坐标系」**:`shell.overlay` 是 AppFrame(`display:grid`) 的子元素,三个列与它都**没写 `grid-area`**(依赖自动放置),且 frame 在 Windows 下有 `padding-top`。观测台浮层因此用 `position:fixed` 相对**视口** 定位,Windows 下自己加 `--dsh-windows-titlebar-height` —— 用一点手工补偿 换来不随壳布局变化的确定基准。 > 教训:位置问题要用 `getBoundingClientRect` 实测(`/diag` 的 `geom` 字段), > 不要靠截图量像素反推 —— 我曾据此推断「overlay 被挤到第二行」,实测 > overlay 就在 `{0,0}`,推断是错的。 --- ## 五、观测台进官方右栏的两种方式(为什么不用 `openRightbar` 抢轨道) 桌面壳的 `ctx.layout` 服务提供: ``` selectPanel(panelId) / toggleSidebar() / openRightbar(track, fullscreen) / closeRightbar() ``` `openRightbar` 打开的是**原生右栏**(`rightbar` 插槽),由文件预览、终端、 文档预览等插件共用。**整块抢走这个 `single` 插槽**会与它们直接冲突。 本插件的分层策略(两阶段实现后的现状): 1. **首选:注册自己的标签页**(`sidebarRightTabs` 两阶段注册 + `openTab`) —— 走官方 `sidebar.right.pane.tab` 扩展席位(`priority:'extension'` 不抢 builtin),观测台成为原生右栏里**并列的一个 tab**:不占额外轨道、随原生 一起开合、可拖拽调宽。打开时机严格「仅在面板已展开时」,不代用户展开。 2. **兜底:`shell.overlay` 浮层** —— 右栏收起时贴右侧显示;官方服务缺失 (Web 端无 `dsh-client-ui-sidebar-right`)时自动接管。 3. 两路径由 `railOwner()` **单点裁决**,判定依据是 `tabMounted`(真的挂着 内容)而非注册状态 —— 注册 ≠ 打开,这是实测踩过的坑。 4. 视口 < 1180px 时浮层隐藏(同 Mornye 断点),避免挤压中栏。 --- ## 六、`shell.leading` 是死插槽(网页端) `shell.leading` 的用途是「macOS 上侧栏收起时,在左上角放重新展开控件」。桌面壳 渲染它,但**仅在 `html[data-platform='darwin']` 且侧栏收起时**: ```js const leadingMounted = darwin && sidebarCollapsed ``` 网页端更是**只声明、无渲染方** —— 注册进去等于石沉大海。所以本插件不用它。 同理,网页端的 `sidebar.brand.mark` / `conversation.hero.brand.mark` 也只声明不 渲染。注册了在网页端不会有副作用(不会报错),在桌面端才显示 —— 这正是我们 要的,所以不需要按平台分支。 --- ## 七、宿主模块缓存(改代码不生效的原因) **这是最容易误判为「代码写错了」的坑。** 桌面端是**两个进程**:Electron 主进程 `spawn` 一个独立的 Node 宿主进程。插件 的 Host 半边跑在那个 Node 进程里。 Node 的 ESM 模块按**解析后的路径**缓存。`plugin_manager` 的 `set_plugin(enabled=false)` → `enabled=true` 只会: - 卸载插件(跑 `ctx.effect` 的清理函数); - 重新 `apply()` —— 用的是**已经 import 过的模块对象**。 它**不会**重新 `import('./index.js')` 或 `./src/palette.js`。所以: | 改了什么 | disable/enable 够不够 | |---|---| | `client.js` | ✅ 够(浏览器半边走 mtime/size 派生的 revision,HMR 会重载) | | `index.js` / `src/*.js` | ❌ **必须重启 DSH** | ### 怎么确认跑的是哪一版 `tools/deploy.ps1` 会在部署时把 `index.js` 里的 `'__ZF_BUILD__'` 占位符替换成 时间戳。探活即可判断: ```powershell Invoke-WebRequest http://127.0.0.1:19387/api/zhuang-fangyi/themes | % Content # {"build":"20261005-123748", ...} ``` - `build` 是刚部署的时间戳 → 跑的是新代码; - `build` 是旧时间戳或**字段缺失** → 宿主还在跑旧模块,需要重启 DSH。 这个标记就是为了把「代码写错了」和「进程没重载」区分开 —— 实测时我一度以为 配色改动没生效,实际是宿主缓存。 --- ## 八、桌面端 `tapIndex` 不执行(壁纸/观测台/气泡重绘全部失效的根因) **这是最隐蔽、代价最大的一个坑,值得单独一节。** 宿主用 `tapIndex` 往 `index.html` 的 `` 前注入首帧样式: ```js ctx.inject(['webServer'], scoped => { scoped.effect(() => server.tapIndex(tap), 'zhuang-fangyi: first-paint tokens') }) ``` 它在 **Web 端有效**,但在**桌面端从来不会执行**。原因在 `app.asar` 的 `dsh-app://` 协议处理器: ```js protocol.handle(SCHEME, (request) => { const url = new URL(request.url) if (url.hostname === "app") { if (url.pathname === "/" || url.pathname === "/index.html" || ...) return serveWebDocument(request, join(resources.dsh, "node_modules", "@deepseek-ai", "dsh-web-frontend", "dist")) ... } }) function serveWebDocument(request, root) { body = await readFile(target) // ← 直接从磁盘读 const content = body.toString().replace("", "") // ↑ 只注入这一个脚本,完全不经过 Host 的 HTTP 服务 return new Response(content, ...) } ``` 也就是说,桌面渲染进程**直接从磁盘读 `index.html`**,绕过了 Host 的 webServer —— 所以 `tapIndex` 注册的回调根本没机会跑。 ### 症状 `structureStyle()` / `structureCss()` 的全部 CSS(壁纸、纱层、观测台、气泡重绘) **一个字都不会出现**。但 token 是由浏览器半边 `overrideTokens` 独立生效的,所以看起来 「**只改了配色**」—— 这正是最初那条反馈的真正原因。不是壁纸被纱遮住了,是 壁纸的 CSS 压根不存在。 ### 修法 两条路都走,**同一份 CSS、两种形态**: | 路径 | 适用 | 形态 | 机制 | |---|---|---|---| | `bootStyle()` → `tapIndex` | Web 端 | `structureStyle()`(**带 ``** (即直接复用 `structureStyle()` 的产物),浏览器会把 `