This repository has no description
0

Configure Feed

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

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