From 3c35b2605b7f4db7fdb397323b59cde4bf6bb4b1 Mon Sep 17 00:00:00 2001 From: Silent Mode Date: Fri, 2 Oct 2026 21:42:31 +0200 Subject: [PATCH] Theseus 0.3.63: lazy tab restore, Privacy language simplified, chip reloads the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session restore now paints the full strip from the saved titles + favicons and loads only the ACTIVE tab's page; every other restored tab lives as a dormant WebContentsView and navigates for the first time when the user clicks it. For a 20-tab user that drops cold start from 20 renderer loads racing chrome.html to one, so launch is roughly flat whatever the tab count — fixes the "not responding" freeze on a session with many restored tabs. session.json is now v3 ({v:3, tabs:[{url,title,favicon}], active}); v1/v2 session files still parse (their tabs restore lazy without a cached title, which arrives on first activation). Reload on a dormant tab materialises it. Privacy › Anti-fingerprinting › Language is now two modes — Automatic (system language) and Manual — matching the General › Website language row and the URL-bar globe chip. The old Spoof-choose top-10 and Hide-en-US modes are gone from the UI; legacy saved values auto-migrate to Automatic on first open. The Manual list is the same 24 languages the General row uses, kept in one place (WEB_LANG_LIST), so all three surfaces stay in sync. Changing the language via the globe chip or either settings row now reloads the active tab — the server picked the response body from Accept-Language on the original request, so an already-rendered page can't adopt the new language on its own. A reload is what a user clicking a one-click language switch expects. The Location row's country dropdown now stacks under the mode dropdown on its own line when Manual is picked, so an open menu above it can't visually cover it (the row's flex-row max-60% layout could wrap it where another dropdown's overlay sat). Also: the settings-update broadcast now reaches every open settings tab, not only the chrome — so changing the chip updates both the General Website- language row and the Privacy Anti-fingerprinting Language row live without a Settings refresh. --- main.js | 147 ++++++++++++++++++++++++++++++----------- package.json | 2 +- settings.html | 177 ++++++++++++++++++++++++-------------------------- 3 files changed, 197 insertions(+), 129 deletions(-) diff --git a/main.js b/main.js index 59ed69f7..64ffb204 100644 --- a/main.js +++ b/main.js @@ -1144,56 +1144,72 @@ function applyClientHintsSpoof() { } // ---- session restore + background throttling ---- const sessionFile = () => path.join(app.getPath("userData"), "session.json"); -// v2 format: { v: 2, urls, active }. v1 was a bare array of URLs; reading one -// maps to active = last tab, which is what the old restore loop ended up -// showing (each createTab activated itself, so the rightmost tab won). +// v3 format: { v: 3, tabs: [{url, title, favicon}], active }. +// v2 was { v: 2, urls, active }; v1 was a bare array of URLs. +// Carrying title + favicon lets restoreTabs paint the full strip at launch +// without any tab having to load first — background tabs stay DORMANT (no +// loadURL, no renderer activity) and come to life only when activated. let sessionSavedAtClose = false; function saveSession() { if (sessionSavedAtClose && !winAlive()) return; // already captured when the window closed try { - const live = tabs.filter((t) => !t.settings && t.url); + const live = tabs.filter((t) => !t.settings && (t.url || t.pending?.url)); const active = Math.max(0, live.findIndex((t) => t.id === activeId)); - fs.writeFileSync(sessionFile(), JSON.stringify({ v: 2, urls: live.map((t) => t.url), active })); + const data = live.map((t) => { + // A still-dormant tab's URL + title + favicon live under `pending` — + // the user never activated it, so the view never paid a renderer cost. + // Save what we would have shown either way. + const url = t.pending?.url ?? t.url ?? ""; + const title = t.pending?.title ?? t.title ?? ""; + const favicon = t.pending?.favicon ?? t.favicon ?? null; + return { url, title, favicon }; + }); + fs.writeFileSync(sessionFile(), JSON.stringify({ v: 3, tabs: data, active })); } catch (e) { console.error("session save failed:", e.message); } } function loadSession() { + const empty = { tabs: [], active: 0 }; try { - if (!fs.existsSync(sessionFile())) return { urls: [], active: 0 }; + if (!fs.existsSync(sessionFile())) return empty; const raw = JSON.parse(fs.readFileSync(sessionFile(), "utf8")); + // v3: {v:3, tabs:[{url,title,favicon}], active} + if (raw && raw.v === 3 && Array.isArray(raw.tabs)) { + const t = raw.tabs.filter((x) => x && typeof x.url === "string" && x.url).map((x) => ({ + url: x.url, title: typeof x.title === "string" ? x.title : "", favicon: typeof x.favicon === "string" ? x.favicon : null, + })); + const active = Math.min(Math.max(Number(raw.active) || 0, 0), Math.max(t.length - 1, 0)); + return { tabs: t, active }; + } + // v2 / v1: just URL lists. Title + favicon arrive once the user activates the tab. const list = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.urls) ? raw.urls : []); const urls = list.filter((u) => typeof u === "string" && u); const last = Math.max(urls.length - 1, 0); const active = Array.isArray(raw) ? last : Math.min(Math.max(Number(raw.active) || 0, 0), last); - return { urls, active }; - } catch { return { urls: [], active: 0 }; } + return { tabs: urls.map((url) => ({ url, title: "", favicon: null })), active }; + } catch { return empty; } } -// Session restore, staggered. Creating every saved tab in one synchronous -// burst meant N renderer processes launching on top of chrome.html's first -// paint — on a slow or busy machine the window sat blank for seconds. The -// active tab is created first (so the user's page is on screen right away) -// and the rest arrive one per RESTORE_STAGGER_MS as background tabs, slotted -// into their saved strip positions. -const RESTORE_STAGGER_MS = 150; +// Session restore, LAZY: only the active tab actually loads a page at startup. +// Every other restored tab is a dormant WebContentsView in the strip — title +// and favicon from the saved session — and only navigates when the user clicks +// it (setActive consumes `pending`). This drops N renderer loads at startup +// to 1, so launch time is roughly flat whether the user has 2 or 25 tabs. function restoreTabs() { - const saved = settings.restoreSession ? loadSession() : { urls: [], active: 0 }; - if (!saved.urls.length) { createTab(); return; } - createTab(saved.urls[saved.active]); - const rest = saved.urls.map((url, i) => ({ url, i })).filter((x) => x.i !== saved.active); - const step = () => { - if (!rest.length || !win || win.isDestroyed()) return; - const { url, i } = rest.shift(); + const saved = settings.restoreSession ? loadSession() : { tabs: [], active: 0 }; + if (!saved.tabs.length) { createTab(); return; } + const activeMeta = saved.tabs[saved.active] || saved.tabs[0]; + createTab(activeMeta.url); + // Build the rest as lazy. Insert at their saved slot so the strip order + // matches the save. + for (let i = 0; i < saved.tabs.length; i++) { + if (i === saved.active) continue; try { - createTab(url, { background: true }); - // createTab appends; move the new tab to its saved slot. Lower indices - // are created first, so `i` is the right position on both sides of - // the active tab. + createTab(null, { background: true, pending: saved.tabs[i] }); + // createTab appends; move it into position. const t = tabs.pop(); tabs.splice(Math.min(i, tabs.length), 0, t); - emitTabs(); } catch (e) { console.warn("session restore: tab failed:", e?.message); } - setTimeout(step, RESTORE_STAGGER_MS); - }; - setTimeout(step, RESTORE_STAGGER_MS); + } + emitTabs(); } // ---- deferred overlay loads ---- // The floating overlays (site-info popover, engine picker, downloads, @@ -3116,6 +3132,8 @@ function setActive(id) { const target = tabs.find((x) => x.id === id); if (target) target.view.setVisible(true); for (const t of tabs) if (t.id !== id) t.view.setVisible(false); + // Dormant restored tabs come to life on first activation. + if (target && target.pending) materializePending(target); const t = activeTab(); // Track the last active tab that isn't an add-on-owned page so captureTab // has a sensible fallback when the user re-triggers the dropdown from @@ -3606,7 +3624,17 @@ function createTab(initial, opts = {}) { layout(); if (opts.background) { view.setVisible(false); emitTabs(); } else setActive(id); - if (opts.settings) { + if (opts.pending) { + // Lazy / dormant: the tab lives in the strip with its saved metadata but + // nothing loads until setActive consumes `pending`. Only valid on + // background tabs — createTab's caller never asks for a foreground tab + // that is also dormant (restoreTabs enforces this). + tab.pending = { url: opts.pending.url, title: opts.pending.title || "", favicon: opts.pending.favicon || null }; + tab.url = opts.pending.url; + if (opts.pending.title) tab.title = opts.pending.title; + if (opts.pending.favicon) tab.favicon = opts.pending.favicon; + emitTabs(); + } else if (opts.settings) { tab.prov = { host: "", kind: "home" }; const settingsOpts = opts.settingsSection ? { hash: opts.settingsSection } : undefined; wc.loadFile("settings.html", settingsOpts); @@ -3625,6 +3653,16 @@ function createTab(initial, opts = {}) { else loadHome(id); return id; } +// First activation of a dormant restored tab: navigate to its saved URL. +// Called from setActive and reload — both the "I'm looking at this tab" and +// "I want it to load" entry points consume `pending`. +function materializePending(tab) { + if (!tab || !tab.pending) return false; + const url = tab.pending.url; + tab.pending = null; + try { navigateTab(tab.id, url); } catch (e) { console.warn("materializePending failed:", e?.message); } + return true; +} function closeTab(id) { const i = tabs.findIndex((t) => t.id === id); if (i < 0) return; @@ -4097,16 +4135,27 @@ const WEBSITE_LANGUAGE_QUICK = [ { tag: "zh-TW", label: "中文(繁體)" }, ]; function setWebsiteLanguage(mode, value) { + const before = { mode: settings.languageMode, value: settings.languageValue }; if (mode === "show") { settings.languageMode = "show"; } else if (mode === "manual" && value) { settings.languageMode = "manual"; settings.languageValue = String(value); } else return; + const changed = before.mode !== settings.languageMode || before.value !== settings.languageValue; saveSettings(); applyFingerprintAll(); applyAcceptLanguage(); - try { chrome?.webContents.send("settings-update", settings); } catch {} + broadcastSettings(); + // Nothing short of a reload makes a loaded page re-render in a new language — + // the server picked the body from the Accept-Language on the ORIGINAL request. + // The chip is a one-click language switch, so the user expects to see it take + // effect on whatever they're looking at: reload the active tab (only). + if (changed) { + const t = activeTab(); + const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null; + try { wc && wc.reload(); } catch {} + } } // Human-readable name for a BCP-47 tag — the plain language name, no regional // qualifier ("en-US" → "English", not "American English"). The picker only @@ -5206,7 +5255,12 @@ ipcMain.handle("home-cards-reset", (e) => { if (!isHomePageSender(e.sender)) ret ipcMain.handle("go-back", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); }); ipcMain.handle("go-forward", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); }); ipcMain.handle("reload", (_e, hard) => { - const wc = activeTab()?.view.webContents; + const t = activeTab(); + if (!t) return; + // A dormant restored tab has nothing to reload — treat Reload as "load it + // for the first time" rather than reloading an empty renderer. + if (t.pending) { materializePending(t); return; } + const wc = t.view.webContents; if (!wc) return; try { hard ? wc.reloadIgnoringCache() : wc.reload(); } catch {} }); @@ -6192,12 +6246,31 @@ ipcMain.handle("settings-set", (_e, key, val) => { if (key === "backgroundThrottle") applyThrottle(); if (["timezoneMode", "timezoneValue", "languageMode", "languageSpoof", "languageValue", "locationMode", "locationRegion", "locationCountry", "locationLat", "locationLon", "hideMediaDevices"].includes(key)) { applyFingerprintAll(); applyAcceptLanguage(); } - // Push updates chrome-side reactively so toolbar-size changes (urlBarSize, - // searchBoxSize) take effect without a relaunch. Whole settings object - // fits in one message and chrome only cares about a handful of fields. - try { chrome?.webContents.send("settings-update", settings); } catch {} + // Language changes alter the Accept-Language the server sees on the NEXT + // request; an already-rendered page keeps the body it was first served. + // Reload the active tab so the user sees the switch take effect without a + // manual F5. (Only for the language keys; timezone / location are JS-side + // overrides and don't need the server round-trip.) + if (key === "languageMode" || key === "languageValue") { + const t = activeTab(); + const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null; + try { wc && wc.reload(); } catch {} + } + // Broadcast to every renderer that watches the live settings — the chrome + // (toolbar sizes, URL-bar language chip) and every open settings tab (so + // the General Website-language row and the Privacy Anti-fingerprinting + // Language row stay in sync with the chip and with each other). + broadcastSettings(); return settings; }); +function broadcastSettings() { + try { chrome?.webContents.send("settings-update", settings); } catch {} + for (const t of tabs) { + if (t.settings && t.view?.webContents) { + try { t.view.webContents.send("settings-update", settings); } catch {} + } + } +} ipcMain.handle("set-chrome-height", (_e, h) => { const next = Math.max(74, Math.min(260, Math.round(h) || 84)); if (next !== CHROME_H) { CHROME_H = next; layout(); } diff --git a/package.json b/package.json index e51c0102..b2e0e307 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.62", + "version": "0.3.63", "license": "MPL-2.0", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode", diff --git a/settings.html b/settings.html index a067bc89..921a7591 100644 --- a/settings.html +++ b/settings.html @@ -271,7 +271,6 @@

General

Changes apply immediately and are saved for next time.

Website language

-

The language sites serve you in — Wikipedia, YouTube, news sites, and anything else that has a localised version. Sent as your Accept-Language header and navigator.language. Automatic follows your operating system; pick a language to override it. The same setting lives under Privacy → Anti-fingerprinting → Language for advanced modes (Spoof, Hide).

Preferred language
Also switchable from the globe icon in the address bar.
@@ -675,62 +674,25 @@
-
Language
navigator.language and the Accept-Language header. Spoof reports a major world language; Manual lets you type any locale.
+
Language
Same control as General › Website language and the globe chip in the URL bar.
- - - - + + + +
Location
Hide denies geolocation entirely; Manual reports the coordinates of a country you pick, so a page's navigator.geolocation answers with a plausible position instead of your real one.
-
+ +
- +
These reduce tracking and hide your IP, but a custom browser can still be fingerprinted. For maximum anonymity, use the Tor Browser.
@@ -829,6 +791,36 @@