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