baa-conductor

git clone 

commit
25be868
parent
782e4c7
author
im_wower
date
2026-03-27 01:15:58 +0800 CST
fix: restore managed firefox shell tabs on startup
4 files changed,  +144, -24
M docs/firefox/README.md
+2, -0
1@@ -169,6 +169,8 @@
2 
3 - Firefox 启动时,`background.js` 会确保 `controller.html` 存在
4 - `controller.html` 启动后立刻连接 `ws://100.71.210.78:4317/ws/firefox`
5+- `controller.html` 启动、刷新或扩展重载后,会自动恢复之前明确启用过、但当前缺失的 shell tab
6+- 如果用户手工打开 Claude `new`、ChatGPT 根页或 Gemini `app` 这类平台根页,插件也会把它们纳入受管理 shell 集合
7 - WS 断开后按固定间隔自动重连
8 - `controller.html` 启动后也会立刻请求 `GET /v1/system/state`
9 - HTTP 成功后按 `15` 秒周期继续同步
M plugins/baa-firefox/README.md
+2, -0
1@@ -47,6 +47,8 @@ Firefox 插件的正式能力已经收口到三件事:
2 
3 - `tab_open` / `tab_focus` / `tab_reload` 会把对应平台写进 `desired`
4 - `tab_restore` 只会恢复 `desired=true` 但 `actual` 缺失的平台
5+- 插件管理页启动、浏览器重开或扩展重载后,会自动对之前明确启用过、但当前缺失的 shell tab 做一次后台恢复
6+- 如果手工打开 Claude `new`、ChatGPT 根页或 Gemini `app` 这类平台根页,插件也会把它们收进受管理 shell 集合
7 - tab 生命周期事件和 `30s` 周期巡检都会刷新 `actual`
8 - 管理页的“空壳页”和“平台状态”面板会直接显示 `desired / actual / drift`
9 
M plugins/baa-firefox/controller.js
+130, -22
  1@@ -221,13 +221,33 @@ function getPlatformShellUrl(platform) {
  2   return PLATFORMS[platform]?.shellUrl || PLATFORMS[platform]?.rootUrl || "";
  3 }
  4 
  5-function isPlatformShellUrl(platform, url) {
  6+function matchesPlatformFallbackShellUrl(platform, parsed) {
  7+  const pathname = trimTrailingSlash(parsed?.pathname || "") || "/";
  8+
  9+  switch (platform) {
 10+    case "claude":
 11+      return pathname === "/new";
 12+    case "chatgpt":
 13+      return pathname === "/";
 14+    case "gemini":
 15+      return pathname === "/app";
 16+    default:
 17+      return false;
 18+  }
 19+}
 20+
 21+function isPlatformShellUrl(platform, url, options = {}) {
 22   if (!platform || !PLATFORMS[platform] || !url) return false;
 23+  const allowFallback = options.allowFallback === true;
 24 
 25   try {
 26     const parsed = new URL(url, PLATFORMS[platform].rootUrl);
 27     const expected = new URL(getPlatformShellUrl(platform));
 28-    return parsed.origin === expected.origin && parsed.pathname === expected.pathname && parsed.hash === expected.hash;
 29+    if (parsed.origin === expected.origin && parsed.pathname === expected.pathname && parsed.hash === expected.hash) {
 30+      return true;
 31+    }
 32+
 33+    return allowFallback && parsed.origin === expected.origin && matchesPlatformFallbackShellUrl(platform, parsed);
 34   } catch (_) {
 35     return false;
 36   }
 37@@ -700,9 +720,10 @@ function updateClaudeState(patch = {}, options = {}) {
 38 }
 39 
 40 async function refreshClaudeTabState(createIfMissing = false) {
 41+  const allowFallbackShell = cloneDesiredTabState("claude", state.desiredTabs.claude).exists;
 42   const tab = createIfMissing
 43     ? await ensurePlatformTab("claude", { focus: false })
 44-    : (await resolveTrackedTab("claude", { requireShell: true })) || await findPlatformShellTab("claude");
 45+    : (await resolveTrackedTab("claude", { requireShell: true, allowFallbackShell })) || await findPlatformShellTab("claude", null, { allowFallbackShell });
 46 
 47   if (!tab) {
 48     updateClaudeState({
 49@@ -720,7 +741,7 @@ async function refreshClaudeTabState(createIfMissing = false) {
 50     tabId: Number.isInteger(tab.id) ? tab.id : null,
 51     currentUrl: tab.url || state.claudeState.currentUrl || getPlatformShellUrl("claude"),
 52     tabTitle: trimToNull(tab.title),
 53-    conversationId: isPlatformShellUrl("claude", tab.url || "")
 54+    conversationId: isPlatformShellUrl("claude", tab.url || "", { allowFallback: allowFallbackShell })
 55       ? null
 56       : (extractClaudeConversationIdFromPageUrl(tab.url || "") || state.claudeState.conversationId),
 57     title: state.claudeState.title || trimToNull(tab.title),
 58@@ -1462,6 +1483,24 @@ function setDesiredTabState(platform, exists, options = {}) {
 59   return true;
 60 }
 61 
 62+function shouldAutoRestoreDesiredTab(platform) {
 63+  const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
 64+  const actual = cloneActualTabState(state.actualTabs[platform]);
 65+  return desired.exists && desired.source !== "migration" && !actual.exists;
 66+}
 67+
 68+function shouldAdoptPlatformTabAsDesired(platform, url) {
 69+  return isPlatformShellUrl(platform, url, { allowFallback: true });
 70+}
 71+
 72+function getManagedSummaryPlatforms() {
 73+  return PLATFORM_ORDER.filter((platform) => {
 74+    const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
 75+    const actual = cloneActualTabState(state.actualTabs[platform]);
 76+    return desired.exists || actual.exists;
 77+  });
 78+}
 79+
 80 function setControllerRuntimeState(patch = {}, options = {}) {
 81   state.controllerRuntime = cloneControllerRuntimeState({
 82     ...state.controllerRuntime,
 83@@ -1481,6 +1520,7 @@ function setControllerRuntimeState(patch = {}, options = {}) {
 84 function buildActualTabSnapshot(platform, shellTab = null, candidateTab = null, previousSnapshot = null) {
 85   const now = Date.now();
 86   const previous = cloneActualTabState(previousSnapshot || state.actualTabs[platform]);
 87+  const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
 88 
 89   if (shellTab && Number.isInteger(shellTab.id)) {
 90     const isReady = String(shellTab.status || "").toLowerCase() === "complete";
 91@@ -1495,7 +1535,7 @@ function buildActualTabSnapshot(platform, shellTab = null, candidateTab = null,
 92       status: trimToNull(shellTab.status),
 93       discarded: shellTab.discarded === true,
 94       hidden: shellTab.hidden === true,
 95-      healthy: isPlatformShellUrl(platform, url || "") && !shellTab.discarded,
 96+      healthy: isPlatformShellUrl(platform, url || "", { allowFallback: allowFallbackShell }) && !shellTab.discarded,
 97       issue: isReady ? null : "loading",
 98       candidateTabId: null,
 99       candidateUrl: null,
100@@ -1626,6 +1666,10 @@ function getRuntimeDriftCount() {
101   return PLATFORM_ORDER.filter((platform) => !buildPlatformRuntimeDrift(platform).aligned).length;
102 }
103 
104+function getPlatformsNeedingShellRestore() {
105+  return PLATFORM_ORDER.filter((platform) => shouldAutoRestoreDesiredTab(platform));
106+}
107+
108 function buildPluginStatusPayload(options = {}) {
109   const includeVolatile = options.includeVolatile !== false;
110   const platforms = {};
111@@ -2503,11 +2547,14 @@ function getTrackedCount() {
112 }
113 
114 function getAccountCount() {
115-  return PLATFORM_ORDER.filter((platform) => !!trimToNull(state.account[platform]?.value)).length;
116+  const managed = new Set(getManagedSummaryPlatforms());
117+  return PLATFORM_ORDER.filter((platform) => managed.has(platform) && !!trimToNull(state.account[platform]?.value)).length;
118 }
119 
120 function getCredentialCount() {
121+  const managed = new Set(getManagedSummaryPlatforms());
122   return PLATFORM_ORDER.filter((platform) => {
123+    if (!managed.has(platform)) return false;
124     const credential = getCredentialState(platform);
125     return credential.valid || !!trimToNull(state.credentialFingerprint[platform]);
126   }).length;
127@@ -2538,8 +2585,10 @@ function formatTrackedMeta() {
128 }
129 
130 function formatAccountMeta() {
131+  const managed = new Set(getManagedSummaryPlatforms());
132   const labels = PLATFORM_ORDER
133     .map((platform) => {
134+      if (!managed.has(platform)) return null;
135       const value = trimToNull(state.account[platform]?.value);
136       return value ? `${platformLabel(platform)}(${value})` : null;
137     })
138@@ -2548,8 +2597,10 @@ function formatAccountMeta() {
139 }
140 
141 function formatCredentialMeta() {
142+  const managed = new Set(getManagedSummaryPlatforms());
143   const labels = PLATFORM_ORDER
144     .map((platform) => {
145+      if (!managed.has(platform)) return null;
146       const credential = getCredentialState(platform);
147       const fingerprint = trimToNull(state.credentialFingerprint[platform]);
148       if (!credential.valid && !fingerprint) return null;
149@@ -3014,6 +3065,41 @@ async function prepareStartupControlState() {
150   }));
151 }
152 
153+async function collapseRecoveredDesiredTabs() {
154+  let changed = false;
155+
156+  for (const platform of PLATFORM_ORDER) {
157+    const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
158+    const actual = cloneActualTabState(state.actualTabs[platform]);
159+    if (desired.source !== "migration" || actual.exists) {
160+      continue;
161+    }
162+
163+    changed = setDesiredTabState(platform, false, {
164+      source: "bootstrap",
165+      reason: "startup_migration_cleared"
166+    }) || changed;
167+  }
168+
169+  if (changed) {
170+    await persistState();
171+    render();
172+  }
173+}
174+
175+async function restoreDesiredTabsOnStartup() {
176+  const targets = getPlatformsNeedingShellRestore();
177+  if (targets.length === 0) {
178+    return null;
179+  }
180+
181+  addLog("info", `启动时自动恢复空壳页:${targets.map((platform) => platformLabel(platform)).join("、")}`, false);
182+  return await runPluginManagementAction("tab_restore", {
183+    source: "startup",
184+    reason: "startup_auto_restore"
185+  });
186+}
187+
188 function wsSend(payload) {
189   if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
190   state.ws.send(JSON.stringify(payload));
191@@ -4045,13 +4131,13 @@ function scheduleReconnect(reason = null) {
192 }
193 
194 async function resolveTrackedTab(platform, options = {}) {
195-  const { requireShell = false } = options;
196+  const { requireShell = false, allowFallbackShell = false } = options;
197   const tabId = state.trackedTabs[platform];
198   if (!Number.isInteger(tabId)) return null;
199   try {
200     const tab = await browser.tabs.get(tabId);
201     if (!tab || !tab.url || !isPlatformUrl(platform, tab.url)) return null;
202-    if (requireShell && !isPlatformShellUrl(platform, tab.url)) return null;
203+    if (requireShell && !isPlatformShellUrl(platform, tab.url, { allowFallback: allowFallbackShell })) return null;
204     return tab;
205   } catch (_) {
206     return null;
207@@ -4070,9 +4156,10 @@ async function findPlatformTab(platform) {
208   return tabs[0];
209 }
210 
211-async function findPlatformShellTab(platform, preferredTabId = null) {
212+async function findPlatformShellTab(platform, preferredTabId = null, options = {}) {
213+  const allowFallbackShell = options.allowFallbackShell === true;
214   const tabs = await browser.tabs.query({ url: PLATFORMS[platform].urlPatterns });
215-  const shellTabs = tabs.filter((tab) => isPlatformShellUrl(platform, tab.url || ""));
216+  const shellTabs = tabs.filter((tab) => isPlatformShellUrl(platform, tab.url || "", { allowFallback: allowFallbackShell }));
217   if (shellTabs.length === 0) return null;
218 
219   shellTabs.sort((left, right) => {
220@@ -4105,11 +4192,12 @@ async function setTrackedTab(platform, tab) {
221   );
222   pruneInvalidCredentialState();
223   if (platform === "claude") {
224+    const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
225     updateClaudeState({
226       tabId: tab?.id ?? null,
227       currentUrl: tab?.url || getPlatformShellUrl(platform),
228       tabTitle: trimToNull(tab?.title),
229-      conversationId: isPlatformShellUrl(platform, tab?.url || "")
230+      conversationId: isPlatformShellUrl(platform, tab?.url || "", { allowFallback: allowFallbackShell })
231         ? null
232         : (extractClaudeConversationIdFromPageUrl(tab?.url || "") || state.claudeState.conversationId)
233     }, {
234@@ -4137,7 +4225,7 @@ async function ensurePlatformTab(platform, options = {}) {
235 
236   let tab = await resolveTrackedTab(platform);
237   if (!tab) {
238-    tab = await findPlatformShellTab(platform);
239+    tab = await findPlatformShellTab(platform, null, { allowFallbackShell: true });
240   }
241 
242   const shellUrl = getPlatformShellUrl(platform);
243@@ -4150,7 +4238,7 @@ async function ensurePlatformTab(platform, options = {}) {
244       active: focus
245     });
246     addLog("info", `已打开 ${platformLabel(platform)} 空壳页 ${tab.id}`);
247-  } else if (!isPlatformShellUrl(platform, tab.url || "")) {
248+  } else if (!isPlatformShellUrl(platform, tab.url || "", { allowFallback: true })) {
249     tab = await browser.tabs.update(tab.id, {
250       url: shellUrl,
251       active: focus
252@@ -4158,13 +4246,13 @@ async function ensurePlatformTab(platform, options = {}) {
253     updatedToShell = true;
254     addLog("info", `已将 ${platformLabel(platform)} 标签页 ${tab.id} 收口为空壳页`);
255   } else if (focus) {
256-    tab = await findPlatformShellTab(platform, tab.id) || tab;
257+    tab = await findPlatformShellTab(platform, tab.id, { allowFallbackShell: true }) || tab;
258     await browser.tabs.update(tab.id, { active: true });
259     if (tab.windowId != null) {
260       await browser.windows.update(tab.windowId, { focused: true });
261     }
262   } else {
263-    tab = await findPlatformShellTab(platform, tab.id) || tab;
264+    tab = await findPlatformShellTab(platform, tab.id, { allowFallbackShell: true }) || tab;
265   }
266 
267   await setTrackedTab(platform, tab);
268@@ -4204,12 +4292,13 @@ async function refreshTrackedTabsFromBrowser(reason = "sync") {
269       const nextActual = createPlatformMap(() => createDefaultActualTabState());
270       for (const platform of PLATFORM_ORDER) {
271         const trackedCandidate = await resolveTrackedTab(platform);
272-        let shellTab = trackedCandidate && isPlatformShellUrl(platform, trackedCandidate.url || "")
273+        const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
274+        let shellTab = trackedCandidate && isPlatformShellUrl(platform, trackedCandidate.url || "", { allowFallback: allowFallbackShell })
275           ? trackedCandidate
276-          : await resolveTrackedTab(platform, { requireShell: true });
277+          : await resolveTrackedTab(platform, { requireShell: true, allowFallbackShell });
278 
279         if (!shellTab) {
280-          shellTab = await findPlatformShellTab(platform);
281+          shellTab = await findPlatformShellTab(platform, null, { allowFallbackShell });
282         }
283 
284         let candidateTab = null;
285@@ -4311,12 +4400,25 @@ function buildNetworkEntry(platform, data, tabId) {
286 
287 function ensureTrackedTabId(platform, tabId, source, senderUrl = "") {
288   if (!Number.isInteger(tabId) || tabId < 0) return false;
289+  const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
290+  const adoptableShell = shouldAdoptPlatformTabAsDesired(platform, senderUrl);
291+  const allowFallbackShell = desired.exists || adoptableShell;
292+
293+  if (!desired.exists && adoptableShell) {
294+    setDesiredTabState(platform, true, {
295+      source: source || "page",
296+      reason: "page_shell_detected",
297+      action: "tab_adopt",
298+      persist: true,
299+      render: true
300+    });
301+  }
302 
303   const current = state.trackedTabs[platform];
304   if (current === tabId) {
305-    return senderUrl ? isPlatformShellUrl(platform, senderUrl) : true;
306+    return senderUrl ? isPlatformShellUrl(platform, senderUrl, { allowFallback: allowFallbackShell }) : true;
307   }
308-  if (!isPlatformShellUrl(platform, senderUrl)) return false;
309+  if (!isPlatformShellUrl(platform, senderUrl, { allowFallback: allowFallbackShell })) return false;
310 
311   state.trackedTabs[platform] = tabId;
312   pruneInvalidCredentialState();
313@@ -5149,14 +5251,18 @@ function registerTabListeners() {
314     const urlPlatform = detectPlatformFromUrl(changeInfo.url || tab?.url || "");
315     if (!platform && !urlPlatform && changeInfo.status !== "complete") return;
316 
317-    if (platform && changeInfo.url && !isPlatformShellUrl(platform, changeInfo.url)) {
318+    const allowFallbackShell = platform
319+      ? cloneDesiredTabState(platform, state.desiredTabs[platform]).exists
320+      : false;
321+
322+    if (platform && changeInfo.url && !isPlatformShellUrl(platform, changeInfo.url, { allowFallback: allowFallbackShell })) {
323       state.trackedTabs[platform] = null;
324       persistState().catch(() => {});
325       render();
326       addLog("warn", `${platformLabel(platform)} 空壳页 ${tabId} 已偏离壳页 URL,等待重新收口`);
327     }
328 
329-    if (platform && changeInfo.status === "complete" && tab?.url && isPlatformShellUrl(platform, tab.url)) {
330+    if (platform && changeInfo.status === "complete" && tab?.url && isPlatformShellUrl(platform, tab.url, { allowFallback: allowFallbackShell })) {
331       addLog("info", `${platformLabel(platform)} shell ready ${tabId}`);
332     }
333 
334@@ -5278,6 +5384,8 @@ async function init() {
335   });
336   await browser.runtime.sendMessage({ type: "controller_ready", tabId: current.id });
337   await refreshTrackedTabsFromBrowser("startup");
338+  await collapseRecoveredDesiredTabs();
339+  await restoreDesiredTabsOnStartup();
340   await refreshClaudeTabState(false);
341   await persistState();
342   render();
M scripts/runtime/verify-mini.sh
+10, -2
 1@@ -111,9 +111,17 @@ while [[ $# -gt 0 ]]; do
 2 done
 3 
 4 runtime_log "mini verify: static launchd checks"
 5-"${SCRIPT_DIR}/check-launchd.sh" --node mini "${launchd_args[@]}"
 6+launchd_cmd=("${SCRIPT_DIR}/check-launchd.sh" --node mini)
 7+if ((${#launchd_args[@]} > 0)); then
 8+  launchd_cmd+=("${launchd_args[@]}")
 9+fi
10+"${launchd_cmd[@]}"
11 
12 runtime_log "mini verify: runtime checks"
13-"${SCRIPT_DIR}/check-node.sh" --node mini --skip-static-check "${node_args[@]}"
14+node_cmd=("${SCRIPT_DIR}/check-node.sh" --node mini --skip-static-check)
15+if ((${#node_args[@]} > 0)); then
16+  node_cmd+=("${node_args[@]}")
17+fi
18+"${node_cmd[@]}"
19 
20 runtime_log "mini verify passed"