├── manifest.json └── background.js /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "External Links → Sink Window", 4 | "version": "0.1.3", 5 | "permissions": ["tabs", "windows", "storage"], 6 | "background": { "scripts": ["background.js"] }, 7 | "action": { "default_title": "Set current window as sink" }, 8 | "browser_specific_settings": { "gecko": { "id": "sink-window@sitalo.org" } } 9 | } -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | // ---- Tunables -------------------------------------------------------------- 2 | const PREGAIN_MS = 2500; // created up to this long BEFORE focus gain → external 3 | const POSTGAIN_MS = 1500; // created up to this long AFTER focus gain → external 4 | const GC_MS = 8000; // drop stale candidates after this long 5 | 6 | // ---- Debug ----------------------------------------------------------------- 7 | let DEBUG = false; 8 | const t0 = Date.now(); 9 | const now = () => Date.now() - t0; 10 | const dbg = (m, ...a) => { if (DEBUG) console.log(`[sink][+${now()}ms] ${m}`, ...a); }; 11 | 12 | // ---- State ----------------------------------------------------------------- 13 | let sinkWindowId = null; 14 | let lastLostFocusAt = 0; 15 | let lastGainedFocusAt = 0; 16 | 17 | // Track recent tabs so we can reclassify when focus changes 18 | // Map 19 | const recent = new Map(); 20 | 21 | // ---- Bootstrap ------------------------------------------------------------- 22 | async function loadSink() { 23 | const { sink, debug } = await browser.storage.local.get(["sink", "debug"]); 24 | sinkWindowId = sink ?? null; 25 | if (typeof debug === "boolean") DEBUG = debug; 26 | dbg("startup: sink=%o debug=%o", sinkWindowId, DEBUG); 27 | } 28 | loadSink(); 29 | 30 | browser.runtime.onInstalled.addListener(() => dbg("onInstalled")); 31 | browser.runtime.onStartup.addListener(() => dbg("onStartup")); 32 | 33 | browser.action.onClicked.addListener(async () => { 34 | const win = await browser.windows.getCurrent(); 35 | sinkWindowId = win.id; 36 | await browser.storage.local.set({ sink: sinkWindowId }); 37 | dbg("sink set to window %o (incognito=%o focused=%o)", win.id, !!win.incognito, !!win.focused); 38 | }); 39 | 40 | browser.runtime.onMessage.addListener(async (msg) => { 41 | if (msg?.cmd === "toggleDebug") { 42 | DEBUG = !DEBUG; 43 | await browser.storage.local.set({ debug: DEBUG }); 44 | dbg("debug toggled -> %o", DEBUG); 45 | } 46 | if (msg?.cmd === "status") { 47 | const sinkWin = sinkWindowId ? await browser.windows.get(sinkWindowId).catch(() => null) : null; 48 | dbg("status: sinkWindowId=%o sinkWinExists=%o lastGain=%o lastLoss=%o", 49 | sinkWindowId, !!sinkWin, lastGainedFocusAt, lastLostFocusAt); 50 | } 51 | }); 52 | 53 | // ---- Helpers ---------------------------------------------------------------- 54 | function markExternalIfTimeMatches(tabId, createdAt, whenFocusGained) { 55 | const dt = whenFocusGained - createdAt; // positive if focus AFTER creation 56 | const isExternal = (dt >= 0 && dt <= PREGAIN_MS) || (dt < 0 && -dt <= POSTGAIN_MS); 57 | dbg("classify tab %o: dt=%oms → external=%o", tabId, dt, isExternal); 58 | return isExternal; 59 | } 60 | 61 | async function maybeMove(tabId, url, tabIncognito) { 62 | if (!sinkWindowId) { dbg("move skip: no sink set"); return; } 63 | if (!/^https?:/i.test(url || "")) { dbg("move skip: not http(s): %o", url); return; } 64 | 65 | const sinkWin = await browser.windows.get(sinkWindowId).catch(() => null); 66 | if (!sinkWin) { dbg("move skip: sink window missing (%o)", sinkWindowId); return; } 67 | if ((!!sinkWin.incognito) !== (!!tabIncognito)) { 68 | dbg("move skip: privacy mismatch (sink incog=%o tab incog=%o)", !!sinkWin.incognito, !!tabIncognito); 69 | return; 70 | } 71 | 72 | try { 73 | dbg("moving tab %o → window %o (url=%o)", tabId, sinkWindowId, url); 74 | await browser.tabs.move(tabId, { windowId: sinkWindowId, index: -1 }); 75 | await browser.tabs.update(tabId, { active: true }); 76 | dbg("moved tab %o ✔", tabId); 77 | } catch (e) { 78 | dbg("move FAILED %o: %o", tabId, e?.message || e); 79 | } 80 | } 81 | 82 | function gcRecent() { 83 | const cutoff = Date.now() - GC_MS; 84 | for (const [id, info] of recent) { 85 | if (info.createdAt < cutoff && !info.awaitingUrl) { 86 | recent.delete(id); 87 | dbg("gc: drop tab %o", id); 88 | } 89 | } 90 | } 91 | 92 | // ---- Focus tracking (retro classify) --------------------------------------- 93 | browser.windows.onFocusChanged.addListener((winId) => { 94 | const t = Date.now(); 95 | if (winId === browser.windows.WINDOW_ID_NONE) { 96 | lastLostFocusAt = t; 97 | dbg("focus LOST"); 98 | return; 99 | } 100 | lastGainedFocusAt = t; 101 | dbg("focus GAINED: window %o", winId); 102 | 103 | // Re-classify tabs created in the lookback window as external 104 | for (const [tabId, info] of recent) { 105 | if (info.external) continue; 106 | if (markExternalIfTimeMatches(tabId, info.createdAt, lastGainedFocusAt)) { 107 | info.external = true; 108 | // If we already have a http(s) URL, move now 109 | if (info.lastUrl && /^https?:/i.test(info.lastUrl)) { 110 | maybeMove(tabId, info.lastUrl, info.incognito); 111 | } else { 112 | info.awaitingUrl = true; 113 | dbg("tab %o: marked external; awaiting URL", tabId); 114 | } 115 | } 116 | } 117 | }); 118 | 119 | // ---- Tab events ------------------------------------------------------------- 120 | browser.tabs.onCreated.addListener((tab) => { 121 | const createdAt = Date.now(); 122 | const lastUrl = tab.pendingUrl || tab.url || ""; 123 | const hasOpener = tab.openerTabId != null; 124 | dbg("onCreated: id=%o win=%o incog=%o opener=%o url=%o", 125 | tab.id, tab.windowId, !!tab.incognito, tab.openerTabId ?? null, lastUrl || "(blank)"); 126 | 127 | // Track only tabs without opener (likely not spawned by in-browser click) 128 | if (hasOpener) { 129 | dbg("onCreated: skip tracking (has opener)"); 130 | return; 131 | } 132 | recent.set(tab.id, { 133 | createdAt, incognito: !!tab.incognito, windowId: tab.windowId, 134 | external: false, awaitingUrl: false, lastUrl 135 | }); 136 | 137 | // Quick re-check shortly after (in case focus event arrives a hair later) 138 | setTimeout(() => { 139 | const info = recent.get(tab.id); 140 | if (!info || info.external) return; 141 | const isExt = markExternalIfTimeMatches(tab.id, info.createdAt, lastGainedFocusAt); 142 | if (isExt) { 143 | info.external = true; 144 | if (info.lastUrl && /^https?:/i.test(info.lastUrl)) { 145 | maybeMove(tab.id, info.lastUrl, info.incognito); 146 | } else { 147 | info.awaitingUrl = true; 148 | dbg("tab %o: marked external via delayed check; awaiting URL", tab.id); 149 | } 150 | } 151 | gcRecent(); 152 | }, 150); 153 | 154 | gcRecent(); 155 | }); 156 | 157 | browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 158 | if (!changeInfo.url) return; 159 | const info = recent.get(tabId); 160 | dbg("onUpdated: id=%o url=%o tracked=%o external=%o awaiting=%o", 161 | tabId, changeInfo.url, !!info, info?.external ?? false, info?.awaitingUrl ?? false); 162 | 163 | if (info) { 164 | info.lastUrl = changeInfo.url; 165 | if (info.external) { 166 | if (info.awaitingUrl) info.awaitingUrl = false; 167 | maybeMove(tabId, changeInfo.url, info.incognito); 168 | } 169 | } 170 | }); 171 | 172 | // ---- Cleanup when a tab closes --------------------------------------------- 173 | browser.tabs.onRemoved.addListener((tabId) => { 174 | if (recent.delete(tabId)) dbg("onRemoved: drop tab %o", tabId); 175 | }); --------------------------------------------------------------------------------