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