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.
@@ -0,0 +1,2 @@
|
||||
MAIN_VITE_SERVER_URL=http://localhost:5000
|
||||
MAIN_VITE_CLIENT_VERSION=latest
|
||||
@@ -0,0 +1,2 @@
|
||||
MAIN_VITE_SERVER_URL=https://octowow.st
|
||||
MAIN_VITE_CLIENT_VERSION=latest
|
||||
@@ -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
|
||||
@@ -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: ''
|
||||
@@ -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
|
||||
@@ -0,0 +1,20 @@
|
||||
node_modules/
|
||||
|
||||
dist*/
|
||||
out/
|
||||
release/
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
Tools/launcher/node/
|
||||
|
||||
.env
|
||||
.env.local
|
||||
|
||||
*.log
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
scripts/
|
||||
hooks/
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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`)
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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 ""
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 201 KiB |
@@ -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/'
|
||||
@@ -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()]
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'tailwindcss/nesting': 'postcss-nested',
|
||||
'tailwindcss': {},
|
||||
'autoprefixer': {}
|
||||
}
|
||||
};
|
||||
@@ -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}`);
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
];
|
||||
@@ -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})`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
});
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -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);
|
||||
})
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -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())
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -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() };
|
||||
})
|
||||
});
|
||||
@@ -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))
|
||||
});
|
||||
@@ -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())
|
||||
});
|
||||
@@ -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())
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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)));
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 =>
|
||||
({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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})`;
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 991 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||