// preload.js - Optimized version const { contextBridge, ipcRenderer } = require('electron'); let pathModule; let fsModule; try { pathModule = require('path'); fsModule = require('fs'); } catch (err) { pathModule = null; 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 // ============================================================================= // This is CRITICAL for Steam Deck Game Mode: Steam only stops applying // Desktop mouse emulation when the app actively reads controller input. // By continuously polling navigator.getGamepads(), Steam recognizes that // the app is consuming gamepad events and backs off the mouse emulation layer. // ============================================================================= const gamepadState = { initialized: false, gamepads: {}, connectedCount: 0, activeGamepadIndex: null, rafId: null, buttonStates: {}, listeners: { connect: [], disconnect: [], button: [], axis: [], input: [] }, }; const GAMEPAD_CONFIG = { STICK_DEADZONE: 0.15, DEBUG: false, }; function gamepadLog(...args) { if (GAMEPAD_CONFIG.DEBUG) { console.log('[NebulaGamepad]', ...args); } } function initGamepadHandler() { if (gamepadState.initialized) return; if (typeof navigator === 'undefined' || !navigator.getGamepads) { console.warn('[NebulaGamepad] Gamepad API not available'); return; } gamepadLog('Initializing gamepad handler'); window.addEventListener('gamepadconnected', handleGamepadConnected); window.addEventListener('gamepaddisconnected', handleGamepadDisconnected); // Initial scan for already-connected gamepads scanGamepads(); // Start polling loop - this is what tells Steam we're consuming gamepad input startGamepadPolling(); gamepadState.initialized = true; console.log('[NebulaGamepad] Gamepad handler initialized - Steam will see controller input being consumed'); } function handleGamepadConnected(event) { const gamepad = event.gamepad; gamepadLog('Gamepad connected:', gamepad.index, gamepad.id); gamepadState.gamepads[gamepad.index] = { id: gamepad.id, index: gamepad.index, connected: true, mapping: gamepad.mapping, timestamp: Date.now(), }; gamepadState.connectedCount++; if (gamepadState.activeGamepadIndex === null) { gamepadState.activeGamepadIndex = gamepad.index; } gamepadState.buttonStates[gamepad.index] = {}; emitGamepadEvent('connect', { gamepad, index: gamepad.index, id: gamepad.id }); } function handleGamepadDisconnected(event) { const gamepad = event.gamepad; gamepadLog('Gamepad disconnected:', gamepad.index, gamepad.id); if (gamepadState.gamepads[gamepad.index]) { delete gamepadState.gamepads[gamepad.index]; gamepadState.connectedCount--; } delete gamepadState.buttonStates[gamepad.index]; if (gamepadState.activeGamepadIndex === gamepad.index) { gamepadState.activeGamepadIndex = null; const gamepads = navigator.getGamepads(); for (let i = 0; i < gamepads.length; i++) { if (gamepads[i]) { gamepadState.activeGamepadIndex = i; break; } } } emitGamepadEvent('disconnect', { index: gamepad.index, id: gamepad.id }); } function scanGamepads() { const gamepads = navigator.getGamepads(); for (let i = 0; i < gamepads.length; i++) { const gamepad = gamepads[i]; if (gamepad && !gamepadState.gamepads[gamepad.index]) { gamepadLog('Found pre-connected gamepad:', gamepad.index, gamepad.id); gamepadState.gamepads[gamepad.index] = { id: gamepad.id, index: gamepad.index, connected: true, mapping: gamepad.mapping, timestamp: Date.now(), }; gamepadState.connectedCount++; if (gamepadState.activeGamepadIndex === null) { gamepadState.activeGamepadIndex = gamepad.index; } gamepadState.buttonStates[gamepad.index] = {}; } } } function startGamepadPolling() { if (gamepadState.rafId !== null) return; function pollLoop(timestamp) { // CRITICAL: This call to getGamepads() tells Steam we're consuming gamepad input const gamepads = navigator.getGamepads(); for (let i = 0; i < gamepads.length; i++) { const gamepad = gamepads[i]; if (gamepad) { processGamepadInput(gamepad); } } // Periodic scan for newly connected gamepads if (timestamp % 1000 < 20) { scanGamepads(); } gamepadState.rafId = requestAnimationFrame(pollLoop); } gamepadState.rafId = requestAnimationFrame(pollLoop); gamepadLog('Started gamepad polling'); } function processGamepadInput(gamepad) { const index = gamepad.index; const buttonState = gamepadState.buttonStates[index] || {}; let hasInput = false; // Process buttons for (let i = 0; i < gamepad.buttons.length; i++) { const button = gamepad.buttons[i]; const wasPressed = buttonState[`b${i}`] || false; const isPressed = button.pressed || button.value > 0.5; if (isPressed !== wasPressed) { buttonState[`b${i}`] = isPressed; hasInput = true; emitGamepadEvent('button', { gamepad, index, button: i, pressed: isPressed, value: button.value }); } } // Process axes for (let i = 0; i < gamepad.axes.length; i++) { const value = gamepad.axes[i]; const prevValue = buttonState[`a${i}`] || 0; if (Math.abs(value - prevValue) > 0.01) { buttonState[`a${i}`] = value; if (Math.abs(value) > GAMEPAD_CONFIG.STICK_DEADZONE) { hasInput = true; emitGamepadEvent('axis', { gamepad, index, axis: i, value }); } } } gamepadState.buttonStates[index] = buttonState; if (hasInput) { emitGamepadEvent('input', { gamepad, index }); } } function emitGamepadEvent(type, data) { // Dispatch as CustomEvent for renderer scripts to listen to try { window.dispatchEvent(new CustomEvent(`nebula-gamepad-${type}`, { detail: data })); } catch (err) { // Ignore errors if CustomEvent isn't available } } function getActiveGamepad() { if (gamepadState.activeGamepadIndex === null) return null; const gamepads = navigator.getGamepads(); return gamepads[gamepadState.activeGamepadIndex] || null; } function getConnectedGamepads() { const gamepads = navigator.getGamepads(); return Array.from(gamepads).filter(gp => gp !== null); } // Cleanup on page unload window.addEventListener('beforeunload', () => { if (gamepadState.rafId !== null) { cancelAnimationFrame(gamepadState.rafId); gamepadState.rafId = null; } }); // ============================================================================= // EARLY GAMEPAD INITIALIZATION - Critical for Steam Deck // ============================================================================= // Initialize gamepad polling as EARLY as possible to signal Steam Input // that this app handles controller input natively. This MUST happen before // Steam decides to apply mouse/keyboard emulation. // // We try to initialize immediately when preload runs, not waiting for DOMContentLoaded, // because Steam's input layer makes decisions very early in the process lifecycle. // ============================================================================= // Try immediate initialization (works in most Electron contexts) try { if (typeof navigator !== 'undefined' && navigator.getGamepads) { // Start polling immediately - this is the key signal to Steam initGamepadHandler(); console.log('[NebulaGamepad] Early initialization successful - Steam should recognize controller input'); } } catch (e) { // Will retry on DOMContentLoaded console.log('[NebulaGamepad] Early init deferred, will retry on DOM ready'); } // ============================================================================= // DOM READY & INITIALIZATION // ============================================================================= // Cache DOM references for performance let domReady = false; window.addEventListener('DOMContentLoaded', () => { domReady = true; console.log("Browser UI loaded."); // Re-initialize gamepad handler if early init failed if (!gamepadState.initialized) { initGamepadHandler(); } }); // Optimized API exposure with error handling and caching const electronAPI = { send: (ch, ...args) => { try { return ipcRenderer.send(ch, ...args); } catch (err) { console.error('IPC send error:', err); } }, // Send message to embedding page (webview host) or to BrowserView host sendToHost: (ch, ...args) => { try { // 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); } }, invoke: (ch, ...args) => { try { return ipcRenderer.invoke(ch, ...args); } catch (err) { console.error('IPC invoke error:', err); return Promise.reject(err); } }, on: (ch, fn) => { try { return ipcRenderer.on(ch, (e, ...args) => fn(...args)); } catch (err) { console.error('IPC on error:', err); } }, // Add removeListener for cleanup removeListener: (ch, fn) => { try { return ipcRenderer.removeListener(ch, fn); } catch (err) { console.error('IPC removeListener error:', err); } }, toggleDevTools: () => { try { return ipcRenderer.invoke('open-devtools'); } catch (err) { console.error('IPC open-devtools error:', err); return Promise.reject(err); } }, openLocalFile: async () => { try { return await ipcRenderer.invoke('show-open-file-dialog'); } catch (err) { console.error('IPC openLocalFile error:', err); return null; } }, showContextMenu: (params) => { try { return ipcRenderer.invoke('show-context-menu', params); } catch (err) { console.error('IPC showContextMenu error:', err); } }, saveImageToDisk: async (suggestedName, dataUrl) => ipcRenderer.invoke('save-image-from-dataurl', { suggestedName, dataUrl }), saveImageFromNet: async (url) => ipcRenderer.invoke('save-image-from-url', { url }) }; // Provide absolute path to the renderer preload for webview guests so // webview `preload` attributes use an absolute, resolvable path on all platforms. const webviewPreloadAbsolutePath = pathModule ? pathModule.join(__dirname, 'preload.js') : null; electronAPI.getWebviewPreloadPath = () => webviewPreloadAbsolutePath; // Fixup any static attributes in the DOM early so // guests receive an absolute path instead of a relative one that may fail // to resolve inside the guest process. window.addEventListener('DOMContentLoaded', () => { try { if (webviewPreloadAbsolutePath) { const els = document.querySelectorAll('webview[preload]'); for (const el of els) { try { el.setAttribute('preload', webviewPreloadAbsolutePath); } catch {}; } } } catch (e) { // non-fatal } }); // Cache for bookmarks to reduce IPC calls let bookmarksCache = null; let bookmarksCacheTime = 0; const CACHE_DURATION = 5000; // 5 seconds const bookmarksAPI = { load: async () => { const now = Date.now(); if (bookmarksCache && (now - bookmarksCacheTime) < CACHE_DURATION) { return bookmarksCache; } try { bookmarksCache = await ipcRenderer.invoke('load-bookmarks'); bookmarksCacheTime = now; return bookmarksCache; } catch (err) { console.error('Bookmarks load error:', err); return []; } }, save: async (data) => { try { bookmarksCache = data; // Update cache immediately bookmarksCacheTime = Date.now(); return await ipcRenderer.invoke('save-bookmarks', data); } catch (err) { console.error('Bookmarks save error:', err); return false; } } }; // Expose APIs to main world contextBridge.exposeInMainWorld('electronAPI', electronAPI); contextBridge.exposeInMainWorld('bookmarksAPI', bookmarksAPI); // Gamepad API - Access to the gamepad handler running in the preload context // The handler actively polls navigator.getGamepads() to signal to Steam that // the app is consuming controller input (prevents mouse emulation on Steam Deck) contextBridge.exposeInMainWorld('gamepadAPI', { // Check if gamepad handler is initialized isAvailable: () => gamepadState.initialized, // Check if any gamepad is connected isConnected: () => gamepadState.connectedCount > 0, // Get connected gamepads info getConnected: () => { const gamepads = getConnectedGamepads(); return gamepads.map(gp => ({ id: gp.id, index: gp.index, mapping: gp.mapping, buttons: gp.buttons.length, axes: gp.axes.length, })); }, // Get the active gamepad's current state getActive: () => { const gp = getActiveGamepad(); if (!gp) return null; return { id: gp.id, index: gp.index, mapping: gp.mapping, buttons: Array.from(gp.buttons).map((b, i) => ({ index: i, pressed: b.pressed, value: b.value })), axes: Array.from(gp.axes), }; }, // Enable debug mode setDebug: (enabled) => { GAMEPAD_CONFIG.DEBUG = !!enabled; }, // Get handler state for debugging getState: () => ({ initialized: gamepadState.initialized, connectedCount: gamepadState.connectedCount, activeGamepadIndex: gamepadState.activeGamepadIndex, isPolling: gamepadState.rafId !== null, }), }); // Minimal about API for settings page contextBridge.exposeInMainWorld('aboutAPI', { getInfo: () => ipcRenderer.invoke('get-about-info') }); // Big Picture Mode API - Steam Deck / Console UI // Note: Big Picture Mode now opens in the main window (not a separate window) to keep resources low // and prevent SteamOS from creating desktop mode alongside when auto-launching. contextBridge.exposeInMainWorld('bigPictureAPI', { // Get screen info to determine if Big Picture Mode is recommended getScreenInfo: () => ipcRenderer.invoke('get-screen-info'), // Check if device is likely a Steam Deck or handheld isSuggested: () => ipcRenderer.invoke('is-bigpicture-suggested'), // Check if currently in Big Picture Mode isActive: () => ipcRenderer.invoke('is-in-bigpicture'), // Launch Big Picture Mode (navigates main window to Big Picture UI) launch: () => ipcRenderer.invoke('launch-bigpicture'), // Exit Big Picture Mode (navigates main window back to desktop UI) exit: () => ipcRenderer.invoke('exit-bigpicture'), // Navigate to URL (from Big Picture Mode) navigate: (url) => ipcRenderer.send('bigpicture-navigate', url), // Send input event to a webview (for virtual cursor clicks) sendInputEvent: (webContentsId, inputEvent) => ipcRenderer.invoke('webview-send-input-event', { webContentsId, inputEvent }) }); // Relay context-menu commands from main to active renderer context (open new tabs etc.) ipcRenderer.on('context-menu-command', (event, payload) => { window.dispatchEvent(new CustomEvent('nebula-context-command', { detail: payload })); }); // Downloads API exposed to renderer contextBridge.exposeInMainWorld('downloadsAPI', { list: () => ipcRenderer.invoke('downloads-get-all'), action: (id, action) => ipcRenderer.invoke('downloads-action', { id, action }), clearCompleted: () => ipcRenderer.invoke('downloads-clear-completed'), onStarted: (handler) => ipcRenderer.on('downloads-started', (_e, payload) => handler(payload)), onUpdated: (handler) => ipcRenderer.on('downloads-updated', (_e, payload) => handler(payload)), onDone: (handler) => ipcRenderer.on('downloads-done', (_e, payload) => handler(payload)), onCleared: (handler) => ipcRenderer.on('downloads-cleared', handler), onScanStarted: (handler) => ipcRenderer.on('downloads-scan-started', (_e, payload) => handler(payload)), onScanResult: (handler) => ipcRenderer.on('downloads-scan-result', (_e, payload) => handler(payload)) }); // Auto-Updater API exposed to renderer contextBridge.exposeInMainWorld('updaterAPI', { checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), downloadUpdate: () => ipcRenderer.invoke('download-update'), installUpdate: () => ipcRenderer.invoke('install-update'), getAppVersion: () => ipcRenderer.invoke('get-app-version'), onUpdateStatus: (handler) => ipcRenderer.on('update-status', (_e, payload) => handler(payload)) }); // First-Time Setup API contextBridge.exposeInMainWorld('api', { // Check if this is the first run isFirstRun: () => ipcRenderer.invoke('is-first-run'), // Get all available themes getAllThemes: () => ipcRenderer.invoke('get-all-themes'), // Apply a theme applyTheme: (themeId) => ipcRenderer.invoke('apply-theme', themeId), // Check if Nebula is the default browser isDefaultBrowser: () => ipcRenderer.invoke('is-default-browser'), // Set Nebula as the default browser setAsDefaultBrowser: () => ipcRenderer.invoke('set-as-default-browser'), // Open OS default browser settings openDefaultBrowserSettings: () => ipcRenderer.invoke('open-default-browser-settings'), // Complete first-run setup completeFirstRun: (data) => ipcRenderer.invoke('complete-first-run', data), // Get first-run data getFirstRunData: () => ipcRenderer.invoke('get-first-run-data') }); // ---------------------------------------- // Plugin renderer preloads // ---------------------------------------- // We request a list of absolute file paths from main and require() them here. // Each file can optionally call contextBridge.exposeInMainWorld to add APIs. (async () => { try { const preloads = await ipcRenderer.invoke('plugins-get-renderer-preloads'); if (Array.isArray(preloads)) { for (const p of preloads) { try { // eslint-disable-next-line global-require, import/no-dynamic-require require(p); } catch (e) { console.error('[Plugins] Failed to load renderer preload:', p, e); } } } } catch (e) { console.warn('[Plugins] No renderer preloads:', e); } })();