From f803491844bc4991b32a04d2082e328d221ce4c1 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 7 Oct 2026 23:08:20 +0200 Subject: [PATCH] Theseus: move a tab to a new window Right-click a tab and pick Move to new window, or drag it out of the tab bar and let go off the window or over the page. The tab's own page moves over, so sign-ins, scroll position and typed text stay as they were. Each window has its own toolbar and tabs. The quick links, side panels and sidebar stay in the first window and pass to another one if it closes; the shared pop-ups (site info, downloads, password fill, page dialogs, approvals) open in whichever window asks. Messages from a window and events from its tabs run in that window's state, so the existing single-window code keeps working. Closing a window's last tab closes that window. The session file keeps every window with its place on screen, and still opens in older builds as the first window. --- chrome.html | 13 +- main.js | 702 +++++++++++++++++++++++++++++++++++++++------------- preload.js | 3 + 3 files changed, 543 insertions(+), 175 deletions(-) diff --git a/chrome.html b/chrome.html index 7064b88e..0da20313 100644 --- a/chrome.html +++ b/chrome.html @@ -1642,8 +1642,17 @@ // chrome view taller and opened a gap under the toolbar). el.addEventListener("contextmenu", (e) => { e.preventDefault(); if (T.tabContextMenuPopup) T.tabContextMenuPopup(id, { x: e.clientX, y: e.clientY }); }); // Drag-reorder — drop side chosen by pointer half (matches Chrome UX). - el.addEventListener("dragstart", (e) => { dragId = id; try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); } catch {} el.classList.add("dragging"); }); - el.addEventListener("dragend", () => { el.classList.remove("dragging"); el.parentElement?.querySelectorAll(".tab").forEach((r) => r.classList.remove("dropbefore", "dropafter")); dragId = null; }); + // Its own data type, not text/plain: let go over a page, a plain-text tab + // id would have been typed into whatever field was under the pointer. + el.addEventListener("dragstart", (e) => { dragId = id; try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("application/x-theseus-tab", String(id)); } catch {} el.classList.add("dragging"); }); + el.addEventListener("dragend", (e) => { + el.classList.remove("dragging"); el.parentElement?.querySelectorAll(".tab").forEach((r) => r.classList.remove("dropbefore", "dropafter")); + // Dropped where nothing took it (off the window, or over the page): a + // new window. Esc reports (0,0) on Windows; that is a cancel, not a drop. + const dropped = e.dataTransfer && e.dataTransfer.dropEffect === "none" && (e.screenX || e.screenY); + if (dropped && dragId === id && T.detachTab) T.detachTab(id, e.screenX, e.screenY); + dragId = null; + }); el.addEventListener("dragover", (e) => { if (dragId == null || id === dragId) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; diff --git a/main.js b/main.js index 70a68622..22957c31 100644 --- a/main.js +++ b/main.js @@ -683,7 +683,7 @@ let bookmarks = []; const bookmarksFile = () => path.join(app.getPath("userData"), "bookmarks.json"); function loadBookmarks() { try { if (fs.existsSync(bookmarksFile())) bookmarks = JSON.parse(fs.readFileSync(bookmarksFile(), "utf8")); } catch (e) { console.error("bookmarks load failed:", e.message); } } function saveBookmarks() { try { fs.writeFileSync(bookmarksFile(), JSON.stringify(bookmarks, null, 2)); } catch (e) { console.error("bookmarks save failed:", e.message); } } -function emitBookmarks() { try { chrome?.webContents.send("bookmarks", bookmarks); } catch {} } +function emitBookmarks() { sendAllChrome("bookmarks", bookmarks); } // ---- in-app update check (cheap) ------------------------------------------ // Fetch the releases manifest at startup + every 6h. If it names a Theseus @@ -803,7 +803,7 @@ function emitUpdateAvailable() { downloadReceived: updateDownloadReceived, downloadTotal: updateDownloadTotal, } : null; - try { chrome?.webContents.send("update-available", payload); } catch {} + sendAllChrome("update-available", payload); } // ---- home page editable cards (userData/home-cards.json) ------------------ @@ -925,8 +925,17 @@ function isSettingsPage(sender) { return tabs.some((t) => t.settings && t.view?.webContents === sender) && isAppPage(sender, "settings.html"); } { + // Every IPC handler runs in the context of the window it came from (see + // withWin): the toolbar of a second window, or a page inside it, sees that + // window's tabs and active tab, and the sender checks below compare against + // that window's toolbar. Applied outermost, before the guards. const handle = ipcMain.handle.bind(ipcMain); - ipcMain.handle = (channel, fn) => handle(channel, + const on = ipcMain.on.bind(ipcMain); + const once = ipcMain.once.bind(ipcMain); + const inSenderWin = (fn) => function (e, ...args) { return withWin(winOfWebContents(e?.sender), () => fn.call(this, e, ...args)); }; + ipcMain.on = (channel, fn) => on(channel, inSenderWin(fn)); + ipcMain.once = (channel, fn) => once(channel, inSenderWin(fn)); + ipcMain.handle = (channel, fn) => handle(channel, inSenderWin( HERMES_ONLY.has(channel) ? (e, ...args) => { if (!hermesWin || hermesWin.isDestroyed() || e.sender !== hermesWin.webContents) throw new Error(`${channel}: messages window only`); @@ -938,7 +947,7 @@ function isSettingsPage(sender) { if (!ok) throw new Error(`${channel}: settings only`); return fn(e, ...args); } - : fn); + : fn)); } // ---- address-bar history (userData/history.json) -------------------------- @@ -1108,7 +1117,7 @@ small{color:#b98b92}code{font:12px ui-monospace,monospace;word-break:break-all}< } function emitEngines() { - try { chrome?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine }); } catch {} + sendAllChrome("engines", { engines: enabledEnginesList(), current: settings.searchEngine }); if (epVisible) try { enginePicker?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null }); } catch {} } // WebRTC IP-handling policy — the same control the "WebRTC Network Limiter" @@ -1446,14 +1455,35 @@ const sessionFile = () => path.join(app.getPath("userData"), "session.json"); // 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. +// Several windows: { v: 3, tabs, active, windows: [{ tabs, active, bounds }] }. +// `tabs`/`active` repeat the first (primary) window, so a build from before +// multi-window restores that one window; `windows` lists all of them. let sessionSavedAtClose = false; let sessionDroppedForQuit = false; // clear-history-on-quit already deleted it; the window's close must not rewrite it +let appQuitting = false; // the quit saved every window; windows closing after it must not rewrite it function saveSession() { if (sessionDroppedForQuit) return; if (sessionSavedAtClose && !winAlive()) return; // already captured when the window closed try { - const live = tabs.filter((t) => !t.settings && (t.url || t.pending?.url)); - const active = Math.max(0, live.findIndex((t) => t.id === activeId)); + saveWinCtx(); + const order = [primaryW, ...windows.filter((w) => w !== primaryW)].filter(Boolean); + const list = []; + for (const w of order) { + const one = sessionWindow(tabs.filter((t) => t.w === w), w === curW ? activeId : w.activeId); + if (!one.tabs.length) continue; + try { if (w.win && !w.win.isDestroyed()) one.bounds = w.win.getNormalBounds(); } catch {} + list.push(one); + } + // No window record (the window is gone): the tabs as they stand. + if (!order.length) list.push(sessionWindow(tabs, activeId)); + const first = list[0] || { tabs: [], active: 0 }; + fs.writeFileSync(sessionFile(), JSON.stringify({ v: 3, tabs: first.tabs, active: first.active, windows: list })); + } catch (e) { console.error("session save failed:", e.message); } +} +function sessionWindow(winTabList, activeTabId) { + { + const live = winTabList.filter((t) => !t.settings && (t.url || t.pending?.url)); + const active = Math.max(0, live.findIndex((t) => t.id === activeTabId)); 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. @@ -1463,28 +1493,36 @@ function saveSession() { 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); } + return { tabs: data, active }; + } } function loadSession() { - const empty = { tabs: [], active: 0 }; + const empty = { windows: [] }; + const one = (rawTabs, rawActive, rawBounds) => { + const t = (Array.isArray(rawTabs) ? rawTabs : []).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(rawActive) || 0, 0), Math.max(t.length - 1, 0)); + const b = rawBounds && ["x", "y", "width", "height"].every((k) => Number.isFinite(rawBounds[k])) ? rawBounds : null; + return { tabs: t, active, bounds: b }; + }; try { if (!fs.existsSync(sessionFile())) return empty; const raw = JSON.parse(fs.readFileSync(sessionFile(), "utf8")); - // v3: {v:3, tabs:[{url,title,favicon}], active} + // v3: {v:3, tabs:[{url,title,favicon}], active, windows?:[{tabs, active, bounds}]} + if (raw && raw.v === 3 && Array.isArray(raw.windows) && raw.windows.length) { + return { windows: raw.windows.map((w) => one(w && w.tabs, w && w.active, w && w.bounds)).filter((w) => w.tabs.length) }; + } 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 }; + const w = one(raw.tabs, raw.active, null); + return { windows: w.tabs.length ? [w] : [] }; } // 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 { tabs: urls.map((url) => ({ url, title: "", favicon: null })), active }; + return { windows: urls.length ? [{ tabs: urls.map((url) => ({ url, title: "", favicon: null })), active, bounds: null }] : [] }; } catch { return empty; } } // Session restore, lazy except for the page on screen: only the tab that was @@ -1495,8 +1533,19 @@ function loadSession() { // whether the session has 2 or 50 tabs, and a saved heavy page in the // background doesn't come back as a 500 MB renderer nobody asked to see. function restoreTabs() { - const saved = settings.restoreSession ? loadSession() : { tabs: [], active: 0 }; - if (!saved.tabs.length) { createTab(); return; } + const saved = settings.restoreSession ? loadSession() : { windows: [] }; + if (!saved.windows.length) { createTab(); return; } + restoreWindowTabs(saved.windows[0]); + // Every other window comes back as a window of its own, with its tabs + // dormant except the one that was in front, like the first. + for (const ws of saved.windows.slice(1)) { + try { + const w = makeBrowserWindow({ bounds: ws.bounds }); + whenChromeReady(w, () => withWin(w, () => { restoreWindowTabs(ws); layout(); })); + } catch (e) { console.warn("session restore: window failed:", e?.message); } + } +} +function restoreWindowTabs(saved) { for (let i = 0; i < saved.tabs.length; i++) { try { createTab(null, { background: true, pending: saved.tabs[i] }); } catch (e) { console.warn("session restore: tab failed:", e?.message); } @@ -1507,12 +1556,14 @@ function restoreTabs() { // applied by hand rather than through setActive(), which would also run // the switch-time side effects (sidebar restore, nav push) for a tab the // user did not switch to. - const idx = Math.min(Math.max(0, saved.active | 0), tabs.length - 1); - const target = tabs[idx]; + const mine = winTabs(); + if (!mine.length) { createTab(); return; } + const idx = Math.min(Math.max(0, saved.active | 0), mine.length - 1); + const target = mine[idx]; if (target) { activeId = target.id; try { target.view.setVisible(true); } catch {} - for (const t of tabs) if (t.id !== activeId) { try { t.view.setVisible(false); } catch {} } + for (const t of mine) if (t.id !== activeId) { try { t.view.setVisible(false); } catch {} } if (target.pending) materializePending(target); } emitTabs(); @@ -1851,7 +1902,7 @@ let torProc = null, torState = "off"; let torWsAgent = null; let SocksProxyAgent; async function loadSocks() { if (!SocksProxyAgent) ({ SocksProxyAgent } = await import("socks-proxy-agent")); } -function sendTor() { try { chrome?.webContents.send("tor", { state: torState }); } catch {} } +function sendTor() { sendAllChrome("tor", { state: torState }); } async function startTor() { if (torProc) return; torState = "connecting"; sendTor(); @@ -2425,11 +2476,92 @@ a.btn{display:inline-block;margin-top:16px;padding:9px 16px;border-radius:999px; } // ---- window + tabs ---- +// `win`, `chrome`, `activeId`, the fullscreen flags and the toolbar heights +// below belong to ONE browser window: the current one (curW). Each window's +// own copy lives in its record in `windows`; withWin() swaps a window's state +// into these globals, runs code, and swaps back. Code written for one window +// keeps working unchanged: IPC handlers run in the window that sent them, +// tab events in the window that holds the tab, everything else in the window +// the user last focused. 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_OVERLAY_H = 0; // > 0 while a chrome.html prompt floats over the page +// { id, win, chrome, activeId, fsTabId, userFullscreen, chromeH, overlayH, chromeReady } +const windows = []; +let curW = null; // window whose state is in the globals +let primaryW = null; // the window with the quick links, side panels and sidebar +let winSeq = 0; +function saveWinCtx() { + if (!curW) return; + curW.win = win; curW.chrome = chrome; curW.activeId = activeId; + curW.fsTabId = fsTabId; curW.userFullscreen = userFullscreen; + curW.chromeH = CHROME_H; curW.overlayH = CHROME_OVERLAY_H; +} +function loadWinCtx(w) { + curW = w || null; + win = w ? w.win : null; chrome = w ? w.chrome : null; activeId = w ? w.activeId : null; + fsTabId = w ? w.fsTabId : null; userFullscreen = w ? !!w.userFullscreen : false; + CHROME_H = w ? w.chromeH : 84; CHROME_OVERLAY_H = w ? w.overlayH : 0; +} +// Run fn with window w's state in the globals. Only the synchronous part runs +// there: after an await, code is back in the current window. +let ctxDepth = 0; // > 0 while withWin has swapped another window in +function withWin(w, fn) { + if (!w || w === curW || !windows.includes(w)) return fn(); + const prev = curW; + saveWinCtx(); loadWinCtx(w); ctxDepth++; + try { return fn(); } + finally { + ctxDepth--; + if (windows.includes(w)) saveWinCtx(); + loadWinCtx(prev && windows.includes(prev) ? prev : (windows.includes(w) ? w : windows[windows.length - 1])); + } +} +const winTabs = (w = curW) => tabs.filter((t) => t.w === w); +// The browser window a webContents belongs to: a toolbar, a tab, an overlay +// shown in a window, or one of the primary window's side panels. +function winOfWebContents(wc) { + if (!wc) return null; + for (const w of windows) if (w.chrome && w.chrome.webContents === wc) return w; + const t = tabs.find((x) => x.view.webContents === wc); + if (t) return t.w || null; + for (const [view, w] of hostOf) if (view.webContents === wc) return windows.includes(w) ? w : null; + for (const v of [sidebar, quicklinks, quickPanel, leftPanel, panelGrip]) if (v && v.webContents === wc) return primaryW; + return null; +} +// Shared overlays (site info, pickers, downloads, password fill, link pill, +// approval, unlock, page dialogs) are one set of views for all windows; each +// lives in the window it was last shown in. raise() moves it to window w if +// needed and brings it to the front there. +const hostOf = new Map(); // view -> window record +function raise(view, w = curW) { + if (!view || !w || !w.win || w.win.isDestroyed()) return; + const from = hostOf.get(view); + try { + if (from && from !== w && from.win && !from.win.isDestroyed()) from.win.contentView.removeChildView(view); + else if (from === w) w.win.contentView.removeChildView(view); + } catch {} + try { w.win.contentView.addChildView(view); } catch {} + hostOf.set(view, w); +} +const hostedHere = (view) => !!view && hostOf.get(view) === curW; +// Toolbar messages every window shows (settings, bookmarks, Tor, updates…). +function sendAllChrome(channel, ...args) { + saveWinCtx(); + for (const w of windows) { try { if (w.chrome && !w.chrome.webContents.isDestroyed()) w.chrome.webContents.send(channel, ...args); } catch {} } +} +// The tab id is the active tab of its own window (and that window is the +// current one): what `id === activeId` meant with a single window. +const isActiveId = (id) => id != null && id === activeId && (tabById(id)?.w ?? curW) === curW; +// The tab is the one on screen in its own window, whichever window is current. +function isShownTabId(id) { + const t = id != null ? tabById(id) : null; + if (!t) return false; + if (!t.w || t.w === curW) return t.id === activeId; + return t.w.activeId === t.id; +} // 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. let popover, popVisible = false, popPos = { x: 8, y: 90 }; @@ -3555,8 +3687,17 @@ function toggleUserFullscreen() { try { win.setFullScreen(userFullscreen); } catch {} layout(); } +// Every window, each with its own toolbar and tabs. The quick links, the left +// panels and the sidebar belong to the primary window; a shared overlay is +// positioned in the window it is showing in. function layout() { + saveWinCtx(); + if (!windows.length) return layoutHere(); + for (const w of windows) withWin(w, layoutHere); +} +function layoutHere() { if (!winAlive()) return; + const primary = !curW || curW === primaryW; if (fsTabId == null && !userFullscreen) { try { if (win.isFullScreen()) scheduleFullscreenCheck(); } catch {} } const { width, height } = win.getContentBounds(); const fsTab = fsTabId != null ? tabs.find((t) => t.id === fsTabId) : null; @@ -3571,32 +3712,32 @@ function layout() { // Quick-links strip on the LEFT edge (optional, 44px). Hidden in HTML // fullscreen so a video truly fills the window. The strip spans the full // body height alongside the tab view. - const leftW = (quicklinks && settings.quickLinksShow && !fsTab) ? QUICKLINKS_W : 0; - if (quicklinks) { + const leftW = (primary && quicklinks && settings.quickLinksShow && !fsTab) ? QUICKLINKS_W : 0; + if (quicklinks && primary) { quicklinks.setBounds({ x: 0, y: chromeH, width: leftW, height: bodyH }); try { quicklinks.setVisible(leftW > 0); } catch {} } // Quick-link panel: a narrow mini-view just right of the strip, showing the // active quick-link's web app. Only takes space when something is open. - const quickW = (quickPanel && activeQuickLinkId && !fsTab) ? leftPanelWidth(QUICK_PANEL_W, width - leftW) : 0; - if (quickPanel) { + const quickW = (primary && quickPanel && activeQuickLinkId && !fsTab) ? leftPanelWidth(QUICK_PANEL_W, width - leftW) : 0; + if (quickPanel && primary) { quickPanel.setBounds({ x: leftW, y: chromeH, width: quickW, height: bodyH }); try { quickPanel.setVisible(quickW > 0); } catch {} } // Sidebar (when visible) claims a fixed slice on the right; the tab views // shrink to fit alongside it. When hidden, tabs get the full width. - const sideW = sidebarVisible && !fsTab ? sidebarW : 0; + const sideW = primary && sidebarVisible && !fsTab ? sidebarW : 0; // An add-on's left panel. Widened, it covers the tab area like a page of // its own, up to the right sidebar, which keeps its width: the tabs keep // their narrow-panel bounds underneath instead of being squeezed aside. - const addonLeftW = (leftPanel && leftPanelId && !fsTab) ? leftPanelWidth(LEFT_PANEL_W, width - leftW) : 0; + const addonLeftW = (primary && leftPanel && leftPanelId && !fsTab) ? leftPanelWidth(LEFT_PANEL_W, width - leftW) : 0; const addonLeftShownW = addonLeftW && leftPanelMax ? Math.max(addonLeftW, width - leftW - sideW) : addonLeftW; - if (leftPanel) { + if (leftPanel && primary) { leftPanel.setBounds({ x: leftW, y: chromeH, width: addonLeftShownW, height: bodyH }); try { leftPanel.setVisible(addonLeftShownW > 0); } catch {} } const panelW = quickW || addonLeftW; - if (panelGrip) { + if (panelGrip && primary) { // At rest a thin strip on the boundary; while dragging, the whole body, // so the drag keeps its events wherever the pointer goes. const showGrip = panelW > 0 && !(leftPanelId && leftPanelMax); @@ -3606,21 +3747,21 @@ function layout() { } const tabX = leftW + panelW; const tabW = Math.max(0, width - tabX - sideW); - for (const t of tabs) t.view.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); - if (sidebar) sidebar.setBounds({ x: tabX + tabW, y: chromeH, width: sideW, height: bodyH }); + for (const t of winTabs()) t.view.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); + if (sidebar && primary) sidebar.setBounds({ x: tabX + tabW, y: chromeH, width: sideW, height: bodyH }); // Approval overlay sits exactly over the tab area — the page underneath // keeps running; only pointer input is intercepted. - if (approvalPop) approvalPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); + if (hostedHere(approvalPop)) approvalPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); // The vault unlock prompt covers the whole body, sidebar included: the // add-on asking for it often lives in the sidebar, which may be widened. - if (unlockPop) unlockPop.setBounds({ x: 0, y: chromeH, width, height: bodyH }); - if (jsDialogPop) jsDialogPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); - positionPopover(); - positionEnginePicker(); - positionDownloads(); - positionAddressPicker(); - positionPwFill(); - if (linkStatusVisible) positionLinkStatus(); + if (hostedHere(unlockPop)) unlockPop.setBounds({ x: 0, y: chromeH, width, height: bodyH }); + if (hostedHere(jsDialogPop)) jsDialogPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH }); + if (hostedHere(popover)) positionPopover(); + if (hostedHere(enginePicker)) positionEnginePicker(); + if (hostedHere(downloadsPop)) positionDownloads(); + if (hostedHere(addressPicker)) positionAddressPicker(); + if (hostedHere(pwFillPop)) positionPwFill(); + if (linkStatusVisible && hostedHere(linkStatus)) positionLinkStatus(); } function positionPopover() { if (!popover) return; @@ -3634,8 +3775,7 @@ function showPopover(show) { if (deferUntilOverlayLoaded(popover, () => showPopover(true))) return; positionPopover(); // Re-add to the top of the z-order (tabs added later would otherwise cover it). - win.contentView.removeChildView(popover); - win.contentView.addChildView(popover); + raise(popover); popover.setVisible(true); popVisible = true; if (win.isFocused()) try { popover.webContents.focus(); } catch {} // see closeOnClickAway popover.webContents.send("site-info", decorate(activeTab()?.prov) || { kind: "home" }); @@ -3652,8 +3792,7 @@ function showEnginePicker(show) { if (show) { if (deferUntilOverlayLoaded(enginePicker, () => showEnginePicker(true))) return; positionEnginePicker(); - win.contentView.removeChildView(enginePicker); - win.contentView.addChildView(enginePicker); + raise(enginePicker); enginePicker.setVisible(true); epVisible = true; if (win.isFocused()) try { enginePicker.webContents.focus(); } catch {} // see closeOnClickAway enginePicker.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null }); @@ -3697,8 +3836,7 @@ function showLangPicker(show) { if (show) { if (deferUntilOverlayLoaded(langPicker, () => showLangPicker(true))) return; positionLangPicker(); - win.contentView.removeChildView(langPicker); - win.contentView.addChildView(langPicker); + raise(langPicker); langPicker.setVisible(true); lpVisible = true; if (win.isFocused()) try { langPicker.webContents.focus(); } catch {} langPicker.webContents.send("lang-picker-state", langPickerPayload()); @@ -3716,8 +3854,7 @@ function showDownloads(show) { if (show) { if (deferUntilOverlayLoaded(downloadsPop, () => showDownloads(true))) return; positionDownloads(); - win.contentView.removeChildView(downloadsPop); - win.contentView.addChildView(downloadsPop); + raise(downloadsPop); downloadsPop.setVisible(true); dlVisible = true; if (win.isFocused()) try { downloadsPop.webContents.focus(); } catch {} // see closeOnClickAway downloadsPop.webContents.send("downloads", downloadsPublic()); @@ -3760,7 +3897,7 @@ function showLinkStatus(url) { if (deferUntilOverlayLoaded(linkStatus, () => showLinkStatus(linkStatusPendingUrl))) return; positionLinkStatus(); // Raise the pill above any tab view that was added after it. - try { win.contentView.removeChildView(linkStatus); win.contentView.addChildView(linkStatus); } catch {} + raise(linkStatus); linkStatus.setVisible(true); linkStatusVisible = true; try { linkStatus.webContents.send("link-status-url", s); } catch {} } @@ -3808,7 +3945,7 @@ function autoHideQuietDock() { if (changed) { settings.dockHidden = [...hidden]; settings.dockAutoHidden = [...seen]; saveSettings(); } } function emitSidebarState() { - try { chrome?.webContents.send("sidebar-state", sidebarStatePayload()); } catch {} + sendAllChrome("sidebar-state", sidebarStatePayload()); } function setSidebar(show, panelId) { if (!sidebar) return; @@ -3843,7 +3980,7 @@ function setSidebar(show, panelId) { if (settings.sidebarHideOnRightPanel !== false) hideLeftPanels(); sidebarVisible = true; sidebar.setVisible(true); - try { win.contentView.removeChildView(sidebar); win.contentView.addChildView(sidebar); } catch {} + raise(sidebar, primaryW); layout(); try { sidebar.webContents.send("sidebar-visibility", true); } catch {} emitSidebarState(); @@ -3860,8 +3997,7 @@ function showPwFill(show, matches, extra = {}) { if (show) { if (deferUntilOverlayLoaded(pwFillPop, () => showPwFill(true, matches, extra))) return; positionPwFill(); - win.contentView.removeChildView(pwFillPop); - win.contentView.addChildView(pwFillPop); + raise(pwFillPop); pwFillPop.setVisible(true); pwfVisible = true; pwFillPop.webContents.send("pw-matches", { matches: matches || [], locked: !!extra.locked, host: extra.host || "", framed: !!extra.framed, topHost: extra.topHost || "" }); @@ -3957,8 +4093,7 @@ function showAddressPicker(show, suggestions) { if (!suggestions || !suggestions.length) return showAddressPicker(false); if (deferUntilOverlayLoaded(addressPicker, () => showAddressPicker(true, suggestions))) return; positionAddressPicker(); - win.contentView.removeChildView(addressPicker); - win.contentView.addChildView(addressPicker); + raise(addressPicker); addressPicker.setVisible(true); apVisible = true; addressPicker.webContents.send("address-suggest", { suggestions }); } else { cancelOverlayShow(addressPicker); addressPicker.setVisible(false); apVisible = false; } @@ -3967,7 +4102,7 @@ function showAddressPicker(show, suggestions) { const downloadsPublic = () => downloads.map((d) => ({ ...d })); function emitDownloads() { const pub = downloadsPublic(); - try { chrome?.webContents.send("downloads", pub); } catch {} + sendAllChrome("downloads", pub); if (dlVisible) try { downloadsPop?.webContents.send("downloads", pub); } catch {} } // Attach the will-download listener to the SHARED default session. Every tab's @@ -4127,9 +4262,9 @@ async function setTabFrozen(tab, frozen) { function scheduleFreeze(tab) { if (!tab) return; clearTimeout(tab.freezeTimer); - if (!settings.freezeBackgroundTabs || tab.keepRunning || tab.pending || !tab.url || tab.id === activeId) return; + if (!settings.freezeBackgroundTabs || tab.keepRunning || tab.pending || !tab.url || isShownTabId(tab.id)) return; tab.freezeTimer = setTimeout(() => { - if (tab.id === activeId || !tabs.includes(tab) || tab.keepRunning || tab.pending || tabHasPendingDialog(tab.id)) return; + if (isShownTabId(tab.id) || !tabs.includes(tab) || tab.keepRunning || tab.pending || tabHasPendingDialog(tab.id)) return; // A tab playing sound (music, a talk) keeps playing, as in any browser; // check again later and freeze it once it falls quiet. try { if (tab.view.webContents.isCurrentlyAudible()) { scheduleFreeze(tab); return; } } catch {} @@ -4143,9 +4278,15 @@ function thawTab(tab) { } // The setting turned off (or on): thaw everything (or freeze the background). function applyFreezeSetting() { - for (const t of tabs) { if (settings.freezeBackgroundTabs && t.id !== activeId) scheduleFreeze(t); else thawTab(t); } + for (const t of tabs) { if (settings.freezeBackgroundTabs && !isShownTabId(t.id)) scheduleFreeze(t); else thawTab(t); } } +// A tab is activated in its own window, whichever window asked. function setActive(id) { + const target = tabById(id); + if (target?.w && target.w !== curW) return withWin(target.w, () => setActiveHere(id)); + return setActiveHere(id); +} +function setActiveHere(id) { const switching = id !== activeId; const previous = switching ? tabs.find((x) => x.id === activeId) : null; activeId = id; @@ -4164,16 +4305,17 @@ function setActive(id) { // the incoming tab hidden behind a maximized sidebar. Auto-restore the // sidebar to its pre-max width so the tab is actually visible; the // user can re-maximize when they're done. - if (switching && sidebarMaximized) setSidebarMaximized(false); + // (The sidebar and the left panels live in the primary window only.) + if (curW === primaryW && switching && sidebarMaximized) setSidebarMaximized(false); // A widened left panel covers the tabs; bringing any tab forward (a tab // click, Settings, a new tab) narrows it back to a bar beside the page. - if (leftPanelMax) setLeftPanelMax(false); + if (curW === primaryW && leftPanelMax) setLeftPanelMax(false); // Show the NEW active tab first, THEN hide the others. Reversing this // order eliminates the "no tab is visible" frame on switch that made the // tab strip flash — the compositor always has at least one tab view up. 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); + for (const t of winTabs()) 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(); @@ -4189,11 +4331,16 @@ function setActive(id) { emitTabs(); if (t) emitTranslateState(t); } +// Every window's tab bar gets its own tabs. function emitTabs() { + saveWinCtx(); + for (const w of windows) withWin(w, emitTabsHere); +} +function emitTabsHere() { const t = activeTab(); const wc = t?.view.webContents; chrome?.webContents.send("tabs", { - tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "", asking: tabHasPendingDialog(x.id), keepRunning: !!x.keepRunning })), + tabs: winTabs().map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "", asking: tabHasPendingDialog(x.id), keepRunning: !!x.keepRunning })), collapsedGroups: [...tabGroupCollapsed], url: t?.url || "", loading: !!t?.loading, @@ -4312,7 +4459,7 @@ function loadHome(id) { const t = tabById(id); if (!t) return; t.url = ""; t.title = "Theseus"; t.prov = { host: "", kind: "home" }; t.view.webContents.loadFile("home.html"); - if (id === activeId) pushNav(t.prov); + if (isActiveId(id)) pushNav(t.prov); emitTabs(); } // Scan the warm BNS index for a name whose `p` record proxies the given @@ -4393,7 +4540,7 @@ function loadErrorPage(t, id, { url, code, desc }) { t.view.webContents.loadFile(path.join(__dirname, "error.html"), { search: q }) .catch((e) => console.warn("error page load failed:", e?.message)) .finally(() => { t.internalNav = false; }); - if (id === activeId) pushNav(t.prov); + if (isActiveId(id)) pushNav(t.prov); emitTabs(); } // Scrollbar theme injected into every webContents we own — tabs, chrome, @@ -4454,6 +4601,31 @@ function createTab(initial, opts = {}) { try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {} applyFingerprint(wc); const tab = { id, view, title: opts.settings ? "Settings" : "New Tab", url: "", favicon: null, prov: null, settings: !!opts.settings, muted: false, group: null, openerId: opts.after ?? null }; + // The window the tab lives in. Its events (title, navigation, fullscreen, + // popups, dialogs) run in that window's context, read at event time, so + // they keep working after the tab moves to another window. + tab.w = curW; + { + const wrapped = new WeakMap(); // listener -> its wrapper, so removeListener still works + const wrap = (fn) => { + if (typeof fn !== "function") return fn; + let w = wrapped.get(fn); + if (!w) { w = function (...a) { return withWin(tab.w, () => fn.apply(this, a)); }; wrapped.set(fn, w); } + return w; + }; + for (const name of ["on", "once", "addListener", "prependListener"]) { + const orig = wc[name].bind(wc); + wc[name] = (event, fn) => orig(event, wrap(fn)); + } + for (const name of ["removeListener", "off"]) { + const orig = wc[name].bind(wc); + wc[name] = (event, fn) => orig(event, (typeof fn === "function" && wrapped.get(fn)) || fn); + } + } + { + const setOpen = wc.setWindowOpenHandler.bind(wc); + wc.setWindowOpenHandler = (fn) => setOpen((details) => withWin(tab.w, () => fn(details))); + } // A tab opened from a link goes right after the tab it came from — and // after any siblings that tab already opened, so a run of links from one // page reads left-to-right — instead of at the far end of the strip. @@ -4485,7 +4657,7 @@ function createTab(initial, opts = {}) { // Chromium fires found-in-page on every findInPage call + on subsequent // match walks. Forward to chrome so the find bar shows "N of M". wc.on("found-in-page", (_e, r) => { - if (tab.id !== activeId) return; + if (!isActiveId(tab.id)) return; try { chrome?.webContents.send("find-result", { activeMatchOrdinal: r.activeMatchOrdinal, matches: r.matches, finalUpdate: r.finalUpdate }); } catch {} }); // A new document means a new (or no) web-app manifest; the chip follows. @@ -4512,9 +4684,9 @@ function createTab(initial, opts = {}) { const host = u.hostname.toLowerCase(); if (tab.prov && tab.prov.host === host) return; tab.prov = { host, kind: "web" }; - if (tab.id === activeId) pushNav(tab.prov); + if (isActiveId(tab.id)) pushNav(tab.prov); }); - wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } }); + wc.on("did-navigate", () => { if (isActiveId(tab.id)) { notifyTabChange(); emitPwAvailability(); } }); // Cloudflare Bot Fight Mode fingerprints Electron's TLS ClientHello and // returns 503 to Theseus regardless of user-agent. When we see 503 on a // top-level main-frame HTTPS load, sniff the body for the CF signature @@ -4553,13 +4725,13 @@ function createTab(initial, opts = {}) { wc.loadFile(path.join(__dirname, "error.html"), { search: q.toString() }) .catch((e) => console.warn("cf-block error page load failed:", e?.message)) .finally(() => { tab.internalNav = false; }); - if (tab.id === activeId) pushNav(tab.prov); + if (isActiveId(tab.id)) pushNav(tab.prov); emitTabs(); }) .catch(() => {}); }, 250); }); - wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); }); + wc.on("did-navigate-in-page", () => { if (isActiveId(tab.id)) notifyTabChange(); }); // Translator state is per-document: a new navigation drops any "translated" // flag, the autoTried latch, and the cached page language. The chip then // re-decides on the next pageLang read whether to light up — and auto- @@ -4572,7 +4744,7 @@ function createTab(initial, opts = {}) { const wasPending = !!tab._tr?.pending; tab._tr = wasPending ? { translated: false, source: "", target: "", error: "", pending: true } : null; tab.pageLang = ""; - if (tab.id === activeId) emitTranslateState(tab); + if (isActiveId(tab.id)) emitTranslateState(tab); }); // After the page has committed, read once so the chip knows // what language the server actually served (which may not match whatever @@ -4585,7 +4757,7 @@ function createTab(initial, opts = {}) { const lang = await wc.executeJavaScript(`document.documentElement.lang || ""`); tab.pageLang = String(lang || "").toLowerCase().split("-")[0]; } catch { tab.pageLang = ""; } - if (tab.id === activeId) emitTranslateState(tab); + if (isActiveId(tab.id)) emitTranslateState(tab); try { const target = translationTargetBase(); const st = tabTranslateState(tab); @@ -4597,9 +4769,9 @@ function createTab(initial, opts = {}) { // (b) Normal auto-offer — translateAutoOffer on, pageLang known and // different from the user's target, both ends supported, once // per document (autoTried latch). - const explicit = st.pending && tab.id === activeId; + const explicit = st.pending && isActiveId(tab.id); const pageSupported = tab.pageLang ? isTranslatorSupported(tab.pageLang) : true; // unknown -> try, backend decides - const autoFire = settings.translateAutoOffer && tab.id === activeId && + const autoFire = settings.translateAutoOffer && isActiveId(tab.id) && isTranslatorSupported(target) && pageSupported && (tab.pageLang ? tab.pageLang !== target : true) && !st.translated && !st.autoTried; @@ -4617,7 +4789,7 @@ function createTab(initial, opts = {}) { wc.on("did-stop-loading", () => setLoading(tab, false)); // Installable site? Read its manifest once the document is in; the chip // and the page's own beforeinstallprompt follow from tab.webapp. - wc.on("did-finish-load", () => { if (!tab.settings && !tab.addonId) webapps.probeTab(tab).then(() => { if (tab.id === activeId) emitTabs(); }).catch(() => {}); }); + wc.on("did-finish-load", () => { if (!tab.settings && !tab.addonId) webapps.probeTab(tab).then(() => { if (isActiveId(tab.id)) emitTabs(); }).catch(() => {}); }); // Failed loads: NAME_NOT_RESOLVED, CONNECTION_REFUSED, cert errors, etc. // Show the branded error page instead of Chromium's default "This site // can't be reached". Skip subframe errors and the couple of Chromium @@ -4636,7 +4808,7 @@ function createTab(initial, opts = {}) { }); // Firefox / Chrome-style bottom-left link preview: fires with the href // when the pointer enters/leaves an anchor. Empty string = no hover. - wc.on("update-target-url", (_e, url) => { if (tab.id === activeId) showLinkStatus(url); }); + wc.on("update-target-url", (_e, url) => { if (isActiveId(tab.id)) showLinkStatus(url); }); wc.on("will-navigate", (e, u) => { try { // Skip our own programmatic loads. fallbackToWeb calls loadURL("https:///") @@ -4894,7 +5066,7 @@ function createTab(initial, opts = {}) { tab.prov = { host: "", kind: "home" }; const settingsOpts = opts.settingsSection ? { hash: opts.settingsSection } : undefined; wc.loadFile("settings.html", settingsOpts); - if (id === activeId) pushNav(tab.prov); + if (isActiveId(id)) pushNav(tab.prov); emitTabs(); } else if (opts.addonFile) { // Same treatment as settings: leave the address bar empty (refreshTabUrl @@ -4903,7 +5075,7 @@ function createTab(initial, opts = {}) { tab.prov = { host: "", kind: "home" }; tab.addonId = opts.addonFile.addonId; wc.loadFile(opts.addonFile.absPath, opts.addonFile.query ? { search: opts.addonFile.query } : undefined); - if (id === activeId) pushNav(tab.prov); + if (isActiveId(id)) pushNav(tab.prov); emitTabs(); } else if (initial) navigateTab(id, initial); else loadHome(id); @@ -4919,30 +5091,90 @@ function materializePending(tab) { try { navigateTab(tab.id, url); } catch (e) { console.warn("materializePending failed:", e?.message); } return true; } +// Moves a tab, page and all, into a new window: the same webContents goes +// over, so sign-ins, scroll position, typed text and history stay as they +// are. `at` (screen point) places the new window under the pointer; without +// it the window opens a little offset from the old one. A window's only tab +// stays where it is. +function moveTabToNewWindow(id, at) { + const t = tabById(id); + if (!t || !t.w || !windows.includes(t.w)) return false; + const src = t.w; + const mine = winTabs(src); + if (mine.length < 2) return false; + let bounds = null; + withWin(src, () => { + if (fsTabId === id) leaveHtmlFullscreen(t, true); + let b = { x: 80, y: 80, width: 1220, height: 840 }; + try { b = win.getNormalBounds(); } catch {} + bounds = at + ? { x: at.x - 160, y: at.y - 18, width: b.width, height: b.height } + : { x: b.x + 36, y: b.y + 36, width: b.width, height: b.height }; + // Kept on the screen it was dropped on, whole: no larger than its work + // area, and pulled back inside it. + try { + const { screen } = require("electron"); + const wa = screen.getDisplayNearestPoint({ x: Math.round(at ? at.x : b.x), y: Math.round(at ? at.y : b.y) }).workArea; + bounds.width = Math.min(bounds.width, wa.width); + bounds.height = Math.min(bounds.height, wa.height); + bounds.x = Math.max(wa.x, Math.min(bounds.x, wa.x + wa.width - bounds.width)); + bounds.y = Math.max(wa.y, Math.min(bounds.y, wa.y + wa.height - bounds.height)); + } catch {} + const i = mine.indexOf(t); + const next = mine[i + 1] || mine[i - 1]; + try { win.contentView.removeChildView(t.view); } catch {} + t.w = null; // between windows + if (activeId === id) setActive(next.id); else emitTabs(); + }); + const w = makeBrowserWindow({ bounds }); + t.w = w; + // The tab goes to the end of the list: it is the new window's only tab. + tabs.splice(tabs.indexOf(t), 1); tabs.push(t); + try { w.win.contentView.addChildView(t.view); } catch {} + withWin(w, () => { + activeId = id; + try { t.view.setVisible(true); } catch {} + layout(); + }); + whenChromeReady(w, () => withWin(w, () => { + setActive(id); + layout(); + try { t.view.webContents.focus(); } catch {} + })); + return true; +} function closeTab(id) { + const owner = tabById(id)?.w; + if (owner && owner !== curW) return withWin(owner, () => closeTabHere(id)); + return closeTabHere(id); +} +function closeTabHere(id) { const i = tabs.findIndex((t) => t.id === id); if (i < 0) return; + const before = winTabs(); + const wi = before.findIndex((t) => t.id === id); const [t] = tabs.splice(i, 1); if (fsTabId === id) leaveHtmlFullscreen(t, true); dismissJsDialogFor(id); cancelApprovalsFor(id); - win.contentView.removeChildView(t.view); + try { win.contentView.removeChildView(t.view); } catch {} t.view.webContents.destroy?.(); - if (tabs.length === 0) { createTab(); return; } - if (activeId === id) setActive(tabs[Math.max(0, i - 1)].id); + const left = winTabs(); + if (left.length === 0) { + // The last tab of one of several windows takes its window with it; the + // last tab of the last window leaves a fresh one, as before. + if (curW && windows.length > 1) { try { curW.win.close(); } catch {} return; } + createTab(); return; + } + if (activeId === id) setActive(left[Math.max(0, wi - 1)].id); else emitTabs(); } -function createWindow() { - chromeReadyDone = false; - overlaysPrewarmed = false; - overlayLoads.length = 0; // views of a previous window, if any - overlayWant.clear(); - 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; - } +// One browser window: the frame, its toolbar (chrome.html) and the events +// that belong to it. Returns its record, already in `windows`. The primary +// window (createWindow) also gets the shared overlays and the side panels; a +// window opened later (moveTabToNewWindow) gets only this. +function makeBrowserWindow({ bounds } = {}) { // 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 @@ -4954,8 +5186,10 @@ function createWindow() { // the OS title bar's height to the page. Kept native elsewhere. const titleBarOverlay = { color: uiBg, symbolColor: nativeTheme.shouldUseDarkColors ? "#e7eaf1" : "#1a1f28", height: 40 }; const frameOpts = process.platform === "win32" ? { titleBarStyle: "hidden", titleBarOverlay } : {}; - win = new BrowserWindow({ - width: 1220, height: 840, title: "Theseus Navigator", backgroundColor: uiBg, ...frameOpts, + const place = bounds && Number.isFinite(bounds.x) && Number.isFinite(bounds.y) ? { x: Math.round(bounds.x), y: Math.round(bounds.y) } : {}; + const bw = new BrowserWindow({ + width: Math.round(bounds?.width) || 1220, height: Math.round(bounds?.height) || 840, ...place, + title: "Theseus Navigator", backgroundColor: uiBg, ...frameOpts, // Taskbar / titlebar icon. Packaged builds ship build/icon.ico as // extraResource; dev reads the source file directly. icon: app.isPackaged @@ -4964,30 +5198,140 @@ function createWindow() { }); // Keep the overlay buttons on the theme when it flips at runtime. if (process.platform === "win32") { - nativeTheme.on("updated", () => { - if (!win || win.isDestroyed()) return; + const onTheme = () => { + if (bw.isDestroyed()) { nativeTheme.removeListener("updated", onTheme); return; } const dark = nativeTheme.shouldUseDarkColors; - try { win.setTitleBarOverlay({ color: dark ? "#0f1420" : "#e6e8ec", symbolColor: dark ? "#e7eaf1" : "#1a1f28", height: 40 }); } catch {} - }); + try { bw.setTitleBarOverlay({ color: dark ? "#0f1420" : "#e6e8ec", symbolColor: dark ? "#e7eaf1" : "#1a1f28", height: 40 }); } catch {} + }; + nativeTheme.on("updated", onTheme); } - 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"); + const cv = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "preload.js") } }); + try { cv.setBackgroundColor(uiBg); } catch {} + bw.contentView.addChildView(cv); + styleScrollbars(cv.webContents); + const w = { id: ++winSeq, win: bw, chrome: cv, activeId: null, fsTabId: null, userFullscreen: false, chromeH: 84, overlayH: 0, chromeReady: false, onChromeReady: [] }; + windows.push(w); + cv.webContents.once("dom-ready", () => { + w.chromeReady = true; + for (const fn of w.onChromeReady.splice(0)) { try { fn(); } catch (e) { console.warn("window ready:", e?.message); } } + }); + bw.on("focus", () => focusWin(w)); + bw.on("blur", closePopupsOnWindowBlur); + bw.on("resize", layout); + bw.on("enter-full-screen", layout); + // Fullscreen left from outside (the OS, or Electron on the page's behalf) + // while a page still held it: put the toolbar back and tell the page. + bw.on("leave-full-screen", () => withWin(w, () => { + userFullscreen = false; + if (fsTabId != null) { // the window is already on its way out: only the page needs telling + const t = tabs.find((x) => x.id === fsTabId); fsTabId = null; + try { t?.view.webContents.executeJavaScript("document.fullscreenElement && document.exitFullscreen(); 0", true).catch(() => {}); } catch {} + } + layout(); + })); + bw.on("close", () => onWindowClose(w)); + bw.on("closed", () => onWindowClosed(w)); + cv.webContents.loadFile("chrome.html"); + return w; +} +// fn once window w's toolbar has loaded (its IPC listeners exist). +function whenChromeReady(w, fn) { + if (w.chromeReady) fn(); else w.onChromeReady.push(fn); +} +// The user brought window w forward: from now on the globals are its state. +// Popups open in the window left behind close with it. +function focusWin(w) { + if (!windows.includes(w) || w === curW) return; + if (ctxDepth > 0) { setImmediate(() => focusWin(w)); return; } // inside withWin: switch once it unwinds + try { + if (popVisible) showPopover(false); + if (epVisible) showEnginePicker(false); + if (lpVisible) showLangPicker(false); + if (dlVisible) showDownloads(false); + if (apVisible) showAddressPicker(false); + if (pwfVisible) showPwFill(false); + if (linkStatusVisible) showLinkStatus(""); + } catch {} + saveWinCtx(); + loadWinCtx(w); + try { notifyTabChange(); } catch {} + try { emitPwAvailability(); } catch {} +} +// A window is closing. The last one saves the session and takes the shared +// views with it, as the single window always did. Any other closes alone: +// its tabs close with it, and the views it held move to a window that stays. +function onWindowClose(w) { + const others = windows.filter((x) => x !== w && x.win && !x.win.isDestroyed()); + if (!others.length) { if (!appQuitting) withWin(w, () => saveSession()); sessionSavedAtClose = true; return; } + const heir = others.find((x) => { try { return x.win.isFocused(); } catch { return false; } }) || others[others.length - 1]; + withWin(w, () => { + for (const t of winTabs(w)) { + tabs.splice(tabs.indexOf(t), 1); + if (fsTabId === t.id) fsTabId = null; + dismissJsDialogFor(t.id); + cancelApprovalsFor(t.id); + try { win.contentView.removeChildView(t.view); } catch {} + try { t.view.webContents.destroy?.(); } catch {} + } + }); + if (w === primaryW) primaryW = heir; + for (const [view, host] of [...hostOf]) { + if (host !== w) continue; + const sidePanel = [sidebar, quicklinks, quickPanel, leftPanel, panelGrip].includes(view); + if (!sidePanel) try { view.setVisible(false); } catch {} + raise(view, heir); + } +} +function onWindowClosed(w) { + const i = windows.indexOf(w); + if (i >= 0) windows.splice(i, 1); + // A WebContentsView's page outlives its window unless closed: the toolbar + // of a closed window kept running (and answering IPC) otherwise. + if (windows.length) { try { if (w.chrome && !w.chrome.webContents.isDestroyed()) w.chrome.webContents.close(); } catch {} } + for (const [view, host] of [...hostOf]) if (host === w) hostOf.delete(view); + if (windows.length) { + if (curW === w) { + curW = null; // its state goes with it + loadWinCtx(windows.find((x) => { try { return x.win.isFocused(); } catch { return false; } }) || windows[windows.length - 1]); + } + layout(); emitTabs(); + if (!appQuitting) saveSession(); + return; + } + // The last window: let go of every view, as the single window always did. + loadWinCtx(null); + primaryW = null; + onLastWindowClosed(); +} + +function createWindow() { + chromeReadyDone = false; + overlaysPrewarmed = false; + overlayLoads.length = 0; // views of a previous window, if any + overlayWant.clear(); + sessionSavedAtClose = false; + hostOf.clear(); + 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; + } + const w = makeBrowserWindow(); + primaryW = w; + saveWinCtx(); + loadWinCtx(w); // The overlays below are created now (cheap) but their pages load via // deferOverlayLoad: each page loads on first use, or in the idle prewarm. // 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); + raise(popover); styleScrollbars(popover.webContents); 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); + raise(enginePicker); styleScrollbars(enginePicker.webContents); deferOverlayLoad(enginePicker, "engine-picker.html"); enginePicker.setVisible(false); @@ -4996,14 +5340,14 @@ function createWindow() { // addChildView bring-to-front) as the engine picker. langPicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "lang-picker-preload.js") } }); try { langPicker.setBackgroundColor("#00000000"); } catch {} - win.contentView.addChildView(langPicker); + raise(langPicker); styleScrollbars(langPicker.webContents); deferOverlayLoad(langPicker, "lang-picker.html"); langPicker.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); + raise(downloadsPop); styleScrollbars(downloadsPop.webContents); deferOverlayLoad(downloadsPop, "downloads.html"); downloadsPop.setVisible(false); @@ -5012,25 +5356,24 @@ function createWindow() { closeOnClickAway(enginePicker, () => epVisible, () => showEnginePicker(false)); closeOnClickAway(langPicker, () => lpVisible, () => showLangPicker(false)); closeOnClickAway(downloadsPop, () => dlVisible, () => showDownloads(false)); - win.on("blur", closePopupsOnWindowBlur); // 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); + raise(addressPicker); styleScrollbars(addressPicker.webContents); 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); + raise(pwFillPop); styleScrollbars(pwFillPop.webContents); 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); + raise(linkStatus); styleScrollbars(linkStatus.webContents); deferOverlayLoad(linkStatus, "link-status.html"); linkStatus.setVisible(false); @@ -5040,7 +5383,7 @@ function createWindow() { // brand scrollbar the moment its DOM is ready. sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } }); lockPanelView(sidebar); - win.contentView.addChildView(sidebar); + raise(sidebar); styleScrollbars(sidebar.webContents); sidebar.setVisible(false); // Opera-style quick-links strip on the left edge: a thin vertical column @@ -5049,7 +5392,7 @@ function createWindow() { // rather than a full-sized tab. quicklinks = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "quicklinks-preload.js") } }); try { quicklinks.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0e131c" : "#eceff3"); } catch {} - win.contentView.addChildView(quicklinks); + raise(quicklinks); styleScrollbars(quicklinks.webContents); quicklinks.webContents.loadFile("quicklinks.html"); // Dedicated mini-view that renders the currently-active quick-link in its @@ -5070,14 +5413,14 @@ function createWindow() { return { action: "deny" }; }); try { quickPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {} - win.contentView.addChildView(quickPanel); + raise(quickPanel); styleScrollbars(quickPanel.webContents); quickPanel.setVisible(false); // Left add-on panels: same preload and IPC surface as the right sidebar. leftPanel = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } }); lockPanelView(leftPanel); try { leftPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {} - win.contentView.addChildView(leftPanel); + raise(leftPanel); styleScrollbars(leftPanel.webContents); leftPanel.setVisible(false); // The add-on panel page gets the RIGHT sidebar's grip from sidebar-preload, @@ -5088,27 +5431,27 @@ function createWindow() { }); panelGrip = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "panel-grip-preload.js") } }); try { panelGrip.setBackgroundColor("#00000000"); } catch {} - win.contentView.addChildView(panelGrip); + raise(panelGrip); panelGrip.webContents.loadFile("panel-grip.html"); panelGrip.setVisible(false); // Add-on approval overlay (approval-modal capability). Transparent view // over the tab area, loaded once, shown per request. approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } }); try { approvalPop.setBackgroundColor("#00000000"); } catch {} - win.contentView.addChildView(approvalPop); + raise(approvalPop); styleScrollbars(approvalPop.webContents); deferOverlayLoad(approvalPop, "approval.html"); approvalPop.setVisible(false); // Vault unlock prompt (PIN or master password), shown per request. unlockPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "unlock-preload.js") } }); try { unlockPop.setBackgroundColor("#00000000"); } catch {} - win.contentView.addChildView(unlockPop); + raise(unlockPop); deferOverlayLoad(unlockPop, "unlock.html"); unlockPop.setVisible(false); // Page dialogs (alert / confirm / prompt) — see the js-dialog block. jsDialogPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "js-dialog-preload.js") } }); try { jsDialogPop.setBackgroundColor("#00000000"); } catch {} - win.contentView.addChildView(jsDialogPop); + raise(jsDialogPop); styleScrollbars(jsDialogPop.webContents); deferOverlayLoad(jsDialogPop, "js-dialog.html"); jsDialogPop.setVisible(false); @@ -5122,25 +5465,15 @@ function createWindow() { // timer covers a chrome.html that fails to load at all. chrome.webContents.once("dom-ready", onChromeReady); setTimeout(onChromeReady, 8000); - win.on("resize", layout); - win.on("enter-full-screen", layout); - // Fullscreen left from outside (the OS, or Electron on the page's behalf) - // while a page still held it: put the toolbar back and tell the page. - win.on("leave-full-screen", () => { - userFullscreen = false; - if (fsTabId != null) { // the window is already on its way out: only the page needs telling - const t = tabs.find((x) => x.id === fsTabId); fsTabId = null; - try { t?.view.webContents.executeJavaScript("document.fullscreenElement && document.exitFullscreen(); 0", true).catch(() => {}); } catch {} - } - 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", () => { + layout(); +} +// The browser window can close while app windows (webapps.js) keep the +// process alive. The session was captured on "close" while the tabs were +// still here; now let go of the 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). +function onLastWindowClosed() { + { win = null; chrome = null; popover = null; enginePicker = null; downloadsPop = null; dismissJsDialogFor(null); // Same for wallet approvals: a request left current would block @@ -5149,8 +5482,7 @@ function createWindow() { for (const q of approvalQueue.splice(0)) { try { q.resolve("cancel"); } catch {} } addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; leftPanel = null; panelGrip = null; panelGripDragging = false; approvalPop = null; unlockPop = null; jsDialogPop = null; linkStatusVisible = false; - }); - layout(); + } } async function navigateTab(id, input) { @@ -5182,7 +5514,7 @@ async function navigateTab(id, input) { if (/^(?:data|blob):/i.test(q)) { t.url = q; t.prov = { host: "", kind: "web" }; await t.view.webContents.loadURL(q).catch(() => {}); - if (id === activeId) pushNav(t.prov); + if (isActiveId(id)) pushNav(t.prov); emitTabs(); return; } @@ -5204,7 +5536,7 @@ async function navigateTab(id, input) { // Legacy "also on BCNR" chip state — kept clean; the passive switch is gone // now that BCNR is priority for every host. t.bcnrOffer = null; - if (id === activeId) chrome.webContents.send("bcnr-offer", null); + if (isActiveId(id)) chrome.webContents.send("bcnr-offer", null); // BCNR-first for every dotted host. loadBns falls through to https:// // on NXDOMAIN or resolver failure, so clearnet still works. @@ -5214,7 +5546,7 @@ async function navigateTab(id, input) { t.url = q.includes("://") ? q : "https://" + q; await t.view.webContents.loadURL(t.url); t.prov = { host, kind: "web" }; - if (id === activeId) pushNav(t.prov); + if (isActiveId(id)) pushNav(t.prov); emitTabs(); } @@ -5224,10 +5556,10 @@ async function loadLocalFile(t, id, fileUrl) { t.url = fileUrl; t.prov = { host: "", kind: "file" }; t.bcnrOffer = null; - if (id === activeId) chrome.webContents.send("bcnr-offer", null); + if (isActiveId(id)) chrome.webContents.send("bcnr-offer", null); setLoading(t, true); t.nav = (t.nav || 0) + 1; - if (id === activeId) pushNav(t.prov); + if (isActiveId(id)) pushNav(t.prov); emitTabs(); try { await t.view.webContents.loadURL(fileUrl); } catch (e) { console.warn("local file load failed:", e?.message); } @@ -5239,7 +5571,7 @@ async function loadLocalFile(t, id, fileUrl) { // is down or the name isn't registered. async function loadBns(t, id, host, rest, tld) { const registry = registryOf(tld); - if (id === activeId) pushNav({ host, kind: "resolving", tld, registry }); + if (isActiveId(id)) pushNav({ host, kind: "resolving", tld, registry }); const fallbackToWeb = async (reason) => { t.url = "https://" + host + (rest === "/" ? "" : rest); t.internalNav = true; @@ -5247,7 +5579,7 @@ async function loadBns(t, id, host, rest, tld) { catch (e) { console.warn("fallback loadURL failed:", e?.message); } finally { t.internalNav = false; } t.prov = { host, kind: "web", note: `fallback: ${reason}`, tld, registry }; - if (id === activeId) pushNav(t.prov); + if (isActiveId(id)) pushNav(t.prov); emitTabs(); }; // Strip and capture the one-shot ?_collision=bcnr param that collision-choose @@ -5296,7 +5628,7 @@ async function loadBns(t, id, host, rest, tld) { const q = new URLSearchParams({ host, tld, resturl: rest }).toString(); await t.view.webContents.loadFile(path.join(__dirname, "collision.html"), { search: q }); t.prov = { host, kind: "resolving", tld, registry }; - if (id === activeId) pushNav(t.prov); + if (isActiveId(id)) pushNav(t.prov); emitTabs(); return; } else choice = "bcnr"; // bcnr-first — the default @@ -5316,7 +5648,7 @@ async function loadBns(t, id, host, rest, tld) { }).toString(); await t.view.webContents.loadFile(path.join(__dirname, "blocked.html"), { search: q }); t.prov = { host, kind: "resolving", tld, registry }; - if (id === activeId) pushNav(t.prov); + if (isActiveId(id)) pushNav(t.prov); emitTabs(); return; } @@ -5333,7 +5665,7 @@ async function loadBns(t, id, host, rest, tld) { : entry.records.u ? "redirect" : "record"; t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), dns: dnsRecordKinds(entry), tld, registry }; - if (id === activeId) pushNav(t.prov); + if (isActiveId(id)) pushNav(t.prov); emitTabs(); } @@ -5425,6 +5757,20 @@ ipcMain.handle("tab-bookmark", (_e, id) => { // popup floats above every WebContentsView and doesn't affect layout at // all. Mirrors the DOM-menu shape: Reload / Duplicate / Group (submenu) / // Add to Bookmarks / Mute / Close. +// A tab dragged out of the tab bar and let go where nothing took it: outside +// the window, or down over the page, opens it in a new window there. Let go +// on the toolbar itself, it stays. +ipcMain.handle("tab-detach", (e, tabId, x, y) => { + if (!chrome || e.sender !== chrome.webContents) throw new Error("tab-detach: untrusted sender"); + const t = tabById(Number(tabId)); + if (!t || t.w !== curW || !winAlive()) return false; + const px = Number(x), py = Number(y); + if (!Number.isFinite(px) || !Number.isFinite(py)) return false; + const b = win.getBounds(); + const inside = px >= b.x && px < b.x + b.width && py >= b.y && py < b.y + b.height; + if (inside && py < b.y + CHROME_H + 24) return false; + return moveTabToNewWindow(t.id, { x: px, y: py }); +}); ipcMain.handle("tab-context-menu-popup", (e, tabId, rect) => { if (chrome && e.sender !== chrome.webContents) throw new Error("tab-context-menu-popup: untrusted sender"); const t = tabById(tabId); @@ -5461,6 +5807,7 @@ ipcMain.handle("tab-context-menu-popup", (e, tabId, rect) => { const template = [ { label: "Reload", click: () => { try { t.view.webContents.reload(); } catch {} } }, { label: "Duplicate", click: () => { if (t.url) createTab(t.url, { after: t.id }); else createTab(null, { after: t.id }); } }, + { label: "Move to new window", enabled: winTabs(t.w).length > 1, click: () => moveTabToNewWindow(t.id) }, { label: "Group", submenu: groupSubmenu }, { label: "Add to Bookmarks", enabled: !bmDisabled, click: () => { if (bmDisabled) return; @@ -5755,7 +6102,7 @@ function tabTranslateState(t) { return t._tr; } function emitTranslateState(t) { - if (!t || t.id !== activeId) return; + if (!t || !isActiveId(t.id)) return; try { chrome?.webContents.send("page-translate-state", { ...tabTranslateState(t), pageLang: t.pageLang || "" }); } catch {} // The language picker (if open) uses this same state to decide whether // to show "Translate" / "Show original" at the top, so repaint it too. @@ -5934,7 +6281,7 @@ function setLeftPanelMax(next) { // One wide view at a time: a maximized right sidebar steps back, and the // panel goes on top of tab views created since it opened. if (sidebarMaximized) setSidebarMaximized(false); - try { win.contentView.removeChildView(leftPanel); win.contentView.addChildView(leftPanel); } catch {} + raise(leftPanel, primaryW); } layout(); try { leftPanel?.webContents.send("sidebar-max-change", leftPanelMax); } catch {} @@ -6404,7 +6751,7 @@ function isPluginAddon(id) { } function notifyStagedAddons(staged) { const list = (staged || listStagedAddons(addonsStagedDir())).filter((s) => !isPluginAddon(s.id)); - try { chrome?.webContents.send("addon-updates", { staged: list.map((s) => ({ id: s.id, name: s.name, version: s.version })) }); } catch {} + sendAllChrome("addon-updates", { staged: list.map((s) => ({ id: s.id, name: s.name, version: s.version })) }); } ipcMain.handle("addons-check-updates", () => pollAddonUpdates("manual")); // Apply staged extension updates now (no restart): promote the staged @@ -6643,7 +6990,7 @@ function jsDialogReply(item, ok, value) { } // Does this tab have a page dialog waiting for the user to come back to it? function tabHasPendingDialog(tabId) { - return jsDialogQueue.some((q) => q.tabId === tabId) || (!!jsDialogCurrent && jsDialogCurrent.tabId === tabId && tabId !== activeId); + return jsDialogQueue.some((q) => q.tabId === tabId) || (!!jsDialogCurrent && jsDialogCurrent.tabId === tabId && !isShownTabId(tabId)); } function pumpJsDialog() { if (jsDialogCurrent || !jsDialogQueue.length) return; @@ -6657,7 +7004,7 @@ function pumpJsDialog() { // the tab strip — until the user switches to it; it never pulls its tab to // the front, which let any page in the background jump over what the user // was doing with alert(). - const i = jsDialogQueue.findIndex((q) => q.tabId == null || q.tabId === activeId); + const i = jsDialogQueue.findIndex((q) => q.tabId == null || isShownTabId(q.tabId)); emitTabs(); if (i < 0) return; // Nothing goes over a wallet approval or the unlock prompt. A dapp could @@ -6674,7 +7021,7 @@ function pumpJsDialog() { try { jsDialogPop.webContents.send("jsdialog-show", next.req); jsDialogPop.setVisible(true); - try { win.contentView.removeChildView(jsDialogPop); win.contentView.addChildView(jsDialogPop); } catch {} + raise(jsDialogPop, ownerWin(next)); layout(); jsDialogPop.webContents.focus(); } catch (err) { @@ -6691,7 +7038,7 @@ function pumpJsDialog() { function syncJsDialogVisibility() { if (!jsDialogPop || !winAlive()) return; if (!jsDialogCurrent) { pumpJsDialog(); return; } - const show = jsDialogCurrent.tabId == null || jsDialogCurrent.tabId === activeId; + const show = jsDialogCurrent.tabId == null || isShownTabId(jsDialogCurrent.tabId); if (!show) { // Its tab went to the background: put it back in line (still answering // nothing — the page stays blocked in its sendSync) so the tab now in @@ -6704,7 +7051,7 @@ function syncJsDialogVisibility() { } try { jsDialogPop.setVisible(true); - win.contentView.removeChildView(jsDialogPop); win.contentView.addChildView(jsDialogPop); jsDialogPop.webContents.focus(); + raise(jsDialogPop, ownerWin(jsDialogCurrent)); layout(); jsDialogPop.webContents.focus(); } catch {} } function finishJsDialog(ok, value) { @@ -6715,7 +7062,7 @@ function finishJsDialog(ok, value) { // Never hand focus to the page while an approval or the unlock prompt is // up: keystrokes meant for the prompt would go to the page. if (!approvalCurrent && !unlockCurrent) { - try { const t = cur.tabId != null ? tabById(cur.tabId) : null; if (t && t.id === activeId) t.view.webContents.focus(); } catch {} + try { const t = cur.tabId != null ? tabById(cur.tabId) : null; if (t && isShownTabId(t.id)) t.view.webContents.focus(); } catch {} } pumpJsDialog(); } @@ -6801,7 +7148,7 @@ function pumpApproval() { const q = approvalQueue[i]; if (q.tabId != null && !tabById(q.tabId)) { approvalQueue.splice(i, 1); try { q.resolve("cancel"); } catch {} } } - const at = approvalQueue.findIndex((q) => q.tabId == null || q.tabId === activeId); + const at = approvalQueue.findIndex((q) => q.tabId == null || isShownTabId(q.tabId)); if (at < 0) return; const next = approvalQueue.splice(at, 1)[0]; approvalCurrent = next; @@ -6813,7 +7160,7 @@ function pumpApproval() { try { approvalPop.webContents.send("approval-show", next.req); approvalPop.setVisible(true); - try { win.contentView.removeChildView(approvalPop); win.contentView.addChildView(approvalPop); } catch {} + raise(approvalPop, ownerWin(next)); layout(); approvalPop.webContents.focus(); } catch (err) { @@ -6823,6 +7170,12 @@ function pumpApproval() { } }); } +// A page dialog or an approval shows in the window of the tab it belongs to; +// one without a tab (a panel, Theseus itself) in the current window. +function ownerWin(entry) { + const t = entry && entry.tabId != null ? tabById(entry.tabId) : null; + return (t && t.w && windows.includes(t.w)) ? t.w : curW; +} function showApprovalModal(opts, addonId, tabId = null) { const a = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId); const req = { @@ -6867,7 +7220,7 @@ function syncApprovalVisibility() { if (!approvalPop) return; const cur = approvalCurrent; if (!cur) { pumpApproval(); return; } - const show = cur.tabId == null || cur.tabId === activeId; + const show = cur.tabId == null || isShownTabId(cur.tabId); if (!show) { try { approvalPop.setVisible(false); } catch {} approvalCurrent = null; @@ -7207,7 +7560,7 @@ async function switchRegistry(arg) { } } catch (e) { console.warn("collision-switch load failed:", e?.message); } finally { t.internalNav = false; setLoading(t, false); } - if (t.id === activeId) pushNav(t.prov); + if (isActiveId(t.id)) pushNav(t.prov); emitTabs(); } // Ariadne's Thread menu — the registry button at the end of the address @@ -7714,7 +8067,7 @@ function pumpUnlock() { const st = vaultPin().status(); overlayReady(unlockPop).then(() => { unlockPop.webContents.send("unlock-show", { ...next.req, pinSet: st.pinSet, pinLength: st.length || 6, lockedMs: st.lockedMs, fails: st.fails, maxFails: vaultPin().MAX_FAILS }); - try { win.contentView.addChildView(unlockPop); } catch {} // re-add = bring to front + raise(unlockPop); // bring to front, in the window that asked unlockPop.setVisible(true); unlockPop.webContents.focus(); }).catch((err) => { @@ -8254,7 +8607,7 @@ ipcMain.on("pw-form", (e, rect) => { if (!settings.pwOfferFill) return; const src = pwSource(e); // The active tab, or the quick panel while it is open beside it. - if (!src || (src.t.quick ? !activeQuickLinkId : src.t.id !== activeId)) return; + if (!src || (src.t.quick ? !activeQuickLinkId : !isActiveId(src.t.id))) return; const { t, host } = src; if (!fs.existsSync(vaultFile())) return; let matches = [], locked = false; @@ -8739,7 +9092,7 @@ function leftPanelWidth(def, room) { // The grip has to stay above whichever panel view was raised last. function raisePanelGrip() { if (!panelGrip) return; - try { win.contentView.removeChildView(panelGrip); win.contentView.addChildView(panelGrip); } catch {} + raise(panelGrip, primaryW); } // Opera-style: the left panel steps aside when the user opens something else // (a new tab, the right sidebar). It is only hidden — the app keeps its @@ -8765,7 +9118,7 @@ ipcMain.handle("quicklinks-pin", (e, on) => { ipcMain.on("tab-pointerdown", (e) => { if (settings.sidebarHideOnPageClick === false || !leftPanelKey() || leftPanelPinned()) return; const t = tabs.find((x) => x.view?.webContents === e.sender); - if (!t || t.id !== activeId) return; + if (!t || !isActiveId(t.id)) return; hideLeftPanels(); }); function hideLeftPanels() { @@ -8823,7 +9176,7 @@ function openLeftPanel(panelId) { leftPanelMax = false; try { leftPanel.webContents.loadFile(p.pageFile); } catch (e) { console.warn("left panel loadFile failed:", e?.message); } } - try { win.contentView.removeChildView(leftPanel); win.contentView.addChildView(leftPanel); } catch {} + raise(leftPanel, primaryW); raisePanelGrip(); layout(); emitQuickLinksState(); @@ -8874,7 +9227,7 @@ ipcMain.handle("quicklinks-open", (_e, id) => { ipcMain.handle("quicklinks-close", () => { closeQuickPanel(); closeLeftPanel(); return true; }); ipcMain.handle("quicklinks-add", () => { openSettingsTab("general"); return true; }); function broadcastSettings() { - try { chrome?.webContents.send("settings-update", settings); } catch {} + sendAllChrome("settings-update", settings); for (const t of tabs) { if (t.settings && t.view?.webContents) { try { t.view.webContents.send("settings-update", settings); } catch {} @@ -9415,10 +9768,12 @@ function inMainWindow(wc) { if (chrome && wc === chrome.webContents) return true; if (tabs.some((t) => t.view?.webContents === wc)) return true; let owner = null; try { owner = BrowserWindow.fromWebContents(wc); } catch {} - return !owner || owner === win; + return !owner || windows.some((w) => w.win === owner); } app.on("web-contents-created", (_event, wc) => { - wc.on("before-input-event", (e, input) => { + // Keys act in the window they were pressed in (a toolbar, a tab or a + // popup belongs to one browser window). + wc.on("before-input-event", (e, input) => withWin(winOfWebContents(wc), () => { if (input.type !== "keyDown") return; // Ctrl+Shift+M -> Messages panel if (input.control && input.shift && (input.key === "M" || input.key === "m")) { @@ -9520,7 +9875,7 @@ app.on("web-contents-created", (_event, wc) => { } return e.preventDefault(); } - }); + })); }); // THESEUS_NO_AUTOSTART lets a test harness reuse serveBns/resolveHost without @@ -9658,6 +10013,7 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) { // history keeps that tab list while restoreSession is on, and deletes it // otherwise so the next launch is genuinely blank. saveSession(); + appQuitting = true; // every window is in that file; the windows closing next must not shrink it stopTor(); stopBnsPolling(); // silence the background delta refresh before exit vaultState = null; // drop the in-memory vault key + purposeRoot diff --git a/preload.js b/preload.js index 032cdb66..dcaa9556 100644 --- a/preload.js +++ b/preload.js @@ -6,6 +6,9 @@ contextBridge.exposeInMainWorld("theseus", { closeTab: (id) => ipcRenderer.invoke("close-tab", id), switchTab: (id) => ipcRenderer.invoke("switch-tab", id), moveTab: (id, targetId, place) => ipcRenderer.invoke("move-tab", id, targetId, place), + // A tab dragged out of the bar and dropped where nothing took it: main opens + // it in a new window if the point (screen coordinates) is off the toolbar. + detachTab: (id, x, y) => ipcRenderer.invoke("tab-detach", id, x, y), suggestAddress: (query, rect) => ipcRenderer.invoke("suggest-address", query, rect), closeAddressPicker: () => ipcRenderer.invoke("close-address-picker"), addressCursor: (dir) => ipcRenderer.invoke("address-cursor", dir),