Add BrowserView tab system and overlay menu for desktop mode
Introduces a BrowserView-based tab management system for desktop mode, replacing webview elements for tab content. Adds IPC handlers and state management for creating, activating, destroying, and communicating with BrowserViews. Implements an overlay menu popup window for tab actions and zoom controls. Updates renderer UI to use a dedicated view host container, and refactors tab creation and navigation logic to use BrowserViews. Improves focus styling in CSS and updates preload and IPC messaging to support BrowserView contexts.
This commit is contained in:
@@ -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);
|
||||
|
||||
+18
-2
@@ -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);
|
||||
}
|
||||
|
||||
+48
-42
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+6
-14
@@ -22,6 +22,11 @@
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
/* Adjust the color and transparency as needed */
|
||||
}
|
||||
|
||||
#view-host {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -100,20 +105,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div id="webviews" class="hidden"></div>
|
||||
|
||||
<!-- Home page container for direct loading -->
|
||||
<div id="home-container" class="active">
|
||||
<webview id="home-webview"
|
||||
src="home.html"
|
||||
preload="../preload.js"
|
||||
partition="persist:main"
|
||||
allowpopups
|
||||
webpreferences="allowRunningInsecureContent=false,javascript=true,webSecurity=true"
|
||||
useragent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Nebula/1.0.0"
|
||||
style="width:100%; height:100%; border:none;">
|
||||
</webview>
|
||||
</div>
|
||||
<div id="view-host"></div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Menu</title>
|
||||
<link rel="stylesheet" href="menu-popup.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="menu-popup" role="menu">
|
||||
<button data-cmd="open-settings" role="menuitem">Settings</button>
|
||||
<button data-cmd="big-picture" role="menuitem">🎮 Big Picture Mode</button>
|
||||
<button data-cmd="toggle-devtools" role="menuitem">Toggle Developer Tools</button>
|
||||
<div class="zoom-controls" role="group" aria-label="Zoom controls">
|
||||
<button data-cmd="zoom-out" aria-label="Zoom out">-</button>
|
||||
<span id="zoom-percent">100%</span>
|
||||
<button data-cmd="zoom-in" aria-label="Zoom in">+</button>
|
||||
</div>
|
||||
<button data-cmd="hard-reload" role="menuitem">Hard Reload (Ignore Cache)</button>
|
||||
<button data-cmd="fresh-reload" role="menuitem">Reload Fresh (Add Cache-Buster)</button>
|
||||
</div>
|
||||
<script src="menu-popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
+200
-519
@@ -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 = `<html><body style="margin:0;background:#111;display:flex;align-items:center;justify-content:center;">`+
|
||||
`<img src="${resolvedUrl}" style="max-width:100%;max-height:100%;object-fit:contain;"/>`+
|
||||
`</body></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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user