From 8994b9b2d32732b5d8a0ce73b50bdaeff563d3f2 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Tue, 30 Dec 2025 17:52:17 +1300 Subject: [PATCH] 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. --- main.js | 23 +++ package-lock.json | 47 +++++- package.json | 20 ++- preload.js | 12 ++ renderer/bigpicture.js | 331 +++++++++++++++++++++++++---------------- steam-input-manager.js | 310 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 601 insertions(+), 142 deletions(-) create mode 100644 steam-input-manager.js diff --git a/main.js b/main.js index 0954e2e..710ec71 100644 --- a/main.js +++ b/main.js @@ -9,12 +9,14 @@ const PerformanceMonitor = require('./performance-monitor'); const GPUFallback = require('./gpu-fallback'); const GPUConfig = require('./gpu-config'); const PluginManager = require('./plugin-manager'); +const SteamInputManager = require('./steam-input-manager'); // Initialize performance monitoring and GPU management const perfMonitor = new PerformanceMonitor(); const gpuFallback = new GPUFallback(); const gpuConfig = new GPUConfig(); const pluginManager = new PluginManager(); +const steamInputManager = new SteamInputManager(); // Try to enable WebAuthn/platform authenticator features early. // This helps Chromium expose platform authenticators (Touch ID / built-in) where supported. @@ -263,6 +265,19 @@ ipcMain.on('exit-bigpicture', () => { 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) ipcMain.handle('webview-send-input-event', async (event, { webContentsId, inputEvent }) => { try { @@ -676,6 +691,14 @@ app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); +app.on('before-quit', () => { + try { + steamInputManager?.dispose?.(); + } catch (err) { + console.warn('[SteamInput] dispose failed:', err); + } +}); + // ipcMain handlers // --- Auto-Update IPC handlers --- diff --git a/package-lock.json b/package-lock.json index 23be45a..307be4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,22 @@ { "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", "electron-updater": "^6.6.2", "highlight.js": "^11.9.0", - "marked": "^12.0.2" + "marked": "^12.0.2", + "steamworks.js": "^0.4.0" }, "devDependencies": { + "cross-env": "^10.1.0", "electron": "^39.2.7", "electron-builder": "^23.0.0", "electron-nightly": "^39.0.0-nightly.20250811" @@ -118,6 +120,13 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", @@ -313,7 +322,6 @@ "version": "22.16.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.3.tgz", "integrity": "sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1097,6 +1105,24 @@ "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3079,6 +3105,18 @@ "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": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3254,7 +3292,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { diff --git a/package.json b/package.json index 2c339ed..92801a9 100644 --- a/package.json +++ b/package.json @@ -5,22 +5,26 @@ "main": "main.js", "scripts": { "start": "electron .", - "start:dev": "electron . --no-sandbox --disable-gpu", - "start:linux": "electron . --no-sandbox", + "start:steam-deck": "NEBULA_PROFILE=steam-deck electron .", + "start:big-picture": "NEBULA_PROFILE=big-picture electron .", "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 ." }, "keywords": [], "author": "", "license": "ISC", - "description": "", + "description": "A lightweight, privacy-focused browser with controller-friendly Big Picture Mode for gaming and Steam Deck", "dependencies": { "dompurify": "^3.1.6", "electron-updater": "^6.6.2", "highlight.js": "^11.9.0", - "marked": "^12.0.2" + "marked": "^12.0.2", + "steamworks.js": "^0.4.0" }, "devDependencies": { + "cross-env": "^10.1.0", "electron": "^39.2.7", "electron-builder": "^23.0.0", "electron-nightly": "^39.0.0-nightly.20250811" @@ -42,7 +46,13 @@ "icon": "assets/images/Logos/Nebula-Favicon.ico" }, "linux": { - "icon": "assets/images/Logos/Nebula-Favicon.png" + "target": [ + "AppImage", + "tar.gz" + ], + "icon": "assets/images/Logos/Nebula-Favicon.png", + "category": "Utility", + "maintainer": "NebulaBrowser Contributors" } } } diff --git a/preload.js b/preload.js index 2ac8079..757f3f5 100644 --- a/preload.js +++ b/preload.js @@ -160,6 +160,18 @@ contextBridge.exposeInMainWorld('bigPictureAPI', { 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.) ipcRenderer.on('context-menu-command', (event, payload) => { window.dispatchEvent(new CustomEvent('nebula-context-command', { detail: payload })); diff --git a/renderer/bigpicture.js b/renderer/bigpicture.js index 5b33442..990c81d 100644 --- a/renderer/bigpicture.js +++ b/renderer/bigpicture.js @@ -144,8 +144,13 @@ const state = { // Gamepad gamepadConnected: false, gamepadIndex: null, - lastInput: { x: 0, y: 0 }, + lastInput: {}, inputRepeatTimer: null, + legacyGamepadEnabled: false, + useSteamInput: false, + steamInputStatus: null, + steamInputUnsubscribe: null, + steamInputCleanupBound: false, // Virtual cursor for webview cursorEnabled: false, @@ -648,12 +653,63 @@ function goForward() { // 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) { console.warn('[BigPicture] Gamepad API not available in this environment'); return; } + state.legacyGamepadEnabled = true; + // 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. @@ -731,6 +787,10 @@ function refreshActiveGamepad(isInitial = false) { } function pollGamepad() { + if (!state.legacyGamepadEnabled || state.useSteamInput) { + requestAnimationFrame(pollGamepad); + return; + } const { active } = refreshActiveGamepad(false); if (active) { handleGamepadInput(active); @@ -740,9 +800,11 @@ function pollGamepad() { } function handleGamepadInput(gamepad) { + if (state.useSteamInput) return; + // D-pad and left stick for navigation - const leftX = gamepad.axes[0]; - const leftY = gamepad.axes[1]; + const leftX = gamepad.axes[0] || 0; + const leftY = gamepad.axes[1] || 0; // D-pad buttons (indices may vary by controller) const dpadUp = gamepad.buttons[12]?.pressed; @@ -756,122 +818,49 @@ function handleGamepadInput(gamepad) { const stickLeft = 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; - - // Combine inputs - but only use D-Pad when in webview mode const up = inWebviewMode ? dpadUp : (dpadUp || stickUp); const down = inWebviewMode ? dpadDown : (dpadDown || stickDown); const left = inWebviewMode ? dpadLeft : (dpadLeft || stickLeft); const right = inWebviewMode ? dpadRight : (dpadRight || stickRight); - - // Navigation with repeat prevention - const now = Date.now(); - - if (up && !state.lastInput.up) { - navigateFocus('up'); - state.lastInput.up = now; - } else if (!up) { - state.lastInput.up = 0; - } - - if (down && !state.lastInput.down) { - 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) { + + processDirectionalInput('up', up); + processDirectionalInput('down', down); + processDirectionalInput('left', left); + processDirectionalInput('right', right); + + processButtonInput('a', gamepad.buttons[0]?.pressed, activateFocused); + processButtonInput('b', gamepad.buttons[1]?.pressed, () => goBack()); + processButtonInput('x', gamepad.buttons[2]?.pressed, () => { + if (state.oskVisible) backspaceOSK(); + }); + processButtonInput('y', gamepad.buttons[3]?.pressed, () => { if (state.oskVisible) { appendToOSK(' '); } else { openOSK('search'); } - state.lastInput.y = true; - } else if (!gamepad.buttons[3]?.pressed) { - state.lastInput.y = false; - } - - // LB button (usually index 4) - Go back in webview / clear OSK - if (gamepad.buttons[4]?.pressed && !state.lastInput.lb) { + }); + processButtonInput('lb', gamepad.buttons[4]?.pressed, () => { if (state.oskVisible) { clearOSK(); } else if (state.currentSection === 'browse' && state.currentWebview) { goBack(); } - state.lastInput.lb = true; - } else if (!gamepad.buttons[4]?.pressed) { - state.lastInput.lb = false; - } - - // RB button (usually index 5) - Go forward in webview / submit OSK - if (gamepad.buttons[5]?.pressed && !state.lastInput.rb) { + }); + processButtonInput('rb', gamepad.buttons[5]?.pressed, () => { if (state.oskVisible) { submitOSK(); } else if (state.currentSection === 'browse' && state.currentWebview) { goForward(); } - state.lastInput.rb = true; - } else if (!gamepad.buttons[5]?.pressed) { - state.lastInput.rb = false; - } - - // Back/Select button (usually index 8) - Toggle sidebar when in webview - if (gamepad.buttons[8]?.pressed && !state.lastInput.select) { + }); + processButtonInput('select', gamepad.buttons[8]?.pressed, () => { if (state.currentSection === 'browse' && state.currentWebview) { toggleSidebar(); } - state.lastInput.select = true; - } else if (!gamepad.buttons[8]?.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 + }); + processButtonInput('start', gamepad.buttons[9]?.pressed, () => { if (state.currentSection === 'browse' && state.currentWebview) { toggleSidebar(); } else if (state.currentSection !== 'settings') { @@ -879,60 +868,138 @@ function handleGamepadInput(gamepad) { } else { 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) { - // Right stick for cursor movement const rightX = gamepad.axes[2] || 0; const rightY = gamepad.axes[3] || 0; - - // Apply deadzone const deadzone = 0.15; const moveX = Math.abs(rightX) > deadzone ? rightX : 0; const moveY = Math.abs(rightY) > deadzone ? rightY : 0; - if (moveX !== 0 || moveY !== 0) { moveCursor(moveX * state.cursorSpeed, moveY * state.cursorSpeed); } - - // Left stick for scrolling in webview mode + const scrollDeadzone = 0.25; const scrollX = Math.abs(leftX) > scrollDeadzone ? leftX : 0; const scrollY = Math.abs(leftY) > scrollDeadzone ? leftY : 0; - if (scrollX !== 0 || scrollY !== 0) { scrollWebview(scrollY * 20, scrollX * 20); } - - // Right trigger (index 7) - Left click - if (gamepad.buttons[7]?.pressed && !state.lastInput.rt) { - virtualClick(); - state.lastInput.rt = true; - } else if (!gamepad.buttons[7]?.pressed) { - state.lastInput.rt = false; + + processButtonInput('rt', gamepad.buttons[7]?.pressed, () => virtualClick()); + processButtonInput('lt', gamepad.buttons[6]?.pressed, () => virtualClick(true)); + processButtonInput('rs', gamepad.buttons[11]?.pressed, handleCursorSpeedToggle); + } +} + +function handleSteamInputState(payload) { + if (!payload || !payload.connected || !payload.controller) { + state.useSteamInput = false; + if (!state.legacyGamepadEnabled) { + initLegacyGamepadSupport(); } - - // Left trigger (index 6) - Right click - if (gamepad.buttons[6]?.pressed && !state.lastInput.lt) { - virtualClick(true); - state.lastInput.lt = true; - } else if (!gamepad.buttons[6]?.pressed) { - state.lastInput.lt = false; + return; + } + + state.gamepadConnected = true; + state.useSteamInput = true; + const controller = payload.controller; + 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) { - state.cursorSpeed = state.cursorSpeed === 15 ? 8 : (state.cursorSpeed === 8 ? 25 : 15); - showToast(`Cursor speed: ${state.cursorSpeed === 8 ? 'Slow' : state.cursorSpeed === 15 ? 'Normal' : 'Fast'}`); - state.lastInput.rs = true; - } else if (!gamepad.buttons[11]?.pressed) { - state.lastInput.rs = false; + }); + + const shoulderLeftPressed = !!buttons.shoulderLeft || triggers.left > 0.6; + const shoulderRightPressed = !!buttons.shoulderRight || triggers.right > 0.6; + + processButtonInput('lb', shoulderLeftPressed, () => { + if (state.oskVisible) { + 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; } // ============================================================================= diff --git a/steam-input-manager.js b/steam-input-manager.js new file mode 100644 index 0000000..1ec7f81 --- /dev/null +++ b/steam-input-manager.js @@ -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;