# 双壳适配说明
本插件要改外壳布局(壁纸纱层、观测台浮层、气泡重绘),所以必须**稳定地
找到外壳元素**。本机实测发现:**桌面端与网页端跑的是两套不同的壳**,类名哈希
完全不同。这份文档记录核实结果与应对策略,避免以后重新踩坑。
---
## 一、两套壳的实测差异
| 维度 | 桌面端(本机实际运行) | 网页端 |
|---|---|---|
| 版本 | `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()` 的产物),浏览器会把 `