diff --git a/.gitignore b/.gitignore index 6bb0978..a89abd1 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,5 @@ downloads gameflow-deck.code-workspace .env.local src/tests/mock-roms/db.sqlite -src/tests/mock-config \ No newline at end of file +src/tests/mock-config +bin \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index bea05da..e65bd08 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -38,11 +38,6 @@ "label": "Start Dev (Hot Reload)", "type": "shell", "command": "bun run dev:hmr", - "options": { - "env": { - "FORCE_BROWSER": "false" - } - }, "isBackground": true, "problemMatcher": [], "presentation": { diff --git a/bun.lock b/bun.lock index 9741115..a73c2b2 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,6 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@jimp/wasm-webp": "^1.6.0", - "@kmamal/sdl": "^0.11.13", "cheerio": "^1.2.0", "conf": "^15.0.2", "drizzle-orm": "^0.45.1", @@ -327,8 +326,6 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], - "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], @@ -403,8 +400,6 @@ "@jsquash/webp": ["@jsquash/webp@1.5.0", "", { "dependencies": { "wasm-feature-detect": "^1.2.11" } }, "sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw=="], - "@kmamal/sdl": ["@kmamal/sdl@0.11.13", "", { "dependencies": { "tar": "^7.4.3" } }, "sha512-9WmxYNtCggi7Ovq1cU7m/s5WXD/+eKQxDMnL3bU8B5vr5GlaLg4xLykDCpcbWKkJJ2i6llTrdL7LiqikwDFz4w=="], - "@node-minify/clean-css": ["@node-minify/clean-css@9.0.1", "", { "dependencies": { "@node-minify/utils": "9.0.1", "clean-css": "5.3.3" } }, "sha512-GHTMmjGloRvNzqdG7foI0iZeS2QmuYCQvdASJP9sCKjkpH45bygODpXPYKnlzUEpQgYvPK9Q3GxqYnVY9SdoqA=="], "@node-minify/core": ["@node-minify/core@9.0.2", "", { "dependencies": { "@node-minify/utils": "9.0.1", "glob": "10.3.3", "mkdirp": "3.0.1" } }, "sha512-FNhv29Wom6wKrrFKaeAfmZqz7TX5A1E6P+bpd0VIc+DYWMLUIhAViS8riaZg3A1oD0s06s+5BG2Fg7RqMKiKHw=="], @@ -739,8 +734,6 @@ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], - "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], - "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], @@ -1219,8 +1212,6 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], "modify-values": ["modify-values@1.0.1", "", {}, "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw=="], @@ -1559,8 +1550,6 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], - "tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="], - "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="], "text-extensions": ["text-extensions@1.9.0", "", {}, "sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ=="], @@ -1691,7 +1680,7 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -1821,8 +1810,6 @@ "load-json-file/pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], - "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "meow/read-pkg-up": ["read-pkg-up@7.0.1", "", { "dependencies": { "find-up": "^4.1.0", "read-pkg": "^5.2.0", "type-fest": "^0.8.1" } }, "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg=="], "meow/type-fest": ["type-fest@0.18.1", "", {}, "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw=="], diff --git a/package.json b/package.json index 1b39dbb..d43210f 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts", "version:generate": "standard-version --sign", "package:Linux": "bun run build:prod:appimage", - "package:Windows": "bun run build:prod" + "package:Windows": "bun run build:prod", + "download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium" }, "dependencies": { "7zip-bin": "^5.2.0", @@ -47,7 +48,6 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@jimp/wasm-webp": "^1.6.0", - "@kmamal/sdl": "^0.11.13", "cheerio": "^1.2.0", "conf": "^15.0.2", "drizzle-orm": "^0.45.1", diff --git a/scripts/download-chromium.ts b/scripts/download-chromium.ts new file mode 100644 index 0000000..1e4a1c3 --- /dev/null +++ b/scripts/download-chromium.ts @@ -0,0 +1,283 @@ +#!/usr/bin/env bun +/** + * download-chromium.ts + * Downloads the latest ungoogled-chromium for the current platform + arch. + * Skips the download if the binary is already present and up to date. + * + * Usage: bun download-chromium.ts [--out=./chromium] [--force] + * In package.json scripts: "prebuild": "bun scripts/download-chromium.ts" + */ + +import { mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import StreamZip from "node-stream-zip"; + +// --- Config ------------------------------------------------------------------ + +const GITHUB_API = "https://api.github.com"; +const VERSION_FILE = ".chromium-version"; + +const REPOS: Record = { + linux: "ungoogled-software/ungoogled-chromium-portablelinux", + darwin: "ungoogled-software/ungoogled-chromium-macos", + win32: "ungoogled-software/ungoogled-chromium-windows", +}; + +const PLATFORM_MAP: Record = { + linux: "linux", + win32: "windows", + darwin: 'macos' +}; + +const ARCH_MAP: Record> = { + linux: { x64: "x86_64", arm64: "arm64" }, + darwin: { x64: "x86_64", arm64: "arm64" }, + win32: { x64: "x64", arm64: "arm64" }, +}; + +const PREFERRED_EXT: Record = { + linux: [".tar.xz"], + darwin: [".dmg", ".zip"], + win32: [".zip"], +}; + +/** The expected binary path per platform after extraction */ +function getBinaryPath (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"); +} + +// --- Helpers ----------------------------------------------------------------- + +function log (msg: string) +{ + process.stdout.write(`\x1b[36m[chromium]\x1b[0m ${msg}\n`); +} + +function err (msg: string): never +{ + process.stderr.write(`\x1b[31m[error]\x1b[0m ${msg}\n`); + process.exit(1); +} + +async function ghFetch (url: string) +{ + const headers: Record = { "User-Agent": "bun-chromium-downloader" }; + const token = process.env.GITHUB_TOKEN; + if (token) headers["Authorization"] = `Bearer ${token}`; + const res = await fetch(url, { headers }); + if (!res.ok) err(`GitHub API error ${res.status}: ${url}`); + return res.json(); +} + +async function readVersionCache (outDir: string): Promise +{ + const file = path.join(outDir, VERSION_FILE); + if (!existsSync(file)) return null; + return (await Bun.file(file).text()).trim(); +} + +async function writeVersionCache (outDir: string, version: string) +{ + await Bun.write(path.join(outDir, VERSION_FILE), version); +} + +async function downloadWithProgress (url: string, dest: string) +{ + log(`Downloading -> ${dest}`); + const res = await fetch(url); + if (!res.ok) err(`Download failed: ${res.status} ${url}`); + + const total = Number(res.headers.get("content-length") ?? 0); + let received = 0; + const writer = Bun.file(dest).writer(); + const reader = res.body!.getReader(); + + while (true) + { + const { done, value } = await reader.read(); + if (done) break; + writer.write(value); + received += value.length; + if (total > 0) + { + const pct = ((received / total) * 100).toFixed(1); + const mb = (received / 1e6).toFixed(1); + const totalMb = (total / 1e6).toFixed(1); + process.stdout.write(`\r ${pct}% ${mb} / ${totalMb} MB `); + } + } + await writer.end(); + process.stdout.write("\n"); + log("Download complete."); +} + +async function extractZip (src: string, outDir: string) +{ + log(`Extracting zip -> ${outDir}`); + const zip = new StreamZip.async({ file: src }); + const entries = await zip.entries(); + const total = Object.keys(entries).length; + await zip.extract(null, outDir); + await zip.close(); + log(`Extracted ${total} files.`); +} + +function extractNative (src: string, outDir: string) +{ + if (src.endsWith(".AppImage")) + { + const dest = path.join(outDir, "chromium.AppImage"); + spawnSync("cp", [src, dest]); + spawnSync("chmod", ["+x", dest]); + log(`AppImage ready at: ${dest}`); + return; + } + + if (src.endsWith(".tar.xz")) + { + const result = spawnSync("tar", ["-xJf", src, "-C", outDir], { stdio: "inherit" }); + if (result.status !== 0) err("tar extraction failed"); + return; + } + + if (src.endsWith(".dmg")) + { + log("Mounting DMG..."); + const mount = spawnSync("hdiutil", ["attach", src, "-nobrowse", "-quiet"], { + encoding: "utf8", + }); + if (mount.status !== 0) err("hdiutil mount failed"); + const mountLine = mount.stdout.split("\n").find((l) => l.includes("/Volumes/")); + const mountPoint = mountLine?.split("\t").at(-1)?.trim(); + if (!mountPoint) err("Could not find DMG mount point"); + spawnSync("cp", ["-R", mountPoint!, outDir], { stdio: "inherit" }); + spawnSync("hdiutil", ["detach", mountPoint!, "-quiet"]); + log(`DMG contents copied to: ${outDir}`); + return; + } + + err(`Unknown archive format: ${src}`); +} + +// --- Main -------------------------------------------------------------------- + +async function main () +{ + const platform = process.platform; + const arch = process.arch; + const force = process.argv.includes("--force"); + const outArg = process.argv.find(a => a.startsWith("--out="))?.slice(6) + ?? "./chromium"; + const outDir = path.resolve(outArg); + + log(`Platform: ${platform} Arch: ${arch}`); + + const repo = REPOS[platform]; + if (!repo) err(`Unsupported platform: ${platform}`); + + const archStr = ARCH_MAP[platform]?.[arch]; + if (!archStr) err(`Unsupported arch "${arch}" on ${platform}`); + + // Fetch latest version (lightweight — just the tag, no asset download yet) + log(`Checking latest release from ${repo}...`); + const release = await ghFetch(`${GITHUB_API}/repos/${repo}/releases/latest`); + const version: string = release.tag_name ?? release.name ?? "unknown"; + log(`Latest version: ${version}`); + + // Check if already downloaded and up to date + if (!force) + { + const cachedVersion = await readVersionCache(outDir); + const assets: Array<{ name: string; }> = release.assets ?? []; + const preferred = PREFERRED_EXT[platform] ?? []; + let assetName: string | undefined; + for (const ext of preferred) + { + assetName = assets.find(a => a.name.includes(archStr) && a.name.endsWith(ext))?.name; + if (assetName) break; + } + if (!assetName) assetName = assets.find(a => a.name.includes(archStr))?.name; + + if (cachedVersion === version) + { + const binaryPath = getBinaryPath(outDir, cachedVersion, platform, arch); + if (existsSync(binaryPath)) + { + log(`Already up to date (${version}). Skipping download.`); + log(`Binary: ${binaryPath}`); + return; + } else + { + log(`Version matches but binary missing — re-downloading.`); + } + } else if (cachedVersion) + { + log(`New version available: ${cachedVersion} -> ${version}`); + } + } else + { + log("--force flag set, re-downloading."); + } + + // Pick asset to download + const assets: Array<{ name: string; browser_download_url: string; }> = release.assets ?? []; + if (assets.length === 0) err("No assets found in the latest release."); + + const preferred = PREFERRED_EXT[platform] ?? []; + let chosen: (typeof assets)[0] | undefined; + + for (const ext of preferred) + { + chosen = assets.find(a => a.name.includes(archStr) && a.name.endsWith(ext)); + if (chosen) break; + } + if (!chosen) chosen = assets.find(a => a.name.includes(archStr)); + + if (!chosen) + { + log("Available assets:"); + for (const a of assets) log(` ${a.name}`); + err(`No asset found matching arch "${archStr}" on ${platform}.`); + } + + log(`Selected asset: ${chosen.name}`); + + if (!existsSync(outDir)) await mkdir(outDir, { recursive: true }); + + const tmpFile = path.join(outDir, chosen.name); + await downloadWithProgress(chosen.browser_download_url, tmpFile); + + const { unlink } = await import("node:fs/promises"); + + if (chosen.name.endsWith(".zip")) + { + await extractZip(tmpFile, outDir); + await unlink(tmpFile); + } else + { + extractNative(tmpFile, outDir); + if (!chosen.name.endsWith(".AppImage")) + { + await unlink(tmpFile); + } + } + + // Save version so next run can skip + await writeVersionCache(outDir, version); + + log(`\nDone! Chromium ${version} extracted to: ${outDir}`); + + const binaryPath = getBinaryPath(outDir, version, platform, arch); + log(`Binary: ${binaryPath}`); +} + +main().catch((e) => err(String(e))); \ No newline at end of file diff --git a/scripts/package-bun.ts b/scripts/package-bun.ts index c70df95..f681943 100644 --- a/scripts/package-bun.ts +++ b/scripts/package-bun.ts @@ -95,8 +95,9 @@ await Bun.build({ await fs.cp('./drizzle', `${buildSubDir}/drizzle`, { recursive: true }); await fs.cp(`./vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, `${buildSubDir}/vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, { recursive: true }); await fs.cp(path.join(`node_modules/webview-bun/build/`, webviewLib), path.join(buildSubDir, webviewLib)); - await fs.cp(`node_modules/@kmamal/sdl/dist`, buildSubDir, { recursive: true, errorOnExist: false }); await fs.cp(`node_modules/7zip-bin/${zipNodePath}/${process.arch}`, buildSubDir, { recursive: true, errorOnExist: false }); + if (await fs.exists('bin/chromium')) + await fs.cp('bin/chromium', `${buildSubDir}/bin/chromium`, { recursive: true, errorOnExist: false }); }); }, }] diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index 597a475..cd96182 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -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({ projectName: projectPackage.name, diff --git a/src/bun/api/controls/controls.ts b/src/bun/api/controls/controls.ts new file mode 100644 index 0000000..63acda0 --- /dev/null +++ b/src/bun/api/controls/controls.ts @@ -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); +} \ No newline at end of file diff --git a/src/bun/api/controls/gamepad.ts b/src/bun/api/controls/gamepad.ts new file mode 100644 index 0000000..e58d615 --- /dev/null +++ b/src/bun/api/controls/gamepad.ts @@ -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?.(); + } +} \ No newline at end of file diff --git a/src/bun/api/controls/linux.ts b/src/bun/api/controls/linux.ts new file mode 100644 index 0000000..fead3a0 --- /dev/null +++ b/src/bun/api/controls/linux.ts @@ -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 = { + 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); + } +} \ No newline at end of file diff --git a/src/bun/api/controls/manager.ts b/src/bun/api/controls/manager.ts new file mode 100644 index 0000000..4d2077a --- /dev/null +++ b/src/bun/api/controls/manager.ts @@ -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?.(); + } +} \ No newline at end of file diff --git a/src/bun/api/controls/types.ts b/src/bun/api/controls/types.ts new file mode 100644 index 0000000..03298fd --- /dev/null +++ b/src/bun/api/controls/types.ts @@ -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; + 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; +} \ No newline at end of file diff --git a/src/bun/api/controls/windows.ts b/src/bun/api/controls/windows.ts new file mode 100644 index 0000000..4583b17 --- /dev/null +++ b/src/bun/api/controls/windows.ts @@ -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 = { + 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; + } +} \ No newline at end of file diff --git a/src/bun/browser.ts b/src/bun/browser.ts index 1feb8fd..79c01b8 100644 --- a/src/bun/browser.ts +++ b/src/bun/browser.ts @@ -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); }); }); } diff --git a/src/bun/controls.ts b/src/bun/controls.ts deleted file mode 100644 index f88707a..0000000 --- a/src/bun/controls.ts +++ /dev/null @@ -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; - }); - } -} \ No newline at end of file diff --git a/src/bun/index.ts b/src/bun/index.ts index 5601e11..ded9f6c 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -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'), diff --git a/src/bun/utils/browser-spawner.ts b/src/bun/utils/browser-spawner.ts index 670c41d..94d2801 100644 --- a/src/bun/utils/browser-spawner.ts +++ b/src/bun/utils/browser-spawner.ts @@ -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. diff --git a/src/bun/utils/get-browser.ts b/src/bun/utils/get-browser.ts index 73370a2..f3a473d 100644 --- a/src/bun/utils/get-browser.ts +++ b/src/bun/utils/get-browser.ts @@ -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 = { + linux: "linux", + win32: "windows", + darwin: 'macos' +}; + +const ARCH_MAP: Record> = { + 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