dsh-builtin-browser 0.4.5 → 0.4.7

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/CHANGELOG.md CHANGED
@@ -4,6 +4,108 @@
4
4
 
5
5
  ---
6
6
 
7
+ # 第四十三轮(2026-10-12,三个"看起来无关"的缺陷:载体被写死、宿主死了不换、设置读了不生效)
8
+
9
+ 这一轮的共同点不是症状,而是**判断被做了一次就再也不做了**:载体在启动时选一次、连接端口在建立时记一次、`browser.channel` 在 apply 时读一次。三次都发生在"当时是对的"的情况下,三次的后果都是"之后永远不对"。
10
+
11
+ ## 一、标签级三件套 + 裸根拦截(issue #26,@dengyun2018-commits)
12
+
13
+ 他带着一份本地 patch 来,已在 0.4.0 → 0.4.5 四个版本上实跑、每次 `apply --check` 零偏移。四条全部收下,两条判据换了实现:
14
+
15
+ **标签 URL 跟踪**:每个标签记住"最后一次被接受的 URL"。`listTabs` 不再探测它已知为空的标签——未提交的视图上那次往返只能回答"没有";而"从未导航"与"导航过但读不出来"终于可区分(此前两者都塌进同一个空字符串)。读失败时保留上次的 URL,而不是把标签报成空的。
16
+
17
+ **空白标签短路**:`snapshot` / `a11y` 在未导航的标签上立即返回空页,而不是在一个没有文档可提交的视图里跑采集器、先耗尽整个 30 秒预算。
18
+
19
+ **超时自愈**:超时的读操作**原位替换**卡死的视图(标签对调用方保持同一个 id),并抛出 `BROWSER_SNAPSHOT_TIMEOUT` / `BROWSER_A11Y_TIMEOUT`。此前视图留在会话里,于是**之后的每一次读都要再付一遍 30 秒**——`terminatePage` 只中断正在跑的脚本,并不会让视图变得可读。它还有个每视图 3 次的待终止上限,所以反复读会累积挂起的 evaluate。
20
+
21
+ **裸根拦截**:`browser_open` 拒绝不带 token 的 DSH web 根地址——它返回 401 页面,读起来像一个真站点。**判据用的是 `DSH_WEB_URL`(harness 自己导出的环境变量),而不是端口号**:这台机器的 GUI 在 3080 和 19387 上都跑过,硬编码端口会在最需要它的那次不匹配。识别陷阱靠的是形状——同源 + 根路径 + 无 token——不是数字。
22
+
23
+ ## 二、桌面壳退出后,浏览器面板跟着一起死
24
+
25
+ 用户报的是"浏览器不能正常使用了,只能重启 DSH"。
26
+
27
+ 链条是两处叠加:**载体只在启动时选一次,而且只能向上走**——自托管先注册,发现桌面 bridge 后把它换成 provider,紧接着 `selfHosted.dispose()`。**没有回退目标。**启动时那四次重试只覆盖"bridge 出现得晚",而代码注释自己写着这个交换是单向的。
28
+
29
+ **`refreshEndpoint` 也救不了,原因很精确**:它比较 port 和 token,而死掉的 shell 留下的文件**内容原封不动**——文件看着是新的,指向的是尸体。
30
+
31
+ **判据来自文件里本来就有的字段**:活的 shell 每 15 秒重写 `updatedAt`,所以**四次没刷新就不是"慢",是"没了"**。超过一分钟的端点现在被判定为墓碑,一次性上报,经由载体接口上新增的 `onLost`;entry 收到后用自托管把 provider 重新注册回来。**缺失 `updatedAt` 刻意不算过期**,否则所有比这个字段更老的 shell 都会在首次使用时就判死。
32
+
33
+ 失败信息也不再是裸的 `ECONNREFUSED`——它点名原因,因为读它的人正是决定要不要回退的那个。
34
+
35
+ ## 三、`browser.channel` 只在 apply 时读一次(issue #27,@sbtxdy)
36
+
37
+ 他给了一组对照测量:`Electron/44.5.1` 对 `Edg/155.0.0.0`,以及"改完不重启、进程里没有任何带插件自有 `--user-data-dir` 的 msedge.exe"。三条建议全收,第一条选了**热切换**而不是退让到 `restartRequired`。
38
+
39
+ **载体实时重选**:选择逻辑改成一个 `chooseCarrier()`,设置路由在写入改变 channel 时重跑它。这里有一处必须一起动:`adopt` 原有的一次性 guard 会把晚到的载体直接 `dispose` 掉——**防两个载体抢入是对的,对"用户改设置"恰好是错的**,因为那正是"写成功了、然后新载体被安静丢掉"的来源。现在换掉旧载体的是新载体,且在装好之后才释放。
40
+
41
+ **不生效的写会说出来**:响应区分"已应用"和"只存了"(`carrierRechosen`);载体由 composition 提供(桌面 shell)而 channel 无法生效时打 WARN。**对一次没人会读的写回 `ok`,是调用方唯一分不出"成功"和"什么都没做"的地方**。
42
+
43
+ **拼错的值不再静默**:`resolveChannel` 把未知值退回默认——它的注释写着这是有意为之("手改的错别字不该静默选中别的浏览器"),**但结果让错别字和"设置没生效"长得一模一样**。退回是对的,静默退回不是:现在会报出原值并列出接受集合。**缺席保持安静**,否则警告会变成噪音。
44
+
45
+ ## 四、自我更正
46
+
47
+ **第二节的光标是我的**:`BridgeConnection.refreshEndpoint` 的存在恰恰证明我知道"端口会变"这件事,但判据选成了"与当前不同",而墓碑的特征是"与当前相同但已经死了"。同一个字段的两端,我只写了会变的那一端。
48
+
49
+ **测试**:237 → 250(本轮新增 13 条:标签 URL 与空白短路 3 条、墓碑与回退 4 条、channel 重选 6 条)。`tsc` 干净。其中超时自愈那条是**形状断言**而不是行为断言——真跑 30 秒太慢——这一点写进了 commit 和给报告者的回复里,免得以后有人把它读强。
50
+
51
+ **发布**:0.4.7。`npm` 与仓库同步。
52
+
53
+ ---
54
+
55
+ # 第四十二轮(2026-10-08,三个"能看见的"缺陷:跨会话读错、空白页堆积、升级登出)
56
+
57
+ 0.4.5 发布后,用户连续报了三个现象。**三个都在同一段代码的新实现里**,而它们看着毫不相干——一个是"别的会话读到我这边",一个是"一次开一堆空白页",一个是"升级后所有站点被登出"。前两个实际上是**同一个会话在两种状态下的两种表现**。
58
+
59
+ ## 一、面板查找越出本会话(跨会话读错)
60
+
61
+ **症状**:另一个会话调用时读到了**本会话的侧栏**——同一段对话里两次读取给出相反结论(先说"已登录"、后说"未登录"),点 GitHub 登录也没有反应。
62
+
63
+ **根因(实测确认,不是推理)**:客户端半边的 `guestsInPanel` 从本会话的侧栏条**向上走**,找"第一个含 `<webview>` 的祖先"。而**外壳把所有会话的侧栏都挂在同一棵文档树里**(隐藏的也在),所以多走一步就落到了共同根,那里的"第一个 guest"属于**外壳先渲染的那个会话**——顺序随挂载状态漂移,于是同一个会话两次能拿到不同的页面。
64
+
65
+ 现场证据:
66
+
67
+ panelGuest(本会话 session-fe0a980a) →
68
+ {"panel":true,"guestId":3,"url":"https://github.com/login?...virtualembryo.ai..."}
69
+
70
+ 那个 GitHub 登录页**不是本会话打开的**。本会话因此会去驱动一个不属于它的页面——第二次读取时那个页面已不在,于是"未登录"。
71
+
72
+ **修法**:向上走的循环**一遇到不属于本会话的侧栏条就停**。同级的侧栏条是"已经跨出本会话"的精确标志,且不依赖任何类名;一个会话自己可以有多个,所以判据是**"有没有不是我的"**,不是数量。起点也改回侧栏条自身(而非其父节点),这样 guest 与条同级的布局也能走到。
73
+
74
+ **守护用例**构造了两会话共存的树、邻居持有 guest,断言本会话报告自己没有、且打开面板时**不导航邻居的任何页面**。
75
+
76
+ ## 二、等待期间误建页面(一次开一堆空白初始页)
77
+
78
+ **症状**:"一次性打开很多个浏览器初始页面,但只有最后一个是我们需要的。"
79
+
80
+ **根因**:桥在等 guest 附着时每 250ms 轮询一次,**而每轮调的都是 `openPanel`——那是个会创建标签的动作**。20 秒的预算 = 最多 80 次创建,每次一个不带地址的初始页。"最后一个是需要的"也对得上:第一个才是带地址的那次。
81
+
82
+ **修法**:等待改用**只读**形式。`evaluatePanelService` 现在明确区分两种语义——**`null` 是读,字符串才是放置/导航**。只读形式自带 `ok`,因为"你现在有什么"回答"还没有"是合法答案,不是请求失败(否则每一轮都像出错)。
83
+
84
+ **守护用例**:无论等待多久都**恰好一次**放置,且每一轮调的都是 `api.panelGuest(`、从不调 `api.openPanel(`。匹配的是**调用**而非名字,因为每个表达式都带 `typeof api.openPanel !== 'function'` 的能力检查。
85
+
86
+ ## 三、升级把浏览器登出(profile 路径变更的尾巴)
87
+
88
+ **症状**(由 @EmyiaC 在 issue #21 复测后报告):0.4.1 → 0.4.5 升级后,浏览器**登出所有站点**。他建议写进 release notes。
89
+
90
+ **根因**:早先修过一个路径不一致——从快捷方式启动的桌面端(环境里没有 `DSH_HOME`)把浏览器 profile 放在 `<homedir>/dsh-builtin-browser-host/`,而 settings 与 history 在 `<homedir>/.dsh/dsh-builtin-browser-host/`。**登录态就在 profile 里**,路径一改,旧目录连同登录态被丢下。
91
+
92
+ **修法**:**不写那行 release note,而是把代价去掉。** apply 时若旧根存在、当前根不存在,则把旧根**移**过去。两条规则兜住风险:
93
+
94
+ - **只在目标缺席时移动**——正在用的 profile 永不被覆盖;
95
+ - **任何失败都退回空 profile**(跨设备 rename、权限、被别的进程占用),此时旧目录**原样保留**而不是移一半。Windows 的 rename 需要目标父目录存在,所以先建。
96
+
97
+ **守护用例四条**:移动、不覆盖、无可迁移、两侧同路径。旧根做成**带默认值的参数**,这让移动**可以在不碰真实 home 的情况下被测**。
98
+
99
+ **对已升级的人(包括报告者)为时已晚**——他们的登出发生在修复发布之前。它有效的是"下一次升级的人不必再付这笔代价"。
100
+
101
+ ## 四、这一轮的自我更正
102
+
103
+ **第一个缺陷是我两条命令之前刚引入的**:同一轮里我先修了"向外读错",又在修"等待误建"时把轮询从创建改成只读,**中间那版一度让"标签数没增加"被误判成"侧栏没挂载"**——而 `openTabIn` 对同类页面是**去重**的,所以那个判据会把"已经持有该页面"读成"未挂载",于是"打开过的地址反而打不开"。判据改为**"从 0 到 0"**才是真信号,那条守护用例在写出来的当次就抓到了同批的一个 `created` 恒真错误。
104
+
105
+ **发布**:0.4.6。`npm` 与仓库同步——**没发出去的修复等于没有修复**。
106
+
107
+ ---
108
+
7
109
  # 第四十一轮(2026-10-06,桌面端:会话串扰的真正根因,以及一次架构级改正)
8
110
 
9
111
  用户报的症状只有一句:**"还是其他窗口的侧栏"**。前几轮都在 owner 台账上加检查,这一轮做的第一件事是**不采信台账的立论,把它实测一遍**。
package/client.js CHANGED
@@ -617,15 +617,30 @@ window.__ModuleLoader__.load({ id: "dsh-builtin-browser", factory: (require) =>
617
617
  return null
618
618
  }
619
619
 
620
- /** Every live `<webview>` guest inside this conversation's panel subtree. */
620
+ /**
621
+ * Every live `<webview>` guest inside this conversation's panel subtree.
622
+ *
623
+ * The walk up stops the moment an ancestor carries ANOTHER conversation's sidebar, and that stop
624
+ * condition is the whole point of this function. The shell mounts every conversation's panel in
625
+ * the same document — hidden ones included — so one step too far lands on a subtree that holds
626
+ * all of them, and the first guest found there belongs to whichever conversation the shell
627
+ * happens to render first. Measured: a GitHub login page opened in a different conversation was
628
+ * reported as this one's guest, which is the cross-conversation bleed this service exists to
629
+ * prevent. A sibling strip is the exact, class-name-independent signature of having stepped
630
+ * outside; a conversation may legitimately own more than one strip of its own, so the test is
631
+ * "is there one that is NOT mine", never a count.
632
+ */
621
633
  function guestsInPanel(sessionId) {
622
634
  const strip = tabStripFor(sessionId)
623
635
  if (strip === null) return []
624
- // Walk up to the first ancestor that owns a guest: the strip itself is outside the
625
- // body region, so the search starts low and stops as soon as it has one. It never
626
- // crosses out of this conversation's branch, which is what keeps it scoped.
636
+ // Start at the strip itself: it is by construction this conversation's own, so the foreign test
637
+ // below cannot trip on it, and a shell that renders the guest as a sibling of the strip is
638
+ // still reached.
627
639
  let node = strip
628
640
  while (node !== null && node !== undefined && node !== document.body) {
641
+ const foreign = Array.from(node.querySelectorAll("[class*=_tabStrip]"))
642
+ .some(el => sessionIdOfElement(el) !== sessionId)
643
+ if (foreign) break
629
644
  const found = Array.from(node.querySelectorAll("webview")).filter(view => {
630
645
  try { return typeof view.getWebContentsId === "function" && view.getWebContentsId() > 0 }
631
646
  catch (error) { return false }
@@ -238,17 +238,26 @@ async function sendCdp(id, method, params) {
238
238
  *
239
239
  * @param shellId - the shell window's webContents id.
240
240
  * @param sessionId - the conversation that asked (the request's `owner`).
241
- * @param url - address to load when a panel has to be opened; empty to only read.
241
+ * @param url - address to load when a panel has to be opened; `null` to only READ.
242
242
  * @returns the renderer's verdict, normalized so a malformed answer is a named failure.
243
243
  */
244
244
  async function evaluatePanelService(shellId, sessionId, url) {
245
+ // `null` means read-only. This distinction is not cosmetic: `openPanel` CREATES a tab when the
246
+ // conversation has none, so polling with it turned a 20-second wait into one empty tab every
247
+ // 250ms — the "it opens a pile of blank pages at once" report. A wait must never be a write.
248
+ const reading = url === null
249
+ const call = reading
250
+ // `panelGuest` reports state, not success, so the read form supplies the verdict itself: a
251
+ // conversation with no panel yet is a legitimate answer to "what do you have", not a failure.
252
+ ? `JSON.stringify({ ok: true, ...api.panelGuest(${JSON.stringify(String(sessionId))}) })`
253
+ : `JSON.stringify(api.openPanel(${JSON.stringify(String(sessionId))}, ${JSON.stringify(String(url ?? ''))}))`
245
254
  const expression = `(() => {
246
255
  const api = globalThis.__dshBuiltinBrowser;
247
256
  if (api === undefined || api === null || typeof api.openPanel !== 'function') {
248
257
  return JSON.stringify({ ok: false, reason: 'the plugin client half is not loaded in this window' });
249
258
  }
250
259
  try {
251
- return JSON.stringify(api.openPanel(${JSON.stringify(String(sessionId))}, ${JSON.stringify(String(url ?? ''))}));
260
+ return ${call};
252
261
  } catch (error) {
253
262
  return JSON.stringify({ ok: false, reason: String((error && error.message) || error) });
254
263
  }
@@ -361,7 +370,9 @@ async function handle(request) {
361
370
  }
362
371
 
363
372
  const ask = async (withUrl) => {
364
- const answer = await evaluatePanelService(shell.id, owner, withUrl ? url : '')
373
+ // `null` reads, a string places or navigates. Every round of the wait below uses the READ
374
+ // form, because a wait must never create anything.
375
+ const answer = await evaluatePanelService(shell.id, owner, withUrl ? url : null)
365
376
  if (answer.ok !== true) {
366
377
  // Carry the renderer's own findings into the failure: it is the side that can see why the
367
378
  // lookup missed, and a bare "not mounted" leaves the next reader guessing between a real
@@ -423,9 +434,9 @@ async function handle(request) {
423
434
  // human was looking at. Reported as issue #23; the other two carriers already did this
424
435
  // (the self-hosted window re-adds the view, the system browser calls Page.bringToFront).
425
436
  //
426
- // The tab belongs to the renderer, so the same reasoning as `ensureTabs` applies: drive the
427
- // sidebar's own control. A tab is matched by the title it shows, which is what the sidebar
428
- // renders from the guest — the guest's id is not exposed in the DOM.
437
+ // The tab strip belongs to the renderer, so raising one means asking the sidebar to do it. A
438
+ // tab is matched by the title it shows, which is what the sidebar renders from the guest —
439
+ // the guest's id is not exposed in the DOM.
429
440
  const viewId = Number(request.viewId)
430
441
  if (!Number.isFinite(viewId)) throw new Error('showTab needs a viewId')
431
442
  const guest = webContents.fromId(viewId)
@@ -23,6 +23,7 @@ export interface BridgeEndpoint {
23
23
  export declare class BridgeConnection {
24
24
  private endpoint;
25
25
  private readonly endpointPath?;
26
+ private readonly onLost?;
26
27
  private socket;
27
28
  private buffer;
28
29
  /** Outstanding requests by id. */
@@ -34,11 +35,14 @@ export declare class BridgeConnection {
34
35
  private idLessBridge;
35
36
  /** True once this connection may only have one request in flight at a time. */
36
37
  private serialisable;
38
+ /** True once the shell behind this connection has been reported gone. */
39
+ private lostNotified;
37
40
  /**
38
41
  * @param endpoint - the shell's published bridge endpoint.
39
42
  * @param endpointPath - where that endpoint is republished, so a moved port can be followed.
43
+ * @param onLost - called once when this connection concludes the shell behind it is gone.
40
44
  */
41
- constructor(endpoint: BridgeEndpoint, endpointPath?: string | undefined);
45
+ constructor(endpoint: BridgeEndpoint, endpointPath?: string | undefined, onLost?: (() => void) | undefined);
42
46
  /**
43
47
  * Re-read the published endpoint, and adopt it if the shell has moved.
44
48
  *
@@ -53,6 +57,8 @@ export declare class BridgeConnection {
53
57
  * @returns true when a different endpoint was adopted.
54
58
  */
55
59
  private refreshEndpoint;
60
+ /** Report once that the shell is gone, so the surface can fall back instead of dialling it. */
61
+ private notifyLost;
56
62
  /**
57
63
  * Send one request, reusing the socket if it is still healthy.
58
64
  *
@@ -33,6 +33,15 @@ const MAX_BUFFER_BYTES = 128 * 1024 * 1024;
33
33
  * client then falls back to arrival order, which is what the protocol guaranteed before.
34
34
  */
35
35
  const REQUEST_ID_FIELD = 'bridgeRequestId';
36
+ /**
37
+ * How long the published endpoint may go un-refreshed before its shell is presumed gone.
38
+ *
39
+ * A live shell rewrites the file every fifteen seconds (its keep-alive), so four missed refreshes
40
+ * is not a slow shell — it is a shell that exited and left its last write behind. The distinction
41
+ * matters because the file is the ONLY thing that says where the bridge is: a tombstone names a
42
+ * port nobody owns, and a client that trusts it dials that port for the rest of its life.
43
+ */
44
+ const ENDPOINT_TTL_MS = 60_000;
36
45
  /**
37
46
  * A reusable connection to the bridge.
38
47
  *
@@ -51,6 +60,7 @@ const REQUEST_ID_FIELD = 'bridgeRequestId';
51
60
  export class BridgeConnection {
52
61
  endpoint;
53
62
  endpointPath;
63
+ onLost;
54
64
  socket;
55
65
  buffer = '';
56
66
  /** Outstanding requests by id. */
@@ -62,13 +72,17 @@ export class BridgeConnection {
62
72
  idLessBridge = false;
63
73
  /** True once this connection may only have one request in flight at a time. */
64
74
  serialisable = false;
75
+ /** True once the shell behind this connection has been reported gone. */
76
+ lostNotified = false;
65
77
  /**
66
78
  * @param endpoint - the shell's published bridge endpoint.
67
79
  * @param endpointPath - where that endpoint is republished, so a moved port can be followed.
80
+ * @param onLost - called once when this connection concludes the shell behind it is gone.
68
81
  */
69
- constructor(endpoint, endpointPath) {
82
+ constructor(endpoint, endpointPath, onLost) {
70
83
  this.endpoint = endpoint;
71
84
  this.endpointPath = endpointPath;
85
+ this.onLost = onLost;
72
86
  }
73
87
  /**
74
88
  * Re-read the published endpoint, and adopt it if the shell has moved.
@@ -90,6 +104,13 @@ export class BridgeConnection {
90
104
  const raw = JSON.parse(readFileSync(this.endpointPath, 'utf8'));
91
105
  if (typeof raw.port !== 'number' || typeof raw.token !== 'string')
92
106
  return false;
107
+ // A shell that exited stops refreshing the file, and what it left names a port nobody owns.
108
+ // Trusting it is how a host ends up dialling a dead port for the rest of the process's life
109
+ // — reported as "the browser stopped working and only restarting DSH brought it back".
110
+ if (endpointExpired(raw.updatedAt)) {
111
+ this.notifyLost();
112
+ return false;
113
+ }
93
114
  if (raw.port === this.endpoint.port && raw.token === this.endpoint.token)
94
115
  return false;
95
116
  this.endpoint = {
@@ -105,6 +126,18 @@ export class BridgeConnection {
105
126
  return false;
106
127
  }
107
128
  }
129
+ /** Report once that the shell is gone, so the surface can fall back instead of dialling it. */
130
+ notifyLost() {
131
+ if (this.lostNotified)
132
+ return;
133
+ this.lostNotified = true;
134
+ try {
135
+ this.onLost?.();
136
+ }
137
+ catch {
138
+ // A listener must never break the transport: the caller's error is the one that matters.
139
+ }
140
+ }
108
141
  /**
109
142
  * Send one request, reusing the socket if it is still healthy.
110
143
  *
@@ -126,6 +159,14 @@ export class BridgeConnection {
126
159
  // when the condition could not clear, spun until the process ran out of heap.
127
160
  // Follow the shell if its bridge moved to a new port (see refreshEndpoint).
128
161
  this.refreshEndpoint();
162
+ // The shell behind this connection is gone, and the endpoint file says so. Refusing here is
163
+ // what turns a permanent ECONNREFUSED into one named error the surface can act on — by
164
+ // falling back to the browser it hosts itself, instead of dialling a dead port until DSH is
165
+ // restarted.
166
+ if (this.lostNotified) {
167
+ throw new Error('dsh-builtin-browser: the desktop shell that backed this browser has exited '
168
+ + '(its published endpoint stopped being refreshed)');
169
+ }
129
170
  let socket = this.ensureSocket();
130
171
  if (this.serialisable && this.queue.length > 0) {
131
172
  this.reset(new Error('dsh-builtin-browser: bridge connection reopened so replies stay attributable'));
@@ -281,8 +322,22 @@ export class BridgeConnection {
281
322
  entry.reject(error);
282
323
  }
283
324
  }
284
- /** The request id a raw answer line echoes back, when it carries one. */
285
- function echoOf(line) {
325
+ /**
326
+ * Whether a published endpoint has gone un-refreshed long enough to be a tombstone.
327
+ *
328
+ * A missing or unparseable stamp is NOT expired: an older bridge that never wrote one must keep
329
+ * working, and guessing "stale" from absence would break every shell that predates the field.
330
+ * @param updatedAt - the file's own refresh stamp, when it carries one.
331
+ */
332
+ function endpointExpired(updatedAt) {
333
+ if (typeof updatedAt !== 'string')
334
+ return false;
335
+ const at = Date.parse(updatedAt);
336
+ if (!Number.isFinite(at))
337
+ return false;
338
+ return Date.now() - at > ENDPOINT_TTL_MS;
339
+ }
340
+ /** The request id a raw answer line echoes back, when it carries one. */ function echoOf(line) {
286
341
  try {
287
342
  const answer = JSON.parse(line);
288
343
  const value = answer[REQUEST_ID_FIELD];
@@ -94,10 +94,21 @@ export declare class DesktopBridgeViewHost implements ElectronBrowserViewHost {
94
94
  * the desktop sidebar is the carrier — by design, not by omission.
95
95
  */
96
96
  private userActionHandler;
97
+ /** Told when the shell behind this bridge exits; see `onLost`. */
98
+ private lostListener;
97
99
  /**
98
100
  * @param endpoint - the shell's published bridge endpoint.
99
101
  */
100
102
  private constructor();
103
+ /**
104
+ * Called by the transport once the shell behind it is gone.
105
+ *
106
+ * Forwarded rather than acted on here: only the entry point owns the swap back to the self-hosted
107
+ * browser, and a host that tried to replace itself would be reaching outside its own contract.
108
+ */
109
+ private reportLost;
110
+ /** Register the "this carrier is gone" listener (see the interface's `onLost`). */
111
+ onLost(listener: () => void): void;
101
112
  /**
102
113
  * Find a usable desktop bridge, if this surface has one.
103
114
  *
@@ -111,12 +111,32 @@ export class DesktopBridgeViewHost {
111
111
  * the desktop sidebar is the carrier — by design, not by omission.
112
112
  */
113
113
  userActionHandler;
114
+ /** Told when the shell behind this bridge exits; see `onLost`. */
115
+ lostListener;
114
116
  /**
115
117
  * @param endpoint - the shell's published bridge endpoint.
116
118
  */
117
119
  constructor(endpoint, sessionId) {
118
120
  this.owner = sessionId !== undefined && sessionId !== String.fromCharCode(39, 39) ? sessionId : randomUUID();
119
- this.connection = new BridgeConnection(endpoint, bridgeEndpointPath());
121
+ this.connection = new BridgeConnection(endpoint, bridgeEndpointPath(), () => this.reportLost());
122
+ }
123
+ /**
124
+ * Called by the transport once the shell behind it is gone.
125
+ *
126
+ * Forwarded rather than acted on here: only the entry point owns the swap back to the self-hosted
127
+ * browser, and a host that tried to replace itself would be reaching outside its own contract.
128
+ */
129
+ reportLost() {
130
+ try {
131
+ this.lostListener?.();
132
+ }
133
+ catch {
134
+ // The listener's failure is not the transport's problem.
135
+ }
136
+ }
137
+ /** Register the "this carrier is gone" listener (see the interface's `onLost`). */
138
+ onLost(listener) {
139
+ this.lostListener = listener;
120
140
  }
121
141
  /**
122
142
  * Find a usable desktop bridge, if this surface has one.
@@ -42,3 +42,23 @@ export declare const Config: z<Config>;
42
42
  export declare function apply(ctx: Context & {
43
43
  browser: BrowserRuntime;
44
44
  }, config: Config): void;
45
+ /**
46
+ * Move a browser profile left behind by the old layout into the directory the plugin uses now.
47
+ *
48
+ * Before the `?.dsh` component was added here, a desktop launched from a shortcut (no `DSH_HOME`
49
+ * in the environment) put its browser profile in `<homedir>/dsh-builtin-browser-host/` while its
50
+ * settings and history lived in `<homedir>/.dsh/dsh-builtin-browser-host/`. Login state lives
51
+ * INSIDE the browser profile, so leaving the old directory behind silently signs the user out of
52
+ * everything they had signed into — reported after the fix shipped, as a one-time cost of it.
53
+ *
54
+ * That cost does not have to be paid. The move happens only when the new location is absent, so a
55
+ * profile in use is never overwritten, and every failure — a cross-device rename, a permission
56
+ * problem, a profile another process holds — falls through to an empty profile, which is exactly
57
+ * what would have happened without this function. The old directory is then left untouched rather
58
+ * than deleted, because a half-moved profile is worse than a stale one.
59
+ *
60
+ * @param profileRoot - the root the plugin resolves today (`<DSH_HOME>/dsh-builtin-browser-host`).
61
+ * @param legacyRoot - the pre-`DSH_HOME` layout's root. Injected so the move can be exercised
62
+ * without touching the real home directory.
63
+ */
64
+ export declare function adoptLegacyProfileRoot(profileRoot: string, legacyRoot?: string): void;