面板跑在 DSH 的 document 里,独立窗口是另一个进程里的另一个 document。两者渲染的是同一份
lib/client.js,但它们彼此看不见:没有共享的 DOM、没有共享的 CSS、没有共享的事件总线。
这两件事——导航命令和配色——就靠下面这套很窄的通道传过去。
lib/host/bridge.js:40 的 createBridge() 是一个进程内的环形缓冲,不是消息队列:
| 参数 | 值 | 理由 |
|---|---|---|
capacity |
64 条 | 面板关着时攒下的命令,重连时要能补发 |
MAX_AGE_MS |
30 000 | 半分钟前的”打开这个会话”已经不是用户的意图了 |
每条命令是 { seq, kind, payload, createdAt },seq 单调递增,永不复用。
since(cursor) 返回 seq > cursor 且 未过期的命令:游标太旧时返回的只是还留着的那些,
补发一部分总比什么都不补发好。
总线故意是哑的(lib/host/bridge.js:60-66):它不校验 payload 的语义,只搬运。
认识某个 kind 的监听者自己处理,不认识的忽略。这样新面板可以对着旧窗口说话,不需要升 schema。
WINDOW_COMMANDS(lib/host/bridge.js:133)是窗口唯一被允许发的五种命令,其余一律 bad-request。
另外两种由 host 自己发,用来通知页面窗口的生命周期。
| kind | 谁发 | 谁执行 | 作用 |
|---|---|---|---|
open-session |
窗口 | DSH 页面 | 在 DSH 里打开某个会话 |
new-session |
窗口 | DSH 页面 | 在某个 workspace(或默认位置)新建会话 |
open-settings |
窗口 | DSH 页面 | 打开 DSH 的设置(窗口没有设置外壳) |
reveal |
DSH 页面 | 窗口 | 在资源管理器里定位会话文件 |
refresh |
窗口 | DSH 页面 | 刷新面板数据 |
window-open |
host | DSH 页面 | 窗口已打开(用于同步”默认打开方式”的状态) |
window-closed |
host | DSH 页面 | 窗口已关闭 |
payload 只接受三个字符串字段,各自截到 200 字符:
{ "kind": "open-session", "payload": { "sessionId": "01J…" } }
sessionId / workspaceId / scope 之外的键直接丢掉。桥是一根窄管子,不是插件的遥控器。
为什么方向是分开的(lib/client.js:14226-14281):
open-session / new-session 只由 DSH 页面执行。窗口是发起方;窗口若也听自己的命令,
就会把命令原样再发一次(shim 里的 ECHO_MS 回声守卫只是时间启发式,不是契约)。reveal 只由窗口执行。反过来会让页面在自己刚打开窗口之后,又把面板盖在那个窗口上面。windowHost():同一个 client.js,靠这一个判断决定自己现在是哪一端。GET /bridge/events?since=<cursor>(lib/index.js:1693)。帧序列固定:
event: ready
data: {"cursor":42,"kinds":["open-session","new-session","reveal","refresh","open-settings"]}
id: 43
event: command
data: {"seq":43,"kind":"open-session","payload":{…},"createdAt":1730000000000}
: keep-alive
ready 先发,带上当前游标和允许的 kind,客户端据此决定要补哪些;since 之后的命令(一次往返就能追上,不用第二次请求);command,每帧带 id: <seq>;: keep-alive,防止中间层掐掉空闲连接(keepAlive.unref(),
它不该阻止进程退出);req.on("close" | "aborted") 时清定时器、退订、end()。重连不重放:EventSource 断线后会自己重连同一个 URL,并带上 Last-Event-ID。服务端优先
读这个头,读不到才回落到查询参数 since(lib/index.js:1716-1717)。没有这一步,重连会从
首次连接的游标开始补发,new-session 就开两个窗口。
背压与上限:res.write() 返回 false 表示套接字缓冲已满,此时直接结束这条流——面板会带
Last-Event-ID 重连,从它真正收到的最后一帧继续,而不是让缓冲无限增长。同时订阅者上限 32
(MAX_LISTENERS,lib/host/bridge.js:35),满了回 503:重连风暴不能把卡住的响应再叠一层。
SSE 而不是轮询:命令是事件,而唯一会变的数字(下载百分比)走的是轮询——两者的性质不同。
面板在 DSH 的 document 里,getComputedStyle 能解出每个 --dsw-alias-* 的当前值。
独立窗口是另一个 document,DSH 的样式表不在那里,那些 token 会退回面板自己的默认值——
于是用户装了主题插件、或者切到浅色,窗口不跟着变。
面板把它看到的报给 host,窗口来问 host 要。lib/host/theme.js 是个极小的共享 store。
只接受样式表真正用到的 14 个 token(THEME_TOKENS,lib/host/theme.js:31):
--dsw-alias-bg-layer-1 / -2 / -3
--dsw-alias-label-primary / -secondary / -tertiary
--dsw-alias-border-l1 / -l2
--dsw-alias-state-business-primary / -error-primary / -success-primary / -warning-primary
--dsw-alias-brand-primary
--dsw-alias-interactive-bg-hover
校验(isSafeTokenValue,lib/host/theme.js:57):值最终会进 CSS 自定义属性,所以无界的
map 就是一条样式注入通道。规则是:
VALUE_MAX,够放颜色和小的 color-mix 表达式);; { } → 拒;url( / expression( / @import / javascript:(忽略大小写)→ 拒;^[#\w\s.,%()\-+/'"*]+$。不匹配的单个 token 被丢掉,不影响其它 token。
reportTheme()(lib/client.js:11130):读 .se-shell(没挂载就读 body,最后才退到
documentElement)的 computed style,取这 14 个 token 的值,POST /theme 带 { mode, tokens }。
mode 来自 documentElement 的 data-theme 是否为 light。
读 body 而不是 documentElement 是有原因的:DSH 把调色板声明在 body 上
(--dsw-alias-* 定义在 body / body[data-ds-dark-theme]),自定义属性只向下继承,
所以从 <html> 读拿到的是空调色板。上一版正是在 apply() 里从 documentElement
读一次——那时面板还没挂载、<html> 又没有 token,于是 host 存下一份空的调色板,
窗口只好用样式表里硬编码的兜底色(工作区名字/图标因此是深蓝 #4176e6,而 DSH 里是主题色)。
值必须解析成具体颜色(resolveThemeValue(),lib/client.js:11092):DSH 的 alias token
几乎都是指向自己调色板的引用,例如 --dsw-alias-label-primary: var(--dsw-static-neutral-bluish-50)。
窗口没有 --dsw-static-* 这一层,原样搬过去会得到一个”引用了不存在变量”的值——这在 CSS 里是
无效而不是未设置,消费方 var(--token, fallback) 的 fallback 根本轮不到,文字会掉成
unset。所以在这里就地展开引用(深度上限 8 层,支持 var(--x, #fallback)),解不出来的
token 直接丢给 host 丢弃,让窗口保留自己的兜底。
触发点有三个:apply() 时一次;Panel 挂载后一次(lib/client.js:6386,此时 .se-shell
才存在,读到的就是面板真正看到的调色板);以及一个 MutationObserver——监视
documentElement 的 class / data-theme / style 三个属性。这是 DSH 里所有主题插件切换
明暗的方式,所以监听它就等于监听”主题变了”。观察器挂在 ctx.effect 里,卸载时断开。
assets/detached/shim.js 在窗口启动时 GET /theme,把每个 token setProperty 到
documentElement,并同步 data-theme。
shim 会拦掉窗口发出的 POST /theme(assets/detached/shim.js:93)。因为窗口里的
client.js 也在跑,它也会上报——但窗口的配色本来就是从 host 拿来的,回声会把一份空调色板
盖在真的上面(首帧还没拿到回复时尤其明显)。所以这条通道只允许单向:面板 → host → 窗口。
POST /detached/open / POST /detached/close(lib/index.js:1776-1786)。窗口本体是 DSH 的
Electron 窗口,加载 /session-navigator/api/ui,需要浏览器组件就绪,否则 404 component-not-installed。
窗口打开/关闭时 host 往总线发 window-open / window-closed(lib/index.js:179-180),
页面据此更新”默认打开方式”的状态和按钮文案。
窗口的几何(shell.window.bounds / alwaysOnTop)存在设置里:bounds 为 null 表示”还没放过”,
客户端先居中一次再把落点记下来。
assets/detached/shim.js:1-25 写得很清楚:窗口和面板是同一个 UI,窗口原样加载
lib/client.js,shim 只是在那份模块 mount 之前假装自己是 DSH。client.js 向平台要四样东西,
shim 四样全给:
| 平台接口 | shim 给的 |
|---|---|
window.__ModuleLoader__.load({ id, factory }) |
直接调用 factory |
require("react") |
仓库里自带的 UMD 构建 |
ctx.slots / ctx.locale / ctx.effect / ctx.get |
一个”刚好够用”的 ctx |
document.body + createPortal |
窗口自己的 document |
因为面板是通过 portal 渲染进 document.body 的(getOverlayRoot),这在任何 document 里都能工作,
所以窗口只需要渲染一次 footer-action 槽位、把 panel store 打开就行。
shim 故意不做两件事:不上报主题(见上),不订阅命令桥(窗口只向 host 请求导航, 执行由 DSH 页面里的面板完成)。
| 文件 | 职责 |
|---|---|
lib/host/bridge.js |
总线、WINDOW_COMMANDS、normalizeCommand |
lib/host/theme.js |
THEME_TOKENS、isSafeTokenValue、theme store |
lib/index.js:1680 |
POST /bridge/commands |
lib/index.js:1693 |
GET /bridge/events(SSE) |
lib/client.js:11130 |
reportTheme() + resolveThemeValue()(lib/client.js:11092)+ MutationObserver |
lib/client.js:828 |
subscribeBridge() |
lib/client.js:14210 |
apply() 里装配桥与主题监听 |
assets/detached/shim.js |
窗口的假 DSH 外壳、主题拉取、回声守卫 |