Linux build with proton picker

This commit is contained in:
2026-07-29 22:33:23 -04:00
committed by Patrick Begley
parent 5812065b56
commit 275166559a
18 changed files with 862 additions and 54 deletions
+25 -2
View File
@@ -2,11 +2,11 @@ name: Build check
on: on:
push: push:
branches: [main, master] branches: [main, master, linux_build]
pull_request: pull_request:
jobs: jobs:
build: build-windows:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
@@ -32,3 +32,26 @@ jobs:
run: npm run build run: npm run build
env: env:
ELECTRON_RUN_AS_NODE: '' 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
-1
View File
@@ -16,5 +16,4 @@ Tools/launcher/node/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
scripts/
hooks/ hooks/
+7
View File
@@ -27,8 +27,15 @@ files:
- '!**/node_modules/**/build/Release/obj/**' - '!**/node_modules/**/build/Release/obj/**'
- '!**/node_modules/**/build/Release/{*.iobj,*.ipdb,*.recipe,*.exp,*.lib,*.pdb,*.obj}' - '!**/node_modules/**/build/Release/{*.iobj,*.ipdb,*.recipe,*.exp,*.lib,*.pdb,*.obj}'
- '!**/node_modules/**/*.{vcxproj,vcxproj.filters}' - '!**/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 npmRebuild: false
electronLanguages: en electronLanguages: en
linux:
icon: build/icon.png
category: Game
win: win:
artifactName: ${productName}.${ext} artifactName: ${productName}.${ext}
target: target:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "octo-launcher", "name": "octo-launcher",
"version": "1.2.0", "version": "1.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "octo-launcher", "name": "octo-launcher",
"version": "1.2.0", "version": "1.2.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^1.0.3", "@electron-toolkit/preload": "^1.0.3",
+2 -1
View File
@@ -16,7 +16,8 @@
"pack": "electron-builder --config", "pack": "electron-builder --config",
"pack:ptr": "electron-builder --config electron-builder.ptr.yml", "pack:ptr": "electron-builder --config electron-builder.ptr.yml",
"dist": "tsc && npm run build && npm run pack", "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": { "dependencies": {
"@electron-toolkit/preload": "^1.0.3", "@electron-toolkit/preload": "^1.0.3",
+126
View File
@@ -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
+5 -1
View File
@@ -74,7 +74,11 @@ export const PreferencesSchema = z.object({
config: ConfigWtfSchema.default({}), config: ConfigWtfSchema.default({}),
mods: z.record(ModStateSchema).default({}), mods: z.record(ModStateSchema).default({}),
hardware: HardwareInfoSchema.optional(), 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>; export type PreferencesSchema = z.infer<typeof PreferencesSchema>;
+3
View File
@@ -6,11 +6,14 @@ import { mainWindow } from '~main/index';
import Preferences from '~main/modules/preferences'; import Preferences from '~main/modules/preferences';
import { addDefenderExclusions } from '~main/modules/defender'; import { addDefenderExclusions } from '~main/modules/defender';
import { detectHardware, recommendFarClip } from '~main/modules/hardware'; import { detectHardware, recommendFarClip } from '~main/modules/hardware';
import { listProtonVersions } from '~main/modules/proton';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
export const generalRouter = createTRPCRouter({ export const generalRouter = createTRPCRouter({
appVersion: publicProcedure.query(() => app.getVersion()), appVersion: publicProcedure.query(() => app.getVersion()),
platform: publicProcedure.query(() => process.platform),
protonVersions: publicProcedure.query(() => listProtonVersions()),
hardware: publicProcedure.query(() => { hardware: publicProcedure.query(() => {
const hardware = Preferences.data.hardware ?? null; const hardware = Preferences.data.hardware ?? null;
return { hardware, recommendedFarClip: recommendFarClip(hardware) }; return { hardware, recommendedFarClip: recommendFarClip(hardware) };
+88 -18
View File
@@ -1,5 +1,5 @@
import path from 'path'; import path from 'path';
import { spawn } from 'child_process'; import os from 'os';
import fs from 'fs-extra'; import fs from 'fs-extra';
import Logger from 'electron-log/main'; import Logger from 'electron-log/main';
@@ -12,6 +12,12 @@ import { patchConfig } from '~main/modules/patcher';
import { applyLocalePatch } from '~main/modules/localePatch'; import { applyLocalePatch } from '~main/modules/localePatch';
import { minimizeToTray, restoreFromTray } from '~main/modules/tray'; import { minimizeToTray, restoreFromTray } from '~main/modules/tray';
import { getMod } from '~common/mods'; import { getMod } from '~common/mods';
import {
ensureWowDesktopEntry,
gameLaunchEnv,
spawnDetachedGame,
spawnWithProton
} from '~main/modules/proton';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -33,7 +39,13 @@ type StartResult = { ok: boolean; error?: string };
export const launcherRouter = createTRPCRouter({ export const launcherRouter = createTRPCRouter({
start: publicProcedure.mutation(async (): Promise<StartResult> => { 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.' }; if (!clientDir) return { ok: false, error: 'No game folder is set.' };
const exePath = path.join(clientDir, 'WoW.exe'); const exePath = path.join(clientDir, 'WoW.exe');
@@ -64,22 +76,49 @@ export const launcherRouter = createTRPCRouter({
const octoLocale = Preferences.data.locale || 'enUS'; const octoLocale = Preferences.data.locale || 'enUS';
const gameEnv = { ...process.env, OCTO_LOCALE: octoLocale }; 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( Logger.log(
useLoader wantProton
? `Launching via Proton${useLoader ? ' + VanillaFixes' : ''} (OCTO_LOCALE=${octoLocale})...`
: useLoader
? `Launching via VanillaFixes (OCTO_LOCALE=${octoLocale})...` ? `Launching via VanillaFixes (OCTO_LOCALE=${octoLocale})...`
: `Launching ${exePath} (OCTO_LOCALE=${octoLocale})...` : `Launching ${exePath} (OCTO_LOCALE=${octoLocale})...`
); );
const child = useLoader
? spawn(loaderPath, ['WoW.exe'], { const launchEnv = gameLaunchEnv(gameEnv);
env: gameEnv, if (os.platform() === 'linux')
cwd: clientDir, await ensureWowDesktopEntry(clientDir).catch(e =>
detached: !minimizeToTrayOnPlay Logger.warn('Failed to write WoW desktop entry', e)
}) );
: spawn(exePath, {
env: gameEnv, let child;
cwd: clientDir, try {
detached: !minimizeToTrayOnPlay child = wantProton
}); ? await spawnWithProton({
protonDir: protonPath!,
exePath: launchExe,
args: launchArgs,
cwd: clientDir,
env: launchEnv
})
: 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 { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -93,6 +132,13 @@ export const launcherRouter = createTRPCRouter({
} }
child.on('error', e => Logger.error('Game process error', e)); 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) { if (!minimizeToTrayOnPlay) {
mainWindow?.close(); mainWindow?.close();
@@ -100,10 +146,34 @@ export const launcherRouter = createTRPCRouter({
} }
minimizeToTray(); minimizeToTray();
child.on('exit', () => { void watchUntilGameExits(exePath);
Logger.log('WoW stopped');
restoreFromTray();
});
return { ok: true }; 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
View File
@@ -5,14 +5,13 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import { createIPCHandler } from 'electron-trpc/main'; import { createIPCHandler } from 'electron-trpc/main';
import Logger from 'electron-log/main'; import Logger from 'electron-log/main';
import icon from '~build/icon.png?asset';
import { appRouter } from './api/root'; import { appRouter } from './api/root';
import Preferences from './modules/preferences'; import Preferences from './modules/preferences';
import Updater from './modules/updater'; import Updater from './modules/updater';
import Addons from './modules/addons'; import Addons from './modules/addons';
import Mods from './modules/mods'; import Mods from './modules/mods';
import { initSelfUpdater } from './modules/selfUpdater'; import { initSelfUpdater } from './modules/selfUpdater';
import { loadAppIcon } from './modules/appIcon';
import { import {
detectHardware, detectHardware,
recommendFarClip, recommendFarClip,
@@ -55,11 +54,12 @@ const createWindow = async () => {
: undefined; : undefined;
const position = saved ?? { width: 1000, height: 700 }; const position = saved ?? { width: 1000, height: 700 };
const appIcon = loadAppIcon();
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
...position, ...position,
minWidth: 1000, minWidth: 1000,
minHeight: 700, minHeight: 700,
icon, ...(appIcon ? { icon: appIcon } : {}),
frame: false, frame: false,
maximizable: false, maximizable: false,
fullscreenable: 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) => { mainWindow.webContents.on('render-process-gone', (_e, details) => {
Logger.error('Renderer process gone:', details); Logger.error('Renderer process gone:', details);
}); });
+52
View File
@@ -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;
};
+308
View File
@@ -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
});
};
+43 -2
View File
@@ -19,6 +19,19 @@ export type SelfUpdaterStatus =
| { state: 'ready'; currentVersion: string; nextVersion: string } | { state: 'ready'; currentVersion: string; nextVersion: string }
| { state: 'error'; currentVersion: string; message: 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> { class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
protected _value: SelfUpdaterStatus = { protected _value: SelfUpdaterStatus = {
state: 'idle', state: 'idle',
@@ -48,7 +61,21 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
const currentVersion = app.getVersion(); 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.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = false; autoUpdater.autoInstallOnAppQuit = false;
@@ -70,11 +97,18 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
this.status = { state: 'unavailable', currentVersion }; this.status = { state: 'unavailable', currentVersion };
}); });
autoUpdater.on('error', err => { 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); Logger.error('[selfUpdater] error', err);
this.status = { this.status = {
state: 'error', state: 'error',
currentVersion, currentVersion,
message: err?.message ?? String(err) message: errMessage(err)
}; };
}); });
autoUpdater.on('download-progress', p => { autoUpdater.on('download-progress', p => {
@@ -98,6 +132,13 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
}); });
autoUpdater.checkForUpdates().catch(err => { 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); Logger.error('[selfUpdater] checkForUpdates failed', err);
}); });
} }
+49 -3
View File
@@ -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 { Tray, Menu, nativeImage, app } from 'electron';
import Logger from 'electron-log/main'; import Logger from 'electron-log/main';
import icon from '~build/icon.png?asset'; import { resolveAppIconPath } from '~main/modules/appIcon';
import { mainWindow } from '~main/index'; import { mainWindow } from '~main/index';
let tray: Tray | null = null; let tray: Tray | null = null;
@@ -16,9 +19,52 @@ const restoreWindow = () => {
isMinimizedToTray = false; 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 = () => { const ensureTray = () => {
if (tray) return tray; 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 = new Tray(trayIcon);
tray.setToolTip('OctoLauncher'); tray.setToolTip('OctoLauncher');
tray.setContextMenu( tray.setContextMenu(
+36 -20
View File
@@ -131,28 +131,44 @@ const getManifestItem = (
); );
}; };
export const isGameRunning = (executablePath: string) => export const isGameRunning = (executablePath: string) => {
os.platform() === 'win32' const exeName = path.basename(executablePath);
? new Promise<boolean>(resolve => { if (os.platform() === 'win32')
const exeName = path.basename(executablePath); return new Promise<boolean>(resolve => {
exec( exec(
`tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH`, `tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH`,
(error, stdout) => { (error, stdout) => {
if (error) { if (error) {
Logger.warn( Logger.warn(
`tasklist probe for "${exeName}" failed; assuming game ` + `tasklist probe for "${exeName}" failed; assuming game ` +
`is not running. Error: ${error.message}` `is not running. Error: ${error.message}`
);
resolve(false);
return;
}
resolve(
stdout.toLowerCase().includes(`"${exeName.toLowerCase()}"`)
); );
resolve(false);
return;
} }
); resolve(
}) stdout.toLowerCase().includes(`"${exeName.toLowerCase()}"`)
: 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) => const toUrlPath = (p: string) =>
p.split(path.sep).map(encodeURIComponent).join('/'); p.split(path.sep).map(encodeURIComponent).join('/');
+66 -1
View File
@@ -38,10 +38,18 @@ const MirrorStatus = () => {
type Props = { close: () => void }; type Props = { close: () => void };
const isLinux =
typeof window !== 'undefined' &&
window.electron?.process?.platform === 'linux';
const PreferencesDialog = ({ close }: Props) => { const PreferencesDialog = ({ close }: Props) => {
const t = useT(); const t = useT();
const { data: pref } = api.preferences.get.useQuery(); const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation(); const setPref = api.preferences.set.useMutation();
const { data: protonVersions = [] } = api.general.protonVersions.useQuery(
undefined,
{ enabled: isLinux }
);
const verify = api.updater.verify.useMutation(); const verify = api.updater.verify.useMutation();
const repair = api.mods.repair.useMutation(); const repair = api.mods.repair.useMutation();
@@ -65,13 +73,28 @@ const PreferencesDialog = ({ close }: Props) => {
shouldValidate: true 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 ( return (
<form <form
className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1" className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1"
onSubmit={handleSubmit(async v => { onSubmit={handleSubmit(async v => {
await setPref.mutateAsync({ await setPref.mutateAsync({
cleanWdb: v.cleanWdb, cleanWdb: v.cleanWdb,
minimizeToTrayOnPlay: v.minimizeToTrayOnPlay minimizeToTrayOnPlay: v.minimizeToTrayOnPlay,
useProton: v.useProton,
protonPath: v.protonPath
}); });
close(); close();
})} })}
@@ -193,6 +216,48 @@ const PreferencesDialog = ({ close }: Props) => {
</div> </div>
</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"> <TextButton type="submit" className="mt-1 self-end text-green">
{t('prefs.save')} {t('prefs.save')}
</TextButton> </TextButton>
+8
View File
@@ -4,3 +4,11 @@ interface ImportMetaEnv {
readonly MAIN_VITE_SERVER_URL: string; readonly MAIN_VITE_SERVER_URL: string;
readonly MAIN_VITE_CLIENT_VERSION: string; readonly MAIN_VITE_CLIENT_VERSION: string;
} }
interface Window {
electron?: {
process?: {
platform?: NodeJS.Platform;
};
};
}
+36
View File
@@ -212,6 +212,12 @@ const enUS: Dict = {
'prefs.generalSettings': 'GENERAL SETTINGS:', 'prefs.generalSettings': 'GENERAL SETTINGS:',
'prefs.cleanWdb': 'Clean WDB on each launch', 'prefs.cleanWdb': 'Clean WDB on each launch',
'prefs.minimizeToTray': 'Minimize to tray while playing', '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.save': 'Save',
'prefs.installLocationTitle': 'Install location', 'prefs.installLocationTitle': 'Install location',
'prefs.portableInfo': 'prefs.portableInfo':
@@ -417,6 +423,12 @@ const deDE: Dict = {
'prefs.generalSettings': 'ALLGEMEINE EINSTELLUNGEN:', 'prefs.generalSettings': 'ALLGEMEINE EINSTELLUNGEN:',
'prefs.cleanWdb': 'WDB bei jedem Start leeren', 'prefs.cleanWdb': 'WDB bei jedem Start leeren',
'prefs.minimizeToTray': 'Während des Spielens in den Infobereich minimieren', '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.save': 'Speichern',
'prefs.installLocationTitle': 'Installationsort', 'prefs.installLocationTitle': 'Installationsort',
'prefs.portableInfo': 'prefs.portableInfo':
@@ -627,6 +639,12 @@ const zhCN: Dict = {
'prefs.generalSettings': '常规设置:', 'prefs.generalSettings': '常规设置:',
'prefs.cleanWdb': '每次启动时清理 WDB', 'prefs.cleanWdb': '每次启动时清理 WDB',
'prefs.minimizeToTray': '游戏时最小化到托盘', 'prefs.minimizeToTray': '游戏时最小化到托盘',
'prefs.proton': 'PROTON',
'prefs.useProton': '使用 Proton 启动',
'prefs.protonVersion': 'Proton 版本',
'prefs.protonNone': '未找到 Proton 安装',
'prefs.protonNoneHint':
'请通过 SteamSteam Play)安装 Proton,或将 GE-Proton 放入 compatibilitytools.d。',
'prefs.save': '保存', 'prefs.save': '保存',
'prefs.installLocationTitle': '安装位置', 'prefs.installLocationTitle': '安装位置',
'prefs.portableInfo': 'prefs.portableInfo':
@@ -849,6 +867,12 @@ const esES: Dict = {
'prefs.generalSettings': 'AJUSTES GENERALES:', 'prefs.generalSettings': 'AJUSTES GENERALES:',
'prefs.cleanWdb': 'Limpiar WDB en cada inicio', 'prefs.cleanWdb': 'Limpiar WDB en cada inicio',
'prefs.minimizeToTray': 'Minimizar a la bandeja mientras juegas', '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.save': 'Guardar',
'prefs.installLocationTitle': 'Ubicación de instalación', 'prefs.installLocationTitle': 'Ubicación de instalación',
'prefs.portableInfo': 'prefs.portableInfo':
@@ -1074,6 +1098,12 @@ const ptBR: Dict = {
'prefs.generalSettings': 'CONFIGURAÇÕES GERAIS:', 'prefs.generalSettings': 'CONFIGURAÇÕES GERAIS:',
'prefs.cleanWdb': 'Limpar WDB a cada inicialização', 'prefs.cleanWdb': 'Limpar WDB a cada inicialização',
'prefs.minimizeToTray': 'Minimizar para a bandeja durante o jogo', '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.save': 'Salvar',
'prefs.installLocationTitle': 'Local de instalação', 'prefs.installLocationTitle': 'Local de instalação',
'prefs.portableInfo': 'prefs.portableInfo':
@@ -1294,6 +1324,12 @@ const ruRU: Dict = {
'prefs.generalSettings': 'ОБЩИЕ НАСТРОЙКИ:', 'prefs.generalSettings': 'ОБЩИЕ НАСТРОЙКИ:',
'prefs.cleanWdb': 'Очищать WDB при каждом запуске', 'prefs.cleanWdb': 'Очищать WDB при каждом запуске',
'prefs.minimizeToTray': 'Сворачивать в трей во время игры', '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.save': 'Сохранить',
'prefs.installLocationTitle': 'Папка установки', 'prefs.installLocationTitle': 'Папка установки',
'prefs.portableInfo': 'prefs.portableInfo':