Steam Controller Reg
This commit is contained in:
+108
@@ -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
|
||||
```
|
||||
|
||||
@@ -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');
|
||||
|
||||
})();
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+273
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+4
@@ -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
|
||||
Reference in New Issue
Block a user