diff --git a/main.js b/main.js index 5423ce1..74de7ad 100644 --- a/main.js +++ b/main.js @@ -127,7 +127,7 @@ function initializeSteamworks() { // This is critical for Steam Input to recognize native controller support initializeSteamworks(); -const { app, BrowserWindow, ipcMain, session, screen, shell, dialog, Menu, clipboard, webContents } = require('electron'); +const { app, BrowserWindow, BrowserView, ipcMain, session, screen, shell, dialog, Menu, clipboard, webContents } = require('electron'); // Cleanup Steam callback pump on exit app.once('before-quit', () => { @@ -157,6 +157,308 @@ const gpuFallback = new GPUFallback(); const gpuConfig = new GPUConfig(); const pluginManager = new PluginManager(); +// ============================================================================= +// DESKTOP MODE: BrowserView tab management +// ============================================================================= +const desktopViewStateByWindowId = new Map(); +const desktopViewByWebContentsId = new Map(); +const menuPopupByWindowId = new Map(); +const MENU_POPUP_SIZE = { width: 240, height: 240 }; + +const SCROLL_NORMALIZATION_CSS = ` + *, *::before, *::after { scroll-behavior: auto !important; } + html, body { scroll-behavior: auto !important; } +`; + +const SCROLL_NORMALIZATION_JS = ` +(function() { + if (window.__nebulaScrollNormalized) return; + window.__nebulaScrollNormalized = true; + const SCROLL_SPEED = 100; + document.addEventListener('wheel', function(e) { + if (e.ctrlKey || e.metaKey || e.altKey) return; + let target = e.target; + let scrollable = null; + while (target && target !== document.body && target !== document.documentElement) { + const style = window.getComputedStyle(target); + const overflowY = style.overflowY; + const overflowX = style.overflowX; + if ((overflowY === 'auto' || overflowY === 'scroll') && target.scrollHeight > target.clientHeight) { scrollable = target; break; } + if ((overflowX === 'auto' || overflowX === 'scroll') && target.scrollWidth > target.clientWidth && e.shiftKey) { scrollable = target; break; } + target = target.parentElement; + } + if (!scrollable) scrollable = document.scrollingElement || document.documentElement || document.body; + let deltaY = e.deltaY; + let deltaX = e.deltaX; + if (e.deltaMode === 1) { + deltaY *= SCROLL_SPEED; deltaX *= SCROLL_SPEED; + } else if (e.deltaMode === 2) { + deltaY *= window.innerHeight; deltaX *= window.innerWidth; + } else { + const sign = deltaY > 0 ? 1 : -1; + deltaY = sign * Math.min(Math.abs(deltaY), SCROLL_SPEED * 3); + const signX = deltaX > 0 ? 1 : -1; + deltaX = signX * Math.min(Math.abs(deltaX), SCROLL_SPEED * 3); + } + e.preventDefault(); + scrollable.scrollBy({ top: deltaY, left: e.shiftKey ? deltaX : 0, behavior: 'auto' }); + }, { passive: false, capture: true }); +})(); +`; + +function getDesktopViewState(win) { + if (!win) return null; + let state = desktopViewStateByWindowId.get(win.id); + if (!state) { + state = { + views: new Map(), // tabId -> BrowserView + activeTabId: null, + bounds: null + }; + desktopViewStateByWindowId.set(win.id, state); + } + return state; +} + +function createMenuPopupWindow(parentWin) { + const menuWin = new BrowserWindow({ + parent: parentWin, + modal: false, + frame: false, + transparent: true, + resizable: false, + show: false, + alwaysOnTop: true, + skipTaskbar: true, + focusable: true, + hasShadow: true, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + nodeIntegration: false, + contextIsolation: true, + sandbox: false, + partition: 'persist:main' + } + }); + + menuWin.setMenu(null); + try { menuWin.setAlwaysOnTop(true, 'pop-up-menu'); } catch {} + + const hideMenu = () => { + if (!menuWin.isDestroyed()) menuWin.hide(); + }; + + menuWin.on('blur', hideMenu); + parentWin.on('move', hideMenu); + parentWin.on('resize', hideMenu); + + menuWin.on('closed', () => { + try { menuPopupByWindowId.delete(parentWin.id); } catch {} + try { parentWin.removeListener('move', hideMenu); } catch {} + try { parentWin.removeListener('resize', hideMenu); } catch {} + }); + + menuWin.loadFile(path.join(__dirname, 'renderer', 'menu-popup.html')); + return menuWin; +} + +function positionMenuPopup(parentWin, menuWin, anchorRect) { + if (!parentWin || !menuWin || !anchorRect) return; + const contentBounds = parentWin.getContentBounds(); + const display = screen.getDisplayMatching(contentBounds); + const workArea = display?.workArea || contentBounds; + + const width = MENU_POPUP_SIZE.width; + const height = MENU_POPUP_SIZE.height; + let x = Math.round(contentBounds.x + anchorRect.x + anchorRect.width - width); + let y = Math.round(contentBounds.y + anchorRect.y + anchorRect.height + 6); + + if (x < workArea.x) x = workArea.x; + if (y < workArea.y) y = workArea.y; + if (x + width > workArea.x + workArea.width) x = workArea.x + workArea.width - width; + if (y + height > workArea.y + workArea.height) y = workArea.y + workArea.height - height; + + menuWin.setBounds({ x, y, width, height }, false); +} + +function getOwnerWindowForContents(contents) { + if (!contents) return null; + try { + if (contents.hostWebContents) { + return BrowserWindow.fromWebContents(contents.hostWebContents); + } + } catch {} + try { + const maybeWin = BrowserWindow.fromWebContents(contents); + if (maybeWin) return maybeWin; + } catch {} + const mapped = desktopViewByWebContentsId.get(contents.id); + return mapped?.win || null; +} + +function getActiveDesktopViewWebContents(win) { + const state = getDesktopViewState(win); + if (!state || !state.activeTabId) return null; + const view = state.views.get(state.activeTabId); + return view?.webContents || null; +} + +function sendBrowserViewEvent(win, payload) { + try { + if (win && !win.isDestroyed()) { + win.webContents.send('browserview-event', payload); + } + } catch {} +} + +function createBrowserViewForTab(win, tabId, url) { + const state = getDesktopViewState(win); + if (!state) return null; + if (state.views.has(tabId)) return state.views.get(tabId); + + const view = new BrowserView({ + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + nodeIntegration: false, + contextIsolation: true, + partition: 'persist:main', + sandbox: false, + webSecurity: true, + allowRunningInsecureContent: false, + nativeWindowOpen: false, + additionalArguments: [`--nebula-tab-id=${tabId}`] + } + }); + + try { + if (!process.env.NEBULA_DEBUG_ELECTRON_UA) { + view.webContents.setUserAgent(app.userAgentFallback || computeBaseUA()); + } + } catch {} + + state.views.set(tabId, view); + desktopViewByWebContentsId.set(view.webContents.id, { win, tabId, view }); + + view.webContents.on('page-title-updated', (_e, title) => { + sendBrowserViewEvent(win, { tabId, type: 'page-title-updated', title }); + }); + + view.webContents.on('destroyed', () => { + try { desktopViewByWebContentsId.delete(view.webContents.id); } catch {} + try { state.views.delete(tabId); } catch {} + if (state.activeTabId === tabId) state.activeTabId = null; + }); + + view.webContents.on('page-favicon-updated', (_e, favicons) => { + sendBrowserViewEvent(win, { tabId, type: 'page-favicon-updated', favicons }); + }); + + view.webContents.on('did-navigate', (_e, url) => { + sendBrowserViewEvent(win, { tabId, type: 'did-navigate', url }); + }); + + view.webContents.on('did-navigate-in-page', (_e, url) => { + sendBrowserViewEvent(win, { tabId, type: 'did-navigate-in-page', url }); + }); + + view.webContents.on('did-finish-load', () => { + sendBrowserViewEvent(win, { tabId, type: 'did-finish-load' }); + }); + + view.webContents.on('did-fail-load', (_e, errorCode, errorDescription, validatedURL, isMainFrame) => { + sendBrowserViewEvent(win, { + tabId, + type: 'did-fail-load', + errorCode, + errorDescription, + validatedURL, + isMainFrame + }); + }); + + view.webContents.on('dom-ready', () => { + try { view.webContents.insertCSS(SCROLL_NORMALIZATION_CSS); } catch {} + try { view.webContents.executeJavaScript(SCROLL_NORMALIZATION_JS, true); } catch {} + sendBrowserViewEvent(win, { tabId, type: 'dom-ready' }); + }); + + view.webContents.on('focus', () => { + sendBrowserViewEvent(win, { tabId, type: 'focus' }); + }); + + // Route window.open() calls to tabs unless OAuth allowlist matched + view.webContents.setWindowOpenHandler((details) => { + const { url: targetUrl } = details; + if (!/^https?:\/\//i.test(targetUrl)) return { action: 'deny' }; + const oauthDomains = [ + 'accounts.google.com', + 'login.microsoftonline.com', + 'appleid.apple.com', + 'github.com/login', + 'auth0.com', + 'okta.com', + 'login.live.com', + 'facebook.com/dialog', + 'api.twitter.com/oauth', + 'discord.com/oauth2' + ]; + const isOAuthDomain = oauthDomains.some(domain => targetUrl.toLowerCase().includes(domain.toLowerCase())); + if (isOAuthDomain) return { action: 'allow' }; + try { win.webContents.send('open-url-new-tab', targetUrl); } catch {} + return { action: 'deny' }; + }); + + if (url) { + try { view.webContents.loadURL(url); } catch {} + } + + return view; +} + +function setActiveBrowserView(win, tabId) { + const state = getDesktopViewState(win); + if (!state) return null; + const view = state.views.get(tabId); + if (!view) return null; + + state.activeTabId = tabId; + try { + win.setBrowserView(view); + if (state.bounds) { + view.setBounds(state.bounds); + } + view.setAutoResize({ width: true, height: true }); + view.webContents.focus(); + } catch {} + return view; +} + +function destroyBrowserView(win, tabId) { + const state = getDesktopViewState(win); + if (!state) return false; + const view = state.views.get(tabId); + if (!view) return false; + try { + if (state.activeTabId === tabId) { + try { win.setBrowserView(null); } catch {} + state.activeTabId = null; + } + state.views.delete(tabId); + desktopViewByWebContentsId.delete(view.webContents.id); + try { view.webContents.destroy(); } catch {} + } catch {} + return true; +} + +function getZoomTargetForEvent(event) { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return null; + if (win.__nebulaMode === 'desktop') { + return getActiveDesktopViewWebContents(win) || win.webContents; + } + return win.webContents; +} + // Initialize portable data paths BEFORE app.ready (must be done early) // This enables portable mode on all platforms (Windows, macOS, Linux) // Data is stored in 'user-data' folder within the application directory @@ -369,7 +671,10 @@ function getScreenInfo() { */ function launchBigPictureMode() { const windows = BrowserWindow.getAllWindows(); - const mainWindow = windows[0]; + // Prefer the top-level app window (menu popup is a child window) + const mainWindow = windows.find(w => { + try { return w && !w.isDestroyed() && !w.getParentWindow(); } catch { return false; } + }); if (!mainWindow || mainWindow.isDestroyed()) { console.warn('[BigPicture] No main window available'); @@ -383,6 +688,10 @@ function launchBigPictureMode() { } isInBigPictureMode = true; + + // Switch mode and ensure any active BrowserView is detached so it can't cover the UI. + try { mainWindow.__nebulaMode = 'bigpicture'; } catch {} + try { mainWindow.setBrowserView(null); } catch {} // Enter fullscreen for Big Picture experience mainWindow.setFullScreen(true); @@ -400,7 +709,10 @@ function launchBigPictureMode() { */ function exitBigPictureMode() { const windows = BrowserWindow.getAllWindows(); - const mainWindow = windows[0]; + // Prefer the top-level app window (menu popup is a child window) + const mainWindow = windows.find(w => { + try { return w && !w.isDestroyed() && !w.getParentWindow(); } catch { return false; } + }); if (!mainWindow || mainWindow.isDestroyed()) { console.warn('[BigPicture] No main window to exit from'); @@ -413,6 +725,9 @@ function exitBigPictureMode() { } isInBigPictureMode = false; + + // Restore desktop mode and (after the UI reloads) reattach the active BrowserView. + try { mainWindow.__nebulaMode = 'desktop'; } catch {} // Exit fullscreen and restore normal window mainWindow.setFullScreen(false); @@ -420,6 +735,21 @@ function exitBigPictureMode() { // Navigate back to desktop UI mainWindow.loadFile('renderer/index.html'); + + try { + mainWindow.webContents.once('did-finish-load', () => { + try { + const state = getDesktopViewState(mainWindow); + const tabId = state?.activeTabId; + const view = tabId ? state?.views?.get(tabId) : null; + if (view) { + try { mainWindow.setBrowserView(view); } catch {} + try { if (state.bounds) view.setBounds(state.bounds); } catch {} + try { view.setAutoResize({ width: true, height: true }); } catch {} + } + } catch {} + }); + } catch {} // Maximize on Windows after exiting fullscreen if (process.platform === 'win32') { @@ -551,6 +881,17 @@ function createWindow(startUrl, bigPictureMode = false) { } const win = new BrowserWindow(windowOptions); + win.__nebulaMode = bigPictureMode ? 'bigpicture' : 'desktop'; + win.on('closed', () => { + const state = desktopViewStateByWindowId.get(win.id); + if (state) { + for (const view of state.views.values()) { + try { desktopViewByWebContentsId.delete(view.webContents.id); } catch {} + try { view.webContents.destroy(); } catch {} + } + desktopViewStateByWindowId.delete(win.id); + } + }); perfMarks.browserWindow_instantiated = performance.now(); // Intercept window.open() requests and route them into the existing window as a new tab @@ -824,8 +1165,14 @@ app.whenReady().then(() => { // --- Auto-Updater Setup --- // Configure auto-updater logging - autoUpdater.logger = require('electron-updater').autoUpdater.logger; - if (autoUpdater.logger) autoUpdater.logger.transports.file.level = 'info'; + try { + autoUpdater.logger = require('electron-updater').autoUpdater.logger; + if (autoUpdater.logger && autoUpdater.logger.transports && autoUpdater.logger.transports.file) { + autoUpdater.logger.transports.file.level = 'info'; + } + } catch (err) { + console.log('[AutoUpdater] Could not configure logger:', err.message); + } // Check for updates after a short delay to not block startup setTimeout(() => { @@ -1155,12 +1502,13 @@ ipcMain.handle('clear-search-history', async () => { }); ipcMain.handle('get-zoom-factor', event => { - const wc = BrowserWindow.fromWebContents(event.sender).webContents; - return wc.getZoomFactor(); + const wc = getZoomTargetForEvent(event); + return wc ? wc.getZoomFactor() : 1.0; }); ipcMain.handle('zoom-in', event => { - const wc = BrowserWindow.fromWebContents(event.sender).webContents; + const wc = getZoomTargetForEvent(event); + if (!wc) return 1.0; const current = wc.getZoomFactor(); const z = Math.min(current + 0.1, 3); wc.setZoomFactor(z); @@ -1169,7 +1517,8 @@ ipcMain.handle('zoom-in', event => { ipcMain.handle('zoom-out', event => { - const wc = BrowserWindow.fromWebContents(event.sender).webContents; + const wc = getZoomTargetForEvent(event); + if (!wc) return 1.0; const current = wc.getZoomFactor(); const z = Math.max(current - 0.1, 0.25); wc.setZoomFactor(z); @@ -1192,7 +1541,7 @@ ipcMain.handle('get-display-scale', async (event) => { }); ipcMain.handle('set-zoom-factor', (event, zoomFactor) => { - const wc = BrowserWindow.fromWebContents(event.sender).webContents; + const wc = getZoomTargetForEvent(event); if (wc && typeof wc.setZoomFactor === 'function') { wc.setZoomFactor(zoomFactor); return true; @@ -1298,9 +1647,11 @@ ipcMain.handle('get-about-info', () => { // Toggle DevTools for the requesting window (main window webContents) ipcMain.handle('open-devtools', (event) => { - const wc = BrowserWindow.fromWebContents(event.sender); - if (!wc) return false; - const contents = wc.webContents; + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return false; + const contents = win.__nebulaMode === 'desktop' + ? (getActiveDesktopViewWebContents(win) || win.webContents) + : win.webContents; if (contents.isDevToolsOpened()) { contents.closeDevTools(); } else { @@ -1310,6 +1661,231 @@ ipcMain.handle('open-devtools', (event) => { return contents.isDevToolsOpened(); }); +// ============================================================================= +// BrowserView IPC (desktop mode tabs) +// ============================================================================= +ipcMain.handle('browserview-create', (event, { tabId, url }) => { + try { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return { success: false, error: 'no-window' }; + if (win.__nebulaMode !== 'desktop') return { success: false, error: 'not-desktop' }; + const view = createBrowserViewForTab(win, tabId, url); + return { success: !!view }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('browserview-set-active', (event, { tabId }) => { + try { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return false; + if (win.__nebulaMode !== 'desktop') return false; + return !!setActiveBrowserView(win, tabId); + } catch { + return false; + } +}); + +ipcMain.handle('browserview-destroy', (event, { tabId }) => { + try { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return false; + if (win.__nebulaMode !== 'desktop') return false; + return destroyBrowserView(win, tabId); + } catch { + return false; + } +}); + +ipcMain.handle('browserview-load-url', (event, { tabId, url }) => { + try { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return false; + if (win.__nebulaMode !== 'desktop') return false; + const state = getDesktopViewState(win); + const view = state?.views.get(tabId); + if (!view) return false; + view.webContents.loadURL(url); + return true; + } catch { + return false; + } +}); + +ipcMain.handle('browserview-reload', (event, { tabId, ignoreCache }) => { + try { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return false; + if (win.__nebulaMode !== 'desktop') return false; + const state = getDesktopViewState(win); + const view = state?.views.get(tabId); + if (!view) return false; + if (ignoreCache && typeof view.webContents.reloadIgnoringCache === 'function') { + view.webContents.reloadIgnoringCache(); + } else { + view.webContents.reload(); + } + return true; + } catch { + return false; + } +}); + +ipcMain.handle('browserview-get-url', (event, { tabId }) => { + try { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return null; + if (win.__nebulaMode !== 'desktop') return null; + const state = getDesktopViewState(win); + const view = state?.views.get(tabId); + return view?.webContents.getURL() || null; + } catch { + return null; + } +}); + +ipcMain.handle('browserview-execute-js', async (event, { tabId, code }) => { + try { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return null; + if (win.__nebulaMode !== 'desktop') return null; + const state = getDesktopViewState(win); + const view = state?.views.get(tabId); + if (!view) return null; + return await view.webContents.executeJavaScript(code, true); + } catch { + return null; + } +}); + +ipcMain.handle('browserview-set-bounds', (event, bounds) => { + try { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return false; + if (win.__nebulaMode !== 'desktop') return false; + const state = getDesktopViewState(win); + if (!state) return false; + const safeBounds = { + x: Math.max(0, Math.round(bounds?.x || 0)), + y: Math.max(0, Math.round(bounds?.y || 0)), + width: Math.max(0, Math.round(bounds?.width || 0)), + height: Math.max(0, Math.round(bounds?.height || 0)) + }; + state.bounds = safeBounds; + if (state.activeTabId) { + const view = state.views.get(state.activeTabId); + if (view) { + view.setBounds(safeBounds); + } + } + return true; + } catch { + return false; + } +}); + +// Overlay menu (to sit above BrowserView) +ipcMain.on('menu-popup-toggle', (event, payload = {}) => { + try { + const parentWin = BrowserWindow.fromWebContents(event.sender); + if (!parentWin) return; + + let menuWin = menuPopupByWindowId.get(parentWin.id); + if (!menuWin || menuWin.isDestroyed()) { + menuWin = createMenuPopupWindow(parentWin); + menuPopupByWindowId.set(parentWin.id, menuWin); + } + + if (menuWin.isVisible()) { + menuWin.hide(); + return; + } + + positionMenuPopup(parentWin, menuWin, payload.anchorRect); + + const initPayload = { theme: payload.theme || null }; + const sendInit = () => { + try { menuWin.webContents.send('menu-popup-init', initPayload); } catch {} + }; + try { + if (menuWin.webContents.isLoadingMainFrame()) { + menuWin.webContents.once('did-finish-load', sendInit); + } else { + sendInit(); + } + } catch {} + + menuWin.show(); + menuWin.focus(); + } catch {} +}); + +ipcMain.on('menu-popup-hide', (event) => { + try { + const parentWin = BrowserWindow.fromWebContents(event.sender); + if (!parentWin) return; + const menuWin = menuPopupByWindowId.get(parentWin.id); + if (menuWin && !menuWin.isDestroyed()) menuWin.hide(); + } catch {} +}); + +ipcMain.on('menu-popup-command', (event, payload = {}) => { + try { + const menuWin = BrowserWindow.fromWebContents(event.sender); + const parentWin = menuWin?.getParentWindow(); + if (menuWin && !menuWin.isDestroyed()) menuWin.hide(); + if (!parentWin || parentWin.isDestroyed()) return; + if (!payload?.cmd || payload.cmd === 'close') return; + parentWin.webContents.send('menu-command', payload); + } catch {} +}); + +ipcMain.on('browserview-broadcast', (event, { channel, args }) => { + try { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return; + if (win.__nebulaMode !== 'desktop') return; + const state = getDesktopViewState(win); + if (!state) return; + for (const view of state.views.values()) { + try { view.webContents.send(channel, ...(args || [])); } catch {} + } + } catch {} +}); + +ipcMain.on('browserview-host-message', (event, payload = {}) => { + try { + const { tabId, channel, args } = payload || {}; + console.log('[IPC Main] browserview-host-message received, tabId:', tabId, 'channel:', channel); + + let win = getOwnerWindowForContents(event.sender); + console.log('[IPC Main] getOwnerWindowForContents returned:', win ? 'window found' : 'null'); + + if (!win && tabId) { + console.log('[IPC Main] Trying to find window by tabId...'); + for (const candidate of BrowserWindow.getAllWindows()) { + const state = desktopViewStateByWindowId.get(candidate.id); + console.log('[IPC Main] Checking window', candidate.id, 'state:', state ? 'found' : 'null', 'has tabId:', state?.views?.has(tabId)); + if (state && state.views && state.views.has(tabId)) { + win = candidate; + console.log('[IPC Main] Found window by tabId'); + break; + } + } + } + + if (!win || win.isDestroyed()) { + console.log('[IPC Main] No valid window found, returning'); + return; + } + console.log('[IPC Main] Forwarding to renderer'); + win.webContents.send('browserview-host-message', { tabId, channel, args: args || [] }); + } catch (err) { + console.error('[IPC Main] Error:', err); + } +}); + // Helper function to read package.json version function getInstalledElectronVersion() { try { @@ -1524,7 +2100,8 @@ ipcMain.handle('show-open-file-dialog', async () => { // Helper to build and show a native context menu for a given webContents + params function buildAndShowContextMenu(sender, params = {}) { try { - const embedder = sender.hostWebContents || sender; + const ownerWin = getOwnerWindowForContents(sender); + const embedder = ownerWin?.webContents || sender.hostWebContents || sender; const template = []; template.push( @@ -1584,21 +2161,19 @@ function buildAndShowContextMenu(sender, params = {}) { label: 'Inspect Element', click: () => { try { - // Use the main window's webContents for DevTools - const mainWin = BrowserWindow.fromWebContents(sender.hostWebContents || sender); - const mainWC = mainWin.webContents; + const inspectTarget = sender; const inspectX = params.x ?? params.clientX ?? 0; const inspectY = params.y ?? params.clientY ?? 0; // Open DevTools docked at bottom if not already open - if (!mainWC.isDevToolsOpened()) { - mainWC.openDevTools({ mode: 'bottom' }); + if (!inspectTarget.isDevToolsOpened()) { + inspectTarget.openDevTools({ mode: 'bottom' }); } // Inspect the element setTimeout(() => { try { - mainWC.inspectElement(inspectX, inspectY); + inspectTarget.inspectElement(inspectX, inspectY); } catch (e) { // Fallback: try on original sender try { sender.inspectElement(inspectX, inspectY); } catch {} @@ -1613,7 +2188,7 @@ function buildAndShowContextMenu(sender, params = {}) { // Allow plugins to customize/append context menu try { pluginManager.applyContextMenuContrib(template, params, sender); } catch {} const menu = Menu.buildFromTemplate(template); - const win = BrowserWindow.fromWebContents(embedder); + const win = ownerWin || BrowserWindow.fromWebContents(embedder); if (win) menu.popup({ window: win }); } catch (err) { console.error('Failed to build context menu:', err); diff --git a/preload.js b/preload.js index 9d5d17d..7b6ea5e 100644 --- a/preload.js +++ b/preload.js @@ -10,6 +10,13 @@ try { fsModule = null; } +// BrowserView tab id (desktop mode) injected via additionalArguments +let nebulaTabId = null; +try { + const arg = (process?.argv || []).find(a => typeof a === 'string' && a.startsWith('--nebula-tab-id=')); + if (arg) nebulaTabId = arg.split('=')[1] || null; +} catch {} + // ============================================================================= // GAMEPAD HANDLER - Steam Deck / SteamOS Support // ============================================================================= @@ -272,10 +279,19 @@ const electronAPI = { console.error('IPC send error:', err); } }, - // Send message to embedding page (webview host) + // Send message to embedding page (webview host) or to BrowserView host sendToHost: (ch, ...args) => { try { - return ipcRenderer.sendToHost(ch, ...args); + // If running in BrowserView context, ALWAYS use browserview-host-message + if (nebulaTabId) { + return ipcRenderer.send('browserview-host-message', { tabId: nebulaTabId, channel: ch, args }); + } + // Otherwise try ipcRenderer.sendToHost (for webview contexts) + if (typeof ipcRenderer.sendToHost === 'function') { + return ipcRenderer.sendToHost(ch, ...args); + } + // Final fallback + return ipcRenderer.send(ch, ...args); } catch (err) { console.error('IPC sendToHost error:', err); } diff --git a/renderer/bigpicture.css b/renderer/bigpicture.css index f24f426..1c185fb 100644 --- a/renderer/bigpicture.css +++ b/renderer/bigpicture.css @@ -240,7 +240,8 @@ body.mouse-active { } .bp-exit-btn:hover, -.bp-exit-btn:focus { +.bp-exit-btn:focus, +.bp-exit-btn.focused { background: var(--bp-surface-hover); border-color: var(--bp-primary); box-shadow: var(--bp-focus-ring); @@ -306,7 +307,8 @@ body.mouse-active { color: var(--bp-text); } -.nav-item:focus { +.nav-item:focus, +.nav-item.focused { outline: none; background: var(--bp-surface-hover); border-color: var(--bp-primary); @@ -314,7 +316,8 @@ body.mouse-active { color: var(--bp-text); } -.nav-item:focus .material-symbols-outlined { +.nav-item:focus .material-symbols-outlined, +.nav-item.focused .material-symbols-outlined { transform: scale(1.1); } @@ -449,7 +452,8 @@ body.mouse-active { border-color: var(--bp-text-dim); } -.action-btn:focus { +.action-btn:focus, +.action-btn.focused { outline: none; border-color: var(--bp-primary); box-shadow: var(--bp-focus-ring); @@ -461,7 +465,8 @@ body.mouse-active { } .action-btn.danger:hover, -.action-btn.danger:focus { +.action-btn.danger:focus, +.action-btn.danger.focused { border-color: var(--bp-danger); color: var(--bp-danger); } @@ -495,7 +500,8 @@ body.mouse-active { } .search-card:focus, -.search-card:focus-within { +.search-card:focus-within, +.search-card.focused { outline: none; border-color: var(--bp-accent); box-shadow: var(--bp-focus-ring-accent); @@ -599,14 +605,16 @@ body.mouse-active { opacity: 1; } -.tile:focus { +.tile:focus, +.tile.focused { outline: none; border-color: var(--bp-primary); box-shadow: var(--bp-focus-ring); transform: scale(1.02); } -.tile:focus::before { +.tile:focus::before, +.tile.focused::before { opacity: 1; } @@ -671,13 +679,15 @@ body.mouse-active { } .tile.add-tile:hover, -.tile.add-tile:focus { +.tile.add-tile:focus, +.tile.add-tile.focused { border-color: var(--bp-accent); border-style: solid; } .tile.add-tile:hover .material-symbols-outlined, -.tile.add-tile:focus .material-symbols-outlined { +.tile.add-tile:focus .material-symbols-outlined, +.tile.add-tile.focused .material-symbols-outlined { color: var(--bp-accent); } @@ -722,7 +732,8 @@ body.mouse-active { transform: translateY(-4px); } -.scroll-card:focus { +.scroll-card:focus, +.scroll-card.focused { outline: none; border-color: var(--bp-primary); box-shadow: var(--bp-focus-ring); @@ -795,7 +806,8 @@ body.mouse-active { background: var(--bp-surface-hover); } -.list-item:focus { +.list-item:focus, +.list-item.focused { outline: none; border-color: var(--bp-primary); box-shadow: var(--bp-focus-ring); @@ -910,7 +922,8 @@ body.mouse-active { transform: scale(1.02); } -.nebot-card:focus { +.nebot-card:focus, +.nebot-card.focused { outline: none; border-color: var(--bp-accent); box-shadow: var(--bp-focus-ring-accent); @@ -974,7 +987,8 @@ body.mouse-active { transform: scale(1.02); } -.settings-card:focus { +.settings-card:focus, +.settings-card.focused { outline: none; border-color: var(--bp-primary); box-shadow: var(--bp-focus-ring); @@ -1028,7 +1042,8 @@ body.mouse-active { color: var(--bp-text); } -.settings-tab:focus { +.settings-tab:focus, +.settings-tab.focused { outline: none; border-color: var(--bp-primary); box-shadow: var(--bp-focus-ring); @@ -1091,14 +1106,9 @@ body.mouse-active { transform: translateY(-2px); } -.theme-card:focus { - outline: none; - border-color: var(--bp-primary); - box-shadow: var(--bp-focus-ring); - transform: translateY(-2px) scale(1.02); -} - +.theme-card:focus, .theme-card.focused { + outline: none; border-color: var(--bp-primary); box-shadow: var(--bp-focus-ring); transform: translateY(-2px) scale(1.02); @@ -1187,18 +1197,13 @@ body.mouse-active { border-color: var(--bp-primary); } -.scale-btn:focus { +.scale-btn:focus, +.scale-btn.focused { outline: none; border-color: var(--bp-primary); box-shadow: var(--bp-focus-ring); } -.scale-btn.focused { - border-color: var(--bp-primary); - box-shadow: var(--bp-focus-ring); - background: var(--bp-primary); -} - .scale-value { min-width: 60px; text-align: center; @@ -1227,18 +1232,13 @@ body.mouse-active { border-color: var(--bp-primary); } -.action-button:focus { +.action-button:focus, +.action-button.focused { outline: none; border-color: var(--bp-primary); box-shadow: var(--bp-focus-ring); } -.action-button.focused { - border-color: var(--bp-primary); - box-shadow: var(--bp-focus-ring); - background: var(--bp-primary); -} - .action-button.danger:hover { background: #dc3545; border-color: #dc3545; @@ -1500,7 +1500,8 @@ body.mouse-active { } .osk-close:hover, -.osk-close:focus { +.osk-close:focus, +.osk-close.focused { background: var(--bp-danger); border-color: var(--bp-danger); outline: none; @@ -1542,7 +1543,8 @@ body.mouse-active { transform: scale(1.05); } -.osk-key:focus { +.osk-key:focus, +.osk-key.focused { outline: none; border-color: var(--bp-accent); box-shadow: 0 0 0 4px var(--bp-accent-glow), 0 0 20px var(--bp-accent-glow); @@ -1592,7 +1594,8 @@ body.mouse-active { } .osk-action-btn:hover, -.osk-action-btn:focus { +.osk-action-btn:focus, +.osk-action-btn.focused { background: var(--bp-surface-active); outline: none; border-color: var(--bp-accent); @@ -1604,7 +1607,8 @@ body.mouse-active { } .osk-action-btn.primary:hover, -.osk-action-btn.primary:focus { +.osk-action-btn.primary:focus, +.osk-action-btn.primary.focused { box-shadow: var(--bp-focus-ring); } @@ -1676,7 +1680,8 @@ body.mouse-active { background: var(--bp-surface-hover); } -.context-item:focus { +.context-item:focus, +.context-item.focused { outline: none; border-color: var(--bp-primary); background: var(--bp-surface-hover); @@ -1688,7 +1693,8 @@ body.mouse-active { } /* Focus indicators for controller navigation */ -[data-focusable]:focus { +[data-focusable]:focus, +[data-focusable].focused { outline: none; } diff --git a/renderer/bigpicture.js b/renderer/bigpicture.js index 38830b6..ab66540 100644 --- a/renderer/bigpicture.js +++ b/renderer/bigpicture.js @@ -1912,10 +1912,15 @@ function navigateTo(url) { webview.style.width = '100%'; webview.style.height = '100%'; webview.style.border = 'none'; - webview.preload = '../preload.js'; + const preloadPath = window.electronAPI?.getWebviewPreloadPath?.(); + if (preloadPath) { + webview.setAttribute('preload', preloadPath); + } else { + webview.setAttribute('preload', '../preload.js'); + } webview.partition = 'persist:main'; webview.allowpopups = true; - webview.webpreferences = 'allowRunningInsecureContent=false,javascript=true,webSecurity=true'; + webview.setAttribute('webpreferences', 'allowRunningInsecureContent=false,javascript=true,webSecurity=true'); container.appendChild(webview); state.currentWebview = webview; diff --git a/renderer/index.html b/renderer/index.html index 0280dd1..1a3470c 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -22,6 +22,11 @@ color: rgba(255, 255, 255, 0.5); /* Adjust the color and transparency as needed */ } + + #view-host { + flex: 1; + width: 100%; + } @@ -100,20 +105,7 @@ - - - -
- - -
+
diff --git a/renderer/menu-popup.css b/renderer/menu-popup.css new file mode 100644 index 0000000..ffc9cef --- /dev/null +++ b/renderer/menu-popup.css @@ -0,0 +1,66 @@ +:root { + --bg: #0b0d10; + --primary: #7b2eff; + --accent: #00c6ff; + --text: #e0e0e0; + --url-bar-bg: #1c2030; + --url-bar-border: #3e4652; + --shadow-1: 0 12px 30px rgba(0, 0, 0, 0.35); + --blur: 12px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: transparent; + color: var(--text); + font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +#menu-popup { + background: color-mix(in srgb, var(--url-bar-bg) 92%, var(--text) 8%); + border: 1px solid color-mix(in srgb, var(--primary) 25%, color-mix(in srgb, var(--accent) 18%, transparent)); + border-radius: 14px; + padding: 8px; + display: flex; + flex-direction: column; + min-width: 220px; + box-shadow: var(--shadow-1); + -webkit-backdrop-filter: blur(var(--blur)); + backdrop-filter: blur(var(--blur)); +} + +#menu-popup button { + background: transparent; + border: none; + color: var(--text); + text-align: left; + padding: 8px 10px; + border-radius: 10px; + cursor: pointer; + transition: background 120ms ease, filter 120ms ease; +} + +#menu-popup button:hover { + background: color-mix(in srgb, var(--text) 8%, transparent); +} + +.zoom-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; +} + +.zoom-controls button { + width: 28px; + height: 28px; + text-align: center; +} + +#zoom-percent { + min-width: 54px; + text-align: center; +} diff --git a/renderer/menu-popup.html b/renderer/menu-popup.html new file mode 100644 index 0000000..3e8678c --- /dev/null +++ b/renderer/menu-popup.html @@ -0,0 +1,23 @@ + + + + + Menu + + + + + + + diff --git a/renderer/menu-popup.js b/renderer/menu-popup.js new file mode 100644 index 0000000..901598f --- /dev/null +++ b/renderer/menu-popup.js @@ -0,0 +1,46 @@ +const zoomPercentEl = document.getElementById('zoom-percent'); + +function setCssVar(name, value, fallback) { + const val = value || fallback; + if (val) document.documentElement.style.setProperty(name, val); +} + +function applyTheme(theme) { + const colors = theme?.colors || theme || {}; + setCssVar('--bg', colors.bg, '#0b0d10'); + setCssVar('--dark-blue', colors.darkBlue, '#0b1c2b'); + setCssVar('--dark-purple', colors.darkPurple, '#1b1035'); + setCssVar('--primary', colors.primary, '#7b2eff'); + setCssVar('--accent', colors.accent, '#00c6ff'); + setCssVar('--text', colors.text, '#e0e0e0'); + setCssVar('--url-bar-bg', colors.urlBarBg, '#1c2030'); + setCssVar('--url-bar-border', colors.urlBarBorder, '#3e4652'); +} + +async function refreshZoom() { + if (!window.electronAPI?.invoke || !zoomPercentEl) return; + try { + const z = await window.electronAPI.invoke('get-zoom-factor'); + zoomPercentEl.textContent = `${Math.round(z * 100)}%`; + } catch {} +} + +window.electronAPI?.on?.('menu-popup-init', (payload) => { + applyTheme(payload?.theme); + refreshZoom(); +}); + +window.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-cmd]'); + if (!btn) return; + const cmd = btn.getAttribute('data-cmd'); + window.electronAPI?.send?.('menu-popup-command', { cmd }); +}); + +window.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + window.electronAPI?.send?.('menu-popup-command', { cmd: 'close' }); + } +}); + +refreshZoom(); diff --git a/renderer/script.js b/renderer/script.js index 08759cf..519ec26 100644 --- a/renderer/script.js +++ b/renderer/script.js @@ -268,10 +268,25 @@ function applyThemeToMainUI(theme) { // 1) cache hot DOM references const urlBox = document.getElementById('url'); const tabBarEl = document.getElementById('tab-bar'); -const webviewsEl = document.getElementById('webviews'); +const viewHostEl = document.getElementById('view-host'); const menuPopup = document.getElementById('menu-popup'); // (Removed old custom HTML context menu in favor of native Electron menu) +function updateBrowserViewBounds() { + if (!viewHostEl) return; + const rect = viewHostEl.getBoundingClientRect(); + ipcRenderer.invoke('browserview-set-bounds', { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height + }).catch(() => {}); +} + +window.addEventListener('resize', () => { + updateBrowserViewBounds(); +}); + // Select all text on focus and prevent mouseup from deselecting urlBox.addEventListener('focus', () => { urlBox.select(); @@ -430,6 +445,71 @@ ipcRenderer.on('open-url-new-tab', (url) => { if (typeof url === 'string' && url) createTab(url); }); +// Messages from BrowserView pages (sendToHost fallback) +ipcRenderer.on('browserview-host-message', (payload) => { + console.log('[Renderer] browserview-host-message received:', payload); + const data = payload || {}; + const channel = data.channel; + const args = data.args || []; + if (!channel) return; + + if (channel === 'navigate' && args[0]) { + console.log('[Renderer] Navigating to:', args[0]); + const targetUrl = args[0]; + const opts = args[1] || {}; + try { + if (opts.insecureBypass && /^http:\/\//i.test(targetUrl)) { + const h = new URL(targetUrl).hostname; + insecureBypassedHosts.add(h); + } + } catch {} + if (opts.newTab) { + createTab(targetUrl); + } else { + urlBox.value = targetUrl; + navigate(); + } + } else if (channel === 'theme-update' && args[0]) { + const theme = args[0]; + applyThemeToMainUI(theme); + ipcRenderer.send('browserview-broadcast', { channel: 'theme-update', args: [theme] }); + } +}); + +// Commands from the overlay menu window +ipcRenderer.on('menu-command', (payload) => { + const cmd = payload?.cmd; + if (!cmd) return; + switch (cmd) { + case 'open-settings': + openSettings(); + break; + case 'open-downloads': + openDownloads(); + break; + case 'toggle-devtools': + window.electronAPI?.toggleDevTools?.(); + break; + case 'big-picture': + window.bigPictureAPI?.launch?.(); + break; + case 'zoom-in': + zoomIn(); + break; + case 'zoom-out': + zoomOut(); + break; + case 'hard-reload': + hardReload(); + break; + case 'fresh-reload': + freshReload(); + break; + default: + break; + } +}); + // Auto-open on download start is disabled by design now. function createTab(inputUrl) { @@ -442,172 +522,27 @@ function createTab(inputUrl) { pendingInternalNavigations.push(() => createTab(inputUrl)); return id; } - - // Handle home page specially - if (inputUrl === 'nebula://home') { - // Show home container and hide webviews - const homeContainer = document.getElementById('home-container'); - const webviewsEl = document.getElementById('webviews'); - if (homeContainer) homeContainer.classList.add('active'); - if (webviewsEl) webviewsEl.classList.add('hidden'); - const tab = { - id, - url: inputUrl, - title: 'New Tab', - favicon: '', - history: [inputUrl], - historyIndex: 0, - isHome: true - }; - tabs.push(tab); - setActiveTab(id); - // Render the tab bar so the new home tab appears - scheduleRenderTabs(); - return id; - } - - // For all other URLs, use webview let resolvedUrl = resolveInternalUrl(inputUrl); console.log('[DEBUG] createTab resolvedUrl:', resolvedUrl, 'from inputUrl:', inputUrl); - // If it's a raw data: URL (image) keep as is; blob: will only resolve within its origin context (may fail) - // For very long data URLs we could embed them in a minimal viewer page for cleaner rendering. - if (resolvedUrl.startsWith('data:') && resolvedUrl.length > 4096) { - // Create a simple object URL page to avoid huge URL in the address bar (cannot easily persist across restarts). - const html = ``+ - ``+ - ``; - const blob = new Blob([html], { type: 'text/html' }); - resolvedUrl = URL.createObjectURL(blob); - } - debug('[DEBUG] createTab() resolvedUrl =', resolvedUrl); - - const webview = document.createElement('webview'); - // give the webview an id and set its source and attributes so it actually loads and can be managed - webview.id = `tab-${id}`; - webview.src = resolvedUrl; - webview.setAttribute('allowpopups', ''); - webview.setAttribute('partition', 'persist:main'); - // Use absolute preload path provided by the main-window preload to ensure - // the guest process can resolve the file (important on Linux/SteamOS). - try { - const preloadPath = (window.electronAPI && typeof window.electronAPI.getWebviewPreloadPath === 'function') - ? window.electronAPI.getWebviewPreloadPath() - : '../preload.js'; - webview.setAttribute('preload', preloadPath); - } catch (e) { - webview.setAttribute('preload', '../preload.js'); - } - // Add attributes needed for Google OAuth and sign-in flows - webview.setAttribute('webpreferences', 'allowRunningInsecureContent=false,javascript=true,webSecurity=true'); - try { - const baseUA = navigator.userAgent.includes('Nebula/') ? navigator.userAgent : navigator.userAgent + ' Nebula/1.0.0'; - webview.setAttribute('useragent', baseUA); - } catch { - // fallback: let Electron supply default UA - } - - webview.addEventListener('page-favicon-updated', e => { - if (e.favicons.length > 0) updateTabMetadata(id, 'favicon', e.favicons[0]); - }); - - // Send bookmarks to home page when it loads and apply scroll normalization - webview.addEventListener('dom-ready', () => { - // Apply scroll normalization to all sites for consistent scrolling - applyScrollNormalization(webview); - - if (inputUrl === 'nebula://home') { - webview.executeJavaScript(` - if (window.receiveBookmarks) { - window.receiveBookmarks(${JSON.stringify(bookmarks)}); - } else { - // Store bookmarks for when the page script loads - window._pendingBookmarks = ${JSON.stringify(bookmarks)}; - } - `); - } - }); - - // Consolidated navigation recording - only use did-navigate to avoid duplicates - webview.addEventListener('did-navigate', e => { - handleNavigation(id, e.url); - if (e.url.startsWith('http')) debug('[DEBUG] Recording navigation to:', e.url); - if (/\/cdn-cgi\//.test(e.url) || /challenge/i.test(e.url)) { - console.log('[Nebula] Cloudflare challenge detected at', e.url); - } - }); - - webview.addEventListener('did-navigate-in-page', e => { - handleNavigation(id, e.url); - if (e.url.startsWith('http')) debug('[DEBUG] Recording in-page navigation to:', e.url); - }); - - // After load, just refresh nav buttons to avoid jank - webview.addEventListener('did-finish-load', () => { - scheduleUpdateNavButtons(); - }); - // Catch failed loads for diagnostics (e.g., http -> https transitions failing) - webview.addEventListener('did-fail-load', (e) => { - console.warn('[DEBUG] did-fail-load (createTab) id:', id, 'code:', e.errorCode, 'desc:', e.errorDescription, 'validatedURL:', e.validatedURL, 'isMainFrame:', e.isMainFrame); - }); - - // catch any target="_blank" or window.open() calls and open them as new tabs - webview.addEventListener('new-window', e => { - // Always open external http(s) targets in a new in-app tab instead of spawning - // a separate Electron BrowserWindow. (User request) - // If you need to allow real popup windows for specific OAuth flows later, - // introduce an allowlist check here before preventDefault(). - if (e.url && /^https?:\/\//i.test(e.url)) { - e.preventDefault(); - createTab(e.url); - } else { - // Block other scheme popups for safety (could extend with custom handling) - e.preventDefault(); - } - }); - - // After creating dynamic webview: - webview.addEventListener('ipc-message', e => { - if (e.channel === 'navigate' && e.args[0]) { - const targetUrl = e.args[0]; - const opts = e.args[1] || {}; - // If user accepted insecure warning, record host to bypass for session - try { - if (opts.insecureBypass && /^http:\/\//i.test(targetUrl)) { - const h = new URL(targetUrl).hostname; - insecureBypassedHosts.add(h); - } - } catch {} - if (opts.newTab) { - createTab(targetUrl); - } else { - urlBox.value = targetUrl; - navigate(); - } - } else if (e.channel === 'theme-update') { - const theme = e.args[0]; - // Apply theme to main UI - applyThemeToMainUI(theme); - const home = document.getElementById('home-webview'); - if (home) home.send('theme-update', theme); - } - }); - - // Ensure interacting with the webview closes any open menu popup - attachCloseMenuOnInteract(webview); - - webviewsEl.appendChild(webview); + // Keep data: URLs intact; BrowserView cannot consume blob URLs created in the UI process. tabs.push({ id, - url: inputUrl, // ← save the original input like "nebula://home" + url: inputUrl, title: 'New Tab', favicon: null, history: [inputUrl], historyIndex: 0 }); - setActiveTab(id); + ipcRenderer.invoke('browserview-create', { tabId: id, url: resolvedUrl }) + .then(() => { + setActiveTab(id); + updateBrowserViewBounds(); + }) + .catch(() => {}); scheduleRenderTabs(); + return id; } // Expose for plugin usage (e.g., Nebot panel "Open Page") @@ -645,13 +580,21 @@ function resolveInternalUrl(url) { console.log('[DEBUG] Resolved plugin page', page, '->', resolved); return resolved + suffix; } - // Fallback: built-in renderer copy (e.g., renderer/nebot.html) + // Fallback: built-in renderer copy (resolve to absolute file URL) console.log('[DEBUG] Using fallback for page:', page); - if (page === 'nebot') return 'nebot.html' + suffix; - return `${page}.html${suffix}`; + const rel = `${page}.html${suffix}`; + try { + return new URL(rel, window.location.href).toString(); + } catch { + return rel; + } } console.log('[DEBUG] Page not in allowedInternalPages, returning 404'); - return '404.html'; + try { + return new URL('404.html', window.location.href).toString(); + } catch { + return '404.html'; + } } // Allow direct loading of common schemes without forcing https:// if (/^(https?:|file:|data:|blob:)/i.test(url)) return url; @@ -662,8 +605,11 @@ function resolveInternalUrl(url) { function handleLoadFail(tabId) { return (event) => { if (!event.validatedURL.includes('nebula://') && event.errorCode !== -3) { - const webview = document.getElementById(`tab-${tabId}`); - webview.src = `404.html?url=${encodeURIComponent(tabs.find(t => t.id === tabId).url)}`; + const badUrl = tabs.find(t => t.id === tabId)?.url || ''; + ipcRenderer.invoke('browserview-load-url', { + tabId, + url: `404.html?url=${encodeURIComponent(badUrl)}` + }).catch(() => {}); } }; } @@ -700,7 +646,7 @@ function performNavigation(input, originalInputForHistory) { resolved = resolveInternalUrl(input); } - console.log('[DEBUG] performNavigation input:', input, 'resolved:', resolved, 'tab.isHome:', tab.isHome, 'isInternal:', isInternal); + console.log('[DEBUG] performNavigation input:', input, 'resolved:', resolved, 'isInternal:', isInternal); // Intercept plain HTTP (not HTTPS) navigations (excluding localhost / 127.* / internal pages) try { @@ -711,40 +657,12 @@ function performNavigation(input, originalInputForHistory) { if (!isLoopback && !insecureBypassedHosts.has(host)) { const encoded = encodeURIComponent(resolved); // Directly load insecure.html (avoid custom scheme so OS doesn't try to resolve an external handler) - const interstitial = `insecure.html?target=${encoded}`; - // For a fresh home tab, convert directly to webview showing the interstitial - if (tab.isHome) { - convertHomeTabToWebview(tab.id, originalInputForHistory, interstitial); - return; - } - // Navigate existing webview to interstitial instead - const webviewExisting = document.getElementById(`tab-${activeTabId}`); - if (webviewExisting) webviewExisting.src = interstitial; - tab.history = tab.history.slice(0, tab.historyIndex + 1); - tab.history.push(originalInputForHistory); - tab.historyIndex++; - tab.url = originalInputForHistory; - scheduleRenderTabs(); - scheduleUpdateNavButtons(); - return; + resolved = `insecure.html?target=${encoded}`; } } } catch (e) { debug('[DEBUG] HTTP interception error', e); } - if (tab.isHome && !isInternal) { - convertHomeTabToWebview(tab.id, originalInputForHistory, resolved); - return; - } - - // If this is a home tab and we're navigating to an internal page, convert to webview - if (tab.isHome && isInternal) { - convertHomeTabToWebview(tab.id, originalInputForHistory, resolved); - return; - } - - const webview = document.getElementById(`tab-${activeTabId}`); - if (!webview) { - console.log('[DEBUG] No webview found for tab', activeTabId, 'creating new tab instead'); + if (!activeTabId) { createTab(input); return; } @@ -752,7 +670,7 @@ function performNavigation(input, originalInputForHistory) { tab.history.push(originalInputForHistory); tab.historyIndex++; tab.url = originalInputForHistory; - webview.src = resolved; + ipcRenderer.invoke('browserview-load-url', { tabId: activeTabId, url: resolved }).catch(() => {}); scheduleRenderTabs(); scheduleUpdateNavButtons(); } @@ -786,113 +704,6 @@ document.addEventListener('keydown', async (e) => { } }); -function convertHomeTabToWebview(tabId, inputUrl, resolvedUrl) { - const tab = tabs.find(t => t.id === tabId); - if (!tab) return; - - // Ensure webviews container is visible - const webviewsEl = document.getElementById('webviews'); - if (webviewsEl) webviewsEl.classList.remove('hidden'); - // Create a new webview for this tab - const webview = document.createElement('webview'); - webview.id = `tab-${tabId}`; - webview.src = resolvedUrl; - webview.setAttribute('allowpopups', ''); - webview.setAttribute('partition', 'persist:main'); - try { - const preloadPath = (window.electronAPI && typeof window.electronAPI.getWebviewPreloadPath === 'function') - ? window.electronAPI.getWebviewPreloadPath() - : '../preload.js'; - webview.setAttribute('preload', preloadPath); - } catch (e) { - webview.setAttribute('preload', '../preload.js'); - } - // Add attributes needed for Google OAuth and sign-in flows - webview.setAttribute('webpreferences', 'allowRunningInsecureContent=false,javascript=true,webSecurity=true'); - try { - const baseUA2 = navigator.userAgent.includes('Nebula/') ? navigator.userAgent : navigator.userAgent + ' Nebula/1.0.0'; - webview.setAttribute('useragent', baseUA2); - } catch {} - - // Add event listeners - webview.addEventListener('did-fail-load', handleLoadFail(tabId)); - webview.addEventListener('page-title-updated', e => updateTabMetadata(tabId, 'title', e.title)); - webview.addEventListener('page-favicon-updated', e => { - if (e.favicons.length > 0) updateTabMetadata(tabId, 'favicon', e.favicons[0]); - }); - - webview.addEventListener('did-navigate', e => { - handleNavigation(tabId, e.url); - if (/\/cdn-cgi\//.test(e.url) || /challenge/i.test(e.url)) { - console.log('[Nebula] Cloudflare challenge detected at', e.url); - } - }); - webview.addEventListener('did-navigate-in-page', e => { - handleNavigation(tabId, e.url); - }); - webview.addEventListener('did-finish-load', () => { - scheduleUpdateNavButtons(); - }); - - // Apply scroll normalization when webview is ready - webview.addEventListener('dom-ready', () => { - applyScrollNormalization(webview); - }); - - webview.addEventListener('new-window', e => { - // Unified behavior: always open http(s) targets in a new tab (no extra window) - if (e.url && /^https?:\/\//i.test(e.url)) { - e.preventDefault(); - createTab(e.url); - } else { - e.preventDefault(); - } - }); - - // After creating dynamic webview: - webview.addEventListener('ipc-message', e => { - if (e.channel === 'theme-update') { - const theme = e.args && e.args[0]; - if (theme) applyThemeToMainUI(theme); - const home = document.getElementById('home-webview'); - if (home) home.send('theme-update', ...e.args); - } else if (e.channel === 'navigate' && e.args[0]) { - const targetUrl = e.args[0]; - const opts = e.args[1] || {}; - try { - if (opts.insecureBypass && /^http:\/\//i.test(targetUrl)) { - const h = new URL(targetUrl).hostname; - insecureBypassedHosts.add(h); - } - } catch {} - urlBox.value = targetUrl; - navigate(); - } - }); - - // Add webview to DOM - webviewsEl.appendChild(webview); - - // Ensure interacting with the webview closes any open menu popup - attachCloseMenuOnInteract(webview); - - // Update tab properties - tab.isHome = false; - tab.webview = webview; - tab.url = inputUrl; - // Keep existing history (including home) - the new URL will be added by handleNavigation when webview loads - // Don't modify historyIndex here - handleNavigation will handle it - - // Hide home container and show webview - const homeContainer = document.getElementById('home-container'); - if (homeContainer) homeContainer.classList.remove('active'); - webview.classList.add('active'); - - scheduleUpdateNavButtons(); - // Activate converted webview tab and update UI - setActiveTab(tabId); - scheduleRenderTabs(); -} function handleNavigation(tabId, newUrl) { const tab = tabs.find(t => t.id === tabId); @@ -980,36 +791,16 @@ function handleNavigation(tabId, newUrl) { function setActiveTab(id) { - // hide all individual webviews - tabs.forEach(t => { - const w = document.getElementById(`tab-${t.id}`); - if (w) w.classList.remove('active'); - }); - // toggle containers - const homeContainer = document.getElementById('home-container'); - const webviewsEl = document.getElementById('webviews'); + activeTabId = id; + ipcRenderer.invoke('browserview-set-active', { tabId: id }).catch(() => {}); + updateBrowserViewBounds(); const tab = tabs.find(t => t.id === id); if (tab) { - if (tab.isHome) { - homeContainer.classList.add('active'); - webviewsEl.classList.add('hidden'); - } else { - if (homeContainer) homeContainer.classList.remove('active'); - webviewsEl.classList.remove('hidden'); - const activeWebview = document.getElementById(`tab-${id}`); - if (activeWebview) activeWebview.classList.add('active'); - } - } - - activeTabId = id; - - if (tab) { - // If the tab URL represents the home page, keep the URL bar blank. urlBox.value = tab.url === 'nebula://home' ? '' : tab.url; - scheduleRenderTabs(); + scheduleRenderTabs(); updateNavButtons(); - updateZoomUI(); // ← update zoom display for new active tab + updateZoomUI(); } } @@ -1025,9 +816,7 @@ function closeTab(id) { ? (tabs[idx - 1]?.id ?? tabs[idx + 1]?.id ?? tabs[0]?.id) : activeTabId; btn.addEventListener('animationend', () => { - // Remove webview - const w = document.getElementById(`tab-${id}`); - if (w) w.remove(); + ipcRenderer.invoke('browserview-destroy', { tabId: id }).catch(() => {}); // Remove from model tabs = tabs.filter(t => t.id !== id); // Choose a new active tab if needed @@ -1039,8 +828,7 @@ function closeTab(id) { return; } // Fallback (no button rendered yet) - const w = document.getElementById(`tab-${id}`); - if (w) w.remove(); + ipcRenderer.invoke('browserview-destroy', { tabId: id }).catch(() => {}); tabs = tabs.filter(t => t.id !== id); if (id === activeTabId && tabs.length > 0) setActiveTab(tabs[0].id); scheduleRenderTabs(); @@ -1259,9 +1047,11 @@ function renderTabs() { // 1) handle URL sent by main for a detached window ipcRenderer.on('open-url', (url) => { + for (const t of tabs) { + ipcRenderer.invoke('browserview-destroy', { tabId: t.id }).catch(() => {}); + } tabs = []; activeTabId = null; - webviewsEl.innerHTML = ''; tabBarEl.innerHTML = ''; if (typeof url === 'string' && url) createTab(url); else createTab(); }); @@ -1274,40 +1064,9 @@ function goBack() { if (tab.historyIndex > 0) { tab.historyIndex--; const targetUrl = tab.history[tab.historyIndex]; - - // Special handling for nebula://home - convert webview tab back to home tab - if (targetUrl === 'nebula://home') { - const homeContainer = document.getElementById('home-container'); - const webviewsEl = document.getElementById('webviews'); - const webview = document.getElementById(`tab-${activeTabId}`); - - // Remove the webview if it exists - if (webview) { - webview.remove(); - } - - // Convert tab back to home tab - tab.isHome = true; - tab.url = targetUrl; - delete tab.webview; - - // Show home container - if (homeContainer) homeContainer.classList.add('active'); - if (webviewsEl) webviewsEl.classList.add('hidden'); - - urlBox.value = ''; - scheduleRenderTabs(); - scheduleUpdateNavButtons(); - return; - } - - const webview = document.getElementById(`tab-${activeTabId}`); - if (webview) { - isHistoryNavigation = true; // Prevent adding to history during programmatic navigation - // Resolve internal URLs (nebula://) to actual file paths - const resolvedUrl = resolveInternalUrl(targetUrl); - webview.loadURL(resolvedUrl); - } + isHistoryNavigation = true; + const resolvedUrl = resolveInternalUrl(targetUrl); + ipcRenderer.invoke('browserview-load-url', { tabId: activeTabId, url: resolvedUrl }).catch(() => {}); } } @@ -1319,48 +1078,9 @@ function goForward() { if (tab.historyIndex < tab.history.length - 1) { tab.historyIndex++; const targetUrl = tab.history[tab.historyIndex]; - - // Special handling for nebula://home - it doesn't use a webview - if (targetUrl === 'nebula://home') { - const homeContainer = document.getElementById('home-container'); - const webviewsEl = document.getElementById('webviews'); - const webview = document.getElementById(`tab-${activeTabId}`); - - // Remove the webview if it exists - if (webview) { - webview.remove(); - } - - // Convert tab back to home tab - tab.isHome = true; - tab.url = targetUrl; - delete tab.webview; - - // Show home container - if (homeContainer) homeContainer.classList.add('active'); - if (webviewsEl) webviewsEl.classList.add('hidden'); - - urlBox.value = ''; - scheduleRenderTabs(); - scheduleUpdateNavButtons(); - return; - } - - // Check if we're currently on home and need to create a webview - if (tab.isHome && targetUrl !== 'nebula://home') { - // We're going forward from home to a webview page - const resolvedUrl = resolveInternalUrl(targetUrl); - convertHomeTabToWebview(activeTabId, targetUrl, resolvedUrl); - return; - } - - const webview = document.getElementById(`tab-${activeTabId}`); - if (webview) { - isHistoryNavigation = true; // Prevent adding to history during programmatic navigation - // Resolve internal URLs (nebula://) to actual file paths - const resolvedUrl = resolveInternalUrl(targetUrl); - webview.loadURL(resolvedUrl); - } + isHistoryNavigation = true; + const resolvedUrl = resolveInternalUrl(targetUrl); + ipcRenderer.invoke('browserview-load-url', { tabId: activeTabId, url: resolvedUrl }).catch(() => {}); } } @@ -1376,35 +1096,29 @@ function updateNavButtons() { } function reload() { - const webview = document.getElementById(`tab-${activeTabId}`); - if (webview) { - webview.reload(); - scheduleUpdateNavButtons(); // keep back/forward buttons in sync after a reload - } + if (!activeTabId) return; + ipcRenderer.invoke('browserview-reload', { tabId: activeTabId, ignoreCache: false }).catch(() => {}); + scheduleUpdateNavButtons(); } function hardReload() { - const webview = document.getElementById(`tab-${activeTabId}`); - if (webview && typeof webview.reloadIgnoringCache === 'function') { - webview.reloadIgnoringCache(); - scheduleUpdateNavButtons(); - } else if (webview) { - // Fallback - webview.reload(); - } + if (!activeTabId) return; + ipcRenderer.invoke('browserview-reload', { tabId: activeTabId, ignoreCache: true }).catch(() => {}); + scheduleUpdateNavButtons(); } function freshReload() { - const webview = document.getElementById(`tab-${activeTabId}`); - if (!webview) return; - try { - const u = new URL(webview.getURL()); - u.searchParams.set('_bust', Date.now().toString()); - webview.src = u.toString(); - } catch { - // If URL parsing fails (e.g., internal pages), fall back to hard reload - hardReload(); - } + if (!activeTabId) return; + ipcRenderer.invoke('browserview-get-url', { tabId: activeTabId }).then((currentUrl) => { + if (!currentUrl) return hardReload(); + try { + const u = new URL(currentUrl); + u.searchParams.set('_bust', Date.now().toString()); + ipcRenderer.invoke('browserview-load-url', { tabId: activeTabId, url: u.toString() }).catch(() => {}); + } catch { + hardReload(); + } + }); } // Function to open the Settings page @@ -1431,47 +1145,63 @@ let ringSvgEl = null; // Open/close on button click; stop propagation so outside-click handler doesn't immediately close it menuBtn.addEventListener('click', (e) => { e.stopPropagation(); - menuPopup.classList.toggle('hidden'); - if (!menuPopup.classList.contains('hidden')) { - updateZoomUI(); // ← refresh zoom % whenever menu opens - } -}); - -// Prevent clicks inside the popup from bubbling to the document -if (menuPopup) { - menuPopup.addEventListener('click', (e) => e.stopPropagation()); -} - -// Close when clicking anywhere outside the menu wrapper -document.addEventListener('click', (e) => { - if (!menuPopup || menuPopup.classList.contains('hidden')) return; - if (menuWrapper && !menuWrapper.contains(e.target)) { - menuPopup.classList.add('hidden'); - } + if (!menuBtn) return; + const rect = menuBtn.getBoundingClientRect(); + const theme = currentThemeColors ? { colors: currentThemeColors } : null; + ipcRenderer.send('menu-popup-toggle', { + anchorRect: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, + theme + }); }); // Close on Escape key document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && menuPopup && !menuPopup.classList.contains('hidden')) { - menuPopup.classList.add('hidden'); - } + if (e.key === 'Escape') ipcRenderer.send('menu-popup-hide'); if (e.key === 'Escape' && downloadsPopupEl && !downloadsPopupEl.classList.contains('hidden')) { hideDownloadsPopup(); } }); -// Also close when interacting with main content areas (covers webview clicks) -const homeContainerEl = document.getElementById('home-container'); -if (webviewsEl) { - webviewsEl.addEventListener('pointerdown', () => { - if (!menuPopup.classList.contains('hidden')) menuPopup.classList.add('hidden'); - }); -} -if (homeContainerEl) { - homeContainerEl.addEventListener('pointerdown', () => { - if (!menuPopup.classList.contains('hidden')) menuPopup.classList.add('hidden'); - }); -} +// Close menus when BrowserView receives focus +ipcRenderer.on('browserview-event', (payload) => { + if (!payload || !payload.type) return; + const { tabId, type } = payload; + if (type === 'focus') { + ipcRenderer.send('menu-popup-hide'); + if (downloadsPopupEl && !downloadsPopupEl.classList.contains('hidden')) hideDownloadsPopup(); + return; + } + if (type === 'page-title-updated') { + updateTabMetadata(tabId, 'title', payload.title); + return; + } + if (type === 'page-favicon-updated') { + const fav = payload.favicons?.[0]; + if (fav) updateTabMetadata(tabId, 'favicon', fav); + return; + } + if (type === 'did-navigate' || type === 'did-navigate-in-page') { + if (payload.url) { + handleNavigation(tabId, payload.url); + if (/\/cdn-cgi\//.test(payload.url) || /challenge/i.test(payload.url)) { + console.log('[Nebula] Cloudflare challenge detected at', payload.url); + } + } + return; + } + if (type === 'did-finish-load') { + scheduleUpdateNavButtons(); + return; + } + if (type === 'did-fail-load') { + handleLoadFail(tabId)({ + validatedURL: payload.validatedURL || '', + errorCode: payload.errorCode, + errorDescription: payload.errorDescription, + isMainFrame: payload.isMainFrame + }); + } +}); window.addEventListener('DOMContentLoaded', () => { // Initialize theme from localStorage @@ -1480,18 +1210,7 @@ window.addEventListener('DOMContentLoaded', () => { try { const theme = JSON.parse(savedTheme); applyThemeToMainUI(theme); - // Also send to home-webview once it's ready - const homeWebview = document.getElementById('home-webview'); - if (homeWebview) { - const sendThemeToHome = () => { - try { homeWebview.send('theme-update', theme); } catch {} - }; - // If already loaded, send immediately; otherwise wait for dom-ready - if (homeWebview.getWebContentsId) { - sendThemeToHome(); - } - homeWebview.addEventListener('dom-ready', sendThemeToHome, { once: true }); - } + ipcRenderer.send('browserview-broadcast', { channel: 'theme-update', args: [theme] }); } catch (err) { console.error('Error applying saved theme:', err); } @@ -1517,43 +1236,7 @@ window.addEventListener('DOMContentLoaded', () => { // Initial boot createTab(); - // Handle IPC messages from the static home webview (bookmarks navigation) - const staticHome = document.getElementById('home-webview'); - if (staticHome) { - // Close menu when interacting with the home webview - attachCloseMenuOnInteract(staticHome); - staticHome.addEventListener('ipc-message', (e) => { - if (e.channel === 'navigate' && e.args[0]) { - urlBox.value = e.args[0]; - navigate(); - } - }); - } - // Listen for IPC messages from other webviews (e.g., settings) - webviewsEl.addEventListener('ipc-message', (e) => { - // Navigation messages from home or other pages - if (e.channel === 'navigate' && e.args[0]) { - const targetUrl = e.args[0]; - const opts = e.args[1] || {}; - if (opts.newTab) { - // Open in a new tab, leaving settings/home intact - createTab(targetUrl); - } else { - urlBox.value = targetUrl; - navigate(); - } - } - // Theme update from settings webview - if (e.channel === 'theme-update' && e.args[0]) { - const theme = e.args[0]; - // Apply theme colors to the main renderer - applyThemeToMainUI(theme); - const homeWebview = document.getElementById('home-webview'); - if (homeWebview) { - homeWebview.send('theme-update', theme); - } - } - }); + updateBrowserViewBounds(); // Fallback: listen for postMessage navigations from embedded pages (home/settings) window.addEventListener('message', (event) => { if (event.data && event.data.type === 'navigate' && event.data.url) { @@ -1593,8 +1276,8 @@ window.addEventListener('DOMContentLoaded', () => { bigPictureBtn.addEventListener('click', async () => { try { await window.bigPictureAPI.launch(); - // Close the menu popup - if (menuPopup) menuPopup.classList.add('hidden'); + // Close the overlay menu + ipcRenderer.send('menu-popup-hide'); } catch (e) { console.error('Failed to launch Big Picture Mode:', e); } @@ -1779,9 +1462,7 @@ try { function attachCloseMenuOnInteract(el) { if (!el) return; const closeIfOpen = () => { - if (menuPopup && !menuPopup.classList.contains('hidden')) { - menuPopup.classList.add('hidden'); - } + ipcRenderer.send('menu-popup-hide'); if (downloadsPopupEl && !downloadsPopupEl.classList.contains('hidden')) { hideDownloadsPopup(); } @@ -1830,9 +1511,9 @@ window.addEventListener('nebula-context-command', (e) => { } // For blob: URLs we need to resolve inside the active webview by converting to dataURL if (url.startsWith('blob:')) { - const webview = document.getElementById(`tab-${activeTabId}`); - if (webview) { - webview.executeJavaScript(`(async()=>{try{const r=await fetch('${url}');const b=await r.blob();return new Promise(res=>{const fr=new FileReader();fr.onload=()=>res(fr.result);fr.readAsDataURL(b);});}catch(e){return null;}})();`).then(dataUrl=>{ + if (activeTabId) { + const code = `(async()=>{try{const r=await fetch('${url}');const b=await r.blob();return new Promise(res=>{const fr=new FileReader();fr.onload=()=>res(fr.result);fr.readAsDataURL(b);});}catch(e){return null;}})();`; + ipcRenderer.invoke('browserview-execute-js', { tabId: activeTabId, code }).then(dataUrl => { if (dataUrl) { window.electronAPI.saveImageToDisk('image', dataUrl); }