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.
This commit is contained in:
Local Dev 2026-09-27 11:22:07 +02:00
parent 729930f0b5
commit 0774235486

34
main.js
View file

@ -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 // 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 // maps to active = last tab, which is what the old restore loop ended up
// showing (each createTab activated itself, so the rightmost tab won). // showing (each createTab activated itself, so the rightmost tab won).
let sessionSavedAtClose = false;
function saveSession() { function saveSession() {
if (sessionSavedAtClose && !winAlive()) return; // already captured when the window closed
try { try {
const live = tabs.filter((t) => !t.settings && t.url); const live = tabs.filter((t) => !t.settings && t.url);
const active = Math.max(0, live.findIndex((t) => t.id === activeId)); 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 ---- // ---- window + tabs ----
let win, chrome; 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 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 // 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. // never pushes the page down. Positioned under the address-bar badge on demand.
@ -2398,7 +2402,7 @@ function pushNav(prov) {
} }
function layout() { function layout() {
if (!win) return; if (!winAlive()) return;
const { width, height } = win.getContentBounds(); const { width, height } = win.getContentBounds();
chrome.setBounds({ x: 0, y: 0, width, height: CHROME_H }); chrome.setBounds({ x: 0, y: 0, width, height: CHROME_H });
const bodyH = Math.max(0, height - CHROME_H); const bodyH = Math.max(0, height - CHROME_H);
@ -2471,19 +2475,19 @@ function showDownloads(show) {
} else { downloadsPop.setVisible(false); dlVisible = false; } } else { downloadsPop.setVisible(false); dlVisible = false; }
} }
function positionAddressPicker() { function positionAddressPicker() {
if (!addressPicker) return; if (!addressPicker || !winAlive()) return;
const { width } = win.getContentBounds(); const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(apPos.x, width - apW - 6)); const x = Math.max(6, Math.min(apPos.x, width - apW - 6));
addressPicker.setBounds({ x, y: apPos.y, width: apW, height: apH }); addressPicker.setBounds({ x, y: apPos.y, width: apW, height: apH });
} }
function positionPwFill() { function positionPwFill() {
if (!pwFillPop) return; if (!pwFillPop || !winAlive()) return;
const { width } = win.getContentBounds(); const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(pwfPos.x, width - PWF_W - 6)); const x = Math.max(6, Math.min(pwfPos.x, width - PWF_W - 6));
pwFillPop.setBounds({ x, y: pwfPos.y, width: PWF_W, height: pwfH }); pwFillPop.setBounds({ x, y: pwfPos.y, width: PWF_W, height: pwfH });
} }
function positionLinkStatus() { function positionLinkStatus() {
if (!linkStatus || !win) return; if (!linkStatus || !winAlive()) return;
const { width, height } = win.getContentBounds(); const { width, height } = win.getContentBounds();
// The pill may grow to (almost) the full width of the tab area — long // 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 // 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 }); linkStatus.setBounds({ x: 0, y: Math.max(0, height - h), width: w, height: h });
} }
function showLinkStatus(url) { function showLinkStatus(url) {
if (!linkStatus) return; if (!linkStatus || !winAlive()) return;
const s = String(url || ""); const s = String(url || "");
if (!s) { if (!s) {
if (linkStatusVisible) { linkStatus.setVisible(false); linkStatusVisible = false; } if (linkStatusVisible) { linkStatus.setVisible(false); linkStatusVisible = false; }
@ -2802,8 +2806,8 @@ function emitTabs() {
collapsedGroups: [...tabGroupCollapsed], collapsedGroups: [...tabGroupCollapsed],
url: t?.url || "", url: t?.url || "",
loading: !!t?.loading, loading: !!t?.loading,
canBack: wc ? wc.navigationHistory.canGoBack() : false, canBack: (() => { try { return !!wc && wc.navigationHistory.canGoBack(); } catch { return false; } })(),
canForward: wc ? wc.navigationHistory.canGoForward() : false, canForward: (() => { try { return !!wc && wc.navigationHistory.canGoForward(); } catch { return false; } })(),
zoom: zoomPercent(t), zoom: zoomPercent(t),
webapp: webapps.chipState(t), webapp: webapps.chipState(t),
}); });
@ -3279,6 +3283,11 @@ function closeTab(id) {
function createWindow() { function createWindow() {
chromeReadyDone = false; chromeReadyDone = false;
overlaysLoaded = 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 // 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 // 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 // until chrome.html painted, which is the "white strip over a dark
@ -3382,6 +3391,17 @@ function createWindow() {
chrome.webContents.once("dom-ready", onChromeReady); chrome.webContents.once("dom-ready", onChromeReady);
setTimeout(onChromeReady, 8000); setTimeout(onChromeReady, 8000);
win.on("resize", layout); 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(); layout();
} }