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:
galinhaman 2026-10-07 01:28:30 -03:00
parent c4eec1370b
commit a7a6d5b933
16 changed files with 870 additions and 12 deletions

Binary file not shown.

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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 = {

View file

@ -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();

View 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"
);
});
});

View 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();
};
};

View 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);
});
});

View 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;
};

View 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),
});
}
}
}

View file

@ -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(

View file

@ -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 {

View file

@ -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"

View file

@ -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 {