Revert "Add Steam Input support for Big Picture Mode"

This reverts commit 8994b9b2d3.
This commit is contained in:
2025-12-30 18:15:14 +13:00
parent 8994b9b2d3
commit f59f4576b9
6 changed files with 142 additions and 601 deletions
-23
View File
@@ -9,14 +9,12 @@ 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.
@@ -265,19 +263,6 @@ 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 {
@@ -691,14 +676,6 @@ 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 ---
+5 -42
View File
@@ -1,22 +1,20 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.3.2", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nebula", "name": "nebula",
"version": "1.3.2", "version": "1.0.0",
"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"
@@ -120,13 +118,6 @@
"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",
@@ -322,6 +313,7 @@
"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"
@@ -1105,24 +1097,6 @@
"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",
@@ -3105,18 +3079,6 @@
"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",
@@ -3292,6 +3254,7 @@
"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": {
+5 -15
View File
@@ -5,26 +5,22 @@
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"start:steam-deck": "NEBULA_PROFILE=steam-deck electron .", "start:dev": "electron . --no-sandbox --disable-gpu",
"start:big-picture": "NEBULA_PROFILE=big-picture electron .", "start:linux": "electron . --no-sandbox",
"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": "A lightweight, privacy-focused browser with controller-friendly Big Picture Mode for gaming and Steam Deck", "description": "",
"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"
@@ -46,13 +42,7 @@
"icon": "assets/images/Logos/Nebula-Favicon.ico" "icon": "assets/images/Logos/Nebula-Favicon.ico"
}, },
"linux": { "linux": {
"target": [ "icon": "assets/images/Logos/Nebula-Favicon.png"
"AppImage",
"tar.gz"
],
"icon": "assets/images/Logos/Nebula-Favicon.png",
"category": "Utility",
"maintainer": "NebulaBrowser Contributors"
} }
} }
} }
-12
View File
@@ -160,18 +160,6 @@ 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 }));
+132 -199
View File
@@ -144,13 +144,8 @@ const state = {
// Gamepad // Gamepad
gamepadConnected: false, gamepadConnected: false,
gamepadIndex: null, gamepadIndex: null,
lastInput: {}, lastInput: { x: 0, y: 0 },
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,
@@ -653,63 +648,12 @@ function goForward() {
// GAMEPAD SUPPORT // GAMEPAD SUPPORT
// ============================================================================= // =============================================================================
function processDirectionalInput(direction, isPressed) { function initGamepadSupport() {
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.
@@ -787,10 +731,6 @@ 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);
@@ -800,11 +740,9 @@ 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] || 0; const leftX = gamepad.axes[0];
const leftY = gamepad.axes[1] || 0; const leftY = gamepad.axes[1];
// 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;
@@ -818,49 +756,122 @@ 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);
processDirectionalInput('up', up); // Navigation with repeat prevention
processDirectionalInput('down', down); const now = Date.now();
processDirectionalInput('left', left);
processDirectionalInput('right', right); if (up && !state.lastInput.up) {
navigateFocus('up');
processButtonInput('a', gamepad.buttons[0]?.pressed, activateFocused); state.lastInput.up = now;
processButtonInput('b', gamepad.buttons[1]?.pressed, () => goBack()); } else if (!up) {
processButtonInput('x', gamepad.buttons[2]?.pressed, () => { state.lastInput.up = 0;
if (state.oskVisible) backspaceOSK(); }
});
processButtonInput('y', gamepad.buttons[3]?.pressed, () => { 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) { if (state.oskVisible) {
appendToOSK(' '); appendToOSK(' ');
} else { } else {
openOSK('search'); openOSK('search');
} }
}); state.lastInput.y = true;
processButtonInput('lb', gamepad.buttons[4]?.pressed, () => { } 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) { 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;
processButtonInput('rb', gamepad.buttons[5]?.pressed, () => { } 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) { 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;
processButtonInput('select', gamepad.buttons[8]?.pressed, () => { } 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) { if (state.currentSection === 'browse' && state.currentWebview) {
toggleSidebar(); toggleSidebar();
} }
}); state.lastInput.select = true;
processButtonInput('start', gamepad.buttons[9]?.pressed, () => { } 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) { if (state.currentSection === 'browse' && state.currentWebview) {
toggleSidebar(); toggleSidebar();
} else if (state.currentSection !== 'settings') { } else if (state.currentSection !== 'settings') {
@@ -868,138 +879,60 @@ 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);
} }
processButtonInput('rt', gamepad.buttons[7]?.pressed, () => virtualClick()); // Right trigger (index 7) - Left click
processButtonInput('lt', gamepad.buttons[6]?.pressed, () => virtualClick(true)); if (gamepad.buttons[7]?.pressed && !state.lastInput.rt) {
processButtonInput('rs', gamepad.buttons[11]?.pressed, handleCursorSpeedToggle); virtualClick();
} 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) {
state.gamepadConnected = true; virtualClick(true);
state.useSteamInput = true; state.lastInput.lt = true;
const controller = payload.controller; } else if (!gamepad.buttons[6]?.pressed) {
const nav = controller.nav || {}; state.lastInput.lt = false;
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
const shoulderLeftPressed = !!buttons.shoulderLeft || triggers.left > 0.6; if (gamepad.buttons[11]?.pressed && !state.lastInput.rs) {
const shoulderRightPressed = !!buttons.shoulderRight || triggers.right > 0.6; state.cursorSpeed = state.cursorSpeed === 15 ? 8 : (state.cursorSpeed === 8 ? 25 : 15);
showToast(`Cursor speed: ${state.cursorSpeed === 8 ? 'Slow' : state.cursorSpeed === 15 ? 'Normal' : 'Fast'}`);
processButtonInput('lb', shoulderLeftPressed, () => { state.lastInput.rs = true;
if (state.oskVisible) { } else if (!gamepad.buttons[11]?.pressed) {
clearOSK(); state.lastInput.rs = false;
} 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
@@ -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;