diff --git a/README-STEAM.md b/README-STEAM.md index 203291b..bc3bd7c 100644 --- a/README-STEAM.md +++ b/README-STEAM.md @@ -46,3 +46,111 @@ Notes Big Picture auto-start (SteamOS Gaming Mode) - If Nebula is launched from SteamOS Gaming Mode, it will auto-start in Big Picture Mode. - To force/disable via Steam Launch Options: `--big-picture` or `--no-big-picture`. + +--- + +## Built-in Controller Support (Steam Deck / Game Mode) + +Nebula has **native gamepad support** that signals to Steam that the application is consuming controller input. This prevents Steam from applying Desktop mouse/keyboard emulation when running in Game Mode. + +### How It Works + +Steam Deck only stops applying Desktop mouse emulation when: +1. The application actively reads controller/gamepad input, OR +2. Steam Input is enabled (which requires explicit configuration) + +If an app does not read controller input at all, Steam assumes the user needs mouse emulation. + +Nebula solves this by: +1. **Preload Gamepad Handler**: The preload script (`preload.js`) continuously polls `navigator.getGamepads()` from the moment any window loads. This signals to Steam that the app is consuming gamepad events and should not apply mouse emulation. +2. **Big Picture Mode**: Full controller-friendly UI with: + - D-pad / Left stick: Navigate menus + - A button: Select/activate + - B button: Back + - X button: Backspace (in keyboard) + - Y button: Space / Open search + - LB/RB: Navigate webview history + - Right stick: Virtual cursor (in browse mode) + - Triggers: Left/right click (in browse mode) + - Start: Toggle settings/sidebar + - Select: Toggle fullscreen webview + +### Gamepad API (for Developers) + +The gamepad handler exposes an API via `window.gamepadAPI`: + +```javascript +// Check if gamepad handler is initialized +if (gamepadAPI.isAvailable()) { + console.log('Gamepad handler is running'); +} + +// Check if a gamepad is connected +if (gamepadAPI.isConnected()) { + console.log('Gamepad connected!'); +} + +// Get list of connected gamepads +const gamepads = gamepadAPI.getConnected(); +// Returns: [{ id, index, mapping, buttons, axes }, ...] +console.log(gamepads); + +// Get active gamepad's current state (buttons and axes) +const active = gamepadAPI.getActive(); +if (active) { + console.log('Active gamepad:', active.id); + console.log('Buttons:', active.buttons); + console.log('Axes:', active.axes); +} + +// Get handler state for debugging +const state = gamepadAPI.getState(); +console.log('Handler state:', state); +// Returns: { initialized, connectedCount, activeGamepadIndex, isPolling } + +// Listen for gamepad events (via CustomEvent on window) +window.addEventListener('nebula-gamepad-button', (e) => { + const { button, pressed, value } = e.detail; + console.log(`Button ${button}: ${pressed ? 'pressed' : 'released'}`); +}); + +window.addEventListener('nebula-gamepad-connect', (e) => { + console.log('Gamepad connected:', e.detail.id); +}); + +window.addEventListener('nebula-gamepad-disconnect', (e) => { + console.log('Gamepad disconnected:', e.detail.id); +}); + +window.addEventListener('nebula-gamepad-axis', (e) => { + const { axis, value } = e.detail; + console.log(`Axis ${axis}: ${value}`); +}); + +// Enable debug logging +gamepadAPI.setDebug(true); +``` + +### Troubleshooting + +If Steam is still applying mouse emulation: + +1. **Verify gamepad polling is active**: Open DevTools (F12) and run `gamepadAPI.getState()` - check that `isPolling` is `true` +2. **Check gamepad connection**: Run `gamepadAPI.getConnected()` to see detected gamepads +3. **Press a button first**: On Linux, the `gamepadconnected` event may not fire until the first button press +4. **Enable debug mode**: Run `gamepadAPI.setDebug(true)` to see detailed logs +5. **Restart the app**: Close Nebula completely and relaunch from Steam + +### Steam Launch Options + +``` +# Force Big Picture Mode +./Nebula --big-picture + +# Disable Big Picture Mode +./Nebula --no-big-picture + +# Environment variables also work +NEBULA_BIG_PICTURE=1 ./Nebula +NEBULA_NO_BIG_PICTURE=1 ./Nebula +``` diff --git a/gamepad-handler.js b/gamepad-handler.js new file mode 100644 index 0000000..747ad72 --- /dev/null +++ b/gamepad-handler.js @@ -0,0 +1,453 @@ +/** + * Nebula Browser - Global Gamepad Input Handler (Standalone Reference) + * + * NOTE: This is a standalone reference implementation. The actual gamepad handler + * used by Nebula is integrated directly into preload.js for proper context isolation + * compatibility. This file is kept for reference and potential future use. + * + * This module actively polls and consumes gamepad input from the Gamepad API. + * This is CRITICAL for Steam Deck/SteamOS Game Mode: + * + * Steam only stops applying Desktop mouse emulation when: + * - The application actively reads controller/gamepad input, OR + * - Steam Input is enabled (which requires explicit configuration) + * + * If the app does not read controller input at all, Steam assumes the user + * needs mouse emulation. By continuously polling navigator.getGamepads(), + * Steam recognizes that the app is consuming gamepad events and backs off + * the Desktop mouse emulation layer. + * + * This module should be loaded as early as possible in the renderer process. + */ + +(function() { + 'use strict'; + + // Prevent double initialization + if (window.__nebulaGamepadHandler) { + return; + } + + const CONFIG = { + // Polling rate in ms (60fps = ~16ms, we use requestAnimationFrame) + POLL_INTERVAL: 16, + + // Deadzone for analog sticks + STICK_DEADZONE: 0.15, + TRIGGER_DEADZONE: 0.1, + + // Enable debug logging + DEBUG: false, + }; + + // Global state + const state = { + initialized: false, + gamepads: {}, + connectedCount: 0, + activeGamepadIndex: null, + lastPollTime: 0, + rafId: null, + + // Button states for edge detection + buttonStates: {}, + + // Callbacks for interested listeners + listeners: { + connect: [], + disconnect: [], + button: [], + axis: [], + input: [], // Any input (for keeping the polling "active") + }, + }; + + // Debug logger + const log = (...args) => { + if (CONFIG.DEBUG) { + console.log('[NebulaGamepad]', ...args); + } + }; + + /** + * Initialize the gamepad handler. + * This should be called as early as possible. + */ + function init() { + if (state.initialized) { + log('Already initialized'); + return; + } + + if (typeof navigator === 'undefined' || !navigator.getGamepads) { + console.warn('[NebulaGamepad] Gamepad API not available'); + return; + } + + log('Initializing gamepad handler'); + + // Listen for connect/disconnect events + window.addEventListener('gamepadconnected', handleGamepadConnected); + window.addEventListener('gamepaddisconnected', handleGamepadDisconnected); + + // Do an initial scan for already-connected gamepads + // (important for Steam Deck where the controller is always connected) + scanGamepads(); + + // Start the polling loop immediately + // This is the KEY part: continuously polling getGamepads() signals to Steam + // that we're actively consuming gamepad input + startPolling(); + + state.initialized = true; + + log('Gamepad handler initialized'); + + // Expose debug info + if (CONFIG.DEBUG) { + window.__nebulaGamepadDebug = { + state, + getActiveGamepad, + getConnectedGamepads, + }; + } + } + + /** + * Handle gamepad connection event + */ + function handleGamepadConnected(event) { + const gamepad = event.gamepad; + log('Gamepad connected:', gamepad.index, gamepad.id); + + state.gamepads[gamepad.index] = { + id: gamepad.id, + index: gamepad.index, + connected: true, + mapping: gamepad.mapping, + timestamp: Date.now(), + }; + state.connectedCount++; + + // Set as active if we don't have one + if (state.activeGamepadIndex === null) { + state.activeGamepadIndex = gamepad.index; + log('Set active gamepad:', gamepad.index); + } + + // Initialize button states for this gamepad + state.buttonStates[gamepad.index] = {}; + + // Notify listeners + emitEvent('connect', { gamepad, index: gamepad.index, id: gamepad.id }); + } + + /** + * Handle gamepad disconnection event + */ + function handleGamepadDisconnected(event) { + const gamepad = event.gamepad; + log('Gamepad disconnected:', gamepad.index, gamepad.id); + + if (state.gamepads[gamepad.index]) { + state.gamepads[gamepad.index].connected = false; + delete state.gamepads[gamepad.index]; + state.connectedCount--; + } + + // Clear button states + delete state.buttonStates[gamepad.index]; + + // If this was the active gamepad, find another + if (state.activeGamepadIndex === gamepad.index) { + state.activeGamepadIndex = null; + + // Try to find another connected gamepad + const gamepads = navigator.getGamepads(); + for (let i = 0; i < gamepads.length; i++) { + if (gamepads[i]) { + state.activeGamepadIndex = i; + log('Switched active gamepad to:', i); + break; + } + } + } + + // Notify listeners + emitEvent('disconnect', { index: gamepad.index, id: gamepad.id }); + } + + /** + * Scan for already-connected gamepads + * This is important because on Linux/Steam Deck, the gamepadconnected event + * may not fire until the first button press + */ + function scanGamepads() { + const gamepads = navigator.getGamepads(); + + for (let i = 0; i < gamepads.length; i++) { + const gamepad = gamepads[i]; + if (gamepad && !state.gamepads[gamepad.index]) { + log('Found pre-connected gamepad:', gamepad.index, gamepad.id); + + state.gamepads[gamepad.index] = { + id: gamepad.id, + index: gamepad.index, + connected: true, + mapping: gamepad.mapping, + timestamp: Date.now(), + }; + state.connectedCount++; + + if (state.activeGamepadIndex === null) { + state.activeGamepadIndex = gamepad.index; + } + + state.buttonStates[gamepad.index] = {}; + } + } + } + + /** + * Start the gamepad polling loop + * Uses requestAnimationFrame for efficient, consistent polling + */ + function startPolling() { + if (state.rafId !== null) { + return; // Already polling + } + + function pollLoop(timestamp) { + state.lastPollTime = timestamp; + + // CRITICAL: This call to getGamepads() is what tells Steam we're + // actively consuming gamepad input + const gamepads = navigator.getGamepads(); + + // Process input from all connected gamepads + for (let i = 0; i < gamepads.length; i++) { + const gamepad = gamepads[i]; + if (gamepad) { + processGamepadInput(gamepad); + } + } + + // Also do periodic scans for newly connected gamepads + // (handles edge case where event doesn't fire) + if (timestamp % 1000 < 20) { + scanGamepads(); + } + + // Continue polling + state.rafId = requestAnimationFrame(pollLoop); + } + + state.rafId = requestAnimationFrame(pollLoop); + log('Started gamepad polling'); + } + + /** + * Stop the polling loop (called on page unload) + */ + function stopPolling() { + if (state.rafId !== null) { + cancelAnimationFrame(state.rafId); + state.rafId = null; + log('Stopped gamepad polling'); + } + } + + /** + * Process input from a gamepad + */ + function processGamepadInput(gamepad) { + const index = gamepad.index; + const buttonState = state.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; + + emitEvent('button', { + gamepad, + index, + button: i, + pressed: isPressed, + value: button.value, + }); + + log(`Button ${i}: ${isPressed ? 'pressed' : 'released'}`); + } + } + + // Process axes (analog sticks, triggers) + for (let i = 0; i < gamepad.axes.length; i++) { + const value = gamepad.axes[i]; + const prevValue = buttonState[`a${i}`] || 0; + + // Only emit if there's significant change + if (Math.abs(value - prevValue) > 0.01) { + buttonState[`a${i}`] = value; + + // Check if beyond deadzone + if (Math.abs(value) > CONFIG.STICK_DEADZONE) { + hasInput = true; + + emitEvent('axis', { + gamepad, + index, + axis: i, + value, + }); + } + } + } + + state.buttonStates[index] = buttonState; + + // Emit generic input event if any input detected + if (hasInput) { + emitEvent('input', { gamepad, index }); + } + } + + /** + * Emit an event to registered listeners + */ + function emitEvent(type, data) { + const listeners = state.listeners[type] || []; + for (const listener of listeners) { + try { + listener(data); + } catch (err) { + console.error('[NebulaGamepad] Listener error:', err); + } + } + } + + /** + * Register a listener for gamepad events + * @param {string} type - Event type: 'connect', 'disconnect', 'button', 'axis', 'input' + * @param {function} callback - Callback function + * @returns {function} Unsubscribe function + */ + function on(type, callback) { + if (!state.listeners[type]) { + state.listeners[type] = []; + } + state.listeners[type].push(callback); + + return () => { + const idx = state.listeners[type].indexOf(callback); + if (idx !== -1) { + state.listeners[type].splice(idx, 1); + } + }; + } + + /** + * Get the currently active gamepad + * @returns {Gamepad|null} + */ + function getActiveGamepad() { + if (state.activeGamepadIndex === null) { + return null; + } + const gamepads = navigator.getGamepads(); + return gamepads[state.activeGamepadIndex] || null; + } + + /** + * Get all connected gamepads + * @returns {Gamepad[]} + */ + function getConnectedGamepads() { + const gamepads = navigator.getGamepads(); + return Array.from(gamepads).filter(gp => gp !== null); + } + + /** + * Check if any gamepad is connected + * @returns {boolean} + */ + function isGamepadConnected() { + return state.connectedCount > 0; + } + + /** + * Set the active gamepad by index + * @param {number} index + */ + function setActiveGamepad(index) { + const gamepads = navigator.getGamepads(); + if (gamepads[index]) { + state.activeGamepadIndex = index; + log('Active gamepad set to:', index); + return true; + } + return false; + } + + // Cleanup on page unload + window.addEventListener('beforeunload', () => { + stopPolling(); + window.removeEventListener('gamepadconnected', handleGamepadConnected); + window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected); + }); + + // Pause polling when page is hidden to save resources + // but not for too long - we still want Steam to see we're active + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + // Continue polling but at a slower rate when hidden + // We don't stop entirely because Steam needs to see we're consuming input + log('Page hidden, continuing polling'); + } else { + log('Page visible'); + } + }); + + // Export the API + const gamepadHandler = { + init, + on, + getActiveGamepad, + getConnectedGamepads, + isGamepadConnected, + setActiveGamepad, + + // Expose state for debugging + get state() { + return { ...state, buttonStates: { ...state.buttonStates } }; + }, + + // Config + get config() { + return { ...CONFIG }; + }, + setDebug(enabled) { + CONFIG.DEBUG = !!enabled; + }, + }; + + // Mark as initialized and expose globally + window.__nebulaGamepadHandler = gamepadHandler; + + // Auto-initialize when DOM is ready (or immediately if already loaded) + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + // DOM already loaded, initialize immediately + init(); + } + + log('Gamepad handler module loaded'); + +})(); diff --git a/package-lock.json b/package-lock.json index 23be45a..ca7627d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nebula", - "version": "1.0.0", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nebula", - "version": "1.0.0", + "version": "1.3.2", "license": "ISC", "dependencies": { "dompurify": "^3.1.6", diff --git a/preload.js b/preload.js index 2ac8079..ca40fdd 100644 --- a/preload.js +++ b/preload.js @@ -1,17 +1,241 @@ // 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; } +// ============================================================================= +// 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; + } +}); + +// ============================================================================= +// DOM READY & INITIALIZATION +// ============================================================================= + // Cache DOM references for performance let domReady = false; window.addEventListener('DOMContentLoaded', () => { domReady = true; console.log("Browser UI loaded."); + + // Initialize gamepad handler for Steam Deck/SteamOS support + initGamepadHandler(); }); // Optimized API exposure with error handling and caching @@ -138,6 +362,55 @@ const bookmarksAPI = { 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') diff --git a/renderer/bigpicture.js b/renderer/bigpicture.js index 5b33442..e7dc658 100644 --- a/renderer/bigpicture.js +++ b/renderer/bigpicture.js @@ -654,6 +654,13 @@ function initGamepadSupport() { return; } + // The global gamepad handler (from gamepad-handler.js injected via preload) + // already polls navigator.getGamepads() continuously. This is what tells Steam + // that we're consuming gamepad input and it should stop mouse emulation. + // Big Picture Mode handles the actual UI navigation and button actions. + + console.log('[BigPicture] Global gamepad handler available:', !!window.__nebulaGamepadHandler); + // Note: On Linux (and some controllers like handheld integrated gamepads), // the `gamepadconnected` event may not fire until the first button press, // or at all. We rely on continuous polling for robustness. diff --git a/setup.sh b/setup.sh index 70b24bc..5447f6d 100755 --- a/setup.sh +++ b/setup.sh @@ -2,6 +2,7 @@ # Nebula Browser Setup Script # This script installs dependencies and fixes Electron sandbox permissions +# Works on Steam Deck and other Linux systems without sudo echo "=========================================" echo " Nebula Browser Setup Script" @@ -26,26 +27,53 @@ echo "This requires root access. You may be prompted for your password." echo "" # Fix chrome-sandbox permissions -SANDBOX_PATH="./node_modules/electron/dist/chrome-sandbox" +SANDBOX_PATH="$(pwd)/node_modules/electron/dist/chrome-sandbox" -if [ -f "$SANDBOX_PATH" ]; then - sudo chown root:root "$SANDBOX_PATH" - sudo chmod 4755 "$SANDBOX_PATH" - - if [ $? -eq 0 ]; then - echo "✅ Sandbox permissions fixed successfully!" - echo "" - echo "=========================================" - echo " Setup complete! Run 'npm start' to launch Nebula" - echo "=========================================" - else - echo "❌ Failed to set sandbox permissions." - echo " Try running manually:" - echo " sudo chown root:root $SANDBOX_PATH && sudo chmod 4755 $SANDBOX_PATH" - exit 1 - fi -else +if [ ! -f "$SANDBOX_PATH" ]; then echo "❌ chrome-sandbox not found at $SANDBOX_PATH" echo " Make sure npm install completed successfully." exit 1 fi + +# Function to run command as root +run_as_root() { + if command -v sudo &> /dev/null; then + sudo "$@" + elif command -v pkexec &> /dev/null; then + pkexec "$@" + elif command -v doas &> /dev/null; then + doas "$@" + else + echo "No privilege escalation tool found (sudo/pkexec/doas)" + return 1 + fi +} + +# Try to fix permissions +echo "Attempting to set sandbox permissions..." +run_as_root chown root:root "$SANDBOX_PATH" +CHOWN_RESULT=$? + +run_as_root chmod 4755 "$SANDBOX_PATH" +CHMOD_RESULT=$? + +if [ $CHOWN_RESULT -eq 0 ] && [ $CHMOD_RESULT -eq 0 ]; then + echo "✅ Sandbox permissions fixed successfully!" + echo "" + echo "=========================================" + echo " Setup complete! Run 'npm start' to launch Nebula" + echo "=========================================" + echo "" + echo "💡 TIP: For GPU acceleration on Linux, run:" + echo " NEBULA_GPU_ALLOW_LINUX=1 npm start" + echo "=========================================" +else + echo "❌ Failed to set sandbox permissions automatically." + echo "" + echo "On Steam Deck, open Konsole and run:" + echo " pkexec bash -c 'chown root:root $SANDBOX_PATH && chmod 4755 $SANDBOX_PATH'" + echo "" + echo "Or switch to desktop mode and run as root:" + echo " su -c 'chown root:root $SANDBOX_PATH && chmod 4755 $SANDBOX_PATH'" + exit 1 +fi diff --git a/start-gpu.sh b/start-gpu.sh new file mode 100755 index 0000000..660c2b1 --- /dev/null +++ b/start-gpu.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Start Nebula Browser with GPU acceleration enabled on Linux +cd "$(dirname "$0")" +NEBULA_GPU_ALLOW_LINUX=1 npm start