# 实现说明 ## 组成 ```text index.js Host 半部。空 apply,仅用于成为合法 Cordis bundle。 client.js Client 半部。全部界面、webview、主题与登录逻辑。 cordis.patch.yml 把 Host bundle 插入 desktop profile。 package.json 声明 dsh.bundle.patch 与 dsh.client。 icon.svg 插件图标。 ``` 没有构建步骤。`client.js` 直接使用 DSH 的预打包模块格式: ```js window.__ModuleLoader__.load({ id: '@local/dsh-deepseek-web', factory(require) { /* React + client code */ }, }) ``` `id` 必须与 `package.json` 的 `name` 一致;`dsh.client` 声明 `platform: "web"`、`immediately: true` 以及需要预加载的侧栏/Workspace 包。 ## 启动与注册 Client 半部注入 `slots`,然后等待右侧栏服务: ```js ctx.inject(['sidebarRightTabs', 'sidebarRight'], (scope) => { scope.effect(() => scope.get('sidebarRightTabs').register({ ... })) scope.slots.inject('sidebar.right.pane.tab', () => scope.slots.register({ ... })) }) ``` - Tab type:`id: '@local/dsh-deepseek-web/panel'`,`kind: 'deepseek-web'`,`keepMounted: true`。 - Body 槽位:`sidebar.right.pane.tab`,用同一个 type id 作为 `key`。 - 页面正文在 `DeepSeekPanel` 中创建 webview。 ## 工作区按钮 DSH 没有暴露「Workspace 区头上方」的专用 slot,因此按钮是一个普通 DOM 节点: 1. 找到左侧栏列(`[class*="sidebarCol"]`)。 2. 找到 Workspace 区头(`[class*="sectionHeader"]`,或从 `[role="tree"]` 向上定位)。 3. 把 `triggerHost` 插到区头前一个兄弟位置。 4. `MutationObserver` 只在区头/侧栏重挂载时重新定位;流式输出不会每帧触发布局测量。 5. `ResizeObserver` 在侧栏折叠到 56 px 时隐藏按钮。 右侧栏打开时若左侧栏被 DSH 自动收起,按钮会隐藏;侧栏重新展开后恢复。 ## 单击 / 双击状态机 ```mermaid flowchart TD A[button click] --> B{second click within 175 ms?} B -- yes --> C[cancel pending single action] C --> D[open/focus tab · force fullscreen] B -- no --> E[arm 175 ms single action] E --> F{was panel open?} F -- no --> G[open/focus tab · force push] F -- yes --> H[click native collapse control] D --> I[watch data-sidebar-right-panel] G --> I I --> J{toggleFullscreen needed?} J -- yes --> K[commandTarget · toggleFullscreen] J -- no --> L[done] ``` 关键点: - 第一次点击不立即展开,单次动作要等 175 ms 才能确认不是双击。 - 双击的第二次点击会 `cancelScheduledClick()`,然后直接走全屏路径,因此不会先闪半屏。 - 展示模式由 DSH 持久化。打开后读取 `[data-sidebar-right-panel]` 的实际值(`push` / `fullscreen`),不一致时用 `commandTarget(null)` + `toggleFullscreen(target)` 强制到目标模式。 - Esc 会取消尚未执行的单击动作。 ## 关闭与动画 关闭不是直接改布局,而是触发右侧栏自带的收起按钮: ```js const panel = currentPanelElement() const toggle = panel.querySelector('[data-sidebar-right-toggle]') toggle.click() ``` 这和鼠标点击右栏右上角收起按钮走到同一个 React 处理器,因此滑动动画一致。找不到按钮时回退到 `sidebarRight.toggleExpanded()`。 关闭时: - 先把焦点移回左侧触发按钮,避免 Esc 后键盘焦点留在隐藏的 webview 里。 - **不关闭 Tab**。`keepMounted` 的页面继续保留,重新打开时回到原页面。 - 若要真正关闭 Tab,点击标签页上的 ×,走 DSH 自己的 `close`。 ## Webview 生命周期 ```mermaid sequenceDiagram participant P as DeepSeekPanel participant B as window.dshDesktop.browser participant W as webview guest participant D as chat.deepseek.com P->>B: acquire('dsh-deepseek-web') B-->>P: { lease, partition } P->>W: create webview(name=lease, partition=partition, src=about:blank#lease) W-->>P: dom-ready P->>W: loadURL('https://chat.deepseek.com/') W->>D: navigate D-->>P: did-finish-load P->>W: executeJavaScript(theme + Escape listener) P->>B: release(lease) on unmount ``` - 使用真实的 Desktop browser bridge,而不是 iframe。 - 主进程把 guest 放进 sandbox:无 Node、无 preload、无 webviewTag,权限请求默认拒绝。 - 允许 HTTP/HTTPS 导航,排除应用自身 Host。 - 同一个 workspace key(`dsh-deepseek-web`)让多个面板共享本次进程内的 cookie 分区。 - 面板卸载时释放 lease,主进程关闭 guest。 ## 登录态保存与恢复 DeepSeek 网页把登录信息放在版本化 wrapper 中: ```text userToken __appKit_userInfo ``` 插件只保存逻辑值,不保存 wrapper 文本: ```js localStorage['dsh.deepseek-web.auth.v1'] = { token, tokenVersion, info, infoVersion, savedAt } ``` 流程: 1. 面板启动时先在同源 `robots.txt` 页读取 DSH 保存的记录,把 `userToken` / `__appKit_userInfo` 写回 guest 的 `localStorage`;此时聊天应用尚未加载,因此不会先画登录界面。 2. 再导航到 `https://chat.deepseek.com/`;应用启动时已经能看到 token。 3. `did-finish-load` 后读取 guest 的 `userToken`:有 token 就更新保存记录;没有则执行兜底恢复/提示。 4. 恢复失败:清空记录,面板显示提示,让用户重新登录。 5. 最多轮询一次/5 s;连续多次没有 token 时清理过期记录。 登录恢复是 best-effort:DeepSeek 更新存储格式后可能需要重新登录。 ## 环境提示 DeepSeek 在检测到 Electron(`navigator.userAgent` 包含 `electron`)时,会在聊天界面弹出“使用环境异常”。页面自己的关闭状态保存在: ```text closeUnsafeEnvWarn = {"value":true,"__version":"0"} ``` DSH 的 guest 分区是进程级的,应用退出就清空,所以这个“不再提示”状态每次启动都会丢。插件在同一个 `robots.txt` 预热页、聊天应用启动前写入该值,等同于用户点击弹窗上的 ×;不修改 User-Agent,也不改变 webview 隔离。 ## 主题 - 读 DSH `body[data-ds-dark-theme]` 和主题变量。 - 向 guest 写 `data-ds-dark-theme`、`color-scheme` 与背景色。 - 监听 `theme/change` 和 DOM 属性变化,对已打开的 guest 重新同步。 ## Esc Webview 内的键盘事件不会冒泡到 Harness 文档,因此分两条路径: - **Guest 内**:`executeJavaScript` 安装 capture 阶段 `keydown`,按 Esc 时输出固定 console marker;Host 的 `console-message` 收到后收起侧栏。 - **DSH 界面内**:文档 `keydown` 监听;若待执行的单击动作存在则先取消,再收起。 两条路径最终都调用同一个 `closeDeepSeek()`,即点击原生收起按钮。 ## 安全边界 - 无 Host HTTP 路由。 - 不读取 Harness API Key 或账号凭证。 - Guest 内容不被视为可信输入;插件不把 guest HTML 注入 DSH。 - 登录 token 以明文存在本机 DSH `localStorage`,与 DSH 其他浏览器存储同权限级。 - 插件代码以当前用户权限运行;安装第三方插件本身需要信任。 ## 兼容性检查清单 DSH 升级后建议确认: 1. 侧栏仍能通过 `sidebarRight.openTab` 打开 `deepseek-web`。 2. `sidebar.right.pane.tab` body 仍能挂载。 3. `[data-sidebar-right-toggle]` 仍存在(否则回退 `toggleExpanded`)。 4. `data-sidebar-right-panel` 仍表示 `push` / `fullscreen`。 5. Desktop bridge 的 `acquire/release`、`about:blank#lease` 协议未变。 6. DeepSeek 的登录存储键仍为 `userToken` / `__appKit_userInfo`。 ## 校验 ```bash npm run check ``` 检查 bundle 清单、client loader id、patch 名称、必需文件和 JS 语法。没有第三方测试依赖。