fix: ditched sdl and moved to xinput for windows for less ram usage

This commit is contained in:
Simeon Radivoev 2026-03-30 02:02:12 +03:00
parent 90d6711935
commit dc0f2d150a
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
18 changed files with 663 additions and 100 deletions

View file

@ -22,7 +22,7 @@ import UpdateStoreJob from "./jobs/update-store";
import { getStoreFolder } from "./store/services/gamesService";
import { PluginManager } from "./plugins/plugin-manager";
import registerPlugins from "./plugins/register-plugins";
import controls from '../controls';
import controls from './controls/controls';
export const config = new Conf<SettingsType>({
projectName: projectPackage.name,

View file

@ -0,0 +1,40 @@
import { LaunchGameJob } from '../jobs/launch-game-job';
import { events, taskQueue } from '../app';
import { GamepadManager } from './manager';
export default async function Initialize ()
{
let startSelectPressed = false;
const manager = new GamepadManager();
setInterval(() =>
{
for (const pad of manager.getGamepads())
{
const state = pad.update();
if (!state) continue;
if (state.buttons.START && state.buttons.SELECT)
{
if (!startSelectPressed)
{
startSelectPressed = true;
console.log("Focus");
const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
if (launchGameTask)
{
launchGameTask.abort('exit');
taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300));
} else
{
events.emit('focus');
}
}
} else
{
startSelectPressed = false;
}
}
}, 100);
}

View file

@ -0,0 +1,32 @@
// ./gamepad/index.ts
import { platform } from "os";
import { GamepadWindows } from "./windows";
import { GamepadLinux } from "./linux";
import type { IGamepadBackend, GamepadState } from "./types";
export class Gamepad
{
private backend: IGamepadBackend;
constructor(index = 0)
{
if (platform() === "win32")
{
this.backend = new GamepadWindows(index);
} else
{
this.backend = new GamepadLinux(index);
}
}
update (): GamepadState | null
{
return this.backend.update();
}
close ()
{
this.backend.close?.();
}
}

View file

@ -0,0 +1,87 @@
import { IGamepadBackend, GamepadState, ButtonName } from "./types";
import { openSync, readSync, closeSync, readdirSync } from "fs";
export class GamepadLinux implements IGamepadBackend
{
private fd: number;
private buttons: boolean[];
private axes: number[];
private buttonsCount = 16;
private axesCount = 4;
constructor(index = 0)
{
const devices = readdirSync("/dev/input").filter(f => f.startsWith("js"));
if (!devices[index]) throw new Error("No gamepad found");
const path = `/dev/input/${devices[index]}`;
this.fd = openSync(path, "r");
this.buttons = Array(this.buttonsCount).fill(false);
this.axes = Array(this.axesCount).fill(0);
}
update (): GamepadState | null
{
const buf = Buffer.alloc(8);
let bytesRead;
try
{
bytesRead = readSync(this.fd, buf, 0, 8, null);
} catch
{
return null;
}
if (bytesRead !== 8) return null;
const [time, value, type, number] = [
buf.readUInt32LE(0),
buf.readInt16LE(4),
buf[6],
buf[7],
];
if (type === 1) this.buttons[number] = value !== 0;
else if (type === 2 && number < 4) this.axes[number] = value / 32767;
const btnMap: Record<ButtonName, boolean> = {
A: this.buttons[0] ?? false,
B: this.buttons[1] ?? false,
X: this.buttons[2] ?? false,
Y: this.buttons[3] ?? false,
UP: this.buttons[4] ?? false,
DOWN: this.buttons[5] ?? false,
LEFT: this.buttons[6] ?? false,
RIGHT: this.buttons[7] ?? false,
LB: this.buttons[8] ?? false,
RB: this.buttons[9] ?? false,
START: this.buttons[10] ?? false,
SELECT: this.buttons[11] ?? false,
L3: this.buttons[12] ?? false,
R3: this.buttons[13] ?? false,
};
return {
buttons: btnMap,
leftStick: { x: this.axes[0] ?? 0, y: this.axes[1] ?? 0 },
rightStick: { x: this.axes[2] ?? 0, y: this.axes[3] ?? 0 },
triggers: { left: 0, right: 0 },
};
}
isConnected ()
{
try
{
readSync(this.fd, Buffer.alloc(1), 0, 1, null);
return true;
} catch
{
return false; // file disappeared or read failed
}
}
close ()
{
closeSync(this.fd);
}
}

