- 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
+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` 秒周期继续同步
+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
+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();
+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"