Linux build with proton picker
This commit is contained in:
@@ -2,11 +2,11 @@ name: Build check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
branches: [main, master, linux_build]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
@@ -32,3 +32,26 @@ jobs:
|
||||
run: npm run build
|
||||
env:
|
||||
ELECTRON_RUN_AS_NODE: ''
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Build Linux AppImage
|
||||
run: bash scripts/build.sh --deps
|
||||
|
||||
- name: Upload Linux binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: OctoLauncher-linux
|
||||
path: distprod/*.AppImage
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -16,5 +16,4 @@ Tools/launcher/node/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
scripts/
|
||||
hooks/
|
||||
|
||||
@@ -27,8 +27,15 @@ files:
|
||||
- '!**/node_modules/**/build/Release/obj/**'
|
||||
- '!**/node_modules/**/build/Release/{*.iobj,*.ipdb,*.recipe,*.exp,*.lib,*.pdb,*.obj}'
|
||||
- '!**/node_modules/**/*.{vcxproj,vcxproj.filters}'
|
||||
# Window/tray icon outside asar (Linux WMs need a real path).
|
||||
extraResources:
|
||||
- from: build/icon.png
|
||||
to: icon.png
|
||||
npmRebuild: false
|
||||
electronLanguages: en
|
||||
linux:
|
||||
icon: build/icon.png
|
||||
category: Game
|
||||
win:
|
||||
artifactName: ${productName}.${ext}
|
||||
target:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "octo-launcher",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "octo-launcher",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^1.0.3",
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"pack": "electron-builder --config",
|
||||
"pack:ptr": "electron-builder --config electron-builder.ptr.yml",
|
||||
"dist": "tsc && npm run build && npm run pack",
|
||||
"dist:ptr": "tsc && npm run build:ptr && npm run pack:ptr"
|
||||
"dist:ptr": "tsc && npm run build:ptr && npm run pack:ptr",
|
||||
"dist:linux": "bash scripts/build.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^1.0.3",
|
||||
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a Linux AppImage of OctoLauncher (local or CI).
|
||||
# Usage: ./scripts/build.sh [--deps]
|
||||
# --deps install native build deps via the distro package manager (needs sudo)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
WITH_DEPS=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--deps) WITH_DEPS=1 ;;
|
||||
-h | --help)
|
||||
sed -n '2,5p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$(uname -s)" != "Linux" ]]; then
|
||||
echo "This script only builds Linux packages (host is $(uname -s))." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NODE_MAJOR="$(node -p "process.versions.node.split('.')[0]" 2>/dev/null || true)"
|
||||
if [[ -z "${NODE_MAJOR}" || "${NODE_MAJOR}" -ne 20 ]]; then
|
||||
echo "Warning: Node 20 is recommended (found: $(node -v 2>/dev/null || echo none))." >&2
|
||||
fi
|
||||
|
||||
# VS Code / Cursor set this and break Electron.
|
||||
unset ELECTRON_RUN_AS_NODE
|
||||
export ELECTRON_RUN_AS_NODE=
|
||||
|
||||
is_arch_like() {
|
||||
# CachyOS, Arch, EndeavourOS, Manjaro, etc.
|
||||
[[ -f /etc/arch-release ]] && return 0
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
[[ "${ID:-}" == "arch" || "${ID:-}" == "cachyos" || "${ID_LIKE:-}" == *"arch"* ]] && return 0
|
||||
fi
|
||||
command -v pacman >/dev/null 2>&1
|
||||
}
|
||||
|
||||
is_debian_like() {
|
||||
command -v apt-get >/dev/null 2>&1 || return 1
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
[[ "${ID:-}" == "debian" || "${ID:-}" == "ubuntu" || "${ID_LIKE:-}" == *"debian"* ]] && return 0
|
||||
fi
|
||||
# GitHub ubuntu-latest / other apt hosts without a clear ID_LIKE
|
||||
return 0
|
||||
}
|
||||
|
||||
install_deps() {
|
||||
if is_arch_like; then
|
||||
echo "==> install deps (pacman: base-devel python)"
|
||||
sudo pacman -S --needed --noconfirm base-devel python
|
||||
elif is_debian_like; then
|
||||
echo "==> install deps (apt: build-essential python3)"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential python3
|
||||
else
|
||||
echo "Unsupported distro for --deps. Install a C++ toolchain + Python 3, then re-run without --deps." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${WITH_DEPS}" -eq 1 ]]; then
|
||||
install_deps
|
||||
fi
|
||||
|
||||
if ! command -v g++ >/dev/null 2>&1; then
|
||||
echo "g++ not found. Install build tools (e.g. ./scripts/build.sh --deps) and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> npm install (ignore-scripts)"
|
||||
npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
# dll-inject is Windows-only (LoadLibrary) and unused in source.
|
||||
echo "==> drop unused Windows native dep (dll-inject)"
|
||||
rm -rf node_modules/dll-inject
|
||||
|
||||
echo "==> download Electron binary"
|
||||
node node_modules/electron/install.js
|
||||
|
||||
# npm 12+ may no-op `npm rebuild` (install scripts blocked), so build stormlib
|
||||
# against Electron headers explicitly. Package ships a Windows .node in dist/.
|
||||
ELECTRON_VERSION="$(node -p "require('./node_modules/electron/package.json').version")"
|
||||
ELECTRON_ARCH="$(node -p "process.arch")"
|
||||
echo "==> rebuild stormlib-node for Electron ${ELECTRON_VERSION} (${ELECTRON_ARCH})"
|
||||
(
|
||||
cd node_modules/stormlib-node
|
||||
# Don't use post-build.js — it deletes dist/ (wiping enums.js).
|
||||
node scripts/pre-configure.js
|
||||
npx --yes node-gyp rebuild \
|
||||
--target="${ELECTRON_VERSION}" \
|
||||
--arch="${ELECTRON_ARCH}" \
|
||||
--dist-url=https://electronjs.org/headers
|
||||
test -f build/Release/stormlib.node
|
||||
cp build/Release/stormlib.node dist/stormlib.node
|
||||
rm -rf build
|
||||
)
|
||||
file node_modules/stormlib-node/dist/stormlib.node
|
||||
# Must be ELF on Linux, not the published Windows PE.
|
||||
if ! file node_modules/stormlib-node/dist/stormlib.node | grep -q ELF; then
|
||||
echo "stormlib.node is not a Linux ELF binary after rebuild." >&2
|
||||
exit 1
|
||||
fi
|
||||
test -f node_modules/stormlib-node/dist/enums.js
|
||||
|
||||
echo "==> electron-vite build"
|
||||
npm run build
|
||||
|
||||
echo "==> package AppImage"
|
||||
npx electron-builder --linux AppImage --config
|
||||
|
||||
echo "==> done"
|
||||
ls -lh distprod/*.AppImage
|
||||
@@ -74,7 +74,11 @@ export const PreferencesSchema = z.object({
|
||||
config: ConfigWtfSchema.default({}),
|
||||
mods: z.record(ModStateSchema).default({}),
|
||||
hardware: HardwareInfoSchema.optional(),
|
||||
farClipUserSet: z.boolean().optional()
|
||||
farClipUserSet: z.boolean().optional(),
|
||||
/** Linux: launch WoW.exe through Steam Proton instead of a bare spawn. */
|
||||
useProton: f.boolean(),
|
||||
/** Absolute path to a Proton install directory (contains the `proton` script). */
|
||||
protonPath: z.string().optional()
|
||||
});
|
||||
export type PreferencesSchema = z.infer<typeof PreferencesSchema>;
|
||||
|
||||
|
||||
@@ -6,11 +6,14 @@ import { mainWindow } from '~main/index';
|
||||
import Preferences from '~main/modules/preferences';
|
||||
import { addDefenderExclusions } from '~main/modules/defender';
|
||||
import { detectHardware, recommendFarClip } from '~main/modules/hardware';
|
||||
import { listProtonVersions } from '~main/modules/proton';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const generalRouter = createTRPCRouter({
|
||||
appVersion: publicProcedure.query(() => app.getVersion()),
|
||||
platform: publicProcedure.query(() => process.platform),
|
||||
protonVersions: publicProcedure.query(() => listProtonVersions()),
|
||||
hardware: publicProcedure.query(() => {
|
||||
const hardware = Preferences.data.hardware ?? null;
|
||||
return { hardware, recommendedFarClip: recommendFarClip(hardware) };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import os from 'os';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import Logger from 'electron-log/main';
|
||||
@@ -12,6 +12,12 @@ import { patchConfig } from '~main/modules/patcher';
|
||||
import { applyLocalePatch } from '~main/modules/localePatch';
|
||||
import { minimizeToTray, restoreFromTray } from '~main/modules/tray';
|
||||
import { getMod } from '~common/mods';
|
||||
import {
|
||||
ensureWowDesktopEntry,
|
||||
gameLaunchEnv,
|
||||
spawnDetachedGame,
|
||||
spawnWithProton
|
||||
} from '~main/modules/proton';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
@@ -33,7 +39,13 @@ type StartResult = { ok: boolean; error?: string };
|
||||
|
||||
export const launcherRouter = createTRPCRouter({
|
||||
start: publicProcedure.mutation(async (): Promise<StartResult> => {
|
||||
const { cleanWdb, minimizeToTrayOnPlay, clientDir } = Preferences.data;
|
||||
const {
|
||||
cleanWdb,
|
||||
minimizeToTrayOnPlay,
|
||||
clientDir,
|
||||
useProton,
|
||||
protonPath
|
||||
} = Preferences.data;
|
||||
if (!clientDir) return { ok: false, error: 'No game folder is set.' };
|
||||
|
||||
const exePath = path.join(clientDir, 'WoW.exe');
|
||||
@@ -64,22 +76,49 @@ export const launcherRouter = createTRPCRouter({
|
||||
|
||||
const octoLocale = Preferences.data.locale || 'enUS';
|
||||
const gameEnv = { ...process.env, OCTO_LOCALE: octoLocale };
|
||||
const launchExe = useLoader ? loaderPath : exePath;
|
||||
const launchArgs = useLoader ? ['WoW.exe'] : [];
|
||||
const wantProton = os.platform() === 'linux' && !!useProton;
|
||||
|
||||
if (wantProton && !protonPath)
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Proton is enabled but no Proton version is selected.'
|
||||
};
|
||||
|
||||
Logger.log(
|
||||
useLoader
|
||||
wantProton
|
||||
? `Launching via Proton${useLoader ? ' + VanillaFixes' : ''} (OCTO_LOCALE=${octoLocale})...`
|
||||
: useLoader
|
||||
? `Launching via VanillaFixes (OCTO_LOCALE=${octoLocale})...`
|
||||
: `Launching ${exePath} (OCTO_LOCALE=${octoLocale})...`
|
||||
);
|
||||
const child = useLoader
|
||||
? spawn(loaderPath, ['WoW.exe'], {
|
||||
env: gameEnv,
|
||||
|
||||
const launchEnv = gameLaunchEnv(gameEnv);
|
||||
if (os.platform() === 'linux')
|
||||
await ensureWowDesktopEntry(clientDir).catch(e =>
|
||||
Logger.warn('Failed to write WoW desktop entry', e)
|
||||
);
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = wantProton
|
||||
? await spawnWithProton({
|
||||
protonDir: protonPath!,
|
||||
exePath: launchExe,
|
||||
args: launchArgs,
|
||||
cwd: clientDir,
|
||||
detached: !minimizeToTrayOnPlay
|
||||
env: launchEnv
|
||||
})
|
||||
: spawn(exePath, {
|
||||
env: gameEnv,
|
||||
cwd: clientDir,
|
||||
detached: !minimizeToTrayOnPlay
|
||||
: spawnDetachedGame(launchExe, launchArgs, {
|
||||
env: launchEnv,
|
||||
cwd: clientDir
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.error('Failed to launch the game', e);
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: `Failed to launch the game: ${message}` };
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -93,6 +132,13 @@ export const launcherRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
child.on('error', e => Logger.error('Game process error', e));
|
||||
// VanillaFixes / Proton often exit right after spawning WoW — don't treat
|
||||
// that as the game ending. Tray restore uses isGameRunning polling.
|
||||
child.on('exit', code => {
|
||||
Logger.log(
|
||||
`Launch helper exited (code=${code}); tracking WoW.exe separately`
|
||||
);
|
||||
});
|
||||
|
||||
if (!minimizeToTrayOnPlay) {
|
||||
mainWindow?.close();
|
||||
@@ -100,10 +146,34 @@ export const launcherRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
minimizeToTray();
|
||||
child.on('exit', () => {
|
||||
Logger.log('WoW stopped');
|
||||
restoreFromTray();
|
||||
});
|
||||
void watchUntilGameExits(exePath);
|
||||
return { ok: true };
|
||||
})
|
||||
});
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
||||
|
||||
/** Wait for WoW.exe to appear (chainloader/proton lag), then restore tray on exit. */
|
||||
const watchUntilGameExits = async (exePath: string) => {
|
||||
const appearDeadline = Date.now() + 45_000;
|
||||
let seen = false;
|
||||
while (Date.now() < appearDeadline) {
|
||||
if (await isGameRunning(exePath)) {
|
||||
seen = true;
|
||||
break;
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
if (!seen) {
|
||||
Logger.warn('WoW.exe never appeared after launch; restoring tray');
|
||||
restoreFromTray();
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.log('WoW.exe detected; waiting for it to exit...');
|
||||
while (await isGameRunning(exePath)) {
|
||||
await sleep(2000);
|
||||
}
|
||||
Logger.log('WoW stopped');
|
||||
restoreFromTray();
|
||||
};
|
||||
|
||||
+6
-3
@@ -5,14 +5,13 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils';
|
||||
import { createIPCHandler } from 'electron-trpc/main';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
import icon from '~build/icon.png?asset';
|
||||
|
||||
import { appRouter } from './api/root';
|
||||
import Preferences from './modules/preferences';
|
||||
import Updater from './modules/updater';
|
||||
import Addons from './modules/addons';
|
||||
import Mods from './modules/mods';
|
||||
import { initSelfUpdater } from './modules/selfUpdater';
|
||||
import { loadAppIcon } from './modules/appIcon';
|
||||
import {
|
||||
detectHardware,
|
||||
recommendFarClip,
|
||||
@@ -55,11 +54,12 @@ const createWindow = async () => {
|
||||
: undefined;
|
||||
const position = saved ?? { width: 1000, height: 700 };
|
||||
|
||||
const appIcon = loadAppIcon();
|
||||
mainWindow = new BrowserWindow({
|
||||
...position,
|
||||
minWidth: 1000,
|
||||
minHeight: 700,
|
||||
icon,
|
||||
...(appIcon ? { icon: appIcon } : {}),
|
||||
frame: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
@@ -71,6 +71,9 @@ const createWindow = async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Some Linux WMs ignore constructor icon; set again after create.
|
||||
if (appIcon) mainWindow.setIcon(appIcon);
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (_e, details) => {
|
||||
Logger.error('Renderer process gone:', details);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, nativeImage, type NativeImage } from 'electron';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
/**
|
||||
* Resolve the app icon without the electron-vite `?asset` pipeline
|
||||
* (that emits into out/main/chunks/, which electron-builder excludes).
|
||||
*
|
||||
* Packaged Linux builds also put a copy at resources/icon.png via extraResources.
|
||||
*/
|
||||
export const resolveAppIconPath = (): string | undefined => {
|
||||
const appPath = app.getAppPath();
|
||||
const candidates = [
|
||||
// electron-builder extraResources
|
||||
path.join(process.resourcesPath, 'icon.png'),
|
||||
path.join(appPath, 'build', 'icon.png'),
|
||||
path.join(
|
||||
appPath.replace(/app\.asar$/, 'app.asar.unpacked'),
|
||||
'build',
|
||||
'icon.png'
|
||||
),
|
||||
path.join(process.resourcesPath, 'app.asar.unpacked', 'build', 'icon.png'),
|
||||
// electron-vite / repo checkout: out/main -> ../../build/icon.png
|
||||
path.join(__dirname, '../../build/icon.png'),
|
||||
path.join(__dirname, '../../../build/icon.png')
|
||||
];
|
||||
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(p)) return p;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
Logger.warn(
|
||||
'App icon not found. Checked:\n' + candidates.map(c => ` ${c}`).join('\n')
|
||||
);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const loadAppIcon = (): NativeImage | undefined => {
|
||||
const p = resolveAppIconPath();
|
||||
if (!p) return undefined;
|
||||
const img = nativeImage.createFromPath(p);
|
||||
if (img.isEmpty()) {
|
||||
Logger.warn(`App icon at ${p} loaded empty`);
|
||||
return undefined;
|
||||
}
|
||||
return img;
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { spawn, type ChildProcess, execFileSync } from 'node:child_process';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { app } from 'electron';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
export type ProtonInstall = {
|
||||
/** Display name, e.g. "Proton 9.0" or "GE-Proton9-22". */
|
||||
name: string;
|
||||
/** Directory containing the `proton` launcher script. */
|
||||
path: string;
|
||||
};
|
||||
|
||||
const steamRoots = (): string[] => {
|
||||
const home = os.homedir();
|
||||
return [
|
||||
path.join(home, '.local', 'share', 'Steam'),
|
||||
path.join(home, '.steam', 'steam'),
|
||||
path.join(home, '.steam', 'root'),
|
||||
path.join(
|
||||
home,
|
||||
'.var',
|
||||
'app',
|
||||
'com.valvesoftware.Steam',
|
||||
'.local',
|
||||
'share',
|
||||
'Steam'
|
||||
),
|
||||
path.join(
|
||||
home,
|
||||
'.var',
|
||||
'app',
|
||||
'com.valvesoftware.Steam',
|
||||
'data',
|
||||
'Steam'
|
||||
)
|
||||
];
|
||||
};
|
||||
|
||||
const isProtonDir = async (dir: string): Promise<boolean> => {
|
||||
try {
|
||||
const proton = path.join(dir, 'proton');
|
||||
const st = await fs.stat(proton);
|
||||
return st.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const scanCommon = async (steamRoot: string): Promise<ProtonInstall[]> => {
|
||||
const common = path.join(steamRoot, 'steamapps', 'common');
|
||||
if (!(await fs.pathExists(common))) return [];
|
||||
|
||||
const entries = await fs.readdir(common);
|
||||
const found: ProtonInstall[] = [];
|
||||
for (const name of entries) {
|
||||
if (!/^Proton/i.test(name)) continue;
|
||||
const dir = path.join(common, name);
|
||||
if (await isProtonDir(dir)) found.push({ name, path: dir });
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const scanCompatTools = async (steamRoot: string): Promise<ProtonInstall[]> => {
|
||||
const toolsDir = path.join(steamRoot, 'compatibilitytools.d');
|
||||
if (!(await fs.pathExists(toolsDir))) return [];
|
||||
|
||||
const entries = await fs.readdir(toolsDir);
|
||||
const found: ProtonInstall[] = [];
|
||||
for (const name of entries) {
|
||||
const dir = path.join(toolsDir, name);
|
||||
if (await isProtonDir(dir)) found.push({ name, path: dir });
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
/** Unique existing Steam roots (`.steam/steam` etc. are usually symlinks). */
|
||||
const uniqueSteamRoots = async (): Promise<string[]> => {
|
||||
const seen = new Set<string>();
|
||||
const roots: string[] = [];
|
||||
for (const root of steamRoots()) {
|
||||
if (!(await fs.pathExists(root))) continue;
|
||||
let real: string;
|
||||
try {
|
||||
real = await fs.realpath(root);
|
||||
} catch {
|
||||
real = root;
|
||||
}
|
||||
if (seen.has(real)) continue;
|
||||
seen.add(real);
|
||||
roots.push(real);
|
||||
}
|
||||
return roots;
|
||||
};
|
||||
|
||||
/**
|
||||
* Discover installed Proton versions under common Steam paths.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const versions = await listProtonVersions();
|
||||
* // [{ name: 'Proton 9.0', path: '/home/…/steamapps/common/Proton 9.0' }, …]
|
||||
* ```
|
||||
*/
|
||||
export const listProtonVersions = async (): Promise<ProtonInstall[]> => {
|
||||
if (os.platform() !== 'linux') return [];
|
||||
|
||||
const byRealPath = new Map<string, ProtonInstall>();
|
||||
for (const root of await uniqueSteamRoots()) {
|
||||
for (const install of [
|
||||
...(await scanCommon(root)),
|
||||
...(await scanCompatTools(root))
|
||||
]) {
|
||||
let real: string;
|
||||
try {
|
||||
real = await fs.realpath(install.path);
|
||||
} catch {
|
||||
real = install.path;
|
||||
}
|
||||
if (byRealPath.has(real)) continue;
|
||||
byRealPath.set(real, { name: install.name, path: real });
|
||||
}
|
||||
}
|
||||
|
||||
return [...byRealPath.values()].sort((a, b) =>
|
||||
b.name.localeCompare(a.name, undefined, { numeric: true })
|
||||
);
|
||||
};
|
||||
|
||||
const resolveSteamClientPath = async (): Promise<string | undefined> => {
|
||||
const roots = await uniqueSteamRoots();
|
||||
return roots[0];
|
||||
};
|
||||
|
||||
export type ProtonLaunchOptions = {
|
||||
protonDir: string;
|
||||
exePath: string;
|
||||
args?: string[];
|
||||
cwd: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
/** Env that ties child windows to the Electron/AppImage launcher on Linux DEs. */
|
||||
const LAUNCHER_IDENTITY_ENV = [
|
||||
'BAMF_DESKTOP_FILE_HINT',
|
||||
'DESKTOP_STARTUP_ID',
|
||||
'GIO_LAUNCHED_DESKTOP_FILE',
|
||||
'GIO_LAUNCHED_DESKTOP_FILE_PID',
|
||||
'XDG_ACTIVATION_TOKEN',
|
||||
// AppImage — Plasma groups children that still carry these as the AppImage app
|
||||
'APPIMAGE',
|
||||
'APPDIR',
|
||||
'OWD',
|
||||
'ARGV0',
|
||||
'APPIMAGE_EXTRACT_AND_RUN',
|
||||
'APPIMAGE_SILENT_MESSAGE'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Env for a game process that should not inherit the launcher's taskbar identity.
|
||||
*/
|
||||
export const gameLaunchEnv = (
|
||||
extra: NodeJS.ProcessEnv = {}
|
||||
): NodeJS.ProcessEnv => {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, ...extra };
|
||||
for (const key of LAUNCHER_IDENTITY_ENV) delete env[key];
|
||||
return env;
|
||||
};
|
||||
|
||||
const hasSystemdRun = (): boolean => {
|
||||
try {
|
||||
execFileSync('systemd-run', ['--version'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Spawn outside Electron's process/app scope so Plasma/GNOME don't paint the
|
||||
* game with OctoLauncher's icon.
|
||||
*
|
||||
* Prefers `systemd-run --user --scope` (new cgroup), else `setsid`.
|
||||
*/
|
||||
export const spawnDetachedGame = (
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: { cwd: string; env: NodeJS.ProcessEnv }
|
||||
): ChildProcess => {
|
||||
const { cwd, env } = opts;
|
||||
|
||||
if (hasSystemdRun()) {
|
||||
const unit = `octolauncher-game-${process.pid}-${Date.now()}`;
|
||||
Logger.log(`Launching via systemd-run scope (${unit})`);
|
||||
const child = spawn(
|
||||
'systemd-run',
|
||||
[
|
||||
'--user',
|
||||
'--scope',
|
||||
'--collect',
|
||||
`--unit=${unit}`,
|
||||
`--working-directory=${cwd}`,
|
||||
command,
|
||||
...args
|
||||
],
|
||||
{ env, detached: true, stdio: 'ignore' }
|
||||
);
|
||||
child.unref();
|
||||
return child;
|
||||
}
|
||||
|
||||
Logger.log('Launching via setsid (systemd-run unavailable)');
|
||||
const child = spawn('setsid', ['--fork', command, ...args], {
|
||||
env,
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
child.unref();
|
||||
return child;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a .desktop entry so the DE matches Wine's WM_CLASS to WoW, not us.
|
||||
* Wine typically reports class `wow.exe` / `WoW.exe`.
|
||||
*/
|
||||
export const ensureWowDesktopEntry = async (
|
||||
clientDir: string
|
||||
): Promise<void> => {
|
||||
if (os.platform() !== 'linux') return;
|
||||
|
||||
const appsDir = path.join(os.homedir(), '.local', 'share', 'applications');
|
||||
await fs.ensureDir(appsDir);
|
||||
const desktopPath = path.join(appsDir, 'octolauncher-wow.desktop');
|
||||
|
||||
// Prefer a client-local icon if present; else omit (Wine supplies _NET_WM_ICON).
|
||||
const iconCandidates = [
|
||||
path.join(clientDir, 'Wow.ico'),
|
||||
path.join(clientDir, 'WoW.ico'),
|
||||
path.join(clientDir, 'wow.ico'),
|
||||
path.join(clientDir, 'Wow.png'),
|
||||
path.join(clientDir, 'WoW.png')
|
||||
];
|
||||
let iconLine = '';
|
||||
for (const p of iconCandidates) {
|
||||
if (await fs.pathExists(p)) {
|
||||
iconLine = `Icon=${p}\n`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const body =
|
||||
'[Desktop Entry]\n' +
|
||||
'Type=Application\n' +
|
||||
'Name=World of Warcraft\n' +
|
||||
'Comment=Launched via OctoLauncher\n' +
|
||||
'Exec=true\n' +
|
||||
'Terminal=false\n' +
|
||||
'NoDisplay=true\n' +
|
||||
'StartupNotify=false\n' +
|
||||
'StartupWMClass=wow.exe\n' +
|
||||
iconLine;
|
||||
|
||||
await fs.writeFile(desktopPath, body, 'utf8');
|
||||
try {
|
||||
execFileSync('update-desktop-database', [appsDir], { stdio: 'ignore' });
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Spawn `proton run <exe> …` with the STEAM_COMPAT_* env Proton expects
|
||||
* for non-Steam titles.
|
||||
*/
|
||||
export const spawnWithProton = async ({
|
||||
protonDir,
|
||||
exePath,
|
||||
args = [],
|
||||
cwd,
|
||||
env = {}
|
||||
}: ProtonLaunchOptions): Promise<ChildProcess> => {
|
||||
const protonBin = path.join(protonDir, 'proton');
|
||||
if (!(await fs.pathExists(protonBin)))
|
||||
throw new Error(`Proton launcher not found at ${protonBin}`);
|
||||
|
||||
const steamClient =
|
||||
(await resolveSteamClientPath()) ?? path.dirname(protonDir);
|
||||
const compatData = path.join(app.getPath('userData'), 'proton-prefix');
|
||||
await fs.ensureDir(compatData);
|
||||
|
||||
const protonEnv = gameLaunchEnv({
|
||||
...env,
|
||||
STEAM_COMPAT_CLIENT_INSTALL_PATH: steamClient,
|
||||
STEAM_COMPAT_DATA_PATH: compatData
|
||||
});
|
||||
|
||||
Logger.log(
|
||||
`Launching via Proton (${path.basename(protonDir)}): ${protonBin} run ${exePath}`
|
||||
);
|
||||
|
||||
return spawnDetachedGame(protonBin, ['run', exePath, ...args], {
|
||||
cwd,
|
||||
env: protonEnv
|
||||
});
|
||||
};
|
||||
@@ -19,6 +19,19 @@ export type SelfUpdaterStatus =
|
||||
| { state: 'ready'; currentVersion: string; nextVersion: string }
|
||||
| { state: 'error'; currentVersion: string; message: string };
|
||||
|
||||
/** Missing latest(-linux).yml / 404 — expected until a Linux feed is published. */
|
||||
const isMissingUpdateFeed = (err: unknown): boolean => {
|
||||
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||
return (
|
||||
msg.includes('cannot find channel') ||
|
||||
msg.includes('latest-linux.yml') ||
|
||||
(msg.includes('404') && msg.includes('latest'))
|
||||
);
|
||||
};
|
||||
|
||||
const errMessage = (err: unknown) =>
|
||||
err instanceof Error ? err.message : String(err);
|
||||
|
||||
class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
|
||||
protected _value: SelfUpdaterStatus = {
|
||||
state: 'idle',
|
||||
@@ -48,7 +61,21 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
|
||||
|
||||
const currentVersion = app.getVersion();
|
||||
|
||||
autoUpdater.logger = Logger;
|
||||
// Downgrade expected missing-feed noise from electron-updater itself.
|
||||
autoUpdater.logger = {
|
||||
info: (...a: unknown[]) => Logger.info(...a),
|
||||
warn: (...a: unknown[]) => Logger.warn(...a),
|
||||
debug: (...a: unknown[]) => Logger.debug(...a),
|
||||
error: (...a: unknown[]) => {
|
||||
if (a.some(isMissingUpdateFeed)) {
|
||||
Logger.info(
|
||||
'[selfUpdater] no update feed for this platform (skipping)'
|
||||
);
|
||||
return;
|
||||
}
|
||||
Logger.error(...a);
|
||||
}
|
||||
};
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
|
||||
@@ -70,11 +97,18 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
|
||||
this.status = { state: 'unavailable', currentVersion };
|
||||
});
|
||||
autoUpdater.on('error', err => {
|
||||
if (isMissingUpdateFeed(err)) {
|
||||
Logger.info(
|
||||
'[selfUpdater] update feed not published yet; treating as up to date'
|
||||
);
|
||||
this.status = { state: 'unavailable', currentVersion };
|
||||
return;
|
||||
}
|
||||
Logger.error('[selfUpdater] error', err);
|
||||
this.status = {
|
||||
state: 'error',
|
||||
currentVersion,
|
||||
message: err?.message ?? String(err)
|
||||
message: errMessage(err)
|
||||
};
|
||||
});
|
||||
autoUpdater.on('download-progress', p => {
|
||||
@@ -98,6 +132,13 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
|
||||
});
|
||||
|
||||
autoUpdater.checkForUpdates().catch(err => {
|
||||
if (isMissingUpdateFeed(err)) {
|
||||
Logger.info(
|
||||
'[selfUpdater] update feed not published yet; treating as up to date'
|
||||
);
|
||||
this.status = { state: 'unavailable', currentVersion };
|
||||
return;
|
||||
}
|
||||
Logger.error('[selfUpdater] checkForUpdates failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Tray, Menu, nativeImage, app } from 'electron';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
import icon from '~build/icon.png?asset';
|
||||
|
||||
import { resolveAppIconPath } from '~main/modules/appIcon';
|
||||
import { mainWindow } from '~main/index';
|
||||
|
||||
let tray: Tray | null = null;
|
||||
@@ -16,9 +19,52 @@ const restoreWindow = () => {
|
||||
isMinimizedToTray = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Linux StatusNotifierItem often fails with asar paths / tiny 16px icons
|
||||
* (blank/black slot). Copy the PNG to a real filesystem path at 32px.
|
||||
*/
|
||||
const loadTrayIcon = () => {
|
||||
const size = process.platform === 'linux' ? 32 : 16;
|
||||
const src = resolveAppIconPath();
|
||||
if (!src) {
|
||||
Logger.warn('App icon not found for tray');
|
||||
return nativeImage.createEmpty();
|
||||
}
|
||||
|
||||
const tmpSrc = path.join(os.tmpdir(), 'octolauncher-icon-src.png');
|
||||
const tmpTray = path.join(os.tmpdir(), 'octolauncher-tray.png');
|
||||
|
||||
try {
|
||||
fs.copyFileSync(src, tmpSrc);
|
||||
} catch (e) {
|
||||
Logger.warn('Failed to copy tray icon', e);
|
||||
return nativeImage.createFromPath(src).resize({
|
||||
width: size,
|
||||
height: size,
|
||||
quality: 'best'
|
||||
});
|
||||
}
|
||||
|
||||
let img = nativeImage.createFromPath(tmpSrc);
|
||||
img = img.resize({ width: size, height: size, quality: 'best' });
|
||||
|
||||
if (process.platform === 'linux' && !img.isEmpty()) {
|
||||
try {
|
||||
fs.writeFileSync(tmpTray, Uint8Array.from(img.toPNG()));
|
||||
const fromDisk = nativeImage.createFromPath(tmpTray);
|
||||
if (!fromDisk.isEmpty()) return fromDisk;
|
||||
} catch (e) {
|
||||
Logger.warn('Failed to materialize tray icon on disk', e);
|
||||
}
|
||||
}
|
||||
return img;
|
||||
};
|
||||
|
||||
const ensureTray = () => {
|
||||
if (tray) return tray;
|
||||
const trayIcon = nativeImage.createFromPath(icon).resize({ width: 16, height: 16 });
|
||||
const trayIcon = loadTrayIcon();
|
||||
if (trayIcon.isEmpty())
|
||||
Logger.warn('Tray icon is empty — panel may show a blank slot');
|
||||
tray = new Tray(trayIcon);
|
||||
tray.setToolTip('OctoLauncher');
|
||||
tray.setContextMenu(
|
||||
|
||||
@@ -131,10 +131,10 @@ const getManifestItem = (
|
||||
);
|
||||
};
|
||||
|
||||
export const isGameRunning = (executablePath: string) =>
|
||||
os.platform() === 'win32'
|
||||
? new Promise<boolean>(resolve => {
|
||||
export const isGameRunning = (executablePath: string) => {
|
||||
const exeName = path.basename(executablePath);
|
||||
if (os.platform() === 'win32')
|
||||
return new Promise<boolean>(resolve => {
|
||||
exec(
|
||||
`tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH`,
|
||||
(error, stdout) => {
|
||||
@@ -151,8 +151,24 @@ export const isGameRunning = (executablePath: string) =>
|
||||
);
|
||||
}
|
||||
);
|
||||
})
|
||||
: false;
|
||||
});
|
||||
|
||||
if (os.platform() === 'linux')
|
||||
return new Promise<boolean>(resolve => {
|
||||
// Match wine/proton cmdlines. Bracket trick avoids matching this pgrep.
|
||||
const needle = exeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = needle.replace(/^([A-Za-z])/, '[$1]');
|
||||
exec(`pgrep -afi '${pattern}'`, (error, stdout) => {
|
||||
if (error) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim().length > 0);
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.resolve(false);
|
||||
};
|
||||
|
||||
const toUrlPath = (p: string) =>
|
||||
p.split(path.sep).map(encodeURIComponent).join('/');
|
||||
|
||||
@@ -38,10 +38,18 @@ const MirrorStatus = () => {
|
||||
|
||||
type Props = { close: () => void };
|
||||
|
||||
const isLinux =
|
||||
typeof window !== 'undefined' &&
|
||||
window.electron?.process?.platform === 'linux';
|
||||
|
||||
const PreferencesDialog = ({ close }: Props) => {
|
||||
const t = useT();
|
||||
const { data: pref } = api.preferences.get.useQuery();
|
||||
const setPref = api.preferences.set.useMutation();
|
||||
const { data: protonVersions = [] } = api.general.protonVersions.useQuery(
|
||||
undefined,
|
||||
{ enabled: isLinux }
|
||||
);
|
||||
|
||||
const verify = api.updater.verify.useMutation();
|
||||
const repair = api.mods.repair.useMutation();
|
||||
@@ -65,13 +73,28 @@ const PreferencesDialog = ({ close }: Props) => {
|
||||
shouldValidate: true
|
||||
});
|
||||
|
||||
const useProton = !!watch('useProton');
|
||||
const protonPath = watch('protonPath');
|
||||
|
||||
useEffect(() => {
|
||||
if (!useProton || !protonVersions.length) return;
|
||||
const stillValid = protonVersions.some(v => v.path === protonPath);
|
||||
if (!stillValid)
|
||||
setValue('protonPath', protonVersions[0].path, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true
|
||||
});
|
||||
}, [useProton, protonVersions, protonPath, setValue]);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1"
|
||||
onSubmit={handleSubmit(async v => {
|
||||
await setPref.mutateAsync({
|
||||
cleanWdb: v.cleanWdb,
|
||||
minimizeToTrayOnPlay: v.minimizeToTrayOnPlay
|
||||
minimizeToTrayOnPlay: v.minimizeToTrayOnPlay,
|
||||
useProton: v.useProton,
|
||||
protonPath: v.protonPath
|
||||
});
|
||||
close();
|
||||
})}
|
||||
@@ -193,6 +216,48 @@ const PreferencesDialog = ({ close }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLinux && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<h4 className="tw-color">{t('prefs.proton')}</h4>
|
||||
<CheckboxInput
|
||||
value={useProton}
|
||||
setValue={setBool('useProton')}
|
||||
label={t('prefs.useProton')}
|
||||
/>
|
||||
{useProton && (
|
||||
<>
|
||||
<label className="s1 flex flex-col gap-0.5 pl-2 text-blueGray">
|
||||
{t('prefs.protonVersion')}
|
||||
<select
|
||||
className="border border-blueGray/30 bg-darkGray px-2 py-1 text-white outline-none focus:border-warmGreen"
|
||||
value={protonPath ?? ''}
|
||||
onChange={e =>
|
||||
setValue('protonPath', e.target.value || undefined, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
>
|
||||
{protonVersions.length === 0 && (
|
||||
<option value="">{t('prefs.protonNone')}</option>
|
||||
)}
|
||||
{protonVersions.map(v => (
|
||||
<option key={v.path} value={v.path}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{protonVersions.length === 0 && (
|
||||
<span className="s1 pl-2 text-orange">
|
||||
{t('prefs.protonNoneHint')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextButton type="submit" className="mt-1 self-end text-green">
|
||||
{t('prefs.save')}
|
||||
</TextButton>
|
||||
|
||||
Vendored
+8
@@ -4,3 +4,11 @@ interface ImportMetaEnv {
|
||||
readonly MAIN_VITE_SERVER_URL: string;
|
||||
readonly MAIN_VITE_CLIENT_VERSION: string;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
electron?: {
|
||||
process?: {
|
||||
platform?: NodeJS.Platform;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,6 +212,12 @@ const enUS: Dict = {
|
||||
'prefs.generalSettings': 'GENERAL SETTINGS:',
|
||||
'prefs.cleanWdb': 'Clean WDB on each launch',
|
||||
'prefs.minimizeToTray': 'Minimize to tray while playing',
|
||||
'prefs.proton': 'PROTON:',
|
||||
'prefs.useProton': 'Launch with Proton',
|
||||
'prefs.protonVersion': 'Proton version',
|
||||
'prefs.protonNone': 'No Proton installs found',
|
||||
'prefs.protonNoneHint':
|
||||
'Install Proton via Steam (Steam Play) or place GE-Proton in compatibilitytools.d.',
|
||||
'prefs.save': 'Save',
|
||||
'prefs.installLocationTitle': 'Install location',
|
||||
'prefs.portableInfo':
|
||||
@@ -417,6 +423,12 @@ const deDE: Dict = {
|
||||
'prefs.generalSettings': 'ALLGEMEINE EINSTELLUNGEN:',
|
||||
'prefs.cleanWdb': 'WDB bei jedem Start leeren',
|
||||
'prefs.minimizeToTray': 'Während des Spielens in den Infobereich minimieren',
|
||||
'prefs.proton': 'PROTON:',
|
||||
'prefs.useProton': 'Mit Proton starten',
|
||||
'prefs.protonVersion': 'Proton-Version',
|
||||
'prefs.protonNone': 'Keine Proton-Installation gefunden',
|
||||
'prefs.protonNoneHint':
|
||||
'Installiere Proton über Steam (Steam Play) oder lege GE-Proton in compatibilitytools.d ab.',
|
||||
'prefs.save': 'Speichern',
|
||||
'prefs.installLocationTitle': 'Installationsort',
|
||||
'prefs.portableInfo':
|
||||
@@ -627,6 +639,12 @@ const zhCN: Dict = {
|
||||
'prefs.generalSettings': '常规设置:',
|
||||
'prefs.cleanWdb': '每次启动时清理 WDB',
|
||||
'prefs.minimizeToTray': '游戏时最小化到托盘',
|
||||
'prefs.proton': 'PROTON:',
|
||||
'prefs.useProton': '使用 Proton 启动',
|
||||
'prefs.protonVersion': 'Proton 版本',
|
||||
'prefs.protonNone': '未找到 Proton 安装',
|
||||
'prefs.protonNoneHint':
|
||||
'请通过 Steam(Steam Play)安装 Proton,或将 GE-Proton 放入 compatibilitytools.d。',
|
||||
'prefs.save': '保存',
|
||||
'prefs.installLocationTitle': '安装位置',
|
||||
'prefs.portableInfo':
|
||||
@@ -849,6 +867,12 @@ const esES: Dict = {
|
||||
'prefs.generalSettings': 'AJUSTES GENERALES:',
|
||||
'prefs.cleanWdb': 'Limpiar WDB en cada inicio',
|
||||
'prefs.minimizeToTray': 'Minimizar a la bandeja mientras juegas',
|
||||
'prefs.proton': 'PROTON:',
|
||||
'prefs.useProton': 'Iniciar con Proton',
|
||||
'prefs.protonVersion': 'Versión de Proton',
|
||||
'prefs.protonNone': 'No se encontraron instalaciones de Proton',
|
||||
'prefs.protonNoneHint':
|
||||
'Instala Proton vía Steam (Steam Play) o coloca GE-Proton en compatibilitytools.d.',
|
||||
'prefs.save': 'Guardar',
|
||||
'prefs.installLocationTitle': 'Ubicación de instalación',
|
||||
'prefs.portableInfo':
|
||||
@@ -1074,6 +1098,12 @@ const ptBR: Dict = {
|
||||
'prefs.generalSettings': 'CONFIGURAÇÕES GERAIS:',
|
||||
'prefs.cleanWdb': 'Limpar WDB a cada inicialização',
|
||||
'prefs.minimizeToTray': 'Minimizar para a bandeja durante o jogo',
|
||||
'prefs.proton': 'PROTON:',
|
||||
'prefs.useProton': 'Iniciar com Proton',
|
||||
'prefs.protonVersion': 'Versão do Proton',
|
||||
'prefs.protonNone': 'Nenhuma instalação do Proton encontrada',
|
||||
'prefs.protonNoneHint':
|
||||
'Instale o Proton via Steam (Steam Play) ou coloque o GE-Proton em compatibilitytools.d.',
|
||||
'prefs.save': 'Salvar',
|
||||
'prefs.installLocationTitle': 'Local de instalação',
|
||||
'prefs.portableInfo':
|
||||
@@ -1294,6 +1324,12 @@ const ruRU: Dict = {
|
||||
'prefs.generalSettings': 'ОБЩИЕ НАСТРОЙКИ:',
|
||||
'prefs.cleanWdb': 'Очищать WDB при каждом запуске',
|
||||
'prefs.minimizeToTray': 'Сворачивать в трей во время игры',
|
||||
'prefs.proton': 'PROTON:',
|
||||
'prefs.useProton': 'Запускать через Proton',
|
||||
'prefs.protonVersion': 'Версия Proton',
|
||||
'prefs.protonNone': 'Установки Proton не найдены',
|
||||
'prefs.protonNoneHint':
|
||||
'Установите Proton через Steam (Steam Play) или поместите GE-Proton в compatibilitytools.d.',
|
||||
'prefs.save': 'Сохранить',
|
||||
'prefs.installLocationTitle': 'Папка установки',
|
||||
'prefs.portableInfo':
|
||||
|
||||
Reference in New Issue
Block a user