View file

@ -0,0 +1,55 @@
import { Gamepad } from "./gamepad";
import { platform } from "os";
export class GamepadManager
{
private gamepads: Gamepad[] = [];
private scanInterval: any;
constructor()
{
this.scanGamepads();
// scan every second for new/disconnected devices
this.scanInterval = setInterval(() => this.scanGamepads(), 1000);
}
private scanGamepads ()
{
const max = platform() === "win32" ? 4 : 8; // max controllers
for (let i = 0; i < max; i++)
{
if (!this.gamepads[i])
{
try
{
const pad = new Gamepad(i);
if (pad.update())
{
this.gamepads[i] = pad;
console.log(`Gamepad ${i} connected`);
}
} catch { }
} else
{
const connected = this.gamepads[i].update() !== null;
if (!connected)
{
console.log(`Gamepad ${i} disconnected`);
this.gamepads[i].close();
delete this.gamepads[i];
}
}
}
}
getGamepads ()
{
return this.gamepads.filter(Boolean);
}
stop ()
{
clearInterval(this.scanInterval);
for (const pad of this.gamepads) pad.close?.();
}
}

View file

@ -0,0 +1,38 @@
export type ButtonName =
| "A" | "B" | "X" | "Y"
| "UP" | "DOWN" | "LEFT" | "RIGHT"
| "LB" | "RB"
| "START" | "SELECT"
| "L3" | "R3";
export interface Stick
{
x: number; // -1 → 1
y: number; // -1 → 1
}
export interface Triggers
{
left: number; // 0 → 1
right: number; // 0 → 1
}
export interface GamepadState
{
buttons: Record<ButtonName, boolean>;
leftStick: Stick;
rightStick: Stick;
triggers: Triggers;
}
export interface IGamepadBackend
{
/** Polls the current state; returns null if disconnected */
update (): GamepadState | null;
/** Optional: release resources (like closing fd on Linux) */
close?(): void;
/** Optional: check if the gamepad is still connected */
isConnected?(): boolean;
}

View file

@ -0,0 +1,57 @@
import { IGamepadBackend, GamepadState, ButtonName } from "./types";
import { dlopen, FFIType } from "bun:ffi";
const xinput = dlopen("xinput1_4.dll", {
XInputGetState: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.u32 },
});
const ERROR_SUCCESS = 0;
export class GamepadWindows implements IGamepadBackend
{
private index: number;
private buffer = new ArrayBuffer(16);
private view = new DataView(this.buffer);
private prevButtons = 0;
private currButtons = 0;
constructor(index = 0) { this.index = index; }
update (): GamepadState | null
{
const res = xinput.symbols.XInputGetState(this.index, this.buffer);
if (res !== ERROR_SUCCESS) return null;
this.prevButtons = this.currButtons;
this.currButtons = this.view.getUint16(4, true);
const btns: Record<ButtonName, boolean> = {
A: (this.currButtons & 0x1000) !== 0,
B: (this.currButtons & 0x2000) !== 0,
X: (this.currButtons & 0x4000) !== 0,
Y: (this.currButtons & 0x8000) !== 0,
UP: (this.currButtons & 0x0001) !== 0,
DOWN: (this.currButtons & 0x0002) !== 0,
LEFT: (this.currButtons & 0x0004) !== 0,
RIGHT: (this.currButtons & 0x0008) !== 0,
LB: (this.currButtons & 0x0100) !== 0,
RB: (this.currButtons & 0x0200) !== 0,
START: (this.currButtons & 0x0010) !== 0,
SELECT: (this.currButtons & 0x0020) !== 0,
L3: (this.currButtons & 0x0040) !== 0,
R3: (this.currButtons & 0x0080) !== 0,
};
return {
buttons: btns,
leftStick: { x: this.view.getInt16(6, true) / 32767, y: this.view.getInt16(8, true) / 32767 },
rightStick: { x: this.view.getInt16(10, true) / 32767, y: this.view.getInt16(12, true) / 32767 },
triggers: { left: this.view.getUint8(14) / 255, right: this.view.getUint8(15) / 255 },
};
}
isConnected ()
{
const res = xinput.symbols.XInputGetState(this.index, this.buffer);
return res === ERROR_SUCCESS;
}
}

