9 Commits

Author SHA1 Message Date
octocontr 60cdad4e31 remember the damn window position
Build check / build-windows (pull_request) Has been cancelled
Build check / build-linux (pull_request) Has been cancelled
2026-07-29 23:47:04 -04:00
octocontr 275166559a Linux build with proton picker
Build check / build-windows (pull_request) Has been cancelled
Build check / build-linux (pull_request) Has been cancelled
2026-07-29 23:27:59 -04:00
OctoWoW 5812065b56 Fix monitor resolution issue with correct monitor detection
Build check / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:58:15 -07:00
OctoWoW fbad749f0c Sync launcher: stop phantom update prompt, forum News panel, hardware-aware render distance
Build check / build (push) Has been cancelled
Squashed sync from upstream. Highlights:
- Updater no longer reports already-applied deletes as a pending update on
  every launch (guard the del branch on the target still existing)
- Derive the packaged CSP image origin from the configured server URL
- Forum Announcements panel + News tab; hardware-aware farClip recommendation;
  parchment UI; localization and tweak updates
- Addon source refresh; schema and mod-state fixes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:02:46 -07:00
octoadmin 16e442ea0f ColoredText: case-insensitive color-code regex so uppercase |c/|r titles render
Build check / build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 09:59:13 -07:00
OctoWoW 1047a90704 Fixed tweaks and mods, added localization, added antivirus walkthrough
Build check / build (push) Has been cancelled
2026-06-28 19:34:40 +00:00
octoadmin c2f7b7d6e4 Point addon sources at maintained forks; nampower to Emyrk v4.6.2
Build check / build (pull_request) Has been cancelled
Build check / build (push) Has been cancelled
2026-06-20 15:05:43 -07:00
OctoWoW 14ab791f9b updating open source launcher
Build check / build (pull_request) Has been cancelled
Build check / build (push) Has been cancelled
2026-06-20 01:43:20 -07:00
octoadmin 530ec7a144 Initial commit 2026-05-08 00:00:00 +00:00
59 changed files with 2429 additions and 3359 deletions
-2
View File
@@ -1,4 +1,2 @@
MAIN_VITE_SERVER_URL=https://octowow.st
MAIN_VITE_CLIENT_VERSION=latest
MAIN_VITE_CLIENT_TORRENT_URL=https://dl.octowow.st/download/client.torrent
MAIN_VITE_RAID_VISUALS_URL=https://dl.octowow.st/client/latest/Data/patch-O.mpq
+25 -2
View File
@@ -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
-1
View File
@@ -16,5 +16,4 @@ Tools/launcher/node/
.DS_Store
Thumbs.db
scripts/
hooks/
+11 -11
View File
@@ -8,10 +8,10 @@ The project as checked out does **not** build on a default up-to-date Windows de
1. **Added a Node version manager (`fnm`) and installed Node 20** alongside the existing Node 24. Node 24 was the system default and caused `nan` / `dll-inject` compile failures. Node 20 is now the fnm default but Node 24 is still available via `fnm use system`.
2. **Installed Visual Studio 2022 Build Tools** with the `VCTools` workload and Windows 11 SDK. Machine already had VS2026 (v18), but `node-gyp` v10 (shipped with Node 20's npm) doesn't detect it. VS2022 now lives side-by-side under `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools`.
3. **Unset `ELECTRON_RUN_AS_NODE`** per-shell before launching Electron. This var is set globally by VSCode's integrated terminal (inherited from the extension host): it is not something we can remove permanently without breaking VSCode. It has to be unset in each shell that runs `npm run dev` / `dist`.
3. **Unset `ELECTRON_RUN_AS_NODE`** per-shell before launching Electron. This var is set globally by VSCode's integrated terminal (inherited from the extension host) it is not something we can remove permanently without breaking VSCode. It has to be unset in each shell that runs `npm run dev` / `dist`.
4. **Populated `node_modules`** in both `main/` and `main/server/`. The tree was checked in empty.
Nothing in the repo itself was modified; all fixes were environmental. If another developer checks this repo out, they need to apply items 1-4 on their own machine. The sections below are that recipe.
Nothing in the repo itself was modified all fixes were environmental. If another developer checks this repo out, they need to apply items 14 on their own machine. The sections below are that recipe.
---
@@ -19,7 +19,7 @@ Nothing in the repo itself was modified; all fixes were environmental. If anothe
### 1. Node.js 20 (not 22, not 24)
Node 24 breaks the `dll-inject` native module; its `nan` C++ bindings don't compile against V8 in Node 22+. Stick to Node 20 LTS.
Node 24 breaks the `dll-inject` native module its `nan` C++ bindings don't compile against V8 in Node 22+. Stick to Node 20 LTS.
Install via `fnm` so you can keep your system Node separate:
@@ -33,7 +33,7 @@ Verify: `node -v` should print `v20.x.x`.
### 2. Visual Studio 2022 Build Tools (C++ workload)
`dll-inject` and `stormlib-node` compile native addons via `node-gyp`. `node-gyp` v10 (bundled with Node 20's npm) only recognizes VS2017-2022; newer VS versions (2026 / v18) are not detected.
`dll-inject` and `stormlib-node` compile native addons via `node-gyp`. `node-gyp` v10 (bundled with Node 20's npm) only recognizes VS20172022 newer VS versions (2026 / v18) are not detected.
```bash
winget install Microsoft.VisualStudio.2022.BuildTools \
@@ -75,7 +75,7 @@ cd ..
## Critical env var: `ELECTRON_RUN_AS_NODE`
**VSCode's integrated terminal sets `ELECTRON_RUN_AS_NODE=1`** (inherited from VSCode's extension host). This makes Electron binaries launch as plain Node, so `require('electron')` returns a path string instead of the API: the app crashes with `TypeError: Cannot read properties of undefined (reading 'isPackaged')`.
**VSCode's integrated terminal sets `ELECTRON_RUN_AS_NODE=1`** (inherited from VSCode's extension host). This makes Electron binaries launch as plain Node, so `require('electron')` returns a path string instead of the API the app crashes with `TypeError: Cannot read properties of undefined (reading 'isPackaged')`.
Before any `npm run dev` / `npm run build` / `npm run dist`:
@@ -87,7 +87,7 @@ unset ELECTRON_RUN_AS_NODE
Remove-Item Env:ELECTRON_RUN_AS_NODE
```
An external terminal (Windows Terminal, cmd, plain PowerShell) doesn't have this problem; the variable is only set inside VSCode.
An external terminal (Windows Terminal, cmd, plain PowerShell) doesn't have this problem the variable is only set inside VSCode.
## Running in dev
@@ -99,8 +99,8 @@ Starts electron-vite, builds main + preload + renderer, opens an Electron window
You'll see benign warnings in the console:
- `ERROR:cache_util_win.cc ... Access is denied`: OneDrive sync locking Electron's user-data cache. Cosmetic. To silence, move the project out of OneDrive or set a custom user-data dir.
- `Browserslist: caniuse-lite is outdated`: cosmetic.
- `ERROR:cache_util_win.cc ... Access is denied` OneDrive sync locking Electron's user-data cache. Cosmetic. To silence, move the project out of OneDrive or set a custom user-data dir.
- `Browserslist: caniuse-lite is outdated` cosmetic.
## Building for distribution
@@ -113,14 +113,14 @@ npm run dist
Outputs land in `dist/`:
- `OctoLauncher.exe`: portable single-file build
- `OctoLauncher_Installer.exe`: NSIS installer
- `OctoLauncher.exe` portable single-file build
- `OctoLauncher_Installer.exe` NSIS installer
Targets are configured in [electron-builder.yml](electron-builder.yml).
### Before publishing
- The build uses `.env.production` (committed) which already points to `https://octowow.st`: no `.env` file needed for production builds.
- The build uses `.env.production` (committed) which already points to `https://octowow.st` no `.env` file needed for production builds.
- Code signing is not configured. Unsigned Windows builds trigger SmartScreen warnings. To sign, add a `win.certificateFile` + password (or use env-based signing) to the electron-builder config.
## Troubleshooting
+10 -10
View File
@@ -1,6 +1,6 @@
# News feed
**Note:** The launcher no longer uses a static `news.json` file. The News tab now pulls live from the OctoWoW announcements forum via the website's `/news.json` endpoint, which is backed by `ForumFeedService` on the Laravel side. To publish a news item in the launcher, post in the configured announcements forum; the launcher will pick it up within the cache TTL (default 10 minutes). There is no JSON file to edit or deploy.
**Note:** The launcher no longer uses a static `news.json` file. The News tab now pulls live from the OctoWoW announcements forum via the website's `/news.json` endpoint, which is backed by `ForumFeedService` on the Laravel side. To publish a news item in the launcher, simply post in the configured announcements forum the launcher will pick it up within the cache TTL (default 10 minutes). There is no JSON file to edit or deploy.
The launcher's News tab fetches `${MAIN_VITE_SERVER_URL}/news.json` and renders the entries on the landing screen. The endpoint is dynamic: it mirrors the same forum posts the website's homepage shows in its "Recent forum posts" cards, so updating the forum updates the launcher.
@@ -12,7 +12,7 @@ The launcher's News tab fetches `${MAIN_VITE_SERVER_URL}/news.json` and renders
The route is served by Laravel (`routes/web.php``news.json`) and reads from `App\Services\ForumFeedService`, which fetches the configured phpBB Atom feed (`FORUM_FEED_BASE_URL`/`FORUM_FEED_MODE`/`FORUM_FEED_FORUM_ID` in `config/customs.php``forum_feed`). The same service backs the homepage's `recent-forum-posts` Livewire component, so what shows in the launcher is exactly what shows on the site.
No auth. The launcher times out after 8 seconds and validates the body against the schema below; malformed payloads surface as the "Couldn't reach the news feed" error state (with a Try again button).
No auth. The launcher times out after 8 seconds and validates the body against the schema below malformed payloads surface as the "Couldn't reach the news feed" error state (with a Try again button).
## Payload contract
@@ -34,7 +34,7 @@ No auth. The launcher times out after 8 seconds and validates the body against t
Source of truth for the schema: [src/common/schemas.ts](src/common/schemas.ts) (`NewsItemSchema`, `NewsFeedSchema`). If you change the contract, update both ends.
Notes:
- `items` is rendered in the order returned: sort newest-first on the server.
- `items` is rendered in the order returned sort newest-first on the server.
- `body` is rendered as plain text with `whitespace-pre-wrap`. No HTML/markdown.
- `url`, when present, becomes a "Read more" button that opens in the user's default browser via `shell.openExternal`. Skip it for inline-only posts.
- `id` should never change for an existing post (stable React keys, future bookmarking/read-state).
@@ -43,8 +43,8 @@ Notes:
There is no static file to edit anymore. To change what the launcher shows, post on the forum (`FORUM_FEED_BASE_URL`, e.g. `https://octowow.st/forum`). The next launcher fetch picks it up subject to two cache layers:
- `forum_feed.cache_ttl` (default 600 s, env `FORUM_FEED_CACHE_TTL`): Laravel server-side cache of the parsed Atom feed.
- `Cache-Control: public, max-age=120` on the `/news.json` response: short edge cache so launcher launches in a burst don't all hit Laravel.
- `forum_feed.cache_ttl` (default 600 s, env `FORUM_FEED_CACHE_TTL`) Laravel server-side cache of the parsed Atom feed.
- `Cache-Control: public, max-age=120` on the `/news.json` response short edge cache so launcher launches in a burst don't all hit Laravel.
The launcher's react-query cache also holds for 5 minutes per session; users can hit the refresh icon in the News header to force a re-fetch (which still hits the two cache layers above).
@@ -62,11 +62,11 @@ The launcher's react-query cache also holds for 5 minutes per session; users can
curl -s ${MAIN_VITE_SERVER_URL}/news.json | jq .
```
Expected: a `{"items": [...]}` body. An empty `items: []` means the forum feed is reachable but has nothing matching the configured mode (or the cache is still warm with an empty result; bust it by `php artisan cache:clear` inside the website container, or wait `FORUM_FEED_CACHE_TTL` seconds).
Expected: a `{"items": [...]}` body. An empty `items: []` means the forum feed is reachable but has nothing matching the configured mode (or the cache is still warm with an empty result bust it by `php artisan cache:clear` inside the website container, or wait `FORUM_FEED_CACHE_TTL` seconds).
**No items / errors:**
- `{"items": []}`: `FORUM_FEED_BASE_URL` is unset, the feed returned non-2xx, the body wasn't parseable Atom XML, or the configured forum has no posts. Check the website container's `storage/logs/laravel.log` for `ForumFeedService` warnings.
- `Couldn't reach the news feed` in the launcher: Laravel returned a 5xx (route exception, missing `ForumFeedService` binding) or the schema validator rejected the body. Check the launcher's main-process log at `%APPDATA%\octo-launcher\logs\main.log` for `Malformed news feed`.
- `{"items": []}` `FORUM_FEED_BASE_URL` is unset, the feed returned non-2xx, the body wasn't parseable Atom XML, or the configured forum has no posts. Check the website container's `storage/logs/laravel.log` for `ForumFeedService` warnings.
- `Couldn't reach the news feed` in the launcher Laravel returned a 5xx (route exception, missing `ForumFeedService` binding) or the schema validator rejected the body. Check the launcher's main-process log at `%APPDATA%\octo-launcher\logs\main.log` for `Malformed news feed`.
**End-to-end check in the launcher:**
1. Open the launcher (the News tab is the default view when no other tab is selected).
@@ -78,11 +78,11 @@ Expected: a `{"items": [...]}` body. An empty `items: []` means the forum feed i
| Server response | UI behaviour |
| --- | --- |
| `200` with valid JSON | Renders entries |
| `200` with empty `items: []` | "No news yet: check back later." |
| `200` with empty `items: []` | "No news yet check back later." |
| `200` with malformed JSON or missing required fields | Error state + Try again. Reason logged in main-process logs (`%APPDATA%\octo-launcher\logs\main.log`). |
| `404`, `5xx`, network unreachable, > 8s timeout | Error state + Try again. |
You don't need to ship a placeholder `news.json` to avoid 404s; the empty/error state is intentional.
You don't need to ship a placeholder `news.json` to avoid 404s the empty/error state is intentional.
## Where the code lives
+12 -12
View File
@@ -18,7 +18,7 @@ Desktop launcher for the OctoWoW (World of Warcraft 1.12.1 private server) clien
2. Run it and set your WoW client directory when prompted.
3. Click **Verify** to download any missing game files, then **Play**.
No server configuration needed; the launcher connects to `octowow.st` by default.
No server configuration needed the launcher connects to `octowow.st` by default.
---
@@ -28,8 +28,8 @@ No server configuration needed; the launcher connects to `octowow.st` by default
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20 LTS | Node 22+ breaks `dll-inject` native bindings: use Node 20 |
| VS 2022 Build Tools | C++ workload + Win SDK | `node-gyp` v10 only detects VS2017-2022 |
| Node.js | 20 LTS | Node 22+ breaks `dll-inject` native bindings use Node 20 |
| VS 2022 Build Tools | C++ workload + Win SDK | `node-gyp` v10 only detects VS20172022 |
| Python | 3.x | Required by `node-gyp` |
Install Node 20 with `fnm`:
@@ -51,7 +51,7 @@ winget install Microsoft.VisualStudio.2022.BuildTools `
npm install
```
`postinstall` rebuilds the native modules (`dll-inject`, `stormlib-node`) against the Electron ABI; expect C++ compiler output.
`postinstall` rebuilds the native modules (`dll-inject`, `stormlib-node`) against the Electron ABI expect C++ compiler output.
### Run in development
@@ -64,7 +64,7 @@ npm install
npm run dev
```
Opens the app in a hot-reloading Electron window. The dev build points to `http://localhost:5000` by default; create a `.env` file from `.env.example` if you want to run against a local server, otherwise it falls back to `https://octowow.st`.
Opens the app in a hot-reloading Electron window. The dev build points to `http://localhost:5000` by default create a `.env` file from `.env.example` if you want to run against a local server, otherwise it falls back to `https://octowow.st`.
### Build for distribution
@@ -74,8 +74,8 @@ npm run dist
```
Outputs to `dist/`:
- `OctoLauncher.exe`: portable single-file
- `OctoLauncher_Installer.exe`: NSIS installer
- `OctoLauncher.exe` portable single-file
- `OctoLauncher_Installer.exe` NSIS installer
The production build uses `.env.production` (committed) which points to `https://octowow.st`. No `.env` file needed.
@@ -98,7 +98,7 @@ npm run dev
The server listens on `http://localhost:5000` and serves:
- `GET /api/file/:version/manifest.json`
- `GET /client/:version/*`: per-file downloads
- `GET /client/:version/*` per-file downloads
- `GET /api/addons.json`
---
@@ -107,11 +107,11 @@ The server listens on `http://localhost:5000` and serves:
Three Vite bundles tied together by tRPC over Electron IPC:
- **Main** ([src/main/](src/main/)): Electron main process; owns all filesystem/native work and the tRPC router
- **Preload** ([src/preload/](src/preload/)): secure IPC bridge via `exposeElectronTRPC()`
- **Renderer** ([src/renderer/](src/renderer/)): React 18 + Tailwind UI; no direct Node access
- **Main** ([src/main/](src/main/)) Electron main process; owns all filesystem/native work and the tRPC router
- **Preload** ([src/preload/](src/preload/)) secure IPC bridge via `exposeElectronTRPC()`
- **Renderer** ([src/renderer/](src/renderer/)) React 18 + Tailwind UI; no direct Node access
All cross-process data shapes are Zod schemas in [src/common/schemas.ts](src/common/schemas.ts). All renderer→main calls go through tRPC procedures in [src/main/api/routers/](src/main/api/routers/); never raw `ipcMain.handle`.
All cross-process data shapes are Zod schemas in [src/common/schemas.ts](src/common/schemas.ts). All renderer→main calls go through tRPC procedures in [src/main/api/routers/](src/main/api/routers/) never raw `ipcMain.handle`.
---
+1 -1
View File
@@ -14,4 +14,4 @@ cd Tools\launcher
Output: `dist\OctoLauncher.exe` (portable) and `dist\OctoLauncher_Installer.exe` (NSIS).
The `node/` directory is gitignored; it is recreated by `install.ps1`.
The `node/` directory is gitignored it is recreated by `install.ps1`.
+4 -4
View File
@@ -1,4 +1,4 @@
# opentracker: OctoWow launcher torrent swarm
# opentracker OctoWow launcher torrent swarm
BitTorrent tracker the launcher's webtorrent clients announce to. Runs
on your VPS alongside the companion update server. Tiny (~2 MB RSS),
@@ -7,7 +7,7 @@ near-zero CPU, zero disk IO after boot.
**Why your own tracker**: public trackers (opentrackr.org, etc.) are
reliable enough for hobby swarms but add a single-point-of-failure you
don't control, and often rate-limit new info-hashes. The launcher also
announces over DHT, so your tracker is redundant with DHT, but it is
announces over DHT, so your tracker is redundant with DHT but it's
the fastest path for a fresh peer to find the swarm before DHT has
warmed up.
@@ -21,7 +21,7 @@ chmod +x install.sh
./install.sh
```
`install.sh` is idempotent; re-run to update. It builds opentracker
`install.sh` is idempotent re-run to update. It builds opentracker
from CVS (only distribution upstream offers), installs it under
`/opt/opentracker/bin/`, drops a hardened systemd unit, and starts the
service bound to `0.0.0.0:6969`.
@@ -51,7 +51,7 @@ TRACKER_URL=http://<your-vps-ip>:6969/announce npm run server
Default is `http://127.0.0.1:6969/announce` (assumes tracker + companion
server run on the same VPS, which is the normal deployment).
Clients pull the `.torrent` blob from the companion server; the URL
Clients pull the `.torrent` blob from the companion server the URL
is already baked in by `create-torrent` at generation time, so no
launcher-side config needed.
+2 -2
View File
@@ -1,5 +1,5 @@
[Unit]
Description=opentracker: BitTorrent tracker for OctoWow launcher swarm
Description=opentracker BitTorrent tracker for OctoWow launcher swarm
After=network.target
[Service]
@@ -11,7 +11,7 @@ ExecStart=/opt/opentracker/bin/opentracker -i 0.0.0.0 -p 6969 -P 6969
Restart=on-failure
RestartSec=5
# Hardening: opentracker does no filesystem IO after boot, so most of
# Hardening opentracker does no filesystem IO after boot, so most of
# the namespace can be locked down.
NoNewPrivileges=true
PrivateTmp=true
+9 -6
View File
@@ -11,7 +11,6 @@ files:
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,.prettierrc.cjs,dev-app-update.yml}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!*.tsbuildinfo'
- '!{tailwind.config.ts,postcss.config.cjs}'
- '!dist*/**'
- '!out/main/chunks/**'
@@ -28,18 +27,22 @@ 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
extraResources:
- from: resources/aria2c.exe
to: aria2c.exe
linux:
icon: build/icon.png
category: Game
win:
artifactName: ${productName}.${ext}
target:
- portable
- nsis
nsis:
# versioned: differential updates need the old blockmap to stay fetchable
artifactName: ${productName}_Installer-${version}.${ext}
artifactName: ${productName}_Installer.${ext}
uninstallDisplayName: ${productName}
oneClick: false
removeDefaultUninstallWelcomePage: true
+11 -22
View File
@@ -2,7 +2,6 @@ import { resolve } from 'path';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import { loadEnv } from 'vite';
const alias = {
'~common': resolve('src/common'),
@@ -11,26 +10,16 @@ const alias = {
'~build': resolve('build')
};
export default defineConfig(({ mode }) => {
if (mode === 'ptr') {
const realm = loadEnv(mode, process.cwd(), 'MAIN_VITE_')
.MAIN_VITE_PTR_REALMLIST;
if (!realm || realm === 'octowow.st')
throw new Error(
'PTR build needs MAIN_VITE_PTR_REALMLIST set to a non-prod realm host'
);
export default defineConfig({
main: {
resolve: { alias },
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: { alias },
plugins: [react()]
}
return {
main: {
resolve: { alias },
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: { alias },
plugins: [react()]
}
};
});
+2 -2
View File
@@ -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",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "octo-launcher",
"version": "1.3.6",
"version": "1.2.1",
"description": "An Electron application for launching and updating the OctoWoW client",
"author": "OctoWoW",
"copyright": "Copyright © 2026 OctoWoW",
@@ -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",
+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
-97
View File
@@ -1,97 +0,0 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const FILL = 0x78;
function pathVariants(p) {
const set = new Set([p, p.replace(/\\/g, '/'), p.replace(/\//g, '\\')]);
return [...set].filter(Boolean);
}
const root = process.cwd();
const home = os.homedir();
let username = '';
try {
username = os.userInfo().username;
} catch {
username = '';
}
const secrets = [];
if (root.length > 2) secrets.push(...pathVariants(root));
if (home.length > 2 && home !== root) secrets.push(...pathVariants(home));
if (username.length >= 4) secrets.push(username);
const needles = [...new Set(secrets)]
.filter(s => s.length > 0)
.map(s => s.toLowerCase())
.sort((a, b) => b.length - a.length);
function scanAndFill(buf, s, stride) {
const n = s.length;
const span = n * stride;
if (span === 0 || span > buf.length) return 0;
let hits = 0;
outer: for (let i = 0; i + span <= buf.length; i++) {
for (let j = 0; j < n; j++) {
const at = i + j * stride;
let b = buf[at];
if (b >= 0x41 && b <= 0x5a) b += 0x20;
if (b !== s.charCodeAt(j)) continue outer;
if (stride === 2 && buf[at + 1] !== 0x00) continue outer;
}
buf.fill(FILL, i, i + span);
hits++;
i += span - 1;
}
return hits;
}
function redact(buf) {
let hits = 0;
for (const s of needles) {
hits += scanAndFill(buf, s, 1);
hits += scanAndFill(buf, s, 2);
}
return hits;
}
function collect(dir, out) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) collect(p, out);
else if (e.isFile() && p.endsWith('.node')) out.push(p);
}
}
const addons = [];
collect(path.join(root, 'node_modules'), addons);
let files = 0;
let total = 0;
const redacted = [];
for (const file of addons) {
try {
const buf = fs.readFileSync(file);
const hits = redact(buf);
if (hits > 0) {
fs.writeFileSync(file, buf);
files++;
total += hits;
redacted.push(`${path.relative(root, file)} (${hits})`);
}
} catch (err) {
console.warn(`scrub-native-paths: skipped ${path.basename(file)} (${err.message})`);
}
}
console.log(`scrub-native-paths: redacted ${total} path reference(s) across ${files} addon(s)`);
for (const r of redacted) console.log(` ${r}`);
+1 -7
View File
@@ -15,13 +15,7 @@ export const defaultSources: AddonSource[] = [
git: 'https://github.com/McPewPew/ArcHUD2.git',
description: 'Combat HUD showing health and power as arcs around your character'
},
{
git: 'https://octowow.st/git/shaga/AtlasLoot.git',
name: 'AtlasLoot',
branch: 'main',
description:
'Loot browser for every dungeon and raid, including the custom OctoWoW instances (Windhorn Canyon, Dragonmaw Retreat, and more)'
},
{ git: 'https://github.com/CosminPOP/AtlasLoot.git', name: 'AtlasLoot' },
{
git: 'https://github.com/byCFM2/Atlas-TW.git',
name: 'Atlas-CFM'
+1 -9
View File
@@ -15,7 +15,6 @@ const allowedExtra = [
];
const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf'];
const raidVisuals = ['patch-O.mpq'];
const skipFiles = new Set([
'manifest.json',
@@ -46,7 +45,7 @@ const isSkipDir = (...filePath: string[]) =>
skipDirsPosix.has(filePath.join('/'));
type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes' | 'raidVisuals';
type FileTags = 'vanillaFixes';
type FileManifest = { name: string } & (
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
@@ -192,12 +191,6 @@ export const buildCache = async (
if (stats.isDirectory()) {
if (isSkipDir(...filePath, file)) continue;
if (file.match(/patch-./)) {
if (raidVisuals.includes(`${file}.mpq`))
throw new Error(
`${file}/ exists beside ${file}.mpq. Opt-in archives must stay ` +
'whole-file: an mpq node carries no tags, so this would ' +
'ship the patch to every player regardless of preference.'
);
patches.push(file);
const mpqRelPath = path
.join(...filePath, `${file}.mpq`)
@@ -250,7 +243,6 @@ export const buildCache = async (
const tags: FileTags[] = [];
vanillaFixes.includes(file) && tags.push('vanillaFixes');
raidVisuals.includes(file) && tags.push('raidVisuals');
tree.push({
type: 'file',
-36
View File
@@ -4,7 +4,6 @@ export const ModIdSchema = z.enum([
'dxvk',
'nampower',
'multiMonitorFix',
'superWow',
'transmogFix',
'unitXp',
'vanillaFixes',
@@ -20,7 +19,6 @@ export type ModSource =
apiUrl?: string;
pinnedTag?: string;
assetName: string;
sha256?: string;
}
| {
kind: 'archive';
@@ -30,7 +28,6 @@ export type ModSource =
pinnedTag?: string;
format: 'zip' | 'tar.gz';
extractMap: Record<string, string>;
sha256?: string;
}
| { kind: 'managed' };
@@ -44,8 +41,6 @@ export type ModEntry = {
repoUrl: string;
source: ModSource;
registerInDllsTxt?: string;
// hidden from the Mods tab, never enabled on fresh installs; existing installs keep it
disabled?: boolean;
};
export const MODS: ModEntry[] = [
@@ -103,30 +98,6 @@ export const MODS: ModEntry[] = [
},
registerInDllsTxt: 'VanillaMultiMonitorFix.dll'
},
{
id: 'superWow',
name: 'SuperWoW',
version: '2.2',
description:
'Extends the client Lua API with unit GUIDs and other data many addons rely on.',
repoUrl: 'https://github.com/balakethelock/SuperWoW',
requires: ['vanillaFixes'],
source: {
kind: 'archive',
url: 'https://github.com/balakethelock/SuperWoW/releases/download/Release/SuperWoW.release.2.2.zip',
apiUrl:
'https://api.github.com/repos/balakethelock/SuperWoW/releases/latest',
parseLatest: 'githubRelease',
pinnedTag: '2.2',
format: 'zip',
extractMap: {
'SuperWoWhook.dll': 'SuperWoWhook.dll'
}
},
registerInDllsTxt: 'SuperWoWhook.dll',
// disabled 2026-08-08 pending distribution permission; delete this line to re-enable
disabled: true
},
{
id: 'transmogFix',
name: 'transmogFix',
@@ -206,10 +177,3 @@ export const MODS: ModEntry[] = [
export const getMod = (id: ModId): ModEntry | undefined =>
MODS.find(m => m.id === id);
// fallback for profiles with no stored state: enabled, so legacy installs
// keep their mods; fresh installs seed explicit off rows instead (do NOT
// flip this list to change defaults, it strips mods from legacy profiles)
export const DEFAULT_ENABLED_MODS: ModId[] = MODS.filter(m => !m.disabled).map(
m => m.id
);
+5 -11
View File
@@ -17,7 +17,6 @@ const f = {
export const ConfigWtfSchema = z.object({
vanillaFixes: f.boolean(),
raidVisuals: f.boolean(),
largeAddress: f.boolean(true),
nameplateRange: f.number(41),
alwaysAutoLoot: f.boolean(),
@@ -58,20 +57,11 @@ export const PreferencesSchema = z.object({
expectedPatchedWowHash: z.string().optional(),
minimizeToTrayOnPlay: f.boolean(true),
cleanWdb: f.boolean(true),
shareDownloads: f.boolean(true),
locale: z
.enum(['enUS', 'deDE', 'zhCN', 'esES', 'ptBR', 'ruRU'])
.default('enUS'),
localePatchLetter: z.string().optional(),
localePatchLocale: z.string().optional(),
patchedLocale: z.string().optional(),
syncedTorrentHash: z.string().optional(),
activeTorrentHash: z.string().optional(),
activeClientDir: z.string().optional(),
raidVisualsHash: z.string().optional(),
clientPatchHash: z.string().optional(),
vmmfWrittenIndex: z.number().int().nonnegative().optional(),
lastWrittenResolution: z.string().optional(),
rememberPosition: f.boolean(),
windowPosition: z
.object({
@@ -84,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>;
+2 -2
View File
@@ -47,7 +47,7 @@ export const asyncMap = async <T, U>(
export const isNotUndef = <T>(obj: T): obj is Exclude<T, undefined> =>
obj !== undefined;
export const formatFileSize = (bytes: number, decimals = 2) => {
export const formatFileSize = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
@@ -57,7 +57,7 @@ export const formatFileSize = (bytes: number, decimals = 2) => {
unitIndex++;
}
return `${parseFloat(size.toFixed(decimals))} ${units[unitIndex]}`;
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
export const formatDuration = (remaining: number) => {
+2 -5
View File
@@ -1,10 +1,7 @@
import fetch from 'node-fetch';
import Logger from 'electron-log/main';
import {
ForumAnnouncementSchema,
type ForumAnnouncement
} from '~common/schemas';
import { ForumAnnouncementSchema, type ForumAnnouncement } from '~common/schemas';
import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -12,7 +9,7 @@ const FETCH_TIMEOUT_MS = 8_000;
const fetchLatestAnnouncement = async (): Promise<ForumAnnouncement | null> => {
const url = `${
import.meta.env.MAIN_VITE_FORUM_URL || 'https://octowow.st'
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/forum/octonews.php?forum=35&mode=full`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
+6 -10
View File
@@ -1,21 +1,19 @@
import path from 'node:path';
import { app, dialog, shell } from 'electron';
import Logger from 'electron-log/main';
import { z } from 'zod';
import { mainWindow } from '~main/index';
import Preferences from '~main/modules/preferences';
import {
addDefenderExclusions,
detectAntivirusBlocks
} from '~main/modules/defender';
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) };
@@ -31,16 +29,14 @@ export const generalRouter = createTRPCRouter({
.input(z.string().url())
.mutation(({ input }) => shell.openExternal(input)),
openInstallFolder: publicProcedure.mutation(() => {
// Explorer needs native separators; a stored forward-slash path fails to open.
const dir = Preferences.data.clientDir;
if (dir) shell.openPath(path.normalize(dir));
if (dir) shell.openPath(dir);
}),
openLogFile: publicProcedure.mutation(() => {
const file = Logger.transports.file.getFile().path;
shell.openPath(path.normalize(file));
shell.openPath(file);
}),
addDefenderExclusion: publicProcedure.mutation(() => addDefenderExclusions()),
antivirusBlocks: publicProcedure.query(() => detectAntivirusBlocks()),
filePicker: publicProcedure
.input(
z.object({
+143 -136
View File
@@ -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';
@@ -7,17 +7,17 @@ import Logger from 'electron-log/main';
import Preferences from '~main/modules/preferences';
import Mods from '~main/modules/mods';
import { mainWindow } from '~main/index';
import Updater, { isGameRunning } from '~main/modules/updater';
import {
patchConfig,
patchExecutable,
ensureDxvkConf
} from '~main/modules/patcher';
import { removeLegacyLocalePatches } from '~main/modules/localePatch';
import { syncVanillaFixesCache } from '~main/modules/dllsTxt';
import { stopSeeding } from '~main/modules/aria2';
import { isGameRunning } from '~main/modules/updater';
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';
@@ -37,136 +37,143 @@ const chainloaderNeeded = async (clientDir: string): Promise<boolean> => {
type StartResult = { ok: boolean; error?: string };
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
let starting = false;
export const launcherRouter = createTRPCRouter({
start: publicProcedure.mutation(async (): Promise<StartResult> => {
if (starting) return { ok: false, error: 'The game is already launching.' };
starting = true;
try {
const { cleanWdb, minimizeToTrayOnPlay, clientDir } = Preferences.data;
if (!clientDir) return { ok: false, error: 'No game folder is set.' };
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');
if (!(await fs.pathExists(exePath)))
return {
ok: false,
error: 'WoW.exe was not found in the game folder.'
};
if (await isGameRunning(exePath))
return { ok: false, error: 'WoW is already running.' };
const exePath = path.join(clientDir, 'WoW.exe');
if (!(await fs.pathExists(exePath)))
return { ok: false, error: 'WoW.exe was not found in the game folder.' };
if (await isGameRunning(exePath))
return { ok: false, error: 'WoW is already running.' };
if (Mods.status.dirty)
return {
ok: false,
error: 'You have unapplied mod changes. Click Apply first.'
};
stopSeeding();
if (cleanWdb) {
Logger.log('Cleaning up WDB...');
await fs.remove(path.join(clientDir, 'WDB'));
}
Logger.log('Syncing preferred monitor...');
await Mods.verify();
Logger.log('Checking Config.wtf...');
await patchConfig();
await ensureDxvkConf(clientDir);
await removeLegacyLocalePatches(clientDir);
if (Preferences.data.patchedLocale !== Preferences.data.locale) {
Logger.log(
`Applying the client language (${Preferences.data.locale})...`
);
try {
await patchExecutable();
await patchConfig(true);
await Updater.recordPatchedWow();
if (!cleanWdb)
await fs.remove(path.join(clientDir, 'WDB')).catch(() => {});
} catch (e) {
Logger.error(
'Could not apply the client language; launching with the previous one',
e
);
}
}
const loaderPath = path.join(clientDir, 'VanillaFixes.exe');
const needsLoader = await chainloaderNeeded(clientDir);
const useLoader = needsLoader && (await fs.pathExists(loaderPath));
if (useLoader) await syncVanillaFixesCache(clientDir);
if (needsLoader && !useLoader)
Logger.warn(
'VanillaFixes.exe is missing but mods/dlls.txt expect a chainloader; ' +
'launching WoW.exe directly (mods will not load).'
);
Logger.log(
useLoader ? 'Launching via VanillaFixes...' : `Launching ${exePath}...`
);
const child = useLoader
? spawn(loaderPath, ['WoW.exe'], {
cwd: clientDir,
detached: !minimizeToTrayOnPlay
})
: spawn(exePath, {
cwd: clientDir,
detached: !minimizeToTrayOnPlay
});
try {
await new Promise<void>((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
});
} 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}` };
}
child.on('error', e => Logger.error('Game process error', e));
if (!minimizeToTrayOnPlay) {
mainWindow?.close();
return { ok: true };
}
minimizeToTray();
if (useLoader) {
void (async () => {
try {
const started = Date.now();
while (
Date.now() - started < 30_000 &&
!(await isGameRunning(exePath))
)
await delay(1000);
while (await isGameRunning(exePath)) await delay(3000);
} finally {
Logger.log('WoW stopped');
restoreFromTray();
}
})();
} else {
child.on('exit', () => {
Logger.log('WoW stopped');
restoreFromTray();
});
}
return { ok: true };
} catch (e) {
Logger.error('Failed to start the game', e);
return { ok: false, error: e instanceof Error ? e.message : String(e) };
} finally {
starting = false;
if (cleanWdb) {
Logger.log('Cleaning up WDB...');
await fs.remove(path.join(clientDir, 'WDB'));
}
Logger.log('Checking Config.wtf...');
await patchConfig();
Logger.log('Applying UI language...');
await applyLocalePatch(clientDir, Preferences.data.locale);
const loaderPath = path.join(clientDir, 'VanillaFixes.exe');
const needsLoader = await chainloaderNeeded(clientDir);
const useLoader = needsLoader && (await fs.pathExists(loaderPath));
if (needsLoader && !useLoader)
Logger.warn(
'VanillaFixes.exe is missing but mods/dlls.txt expect a chainloader; ' +
'launching WoW.exe directly (mods will not load).'
);
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(
wantProton
? `Launching via Proton${useLoader ? ' + VanillaFixes' : ''} (OCTO_LOCALE=${octoLocale})...`
: useLoader
? `Launching via VanillaFixes (OCTO_LOCALE=${octoLocale})...`
: `Launching ${exePath} (OCTO_LOCALE=${octoLocale})...`
);
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<void>((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
});
} 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}` };
}
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();
return { ok: true };
}
minimizeToTray();
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
View File
@@ -15,12 +15,6 @@ export const modsRouter = createTRPCRouter({
toggle: publicProcedure
.input(z.object({ id: ModIdSchema, enabled: z.boolean() }))
.mutation(({ input }) => Mods.toggle(input.id, input.enabled)),
toggleCustom: publicProcedure
.input(z.object({ name: z.string(), enabled: z.boolean() }))
.mutation(({ input }) => Mods.toggleCustom(input.name, input.enabled)),
addCustomDll: publicProcedure
.input(z.object({ path: z.string() }))
.mutation(({ input }) => Mods.addCustomDll(input.path)),
setIgnoreUpdates: publicProcedure
.input(z.object({ id: ModIdSchema, ignore: z.boolean() }))
.mutation(({ input }) => Mods.setIgnoreUpdates(input.id, input.ignore)),
+12 -22
View File
@@ -1,4 +1,3 @@
import { z } from 'zod';
import fetch from 'node-fetch';
import Logger from 'electron-log/main';
@@ -8,14 +7,10 @@ import { createTRPCRouter, publicProcedure } from '../trpc';
const FETCH_TIMEOUT_MS = 8_000;
// Boards octonews.php exposes as a list: 2 = Announcements, 4 = Patch Notes.
const FEED_FORUMS = [2, 4];
const fetchNews = async (forum: number): Promise<NewsItem[]> => {
const f = FEED_FORUMS.includes(forum) ? forum : 2;
const fetchNews = async (): Promise<NewsItem[]> => {
const url = `${
import.meta.env.MAIN_VITE_FORUM_URL || 'https://octowow.st'
}/forum/octonews.php?mode=list&forum=${f}&limit=5`;
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/forum/octonews.php?mode=list&forum=2&limit=3`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
@@ -23,10 +18,7 @@ const fetchNews = async (forum: number): Promise<NewsItem[]> => {
if (!res.ok) throw Error(`HTTP ${res.status}`);
const parsed = NewsFeedSchema.safeParse(await res.json());
if (!parsed.success) {
Logger.error(
'News feed failed schema validation',
parsed.error.flatten()
);
Logger.error('News feed failed schema validation', parsed.error.flatten());
throw Error('Malformed news feed');
}
return parsed.data.items;
@@ -36,14 +28,12 @@ const fetchNews = async (forum: number): Promise<NewsItem[]> => {
};
export const newsRouter = createTRPCRouter({
list: publicProcedure
.input(z.object({ forum: z.number() }).optional())
.query(async ({ input }) => {
try {
return await fetchNews(input?.forum ?? 2);
} catch (e) {
Logger.error('Failed to fetch news', e);
throw e;
}
})
list: publicProcedure.query(async () => {
try {
return await fetchNews();
} catch (e) {
Logger.error('Failed to fetch news', e);
throw e;
}
})
});
+4 -11
View File
@@ -2,21 +2,14 @@ import { patchConfig, patchExecutable } from '~main/modules/patcher';
import Preferences from '~main/modules/preferences';
import Updater from '~main/modules/updater';
import { getClientVersion } from '~main/utils';
import { stopSeeding } from '~main/modules/aria2';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const patcherRouter = createTRPCRouter({
apply: publicProcedure.mutation(async () => {
// release the seeder's file handles so the patchers can write
stopSeeding();
try {
await patchExecutable();
await patchConfig(true);
await Updater.recordPatchedWow();
Preferences.data = { version: await getClientVersion() };
} finally {
await Updater.refreshSeeding();
}
await patchExecutable();
await patchConfig(true);
await Updater.recordPatchedWow();
Preferences.data = { version: await getClientVersion() };
})
});
+3 -4
View File
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { PreferencesSchema } from '~common/schemas';
import Preferences from '~main/modules/preferences';
import Updater from '~main/modules/updater';
import { applyLocalePatch } from '~main/modules/localePatch';
import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -11,10 +11,9 @@ export const preferencesRouter = createTRPCRouter({
set: publicProcedure
.input(PreferencesSchema.partial())
.mutation(async ({ input }) => {
// Language change no longer touches the game folder; the exe is re-patched on
// the next Play (launcher router), so this stays network-free and can't fail.
Preferences.data = input;
if (input.shareDownloads !== undefined) void Updater.refreshSeeding();
if (input.locale !== undefined)
await applyLocalePatch(Preferences.data.clientDir, input.locale);
return Preferences.data;
}),
isValidClientDir: publicProcedure
-1
View File
@@ -6,7 +6,6 @@ import { createTRPCRouter, publicProcedure } from '../trpc';
export const updaterRouter = createTRPCRouter({
verify: publicProcedure.mutation(() => Updater.verify()),
syncRaidVisuals: publicProcedure.mutation(() => Updater.syncRaidVisuals()),
update: publicProcedure
.input(z.boolean().optional())
.mutation(async ({ input }) => Updater.update(input)),
+24 -32
View File
@@ -5,16 +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 { PreferencesSchema } from '~common/schemas';
import { appRouter } from './api/root';
import { stopSyncing, stopSeeding } from './modules/aria2';
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,
@@ -23,7 +20,6 @@ import {
Logger.initialize();
Logger.errorHandler.startCatching();
Logger.transports.ipc.level = false;
Logger.info('Launcher starting...');
app.disableHardwareAcceleration();
@@ -50,19 +46,30 @@ const isOnScreen = (
});
};
const createWindow = async () => {
const saved =
Preferences.data.rememberPosition &&
isOnScreen(Preferences.data.windowPosition)
? Preferences.data.windowPosition
: undefined;
const position = saved ?? { width: 1000, height: 700 };
const centerOnPrimary = (width: number, height: number) => {
const { x, y, width: dw, height: dh } = screen.getPrimaryDisplay().workArea;
return {
x: Math.round(x + (dw - width) / 2),
y: Math.round(y + (dh - height) / 2),
width,
height
};
};
const createWindow = async () => {
// Always restore last geometry when it still intersects a display so the
// launcher stays on the monitor the user left it on.
const saved = isOnScreen(Preferences.data.windowPosition)
? Preferences.data.windowPosition
: undefined;
const position = saved ?? centerOnPrimary(1000, 700);
const appIcon = loadAppIcon();
mainWindow = new BrowserWindow({
...position,
minWidth: 1000,
minHeight: 700,
icon,
...(appIcon ? { icon: appIcon } : {}),
frame: false,
maximizable: false,
fullscreenable: false,
@@ -74,6 +81,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);
});
@@ -135,13 +145,7 @@ if (!gotSingleInstanceLock) {
});
app.whenReady().then(async () => {
// defaults on failure so createWindow() below still runs
try {
Preferences.data = await Preferences.load();
} catch (e) {
Logger.error('Preferences.load() failed; starting on defaults', e);
Preferences.data = PreferencesSchema.parse({});
}
Preferences.data = await Preferences.load();
Addons.verify();
Updater.verify();
@@ -200,18 +204,6 @@ if (!gotSingleInstanceLock) {
await createWindow();
});
let settingsFlushed = false;
app.on('before-quit', event => {
stopSyncing();
stopSeeding();
if (settingsFlushed) return;
settingsFlushed = true;
event.preventDefault();
Promise.race([Preferences.save(), new Promise(r => setTimeout(r, 3000))])
.catch(e => Logger.error('Failed to flush settings before quit', e))
.finally(() => app.quit());
});
app.on('window-all-closed', () => {
app.quit();
});
+1 -1
View File
@@ -195,7 +195,7 @@ class AddonsClass extends Observable<AddonsStatus> {
: [];
const addons: AddonsStatus['addons'] = Object.fromEntries(
dirs
.filter(d => !d.startsWith('Blizzard_') && !/\.(tmp|bak)$/.test(d))
.filter(d => !d.startsWith('Blizzard_'))
.map(name => [name, { status: 'fetching' as const, folder: name }])
);
+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;
};
-514
View File
@@ -1,514 +0,0 @@
import crypto from 'crypto';
import path from 'path';
import { spawn, type ChildProcess } from 'child_process';
import { app } from 'electron';
import fs from 'fs-extra';
import Logger from 'electron-log/main';
import { mapPort, type PortMapping } from './upnp';
const TORRENT_NAME = 'client';
const bin = () =>
app.isPackaged
? path.join(process.resourcesPath, 'aria2c.exe')
: path.join(app.getAppPath(), 'resources', 'aria2c.exe');
type SyncOpts = {
torrentUrl: string;
clientDir: string;
totalBytes?: number;
checkIntegrity?: boolean;
seedTime?: number;
selectFiles?: number[];
onProgress?: (p: SyncProgress) => void;
signal?: AbortSignal;
};
export type SyncProgress = {
progress: number;
bytesDone: number;
bytesTotal: number;
bytesPerSecond: number;
};
const ensureJunction = async (clientDir: string): Promise<string> => {
await fs.ensureDir(clientDir);
const staging = path.join(app.getPath('userData'), 'torrent-root');
await fs.ensureDir(staging);
const link = path.join(staging, TORRENT_NAME);
const target = path.resolve(clientDir);
try {
const cur = await fs.lstat(link);
if (cur.isSymbolicLink() || cur.isDirectory()) {
const resolved = await fs.realpath(link).catch(() => '');
if (path.resolve(resolved) === target) return staging;
}
await fs.remove(link);
} catch {}
await fs.symlink(target, link, 'junction');
return staging;
};
const SIZE_UNITS: Record<string, number> = {
B: 1,
KiB: 1024,
MiB: 1024 ** 2,
GiB: 1024 ** 3,
TiB: 1024 ** 4
};
const toBytes = (s: string): number => {
const m = /^([\d.]+)(B|KiB|MiB|GiB|TiB)$/.exec(s.trim());
if (!m) return 0;
return parseFloat(m[1]) * (SIZE_UNITS[m[2]] ?? 1);
};
const parseProgress = (
line: string,
totalHint: number
): SyncProgress | undefined => {
const frac =
/([\d.]+(?:B|KiB|MiB|GiB|TiB))\/([\d.]+(?:B|KiB|MiB|GiB|TiB))\((\d+)%\)/.exec(
line
);
if (!frac) return undefined;
const dl = /DL:([\d.]+(?:B|KiB|MiB|GiB|TiB))/.exec(line);
return {
progress: parseInt(frac[3], 10) / 100,
bytesDone: toBytes(frac[1]),
bytesTotal: toBytes(frac[2]) || totalHint,
bytesPerSecond: dl ? toBytes(dl[1]) : 0
};
};
let syncChild: ChildProcess | undefined;
// aria2's --stop-with-process is unreliable on Windows; kill the download
// child explicitly on quit or it keeps running headless
export const stopSyncing = (): void => {
syncChild?.kill();
syncChild = undefined;
};
export const syncClient = (opts: SyncOpts): Promise<void> =>
new Promise<void>((resolve, reject) => {
let child: ChildProcess | undefined;
ensureJunction(opts.clientDir)
.then(dir => {
const args = [
`--dir=${dir}`,
`--seed-time=${opts.seedTime ?? 0}`,
`--check-integrity=${opts.checkIntegrity ? 'true' : 'false'}`,
'--bt-save-metadata=true',
'--bt-remove-unselected-file=false',
'--continue=true',
'--allow-overwrite=true',
'--auto-file-renaming=false',
'--file-allocation=none',
'--disk-cache=128M',
'--stream-piece-selector=inorder',
'--max-tries=0',
'--retry-wait=5',
'--bt-stop-timeout=120',
'--auto-save-interval=15',
'--summary-interval=1',
'--console-log-level=warn',
'--enable-dht=true',
'--bt-enable-lpd=true',
'--max-connection-per-server=8',
'--split=16',
'--min-split-size=1M',
...(opts.selectFiles?.length
? [`--select-file=${opts.selectFiles.join(',')}`]
: []),
'--stop-with-process=' + process.pid,
opts.torrentUrl
];
Logger.log(`aria2c ${args.join(' ')}`);
child = spawn(bin(), args, { windowsHide: true });
syncChild = child;
const onLine = (buf: Buffer) => {
for (const line of buf.toString().split(/\r?\n/)) {
if (!line.trim()) continue;
const p = parseProgress(line, opts.totalBytes ?? 0);
if (p) opts.onProgress?.(p);
else Logger.log(`[aria2] ${line}`);
}
};
child.stdout?.on('data', onLine);
child.stderr?.on('data', onLine);
opts.signal?.addEventListener('abort', () => child?.kill());
child.on('error', reject);
child.on('close', code => {
if (syncChild === child) syncChild = undefined;
if (code === 0) resolve();
else reject(new Error(`aria2c exited with code ${code}`));
});
})
.catch(reject);
});
export const aria2Available = () => fs.pathExists(bin());
export const downloadIsComplete = async (): Promise<boolean> => {
const control = path.join(
app.getPath('userData'),
'torrent-root',
`${TORRENT_NAME}.aria2`
);
return !(await fs.pathExists(control));
};
export const clearTorrentResumeState = async (): Promise<void> => {
const dir = path.join(app.getPath('userData'), 'torrent-root');
await Promise.all([
fs.remove(path.join(dir, `${TORRENT_NAME}.aria2`)),
fs.remove(path.join(dir, `${TORRENT_NAME}.torrent`))
]);
};
export const torrentUrl = (): string | undefined =>
import.meta.env.MAIN_VITE_CLIENT_TORRENT_URL || undefined;
export const isTorrentMode = (): boolean => !!torrentUrl();
export const raidVisualsUrl = (): string | undefined =>
import.meta.env.MAIN_VITE_RAID_VISUALS_URL || undefined;
export const clientPatchUrl = (): string | undefined =>
import.meta.env.MAIN_VITE_CLIENT_PATCH_URL || undefined;
export const fetchTorrentSha = async (url: string): Promise<string> => {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const buf = Buffer.from(await r.arrayBuffer());
return crypto.createHash('sha1').update(buf).digest('hex');
};
const bdecode = (buf: Buffer, pos = 0): [unknown, number] => {
if (pos >= buf.length) throw new Error('bencode: unexpected end of data');
const ch = buf[pos];
if (ch === 0x69) {
const end = buf.indexOf(0x65, pos);
if (end === -1) throw new Error('bencode: unterminated integer');
return [parseInt(buf.toString('latin1', pos + 1, end), 10), end + 1];
}
if (ch === 0x6c) {
const list: unknown[] = [];
let p = pos + 1;
while (buf[p] !== 0x65) {
if (p >= buf.length) throw new Error('bencode: unterminated list');
const [v, np] = bdecode(buf, p);
list.push(v);
p = np;
}
return [list, p + 1];
}
if (ch === 0x64) {
const dict: Record<string, unknown> = {};
let p = pos + 1;
while (buf[p] !== 0x65) {
if (p >= buf.length) throw new Error('bencode: unterminated dict');
const [k, kp] = bdecode(buf, p);
const [v, vp] = bdecode(buf, kp);
dict[k as string] = v;
p = vp;
}
return [dict, p + 1];
}
const colon = buf.indexOf(0x3a, pos);
if (colon === -1) throw new Error('bencode: unterminated string length');
const len = parseInt(buf.toString('latin1', pos, colon), 10);
if (!Number.isInteger(len) || len < 0 || colon + 1 + len > buf.length)
throw new Error('bencode: invalid string length');
const start = colon + 1;
return [buf.toString('latin1', start, start + len), start + len];
};
const torrentDataArchives = (torrentBytes: Buffer): Set<string> => {
const [torrent] = bdecode(torrentBytes) as [
{ info?: { files?: { path?: string[] }[] } },
number
];
const files = torrent?.info?.files ?? [];
return new Set(
files
.filter(
f =>
f.path?.length === 2 &&
f.path[0] === 'Data' &&
/\.mpq$/i.test(f.path[1])
)
.map(f => f.path![1].toLowerCase())
);
};
const LOCALE_DIRS = new Set([
'enus',
'engb',
'encn',
'entw',
'kokr',
'frfr',
'dede',
'zhcn',
'zhtw',
'eses',
'esmx',
'ruru',
'ptbr',
'ptpt',
'itit'
]);
const torrentDataDirs = (torrentBytes: Buffer): Set<string> => {
const [torrent] = bdecode(torrentBytes) as [
{ info?: { files?: { path?: string[] }[] } },
number
];
const files = torrent?.info?.files ?? [];
return new Set(
files
.filter(f => (f.path?.length ?? 0) >= 3 && f.path![0] === 'Data')
.map(f => f.path![1].toLowerCase())
);
};
// archives the old client shipped under names the current one no longer uses;
// matched by name AND exact size so player mods reusing a name are never touched
const LEGACY_ARCHIVES: Record<string, number> = {
'patch-6.mpq': 451195806,
'patch-7.mpq': 175256564,
'patch-8.mpq': 484649870,
'patch-9.mpq': 506808141,
'patch-a.mpq': 241751337
};
export const pruneStaleArchives = async (
clientDir: string,
url: string,
_owned: Set<string>
): Promise<string[]> => {
try {
const r = await fetch(url);
if (!r.ok) return [];
const bytes = Buffer.from(await r.arrayBuffer());
const expected = torrentDataArchives(bytes);
if (!expected.size) return [];
for (const u of [clientPatchUrl(), raidVisualsUrl()])
if (u) expected.add(path.basename(u).toLowerCase());
const usedDirs = torrentDataDirs(bytes);
const dataDir = path.join(clientDir, 'Data');
const onDisk = await fs.readdir(dataDir).catch(() => []);
const removed: string[] = [];
for (const name of onDisk) {
const lc = name.toLowerCase();
const full = path.join(dataDir, name);
const st = await fs.stat(full).catch(() => null);
if (!st) continue;
if (st.isDirectory()) {
if (LOCALE_DIRS.has(lc) && !usedDirs.has(lc)) {
await fs.remove(full);
removed.push(name + '/');
}
continue;
}
if (!/\.mpq$/i.test(name) || expected.has(lc)) continue;
if (LEGACY_ARCHIVES[lc] !== st.size) continue;
await fs.remove(full);
removed.push(name);
}
return removed;
} catch (e) {
Logger.warn('Prune of stale archives failed', e);
return [];
}
};
// files the torrent ships but a mod toggle owns; the sync must not re-add
// them or count their absence as an incomplete tree
const LAUNCHER_OWNED_FILES = new Set(['d3d9.dll']);
const isLauncherOwned = (parts: string[]) =>
parts.length === 1 && LAUNCHER_OWNED_FILES.has(parts[0].toLowerCase());
export const torrentDownloadSelection = async (
clientDir: string,
url: string,
dropMismatched = false
): Promise<number[] | null> => {
try {
const r = await fetch(url);
if (!r.ok) return null;
const [torrent] = bdecode(Buffer.from(await r.arrayBuffer())) as [
{ info?: { files?: { path?: string[]; length?: number }[] } },
number
];
const files = torrent?.info?.files ?? [];
if (!files.length) return null;
const need: number[] = [];
let missing = false;
for (let i = 0; i < files.length; i++) {
const f = files[i];
if (!f.path?.length || typeof f.length !== 'number') return null;
if (isLauncherOwned(f.path)) continue;
const dest = path.join(clientDir, ...f.path);
const st = await fs.stat(dest).catch(() => null);
if (!st) {
missing = true;
need.push(i + 1);
continue;
}
if (st.size !== f.length) {
if (dropMismatched || st.size > f.length)
await fs.remove(dest).catch(() => {});
need.push(i + 1);
}
}
// a deleted file poisons the resume state (its pieces are marked
// done, so aria2 skips them forever); partial files keep it so an
// interrupted download resumes instead of restarting
if (missing) await clearTorrentResumeState().catch(() => undefined);
return need;
} catch (e) {
Logger.warn('Torrent selection computation failed', e);
return null;
}
};
export const torrentTreeIntact = async (
clientDir: string,
url: string
): Promise<boolean> => {
try {
const r = await fetch(url);
if (!r.ok) return false;
const [torrent] = bdecode(Buffer.from(await r.arrayBuffer())) as [
{ info?: { files?: { path?: string[]; length?: number }[] } },
number
];
const files = torrent?.info?.files ?? [];
if (!files.length) return false;
for (const f of files) {
if (!f.path?.length || typeof f.length !== 'number') return false;
if (isLauncherOwned(f.path)) continue;
const st = await fs
.stat(path.join(clientDir, ...f.path))
.catch(() => null);
if (!st || st.size !== f.length) return false;
}
return true;
} catch (e) {
Logger.warn('Torrent tree check failed', e);
return false;
}
};
const LOCALE_ASSERT_OFFSET = 0x1b2115;
const pristineWowPath = () =>
path.join(app.getPath('userData'), 'base-WoW.exe');
export const refreshPristineWow = async (clientDir: string): Promise<void> => {
const exe = path.join(clientDir, 'WoW.exe');
if (!(await fs.pathExists(exe))) return;
const fd = await fs.open(exe, 'r');
try {
const b = Buffer.alloc(1);
await fs.read(fd, b, 0, 1, LOCALE_ASSERT_OFFSET);
if (b[0] === 0xa1) {
await fs.copy(exe, pristineWowPath(), { overwrite: true });
Logger.log('Cached pristine WoW.exe base');
}
} finally {
await fs.close(fd);
}
};
export const readPristineWow = async (clientDir: string): Promise<Buffer> => {
const cache = pristineWowPath();
if (await fs.pathExists(cache)) return fs.readFile(cache);
return fs.readFile(path.join(clientDir, 'WoW.exe'));
};
let seeder: ChildProcess | undefined;
let mapping: Promise<PortMapping> | undefined;
let wantSeeding = false;
let starting = false;
const SEED_PORT = 6881;
const SEED_TIME_MINUTES = 525600;
export const isSeeding = (): boolean => !!seeder;
const releaseMapping = (): void => {
const m = mapping;
mapping = undefined;
if (m) void m.then(x => x.stop()).catch(() => {});
};
export const stopSeeding = (): void => {
wantSeeding = false;
seeder?.kill();
seeder = undefined;
releaseMapping();
};
export const startSeeding = async (
clientDir: string,
uploadLimit = '2M'
): Promise<void> => {
wantSeeding = true;
if (seeder || starting) return;
starting = true;
try {
const url = torrentUrl();
if (!url) return;
const dir = await ensureJunction(clientDir);
if (!wantSeeding || seeder) return;
const args = [
`--dir=${dir}`,
'--bt-seed-unverified=true',
`--seed-time=${SEED_TIME_MINUTES}`,
'--check-integrity=false',
// no prealloc: the seeder must not recreate missing files as zeros
'--file-allocation=none',
'--continue=true',
'--bt-save-metadata=true',
'--enable-dht=true',
'--bt-enable-lpd=true',
`--listen-port=${SEED_PORT}`,
`--dht-listen-port=${SEED_PORT}`,
`--max-overall-upload-limit=${uploadLimit}`,
'--summary-interval=0',
'--console-log-level=warn',
'--stop-with-process=' + process.pid,
url
];
Logger.log('aria2c (seed) ' + args.join(' '));
const child = spawn(bin(), args, { windowsHide: true });
seeder = child;
const onExit = () => {
if (seeder === child) {
seeder = undefined;
releaseMapping();
}
};
child.on('close', onExit);
child.on('error', e => {
Logger.warn('Seeder failed', e);
onExit();
});
mapping = mapPort(SEED_PORT, { description: 'OctoWoW' });
void mapping.catch(() => undefined);
} catch (e) {
Logger.warn('startSeeding failed', e);
} finally {
starting = false;
}
};
+15 -134
View File
@@ -1,5 +1,4 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -30,38 +29,21 @@ export const addDefenderExclusions = async (): Promise<ExclusionResult> => {
process.env.PORTABLE_EXECUTABLE_DIR ?? path.dirname(app.getPath('exe'));
const paths = [...new Set([clientDir, launcherDir])];
const resultFile = path.join(
os.tmpdir(),
`octo-defender-${process.pid}-${Date.now()}.txt`
);
const write = (v: string) =>
`Set-Content -LiteralPath ${psSingleQuote(
resultFile
)} -Value "${v}" -Encoding ASCII`;
const inner = [
'$ErrorActionPreference = "Stop"',
'try {',
...paths.map(
p =>
` Add-MpPreference -ExclusionPath ${psSingleQuote(
p
)} -ErrorAction Stop`
),
' Add-MpPreference -ExclusionProcess "WoW.exe" -ErrorAction Stop',
' Add-MpPreference -ExclusionProcess "VanillaFixes.exe" -ErrorAction Stop',
` ${write('OK')}`,
'} catch {',
' $t = $false',
' try { $t = (Get-MpComputerStatus).IsTamperProtected } catch {}',
` if ($t) { ${write('TAMPER')} } else { ${write('FAIL')} }`,
'}'
...paths.map(p => `Add-MpPreference -ExclusionPath ${psSingleQuote(p)}`),
'Add-MpPreference -ExclusionProcess "WoW.exe"',
'Add-MpPreference -ExclusionProcess "VanillaFixes.exe"',
'exit 0',
'} catch { exit 2 }'
].join('\n');
const encoded = Buffer.from(inner, 'utf16le').toString('base64');
const outer =
'try { Start-Process powershell -Verb RunAs -WindowStyle Hidden -Wait ' +
"-ArgumentList '-NoProfile','-NonInteractive'," +
`'-EncodedCommand','${encoded}' } catch { exit 1 }`;
'try { $p = Start-Process powershell -Verb RunAs -WindowStyle Hidden ' +
"-Wait -PassThru -ArgumentList '-NoProfile','-NonInteractive'," +
`'-EncodedCommand','${encoded}'; exit $p.ExitCode } catch { exit 1 }`;
return new Promise<ExclusionResult>(resolve => {
const child = spawn(
@@ -76,124 +58,23 @@ export const addDefenderExclusions = async (): Promise<ExclusionResult> => {
resolve({ ok: false, error: 'Could not run Windows PowerShell.' });
});
child.on('exit', code => {
let result: string | null = null;
try {
result = fs.readFileSync(resultFile, 'utf8').trim();
} catch {}
try {
fs.rmSync(resultFile, { force: true });
} catch {}
if (result === 'OK') {
if (code === 0) {
Logger.info(`Added Defender exclusions: ${paths.join(', ')}`);
resolve({ ok: true, paths });
return;
}
if (result === 'TAMPER') {
Logger.error('Defender exclusion blocked by Tamper Protection');
} else if (code === 1) {
resolve({
ok: false,
error:
'Windows Security Tamper Protection is blocking this. Turn it off in Windows Security, or add your game folder by hand under Exclusions.'
'No permission granted. Click Yes on the Windows prompt to add the exclusion.'
});
return;
}
if (result === 'FAIL') {
Logger.error(`Defender exclusion failed: ${stderr}`.trim());
} else {
Logger.error(`Defender exclusion failed (code ${code}): ${stderr}`);
resolve({
ok: false,
error:
'Windows would not add the exclusion. You can add your game folder by hand in Windows Security, under Exclusions.'
'Could not add the exclusion automatically. You may need to add it in Windows Security manually.'
});
return;
}
Logger.warn(
`Defender exclusion: no result (exit ${code}) ${stderr}`.trim()
);
resolve({
ok: false,
error:
'Windows did not grant permission. Click Yes on the User Account Control prompt to add the exclusion.'
});
});
});
};
const SENSITIVE_FILES = [
'WoW.exe',
'VanillaFixes.exe',
'd3d9.dll',
'UnitXP_SP3.dll',
'nampower.dll',
'VfPatcher.dll',
'VanillaHelpers.dll',
'VanillaMultiMonitorFix.dll',
'transmogfix.dll'
];
export const detectAntivirusBlocks = async (): Promise<string[]> => {
if (os.platform() !== 'win32') return [];
const clientDir = Preferences.data.clientDir;
const launcherDir =
process.env.PORTABLE_EXECUTABLE_DIR ?? path.dirname(app.getPath('exe'));
const roots = [clientDir, launcherDir]
.filter((p): p is string => !!p)
.map(p => p.toLowerCase());
if (!roots.length) return [];
const blocked = new Set<string>();
if (clientDir && Preferences.data.syncedTorrentHash)
for (const name of SENSITIVE_FILES) {
// d3d9.dll is deliberately parked while dxvk is off, not blocked
if (
name === 'd3d9.dll' &&
Preferences.data.mods?.dxvk?.enabled === false
)
continue;
if (!fs.existsSync(path.join(clientDir, name))) blocked.add(name);
}
const script =
'Get-MpThreatDetection | Where-Object ' +
'{ $_.InitialDetectionTime -gt (Get-Date).AddHours(-12) } | ' +
'Select-Object -ExpandProperty Resources';
await new Promise<void>(resolve => {
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', script],
{ windowsHide: true }
);
let out = '';
let settled = false;
const timer = setTimeout(() => {
try {
child.kill();
} catch {}
}, 15_000);
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve();
};
child.stdout.on('data', d => (out += String(d)));
child.on('error', finish);
child.on('exit', () => {
for (const line of out.split(/\r?\n/)) {
const m = /^file:_?(.+)$/.exec(line.trim());
if (!m) continue;
const full = m[1];
if (
roots.some(r => full.toLowerCase().startsWith(r)) &&
!fs.existsSync(full)
)
blocked.add(path.basename(full));
}
finish();
});
});
return [...blocked];
};
+21 -91
View File
@@ -3,21 +3,8 @@ import { spawn } from 'node:child_process';
import Logger from 'electron-log/main';
export type DisplayDevice = {
index: number;
deviceName: string;
deviceString: string;
attached: boolean;
primary: boolean;
width: number;
height: number;
refresh: number;
modes: string[];
};
const SCRIPT = [
'$ErrorActionPreference = "Stop"',
"$ProgressPreference = 'SilentlyContinue'",
"Add-Type -TypeDefinition @'",
'using System;',
'using System.Runtime.InteropServices;',
@@ -31,79 +18,30 @@ const SCRIPT = [
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceID;',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceKey;',
' }',
' [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)]',
' public struct DEVMODE {',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string dmDeviceName;',
' public short dmSpecVersion; public short dmDriverVersion; public short dmSize; public short dmDriverExtra;',
' public int dmFields; public int dmPositionX; public int dmPositionY;',
' public int dmDisplayOrientation; public int dmDisplayFixedOutput;',
' public short dmColor; public short dmDuplex; public short dmYResolution; public short dmTTOption; public short dmCollate;',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string dmFormName;',
' public short dmLogPixels; public int dmBitsPerPel; public int dmPelsWidth; public int dmPelsHeight;',
' public int dmDisplayFlags; public int dmDisplayFrequency;',
' public int dmICMMethod; public int dmICMIntent; public int dmMediaType; public int dmDitherType;',
' public int dmReserved1; public int dmReserved2; public int dmPanningWidth; public int dmPanningHeight;',
' }',
' [DllImport("user32.dll", EntryPoint="EnumDisplayDevicesA", CharSet=CharSet.Ansi)]',
' public static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags);',
' [DllImport("user32.dll", EntryPoint="EnumDisplaySettingsA", CharSet=CharSet.Ansi)]',
' public static extern bool EnumDisplaySettings(string lpszDeviceName, int iModeNum, ref DEVMODE lpDevMode);',
'}',
"'@",
'for ($i = 0; ; $i++) {',
' $dd = New-Object VmmfDisplays+DISPLAY_DEVICE',
' $dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)',
' if (-not [VmmfDisplays]::EnumDisplayDevices([NullString]::Value, $i, [ref]$dd, 0)) { break }',
' $dm = New-Object VmmfDisplays+DEVMODE',
' $dm.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($dm)',
' $cur = ""',
' if ([VmmfDisplays]::EnumDisplaySettings($dd.DeviceName, -1, [ref]$dm)) {',
' $cur = "$($dm.dmPelsWidth)|$($dm.dmPelsHeight)|$($dm.dmDisplayFrequency)"',
' }',
' $modes = New-Object System.Collections.Generic.HashSet[string]',
' for ($m = 0; ; $m++) {',
' $d2 = New-Object VmmfDisplays+DEVMODE',
' $d2.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($d2)',
' if (-not [VmmfDisplays]::EnumDisplaySettings($dd.DeviceName, $m, [ref]$d2)) { break }',
' [void]$modes.Add("$($d2.dmPelsWidth)x$($d2.dmPelsHeight)")',
' }',
' Write-Output ("{0}`t{1}`t{2}`t{3}`t{4}`t{5}" -f $i, $dd.DeviceName, $dd.DeviceString, $dd.StateFlags, $cur, ($modes -join ","))',
'}'
'$dd = New-Object VmmfDisplays+DISPLAY_DEVICE',
'$dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)',
'for ($i = 0; [VmmfDisplays]::EnumDisplayDevices([NullString]::Value, $i, [ref]$dd, 0); $i++) {',
' if ($dd.StateFlags -band 4) { Write-Output $i; exit 0 }',
'}',
'exit 1'
].join('\n');
const parseRow = (line: string): DisplayDevice | undefined => {
const f = line.split('\t');
if (f.length < 6) return undefined;
const index = Number(f[0]);
const stateFlags = Number(f[3]);
if (!Number.isInteger(index) || index < 0 || !Number.isInteger(stateFlags))
return undefined;
const [w, h, hz] = (f[4] || '').split('|').map(Number);
return {
index,
deviceName: f[1],
deviceString: f[2],
attached: (stateFlags & 1) !== 0,
primary: (stateFlags & 4) !== 0,
width: Number.isFinite(w) ? w : 0,
height: Number.isFinite(h) ? h : 0,
refresh: Number.isFinite(hz) ? hz : 0,
modes: (f[5] || '').split(',').filter(Boolean)
};
};
export const enumerateDisplays = (): Promise<DisplayDevice[] | null> => {
if (os.platform() !== 'win32') return Promise.resolve(null);
export const detectPrimaryDisplayIndex = (): Promise<number> => {
if (os.platform() !== 'win32') return Promise.resolve(0);
const encoded = Buffer.from(SCRIPT, 'utf16le').toString('base64');
return new Promise(resolve => {
let settled = false;
const finish = (v: DisplayDevice[] | null) => {
const finish = (index: number) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(v);
resolve(index);
};
const child = spawn(
@@ -114,33 +52,25 @@ export const enumerateDisplays = (): Promise<DisplayDevice[] | null> => {
const timer = setTimeout(() => {
child.kill();
Logger.warn('Display enumeration timed out');
finish(null);
}, 10000);
Logger.warn('Primary display detection timed out');
finish(0);
}, 8000);
let stdout = '';
child.stdout.on('data', d => (stdout += String(d)));
child.on('error', e => {
Logger.warn('Display enumeration failed to launch PowerShell', e);
finish(null);
Logger.warn('Primary display detection failed to launch PowerShell', e);
finish(0);
});
child.on('exit', code => {
const devices = stdout
.split(/\r?\n/)
.map(parseRow)
.filter((d): d is DisplayDevice => d !== undefined);
if (code === 0 && devices.length) {
Logger.info(`Enumerated ${devices.length} display device(s)`);
finish(devices);
const index = Number(stdout.trim());
if (code === 0 && Number.isInteger(index) && index >= 0) {
Logger.info(`Detected primary display at device index ${index}`);
finish(index);
} else {
Logger.warn('Display enumeration returned nothing usable');
finish(null);
Logger.warn('Primary display detection failed, defaulting to 0');
finish(0);
}
});
});
};
export const detectPrimaryDisplayIndex = async (): Promise<number | null> => {
const devices = await enumerateDisplays();
return devices?.find(d => d.primary && d.attached)?.index ?? null;
};
-24
View File
@@ -19,37 +19,16 @@ const readLines = async (clientDir: string): Promise<string[]> => {
return text.split(/\r?\n/);
};
const dllNames = (lines: string[]) =>
lines.map(l => l.trim()).filter(l => l && !l.startsWith('#'));
// keep VanillaFixes' consent cache in step with dlls.txt so it won't re-prompt
const writeCache = async (clientDir: string, names: string[]) => {
const cache = path.join(clientDir, 'dlls.txt.cache');
if (!names.length) {
await fs.remove(cache).catch(() => {});
return;
}
const body = names.map(n => path.win32.join(clientDir, n)).join('\r\n');
await fs.writeFile(cache, body, 'utf8').catch(() => {});
};
const writeLines = async (clientDir: string, lines: string[]) => {
const file = dllsPath(clientDir);
const trimmed = lines.join('\n').replace(/\n+$/, '');
if (!trimmed.trim()) {
if (await fs.pathExists(file)) await fs.remove(file);
await writeCache(clientDir, []);
return;
}
await fs.writeFile(file, trimmed + '\n', 'utf8');
await writeCache(clientDir, dllNames(lines));
};
export const syncVanillaFixesCache = (clientDir: string) =>
serial(async () =>
writeCache(clientDir, dllNames(await readLines(clientDir)))
);
const matches = (line: string, name: string) =>
line.trim().toLowerCase() === name.toLowerCase();
@@ -74,6 +53,3 @@ export const hasDll = (clientDir: string, name: string) =>
const lines = await readLines(clientDir);
return lines.some(l => matches(l, name));
});
export const listDlls = (clientDir: string): Promise<string[]> =>
serial(async () => dllNames(await readLines(clientDir)));
+76 -20
View File
@@ -11,13 +11,16 @@ import Logger from 'electron-log/main';
import Preferences from './preferences';
// old installs may have a copied patch-<letter>.mpq that overrides patch-5; sweep by marker
const ALL_LETTERS = 'BCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const PREFERRED = 'L';
const LETTERS = 'BCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const MARKER = 'octolocale.marker';
const patchFile = (dataDir: string, letter: string) =>
path.join(dataDir, `patch-${letter}.mpq`);
const prebuiltFor = (dataDir: string, locale: string) =>
path.join(dataDir, locale, 'patch-L.mpq');
const isOurPatch = (mpqPath: string): boolean => {
if (!fs.existsSync(mpqPath)) return false;
try {
@@ -32,29 +35,82 @@ const isOurPatch = (mpqPath: string): boolean => {
}
};
// remove locale patches we copied in (marker-carrying archives only); never throws
export const removeLegacyLocalePatches = async (
clientDir: string | undefined
): Promise<void> => {
if (!clientDir) return;
const dataDir = path.join(clientDir, 'Data');
if (!(await fs.pathExists(dataDir))) return;
const usableSlot = (dataDir: string, letter: string): boolean => {
if (fs.existsSync(path.join(dataDir, `patch-${letter}.MPQ`))) return false;
const f = patchFile(dataDir, letter);
return !fs.existsSync(f) || isOurPatch(f);
};
for (const letter of ALL_LETTERS) {
const f = patchFile(dataDir, letter);
if (!isOurPatch(f)) continue;
try {
await fs.remove(f);
Logger.log(`Removed the retired locale patch patch-${letter}.mpq`);
} catch (e) {
Logger.error(`Could not remove patch-${letter}.mpq`, e);
}
const removeOurPatch = async (dataDir: string) => {
for (const l of LETTERS) {
const f = patchFile(dataDir, l);
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
}
// clear the stale tracking keys
if (Preferences.data.localePatchLetter || Preferences.data.localePatchLocale)
Preferences.data = {
localePatchLetter: undefined,
localePatchLocale: undefined
};
};
export const applyLocalePatch = async (
clientDir: string | undefined,
locale: string | undefined
): Promise<void> => {
if (!clientDir) return;
const dataDir = path.join(clientDir, 'Data');
const nextLocale = !locale || locale === 'enUS' ? undefined : locale;
if (Preferences.data.localePatchLocale !== nextLocale)
await fs.remove(path.join(clientDir, 'WDB')).catch(() => {});
if (!locale || locale === 'enUS') {
await removeOurPatch(dataDir);
return;
}
const source = prebuiltFor(dataDir, locale);
if (!(await fs.pathExists(source))) {
Logger.warn(
`Locale patch: no prebuilt patch-L for ${locale}; leaving UI as-is`
);
return;
}
const tracked = Preferences.data.localePatchLetter;
const letter =
(tracked && usableSlot(dataDir, tracked) ? tracked : undefined) ??
(usableSlot(dataDir, PREFERRED)
? PREFERRED
: LETTERS.find(l => usableSlot(dataDir, l)));
if (!letter) {
Logger.warn('Locale patch: no usable patch slot');
return;
}
const target = patchFile(dataDir, letter);
try {
if (
Preferences.data.localePatchLocale === locale &&
isOurPatch(target) &&
fs.statSync(target).mtimeMs >= fs.statSync(source).mtimeMs
)
return;
} catch {
}
try {
for (const l of LETTERS) {
if (l === letter) continue;
const f = patchFile(dataDir, l);
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
}
await fs.copy(source, target, { overwrite: true });
Preferences.data = { localePatchLetter: letter, localePatchLocale: locale };
Logger.log(
`Locale patch: swapped prebuilt ${locale} -> patch-${letter}.mpq`
);
} catch (e) {
Logger.error('Locale patch: failed to swap in prebuilt patch', e);
}
};
+16 -398
View File
@@ -1,5 +1,4 @@
import path from 'path';
import { createHash } from 'crypto';
import fs from 'fs-extra';
import fetch from 'node-fetch';
@@ -7,71 +6,20 @@ import AdmZip from 'adm-zip';
import * as tar from 'tar';
import Logger from 'electron-log/main';
import {
MODS,
DEFAULT_ENABLED_MODS,
type ModEntry,
type ModId,
getMod
} from '~common/mods';
import { MODS, type ModEntry, type ModId, getMod } from '~common/mods';
import { type ModState } from '~common/schemas';
import Preferences from './preferences';
import { isTorrentMode, stopSeeding } from './aria2';
import Observable from './observable';
import Updater from './updater';
import { addDll, removeDll, listDlls } from './dllsTxt';
import { enumerateDisplays } from './displays';
import { addDll, removeDll } from './dllsTxt';
import { detectPrimaryDisplayIndex } from './displays';
const MOD_DOWNLOAD_TIMEOUT_MS = 60_000;
/** Files a mod installs on disk. */
const modTargetFiles = (m: ModEntry): string[] => {
if (m.source.kind === 'directFile') return [m.source.assetName];
if (m.source.kind === 'archive') return Object.values(m.source.extractMap);
return [];
};
// client-shipped DLLs that aren't injectable mods; not counted as custom mods
const RESERVED_DLLS = new Set([
'ace.dll',
'divxdecoder.dll',
'discordoverlay.dll',
'discord_game_sdk.dll',
'dbghelp.dll',
'fmod.dll',
'ijl15.dll',
'sdl.dll',
'scan.dll',
'unicows.dll',
'zlib1.dll'
]);
// files owned by an active built-in mod; a disabled mod's files are fair game to add by hand
const KNOWN_DLLS = new Set(
MODS.filter(m => !m.disabled)
.flatMap(m => [m.registerInDllsTxt, ...modTargetFiles(m)])
.filter((f): f is string => !!f)
.map(f => f.toLowerCase())
);
const AV_ERROR =
'Windows Defender blocked this download. Use "Allow through antivirus" and apply again.';
// pinned dxvk-gplasync v2.7.1-1 x32 d3d9.dll (same build the client ships)
const DXVK_DLL_SHA256 =
'a2cd6841e102f37189527c118ec416fa5071ac4d3120762973d9a0c6c5fd067e';
const fileSha256 = async (p: string): Promise<string | null> => {
try {
return createHash('sha256')
.update(await fs.readFile(p))
.digest('hex');
} catch {
return null;
}
};
const looksLikeAvBlock = (msg: string) =>
/windows defender|virus|potentially unwanted/i.test(msg);
@@ -91,31 +39,19 @@ export type ModRowStatus = {
error?: string;
};
export type CustomMod = { name: string; enabled: boolean };
export type ModsStatus = {
state: 'verifying' | 'idle' | 'busy';
dirty: boolean;
mods: ModRowStatus[];
custom: CustomMod[];
// enabled mods whose files are missing (AV quarantine or incomplete sync)
missingFiles: string[];
};
class ModsClass extends Observable<ModsStatus> {
protected _value: ModsStatus = {
state: 'verifying',
dirty: false,
mods: [],
custom: [],
missingFiles: []
mods: []
};
// staged custom-DLL toggles, keyed lower-case; #customApplied mirrors dlls.txt
#customDesired = new Map<string, boolean>();
#customApplied = new Map<string, boolean>();
#customNames = new Map<string, string>();
get status(): ModsStatus {
return this._value;
}
@@ -147,7 +83,6 @@ class ModsClass extends Observable<ModsStatus> {
}
#computeDirty(): boolean {
if (this.#customDesired.size > 0) return true;
return this._value.mods.some(r => {
const wantInstalled = r.enabled;
const isInstalled = !!r.installedVersion;
@@ -166,121 +101,10 @@ class ModsClass extends Observable<ModsStatus> {
this._value = {
state: 'verifying',
dirty: false,
mods: MODS.filter(m => !m.disabled).map(m => this.#initialRow(m)),
custom: this._value.custom,
missingFiles: []
mods: MODS.map(m => this.#initialRow(m))
};
}
// DLLs in the client dir we neither ship nor own
async #detectCustomDlls(clientDir: string): Promise<CustomMod[]> {
const inDllsTxt = await listDlls(clientDir);
const enabled = new Set(inDllsTxt.map(n => n.toLowerCase()));
const found = new Map<string, string>();
const consider = (name: string) => {
const lc = name.toLowerCase();
if (RESERVED_DLLS.has(lc) || KNOWN_DLLS.has(lc) || found.has(lc)) return;
found.set(lc, name);
};
for (const f of await fs.readdir(clientDir).catch(() => [] as string[]))
if (/\.dll$/i.test(f)) consider(f);
inDllsTxt.forEach(consider);
const names = [...found.values()].sort((a, b) => a.localeCompare(b));
this.#customApplied = new Map(
names.map(n => [n.toLowerCase(), enabled.has(n.toLowerCase())])
);
this.#customNames = new Map(names.map(n => [n.toLowerCase(), n]));
// drop staged changes for DLLs no longer present
const present = new Set(names.map(n => n.toLowerCase()));
for (const lc of [...this.#customDesired.keys()])
if (!present.has(lc)) this.#customDesired.delete(lc);
return names.map(name => {
const lc = name.toLowerCase();
return {
name,
enabled: this.#customDesired.has(lc)
? !!this.#customDesired.get(lc)
: !!this.#customApplied.get(lc)
};
});
}
// flush staged custom-DLL changes to dlls.txt; a failed write stays staged (still pending)
async #applyCustomDlls(clientDir: string) {
for (const [lc, enabled] of [...this.#customDesired]) {
const name = this.#customNames.get(lc) ?? lc;
try {
await (enabled ? addDll(clientDir, name) : removeDll(clientDir, name));
this.#customDesired.delete(lc);
} catch (e) {
Logger.warn(`custom dll apply failed for ${name}`, e);
}
}
}
async #syncPreferredMonitor(clientDir: string) {
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
if (!(await fs.pathExists(vmmfDll))) return;
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
const existsCfg = await fs.pathExists(vmmfCfg);
const current = existsCfg
? Number(
await fs
.readFile(vmmfCfg, 'utf8')
.then(s => s.trim())
.catch(() => '')
)
: NaN;
const hasCurrent = Number.isInteger(current);
const ours = Preferences.data?.vmmfWrittenIndex;
const devices = await enumerateDisplays();
const usable = devices?.filter(d => d.attached && d.width > 0);
if (!devices || !usable?.length) {
Logger.warn('Could not enumerate displays; preferred monitor unchanged');
return;
}
const primary = usable.find(d => d.primary) ?? usable[0];
if (hasCurrent && ours === undefined) {
const pinned = devices.find(d => d.index === current);
const broken = !pinned || !pinned.attached || !pinned.primary;
if (!broken) {
Preferences.data = { vmmfWrittenIndex: current };
Logger.info(`Adopting existing preferred monitor ${current} as chosen`);
return;
}
Logger.warn(
`Preferred monitor ${current} (${
pinned ? pinned.deviceName : 'missing'
}) is ${
!pinned || !pinned.attached
? 'not attached'
: 'not the primary display'
}; healing to ${primary.index}`
);
} else if (hasCurrent && current !== ours) {
Logger.info(
`Preferred monitor ${current} was set manually; leaving it alone`
);
Preferences.data = { vmmfWrittenIndex: current };
return;
} else if (hasCurrent && current === primary.index) {
return;
}
await fs
.writeFile(vmmfCfg, `${primary.index}\n`, 'utf8')
.then(() => {
Preferences.data = { vmmfWrittenIndex: primary.index };
Logger.info(
`Preferred monitor set to ${primary.index} (${primary.deviceName} ${primary.width}x${primary.height})`
);
})
.catch(e => Logger.warn('Failed to write preferred monitor', e));
}
async verify() {
this.load();
this._notifyObservers();
@@ -288,84 +112,18 @@ class ModsClass extends Observable<ModsStatus> {
const clientDir = Preferences.data?.clientDir;
if (clientDir) {
await this.#syncPreferredMonitor(clientDir);
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
if ((await fs.pathExists(vmmfDll)) && !(await fs.pathExists(vmmfCfg))) {
const index = await detectPrimaryDisplayIndex();
await fs.writeFile(vmmfCfg, `${index}\n`, 'utf8').catch(() => {});
}
}
const missing: string[] = [];
let dxvkRepair = false;
for (const m of MODS) {
// disabled mods: leave dlls.txt and installed state untouched
if (m.disabled) continue;
const state = Preferences.data?.mods?.[m.id];
let installedVersion = state?.installedVersion;
// torrent mode: DLLs ship in the client; a missing file goes to `missing`, not dirty
if (isTorrentMode()) {
const enabled = state?.enabled ?? DEFAULT_ENABLED_MODS.includes(m.id);
// dxvk loads by file presence and torrent piece spillover can
// corrupt it; hash-verify every state: park/restore verified
// copies only, delete junk, re-download the pin when needed
if (m.id === 'dxvk' && clientDir) {
const live = path.join(clientDir, 'd3d9.dll');
const off = path.join(clientDir, 'd3d9.dll.off');
const liveSha = await fileSha256(live);
if (!enabled) {
if (
liveSha === DXVK_DLL_SHA256 &&
!(await fs.pathExists(off))
) {
await fs
.move(live, off)
.then(() => Logger.info('dxvk disabled: parked d3d9.dll'))
.catch(e => Logger.warn('Could not park d3d9.dll', e));
} else if (liveSha !== null) {
await fs.remove(live).catch(() => undefined);
}
} else if (liveSha !== DXVK_DLL_SHA256) {
if (liveSha !== null) {
Logger.warn('dxvk: d3d9.dll failed verification; replacing');
await fs.remove(live).catch(() => undefined);
}
const offSha = await fileSha256(off);
if (offSha === DXVK_DLL_SHA256) {
await fs
.move(off, live)
.then(() => Logger.info('dxvk enabled: restored d3d9.dll'))
.catch(e => Logger.warn('Could not restore d3d9.dll', e));
} else {
if (offSha !== null)
await fs.remove(off).catch(() => undefined);
dxvkRepair = true;
}
}
}
const files = modTargetFiles(m);
const present =
!!clientDir &&
files.length > 0 &&
(
await Promise.all(
files.map(rel => fs.pathExists(path.join(clientDir, rel)))
)
).every(Boolean);
installedVersion = enabled ? m.version : undefined;
if (enabled && files.length > 0 && !present) missing.push(m.name);
// only point dlls.txt at a file actually on disk
if (clientDir && m.registerInDllsTxt)
await (present && enabled
? addDll(clientDir, m.registerInDllsTxt)
: removeDll(clientDir, m.registerInDllsTxt)
).catch(e => Logger.warn(`dlls.txt update failed for ${m.id}`, e));
this.#patchRow(m.id, {
installedVersion,
latestVersion: m.version,
enabled,
ignoreUpdates: true
});
continue;
}
if (clientDir && installedVersion) {
const filesPresent = await Promise.all(
(state?.installedFiles ?? []).map(rel =>
@@ -397,72 +155,14 @@ class ModsClass extends Observable<ModsStatus> {
});
}
if (dxvkRepair) {
const dm = getMod('dxvk');
if (dm)
await this.#install(dm).catch(e =>
Logger.warn('dxvk repair download failed', e)
);
}
this._value = {
...this._value,
state: 'idle',
dirty: this.#computeDirty(),
custom: clientDir ? await this.#detectCustomDlls(clientDir) : [],
missingFiles: missing
dirty: this.#computeDirty()
};
this._notifyObservers();
}
async toggleCustom(name: string, enabled: boolean) {
const clientDir = Preferences.data?.clientDir;
if (!clientDir) return;
// stage; matching dlls.txt clears the pending change
const lc = name.toLowerCase();
if (enabled === !!this.#customApplied.get(lc))
this.#customDesired.delete(lc);
else this.#customDesired.set(lc, enabled);
this._value = {
...this._value,
custom: await this.#detectCustomDlls(clientDir)
};
this._value = { ...this._value, dirty: this.#computeDirty() };
this._notifyObservers();
}
async addCustomDll(
srcPath: string
): Promise<{ ok: boolean; error?: string }> {
const clientDir = Preferences.data?.clientDir;
if (!clientDir) return { ok: false, error: 'No game folder is set.' };
const name = path.basename(srcPath);
if (!/\.dll$/i.test(name))
return { ok: false, error: 'Please choose a .dll file.' };
const lc = name.toLowerCase();
if (RESERVED_DLLS.has(lc) || KNOWN_DLLS.has(lc))
return {
ok: false,
error: `${name} is a built-in file and can't be added as a custom mod.`
};
try {
const dest = path.join(clientDir, name);
if (path.resolve(srcPath) !== path.resolve(dest))
await fs.copy(srcPath, dest, { overwrite: true });
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
// stage enabled; Apply writes dlls.txt
this.#customDesired.set(name.toLowerCase(), true);
this._value = {
...this._value,
custom: await this.#detectCustomDlls(clientDir)
};
this._value = { ...this._value, dirty: this.#computeDirty() };
this._notifyObservers();
return { ok: true };
}
async toggle(id: ModId, enabled: boolean) {
const cur = Preferences.data?.mods?.[id];
await this.#savePref(id, {
@@ -491,38 +191,6 @@ class ModsClass extends Observable<ModsStatus> {
Logger.warn('No clientDir set; cannot apply mods.');
return;
}
// don't commit a mod set with an unmet dependency; dirty stays set. repair is exempt.
if (!opts.repairOnly) {
const enabledIds = new Set(
this._value.mods.filter(r => r.enabled).map(r => r.id)
);
const missingDeps = [
...new Set(
this._value.mods
.filter(r => r.enabled)
.flatMap(r => r.requires.filter(dep => !enabledIds.has(dep)))
)
];
if (missingDeps.length) {
Logger.warn(
`Not applying mods: unmet dependencies ${missingDeps.join(', ')}`
);
return;
}
}
// commit the player's own DLL toggles first
await this.#applyCustomDlls(clientDir);
// torrent mode: mods ship in the client; reconcile dlls.txt. The
// seeder holds files open, so release it for the dxvk park/restore.
if (isTorrentMode()) {
stopSeeding();
try {
await this.verify();
} finally {
await Updater.refreshSeeding().catch(() => undefined);
}
return;
}
if (this._value.state === 'busy') {
Logger.warn('applyAll already running; ignoring re-entrant call.');
return;
@@ -575,32 +243,6 @@ class ModsClass extends Observable<ModsStatus> {
async #install(m: ModEntry) {
const clientDir = Preferences.data?.clientDir;
// dxvk: restoring a parked copy is the only enable path that works in
// torrent mode (nothing is fetched there, the sync ignores d3d9.dll)
if (m.id === 'dxvk' && clientDir) {
const live = path.join(clientDir, 'd3d9.dll');
const off = path.join(clientDir, 'd3d9.dll.off');
if (!(await fs.pathExists(live)) && (await fs.pathExists(off))) {
Logger.info('Restoring parked d3d9.dll for dxvk');
await fs.move(off, live);
await this.#savePref(m.id, {
enabled: true,
installedVersion: m.version,
installedFiles: ['d3d9.dll'],
ignoreUpdates:
Preferences.data?.mods?.[m.id]?.ignoreUpdates ?? false
});
this.#patchRow(m.id, {
state: 'idle',
installedVersion: m.version,
progress: 1
});
return;
}
}
// torrent mode ships mod binaries with the client; dxvk is the
// exception (unsynced), a fresh enable with no parked copy downloads
if (isTorrentMode() && m.id !== 'dxvk') return;
if (!clientDir) throw new Error('No client dir');
if (m.source.kind === 'managed') return;
@@ -616,7 +258,7 @@ class ModsClass extends Observable<ModsStatus> {
if (m.source.kind === 'directFile') {
const dest = path.join(clientDir, m.source.assetName);
await this.#downloadTo(m.source.url, dest, m.source.sha256);
await this.#downloadTo(m.source.url, dest);
written.push(m.source.assetName);
} else if (m.source.kind === 'archive') {
const scratch = path.join(clientDir, '.octolauncher-tmp');
@@ -625,7 +267,7 @@ class ModsClass extends Observable<ModsStatus> {
scratch,
`${m.id}-${Date.now()}.${m.source.format}`
);
await this.#downloadTo(m.source.url, tmp, m.source.sha256);
await this.#downloadTo(m.source.url, tmp);
this.#patchRow(m.id, { state: 'installing' });
const map = m.source.extractMap;
@@ -695,20 +337,7 @@ class ModsClass extends Observable<ModsStatus> {
this.#patchRow(m.id, { state: 'uninstalling', error: undefined });
const cur = Preferences.data?.mods?.[m.id];
// dxvk: park instead of delete so re-enable is instant and offline
const files = [...(cur?.installedFiles ?? [])].filter(
f => !(m.id === 'dxvk' && /d3d9\.dll$/i.test(f))
);
if (m.id === 'dxvk') {
const live = path.join(clientDir, 'd3d9.dll');
const off = path.join(clientDir, 'd3d9.dll.off');
if (await fs.pathExists(live)) {
await fs.remove(off).catch(() => undefined);
await fs
.move(live, off)
.catch(err => Logger.warn(`Couldn't park ${live}:`, err));
}
}
const files = cur?.installedFiles ?? [];
for (const rel of files) {
const fullPath = path.join(clientDir, rel);
@@ -731,7 +360,7 @@ class ModsClass extends Observable<ModsStatus> {
this.#patchRow(m.id, { state: 'idle', installedVersion: undefined });
}
async #downloadTo(url: string, dest: string, sha256?: string) {
async #downloadTo(url: string, dest: string) {
const res = await fetch(url, {
headers: { 'User-Agent': 'OctoLauncher' },
timeout: MOD_DOWNLOAD_TIMEOUT_MS
@@ -739,17 +368,6 @@ class ModsClass extends Observable<ModsStatus> {
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
await fs.ensureDir(path.dirname(dest));
const buf = await res.arrayBuffer();
if (sha256) {
const got = createHash('sha256').update(Buffer.from(buf)).digest('hex');
if (got !== sha256.toLowerCase())
throw new Error(
`Checksum mismatch for ${path.basename(
dest
)}: expected ${sha256}, got ${got}. Refusing to install.`
);
}
await fs.writeFile(dest, Buffer.from(buf));
if (!(await fs.pathExists(dest)))
throw new Error(
+63 -353
View File
@@ -7,8 +7,7 @@ import Logger from 'electron-log/main';
import Preferences from '~main/modules/preferences';
import { ConfigWtfSchema, type PreferencesSchema } from '~common/schemas';
import { isNotUndef } from '~common/utils';
import { readPristineWow } from '~main/modules/aria2';
import { enumerateDisplays } from '~main/modules/displays';
import { fetchFile } from '~main/modules/updater';
const Servers = {
live: {
@@ -17,39 +16,12 @@ const Servers = {
realmName: 'OctoWoW'
},
ptr: {
realmList: import.meta.env.MAIN_VITE_PTR_REALMLIST || 'octowow.st',
patchList: import.meta.env.MAIN_VITE_PTR_REALMLIST || 'octowow.st',
realmList: 'octowow.st',
patchList: 'octowow.st',
realmName: 'OctoWoW PTR'
}
} as const;
const LOCALES = {
enUS: { tag: 'enUS', index: 0 },
deDE: { tag: 'deDE', index: 3 },
zhCN: { tag: 'zhCN', index: 4 },
ruRU: { tag: 'ruRU', index: 5 },
esES: { tag: 'esES', index: 6 },
ptBR: { tag: 'ptBR', index: 7 }
} as const satisfies Record<
PreferencesSchema['locale'],
{ tag: string; index: number }
>;
const LOCALE_NAMES = [
'enUS',
'koKR',
'frFR',
'deDE',
'zhCN',
'zhTW',
'esES',
'xxYY'
] as const;
const localeNameOffset = (index: number) => 0x45591c - index * 8;
const carrierName = (index: number) => LOCALE_NAMES[index];
type TweakKey =
| { synthetic?: false; key: keyof PreferencesSchema['config'] }
| { synthetic: true; key: string };
@@ -60,7 +32,7 @@ type Tweak = TweakKey & {
} & (
| {
type: 'bytes';
tweaks: [number, number[], number[]?][];
tweaks: [number, number[]][];
}
| {
type: 'int8' | 'uint16' | 'float';
@@ -69,57 +41,17 @@ type Tweak = TweakKey & {
}
);
const hex = (bytes: number[]) =>
bytes.map(b => b.toString(16).padStart(2, '0')).join(' ');
export const patchExecutable = async () => {
Logger.log('Patching WoW.exe...');
const { clientDir, config, locale } = Preferences.data;
const { clientDir, config } = Preferences.data;
if (!clientDir) return;
const exePath = path.join(clientDir, 'WoW.exe');
try {
Logger.log('Reading clean WoW.exe base...');
const buffer = await readPristineWow(clientDir);
const loc = LOCALES[locale];
// revert any previous locale patch to the pristine bytes first, so a
// language switch (or an adopted pre-patched exe) can re-patch cleanly
const TAG_OFFSET = 0x1b2115;
const INDEX_OFFSET = 0x253c;
const PRISTINE_TAG = [0xa1, 0xa4, 0xa2, 0xc2, 0x00];
const PRISTINE_INDEX = [0x33, 0xf6, 0x8b, 0xff, 0x8b, 0x04, 0xb5];
if (
buffer[TAG_OFFSET] === 0xb8 &&
buffer[INDEX_OFFSET] === 0xbe &&
buffer[INDEX_OFFSET + 5] === 0xeb
) {
const prevIndex = buffer[INDEX_OFFSET + 1];
const prevCarrier = LOCALE_NAMES[prevIndex] as string | undefined;
const prevTag = prevCarrier
? Buffer.from([
0xb8,
...Buffer.from(prevCarrier, 'latin1').reverse()
])
: undefined;
if (
prevCarrier &&
prevTag &&
buffer.subarray(TAG_OFFSET, TAG_OFFSET + 5).equals(prevTag)
) {
Logger.log(
`Reverting previous locale patch (index ${prevIndex}) to the clean base`
);
Buffer.from(PRISTINE_TAG).copy(buffer, TAG_OFFSET);
Buffer.from(PRISTINE_INDEX).copy(buffer, INDEX_OFFSET);
Buffer.from(prevCarrier, 'latin1').copy(
buffer,
localeNameOffset(prevIndex)
);
}
}
Logger.log('Fetching clean WoW.exe...');
const file = await fetchFile('WoW.exe');
const buffer = Buffer.from(file);
const Tweaks = [
{
@@ -146,16 +78,25 @@ export const patchExecutable = async () => {
default: false
},
{
// shipped exe carries the enabled bytes; off must write 0x74 back
key: 'alwaysAutoLoot',
type: 'bytes',
tweaks: [
[0x0c1ecf, [0x75], [0x74]],
[0x0c2b25, [0x75], [0x74]]
[0x0c1ecf, [0x75]],
[0x0c2b25, [0x75]]
]
},
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 },
{ key: 'cameraDistance', type: 'float', offset: 0x4089a4 },
{
synthetic: true,
key: 'crossFactionResurrect',
type: 'bytes',
default: true,
tweaks: [
[0x006e5fb8, [0x006e5fb9]],
[0x006e62a8, [0x006e62a9]]
]
},
{
synthetic: true,
key: 'skillUiGateHijack',
@@ -196,53 +137,11 @@ export const patchExecutable = async () => {
[
0x45ccd8,
[
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]
]
]
},
{
synthetic: true,
key: 'localeTag',
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
0x1b2115,
[0xb8, ...Buffer.from(carrierName(loc.index), 'latin1').reverse()],
[0xa1, 0xa4, 0xa2, 0xc2, 0x00]
]
]
},
{
synthetic: true,
key: 'localeIndex',
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
0x253c,
[0xbe, loc.index, 0x00, 0x00, 0x00, 0xeb, 0x1f],
[0x33, 0xf6, 0x8b, 0xff, 0x8b, 0x04, 0xb5]
]
]
},
{
synthetic: true,
key: 'localeName',
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
localeNameOffset(loc.index),
[...Buffer.from(loc.tag, 'latin1')],
[...Buffer.from(LOCALE_NAMES[loc.index], 'latin1')]
]
]
}
] satisfies Tweak[];
@@ -260,163 +159,25 @@ export const patchExecutable = async () => {
if (!t.forced && !val) return;
buffer.writeUInt16LE(t.value ?? (val as number), t.offset);
} else if (t.type === 'bytes') {
if (!t.forced && !val) {
// disabled: revert sites carrying the enabled bytes to the
// stock bytes when known; unknown bytes stay untouched
t.tweaks.forEach(
([offset, bytes, expect]: [number, number[], number[]?]) => {
if (!expect) return;
const current = buffer.subarray(
offset,
offset + bytes.length
);
if (current.equals(Buffer.from(bytes)))
Buffer.from(expect).copy(buffer, offset);
}
);
return;
}
t.tweaks.forEach(
([offset, bytes, expect]: [number, number[], number[]?]) => {
if (expect) {
const current = buffer.subarray(offset, offset + expect.length);
if (current.equals(Buffer.from(bytes))) return;
if (!current.equals(Buffer.from(expect)))
throw new Error(
`"${t.key}" expected [${hex(expect)}] at 0x${offset.toString(
16
)} ` +
`but found [${hex([
...current
])}]; refusing to patch WoW.exe`
);
}
const written = Buffer.from(bytes).copy(buffer, offset);
if (written !== bytes.length)
Logger.error(
`"${t.key}" wrote ${written}/${bytes.length} bytes at ` +
`0x${offset.toString(16)}: past end of file (${
buffer.length
} bytes). ` +
'This tweak is a no-op; the offset is probably a virtual address.'
);
}
if (!t.forced && !val) return;
t.tweaks.forEach(([offset, bytes]) =>
Buffer.from(bytes).copy(buffer, offset)
);
}
});
await fs.writeFile(exePath, buffer);
Preferences.data = { patchedLocale: locale };
Logger.log(`WoW.exe successfully patched (language: ${locale})`);
Logger.log('WoW.exe successfully patched');
} catch (e) {
Logger.error('Failed to patch WoW.exe', e);
throw e instanceof Error ? e : new Error('Failed to patch WoW.exe');
}
};
const repairResolution = async (
clientDir: string,
current: string | undefined,
lastWritten: string | undefined
): Promise<{ gxResolution?: string }> => {
const devices = await enumerateDisplays();
if (!devices?.length) return {};
const pinnedRaw = await fs
.readFile(path.join(clientDir, 'VMMFix_preferred_monitor.txt'), 'utf8')
.then(s => s.trim())
.catch(() => '');
const pinned = pinnedRaw ? Number(pinnedRaw) : NaN;
const target =
devices.find(d => d.index === pinned && d.attached) ??
devices.find(d => d.primary && d.attached);
if (!target?.modes.length) return {};
const native = `${target.width}x${target.height}`;
if (!target.modes.includes(native)) return {};
let owned = !current || current === lastWritten;
if (!owned && lastWritten === undefined && current) {
const width = Number(current.split('x')[0]);
if (Number.isFinite(width) && width * 2 < target.width) {
Logger.warn(
`gxResolution ${current} is far below ${target.deviceName}'s ${native} and predates resolution tracking; treating it as a client fallback`
);
owned = true;
}
}
if (!owned) return {};
if (current === native) return {};
Logger.warn(
`gxResolution ${current ?? '<unset>'} is launcher-owned; correcting to ${
target.deviceName
}'s ${native}`
);
return { gxResolution: native };
};
const applyRealmlist = async (clientDir: string, host: string) => {
const body = `set realmlist "${host}"\n`;
const write = async (target: string) => {
// already correct: leave it alone (the seeder may hold the file open)
const current = await fs
.readFile(target, { encoding: 'utf-8' })
.catch(() => null);
if (current === body) return;
const tmp = `${target}.tmp`;
try {
await fs.writeFile(tmp, body);
await fs.move(tmp, target, { overwrite: true });
} catch (e) {
await fs.remove(tmp).catch(() => undefined);
throw e;
}
};
await write(path.join(clientDir, 'realmlist.wtf'));
const dataDir = path.join(clientDir, 'Data');
if (await fs.pathExists(dataDir))
for (const entry of await fs.readdir(dataDir)) {
const scoped = path.join(dataDir, entry, 'realmlist.wtf');
if (!(await fs.pathExists(scoped))) continue;
try {
await write(scoped);
} catch (e) {
Logger.warn(`Could not rewrite ${scoped}: ${String(e)}`);
}
}
};
// rewrite realmlist.wtf when missing, empty, or wrong; an interrupted sync
// can leave a 0-byte placeholder that disconnects direct game launches
export const healRealmlist = async (clientDir: string) => {
const server: keyof typeof Servers = import.meta.env.MAIN_VITE_PTR_REALMLIST
? 'ptr'
: 'live';
const expected = `set realmlist "${Servers[server].realmList}"\n`;
const target = path.join(clientDir, 'realmlist.wtf');
const current = await fs
.readFile(target, { encoding: 'utf-8' })
.catch(() => null);
if (current === expected) return;
Logger.log(
`realmlist.wtf ${
current === null ? 'missing' : current.trim() ? 'wrong' : 'empty'
}; rewriting`
);
await applyRealmlist(clientDir, Servers[server].realmList);
};
export const patchConfig = async (forceTweaks = false) => {
const { clientDir, config, locale } = Preferences.data;
const { clientDir, server, config, locale } = Preferences.data;
if (!clientDir) return;
const server: keyof typeof Servers = import.meta.env.MAIN_VITE_PTR_REALMLIST
? 'ptr'
: 'live';
const configPath = path.join(clientDir, 'WTF', 'Config.wtf');
await fs.ensureDir(path.dirname(configPath));
const raw = (await fs.pathExists(configPath))
@@ -433,73 +194,50 @@ export const patchConfig = async (forceTweaks = false) => {
.filter(isNotUndef)
);
const isFirstRun = Object.keys(configWtf).length === 0;
const primaryDisplay = screen.getPrimaryDisplay();
const scale = primaryDisplay.scaleFactor || 1;
const width = Math.round(primaryDisplay.bounds.width * scale);
const height = Math.round(primaryDisplay.bounds.height * scale);
const seededResolution = `${width}x${height}`;
const seed = isFirstRun
? {
scriptMemory: 512000,
gxResolution: seededResolution,
gxColorBits: primaryDisplay.colorDepth,
gxDepthBits: primaryDisplay.colorDepth,
gxRefresh: 60,
gxMultisample: 8,
gxMultisampleQuality: 0,
gxTripleBuffer: 1,
anisotropic: 16,
frillDensity: 48,
fullAlpha: 1,
SmallCull: 0.01,
DistCull: 888.8,
shadowLevel: 0,
trilinear: 1,
specular: 1,
pixelShaders: 1,
M2UsePixelShaders: 1,
M2UseShaders: 1,
particleDensity: 1,
unitDrawDist: 300,
weatherDensity: 3,
movieSubtitle: 1,
minimapZoom: 0,
minimapInsideZoom: 0,
SoundZoneMusicNoDelay: 1,
gxWindow: 1,
gxMaximize: 1,
gxCursor: 1,
checkAddonVersion: 0,
farClip: config.farClip,
CameraDistanceMax: config.cameraDistance,
patchList: Servers[server].patchList,
realmName: Servers[server].realmName
}
: {};
const owned = {
locale: carrierName(LOCALES[locale].index),
const parsed = {
scriptMemory: 512000,
gxResolution: `${width}x${height}`,
gxColorBits: primaryDisplay.colorDepth,
gxDepthBits: primaryDisplay.colorDepth,
gxRefresh: 60,
gxMultisample: 8,
gxMultisampleQuality: 0,
gxTripleBuffer: 1,
anisotropic: 16,
frillDensity: 48,
fullAlpha: 1,
SmallCull: 0.01,
DistCull: 888.8,
shadowLevel: 0,
trilinear: 1,
specular: 1,
pixelShaders: 1,
M2UsePixelShaders: 1,
particleDensity: 1,
unitDrawDist: 300,
weatherDensity: 3,
movieSubtitle: 1,
minimapZoom: 0,
minimapInsideZoom: 0,
SoundZoneMusicNoDelay: 1,
patchList: configWtf['patchList'] ?? Servers[server].patchList,
realmName: configWtf['realmName'] ?? Servers[server].realmName,
hwDetect: 0,
BackgroundSound: config.soundInBackground ? 1 : 0
};
const repaired = await repairResolution(
clientDir,
configWtf['gxResolution'],
Preferences.data.lastWrittenResolution
);
const parsed = {
...seed,
gxWindow: configWtf['gxWindow'] ?? 1,
gxMaximize: configWtf['gxMaximize'] ?? 1,
gxCursor: configWtf['gxCursor'] ?? 1,
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0,
farClip: configWtf['farClip'] ?? config.farClip,
CameraDistanceMax: configWtf['CameraDistanceMax'] ?? config.cameraDistance,
...configWtf,
...repaired,
...owned,
locale,
realmList: Servers[server].realmList,
hwDetect: 0,
M2UseShaders: 1,
...(forceTweaks
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance }
: {})
@@ -507,38 +245,10 @@ export const patchConfig = async (forceTweaks = false) => {
const body = Object.entries(parsed)
.filter(v => v[1] !== undefined && v[1] !== null)
.filter(([k]) => !/^realmlist$/i.test(k))
.map(l => `SET ${l[0]} "${l[1]}"`)
.join('\n');
const tmpPath = `${configPath}.tmp`;
await fs.writeFile(tmpPath, body);
await fs.move(tmpPath, configPath, { overwrite: true });
await applyRealmlist(clientDir, Servers[server].realmList);
const chosen =
repaired.gxResolution ?? (isFirstRun ? seededResolution : undefined);
if (chosen && chosen !== Preferences.data.lastWrittenResolution)
Preferences.data = { lastWrittenResolution: chosen };
Logger.log('Config.wtf successfully patched');
};
export const ensureDxvkConf = async (clientDir: string) => {
if (!(await fs.pathExists(path.join(clientDir, 'd3d9.dll')))) return;
const confPath = path.join(clientDir, 'dxvk.conf');
if (await fs.pathExists(confPath)) return;
await fs.writeFile(
confPath,
[
'# Cap the texture memory the 32-bit client believes it has so it cannot',
'# over-commit its address space (the common DXVK out-of-memory crash).',
'd3d9.maxAvailableMemory = 2048',
'd3d9.maxFrameLatency = 1',
'dxvk.numCompilerThreads = 2',
'dxvk.logLevel = none',
''
].join('\n')
);
Logger.log('Wrote dxvk.conf');
};
+34 -196
View File
@@ -6,144 +6,40 @@ import { app } from 'electron';
import Logger from 'electron-log/main';
import { PreferencesSchema } from '~common/schemas';
import { DEFAULT_ENABLED_MODS } from '~common/mods';
import { omit } from '~common/utils';
import { isTorrentMode } from '~main/modules/aria2';
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR;
const errCode = (e: unknown) =>
e && typeof e === 'object' ? (e as NodeJS.ErrnoException).code : undefined;
const LOCK_CODES = ['EPERM', 'EACCES', 'EBUSY', 'EMFILE', 'ENFILE'];
const isLocked = (e: unknown) => LOCK_CODES.includes(errCode(e) ?? '');
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const readJsonRetrying = async (file: string, attempts = 5) => {
for (let i = 0; ; i++) {
try {
return await fs.readJSON(file);
} catch (e) {
if (i >= attempts - 1 || !isLocked(e)) throw e;
await delay(60 * (i + 1));
}
}
};
const renameRetrying = async (from: string, to: string, attempts = 5) => {
for (let i = 0; ; i++) {
try {
return await fs.rename(from, to);
} catch (e) {
if (i >= attempts - 1 || !isLocked(e)) throw e;
await delay(60 * (i + 1));
}
}
};
const writeJsonAtomic = async (file: string, data: unknown) => {
const tmp = `${file}.tmp`;
await fs.writeJSON(tmp, data, { spaces: 2 });
await renameRetrying(tmp, file);
};
const dropUndefined = <T extends object>(obj: T): Partial<T> =>
Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined)
) as Partial<T>;
abstract class Preferences {
static #data: z.infer<typeof PreferencesSchema>;
static #writeChain: Promise<void> = Promise.resolve();
static #readOnly = false;
static #rememberedClientDir?: string;
static #freshInstall = false;
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
: app.getPath('userData');
static readonly #settingsPath = path.join(
Preferences.userDataDir,
'settings.json'
);
static readonly #installPath = path.join(
Preferences.userDataDir,
'install.json'
);
static get isFreshInstall() {
return this.#freshInstall;
}
static async #detectFreshInstall() {
const [settings, install, pending] = await Promise.all([
fs.pathExists(this.#settingsPath),
fs.pathExists(this.#installPath),
fs.pathExists(`${this.#settingsPath}.tmp`)
]);
return !settings && !install && !pending;
}
static #withFreshInstallDefaults(data: PreferencesSchema): PreferencesSchema {
if (!this.#freshInstall || Object.keys(data.mods).length) return data;
// fresh installs seed every mod EXPLICITLY off; a missing row falls
// back to enabled, which keeps legacy profiles untouched
const mods = { ...data.mods };
for (const id of DEFAULT_ENABLED_MODS)
mods[id] = { enabled: false, installedFiles: [], ignoreUpdates: false };
Logger.info('Fresh install: all mods start disabled (opt-in)');
return { ...data, mods };
}
static async load() {
this.#freshInstall = await this.#detectFreshInstall();
await fs.ensureDir(this.userDataDir);
const settingsPath = this.#settingsPath;
const settingsPath = path.join(this.userDataDir, 'settings.json');
let json: Record<string, unknown> = {};
let json: Record<string, unknown>;
try {
json = await readJsonRetrying(settingsPath);
} catch (e) {
if (isLocked(e)) {
this.#readOnly = true;
Logger.error(
`Could not read ${settingsPath} (${errCode(e)}); running on ` +
'defaults and leaving settings untouched for this session.',
e
);
} else {
if (errCode(e) !== 'ENOENT') {
Logger.warn(`${settingsPath} is unreadable; keeping a copy`, e);
await fs
.copy(settingsPath, `${settingsPath}.corrupt`)
.catch(() => {});
}
const recovered = await fs
.readJSON(`${settingsPath}.tmp`)
.catch(() => null);
if (recovered && typeof recovered === 'object') {
Logger.warn(`Recovered settings from ${settingsPath}.tmp`);
json = recovered as Record<string, unknown>;
}
}
json = await fs.readJSON(settingsPath);
} catch {
return PreferencesSchema.parse({
isPortable: !!portableDir,
clientDir: portableDir
});
}
const merged = dropUndefined({
const merged = {
...json,
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
});
};
const parsed = PreferencesSchema.safeParse(merged);
if (parsed.success)
return this.#withKnownClientDir(
this.#withFreshInstallDefaults(parsed.data)
);
if (parsed.success) return parsed.data;
Logger.warn(
'settings.json failed validation; salvaging valid fields',
@@ -151,48 +47,17 @@ abstract class Preferences {
);
await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {});
const salvaged: Record<string, unknown> = dropUndefined({
const salvaged: Record<string, unknown> = {
isPortable: !!portableDir,
// coerce to string/undefined; the shape loop never clears a set key, so a
// non-string would survive and throw at the final parse
clientDir:
portableDir ??
(typeof json.clientDir === 'string' ? json.clientDir : undefined)
});
clientDir: portableDir ?? json.clientDir
};
const shape = PreferencesSchema.shape;
for (const key of Object.keys(shape) as (keyof typeof shape)[]) {
if (!(key in merged)) continue;
const value = (merged as Record<string, unknown>)[key];
if (shape[key].safeParse(value).success) salvaged[key] = value;
}
// defaults if even the salvaged set is invalid; never throw out of load()
const salvagedParsed = PreferencesSchema.safeParse(salvaged);
return this.#withKnownClientDir(
salvagedParsed.success ? salvagedParsed.data : PreferencesSchema.parse({})
);
}
static async #withKnownClientDir(data: PreferencesSchema) {
if (portableDir) return data;
const remembered = await fs
.readJSON(this.#installPath)
.then(j => {
const dir = (j as { clientDir?: unknown })?.clientDir;
return typeof dir === 'string' && dir ? dir : undefined;
})
.catch(() => undefined);
this.#rememberedClientDir = remembered;
if (await this.isValidClientDir(data.clientDir)) return data;
if (!remembered || remembered === data.clientDir) return data;
if (!(await this.isValidClientDir(remembered))) return data;
Logger.warn(
`No usable clientDir in settings.json; restored "${remembered}" from ` +
this.#installPath
);
return { ...data, clientDir: remembered };
return PreferencesSchema.parse(salvaged);
}
static get data(): PreferencesSchema {
@@ -202,50 +67,31 @@ abstract class Preferences {
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
this.#data = { ...this.#data, ...newData };
if (this.#readOnly) return;
const settingsPath = this.#settingsPath;
const dropped = portableDir ? ['isPortable', 'clientDir'] : ['isPortable'];
const delta = dropUndefined(
omit(newData, dropped as (keyof typeof newData)[])
const settingsPath = path.join(this.userDataDir, 'settings.json');
const delta = omit(
newData,
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
);
const snapshot = dropUndefined(
omit(this.#data, dropped as (keyof PreferencesSchema)[])
const snapshot = omit(
this.#data,
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
);
this.#writeChain = this.#writeChain
.then(async () => {
let base: Record<string, unknown> | null = null;
let onDisk: unknown = null;
try {
const onDisk = await readJsonRetrying(settingsPath);
base =
!!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk)
? (onDisk as Record<string, unknown>)
: null;
} catch (e) {
if (isLocked(e)) {
Logger.error(
`Skipping settings write; ${settingsPath} is locked (${errCode(
e
)})`,
e
);
return;
}
onDisk = await fs.readJSON(settingsPath);
} catch {
onDisk = null;
}
const base =
!!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk)
? (onDisk as Record<string, unknown>)
: null;
const merged = base ? { ...base, ...delta } : snapshot;
await writeJsonAtomic(settingsPath, merged);
const clientDir = (merged as { clientDir?: unknown }).clientDir;
if (
typeof clientDir === 'string' &&
clientDir &&
clientDir !== this.#rememberedClientDir
) {
this.#rememberedClientDir = clientDir;
await writeJsonAtomic(this.#installPath, { clientDir }).catch(e =>
Logger.warn(`Failed to write ${this.#installPath}`, e)
);
}
const tmp = `${settingsPath}.tmp`;
await fs.writeJSON(tmp, merged, { spaces: 2 });
await fs.move(tmp, settingsPath, { overwrite: true });
})
.catch(e => Logger.error('Failed to persist settings.json', e));
}
@@ -255,15 +101,7 @@ abstract class Preferences {
}
static async isValidClientDir(clientDir?: string) {
if (!clientDir) return false;
if (await fs.exists(path.join(clientDir, 'WoW.exe'))) return true;
// torrent mode: no WoW.exe yet, accept a dir the download can populate
if (isTorrentMode())
return (
(await fs.exists(clientDir)) ||
(await fs.exists(path.dirname(clientDir)))
);
return false;
return !!clientDir && (await fs.exists(path.join(clientDir, 'WoW.exe')));
}
}
+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: '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);
});
}
+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 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(
File diff suppressed because it is too large Load Diff
-374
View File
@@ -1,374 +0,0 @@
import dgram from 'dgram';
import http from 'http';
import os from 'os';
import Logger from 'electron-log/main';
// UPnP-IGD port mapping (best effort) for a NAT'd seeder; node builtins only, no-ops on failure.
export type PortMapping = { stop: () => Promise<void> };
const NOOP: PortMapping = { stop: async () => {} };
const SSDP_ADDR = '239.255.255.250';
const SSDP_PORT = 1900;
const SEARCH = Buffer.from(
[
'M-SEARCH * HTTP/1.1',
`HOST: ${SSDP_ADDR}:${SSDP_PORT}`,
'MAN: "ssdp:discover"',
'MX: 2',
'ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1',
'',
''
].join('\r\n')
);
// exposes AddPortMapping, newest first
const WAN_SERVICES = [
'urn:schemas-upnp-org:service:WANIPConnection:2',
'urn:schemas-upnp-org:service:WANIPConnection:1',
'urn:schemas-upnp-org:service:WANPPPConnection:1'
];
type Gateway = { location: string; address: string; localAddress: string };
type WanService = { controlUrl: string; serviceType: string };
class SoapError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.code = code;
}
}
const candidateAddresses = (): string[] =>
Object.values(os.networkInterfaces())
.flat()
.filter(
(a): a is os.NetworkInterfaceInfo =>
!!a &&
a.family === 'IPv4' &&
!a.internal &&
!a.address.startsWith('169.254.')
)
.map(a => a.address);
// a 0.0.0.0/empty host in LOCATION is really the address the datagram came from
const fixLocation = (location: string, responder: string): string => {
try {
const u = new URL(location);
if (u.hostname === '0.0.0.0' || u.hostname === '') u.hostname = responder;
return u.toString();
} catch {
return location;
}
};
// M-SEARCH one interface; collect every responder (more than one can answer)
const searchInterface = (
localAddress: string,
timeoutMs: number
): Promise<Gateway[]> =>
new Promise(resolve => {
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
const found = new Map<string, Gateway>();
let retry: ReturnType<typeof setInterval> | undefined;
let done = false;
const finish = () => {
if (done) return;
done = true;
if (retry) clearInterval(retry);
try {
socket.close();
} catch {
// already closed
}
resolve([...found.values()]);
};
socket.on('message', (msg, rinfo) => {
const m = /^location:\s*(\S+)/im.exec(msg.toString('utf8'));
if (!m) return;
const location = fixLocation(m[1].trim(), rinfo.address);
if (!found.has(location))
found.set(location, { location, address: rinfo.address, localAddress });
});
socket.on('error', () => finish());
socket.bind(0, localAddress, () => {
try {
socket.setMulticastInterface(localAddress);
} catch {
// fall back to the default multicast interface
}
const send = () =>
socket.send(SEARCH, SSDP_PORT, SSDP_ADDR, () => {
/* fire-and-forget */
});
send();
// Routers sometimes miss the first datagram; re-ask until the window closes.
retry = setInterval(send, 700);
setTimeout(finish, timeoutMs);
});
});
// search all interfaces: a VPN often owns the default route
const discoverGateways = async (timeoutMs: number): Promise<Gateway[]> => {
const perInterface = await Promise.all(
candidateAddresses().map(a => searchInterface(a, timeoutMs))
);
const seen = new Set<string>();
const gateways: Gateway[] = [];
for (const list of perInterface)
for (const gw of list)
if (!seen.has(gw.location)) {
seen.add(gw.location);
gateways.push(gw);
}
return gateways;
};
// build the control URL from the host we reached; routers advertise a bogus URLBase
const controlUrlFrom = (descriptorUrl: string, controlPath: string): string => {
const desc = new URL(descriptorUrl);
let path: string;
try {
const c = new URL(controlPath, descriptorUrl);
path = `${c.pathname}${c.search}`;
} catch {
path = controlPath.startsWith('/') ? controlPath : `/${controlPath}`;
}
return `${desc.protocol}//${desc.host}${path}`;
};
// raw http, not fetch: many UPnP servers are non-compliant and undici rejects them
const httpRequest = (
url: string,
opts: {
method?: string;
headers?: Record<string, string>;
body?: string;
timeoutMs?: number;
} = {}
): Promise<{ status: number; body: string }> =>
new Promise((resolve, reject) => {
let u: URL;
try {
u = new URL(url);
} catch (e) {
reject(e as Error);
return;
}
const headers = { ...(opts.headers ?? {}) };
const body = opts.body ? Buffer.from(opts.body, 'utf8') : undefined;
if (body) headers['Content-Length'] = String(body.length);
const req = http.request(
{
hostname: u.hostname,
port: u.port || 80,
path: `${u.pathname}${u.search}`,
method: opts.method ?? 'GET',
headers
},
res => {
const chunks: Buffer[] = [];
res.on('data', c => chunks.push(c));
res.on('end', () =>
resolve({
status: res.statusCode ?? 0,
body: Buffer.concat(chunks).toString('utf8')
})
);
}
);
req.on('error', reject);
req.setTimeout(opts.timeoutMs ?? 5000, () =>
req.destroy(new Error('request timed out'))
);
if (body) req.write(body);
req.end();
});
// first WAN service + control URL from a device descriptor
const findWanService = (
xml: string,
descriptorUrl: string
): WanService | undefined => {
for (const block of xml.split(/<service>/i).slice(1)) {
const type = /<serviceType>\s*([^<]+?)\s*<\/serviceType>/i
.exec(block)?.[1]
?.trim();
const ctrl = /<controlURL>\s*([^<]+?)\s*<\/controlURL>/i
.exec(block)?.[1]
?.trim();
if (
type &&
ctrl &&
WAN_SERVICES.some(w => w.toLowerCase() === type.toLowerCase())
)
return {
controlUrl: controlUrlFrom(descriptorUrl, ctrl),
serviceType: type
};
}
return undefined;
};
const xmlEscape = (s: string): string =>
s.replace(
/[<>&'"]/g,
c =>
({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[
c
] as string)
);
const arg = (name: string, value: string | number): string =>
`<${name}>${value}</${name}>`;
const soap = async (
svc: WanService,
action: string,
body: string
): Promise<void> => {
const envelope =
'<?xml version="1.0"?>' +
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' +
'<s:Body>' +
`<u:${action} xmlns:u="${svc.serviceType}">${body}</u:${action}>` +
'</s:Body></s:Envelope>';
const res = await httpRequest(svc.controlUrl, {
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPAction': `"${svc.serviceType}#${action}"`
},
body: envelope
});
if (res.status < 200 || res.status >= 300) {
const code = /<errorCode>\s*(\d+)/i.exec(res.body)?.[1];
throw new SoapError(
`${action} failed: HTTP ${res.status}${code ? ` (UPnP ${code})` : ''}`,
code
);
}
};
const addMapping = (
svc: WanService,
port: number,
protocol: 'TCP' | 'UDP',
client: string,
description: string,
lease: number
): Promise<void> =>
soap(
svc,
'AddPortMapping',
arg('NewRemoteHost', '') +
arg('NewExternalPort', port) +
arg('NewProtocol', protocol) +
arg('NewInternalPort', port) +
arg('NewInternalClient', client) +
arg('NewEnabled', 1) +
arg('NewPortMappingDescription', xmlEscape(description)) +
arg('NewLeaseDuration', lease)
);
const deleteMapping = (
svc: WanService,
port: number,
protocol: 'TCP' | 'UDP'
): Promise<void> =>
soap(
svc,
'DeletePortMapping',
arg('NewRemoteHost', '') +
arg('NewExternalPort', port) +
arg('NewProtocol', protocol)
);
// map port (TCP+UDP), kept alive until stop(); returns a no-op handle when no gateway
export const mapPort = async (
port: number,
opts: { description?: string; ttlSeconds?: number } = {}
): Promise<PortMapping> => {
const description = opts.description ?? 'OctoWoW';
try {
const gateways = await discoverGateways(4000);
if (!gateways.length) {
Logger.log('UPnP: no gateway found; seeding without a port mapping');
return NOOP;
}
// take the first responder that exposes a WAN service
const probed = await Promise.all(
gateways.map(async gw => {
const res = await httpRequest(gw.location).catch(() => undefined);
const svc =
res && res.status < 400
? findWanService(res.body, gw.location)
: undefined;
return svc ? { svc, client: gw.localAddress } : undefined;
})
);
const target = probed.find(Boolean);
if (!target) {
Logger.log('UPnP: no gateway exposes a WAN service; skipping mapping');
return NOOP;
}
const { svc, client } = target;
// Some routers only grant permanent leases (UPnP error 725); fall back to one.
let lease = opts.ttlSeconds ?? 3600;
const mapped: ('TCP' | 'UDP')[] = [];
const mapOne = async (protocol: 'TCP' | 'UDP') => {
try {
await addMapping(svc, port, protocol, client, description, lease);
} catch (e) {
if (e instanceof SoapError && e.code === '725' && lease !== 0) {
lease = 0;
await addMapping(svc, port, protocol, client, description, lease);
} else throw e;
}
mapped.push(protocol);
};
try {
await mapOne('TCP');
await mapOne('UDP');
} catch (e) {
for (const p of mapped) await deleteMapping(svc, port, p).catch(() => {});
throw e;
}
// A finite lease self-heals if we exit uncleanly; renew ahead of expiry.
let renew: ReturnType<typeof setInterval> | undefined;
if (lease > 0) {
const period = Math.max(60_000, (lease - 60) * 1000);
renew = setInterval(() => {
addMapping(svc, port, 'TCP', client, description, lease).catch(
() => {}
);
addMapping(svc, port, 'UDP', client, description, lease).catch(
() => {}
);
}, period);
renew.unref?.();
}
Logger.log(
`UPnP: mapped ${port} TCP+UDP to ${client} (lease ${
lease || 'permanent'
})`
);
return {
stop: async () => {
if (renew) clearInterval(renew);
await deleteMapping(svc, port, 'TCP').catch(() => {});
await deleteMapping(svc, port, 'UDP').catch(() => {});
}
};
} catch (e) {
Logger.warn('UPnP: port mapping failed; seeding without it', e);
return NOOP;
}
};
+1 -2
View File
@@ -3,8 +3,7 @@ export { type UpdaterStatus } from './modules/updater';
export { type AddonsStatus, type AddonData } from './modules/addons';
export {
type ModsStatus,
type ModRowStatus,
type CustomMod
type ModRowStatus
} from './modules/mods';
export {
type NewsItem,
+2 -13
View File
@@ -10,7 +10,6 @@ if (!port) throw new Error('IllegalState');
const { dir, url, ref } = workerData;
const tmpDir = `${dir}.tmp`;
const bakDir = `${dir}.bak`;
const run = async () => {
await fs.remove(tmpDir);
@@ -24,18 +23,8 @@ const run = async () => {
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
});
await fs.remove(bakDir);
const hadExisting = await fs.pathExists(dir);
if (hadExisting) await fs.move(dir, bakDir);
try {
await fs.move(tmpDir, dir);
} catch (e) {
if (hadExisting) await fs.move(bakDir, dir).catch(() => undefined);
throw e;
}
await fs.remove(bakDir).catch(() => undefined);
await fs.remove(dir);
await fs.move(tmpDir, dir);
};
run()
+8 -44
View File
@@ -3,7 +3,7 @@ import { ShieldAlert, HelpCircle } from 'lucide-react';
import { createPortal } from 'react-dom';
import { api } from '~renderer/utils/api';
import { type ModsStatus, type UpdaterStatus } from '~main/types';
import { type ModsStatus } from '~main/types';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
@@ -15,34 +15,11 @@ const AntivirusModal = () => {
const [status, setStatus] = useState<ModsStatus>();
api.mods.observe.useSubscription(undefined, { onData: setStatus });
// re-scan for AV blocks once the updater settles (not mid-download); catches an aborted
// download or a file quarantined after the fact
const [updateState, setUpdateState] = useState<UpdaterStatus['state']>();
api.updater.observe.useSubscription(undefined, {
onData: s => setUpdateState(s?.state)
});
const settled =
!!updateState && updateState !== 'verifying' && updateState !== 'updating';
const { data: quarantined, refetch: refetchQuarantined } =
api.general.antivirusBlocks.useQuery(undefined, {
enabled: false,
refetchOnWindowFocus: false,
staleTime: Infinity
});
useEffect(() => {
if (settled) refetchQuarantined();
}, [updateState, settled, refetchQuarantined]);
const addExclusion = api.general.addDefenderExclusion.useMutation();
const blocked = [
...new Set([
...(quarantined ?? []),
...(status?.mods ?? [])
.filter(m => m.state === 'error' && m.error?.includes('Defender'))
.map(m => m.name)
])
];
const blocked = (status?.mods ?? [])
.filter(m => m.state === 'error' && m.error?.includes('Defender'))
.map(m => m.name);
const blockedKey = blocked.join(',');
const dialogRef = useRef<HTMLDialogElement>(null);
@@ -50,8 +27,7 @@ const AntivirusModal = () => {
useEffect(() => {
if (view) {
// showModal() throws (and crashes to the error screen) if already open, e.g. the av<->why switch
if (!dialogRef.current?.open) dialogRef.current?.showModal();
dialogRef.current?.showModal();
(document.activeElement as HTMLElement | null)?.blur();
} else dialogRef.current?.close();
}, [view]);
@@ -60,13 +36,6 @@ const AntivirusModal = () => {
if (blockedKey) setView('av');
}, [blockedKey]);
// The settings dialog opens the explainer straight from its antivirus button.
useEffect(() => {
const open = () => setView('why');
window.addEventListener('av-help', open);
return () => window.removeEventListener('av-help', open);
}, []);
const names = blockedKey ? blockedKey.split(',') : [];
return createPortal(
@@ -208,14 +177,9 @@ const AntivirusModal = () => {
</div>
</div>
<div className="flex items-center justify-end gap-3">
{names.length > 0 && (
<TextButton
onClick={() => setView('av')}
className="text-blueGray"
>
{t('av.back')}
</TextButton>
)}
<TextButton onClick={() => setView('av')} className="text-blueGray">
{t('av.back')}
</TextButton>
<TextButton onClick={() => setView(null)} className="text-green">
{t('av.close')}
</TextButton>
+1 -30
View File
@@ -1,5 +1,5 @@
import { useForm } from 'react-hook-form';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { PreferencesSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver';
@@ -8,7 +8,6 @@ import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
import FilePickerInput from './form/FilePickerInput';
import CheckboxInput from './form/CheckboxInput';
import CloseButton from './styled/CloseButton';
type Props = { close: () => void };
@@ -37,19 +36,6 @@ const ClientDirDialog = ({ close }: Props) => {
resolver: zodResolver(PreferencesSchema.pick({ clientDir: true }))
});
const chosen = watch('clientDir');
const [acceptEmpty, setAcceptEmpty] = useState(false);
const chosenIsClient = api.preferences.isValidClientDir.useQuery(chosen, {
enabled: !!chosen && !pref?.isPortable
});
const needsEmptyConfirm =
!!chosen && chosenIsClient.isFetched && chosenIsClient.data === false;
useEffect(() => {
setAcceptEmpty(false);
}, [chosen]);
useEffect(() => {
pref && reset(pref);
}, [reset, pref]);
@@ -76,7 +62,6 @@ const ClientDirDialog = ({ close }: Props) => {
<form
className="tw-dialog"
onSubmit={handleSubmit(async ({ clientDir }) => {
if (needsEmptyConfirm && !acceptEmpty) return;
try {
await setPref.mutateAsync({ clientDir });
verify.mutate();
@@ -120,23 +105,9 @@ const ClientDirDialog = ({ close }: Props) => {
</p>
)}
{needsEmptyConfirm && (
<>
<p className="text-secondary text-sm">
{t('prefs.noClientHere', { exe: 'WoW.exe' })}
</p>
<CheckboxInput
value={acceptEmpty}
setValue={setAcceptEmpty}
label={t('prefs.noClientHereConfirm')}
/>
</>
)}
<TextButton
type="submit"
loading={formState.isSubmitting}
disabled={needsEmptyConfirm && !acceptEmpty}
className="self-end text-green"
>
{t('prefs.confirm')}
@@ -0,0 +1,218 @@
import DOMPurify from 'dompurify';
import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import useScrollHint from '~renderer/utils/useScrollHint';
import Parchment from '~renderer/assets/parchment.jpg';
import IconSpinner from './styled/IconSpinner';
import TextButton from './styled/TextButton';
const SANITIZE_CONFIG = {
ALLOWED_TAGS: [
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'a', 'ul', 'ol', 'li',
'blockquote', 'cite', 'span', 'div', 'img', 'br', 'p', 'code', 'pre',
'dl', 'dt', 'dd', 'hr', 'sub', 'sup', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tr', 'td', 'th'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'style'],
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'style', 'link', 'meta']
};
const LIGHT_NAMED = new Set([
'white', 'snow', 'ivory', 'floralwhite', 'ghostwhite', 'seashell', 'beige',
'linen', 'cornsilk', 'lightyellow', 'lightgoldenrodyellow', 'lemonchiffon',
'yellow', 'aqua', 'cyan', 'lime', 'aquamarine', 'azure', 'mintcream',
'honeydew', 'lavender', 'lavenderblush', 'aliceblue', 'whitesmoke',
'gainsboro', 'silver', 'lightgray', 'lightgrey', 'antiquewhite', 'papayawhip',
'blanchedalmond', 'bisque', 'moccasin', 'navajowhite', 'peachpuff', 'khaki',
'wheat', 'greenyellow', 'chartreuse', 'springgreen', 'palegoldenrod', 'gold'
]);
const LIGHT_THRESHOLD = 165;
const SAFE_STYLE_PROPS = new Set([
'color',
'background-color',
'font-weight',
'font-style',
'text-decoration',
'text-align',
'font-size'
]);
const colorIsTooLight = (raw: string): boolean => {
const v = raw.trim().toLowerCase();
const lum = (r: number, g: number, b: number) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
const hex = v.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
if (hex) {
const h =
hex[1].length === 3
? hex[1]
.split('')
.map(c => c + c)
.join('')
: hex[1];
return (
lum(
parseInt(h.slice(0, 2), 16),
parseInt(h.slice(2, 4), 16),
parseInt(h.slice(4, 6), 16)
) > LIGHT_THRESHOLD
);
}
const rgb = v.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/);
if (rgb) return lum(+rgb[1], +rgb[2], +rgb[3]) > LIGHT_THRESHOLD;
return LIGHT_NAMED.has(v);
};
const clampFontSize = (val: string): string => {
const m = val.match(/^(\d+(?:\.\d+)?)(px|pt|%|em|rem)$/i);
if (!m) return val;
const n = parseFloat(m[1]);
const unit = m[2].toLowerCase();
const max = unit === 'px' ? 28 : unit === 'pt' ? 21 : unit === '%' ? 200 : 2;
return `${Math.min(n, max)}${unit}`;
};
DOMPurify.addHook('afterSanitizeAttributes', node => {
if (node.tagName === 'IMG') node.setAttribute('loading', 'lazy');
const style = node.getAttribute('style');
if (!style) return;
const kept: string[] = [];
for (const decl of style.split(';')) {
const idx = decl.indexOf(':');
if (idx < 0) continue;
const prop = decl.slice(0, idx).trim().toLowerCase();
let val = decl.slice(idx + 1).trim();
if (!val || !SAFE_STYLE_PROPS.has(prop)) continue;
if (prop === 'color' && colorIsTooLight(val)) continue;
if (prop === 'font-size') val = clampFontSize(val);
kept.push(`${prop}:${val}`);
}
if (kept.length) node.setAttribute('style', kept.join(';'));
else node.removeAttribute('style');
});
const formatDate = (raw: string) => {
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const ForumAnnouncementPanel = () => {
const t = useT();
const openLink = api.general.openLink.useMutation();
const query = api.forum.latestAnnouncement.useQuery(undefined, {
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1
});
const scrollRef = useScrollHint<HTMLDivElement>();
const data = query.data;
const safeHtml = useMemo(
() => (data ? DOMPurify.sanitize(data.html, SANITIZE_CONFIG) : ''),
[data]
);
const onBodyClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const anchor = (e.target as HTMLElement).closest('a[href]');
if (!anchor) return;
e.preventDefault();
const href = anchor.getAttribute('href');
if (href) openLink.mutateAsync(href);
},
[openLink]
);
return (
<aside
className="parchment-post flex min-h-0 flex-grow flex-col gap-3"
style={{ backgroundImage: `url(${Parchment})` }}
>
<div className="flex items-start justify-between gap-2">
<h4 className="parchment-post-title min-w-0 flex-1 break-words">
{data?.title ?? t('forum.title')}
</h4>
<TextButton
icon={RefreshCw}
size={18}
className="-mr-2 -mt-1 shrink-0"
loading={query.isFetching}
onClick={() => query.refetch()}
title={t('misc.refresh')}
/>
</div>
{data?.author && (
<span className="parchment-post-meta">
{t('misc.newsByAuthor', { author: data.author })} · {formatDate(data.date)}
</span>
)}
<hr />
<div
ref={scrollRef}
className="relative -mx-4 flex min-h-0 flex-grow flex-col overflow-y-auto overflow-x-hidden px-4"
>
{query.isLoading ? (
<div className="flex flex-grow flex-col items-center justify-center gap-2">
<IconSpinner className="parchment-post-muted" />
<p className="parchment-post-muted italic">{t('forum.loading')}</p>
</div>
) : query.isError ? (
<div className="flex flex-grow flex-col items-center justify-center gap-3">
<AlertTriangle size={32} className="text-red" />
<p className="parchment-post-muted italic">{t('forum.error')}</p>
<TextButton
icon={RefreshCw}
size={18}
onClick={() => query.refetch()}
>
{t('misc.tryAgain')}
</TextButton>
</div>
) : !data ? (
<div className="flex flex-grow flex-col items-center justify-center">
<p className="parchment-post-muted italic">{t('forum.empty')}</p>
</div>
) : (
<div
className="parchment-post-body"
onClick={onBodyClick}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
)}
</div>
{data && (
<TextButton
icon={ExternalLink}
size={14}
className="-ml-2 self-start"
onClick={() => openLink.mutateAsync(data.url)}
>
{t('forum.readFullPost')}
</TextButton>
)}
</aside>
);
};
export default ForumAnnouncementPanel;
+9 -38
View File
@@ -22,8 +22,7 @@ const formatDuration = (seconds: number) => {
return minRem ? `${h}h ${minRem}m` : `${h}h`;
};
const formatPercent = (progress: number) =>
`${parseFloat((progress * 100).toFixed(1))}%`;
const formatPercent = (progress: number) => `${(progress * 100).toFixed(1)}%`;
const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
const t = useT();
@@ -42,7 +41,7 @@ const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
· {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}
</span>
{bytesPerSecond !== undefined && bytesPerSecond > 0 && (
<span> · {formatFileSize(bytesPerSecond, 1)}/s</span>
<span> · {formatFileSize(bytesPerSecond)}/s</span>
)}
<span>
{' · '}
@@ -74,17 +73,6 @@ const LaunchPanel = () => {
const start = api.launcher.start.useMutation();
const applyMods = api.mods.applyAll.useMutation();
const modRows = modsStatus?.mods ?? [];
const enabledIds = new Set(modRows.filter(m => m.enabled).map(m => m.id));
const missingDeps = [
...new Set(
modRows
.filter(m => m.enabled)
.flatMap(m => m.requires.filter(d => !enabledIds.has(d)))
)
];
const modName = (id: string) => modRows.find(m => m.id === id)?.name ?? id;
const props: Record<
UpdaterStatus['state'],
{ button: ReactElement; helperText?: ReactElement }
@@ -92,9 +80,7 @@ const LaunchPanel = () => {
verifying: { button: <Button disabled>{t('launch.verifying')}</Button> },
serverUnreachable: {
button: pref?.version ? (
<Button disabled={start.isLoading} onClick={() => start.mutateAsync()}>
{t('launch.play')}
</Button>
<Button onClick={() => start.mutateAsync()}>{t('launch.play')}</Button>
) : (
<Button onClick={() => verify.mutateAsync()}>
{t('launch.retry')}
@@ -152,7 +138,8 @@ const LaunchPanel = () => {
</span>
</>
)}
<span className="break-all">{status.message}</span>
<span className="break-all">{status.message}</span>{' '}
{t('launch.remaining')}
</p>
</div>
)
@@ -173,37 +160,21 @@ const LaunchPanel = () => {
<Button
primary
onClick={() => applyMods.mutateAsync()}
disabled={
applyMods.isLoading ||
modsStatus?.state === 'busy' ||
missingDeps.length > 0
}
disabled={applyMods.isLoading || modsStatus?.state === 'busy'}
>
{modsStatus?.state === 'busy'
? t('launch.applying')
: t('mods.apply')}
: t('launch.update')}
</Button>
) : (
<Button
primary
disabled={start.isLoading}
onClick={() => start.mutateAsync()}
>
<Button primary onClick={() => start.mutateAsync()}>
{t('launch.play')}
</Button>
),
helperText: (
<div className="-mb-2">
{modsStatus?.dirty ? (
missingDeps.length ? (
<p className="text-orange">
{t('mods.enableRequired', {
mods: missingDeps.map(modName).join(', ')
})}
</p>
) : (
<p>{t('launch.modsChanged')}</p>
)
<p>{t('launch.modsChanged')}</p>
) : (
<p>{t('launch.upToDate')}</p>
)}
+77 -38
View File
@@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
import {
FilePen,
FolderOpen,
HelpCircle,
RefreshCw,
ScrollText,
ShieldAlert,
@@ -39,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();
@@ -54,7 +61,6 @@ const PreferencesDialog = ({ close }: Props) => {
defaultValues: pref ?? {},
resolver: zodResolver(PreferencesSchema)
});
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
pref && reset(pref);
@@ -67,21 +73,30 @@ 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 => {
setSaveError(null);
try {
await setPref.mutateAsync({
cleanWdb: v.cleanWdb,
minimizeToTrayOnPlay: v.minimizeToTrayOnPlay,
shareDownloads: v.shareDownloads
});
close();
} catch (e) {
setSaveError(e instanceof Error ? e.message : String(e));
}
await setPref.mutateAsync({
cleanWdb: v.cleanWdb,
minimizeToTrayOnPlay: v.minimizeToTrayOnPlay,
useProton: v.useProton,
protonPath: v.protonPath
});
close();
})}
>
<CloseButton
@@ -168,24 +183,14 @@ const PreferencesDialog = ({ close }: Props) => {
>
{t('prefs.openLogFile')}
</TextButton>
<div className="flex items-start">
<TextButton
icon={ShieldAlert}
onClick={() => addExclusion.mutateAsync()}
loading={addExclusion.isLoading}
className="!items-start text-left text-orange"
>
{t('prefs.allowThroughAntivirus')}
</TextButton>
{/* sits on the label's first line even when a locale wraps it */}
<TextButton
icon={HelpCircle}
size={14}
onClick={() => window.dispatchEvent(new Event('av-help'))}
title={t('av.whatAllowDoesTitle')}
className="mt-[14px] !p-0 text-yellow hocus:!text-yellow"
/>
</div>
<TextButton
icon={ShieldAlert}
onClick={() => addExclusion.mutateAsync()}
loading={addExclusion.isLoading}
className="!items-start text-left text-orange"
>
{t('prefs.allowThroughAntivirus')}
</TextButton>
{addExclusion.data?.ok === true && (
<span className="s1 text-warmGreen">
{t('prefs.exclusionAdded')}
@@ -208,17 +213,51 @@ const PreferencesDialog = ({ close }: Props) => {
setValue={setBool('minimizeToTrayOnPlay')}
label={t('prefs.minimizeToTray')}
/>
<CheckboxInput
value={watch('shareDownloads') !== false}
setValue={setBool('shareDownloads')}
label={t('prefs.shareDownloads')}
/>
</div>
</div>
{saveError && (
<span className="s1 self-end text-orange">{saveError}</span>
{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>
+4 -4
View File
@@ -5,10 +5,10 @@ import TweaksTab from './tabs/TweaksTab';
import TabErrorBoundary from './TabErrorBoundary';
const Tabs = {
news: NewsTab,
tweaks: TweaksTab,
addons: AddonsTab,
mods: ModsTab
'news': NewsTab,
'tweaks': TweaksTab,
'addons': AddonsTab,
'mods': ModsTab
} as const;
export const TabNames = Object.keys(Tabs) as TabType[];
@@ -3,7 +3,6 @@ import { type ReactNode } from 'react';
import TextButton from '../styled/TextButton';
// mt centers this 16px box on the 26px label line box
const Checkbox = () => (
<svg
width={16}
@@ -11,7 +10,7 @@ const Checkbox = () => (
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="mt-[5px] shrink-0"
className="shrink-0"
>
<rect
x="1"
+9 -27
View File
@@ -1,39 +1,21 @@
type Run = { text: string; color?: string };
// Keep the WoW "|c" color runs, strip every other "|" escape (textures, links, pipes).
const ESCAPE_RE =
/\|\||\|c([0-9a-f]{8})|\|r|\|T[^|]*\|t|\|H[^|]*\|h|\|h|\|./gi;
const tokenize = (s: string): Run[] => {
const runs: Run[] = [];
let color: string | undefined;
let buf = '';
const re = /\|c([0-9a-fA-F]{8})|\|r/g;
let i = 0;
const flush = () => {
if (buf) runs.push({ text: buf, color });
buf = '';
};
let color: string | undefined;
let m: RegExpExecArray | null;
while ((m = ESCAPE_RE.exec(s)) !== null) {
buf += s.slice(i, m.index);
i = ESCAPE_RE.lastIndex;
const tok = m[0];
if (tok === '||') {
buf += '|';
} else if (m[1]) {
// drop the leading alpha byte, keep RGB
flush();
color = `#${m[1].slice(2).toLowerCase()}`;
} else if (tok.toLowerCase() === '|r') {
flush();
while ((m = re.exec(s)) !== null) {
if (m.index > i) runs.push({ text: s.slice(i, m.index), color });
if (m[0].toLowerCase() === '|r') {
color = undefined;
} else if (m[1]) {
color = `#${m[1].slice(2).toLowerCase()}`;
}
i = re.lastIndex;
}
buf += s.slice(i);
flush();
if (i < s.length) runs.push({ text: s.slice(i), color });
return runs.filter(r => r.text.length > 0);
};
+13 -114
View File
@@ -6,11 +6,7 @@ import cls from 'classnames';
import { api } from '~renderer/utils/api';
import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import {
type ModRowStatus,
type ModsStatus,
type CustomMod
} from '~main/types';
import { type ModRowStatus, type ModsStatus } from '~main/types';
import TextButton from '../styled/TextButton';
import CheckboxInput from '../form/CheckboxInput';
@@ -77,52 +73,6 @@ const ModRow = ({ row }: { row: ModRowStatus }) => {
);
};
const CustomRow = ({ row }: { row: CustomMod }) => {
const toggle = api.mods.toggleCustom.useMutation();
return (
<>
<span className="break-all">{row.name}</span>
<CheckboxInput
value={row.enabled}
setValue={v => toggle.mutate({ name: row.name, enabled: v })}
className="justify-self-center"
/>
</>
);
};
const AddDllButton = () => {
const t = useT();
const pick = api.general.filePicker.useMutation();
const add = api.mods.addCustomDll.useMutation();
const [error, setError] = useState<string | null>(null);
const onClick = async () => {
setError(null);
const res = await pick.mutateAsync({
title: t('mods.addDllTitle'),
filters: [{ name: 'DLL', extensions: ['dll'] }],
properties: ['openFile']
});
if (res.canceled) return;
const result = await add.mutateAsync({ path: res.path[0] });
if (!result.ok) setError(result.error ?? t('mods.addDllFailed'));
};
return (
<div className="flex items-center gap-2">
{error && <span className="s1 text-orange">{error}</span>}
<TextButton
onClick={onClick}
loading={pick.isLoading || add.isLoading}
className="text-green"
>
{t('mods.addDll')}
</TextButton>
</div>
);
};
const ModsTab = () => {
const t = useT();
const [status, setStatus] = useState<ModsStatus>();
@@ -138,8 +88,6 @@ const ModsTab = () => {
}, [list.data, status]);
const apply = api.mods.applyAll.useMutation();
const resync = api.updater.update.useMutation();
const revalidate = api.mods.verify.useMutation();
const scrollRef = useScrollHint<HTMLDivElement>();
@@ -168,24 +116,17 @@ const ModsTab = () => {
useEffect(() => {
if (shownDepMessage) {
if (!dialogRef.current?.open) dialogRef.current?.showModal();
dialogRef.current?.showModal();
(document.activeElement as HTMLElement | null)?.blur();
} else dialogRef.current?.close();
}, [shownDepMessage]);
const [applied, setApplied] = useState(false);
const appliedTimer = useRef<number>();
useEffect(() => () => window.clearTimeout(appliedTimer.current), []);
const onApply = async () => {
const onApply = () => {
if (missingDeps.length) {
setShownDepMessage(pendingDepMessage);
return;
}
setApplied(false);
await apply.mutateAsync();
setApplied(true);
window.clearTimeout(appliedTimer.current);
appliedTimer.current = window.setTimeout(() => setApplied(false), 2500);
apply.mutateAsync();
};
const showApply =
@@ -195,11 +136,9 @@ const ModsTab = () => {
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
<div className="flex items-baseline justify-between">
<h4 className="tw-color">{t('mods.title')}</h4>
{status?.dirty ? (
{status?.dirty && (
<span className="s1 text-pink">{t('mods.unsavedChanges')}</span>
) : applied ? (
<span className="s1 text-warmGreen">{t('mods.applied')}</span>
) : null}
)}
</div>
<p className="s1 text-blueGray">
<span className="text-orange"></span> {t('mods.warning')}
@@ -212,63 +151,23 @@ const ModsTab = () => {
})}
</p>
)}
{!!status?.missingFiles?.length && (
<div className="s1 flex flex-col items-start gap-1 text-orange">
<span>
{t('mods.missingFiles', { mods: status.missingFiles.join(', ') })}
</span>
<TextButton
onClick={async () => {
await resync.mutateAsync();
await revalidate.mutateAsync();
}}
loading={resync.isLoading || revalidate.isLoading}
className="text-warmGreen"
>
{t('mods.reverify')}
</TextButton>
</div>
)}
<hr />
<div
ref={scrollRef}
className="relative -m-4 -mt-0 flex flex-grow flex-col gap-3 overflow-y-auto p-4 pt-0"
className="relative -m-4 -mt-0 grid flex-grow grid-cols-[auto_auto_1fr_auto] content-start items-center gap-x-4 gap-y-2 overflow-y-auto p-4 pt-0"
>
<div className="grid grid-cols-[auto_auto_1fr_auto] content-start items-center gap-x-4 gap-y-2">
{status?.mods.map(row => (
<ModRow key={row.id} row={row} />
))}
</div>
<hr />
<div className="flex items-baseline justify-between gap-2">
<h4 className="tw-color">{t('mods.yourDlls')}</h4>
<AddDllButton />
</div>
{status?.custom?.length ? (
<div className="grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-1">
{status.custom.map(c => (
<CustomRow key={c.name} row={c} />
))}
</div>
) : (
<p className="s1 text-blueGray">{t('mods.yourDllsEmpty')}</p>
)}
{status?.mods.map(row => (
<ModRow key={row.id} row={row} />
))}
</div>
<hr />
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
<p className="s1 flex-grow text-blueGray">
{status?.dirty ? (
<span className="text-pink">{t('mods.unsavedChanges')}</span>
) : applied ? (
<span className="text-warmGreen">{t('mods.applied')}</span>
) : (
<>
<span className="text-warmGreen">{t('mods.highlighted')}</span>{' '}
{t('mods.highlightedRecommended')}
</>
)}
<span className="text-warmGreen">{t('mods.highlighted')}</span>{' '}
{t('mods.highlightedRecommended')}
</p>
<TextButton
type="button"
loading={apply.isLoading || status?.state === 'busy'}
onClick={onApply}
className={cls('text-green', !showApply && 'invisible')}
+19 -20
View File
@@ -5,6 +5,7 @@ import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import useScrollHint from '~renderer/utils/useScrollHint';
import ForumAnnouncementPanel from '../ForumAnnouncementPanel';
import IconSpinner from '../styled/IconSpinner';
import TextButton from '../styled/TextButton';
@@ -49,22 +50,20 @@ const NewsEntry = ({ item }: { item: NewsItem }) => {
);
};
const NewsColumn = ({ forum, title }: { forum: number; title: string }) => {
// The "Announcements" list — most-recent forum topics as short previews + links.
const AnnouncementsBox = () => {
const t = useT();
const query = api.news.list.useQuery(
{ forum },
{
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1
}
);
const query = api.news.list.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1
});
const scrollRef = useScrollHint<HTMLDivElement>();
return (
<div className="tw-surface flex min-h-0 flex-1 flex-col gap-3">
<div className="tw-surface flex min-h-0 w-[360px] shrink-0 flex-col gap-3">
<div className="flex items-center justify-between">
<h4 className="tw-color">{title}</h4>
<h4 className="tw-color">{t('misc.announcementsTitle')}</h4>
<TextButton
icon={RefreshCw}
size={18}
@@ -109,14 +108,14 @@ const NewsColumn = ({ forum, title }: { forum: number; title: string }) => {
);
};
const NewsTab = () => {
const t = useT();
return (
<div className="flex min-h-0 flex-grow gap-3">
<NewsColumn forum={2} title={t('misc.announcementsTitle')} />
<NewsColumn forum={4} title={t('misc.patchNotesTitle')} />
</div>
);
};
// The News tab holds both boxes side by side: the parchment "newsletter" (the
// featured Nautilus News Network post, biggest) and the "Announcements" list.
// Living inside the tab means they only show on News — not on Tweaks/Addons/Mods.
const NewsTab = () => (
<div className="flex min-h-0 flex-grow gap-3">
<ForumAnnouncementPanel />
<AnnouncementsBox />
</div>
);
export default NewsTab;
+5 -18
View File
@@ -70,7 +70,7 @@ const TweaksTab = () => {
const setPref = api.preferences.set.useMutation();
const applyPatch = api.patcher.apply.useMutation();
const syncRaidVisuals = api.updater.syncRaidVisuals.useMutation();
const verify = api.updater.verify.useMutation();
const form = useForm<ConfigWtfSchema>({
defaultValues: pref?.config ?? {},
@@ -88,7 +88,7 @@ const TweaksTab = () => {
: '');
const isApplying =
setPref.isLoading || applyPatch.isLoading || syncRaidVisuals.isLoading;
setPref.isLoading || applyPatch.isLoading || verify.isLoading;
useEffect(() => {
pref && reset(pref.config);
@@ -101,7 +101,7 @@ const TweaksTab = () => {
onSubmit={handleSubmit(async config => {
await setPref.mutateAsync({ config, farClipUserSet: true });
await applyPatch.mutateAsync();
await syncRaidVisuals.mutateAsync();
await verify.mutateAsync();
reset(config);
})}
@@ -117,12 +117,6 @@ const TweaksTab = () => {
label={t('tweaks.alwaysAutoLoot.label')}
text={t('tweaks.alwaysAutoLoot.text')}
/>
<Item
form={form}
id="raidVisuals"
label={t('tweaks.raidVisuals.label')}
text={t('tweaks.raidVisuals.text')}
/>
<Item
form={form}
id="largeAddress"
@@ -220,15 +214,8 @@ const TweaksTab = () => {
onClick={async () => {
const config =
recommendedFarClip != null
? {
...ConfigWtfSchema.parse({}),
farClip: recommendedFarClip,
raidVisuals: form.getValues('raidVisuals')
}
: {
...ConfigWtfSchema.parse({}),
raidVisuals: form.getValues('raidVisuals')
};
? { ...ConfigWtfSchema.parse({}), farClip: recommendedFarClip }
: ConfigWtfSchema.parse({});
await setPref.mutateAsync({ config, farClipUserSet: false });
reset(config);
}}
+8 -10
View File
@@ -2,15 +2,13 @@
interface ImportMetaEnv {
readonly MAIN_VITE_SERVER_URL: string;
// forum base for the news feed; defaults to the live forum so PTR shows real posts
readonly MAIN_VITE_FORUM_URL: string;
readonly MAIN_VITE_CLIENT_VERSION: string;
// PTR realm/patch host; only set for PTR builds, live falls back to octowow.st.
readonly MAIN_VITE_PTR_REALMLIST: string;
// When set, sync the client from this web-seeded .torrent instead of the manifest.
readonly MAIN_VITE_CLIENT_TORRENT_URL: string;
// optional raid-visuals patch (patch-O.mpq); a ".sha256" sidecar drives change detection
readonly MAIN_VITE_RAID_VISUALS_URL: string;
// the content patch (patch-5.mpq), served outside the torrent; kept current by a ".sha256" sidecar
readonly MAIN_VITE_CLIENT_PATCH_URL: string;
}
interface Window {
electron?: {
process?: {
platform?: NodeJS.Platform;
};
};
}
+42 -57
View File
@@ -34,9 +34,6 @@ const enUS: Dict = {
'launch.remaining': 'remaining',
'launch.calculating': 'calculating…',
'launch.onDisk': 'on disk',
'tweaks.raidVisuals.label': 'Updated Raid Visuals',
'tweaks.raidVisuals.text':
'Optional ~9 MB download. Adds clearer ground markers and sounds for raid boss abilities, kept in sync with the server automatically. (AKA Patch-O)',
'tweaks.alwaysAutoLoot.label': 'Always auto-loot',
'tweaks.alwaysAutoLoot.text':
'Reverses auto-loot behavior to always auto-loot and disable auto-with bound key.',
@@ -70,7 +67,6 @@ const enUS: Dict = {
'tweaks.apply': 'Apply',
'misc.newsTitle': 'News',
'misc.announcementsTitle': 'Announcements',
'misc.patchNotesTitle': 'Patch Notes',
'misc.newsByAuthor': 'by {author}',
'misc.newsReadMore': 'Read more',
'misc.refresh': 'Refresh',
@@ -104,19 +100,9 @@ const enUS: Dict = {
'Enabling custom mods may not provide any performance benefits or may even cause game crashes depending on your system. Please try disabling them if you experience any issues.',
'mods.enableRequired': 'Enable {mods}, required by your selected mods.',
'mods.depRequired': '{mod} must be enabled. It is required by {requiredBy}.',
'mods.missingFiles':
'Missing game files for: {mods}. Re-sync your client to restore them. If antivirus is blocking them, allow them through first.',
'mods.reverify': 'Re-sync client',
'mods.yourDlls': 'Your DLL mods',
'mods.yourDllsEmpty':
'Drop a .dll into your game folder, or add one below, to toggle it here.',
'mods.addDll': 'Add DLL',
'mods.addDllTitle': 'Choose a DLL mod',
'mods.addDllFailed': 'Failed to add DLL.',
'mods.highlighted': 'Highlighted',
'mods.highlightedRecommended': 'mods are recommended.',
'mods.apply': 'Apply',
'mods.applied': 'Applied',
'mods.cantApplyYet': "CAN'T APPLY YET",
'mods.close': 'Close',
'av.blockedTitle': 'BLOCKED BY ANTIVIRUS',
@@ -130,7 +116,7 @@ const enUS: Dict = {
'av.close': 'Close',
'av.whyTitle': 'WHY ANTIVIRUS FLAGS MODS',
'av.whyIntro':
'Some of the mods the launcher installs get flagged by Windows Defender (or other antivirus) as a threat such as "{detection}". This is a',
'Some of these mods get flagged by Windows Defender (or other antivirus) as a threat such as "{detection}". This is a',
'av.falsePositive': 'false positive',
'av.whatSetsItOff': 'What sets it off',
'av.whatSetsItOffIntro':
@@ -226,7 +212,12 @@ const enUS: Dict = {
'prefs.generalSettings': 'GENERAL SETTINGS:',
'prefs.cleanWdb': 'Clean WDB on each launch',
'prefs.minimizeToTray': 'Minimize to tray while playing',
'prefs.shareDownloads': 'Help share downloads with other players',
'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':
@@ -239,10 +230,6 @@ const enUS: Dict = {
'prefs.upgradeExisting':
'You may also choose a directory with an existing Turtle WoW or Vanilla WoW installation, and it will be automatically upgraded.',
'prefs.installDirectory': 'Install directory:',
'prefs.noClientHere':
'No {exe} in this folder. The launcher will download a fresh client here, and it will not contain your existing addons or settings. If you already have an install, pick that folder instead.',
'prefs.noClientHereConfirm':
'I understand — download a fresh client into this folder',
'prefs.confirm': 'Confirm'
};
@@ -279,9 +266,6 @@ const deDE: Dict = {
'launch.remaining': 'verbleibend',
'launch.calculating': 'wird berechnet…',
'launch.onDisk': 'auf der Festplatte',
'tweaks.raidVisuals.label': 'Aktualisierte Raid-Effekte',
'tweaks.raidVisuals.text':
'Optionaler Download (~9 MB). Fügt deutlichere Bodenmarkierungen und Sounds für Raidboss-Fähigkeiten hinzu, automatisch mit dem Server synchron gehalten. (auch bekannt als Patch-O)',
'tweaks.alwaysAutoLoot.label': 'Immer automatisch plündern',
'tweaks.alwaysAutoLoot.text':
'Kehrt das Auto-Plündern-Verhalten um, sodass immer automatisch geplündert wird und das Auto-Plündern per Tastenkombination deaktiviert ist.',
@@ -341,7 +325,7 @@ const deDE: Dict = {
'av.close': 'Schließen',
'av.whyTitle': 'WARUM ANTIVIRENPROGRAMME MODS MELDEN',
'av.whyIntro':
'Einige der vom Launcher installierten Mods werden von Windows Defender (oder anderen Antivirenprogrammen) als Bedrohung wie z. B. "{detection}" gemeldet. Dabei handelt es sich um einen',
'Einige dieser Mods werden von Windows Defender (oder anderen Antivirenprogrammen) als Bedrohung wie z. B. "{detection}" gemeldet. Dabei handelt es sich um einen',
'av.falsePositive': 'Fehlalarm',
'av.whatSetsItOff': 'Was den Alarm auslöst',
'av.whatSetsItOffIntro':
@@ -439,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':
@@ -451,10 +441,6 @@ const deDE: Dict = {
'prefs.upgradeExisting':
'Du kannst auch ein Verzeichnis mit einer vorhandenen Turtle-WoW- oder Vanilla-WoW-Installation wählen, und es wird automatisch aktualisiert.',
'prefs.installDirectory': 'Installationsverzeichnis:',
'prefs.noClientHere':
'In diesem Ordner ist keine {exe}. Der Launcher lädt hier einen neuen Client herunter, der deine vorhandenen Addons und Einstellungen nicht enthält. Wenn du bereits eine Installation hast, wähle stattdessen deren Ordner.',
'prefs.noClientHereConfirm':
'Verstanden — einen neuen Client in diesen Ordner herunterladen',
'prefs.confirm': 'Bestätigen',
'misc.newsTitle': 'Neuigkeiten',
'misc.newsByAuthor': 'von {author}',
@@ -511,9 +497,6 @@ const zhCN: Dict = {
'launch.remaining': '剩余',
'launch.calculating': '计算中…',
'launch.onDisk': '在磁盘上',
'tweaks.raidVisuals.label': '团队副本视觉增强',
'tweaks.raidVisuals.text':
'可选下载(约 9 MB)。为团队首领技能添加更清晰的地面标记和音效,并自动与服务器保持同步。(又称 Patch-O)',
'tweaks.alwaysAutoLoot.label': '始终自动拾取',
'tweaks.alwaysAutoLoot.text':
'反转自动拾取行为,改为始终自动拾取,并禁用按住绑定键的自动拾取。',
@@ -568,7 +551,7 @@ const zhCN: Dict = {
'av.close': '关闭',
'av.whyTitle': '为什么杀毒软件会标记 Mods',
'av.whyIntro':
'启动器安装的部分 Mods 会被 Windows Defender(或其他杀毒软件)标记为威胁,例如“{detection}”。这是一个',
'其中一些 Mods 会被 Windows Defender(或其他杀毒软件)标记为威胁,例如“{detection}”。这是一个',
'av.falsePositive': '误报',
'av.whatSetsItOff': '触发原因',
'av.whatSetsItOffIntro':
@@ -656,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':
'请通过 SteamSteam Play)安装 Proton,或将 GE-Proton 放入 compatibilitytools.d。',
'prefs.save': '保存',
'prefs.installLocationTitle': '安装位置',
'prefs.portableInfo':
@@ -667,9 +656,6 @@ const zhCN: Dict = {
'prefs.upgradeExisting':
'你也可以选择一个已有 Turtle WoW 或 Vanilla WoW 安装的目录,它将被自动升级。',
'prefs.installDirectory': '安装目录:',
'prefs.noClientHere':
'此文件夹中没有 {exe}。启动器将在此处下载全新的客户端,其中不会包含你现有的插件和设置。如果你已经安装过,请改为选择原有的安装目录。',
'prefs.noClientHereConfirm': '我已了解——在此文件夹下载全新客户端',
'prefs.confirm': '确认',
'misc.newsTitle': '新闻',
'misc.newsByAuthor': '作者:{author}',
@@ -725,9 +711,6 @@ const esES: Dict = {
'launch.remaining': 'restante',
'launch.calculating': 'calculando…',
'launch.onDisk': 'en disco',
'tweaks.raidVisuals.label': 'Efectos de banda mejorados',
'tweaks.raidVisuals.text':
'Descarga opcional de ~9 MB. Añade marcadores de suelo y sonidos más claros para las habilidades de los jefes de banda, sincronizados automáticamente con el servidor. (también conocido como Patch-O)',
'tweaks.alwaysAutoLoot.label': 'Saqueo automático siempre',
'tweaks.alwaysAutoLoot.text':
'Invierte el comportamiento del saqueo automático para saquear siempre de forma automática y desactivar el saqueo automático con tecla asignada.',
@@ -787,7 +770,7 @@ const esES: Dict = {
'av.close': 'Cerrar',
'av.whyTitle': 'POR QUÉ EL ANTIVIRUS MARCA LOS MODS',
'av.whyIntro':
'Algunos de los mods que instala el launcher son marcados por Windows Defender (u otro antivirus) como una amenaza, por ejemplo «{detection}». Se trata de un',
'Algunos de estos mods son marcados por Windows Defender (u otro antivirus) como una amenaza, por ejemplo «{detection}». Se trata de un',
'av.falsePositive': 'falso positivo',
'av.whatSetsItOff': 'Qué lo provoca',
'av.whatSetsItOffIntro':
@@ -884,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':
@@ -896,10 +885,6 @@ const esES: Dict = {
'prefs.upgradeExisting':
'También puedes elegir un directorio con una instalación existente de Turtle WoW o Vanilla WoW, y se actualizará automáticamente.',
'prefs.installDirectory': 'Directorio de instalación:',
'prefs.noClientHere':
'No hay ningún {exe} en esta carpeta. El launcher descargará aquí un cliente nuevo, que no incluirá tus addons ni tu configuración actuales. Si ya tienes una instalación, elige esa carpeta.',
'prefs.noClientHereConfirm':
'Lo entiendo: descargar un cliente nuevo en esta carpeta',
'prefs.confirm': 'Confirmar',
'misc.newsTitle': 'Noticias',
'misc.newsByAuthor': 'por {author}',
@@ -958,9 +943,6 @@ const ptBR: Dict = {
'launch.remaining': 'restante',
'launch.calculating': 'calculando…',
'launch.onDisk': 'no disco',
'tweaks.raidVisuals.label': 'Efeitos de raide atualizados',
'tweaks.raidVisuals.text':
'Download opcional de ~9 MB. Adiciona marcações de chão e sons mais claros para as habilidades dos chefes de raide, mantidos em sincronia com o servidor automaticamente. (também conhecido como Patch-O)',
'tweaks.alwaysAutoLoot.label': 'Saque automático sempre ativo',
'tweaks.alwaysAutoLoot.text':
'Inverte o comportamento do saque automático para saquear sempre automaticamente e desativa o saque automático com a tecla atribuída.',
@@ -1018,7 +1000,7 @@ const ptBR: Dict = {
'av.close': 'Fechar',
'av.whyTitle': 'POR QUE O ANTIVÍRUS SINALIZA OS MODS',
'av.whyIntro':
'Alguns dos mods que o launcher instala são sinalizados pelo Windows Defender (ou outro antivírus) como uma ameaça, por exemplo "{detection}". Isso é um',
'Alguns destes mods são sinalizados pelo Windows Defender (ou outro antivírus) como uma ameaça, por exemplo "{detection}". Isso é um',
'av.falsePositive': 'falso positivo',
'av.whatSetsItOff': 'O que dispara o alerta',
'av.whatSetsItOffIntro':
@@ -1116,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':
@@ -1128,9 +1116,6 @@ const ptBR: Dict = {
'prefs.upgradeExisting':
'Você também pode escolher um diretório com uma instalação existente do Turtle WoW ou Vanilla WoW, que será atualizada automaticamente.',
'prefs.installDirectory': 'Diretório de instalação:',
'prefs.noClientHere':
'Não há {exe} nesta pasta. O launcher vai baixar um cliente novo aqui, sem os seus addons e configurações atuais. Se você já tem uma instalação, escolha a pasta dela.',
'prefs.noClientHereConfirm': 'Entendi — baixar um cliente novo nesta pasta',
'prefs.confirm': 'Confirmar',
'misc.newsTitle': 'Notícias',
'misc.newsByAuthor': 'por {author}',
@@ -1187,9 +1172,6 @@ const ruRU: Dict = {
'launch.remaining': 'осталось',
'launch.calculating': 'вычисление…',
'launch.onDisk': 'на диске',
'tweaks.raidVisuals.label': 'Улучшенные эффекты рейдов',
'tweaks.raidVisuals.text':
'Дополнительная загрузка (~9 МБ). Добавляет более заметные отметки на земле и звуки для способностей рейдовых боссов, автоматически синхронизируется с сервером. (также известен как Patch-O)',
'tweaks.alwaysAutoLoot.label': 'Всегда автосбор',
'tweaks.alwaysAutoLoot.text':
'Меняет поведение автосбора на противоположное: всегда автоматически собирать добычу, а ручной сбор включается зажатой клавишей.',
@@ -1246,7 +1228,7 @@ const ruRU: Dict = {
'av.close': 'Закрыть',
'av.whyTitle': 'ПОЧЕМУ АНТИВИРУС ПОМЕЧАЕТ МОДЫ',
'av.whyIntro':
'Некоторые из модов, устанавливаемых лаунчером, помечаются Windows Defender (или другим антивирусом) как угроза, например «{detection}». Это',
'Некоторые из этих модов помечаются Windows Defender (или другим антивирусом) как угроза, например «{detection}». Это',
'av.falsePositive': 'ложное срабатывание',
'av.whatSetsItOff': 'Что вызывает срабатывание',
'av.whatSetsItOffIntro':
@@ -1342,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':
@@ -1353,9 +1341,6 @@ const ruRU: Dict = {
'prefs.upgradeExisting':
'Вы также можете выбрать папку с уже установленным Turtle WoW или Vanilla WoW, и она будет автоматически обновлена.',
'prefs.installDirectory': 'Папка установки:',
'prefs.noClientHere':
'В этой папке нет {exe}. Лаунчер скачает сюда новый клиент, в котором не будет ваших текущих аддонов и настроек. Если игра уже установлена, выберите её папку.',
'prefs.noClientHereConfirm': 'Понятно — скачать новый клиент в эту папку',
'prefs.confirm': 'Подтвердить',
'misc.newsTitle': 'Новости',
'misc.newsByAuthor': 'от {author}',