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