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),