mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-06-02 06:14:48 +02:00
fix: offload process listing from main thread
Move native process enumeration into a worker so process watching does not block the main process, and add LF text-integrity checks for normalized files.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Text Integrity
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
text-integrity:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify LF line endings
|
||||
run: |
|
||||
files_with_cr="$(git grep -Il $'\r' -- . || true)"
|
||||
|
||||
if [ -n "$files_with_cr" ]; then
|
||||
echo "Tracked text files must use LF line endings. Files containing CR characters:"
|
||||
echo "$files_with_cr"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify LICENSE hash
|
||||
run: |
|
||||
expected="32619612c2e0223e86c4908747ec14bef64c3c423fee80910c1aa944769b66f9"
|
||||
actual="$(sha256sum LICENSE | cut -d ' ' -f1)"
|
||||
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
echo "LICENSE hash changed."
|
||||
echo "Expected: $expected"
|
||||
echo "Actual: $actual"
|
||||
exit 1
|
||||
fi
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "3.9.6",
|
||||
"version": "3.9.7",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
|
||||
@@ -20,7 +20,7 @@ const closeGame = async (
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => {
|
||||
const processes = NativeAddon.listProcesses();
|
||||
const processes = await NativeAddon.listProcesses();
|
||||
|
||||
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ const cleanupStaleCompatibilityProcesses = async (
|
||||
const defaultPrefixPath = Wine.getDefaultPrefixPathForGame(objectId);
|
||||
if (defaultPrefixPath !== winePrefixPath) return;
|
||||
|
||||
const processes = NativeAddon.listProcesses();
|
||||
const processes = await NativeAddon.listProcesses();
|
||||
|
||||
const stalePids = processes
|
||||
.filter((runningProcess) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { Worker } from "node:worker_threads";
|
||||
|
||||
import { app } from "electron";
|
||||
import type { ProcessPayload } from "./download/types";
|
||||
@@ -22,8 +23,89 @@ type HydraNativeModule = {
|
||||
listProcesses: () => ProcessPayload[];
|
||||
};
|
||||
|
||||
export type SystemProcessMap = {
|
||||
processMap: Record<string, string[]>;
|
||||
winePrefixMap: Record<string, string>;
|
||||
linuxProcesses: Array<{
|
||||
name: string;
|
||||
cwd: string;
|
||||
exe: string;
|
||||
steamCompatDataPath: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Runs in the worker thread (CJS context).
|
||||
// "list" → posts back the raw ProcessPayload array (used by close-game, launch-game)
|
||||
// "map" → posts back compact pre-built maps (used by the main loop's watchProcesses)
|
||||
const WORKER_CODE = `
|
||||
const { workerData, parentPort } = require('worker_threads');
|
||||
const path = require('path');
|
||||
if (process.platform === 'linux' && workerData.addonDir) {
|
||||
process.env.LD_LIBRARY_PATH = process.env.LD_LIBRARY_PATH
|
||||
? workerData.addonDir + ':' + process.env.LD_LIBRARY_PATH
|
||||
: workerData.addonDir;
|
||||
}
|
||||
const addon = require(workerData.addonPath);
|
||||
const platform = process.platform;
|
||||
|
||||
function buildMaps(processes) {
|
||||
const processMap = Object.create(null);
|
||||
const winePrefixMap = Object.create(null);
|
||||
const linuxProcesses = [];
|
||||
|
||||
for (const proc of processes) {
|
||||
const key = proc.name && proc.name.toLowerCase();
|
||||
const value = platform === 'win32'
|
||||
? proc.exe
|
||||
: path.join(proc.cwd || '', proc.name || '');
|
||||
|
||||
if (!key || !value) continue;
|
||||
|
||||
const steamCompatDataPath = proc.environ && proc.environ.STEAM_COMPAT_DATA_PATH;
|
||||
if (steamCompatDataPath) winePrefixMap[value] = steamCompatDataPath;
|
||||
|
||||
if (platform === 'linux') {
|
||||
linuxProcesses.push({
|
||||
name: key,
|
||||
cwd: (proc.cwd || '').toLowerCase(),
|
||||
exe: (proc.exe || '').toLowerCase(),
|
||||
steamCompatDataPath: steamCompatDataPath ? steamCompatDataPath.toLowerCase() : null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!processMap[key]) processMap[key] = [];
|
||||
processMap[key].push(value);
|
||||
}
|
||||
|
||||
return { processMap, winePrefixMap, linuxProcesses };
|
||||
}
|
||||
|
||||
parentPort.on('message', (type) => {
|
||||
try {
|
||||
const processes = addon.listProcesses();
|
||||
if (type === 'map') {
|
||||
parentPort.postMessage({ type: 'map', result: buildMaps(processes) });
|
||||
} else {
|
||||
parentPort.postMessage({ type: 'list', result: processes });
|
||||
}
|
||||
} catch (_) {
|
||||
if (type === 'map') {
|
||||
parentPort.postMessage({ type: 'map', result: { processMap: {}, winePrefixMap: {}, linuxProcesses: [] } });
|
||||
} else {
|
||||
parentPort.postMessage({ type: 'list', result: [] });
|
||||
}
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
type PendingResolver =
|
||||
| { type: "list"; resolve: (p: ProcessPayload[]) => void }
|
||||
| { type: "map"; resolve: (m: SystemProcessMap) => void };
|
||||
|
||||
export class NativeAddon {
|
||||
private static nativeModule: HydraNativeModule | null = null;
|
||||
private static worker: Worker | null = null;
|
||||
private static pendingResolvers: PendingResolver[] = [];
|
||||
|
||||
private static resolveAddonPath() {
|
||||
if (app.isPackaged) {
|
||||
@@ -61,6 +143,55 @@ export class NativeAddon {
|
||||
return nativeModule;
|
||||
}
|
||||
|
||||
private static getWorker(): Worker {
|
||||
if (this.worker) return this.worker;
|
||||
|
||||
const addonPath = this.resolveAddonPath();
|
||||
const addonDir = path.dirname(addonPath);
|
||||
|
||||
if (!fs.existsSync(addonPath)) {
|
||||
throw new Error(`Hydra native addon not found at ${addonPath}`);
|
||||
}
|
||||
|
||||
this.worker = new Worker(WORKER_CODE, {
|
||||
eval: true,
|
||||
workerData: { addonPath, addonDir },
|
||||
});
|
||||
|
||||
this.worker.on("message", ({ result }) => {
|
||||
const pending = this.pendingResolvers.shift();
|
||||
if (!pending) return;
|
||||
if (pending.type === "list") {
|
||||
(pending.resolve as (p: ProcessPayload[]) => void)(
|
||||
(result as ProcessPayload[]).filter(
|
||||
(p): p is ProcessPayload =>
|
||||
typeof p?.pid === "number" &&
|
||||
typeof p?.name === "string" &&
|
||||
p.name.length > 0
|
||||
)
|
||||
);
|
||||
} else {
|
||||
(pending.resolve as (m: SystemProcessMap) => void)(
|
||||
result as SystemProcessMap
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.on("error", (error) => {
|
||||
logger.error("Process list worker error", error);
|
||||
this.drainResolvers();
|
||||
});
|
||||
|
||||
this.worker.on("exit", (code) => {
|
||||
if (code !== 0)
|
||||
logger.error(`Process list worker exited with code ${code}`);
|
||||
this.worker = null;
|
||||
this.drainResolvers();
|
||||
});
|
||||
|
||||
return this.worker;
|
||||
}
|
||||
|
||||
public static processProfileImage(
|
||||
imagePath: string,
|
||||
targetExtension = "webp"
|
||||
@@ -88,24 +219,36 @@ export class NativeAddon {
|
||||
}
|
||||
}
|
||||
|
||||
public static listProcesses(): ProcessPayload[] {
|
||||
try {
|
||||
const response = this.load().listProcesses();
|
||||
|
||||
if (!Array.isArray(response)) {
|
||||
throw new Error("Hydra native addon returned an invalid process list");
|
||||
}
|
||||
|
||||
return response.filter((process): process is ProcessPayload => {
|
||||
return (
|
||||
typeof process?.pid === "number" &&
|
||||
typeof process?.name === "string" &&
|
||||
process.name.length > 0
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to list processes via native addon", error);
|
||||
return [];
|
||||
private static drainResolvers() {
|
||||
const drained = this.pendingResolvers.splice(0);
|
||||
for (const pending of drained) {
|
||||
if (pending.type === "list") pending.resolve([]);
|
||||
else
|
||||
pending.resolve({ processMap: {}, winePrefixMap: {}, linuxProcesses: [] });
|
||||
}
|
||||
}
|
||||
|
||||
public static listProcesses(): Promise<ProcessPayload[]> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const worker = this.getWorker();
|
||||
this.pendingResolvers.push({ type: "list", resolve });
|
||||
worker.postMessage("list");
|
||||
} catch {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static getSystemProcessMap(): Promise<SystemProcessMap> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const worker = this.getWorker();
|
||||
this.pendingResolvers.push({ type: "map", resolve });
|
||||
worker.postMessage("map");
|
||||
} catch {
|
||||
resolve({ processMap: {}, winePrefixMap: {}, linuxProcesses: [] });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,38 +143,14 @@ const findGamePathByProcess = async (
|
||||
};
|
||||
|
||||
const getSystemProcessMap = async () => {
|
||||
const processes = NativeAddon.listProcesses();
|
||||
const { processMap: rawMap, winePrefixMap: rawWineMap, linuxProcesses } =
|
||||
await NativeAddon.getSystemProcessMap();
|
||||
|
||||
const processMap = new Map<string, Set<string>>();
|
||||
const winePrefixMap = new Map<string, string>();
|
||||
const linuxProcesses: LinuxProcessInfo[] = [];
|
||||
const processMap = new Map<string, Set<string>>(
|
||||
Object.entries(rawMap).map(([k, v]) => [k, new Set(v)])
|
||||
);
|
||||
|
||||
processes.forEach((process) => {
|
||||
const key = process.name?.toLowerCase();
|
||||
const value =
|
||||
platform === "win32"
|
||||
? process.exe
|
||||
: path.join(process.cwd ?? "", process.name ?? "");
|
||||
|
||||
if (!key || !value) return;
|
||||
|
||||
const STEAM_COMPAT_DATA_PATH = process.environ?.STEAM_COMPAT_DATA_PATH;
|
||||
if (STEAM_COMPAT_DATA_PATH) {
|
||||
winePrefixMap.set(value, STEAM_COMPAT_DATA_PATH);
|
||||
}
|
||||
|
||||
if (platform === "linux") {
|
||||
linuxProcesses.push({
|
||||
name: key,
|
||||
cwd: (process.cwd ?? "").toLowerCase(),
|
||||
exe: (process.exe ?? "").toLowerCase(),
|
||||
steamCompatDataPath: STEAM_COMPAT_DATA_PATH?.toLowerCase() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const currentSet = processMap.get(key) ?? new Set();
|
||||
processMap.set(key, currentSet.add(value));
|
||||
});
|
||||
const winePrefixMap = new Map<string, string>(Object.entries(rawWineMap));
|
||||
|
||||
return { processMap, winePrefixMap, linuxProcesses };
|
||||
};
|
||||
|
||||
@@ -93,6 +93,7 @@ export function SettingsDownloadSources() {
|
||||
return () => clearInterval(intervalId);
|
||||
}, [downloadSources]);
|
||||
|
||||
|
||||
const handleRemoveSource = async (downloadSource: DownloadSource) => {
|
||||
setIsRemovingDownloadSource(true);
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export function SettingsGeneral() {
|
||||
setCanInstallCommonRedist(canInstall);
|
||||
});
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const redistInterval = setInterval(() => {
|
||||
window.electron.canInstallCommonRedist().then((canInstall) => {
|
||||
setCanInstallCommonRedist(canInstall);
|
||||
});
|
||||
@@ -90,7 +90,7 @@ export function SettingsGeneral() {
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearInterval(redistInterval);
|
||||
if (volumeUpdateTimeoutRef.current) {
|
||||
clearTimeout(volumeUpdateTimeoutRef.current);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user