diff --git a/main.js b/main.js index 710ec71..0954e2e 100644 --- a/main.js +++ b/main.js @@ -9,14 +9,12 @@ 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. @@ -265,19 +263,6 @@ 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 { @@ -691,14 +676,6 @@ 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 307be4e..23be45a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,20 @@ { "name": "nebula", - "version": "1.3.2", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nebula", - "version": "1.3.2", + "version": "1.0.0", "license": "ISC", "dependencies": { "dompurify": "^3.1.6", "electron-updater": "^6.6.2", "highlight.js": "^11.9.0", - "marked": "^12.0.2", - "steamworks.js": "^0.4.0" + "marked": "^12.0.2" }, "devDependencies": { - "cross-env": "^10.1.0", "electron": "^39.2.7", "electron-builder": "^23.0.0", "electron-nightly": "^39.0.0-nightly.20250811" @@ -120,13 +118,6 @@ "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", @@ -322,6 +313,7 @@ "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" @@ -1105,24 +1097,6 @@ "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", @@ -3105,18 +3079,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": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3292,6 +3254,7 @@ "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 92801a9..2c339ed 100644 --- a/package.json +++ b/package.json @@ -5,26 +5,22 @@ "main": "main.js", "scripts": { "start": "electron .", - "start:steam-deck": "NEBULA_PROFILE=steam-deck electron .", - "start:big-picture": "NEBULA_PROFILE=big-picture electron .", + "start:dev": "electron . --no-sandbox --disable-gpu", + "start:linux": "electron . --no-sandbox", "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": "A lightweight, privacy-focused browser with controller-friendly Big Picture Mode for gaming and Steam Deck", + "description": "", "dependencies": { "dompurify": "^3.1.6", "electron-updater": "^6.6.2", "highlight.js": "^11.9.0", - "marked": "^12.0.2", - "steamworks.js": "^0.4.0" + "marked": "^12.0.2" }, "devDependencies": { - "cross-env": "^10.1.0", "electron": "^39.2.7", "electron-builder": "^23.0.0", "electron-nightly": "^39.0.0-nightly.20250811" @@ -46,13 +42,7 @@ "icon": "assets/images/Logos/Nebula-Favicon.ico" }, "linux": { - "target": [ - "AppImage", - "tar.gz" - ], - "icon": "assets/images/Logos/Nebula-Favicon.png", - "category": "Utility", - "maintainer": "NebulaBrowser Contributors" + "icon": "assets/images/Logos/Nebula-Favicon.png" } } } diff --git a/preload.js b/preload.js index 757f3f5..2ac8079 100644 --- a/preload.js +++ b/preload.js @@ -160,18 +160,6 @@ 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 990c81d..5b33442 100644 --- a/renderer/bigpicture.js +++ b/renderer/bigpicture.js @@ -144,13 +144,8 @@ const state = { // Gamepad gamepadConnected: false, gamepadIndex: null, - lastInput: {}, + lastInput: { x: 0, y: 0 }, inputRepeatTimer: null, - legacyGamepadEnabled: false, - useSteamInput: false, - steamInputStatus: null, - steamInputUnsubscribe: null, - steamInputCleanupBound: false, // Virtual cursor for webview cursorEnabled: false, @@ -653,63 +648,12 @@ function goForward() { // GAMEPAD SUPPORT // ============================================================================= -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; +function initGamepadSupport() { 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. @@ -787,10 +731,6 @@ function refreshActiveGamepad(isInitial = false) { } function pollGamepad() { - if (!state.legacyGamepadEnabled || state.useSteamInput) { - requestAnimationFrame(pollGamepad); - return; - } const { active } = refreshActiveGamepad(false); if (active) { handleGamepadInput(active); @@ -800,11 +740,9 @@ function pollGamepad() { } function handleGamepadInput(gamepad) { - if (state.useSteamInput) return; - // D-pad and left stick for navigation - const leftX = gamepad.axes[0] || 0; - const leftY = gamepad.axes[1] || 0; + const leftX = gamepad.axes[0]; + const leftY = gamepad.axes[1]; // D-pad buttons (indices may vary by controller) const dpadUp = gamepad.buttons[12]?.pressed; @@ -818,49 +756,122 @@ 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); - - 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, () => { + + // 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) { if (state.oskVisible) { appendToOSK(' '); } else { openOSK('search'); } - }); - processButtonInput('lb', gamepad.buttons[4]?.pressed, () => { + 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) { if (state.oskVisible) { clearOSK(); } else if (state.currentSection === 'browse' && state.currentWebview) { goBack(); } - }); - processButtonInput('rb', gamepad.buttons[5]?.pressed, () => { + 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) { if (state.oskVisible) { submitOSK(); } else if (state.currentSection === 'browse' && state.currentWebview) { goForward(); } - }); - processButtonInput('select', gamepad.buttons[8]?.pressed, () => { + 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) { if (state.currentSection === 'browse' && state.currentWebview) { toggleSidebar(); } - }); - processButtonInput('start', gamepad.buttons[9]?.pressed, () => { + 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 if (state.currentSection === 'browse' && state.currentWebview) { toggleSidebar(); } else if (state.currentSection !== 'settings') { @@ -868,138 +879,60 @@ 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); } - - 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(); + + // 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; } - 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'); + + // 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; } - }); - - 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); + + // 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; } } - state.useSteamInput = false; } // ============================================================================= diff --git a/steam-input-manager.js b/steam-input-manager.js deleted file mode 100644 index 1ec7f81..0000000 --- a/steam-input-manager.js +++ /dev/null @@ -1,310 +0,0 @@ -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;