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