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