forked from
lass.pds.lassul.us/superconfig
This repository has no description
16 kB
526 lines
1/* Workspace Tabs - Firefox extension for workspace-manager integration
2 *
3 * - Saves tabs to ~/workspaces/<name>.json via native host on every tab change
4 * - Restores tabs from workspace file when switching to a workspace
5 * - Maintains a hidden anchor window to keep Firefox alive
6 * - Provides "Send to workspace" context menu
7 */
8
9const HOST_NAME = "workspace_tabs";
10const ANCHOR_TITLE = "workspace-anchor";
11
12// Only real web pages belong in a workspace file. Internal pages — most
13// importantly the anchor window's moz-extension:// page, but also about:* and
14// blank tabs — must never be persisted or restored, otherwise a transient
15// startup window gets saved and later reappears as a phantom browser window.
16function isRestorableUrl(url) {
17 return !!url && (url.startsWith("http://") || url.startsWith("https://"));
18}
19
20let port = null;
21let pendingRequests = new Map();
22let requestId = 0;
23let lastWorkspace = null;
24let anchorWindowId = null;
25let restoringWorkspaces = new Set();
26let pendingNewWorkspaceTabId = null;
27let savePending = false;
28let initialized = false;
29
30// ── Native messaging ────────────────────────────────────────
31
32function connectNative() {
33 try {
34 port = browser.runtime.connectNative(HOST_NAME);
35 port.onMessage.addListener(handleNativeMessage);
36 port.onDisconnect.addListener(() => {
37 console.warn("Native host disconnected, reconnecting in 2s...");
38 port = null;
39 setTimeout(connectNative, 2000);
40 });
41 } catch (e) {
42 console.error("Failed to connect to native host:", e);
43 setTimeout(connectNative, 5000);
44 }
45}
46
47function sendNative(cmd, params = {}) {
48 return new Promise((resolve, reject) => {
49 if (!port) {
50 reject(new Error("Native host not connected"));
51 return;
52 }
53 const id = ++requestId;
54 pendingRequests.set(id, { resolve, reject });
55 port.postMessage({ id, cmd, ...params });
56 setTimeout(() => {
57 if (pendingRequests.has(id)) {
58 pendingRequests.delete(id);
59 reject(new Error("Request timed out"));
60 }
61 }, 5000);
62 });
63}
64
65function handleNativeMessage(msg) {
66 if (msg.event === "workspace_change") {
67 onWorkspaceChange(msg.workspace);
68 return;
69 }
70 if (msg.id && pendingRequests.has(msg.id)) {
71 const { resolve } = pendingRequests.get(msg.id);
72 pendingRequests.delete(msg.id);
73 resolve(msg);
74 }
75}
76
77// ── Workspace queries ───────────────────────────────────────
78
79async function getCurrentWorkspace() {
80 try {
81 const resp = await sendNative("current_workspace");
82 return resp.workspace || null;
83 } catch (e) {
84 return null;
85 }
86}
87
88async function listWorkspaces() {
89 try {
90 const resp = await sendNative("list_workspaces");
91 return resp.workspaces || [];
92 } catch (e) {
93 return [];
94 }
95}
96
97// ── Firefox window <-> workspace mapping ────────────────────
98
99// Throws on query failure so callers can distinguish "no Firefox windows"
100// from "we don't know" — a silent empty result here under load caused
101// duplicate window restoration.
102//
103// A workspace holding more than one Firefox window is ambiguous and is left
104// out of the map. This happens after every Firefox restart: session restore
105// reopens all windows on the focused workspace, and mapping any one of them to
106// that workspace would overwrite its file with another workspace's tabs.
107async function getFirefoxWindowMappings() {
108 const resp = await sendNative("map_windows");
109 if (resp.error) throw new Error(resp.error);
110 if (!resp.windows) throw new Error("map_windows: no windows field");
111
112 const perWorkspace = new Map();
113 for (const mapping of resp.windows) {
114 perWorkspace.set(
115 mapping.workspace,
116 (perWorkspace.get(mapping.workspace) || 0) + 1,
117 );
118 }
119
120 const allWindows = await browser.windows.getAll({ populate: true });
121 const result = new Map();
122 const matched = new Set();
123
124 for (const mapping of resp.windows) {
125 if (perWorkspace.get(mapping.workspace) > 1) {
126 console.warn(
127 `Workspace ${mapping.workspace} has several Firefox windows — not mapping it`,
128 );
129 continue;
130 }
131 const swayTitle = mapping.windowTitle || "";
132 for (const win of allWindows) {
133 if (matched.has(win.id) || win.id === anchorWindowId) continue;
134 const activeTab = win.tabs && win.tabs.find((t) => t.active);
135 if (
136 activeTab &&
137 activeTab.title &&
138 swayTitle.startsWith(activeTab.title)
139 ) {
140 result.set(mapping.workspace, win.id);
141 matched.add(win.id);
142 break;
143 }
144 }
145 }
146 return result;
147}
148
149async function getWorkspacesWithFirefox() {
150 const resp = await sendNative("map_windows");
151 if (resp.error) throw new Error(resp.error);
152 if (!resp.windows) throw new Error("map_windows: no windows field");
153 return new Set(resp.windows.map((w) => w.workspace));
154}
155
156// ── Anchor window (keeps Firefox alive) ─────────────────────
157
158async function ensureAnchorWindow() {
159 if (anchorWindowId !== null) {
160 try {
161 await browser.windows.get(anchorWindowId);
162 return; // still exists
163 } catch (e) {
164 anchorWindowId = null;
165 }
166 }
167
168 // Close any existing windows (Firefox's default startup window)
169 const existingWindows = await browser.windows.getAll();
170
171 // Create anchor window with a page that sets the window title
172 const win = await browser.windows.create({
173 url: browser.runtime.getURL("anchor.html"),
174 });
175 anchorWindowId = win.id;
176
177 // Close Firefox's default window(s)
178 for (const w of existingWindows) {
179 if (w.id !== anchorWindowId) {
180 try {
181 await browser.windows.remove(w.id);
182 } catch (e) {
183 // already closed
184 }
185 }
186 }
187 // Daemon's for_window rule hides anchor when title updates to "workspace-anchor"
188}
189
190// Prevent anchor window from being closed
191browser.windows.onRemoved.addListener((windowId) => {
192 if (windowId === anchorWindowId) {
193 anchorWindowId = null;
194 ensureAnchorWindow();
195 }
196});
197
198// ── Tab saving (to workspace file) ─────────────────────────
199
200async function saveTabsForWorkspace(workspace, mappings) {
201 if (!workspace) return;
202
203 const windowId = mappings.get(workspace);
204 if (!windowId) return;
205
206 try {
207 const tabs = await browser.tabs.query({ windowId });
208 const tabData = tabs
209 .filter((t) => isRestorableUrl(t.url))
210 .map((t) => ({
211 url: t.url,
212 pinned: t.pinned,
213 active: t.active,
214 }));
215
216 // Never overwrite a saved workspace with an empty list. A mapped window
217 // showing only internal pages (e.g. the transient blank window right after
218 // a Firefox restart) would otherwise wipe that workspace's real tabs.
219 if (tabData.length === 0) return;
220
221 await sendNative("save_tabs", { workspace, tabs: tabData });
222 } catch (e) {
223 console.error(`Failed to save tabs for ${workspace}:`, e);
224 }
225}
226
227async function saveAllTabs() {
228 let mappings;
229 try {
230 mappings = await getFirefoxWindowMappings();
231 } catch (e) {
232 console.warn("Skipping save — could not query window mappings:", e);
233 return;
234 }
235 for (const [workspace] of mappings) {
236 await saveTabsForWorkspace(workspace, mappings);
237 }
238}
239
240// Debounced save: collects rapid changes, saves after 500ms idle
241function scheduleSave() {
242 if (savePending) return;
243 savePending = true;
244 setTimeout(async () => {
245 savePending = false;
246 await saveAllTabs();
247 }, 500);
248}
249
250// Listen to all tab events that should trigger a save
251browser.tabs.onCreated.addListener(scheduleSave);
252browser.tabs.onRemoved.addListener(scheduleSave);
253browser.tabs.onUpdated.addListener((_tabId, changeInfo) => {
254 // Save on URL changes and title changes (navigation complete)
255 if (changeInfo.url || changeInfo.title) {
256 scheduleSave();
257 }
258});
259browser.tabs.onMoved.addListener(scheduleSave);
260browser.tabs.onActivated.addListener(scheduleSave);
261browser.tabs.onAttached.addListener(scheduleSave);
262browser.tabs.onDetached.addListener(scheduleSave);
263
264// ── Tab restoring (from workspace file) ─────────────────────
265
266async function restoreWorkspace(workspace) {
267 if (restoringWorkspaces.has(workspace)) return;
268 restoringWorkspaces.add(workspace);
269
270 try {
271 const resp = await sendNative("get_tabs", { workspace });
272 // Drop any non-web entries (e.g. a previously-saved anchor page) so stale
273 // bad data in existing workspace files can't resurrect a phantom window.
274 const tabs = (resp.tabs || []).filter((t) => isRestorableUrl(t.url));
275 if (tabs.length === 0) return;
276
277 // Create window with the first tab to avoid a blank "new tab"
278 const first = tabs[0];
279 const newWindow = await browser.windows.create({ url: first.url });
280 const windowId = newWindow.id;
281
282 // Pin the first tab if needed
283 if (first.pinned && newWindow.tabs && newWindow.tabs[0]) {
284 await browser.tabs.update(newWindow.tabs[0].id, { pinned: true });
285 }
286
287 // Create remaining tabs
288 for (let i = 1; i < tabs.length; i++) {
289 await browser.tabs.create({
290 windowId,
291 url: tabs[i].url,
292 pinned: tabs[i].pinned,
293 active: tabs[i].active,
294 });
295 }
296 } catch (e) {
297 console.error(`Failed to restore workspace ${workspace}:`, e);
298 } finally {
299 restoringWorkspaces.delete(workspace);
300 }
301}
302
303// ── Workspace change handling ────────────────────────────────
304
305async function onWorkspaceChange(workspace) {
306 if (!workspace || workspace === lastWorkspace) return;
307
308 // Save all workspaces' tabs before switching
309 await saveAllTabs();
310
311 lastWorkspace = workspace;
312
313 // Don't restore tabs during startup — only after user switches workspace
314 if (!initialized) return;
315
316 // Check if this workspace already has a Firefox window. If we can't tell
317 // (e.g. swaymsg timed out under load), skip — restoring blindly would
318 // duplicate an existing window.
319 let withFirefox;
320 try {
321 withFirefox = await getWorkspacesWithFirefox();
322 } catch (e) {
323 console.warn(`Skipping restore for ${workspace} — query failed:`, e);
324 return;
325 }
326 if (withFirefox.has(workspace)) return;
327
328 // No Firefox window — restore saved tabs
329 const resp = await sendNative("get_tabs", { workspace });
330 const tabs = resp.tabs || [];
331 if (tabs.length > 0) {
332 await restoreWorkspace(workspace);
333 }
334}
335
336// ── Send tab to workspace ───────────────────────────────────
337
338async function sendTabToWorkspace(tabId, targetWorkspace) {
339 try {
340 const mappings = await getFirefoxWindowMappings();
341 const targetWindowId = mappings.get(targetWorkspace);
342
343 if (targetWindowId) {
344 await browser.tabs.move(tabId, { windowId: targetWindowId, index: -1 });
345 } else {
346 const sourceWorkspace = await getCurrentWorkspace();
347 const newWindow = await browser.windows.create({ tabId });
348 await sendNative("move_to_workspace", { name: targetWorkspace });
349 if (sourceWorkspace) {
350 await sendNative("goto_workspace", { name: sourceWorkspace });
351 }
352 }
353 // Save both workspaces after move
354 scheduleSave();
355 } catch (e) {
356 console.error(`Failed to send tab to workspace ${targetWorkspace}:`, e);
357 }
358}
359
360// Handle messages from the new-workspace popup
361browser.runtime.onMessage.addListener((msg) => {
362 if (
363 msg.action === "sendToNewWorkspace" &&
364 msg.workspace &&
365 pendingNewWorkspaceTabId
366 ) {
367 const tabId = pendingNewWorkspaceTabId;
368 pendingNewWorkspaceTabId = null;
369 sendTabToWorkspace(tabId, msg.workspace);
370 }
371});
372
373// ── Context menu ────────────────────────────────────────────
374
375async function rebuildContextMenu() {
376 await browser.menus.removeAll();
377
378 browser.menus.create({
379 id: "send-to-workspace",
380 title: "Send to workspace",
381 contexts: ["tab"],
382 });
383
384 const allWorkspaces = await listWorkspaces();
385 const withFirefox = await getWorkspacesWithFirefox();
386 const wsWithFirefox = [];
387 const wsWithoutFirefox = [];
388
389 for (const ws of allWorkspaces) {
390 if (withFirefox.has(ws)) {
391 wsWithFirefox.push(ws);
392 } else {
393 wsWithoutFirefox.push(ws);
394 }
395 }
396
397 for (const ws of wsWithFirefox) {
398 browser.menus.create({
399 id: `ws-${ws}`,
400 parentId: "send-to-workspace",
401 title: `● ${ws}`,
402 contexts: ["tab"],
403 });
404 }
405
406 if (wsWithFirefox.length > 0 && wsWithoutFirefox.length > 0) {
407 browser.menus.create({
408 id: "ws-separator",
409 parentId: "send-to-workspace",
410 type: "separator",
411 contexts: ["tab"],
412 });
413 }
414
415 for (const ws of wsWithoutFirefox) {
416 browser.menus.create({
417 id: `ws-${ws}`,
418 parentId: "send-to-workspace",
419 title: `○ ${ws}`,
420 contexts: ["tab"],
421 });
422 }
423
424 browser.menus.create({
425 id: "ws-separator-new",
426 parentId: "send-to-workspace",
427 type: "separator",
428 contexts: ["tab"],
429 });
430
431 browser.menus.create({
432 id: "ws-new",
433 parentId: "send-to-workspace",
434 title: "New workspace…",
435 contexts: ["tab"],
436 });
437}
438
439browser.menus.onShown.addListener(async (info) => {
440 if (info.contexts.includes("tab")) {
441 await rebuildContextMenu();
442 browser.menus.refresh();
443 }
444});
445
446browser.menus.onClicked.addListener(async (info, tab) => {
447 if (!tab || !tab.id) return;
448
449 if (info.menuItemId === "ws-new") {
450 pendingNewWorkspaceTabId = tab.id;
451 browser.windows.create({
452 url: browser.runtime.getURL("new-workspace.html"),
453 type: "popup",
454 width: 320,
455 height: 60,
456 });
457 return;
458 }
459
460 if (info.menuItemId.startsWith("ws-")) {
461 const workspace = info.menuItemId.slice(3);
462 await sendTabToWorkspace(tab.id, workspace);
463 }
464});
465
466// ── Init ────────────────────────────────────────────────────
467
468async function init() {
469 // Connect to native host first — we need it to save existing tabs
470 connectNative();
471 await new Promise((r) => setTimeout(r, 500));
472
473 try {
474 lastWorkspace = await getCurrentWorkspace();
475 } catch (e) {
476 console.warn("Failed to get current workspace:", e);
477 }
478
479 const hadExistingWindows = (await browser.windows.getAll()).length > 0;
480
481 // Save all existing tabs before creating the anchor window
482 // (ensureAnchorWindow closes all windows, so save first)
483 if (hadExistingWindows) {
484 try {
485 await saveAllTabs();
486 } catch (e) {
487 console.error("Failed to save existing tabs before anchor creation:", e);
488 }
489 }
490
491 // Create anchor window — closes existing windows via the for_window rule
492 try {
493 await ensureAnchorWindow();
494 } catch (e) {
495 console.error("Failed to create anchor window:", e);
496 }
497
498 try {
499 await rebuildContextMenu();
500 } catch (e) {
501 console.warn("Failed to build context menu:", e);
502 }
503
504 // Mark init complete — workspace changes from now on will restore tabs
505 initialized = true;
506
507 // Restore tabs for the initial workspace if it has saved tabs from a previous session
508 if (lastWorkspace) {
509 let withFirefox;
510 try {
511 withFirefox = await getWorkspacesWithFirefox();
512 } catch (e) {
513 console.warn("Skipping initial restore — query failed:", e);
514 return;
515 }
516 if (!withFirefox.has(lastWorkspace)) {
517 const resp = await sendNative("get_tabs", { workspace: lastWorkspace });
518 const tabs = resp.tabs || [];
519 if (tabs.length > 0) {
520 await restoreWorkspace(lastWorkspace);
521 }
522 }
523 }
524}
525
526init();