diff --git a/addons-host.js b/addons-host.js index e8d180ef..3e6031fa 100644 --- a/addons-host.js +++ b/addons-host.js @@ -200,6 +200,19 @@ class AddonHost { // manifest gate. this._captureTab = typeof captureTab === "function" ? captureTab : null; this._saveCapture = typeof saveCapture === "function" ? saveCapture : null; + // api.whenUiReady() plumbing — see signalUiReady(). + this._uiReady = false; + this._uiReadyWaiters = []; + } + + // main calls this once the browser chrome has painted. Add-ons that pull + // in heavy dependencies (Aegis: noble curve precompute, bitcoinjs, libauth, + // WizardConnect) gate that work on api.whenUiReady() so module evaluation + // doesn't land on the main thread while chrome.html is still trying to + // paint. Sticky: a later discoverAndActivate() resolves immediately. + signalUiReady() { + this._uiReady = true; + for (const resolve of this._uiReadyWaiters.splice(0)) { try { resolve(); } catch {} } } ensureDirs() { @@ -432,6 +445,10 @@ class AddonHost { if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)"); this._openSettings(typeof section === "string" ? section : ""); }, + // Resolves once the browser chrome has painted (immediately if it + // already has). Put expensive dependency loading behind this so it + // never competes with the first frame at launch. + whenUiReady: () => (this._uiReady ? Promise.resolve() : new Promise((resolve) => this._uiReadyWaiters.push(resolve))), // Panel ↔ activate() messaging. Panels (and, for page-inject add-ons, // injected page bridges) call into the add-on with a message name + // one JSON payload; the handler's return value goes back as the diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 8abd5d75..5d10635e 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.6.1", + "version": "0.6.2", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", "author": "Silent Mode", "icon": "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'%3E%3Cpolygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='%230a0a0d' stroke='%23D6FF3D' stroke-width='1.6' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='none' stroke='%23D6FF3D' stroke-width='1.4'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23D6FF3D'/%3E%3C/svg%3E", diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index c29491ee..00f30032 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -1873,7 +1873,12 @@ module.exports = { // Restore the user's opt-in choice from storage. Off by default so a // fresh install never hits CoinGecko without asking. if (api.storage.get("pricesEnabled", false)) c.priceFeed.setEnabled(true).catch(() => {}); - loadDeps(api).then((d) => { + // The deps are heavy to evaluate (noble curve precompute, bitcoinjs, + // libauth, WizardConnect) and that all happens on the main thread. Wait + // for the browser chrome to paint so the cost never delays Theseus's + // first frame; older hosts without whenUiReady load immediately. + const uiReady = typeof api.whenUiReady === "function" ? api.whenUiReady() : Promise.resolve(); + uiReady.then(() => loadDeps(api)).then((d) => { if (ctx !== c) return; c.d = d; // Start the WizardConnect manager once deps are ready. Per-wallet diff --git a/main.js b/main.js index 72e7b758..9b90b5d5 100644 --- a/main.js +++ b/main.js @@ -761,13 +761,113 @@ 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). function saveSession() { - try { fs.writeFileSync(sessionFile(), JSON.stringify(tabs.filter((t) => !t.settings && t.url).map((t) => t.url))); } - catch (e) { console.error("session save failed:", e.message); } + try { + const live = tabs.filter((t) => !t.settings && t.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 })); + } catch (e) { console.error("session save failed:", e.message); } } function loadSession() { - try { if (fs.existsSync(sessionFile())) return JSON.parse(fs.readFileSync(sessionFile(), "utf8")); } catch {} - return []; + try { + if (!fs.existsSync(sessionFile())) return { urls: [], active: 0 }; + const raw = JSON.parse(fs.readFileSync(sessionFile(), "utf8")); + 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 }; } +} +// 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; +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(); + 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. + 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); +} +// ---- deferred overlay loads ---- +// The floating overlays (site-info popover, engine picker, downloads, +// address suggestions, password fill, link-status pill, add-on approval) +// are WebContentsViews created with the window, but their HTML used to be +// loaded in the same tick as chrome.html — seven extra renderers racing the +// toolbar for the first paint. They now load shortly after chrome reports +// did-finish-load, or on first use, whichever comes first. +const overlayLoads = []; // [{ view, file }] waiting for loadOverlays() +let overlaysLoaded = false; +function deferOverlayLoad(view, file) { overlayLoads.push({ view, file }); } +function loadOverlays() { + if (overlaysLoaded) return; + overlaysLoaded = true; + for (const { view, file } of overlayLoads.splice(0)) { + try { view.webContents.loadFile(file); } catch (e) { console.warn(`overlay load failed (${file}):`, e?.message); } + } +} +// Resolves once `view` has finished loading its page, loading the overlays +// first if that hasn't happened yet. Bounded so a wedged renderer can't +// hang the caller forever. +function overlayReady(view, timeoutMs = 4000) { + loadOverlays(); + const wc = view.webContents; + try { if (wc.getURL() && !wc.isLoading()) return Promise.resolve(); } catch {} + return new Promise((resolve) => { + const timer = setTimeout(done, timeoutMs); + function done() { clearTimeout(timer); wc.removeListener("did-finish-load", done); resolve(); } + wc.on("did-finish-load", done); + }); +} +// Post-paint boot. Runs once chrome.html has loaded (createWindow arms a +// fallback timer in case it never does). Everything here used to start in +// whenReady, before the toolbar had painted — the BNS index build, three +// network fetches, every restored tab and seven overlay renderers all piled +// onto the main thread in the same ~300 ms, and the window sat blank with +// a white toolbar strip until they drained. +let chromeReadyDone = false; +let bnsWarm = null; // promise from the warmFromSnapshot() kicked off in whenReady +function onChromeReady() { + if (chromeReadyDone) return; + chromeReadyDone = true; + if (addonHost) addonHost.signalUiReady(); + // Tabs wait for the BNS warm-up so sharedIndex is set before the first + // restored tab navigates; the rest of the multi-source refresh follows + // (see the comment block above startBnsPolling for how the sources + // cooperate). + (bnsWarm || warmFromSnapshot().catch(() => null)).then(() => { + try { restoreTabs(); } + catch (e) { console.error("restoreTabs failed:", e?.message); try { createTab(); } catch {} } + startBnsPolling(); + ensureIndex().catch(() => {}); // fallback for first launch without a bundled snapshot + }); + // Re-emit any pending update notice — harmless if nothing is pending. + emitUpdateAvailable(); + setTimeout(loadOverlays, 250); + refreshSnapshotFromSia().catch(() => {}); + checkForUpdate().catch(() => {}); + refreshRemoteHomeCards().catch(() => {}); } function applyThrottle() { for (const t of tabs) { try { t.view.webContents.setBackgroundThrottling(settings.backgroundThrottle); } catch {} } @@ -1960,6 +2060,7 @@ function positionPopover() { function showPopover(show) { if (!popover) return; if (show) { + loadOverlays(); positionPopover(); // Re-add to the top of the z-order (tabs added later would otherwise cover it). win.contentView.removeChildView(popover); @@ -1977,6 +2078,7 @@ function positionEnginePicker() { function showEnginePicker(show) { if (!enginePicker) return; if (show) { + loadOverlays(); positionEnginePicker(); win.contentView.removeChildView(enginePicker); win.contentView.addChildView(enginePicker); @@ -1993,6 +2095,7 @@ function positionDownloads() { function showDownloads(show) { if (!downloadsPop) return; if (show) { + loadOverlays(); positionDownloads(); win.contentView.removeChildView(downloadsPop); win.contentView.addChildView(downloadsPop); @@ -2067,6 +2170,7 @@ function setSidebar(show, panelId) { function showPwFill(show, matches) { if (!pwFillPop) return; if (show) { + loadOverlays(); positionPwFill(); win.contentView.removeChildView(pwFillPop); win.contentView.addChildView(pwFillPop); @@ -2130,6 +2234,7 @@ function showAddressPicker(show, suggestions) { if (!addressPicker) return; if (show) { if (!suggestions || !suggestions.length) return showAddressPicker(false); + loadOverlays(); positionAddressPicker(); win.contentView.removeChildView(addressPicker); win.contentView.addChildView(addressPicker); @@ -2620,8 +2725,15 @@ function closeTab(id) { } function createWindow() { + chromeReadyDone = false; + overlaysLoaded = false; + // Window ground + chrome view ground both match chrome.html's --bg for the + // active theme. The chrome view used to sit on Chromium's default white + // until chrome.html painted, which is the "white strip over a dark + // window" users saw on a slow launch. + const uiBg = nativeTheme.shouldUseDarkColors ? "#0f1420" : "#e6e8ec"; win = new BrowserWindow({ - width: 1220, height: 840, title: "Theseus Navigator", backgroundColor: "#0f1420", + width: 1220, height: 840, title: "Theseus Navigator", backgroundColor: uiBg, // Taskbar / titlebar icon. Packaged builds ship build/icon.ico as // extraResource; dev reads the source file directly. icon: app.isPackaged @@ -2629,50 +2741,53 @@ function createWindow() { : path.join(__dirname, "build", "icon.ico"), }); chrome = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") } }); + try { chrome.setBackgroundColor(uiBg); } catch {} win.contentView.addChildView(chrome); styleScrollbars(chrome.webContents); chrome.webContents.loadFile("chrome.html"); + // The overlays below are created now (cheap) but their pages load via + // deferOverlayLoad → loadOverlays() after chrome has painted. // Floating site-info overlay (hidden until the address-bar badge is clicked). popover = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "popover-preload.js") } }); try { popover.setBackgroundColor("#00000000"); } catch {} win.contentView.addChildView(popover); styleScrollbars(popover.webContents); - popover.webContents.loadFile("popover.html"); + deferOverlayLoad(popover, "popover.html"); popover.setVisible(false); // Floating engine-picker overlay (custom dropdown with real favicons). enginePicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "engine-picker-preload.js") } }); try { enginePicker.setBackgroundColor("#00000000"); } catch {} win.contentView.addChildView(enginePicker); styleScrollbars(enginePicker.webContents); - enginePicker.webContents.loadFile("engine-picker.html"); + deferOverlayLoad(enginePicker, "engine-picker.html"); enginePicker.setVisible(false); // Floating downloads panel — shows active + recent downloads. downloadsPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "downloads-preload.js") } }); try { downloadsPop.setBackgroundColor("#00000000"); } catch {} win.contentView.addChildView(downloadsPop); styleScrollbars(downloadsPop.webContents); - downloadsPop.webContents.loadFile("downloads.html"); + deferOverlayLoad(downloadsPop, "downloads.html"); downloadsPop.setVisible(false); // Floating address-bar suggestions dropdown. addressPicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "address-picker-preload.js") } }); try { addressPicker.setBackgroundColor("#00000000"); } catch {} win.contentView.addChildView(addressPicker); styleScrollbars(addressPicker.webContents); - addressPicker.webContents.loadFile("address-picker.html"); + deferOverlayLoad(addressPicker, "address-picker.html"); addressPicker.setVisible(false); // Floating password-fill picker. pwFillPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "pw-fill-preload.js") } }); try { pwFillPop.setBackgroundColor("#00000000"); } catch {} win.contentView.addChildView(pwFillPop); styleScrollbars(pwFillPop.webContents); - pwFillPop.webContents.loadFile("pw-fill.html"); + deferOverlayLoad(pwFillPop, "pw-fill.html"); pwFillPop.setVisible(false); // Link-hover status pill (bottom-left of window). linkStatus = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "link-status-preload.js") } }); try { linkStatus.setBackgroundColor("#00000000"); } catch {} win.contentView.addChildView(linkStatus); styleScrollbars(linkStatus.webContents); - linkStatus.webContents.loadFile("link-status.html"); + deferOverlayLoad(linkStatus, "link-status.html"); linkStatus.setVisible(false); // Add-on sidebar host. Doesn't loadFile until an add-on panel is opened — // styleScrollbars hooks dom-ready, which fires per navigation, so every @@ -2688,16 +2803,18 @@ function createWindow() { try { approvalPop.setBackgroundColor("#00000000"); } catch {} win.contentView.addChildView(approvalPop); styleScrollbars(approvalPop.webContents); - approvalPop.webContents.loadFile("approval.html"); + deferOverlayLoad(approvalPop, "approval.html"); approvalPop.setVisible(false); - chrome.webContents.once("did-finish-load", () => { - const saved = settings.restoreSession ? loadSession() : []; - if (saved.length) saved.forEach((u) => createTab(u)); else createTab(); - // Re-emit any pending update notice — the fetch may have completed - // before chrome finished loading, in which case the initial send was - // a no-op. - emitUpdateAvailable(); - }); + // Everything that isn't needed to paint the toolbar waits for chrome.html. + // dom-ready, NOT did-finish-load: the load event also waits for every + // subresource, and the bookmarks bar pulls its favicons over bns:// — + // a BNS lookup plus a network fetch each. On a slow link that held the + // load event (and with it every restored tab) for seconds while the + // toolbar sat blank. By dom-ready the toolbar's scripts have run and its + // IPC listeners exist, which is all the rest of boot needs. The fallback + // timer covers a chrome.html that fails to load at all. + chrome.webContents.once("dom-ready", onChromeReady); + setTimeout(onChromeReady, 8000); win.on("resize", layout); layout(); } @@ -3246,17 +3363,23 @@ function pumpApproval() { if (approvalCurrent || !approvalQueue.length || !approvalPop) return; const next = approvalQueue.shift(); approvalCurrent = next; - try { - approvalPop.webContents.send("approval-show", next.req); - approvalPop.setVisible(true); - try { win.contentView.removeChildView(approvalPop); win.contentView.addChildView(approvalPop); } catch {} - layout(); - approvalPop.webContents.focus(); - } catch (err) { - approvalCurrent = null; - next.resolve("cancel"); - console.warn("[addons] approval show failed:", err?.message); - } + // The overlay's page loads lazily after first paint; a dapp on a restored + // tab can ask for approval before that, so wait for the page rather than + // sending into an empty renderer (the request would silently hang). + overlayReady(approvalPop).then(() => { + if (approvalCurrent !== next) return; + try { + approvalPop.webContents.send("approval-show", next.req); + approvalPop.setVisible(true); + try { win.contentView.removeChildView(approvalPop); win.contentView.addChildView(approvalPop); } catch {} + layout(); + approvalPop.webContents.focus(); + } catch (err) { + approvalCurrent = null; + next.resolve("cancel"); + console.warn("[addons] approval show failed:", err?.message); + } + }); } function showApprovalModal(opts, addonId) { const a = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId); @@ -4752,9 +4875,12 @@ if (!process.env.THESEUS_NO_AUTOSTART) { createWindow(); // Multi-source BNS warm-up so the first .bch page opens near-instantly and // stays fresh for as long as the browser is running. Every source runs in - // parallel — none of them can block a navigation. + // parallel — none of them can block a navigation. Source 1 starts now + // (chrome.html's bookmark favicons are bns:// fetches, so the index has + // to be warm by the time the toolbar asks for them); 2–4 are kicked off + // from onChromeReady() once the toolbar has painted. // 1) sync: load the on-disk snapshot (user cache > bundled). - // sharedIndex is set BEFORE the first navigation can even fire. + // sharedIndex is set BEFORE the first restored tab navigates. // 2) async, continuous: startBnsPolling() opens one electrum connection // every 30 s, fetches the beacon history (one call), and only pulls // the tx bodies we don't already have. Merges into currentSnapshotState @@ -4767,20 +4893,16 @@ if (!process.env.THESEUS_NO_AUTOSTART) { // 4) fallback: ensureIndex() still exists for the very first launch // where the bundled snapshot is absent AND the poll hasn't landed // yet — a full-walk build. - warmFromSnapshot().catch(() => {}); - startBnsPolling(); - refreshSnapshotFromSia().catch(() => {}); - ensureIndex().catch(() => {}); // fallback for first launch without a bundled snapshot + bnsWarm = warmFromSnapshot().catch(() => null); // Cheap update check: fetch the releases manifest and, if a newer // version is out, surface a chip in the toolbar. No auto-install — - // clicking the chip opens the download URL. Recheck every 6h so a - // browser left running for days catches updates without a relaunch. - checkForUpdate().catch(() => {}); + // clicking the chip opens the download URL. First check runs from + // onChromeReady(); recheck every 6h so a browser left running for days + // catches updates without a relaunch. setInterval(() => checkForUpdate().catch(() => {}), 6 * 60 * 60 * 1000); // Home cards are also polled from a remote URL — brand copy updates then // reach every install without a browser release. User's local edits stay // authoritative (loadHomeCards checks them first). - refreshRemoteHomeCards().catch(() => {}); setInterval(() => refreshRemoteHomeCards().catch(() => {}), HOME_CARDS_REFRESH_MS); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); });