From 275166559a3c0921491d031f102f1de244883569 Mon Sep 17 00:00:00 2001 From: octocontr Date: Wed, 29 Jul 2026 22:33:23 -0400 Subject: [PATCH] Linux build with proton picker --- .github/workflows/build.yml | 27 +- .gitignore | 1 - electron-builder.yml | 7 + package-lock.json | 4 +- package.json | 3 +- scripts/build.sh | 126 +++++++ src/common/schemas.ts | 6 +- src/main/api/routers/general.ts | 3 + src/main/api/routers/launcher.ts | 106 +++++- src/main/index.ts | 9 +- src/main/modules/appIcon.ts | 52 +++ src/main/modules/proton.ts | 308 ++++++++++++++++++ src/main/modules/selfUpdater.ts | 45 ++- src/main/modules/tray.ts | 52 ++- src/main/modules/updater.ts | 56 ++-- src/renderer/components/PreferencesDialog.tsx | 67 +++- src/renderer/env.d.ts | 8 + src/renderer/i18n/translations.ts | 36 ++ 18 files changed, 862 insertions(+), 54 deletions(-) create mode 100755 scripts/build.sh create mode 100644 src/main/modules/appIcon.ts create mode 100644 src/main/modules/proton.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d716655..13266ad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore index 02e3b7a..638b52c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,4 @@ Tools/launcher/node/ .DS_Store Thumbs.db -scripts/ hooks/ diff --git a/electron-builder.yml b/electron-builder.yml index 0e0a033..bca534b 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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: diff --git a/package-lock.json b/package-lock.json index 74368df..47389f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d264763..abb8f1d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..a7aa9f5 --- /dev/null +++ b/scripts/build.sh @@ -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 diff --git a/src/common/schemas.ts b/src/common/schemas.ts index c49b897..6f9fbe1 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -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; diff --git a/src/main/api/routers/general.ts b/src/main/api/routers/general.ts index 7bee935..0337982 100644 --- a/src/main/api/routers/general.ts +++ b/src/main/api/routers/general.ts @@ -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) }; diff --git a/src/main/api/routers/launcher.ts b/src/main/api/routers/launcher.ts index e1c875d..03b26b9 100644 --- a/src/main/api/routers/launcher.ts +++ b/src/main/api/routers/launcher.ts @@ -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 => { - 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, - cwd: clientDir, - detached: !minimizeToTrayOnPlay - }) - : spawn(exePath, { - env: gameEnv, - cwd: clientDir, - detached: !minimizeToTrayOnPlay - }); + + 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, + 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 { await new Promise((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(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(); +}; diff --git a/src/main/index.ts b/src/main/index.ts index 98507ae..ff93cfa 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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); }); diff --git a/src/main/modules/appIcon.ts b/src/main/modules/appIcon.ts new file mode 100644 index 0000000..175481e --- /dev/null +++ b/src/main/modules/appIcon.ts @@ -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; +}; diff --git a/src/main/modules/proton.ts b/src/main/modules/proton.ts new file mode 100644 index 0000000..c03ea39 --- /dev/null +++ b/src/main/modules/proton.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + const seen = new Set(); + 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 => { + if (os.platform() !== 'linux') return []; + + const byRealPath = new Map(); + 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 => { + 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 => { + 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 …` with the STEAM_COMPAT_* env Proton expects + * for non-Steam titles. + */ +export const spawnWithProton = async ({ + protonDir, + exePath, + args = [], + cwd, + env = {} +}: ProtonLaunchOptions): Promise => { + 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 + }); +}; diff --git a/src/main/modules/selfUpdater.ts b/src/main/modules/selfUpdater.ts index d715a2c..76bcb9d 100644 --- a/src/main/modules/selfUpdater.ts +++ b/src/main/modules/selfUpdater.ts @@ -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 { protected _value: SelfUpdaterStatus = { state: 'idle', @@ -48,7 +61,21 @@ class SelfUpdaterClass extends Observable { 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 { 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 { }); 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); }); } diff --git a/src/main/modules/tray.ts b/src/main/modules/tray.ts index 2cb71aa..5b21b1a 100644 --- a/src/main/modules/tray.ts +++ b/src/main/modules/tray.ts @@ -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( diff --git a/src/main/modules/updater.ts b/src/main/modules/updater.ts index 08cc171..3fcce8a 100644 --- a/src/main/modules/updater.ts +++ b/src/main/modules/updater.ts @@ -131,28 +131,44 @@ const getManifestItem = ( ); }; -export const isGameRunning = (executablePath: string) => - os.platform() === 'win32' - ? new Promise(resolve => { - const exeName = path.basename(executablePath); - exec( - `tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH`, - (error, stdout) => { - if (error) { - Logger.warn( - `tasklist probe for "${exeName}" failed; assuming game ` + - `is not running. Error: ${error.message}` - ); - resolve(false); - return; - } - resolve( - stdout.toLowerCase().includes(`"${exeName.toLowerCase()}"`) +export const isGameRunning = (executablePath: string) => { + const exeName = path.basename(executablePath); + if (os.platform() === 'win32') + return new Promise(resolve => { + exec( + `tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH`, + (error, stdout) => { + if (error) { + Logger.warn( + `tasklist probe for "${exeName}" failed; assuming game ` + + `is not running. Error: ${error.message}` ); + resolve(false); + return; } - ); - }) - : false; + resolve( + stdout.toLowerCase().includes(`"${exeName.toLowerCase()}"`) + ); + } + ); + }); + + if (os.platform() === 'linux') + return new Promise(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('/'); diff --git a/src/renderer/components/PreferencesDialog.tsx b/src/renderer/components/PreferencesDialog.tsx index 5d75aa6..652940b 100644 --- a/src/renderer/components/PreferencesDialog.tsx +++ b/src/renderer/components/PreferencesDialog.tsx @@ -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 (
{ 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) => { + {isLinux && ( +
+

{t('prefs.proton')}

+ + {useProton && ( + <> + + {protonVersions.length === 0 && ( + + {t('prefs.protonNoneHint')} + + )} + + )} +
+ )} + {t('prefs.save')} diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 7d028e0..e685e36 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -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; + }; + }; +} diff --git a/src/renderer/i18n/translations.ts b/src/renderer/i18n/translations.ts index 803d9c9..853279f 100644 --- a/src/renderer/i18n/translations.ts +++ b/src/renderer/i18n/translations.ts @@ -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':