Add Steam Input support for Big Picture Mode

Introduces a Steam Input bridge using steamworks.js, enabling native controller support in Big Picture Mode and on Steam Deck. Adds a new steam-input-manager.js module, integrates IPC handlers in main.js, exposes a steamInputAPI in preload.js, and updates bigpicture.js to use Steam Input when available with fallback to legacy Gamepad API. Updates dependencies and scripts in package.json for Steam Deck and Big Picture profiles.
This commit is contained in:
2025-12-30 17:52:17 +13:00
parent 55858f13ac
commit 8994b9b2d3
6 changed files with 601 additions and 142 deletions
+23
View File
@@ -9,12 +9,14 @@ const PerformanceMonitor = require('./performance-monitor');
const GPUFallback = require('./gpu-fallback'); const GPUFallback = require('./gpu-fallback');
const GPUConfig = require('./gpu-config'); const GPUConfig = require('./gpu-config');
const PluginManager = require('./plugin-manager'); const PluginManager = require('./plugin-manager');
const SteamInputManager = require('./steam-input-manager');
// Initialize performance monitoring and GPU management // Initialize performance monitoring and GPU management
const perfMonitor = new PerformanceMonitor(); const perfMonitor = new PerformanceMonitor();
const gpuFallback = new GPUFallback(); const gpuFallback = new GPUFallback();
const gpuConfig = new GPUConfig(); const gpuConfig = new GPUConfig();
const pluginManager = new PluginManager(); const pluginManager = new PluginManager();
const steamInputManager = new SteamInputManager();
// Try to enable WebAuthn/platform authenticator features early. // Try to enable WebAuthn/platform authenticator features early.
// This helps Chromium expose platform authenticators (Touch ID / built-in) where supported. // This helps Chromium expose platform authenticators (Touch ID / built-in) where supported.
@@ -263,6 +265,19 @@ ipcMain.on('exit-bigpicture', () => {
exitBigPictureMode(); exitBigPictureMode();
}); });
// Steam Input bridge
ipcMain.handle('steam-input-start', (event) => {
return steamInputManager.subscribe(event.sender);
});
ipcMain.on('steam-input-stop', (event) => {
steamInputManager.unsubscribe(event.sender);
});
ipcMain.handle('steam-input-status', () => {
return steamInputManager.getStatus();
});
// IPC handler for sending mouse input events to webviews (used by Big Picture Mode) // IPC handler for sending mouse input events to webviews (used by Big Picture Mode)
ipcMain.handle('webview-send-input-event', async (event, { webContentsId, inputEvent }) => { ipcMain.handle('webview-send-input-event', async (event, { webContentsId, inputEvent }) => {
try { try {
@@ -676,6 +691,14 @@ app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit(); if (process.platform !== 'darwin') app.quit();
}); });
app.on('before-quit', () => {
try {
steamInputManager?.dispose?.();
} catch (err) {
console.warn('[SteamInput] dispose failed:', err);
}
});
// ipcMain handlers // ipcMain handlers
// --- Auto-Update IPC handlers --- // --- Auto-Update IPC handlers ---
+42 -5
View File
@@ -1,20 +1,22 @@
{ {
"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",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"marked": "^12.0.2" "marked": "^12.0.2",
"steamworks.js": "^0.4.0"
}, },
"devDependencies": { "devDependencies": {
"cross-env": "^10.1.0",
"electron": "^39.2.7", "electron": "^39.2.7",
"electron-builder": "^23.0.0", "electron-builder": "^23.0.0",
"electron-nightly": "^39.0.0-nightly.20250811" "electron-nightly": "^39.0.0-nightly.20250811"
@@ -118,6 +120,13 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@malept/cross-spawn-promise": { "node_modules/@malept/cross-spawn-promise": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz",
@@ -313,7 +322,6 @@
"version": "22.16.3", "version": "22.16.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.3.tgz",
"integrity": "sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==", "integrity": "sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@@ -1097,6 +1105,24 @@
"buffer": "^5.1.0" "buffer": "^5.1.0"
} }
}, },
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3079,6 +3105,18 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/steamworks.js": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/steamworks.js/-/steamworks.js-0.4.0.tgz",
"integrity": "sha512-O5TTRs7ucCRql4IA/kYUIQYeghTsXqf3rAm81sC22RDId264LQYqQjuaMEUSqL60I5LdULiGu0W2/A+ZDcKBKA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -3254,7 +3292,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/universalify": { "node_modules/universalify": {
+15 -5
View File
@@ -5,22 +5,26 @@
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"start:dev": "electron . --no-sandbox --disable-gpu", "start:steam-deck": "NEBULA_PROFILE=steam-deck electron .",
"start:linux": "electron . --no-sandbox", "start:big-picture": "NEBULA_PROFILE=big-picture electron .",
"dist": "electron-builder", "dist": "electron-builder",
"dist:steam-deck": "set NEBULA_PROFILE=steam-deck && electron-builder",
"dist:big-picture": "set NEBULA_PROFILE=big-picture && electron-builder",
"run": "electron ." "run": "electron ."
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "A lightweight, privacy-focused browser with controller-friendly Big Picture Mode for gaming and Steam Deck",
"dependencies": { "dependencies": {
"dompurify": "^3.1.6", "dompurify": "^3.1.6",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"marked": "^12.0.2" "marked": "^12.0.2",
"steamworks.js": "^0.4.0"
}, },
"devDependencies": { "devDependencies": {
"cross-env": "^10.1.0",
"electron": "^39.2.7", "electron": "^39.2.7",
"electron-builder": "^23.0.0", "electron-builder": "^23.0.0",
"electron-nightly": "^39.0.0-nightly.20250811" "electron-nightly": "^39.0.0-nightly.20250811"
@@ -42,7 +46,13 @@
"icon": "assets/images/Logos/Nebula-Favicon.ico" "icon": "assets/images/Logos/Nebula-Favicon.ico"
}, },
"linux": { "linux": {
"icon": "assets/images/Logos/Nebula-Favicon.png" "target": [
"AppImage",
"tar.gz"
],
"icon": "assets/images/Logos/Nebula-Favicon.png",
"category": "Utility",
"maintainer": "NebulaBrowser Contributors"
} }
} }
} }
+12
View File
@@ -160,6 +160,18 @@ contextBridge.exposeInMainWorld('bigPictureAPI', {
ipcRenderer.invoke('webview-send-input-event', { webContentsId, inputEvent }) ipcRenderer.invoke('webview-send-input-event', { webContentsId, inputEvent })
}); });
contextBridge.exposeInMainWorld('steamInputAPI', {
start: () => ipcRenderer.invoke('steam-input-start'),
stop: () => ipcRenderer.send('steam-input-stop'),
getStatus: () => ipcRenderer.invoke('steam-input-status'),
onState: (handler) => {
if (typeof handler !== 'function') return () => {};
const wrapped = (_event, payload) => handler(payload);
ipcRenderer.on('steam-input-state', wrapped);
return () => ipcRenderer.removeListener('steam-input-state', wrapped);
}
});
// Relay context-menu commands from main to active renderer context (open new tabs etc.) // Relay context-menu commands from main to active renderer context (open new tabs etc.)
ipcRenderer.on('context-menu-command', (event, payload) => { ipcRenderer.on('context-menu-command', (event, payload) => {
window.dispatchEvent(new CustomEvent('nebula-context-command', { detail: payload })); window.dispatchEvent(new CustomEvent('nebula-context-command', { detail: payload }));
+199 -132
View File
@@ -144,8 +144,13 @@ const state = {
// Gamepad // Gamepad
gamepadConnected: false, gamepadConnected: false,
gamepadIndex: null, gamepadIndex: null,
lastInput: { x: 0, y: 0 }, lastInput: {},
inputRepeatTimer: null, inputRepeatTimer: null,
legacyGamepadEnabled: false,
useSteamInput: false,
steamInputStatus: null,
steamInputUnsubscribe: null,
steamInputCleanupBound: false,
// Virtual cursor for webview // Virtual cursor for webview
cursorEnabled: false, cursorEnabled: false,
@@ -648,12 +653,63 @@ function goForward() {
// GAMEPAD SUPPORT // GAMEPAD SUPPORT
// ============================================================================= // =============================================================================
function initGamepadSupport() { function processDirectionalInput(direction, isPressed) {
const now = Date.now();
if (isPressed && !state.lastInput[direction]) {
navigateFocus(direction);
state.lastInput[direction] = now;
} else if (!isPressed) {
state.lastInput[direction] = 0;
}
}
function processButtonInput(key, pressed, handler) {
if (pressed && !state.lastInput[key]) {
handler();
state.lastInput[key] = true;
} else if (!pressed) {
state.lastInput[key] = false;
}
}
function handleCursorSpeedToggle() {
state.cursorSpeed = state.cursorSpeed === 15 ? 8 : (state.cursorSpeed === 8 ? 25 : 15);
showToast(`Cursor speed: ${state.cursorSpeed === 8 ? 'Slow' : state.cursorSpeed === 15 ? 'Normal' : 'Fast'}`);
}
async function initGamepadSupport() {
if (window.steamInputAPI) {
try {
const status = await window.steamInputAPI.start();
state.steamInputStatus = status;
if (status?.enabled) {
state.useSteamInput = true;
if (!state.steamInputCleanupBound) {
window.addEventListener('beforeunload', cleanupSteamInput);
state.steamInputCleanupBound = true;
}
state.steamInputUnsubscribe = window.steamInputAPI.onState(handleSteamInputState);
console.log('[BigPicture] Steam Input enabled via steamworks.js');
return;
}
console.log('[BigPicture] Steam Input unavailable, falling back:', status?.reason);
} catch (err) {
console.warn('[BigPicture] Failed to initialize Steam Input:', err);
}
}
initLegacyGamepadSupport();
}
function initLegacyGamepadSupport() {
if (state.legacyGamepadEnabled) return;
if (!navigator.getGamepads) { if (!navigator.getGamepads) {
console.warn('[BigPicture] Gamepad API not available in this environment'); console.warn('[BigPicture] Gamepad API not available in this environment');
return; return;
} }
state.legacyGamepadEnabled = true;
// 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.
@@ -731,6 +787,10 @@ function refreshActiveGamepad(isInitial = false) {
} }
function pollGamepad() { function pollGamepad() {
if (!state.legacyGamepadEnabled || state.useSteamInput) {
requestAnimationFrame(pollGamepad);
return;
}
const { active } = refreshActiveGamepad(false); const { active } = refreshActiveGamepad(false);
if (active) { if (active) {
handleGamepadInput(active); handleGamepadInput(active);
@@ -740,9 +800,11 @@ function pollGamepad() {
} }
function handleGamepadInput(gamepad) { function handleGamepadInput(gamepad) {
if (state.useSteamInput) return;
// D-pad and left stick for navigation // D-pad and left stick for navigation
const leftX = gamepad.axes[0]; const leftX = gamepad.axes[0] || 0;
const leftY = gamepad.axes[1]; const leftY = gamepad.axes[1] || 0;
// D-pad buttons (indices may vary by controller) // D-pad buttons (indices may vary by controller)
const dpadUp = gamepad.buttons[12]?.pressed; const dpadUp = gamepad.buttons[12]?.pressed;
@@ -756,122 +818,49 @@ function handleGamepadInput(gamepad) {
const stickLeft = leftX < -CONFIG.STICK_DEADZONE; const stickLeft = leftX < -CONFIG.STICK_DEADZONE;
const stickRight = leftX > CONFIG.STICK_DEADZONE; const stickRight = leftX > CONFIG.STICK_DEADZONE;
// When cursor is enabled (viewing a webpage), only D-Pad navigates sidebar
// Left stick is ignored for UI navigation in webview mode
const inWebviewMode = state.cursorEnabled && state.currentWebview; const inWebviewMode = state.cursorEnabled && state.currentWebview;
// Combine inputs - but only use D-Pad when in webview mode
const up = inWebviewMode ? dpadUp : (dpadUp || stickUp); const up = inWebviewMode ? dpadUp : (dpadUp || stickUp);
const down = inWebviewMode ? dpadDown : (dpadDown || stickDown); const down = inWebviewMode ? dpadDown : (dpadDown || stickDown);
const left = inWebviewMode ? dpadLeft : (dpadLeft || stickLeft); const left = inWebviewMode ? dpadLeft : (dpadLeft || stickLeft);
const right = inWebviewMode ? dpadRight : (dpadRight || stickRight); const right = inWebviewMode ? dpadRight : (dpadRight || stickRight);
// Navigation with repeat prevention processDirectionalInput('up', up);
const now = Date.now(); processDirectionalInput('down', down);
processDirectionalInput('left', left);
if (up && !state.lastInput.up) { processDirectionalInput('right', right);
navigateFocus('up');
state.lastInput.up = now; processButtonInput('a', gamepad.buttons[0]?.pressed, activateFocused);
} else if (!up) { processButtonInput('b', gamepad.buttons[1]?.pressed, () => goBack());
state.lastInput.up = 0; processButtonInput('x', gamepad.buttons[2]?.pressed, () => {
} if (state.oskVisible) backspaceOSK();
});
if (down && !state.lastInput.down) { processButtonInput('y', gamepad.buttons[3]?.pressed, () => {
navigateFocus('down');
state.lastInput.down = now;
} else if (!down) {
state.lastInput.down = 0;
}
if (left && !state.lastInput.left) {
navigateFocus('left');
state.lastInput.left = now;
} else if (!left) {
state.lastInput.left = 0;
}
if (right && !state.lastInput.right) {
navigateFocus('right');
state.lastInput.right = now;
} else if (!right) {
state.lastInput.right = 0;
}
// A button (usually index 0) - Always select/activate focused menu item
if (gamepad.buttons[0]?.pressed && !state.lastInput.a) {
activateFocused();
state.lastInput.a = true;
} else if (!gamepad.buttons[0]?.pressed) {
state.lastInput.a = false;
}
// B button (usually index 1) - Back/Close OSK
if (gamepad.buttons[1]?.pressed && !state.lastInput.b) {
goBack();
state.lastInput.b = true;
} else if (!gamepad.buttons[1]?.pressed) {
state.lastInput.b = false;
}
// X button (usually index 2) - Backspace when OSK is open
if (gamepad.buttons[2]?.pressed && !state.lastInput.x) {
if (state.oskVisible) {
backspaceOSK();
}
state.lastInput.x = true;
} else if (!gamepad.buttons[2]?.pressed) {
state.lastInput.x = false;
}
// Y button (usually index 3) - Space when OSK open, otherwise open search
if (gamepad.buttons[3]?.pressed && !state.lastInput.y) {
if (state.oskVisible) { if (state.oskVisible) {
appendToOSK(' '); appendToOSK(' ');
} else { } else {
openOSK('search'); openOSK('search');
} }
state.lastInput.y = true; });
} else if (!gamepad.buttons[3]?.pressed) { processButtonInput('lb', gamepad.buttons[4]?.pressed, () => {
state.lastInput.y = false;
}
// LB button (usually index 4) - Go back in webview / clear OSK
if (gamepad.buttons[4]?.pressed && !state.lastInput.lb) {
if (state.oskVisible) { if (state.oskVisible) {
clearOSK(); clearOSK();
} else if (state.currentSection === 'browse' && state.currentWebview) { } else if (state.currentSection === 'browse' && state.currentWebview) {
goBack(); goBack();
} }
state.lastInput.lb = true; });
} else if (!gamepad.buttons[4]?.pressed) { processButtonInput('rb', gamepad.buttons[5]?.pressed, () => {
state.lastInput.lb = false;
}
// RB button (usually index 5) - Go forward in webview / submit OSK
if (gamepad.buttons[5]?.pressed && !state.lastInput.rb) {
if (state.oskVisible) { if (state.oskVisible) {
submitOSK(); submitOSK();
} else if (state.currentSection === 'browse' && state.currentWebview) { } else if (state.currentSection === 'browse' && state.currentWebview) {
goForward(); goForward();
} }
state.lastInput.rb = true; });
} else if (!gamepad.buttons[5]?.pressed) { processButtonInput('select', gamepad.buttons[8]?.pressed, () => {
state.lastInput.rb = false;
}
// Back/Select button (usually index 8) - Toggle sidebar when in webview
if (gamepad.buttons[8]?.pressed && !state.lastInput.select) {
if (state.currentSection === 'browse' && state.currentWebview) { if (state.currentSection === 'browse' && state.currentWebview) {
toggleSidebar(); toggleSidebar();
} }
state.lastInput.select = true; });
} else if (!gamepad.buttons[8]?.pressed) { processButtonInput('start', gamepad.buttons[9]?.pressed, () => {
state.lastInput.select = false;
}
// Start button (usually index 9) - Menu / Toggle sidebar when viewing webpage
if (gamepad.buttons[9]?.pressed && !state.lastInput.start) {
// If viewing a webpage, toggle sidebar instead of going to settings
if (state.currentSection === 'browse' && state.currentWebview) { if (state.currentSection === 'browse' && state.currentWebview) {
toggleSidebar(); toggleSidebar();
} else if (state.currentSection !== 'settings') { } else if (state.currentSection !== 'settings') {
@@ -879,60 +868,138 @@ function handleGamepadInput(gamepad) {
} else { } else {
switchSection('home'); switchSection('home');
} }
state.lastInput.start = true; });
} else if (!gamepad.buttons[9]?.pressed) {
state.lastInput.start = false;
}
// Virtual cursor handling when webview is active
if (state.cursorEnabled && state.currentWebview) { if (state.cursorEnabled && state.currentWebview) {
// Right stick for cursor movement
const rightX = gamepad.axes[2] || 0; const rightX = gamepad.axes[2] || 0;
const rightY = gamepad.axes[3] || 0; const rightY = gamepad.axes[3] || 0;
// Apply deadzone
const deadzone = 0.15; const deadzone = 0.15;
const moveX = Math.abs(rightX) > deadzone ? rightX : 0; const moveX = Math.abs(rightX) > deadzone ? rightX : 0;
const moveY = Math.abs(rightY) > deadzone ? rightY : 0; const moveY = Math.abs(rightY) > deadzone ? rightY : 0;
if (moveX !== 0 || moveY !== 0) { if (moveX !== 0 || moveY !== 0) {
moveCursor(moveX * state.cursorSpeed, moveY * state.cursorSpeed); moveCursor(moveX * state.cursorSpeed, moveY * state.cursorSpeed);
} }
// Left stick for scrolling in webview mode
const scrollDeadzone = 0.25; const scrollDeadzone = 0.25;
const scrollX = Math.abs(leftX) > scrollDeadzone ? leftX : 0; const scrollX = Math.abs(leftX) > scrollDeadzone ? leftX : 0;
const scrollY = Math.abs(leftY) > scrollDeadzone ? leftY : 0; const scrollY = Math.abs(leftY) > scrollDeadzone ? leftY : 0;
if (scrollX !== 0 || scrollY !== 0) { if (scrollX !== 0 || scrollY !== 0) {
scrollWebview(scrollY * 20, scrollX * 20); scrollWebview(scrollY * 20, scrollX * 20);
} }
// Right trigger (index 7) - Left click processButtonInput('rt', gamepad.buttons[7]?.pressed, () => virtualClick());
if (gamepad.buttons[7]?.pressed && !state.lastInput.rt) { processButtonInput('lt', gamepad.buttons[6]?.pressed, () => virtualClick(true));
virtualClick(); processButtonInput('rs', gamepad.buttons[11]?.pressed, handleCursorSpeedToggle);
state.lastInput.rt = true; }
} else if (!gamepad.buttons[7]?.pressed) { }
state.lastInput.rt = false;
function handleSteamInputState(payload) {
if (!payload || !payload.connected || !payload.controller) {
state.useSteamInput = false;
if (!state.legacyGamepadEnabled) {
initLegacyGamepadSupport();
} }
return;
// Left trigger (index 6) - Right click }
if (gamepad.buttons[6]?.pressed && !state.lastInput.lt) {
virtualClick(true); state.gamepadConnected = true;
state.lastInput.lt = true; state.useSteamInput = true;
} else if (!gamepad.buttons[6]?.pressed) { const controller = payload.controller;
state.lastInput.lt = false; const nav = controller.nav || {};
const buttons = controller.buttons || {};
const analog = controller.analog || {};
const triggers = analog.triggers || { left: 0, right: 0 };
processDirectionalInput('up', !!nav.up);
processDirectionalInput('down', !!nav.down);
processDirectionalInput('left', !!nav.left);
processDirectionalInput('right', !!nav.right);
processButtonInput('a', !!buttons.confirm, activateFocused);
processButtonInput('b', !!buttons.back, () => goBack());
processButtonInput('x', !!buttons.oskBackspace, () => {
if (state.oskVisible) backspaceOSK();
});
processButtonInput('y', !!buttons.oskSpace, () => {
if (state.oskVisible) {
appendToOSK(' ');
} else {
openOSK('search');
} }
});
// Right stick click (index 11) - Toggle cursor speed
if (gamepad.buttons[11]?.pressed && !state.lastInput.rs) { const shoulderLeftPressed = !!buttons.shoulderLeft || triggers.left > 0.6;
state.cursorSpeed = state.cursorSpeed === 15 ? 8 : (state.cursorSpeed === 8 ? 25 : 15); const shoulderRightPressed = !!buttons.shoulderRight || triggers.right > 0.6;
showToast(`Cursor speed: ${state.cursorSpeed === 8 ? 'Slow' : state.cursorSpeed === 15 ? 'Normal' : 'Fast'}`);
state.lastInput.rs = true; processButtonInput('lb', shoulderLeftPressed, () => {
} else if (!gamepad.buttons[11]?.pressed) { if (state.oskVisible) {
state.lastInput.rs = false; clearOSK();
} else if (state.currentSection === 'browse' && state.currentWebview) {
goBack();
}
});
processButtonInput('rb', shoulderRightPressed, () => {
if (state.oskVisible) {
submitOSK();
} else if (state.currentSection === 'browse' && state.currentWebview) {
goForward();
}
});
processButtonInput('select', !!buttons.toggleSidebar, () => {
if (state.currentSection === 'browse' && state.currentWebview) {
toggleSidebar();
}
});
processButtonInput('start', !!buttons.menu, () => {
if (state.currentSection === 'browse' && state.currentWebview) {
toggleSidebar();
} else if (state.currentSection !== 'settings') {
switchSection('settings');
} else {
switchSection('home');
}
});
processButtonInput('osk', !!buttons.showOsk, () => {
if (state.currentWebview) {
openOSKForWebview();
} else {
openOSK('search');
}
});
if (state.cursorEnabled && state.currentWebview) {
const cursorVec = analog.cursor || { x: 0, y: 0 };
const scrollVec = analog.scroll || { x: 0, y: 0 };
if (Math.abs(cursorVec.x) > 0.05 || Math.abs(cursorVec.y) > 0.05) {
moveCursor(cursorVec.x * state.cursorSpeed, cursorVec.y * state.cursorSpeed);
}
if (Math.abs(scrollVec.x) > 0.05 || Math.abs(scrollVec.y) > 0.05) {
scrollWebview(scrollVec.y * 40, scrollVec.x * 40);
}
const primaryPressed = !!buttons.cursorPrimary || triggers.right > 0.6;
const secondaryPressed = !!buttons.cursorSecondary || triggers.left > 0.6;
processButtonInput('rt', primaryPressed, () => virtualClick());
processButtonInput('lt', secondaryPressed, () => virtualClick(true));
processButtonInput('rs', !!buttons.cursorSpeed, handleCursorSpeedToggle);
}
}
function cleanupSteamInput() {
if (state.steamInputUnsubscribe) {
try { state.steamInputUnsubscribe(); } catch {}
state.steamInputUnsubscribe = null;
}
if (window.steamInputAPI) {
try { window.steamInputAPI.stop(); } catch (err) {
console.warn('[BigPicture] Failed to stop Steam Input bridge:', err);
} }
} }
state.useSteamInput = false;
} }
// ============================================================================= // =============================================================================
+310
View File
@@ -0,0 +1,310 @@
const ACTION_SET_NAME = 'nebula_bigpicture';
const DIGITAL_ACTIONS = {
up: 'bp_nav_up',
down: 'bp_nav_down',
left: 'bp_nav_left',
right: 'bp_nav_right',
confirm: 'bp_confirm',
back: 'bp_back',
oskBackspace: 'bp_osk_backspace',
oskSpace: 'bp_open_search',
shoulderLeft: 'bp_shoulder_left',
shoulderRight: 'bp_shoulder_right',
toggleSidebar: 'bp_toggle_sidebar',
menu: 'bp_menu',
select: 'bp_select',
cursorPrimary: 'bp_cursor_primary',
cursorSecondary: 'bp_cursor_secondary',
cursorSpeed: 'bp_cursor_speed',
showOsk: 'bp_show_osk'
};
const ANALOG_ACTIONS = {
nav: 'bp_nav',
cursor: 'bp_cursor_vector',
scroll: 'bp_scroll_vector',
triggerLeft: 'bp_trigger_left',
triggerRight: 'bp_trigger_right'
};
class SteamInputManager {
constructor() {
this.sdk = null;
this.client = null;
this.input = null;
this.available = false;
this.handles = {
actionSet: 0n,
digital: {},
analog: {}
};
this.handlesReady = false;
this.handlesReason = 'uninitialized';
this.subscribers = new Map();
this.cachedState = { connected: false, timestamp: Date.now() };
this.pollInterval = null;
this.lastCursorSpeedToggle = 0;
this.init();
}
init() {
if (process.env.NEBULA_DISABLE_STEAMWORKS) {
this.handlesReason = 'disabled-via-env';
return;
}
let steamworks;
try {
// Lazy require so environments without the redistributable don't crash startup.
// eslint-disable-next-line global-require
steamworks = require('steamworks.js');
} catch (err) {
console.warn('[SteamInput] steamworks.js unavailable:', err.message);
this.handlesReason = 'module-missing';
return;
}
this.sdk = steamworks;
try {
const appId = this.resolveAppId();
this.client = steamworks.init(appId);
this.input = this.client?.input;
if (!this.input) throw new Error('Steam Input interface missing');
this.input.init();
if (typeof steamworks.electronEnableSteamOverlay === 'function') {
steamworks.electronEnableSteamOverlay();
}
this.available = true;
this.bootstrapHandles();
this.startPolling();
console.log('[SteamInput] Steamworks initialized', {
appId: appId || 'steam_appid.txt',
handlesReady: this.handlesReady
});
} catch (err) {
console.warn('[SteamInput] Failed to initialize Steamworks:', err.message);
this.client = null;
this.input = null;
this.available = false;
this.handlesReason = 'init-failed';
}
}
resolveAppId() {
const envId = Number(process.env.STEAM_APP_ID || process.env.STEAMWORKS_APPID || process.env.STEAM_APPID);
if (Number.isFinite(envId) && envId > 0) {
return envId;
}
return undefined;
}
bootstrapHandles() {
if (!this.input) return;
try {
const actionSet = this.input.getActionSet?.(ACTION_SET_NAME) || 0n;
const digital = {};
for (const [key, actionName] of Object.entries(DIGITAL_ACTIONS)) {
digital[key] = this.input.getDigitalAction?.(actionName) || 0n;
}
const analog = {};
for (const [key, actionName] of Object.entries(ANALOG_ACTIONS)) {
analog[key] = this.input.getAnalogAction?.(actionName) || 0n;
}
this.handles = { actionSet, digital, analog };
const directionalDigitalReady = Boolean(digital.up && digital.down && digital.left && digital.right);
const confirmReady = Boolean(digital.confirm);
const backReady = Boolean(digital.back);
const analogNavReady = Boolean(analog.nav);
this.handlesReady = Boolean(actionSet && (directionalDigitalReady || analogNavReady) && confirmReady && backReady);
this.handlesReason = this.handlesReady ? 'ok' : 'handles-missing';
} catch (err) {
console.warn('[SteamInput] Failed to read action handles:', err.message);
this.handlesReady = false;
this.handlesReason = 'handles-error';
}
}
startPolling() {
if (!this.available || !this.input || this.pollInterval) return;
this.pollInterval = setInterval(() => this.tick(), 16);
if (this.pollInterval && typeof this.pollInterval.unref === 'function') {
this.pollInterval.unref();
}
}
tick() {
if (!this.available || !this.input) return;
try {
this.sdk?.runCallbacks?.();
} catch (err) {
console.warn('[SteamInput] runCallbacks failed:', err.message);
}
let payload = { connected: false, reason: this.handlesReason, timestamp: Date.now() };
try {
const controllers = this.input.getControllers?.() || [];
const controller = controllers.find(Boolean);
if (controller && this.handlesReady) {
this.activateActionSet(controller);
payload = {
connected: true,
timestamp: Date.now(),
controller: this.buildControllerState(controller),
reason: 'ok'
};
}
} catch (err) {
console.warn('[SteamInput] Failed to query controller state:', err.message);
}
this.cachedState = payload;
if (!this.subscribers.size) return;
for (const [id, wc] of this.subscribers.entries()) {
if (wc.isDestroyed?.()) {
this.subscribers.delete(id);
continue;
}
try {
wc.send('steam-input-state', payload);
} catch (err) {
console.warn('[SteamInput] Failed to send state to renderer:', err.message);
this.subscribers.delete(id);
}
}
}
activateActionSet(controller) {
if (!this.handles.actionSet) return;
try {
controller.activateActionSet?.(this.handles.actionSet);
} catch (err) {
console.warn('[SteamInput] Failed to activate action set:', err.message);
this.handlesReady = false;
this.handlesReason = 'activate-failed';
}
}
buildControllerState(controller) {
const navVector = this.readAnalog(controller, this.handles.analog.nav);
const cursorVector = this.readAnalog(controller, this.handles.analog.cursor);
const scrollVector = this.readAnalog(controller, this.handles.analog.scroll);
const triggerLeft = this.readAnalog(controller, this.handles.analog.triggerLeft);
const triggerRight = this.readAnalog(controller, this.handles.analog.triggerRight);
const nav = {
up: this.readDigital(controller, this.handles.digital.up) || navVector.y < -0.5,
down: this.readDigital(controller, this.handles.digital.down) || navVector.y > 0.5,
left: this.readDigital(controller, this.handles.digital.left) || navVector.x < -0.5,
right: this.readDigital(controller, this.handles.digital.right) || navVector.x > 0.5
};
const buttons = {
confirm: this.readDigital(controller, this.handles.digital.confirm),
back: this.readDigital(controller, this.handles.digital.back),
oskBackspace: this.readDigital(controller, this.handles.digital.oskBackspace),
oskSpace: this.readDigital(controller, this.handles.digital.oskSpace),
shoulderLeft: this.readDigital(controller, this.handles.digital.shoulderLeft),
shoulderRight: this.readDigital(controller, this.handles.digital.shoulderRight),
toggleSidebar: this.readDigital(controller, this.handles.digital.toggleSidebar) || this.readDigital(controller, this.handles.digital.select),
menu: this.readDigital(controller, this.handles.digital.menu),
cursorPrimary: this.readDigital(controller, this.handles.digital.cursorPrimary),
cursorSecondary: this.readDigital(controller, this.handles.digital.cursorSecondary),
cursorSpeed: this.readDigital(controller, this.handles.digital.cursorSpeed),
showOsk: this.readDigital(controller, this.handles.digital.showOsk)
};
const analog = {
nav: navVector,
cursor: cursorVector,
scroll: scrollVector,
triggers: {
left: Math.max(Math.abs(triggerLeft.x), Math.abs(triggerLeft.y)),
right: Math.max(Math.abs(triggerRight.x), Math.abs(triggerRight.y))
}
};
return {
handle: controller.getHandle?.() || 0n,
type: controller.getType?.() || 'Unknown',
nav,
buttons,
analog
};
}
readDigital(controller, handle) {
if (!handle || typeof controller.isDigitalActionPressed !== 'function') return false;
try {
return controller.isDigitalActionPressed(handle);
} catch (err) {
console.warn('[SteamInput] Failed to read digital action:', err.message);
return false;
}
}
readAnalog(controller, handle) {
if (!handle || typeof controller.getAnalogActionVector !== 'function') {
return { x: 0, y: 0 };
}
try {
const vec = controller.getAnalogActionVector(handle) || { x: 0, y: 0 };
return {
x: Number.isFinite(vec.x) ? vec.x : 0,
y: Number.isFinite(vec.y) ? vec.y : 0
};
} catch (err) {
console.warn('[SteamInput] Failed to read analog action:', err.message);
return { x: 0, y: 0 };
}
}
subscribe(webContents) {
if (!webContents) return this.getStatus();
const id = webContents.id;
this.subscribers.set(id, webContents);
webContents.once('destroyed', () => {
this.subscribers.delete(id);
});
if (this.cachedState) {
try {
webContents.send('steam-input-state', this.cachedState);
} catch (err) {
console.warn('[SteamInput] Failed to push cached state:', err.message);
}
}
return this.getStatus();
}
unsubscribe(webContents) {
if (!webContents) return;
this.subscribers.delete(webContents.id);
}
getStatus() {
const steamDeck = Boolean(this.client?.utils?.isSteamRunningOnSteamDeck?.());
return {
enabled: this.available && this.handlesReady,
available: this.available,
handlesReady: this.handlesReady,
reason: this.handlesReason,
steamDeck
};
}
dispose() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
try {
this.input?.shutdown?.();
} catch (err) {
console.warn('[SteamInput] Shutdown failed:', err.message);
}
}
}
module.exports = SteamInputManager;