From adcea19f8aa76d8d80ffe4ea16e777a5bb27c557 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 14:14:47 +0200 Subject: [PATCH] Theseus: background tabs stop unless kept running; popups close on click-away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tab you switch away from is frozen (page lifecycle "frozen": no JS, timers, network callbacks or media) one second later and thawed the moment it is shown again. Right-click a tab → "Keep running in background" exempts it (music, calls, dashboards); the strip marks it ▶. Dormant restored tabs and tabs waiting on a page dialog are never frozen. Settings › Performance › "Stop tabs in the background" (on by default) turns it off. Freezing uses the per-tab debugger applyFingerprint already keeps attached; Chromium only freezes hidden pages. The downloads, site-info and engine-picker popups close when focus moves elsewhere in the window or to another app; the toolbar click that caused that does not reopen them. Focus only moves into a popup while the window is active — focusing it from the background blurs the window and closed the popup it had just opened. Also fixes a bug in the lazy overlays: isLoading() is still true while did-finish-load is delivered, so a first show waited out the 4 s timeout before appearing. Finished loads are now recorded explicitly. --- chrome.html | 6 +++- main.js | 99 +++++++++++++++++++++++++++++++++++++++++++++++++-- preload.js | 1 + settings.html | 6 +++- 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/chrome.html b/chrome.html index 76af064f..adcef4bc 100644 --- a/chrome.html +++ b/chrome.html @@ -88,6 +88,7 @@ .tab .x { opacity: .5; cursor: pointer; padding: 0 2px; border-radius: 4px; } .tab .mute { font-size: 10px; opacity: .8; margin-right: 2px; } .tab .ask { font-size: 10px; margin-right: 2px; } + .tab .bg { font-size: 9px; opacity: .75; margin-right: 2px; } /* Tab group visual: a colored dot before the title, plus a matching top accent stripe on the tab itself so a whole group reads as one cluster even when tabs are next to each other. */ @@ -1579,7 +1580,7 @@ function makeTab(id) { const el = document.createElement("div"); el.className = "tab"; el.dataset.id = String(id); el.dataset.key = "tab:" + id; el.draggable = true; - el.innerHTML = `✕`; + el.innerHTML = `✕`; el.onclick = (e) => { if (e.target.dataset.close) T.closeTab(id); else T.switchTab(id); }; // Right-click → native OS menu popped from main.js (a DOM menu forced the // chrome view taller and opened a gap under the toolbar). @@ -1613,6 +1614,7 @@ if (label.textContent !== txt) label.textContent = txt; el.querySelector(".mute").hidden = !t.muted; el.querySelector(".ask").hidden = !t.asking; + el.querySelector(".bg").hidden = !t.keepRunning; // Icon slot: spinner while loading, otherwise the favicon. The is // only touched when the URL actually changes, so it never reloads. const ico = el.querySelector(".ico"); @@ -1815,6 +1817,8 @@ const bmDisabled = !t.url; m.appendChild(item("Add to Bookmarks", bmDisabled ? "off" : "", bmDisabled ? null : () => T.tabBookmark(t.id))); m.appendChild(item(t.muted ? "Unmute" : "Mute", "", () => T.tabMute(t.id))); + // Background tabs stop (freeze) unless marked — for music, calls, dashboards. + m.appendChild(item(t.keepRunning ? "Stop when in background" : "Keep running in background", "", () => T.tabKeepRunning(t.id))); const sep = document.createElement("div"); sep.className = "sep"; m.appendChild(sep); m.appendChild(item("Close", "danger", () => T.closeTab(t.id))); document.body.appendChild(m); diff --git a/main.js b/main.js index 517e6648..10d33dd3 100644 --- a/main.js +++ b/main.js @@ -478,6 +478,7 @@ const SETTINGS_DEFAULTS = { hideMediaDevices: true, // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox restoreSession: true, // reopen last session's tabs on launch backgroundThrottle: true, // throttle inactive tabs / the window when unfocused + freezeBackgroundTabs: true, // a tab you switch away from stops (JS, timers, media) unless marked "Keep running" // Storage retention — nothing persists by default. Auto-clear on quit // means a session leaves no trace on disk unless the user opts in per-type. clearCookiesOnQuit: true, // drop cookies + logins + saved-form data @@ -1344,9 +1345,16 @@ function loadOverlay(view) { const i = overlayLoads.findIndex((o) => o.view === view); if (i < 0) return; const { file } = overlayLoads.splice(i, 1)[0]; + view.webContents.once("did-finish-load", () => overlayDone.add(view)); try { view.webContents.loadFile(file); } catch (e) { console.warn(`overlay load failed (${file}):`, e?.message); } } +// Pages whose load has finished. Recorded from did-finish-load because +// isLoading() still reports true while that event is being delivered — a +// show deferred to it would then defer again and wait out overlayReady's +// timeout before appearing. +const overlayDone = new WeakSet(); function overlayLoaded(view) { + if (overlayDone.has(view)) return true; try { return !!view.webContents.getURL() && !view.webContents.isLoading(); } catch { return false; } } // The show functions send their data right after showing, which a page still @@ -1362,6 +1370,32 @@ function deferUntilOverlayLoaded(view, again) { return true; } function cancelOverlayShow(view) { overlayWant.delete(view); } +// Click-away for the toolbar popups (downloads, site info, engine picker): +// shown, they take focus; when focus moves anywhere else — a tab, the +// toolbar, another app — they close. The click on their own toolbar button +// is one of those focus moves, so a toggle right after a click-away close is +// that same click and must not reopen the popup. +const clickAwayClosedAt = new Map(); // view -> time it was closed by losing focus +const clickAwayPopups = []; // [{ isOpen, hide }] — also closed when the window loses focus +function closeOnClickAway(view, isOpen, hide) { + clickAwayPopups.push({ isOpen, hide }); + view.webContents.on("blur", () => { + // Only a focus move inside an active window is a click elsewhere in + // Theseus. A popup shown while another app is in front gets focus and + // blur together, and must not close the moment it opens; switching to + // another app is handled by the window's own blur (closePopupsOnWindowBlur). + // The show functions only move focus into a popup while the window is + // active: focusing it from the background makes Windows touch the window, + // whose blur would close the popup it just opened. + if (!isOpen() || !win || win.isDestroyed() || !win.isFocused()) return; + clickAwayClosedAt.set(view, Date.now()); + hide(); + }); +} +function closePopupsOnWindowBlur() { + for (const p of clickAwayPopups) { try { if (p.isOpen()) p.hide(); } catch {} } +} +const justClosedByClickAway = (view) => Date.now() - (clickAwayClosedAt.get(view) || 0) < 400; // Idle prewarm, one at a time, of the overlays used in nearly every session. let overlaysPrewarmed = false; async function prewarmOverlays() { @@ -1380,7 +1414,7 @@ async function prewarmOverlays() { function overlayReady(view, timeoutMs = 4000) { loadOverlay(view); const wc = view.webContents; - try { if (wc.getURL() && !wc.isLoading()) return Promise.resolve(); } catch {} + if (overlayLoaded(view)) return Promise.resolve(); return new Promise((resolve) => { const timer = setTimeout(done, timeoutMs); function done() { clearTimeout(timer); wc.removeListener("did-finish-load", done); resolve(); } @@ -2870,6 +2904,7 @@ function showPopover(show) { win.contentView.removeChildView(popover); win.contentView.addChildView(popover); popover.setVisible(true); popVisible = true; + if (win.isFocused()) try { popover.webContents.focus(); } catch {} // see closeOnClickAway popover.webContents.send("site-info", decorate(activeTab()?.prov) || { kind: "home" }); } else { cancelOverlayShow(popover); popover.setVisible(false); popVisible = false; } } @@ -2887,6 +2922,7 @@ function showEnginePicker(show) { win.contentView.removeChildView(enginePicker); win.contentView.addChildView(enginePicker); enginePicker.setVisible(true); epVisible = true; + if (win.isFocused()) try { enginePicker.webContents.focus(); } catch {} // see closeOnClickAway enginePicker.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null }); } else { cancelOverlayShow(enginePicker); enginePicker.setVisible(false); epVisible = false; } } @@ -2904,6 +2940,7 @@ function showDownloads(show) { win.contentView.removeChildView(downloadsPop); win.contentView.addChildView(downloadsPop); downloadsPop.setVisible(true); dlVisible = true; + if (win.isFocused()) try { downloadsPop.webContents.focus(); } catch {} // see closeOnClickAway downloadsPop.webContents.send("downloads", downloadsPublic()); } else { cancelOverlayShow(downloadsPop); downloadsPop.setVisible(false); dlVisible = false; } } @@ -3254,9 +3291,50 @@ function installDownloadTracker() { }); }); } +// ---- background tab freezing ---- +// A tab the user switches away from is frozen (Chromium's page lifecycle +// "frozen": no JS, timers, network callbacks or media) after a short grace, +// and thawed the moment it is shown again. Exempt: tabs marked "Keep running +// in background" from the tab menu (music, calls, live dashboards), dormant +// restored tabs (nothing loaded), tabs waiting on a page dialog. Uses the +// per-tab debugger applyFingerprint already keeps attached. Chromium only +// freezes hidden pages, which background tabs are. +const FREEZE_GRACE_MS = 1000; +async function setTabFrozen(tab, frozen) { + if (!tab || !!tab.frozen === frozen) return; + const wc = tab.view?.webContents; + if (!wc || wc.isDestroyed()) return; + try { + if (!wc.debugger.isAttached()) wc.debugger.attach("1.3"); + await wc.debugger.sendCommand("Page.setWebLifecycleState", { state: frozen ? "frozen" : "active" }); + tab.frozen = frozen; + } catch (e) { console.warn(`[tabs] ${frozen ? "freeze" : "thaw"} failed:`, e?.message); } +} +function scheduleFreeze(tab) { + if (!tab) return; + clearTimeout(tab.freezeTimer); + if (!settings.freezeBackgroundTabs || tab.keepRunning || tab.pending || !tab.url || tab.id === activeId) return; + tab.freezeTimer = setTimeout(() => { + if (tab.id === activeId || !tabs.includes(tab) || tab.keepRunning || tab.pending || tabHasPendingDialog(tab.id)) return; + setTabFrozen(tab, true); + }, FREEZE_GRACE_MS); +} +function thawTab(tab) { + if (!tab) return; + clearTimeout(tab.freezeTimer); + if (tab.frozen) setTabFrozen(tab, false); +} +// The setting turned off (or on): thaw everything (or freeze the background). +function applyFreezeSetting() { + for (const t of tabs) { if (settings.freezeBackgroundTabs && t.id !== activeId) scheduleFreeze(t); else thawTab(t); } +} function setActive(id) { const switching = id !== activeId; + const previous = switching ? tabs.find((x) => x.id === activeId) : null; activeId = id; + // The incoming tab runs again before it is shown; the one left behind stops. + thawTab(tabs.find((x) => x.id === id)); + if (previous) scheduleFreeze(previous); // A fullscreen video does not follow the user to another tab. if (switching && fsTabId != null && fsTabId !== id) leaveHtmlFullscreen(tabs.find((t) => t.id === fsTabId), true); if (popVisible) showPopover(false); // don't carry a stale popover across tabs @@ -3291,7 +3369,7 @@ function emitTabs() { const t = activeTab(); const wc = t?.view.webContents; chrome?.webContents.send("tabs", { - tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "", asking: tabHasPendingDialog(x.id) })), + tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "", asking: tabHasPendingDialog(x.id), keepRunning: !!x.keepRunning })), collapsedGroups: [...tabGroupCollapsed], url: t?.url || "", loading: !!t?.loading, @@ -3917,6 +3995,11 @@ function createWindow() { styleScrollbars(downloadsPop.webContents); deferOverlayLoad(downloadsPop, "downloads.html"); downloadsPop.setVisible(false); + clickAwayPopups.length = 0; + closeOnClickAway(popover, () => popVisible, () => showPopover(false)); + closeOnClickAway(enginePicker, () => epVisible, () => showEnginePicker(false)); + closeOnClickAway(downloadsPop, () => dlVisible, () => showDownloads(false)); + win.on("blur", closePopupsOnWindowBlur); // Floating address-bar suggestions dropdown. addressPicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "address-picker-preload.js") } }); try { addressPicker.setBackgroundColor("#00000000"); } catch {} @@ -4032,6 +4115,7 @@ function createWindow() { async function navigateTab(id, input) { const t = tabById(id); if (!t) return; + thawTab(t); // A pending tab the user navigates explicitly (URL bar, link follow, // search) has outgrown its saved URL — otherwise a later reload or chip // click would snap it back to that stale URL via materializePending. @@ -4216,6 +4300,13 @@ ipcMain.handle("tab-duplicate", (_e, id) => { const target = t.url || ""; if (target) createTab(target); else createTab(); }); +ipcMain.handle("tab-keep-running", (_e, id, on) => { + const t = tabById(id); if (!t) return false; + t.keepRunning = typeof on === "boolean" ? on : !t.keepRunning; + if (t.keepRunning) thawTab(t); else scheduleFreeze(t); + emitTabs(); + return t.keepRunning; +}); ipcMain.handle("tab-mute", (_e, id, on) => { const t = tabById(id); if (!t) return false; const want = typeof on === "boolean" ? on : !t.muted; @@ -5570,6 +5661,7 @@ ipcMain.handle("settings-open-panel", (e, panelId) => { }); ipcMain.handle("toggle-site-info", (_e, rect) => { if (popVisible) return showPopover(false); + if (justClosedByClickAway(popover)) return; if (rect) popPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; showPopover(true); }); @@ -6325,6 +6417,7 @@ ipcMain.handle("set-engine-order", (_e, ids) => { // The custom dropdown overlay (real favicons). ipcMain.handle("toggle-engine-picker", (_e, rect) => { if (epVisible) return showEnginePicker(false); + if (justClosedByClickAway(enginePicker)) return; if (rect) epPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; showEnginePicker(true); }); @@ -6347,6 +6440,7 @@ ipcMain.handle("picker-open-settings", () => { ipcMain.handle("downloads-get", () => downloadsPublic()); ipcMain.handle("toggle-downloads", (_e, rect) => { if (dlVisible) return showDownloads(false); + if (justClosedByClickAway(downloadsPop)) return; if (rect) dlPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; showDownloads(true); }); @@ -6519,6 +6613,7 @@ ipcMain.handle("settings-set", (_e, key, val) => { if (key === "searchEngine") emitEngines(); // the toolbar button and the picker show the default if (key === "theme") applyTheme(); if (key === "backgroundThrottle") applyThrottle(); + if (key === "freezeBackgroundTabs") applyFreezeSetting(); if (["timezoneMode", "timezoneValue", "languageMode", "languageSpoof", "languageValue", "locationMode", "locationRegion", "locationCountry", "locationLat", "locationLon", "hideMediaDevices"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); } // Language changes alter the Accept-Language the server sees on the NEXT diff --git a/preload.js b/preload.js index 0efce62d..8b3c3f43 100644 --- a/preload.js +++ b/preload.js @@ -68,6 +68,7 @@ contextBridge.exposeInMainWorld("theseus", { tabReload: (id) => ipcRenderer.invoke("tab-reload", id), tabDuplicate: (id) => ipcRenderer.invoke("tab-duplicate", id), tabMute: (id, on) => ipcRenderer.invoke("tab-mute", id, on), + tabKeepRunning: (id, on) => ipcRenderer.invoke("tab-keep-running", id, on), tabGroup: (id, color) => ipcRenderer.invoke("tab-group", id, color), tabGroupToggle: (color) => ipcRenderer.invoke("tab-group-toggle", color), tabBookmark: (id) => ipcRenderer.invoke("tab-bookmark", id), diff --git a/settings.html b/settings.html index b232d88f..83a0f42c 100644 --- a/settings.html +++ b/settings.html @@ -590,6 +590,10 @@
Throttle inactive tabs
Background and inactive tabs use far less CPU. Recommended.
+
+
Stop tabs in the background
A tab you switch away from stops running — scripts, timers, audio and video — until you come back to it. Right-click a tab and choose “Keep running in background” for music, calls or anything that should keep going.
+ +