fix(linux): update umu-launcher and surface runtime setup progress
- Bundle umu-launcher 1.4.4. It downloads the Steam Linux Runtime from the versioned image folder instead of latest-public-beta, whose stale CDN checksums made every fresh runtime install fail with "Digest mismatched", and it restores umu-shim even when a runtime update fails. - Keep umu-launcher up to date on Linux: check the official GitHub release once a day, verify the zipapp against the release sha256 digest, install it under userData/umu and fall back to the bundled copy otherwise. - Follow umu-run output while preparing prefixes and launching games so the launcher window shows runtime download/extract progress, stays open while it runs, and reports the umu failure instead of silently closing.
This commit is contained in:
parent
c4eec1370b
commit
a7a6d5b933
16 changed files with 870 additions and 12 deletions
Binary file not shown.
|
|
@ -2185,7 +2185,10 @@
|
|||
"preflight_installing_detail": "{{detail}}",
|
||||
"generating_achievements": "Generating achievements file",
|
||||
"downloading_achievement_icons": "Downloading achievement icons",
|
||||
"downloading_achievement_icons_detail": "Downloading achievement icons ({{detail}})"
|
||||
"downloading_achievement_icons_detail": "Downloading achievement icons ({{detail}})",
|
||||
"preparing_compatibility_layer": "Preparing compatibility layer",
|
||||
"preparing_compatibility_layer_detail": "Preparing compatibility layer ({{detail}})",
|
||||
"compatibility_layer_failed": "The compatibility layer failed to start the game"
|
||||
},
|
||||
"notifications_page": {
|
||||
"cloud_gift_received_title": "You received Hydra Cloud",
|
||||
|
|
|
|||
|
|
@ -2142,7 +2142,10 @@
|
|||
"preflight_installing_detail": "{{detail}}",
|
||||
"generating_achievements": "Generando archivo de logros",
|
||||
"downloading_achievement_icons": "Descargando iconos de logros",
|
||||
"downloading_achievement_icons_detail": "Descargando iconos de logros ({{detail}})"
|
||||
"downloading_achievement_icons_detail": "Descargando iconos de logros ({{detail}})",
|
||||
"preparing_compatibility_layer": "Preparando la capa de compatibilidad",
|
||||
"preparing_compatibility_layer_detail": "Preparando la capa de compatibilidad ({{detail}})",
|
||||
"compatibility_layer_failed": "La capa de compatibilidad no pudo iniciar el juego"
|
||||
},
|
||||
"friends_window": {
|
||||
"title": "Amigos",
|
||||
|
|
|
|||
|
|
@ -2247,7 +2247,10 @@
|
|||
"preflight_installing_detail": "{{detail}}",
|
||||
"generating_achievements": "Gerando arquivo de conquistas",
|
||||
"downloading_achievement_icons": "Baixando ícones de conquistas",
|
||||
"downloading_achievement_icons_detail": "Baixando ícones de conquistas ({{detail}})"
|
||||
"downloading_achievement_icons_detail": "Baixando ícones de conquistas ({{detail}})",
|
||||
"preparing_compatibility_layer": "Preparando a camada de compatibilidade",
|
||||
"preparing_compatibility_layer_detail": "Preparando a camada de compatibilidade ({{detail}})",
|
||||
"compatibility_layer_failed": "A camada de compatibilidade não conseguiu iniciar o jogo"
|
||||
},
|
||||
"friends_window": {
|
||||
"title": "Amigos",
|
||||
|
|
|
|||
|
|
@ -2118,7 +2118,10 @@
|
|||
"preflight_installing_detail": "{{detail}}",
|
||||
"generating_achievements": "Создание файла достижений",
|
||||
"downloading_achievement_icons": "Загрузка значков достижений",
|
||||
"downloading_achievement_icons_detail": "Загрузка значков достижений ({{detail}})"
|
||||
"downloading_achievement_icons_detail": "Загрузка значков достижений ({{detail}})",
|
||||
"preparing_compatibility_layer": "Подготовка слоя совместимости",
|
||||
"preparing_compatibility_layer_detail": "Подготовка слоя совместимости ({{detail}})",
|
||||
"compatibility_layer_failed": "Слою совместимости не удалось запустить игру"
|
||||
},
|
||||
"notifications_page": {
|
||||
"cloud_gift_received_title": "Вам подарили Hydra Cloud",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ import {
|
|||
Wine,
|
||||
NativeAddon,
|
||||
launchedGamePids,
|
||||
type UmuStatus,
|
||||
} from "@main/services";
|
||||
import { sendGameLauncherStatus } from "@main/services/game-launcher-status";
|
||||
import { updateGameRecord } from "@main/services/game-record-updater";
|
||||
import { dispatchSteamProtocolLaunch } from "@main/services/steam-integration/steam-protocol-launch-dispatch";
|
||||
import { resolveSteamProtocolLaunch } from "@main/services/steam-integration/steam-protocol-launch";
|
||||
|
|
@ -49,6 +51,28 @@ export interface LaunchGameOptions {
|
|||
|
||||
const LAUNCH_DELAY_IN_MS = 2_000;
|
||||
|
||||
const createUmuStatusReporter =
|
||||
(gameKey: string) =>
|
||||
(status: UmuStatus): void => {
|
||||
if (status.type === "progress") {
|
||||
sendGameLauncherStatus(
|
||||
gameKey,
|
||||
"preparing_compatibility_layer",
|
||||
status.message
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (status.type === "failed") {
|
||||
sendGameLauncherStatus(
|
||||
gameKey,
|
||||
"compatibility_layer_failed",
|
||||
status.message
|
||||
);
|
||||
return;
|
||||
}
|
||||
sendGameLauncherStatus(gameKey, "compatibility_layer_ready");
|
||||
};
|
||||
|
||||
const isWindowsExecutable = (executablePath: string) =>
|
||||
path.extname(executablePath).toLowerCase() === ".exe";
|
||||
|
||||
|
|
@ -244,6 +268,7 @@ interface CloudSavePrefixPreparationResult {
|
|||
|
||||
const prepareWinePrefixIfNeeded = async (
|
||||
context: LinuxCompatibilityLaunchContext,
|
||||
gameKey: string,
|
||||
objectId: string,
|
||||
prefixWasReadyForRestore: boolean
|
||||
) => {
|
||||
|
|
@ -254,6 +279,7 @@ const prepareWinePrefixIfNeeded = async (
|
|||
winePrefixPath: context.winePrefixPath!,
|
||||
protonPath: context.protonPath,
|
||||
gameId: objectId,
|
||||
onStatus: createUmuStatusReporter(gameKey),
|
||||
});
|
||||
return false;
|
||||
} catch (error) {
|
||||
|
|
@ -267,6 +293,7 @@ const prepareWinePrefixIfNeeded = async (
|
|||
|
||||
const prepareCompatibilityPrefixForCloudSave = async (
|
||||
context: LinuxCompatibilityLaunchContext,
|
||||
gameKey: string,
|
||||
objectId: string
|
||||
): Promise<CloudSavePrefixPreparationResult> => {
|
||||
const winePrefixPath = context.winePrefixPath;
|
||||
|
|
@ -283,6 +310,7 @@ const prepareCompatibilityPrefixForCloudSave = async (
|
|||
prefixWasValid && Wine.isPrefixReadyForRestore(winePrefixPath);
|
||||
const preparationFailed = await prepareWinePrefixIfNeeded(
|
||||
context,
|
||||
gameKey,
|
||||
objectId,
|
||||
prefixWasReadyForRestore
|
||||
);
|
||||
|
|
@ -395,6 +423,8 @@ const launchWindowsBinaryOnLinux = async (
|
|||
useGamemode: boolean
|
||||
): Promise<boolean> => {
|
||||
const { protonPath, winePrefixPath } = compatibilityContext;
|
||||
const reportUmuStatus = createUmuStatusReporter(gameKey);
|
||||
let umuFailureMessage = "umu-run failed to launch the game";
|
||||
|
||||
try {
|
||||
await Umu.launchExecutable(parsedPath, [], {
|
||||
|
|
@ -404,11 +434,15 @@ const launchWindowsBinaryOnLinux = async (
|
|||
launchOptions,
|
||||
useGamemode,
|
||||
useMangohud,
|
||||
onStatus: reportUmuStatus,
|
||||
});
|
||||
PowerSaveBlockerManager.markCompatibilityLaunchStarted(gameKey);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("Failed to launch game with umu-run, falling back", error);
|
||||
if (error instanceof Error && error.message) {
|
||||
umuFailureMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
const launchedWithWine = await launchWithWine(
|
||||
|
|
@ -420,10 +454,12 @@ const launchWindowsBinaryOnLinux = async (
|
|||
);
|
||||
|
||||
if (launchedWithWine) {
|
||||
reportUmuStatus({ type: "ready" });
|
||||
PowerSaveBlockerManager.markCompatibilityLaunchStarted(gameKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
reportUmuStatus({ type: "failed", message: umuFailureMessage });
|
||||
return false;
|
||||
};
|
||||
|
||||
|
|
@ -501,6 +537,7 @@ const prepareLinuxCompatibilityForLaunch = async (
|
|||
|
||||
const prefixPreparation = await prepareCompatibilityPrefixForCloudSave(
|
||||
context,
|
||||
levelKeys.game(shop, objectId),
|
||||
objectId
|
||||
);
|
||||
context = {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { startSteamSyncOnStartup } from "./services/steam-integration/steam-star
|
|||
import { migrateEmulatorCloudSaveDefaults } from "./services/cloud-save/automatic-sync-emulator-migration";
|
||||
import { watchSteamLibraries } from "./services/steam-integration/steam-install-watcher";
|
||||
import { migrateGameVisibilityFields } from "./services/library-sync/game-visibility-migration";
|
||||
import { UmuUpdater } from "./services/umu-updater";
|
||||
|
||||
const hasMissingSeedFiles = async (download: Download): Promise<boolean> => {
|
||||
if (!download.folderName) return false;
|
||||
|
|
@ -110,6 +111,7 @@ export const loadState = async () => {
|
|||
|
||||
if (process.platform === "linux") {
|
||||
DeckyPlugin.checkAndUpdateIfOutdated();
|
||||
void UmuUpdater.checkForUpdates();
|
||||
}
|
||||
|
||||
void watchSteamLibraries();
|
||||
|
|
|
|||
111
src/main/services/umu-output-monitor.test.ts
Normal file
111
src/main/services/umu-output-monitor.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { UmuOutputMonitor, parseUmuOutputLine } from "./umu-output-monitor.js";
|
||||
|
||||
describe("umu output parsing", () => {
|
||||
it("reports runtime setup progress without the wait suffix", () => {
|
||||
assert.deepEqual(
|
||||
parseUmuOutputLine(
|
||||
"INFO: Downloading steamrt3 (3.0.20260928.262393), please wait..."
|
||||
),
|
||||
{
|
||||
type: "progress",
|
||||
message: "Downloading steamrt3 (3.0.20260928.262393)",
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseUmuOutputLine("INFO: Extracting UMU-Proton-10.0-4.tar.gz..."),
|
||||
{ type: "progress", message: "Extracting UMU-Proton-10.0-4.tar.gz" }
|
||||
);
|
||||
});
|
||||
|
||||
it("strips terminal colors before parsing", () => {
|
||||
assert.deepEqual(
|
||||
parseUmuOutputLine(
|
||||
"\u001b[34m\u001b[1mINFO\u001b[0m: Verifying integrity of sniper_platform_3.0..."
|
||||
),
|
||||
{
|
||||
type: "progress",
|
||||
message: "Verifying integrity of sniper_platform_3.0",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores informational lines that are not setup progress", () => {
|
||||
assert.equal(parseUmuOutputLine("INFO: Using UMU-Proton-10.0-4"), null);
|
||||
assert.equal(parseUmuOutputLine("fsync: up and running."), null);
|
||||
assert.equal(parseUmuOutputLine(" "), null);
|
||||
});
|
||||
|
||||
it("separates recoverable errors from fatal failures", () => {
|
||||
assert.deepEqual(
|
||||
parseUmuOutputLine(
|
||||
"ERROR: Digest mismatched: SteamLinuxRuntime_sniper.tar.xz"
|
||||
),
|
||||
{
|
||||
type: "error",
|
||||
message: "Digest mismatched: SteamLinuxRuntime_sniper.tar.xz",
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseUmuOutputLine(
|
||||
"FileNotFoundError: _v2-entry-point (umu) cannot be found in '/home/user/.local/share/umu/steamrt3'"
|
||||
),
|
||||
{
|
||||
type: "fatal",
|
||||
message:
|
||||
"FileNotFoundError: _v2-entry-point (umu) cannot be found in '/home/user/.local/share/umu/steamrt3'",
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseUmuOutputLine(
|
||||
"pv-adverb[106667]: E: Failed to execute child process “/home/user/.local/share/umu/steamrt4/umu-shim” (No such file or directory)"
|
||||
),
|
||||
{
|
||||
type: "fatal",
|
||||
message:
|
||||
"Failed to execute child process “/home/user/.local/share/umu/steamrt4/umu-shim” (No such file or directory)",
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("umu output monitor", () => {
|
||||
it("joins lines split across chunks", () => {
|
||||
const monitor = new UmuOutputMonitor();
|
||||
assert.deepEqual(monitor.feed("INFO: Downloading UMU-"), []);
|
||||
assert.deepEqual(monitor.feed("Proton-10.0-4.tar.gz...\nINFO: Using"), [
|
||||
{ type: "progress", message: "Downloading UMU-Proton-10.0-4.tar.gz" },
|
||||
]);
|
||||
assert.deepEqual(monitor.flush(), []);
|
||||
});
|
||||
|
||||
it("prefers the fatal failure over earlier recoverable errors", () => {
|
||||
const monitor = new UmuOutputMonitor();
|
||||
monitor.feed(
|
||||
[
|
||||
"ERROR: Digest mismatched: SteamLinuxRuntime_sniper.tar.xz",
|
||||
"Traceback (most recent call last):",
|
||||
"ValueError: Digest mismatched: SteamLinuxRuntime_sniper.tar.xz",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
assert.equal(monitor.hasFatalError, true);
|
||||
assert.equal(
|
||||
monitor.failureMessage,
|
||||
"ValueError: Digest mismatched: SteamLinuxRuntime_sniper.tar.xz"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the last recoverable error when nothing was fatal", () => {
|
||||
const monitor = new UmuOutputMonitor();
|
||||
monitor.feed("ERROR: Aborting steamrt install due to network error");
|
||||
monitor.flush();
|
||||
assert.equal(monitor.hasFatalError, false);
|
||||
assert.equal(
|
||||
monitor.failureMessage,
|
||||
"Aborting steamrt install due to network error"
|
||||
);
|
||||
});
|
||||
});
|
||||
150
src/main/services/umu-output-monitor.ts
Normal file
150
src/main/services/umu-output-monitor.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import fs from "node:fs";
|
||||
|
||||
export type UmuOutputEvent =
|
||||
| { type: "progress"; message: string }
|
||||
| { type: "error"; message: string }
|
||||
| { type: "fatal"; message: string };
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(
|
||||
`${String.fromCharCode(27)}\\[[0-9;]*m`,
|
||||
"g"
|
||||
);
|
||||
const LOG_LINE_PATTERN = /^(INFO|WARNING|ERROR|CRITICAL):\s+(.+)$/;
|
||||
const EXCEPTION_LINE_PATTERN = /^([A-Z]\w*(?:Error|Exception)):\s+(.+)$/;
|
||||
const PRESSURE_VESSEL_ERROR_PATTERN = /^pv-[\w-]+\[\d+\]:\s+E:\s+(.+)$/;
|
||||
const PROGRESS_PREFIXES = [
|
||||
"Setting up Unified Launcher",
|
||||
"Downloading ",
|
||||
"Extracting ",
|
||||
"Verifying integrity of ",
|
||||
"Updating ",
|
||||
"Restoring Runtime Platform",
|
||||
"Found '",
|
||||
];
|
||||
|
||||
const cleanProgressMessage = (message: string) =>
|
||||
message.replace(/,?\s*please wait\.{0,3}$/i, "").replace(/\.{3}$/, "");
|
||||
|
||||
export const parseUmuOutputLine = (rawLine: string): UmuOutputEvent | null => {
|
||||
const line = rawLine.replace(ANSI_ESCAPE_PATTERN, "").trim();
|
||||
if (!line) return null;
|
||||
|
||||
const pressureVesselError = PRESSURE_VESSEL_ERROR_PATTERN.exec(line);
|
||||
if (pressureVesselError) {
|
||||
return { type: "fatal", message: pressureVesselError[1] };
|
||||
}
|
||||
|
||||
const exception = EXCEPTION_LINE_PATTERN.exec(line);
|
||||
if (exception) {
|
||||
return { type: "fatal", message: `${exception[1]}: ${exception[2]}` };
|
||||
}
|
||||
|
||||
const logLine = LOG_LINE_PATTERN.exec(line);
|
||||
if (!logLine) return null;
|
||||
|
||||
const [, level, message] = logLine;
|
||||
if (level === "ERROR" || level === "CRITICAL") {
|
||||
return { type: "error", message };
|
||||
}
|
||||
if (
|
||||
level === "INFO" &&
|
||||
PROGRESS_PREFIXES.some((prefix) => message.startsWith(prefix))
|
||||
) {
|
||||
return { type: "progress", message: cleanProgressMessage(message) };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export class UmuOutputMonitor {
|
||||
private buffer = "";
|
||||
private lastErrorMessage: string | null = null;
|
||||
private fatalErrorMessage: string | null = null;
|
||||
|
||||
public feed(chunk: string): UmuOutputEvent[] {
|
||||
const lines = (this.buffer + chunk).split(/\r?\n/);
|
||||
this.buffer = lines.pop() ?? "";
|
||||
return this.parseLines(lines);
|
||||
}
|
||||
|
||||
public flush(): UmuOutputEvent[] {
|
||||
const remaining = this.buffer;
|
||||
this.buffer = "";
|
||||
return this.parseLines([remaining]);
|
||||
}
|
||||
|
||||
public get failureMessage(): string | null {
|
||||
return this.fatalErrorMessage ?? this.lastErrorMessage;
|
||||
}
|
||||
|
||||
public get hasFatalError(): boolean {
|
||||
return this.fatalErrorMessage !== null;
|
||||
}
|
||||
|
||||
private parseLines(lines: string[]): UmuOutputEvent[] {
|
||||
const events: UmuOutputEvent[] = [];
|
||||
for (const line of lines) {
|
||||
const event = parseUmuOutputLine(line);
|
||||
if (!event) continue;
|
||||
if (event.type === "error") this.lastErrorMessage = event.message;
|
||||
if (event.type === "fatal") this.fatalErrorMessage = event.message;
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
}
|
||||
|
||||
const UMU_LOG_POLL_INTERVAL_MS = 500;
|
||||
|
||||
export const getFileSize = (filePath: string) => {
|
||||
try {
|
||||
return fs.statSync(filePath).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const tailUmuLog = (
|
||||
filePath: string,
|
||||
startOffset: number,
|
||||
onChunk: (chunk: string) => void
|
||||
) => {
|
||||
let offset = startOffset;
|
||||
let reading = false;
|
||||
let stopped = false;
|
||||
|
||||
const readNewContent = () => {
|
||||
if (reading) return;
|
||||
reading = true;
|
||||
try {
|
||||
const size = getFileSize(filePath);
|
||||
if (size <= offset) return;
|
||||
const length = size - offset;
|
||||
const buffer = Buffer.alloc(length);
|
||||
const descriptor = fs.openSync(filePath, "r");
|
||||
try {
|
||||
const bytesRead = fs.readSync(descriptor, buffer, 0, length, offset);
|
||||
offset += bytesRead;
|
||||
if (bytesRead > 0) {
|
||||
onChunk(buffer.subarray(0, bytesRead).toString("utf8"));
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
reading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(readNewContent, UMU_LOG_POLL_INTERVAL_MS);
|
||||
interval.unref?.();
|
||||
|
||||
return () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearInterval(interval);
|
||||
readNewContent();
|
||||
};
|
||||
};
|
||||
69
src/main/services/umu-release.test.ts
Normal file
69
src/main/services/umu-release.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { compareUmuVersions, parseUmuRelease } from "./umu-release.js";
|
||||
|
||||
const zipappAsset = {
|
||||
name: "umu-launcher-1.4.4-zipapp.tar",
|
||||
browser_download_url:
|
||||
"https://github.com/Open-Wine-Components/umu-launcher/releases/download/1.4.4/umu-launcher-1.4.4-zipapp.tar",
|
||||
digest:
|
||||
"sha256:eb590691841f7fad3fc3ad8fd5db4ccb87849fe7948e62b28ece7a4ee48cc851",
|
||||
};
|
||||
|
||||
describe("umu release parsing", () => {
|
||||
it("reads the zipapp asset and its digest", () => {
|
||||
assert.deepEqual(
|
||||
parseUmuRelease({
|
||||
tag_name: "1.4.4",
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{ name: "umu-launcher-1.4.4.fc43.x86_64.rpm", digest: "sha256:00" },
|
||||
zipappAsset,
|
||||
],
|
||||
}),
|
||||
{
|
||||
version: "1.4.4",
|
||||
downloadUrl: zipappAsset.browser_download_url,
|
||||
sha256:
|
||||
"eb590691841f7fad3fc3ad8fd5db4ccb87849fe7948e62b28ece7a4ee48cc851",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects releases that cannot be verified or are not final", () => {
|
||||
assert.equal(
|
||||
parseUmuRelease({
|
||||
assets: [{ ...zipappAsset, digest: undefined }],
|
||||
}),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
parseUmuRelease({
|
||||
assets: [
|
||||
{
|
||||
...zipappAsset,
|
||||
browser_download_url: "https://example.com/umu.tar",
|
||||
},
|
||||
],
|
||||
}),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
parseUmuRelease({ prerelease: true, assets: [zipappAsset] }),
|
||||
null
|
||||
);
|
||||
assert.equal(parseUmuRelease({ assets: [] }), null);
|
||||
assert.equal(parseUmuRelease(null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("umu version comparison", () => {
|
||||
it("compares numeric segments", () => {
|
||||
assert.equal(compareUmuVersions("1.4.4", "1.3.0"), 1);
|
||||
assert.equal(compareUmuVersions("1.4.10", "1.4.9"), 1);
|
||||
assert.equal(compareUmuVersions("1.4", "1.4.0"), 0);
|
||||
assert.equal(compareUmuVersions("1.3.0", "1.4.4"), -1);
|
||||
});
|
||||
});
|
||||
59
src/main/services/umu-release.ts
Normal file
59
src/main/services/umu-release.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export const BUNDLED_UMU_VERSION = "1.4.4";
|
||||
|
||||
export interface UmuRelease {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
const ZIPAPP_ASSET_PATTERN = /^umu-launcher-(\d+(?:\.\d+)*)-zipapp\.tar$/;
|
||||
const SHA256_DIGEST_PATTERN = /^sha256:([0-9a-f]{64})$/i;
|
||||
const VERSION_PATTERN = /^\d+(?:\.\d+)*$/;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
export const isValidUmuVersion = (version: unknown): version is string =>
|
||||
typeof version === "string" && VERSION_PATTERN.test(version);
|
||||
|
||||
export const compareUmuVersions = (left: string, right: string) => {
|
||||
const leftParts = left.split(".").map(Number);
|
||||
const rightParts = right.split(".").map(Number);
|
||||
const length = Math.max(leftParts.length, rightParts.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
||||
if (difference !== 0) return Math.sign(difference);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const parseUmuRelease = (payload: unknown): UmuRelease | null => {
|
||||
if (!isRecord(payload) || payload.draft === true || payload.prerelease) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(payload.assets)) return null;
|
||||
|
||||
for (const asset of payload.assets) {
|
||||
if (!isRecord(asset) || typeof asset.name !== "string") continue;
|
||||
const nameMatch = ZIPAPP_ASSET_PATTERN.exec(asset.name);
|
||||
if (!nameMatch) continue;
|
||||
const digestMatch =
|
||||
typeof asset.digest === "string"
|
||||
? SHA256_DIGEST_PATTERN.exec(asset.digest)
|
||||
: null;
|
||||
if (
|
||||
!digestMatch ||
|
||||
typeof asset.browser_download_url !== "string" ||
|
||||
!asset.browser_download_url.startsWith("https://github.com/")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: nameMatch[1],
|
||||
downloadUrl: asset.browser_download_url,
|
||||
sha256: digestMatch[1].toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
201
src/main/services/umu-updater.ts
Normal file
201
src/main/services/umu-updater.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import axios from "axios";
|
||||
import * as tar from "tar";
|
||||
import { SystemPath } from "./system-path";
|
||||
import { logger } from "./logger";
|
||||
import {
|
||||
BUNDLED_UMU_VERSION,
|
||||
compareUmuVersions,
|
||||
isValidUmuVersion,
|
||||
parseUmuRelease,
|
||||
} from "./umu-release";
|
||||
|
||||
const UMU_RELEASE_API_URL =
|
||||
"https://api.github.com/repos/Open-Wine-Components/umu-launcher/releases/latest";
|
||||
const UMU_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const UMU_RELEASE_REQUEST_TIMEOUT_MS = 15_000;
|
||||
const UMU_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
const UMU_MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
|
||||
const UMU_ZIPAPP_ENTRY = "umu/umu-run";
|
||||
|
||||
interface ManagedUmuState {
|
||||
version: string | null;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
const getManagedUmuDirectory = () =>
|
||||
path.join(SystemPath.getPath("userData"), "umu");
|
||||
|
||||
const getStatePath = () => path.join(getManagedUmuDirectory(), "state.json");
|
||||
|
||||
const getVersionBinaryPath = (version: string) =>
|
||||
path.join(getManagedUmuDirectory(), version, "umu-run");
|
||||
|
||||
const readState = (): ManagedUmuState | null => {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(getStatePath(), "utf8"));
|
||||
if (typeof state?.checkedAt !== "number") return null;
|
||||
return {
|
||||
version: isValidUmuVersion(state.version) ? state.version : null,
|
||||
checkedAt: state.checkedAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeState = async (state: ManagedUmuState) => {
|
||||
await fs.promises.mkdir(getManagedUmuDirectory(), { recursive: true });
|
||||
const temporaryPath = `${getStatePath()}.${randomUUID()}.tmp`;
|
||||
await fs.promises.writeFile(temporaryPath, JSON.stringify(state));
|
||||
await fs.promises.rename(temporaryPath, getStatePath());
|
||||
};
|
||||
|
||||
const isUsableBinary = (binaryPath: string) => {
|
||||
try {
|
||||
const stat = fs.statSync(binaryPath);
|
||||
return stat.isFile() && stat.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getInstalledManagedVersion = (state: ManagedUmuState | null) => {
|
||||
if (!state?.version) return null;
|
||||
if (compareUmuVersions(state.version, BUNDLED_UMU_VERSION) <= 0) return null;
|
||||
return isUsableBinary(getVersionBinaryPath(state.version))
|
||||
? state.version
|
||||
: null;
|
||||
};
|
||||
|
||||
const removeStaleVersions = async (keptVersions: Set<string>) => {
|
||||
const entries = await fs.promises
|
||||
.readdir(getManagedUmuDirectory(), { withFileTypes: true })
|
||||
.catch(() => [] as fs.Dirent[]);
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory() && !keptVersions.has(entry.name))
|
||||
.map((entry) =>
|
||||
fs.promises
|
||||
.rm(path.join(getManagedUmuDirectory(), entry.name), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const installRelease = async (
|
||||
version: string,
|
||||
downloadUrl: string,
|
||||
sha256: string
|
||||
) => {
|
||||
const response = await axios.get<ArrayBuffer>(downloadUrl, {
|
||||
responseType: "arraybuffer",
|
||||
timeout: UMU_DOWNLOAD_TIMEOUT_MS,
|
||||
maxContentLength: UMU_MAX_DOWNLOAD_BYTES,
|
||||
});
|
||||
const archive = Buffer.from(response.data);
|
||||
const digest = createHash("sha256").update(archive).digest("hex");
|
||||
if (digest !== sha256) {
|
||||
throw new Error(`umu ${version} archive digest mismatch`);
|
||||
}
|
||||
|
||||
const workingDirectory = path.join(
|
||||
getManagedUmuDirectory(),
|
||||
`.download-${randomUUID()}`
|
||||
);
|
||||
await fs.promises.mkdir(workingDirectory, { recursive: true });
|
||||
|
||||
try {
|
||||
const archivePath = path.join(workingDirectory, "umu.tar");
|
||||
await fs.promises.writeFile(archivePath, archive);
|
||||
await tar.x({
|
||||
file: archivePath,
|
||||
cwd: workingDirectory,
|
||||
filter: (entryPath) => entryPath === UMU_ZIPAPP_ENTRY,
|
||||
});
|
||||
|
||||
const extractedBinary = path.join(workingDirectory, UMU_ZIPAPP_ENTRY);
|
||||
if (!isUsableBinary(extractedBinary)) {
|
||||
throw new Error(`umu ${version} archive is missing ${UMU_ZIPAPP_ENTRY}`);
|
||||
}
|
||||
|
||||
const versionDirectory = path.dirname(getVersionBinaryPath(version));
|
||||
await fs.promises.rm(versionDirectory, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(versionDirectory, { recursive: true });
|
||||
await fs.promises.rename(extractedBinary, getVersionBinaryPath(version));
|
||||
await fs.promises.chmod(getVersionBinaryPath(version), 0o755);
|
||||
} finally {
|
||||
await fs.promises
|
||||
.rm(workingDirectory, { recursive: true, force: true })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
export class UmuUpdater {
|
||||
private static updatePromise: Promise<void> | null = null;
|
||||
|
||||
public static getManagedBinaryPath(): string | null {
|
||||
if (process.platform !== "linux") return null;
|
||||
const version = getInstalledManagedVersion(readState());
|
||||
return version ? getVersionBinaryPath(version) : null;
|
||||
}
|
||||
|
||||
public static checkForUpdates(): Promise<void> {
|
||||
if (process.platform !== "linux") return Promise.resolve();
|
||||
this.updatePromise ??= this.runUpdateCheck().finally(() => {
|
||||
this.updatePromise = null;
|
||||
});
|
||||
return this.updatePromise;
|
||||
}
|
||||
|
||||
private static async runUpdateCheck() {
|
||||
const state = readState();
|
||||
if (state && Date.now() - state.checkedAt < UMU_UPDATE_CHECK_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const installedVersion = getInstalledManagedVersion(state);
|
||||
const currentVersion = installedVersion ?? BUNDLED_UMU_VERSION;
|
||||
|
||||
try {
|
||||
const response = await axios.get(UMU_RELEASE_API_URL, {
|
||||
timeout: UMU_RELEASE_REQUEST_TIMEOUT_MS,
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
});
|
||||
const release = parseUmuRelease(response.data);
|
||||
|
||||
if (
|
||||
!release ||
|
||||
compareUmuVersions(release.version, currentVersion) <= 0
|
||||
) {
|
||||
await writeState({ version: installedVersion, checkedAt: Date.now() });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Updating umu-launcher", {
|
||||
from: currentVersion,
|
||||
to: release.version,
|
||||
});
|
||||
await installRelease(
|
||||
release.version,
|
||||
release.downloadUrl,
|
||||
release.sha256
|
||||
);
|
||||
await writeState({ version: release.version, checkedAt: Date.now() });
|
||||
await removeStaleVersions(
|
||||
new Set([release.version, installedVersion ?? release.version])
|
||||
);
|
||||
logger.info("umu-launcher updated", { version: release.version });
|
||||
} catch (error) {
|
||||
logger.warn("Failed to update umu-launcher", {
|
||||
currentVersion,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,23 @@ import { resolveLaunchCommand } from "@main/helpers/resolve-launch-command";
|
|||
import { evaluateUmuPrefixPreparation } from "./umu-prefix-preparation";
|
||||
import { Wine } from "./wine";
|
||||
import { getSteamLibraryFolders } from "./steam";
|
||||
import { UmuUpdater } from "./umu-updater";
|
||||
import {
|
||||
UmuOutputMonitor,
|
||||
getFileSize,
|
||||
tailUmuLog,
|
||||
type UmuOutputEvent,
|
||||
} from "./umu-output-monitor";
|
||||
|
||||
export type UmuStatus =
|
||||
| { type: "progress"; message: string }
|
||||
| { type: "ready" }
|
||||
| { type: "failed"; message: string };
|
||||
|
||||
type UmuStatusListener = (status: UmuStatus) => void;
|
||||
|
||||
const UMU_SETUP_QUIET_PERIOD_MS = 8_000;
|
||||
const UMU_SETUP_DETECTION_WINDOW_MS = 30_000;
|
||||
|
||||
const isValidProtonDirectory = (directoryPath: string) => {
|
||||
const protonFilePath = path.join(directoryPath, "proton");
|
||||
|
|
@ -25,11 +42,86 @@ const getVersionName = (directoryPath: string) => {
|
|||
|
||||
const getUmuLogPath = () => path.join(logsPath, "umu.log");
|
||||
|
||||
const getUmuBinaryPath = () =>
|
||||
const getBundledUmuBinaryPath = () =>
|
||||
app.isPackaged
|
||||
? path.join(process.resourcesPath, "umu-run")
|
||||
: path.join(__dirname, "..", "..", "binaries", "umu", "umu-run");
|
||||
|
||||
const getUmuBinaryPath = () =>
|
||||
UmuUpdater.getManagedBinaryPath() ?? getBundledUmuBinaryPath();
|
||||
|
||||
const watchUmuSetup = (
|
||||
umuLogPath: string,
|
||||
enabled: boolean,
|
||||
onStatus?: UmuStatusListener
|
||||
) => {
|
||||
const monitor = new UmuOutputMonitor();
|
||||
let active = enabled;
|
||||
let preparing = false;
|
||||
let stopTail: (() => void) | null = null;
|
||||
let quietTimer: NodeJS.Timeout | null = null;
|
||||
let detectionTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const clearTimers = () => {
|
||||
if (quietTimer) clearTimeout(quietTimer);
|
||||
if (detectionTimer) clearTimeout(detectionTimer);
|
||||
quietTimer = null;
|
||||
detectionTimer = null;
|
||||
};
|
||||
|
||||
const markReady = () => {
|
||||
if (!preparing) return;
|
||||
preparing = false;
|
||||
onStatus?.({ type: "ready" });
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
const stopCurrentTail = stopTail;
|
||||
stopTail = null;
|
||||
stopCurrentTail?.();
|
||||
clearTimers();
|
||||
};
|
||||
|
||||
const handleEvents = (events: UmuOutputEvent[]) => {
|
||||
for (const event of events) {
|
||||
if (event.type !== "progress") continue;
|
||||
preparing = true;
|
||||
onStatus?.({ type: "progress", message: event.message });
|
||||
if (!active) continue;
|
||||
if (quietTimer) clearTimeout(quietTimer);
|
||||
quietTimer = setTimeout(() => {
|
||||
stop();
|
||||
markReady();
|
||||
}, UMU_SETUP_QUIET_PERIOD_MS);
|
||||
quietTimer.unref?.();
|
||||
}
|
||||
};
|
||||
|
||||
if (enabled) {
|
||||
stopTail = tailUmuLog(umuLogPath, getFileSize(umuLogPath), (chunk) =>
|
||||
handleEvents(monitor.feed(chunk))
|
||||
);
|
||||
detectionTimer = setTimeout(() => {
|
||||
if (!preparing) stop();
|
||||
}, UMU_SETUP_DETECTION_WINDOW_MS);
|
||||
detectionTimer.unref?.();
|
||||
}
|
||||
|
||||
return {
|
||||
complete: () => {
|
||||
stop();
|
||||
handleEvents(monitor.flush());
|
||||
return {
|
||||
failureMessage: monitor.failureMessage,
|
||||
hasFatalError: monitor.hasFatalError,
|
||||
};
|
||||
},
|
||||
markReady,
|
||||
};
|
||||
};
|
||||
|
||||
const parsePythonVersion = (versionText: string): [number, number] | null => {
|
||||
const match = versionText.trim().match(/^(\d+)\.(\d+)$/);
|
||||
if (!match) return null;
|
||||
|
|
@ -209,6 +301,7 @@ export class Umu {
|
|||
winePrefixPath: string;
|
||||
protonPath?: string | null;
|
||||
gameId?: string | null;
|
||||
onStatus?: UmuStatusListener;
|
||||
}): Promise<void> {
|
||||
const umuLogPath = getUmuLogPath();
|
||||
const umuBinaryPath = getUmuBinaryPath();
|
||||
|
|
@ -245,6 +338,11 @@ export class Umu {
|
|||
? null
|
||||
: fs.openSync(umuLogPath, "a");
|
||||
let settled = false;
|
||||
const setupWatcher = watchUmuSetup(
|
||||
umuLogPath,
|
||||
!shouldPipeToTerminal,
|
||||
options.onStatus
|
||||
);
|
||||
|
||||
const closeLogFileDescriptor = () => {
|
||||
if (!settled && logFileDescriptor !== null) {
|
||||
|
|
@ -271,6 +369,8 @@ export class Umu {
|
|||
});
|
||||
|
||||
child.once("error", (error) => {
|
||||
setupWatcher.complete();
|
||||
setupWatcher.markReady();
|
||||
finish(() => {
|
||||
logger.error("Failed to start umu-run prefix preparation", {
|
||||
errorName: error.name,
|
||||
|
|
@ -281,6 +381,8 @@ export class Umu {
|
|||
});
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
const setup = setupWatcher.complete();
|
||||
setupWatcher.markReady();
|
||||
finish(() => {
|
||||
let prefixValid = false;
|
||||
|
||||
|
|
@ -311,14 +413,17 @@ export class Umu {
|
|||
return;
|
||||
}
|
||||
|
||||
const errorMessage = setup.failureMessage
|
||||
? `${evaluation.errorMessage}: ${setup.failureMessage}`
|
||||
: evaluation.errorMessage;
|
||||
logger.error("umu-run failed to prepare a valid Wine prefix", {
|
||||
code,
|
||||
signal,
|
||||
prefixValid,
|
||||
umuLogPath,
|
||||
errorMessage: evaluation.errorMessage,
|
||||
errorMessage,
|
||||
});
|
||||
reject(new Error(evaluation.errorMessage));
|
||||
reject(new Error(errorMessage));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -334,6 +439,7 @@ export class Umu {
|
|||
launchOptions?: string | null;
|
||||
useMangohud?: boolean;
|
||||
useGamemode?: boolean;
|
||||
onStatus?: UmuStatusListener;
|
||||
}
|
||||
): Promise<void> {
|
||||
const QUICK_EXIT_THRESHOLD_MS = 3000;
|
||||
|
|
@ -398,6 +504,11 @@ export class Umu {
|
|||
: fs.openSync(umuLogPath, "a");
|
||||
|
||||
let settled = false;
|
||||
const setupWatcher = watchUmuSetup(
|
||||
umuLogPath,
|
||||
!shouldPipeToTerminal,
|
||||
options?.onStatus
|
||||
);
|
||||
|
||||
const closeLogFileDescriptor = () => {
|
||||
if (logFileDescriptor !== null) {
|
||||
|
|
@ -446,10 +557,33 @@ export class Umu {
|
|||
quickExitTimer = null;
|
||||
}
|
||||
|
||||
const setup = setupWatcher.complete();
|
||||
|
||||
if (settled) {
|
||||
if (code !== 0 && setup.hasFatalError && setup.failureMessage) {
|
||||
logger.error("umu-run failed after the game launch started", {
|
||||
code,
|
||||
signal,
|
||||
umuLogPath,
|
||||
errorMessage: setup.failureMessage,
|
||||
});
|
||||
options?.onStatus?.({
|
||||
type: "failed",
|
||||
message: setup.failureMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setupWatcher.markReady();
|
||||
return;
|
||||
}
|
||||
|
||||
finalize(() => {
|
||||
closeLogFileDescriptor();
|
||||
const failureDetail = setup.failureMessage
|
||||
? `: ${setup.failureMessage}`
|
||||
: "";
|
||||
const earlyExitError = new Error(
|
||||
`umu-run exited early with code=${code ?? "null"} signal=${signal ?? "null"}`
|
||||
`umu-run exited early with code=${code ?? "null"} signal=${signal ?? "null"}${failureDetail}`
|
||||
);
|
||||
fs.appendFileSync(
|
||||
umuLogPath,
|
||||
|
|
@ -465,6 +599,8 @@ export class Umu {
|
|||
quickExitTimer = null;
|
||||
}
|
||||
|
||||
setupWatcher.complete();
|
||||
|
||||
finalize(() => {
|
||||
closeLogFileDescriptor();
|
||||
fs.appendFileSync(
|
||||
|
|
|
|||
|
|
@ -137,6 +137,22 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
|
||||
&--error {
|
||||
color: globals.$error-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__status-detail {
|
||||
font-size: 12px;
|
||||
color: globals.$body-color;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ type PreflightStatus =
|
|||
|
||||
type AchievementsExportStatus = GameLauncherStatus | "idle";
|
||||
|
||||
type CompatibilityLayerStatus = "idle" | "preparing" | "failed";
|
||||
|
||||
export default function GameLauncher() {
|
||||
const { t } = useTranslation("game_launcher");
|
||||
const [searchParams] = useSearchParams();
|
||||
|
|
@ -53,6 +55,11 @@ export default function GameLauncher() {
|
|||
string | null
|
||||
>(null);
|
||||
const [protonVersion, setProtonVersion] = useState<string | null>(null);
|
||||
const [compatibilityLayerStatus, setCompatibilityLayerStatus] =
|
||||
useState<CompatibilityLayerStatus>("idle");
|
||||
const [compatibilityLayerDetail, setCompatibilityLayerDetail] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const formatPlayTime = useCallback(
|
||||
(playTimeInMilliseconds = 0) => {
|
||||
|
|
@ -109,6 +116,24 @@ export default function GameLauncher() {
|
|||
({ gameKey, status, detail }) => {
|
||||
if (gameKey !== `${shop}:${objectId}`) return;
|
||||
|
||||
if (status === "preparing_compatibility_layer") {
|
||||
setCompatibilityLayerStatus("preparing");
|
||||
setCompatibilityLayerDetail(detail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "compatibility_layer_ready") {
|
||||
setCompatibilityLayerStatus("idle");
|
||||
setCompatibilityLayerDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "compatibility_layer_failed") {
|
||||
setCompatibilityLayerStatus("failed");
|
||||
setCompatibilityLayerDetail(detail);
|
||||
return;
|
||||
}
|
||||
|
||||
setAchievementsExportStatus(status);
|
||||
setAchievementsExportDetail(detail);
|
||||
}
|
||||
|
|
@ -140,7 +165,8 @@ export default function GameLauncher() {
|
|||
|
||||
const canAutoClose =
|
||||
(isPreflightDone || (!preflightStarted && preflightTimeout)) &&
|
||||
!isGeneratingAchievements;
|
||||
!isGeneratingAchievements &&
|
||||
compatibilityLayerStatus === "idle";
|
||||
|
||||
useEffect(() => {
|
||||
// Don't start timer until window is shown AND preflight is done
|
||||
|
|
@ -219,6 +245,18 @@ export default function GameLauncher() {
|
|||
break;
|
||||
}
|
||||
|
||||
if (compatibilityLayerStatus === "failed") {
|
||||
return t("compatibility_layer_failed");
|
||||
}
|
||||
|
||||
if (compatibilityLayerStatus === "preparing") {
|
||||
return compatibilityLayerDetail
|
||||
? t("preparing_compatibility_layer_detail", {
|
||||
detail: compatibilityLayerDetail,
|
||||
})
|
||||
: t("preparing_compatibility_layer");
|
||||
}
|
||||
|
||||
switch (achievementsExportStatus) {
|
||||
case "generating_achievements":
|
||||
return t("generating_achievements");
|
||||
|
|
@ -234,12 +272,19 @@ export default function GameLauncher() {
|
|||
}, [
|
||||
preflightStatus,
|
||||
preflightDetail,
|
||||
compatibilityLayerStatus,
|
||||
compatibilityLayerDetail,
|
||||
achievementsExportStatus,
|
||||
achievementsExportDetail,
|
||||
t,
|
||||
]);
|
||||
|
||||
const isStatusRunning = isPreflightRunning || isAchievementsExportRunning;
|
||||
const isCompatibilityLayerFailed = compatibilityLayerStatus === "failed";
|
||||
|
||||
const isStatusRunning =
|
||||
isPreflightRunning ||
|
||||
isAchievementsExportRunning ||
|
||||
compatibilityLayerStatus === "preparing";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -388,12 +433,29 @@ export default function GameLauncher() {
|
|||
<div className="game-launcher__center">
|
||||
<h1 className="game-launcher__title">{gameTitle}</h1>
|
||||
|
||||
<p className="game-launcher__status">
|
||||
<p
|
||||
className={`game-launcher__status${
|
||||
isCompatibilityLayerFailed
|
||||
? " game-launcher__status--error"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{isStatusRunning && <span className="game-launcher__spinner" />}
|
||||
{getStatusMessage()}
|
||||
<span className="game-launcher__dots" />
|
||||
{!isCompatibilityLayerFailed && (
|
||||
<span className="game-launcher__dots" />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{isCompatibilityLayerFailed && compatibilityLayerDetail && (
|
||||
<p
|
||||
className="game-launcher__status-detail"
|
||||
title={compatibilityLayerDetail}
|
||||
>
|
||||
{compatibilityLayerDetail}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isMainWindowOpen && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -475,6 +475,9 @@ export interface AchievementMetadataEntry {
|
|||
export type GameLauncherStatus =
|
||||
| "generating_achievements"
|
||||
| "downloading_achievement_icons"
|
||||
| "preparing_compatibility_layer"
|
||||
| "compatibility_layer_ready"
|
||||
| "compatibility_layer_failed"
|
||||
| "complete";
|
||||
|
||||
export interface GameLauncherStatusPayload {
|
||||
|
|
|
|||
Loading…
Reference in a new issue