fix(theseus): own fullscreen; a video's fullscreen no longer strands the window

Nothing handled HTML fullscreen. Electron put the window in fullscreen for a
page (a video player) with the toolbar still on top, and when the page left
fullscreen while its tab was hidden, or the tab was switched away from or
closed, the window stayed fullscreen: no title-bar buttons, the taskbar
covered, and no key to get out. Tabs now report entering and leaving
fullscreen; the toolbar and sidebar make way for the page; switching or
closing the tab ends it and tells the page; F11 toggles a fullscreen with
the toolbar kept and doubles as the way out. A page's own exit is left to
Electron, which has already taken the window out by the time it tells us;
exiting again during that transition brought the window back maximized.
This commit is contained in:
Local Dev 2026-09-28 20:30:33 +02:00
parent 668914354f
commit cd7f8759ea

93
main.js
View file

@ -2638,21 +2638,76 @@ function pushNav(prov) {
emitPwAvailability();
}
// ---- Fullscreen ------------------------------------------------------------
// Two ways in: a page asks for HTML fullscreen (video players: Electron puts
// the whole window in fullscreen for it) or the user presses F11. Nothing
// used to own either, so the toolbar stayed on top of a fullscreen video,
// and a page that left fullscreen while its tab was hidden, or a fullscreen
// tab that was closed or switched away from, left the window in fullscreen
// with no title-bar buttons, the taskbar covered and no key to get out.
let fsTabId = null; // tab whose page holds HTML fullscreen (toolbar + sidebar hidden for it)
let userFullscreen = false; // F11: window fullscreen with the toolbar kept
function enterHtmlFullscreen(tab) {
fsTabId = tab.id;
try { if (!win.isFullScreen()) win.setFullScreen(true); } catch {}
layout();
}
// forced: Theseus is leaving on the page's behalf (tab switched or closed,
// F11) and must take the window out of fullscreen itself. When the page
// leaves on its own, Electron has already taken the window out by the time
// leave-html-full-screen fires; a second exit during that transition
// restored the window maximized (2026-09-28), so that path only checks
// later that the exit really happened.
function leaveHtmlFullscreen(tab, forced = false) {
if (!tab || fsTabId !== tab.id) return;
fsTabId = null;
if (forced) {
// The page keeps its own fullscreen state (a player's controls, the
// fullscreenchange event): tell it.
try { tab.view.webContents.executeJavaScript("document.fullscreenElement && document.exitFullscreen(); 0", true).catch(() => {}); } catch {}
try { if (!userFullscreen && win.isFullScreen()) win.setFullScreen(false); } catch {}
} else scheduleFullscreenCheck();
layout();
}
// A fullscreen the window did not ask for and no page holds — the stuck
// state described above — is left. Checked on a timer: during a fullscreen
// exit the window still reports fullscreen for a moment, and exiting again
// right then is what restored it maximized.
let fsCheckTimer = null;
function scheduleFullscreenCheck() {
clearTimeout(fsCheckTimer);
fsCheckTimer = setTimeout(() => {
fsCheckTimer = null;
if (!winAlive() || fsTabId != null || userFullscreen) return;
try { if (win.isFullScreen()) win.setFullScreen(false); } catch {}
}, 600);
}
function toggleUserFullscreen() {
if (!winAlive()) return;
userFullscreen = !userFullscreen;
if (fsTabId != null) leaveHtmlFullscreen(tabs.find((t) => t.id === fsTabId), true);
try { win.setFullScreen(userFullscreen); } catch {}
layout();
}
function layout() {
if (!winAlive()) return;
if (fsTabId == null && !userFullscreen) { try { if (win.isFullScreen()) scheduleFullscreenCheck(); } catch {} }
const { width, height } = win.getContentBounds();
chrome.setBounds({ x: 0, y: 0, width, height: CHROME_H });
const bodyH = Math.max(0, height - CHROME_H);
const fsTab = fsTabId != null ? tabs.find((t) => t.id === fsTabId) : null;
if (!fsTab && fsTabId != null) fsTabId = null;
const chromeH = fsTab ? 0 : CHROME_H;
chrome.setBounds({ x: 0, y: 0, width, height: chromeH });
const bodyH = Math.max(0, height - chromeH);
// 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 ? sidebarW : 0;
const sideW = sidebarVisible && !fsTab ? sidebarW : 0;
const tabW = Math.max(0, width - sideW);
for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH });
if (sidebar) sidebar.setBounds({ x: tabW, y: CHROME_H, width: sideW, height: bodyH });
for (const t of tabs) t.view.setBounds({ x: 0, y: chromeH, width: tabW, height: bodyH });
if (sidebar) sidebar.setBounds({ x: 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: 0, y: CHROME_H, width: tabW, height: bodyH });
if (jsDialogPop) jsDialogPop.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH });
if (approvalPop) approvalPop.setBounds({ x: 0, y: chromeH, width: tabW, height: bodyH });
if (jsDialogPop) jsDialogPop.setBounds({ x: 0, y: chromeH, width: tabW, height: bodyH });
positionPopover();
positionEnginePicker();
positionDownloads();
@ -3032,6 +3087,8 @@ function installDownloadTracker() {
function setActive(id) {
const switching = id !== activeId;
activeId = id;
// A fullscreen video does not follow the user to another tab.
if (switching && fsTabId != null && fsTabId !== id) leaveHtmlFullscreen(tabs.find((t) => t.id === fsTabId), true);
if (popVisible) showPopover(false); // don't carry a stale popover across tabs
if (epVisible) showEnginePicker(false);
if (linkStatusVisible) showLinkStatus(""); // clear any lingering hover pill
@ -3312,6 +3369,9 @@ function createTab(initial, opts = {}) {
const next = (urls && urls[0]) || null;
if (tab.favicon !== next) { tab.favicon = next; emitTabs(); }
});
// HTML fullscreen (see enterHtmlFullscreen): the page gets the whole window.
wc.on("enter-html-full-screen", () => enterHtmlFullscreen(tab));
wc.on("leave-html-full-screen", () => leaveHtmlFullscreen(tab));
// 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) => {
@ -3557,6 +3617,7 @@ function closeTab(id) {
const i = tabs.findIndex((t) => t.id === id);
if (i < 0) return;
const [t] = tabs.splice(i, 1);
if (fsTabId === id) leaveHtmlFullscreen(t, true);
dismissJsDialogFor(id);
win.contentView.removeChildView(t.view);
t.view.webContents.destroy?.();
@ -3683,6 +3744,17 @@ function createWindow() {
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,
@ -6512,6 +6584,13 @@ app.on("web-contents-created", (_event, wc) => {
// the right edge with whatever we've already positioned there. Users
// who prefer a detached window can still drag out from inside the
// DevTools frontend itself.
// F11 toggles window fullscreen, the toolbar kept (Chrome hides it too,
// Theseus keeps it). Also the way out of a fullscreen the user did not
// ask for.
if (input.key === "F11" && !input.control && !input.alt && !input.shift) {
toggleUserFullscreen();
return e.preventDefault();
}
const isI = input.key === "I" || input.key === "i";
if (input.key === "F12" || (input.control && input.shift && isI)) {
const t = activeTab();