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;