View file

@ -2,7 +2,7 @@ import { killBrowser, spawnBrowser } from './utils/browser-spawner';
import { BrowserParams, BuildParams } from './utils/browser-params';
import os from 'node:os';
import { EventEmitter } from 'node:stream';
import { dlopen, FFIType } from "bun:ffi";
import { dlopen, FFIType, Pointer } from "bun:ffi";
export default async function init (events: EventEmitter, forceBrowser: boolean, params: BrowserParams)
{
@ -21,6 +21,29 @@ export default async function init (events: EventEmitter, forceBrowser: boolean,
}
}
function focusWindow (id: Pointer)
{
if (process.platform === 'win32')
{
const user32 = dlopen("user32.dll", {
SetForegroundWindow: { args: [FFIType.ptr], returns: FFIType.bool },
ShowWindow: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.bool },
BringWindowToTop: { args: [FFIType.ptr], returns: FFIType.bool },
keybd_event: { args: [FFIType.u8, FFIType.u8, FFIType.u32, FFIType.ptr], returns: FFIType.void },
});
const SW_RESTORE = 9;
if (id)
{
user32.symbols.ShowWindow(id, SW_RESTORE);
user32.symbols.keybd_event(0, 0, 0, null); // fake input event
user32.symbols.BringWindowToTop(id);
user32.symbols.SetForegroundWindow(id);
}
}
}
async function runWebview (events: EventEmitter, params: BrowserParams)
{
const webviewPath = process.env.IS_BINARY ? `./webview/${os.platform()}` : new URL(`./webview/${os.platform()}`, import.meta.url).href;
@ -73,25 +96,7 @@ async function runWebview (events: EventEmitter, params: BrowserParams)
events.on('exitapp', handleExit);
events.on('focus', () =>
{
if (process.platform === 'win32')
{
const user32 = dlopen("user32.dll", {
SetForegroundWindow: { args: [FFIType.ptr], returns: FFIType.bool },
ShowWindow: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.bool },
BringWindowToTop: { args: [FFIType.ptr], returns: FFIType.bool },
keybd_event: { args: [FFIType.u8, FFIType.u8, FFIType.u32, FFIType.ptr], returns: FFIType.void },
});
const SW_RESTORE = 9;
if (pointer)
{
user32.symbols.ShowWindow(pointer, SW_RESTORE);
user32.symbols.keybd_event(0, 0, 0, null); // fake input event
user32.symbols.BringWindowToTop(pointer);
user32.symbols.SetForegroundWindow(pointer);
}
}
focusWindow(pointer);
});
});
}

View file

@ -1,53 +0,0 @@
import { LaunchGameJob } from './api/jobs/launch-game-job';
import { events, taskQueue } from './api/app';
process.env.SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS = "1";
process.env.SDL_JOYSTICK_THREAD = "1";
export default async function Initialize ()
{
const { default: sdl } = await import('@kmamal/sdl');
const launcherWin = sdl.video.createWindow({ title: "Launcher", visible: false });
sdl.controller.devices.forEach(d => connectToController(d));
sdl.controller.on('deviceAdd', e =>
{
connectToController(e.device);
});
function connectToController (device: any)
{
let selectHeld = false;
const ctrl = sdl.controller.openDevice(device);
console.log("Connected to", device.name);
ctrl.on("buttonDown", ({ button }) =>
{
if (button === "back") selectHeld = true;
if (button === "start" && selectHeld)
{
const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
if (launchGameTask)
{
launchGameTask.abort('exit');
taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300));
} else
{
events.emit('focus');
}
}
if (button === 'guide')
{
events.emit('focus');
}
});
ctrl.on("buttonUp", ({ button }) =>
{
if (button === "back") selectHeld = false;
});
}
}

