OctoLauncher 1.3.1

Manifest-based CDN updater and mod manager for the OctoWoW 1.12.1 client:
launcher-owned realmlist, torrent-backed content sync with bundled aria2c,
antivirus and Defender exclusion handling, hardware-aware render distance,
optional client tweaks and mods, and the in-launcher news feed.
This commit is contained in:
OctoWoW
2026-08-14 01:45:50 +00:00
commit 5dca94a3fc
124 changed files with 24276 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
MAIN_VITE_SERVER_URL=http://localhost:5000
MAIN_VITE_CLIENT_VERSION=latest
+2
View File
@@ -0,0 +1,2 @@
MAIN_VITE_SERVER_URL=https://octowow.st
MAIN_VITE_CLIENT_VERSION=latest
+10
View File
@@ -0,0 +1,10 @@
# Force LF on shell scripts so git-bash can execute them as hooks on Windows
hooks/* text eol=lf
*.sh text eol=lf
*.py text eol=lf
.gitea/** export-ignore
.env.ptr export-ignore
electron-builder.ptr.yml export-ignore
server/Dockerfile export-ignore
scripts/publish-oss.sh export-ignore
+34
View File
@@ -0,0 +1,34 @@
name: Build check
on:
push:
branches: [main, master]
pull_request:
jobs:
build:
runs-on: windows-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: Install JS dependencies (skip native compile)
run: npm install --ignore-scripts --no-audit --no-fund
- name: Download Electron binary
run: node node_modules/electron/install.js
- name: Rebuild native modules for Electron ABI
run: node_modules/.bin/electron-builder.cmd install-app-deps
- name: Build bundles
run: npm run build
env:
ELECTRON_RUN_AS_NODE: ''
+62
View File
@@ -0,0 +1,62 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: windows-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: Install VS 2022 Build Tools (node-gyp requirement)
run: |
choco install visualstudio2022buildtools `
--package-parameters "--add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --includeRecommended --passive" `
--no-progress -y
shell: powershell
- name: Install JS dependencies (skip native compile)
run: npm install --ignore-scripts --no-audit --no-fund
- name: Download Electron binary
run: node node_modules/electron/install.js
- name: Rebuild native modules for Electron ABI
run: node_modules/.bin/electron-builder.cmd install-app-deps
- name: Build and package
run: npm run dist
env:
ELECTRON_RUN_AS_NODE: ''
- name: Upload portable exe
uses: actions/upload-artifact@v4
with:
name: OctoLauncher-portable
path: dist/OctoLauncher.exe
- name: Upload installer
uses: actions/upload-artifact@v4
with:
name: OctoLauncher-installer
path: dist/OctoLauncher_Installer.exe
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
dist/OctoLauncher.exe
dist/OctoLauncher_Installer.exe
dist/latest.yml
dist/OctoLauncher_Installer.exe.blockmap
+20
View File
@@ -0,0 +1,20 @@
node_modules/
dist*/
out/
release/
*.tsbuildinfo
Tools/launcher/node/
.env
.env.local
*.log
.DS_Store
Thumbs.db
scripts/
hooks/
+134
View File
@@ -0,0 +1,134 @@
# OctoLauncher Build Guide (Windows)
End-to-end setup that gets `npm run dev` and `npm run dist` working on a fresh Windows machine. Captured from a working build on Windows 10 / April 2026.
## Changes made to the dev environment
The project as checked out does **not** build on a default up-to-date Windows dev machine. These are the deltas applied to get it working, in order:
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`.
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.
---
## Prerequisites
### 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.
Install via `fnm` so you can keep your system Node separate:
```bash
winget install Schniz.fnm --accept-source-agreements --accept-package-agreements
fnm install 20
fnm default 20
```
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.
```bash
winget install Microsoft.VisualStudio.2022.BuildTools \
--accept-source-agreements --accept-package-agreements \
--override "--wait --passive --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --includeRecommended"
```
~6 GB download, requires admin. Even if you already have VS2026, you need VS2022 side-by-side for node-gyp.
### 3. Python 3 (usually already present)
`node-gyp` needs Python on PATH. Any 3.x works.
## Install dependencies
From the repo root:
```bash
npm install --ignore-scripts
node node_modules/electron/install.js
node_modules/.bin/electron-builder.cmd install-app-deps
```
Why the three-step approach: `dll-inject` requires ClangCL if compiled against the system Node, but compiles fine against Electron's bundled V8 headers (which `electron-builder install-app-deps` uses). Running plain `npm install` fails if your VS2022 installation doesn't include the LLVM/ClangCL component; `--ignore-scripts` skips that step and lets `install-app-deps` handle it correctly.
Or, use the provided build script which downloads a portable Node 20 and handles everything automatically:
```powershell
.\Tools\launcher\install.ps1
```
Then the server (only needed if running a local CDN, see `server/.env.example`):
```bash
cd server
npm install
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')`.
Before any `npm run dev` / `npm run build` / `npm run dist`:
```bash
unset ELECTRON_RUN_AS_NODE
```
```powershell
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.
## Running in dev
```bash
npm run dev
```
Starts electron-vite, builds main + preload + renderer, opens an Electron window on `http://localhost:5173` (or `5174` if 5173 is taken). Closing the window ends the session.
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.
## Building for distribution
The `dist` script runs `tsc && npm run build && npm run pack`:
```bash
unset ELECTRON_RUN_AS_NODE
npm run dist
```
Outputs land in `dist/`:
- `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.
- 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
| Symptom | Cause | Fix |
|---|---|---|
| `nan_scriptorigin.h ... cannot convert 'v8::Isolate *'` during `npm install` | Node 22+ breaks `nan` | Switch to Node 20 |
| `gyp ERR! find VS Could not find any Visual Studio installation` | Only VS2023+ installed | Install VS2022 Build Tools |
| `error MSB8020: The build tools for ClangCL cannot be found` | Missing LLVM component during plain `npm install` | Use `npm install --ignore-scripts` + `electron-builder install-app-deps` (see above) |
| `TypeError: Cannot read properties of undefined (reading 'isPackaged')` at launch | `ELECTRON_RUN_AS_NODE=1` set by VSCode | `unset ELECTRON_RUN_AS_NODE` |
| `Port 5173 is in use` | Prior dev server didn't exit cleanly | Ignore (vite falls back to 5174) or kill the stale process |
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 OctoWoW Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+92
View File
@@ -0,0 +1,92 @@
# 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.
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.
## Endpoint
`GET ${MAIN_VITE_SERVER_URL}/news.json``200 application/json`
`MAIN_VITE_SERVER_URL` comes from [main/.env](.env) at build time. With the current production setup that resolves to the public site origin (e.g. `https://octowow.st/news.json`).
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).
## Payload contract
```jsonc
{
"items": [
{
"id": "2026-04-24-launch", // required, stable, used as React key
"title": "Welcome to the new client", // required
"date": "2026-04-24", // required, anything Date.parse() accepts
"body": "Multi-line\nbody text supported.", // required, \n preserved
"author": "example", // optional
"url": "https://example.com/changelog" // optional, must be a full URL
}
]
}
```
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.
- `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).
## Publishing
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.
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).
### Tuning what shows
- **Which forum / mode shows**: set `FORUM_FEED_MODE` (`topics_active` | `topics` | `news` | `forum`) and, if `forum`, `FORUM_FEED_FORUM_ID` in the website container's environment.
- **How fresh**: lower `FORUM_FEED_CACHE_TTL` for fresher news at the cost of more upstream forum fetches. Pair with the route's 120-second `Cache-Control` if you also want to relax the edge cache.
- **How many items**: the route currently caps at 10; the homepage shows 3. Edit the `recent(10)` argument in `routes/web.php``news.json` to change the launcher cap independently of the homepage.
## Testing
**Confirm Laravel is serving it:**
```bash
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).
**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`.
**End-to-end check in the launcher:**
1. Open the launcher (the News tab is the default view when no other tab is selected).
2. Click the refresh icon in the News header.
3. Entries should appear within ~1 second once Laravel + the forum cache are warm.
## Failure modes the launcher already handles
| Server response | UI behaviour |
| --- | --- |
| `200` with valid JSON | Renders entries |
| `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.
## Where the code lives
- Main-process fetcher + schema validation: [src/main/api/routers/news.ts](src/main/api/routers/news.ts)
- Schema: [src/common/schemas.ts](src/common/schemas.ts) (`NewsItemSchema`, `NewsFeedSchema`)
- Renderer UI: [src/renderer/components/tabs/NewsTab.tsx](src/renderer/components/tabs/NewsTab.tsx)
- Router wiring: [src/main/api/root.ts](src/main/api/root.ts) (`news`)
+120
View File
@@ -0,0 +1,120 @@
# OctoLauncher
Desktop launcher for the OctoWoW (World of Warcraft 1.12.1 private server) client. Built with Electron, React, and tRPC.
**What it does:**
- Downloads and patches the OctoWoW game client via a manifest-based CDN updater
- Rewrites `Config.wtf` with the correct realm/patch-list on every launch
- Optionally applies binary tweaks to `WoW.exe` (FOV, far-clip, large-address flag, etc.)
- Injects client mods (VanillaFixes, DXVK, nampower, etc.) via a DLL chainloader
- Manages git-based addon installations
- Self-updates via NSIS
---
## Quick start (players)
1. Grab `OctoLauncher.exe` (portable) or `OctoLauncher_Installer.exe` from the [Releases](../../releases) page.
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.
---
## Building from source
### Prerequisites
| 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 |
| Python | 3.x | Required by `node-gyp` |
Install Node 20 with `fnm`:
```powershell
winget install Schniz.fnm
fnm install 20
fnm default 20
```
Install VS 2022 Build Tools:
```powershell
winget install Microsoft.VisualStudio.2022.BuildTools `
--override "--wait --passive --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --includeRecommended"
```
### Install dependencies
```powershell
npm install
```
`postinstall` rebuilds the native modules (`dll-inject`, `stormlib-node`) against the Electron ABI; expect C++ compiler output.
### Run in development
> **VSCode users:** The integrated terminal sets `ELECTRON_RUN_AS_NODE=1`, which crashes Electron. Unset it first:
> ```powershell
> Remove-Item Env:ELECTRON_RUN_AS_NODE
> ```
```powershell
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`.
### Build for distribution
```powershell
Remove-Item Env:ELECTRON_RUN_AS_NODE
npm run dist
```
Outputs to `dist/`:
- `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.
---
## Running the dev backend
The `server/` subdirectory is a standalone Express server that simulates the production CDN for local development. It is **not** bundled into the Electron app.
```powershell
cd server
npm install
```
Create `server/.env` from `server/.env.example` and set `SOURCE_DIR` to your local WoW client directory, then:
```powershell
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 /api/addons.json`
---
## Architecture overview
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
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`.
---
## License
MIT
+17
View File
@@ -0,0 +1,17 @@
# Launcher build tool
`install.ps1` downloads a portable Node.js 20 LTS into `node/` and runs the
full build pipeline (`npm install --ignore-scripts`, Electron binary install,
`electron-builder install-app-deps`, `npm run build`, `npm run pack`).
Use this instead of a global Node install to avoid the ClangCL / Node version
issues documented in the repo root `BUILD.md`.
```powershell
cd Tools\launcher
.\install.ps1
```
Output: `dist\OctoLauncher.exe` (portable) and `dist\OctoLauncher_Installer.exe` (NSIS).
The `node/` directory is gitignored; it is recreated by `install.ps1`.
+71
View File
@@ -0,0 +1,71 @@
$ErrorActionPreference = 'Stop'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$nodeDir = Join-Path $here 'node'
$mainDir = Split-Path -Parent (Split-Path -Parent $here)
$distDir = Join-Path $mainDir 'dist'
$nodeVersion = '20.18.1'
$nodeZipName = "node-v$nodeVersion-win-x64.zip"
$nodeUrl = "https://nodejs.org/dist/v$nodeVersion/$nodeZipName"
$nodeExe = Join-Path $nodeDir "node-v$nodeVersion-win-x64\node.exe"
if (Test-Path $nodeExe) {
Write-Host "Node.js already present at $nodeExe." -ForegroundColor Yellow
} else {
if (-not (Test-Path $nodeDir)) { New-Item -ItemType Directory -Path $nodeDir | Out-Null }
$zipPath = Join-Path $nodeDir $nodeZipName
Write-Host "Downloading Node.js $nodeVersion (~30 MB)..." -ForegroundColor Cyan
Invoke-WebRequest -Uri $nodeUrl -OutFile $zipPath -UseBasicParsing
Write-Host "Extracting to $nodeDir..." -ForegroundColor Cyan
Expand-Archive -Path $zipPath -DestinationPath $nodeDir -Force
Remove-Item $zipPath
}
$nodeBinDir = Split-Path -Parent $nodeExe
$env:PATH = "$nodeBinDir;$env:PATH"
Write-Host ""
Write-Host "node : $(& $nodeExe --version)" -ForegroundColor Green
Write-Host "npm : $(& (Join-Path $nodeBinDir 'npm.cmd') --version)" -ForegroundColor Green
Write-Host ""
Write-Host "Building Electron launcher at $mainDir..." -ForegroundColor Cyan
Push-Location $mainDir
try {
if (-not (Test-Path (Join-Path $mainDir 'node_modules'))) {
Write-Host "[npm] install --ignore-scripts (JS packages only)" -ForegroundColor Cyan
& (Join-Path $nodeBinDir 'npm.cmd') install --ignore-scripts --no-audit --no-fund
if ($LASTEXITCODE -ne 0) { throw "npm install failed" }
Write-Host "[electron] install binary" -ForegroundColor Cyan
& $nodeExe (Join-Path $mainDir 'node_modules\electron\install.js')
if ($LASTEXITCODE -ne 0) { throw "electron install failed" }
Write-Host "[electron-builder] install-app-deps (native modules)" -ForegroundColor Cyan
& (Join-Path $mainDir 'node_modules\.bin\electron-builder.cmd') install-app-deps
if ($LASTEXITCODE -ne 0) { throw "electron-builder install-app-deps failed" }
} else {
Write-Host "node_modules already exists - skipping npm install (delete it to force reinstall)." -ForegroundColor Yellow
}
Write-Host "[npm] run build (electron-vite)" -ForegroundColor Cyan
& (Join-Path $nodeBinDir 'npm.cmd') run build
if ($LASTEXITCODE -ne 0) { throw "npm run build failed" }
Write-Host "[npm] run pack (electron-builder -> dist\)" -ForegroundColor Cyan
& (Join-Path $nodeBinDir 'npm.cmd') run pack
if ($LASTEXITCODE -ne 0) { throw "npm run pack failed" }
} finally {
Pop-Location
}
Write-Host ""
Write-Host "Done." -ForegroundColor Green
if (Test-Path $distDir) {
Get-ChildItem $distDir -Filter '*.exe' | ForEach-Object {
$size = [math]::Round($_.Length / 1MB, 1)
Write-Host " $($_.FullName) ($size MB)" -ForegroundColor Green
}
}
Write-Host ""
+66
View File
@@ -0,0 +1,66 @@
# 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),
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
the fastest path for a fresh peer to find the swarm before DHT has
warmed up.
## Deploy (VPS, Linux)
SSH into the VPS, clone this repo, run:
```
cd Tools/launcher/tracker
chmod +x install.sh
./install.sh
```
`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`.
**Firewall**: open `6969/tcp` + `6969/udp`. On a typical Ubuntu VPS
with ufw: `sudo ufw allow 6969`.
## Verify
```
sudo systemctl status opentracker
curl http://127.0.0.1:6969/stats?mode=tpbs # shows torrents / peers / bytes
```
The launcher's webtorrent client will announce to this URL the moment
a dev runs the companion server with `TRACKER_URL` set to match.
## Wire the companion server to use this tracker
Set the `TRACKER_URL` env var when running the companion server so
every `.torrent` it generates announces to your VPS:
```
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
is already baked in by `create-torrent` at generation time, so no
launcher-side config needed.
## Uninstall
```
sudo systemctl stop opentracker
sudo systemctl disable opentracker
sudo rm /etc/systemd/system/opentracker.service
sudo rm -rf /opt/opentracker
sudo userdel opentracker
```
+42
View File
@@ -0,0 +1,42 @@
set -euo pipefail
INSTALL_PREFIX="${INSTALL_PREFIX:-/opt/opentracker}"
BUILD_DIR="$(mktemp -d)"
trap "rm -rf $BUILD_DIR" EXIT
echo "=== Installing build deps ==="
sudo apt-get update
sudo apt-get install -y build-essential cvs zlib1g-dev
echo "=== Fetching libowfat ==="
cd "$BUILD_DIR"
cvs -d :pserver:cvs@cvs.fefe.de:/cvs -z9 co libowfat
cd libowfat
make
echo "=== Fetching opentracker ==="
cd "$BUILD_DIR"
cvs -d :pserver:anoncvs@cvs.fefe.de:/cvs -z9 co opentracker
cd opentracker
make FEATURES='-DWANT_V6 -DWANT_FULLSCRAPE'
echo "=== Installing to $INSTALL_PREFIX ==="
sudo mkdir -p "$INSTALL_PREFIX/bin"
sudo cp opentracker "$INSTALL_PREFIX/bin/"
sudo cp opentracker.conf.sample "$INSTALL_PREFIX/opentracker.conf" || true
sudo useradd --system --home "$INSTALL_PREFIX" --shell /usr/sbin/nologin opentracker 2>/dev/null || true
sudo chown -R opentracker:opentracker "$INSTALL_PREFIX"
echo "=== Installing systemd unit ==="
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
sudo cp "$SCRIPT_DIR/opentracker.service" /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable opentracker
sudo systemctl start opentracker
echo
echo "Done. Check status:"
echo " sudo systemctl status opentracker"
echo " curl http://127.0.0.1:6969/stats?mode=tpbs"
echo
echo "Don't forget to open port 6969/tcp + 6969/udp on your VPS firewall."
@@ -0,0 +1,31 @@
[Unit]
Description=opentracker: BitTorrent tracker for OctoWow launcher swarm
After=network.target
[Service]
Type=simple
User=opentracker
Group=opentracker
WorkingDirectory=/opt/opentracker
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
# the namespace can be locked down.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+48
View File
@@ -0,0 +1,48 @@
productName: OctoLauncher
appId: st.octowow.launcher
directories:
buildResources: build
output: distprod
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!*.md'
- '!{.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/**'
- '!{.gitea,.github}/**'
- '!Tools/**'
- '!**/builder-debug.yml'
- '!**/electron-builder.*'
- '!.launcher/**'
- '!WTF/**'
- '!server/**'
- '!scripts/**'
- '!**/node_modules/**/{__tests__,test,tests,docs,example,examples,demo,demos,benchmark,benchmarks}/**'
- '!**/node_modules/**/*.{tsx,map,markdown}'
- '!**/node_modules/**/build/Release/obj/**'
- '!**/node_modules/**/build/Release/{*.iobj,*.ipdb,*.recipe,*.exp,*.lib,*.pdb,*.obj}'
- '!**/node_modules/**/*.{vcxproj,vcxproj.filters}'
npmRebuild: false
electronLanguages: en
extraResources:
- from: resources/aria2c.exe
to: aria2c.exe
win:
artifactName: ${productName}.${ext}
target:
- portable
- nsis
nsis:
artifactName: ${productName}_Installer.${ext}
uninstallDisplayName: ${productName}
oneClick: false
removeDefaultUninstallWelcomePage: true
publish:
- provider: generic
url: 'https://octowow.st/launcher-updates/'
+36
View File
@@ -0,0 +1,36 @@
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'),
'~main': resolve('src/main'),
'~renderer': resolve('src/renderer'),
'~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'
);
}
return {
main: {
resolve: { alias },
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: { alias },
plugins: [react()]
}
};
});
+8897
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
{
"name": "octo-launcher",
"version": "1.3.1",
"description": "An Electron application for launching and updating the OctoWoW client",
"author": "OctoWoW",
"copyright": "Copyright © 2026 OctoWoW",
"main": "./out/main/index.js",
"scripts": {
"start": "electron-vite preview",
"dev": "electron-vite dev",
"server": "cd server && npm run dev",
"postinstall": "electron-builder install-app-deps && node scripts/scrub-native-paths.cjs",
"build": "electron-vite build",
"build:test": "electron-vite build --mode test",
"build:ptr": "electron-vite build --mode ptr",
"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"
},
"dependencies": {
"@electron-toolkit/preload": "^1.0.3",
"@electron-toolkit/utils": "^1.0.2",
"@hookform/resolvers": "^3.3.2",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^4.36.1",
"@trpc/client": "^10.43.3",
"@trpc/react-query": "^10.43.3",
"@trpc/server": "^10.43.3",
"adm-zip": "^0.5.17",
"classnames": "^2.3.2",
"dll-inject": "^0.0.3",
"dompurify": "^3.4.11",
"electron-log": "^5.1.5",
"electron-trpc": "^0.5.2",
"electron-updater": "^5.3.0",
"fs-extra": "^11.1.1",
"isomorphic-git": "^1.25.0",
"lucide-react": "^0.399.0",
"node-fetch": "^2.7.0",
"react-hook-form": "^7.48.2",
"stormlib-node": "^1.3.6",
"superjson": "^1.13.3",
"zod": "^3.22.4"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1",
"@haaxor1689/eslint-config": "^3.0.0",
"@haaxor1689/prettier-config": "^3.0.0",
"@tailwindcss/nesting": "0.0.0-insiders.565cd3e",
"@tanstack/react-query-devtools": "^4.36.1",
"@types/adm-zip": "^0.5.8",
"@types/fs-extra": "^11.0.4",
"@types/node": "16.18.21",
"@types/node-fetch": "^2.6.9",
"@types/react": "18.0.30",
"@types/react-dom": "18.0.11",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.16",
"electron": "^27.0.4",
"electron-builder": "^24.6.4",
"electron-vite": "^1.0.28",
"eslint": "^8.53.0",
"eslint-config-next": "^13.5.6",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.33.2",
"postcss": "^8.4.31",
"postcss-nested": "^6.0.1",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.2.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^4.5.0"
},
"eslintConfig": {
"extends": "@haaxor1689/eslint-config",
"parserOptions": {
"project": [
"./tsconfig.node.json",
"./tsconfig.web.json"
]
},
"rules": {
"@next/next/no-img-element": "off"
}
},
"prettier": "@haaxor1689/prettier-config"
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
plugins: {
'tailwindcss/nesting': 'postcss-nested',
'tailwindcss': {},
'autoprefixer': {}
}
};
+97
View File
@@ -0,0 +1,97 @@
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}`);
+7
View File
@@ -0,0 +1,7 @@
# Copy this file to server/.env and set SOURCE_DIR to your local WoW client directory.
# This is the root of the WoW 1.12.1 client tree that the dev server will serve as CDN.
# The directory should contain WoW.exe, Data/, Interface/, etc.
SOURCE_DIR=C:\WoW\client
# optional: path to a JSON file overriding the bundled addon sources list.
# ADDONS_SOURCES_PATH=/path/to/addons-sources.json
+23
View File
@@ -0,0 +1,23 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY server/package.json server/package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund
COPY server/src/ ./src/
FROM node:22-alpine AS runtime
RUN adduser -D -H -s /sbin/nologin manifest
WORKDIR /app
COPY --from=build --chown=manifest:manifest /app /app
RUN npm install --no-save --no-audit --no-fund tsx@^4.21.0
USER manifest
EXPOSE 5000
ENV SOURCE_DIR=/srv/source
ENTRYPOINT ["node", "--import", "tsx/esm", "src/index.ts"]
+1496
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "server",
"version": "1.0.0",
"main": "index.ts",
"type": "module",
"scripts": {
"dev": "tsx src/index.ts"
},
"dependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.9.0",
"express": "^4.18.2",
"fs-extra": "^11.1.1",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
},
"eslintConfig": {
"extends": "@haaxor1689/eslint-config"
},
"prettier": "@haaxor1689/prettier-config",
"devDependencies": {
"tsx": "^4.21.0"
}
}
+680
View File
@@ -0,0 +1,680 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@types/express':
specifier: ^4.17.21
version: 4.17.21
'@types/node':
specifier: ^20.9.0
version: 20.9.0
express:
specifier: ^4.18.2
version: 4.18.2
fs-extra:
specifier: ^11.1.1
version: 11.1.1
ts-node:
specifier: ^10.9.1
version: 10.9.1(@types/node@20.9.0)(typescript@5.2.2)
typescript:
specifier: ^5.2.2
version: 5.2.2
packages:
/@cspotcode/source-map-support@0.8.1:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
dependencies:
'@jridgewell/trace-mapping': 0.3.9
dev: false
/@jridgewell/resolve-uri@3.1.1:
resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==}
engines: {node: '>=6.0.0'}
dev: false
/@jridgewell/sourcemap-codec@1.4.15:
resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
dev: false
/@jridgewell/trace-mapping@0.3.9:
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
dependencies:
'@jridgewell/resolve-uri': 3.1.1
'@jridgewell/sourcemap-codec': 1.4.15
dev: false
/@tsconfig/node10@1.0.9:
resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==}
dev: false
/@tsconfig/node12@1.0.11:
resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
dev: false
/@tsconfig/node14@1.0.3:
resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
dev: false
/@tsconfig/node16@1.0.4:
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
dev: false
/@types/body-parser@1.19.5:
resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==}
dependencies:
'@types/connect': 3.4.38
'@types/node': 20.9.0
dev: false
/@types/connect@3.4.38:
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
dependencies:
'@types/node': 20.9.0
dev: false
/@types/express-serve-static-core@4.17.41:
resolution: {integrity: sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==}
dependencies:
'@types/node': 20.9.0
'@types/qs': 6.9.10
'@types/range-parser': 1.2.7
'@types/send': 0.17.4
dev: false
/@types/express@4.17.21:
resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==}
dependencies:
'@types/body-parser': 1.19.5
'@types/express-serve-static-core': 4.17.41
'@types/qs': 6.9.10
'@types/serve-static': 1.15.5
dev: false
/@types/http-errors@2.0.4:
resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==}
dev: false
/@types/mime@1.3.5:
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
dev: false
/@types/mime@3.0.4:
resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==}
dev: false
/@types/node@20.9.0:
resolution: {integrity: sha512-nekiGu2NDb1BcVofVcEKMIwzlx4NjHlcjhoxxKBNLtz15Y1z7MYf549DFvkHSId02Ax6kGwWntIBPC3l/JZcmw==}
dependencies:
undici-types: 5.26.5
dev: false
/@types/qs@6.9.10:
resolution: {integrity: sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==}
dev: false
/@types/range-parser@1.2.7:
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
dev: false
/@types/send@0.17.4:
resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==}
dependencies:
'@types/mime': 1.3.5
'@types/node': 20.9.0
dev: false
/@types/serve-static@1.15.5:
resolution: {integrity: sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==}
dependencies:
'@types/http-errors': 2.0.4
'@types/mime': 3.0.4
'@types/node': 20.9.0
dev: false
/accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
dependencies:
mime-types: 2.1.35
negotiator: 0.6.3
dev: false
/acorn-walk@8.3.0:
resolution: {integrity: sha512-FS7hV565M5l1R08MXqo8odwMTB02C2UqzB17RVgu9EyuYFBqJZ3/ZY97sQD5FewVu1UyDFc1yztUDrAwT0EypA==}
engines: {node: '>=0.4.0'}
dev: false
/acorn@8.11.2:
resolution: {integrity: sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==}
engines: {node: '>=0.4.0'}
hasBin: true
dev: false
/arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
dev: false
/array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
dev: false
/body-parser@1.20.1:
resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
http-errors: 2.0.0
iconv-lite: 0.4.24
on-finished: 2.4.1
qs: 6.11.0
raw-body: 2.5.1
type-is: 1.6.18
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
dev: false
/bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
dev: false
/call-bind@1.0.5:
resolution: {integrity: sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==}
dependencies:
function-bind: 1.1.2
get-intrinsic: 1.2.2
set-function-length: 1.1.1
dev: false
/content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
dependencies:
safe-buffer: 5.2.1
dev: false
/content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
dev: false
/cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
dev: false
/cookie@0.5.0:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
engines: {node: '>= 0.6'}
dev: false
/create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
dev: false
/debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.0.0
dev: false
/define-data-property@1.1.1:
resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==}
engines: {node: '>= 0.4'}
dependencies:
get-intrinsic: 1.2.2
gopd: 1.0.1
has-property-descriptors: 1.0.1
dev: false
/depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dev: false
/destroy@1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dev: false
/diff@4.0.2:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'}
dev: false
/ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
dev: false
/encodeurl@1.0.2:
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'}
dev: false
/escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
dev: false
/etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
dev: false
/express@4.18.2:
resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==}
engines: {node: '>= 0.10.0'}
dependencies:
accepts: 1.3.8
array-flatten: 1.1.1
body-parser: 1.20.1
content-disposition: 0.5.4
content-type: 1.0.5
cookie: 0.5.0
cookie-signature: 1.0.6
debug: 2.6.9
depd: 2.0.0
encodeurl: 1.0.2
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 1.2.0
fresh: 0.5.2
http-errors: 2.0.0
merge-descriptors: 1.0.1
methods: 1.1.2
on-finished: 2.4.1
parseurl: 1.3.3
path-to-regexp: 0.1.7
proxy-addr: 2.0.7
qs: 6.11.0
range-parser: 1.2.1
safe-buffer: 5.2.1
send: 0.18.0
serve-static: 1.15.0
setprototypeof: 1.2.0
statuses: 2.0.1
type-is: 1.6.18
utils-merge: 1.0.1
vary: 1.1.2
transitivePeerDependencies:
- supports-color
dev: false
/finalhandler@1.2.0:
resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==}
engines: {node: '>= 0.8'}
dependencies:
debug: 2.6.9
encodeurl: 1.0.2
escape-html: 1.0.3
on-finished: 2.4.1
parseurl: 1.3.3
statuses: 2.0.1
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
dev: false
/forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
dev: false
/fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
dev: false
/fs-extra@11.1.1:
resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==}
engines: {node: '>=14.14'}
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.1.0
universalify: 2.0.1
dev: false
/function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
dev: false
/get-intrinsic@1.2.2:
resolution: {integrity: sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==}
dependencies:
function-bind: 1.1.2
has-proto: 1.0.1
has-symbols: 1.0.3
hasown: 2.0.0
dev: false
/gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
dependencies:
get-intrinsic: 1.2.2
dev: false
/graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
dev: false
/has-property-descriptors@1.0.1:
resolution: {integrity: sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==}
dependencies:
get-intrinsic: 1.2.2
dev: false
/has-proto@1.0.1:
resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==}
engines: {node: '>= 0.4'}
dev: false
/has-symbols@1.0.3:
resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
engines: {node: '>= 0.4'}
dev: false
/hasown@2.0.0:
resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==}
engines: {node: '>= 0.4'}
dependencies:
function-bind: 1.1.2
dev: false
/http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.1
toidentifier: 1.0.1
dev: false
/iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
dependencies:
safer-buffer: 2.1.2
dev: false
/inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
dev: false
/ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
dev: false
/jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
dev: false
/make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
dev: false
/media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
dev: false
/merge-descriptors@1.0.1:
resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==}
dev: false
/methods@1.1.2:
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
engines: {node: '>= 0.6'}
dev: false
/mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
dev: false
/mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.52.0
dev: false
/mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
hasBin: true
dev: false
/ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
dev: false
/ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: false
/negotiator@0.6.3:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
dev: false
/object-inspect@1.13.1:
resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==}
dev: false
/on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
dependencies:
ee-first: 1.1.1
dev: false
/parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
dev: false
/path-to-regexp@0.1.7:
resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==}
dev: false
/proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
dev: false
/qs@6.11.0:
resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
engines: {node: '>=0.6'}
dependencies:
side-channel: 1.0.4
dev: false
/range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
dev: false
/raw-body@2.5.1:
resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==}
engines: {node: '>= 0.8'}
dependencies:
bytes: 3.1.2
http-errors: 2.0.0
iconv-lite: 0.4.24
unpipe: 1.0.0
dev: false
/safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
dev: false
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
dev: false
/send@0.18.0:
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
engines: {node: '>= 0.8.0'}
dependencies:
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
encodeurl: 1.0.2
escape-html: 1.0.3
etag: 1.8.1
fresh: 0.5.2
http-errors: 2.0.0
mime: 1.6.0
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.1
transitivePeerDependencies:
- supports-color
dev: false
/serve-static@1.15.0:
resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==}
engines: {node: '>= 0.8.0'}
dependencies:
encodeurl: 1.0.2
escape-html: 1.0.3
parseurl: 1.3.3
send: 0.18.0
transitivePeerDependencies:
- supports-color
dev: false
/set-function-length@1.1.1:
resolution: {integrity: sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==}
engines: {node: '>= 0.4'}
dependencies:
define-data-property: 1.1.1
get-intrinsic: 1.2.2
gopd: 1.0.1
has-property-descriptors: 1.0.1
dev: false
/setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
dev: false
/side-channel@1.0.4:
resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
dependencies:
call-bind: 1.0.5
get-intrinsic: 1.2.2
object-inspect: 1.13.1
dev: false
/statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
dev: false
/toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
dev: false
/ts-node@10.9.1(@types/node@20.9.0)(typescript@5.2.2):
resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==}
hasBin: true
peerDependencies:
'@swc/core': '>=1.2.50'
'@swc/wasm': '>=1.2.50'
'@types/node': '*'
typescript: '>=2.7'
peerDependenciesMeta:
'@swc/core':
optional: true
'@swc/wasm':
optional: true
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.9
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 20.9.0
acorn: 8.11.2
acorn-walk: 8.3.0
arg: 4.1.3
create-require: 1.1.1
diff: 4.0.2
make-error: 1.3.6
typescript: 5.2.2
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
dev: false
/type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
dependencies:
media-typer: 0.3.0
mime-types: 2.1.35
dev: false
/typescript@5.2.2:
resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==}
engines: {node: '>=14.17'}
hasBin: true
dev: false
/undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
dev: false
/universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
dev: false
/unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
dev: false
/utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
dev: false
/v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
dev: false
/vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
dev: false
/yn@3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'}
dev: false
+285
View File
@@ -0,0 +1,285 @@
import fs from 'fs-extra';
import path from 'path';
import { defaultSources, type AddonSource } from './addons-sources.js';
const CACHE_TTL_MS = 60 * 60 * 1000;
const FETCH_CONCURRENCY = 8;
const FETCH_TIMEOUT_MS = 10_000;
const SOURCES_OVERRIDE_PATH = process.env.ADDONS_SOURCES_PATH ?? '';
export type TocData = Record<string, string>;
export type ResolvedAddon = {
name: string;
owner: string;
git: string;
branch?: string;
ref?: string;
toc?: TocData;
description?: string;
lastUpdated?: string;
stars?: number;
};
type CacheEntry = { at: number; data: ResolvedAddon[] };
let cache: CacheEntry | undefined;
let inFlight: Promise<ResolvedAddon[]> | undefined;
const normalizeColorCodes = (s: string): string =>
s.replace(/\|C(?=[0-9a-fA-F]{8})/g, '|c').replace(/\|R/g, '|r');
const parseToc = (content: string): TocData =>
content
.split('\n')
.filter(l => l.startsWith('## '))
.map(l => l.slice(3))
.map(l => {
const idx = l.indexOf(':');
if (idx === -1) return null;
return [l.slice(0, idx).trim(), l.slice(idx + 1).trim()] as const;
})
.filter((e): e is readonly [string, string] => !!e)
.reduce<TocData>((acc, [k, v]) => {
acc[k] = normalizeColorCodes(v);
return acc;
}, {});
const fetchWithTimeout = async (url: string, init?: RequestInit) => {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(t);
}
};
type RepoMeta = {
description?: string;
defaultBranch?: string;
lastUpdated?: string;
stars?: number;
};
type RawMeta = {
description?: string | null;
default_branch?: string;
pushed_at?: string | null;
updated_at?: string | null;
stargazers_count?: number | null;
stars_count?: number | null;
};
type Provider = {
apiUrl: (owner: string, repo: string) => string;
apiHeaders: () => Record<string, string>;
mapMeta: (json: RawMeta) => RepoMeta;
tocUrl: (owner: string, repo: string, ref: string, name: string) => string;
};
const githubProvider: Provider = {
apiUrl: (o, r) => `https://api.github.com/repos/${o}/${r}`,
apiHeaders: () => ({
Accept: 'application/vnd.github+json',
...(process.env.GITHUB_TOKEN && {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
})
}),
mapMeta: j => ({
description: j.description ?? undefined,
defaultBranch: j.default_branch,
lastUpdated: j.pushed_at ?? undefined,
stars: j.stargazers_count ?? undefined
}),
tocUrl: (o, r, ref, name) =>
`https://raw.githubusercontent.com/${o}/${r}/${ref}/${name}.toc`
};
const GITEA_API = 'https://octowow.st/git/api/v1';
const giteaProvider: Provider = {
apiUrl: (o, r) => `${GITEA_API}/repos/${o}/${r}`,
apiHeaders: () => ({ Accept: 'application/json' }),
mapMeta: j => ({
description: j.description ?? undefined,
defaultBranch: j.default_branch,
lastUpdated: j.updated_at ?? undefined,
stars: j.stars_count ?? undefined
}),
tocUrl: (o, r, ref, name) =>
`${GITEA_API}/repos/${o}/${r}/raw/${name}.toc?ref=${encodeURIComponent(
ref
)}`
};
const parseGitUrl = (
git: string
): { owner: string; repo: string; provider: Provider } => {
const gh = git.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
if (gh && gh[1] && gh[2]) {
return { owner: gh[1], repo: gh[2], provider: githubProvider };
}
const gitea = git.match(/octowow\.st\/git\/([^/]+)\/([^/]+?)(?:\.git)?$/);
if (gitea && gitea[1] && gitea[2]) {
return { owner: gitea[1], repo: gitea[2], provider: giteaProvider };
}
throw Error(`Unsupported git URL: ${git}`);
};
const REQUIRED_TOC_KEYS = ['Interface'];
const tryFetchToc = async (
provider: Provider,
owner: string,
repo: string,
name: string,
ref: string
): Promise<TocData | undefined> => {
const res = await fetchWithTimeout(
provider.tocUrl(owner, repo, ref, name)
).catch(() => null);
if (!res?.ok) return undefined;
const parsed = parseToc(await res.text());
return REQUIRED_TOC_KEYS.every(k => typeof parsed[k] === 'string')
? parsed
: undefined;
};
const resolveOne = async (src: AddonSource): Promise<ResolvedAddon | null> => {
try {
const { owner, repo, provider } = parseGitUrl(src.git);
const name = src.name ?? repo;
const apiRes = await fetchWithTimeout(provider.apiUrl(owner, repo), {
headers: provider.apiHeaders()
}).catch(() => null);
let meta: RepoMeta | undefined;
if (apiRes?.ok) meta = provider.mapMeta((await apiRes.json()) as RawMeta);
const candidates = src.ref
? [src.ref]
: src.branch
? [src.branch]
: [
...new Set(
[meta?.defaultBranch, 'main', 'master'].filter(
(b): b is string => !!b
)
)
];
let toc: TocData | undefined;
let resolvedRef: string | undefined;
for (const ref of candidates) {
toc = await tryFetchToc(provider, owner, repo, name, ref);
if (toc) {
resolvedRef = ref;
break;
}
}
const effectiveBranch = src.ref
? undefined
: src.branch ?? resolvedRef ?? meta?.defaultBranch;
let description = meta?.description ?? undefined;
const lastUpdated = meta?.lastUpdated;
const stars = meta?.stars;
if (src.description) {
description = src.description;
if (toc) toc = { ...toc, Notes: src.description };
}
const result: ResolvedAddon = { name, owner, git: src.git };
if (effectiveBranch !== undefined) result.branch = effectiveBranch;
if (src.ref !== undefined) result.ref = src.ref;
if (toc !== undefined) result.toc = toc;
if (description !== undefined) result.description = description;
if (lastUpdated !== undefined) result.lastUpdated = lastUpdated;
if (stars !== undefined) result.stars = stars;
return result;
} catch (e) {
console.error(`Failed to resolve ${src.git}:`, e);
return null;
}
};
const poolMap = async <T, R>(
items: T[],
concurrency: number,
fn: (item: T) => Promise<R>
): Promise<R[]> => {
const results: R[] = new Array(items.length);
let idx = 0;
const worker = async () => {
while (true) {
const i = idx++;
if (i >= items.length) return;
const item = items[i];
if (item === undefined) return;
results[i] = await fn(item);
}
};
await Promise.all(Array.from({ length: concurrency }, worker));
return results;
};
const loadSources = async (): Promise<AddonSource[]> => {
if (!SOURCES_OVERRIDE_PATH) return defaultSources;
try {
if (await fs.pathExists(SOURCES_OVERRIDE_PATH)) {
const override = (await fs.readJSON(
SOURCES_OVERRIDE_PATH
)) as AddonSource[];
if (Array.isArray(override) && override.length > 0) {
console.log(
`Using addon sources override from ${SOURCES_OVERRIDE_PATH}`
);
return override;
}
}
} catch (e) {
console.error(
`Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`,
e
);
}
return defaultSources;
};
const buildList = async (): Promise<ResolvedAddon[]> => {
const sources = await loadSources();
console.log(
`Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...`
);
const t0 = Date.now();
const results = await poolMap(sources, FETCH_CONCURRENCY, resolveOne);
const ok = results.filter((r): r is ResolvedAddon => r !== null);
ok.sort((a, b) => a.name.localeCompare(b.name));
console.log(
`Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms`
);
return ok;
};
export const getAddons = async (force = false): Promise<ResolvedAddon[]> => {
if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) {
return cache.data;
}
if (inFlight) return inFlight;
inFlight = buildList()
.then(data => {
cache = { at: Date.now(), data };
return data;
})
.finally(() => {
inFlight = undefined;
});
return inFlight;
};
export const warmUp = () => {
getAddons().catch(e => console.error('Addon resolver warm-up failed:', e));
};
+253
View File
@@ -0,0 +1,253 @@
export type AddonSource = {
git: string;
branch?: string;
name?: string;
description?: string;
ref?: string;
};
export const defaultSources: AddonSource[] = [
{
git: 'https://github.com/Alukarho/AI_VoiceOver.git',
description: 'Adds AI-generated voice acting to NPC quest and gossip dialogue'
},
{
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/byCFM2/Atlas-TW.git',
name: 'Atlas-CFM'
},
{
git: 'https://github.com/Road-block/AuldLangSyne.git',
description: 'Adds personal notes to friends, ignore, and guild lists, remembered while offline'
},
{
git: 'https://github.com/FSuhas/AutoLFM.git',
description: 'Automated "Looking For More" broadcaster for Turtle WoW dungeons and raids'
},
{
git: 'https://github.com/shirsig/aux-addon-vanilla.git',
name: 'aux-addon',
description: 'Auction House replacement with advanced filtering and search'
},
{ git: 'https://github.com/absir/Bagshui.git' },
{ git: 'https://github.com/pepopo978/BetterCharacterStats.git', branch: 'main' },
{ git: 'https://github.com/pepopo978/BigWigs.git' },
{
git: 'https://github.com/DBFBlackbull/BitesCookBook.git',
description: 'Tracks which items are used in cooking and what they create'
},
{
git: 'https://github.com/brotalnia/BlizzPlates.git',
description: 'Adds castbars, debuffs, and class icons to the default Blizzard nameplates'
},
{
git: 'https://github.com/MDGitHubRepo/CallOfElements.git',
description: 'All-in-one Shaman totem bar and totem/healing manager'
},
{ git: 'https://github.com/bhhandley/CleveRoidMacros.git' },
{
git: 'https://github.com/Cinecom/ConsumesManager.git',
description: 'Tracks consumables and food buffs across alts, bank, and mail'
},
{
git: 'https://github.com/Kirchlive/cursive-raid.git',
name: 'Cursive-Raid',
description: 'Raid debuff tracker with profiles and multi-curse assist (SuperWoW)'
},
{
git: 'https://github.com/Zerf/Decursive.git',
description: 'Raid/party debuff-cleaning helper that dispels whoever needs it'
},
{
git: 'https://github.com/DeterminedPanda/DifficultBulletinBoard.git',
description: 'Organizes LFG, profession, and hardcore chat announcements into a bulletin board'
},
{ git: 'https://github.com/Player-Doite/DoiteAuras.git' },
{ git: 'https://github.com/Stormhand-dev/DragonflightUI-Reforged.git' },
{
git: 'https://github.com/Fiurs-Hearth/ExtraResourceBars.git',
description: 'Adds extra resource bars (mana, energy, rage) to the UI'
},
{
git: 'https://github.com/SeVeN7000/FishingBuddy.git',
description: 'Auto-equips fishing gear and tracks catches, fish, and zone info'
},
{ git: 'https://github.com/tilare/FlightTracker.git' },
{ git: 'https://github.com/lookino/Flyout.git' },
{
git: 'https://github.com/trumpetx/GetHead.git',
description: 'Recovers Onyxia and Nefarian heads from disenchant grief'
},
{
git: 'https://github.com/zanthor/GNS.git',
description: 'Custom naming for Goblin Brainwashing Device specializations'
},
{ git: 'https://github.com/vatichild/guda.git', name: 'Guda' },
{ git: 'https://github.com/vatichild/GudaPlates.git' },
{ git: 'https://github.com/andresuarezschou/HCDeaths.git' },
{
git: 'https://github.com/Arthur-Helias/InstanceJournal.git',
description: "Encounter Journal reimagined for Turtle WoW"
},
{
git: 'https://github.com/Einherjarn/ItemRack.git',
description: 'Item set manager with quick-swap menus for inventory'
},
{
git: 'https://github.com/CosminPOP/_LazyPig.git',
name: '_LazyPig',
description: 'Auto-dismount, auto-accept, auto-roll, and chat spam filter. /lp to configure'
},
{ git: 'https://github.com/Spartelfant/LevelRange-Turtle.git' },
{ git: 'https://github.com/tilare/MessageBox.git' },
{
git: 'https://github.com/tdymel/ModifiedPowerAuras.git',
description: "Advanced version of Sinesther's Power Auras"
},
{
git: 'https://github.com/tilare/ModernMapMarkers.git',
description: 'Shows dungeons, raids, world bosses, and travel routes on the world map'
},
{
git: 'https://github.com/vegeta1k95/ModernSpellBook.git',
description: 'Retail-style spellbook UI for vanilla'
},
{ git: 'https://github.com/tilare/MovementTracker.git' },
{
git: 'https://github.com/Dusk-92/NampowerSettings.git',
description: 'Settings panel for the Nampower spellqueue addon'
},
{
git: 'https://github.com/BlackHobbiT/necrosis-twow.git',
name: 'Necrosis',
description: 'Warlock helper: pets, soul shards, summoning, demon timers'
},
{
git: 'https://github.com/gnwl/NotGrid.git',
name: 'notgrid',
description: 'Grid-like compact party/raid frames with buff/debuff, aggro, and proximity tracking'
},
{
git: 'https://github.com/zanthor/OG-RaidHelper.git',
description: 'Raid management: roles, trade distribution, soft-reserve validation'
},
{
git: 'https://github.com/sica42/Outfitter.git',
description: 'Equipment set manager to save and quickly swap gear outfits, with Turtle mount fixes'
},
{
git: 'https://github.com/CosminPOP/PallyPower.git',
description: 'Paladin buff and assignment manager for raids and parties'
},
{
git: 'https://github.com/Cliencer/pfExtend.git',
description: 'pfQuest extension showing all monster drops and quest chains. /pfex'
},
{ git: 'https://github.com/shagu/pfQuest.git' },
{ git: 'https://github.com/shagu/pfQuest-turtle.git' },
{ git: 'https://github.com/shagu/pfUI.git' },
{
git: 'https://github.com/jrc13245/pfUI-addonskinner.git',
description: 'pfUI module that re-skins other addons to match the pfUI theme'
},
{
git: 'https://github.com/Bombg/pfUI-bettertotems.git',
description: 'pfUI module with improved Shaman totem timers'
},
{
git: 'https://github.com/Arthur-Helias/pfUI-LocationPlus.git',
name: 'pfUI-locplus',
description: 'Adds a location panel and zone info to pfUI'
},
{ git: 'https://github.com/acid9000/PizzaWorldBuffs.git' },
{
git: 'https://github.com/npfs666/ProcDoc.git',
description: 'Visual proc alerts with pulsing images so you never miss them'
},
{ git: 'https://github.com/SabineWren/Quiver.git' },
{
git: 'https://github.com/hazlema/Rested.git',
description: 'Progress bar showing your rested XP while resting'
},
{ git: 'https://github.com/Otari98/Rinse.git' },
{
git: 'https://github.com/anzz1/SellValue.git',
description: 'Shows item vendor sell value in tooltips when not at a vendor'
},
{ git: 'https://github.com/shagu/ShaguDPS.git' },
{
git: 'https://github.com/shagu/ShaguPlates.git',
description: 'Nameplates with castbars and class colors. /splates'
},
{ git: 'https://github.com/shagu/ShaguTweaks.git' },
{
git: 'https://github.com/shagu/ShaguTweaks-extras.git',
description: 'Extras module for ShaguTweaks (additional UI tweaks)'
},
{ git: 'https://github.com/pepopo978/SimpleActionSets.git' },
{
git: 'https://github.com/balakethelock/SuperAPI.git',
description: 'Companion compatibility addon bridging the SuperWoW client mod\'s expanded Lua API'
},
{
git: 'https://github.com/jrc13245/SuperMacro-turtle-SuperWoW.git',
name: 'SuperMacro',
description: 'Extended macros with long macros, keybind execution, item links, and a code editor'
},
{ git: 'https://github.com/Siventt/AttackBar.git' },
{
git: 'https://github.com/Player-Doite/Tactica.git',
description: 'Auto-build raids: invite/gearcheck, tactics, masterloot, role sync'
},
{
git: 'https://github.com/Otari98/Tmog.git',
description: 'Transmog item browser with collection info in tooltips'
},
{
git: 'https://github.com/whtmst/T-RestedXP.git',
description: 'Tracks 0% and 100% rested XP thresholds'
},
{ git: 'https://github.com/sica42/TurtleCalendar.git' },
{
git: 'https://github.com/sica42/TurtleMail.git',
description: 'Mailbox UI enhancement: bulk send, search, multi-mail'
},
{ git: 'https://github.com/tempranova/turtlerp.git', name: 'TurtleRP' },
{ git: 'https://github.com/CosminPOP/TWThreat.git' },
{
git: 'https://github.com/RetroCro/unitscan-turtle-hc.git',
description: 'Hardcore unitscan fork for Turtle WoW that alerts on rares, elites, and dangerous mobs'
},
{
git: 'https://github.com/whtmst/UnitXP_SP3_Addon.git',
description: 'Settings UI for the UnitXP SuperWoW client patch'
},
{
git: 'https://github.com/tdymel/VCB.git',
description: 'Smart consolidated buff frames with extensive customization'
},
{
git: 'https://octowow.st/git/shaga/LifeSafer_LowHealthWarning.git',
branch: 'main',
description: 'Low health and mana fullscreen flash warnings with heartbeat sound; re-enables the hidden Blizzard alert effect'
},
{
git: 'https://github.com/Fiurs-Hearth/WIIIUI.git',
description: 'Compact custom UI replacement for Turtle WoW'
},
{ git: 'https://github.com/refaim/WIM.git' },
{
git: 'https://github.com/Arthur-Helias/ZonesLevel.git',
description: "Shows zone level range under the title on the world map"
}
];
+346
View File
@@ -0,0 +1,346 @@
import crypto from 'crypto';
import path from 'path';
import fs from 'fs-extra';
const allowedExtra = [
'.launcher',
'Data',
'Errors',
'Interface\\AddOns',
'Logs',
'Screenshots',
'WDB',
'WTF\\Account'
];
const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf'];
const raidVisuals = ['patch-O.mpq'];
const skipFiles = new Set([
'manifest.json',
'manifest.json.tmp',
'wow-client.zip',
'.gitkeep',
'.manifest-overrides.json'
]);
const skipPatterns: RegExp[] = [
/\.bak([.\-]|$)/,
/\.crashing(\.|$)/,
/\.torrent$/,
/^manifest\.json\./
];
const isSkipPattern = (file: string) => skipPatterns.some(p => p.test(file));
const skipDirsPosix = new Set([
'Interface/GlueXML',
'Interface/FrameXML',
'Errors',
'Logs',
'Screenshots',
'WDB',
'WTF/Account'
]);
const isSkipDir = (...filePath: string[]) =>
skipDirsPosix.has(filePath.join('/'));
type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes' | 'raidVisuals';
type FileManifest = { name: string } & (
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
| { type: 'mpq'; files: FileManifest[]; hash: string; size: number }
| {
type: 'file';
hash: string;
version?: number;
size: number;
tags?: FileTags[];
}
| { type: 'del' }
);
export type BuildProgress = {
state: 'idle' | 'building' | 'ready' | 'failed';
done: number;
total: number;
currentFile: string;
startedAt: number | null;
finishedAt: number | null;
error: string | null;
};
export type ProgressCallback = (
p: Pick<BuildProgress, 'done' | 'total' | 'currentFile'>
) => void;
const getHash = (...filePath: string[]): Promise<string> =>
new Promise((resolve, reject) => {
const hash = crypto.createHash('sha1');
const stream = fs.createReadStream(path.join(...filePath));
stream.on('error', reject);
stream.on('data', (chunk: Buffer) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex').toLocaleUpperCase()));
});
const countFiles = async (
clientPath: string,
...filePath: string[]
): Promise<number> => {
let total = 0;
const dir = path.join(clientPath, ...filePath);
const files = await fs.readdir(dir);
for (const file of files.sort()) {
if (skipFiles.has(file)) continue;
if (isSkipPattern(file)) continue;
const stats = await fs.stat(path.join(dir, file));
if (stats.isDirectory()) {
if (isSkipDir(...filePath, file)) continue;
if (file.match(/patch-./)) {
const mpqPath = path.join(dir, `${file}.mpq`);
if (await fs.pathExists(mpqPath)) total += 1;
}
total += await countFiles(clientPath, ...filePath, file);
} else {
total += 1;
}
}
return total;
};
export const buildCache = async (
clientPath: string,
onProgress?: ProgressCallback
) => {
console.log('Building cache...');
const prevManifestPath = path.join(clientPath, 'manifest.json');
let prevManifestMtimeMs = 0;
const prevHashByPath = new Map<string, string>();
const prevVersionByPath = new Map<string, number | undefined>();
const prevSizeByPath = new Map<string, number>();
try {
const prevStat = await fs.stat(prevManifestPath);
prevManifestMtimeMs = prevStat.mtimeMs;
const prev = await fs.readJSON(prevManifestPath);
const walk = (node: FileManifest, prefix: string[]) => {
if (node.type === 'dir' || node.type === 'mpq') {
const newPrefix = node.name ? [...prefix, node.name] : prefix;
if (node.type === 'mpq') {
const mpqKey = [...newPrefix.slice(0, -1), node.name + '.mpq'].join(
'/'
);
prevHashByPath.set(mpqKey, node.hash);
prevSizeByPath.set(mpqKey, node.size);
}
for (const child of node.files) walk(child, newPrefix);
} else {
const key = [...prefix, node.name].join('/');
prevHashByPath.set(key, node.hash);
prevVersionByPath.set(key, node.version);
prevSizeByPath.set(key, node.size);
}
};
walk(prev.root, []);
console.log(
`mtime-skip: loaded ${prevHashByPath.size} cached hashes from ` +
`prior manifest (mtime=${new Date(prevManifestMtimeMs).toISOString()})`
);
} catch (e) {
console.log(
'mtime-skip: no usable prior manifest, full rebuild ' +
`(${(e as Error).message})`
);
}
const total = await countFiles(clientPath);
let done = 0;
let reused = 0;
const tick = (currentFile: string) => {
done += 1;
onProgress?.({ done, total, currentFile });
};
console.log(`Building cache: ${total} files to hash...`);
const getHashCached = async (
relPath: string,
mtimeMs: number,
...filePath: string[]
): Promise<string> => {
if (prevManifestMtimeMs > 0 && mtimeMs <= prevManifestMtimeMs) {
const cached = prevHashByPath.get(relPath);
if (cached) {
reused++;
return cached;
}
}
return getHash(clientPath, ...filePath);
};
const buildTree = async (...filePath: string[]): Promise<FileManifest[]> => {
const files = await fs.readdir(path.join(clientPath, ...filePath));
const patches: string[] = [];
const tree: FileManifest[] = [];
for (const file of files.sort()) {
if (skipFiles.has(file)) continue;
if (isSkipPattern(file)) continue;
const stats = await fs.stat(path.join(clientPath, ...filePath, file));
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`)
.split(path.sep)
.join('/');
const mpqStat = await fs.stat(
path.join(clientPath, ...filePath, `${file}.mpq`)
);
tree.push({
type: 'mpq',
name: file,
files: await buildTree(...filePath, file),
size: mpqStat.size,
hash: await getHashCached(
mpqRelPath,
mpqStat.mtimeMs,
...filePath,
`${file}.mpq`
)
});
tick(mpqRelPath);
} else {
const tags: FolderTags[] = [];
allowedExtra.includes(path.join(...filePath, file)) &&
tags.push('allowExtra');
tree.push({
type: 'dir',
name: file,
files: await buildTree(...filePath, file),
tags: tags.length ? tags : undefined
});
}
continue;
}
if (patches.find(v => file.match(v))) continue;
const allowModifiedPaths = new Set([
'WTF/Config.wtf',
'Data/fonts.MPQ',
'Data/sound.MPQ',
'Data/speech.MPQ'
]);
const fullPath = path
.join(...filePath, file)
.split(path.sep)
.join('/');
const allowModified =
file === 'WoW.exe' || allowModifiedPaths.has(fullPath);
const tags: FileTags[] = [];
vanillaFixes.includes(file) && tags.push('vanillaFixes');
raidVisuals.includes(file) && tags.push('raidVisuals');
tree.push({
type: 'file',
name: file,
hash: await getHashCached(fullPath, stats.mtimeMs, ...filePath, file),
version: allowModified ? stats.mtimeMs : undefined,
size: stats.size,
tags: tags.length ? tags : undefined
});
tick(fullPath);
}
return tree;
};
const rootFiles = await buildTree();
const overridesPath = path.join(clientPath, '.manifest-overrides.json');
try {
if (await fs.pathExists(overridesPath)) {
const ov = await fs.readJSON(overridesPath);
const dels: string[] = Array.isArray(ov.del) ? ov.del : [];
for (const relPath of dels) {
const parts = relPath.split('/').filter(Boolean);
if (parts.length === 0) continue;
const fileName = parts.pop()!;
let dirNode: FileManifest = {
type: 'dir',
name: '',
files: rootFiles
} as FileManifest;
let ok = true;
for (const seg of parts) {
if (dirNode.type !== 'dir' && dirNode.type !== 'mpq') {
ok = false;
break;
}
let child = dirNode.files.find(f => f.name === seg);
if (!child) {
child = {
type: 'dir',
name: seg,
files: [],
tags: ['allowExtra']
};
dirNode.files.push(child);
}
dirNode = child;
}
if (!ok || (dirNode.type !== 'dir' && dirNode.type !== 'mpq')) {
console.warn(
`manifest-overrides: del path "${relPath}" hit a non-dir ` +
`node, skipping`
);
continue;
}
const exists = dirNode.files.some(
f => f.name === fileName && f.type === 'del'
);
if (!exists) {
dirNode.files.push({ type: 'del', name: fileName } as FileManifest);
console.log(
`manifest-overrides: inserted {type:'del', name:'${fileName}'} ` +
`under ${parts.join('/') || '<root>'}`
);
}
}
}
} catch (e) {
console.warn(
`manifest-overrides: failed to apply ${overridesPath}, continuing` +
`without overrides (${(e as Error).message})`
);
}
const finalPath = path.join(clientPath, 'manifest.json');
const tmpPath = path.join(clientPath, 'manifest.json.tmp');
await fs.writeJSON(tmpPath, {
build: 3,
buildName: '3',
root: {
type: 'dir',
name: '',
files: rootFiles
}
});
await fs.rename(tmpPath, finalPath);
if (prevManifestMtimeMs > 0) {
console.log(
`mtime-skip: reused ${reused}/${total} cached hashes ` +
`(re-hashed ${total - reused})`
);
}
};
+167
View File
@@ -0,0 +1,167 @@
import path from 'path';
import express from 'express';
import fs from 'fs-extra';
import { buildCache, type BuildProgress } from './cache.js';
import { getAddons, warmUp as warmUpAddons } from './addons-resolver.js';
const SourceDir = process.env.SOURCE_DIR || './client';
const app = express();
const port = 5000;
const buildProgress: BuildProgress = {
state: 'idle',
done: 0,
total: 0,
currentFile: '',
startedAt: null,
finishedAt: null,
error: null
};
let buildInFlight: Promise<void> | null = null;
const ensureManifestBuilt = (): Promise<void> => {
if (buildInFlight) return buildInFlight;
buildProgress.state = 'building';
buildProgress.done = 0;
buildProgress.total = 0;
buildProgress.currentFile = '';
buildProgress.startedAt = Date.now();
buildProgress.finishedAt = null;
buildProgress.error = null;
buildInFlight = buildCache(SourceDir, p => {
buildProgress.done = p.done;
buildProgress.total = p.total;
buildProgress.currentFile = p.currentFile;
})
.then(() => {
buildProgress.state = 'ready';
buildProgress.finishedAt = Date.now();
})
.catch(e => {
buildProgress.state = 'failed';
buildProgress.error = e instanceof Error ? e.message : String(e);
buildProgress.finishedAt = Date.now();
buildInFlight = null;
throw e;
});
return buildInFlight;
};
app.get('/api/build-status', (_req, res) => {
res.json(buildProgress);
});
app.get('/api/file/:version/manifest.json', async (_req, res) => {
console.log(`Fetching manifest`);
const filePath = path.join(SourceDir, 'manifest.json');
if (await fs.pathExists(filePath)) {
res.json(await fs.readJSON(filePath));
return;
}
void ensureManifestBuilt().catch(() => {});
res.setHeader('Retry-After', '5');
res.status(503).json({
error: 'manifest_building',
message:
'Manifest is being built for the first time on this server. ' +
'Poll /api/build-status for progress; retry this endpoint when ready.',
buildProgress
});
});
app.get(
'/api/file/:version/*',
async (req: express.Request<{ 0: string }>, res) => {
const filePath = req.params[0];
console.log(`Fetching file: ${filePath}`);
const root = path.resolve(SourceDir);
const target = path.resolve(SourceDir, filePath);
if (target !== root && !target.startsWith(root + path.sep)) {
res.status(403).end();
return;
}
res.sendFile(target);
}
);
app.get('/api/addons.json', async (req, res) => {
try {
const force = req.query.refresh === '1';
const addons = await getAddons(force);
res.json(addons);
} catch (e) {
console.error('Failed to resolve addons:', e);
res.status(500).json({ error: 'Failed to resolve addons' });
}
});
const newestSourceMtime = async (dir: string): Promise<number> => {
let newest = 0;
const entries = await fs.readdir(dir);
for (const name of entries) {
if (name === 'manifest.json' || name === 'manifest.json.tmp') continue;
const full = path.join(dir, name);
const stat = await fs.stat(full);
if (stat.isDirectory()) {
const inner = await newestSourceMtime(full);
if (inner > newest) newest = inner;
} else if (stat.mtimeMs > newest) {
newest = stat.mtimeMs;
}
}
return newest;
};
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
warmUpAddons();
void (async () => {
const manifestPath = path.join(SourceDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
console.log(`Pre-warming manifest cache for ${SourceDir}...`);
try {
await ensureManifestBuilt();
console.log(`Manifest cache pre-warm complete.`);
} catch (e) {
console.error(
'Manifest pre-warm failed (will fall back to lazy build on first request):',
e
);
}
return;
}
buildProgress.state = 'ready';
buildProgress.finishedAt = Date.now();
try {
const manifestStat = await fs.stat(manifestPath);
const newest = await newestSourceMtime(SourceDir);
if (newest > manifestStat.mtimeMs) {
console.log(
`Manifest is stale (newest source mtime ${new Date(
newest
).toISOString()} > manifest ${new Date(
manifestStat.mtimeMs
).toISOString()}); rebuilding in background.`
);
ensureManifestBuilt()
.then(() => console.log('Background manifest rebuild complete.'))
.catch(e => console.error('Background manifest rebuild failed:', e));
} else {
console.log(
`Manifest cache already on disk at ${manifestPath} and up to date; no rebuild needed.`
);
}
} catch (e) {
console.error('Manifest staleness check failed:', e);
}
})();
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"noUncheckedIndexedAccess": true,
"rootDir": "src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"],
}
+214
View File
@@ -0,0 +1,214 @@
import { z } from 'zod';
export const ModIdSchema = z.enum([
'dxvk',
'nampower',
'multiMonitorFix',
'superWow',
'transmogFix',
'unitXp',
'vanillaFixes',
'vanillaHelpers'
]);
export type ModId = z.infer<typeof ModIdSchema>;
export type ModSource =
| {
kind: 'directFile';
url: string;
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
apiUrl?: string;
pinnedTag?: string;
assetName: string;
sha256?: string;
}
| {
kind: 'archive';
url: string;
apiUrl?: string;
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
pinnedTag?: string;
format: 'zip' | 'tar.gz';
extractMap: Record<string, string>;
sha256?: string;
}
| { kind: 'managed' };
export type ModEntry = {
id: ModId;
name: string;
version: string;
description: string;
recommended?: boolean;
requires?: ModId[];
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[] = [
{
id: 'dxvk',
name: 'dxvk',
version: 'v2.7.1-1',
description: 'Enables Vulkan based rendering mode for better performance.',
recommended: true,
repoUrl: 'https://gitlab.com/Ph42oN/dxvk-gplasync',
source: {
kind: 'archive',
url: 'https://gitlab.com/Ph42oN/dxvk-gplasync/-/raw/main/releases/dxvk-gplasync-v2.7.1-1.tar.gz?ref_type=heads',
pinnedTag: 'v2.7.1-1',
format: 'tar.gz',
extractMap: {
'dxvk-gplasync-v2.7.1-1/x32/d3d9.dll': 'd3d9.dll'
}
}
},
{
id: 'nampower',
name: 'nampower',
version: 'v4.6.2',
description:
'A client modification that minimizes your input lag if you have higher latency.',
repoUrl: 'https://github.com/Emyrk/nampower',
requires: ['vanillaFixes'],
source: {
kind: 'directFile',
url: 'https://github.com/Emyrk/nampower/releases/download/v4.6.2/nampower.dll',
pinnedTag: 'v4.6.2',
assetName: 'nampower.dll'
},
registerInDllsTxt: 'nampower.dll'
},
{
id: 'multiMonitorFix',
name: 'no1600x1200',
version: '0.2',
description: 'Fix for larger resolutions or multi monitor setups.',
repoUrl: 'https://github.com/Mates1500/VanillaMultiMonitorFix',
requires: ['vanillaFixes'],
source: {
kind: 'archive',
url: 'https://github.com/Mates1500/VanillaMultiMonitorFix/releases/download/0.2/release.zip',
apiUrl:
'https://api.github.com/repos/Mates1500/VanillaMultiMonitorFix/releases/latest',
parseLatest: 'githubRelease',
pinnedTag: '0.2',
format: 'zip',
extractMap: {
'VanillaMultiMonitorFix.dll': 'VanillaMultiMonitorFix.dll'
}
},
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',
version: 'v0.7.0',
description:
"A client-side fix that eliminates frame drops caused by the server's transmogrification durability workaround.",
repoUrl: 'https://codeberg.org/MarcelineVQ/WeirdUtils',
requires: ['vanillaFixes'],
source: {
kind: 'directFile',
url: 'https://codeberg.org/MarcelineVQ/WeirdUtils/releases/download/v0.7.0/transmogfix.dll',
pinnedTag: 'v0.7.0',
assetName: 'transmogfix.dll'
},
registerInDllsTxt: 'transmogfix.dll'
},
{
id: 'unitXp',
name: 'unitXp',
version: 'v89',
description: 'An attempt to make Vanilla 1.12 modern.',
repoUrl: 'https://codeberg.org/konaka/UnitXP_SP3',
requires: ['vanillaFixes'],
source: {
kind: 'archive',
url: 'https://codeberg.org/konaka/UnitXP_SP3/releases/download/v89/UnitXP_SP3%20v89.zip',
pinnedTag: 'v89',
format: 'zip',
extractMap: {
'UnitXP_SP3.dll': 'UnitXP_SP3.dll'
}
},
registerInDllsTxt: 'UnitXP_SP3.dll'
},
{
id: 'vanillaFixes',
name: 'vanillaFixes',
version: 'v1.5.3',
description:
'A client modification that eliminates stutter and animation lag.',
recommended: true,
repoUrl: 'https://github.com/hannesmann/vanillafixes',
source: {
kind: 'archive',
url: 'https://github.com/hannesmann/vanillafixes/releases/download/v1.5.3/vanillafixes-1.5.3.zip',
apiUrl:
'https://api.github.com/repos/hannesmann/vanillafixes/releases/latest',
parseLatest: 'githubRelease',
pinnedTag: 'v1.5.3',
format: 'zip',
extractMap: {
'VfPatcher.dll': 'VfPatcher.dll',
'VanillaFixes.exe': 'VanillaFixes.exe'
}
}
},
{
id: 'vanillaHelpers',
name: 'vanillaHelpers',
version: 'v1.1.2',
description:
'Utility library that might be required by other patches and addons.',
repoUrl: 'https://github.com/isfir/VanillaHelpers',
requires: ['vanillaFixes'],
source: {
kind: 'directFile',
url: 'https://github.com/isfir/VanillaHelpers/releases/download/v1.1.2/VanillaHelpers.dll',
apiUrl:
'https://api.github.com/repos/isfir/VanillaHelpers/releases/latest',
parseLatest: 'githubRelease',
pinnedTag: 'v1.1.2',
assetName: 'VanillaHelpers.dll'
},
registerInDllsTxt: 'VanillaHelpers.dll'
}
];
export const getMod = (id: ModId): ModEntry | undefined =>
MODS.find(m => m.id === id);
const NOT_DEFAULT_ENABLED: ModId[] = [];
export const DEFAULT_ENABLED_MODS: ModId[] = MODS.filter(
m => !m.disabled && !NOT_DEFAULT_ENABLED.includes(m.id)
).map(m => m.id);
+149
View File
@@ -0,0 +1,149 @@
import { z } from 'zod';
const f = {
boolean: (defaultValue?: boolean) =>
z.boolean().nullish().default(!!defaultValue),
number: (defaultValue?: number, val?: (v: z.ZodNumber) => z.ZodNumber) =>
z.preprocess(
v =>
v === '' || v === undefined
? defaultValue ?? null
: typeof v === 'string'
? Number(v)
: v,
(val?.(z.number()) ?? z.number()).nullish()
)
};
export const ConfigWtfSchema = z.object({
vanillaFixes: f.boolean(),
raidVisuals: f.boolean(),
largeAddress: f.boolean(true),
nameplateRange: f.number(41),
alwaysAutoLoot: f.boolean(),
fieldOfView: f.number(110),
farClip: f.number(777),
frillDistance: f.number(70),
cameraDistance: f.number(50),
soundInBackground: f.boolean(true)
});
export type ConfigWtfSchema = z.infer<typeof ConfigWtfSchema>;
export const ModStateSchema = z.object({
enabled: z.boolean().default(false),
installedVersion: z.string().optional(),
installedFiles: z.array(z.string()).default([]),
ignoreUpdates: z.boolean().default(false)
});
export type ModState = z.infer<typeof ModStateSchema>;
export const HardwareInfoSchema = z.object({
totalRamMb: z.number(),
cpuCores: z.number(),
cpuModel: z.string(),
gpuModel: z.string(),
vramMb: z.number().nullable(),
vramSource: z.enum(['registry', 'wmi', 'none']),
detectedAt: z.string(),
schemaVersion: z.number()
});
export type HardwareInfo = z.infer<typeof HardwareInfoSchema>;
export const PreferencesSchema = z.object({
isPortable: z.boolean().optional(),
server: z.enum(['live', 'ptr']).default('live'),
clientDir: z.string().optional(),
version: z.string().optional(),
lastPatchedLauncherVersion: z.string().optional(),
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({
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number()
})
.nullish(),
config: ConfigWtfSchema.default({}),
mods: z.record(ModStateSchema).default({}),
hardware: HardwareInfoSchema.optional(),
farClipUserSet: z.boolean().optional()
});
export type PreferencesSchema = z.infer<typeof PreferencesSchema>;
export const TocDataSchema = z.object({
Interface: z.string(),
Title: z.string(),
Author: z.string(),
Notes: z.string(),
Version: z.string(),
Dependencies: z.string().optional(),
OptionalDeps: z.string().optional()
});
export type TocData = z.infer<typeof TocDataSchema>;
export const AddonDataSchema = z.object({
status: z.enum([
'available',
'fetching',
'unknown',
'upToDate',
'outOfDate',
'downloading',
'invalid'
]),
git: z.string().optional(),
toc: TocDataSchema.optional(),
description: z.string().optional(),
error: z.string().optional(),
branch: z.string().optional(),
ref: z.string().optional(),
folder: z.string(),
progress: z.string().optional(),
preview: z.string().optional()
});
export type AddonData = z.infer<typeof AddonDataSchema>;
export const NewsItemSchema = z.object({
id: z.string(),
title: z.string(),
date: z.string(),
body: z.string(),
url: z.string().url().optional(),
author: z.string().nullish()
});
export type NewsItem = z.infer<typeof NewsItemSchema>;
export const NewsFeedSchema = z.object({
items: z.array(NewsItemSchema)
});
export type NewsFeed = z.infer<typeof NewsFeedSchema>;
export const ForumAnnouncementSchema = z.object({
id: z.string(),
title: z.string(),
author: z.string().nullish(),
date: z.string(),
url: z.string().url(),
html: z.string()
});
export type ForumAnnouncement = z.infer<typeof ForumAnnouncementSchema>;
+82
View File
@@ -0,0 +1,82 @@
type Path = readonly (string | number)[];
const isUnsafeKey = (key: string | number) =>
key === '__proto__' || key === 'constructor' || key === 'prototype';
export const nestedGet = <T>(object: unknown, path: Path) =>
path.reduce(
(obj, key) => (isUnsafeKey(key) ? undefined : obj?.[key]),
object
) as T;
export const nestedSet = (obj: any, path: Path, value: unknown) => {
const [key, ...rest] = path;
if (isUnsafeKey(key)) return;
if (path.length === 1) {
obj[key] = value;
return;
}
if (obj[key] === undefined) {
obj[key] = typeof rest[0] === 'number' ? [] : {};
}
nestedSet(obj[key], rest as never, value);
};
export const asyncReduce = async <T, U>(
arr: T[],
reducer: (acc: U, cur: T) => Promise<U>,
init: U
): Promise<U> => {
let acc: U = init;
for (const i of arr) acc = await reducer(acc, i);
return acc;
};
export const asyncMap = async <T, U>(
arr: T[],
map: (cur: T) => Promise<U>
): Promise<U[]> => {
const acc: U[] = [];
for (const i of arr) acc.push(await map(i));
return acc;
};
export const isNotUndef = <T>(obj: T): obj is Exclude<T, undefined> =>
obj !== undefined;
export const formatFileSize = (bytes: number, decimals = 2) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${parseFloat(size.toFixed(decimals))} ${units[unitIndex]}`;
};
export const formatDuration = (remaining: number) => {
const hours = Math.floor(remaining / 3600);
const minutes = Math.floor((remaining % 3600) / 60);
const seconds = Math.floor(remaining % 60);
return `${hours ? `${hours}h ` : ''}${
minutes ? `${minutes}m ` : ''
}${seconds}s`;
};
export const omit = <T extends object, const K extends keyof T>(
obj: T,
keys: K[]
): Omit<T, K> => {
const result = { ...obj };
keys.forEach(key => {
delete result[key];
});
return result;
};
+26
View File
@@ -0,0 +1,26 @@
import { createTRPCRouter } from './trpc';
import { addonsRouter } from './routers/addonts';
import { launcherRouter } from './routers/launcher';
import { updaterRouter } from './routers/updater';
import { patcherRouter } from './routers/patcher';
import { generalRouter } from './routers/general';
import { preferencesRouter } from './routers/preferences';
import { newsRouter } from './routers/news';
import { forumRouter } from './routers/forum';
import { modsRouter } from './routers/mods';
import { selfUpdaterRouter } from './routers/selfUpdater';
export const appRouter = createTRPCRouter({
addons: addonsRouter,
general: generalRouter,
preferences: preferencesRouter,
launcher: launcherRouter,
patcher: patcherRouter,
updater: updaterRouter,
news: newsRouter,
forum: forumRouter,
mods: modsRouter,
selfUpdater: selfUpdaterRouter
});
export type AppRouter = typeof appRouter;
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod';
import Addons from '~main/modules/addons';
import { AddonDataSchema } from '~common/schemas';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const addonsRouter = createTRPCRouter({
verify: publicProcedure.mutation(() => {
Addons.verify();
}),
update: publicProcedure
.input(z.object({ toUpdate: z.array(z.string()).optional() }))
.mutation(({ input }) => Addons.update(input.toUpdate)),
install: publicProcedure
.input(AddonDataSchema)
.mutation(({ input }) => Addons.install(input)),
remove: publicProcedure
.input(z.object({ toDelete: z.array(z.string()) }))
.mutation(({ input }) => Addons.remove(input.toDelete)),
checkGitUrl: publicProcedure
.input(z.string())
.query(({ input }) => Addons.checkGitUrl(input)),
observe: publicProcedure.subscription(() => Addons.observe())
});
+47
View File
@@ -0,0 +1,47 @@
import fetch from 'node-fetch';
import Logger from 'electron-log/main';
import {
ForumAnnouncementSchema,
type ForumAnnouncement
} from '~common/schemas';
import { createTRPCRouter, publicProcedure } from '../trpc';
const FETCH_TIMEOUT_MS = 8_000;
const fetchLatestAnnouncement = async (): Promise<ForumAnnouncement | null> => {
const url = `${
import.meta.env.MAIN_VITE_FORUM_URL || 'https://octowow.st'
}/forum/octonews.php?forum=35&mode=full`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw Error(`HTTP ${res.status}`);
const json = (await res.json()) as unknown;
if (!json || typeof json !== 'object' || !('id' in json)) return null;
const parsed = ForumAnnouncementSchema.safeParse(json);
if (!parsed.success) {
Logger.error(
'Forum announcement failed schema validation',
parsed.error.flatten()
);
throw Error('Malformed forum announcement');
}
return parsed.data;
} finally {
clearTimeout(t);
}
};
export const forumRouter = createTRPCRouter({
latestAnnouncement: publicProcedure.query(async () => {
try {
return await fetchLatestAnnouncement();
} catch (e) {
Logger.error('Failed to fetch forum announcement', e);
throw e;
}
})
});
+88
View File
@@ -0,0 +1,88 @@
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 { detectHardware, recommendFarClip } from '~main/modules/hardware';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const generalRouter = createTRPCRouter({
appVersion: publicProcedure.query(() => app.getVersion()),
hardware: publicProcedure.query(() => {
const hardware = Preferences.data.hardware ?? null;
return { hardware, recommendedFarClip: recommendFarClip(hardware) };
}),
redetectHardware: publicProcedure.mutation(async () => {
const hardware = await detectHardware();
Preferences.data = { hardware };
return { hardware, recommendedFarClip: recommendFarClip(hardware) };
}),
quit: publicProcedure.mutation(() => app.quit()),
minimize: publicProcedure.mutation(() => mainWindow?.minimize()),
openLink: publicProcedure
.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));
}),
openLogFile: publicProcedure.mutation(() => {
const file = Logger.transports.file.getFile().path;
shell.openPath(path.normalize(file));
}),
addDefenderExclusion: publicProcedure.mutation(() => addDefenderExclusions()),
antivirusBlocks: publicProcedure.query(() => detectAntivirusBlocks()),
filePicker: publicProcedure
.input(
z.object({
title: z.string().optional(),
message: z.string().optional(),
filters: z
.array(
z.object({
name: z.string(),
extensions: z.array(z.string())
})
)
.optional(),
properties: z
.array(
z.enum([
'openDirectory',
'openFile',
'multiSelections',
'showHiddenFiles',
'createDirectory',
'promptToCreate',
'noResolveAliases',
'treatPackageAsDirectory',
'dontAddToRecent'
])
)
.optional()
})
)
.mutation(async ({ input }) => {
if (!mainWindow) return { canceled: true } as const;
const { canceled, filePaths } = await dialog.showOpenDialog(
mainWindow,
input
);
return canceled
? ({ canceled: true } as const)
: ({
canceled: false,
path: filePaths as [string, ...string[]]
} as const);
})
});
+172
View File
@@ -0,0 +1,172 @@
import path from 'path';
import { spawn } from 'child_process';
import fs from 'fs-extra';
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 { minimizeToTray, restoreFromTray } from '~main/modules/tray';
import { getMod } from '~common/mods';
import { createTRPCRouter, publicProcedure } from '../trpc';
const chainloaderNeeded = async (clientDir: string): Promise<boolean> => {
const installed = Mods.status.mods.filter(r => r.installedVersion);
if (installed.some(r => r.id === 'vanillaFixes')) return true;
if (installed.some(r => getMod(r.id)?.requires?.includes('vanillaFixes')))
return true;
const dllsPath = path.join(clientDir, 'dlls.txt');
if (await fs.pathExists(dllsPath)) {
const raw = await fs.readFile(dllsPath, 'utf8');
return raw.split(/\r?\n/).some(l => l.trim() && !l.trim().startsWith('#'));
}
return false;
};
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 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;
}
})
});
+38
View File
@@ -0,0 +1,38 @@
import path from 'path';
import { z } from 'zod';
import Mods from '~main/modules/mods';
import Preferences from '~main/modules/preferences';
import { isGameRunning } from '~main/modules/updater';
import { ModIdSchema } from '~common/mods';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const modsRouter = createTRPCRouter({
list: publicProcedure.query(() => Mods.status),
verify: publicProcedure.mutation(() => Mods.verify()),
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)),
applyAll: publicProcedure.mutation(() => Mods.applyAll()),
repair: publicProcedure.mutation(async () => {
const clientDir = Preferences.data?.clientDir;
if (clientDir) {
const exePath = path.join(clientDir, 'WoW.exe');
if (await isGameRunning(exePath))
throw new Error('Please close WoW first before verifying files.');
}
return Mods.applyAll({ repairOnly: true });
}),
observe: publicProcedure.subscription(() => Mods.observe())
});
+49
View File
@@ -0,0 +1,49 @@
import { z } from 'zod';
import fetch from 'node-fetch';
import Logger from 'electron-log/main';
import { NewsFeedSchema, type NewsItem } from '~common/schemas';
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 url = `${
import.meta.env.MAIN_VITE_FORUM_URL || 'https://octowow.st'
}/forum/octonews.php?mode=list&forum=${f}&limit=5`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
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()
);
throw Error('Malformed news feed');
}
return parsed.data.items;
} finally {
clearTimeout(t);
}
};
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;
}
})
});
+15
View File
@@ -0,0 +1,15 @@
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 { createTRPCRouter, publicProcedure } from '../trpc';
export const patcherRouter = createTRPCRouter({
apply: publicProcedure.mutation(async () => {
await patchExecutable();
await patchConfig(true);
await Updater.recordPatchedWow();
Preferences.data = { version: await getClientVersion() };
})
});
+23
View File
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { PreferencesSchema } from '~common/schemas';
import Preferences from '~main/modules/preferences';
import Updater from '~main/modules/updater';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const preferencesRouter = createTRPCRouter({
get: publicProcedure.output(PreferencesSchema).query(() => Preferences.data),
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();
return Preferences.data;
}),
isValidClientDir: publicProcedure
.input(z.string().optional())
.query(({ input }) => Preferences.isValidClientDir(input))
});
+8
View File
@@ -0,0 +1,8 @@
import SelfUpdater from '~main/modules/selfUpdater';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const selfUpdaterRouter = createTRPCRouter({
observe: publicProcedure.subscription(() => SelfUpdater.observe()),
install: publicProcedure.mutation(() => SelfUpdater.triggerInstall())
});
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
import Updater from '~main/modules/updater';
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)),
observe: publicProcedure.subscription(() => Updater.observe())
});
+18
View File
@@ -0,0 +1,18 @@
import { initTRPC } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';
const t = initTRPC.create({
transformer: superjson,
errorFormatter: ({ shape, error }) => ({
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null
}
})
});
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
+215
View File
@@ -0,0 +1,215 @@
import { join } from 'path';
import { app, shell, session, BrowserWindow, screen } from 'electron';
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 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 {
detectHardware,
recommendFarClip,
HARDWARE_SCHEMA_VERSION
} from './modules/hardware';
Logger.initialize();
Logger.errorHandler.startCatching();
Logger.transports.ipc.level = false;
Logger.info('Launcher starting...');
app.disableHardwareAcceleration();
export let mainWindow: BrowserWindow | null = null;
const isOnScreen = (
pos?: {
x: number;
y: number;
width: number;
height: number;
} | null
) => {
if (!pos) return false;
return screen.getAllDisplays().some(d => {
const a = d.workArea;
return (
pos.x < a.x + a.width &&
pos.x + pos.width > a.x &&
pos.y < a.y + a.height &&
pos.y + pos.height > a.y
);
});
};
const createWindow = async () => {
const saved =
Preferences.data.rememberPosition &&
isOnScreen(Preferences.data.windowPosition)
? Preferences.data.windowPosition
: undefined;
const position = saved ?? { width: 1000, height: 700 };
mainWindow = new BrowserWindow({
...position,
minWidth: 1000,
minHeight: 700,
icon,
frame: false,
maximizable: false,
fullscreenable: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
sandbox: false,
devTools: true
}
});
mainWindow.webContents.on('render-process-gone', (_e, details) => {
Logger.error('Renderer process gone:', details);
});
mainWindow.webContents.on('unresponsive', () => {
Logger.error('Renderer unresponsive');
});
mainWindow.webContents.on(
'console-message',
(_e, level, message, line, sourceId) => {
const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info';
Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`);
}
);
mainWindow.webContents.on('before-input-event', (_e, input) => {
if (input.type !== 'keyDown') return;
if (input.key === 'F12') {
mainWindow?.webContents.toggleDevTools();
return;
}
if ((input.control || input.meta) && input.key.toLowerCase() === 'c')
mainWindow?.webContents.copy();
});
createIPCHandler({ router: appRouter, windows: [mainWindow] });
mainWindow.on('ready-to-show', () => {
mainWindow?.show();
});
mainWindow.webContents.setWindowOpenHandler(details => {
shell.openExternal(details.url);
return { action: 'deny' };
});
mainWindow.on('close', () => {
if (!mainWindow) return;
const [x = 0, y = 0] = mainWindow.getPosition();
const [width = 0, height = 0] = mainWindow.getSize();
Preferences.data = { windowPosition: { x, y, width, height } };
});
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
};
const gotSingleInstanceLock = is.dev || app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
if (!mainWindow.isVisible()) mainWindow.show();
mainWindow.focus();
});
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({});
}
Addons.verify();
Updater.verify();
Mods.verify();
initSelfUpdater();
void (async () => {
try {
let hardware = Preferences.data.hardware;
if (!hardware || hardware.schemaVersion < HARDWARE_SCHEMA_VERSION) {
hardware = await detectHardware();
Preferences.data = { hardware };
}
const rec = recommendFarClip(hardware ?? null);
if (
Preferences.data.farClipUserSet !== true &&
Preferences.data.config.farClip !== rec
)
Preferences.data = {
config: { ...Preferences.data.config, farClip: rec }
};
} catch (e) {
Logger.error('Hardware detection / farClip recommendation failed', e);
}
})();
electronApp.setAppUserModelId('st.octowow.launcher');
if (app.isPackaged) {
const serverOrigin = new URL(
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
).origin;
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
cb({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
[
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
`img-src 'self' data: https://octowow.st https://forum.octowow.st ${serverOrigin}`,
"font-src 'self' data:",
"connect-src 'self'"
].join('; ')
]
}
});
});
}
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
});
await createWindow();
});
let settingsFlushed = false;
app.on('before-quit', event => {
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();
});
}
+450
View File
@@ -0,0 +1,450 @@
import path from 'node:path';
import git, { type ProgressCallback } from 'isomorphic-git';
import http from 'isomorphic-git/http/node';
import fs from 'fs-extra';
import fetch from 'node-fetch';
import Logger from 'electron-log/main';
import { isNotUndef } from '~common/utils';
import { type AddonData, type TocData } from '~common/schemas';
import { runWorker } from '~main/utils';
import gitPull from '~main/workers/gitPull?nodeWorker';
import gitClone from '~main/workers/gitClone?nodeWorker';
import Preferences from './preferences';
import Observable from './observable';
export type AddonsStatus = {
state: 'verifying' | 'done';
addons: { [name: string]: AddonData };
available: AddonData[];
};
type AddonsList = {
name: string;
owner: string;
branch?: string;
ref?: string;
git: string;
toc?: TocData;
description?: string;
lastUpdated?: string;
stars?: number;
dependencies?: string[];
}[];
const readTocData = (content: string) =>
(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content)
.split('\n')
.filter(l => l.startsWith('## '))
.map(l => l.slice(3))
.map(l => {
const idx = l.indexOf(':');
if (idx === -1) return null;
return [l.slice(0, idx).trim(), l.slice(idx + 1).trim()] as const;
})
.filter((e): e is readonly [string, string] => !!e)
.reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {} as TocData);
const isUnsafeFolder = (name?: string) =>
!name || name === '.' || name === '..' || /[/\\]/.test(name);
const ALLOWED_GIT_HOSTS = [
'github.com',
'gitlab.com',
'gitea.com',
'codeberg.org',
'octowow.st'
];
const isAllowedGitUrl = (url: string) => {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return false;
const host = parsed.hostname.toLowerCase();
return ALLOWED_GIT_HOSTS.some(h => host === h || host.endsWith('.' + h));
} catch {
return false;
}
};
const fetchAddons = async () => {
try {
const response = await fetch(
`${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/api/addons.json`
);
return (await response.json()) as AddonsList;
} catch (e) {
Logger.error('Failed to reach update server', e);
return [];
}
};
class AddonsClass extends Observable<AddonsStatus> {
protected _value: AddonsStatus = {
state: 'done',
addons: {},
available: []
};
get status() {
return this._value;
}
private set status(v: AddonsStatus) {
this._value = v;
this._notifyObservers(v);
}
#onProgress =
(folder: string, data: AddonData): ProgressCallback =>
progress => {
const getPhase = (step: string) => {
switch (step) {
case 'Counting objects':
return 1;
case 'Compressing objects':
return 2;
case 'Receiving objects':
return 3;
case 'Resolving deltas':
return 4;
case 'Analyzing workdir':
return 5;
case 'Updating workdir':
return 6;
default:
return 0;
}
};
this.#setAddon(folder, {
...data,
progress: `${Math.round(
(progress.loaded / (progress.total ?? progress.loaded)) * 100
)}% (${getPhase(progress.phase)}/6)`
});
};
async checkGitUrl(url: string) {
const clean = url.trim().replace(/\/+$/, '');
const gitUrl = clean.endsWith('.git') ? clean : `${clean}.git`;
if (!isAllowedGitUrl(gitUrl)) return undefined;
try {
await git.getRemoteInfo({
http,
url: gitUrl
});
let preview: string | undefined;
try {
if (isAllowedGitUrl(url)) {
const response = await fetch(url).then(r => r.text());
preview = response.match(
/property="og:image" content="([^"]*)"/
)?.[1];
}
} catch {
}
const folder = gitUrl.slice(0, -4).split('/').at(-1);
if (isUnsafeFolder(folder)) return undefined;
return {
status: 'available',
folder,
git: gitUrl,
preview
} as AddonData;
} catch {
return undefined;
}
}
async verify() {
if (this.status.state !== 'done') return;
this.status = {
...this.status,
state: 'verifying'
};
const remoteAddons = await fetchAddons();
const available: AddonData[] = remoteAddons.map(a => ({
status: 'available',
git: a.git,
toc: a.toc,
description: a.description,
folder: a.name,
branch: a.branch,
ref: a.ref
}));
const clientPath = Preferences.data.clientDir;
if (!clientPath) {
this.status = { state: 'done', addons: {}, available };
return;
}
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dirs = (await fs.pathExists(addonsPath))
? await fs.readdir(addonsPath)
: [];
const addons: AddonsStatus['addons'] = Object.fromEntries(
dirs
.filter(d => !d.startsWith('Blizzard_') && !/\.(tmp|bak)$/.test(d))
.map(name => [name, { status: 'fetching' as const, folder: name }])
);
this.status = { state: 'verifying', addons, available };
const verifyOne = async (folder: string) => {
const dir = path.join(addonsPath, folder);
if (!fs.existsSync(path.join(dir, `${folder}.toc`))) {
this.#setAddon(folder, {
status: 'invalid',
error: 'Missing .toc file',
folder
});
return;
}
const toc = await readTocData(
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
);
const remote = await git
.listRemotes({ fs, dir })
.then(r => r[0])
.catch(() => null);
const avail = remoteAddons.find(a => a.name === folder);
if (!remote) {
Logger.log(`Addon "${folder}" is not a git repository`);
this.#setAddon(
folder,
avail
? {
status: 'outOfDate',
git: avail.git,
toc,
description: avail.description,
folder
}
: { status: 'unknown', toc, folder }
);
return;
}
try {
await git.fetch({ fs, dir, http, tags: true });
const branch = await git.currentBranch({ fs, dir });
const localCommit = await git
.log({ fs, dir, ref: 'HEAD', depth: 1 })
.then(r => r[0].oid)
.catch(() => null);
const remoteCommit = avail?.ref
? await git.resolveRef({ fs, dir, ref: avail.ref }).catch(() => null)
: await git
.log({ fs, dir, ref: `${remote.remote}/${branch}`, depth: 1 })
.then(r => r[0].oid)
.catch(() => null);
const status = await git.statusMatrix({ fs, dir });
const hasChanges = status.some(
([_, HEAD, index, workdir]) => HEAD !== index || index !== workdir
);
const isUpToDate =
!hasChanges && remoteCommit && localCommit === remoteCommit;
this.#setAddon(folder, {
git: remote.url,
status: isUpToDate ? 'upToDate' : 'outOfDate',
toc,
description: avail?.description,
ref: avail?.ref,
folder
});
Logger.log(
isUpToDate
? `Addon "${folder}" is up to date${
avail?.ref ? ` (pinned ${avail.ref})` : ''
}`
: `Addon "${folder}" has an update available`
);
} catch (e) {
this.#setAddon(folder, {
git: remote.url,
status: 'invalid',
error: 'Failed to verify',
toc,
folder
});
Logger.error(`Addon "${folder}" failed to verify`, e);
}
};
const folders = Object.keys(addons);
const VERIFY_CONCURRENCY = 6;
let idx = 0;
await Promise.all(
Array.from(
{ length: Math.min(VERIFY_CONCURRENCY, folders.length) },
async () => {
while (true) {
const i = idx++;
if (i >= folders.length) return;
await verifyOne(folders[i]);
}
}
)
);
this.status = { ...this.status, state: 'done' };
}
async update(
toUpdate = Object.values(this.status.addons)
.filter(e => e.status === 'outOfDate')
.map(e => e.folder)
.filter(isNotUndef)
) {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
if (this.status.state !== 'done') return;
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
for (const folder of toUpdate) {
if (this.status.addons[folder]?.status === 'downloading') continue;
const dir = path.join(addonsPath, folder);
const avail = this.status.available.find(a => a.folder === folder);
const data: AddonData = {
...avail,
...this.status.addons[folder],
status: 'downloading'
};
this.#setAddon(folder, data);
const remote = await git
.listRemotes({ fs, dir })
.then(r => r?.[0])
.catch(() => null);
try {
if (!remote) {
await runWorker(
gitClone,
{ dir, url: data.git, ref: data.ref ?? data.branch },
{ onProgress: this.#onProgress(folder, data) }
);
} else {
const branch =
(await git.currentBranch({ fs, dir })) ?? avail?.branch ?? 'master';
await runWorker(
gitPull,
{
dir,
remote: remote.remote,
branch,
ref: avail?.ref
},
{ onProgress: this.#onProgress(folder, data) }
);
}
const toc = readTocData(
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
);
this.#setAddon(folder, { ...data, toc, status: 'upToDate' });
Logger.log(`Updated addon "${folder}"`);
} catch (e) {
this.#setAddon(folder, {
...data,
status: 'invalid',
error: 'Failed to update'
});
Logger.error(`Addon "${folder}" failed to update`, e);
}
}
}
async remove(toRemove: string[]) {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
if (this.status.state !== 'done') return;
for (const folder of toRemove) {
const dir = path.join(clientPath, 'Interface', 'Addons', folder);
if (fs.existsSync(dir)) await fs.remove(dir);
this.#setAddon(folder);
Logger.log(`Removed addon "${folder}"`);
}
}
async install(data: AddonData) {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
if (isUnsafeFolder(data.folder)) {
Logger.error(`Refusing addon with unsafe folder name: "${data.folder}"`);
this.#setAddon(data.folder, {
...data,
status: 'invalid',
error: 'Invalid addon name'
});
return;
}
if (!data.git || !isAllowedGitUrl(data.git)) {
Logger.error(`Refusing addon from disallowed git host: "${data.git}"`);
this.#setAddon(data.folder, {
...data,
status: 'invalid',
error: 'Addon URL is not from an allowed git host'
});
return;
}
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dir = path.join(addonsPath, data.folder);
try {
await runWorker(
gitClone,
{ dir, url: data.git, ref: data.ref ?? data.branch },
{ onProgress: this.#onProgress(data.folder, data) }
);
const toc = await readTocData(
await fs.readFile(path.join(dir, `${data.folder}.toc`), 'utf-8')
);
this.#setAddon(data.folder, { ...data, toc, status: 'upToDate' });
Logger.log(`Installed addon "${data.folder}"`);
} catch (e) {
this.#setAddon(data.folder, {
...data,
status: 'invalid',
error: 'Failed to install'
});
Logger.error(`Addon "${data.folder}" failed to install`, e);
}
}
#setAddon(folder: string, data?: AddonData) {
const { [folder]: _, ...addons } = this.status.addons;
this.status = {
...this.status,
addons: data ? { ...addons, [folder]: data } : addons
};
}
}
const Addons = new AddonsClass();
export default Addons;
+476
View File
@@ -0,0 +1,476 @@
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
};
};
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 });
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 (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())
);
};
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;
await fs.remove(full);
removed.push(name);
}
return removed;
} catch (e) {
Logger.warn('Prune of stale archives failed', e);
return [];
}
};
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[] = [];
for (let i = 0; i < files.length; i++) {
const f = files[i];
if (!f.path?.length || typeof f.length !== 'number') return null;
const dest = path.join(clientDir, ...f.path);
const st = await fs.stat(dest).catch(() => null);
if (!st) {
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);
}
}
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;
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',
'--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;
}
};
+192
View File
@@ -0,0 +1,192 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { app } from 'electron';
import Logger from 'electron-log/main';
import Preferences from './preferences';
export type ExclusionResult = { ok: boolean; error?: string; paths?: string[] };
const psSingleQuote = (s: string) => `'${s.replace(/'/g, "''")}'`;
export const addDefenderExclusions = async (): Promise<ExclusionResult> => {
if (os.platform() !== 'win32')
return {
ok: false,
error: 'Antivirus exclusions are only needed on Windows.'
};
const clientDir = Preferences.data.clientDir;
if (!clientDir)
return {
ok: false,
error: 'Set your game folder first, then add the exclusion.'
};
const launcherDir =
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 = [
'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')} }`,
'}'
].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 }`;
return new Promise<ExclusionResult>(resolve => {
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', outer],
{ windowsHide: true }
);
let stderr = '';
child.stderr.on('data', d => (stderr += String(d)));
child.on('error', e => {
Logger.error('Failed to launch PowerShell for Defender exclusion', e);
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') {
Logger.info(`Added Defender exclusions: ${paths.join(', ')}`);
resolve({ ok: true, paths });
return;
}
if (result === 'TAMPER') {
Logger.error('Defender exclusion blocked by Tamper Protection');
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.'
});
return;
}
if (result === 'FAIL') {
Logger.error(`Defender exclusion failed: ${stderr}`.trim());
resolve({
ok: false,
error:
'Windows would not add the exclusion. You can add your game folder by hand in Windows Security, under Exclusions.'
});
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)
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];
};
+146
View File
@@ -0,0 +1,146 @@
import os from 'node:os';
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;',
'public static class VmmfDisplays {',
' [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)]',
' public struct DISPLAY_DEVICE {',
' public int cb;',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string DeviceName;',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceString;',
' public int StateFlags;',
' [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 ","))',
'}'
].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);
const encoded = Buffer.from(SCRIPT, 'utf16le').toString('base64');
return new Promise(resolve => {
let settled = false;
const finish = (v: DisplayDevice[] | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(v);
};
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
{ windowsHide: true }
);
const timer = setTimeout(() => {
child.kill();
Logger.warn('Display enumeration timed out');
finish(null);
}, 10000);
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);
});
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);
} else {
Logger.warn('Display enumeration returned nothing usable');
finish(null);
}
});
});
};
export const detectPrimaryDisplayIndex = async (): Promise<number | null> => {
const devices = await enumerateDisplays();
return devices?.find(d => d.primary && d.attached)?.index ?? null;
};
+79
View File
@@ -0,0 +1,79 @@
import path from 'path';
import fs from 'fs-extra';
let queue: Promise<unknown> = Promise.resolve();
const serial = <T>(fn: () => Promise<T>): Promise<T> => {
const next = queue.then(fn, fn);
queue = next.catch(() => {});
return next;
};
const dllsPath = (clientDir: string) => path.join(clientDir, 'dlls.txt');
const readLines = async (clientDir: string): Promise<string[]> => {
const file = dllsPath(clientDir);
if (!(await fs.pathExists(file))) return [];
const text = await fs.readFile(file, 'utf8');
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();
export const addDll = (clientDir: string, name: string) =>
serial(async () => {
const lines = await readLines(clientDir);
if (lines.some(l => matches(l, name))) return;
lines.push(name);
await writeLines(clientDir, lines);
});
export const removeDll = (clientDir: string, name: string) =>
serial(async () => {
const lines = await readLines(clientDir);
const next = lines.filter(l => !matches(l, name));
if (next.length === lines.length) return;
await writeLines(clientDir, next);
});
export const hasDll = (clientDir: string, name: string) =>
serial(async () => {
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)));
+132
View File
@@ -0,0 +1,132 @@
import os from 'node:os';
import { spawn } from 'node:child_process';
import { app } from 'electron';
import Logger from 'electron-log/main';
import type { HardwareInfo } from '~common/schemas';
export const HARDWARE_SCHEMA_VERSION = 1;
export const FARCLIP_FLOOR = 777;
export const FARCLIP_CEILING = 3000;
const VIDEO_CLASS_GUID = '{4d36e968-e325-11ce-bfc1-08002be10318}';
const getVramMb = (): Promise<{
mb: number | null;
source: HardwareInfo['vramSource'];
}> => {
if (os.platform() !== 'win32')
return Promise.resolve({ mb: null, source: 'none' });
const script = [
'$ErrorActionPreference = "Stop"',
`$base = 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\${VIDEO_CLASS_GUID}'`,
'$max = [int64]0',
'Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {',
' $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue',
' $v = [int64]0',
" if ($p.'HardwareInformation.qwMemorySize') { $v = [int64]$p.'HardwareInformation.qwMemorySize' }",
" elseif ($p.'HardwareInformation.MemorySize') {",
" $m = $p.'HardwareInformation.MemorySize'",
' if ($m -is [byte[]]) { $v = [int64][System.BitConverter]::ToUInt32($m, 0) } else { $v = [int64]$m }',
' }',
' if ($v -gt $max) { $max = $v }',
'}',
'Write-Output $max'
].join('\n');
const encoded = Buffer.from(script, 'utf16le').toString('base64');
return new Promise(resolve => {
let settled = false;
const finish = (mb: number | null, source: HardwareInfo['vramSource']) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ mb, source });
};
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
{ windowsHide: true }
);
const timer = setTimeout(() => {
child.kill();
Logger.warn('VRAM detection timed out');
finish(null, 'none');
}, 8000);
let stdout = '';
child.stdout.on('data', d => (stdout += String(d)));
child.on('error', e => {
Logger.warn('VRAM detection failed to launch PowerShell', e);
finish(null, 'none');
});
child.on('exit', code => {
const bytes = Number(stdout.trim());
if (code === 0 && Number.isFinite(bytes) && bytes > 0)
finish(Math.round(bytes / 1024 / 1024), 'registry');
else finish(null, 'none');
});
});
};
const getGpuModel = async (): Promise<string> => {
try {
const info = (await app.getGPUInfo('complete')) as {
auxAttributes?: { glRenderer?: string };
gpuDevice?: { active?: boolean; vendorId?: number; deviceId?: number }[];
};
const renderer = info?.auxAttributes?.glRenderer?.trim();
if (renderer) return renderer;
const active = info?.gpuDevice?.find(d => d.active) ?? info?.gpuDevice?.[0];
if (active) return `vendor ${active.vendorId} device ${active.deviceId}`;
} catch (e) {
Logger.warn('GPU info detection failed', e);
}
return 'unknown';
};
export const detectHardware = async (): Promise<HardwareInfo> => {
const cpus = os.cpus();
const [vram, gpuModel] = await Promise.all([getVramMb(), getGpuModel()]);
const info: HardwareInfo = {
totalRamMb: Math.round(os.totalmem() / 1024 / 1024),
cpuCores: cpus.length,
cpuModel: cpus[0]?.model?.trim() || 'unknown',
gpuModel,
vramMb: vram.mb,
vramSource: vram.source,
detectedAt: new Date().toISOString(),
schemaVersion: HARDWARE_SCHEMA_VERSION
};
Logger.info('Detected hardware', info);
return info;
};
const clampFarClip = (n: number) =>
Math.min(FARCLIP_CEILING, Math.max(FARCLIP_FLOOR, Math.round(n)));
export const recommendFarClip = (hw: HardwareInfo | null): number => {
if (!hw) return clampFarClip(1000);
const ramGb = hw.totalRamMb / 1024;
const cores = hw.cpuCores;
const vramTrusted = hw.vramSource !== 'none' && hw.vramMb != null;
const vramGb = vramTrusted ? (hw.vramMb as number) / 1024 : null;
if (ramGb < 6 || cores <= 2) return clampFarClip(FARCLIP_FLOOR);
if (vramGb === null)
return clampFarClip(ramGb >= 8 && cores >= 4 ? 1500 : 1000);
if (ramGb < 8 || vramGb < 2) return clampFarClip(1000);
if (ramGb >= 32 && vramGb >= 8 && cores >= 8) return clampFarClip(3000);
if (ramGb >= 16 && vramGb >= 4 && cores >= 6) return clampFarClip(2200);
if (ramGb >= 8 && vramGb >= 2 && cores >= 4) return clampFarClip(1500);
return clampFarClip(1000);
};
+60
View File
@@ -0,0 +1,60 @@
import path from 'node:path';
import fs from 'fs-extra';
import {
SFileOpenArchive,
SFileCloseArchive,
SFileHasFile
} from 'stormlib-node';
import { STREAM_FLAG } from 'stormlib-node/dist/enums';
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 MARKER = 'octolocale.marker';
const patchFile = (dataDir: string, letter: string) =>
path.join(dataDir, `patch-${letter}.mpq`);
const isOurPatch = (mpqPath: string): boolean => {
if (!fs.existsSync(mpqPath)) return false;
try {
const h = SFileOpenArchive(mpqPath, STREAM_FLAG.READ_ONLY);
try {
return SFileHasFile(h, MARKER);
} finally {
SFileCloseArchive(h);
}
} catch {
return false;
}
};
// 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;
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);
}
}
// clear the stale tracking keys
if (Preferences.data.localePatchLetter || Preferences.data.localePatchLocale)
Preferences.data = {
localePatchLetter: undefined,
localePatchLocale: undefined
};
};
+665
View File
@@ -0,0 +1,665 @@
import path from 'path';
import { createHash } from 'crypto';
import fs from 'fs-extra';
import fetch from 'node-fetch';
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 { type ModState } from '~common/schemas';
import Preferences from './preferences';
import { isTorrentMode } from './aria2';
import Observable from './observable';
import Updater from './updater';
import { addDll, removeDll, listDlls } from './dllsTxt';
import { enumerateDisplays } 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.';
const looksLikeAvBlock = (msg: string) =>
/windows defender|virus|potentially unwanted/i.test(msg);
export type ModRowStatus = {
id: ModId;
name: string;
description: string;
repoUrl: string;
recommended: boolean;
requires: ModId[];
enabled: boolean;
ignoreUpdates: boolean;
installedVersion?: string;
latestVersion: string;
state: 'idle' | 'downloading' | 'installing' | 'uninstalling' | 'error';
progress?: number;
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: []
};
// 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;
}
#initialRow(m: ModEntry): ModRowStatus {
const state = Preferences.data?.mods?.[m.id];
return {
id: m.id,
name: m.name,
description: m.description,
repoUrl: m.repoUrl,
recommended: !!m.recommended,
requires: m.requires ?? [],
enabled: !!state?.enabled,
ignoreUpdates: !!state?.ignoreUpdates,
installedVersion: state?.installedVersion,
latestVersion: m.version,
state: 'idle'
};
}
#patchRow(id: ModId, patch: Partial<ModRowStatus>) {
this._value = {
...this._value,
mods: this._value.mods.map(r => (r.id === id ? { ...r, ...patch } : r))
};
this._value = { ...this._value, dirty: this.#computeDirty() };
this._notifyObservers();
}
#computeDirty(): boolean {
if (this.#customDesired.size > 0) return true;
return this._value.mods.some(r => {
const wantInstalled = r.enabled;
const isInstalled = !!r.installedVersion;
if (wantInstalled !== isInstalled) return true;
if (
r.installedVersion &&
r.installedVersion !== r.latestVersion &&
!r.ignoreUpdates
)
return true;
return false;
});
}
load() {
this._value = {
state: 'verifying',
dirty: false,
mods: MODS.filter(m => !m.disabled).map(m => this.#initialRow(m)),
custom: this._value.custom,
missingFiles: []
};
}
// 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();
const clientDir = Preferences.data?.clientDir;
if (clientDir) {
await this.#syncPreferredMonitor(clientDir);
}
const missing: string[] = [];
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 files = modTargetFiles(m);
const present =
!!clientDir &&
files.length > 0 &&
(
await Promise.all(
files.map(rel => fs.pathExists(path.join(clientDir, rel)))
)
).every(Boolean);
const enabled = state?.enabled ?? DEFAULT_ENABLED_MODS.includes(m.id);
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 =>
fs.pathExists(path.join(clientDir, rel))
)
);
if (state?.installedFiles?.length && !filesPresent.every(Boolean)) {
installedVersion = undefined;
await this.#savePref(m.id, {
enabled: state?.enabled ?? false,
installedVersion: undefined,
installedFiles: [],
ignoreUpdates: state?.ignoreUpdates ?? false
});
}
}
if (clientDir && m.registerInDllsTxt)
await (installedVersion
? addDll(clientDir, m.registerInDllsTxt)
: removeDll(clientDir, m.registerInDllsTxt)
).catch(() => {});
this.#patchRow(m.id, {
installedVersion,
latestVersion: m.version,
enabled: !!state?.enabled,
ignoreUpdates: !!state?.ignoreUpdates
});
}
this._value = {
...this._value,
state: 'idle',
dirty: this.#computeDirty(),
custom: clientDir ? await this.#detectCustomDlls(clientDir) : [],
missingFiles: missing
};
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, {
enabled,
installedVersion: cur?.installedVersion,
installedFiles: cur?.installedFiles ?? [],
ignoreUpdates: cur?.ignoreUpdates ?? false
});
this.#patchRow(id, { enabled });
}
async setIgnoreUpdates(id: ModId, ignore: boolean) {
const cur = Preferences.data?.mods?.[id];
await this.#savePref(id, {
enabled: cur?.enabled ?? false,
installedVersion: cur?.installedVersion,
installedFiles: cur?.installedFiles ?? [],
ignoreUpdates: ignore
});
this.#patchRow(id, { ignoreUpdates: ignore });
}
async applyAll(opts: { repairOnly?: boolean } = {}) {
const clientDir = Preferences.data?.clientDir;
if (!clientDir) {
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; just reconcile dlls.txt
if (isTorrentMode()) {
await this.verify();
return;
}
if (this._value.state === 'busy') {
Logger.warn('applyAll already running; ignoring re-entrant call.');
return;
}
await this.verify();
this._value = { ...this._value, state: 'busy' };
this._notifyObservers();
const queue = [...this._value.mods];
queue.sort((a, b) => {
if (a.id === 'vanillaFixes') return -1;
if (b.id === 'vanillaFixes') return 1;
return 0;
});
const failures = new Map<ModId, string>();
for (const row of queue) {
const m = getMod(row.id);
if (!m) continue;
const wantInstalled = row.enabled;
const isInstalled = !!row.installedVersion;
const updateAvailable =
isInstalled &&
row.installedVersion !== row.latestVersion &&
!row.ignoreUpdates;
try {
if (wantInstalled && !isInstalled) {
await this.#install(m);
} else if (!wantInstalled && isInstalled) {
await this.#uninstall(m);
} else if (wantInstalled && updateAvailable && !opts.repairOnly) {
await this.#uninstall(m);
await this.#install(m);
}
} catch (e) {
Logger.error(`Failed to apply ${m.id}:`, e);
const msg = e instanceof Error ? e.message : String(e);
failures.set(m.id, looksLikeAvBlock(msg) ? AV_ERROR : msg);
}
}
this._value = { ...this._value, state: 'idle' };
await this.verify();
for (const [id, error] of failures)
this.#patchRow(id, { state: 'error', error });
await Updater.verify();
}
async #install(m: ModEntry) {
// In torrent mode the mod binaries ship with the client; nothing is fetched.
if (isTorrentMode()) return;
const clientDir = Preferences.data?.clientDir;
if (!clientDir) throw new Error('No client dir');
if (m.source.kind === 'managed') return;
Logger.info(`Installing mod ${m.id}...`);
this.#patchRow(m.id, {
state: 'downloading',
progress: 0,
error: undefined
});
const written: string[] = [];
const missing: string[] = [];
if (m.source.kind === 'directFile') {
const dest = path.join(clientDir, m.source.assetName);
await this.#downloadTo(m.source.url, dest, m.source.sha256);
written.push(m.source.assetName);
} else if (m.source.kind === 'archive') {
const scratch = path.join(clientDir, '.octolauncher-tmp');
await fs.ensureDir(scratch);
const tmp = path.join(
scratch,
`${m.id}-${Date.now()}.${m.source.format}`
);
await this.#downloadTo(m.source.url, tmp, m.source.sha256);
this.#patchRow(m.id, { state: 'installing' });
const map = m.source.extractMap;
if (m.source.format === 'zip') {
const zip = new AdmZip(tmp);
const entries = zip.getEntries();
for (const [src, dst] of Object.entries(map)) {
const entry = entries.find(e => e.entryName === src);
if (!entry) {
missing.push(src);
continue;
}
const target = path.join(clientDir, dst);
await fs.ensureDir(path.dirname(target));
await fs.writeFile(target, entry.getData());
written.push(dst);
}
} else {
const stagingDir = path.join(scratch, `${m.id}-${Date.now()}-extract`);
await fs.ensureDir(stagingDir);
await tar.x({ file: tmp, cwd: stagingDir });
for (const [src, dst] of Object.entries(map)) {
const srcPath = path.join(stagingDir, src);
if (!(await fs.pathExists(srcPath))) {
missing.push(src);
continue;
}
const target = path.join(clientDir, dst);
await fs.ensureDir(path.dirname(target));
await fs.copy(srcPath, target);
written.push(dst);
}
await fs.remove(stagingDir).catch(() => {});
}
await fs.remove(tmp).catch(() => {});
}
if (missing.length)
throw new Error(
`${m.name}: download is missing expected file(s): ${missing.join(', ')}`
);
if (m.registerInDllsTxt) {
await addDll(clientDir, m.registerInDllsTxt);
}
await this.#savePref(m.id, {
enabled: true,
installedVersion: m.version,
installedFiles: written,
ignoreUpdates: Preferences.data?.mods?.[m.id]?.ignoreUpdates ?? false
});
this.#patchRow(m.id, {
state: 'idle',
installedVersion: m.version,
progress: 1
});
}
async #uninstall(m: ModEntry) {
const clientDir = Preferences.data?.clientDir;
if (!clientDir) throw new Error('No client dir');
if (m.source.kind === 'managed') return;
Logger.info(`Uninstalling mod ${m.id}...`);
this.#patchRow(m.id, { state: 'uninstalling', error: undefined });
const cur = Preferences.data?.mods?.[m.id];
const files = cur?.installedFiles ?? [];
for (const rel of files) {
const fullPath = path.join(clientDir, rel);
await fs
.remove(fullPath)
.catch(err => Logger.warn(`Couldn't remove ${fullPath}:`, err));
}
if (m.registerInDllsTxt) {
await removeDll(clientDir, m.registerInDllsTxt);
}
await this.#savePref(m.id, {
enabled: cur?.enabled ?? false,
installedVersion: undefined,
installedFiles: [],
ignoreUpdates: cur?.ignoreUpdates ?? false
});
this.#patchRow(m.id, { state: 'idle', installedVersion: undefined });
}
async #downloadTo(url: string, dest: string, sha256?: string) {
const res = await fetch(url, {
headers: { 'User-Agent': 'OctoLauncher' },
timeout: MOD_DOWNLOAD_TIMEOUT_MS
});
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(
`Downloaded file disappeared after writing: ${path.basename(dest)}. ` +
'This is often Windows Defender quarantine; if so, use "Allow through antivirus" and apply again.'
);
}
async #savePref(id: ModId, state: ModState) {
const allMods = { ...(Preferences.data?.mods ?? {}), [id]: state };
Preferences.data = { mods: allMods };
}
}
const Mods = new ModsClass();
export default Mods;
+37
View File
@@ -0,0 +1,37 @@
import { observable } from '@trpc/server/observable';
type Func<T> = (arg: T) => void;
abstract class Observable<T> {
private _listeners: Func<T>[] = [];
protected abstract _value: T;
protected _notifyObservers(v = this._value) {
this._listeners = this._listeners.filter(l => {
try {
l(v);
return true;
} catch (err) {
console.error('Observer threw, removing listener', err);
return false;
}
});
}
observe() {
return observable<T>(e => {
e.next(this._value);
this._listeners.push(e.next);
return () => {
this._listeners = this._listeners.filter(v => v !== e.next);
};
});
}
clearObservers() {
this._listeners = [];
}
}
export default Observable;
+467
View File
@@ -0,0 +1,467 @@
import path from 'path';
import { screen } from 'electron';
import fs from 'fs-extra';
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';
const Servers = {
live: {
realmList: 'octowow.st',
patchList: 'octowow.st',
realmName: 'OctoWoW'
},
ptr: {
realmList: import.meta.env.MAIN_VITE_PTR_REALMLIST || 'octowow.st',
patchList: import.meta.env.MAIN_VITE_PTR_REALMLIST || '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 };
type Tweak = TweakKey & {
default?: unknown;
forced?: boolean;
} & (
| {
type: 'bytes';
tweaks: [number, number[], number[]?][];
}
| {
type: 'int8' | 'uint16' | 'float';
offset: number;
value?: number;
}
);
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;
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];
const Tweaks = [
{
key: 'largeAddress',
type: 'uint16',
offset: 0x126,
value: buffer.readUint16LE(0x126) | 0x20,
default: false
},
{ key: 'farClip', type: 'float', offset: 0x40fed8 },
{
key: 'fieldOfView',
type: 'float',
offset: 0x4089b4,
value: (config.fieldOfView ?? 1) * (Math.PI / 180),
default: 90
},
{ key: 'frillDistance', type: 'float', offset: 0x467958 },
{
key: 'soundInBackground',
type: 'int8',
offset: 0x3a4869,
value: config.soundInBackground ? 0x27 : 0x14,
default: false
},
{
key: 'alwaysAutoLoot',
type: 'bytes',
tweaks: [
[0x0c1ecf, [0x75]],
[0x0c2b25, [0x75]]
]
},
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 },
{ key: 'cameraDistance', type: 'float', offset: 0x4089a4 },
{
synthetic: true,
key: 'skillUiGateHijack',
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
0x002ddf90,
[
0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56, 0x57, 0x8b, 0x3d,
0x60, 0xab, 0xce, 0x00, 0x83, 0xff, 0xff, 0x89, 0x55, 0xfc, 0x89,
0x4d, 0xf8, 0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58, 0xab,
0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8b, 0x4c,
0x82, 0x08, 0xf6, 0xc1, 0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04,
0x85, 0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00, 0xf6, 0xc1,
0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74, 0x4a, 0x39, 0x31, 0x74, 0x13,
0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b,
0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0, 0x8b, 0x59, 0x1c,
0x8b, 0x71, 0x18, 0x33, 0xff, 0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64,
0x24, 0x00, 0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00, 0x6a,
0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b, 0x55, 0xfc, 0xe8, 0xb9,
0xfd, 0xff, 0xff, 0x84, 0xc0, 0x75, 0x13, 0x47, 0x83, 0xc6, 0x20,
0x3b, 0xfb, 0x7c, 0xdd, 0x5f, 0x5e, 0x33, 0xc0, 0x5b, 0x8b, 0xe5,
0x5d, 0xc2, 0x04, 0x00, 0x5f, 0x8b, 0xc6, 0x5e, 0x5b, 0x8b, 0xe5,
0x5d, 0xc2, 0x04, 0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
]
]
]
},
{
synthetic: true,
key: 'octowowUrlAllowlist',
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
0x45ccd8,
[
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[];
Tweaks.forEach(t => {
const val = t.synthetic
? t.default
: config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key];
Logger.log(`Applying "${t.key}" patch with value: ${val}`);
if (t.type === 'float') {
buffer.writeFloatLE(t.value ?? (val as number), t.offset);
} else if (t.type === 'int8') {
buffer.writeInt8(t.value ?? (val as number), t.offset);
} else if (t.type === 'uint16') {
if (!t.forced && !val) return;
buffer.writeUInt16LE(t.value ?? (val as number), t.offset);
} else if (t.type === 'bytes') {
if (!t.forced && !val) 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.'
);
}
);
}
});
await fs.writeFile(exePath, buffer);
Preferences.data = { patchedLocale: locale };
Logger.log(`WoW.exe successfully patched (language: ${locale})`);
} 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) => {
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)}`);
}
}
};
export const patchConfig = async (forceTweaks = false) => {
const { clientDir, 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))
? await fs.readFile(configPath, { encoding: 'utf-8' })
: '';
const configWtf = Object.fromEntries(
raw
.split(/\r?\n/)
.map(l => {
const [, k, v] = l.match(/SET (\w+) "(.*)"/) ?? [];
return !k || v === undefined ? undefined : [k, v];
})
.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),
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,
...configWtf,
...repaired,
...owned,
...(forceTweaks
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance }
: {})
};
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');
};
+270
View File
@@ -0,0 +1,270 @@
import path from 'path';
import fs from 'fs-extra';
import { type z } from 'zod';
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;
const mods = { ...data.mods };
for (const id of DEFAULT_ENABLED_MODS)
mods[id] = { enabled: true, installedFiles: [], ignoreUpdates: false };
Logger.info(
`Fresh install: enabling ${DEFAULT_ENABLED_MODS.join(', ')} by default`
);
return { ...data, mods };
}
static async load() {
this.#freshInstall = await this.#detectFreshInstall();
await fs.ensureDir(this.userDataDir);
const settingsPath = this.#settingsPath;
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>;
}
}
}
const merged = dropUndefined({
...json,
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
});
const parsed = PreferencesSchema.safeParse(merged);
if (parsed.success)
return this.#withKnownClientDir(
this.#withFreshInstallDefaults(parsed.data)
);
Logger.warn(
'settings.json failed validation; salvaging valid fields',
parsed.error
);
await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {});
const salvaged: Record<string, unknown> = dropUndefined({
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)
});
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 };
}
static get data(): PreferencesSchema {
return this.#data;
}
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 snapshot = dropUndefined(
omit(this.#data, dropped as (keyof PreferencesSchema)[])
);
this.#writeChain = this.#writeChain
.then(async () => {
let base: Record<string, unknown> | null = 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;
}
}
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)
);
}
})
.catch(e => Logger.error('Failed to persist settings.json', e));
}
static save() {
return this.#writeChain;
}
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;
}
}
export default Preferences;
+122
View File
@@ -0,0 +1,122 @@
import { app } from 'electron';
import { autoUpdater } from 'electron-updater';
import Logger from 'electron-log/main';
import { is } from '@electron-toolkit/utils';
import Observable from './observable';
export type SelfUpdaterStatus =
| { state: 'idle'; currentVersion: string }
| { state: 'checking'; currentVersion: string }
| { state: 'unavailable'; currentVersion: string }
| { state: 'available'; currentVersion: string; nextVersion: string }
| {
state: 'downloading';
currentVersion: string;
nextVersion: string;
progress: number;
}
| { state: 'ready'; currentVersion: string; nextVersion: string }
| { state: 'error'; currentVersion: string; message: string };
class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
protected _value: SelfUpdaterStatus = {
state: 'idle',
currentVersion: app.getVersion()
};
#initialized = false;
#nextVersion: string | undefined;
get status(): SelfUpdaterStatus {
return this._value;
}
private set status(v: SelfUpdaterStatus) {
this._value = v;
this._notifyObservers();
}
init() {
if (this.#initialized) return;
this.#initialized = true;
if (is.dev) {
Logger.info('[selfUpdater] dev mode, skipping');
return;
}
const currentVersion = app.getVersion();
autoUpdater.logger = Logger;
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.on('checking-for-update', () => {
Logger.info('[selfUpdater] checking');
this.status = { state: 'checking', currentVersion };
});
autoUpdater.on('update-available', info => {
Logger.info(`[selfUpdater] update available: ${info.version}`);
this.#nextVersion = info.version;
this.status = {
state: 'available',
currentVersion,
nextVersion: info.version
};
});
autoUpdater.on('update-not-available', info => {
Logger.info(`[selfUpdater] up to date (current: ${info.version})`);
this.status = { state: 'unavailable', currentVersion };
});
autoUpdater.on('error', err => {
Logger.error('[selfUpdater] error', err);
this.status = {
state: 'error',
currentVersion,
message: err?.message ?? String(err)
};
});
autoUpdater.on('download-progress', p => {
Logger.info(`[selfUpdater] downloading ${Math.round(p.percent)}%`);
this.status = {
state: 'downloading',
currentVersion,
nextVersion: this.#nextVersion ?? '',
progress: Math.max(0, Math.min(1, p.percent / 100))
};
});
autoUpdater.on('update-downloaded', info => {
Logger.info(
`[selfUpdater] downloaded ${info.version}, awaiting user click`
);
this.status = {
state: 'ready',
currentVersion,
nextVersion: info.version
};
});
autoUpdater.checkForUpdates().catch(err => {
Logger.error('[selfUpdater] checkForUpdates failed', err);
});
}
triggerInstall() {
if (this._value.state !== 'ready') {
Logger.warn(
`[selfUpdater] triggerInstall called in state ${this._value.state}, ignoring`
);
return;
}
Logger.info(
'[selfUpdater] user clicked install, quitting + running installer'
);
autoUpdater.quitAndInstall(false, true);
}
}
const SelfUpdater = new SelfUpdaterClass();
export default SelfUpdater;
export const initSelfUpdater = () => SelfUpdater.init();
+53
View File
@@ -0,0 +1,53 @@
import { Tray, Menu, nativeImage, app } from 'electron';
import Logger from 'electron-log/main';
import icon from '~build/icon.png?asset';
import { mainWindow } from '~main/index';
let tray: Tray | null = null;
let isMinimizedToTray = false;
const restoreWindow = () => {
if (!mainWindow) return;
mainWindow.show();
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
isMinimizedToTray = false;
};
const ensureTray = () => {
if (tray) return tray;
const trayIcon = nativeImage.createFromPath(icon).resize({ width: 16, height: 16 });
tray = new Tray(trayIcon);
tray.setToolTip('OctoLauncher');
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Show launcher', click: restoreWindow },
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() }
])
);
tray.on('click', restoreWindow);
return tray;
};
export const minimizeToTray = () => {
if (!mainWindow) return;
ensureTray();
mainWindow.hide();
isMinimizedToTray = true;
Logger.info('Minimized to tray');
};
export const restoreFromTray = () => {
if (!isMinimizedToTray) return;
restoreWindow();
};
export const isInTray = () => isMinimizedToTray;
export const destroyTray = () => {
tray?.destroy();
tray = null;
};
+542
View File
@@ -0,0 +1,542 @@
import path from 'node:path';
import crypto from 'node:crypto';
import { exec } from 'node:child_process';
import os from 'node:os';
import { app } from 'electron';
import fetch from 'node-fetch';
import fs from 'fs-extra';
import Logger from 'electron-log/main';
import { nestedGet, nestedSet } from '~common/utils';
import { mainWindow } from '~main/index';
import { getClientVersion } from '~main/utils';
import {
torrentUrl,
fetchTorrentSha,
syncClient,
refreshPristineWow,
clearTorrentResumeState,
raidVisualsUrl,
clientPatchUrl,
pruneStaleArchives,
torrentTreeIntact,
torrentDownloadSelection,
startSeeding,
stopSeeding
} from '~main/modules/aria2';
import Preferences from './preferences';
import Observable from './observable';
type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes' | 'raidVisuals';
type FileManifest = { name: string } & (
| { type: 'del' }
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
| { type: 'mpq'; files: FileManifest[]; hash: string; size: number }
| {
type: 'file';
hash: string;
version?: number;
size: number;
tags?: FileTags[];
}
);
type CacheEntry = [hash: string, mtime: number];
type CacheTree = { [key: string]: CacheTree & CacheEntry };
const getManifestItem = (
m?: FileManifest,
p?: string[]
): FileManifest | undefined => {
if (!p?.length) return m;
if (m?.type === 'file' || m?.type === 'del')
throw Error(`Can't access ${p.join('.')} from file ${m.name}`);
const [next, ...rest] = p;
return getManifestItem(
m?.files.find(f => f.name === next),
rest
);
};
const ownedDataArchives = async (): Promise<Set<string>> => {
try {
const j = await fs.readJSON(
path.join(Preferences.userDataDir, 'manifest.json')
);
const data = getManifestItem(j?.root ?? j, ['Data']);
if (!data || data.type === 'file' || data.type === 'del') return new Set();
return new Set(
data.files
.filter(f => f.type !== 'dir' && /\.mpq$/i.test(f.name))
.map(f => f.name.toLowerCase())
);
} catch {
return new Set();
}
};
export const isGameRunning = (executablePath: string) =>
os.platform() === 'win32'
? new Promise<boolean>(resolve => {
const exeName = path.basename(executablePath);
exec(
`tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH`,
(error, stdout) => {
if (error) {
Logger.warn(
`tasklist probe for "${exeName}" failed; assuming game ` +
`is not running. Error: ${error.message}`
);
resolve(false);
return;
}
resolve(
stdout.toLowerCase().includes(`"${exeName.toLowerCase()}"`)
);
}
);
})
: false;
type UpdaterState =
| 'verifying'
| 'serverUnreachable'
| 'noClient'
| 'updateAvailable'
| 'updating'
| 'upToDate'
| 'failed';
export type UpdaterStatus = {
state: UpdaterState;
progress?: number;
message?: string;
bytesDone?: number;
bytesTotal?: number;
bytesPerSecond?: number;
etaSeconds?: number;
};
const SIDECAR_TIMEOUT_MS = 30_000;
class UpdaterClass extends Observable<UpdaterStatus> {
#cachePath = path.join(Preferences.userDataDir, 'cache.json');
#cache: CacheTree = this.#readCache();
#readCache(): CacheTree {
try {
return fs.existsSync(this.#cachePath)
? fs.readJSONSync(this.#cachePath)
: {};
} catch {
return {};
}
}
async #saveCache() {
await fs.writeJSON(this.#cachePath, this.#cache);
}
async #getHash(clientPath: string, ...filePath: string[]) {
if (!(await fs.exists(path.join(clientPath, ...filePath)))) {
nestedSet(this.#cache, filePath, undefined);
return undefined;
}
const stats = await fs.stat(path.join(clientPath, ...filePath));
if (stats.isDirectory())
throw Error(`Tried to get hash of directory ${path.join(...filePath)}`);
const c = nestedGet<CacheEntry>(this.#cache, filePath);
if (c?.[0] && c[1] === stats.mtimeMs) return c[0];
const newHash = crypto
.createHash('sha1')
.update(await fs.readFile(path.join(clientPath, ...filePath)))
.digest('hex')
.toLocaleUpperCase();
nestedSet(this.#cache, filePath, {
...c,
[0]: newHash,
[1]: stats.mtimeMs
});
return newHash;
}
protected _value: UpdaterStatus = { state: 'failed' };
get status() {
return this._value;
}
private set status(v: UpdaterStatus) {
this._value = v;
this._notifyObservers(v);
if (this.status.state === 'failed') {
mainWindow?.setProgressBar(1, { mode: 'error' });
} else if (this.status.progress === 1) {
mainWindow?.setProgressBar(0);
} else {
mainWindow?.setProgressBar(this.status.progress ?? 0, {
mode: this.status.progress === -1 ? 'indeterminate' : 'normal'
});
}
}
async refreshSeeding() {
if (
Preferences.data.shareDownloads !== false &&
Preferences.data.clientDir &&
this.status.state === 'upToDate'
)
await startSeeding(Preferences.data.clientDir);
else stopSeeding();
}
async #torrentVerify(clientPath: string) {
const url = torrentUrl();
if (!url) {
this.status = { state: 'serverUnreachable' };
return;
}
this.status = {
state: 'verifying',
progress: -1,
message: 'Checking for updates...'
};
try {
const sha = await fetchTorrentSha(url);
await this.#reconcileClientPatch(clientPath);
await this.#reconcileRaidVisuals(clientPath);
const haveExe = await fs.pathExists(path.join(clientPath, 'WoW.exe'));
if (
haveExe &&
sha !== Preferences.data.syncedTorrentHash &&
(await this.#torrentFastForward(clientPath, sha, url))
) {
this.status = { state: 'upToDate', progress: 1 };
await this.refreshSeeding();
return;
}
const upToDate =
haveExe &&
sha === Preferences.data.syncedTorrentHash &&
(await torrentTreeIntact(clientPath, url));
if (upToDate) {
const removed = await pruneStaleArchives(clientPath, url, new Set());
if (removed.length)
Logger.log(`Removed stale archives: ${removed.join(', ')}`);
}
this.status = upToDate
? { state: 'upToDate', progress: 1 }
: { state: 'updateAvailable' };
await this.refreshSeeding();
} catch (e) {
Logger.error('Torrent verify failed', e);
this.status = { state: 'serverUnreachable' };
}
}
async #fetchSidecarFile(
url: string,
dest: string,
storedHash: string | undefined,
busyMessage: string
): Promise<string | undefined> {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), SIDECAR_TIMEOUT_MS);
const sha = await fetch(`${url}.sha256`, { signal: ac.signal })
.then(r => {
if (!r.ok) throw new Error(`sha256 HTTP ${r.status}`);
return r.text();
})
.then(s => s.trim())
.finally(() => clearTimeout(t));
if ((await fs.pathExists(dest)) && storedHash === sha) return undefined;
this.status = { state: 'updating', progress: -1, message: busyMessage };
const buf = await this.#downloadWithIdleTimeout(url);
if (crypto.createHash('sha256').update(buf).digest('hex') !== sha)
throw new Error('checksum mismatch');
await fs.ensureDir(path.dirname(dest));
await fs.writeFile(`${dest}.part`, buf);
await fs.move(`${dest}.part`, dest, { overwrite: true });
return sha;
}
async #downloadWithIdleTimeout(url: string): Promise<Buffer> {
const ac = new AbortController();
let idle: ReturnType<typeof setTimeout> | undefined;
const arm = () => {
if (idle) clearTimeout(idle);
idle = setTimeout(() => ac.abort(), SIDECAR_TIMEOUT_MS);
};
arm();
try {
const res = await fetch(url, { signal: ac.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = res.body as NodeJS.ReadableStream | null;
if (!body) throw new Error('empty response body');
const chunks: Buffer[] = [];
for await (const chunk of body) {
arm();
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
} finally {
if (idle) clearTimeout(idle);
}
}
async #reconcileClientPatch(clientPath: string) {
const url = clientPatchUrl();
if (!url) return;
const dest = path.join(clientPath, 'Data', 'patch-5.mpq');
try {
const sha = await this.#fetchSidecarFile(
url,
dest,
Preferences.data.clientPatchHash,
'Updating game files...'
);
if (sha) Preferences.data = { clientPatchHash: sha };
} catch (e) {
Logger.warn('Content patch reconcile failed', e);
if (!(await fs.pathExists(dest)))
throw new Error(
'Could not download core game content. Please check your connection and try again.'
);
}
}
async #reconcileRaidVisuals(clientPath: string) {
const url = raidVisualsUrl();
if (!url) return;
const dest = path.join(clientPath, 'Data', 'patch-O.mpq');
try {
if (!Preferences.data.config?.raidVisuals) {
if (await fs.pathExists(dest)) {
await fs.remove(dest);
Preferences.data = { raidVisualsHash: undefined };
}
return;
}
const sha = await this.#fetchSidecarFile(
url,
dest,
Preferences.data.raidVisualsHash,
'Updating raid visuals...'
);
if (sha) Preferences.data = { raidVisualsHash: sha };
} catch (e) {
Logger.warn('Raid visuals reconcile failed', e);
}
}
async syncRaidVisuals() {
if (this.status?.state === 'verifying' || this.status?.state === 'updating')
return;
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
if (await isGameRunning(path.join(clientPath, 'WoW.exe'))) {
this.status = {
state: 'failed',
message: 'Please close WoW first, before updating.'
};
return;
}
if (!torrentUrl()) return this.verify();
await this.#reconcileRaidVisuals(clientPath);
this.status = { state: 'upToDate', progress: 1 };
}
async #torrentFastForward(
clientPath: string,
sha: string,
url: string
): Promise<boolean> {
if (!Preferences.data.syncedTorrentHash) return false;
if (!(await torrentTreeIntact(clientPath, url))) return false;
await refreshPristineWow(clientPath);
const removed = await pruneStaleArchives(
clientPath,
url,
await ownedDataArchives()
);
if (removed.length)
Logger.log(`Removed stale archives: ${removed.join(', ')}`);
Preferences.data = {
syncedTorrentHash: sha,
version: await getClientVersion()
};
return true;
}
async #torrentUpdate(clientPath: string, clean?: boolean) {
const url = torrentUrl();
if (!url) {
this.status = { state: 'serverUnreachable' };
return;
}
try {
stopSeeding();
const sha = await fetchTorrentSha(url);
if (!clean && (await this.#torrentFastForward(clientPath, sha, url))) {
await this.#reconcileClientPatch(clientPath);
await this.#reconcileRaidVisuals(clientPath);
this.status = { state: 'upToDate', progress: 1 };
await this.refreshSeeding();
return;
}
const isUpdate = await fs.pathExists(path.join(clientPath, 'WoW.exe'));
const staleContext =
Preferences.data.activeTorrentHash !== sha ||
Preferences.data.activeClientDir !== clientPath;
if (staleContext) {
await clearTorrentResumeState();
Preferences.data = {
activeTorrentHash: sha,
activeClientDir: clientPath
};
}
const selection = clean
? null
: await torrentDownloadSelection(clientPath, url, staleContext);
const selectFiles =
selection && selection.length > 0 ? selection : undefined;
this.status = {
state: 'updating',
progress: -1,
message: clean
? 'Verifying game files...'
: isUpdate
? 'Connecting...'
: 'Preparing download...'
};
let downloading = false;
await syncClient({
torrentUrl: url,
clientDir: clientPath,
checkIntegrity: !!clean,
selectFiles,
seedTime: 0,
onProgress: p => {
if (p.bytesPerSecond > 0) downloading = true;
const phase = downloading
? 'Downloading'
: clean
? 'Verifying game files'
: isUpdate
? 'Connecting'
: 'Preparing';
this.status = {
state: 'updating',
progress: p.progress,
bytesDone: p.bytesDone,
bytesTotal: p.bytesTotal,
bytesPerSecond: p.bytesPerSecond,
message: `${phase}...`
};
}
});
if (!(await torrentTreeIntact(clientPath, url))) {
this.status = {
state: 'updateAvailable',
message: 'Download incomplete. Click update to finish.'
};
return;
}
await refreshPristineWow(clientPath);
const removed = await pruneStaleArchives(
clientPath,
url,
await ownedDataArchives()
);
if (removed.length)
Logger.log(`Removed stale archives: ${removed.join(', ')}`);
await this.#reconcileClientPatch(clientPath);
await this.#reconcileRaidVisuals(clientPath);
Preferences.data = {
syncedTorrentHash: sha,
version: await getClientVersion()
};
this.status = { state: 'upToDate', progress: 1 };
await this.refreshSeeding();
} catch (e) {
Logger.error('Torrent update failed', e);
this.status = {
state: 'failed',
message: e instanceof Error ? e.message : 'Download failed'
};
}
}
async verify() {
if (this.status?.state === 'verifying' || this.status?.state === 'updating')
return;
const clientPath = Preferences.data.clientDir;
if (!clientPath) {
this.status = { state: 'noClient' };
return;
}
if (os.platform() === 'win32' && clientPath.length > 220) {
this.status = {
state: 'failed',
message:
'Path to current install location is too long and may cause issues.'
};
return;
}
if (await isGameRunning(path.join(clientPath, 'WoW.exe'))) {
this.status = {
state: 'failed',
message: 'Please close WoW first, before updating.'
};
return;
}
return this.#torrentVerify(clientPath);
}
async update(clean?: boolean) {
if (this.status?.state === 'verifying' || this.status?.state === 'updating')
return;
const clientPath = Preferences.data.clientDir;
if (!clientPath) {
this.status = { state: 'noClient' };
return;
}
if (await isGameRunning(path.join(clientPath, 'WoW.exe'))) {
this.status = {
state: 'failed',
message: 'Please close WoW first, before updating.'
};
return;
}
return this.#torrentUpdate(clientPath, clean);
}
async recordPatchedWow() {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
const patchedWowHash = await this.#getHash(clientPath, 'WoW.exe');
await this.#saveCache();
Preferences.data = {
lastPatchedLauncherVersion: app.getVersion(),
expectedPatchedWowHash: patchedWowHash
};
}
}
const Updater = new UpdaterClass();
export default Updater;
+374
View File
@@ -0,0 +1,374 @@
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;
}
};
+13
View File
@@ -0,0 +1,13 @@
export { type AppRouter } from './api/root';
export { type UpdaterStatus } from './modules/updater';
export { type AddonsStatus, type AddonData } from './modules/addons';
export {
type ModsStatus,
type ModRowStatus,
type CustomMod
} from './modules/mods';
export {
type NewsItem,
type NewsFeed,
type ForumAnnouncement
} from '../common/schemas';
+69
View File
@@ -0,0 +1,69 @@
import { type Worker, type WorkerOptions } from 'node:worker_threads';
import path from 'node:path';
import Logger from 'electron-log/main';
import fs from 'fs-extra';
import Preferences from './modules/preferences';
const isCallbackResponse = (
data: unknown
): data is { cb: string; args: unknown[] } =>
typeof data === 'object' &&
data !== null &&
'cb' in data &&
typeof (data as { cb: unknown }).cb === 'string' &&
'args' in data &&
Array.isArray((data as { args: unknown }).args);
export const runWorker = <T>(
worker: (o: WorkerOptions) => Worker,
workerData: Record<string, unknown>,
callbacks?: Record<string, (...data: any[]) => void>
) =>
new Promise<T>((resolve, reject) =>
worker({ workerData })
.on('message', (m: unknown) => {
if (!isCallbackResponse(m)) return resolve(m as T);
const callback = callbacks?.[m.cb];
if (callback) callback(...m.args);
else Logger.warn('Unknown worker callback', m.cb);
})
.on('error', reject)
.on('exit', code =>
reject(new Error(`Worker exited (code ${code}) without finishing`))
)
);
export const getClientVersion = async () => {
Logger.log('Reading client version...');
const exePath = path.join(Preferences.data.clientDir ?? '', 'WoW.exe');
if (!(await fs.exists(exePath))) {
Logger.log('Client not found...');
return undefined;
}
const file = await fs.readFile(exePath);
const buffer = Buffer.from(file);
const VERSION_OFFSET = 0x00437c04;
const VERSION_LEN = 6;
const BUILD_OFFSET = 0x00437bfc;
const BUILD_LEN = 4;
const version = buffer.toString(
'utf-8',
VERSION_OFFSET,
VERSION_OFFSET + VERSION_LEN
);
const build = buffer.toString(
'utf-8',
BUILD_OFFSET,
BUILD_OFFSET + BUILD_LEN
);
Logger.log(`Client version is: ${version} (${build})`);
return `${version} (${build})`;
};
+46
View File
@@ -0,0 +1,46 @@
import { workerData, parentPort } from 'worker_threads';
import git from 'isomorphic-git';
import http from 'isomorphic-git/http/node';
import fs from 'fs-extra';
const port = parentPort;
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);
await git.clone({
dir: tmpDir,
fs,
http,
url,
ref,
singleBranch: !ref || ref === 'master' || ref === 'main',
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);
};
run()
.then(() => port.postMessage(true))
.catch(async err => {
await fs.remove(tmpDir).catch(() => undefined);
throw err;
});
+56
View File
@@ -0,0 +1,56 @@
import { workerData, parentPort } from 'worker_threads';
import git from 'isomorphic-git';
import http from 'isomorphic-git/http/node';
import fs from 'fs-extra';
const port = parentPort;
if (!port) throw new Error('gitPull worker has no parentPort');
const { dir, remote, branch, ref } = workerData as {
dir: string;
remote: string;
branch: string;
ref?: string;
};
const onProgress = (...args: unknown[]) =>
port.postMessage({ cb: 'onProgress', args });
const run = async () => {
if (ref) {
await git.fetch({
fs,
http,
dir,
tags: true,
singleBranch: false,
onProgress
});
await git.checkout({ fs, dir, force: true, ref, onProgress });
return;
}
await git.checkout({
fs,
dir,
force: true,
ref: `${remote}/${branch}`,
onProgress
});
await git.pull({
fs,
http,
dir,
ref: branch,
singleBranch: true,
author: { name: 'Octo Launcher' },
onProgress
});
};
run()
.then(() => port.postMessage(true))
.catch(err => {
throw err;
});
+16
View File
@@ -0,0 +1,16 @@
import path from 'path';
import { contextBridge } from 'electron';
import { electronAPI } from '@electron-toolkit/preload';
import { exposeElectronTRPC } from 'electron-trpc/main';
try {
contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('path', path);
} catch (error) {
console.error(error);
}
process.once('loaded', async () => {
exposeElectronTRPC();
});
+10
View File
@@ -0,0 +1,10 @@
import type path from 'path';
import { type ElectronAPI } from '@electron-toolkit/preload';
declare global {
interface Window {
electron: ElectronAPI;
path: typeof path;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { useState } from 'react';
import { api } from './utils/api';
import PageBackground from './assets/background.png';
import AntivirusModal from './components/AntivirusModal';
import Header from './components/Header';
import LaunchPanel from './components/LaunchPanel';
import SelfUpdateBanner from './components/SelfUpdateBanner';
import TabsPanel, { type TabType } from './components/TabsPanel';
import TopBar from './components/TopBar';
import IconSpinner from './components/styled/IconSpinner';
import usePreventDefaultEvents from './utils/usePreventDefaultEvents';
const App = () => {
const { isLoading } = api.preferences.get.useQuery();
const { data: appVersion } = api.general.appVersion.useQuery();
const [activeTab, setActiveTab] = useState<TabType>();
usePreventDefaultEvents();
return (
<div
className="relative flex grow flex-col gap-3 overflow-hidden bg-cover bg-top bg-no-repeat p-[44px]"
style={{ backgroundImage: `url(${PageBackground})` }}
>
<TopBar />
<SelfUpdateBanner />
<Header {...{ activeTab, setActiveTab }} />
{isLoading ? (
<div className="flex flex-grow items-center justify-center">
<IconSpinner />
</div>
) : (
<>
<TabsPanel activeTab={activeTab} />
<LaunchPanel />
</>
)}
{appVersion && (
<span className="pointer-events-none absolute bottom-2 right-3 select-none font-mono text-[10px] uppercase tracking-wider text-white/40">
v{appVersion}
</span>
)}
<AntivirusModal />
</div>
);
};
export default App;
+91
View File
@@ -0,0 +1,91 @@
import { Clipboard, RefreshCw, ServerCrash } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer';
import { useT } from '~renderer/i18n';
import PageBackground from './assets/background.png';
import TextButton from './components/styled/TextButton';
type State = {
didCatch?: boolean;
error?: Error;
errorInfo?: ErrorInfo;
};
type Props = {
children: ReactNode;
};
const ErrorFallback = ({
error,
errorInfo
}: {
error?: Error;
errorInfo?: ErrorInfo;
}) => {
const t = useT();
const title = t('misc.uncaughtError', {
name: error?.name ?? '',
message: error?.message ?? t('misc.unknownError')
});
const detail = errorInfo?.componentStack?.slice(1);
return (
<div
className="relative flex h-screen w-screen grow flex-col overflow-hidden border border-blueGray/10 bg-cover bg-top bg-no-repeat p-3"
style={{ backgroundImage: `url(${PageBackground})` }}
>
<div className="tw-surface flex grow flex-col gap-3">
<div className="flex items-center gap-2">
<ServerCrash size={26} className="text-red" />
<h3 className="text-red">{t('misc.somethingWentWrong')}</h3>
</div>
<hr />
<div className="text-white">{title}</div>
<pre className="s1 -mt-2 grow overflow-auto text-blueGray">
{detail}
</pre>
<hr />
<div className="-mx-3 -mb-3 flex justify-end gap-2">
<TextButton
icon={Clipboard}
onClick={() =>
navigator.clipboard.writeText(
`\`\`\`\n${title}\n${detail}\n\`\`\``
)
}
>
{t('misc.copyError')}
</TextButton>
<TextButton
icon={RefreshCw}
onClick={() => window.location.reload()}
className="text-warmGreen"
>
{t('misc.reload')}
</TextButton>
</div>
</div>
</div>
);
};
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
log.error('Client crash:', error, errorInfo);
this.setState({ didCatch: true, error, errorInfo });
}
render() {
if (!this.state.didCatch) return this.props.children;
const { error, errorInfo } = this.state;
return <ErrorFallback error={error} errorInfo={errorInfo} />;
}
}
export default ErrorBoundary;
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+230
View File
@@ -0,0 +1,230 @@
import { useEffect, useRef, useState } from 'react';
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 { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
const DETECTION = 'Trojan:Win32/Vigorf.A';
const AntivirusModal = () => {
const t = useT();
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 blockedKey = blocked.join(',');
const dialogRef = useRef<HTMLDialogElement>(null);
const [view, setView] = useState<'av' | 'why' | null>(null);
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();
(document.activeElement as HTMLElement | null)?.blur();
} else dialogRef.current?.close();
}, [view]);
useEffect(() => {
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(
<dialog
ref={dialogRef}
onClose={() => setView(null)}
className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex"
>
{view === 'av' && (
<div className="tw-dialog !w-fit min-w-[380px] max-w-[480px] !gap-3">
<h3 className="tw-color">{t('av.blockedTitle')}</h3>
<p className="s1">
{names.length === 1 ? t('av.blockedOne') : t('av.blockedMany')}
</p>
<ul className="gap-0.5 flex flex-col pl-1">
{names.map(n => (
<li key={n} className="s1 text-orange">
{n}
</li>
))}
</ul>
<p className="s1 text-blueGray">{t('av.blockedExplain')}</p>
<TextButton
icon={HelpCircle}
onClick={() => setView('why')}
className="self-start text-yellow hocus:!text-yellow"
>
{t('av.whyHappening')}
</TextButton>
<TextButton
icon={ShieldAlert}
loading={addExclusion.isLoading}
onClick={() => addExclusion.mutate()}
className="self-start text-orange"
>
{t('av.allowThrough')}
</TextButton>
{addExclusion.data?.ok === true && (
<span className="s1 text-warmGreen">{t('av.addedRetry')}</span>
)}
{addExclusion.data?.ok === false && (
<span className="s1 text-orange">{addExclusion.data.error}</span>
)}
<div className="mt-1 flex justify-end">
<TextButton onClick={() => setView(null)} className="text-green">
{t('av.close')}
</TextButton>
</div>
</div>
)}
{view === 'why' && (
<div className="tw-dialog !w-fit min-w-[420px] max-w-[560px] !gap-3">
<h3 className="tw-color">{t('av.whyTitle')}</h3>
<div className="flex max-h-[60vh] transform-gpu flex-col gap-3 overflow-y-auto overscroll-contain pr-1 [will-change:transform]">
<p className="s1 text-blueGray">
{t('av.whyIntro', { detection: DETECTION })}{' '}
<span className="text-warmGreen">{t('av.falsePositive')}</span>.
</p>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatSetsItOff')}
</p>
<p className="s1 text-blueGray">{t('av.whatSetsItOffIntro')}</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
<span className="text-orange">{t('av.dllInjection')}</span>:{' '}
{t('av.dllInjectionText')}
</li>
<li className="s1 text-blueGray">
<span className="text-orange">{t('av.exePatching')}</span>:{' '}
{t('av.exePatchingText')}
</li>
</ul>
<p className="s1 mt-1 text-blueGray">{t('av.heuristicNote')}</p>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatModsDo')}
</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
<span className="text-warmGreen">VanillaFixes</span>:{' '}
{t('av.modVanillaFixes')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">nampower</span>:{' '}
{t('av.modNampower')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">SuperWoW and UnitXP</span>:{' '}
{t('av.modSuperWowUnitXp')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">DXVK</span>:{' '}
{t('av.modDxvk')}
</li>
</ul>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.howToVerify')}
</p>
<p className="s1 mt-1 text-blueGray">
{t('av.howToVerifyText', {
detection: DETECTION
})}
</p>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatAllowDoesTitle')}
</p>
<p className="s1 mt-1 text-blueGray">
{t('av.whatAllowDoesIntro')}{' '}
<span className="text-warmGreen">Add-MpPreference</span>{' '}
{t('av.whatAllowDoesIntroAfter')}
</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
{t('av.exclusionFoldersBefore')}{' '}
<span className="text-warmGreen">{t('av.gameFolder')}</span>{' '}
{t('av.exclusionFoldersAnd')}{' '}
<span className="text-warmGreen">
{t('av.launcherFolder')}
</span>
{t('av.exclusionFoldersAfter')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">WoW.exe</span>{' '}
{t('av.exclusionExesAnd')}{' '}
<span className="text-warmGreen">VanillaFixes.exe</span>{' '}
{t('av.exclusionExesAfter')}
</li>
</ul>
<p className="s1 mt-1 text-blueGray">
{t('av.whatAllowDoesOutro')}
</p>
</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(null)} className="text-green">
{t('av.close')}
</TextButton>
</div>
</div>
)}
</dialog>,
document.body
);
};
export default AntivirusModal;
+148
View File
@@ -0,0 +1,148 @@
import { useForm } from 'react-hook-form';
import { useEffect, useState } from 'react';
import { PreferencesSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver';
import { api } from '~renderer/utils/api';
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 };
const ClientDirDialog = ({ close }: Props) => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation();
const isValidClientDir = api.preferences.isValidClientDir.useQuery(
pref?.clientDir,
{ enabled: !!pref?.isPortable }
);
const verify = api.updater.verify.useMutation();
const {
register,
handleSubmit,
watch,
formState,
setValue,
setError,
reset
} = useForm({
defaultValues: { clientDir: pref?.clientDir ?? '' },
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]);
if (pref?.isPortable) {
return (
<form className="tw-dialog">
<CloseButton close={close} />
<h2 className="color mb-2 text-xl">
{t('prefs.installLocationTitle')}
</h2>
<p>{t('prefs.portableInfo')}</p>
{!isValidClientDir.isLoading && !isValidClientDir.data && (
<p>
<span className="text-secondary">{t('prefs.errorLabel')}</span>
{t('prefs.wowExeNotFound', { exe: 'WoW.exe' })}
</p>
)}
</form>
);
}
return (
<form
className="tw-dialog"
onSubmit={handleSubmit(async ({ clientDir }) => {
if (needsEmptyConfirm && !acceptEmpty) return;
try {
await setPref.mutateAsync({ clientDir });
verify.mutate();
close();
} catch (e) {
setError('clientDir', {
message: e instanceof Error ? e.message : JSON.stringify(e)
});
}
})}
>
<CloseButton
close={() => {
reset();
close();
}}
/>
<h3 className="tw-color">{t('prefs.installLocationTitle')}</h3>
<hr />
<p className="text-blueGray">{t('prefs.selectDirectory')}</p>
<p className="text-blueGray">{t('prefs.upgradeExisting')}</p>
<div className="flex items-center gap-3">
<label htmlFor="clientDir">{t('prefs.installDirectory')}</label>
<FilePickerInput
{...register('clientDir')}
title={watch('clientDir') ?? undefined}
setValue={v =>
setValue('clientDir', v, {
shouldTouch: true,
shouldDirty: true,
shouldValidate: true
})
}
options={{ properties: ['openDirectory', 'createDirectory'] }}
/>
</div>
{formState.errors.clientDir && (
<p className="text-secondary text-sm">
{formState.errors.clientDir.message}
</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')}
</TextButton>
</form>
);
};
export default ClientDirDialog;
+37
View File
@@ -0,0 +1,37 @@
import OctoLogo from '~renderer/assets/logo.png';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
import { TabNames, type TabType } from './TabsPanel';
type Props = {
activeTab?: TabType;
setActiveTab: (tab?: TabType) => void;
};
const Header = ({ activeTab, setActiveTab }: Props) => {
const t = useT();
return (
<div className="-mb-3 flex select-none items-center gap-1">
<button
onClick={() => setActiveTab(undefined)}
className="z-10 -my-3 mx-3 w-[180px] cursor-pointer"
>
<img src={OctoLogo} alt="OctoWoW" className="pointer-events-none" />
</button>
{TabNames.map(tab => (
<TextButton
key={tab}
onClick={() => setActiveTab(tab)}
active={activeTab === tab}
className="uppercase"
>
{t(`tab.${tab}`)}
</TextButton>
))}
</div>
);
};
export default Header;
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react';
import { Globe } from 'lucide-react';
import { createPortal } from 'react-dom';
import cls from 'classnames';
import { api } from '~renderer/utils/api';
import { useLocale } from '~renderer/i18n';
import { type Lang } from '~renderer/i18n/translations';
const LOCALES: { value: Lang; code: string; label: string }[] = [
{ value: 'enUS', code: 'En', label: 'English' },
{ value: 'deDE', code: 'De', label: 'Deutsch' },
{ value: 'zhCN', code: 'Zh', label: '中文' },
{ value: 'esES', code: 'Es', label: 'Español' },
{ value: 'ptBR', code: 'Pt', label: 'Português' },
{ value: 'ruRU', code: 'Ru', label: 'Русский' }
];
const LanguageDropdown = () => {
const { lang, setLang, t } = useLocale();
const setPref = api.preferences.set.useMutation();
const code = LOCALES.find(l => l.value === lang)?.code ?? 'En';
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; right: number }>();
const btnRef = useRef<HTMLButtonElement>(null);
const toggle = () => {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 4, right: window.innerWidth - r.right });
setOpen(o => !o);
};
useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
window.addEventListener('click', close);
return () => window.removeEventListener('click', close);
}, [open]);
const pick = (v: Lang) => {
setLang(v);
setPref.mutate({ locale: v });
setOpen(false);
};
return (
<>
<button
ref={btnRef}
type="button"
title={t('topbar.language')}
onClick={e => {
e.stopPropagation();
toggle();
}}
className="bg-transparent flex cursor-pointer items-center border-0 px-1 text-[12px] tracking-wide hocus:text-orange"
>
<Globe size={14} />
{code}
</button>
{open &&
pos &&
createPortal(
<div
onClick={e => e.stopPropagation()}
style={{ top: pos.top, right: pos.right }}
className="fixed z-50 flex flex-col border border-blueGray/30 bg-darkGray py-1 shadow-[0_8px_20px_rgba(0,0,0,0.5)]"
>
{LOCALES.map(l => (
<button
key={l.value}
type="button"
title={l.label}
onClick={() => pick(l.value)}
className={cls(
'bg-transparent cursor-pointer border-0 px-3 py-1 text-center text-[12px] hocus:bg-orange/20',
l.value === lang ? 'text-warmGreen' : 'text-white'
)}
>
{l.code}
</button>
))}
</div>,
document.body
)}
</>
);
};
export default LanguageDropdown;
+268
View File
@@ -0,0 +1,268 @@
import { useState, type ReactElement } from 'react';
import cls from 'classnames';
import log from 'electron-log/renderer';
import { type UpdaterStatus, type ModsStatus } from '~main/types';
import { formatFileSize } from '~common/utils';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import Button from './styled/Button';
import DialogButton from './styled/DialogButton';
import ClientDirDialog from './ClientDirDialog';
const formatDuration = (seconds: number) => {
const s = Math.max(0, Math.round(seconds));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`;
const h = Math.floor(m / 60);
const minRem = m % 60;
return minRem ? `${h}h ${minRem}m` : `${h}h`;
};
const formatPercent = (progress: number) =>
`${parseFloat((progress * 100).toFixed(1))}%`;
const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
const t = useT();
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } =
status;
if (bytesTotal === undefined || bytesDone === undefined) return null;
const pct =
progress !== undefined && progress >= 0 ? formatPercent(progress) : '—';
return (
<p className="s1 text-blueGray">
<span className="tw-color">{pct}</span>
<span>
{' '}
· {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}
</span>
{bytesPerSecond !== undefined && bytesPerSecond > 0 && (
<span> · {formatFileSize(bytesPerSecond, 1)}/s</span>
)}
<span>
{' · '}
{etaSeconds !== undefined
? `~${formatDuration(etaSeconds)} ${t('launch.remaining')}`
: t('launch.calculating')}
</span>
</p>
);
};
const LaunchPanel = () => {
const t = useT();
const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' });
api.updater.observe.useSubscription(undefined, {
onData: setStatus,
onError: err => log.error('Updater subscription error:', err)
});
const { data: pref } = api.preferences.get.useQuery();
const [modsStatus, setModsStatus] = useState<ModsStatus>();
api.mods.observe.useSubscription(undefined, {
onData: setModsStatus
});
const verify = api.updater.verify.useMutation();
const update = api.updater.update.useMutation();
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 }
> = {
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={() => verify.mutateAsync()}>
{t('launch.retry')}
</Button>
),
helperText: (
<div className="-mb-2">
<p>
<span className="text-orange">{t('launch.errorLabel')}</span>{' '}
{t('launch.serverFail')}
</p>
<p className="s1 text-blueGray">
{pref?.version
? t('launch.localVersion', { version: pref.version })
: t('launch.tryLater')}
</p>
</div>
)
},
noClient: {
button: (
<DialogButton
clickAway
dialog={close => <ClientDirDialog close={close} />}
>
{open => (
<Button primary onClick={open}>
{t('launch.install')}
</Button>
)}
</DialogButton>
)
},
updateAvailable: {
button: (
<Button onClick={() => update.mutateAsync()}>
{t('launch.update')}
</Button>
),
helperText: (
<div className="-mb-2 flex flex-col gap-1">
<p>{t('launch.updateAvailable')}</p>
<p className="s1 text-blueGray">
{status.progress !== undefined &&
status.bytesDone !== undefined &&
status.bytesTotal !== undefined && (
<>
<span className="tw-color">
{formatPercent(status.progress)}
</span>
<span>
{' '}
· {formatFileSize(status.bytesDone)} /{' '}
{formatFileSize(status.bytesTotal)} {t('launch.onDisk')} ·{' '}
</span>
</>
)}
<span className="break-all">{status.message}</span>
</p>
</div>
)
},
updating: {
button: <Button disabled>{t('launch.updating')}</Button>,
helperText: (
<div className="-mb-2 flex flex-col gap-1">
{status.message && (
<p className="s1 truncate text-blueGray">{status.message}</p>
)}
<ProgressDetails status={status} />
</div>
)
},
upToDate: {
button: modsStatus?.dirty ? (
<Button
primary
onClick={() => applyMods.mutateAsync()}
disabled={
applyMods.isLoading ||
modsStatus?.state === 'busy' ||
missingDeps.length > 0
}
>
{modsStatus?.state === 'busy'
? t('launch.applying')
: t('mods.apply')}
</Button>
) : (
<Button
primary
disabled={start.isLoading}
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.upToDate')}</p>
)}
<p className="s1 text-blueGray">
{t('launch.version', { version: pref?.version ?? '' })}
</p>
</div>
)
},
failed: {
button: (
<Button onClick={() => verify.mutateAsync()}>
{t('launch.retry')}
</Button>
),
helperText: (
<div className="-mb-2">
<p>
<span className="text-orange">{t('launch.errorLabel')}</span>{' '}
{status.message}
</p>
<p className="s1 text-blueGray">{t('launch.verifyHint')}</p>
</div>
)
}
};
return (
<div className="flex gap-3">
<div className="flex flex-grow flex-col justify-end gap-3">
{props[status.state].helperText ??
(status.message && (
<p className="s1 -mb-2 text-blueGray">{status.message}</p>
))}
{start.data && !start.data.ok && start.data.error && (
<p className="s1 -mb-2 text-orange">{start.data.error}</p>
)}
<div className="tw-loading-wrapper">
{status.progress !== undefined && (
<div
className={cls('tw-loading', {
'tw-loading-unknown': status.progress === -1
})}
style={
status.progress !== -1
? {
clipPath: `inset(0 ${
100 - Math.ceil(Math.abs(status.progress) * 100)
}% 0 0)`
}
: undefined
}
/>
)}
</div>
</div>
{props[status.state].button}
</div>
);
};
export default LaunchPanel;
@@ -0,0 +1,229 @@
import { useForm } from 'react-hook-form';
import { useEffect, useState } from 'react';
import {
FilePen,
FolderOpen,
HelpCircle,
RefreshCw,
ScrollText,
ShieldAlert,
ShieldCheck
} from 'lucide-react';
import { PreferencesSchema } from '~common/schemas';
import { api } from '~renderer/utils/api';
import zodResolver from '~renderer/utils/zodResolver';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
import CheckboxInput from './form/CheckboxInput';
import DialogButton from './styled/DialogButton';
import ClientDirDialog from './ClientDirDialog';
import CloseButton from './styled/CloseButton';
const MirrorStatus = () => {
const t = useT();
const [state, setState] = useState<string>('verifying');
api.updater.observe.useSubscription(undefined, {
onData: ({ state }) => setState(state)
});
if (state === 'serverUnreachable')
return <span className="s1 text-red">{t('prefs.mirrorOffline')}</span>;
if (state === 'verifying' || state === 'updating')
return (
<span className="s1 text-blueGray">{t('prefs.mirrorChecking')}</span>
);
return <span className="s1 text-warmGreen">{t('prefs.mirrorOnline')}</span>;
};
type Props = { close: () => void };
const PreferencesDialog = ({ close }: Props) => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation();
const verify = api.updater.verify.useMutation();
const repair = api.mods.repair.useMutation();
const openInstallFolder = api.general.openInstallFolder.useMutation();
const openLogFile = api.general.openLogFile.useMutation();
const addExclusion = api.general.addDefenderExclusion.useMutation();
const { handleSubmit, watch, setValue, reset } = useForm({
defaultValues: pref ?? {},
resolver: zodResolver(PreferencesSchema)
});
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
pref && reset(pref);
}, [reset, pref]);
const setBool = (key: keyof PreferencesSchema) => (v: boolean) =>
setValue(key, v, {
shouldTouch: true,
shouldDirty: true,
shouldValidate: true
});
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));
}
})}
>
<CloseButton
close={() => {
reset();
close();
}}
/>
<h3 className="tw-color">{t('prefs.title')}</h3>
<hr className="mb-1" />
<div className="flex items-center gap-3">
<h4 className="tw-color">{t('prefs.installLocation')}</h4>
<TextButton
icon={FolderOpen}
size={14}
onClick={() => openInstallFolder.mutateAsync()}
className="!p-1 text-blueGray"
>
{t('prefs.openFolder')}
</TextButton>
</div>
<div className="flex items-center gap-2 border border-blueGray/20 bg-darkGray/40 px-3 py-1">
<span
title={pref?.clientDir}
className="min-w-0 shrink grow overflow-hidden text-ellipsis whitespace-nowrap"
>
{pref?.clientDir ?? t('prefs.notSelected')}
</span>
<DialogButton
dialog={closeInner => (
<ClientDirDialog
close={() => {
closeInner();
close();
}}
/>
)}
clickAway={pref?.isPortable}
>
{open => (
<TextButton
icon={FilePen}
size={14}
onClick={open}
className="!p-1"
>
{t('prefs.change')}
</TextButton>
)}
</DialogButton>
</div>
<div className="mt-1 flex items-center gap-3">
<h4 className="tw-color">{t('prefs.downloadMirror')}</h4>
</div>
<div className="flex items-center gap-2 pl-2">
<input type="radio" checked readOnly className="accent-warmGreen" />
<span>Iceland</span>
<MirrorStatus />
<TextButton
icon={RefreshCw}
size={12}
onClick={() => verify.mutateAsync()}
title={t('prefs.recheck')}
className="!p-0 text-blueGray"
/>
</div>
<div className="flex items-start gap-3">
<div className="flex min-w-0 flex-col">
<h4 className="tw-color">{t('prefs.troubleshooting')}</h4>
<TextButton
icon={ShieldCheck}
onClick={() => repair.mutateAsync().then(close)}
className="!items-start text-left text-warmGreen"
>
{t('prefs.verifyGameFiles')}
</TextButton>
<TextButton
icon={ScrollText}
onClick={() => openLogFile.mutateAsync()}
className="!items-start text-left text-pink"
>
{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>
{addExclusion.data?.ok === true && (
<span className="s1 text-warmGreen">
{t('prefs.exclusionAdded')}
</span>
)}
{addExclusion.data?.ok === false && (
<span className="s1 text-orange">{addExclusion.data.error}</span>
)}
</div>
<div className="flex min-w-0 flex-col">
<h4 className="tw-color">{t('prefs.generalSettings')}</h4>
<CheckboxInput
value={!!watch('cleanWdb')}
setValue={setBool('cleanWdb')}
label={t('prefs.cleanWdb')}
/>
<CheckboxInput
value={!!watch('minimizeToTrayOnPlay')}
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>
)}
<TextButton type="submit" className="mt-1 self-end text-green">
{t('prefs.save')}
</TextButton>
</form>
);
};
export default PreferencesDialog;
@@ -0,0 +1,76 @@
import { useState } from 'react';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import Button from './styled/Button';
type Status =
| { state: 'idle'; currentVersion: string }
| { state: 'checking'; currentVersion: string }
| { state: 'unavailable'; currentVersion: string }
| { state: 'available'; currentVersion: string; nextVersion: string }
| {
state: 'downloading';
currentVersion: string;
nextVersion: string;
progress: number;
}
| { state: 'ready'; currentVersion: string; nextVersion: string }
| { state: 'error'; currentVersion: string; message: string };
const SelfUpdateBanner = () => {
const t = useT();
const [status, setStatus] = useState<Status>({
state: 'idle',
currentVersion: ''
});
api.selfUpdater.observe.useSubscription(undefined, {
onData: setStatus
});
const install = api.selfUpdater.install.useMutation();
if (
status.state === 'idle' ||
status.state === 'checking' ||
status.state === 'unavailable'
) {
return null;
}
const tone = status.state === 'error' ? 'border-red/40' : 'border-tw/40';
const label =
status.state === 'error'
? t('misc.selfUpdateCheckFailed', { message: status.message })
: status.state === 'available'
? t('misc.selfUpdateAvailable', {
version: status.nextVersion
})
: status.state === 'downloading'
? t('misc.selfUpdateDownloading', {
version: status.nextVersion,
percent: Math.round(status.progress * 100)
})
: status.state === 'ready'
? t('misc.selfUpdateReady', { version: status.nextVersion })
: '';
return (
<div
className={`relative z-10 flex items-center gap-3 rounded-md border ${tone} bg-black/60 px-4 py-2 text-sm`}
>
<span className="flex-grow break-all">{label}</span>
{status.state === 'ready' && (
<Button
primary
onClick={() => install.mutateAsync()}
disabled={install.isLoading}
>
{t('misc.selfUpdateInstallNow')}
</Button>
)}
</div>
);
};
export default SelfUpdateBanner;
@@ -0,0 +1,94 @@
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
type Props = {
tabName: string;
children: ReactNode;
};
type State = {
error?: Error;
componentStack?: string;
};
type FallbackProps = {
tabName: string;
error: Error;
componentStack?: string;
onReset: () => void;
};
const TabErrorFallback = ({
tabName,
error,
componentStack,
onReset
}: FallbackProps) => {
const t = useT();
return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
<div className="flex items-center gap-2">
<AlertTriangle size={22} className="text-red" />
<h4 className="text-red">{t('misc.tabCrashed', { tab: tabName })}</h4>
</div>
<hr />
<p className="text-white">
{error.name}: {error.message}
</p>
{componentStack && (
<pre className="s1 max-h-[200px] overflow-auto whitespace-pre-wrap text-blueGray">
{componentStack.trim()}
</pre>
)}
<hr />
<TextButton
icon={RefreshCw}
onClick={onReset}
className="self-end text-warmGreen"
>
{t('misc.tryAgain')}
</TextButton>
</div>
);
};
class TabErrorBoundary extends Component<Props, State> {
state: State = {};
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
log.error(`Tab "${this.props.tabName}" crashed:`, error, info);
this.setState({ error, componentStack: info.componentStack ?? undefined });
}
componentDidUpdate(prevProps: Props) {
if (prevProps.tabName !== this.props.tabName) {
this.setState({ error: undefined, componentStack: undefined });
}
}
#reset = () => this.setState({ error: undefined, componentStack: undefined });
render() {
if (!this.state.error) return this.props.children;
const { error, componentStack } = this.state;
return (
<TabErrorFallback
tabName={this.props.tabName}
error={error}
componentStack={componentStack}
onReset={this.#reset}
/>
);
}
}
export default TabErrorBoundary;
+30
View File
@@ -0,0 +1,30 @@
import AddonsTab from './tabs/AddonsTab';
import ModsTab from './tabs/ModsTab';
import NewsTab from './tabs/NewsTab';
import TweaksTab from './tabs/TweaksTab';
import TabErrorBoundary from './TabErrorBoundary';
const Tabs = {
news: NewsTab,
tweaks: TweaksTab,
addons: AddonsTab,
mods: ModsTab
} as const;
export const TabNames = Object.keys(Tabs) as TabType[];
export type TabType = keyof typeof Tabs;
type Props = { activeTab?: TabType };
const TabsPanel = ({ activeTab }: Props) => {
const tab: TabType = activeTab ?? 'news';
const Component = Tabs[tab];
return (
<TabErrorBoundary key={tab} tabName={tab}>
<Component />
</TabErrorBoundary>
);
};
export default TabsPanel;
+83
View File
@@ -0,0 +1,83 @@
import { Settings, Minus, X } from 'lucide-react';
import { useState } from 'react';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import DialogButton from './styled/DialogButton';
import PreferencesDialog from './PreferencesDialog';
import TextButton from './styled/TextButton';
import LanguageDropdown from './LanguageDropdown';
const TopBar = () => {
const t = useT();
const [safeToQuit, setSafeToQuit] = useState(true);
api.updater.observe.useSubscription(undefined, {
onData: ({ state }) =>
setSafeToQuit(state !== 'verifying' && state !== 'updating')
});
const minimize = api.general.minimize.useMutation();
const quit = api.general.quit.useMutation();
return (
<div
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
className="absolute left-0 right-0 top-0 flex justify-end pr-2 pt-2 opacity-50"
>
<div
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
className="flex items-center"
>
<LanguageDropdown />
<DialogButton dialog={close => <PreferencesDialog close={close} />}>
{open => (
<TextButton
icon={Settings}
title={t('topbar.settings')}
onClick={open}
size={16}
className="!p-1"
/>
)}
</DialogButton>
<TextButton
icon={Minus}
title={t('topbar.minimize')}
onClick={() => minimize.mutateAsync()}
size={16}
className="!p-1"
/>
<DialogButton
dialog={close => (
<div className="tw-dialog">
<h3 className="tw-color">{t('quit.title')}</h3>
<hr />
<p className="text-blueGray">{t('quit.warn')}</p>
<div className="flex gap-2 self-end">
<TextButton onClick={close}>{t('quit.return')}</TextButton>
<TextButton
onClick={() => quit.mutateAsync()}
className="text-red"
>
{t('topbar.quit')}
</TextButton>
</div>
</div>
)}
>
{open => (
<TextButton
icon={X}
title={t('topbar.quit')}
onClick={() => (!safeToQuit ? open() : quit.mutateAsync())}
size={16}
className="!p-1 hocus:text-red"
/>
)}
</DialogButton>
</div>
</div>
);
};
export default TopBar;
@@ -0,0 +1,57 @@
import cls from 'classnames';
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}
height={16}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="mt-[5px] shrink-0"
>
<rect
x="1"
y="1"
width="10"
height="10"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
<rect x="3.5" y="3.5" width="5" height="5" fill="white" />
</svg>
);
type Props = {
label?: ReactNode;
value: boolean;
setValue: (v: boolean) => void;
disabled?: boolean;
className?: cls.Value;
};
const CheckboxInput = ({
label,
value,
setValue,
disabled,
className
}: Props) => (
<TextButton
onClick={() => !disabled && setValue(!value)}
icon={Checkbox}
className={cls(
'!items-start text-left text-blueGray',
{ '[&_*]:fill-none': !value, 'pointer-events-none opacity-40': disabled },
className
)}
>
{label}
</TextButton>
);
export default CheckboxInput;
@@ -0,0 +1,47 @@
import cls from 'classnames';
import { forwardRef, type HTMLProps } from 'react';
import { AppWindow, FolderOpen } from 'lucide-react';
import { api, type RouterInputs } from '~renderer/utils/api';
import TextButton from '../styled/TextButton';
type Props = HTMLProps<HTMLInputElement> & {
setValue: (newVal: string) => void;
options: RouterInputs['general']['filePicker'];
};
const FilePickerInput = forwardRef<HTMLInputElement, Props>(
({ setValue, options, className, ...props }, ref) => {
const filePicker = api.general.filePicker.useMutation();
return (
<div className="relative flex grow">
<input
ref={ref}
id={props.name}
{...props}
className={cls(
'grow border-b border-blueGray bg-inherit p-1 pr-[44px] hocus:border-orange',
className
)}
/>
<TextButton
className="absolute right-1 top-0 h-full"
icon={
options.properties?.includes('openDirectory')
? FolderOpen
: AppWindow
}
title="Pick file"
onClick={async () => {
const r = await filePicker.mutateAsync(options);
if (r.canceled) return;
setValue(r.path[0]);
}}
/>
</div>
);
}
);
export default FilePickerInput;
@@ -0,0 +1,59 @@
import cls from 'classnames';
import { type ChangeEvent, type FocusEvent, type HTMLProps, forwardRef } from 'react';
type Props = Omit<
HTMLProps<HTMLInputElement>,
'value' | 'min' | 'max' | 'step'
> & {
setValue: (v: number) => void;
min?: number;
max?: number;
step?: number;
sensitivity?: number;
};
const NumberGrabInput = forwardRef<HTMLInputElement, Props>(
(
{
setValue,
className,
max = Infinity,
min = -Infinity,
step: _step,
sensitivity: _sensitivity,
type: _ignored,
onChange,
onBlur,
...props
},
ref
) => (
<input
ref={ref}
type="text"
inputMode="numeric"
{...props}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const n = Number(e.currentTarget.value);
if (Number.isFinite(n) && n > max) {
e.currentTarget.value = String(max);
}
onChange?.(e);
}}
onBlur={(e: FocusEvent<HTMLInputElement>) => {
const n = Number(e.currentTarget.value);
const clamped = Math.max(
Math.min(Number.isFinite(n) ? n : min, max),
min
);
setValue(clamped);
onBlur?.(e);
}}
onWheel={e => !e.shiftKey && e.currentTarget.blur()}
className={cls(
className,
'w-[70px] cursor-text border-b border-blueGray bg-inherit p-1 text-center hocus:border-orange'
)}
/>
)
);
export default NumberGrabInput;
@@ -0,0 +1,44 @@
import cls from 'classnames';
import TextButton from '../styled/TextButton';
const Radio = () => (
<svg
width={16}
height={16}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="6" cy="6" r="5.5" stroke="currentColor" />
<path
d="M8 6C8 7.10429 7.10429 8 6 8C4.89571 8 4 7.10429 4 6C4 4.8957 4.89571 4 6 4C7.10429 4 8 4.8957 8 6Z"
fill="white"
/>
</svg>
);
type Props<T> = {
value: T;
setValue: (val: T) => void;
options: { label: string; value: T }[];
};
const RadioInput = <const T,>({ value, setValue, options }: Props<T>) => (
<div className="flex justify-start">
{options.map(o => (
<TextButton
key={`${o.value}`}
onClick={() => setValue(o.value)}
icon={Radio}
className={cls('text-blueGray', {
'[&_*]:fill-none': value !== o.value
})}
>
{o.label}
</TextButton>
))}
</div>
);
export default RadioInput;
@@ -0,0 +1,17 @@
import cls from 'classnames';
import { forwardRef, type HTMLProps } from 'react';
const TextInput = forwardRef<HTMLInputElement, HTMLProps<HTMLInputElement>>(
(props, ref) => (
<input
ref={ref}
{...props}
className={cls(
'cursor-text border-b border-blueGray bg-inherit p-1 hocus:border-orange',
props.className
)}
/>
)
);
export default TextInput;
+44
View File
@@ -0,0 +1,44 @@
import type { ButtonHTMLAttributes } from 'react';
import cls from 'classnames';
import { type LucideIcon } from 'lucide-react';
import IconSpinner from './IconSpinner';
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
primary?: boolean;
loading?: boolean;
disabled?: boolean;
icon?: LucideIcon;
};
const Button = ({
primary,
loading,
disabled,
icon: Icon,
children,
className,
...props
}: Props) => (
<button
{...props}
onClick={props.onClick}
tabIndex={!!loading || !!disabled ? -1 : props.tabIndex}
className={cls('tw-button', className, {
'pointer-events-none': !!disabled || !!loading,
'grayscale': disabled,
'tw-button-primary': primary
})}
>
<span className={cls('select-none', { 'ml-[-12px]': !!loading || !!Icon })}>
{loading ? (
<IconSpinner size={23} strokeWidth={1.5} />
) : Icon ? (
<Icon size={23} strokeWidth={1.5} />
) : null}
{children}
</span>
</button>
);
export default Button;
@@ -0,0 +1,14 @@
import { X } from 'lucide-react';
import TextButton from './TextButton';
const CloseButton = ({ close }: { close: () => void }) => (
<TextButton
title="Close"
icon={X}
size={16}
onClick={close}
className="absolute right-1 top-1 text-blueGray hocus:text-red"
/>
);
export default CloseButton;
@@ -0,0 +1,72 @@
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 = '';
let i = 0;
const flush = () => {
if (buf) runs.push({ text: buf, color });
buf = '';
};
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();
color = undefined;
}
}
buf += s.slice(i);
flush();
return runs.filter(r => r.text.length > 0);
};
export const stripColorCodes = (s: string) =>
tokenize(s)
.map(r => r.text)
.join('');
export const ColoredText = ({
children,
className,
style
}: {
children: string;
className?: string;
style?: React.CSSProperties;
}) => {
const runs = tokenize(children);
return (
<p className={className} style={style}>
{runs.map((r, i) =>
r.color ? (
<span
key={i}
className="text-size-inherit text-inherit"
style={{ color: r.color }}
>
{r.text}
</span>
) : (
<span key={i}>{r.text}</span>
)
)}
</p>
);
};
@@ -0,0 +1,79 @@
import cls from 'classnames';
import {
useRef,
type ReactElement,
useEffect,
useCallback,
type FC,
isValidElement
} from 'react';
import { createPortal } from 'react-dom';
type Props = {
clickAway?: boolean;
noBlur?: boolean;
focusOnOpen?: boolean;
afterClose?: () => void;
dialog: ReactElement | ((close: () => void) => ReactElement);
children: ReactElement | ((open: () => void) => ReactElement);
};
const DialogButton = ({
clickAway,
noBlur,
focusOnOpen,
afterClose,
dialog,
children
}: Props) => {
const ref = useRef<HTMLDialogElement>(null);
const open = useCallback(() => {
if (!ref.current) return;
!focusOnOpen && (ref.current.inert = true);
ref.current.showModal();
!focusOnOpen && (ref.current.inert = false);
}, [focusOnOpen]);
const close = useCallback(() => {
ref.current?.close();
}, []);
useEffect(() => {
if (!clickAway) return;
const callback = (e: MouseEvent) => e.target === ref.current && close();
window.addEventListener('click', callback);
return () => window.removeEventListener('click', callback);
}, [clickAway, close]);
useEffect(() => {
const callback = () => {
afterClose?.();
return (document.activeElement as HTMLElement)?.blur();
};
const r = ref.current;
r?.addEventListener('close', callback);
return () => r?.removeEventListener('close', callback);
}, [afterClose]);
return (
<>
{createPortal(
<dialog
ref={ref}
onSubmit={e => e.stopPropagation()}
className={cls(
'h-full w-full items-center justify-center bg-[transparent] [&[open]]:flex',
{ 'backdrop:backdrop-blur-md': !noBlur }
)}
>
{typeof dialog === 'function' ? dialog(close) : dialog}
</dialog>,
document.body
)}
{typeof children === 'function' ? children(open) : children}
</>
);
};
export default DialogButton;
@@ -0,0 +1,8 @@
import cls from 'classnames';
import { Loader2, type LucideProps } from 'lucide-react';
const IconSpinner = ({ className, ...props }: LucideProps) => (
<Loader2 {...props} className={cls(className, 'animate-spin')} />
);
export default IconSpinner;
@@ -0,0 +1,66 @@
import cls from 'classnames';
import { type LucideIcon } from 'lucide-react';
import { type ReactNode } from 'react';
import IconSpinner from './IconSpinner';
type Props = {
active?: boolean;
loading?: boolean;
disabled?: boolean;
size?: number;
className?: cls.Value;
style?: React.CSSProperties;
} & (
| { type: 'submit'; onClick?: never }
| { type?: never; onClick: () => void }
) &
(
| { children: ReactNode; icon?: LucideIcon; title?: never }
| { children?: never; icon: LucideIcon; title: string }
);
const TextButton = ({
title,
type,
active,
loading,
disabled,
icon: Icon,
size,
onClick,
className,
children,
...props
}: Props) => (
<button
title={title ?? (typeof children === 'string' ? children : undefined)}
type={type ?? 'button'}
onClick={onClick}
tabIndex={!!loading || !!disabled ? -1 : undefined}
className={cls(
'flex cursor-pointer items-center gap-2 border-0 p-2',
className,
{
'tw-color drop-shadow-[0px_0px_10px_white]':
active && !loading && !disabled,
'pointer-events-none text-gray': !!loading || !!disabled,
'tw-hocus': !loading && !disabled
}
)}
{...props}
>
{loading ? (
<IconSpinner size={size ?? 24} strokeWidth={1.5} />
) : (
Icon && <Icon size={size} className="shrink-0" />
)}
{children && (
<span className="cursor-pointer select-none tracking-wide text-inherit [font-size:_inherit]">
{children}
</span>
)}
</button>
);
export default TextButton;

Some files were not shown because too many files have changed in this diff Show More