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