Theseus: background tabs stop unless kept running; popups close on click-away

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.
This commit is contained in:
Local Dev 2026-10-03 14:14:47 +02:00
parent b531c89f82
commit adcea19f8a
4 changed files with 108 additions and 4 deletions

View file

@ -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 = `<span class="ico"></span><span class="t"></span><span class="ask" title="This page is waiting for you" hidden>💬</span><span class="mute" title="Muted" hidden>🔇</span><span class="x" data-close="${id}">✕</span>`;
el.innerHTML = `<span class="ico"></span><span class="t"></span><span class="ask" title="This page is waiting for you" hidden>💬</span><span class="bg" title="Keeps running in the background" hidden>▶</span><span class="mute" title="Muted" hidden>🔇</span><span class="x" data-close="${id}">✕</span>`;
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 <img> 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);

99
main.js
View file

@ -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

View file

@ -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),

View file

@ -590,6 +590,10 @@
<div class="txt"><div class="t">Throttle inactive tabs</div><div class="d">Background and inactive tabs use far less CPU. Recommended.</div></div>
<label class="sw"><input type="checkbox" id="backgroundThrottle"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Stop tabs in the background</div><div class="d">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.</div></div>
<label class="sw"><input type="checkbox" id="freezeBackgroundTabs"><span class="track"><span class="knob"></span></span></label>
</div>
</section>
<!-- PRIVACY -->
<section id="privacy" hidden>
@ -1018,7 +1022,7 @@
el("dohCustom").addEventListener("change", () => C.set("dohCustom", el("dohCustom").value.trim()));
});
})();
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
"quickLinksShow"];
C.get().then((s) => {