From 0774235486d6c84e658699d50b2d33d10a2c55e7 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 27 Sep 2026 11:22:07 +0200 Subject: [PATCH] fix(theseus): closing the browser window must not leave tabs talking to a destroyed window Tabs keep emitting events while the window is torn down: a hovered link fires update-target-url, which positioned the link-status pill against win.getContentBounds() on a destroyed window ("Object has been destroyed", 2026-09-27). With installed web apps the browser window can now close while their windows keep the process alive, so this stops being a quit-time blip and becomes a normal state. The overlay helpers and layout() now check the window is alive, the session is captured on close (the quit-time save no longer overwrites it with an empty list once the tabs are gone), and "closed" drops every reference to the window's views. A later createWindow() starts from a clean tab list, so a page opened from an app window after the browser window was closed brings the window back with the restored session. --- main.js | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/main.js b/main.js index 00f2b0fd..24615217 100644 --- a/main.js +++ b/main.js @@ -1009,7 +1009,9 @@ 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). +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 active = Math.max(0, live.findIndex((t) => t.id === activeId)); @@ -1702,6 +1704,8 @@ a.btn{display:inline-block;margin-top:16px;padding:9px 16px;border-radius:999px; // ---- window + tabs ---- let win, chrome; +// The window can be gone while tabs and app windows still fire events. +const winAlive = () => !!win && !win.isDestroyed(); let CHROME_H = 84; // grows when an extra bar (Tor notice / BCNR offer) is shown // Site-info popover: a floating overlay VIEW on top of the page content, so it // never pushes the page down. Positioned under the address-bar badge on demand. @@ -2398,7 +2402,7 @@ function pushNav(prov) { } function layout() { - if (!win) return; + if (!winAlive()) return; const { width, height } = win.getContentBounds(); chrome.setBounds({ x: 0, y: 0, width, height: CHROME_H }); const bodyH = Math.max(0, height - CHROME_H); @@ -2471,19 +2475,19 @@ function showDownloads(show) { } else { downloadsPop.setVisible(false); dlVisible = false; } } function positionAddressPicker() { - if (!addressPicker) return; + if (!addressPicker || !winAlive()) return; const { width } = win.getContentBounds(); const x = Math.max(6, Math.min(apPos.x, width - apW - 6)); addressPicker.setBounds({ x, y: apPos.y, width: apW, height: apH }); } function positionPwFill() { - if (!pwFillPop) return; + if (!pwFillPop || !winAlive()) return; const { width } = win.getContentBounds(); const x = Math.max(6, Math.min(pwfPos.x, width - PWF_W - 6)); pwFillPop.setBounds({ x, y: pwfPos.y, width: PWF_W, height: pwfH }); } function positionLinkStatus() { - if (!linkStatus || !win) return; + if (!linkStatus || !winAlive()) return; const { width, height } = win.getContentBounds(); // The pill may grow to (almost) the full width of the tab area — long // URLs stay readable — and ellipsises past that. It never runs under @@ -2494,7 +2498,7 @@ function positionLinkStatus() { linkStatus.setBounds({ x: 0, y: Math.max(0, height - h), width: w, height: h }); } function showLinkStatus(url) { - if (!linkStatus) return; + if (!linkStatus || !winAlive()) return; const s = String(url || ""); if (!s) { if (linkStatusVisible) { linkStatus.setVisible(false); linkStatusVisible = false; } @@ -2802,8 +2806,8 @@ function emitTabs() { collapsedGroups: [...tabGroupCollapsed], url: t?.url || "", loading: !!t?.loading, - canBack: wc ? wc.navigationHistory.canGoBack() : false, - canForward: wc ? wc.navigationHistory.canGoForward() : false, + canBack: (() => { try { return !!wc && wc.navigationHistory.canGoBack(); } catch { return false; } })(), + canForward: (() => { try { return !!wc && wc.navigationHistory.canGoForward(); } catch { return false; } })(), zoom: zoomPercent(t), webapp: webapps.chipState(t), }); @@ -3279,6 +3283,11 @@ function closeTab(id) { function createWindow() { chromeReadyDone = false; overlaysLoaded = false; + sessionSavedAtClose = false; + if (tabs.length) { // leftovers from a window that closed while app windows kept the process alive + for (const t of tabs.splice(0)) { try { t.view.webContents.destroy?.(); } catch {} } + activeId = null; + } // 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 @@ -3382,6 +3391,17 @@ function createWindow() { chrome.webContents.once("dom-ready", onChromeReady); setTimeout(onChromeReady, 8000); win.on("resize", layout); + // The browser window can close while app windows (webapps.js) keep the + // process alive. Capture the session while the tabs are still here, then + // let go of the window's views: tab events (hover → update-target-url, + // title updates) keep arriving during teardown and used to reach the + // destroyed window through positionLinkStatus (2026-09-27). + win.on("close", () => { saveSession(); sessionSavedAtClose = true; }); + win.on("closed", () => { + win = null; chrome = null; popover = null; enginePicker = null; downloadsPop = null; + addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; approvalPop = null; + linkStatusVisible = false; + }); layout(); }