Steam Controller Reg
This commit is contained in:
+108
@@ -46,3 +46,111 @@ Notes
|
|||||||
Big Picture auto-start (SteamOS Gaming Mode)
|
Big Picture auto-start (SteamOS Gaming Mode)
|
||||||
- If Nebula is launched from SteamOS Gaming Mode, it will auto-start in Big Picture 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`.
|
- 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",
|
"name": "nebula",
|
||||||
"version": "1.0.0",
|
"version": "1.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "1.0.0",
|
"version": "1.3.2",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.1.6",
|
"dompurify": "^3.1.6",
|
||||||
|
|||||||
+273
@@ -1,17 +1,241 @@
|
|||||||
// preload.js - Optimized version
|
// preload.js - Optimized version
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
let pathModule;
|
let pathModule;
|
||||||
|
let fsModule;
|
||||||
try {
|
try {
|
||||||
pathModule = require('path');
|
pathModule = require('path');
|
||||||
|
fsModule = require('fs');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pathModule = null;
|
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
|
// Cache DOM references for performance
|
||||||
let domReady = false;
|
let domReady = false;
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
domReady = true;
|
domReady = true;
|
||||||
console.log("Browser UI loaded.");
|
console.log("Browser UI loaded.");
|
||||||
|
|
||||||
|
// Initialize gamepad handler for Steam Deck/SteamOS support
|
||||||
|
initGamepadHandler();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optimized API exposure with error handling and caching
|
// Optimized API exposure with error handling and caching
|
||||||
@@ -138,6 +362,55 @@ const bookmarksAPI = {
|
|||||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||||
contextBridge.exposeInMainWorld('bookmarksAPI', bookmarksAPI);
|
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
|
// Minimal about API for settings page
|
||||||
contextBridge.exposeInMainWorld('aboutAPI', {
|
contextBridge.exposeInMainWorld('aboutAPI', {
|
||||||
getInfo: () => ipcRenderer.invoke('get-about-info')
|
getInfo: () => ipcRenderer.invoke('get-about-info')
|
||||||
|
|||||||
@@ -654,6 +654,13 @@ function initGamepadSupport() {
|
|||||||
return;
|
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),
|
// Note: On Linux (and some controllers like handheld integrated gamepads),
|
||||||
// the `gamepadconnected` event may not fire until the first button press,
|
// the `gamepadconnected` event may not fire until the first button press,
|
||||||
// or at all. We rely on continuous polling for robustness.
|
// or at all. We rely on continuous polling for robustness.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
# Nebula Browser Setup Script
|
# Nebula Browser Setup Script
|
||||||
# This script installs dependencies and fixes Electron sandbox permissions
|
# This script installs dependencies and fixes Electron sandbox permissions
|
||||||
|
# Works on Steam Deck and other Linux systems without sudo
|
||||||
|
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo " Nebula Browser Setup Script"
|
echo " Nebula Browser Setup Script"
|
||||||
@@ -26,26 +27,53 @@ echo "This requires root access. You may be prompted for your password."
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Fix chrome-sandbox permissions
|
# 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
|
if [ ! -f "$SANDBOX_PATH" ]; then
|
||||||
sudo chown root:root "$SANDBOX_PATH"
|
echo "❌ chrome-sandbox not found at $SANDBOX_PATH"
|
||||||
sudo chmod 4755 "$SANDBOX_PATH"
|
echo " Make sure npm install completed successfully."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
# 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 "✅ Sandbox permissions fixed successfully!"
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo " Setup complete! Run 'npm start' to launch Nebula"
|
echo " Setup complete! Run 'npm start' to launch Nebula"
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
else
|
echo ""
|
||||||
echo "❌ Failed to set sandbox permissions."
|
echo "💡 TIP: For GPU acceleration on Linux, run:"
|
||||||
echo " Try running manually:"
|
echo " NEBULA_GPU_ALLOW_LINUX=1 npm start"
|
||||||
echo " sudo chown root:root $SANDBOX_PATH && sudo chmod 4755 $SANDBOX_PATH"
|
echo "========================================="
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
echo "❌ chrome-sandbox not found at $SANDBOX_PATH"
|
echo "❌ Failed to set sandbox permissions automatically."
|
||||||
echo " Make sure npm install completed successfully."
|
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
|
exit 1
|
||||||
fi
|
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