View file

@ -50,7 +50,7 @@ if (process.env.HEADLESS)
});
} else
{
await init(app.events, Bun.env.FORCE_BROWSER === "true", {
await init(app.events, process.env.FORCE_BROWSER === "true", {
configPath: dirname(app.config.path),
windowPosition: app.config.get('windowPosition'),
windowSize: app.config.get('windowSize'),

View file

@ -3,7 +3,7 @@ import { ChildProcessWithoutNullStreams } from "node:child_process";
import os from 'node:os';
export type RunBrowserType = "chrome" | "chromium" | "firefox" | "edge";
export type RunBrowserSource = "running" | "system" | "flatpak";
export type RunBrowserSource = "running" | "system" | "flatpak" | "bundled";
/**
* Options for spawning a browser process.

View file

@ -1,9 +1,10 @@
import { spawnSync } from "bun";
import { platform } from "node:os";
import { RunBrowserType } from "./browser-spawner";
import path from 'node:path';
export type GetBrowserType = "chrome" | "chromium" | "firefox";
export type GetBrowserSource = "running" | "system" | "flatpak";
export type GetBrowserSource = "running" | "system" | "flatpak" | "bundled";
/**
* Browser discovery priority configuration
@ -12,6 +13,7 @@ interface BrowserPriorityConfig
{
/** Include currently running browser processes in search */
includeRunning?: boolean;
includeBundled?: boolean;
/** Browser types to search for, in priority order */
browserOrder?: GetBrowserType[];
/** Include system default browser on Windows */
@ -33,6 +35,27 @@ interface BrowserResult
source: GetBrowserSource;
}
const PLATFORM_MAP: Record<string, string> = {
linux: "linux",
win32: "windows",
darwin: 'macos'
};
const ARCH_MAP: Record<string, Record<string, string>> = {
linux: { x64: "x86_64", arm64: "arm64" },
darwin: { x64: "x86_64", arm64: "arm64" },
win32: { x64: "x64", arm64: "arm64" },
};
/** The expected binary path per platform after extraction */
function getBundledBinaryPath (outDir: string, version: string, platform: string, arch: string): string
{
const subFolder = `ungoogled-chromium_${version}_${PLATFORM_MAP[platform]}_${ARCH_MAP[platform][arch]}`;
if (platform === "linux") return path.join(outDir, subFolder, "chrome");
if (platform === "darwin") return path.join(outDir, "Chromium.app");
return path.join(outDir, subFolder, "chrome.exe");
}
/**
* Main function to find a valid browser executable.
*
@ -63,6 +86,7 @@ export async function getBrowserPath (config?: BrowserPriorityConfig): Promise<B
// Default configuration
const {
includeRunning = true,
includeBundled = true,
browserOrder = ["firefox", "chrome", "chromium"],
includeSystemDefault = true,
includeFlatpak = true
@ -70,6 +94,17 @@ export async function getBrowserPath (config?: BrowserPriorityConfig): Promise<B
const currentPlatform = platform();
// Check bundled
if (includeBundled)
{
const getVerstion = await Bun.file('./bin/chromium/.chromium-version').text();
const binPath = getBundledBinaryPath("./bin/chromium", getVerstion, process.platform, process.arch);
if (await Bun.file(binPath).exists())
{
return { path: binPath, type: "chromium", source: "bundled" };
}
}
// 1. Check for currently running browser process
if (includeRunning)
{