This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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();