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