Compare commits

1 Commits

Author SHA1 Message Date
octoadmin 530ec7a144 Initial commit 2026-05-08 00:00:00 +00:00
83 changed files with 1865 additions and 7862 deletions
-2
View File
@@ -1,4 +1,2 @@
MAIN_VITE_SERVER_URL=https://octowow.st MAIN_VITE_SERVER_URL=https://octowow.st
MAIN_VITE_CLIENT_VERSION=latest MAIN_VITE_CLIENT_VERSION=latest
MAIN_VITE_CLIENT_TORRENT_URL=https://dl.octowow.st/download/client.torrent
MAIN_VITE_RAID_VISUALS_URL=https://dl.octowow.st/client/latest/Data/patch-O.mpq
-6
View File
@@ -2,9 +2,3 @@
hooks/* text eol=lf hooks/* text eol=lf
*.sh text eol=lf *.sh text eol=lf
*.py 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
+1 -1
View File
@@ -1,6 +1,6 @@
node_modules/ node_modules/
dist*/ dist/
out/ out/
release/ release/
+11 -11
View File
@@ -8,10 +8,10 @@ The project as checked out does **not** build on a default up-to-date Windows de
1. **Added a Node version manager (`fnm`) and installed Node 20** alongside the existing Node 24. Node 24 was the system default and caused `nan` / `dll-inject` compile failures. Node 20 is now the fnm default but Node 24 is still available via `fnm use system`. 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`. 2. **Installed Visual Studio 2022 Build Tools** with the `VCTools` workload and Windows 11 SDK. Machine already had VS2026 (v18), but `node-gyp` v10 (shipped with Node 20's npm) doesn't detect it. VS2022 now lives side-by-side under `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools`.
3. **Unset `ELECTRON_RUN_AS_NODE`** per-shell before launching Electron. This var is set globally by VSCode's integrated terminal (inherited from the extension host): it is not something we can remove permanently without breaking VSCode. It has to be unset in each shell that runs `npm run dev` / `dist`. 3. **Unset `ELECTRON_RUN_AS_NODE`** per-shell before launching Electron. This var is set globally by VSCode's integrated terminal (inherited from the extension host) it is not something we can remove permanently without breaking VSCode. It has to be unset in each shell that runs `npm run dev` / `dist`.
4. **Populated `node_modules`** in both `main/` and `main/server/`. The tree was checked in empty. 4. **Populated `node_modules`** in both `main/` and `main/server/`. The tree was checked in empty.
Nothing in the repo itself was modified; all fixes were environmental. If another developer checks this repo out, they need to apply items 1-4 on their own machine. The sections below are that recipe. Nothing in the repo itself was modified all fixes were environmental. If another developer checks this repo out, they need to apply items 14 on their own machine. The sections below are that recipe.
--- ---
@@ -19,7 +19,7 @@ Nothing in the repo itself was modified; all fixes were environmental. If anothe
### 1. Node.js 20 (not 22, not 24) ### 1. Node.js 20 (not 22, not 24)
Node 24 breaks the `dll-inject` native module; its `nan` C++ bindings don't compile against V8 in Node 22+. Stick to Node 20 LTS. Node 24 breaks the `dll-inject` native module its `nan` C++ bindings don't compile against V8 in Node 22+. Stick to Node 20 LTS.
Install via `fnm` so you can keep your system Node separate: Install via `fnm` so you can keep your system Node separate:
@@ -33,7 +33,7 @@ Verify: `node -v` should print `v20.x.x`.
### 2. Visual Studio 2022 Build Tools (C++ workload) ### 2. Visual Studio 2022 Build Tools (C++ workload)
`dll-inject` and `stormlib-node` compile native addons via `node-gyp`. `node-gyp` v10 (bundled with Node 20's npm) only recognizes VS2017-2022; newer VS versions (2026 / v18) are not detected. `dll-inject` and `stormlib-node` compile native addons via `node-gyp`. `node-gyp` v10 (bundled with Node 20's npm) only recognizes VS20172022 newer VS versions (2026 / v18) are not detected.
```bash ```bash
winget install Microsoft.VisualStudio.2022.BuildTools \ winget install Microsoft.VisualStudio.2022.BuildTools \
@@ -75,7 +75,7 @@ cd ..
## Critical env var: `ELECTRON_RUN_AS_NODE` ## Critical env var: `ELECTRON_RUN_AS_NODE`
**VSCode's integrated terminal sets `ELECTRON_RUN_AS_NODE=1`** (inherited from VSCode's extension host). This makes Electron binaries launch as plain Node, so `require('electron')` returns a path string instead of the API: the app crashes with `TypeError: Cannot read properties of undefined (reading 'isPackaged')`. **VSCode's integrated terminal sets `ELECTRON_RUN_AS_NODE=1`** (inherited from VSCode's extension host). This makes Electron binaries launch as plain Node, so `require('electron')` returns a path string instead of the API the app crashes with `TypeError: Cannot read properties of undefined (reading 'isPackaged')`.
Before any `npm run dev` / `npm run build` / `npm run dist`: Before any `npm run dev` / `npm run build` / `npm run dist`:
@@ -87,7 +87,7 @@ unset ELECTRON_RUN_AS_NODE
Remove-Item Env:ELECTRON_RUN_AS_NODE Remove-Item Env:ELECTRON_RUN_AS_NODE
``` ```
An external terminal (Windows Terminal, cmd, plain PowerShell) doesn't have this problem; the variable is only set inside VSCode. An external terminal (Windows Terminal, cmd, plain PowerShell) doesn't have this problem the variable is only set inside VSCode.
## Running in dev ## Running in dev
@@ -99,8 +99,8 @@ Starts electron-vite, builds main + preload + renderer, opens an Electron window
You'll see benign warnings in the console: 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. - `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. - `Browserslist: caniuse-lite is outdated` cosmetic.
## Building for distribution ## Building for distribution
@@ -113,14 +113,14 @@ npm run dist
Outputs land in `dist/`: Outputs land in `dist/`:
- `OctoLauncher.exe`: portable single-file build - `OctoLauncher.exe` portable single-file build
- `OctoLauncher_Installer.exe`: NSIS installer - `OctoLauncher_Installer.exe` NSIS installer
Targets are configured in [electron-builder.yml](electron-builder.yml). Targets are configured in [electron-builder.yml](electron-builder.yml).
### Before publishing ### Before publishing
- The build uses `.env.production` (committed) which already points to `https://octowow.st`: no `.env` file needed for production builds. - The build uses `.env.production` (committed) which already points to `https://octowow.st` no `.env` file needed for production builds.
- Code signing is not configured. Unsigned Windows builds trigger SmartScreen warnings. To sign, add a `win.certificateFile` + password (or use env-based signing) to the electron-builder config. - 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 ## Troubleshooting
+10 -10
View File
@@ -1,6 +1,6 @@
# News feed # News feed
**Note:** The launcher no longer uses a static `news.json` file. The News tab now pulls live from the OctoWoW announcements forum via the website's `/news.json` endpoint, which is backed by `ForumFeedService` on the Laravel side. To publish a news item in the launcher, post in the configured announcements forum; the launcher will pick it up within the cache TTL (default 10 minutes). There is no JSON file to edit or deploy. **Note:** The launcher no longer uses a static `news.json` file. The News tab now pulls live from the OctoWoW announcements forum via the website's `/news.json` endpoint, which is backed by `ForumFeedService` on the Laravel side. To publish a news item in the launcher, simply post in the configured announcements forum the launcher will pick it up within the cache TTL (default 10 minutes). There is no JSON file to edit or deploy.
The launcher's News tab fetches `${MAIN_VITE_SERVER_URL}/news.json` and renders the entries on the landing screen. The endpoint is dynamic: it mirrors the same forum posts the website's homepage shows in its "Recent forum posts" cards, so updating the forum updates the launcher. The launcher's News tab fetches `${MAIN_VITE_SERVER_URL}/news.json` and renders the entries on the landing screen. The endpoint is dynamic: it mirrors the same forum posts the website's homepage shows in its "Recent forum posts" cards, so updating the forum updates the launcher.
@@ -12,7 +12,7 @@ The launcher's News tab fetches `${MAIN_VITE_SERVER_URL}/news.json` and renders
The route is served by Laravel (`routes/web.php``news.json`) and reads from `App\Services\ForumFeedService`, which fetches the configured phpBB Atom feed (`FORUM_FEED_BASE_URL`/`FORUM_FEED_MODE`/`FORUM_FEED_FORUM_ID` in `config/customs.php``forum_feed`). The same service backs the homepage's `recent-forum-posts` Livewire component, so what shows in the launcher is exactly what shows on the site. The route is served by Laravel (`routes/web.php``news.json`) and reads from `App\Services\ForumFeedService`, which fetches the configured phpBB Atom feed (`FORUM_FEED_BASE_URL`/`FORUM_FEED_MODE`/`FORUM_FEED_FORUM_ID` in `config/customs.php``forum_feed`). The same service backs the homepage's `recent-forum-posts` Livewire component, so what shows in the launcher is exactly what shows on the site.
No auth. The launcher times out after 8 seconds and validates the body against the schema below; malformed payloads surface as the "Couldn't reach the news feed" error state (with a Try again button). No auth. The launcher times out after 8 seconds and validates the body against the schema below malformed payloads surface as the "Couldn't reach the news feed" error state (with a Try again button).
## Payload contract ## Payload contract
@@ -34,7 +34,7 @@ No auth. The launcher times out after 8 seconds and validates the body against t
Source of truth for the schema: [src/common/schemas.ts](src/common/schemas.ts) (`NewsItemSchema`, `NewsFeedSchema`). If you change the contract, update both ends. 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: Notes:
- `items` is rendered in the order returned: sort newest-first on the server. - `items` is rendered in the order returned sort newest-first on the server.
- `body` is rendered as plain text with `whitespace-pre-wrap`. No HTML/markdown. - `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. - `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). - `id` should never change for an existing post (stable React keys, future bookmarking/read-state).
@@ -43,8 +43,8 @@ Notes:
There is no static file to edit anymore. To change what the launcher shows, post on the forum (`FORUM_FEED_BASE_URL`, e.g. `https://octowow.st/forum`). The next launcher fetch picks it up subject to two cache layers: 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. - `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. - `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). The launcher's react-query cache also holds for 5 minutes per session; users can hit the refresh icon in the News header to force a re-fetch (which still hits the two cache layers above).
@@ -62,11 +62,11 @@ The launcher's react-query cache also holds for 5 minutes per session; users can
curl -s ${MAIN_VITE_SERVER_URL}/news.json | jq . curl -s ${MAIN_VITE_SERVER_URL}/news.json | jq .
``` ```
Expected: a `{"items": [...]}` body. An empty `items: []` means the forum feed is reachable but has nothing matching the configured mode (or the cache is still warm with an empty result; bust it by `php artisan cache:clear` inside the website container, or wait `FORUM_FEED_CACHE_TTL` seconds). Expected: a `{"items": [...]}` body. An empty `items: []` means the forum feed is reachable but has nothing matching the configured mode (or the cache is still warm with an empty result bust it by `php artisan cache:clear` inside the website container, or wait `FORUM_FEED_CACHE_TTL` seconds).
**No items / errors:** **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. - `{"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`. - `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:** **End-to-end check in the launcher:**
1. Open the launcher (the News tab is the default view when no other tab is selected). 1. Open the launcher (the News tab is the default view when no other tab is selected).
@@ -78,11 +78,11 @@ Expected: a `{"items": [...]}` body. An empty `items: []` means the forum feed i
| Server response | UI behaviour | | Server response | UI behaviour |
| --- | --- | | --- | --- |
| `200` with valid JSON | Renders entries | | `200` with valid JSON | Renders entries |
| `200` with empty `items: []` | "No news yet: check back later." | | `200` with empty `items: []` | "No news yet check back later." |
| `200` with malformed JSON or missing required fields | Error state + Try again. Reason logged in main-process logs (`%APPDATA%\octo-launcher\logs\main.log`). | | `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. | | `404`, `5xx`, network unreachable, > 8s timeout | Error state + Try again. |
You don't need to ship a placeholder `news.json` to avoid 404s; the empty/error state is intentional. You don't need to ship a placeholder `news.json` to avoid 404s the empty/error state is intentional.
## Where the code lives ## Where the code lives
+12 -12
View File
@@ -18,7 +18,7 @@ Desktop launcher for the OctoWoW (World of Warcraft 1.12.1 private server) clien
2. Run it and set your WoW client directory when prompted. 2. Run it and set your WoW client directory when prompted.
3. Click **Verify** to download any missing game files, then **Play**. 3. Click **Verify** to download any missing game files, then **Play**.
No server configuration needed; the launcher connects to `octowow.st` by default. No server configuration needed the launcher connects to `octowow.st` by default.
--- ---
@@ -28,8 +28,8 @@ No server configuration needed; the launcher connects to `octowow.st` by default
| Requirement | Version | Notes | | Requirement | Version | Notes |
|---|---|---| |---|---|---|
| Node.js | 20 LTS | Node 22+ breaks `dll-inject` native bindings: use Node 20 | | 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 | | VS 2022 Build Tools | C++ workload + Win SDK | `node-gyp` v10 only detects VS20172022 |
| Python | 3.x | Required by `node-gyp` | | Python | 3.x | Required by `node-gyp` |
Install Node 20 with `fnm`: Install Node 20 with `fnm`:
@@ -51,7 +51,7 @@ winget install Microsoft.VisualStudio.2022.BuildTools `
npm install npm install
``` ```
`postinstall` rebuilds the native modules (`dll-inject`, `stormlib-node`) against the Electron ABI; expect C++ compiler output. `postinstall` rebuilds the native modules (`dll-inject`, `stormlib-node`) against the Electron ABI expect C++ compiler output.
### Run in development ### Run in development
@@ -64,7 +64,7 @@ npm install
npm run dev npm run dev
``` ```
Opens the app in a hot-reloading Electron window. The dev build points to `http://localhost:5000` by default; create a `.env` file from `.env.example` if you want to run against a local server, otherwise it falls back to `https://octowow.st`. Opens the app in a hot-reloading Electron window. The dev build points to `http://localhost:5000` by default create a `.env` file from `.env.example` if you want to run against a local server, otherwise it falls back to `https://octowow.st`.
### Build for distribution ### Build for distribution
@@ -74,8 +74,8 @@ npm run dist
``` ```
Outputs to `dist/`: Outputs to `dist/`:
- `OctoLauncher.exe`: portable single-file - `OctoLauncher.exe` portable single-file
- `OctoLauncher_Installer.exe`: NSIS installer - `OctoLauncher_Installer.exe` NSIS installer
The production build uses `.env.production` (committed) which points to `https://octowow.st`. No `.env` file needed. The production build uses `.env.production` (committed) which points to `https://octowow.st`. No `.env` file needed.
@@ -98,7 +98,7 @@ npm run dev
The server listens on `http://localhost:5000` and serves: The server listens on `http://localhost:5000` and serves:
- `GET /api/file/:version/manifest.json` - `GET /api/file/:version/manifest.json`
- `GET /client/:version/*`: per-file downloads - `GET /client/:version/*` per-file downloads
- `GET /api/addons.json` - `GET /api/addons.json`
--- ---
@@ -107,11 +107,11 @@ The server listens on `http://localhost:5000` and serves:
Three Vite bundles tied together by tRPC over Electron IPC: 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 - **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()` - **Preload** ([src/preload/](src/preload/)) secure IPC bridge via `exposeElectronTRPC()`
- **Renderer** ([src/renderer/](src/renderer/)): React 18 + Tailwind UI; no direct Node access - **Renderer** ([src/renderer/](src/renderer/)) React 18 + Tailwind UI; no direct Node access
All cross-process data shapes are Zod schemas in [src/common/schemas.ts](src/common/schemas.ts). All renderer→main calls go through tRPC procedures in [src/main/api/routers/](src/main/api/routers/); never raw `ipcMain.handle`. All cross-process data shapes are Zod schemas in [src/common/schemas.ts](src/common/schemas.ts). All renderer→main calls go through tRPC procedures in [src/main/api/routers/](src/main/api/routers/) never raw `ipcMain.handle`.
--- ---
+1 -1
View File
@@ -14,4 +14,4 @@ cd Tools\launcher
Output: `dist\OctoLauncher.exe` (portable) and `dist\OctoLauncher_Installer.exe` (NSIS). Output: `dist\OctoLauncher.exe` (portable) and `dist\OctoLauncher_Installer.exe` (NSIS).
The `node/` directory is gitignored; it is recreated by `install.ps1`. The `node/` directory is gitignored it is recreated by `install.ps1`.
+4 -4
View File
@@ -1,4 +1,4 @@
# opentracker: OctoWow launcher torrent swarm # opentracker OctoWow launcher torrent swarm
BitTorrent tracker the launcher's webtorrent clients announce to. Runs BitTorrent tracker the launcher's webtorrent clients announce to. Runs
on your VPS alongside the companion update server. Tiny (~2 MB RSS), on your VPS alongside the companion update server. Tiny (~2 MB RSS),
@@ -7,7 +7,7 @@ near-zero CPU, zero disk IO after boot.
**Why your own tracker**: public trackers (opentrackr.org, etc.) are **Why your own tracker**: public trackers (opentrackr.org, etc.) are
reliable enough for hobby swarms but add a single-point-of-failure you 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 don't control, and often rate-limit new info-hashes. The launcher also
announces over DHT, so your tracker is redundant with DHT, but it is announces over DHT, so your tracker is redundant with DHT but it's
the fastest path for a fresh peer to find the swarm before DHT has the fastest path for a fresh peer to find the swarm before DHT has
warmed up. warmed up.
@@ -21,7 +21,7 @@ chmod +x install.sh
./install.sh ./install.sh
``` ```
`install.sh` is idempotent; re-run to update. It builds opentracker `install.sh` is idempotent re-run to update. It builds opentracker
from CVS (only distribution upstream offers), installs it under from CVS (only distribution upstream offers), installs it under
`/opt/opentracker/bin/`, drops a hardened systemd unit, and starts the `/opt/opentracker/bin/`, drops a hardened systemd unit, and starts the
service bound to `0.0.0.0:6969`. service bound to `0.0.0.0:6969`.
@@ -51,7 +51,7 @@ TRACKER_URL=http://<your-vps-ip>:6969/announce npm run server
Default is `http://127.0.0.1:6969/announce` (assumes tracker + companion Default is `http://127.0.0.1:6969/announce` (assumes tracker + companion
server run on the same VPS, which is the normal deployment). server run on the same VPS, which is the normal deployment).
Clients pull the `.torrent` blob from the companion server; the URL Clients pull the `.torrent` blob from the companion server the URL
is already baked in by `create-torrent` at generation time, so no is already baked in by `create-torrent` at generation time, so no
launcher-side config needed. launcher-side config needed.
+2 -2
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=opentracker: BitTorrent tracker for OctoWow launcher swarm Description=opentracker BitTorrent tracker for OctoWow launcher swarm
After=network.target After=network.target
[Service] [Service]
@@ -11,7 +11,7 @@ ExecStart=/opt/opentracker/bin/opentracker -i 0.0.0.0 -p 6969 -P 6969
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
# Hardening: opentracker does no filesystem IO after boot, so most of # Hardening opentracker does no filesystem IO after boot, so most of
# the namespace can be locked down. # the namespace can be locked down.
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
+8 -15
View File
@@ -1,24 +1,18 @@
productName: OctoLauncher productName: OctoLauncher
appId: st.octowow.launcher
directories: directories:
buildResources: build buildResources: build
output: distprod output: dist
files: files:
- '!**/.vscode/*' - '!**/.vscode/*'
- '!src/*' - '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}' - '!electron.vite.config.{js,ts,mjs,cjs}'
# Strip every project-root .md file from the asar bundle
- '!*.md' - '!*.md'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,.prettierrc.cjs,dev-app-update.yml}' - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,.prettierrc.cjs,dev-app-update.yml}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!*.tsbuildinfo'
- '!{tailwind.config.ts,postcss.config.cjs}' - '!{tailwind.config.ts,postcss.config.cjs}'
- '!dist*/**' - '!{dist,dist-new,dist-test,out/main/chunks}/**'
- '!out/main/chunks/**'
- '!{.gitea,.github}/**'
- '!Tools/**'
- '!**/builder-debug.yml'
- '!**/electron-builder.*'
- '!.launcher/**' - '!.launcher/**'
- '!WTF/**' - '!WTF/**'
- '!server/**' - '!server/**'
@@ -26,20 +20,19 @@ files:
- '!**/node_modules/**/{__tests__,test,tests,docs,example,examples,demo,demos,benchmark,benchmarks}/**' - '!**/node_modules/**/{__tests__,test,tests,docs,example,examples,demo,demos,benchmark,benchmarks}/**'
- '!**/node_modules/**/*.{tsx,map,markdown}' - '!**/node_modules/**/*.{tsx,map,markdown}'
- '!**/node_modules/**/build/Release/obj/**' - '!**/node_modules/**/build/Release/obj/**'
- '!**/node_modules/**/build/Release/{*.iobj,*.ipdb,*.recipe,*.exp,*.lib,*.pdb,*.obj}' - '!**/node_modules/**/build/Release/{*.iobj,*.ipdb,*.recipe}'
- '!**/node_modules/**/*.{vcxproj,vcxproj.filters}' - '!**/node_modules/**/*.{vcxproj,vcxproj.filters}'
asarUnpack:
- resources/**
npmRebuild: false npmRebuild: false
electronLanguages: en electronLanguages: en
extraResources:
- from: resources/aria2c.exe
to: aria2c.exe
win: win:
artifactName: ${productName}.${ext} artifactName: ${productName}.${ext}
target: target:
- portable
- nsis - nsis
nsis: nsis:
# versioned: differential updates need the old blockmap to stay fetchable artifactName: ${productName}_Installer.${ext}
artifactName: ${productName}_Installer-${version}.${ext}
uninstallDisplayName: ${productName} uninstallDisplayName: ${productName}
oneClick: false oneClick: false
removeDefaultUninstallWelcomePage: true removeDefaultUninstallWelcomePage: true
+1 -12
View File
@@ -2,7 +2,6 @@ import { resolve } from 'path';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { loadEnv } from 'vite';
const alias = { const alias = {
'~common': resolve('src/common'), '~common': resolve('src/common'),
@@ -11,16 +10,7 @@ const alias = {
'~build': resolve('build') '~build': resolve('build')
}; };
export default defineConfig(({ mode }) => { export default defineConfig({
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: { main: {
resolve: { alias }, resolve: { alias },
plugins: [externalizeDepsPlugin()] plugins: [externalizeDepsPlugin()]
@@ -32,5 +22,4 @@ export default defineConfig(({ mode }) => {
resolve: { alias }, resolve: { alias },
plugins: [react()] plugins: [react()]
} }
};
}); });
+2 -19
View File
@@ -1,12 +1,12 @@
{ {
"name": "octo-launcher", "name": "octo-launcher",
"version": "1.2.0", "version": "1.0.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "octo-launcher", "name": "octo-launcher",
"version": "1.2.0", "version": "1.0.18",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^1.0.3", "@electron-toolkit/preload": "^1.0.3",
@@ -20,7 +20,6 @@
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"dll-inject": "^0.0.3", "dll-inject": "^0.0.3",
"dompurify": "^3.4.11",
"electron-log": "^5.1.5", "electron-log": "^5.1.5",
"electron-trpc": "^0.5.2", "electron-trpc": "^0.5.2",
"electron-updater": "^5.3.0", "electron-updater": "^5.3.0",
@@ -1786,13 +1785,6 @@
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.5.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.5.tgz",
"integrity": "sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg==" "integrity": "sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg=="
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/verror": { "node_modules/@types/verror": {
"version": "1.10.9", "version": "1.10.9",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.9.tgz", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.9.tgz",
@@ -3433,15 +3425,6 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz",
+2 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "octo-launcher", "name": "octo-launcher",
"version": "1.3.6", "version": "1.0.27",
"description": "An Electron application for launching and updating the OctoWoW client", "description": "An Electron application for launching and updating the OctoWoW client",
"author": "OctoWoW", "author": "OctoWoW",
"copyright": "Copyright © 2026 OctoWoW", "copyright": "Copyright © 2026 OctoWoW",
@@ -12,11 +12,8 @@
"postinstall": "electron-builder install-app-deps && node scripts/scrub-native-paths.cjs", "postinstall": "electron-builder install-app-deps && node scripts/scrub-native-paths.cjs",
"build": "electron-vite build", "build": "electron-vite build",
"build:test": "electron-vite build --mode test", "build:test": "electron-vite build --mode test",
"build:ptr": "electron-vite build --mode ptr",
"pack": "electron-builder --config", "pack": "electron-builder --config",
"pack:ptr": "electron-builder --config electron-builder.ptr.yml", "dist": "tsc && npm run build && npm run pack"
"dist": "tsc && npm run build && npm run pack",
"dist:ptr": "tsc && npm run build:ptr && npm run pack:ptr"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^1.0.3", "@electron-toolkit/preload": "^1.0.3",
@@ -30,7 +27,6 @@
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"dll-inject": "^0.0.3", "dll-inject": "^0.0.3",
"dompurify": "^3.4.11",
"electron-log": "^5.1.5", "electron-log": "^5.1.5",
"electron-trpc": "^0.5.2", "electron-trpc": "^0.5.2",
"electron-updater": "^5.3.0", "electron-updater": "^5.3.0",
-97
View File
@@ -1,97 +0,0 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const FILL = 0x78;
function pathVariants(p) {
const set = new Set([p, p.replace(/\\/g, '/'), p.replace(/\//g, '\\')]);
return [...set].filter(Boolean);
}
const root = process.cwd();
const home = os.homedir();
let username = '';
try {
username = os.userInfo().username;
} catch {
username = '';
}
const secrets = [];
if (root.length > 2) secrets.push(...pathVariants(root));
if (home.length > 2 && home !== root) secrets.push(...pathVariants(home));
if (username.length >= 4) secrets.push(username);
const needles = [...new Set(secrets)]
.filter(s => s.length > 0)
.map(s => s.toLowerCase())
.sort((a, b) => b.length - a.length);
function scanAndFill(buf, s, stride) {
const n = s.length;
const span = n * stride;
if (span === 0 || span > buf.length) return 0;
let hits = 0;
outer: for (let i = 0; i + span <= buf.length; i++) {
for (let j = 0; j < n; j++) {
const at = i + j * stride;
let b = buf[at];
if (b >= 0x41 && b <= 0x5a) b += 0x20;
if (b !== s.charCodeAt(j)) continue outer;
if (stride === 2 && buf[at + 1] !== 0x00) continue outer;
}
buf.fill(FILL, i, i + span);
hits++;
i += span - 1;
}
return hits;
}
function redact(buf) {
let hits = 0;
for (const s of needles) {
hits += scanAndFill(buf, s, 1);
hits += scanAndFill(buf, s, 2);
}
return hits;
}
function collect(dir, out) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) collect(p, out);
else if (e.isFile() && p.endsWith('.node')) out.push(p);
}
}
const addons = [];
collect(path.join(root, 'node_modules'), addons);
let files = 0;
let total = 0;
const redacted = [];
for (const file of addons) {
try {
const buf = fs.readFileSync(file);
const hits = redact(buf);
if (hits > 0) {
fs.writeFileSync(file, buf);
files++;
total += hits;
redacted.push(`${path.relative(root, file)} (${hits})`);
}
} catch (err) {
console.warn(`scrub-native-paths: skipped ${path.basename(file)} (${err.message})`);
}
}
console.log(`scrub-native-paths: redacted ${total} path reference(s) across ${files} addon(s)`);
for (const r of redacted) console.log(` ${r}`);
-23
View File
@@ -1,23 +0,0 @@
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"]
-545
View File
@@ -14,9 +14,6 @@
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.2.2" "typescript": "^5.2.2"
},
"devDependencies": {
"tsx": "^4.21.0"
} }
}, },
"node_modules/@cspotcode/source-map-support": { "node_modules/@cspotcode/source-map-support": {
@@ -30,448 +27,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/resolve-uri": { "node_modules/@jridgewell/resolve-uri": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
@@ -781,48 +336,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -923,21 +436,6 @@
"node": ">=14.14" "node": ">=14.14"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -960,19 +458,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-tsconfig": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
@@ -1232,16 +717,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1398,26 +873,6 @@
} }
} }
}, },
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+3 -5
View File
@@ -4,11 +4,12 @@
"main": "index.ts", "main": "index.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx src/index.ts" "dev": "node --loader ts-node/esm src/index.ts"
}, },
"dependencies": { "dependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"dotenv": "^16.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
@@ -17,8 +18,5 @@
"eslintConfig": { "eslintConfig": {
"extends": "@haaxor1689/eslint-config" "extends": "@haaxor1689/eslint-config"
}, },
"prettier": "@haaxor1689/prettier-config", "prettier": "@haaxor1689/prettier-config"
"devDependencies": {
"tsx": "^4.21.0"
}
} }
+47 -140
View File
@@ -26,9 +26,6 @@ type CacheEntry = { at: number; data: ResolvedAddon[] };
let cache: CacheEntry | undefined; let cache: CacheEntry | undefined;
let inFlight: Promise<ResolvedAddon[]> | 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 => const parseToc = (content: string): TocData =>
content content
.split('\n') .split('\n')
@@ -41,7 +38,7 @@ const parseToc = (content: string): TocData =>
}) })
.filter((e): e is readonly [string, string] => !!e) .filter((e): e is readonly [string, string] => !!e)
.reduce<TocData>((acc, [k, v]) => { .reduce<TocData>((acc, [k, v]) => {
acc[k] = normalizeColorCodes(v); acc[k] = v;
return acc; return acc;
}, {}); }, {});
@@ -55,137 +52,57 @@ const fetchWithTimeout = async (url: string, init?: RequestInit) => {
} }
}; };
type RepoMeta = { const parseGitUrl = (git: string) => {
description?: string; // https://github.com/{owner}/{repo}.git
defaultBranch?: string; const m = git.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
lastUpdated?: string; if (!m || !m[1] || !m[2]) throw Error(`Unsupported git URL: ${git}`);
stars?: number; return { owner: m[1], repo: m[2] };
};
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> => { const resolveOne = async (src: AddonSource): Promise<ResolvedAddon | null> => {
try { try {
const { owner, repo, provider } = parseGitUrl(src.git); const { owner, repo } = parseGitUrl(src.git);
const name = src.name ?? repo; const name = src.name ?? repo;
const branch = src.branch ?? 'master';
const tocRef = src.ref ?? branch;
const apiRes = await fetchWithTimeout(provider.apiUrl(owner, repo), { const tocUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${tocRef}/${name}.toc`;
headers: provider.apiHeaders() const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
}).catch(() => null);
let meta: RepoMeta | undefined; const [tocRes, apiRes] = await Promise.all([
if (apiRes?.ok) meta = provider.mapMeta((await apiRes.json()) as RawMeta); fetchWithTimeout(tocUrl).catch(() => null),
fetchWithTimeout(apiUrl, {
const candidates = src.ref headers: {
? [src.ref] Accept: 'application/vnd.github+json',
: src.branch ...(process.env.GITHUB_TOKEN && {
? [src.branch] Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
: [ })
...new Set( }
[meta?.defaultBranch, 'main', 'master'].filter( }).catch(() => null)
(b): b is string => !!b ]);
)
)
];
let toc: TocData | undefined; let toc: TocData | undefined;
let resolvedRef: string | undefined; if (tocRes?.ok) {
for (const ref of candidates) { const parsed = parseToc(await tocRes.text());
toc = await tryFetchToc(provider, owner, repo, name, ref); const required = ['Interface', 'Title', 'Author', 'Notes', 'Version'];
if (toc) { if (required.every(k => typeof parsed[k] === 'string')) {
resolvedRef = ref; toc = parsed;
break;
} }
} }
const effectiveBranch = src.ref let description: string | undefined;
? undefined let lastUpdated: string | undefined;
: src.branch ?? resolvedRef ?? meta?.defaultBranch; let stars: number | undefined;
if (apiRes?.ok) {
let description = meta?.description ?? undefined; const meta = (await apiRes.json()) as {
const lastUpdated = meta?.lastUpdated; description?: string;
const stars = meta?.stars; pushed_at?: string;
stargazers_count?: number;
};
description = meta.description ?? undefined;
lastUpdated = meta.pushed_at;
stars = meta.stargazers_count;
}
if (src.description) { if (src.description) {
description = src.description; description = src.description;
@@ -193,7 +110,7 @@ const resolveOne = async (src: AddonSource): Promise<ResolvedAddon | null> => {
} }
const result: ResolvedAddon = { name, owner, git: src.git }; const result: ResolvedAddon = { name, owner, git: src.git };
if (effectiveBranch !== undefined) result.branch = effectiveBranch; if (src.branch !== undefined) result.branch = src.branch;
if (src.ref !== undefined) result.ref = src.ref; if (src.ref !== undefined) result.ref = src.ref;
if (toc !== undefined) result.toc = toc; if (toc !== undefined) result.toc = toc;
if (description !== undefined) result.description = description; if (description !== undefined) result.description = description;
@@ -230,37 +147,26 @@ const loadSources = async (): Promise<AddonSource[]> => {
if (!SOURCES_OVERRIDE_PATH) return defaultSources; if (!SOURCES_OVERRIDE_PATH) return defaultSources;
try { try {
if (await fs.pathExists(SOURCES_OVERRIDE_PATH)) { if (await fs.pathExists(SOURCES_OVERRIDE_PATH)) {
const override = (await fs.readJSON( const override = (await fs.readJSON(SOURCES_OVERRIDE_PATH)) as AddonSource[];
SOURCES_OVERRIDE_PATH
)) as AddonSource[];
if (Array.isArray(override) && override.length > 0) { if (Array.isArray(override) && override.length > 0) {
console.log( console.log(`Using addon sources override from ${SOURCES_OVERRIDE_PATH}`);
`Using addon sources override from ${SOURCES_OVERRIDE_PATH}`
);
return override; return override;
} }
} }
} catch (e) { } catch (e) {
console.error( console.error(`Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`, e);
`Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`,
e
);
} }
return defaultSources; return defaultSources;
}; };
const buildList = async (): Promise<ResolvedAddon[]> => { const buildList = async (): Promise<ResolvedAddon[]> => {
const sources = await loadSources(); const sources = await loadSources();
console.log( console.log(`Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...`);
`Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...`
);
const t0 = Date.now(); const t0 = Date.now();
const results = await poolMap(sources, FETCH_CONCURRENCY, resolveOne); const results = await poolMap(sources, FETCH_CONCURRENCY, resolveOne);
const ok = results.filter((r): r is ResolvedAddon => r !== null); const ok = results.filter((r): r is ResolvedAddon => r !== null);
ok.sort((a, b) => a.name.localeCompare(b.name)); ok.sort((a, b) => a.name.localeCompare(b.name));
console.log( console.log(`Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms`);
`Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms`
);
return ok; return ok;
}; };
@@ -268,6 +174,7 @@ export const getAddons = async (force = false): Promise<ResolvedAddon[]> => {
if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) { if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) {
return cache.data; return cache.data;
} }
// Deduplicate concurrent callers — only one scrape in flight at a time.
if (inFlight) return inFlight; if (inFlight) return inFlight;
inFlight = buildList() inFlight = buildList()
.then(data => { .then(data => {
+32 -89
View File
@@ -7,56 +7,28 @@ export type AddonSource = {
}; };
export const defaultSources: AddonSource[] = [ export const defaultSources: AddonSource[] = [
{ { git: 'https://github.com/CosminPOP/AtlasLoot.git', name: 'AtlasLoot' },
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', git: 'https://github.com/byCFM2/Atlas-TW.git',
name: 'Atlas-CFM' branch: 'main',
}, ref: 'pre-rewrite-backup'
{
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', git: 'https://github.com/shirsig/aux-addon-vanilla.git',
name: 'aux-addon', name: 'aux-addon',
description: 'Auction House replacement with advanced filtering and search' description: 'Auction House replacement with advanced filtering and search'
}, },
{ git: 'https://github.com/absir/Bagshui.git' }, { git: 'https://github.com/absir/Bagshui.git', branch: 'main' },
{ git: 'https://github.com/pepopo978/BetterCharacterStats.git', branch: 'main' }, { git: 'https://github.com/pepopo978/BetterCharacterStats.git', branch: 'main' },
{ git: 'https://github.com/pepopo978/BigWigs.git' }, { git: 'https://github.com/pepopo978/BigWigs.git' },
{ {
git: 'https://github.com/DBFBlackbull/BitesCookBook.git', git: 'https://github.com/DBFBlackbull/BitesCookBook.git',
description: 'Tracks which items are used in cooking and what they create' description: 'Tracks which items are used in cooking and what they create'
}, },
{ { git: 'https://github.com/bhhandley/CleveRoidMacros.git', branch: 'main' },
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', git: 'https://github.com/Cinecom/ConsumesManager.git',
branch: 'main',
description: 'Tracks consumables and food buffs across alts, bank, and mail' description: 'Tracks consumables and food buffs across alts, bank, and mail'
}, },
{ {
@@ -64,37 +36,26 @@ export const defaultSources: AddonSource[] = [
name: 'Cursive-Raid', name: 'Cursive-Raid',
description: 'Raid debuff tracker with profiles and multi-curse assist (SuperWoW)' description: 'Raid debuff tracker with profiles and multi-curse assist (SuperWoW)'
}, },
{ { git: 'https://github.com/Player-Doite/DoiteAuras.git', branch: 'main' },
git: 'https://github.com/Zerf/Decursive.git', { git: 'https://github.com/Stormhand-dev/DragonflightUI-Reforged.git', branch: 'main' },
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', git: 'https://github.com/Fiurs-Hearth/ExtraResourceBars.git',
description: 'Adds extra resource bars (mana, energy, rage) to the UI' description: 'Adds extra resource bars (mana, energy, rage) to the UI'
}, },
{ { git: 'https://github.com/tilare/FlightTracker.git', branch: 'main' },
git: 'https://github.com/SeVeN7000/FishingBuddy.git', { git: 'https://github.com/lookino/Flyout.git', branch: 'main' },
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', git: 'https://github.com/trumpetx/GetHead.git',
description: 'Recovers Onyxia and Nefarian heads from disenchant grief' description: 'Recovers Onyxia and Nefarian heads from disenchant grief'
}, },
{ {
git: 'https://github.com/zanthor/GNS.git', git: 'https://github.com/zanthor/GNS.git',
branch: 'main',
description: 'Custom naming for Goblin Brainwashing Device specializations' description: 'Custom naming for Goblin Brainwashing Device specializations'
}, },
{ git: 'https://github.com/vatichild/guda.git', name: 'Guda' }, { git: 'https://github.com/vatichild/guda.git', name: 'Guda', branch: 'main' },
{ git: 'https://github.com/vatichild/GudaPlates.git' }, { git: 'https://github.com/vatichild/GudaPlates.git', branch: 'main' },
{ git: 'https://github.com/andresuarezschou/HCDeaths.git' }, { git: 'https://github.com/andresuarezschou/HCDeaths.git', branch: 'main' },
{ {
git: 'https://github.com/Arthur-Helias/InstanceJournal.git', git: 'https://github.com/Arthur-Helias/InstanceJournal.git',
description: "Encounter Journal reimagined for Turtle WoW" description: "Encounter Journal reimagined for Turtle WoW"
@@ -108,49 +69,43 @@ export const defaultSources: AddonSource[] = [
name: '_LazyPig', name: '_LazyPig',
description: 'Auto-dismount, auto-accept, auto-roll, and chat spam filter. /lp to configure' 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/Spartelfant/LevelRange-Turtle.git', branch: 'main' },
{ git: 'https://github.com/tilare/MessageBox.git' }, { git: 'https://github.com/tilare/MessageBox.git', branch: 'main' },
{ {
git: 'https://github.com/tdymel/ModifiedPowerAuras.git', git: 'https://github.com/tdymel/ModifiedPowerAuras.git',
description: "Advanced version of Sinesther's Power Auras" description: "Advanced version of Sinesther's Power Auras"
}, },
{ {
git: 'https://github.com/tilare/ModernMapMarkers.git', git: 'https://github.com/tilare/ModernMapMarkers.git',
branch: 'main',
description: 'Shows dungeons, raids, world bosses, and travel routes on the world map' description: 'Shows dungeons, raids, world bosses, and travel routes on the world map'
}, },
{ {
git: 'https://github.com/vegeta1k95/ModernSpellBook.git', git: 'https://github.com/vegeta1k95/ModernSpellBook.git',
description: 'Retail-style spellbook UI for vanilla' description: 'Retail-style spellbook UI for vanilla'
}, },
{ git: 'https://github.com/tilare/MovementTracker.git' }, { git: 'https://github.com/tilare/MovementTracker.git', branch: 'main' },
{ {
git: 'https://github.com/Dusk-92/NampowerSettings.git', git: 'https://github.com/pepopo978/NampowerSettings.git',
description: 'Settings panel for the Nampower spellqueue addon' description: 'Settings panel for the Nampower spellqueue addon'
}, },
{ {
git: 'https://github.com/BlackHobbiT/necrosis-twow.git', git: 'https://github.com/BlackHobbiT/necrosis-twow.git',
name: 'Necrosis', branch: 'main',
description: 'Warlock helper: pets, soul shards, summoning, demon timers' 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', git: 'https://github.com/zanthor/OG-RaidHelper.git',
branch: 'main',
description: 'Raid management: roles, trade distribution, soft-reserve validation' 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', git: 'https://github.com/CosminPOP/PallyPower.git',
description: 'Paladin buff and assignment manager for raids and parties' description: 'Paladin buff and assignment manager for raids and parties'
}, },
{ {
git: 'https://github.com/Cliencer/pfExtend.git', git: 'https://github.com/Cliencer/pfExtend.git',
branch: 'main',
description: 'pfQuest extension showing all monster drops and quest chains. /pfex' description: 'pfQuest extension showing all monster drops and quest chains. /pfex'
}, },
{ git: 'https://github.com/shagu/pfQuest.git' }, { git: 'https://github.com/shagu/pfQuest.git' },
@@ -158,10 +113,12 @@ export const defaultSources: AddonSource[] = [
{ git: 'https://github.com/shagu/pfUI.git' }, { git: 'https://github.com/shagu/pfUI.git' },
{ {
git: 'https://github.com/jrc13245/pfUI-addonskinner.git', git: 'https://github.com/jrc13245/pfUI-addonskinner.git',
branch: 'main',
description: 'pfUI module that re-skins other addons to match the pfUI theme' description: 'pfUI module that re-skins other addons to match the pfUI theme'
}, },
{ {
git: 'https://github.com/Bombg/pfUI-bettertotems.git', git: 'https://github.com/Bombg/pfUI-bettertotems.git',
branch: 'main',
description: 'pfUI module with improved Shaman totem timers' description: 'pfUI module with improved Shaman totem timers'
}, },
{ {
@@ -169,12 +126,13 @@ export const defaultSources: AddonSource[] = [
name: 'pfUI-locplus', name: 'pfUI-locplus',
description: 'Adds a location panel and zone info to pfUI' description: 'Adds a location panel and zone info to pfUI'
}, },
{ git: 'https://github.com/acid9000/PizzaWorldBuffs.git' }, { git: 'https://github.com/acid9000/PizzaWorldBuffs.git', branch: 'main' },
{ {
git: 'https://github.com/npfs666/ProcDoc.git', git: 'https://github.com/npfs666/ProcDoc.git',
branch: 'main',
description: 'Visual proc alerts with pulsing images so you never miss them' description: 'Visual proc alerts with pulsing images so you never miss them'
}, },
{ git: 'https://github.com/SabineWren/Quiver.git' }, { git: 'https://github.com/SabineWren/Quiver.git', branch: 'main' },
{ {
git: 'https://github.com/hazlema/Rested.git', git: 'https://github.com/hazlema/Rested.git',
description: 'Progress bar showing your rested XP while resting' description: 'Progress bar showing your rested XP while resting'
@@ -195,18 +153,10 @@ export const defaultSources: AddonSource[] = [
description: 'Extras module for ShaguTweaks (additional UI tweaks)' description: 'Extras module for ShaguTweaks (additional UI tweaks)'
}, },
{ git: 'https://github.com/pepopo978/SimpleActionSets.git' }, { 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/Siventt/AttackBar.git' },
{ {
git: 'https://github.com/Player-Doite/Tactica.git', git: 'https://github.com/Player-Doite/Tactica.git',
branch: 'main',
description: 'Auto-build raids: invite/gearcheck, tactics, masterloot, role sync' description: 'Auto-build raids: invite/gearcheck, tactics, masterloot, role sync'
}, },
{ {
@@ -215,32 +165,25 @@ export const defaultSources: AddonSource[] = [
}, },
{ {
git: 'https://github.com/whtmst/T-RestedXP.git', git: 'https://github.com/whtmst/T-RestedXP.git',
branch: 'main',
description: 'Tracks 0% and 100% rested XP thresholds' description: 'Tracks 0% and 100% rested XP thresholds'
}, },
{ git: 'https://github.com/sica42/TurtleCalendar.git' }, { git: 'https://github.com/sica42/TurtleCalendar.git', branch: 'main' },
{ {
git: 'https://github.com/sica42/TurtleMail.git', git: 'https://github.com/sica42/TurtleMail.git',
description: 'Mailbox UI enhancement: bulk send, search, multi-mail' description: 'Mailbox UI enhancement: bulk send, search, multi-mail'
}, },
{ git: 'https://github.com/tempranova/turtlerp.git', name: 'TurtleRP' }, { git: 'https://github.com/tempranova/turtlerp.git', name: 'TurtleRP', branch: 'main' },
{ git: 'https://github.com/CosminPOP/TWThreat.git' }, { 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', git: 'https://github.com/whtmst/UnitXP_SP3_Addon.git',
branch: 'main',
description: 'Settings UI for the UnitXP SuperWoW client patch' description: 'Settings UI for the UnitXP SuperWoW client patch'
}, },
{ {
git: 'https://github.com/tdymel/VCB.git', git: 'https://github.com/tdymel/VCB.git',
description: 'Smart consolidated buff frames with extensive customization' 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', git: 'https://github.com/Fiurs-Hearth/WIIIUI.git',
description: 'Compact custom UI replacement for Turtle WoW' description: 'Compact custom UI replacement for Turtle WoW'
+11 -234
View File
@@ -15,38 +15,11 @@ const allowedExtra = [
]; ];
const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf']; const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf'];
const raidVisuals = ['patch-O.mpq'];
const skipFiles = new Set([ const skipFiles = new Set(['manifest.json', 'wow-client.zip', '.gitkeep']);
'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 FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes' | 'raidVisuals'; type FileTags = 'vanillaFixes';
type FileManifest = { name: string } & ( type FileManifest = { name: string } & (
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] } | { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
@@ -58,23 +31,8 @@ type FileManifest = { name: string } & (
size: number; size: number;
tags?: FileTags[]; 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> => const getHash = (...filePath: string[]): Promise<string> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const hash = crypto.createHash('sha1'); const hash = crypto.createHash('sha1');
@@ -84,100 +42,9 @@ const getHash = (...filePath: string[]): Promise<string> =>
stream.on('end', () => resolve(hash.digest('hex').toLocaleUpperCase())); stream.on('end', () => resolve(hash.digest('hex').toLocaleUpperCase()));
}); });
const countFiles = async ( export const buildCache = async (clientPath: string) => {
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...'); 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 buildTree = async (...filePath: string[]): Promise<FileManifest[]> => {
const files = await fs.readdir(path.join(clientPath, ...filePath)); const files = await fs.readdir(path.join(clientPath, ...filePath));
@@ -185,40 +52,21 @@ export const buildCache = async (
const tree: FileManifest[] = []; const tree: FileManifest[] = [];
for (const file of files.sort()) { for (const file of files.sort()) {
if (skipFiles.has(file)) continue; if (skipFiles.has(file)) continue;
if (isSkipPattern(file)) continue;
const stats = await fs.stat(path.join(clientPath, ...filePath, file)); const stats = await fs.stat(path.join(clientPath, ...filePath, file));
if (stats.isDirectory()) { if (stats.isDirectory()) {
if (isSkipDir(...filePath, file)) continue;
if (file.match(/patch-./)) { 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); 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({ tree.push({
type: 'mpq', type: 'mpq',
name: file, name: file,
files: await buildTree(...filePath, file), files: await buildTree(...filePath, file),
size: mpqStat.size, size: (
hash: await getHashCached( await fs.stat(path.join(clientPath, ...filePath, `${file}.mpq`))
mpqRelPath, ).size,
mpqStat.mtimeMs, hash: await getHash(clientPath, ...filePath, `${file}.mpq`)
...filePath,
`${file}.mpq`
)
}); });
tick(mpqRelPath);
} else { } else {
const tags: FolderTags[] = []; const tags: FolderTags[] = [];
allowedExtra.includes(path.join(...filePath, file)) && allowedExtra.includes(path.join(...filePath, file)) &&
@@ -233,8 +81,8 @@ export const buildCache = async (
continue; continue;
} }
// Skip if extracted mpq patch
if (patches.find(v => file.match(v))) continue; if (patches.find(v => file.match(v))) continue;
const allowModifiedPaths = new Set([ const allowModifiedPaths = new Set([
'WTF/Config.wtf', 'WTF/Config.wtf',
'Data/fonts.MPQ', 'Data/fonts.MPQ',
@@ -250,97 +98,26 @@ export const buildCache = async (
const tags: FileTags[] = []; const tags: FileTags[] = [];
vanillaFixes.includes(file) && tags.push('vanillaFixes'); vanillaFixes.includes(file) && tags.push('vanillaFixes');
raidVisuals.includes(file) && tags.push('raidVisuals');
tree.push({ tree.push({
type: 'file', type: 'file',
name: file, name: file,
hash: await getHashCached(fullPath, stats.mtimeMs, ...filePath, file), hash: await getHash(clientPath, ...filePath, file),
version: allowModified ? stats.mtimeMs : undefined, version: allowModified ? stats.mtimeMs : undefined,
size: stats.size, size: stats.size,
tags: tags.length ? tags : undefined tags: tags.length ? tags : undefined
}); });
tick(fullPath);
} }
return tree; return tree;
}; };
const rootFiles = await buildTree(); await fs.writeJSON(path.join(clientPath, 'manifest.json'), {
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, build: 3,
buildName: '3', buildName: '3',
root: { root: {
type: 'dir', type: 'dir',
name: '', name: '',
files: rootFiles files: await buildTree()
} }
}); });
await fs.rename(tmpPath, finalPath);
if (prevManifestMtimeMs > 0) {
console.log(
`mtime-skip: reused ${reused}/${total} cached hashes ` +
`(re-hashed ${total - reused})`
);
}
}; };
+28 -107
View File
@@ -1,92 +1,61 @@
import path from 'path'; import path from 'path';
import { config as loadEnv } from 'dotenv';
import express from 'express'; import express from 'express';
loadEnv();
import fs from 'fs-extra'; import fs from 'fs-extra';
import { buildCache, type BuildProgress } from './cache.js'; import { buildCache } from './cache.js';
import { getAddons, warmUp as warmUpAddons } from './addons-resolver.js'; import { getAddons, warmUp as warmUpAddons } from './addons-resolver.js';
const SourceDir = process.env.SOURCE_DIR || './client'; // Set SOURCE_DIR to your local WoW client directory (see server/.env.example).
const SourceDir: string = (() => {
const dir = process.env.SOURCE_DIR;
if (!dir) {
console.error(
'ERROR: SOURCE_DIR is not set.\n' +
'Set it to your local WoW client directory.\n' +
'Example: SOURCE_DIR="C:\\\\WoW\\\\client" npm run dev\n' +
'Or create server/.env — see server/.env.example.'
);
process.exit(1);
}
return dir;
})();
const app = express(); const app = express();
const port = 5000; 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; let buildInFlight: Promise<void> | null = null;
const ensureManifestBuilt = (): Promise<void> => { const ensureManifestBuilt = (): Promise<void> => {
if (buildInFlight) return buildInFlight; if (buildInFlight) return buildInFlight;
buildProgress.state = 'building'; buildInFlight = buildCache(SourceDir).catch(e => {
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; buildInFlight = null;
throw e; throw e;
}); });
return buildInFlight; return buildInFlight;
}; };
app.get('/api/build-status', (_req, res) => {
res.json(buildProgress);
});
app.get('/api/file/:version/manifest.json', async (_req, res) => { app.get('/api/file/:version/manifest.json', async (_req, res) => {
console.log(`Fetching manifest`); console.log(`Fetching manifest`);
const filePath = path.join(SourceDir, 'manifest.json'); const filePath = path.join(SourceDir, 'manifest.json');
if (!fs.existsSync(filePath)) await ensureManifestBuilt();
if (await fs.pathExists(filePath)) {
res.json(await fs.readJSON(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( app.get(
'/api/file/:version/*', '/api/file/:version/*',
async (req: express.Request<{ 0: string }>, res) => { async (req: express.Request<{ 0: string }>, res) => {
const filePath = req.params[0]; const filePath = req.params[0];
console.log(`Fetching file: ${filePath}`); const resolved = path.resolve(SourceDir, filePath);
if (!resolved.startsWith(path.resolve(SourceDir) + path.sep)) {
const root = path.resolve(SourceDir); res.status(403).send('Forbidden');
const target = path.resolve(SourceDir, filePath);
if (target !== root && !target.startsWith(root + path.sep)) {
res.status(403).end();
return; return;
} }
console.log(`Fetching file: ${filePath}`);
res.sendFile(target); res.sendFile(resolved);
} }
); );
@@ -101,67 +70,19 @@ app.get('/api/addons.json', async (req, res) => {
} }
}); });
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, () => { app.listen(port, () => {
console.log(`Server listening on port ${port}`); console.log(`Server listening on port ${port}`);
warmUpAddons(); warmUpAddons();
void (async () => { void (async () => {
const manifestPath = path.join(SourceDir, 'manifest.json'); const manifestPath = path.join(SourceDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) { if (fs.existsSync(manifestPath)) return;
console.log(`Pre-warming manifest cache for ${SourceDir}...`); console.log(`Pre-warming manifest cache for ${SourceDir}...`);
try { try {
await ensureManifestBuilt(); await ensureManifestBuilt();
console.log(`Manifest cache pre-warm complete.`); console.log(`Manifest cache pre-warm complete.`);
} catch (e) { } catch (e) {
console.error( console.error('Manifest pre-warm failed:', e);
'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);
} }
})(); })();
}); });
+9 -44
View File
@@ -4,7 +4,6 @@ export const ModIdSchema = z.enum([
'dxvk', 'dxvk',
'nampower', 'nampower',
'multiMonitorFix', 'multiMonitorFix',
'superWow',
'transmogFix', 'transmogFix',
'unitXp', 'unitXp',
'vanillaFixes', 'vanillaFixes',
@@ -16,21 +15,22 @@ export type ModSource =
| { | {
kind: 'directFile'; kind: 'directFile';
url: string; url: string;
versionUrl?: string;
latestVersionUrl?: string;
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease'; parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
apiUrl?: string; apiUrl?: string;
pinnedTag?: string; pinnedTag?: string;
assetName: string; assetName: string;
sha256?: string;
} }
| { | {
kind: 'archive'; kind: 'archive';
url: string; url: string;
latestVersionUrl?: string;
apiUrl?: string; apiUrl?: string;
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease'; parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
pinnedTag?: string; pinnedTag?: string;
format: 'zip' | 'tar.gz'; format: 'zip' | 'tar.gz';
extractMap: Record<string, string>; extractMap: Record<string, string>;
sha256?: string;
} }
| { kind: 'managed' }; | { kind: 'managed' };
@@ -44,8 +44,6 @@ export type ModEntry = {
repoUrl: string; repoUrl: string;
source: ModSource; source: ModSource;
registerInDllsTxt?: string; registerInDllsTxt?: string;
// hidden from the Mods tab, never enabled on fresh installs; existing installs keep it
disabled?: boolean;
}; };
export const MODS: ModEntry[] = [ export const MODS: ModEntry[] = [
@@ -69,15 +67,15 @@ export const MODS: ModEntry[] = [
{ {
id: 'nampower', id: 'nampower',
name: 'nampower', name: 'nampower',
version: 'v4.6.2', version: 'v4.6.0',
description: description:
'A client modification that minimizes your input lag if you have higher latency.', 'A client modification that minimizes your input lag if you have higher latency.',
repoUrl: 'https://github.com/Emyrk/nampower', repoUrl: 'https://gitea.com/avitasia/nampower',
requires: ['vanillaFixes'], requires: ['vanillaFixes'],
source: { source: {
kind: 'directFile', kind: 'directFile',
url: 'https://github.com/Emyrk/nampower/releases/download/v4.6.2/nampower.dll', url: 'https://gitea.com/avitasia/nampower/releases/download/v4.6.0/nampower.dll',
pinnedTag: 'v4.6.2', pinnedTag: 'v4.6.0',
assetName: 'nampower.dll' assetName: 'nampower.dll'
}, },
registerInDllsTxt: 'nampower.dll' registerInDllsTxt: 'nampower.dll'
@@ -103,30 +101,6 @@ export const MODS: ModEntry[] = [
}, },
registerInDllsTxt: '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', id: 'transmogFix',
name: 'transmogFix', name: 'transmogFix',
@@ -165,8 +139,7 @@ export const MODS: ModEntry[] = [
id: 'vanillaFixes', id: 'vanillaFixes',
name: 'vanillaFixes', name: 'vanillaFixes',
version: 'v1.5.3', version: 'v1.5.3',
description: description: 'A client modification that eliminates stutter and animation lag.',
'A client modification that eliminates stutter and animation lag.',
recommended: true, recommended: true,
repoUrl: 'https://github.com/hannesmann/vanillafixes', repoUrl: 'https://github.com/hannesmann/vanillafixes',
source: { source: {
@@ -187,8 +160,7 @@ export const MODS: ModEntry[] = [
id: 'vanillaHelpers', id: 'vanillaHelpers',
name: 'vanillaHelpers', name: 'vanillaHelpers',
version: 'v1.1.2', version: 'v1.1.2',
description: description: 'Utility library that might be required by other patches and addons.',
'Utility library that might be required by other patches and addons.',
repoUrl: 'https://github.com/isfir/VanillaHelpers', repoUrl: 'https://github.com/isfir/VanillaHelpers',
requires: ['vanillaFixes'], requires: ['vanillaFixes'],
source: { source: {
@@ -206,10 +178,3 @@ export const MODS: ModEntry[] = [
export const getMod = (id: ModId): ModEntry | undefined => export const getMod = (id: ModId): ModEntry | undefined =>
MODS.find(m => m.id === id); MODS.find(m => m.id === id);
// fallback for profiles with no stored state: enabled, so legacy installs
// keep their mods; fresh installs seed explicit off rows instead (do NOT
// flip this list to change defaults, it strips mods from legacy profiles)
export const DEFAULT_ENABLED_MODS: ModId[] = MODS.filter(m => !m.disabled).map(
m => m.id
);
+3 -42
View File
@@ -17,7 +17,6 @@ const f = {
export const ConfigWtfSchema = z.object({ export const ConfigWtfSchema = z.object({
vanillaFixes: f.boolean(), vanillaFixes: f.boolean(),
raidVisuals: f.boolean(),
largeAddress: f.boolean(true), largeAddress: f.boolean(true),
nameplateRange: f.number(41), nameplateRange: f.number(41),
alwaysAutoLoot: f.boolean(), alwaysAutoLoot: f.boolean(),
@@ -37,18 +36,6 @@ export const ModStateSchema = z.object({
}); });
export type ModState = z.infer<typeof ModStateSchema>; 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({ export const PreferencesSchema = z.object({
isPortable: z.boolean().optional(), isPortable: z.boolean().optional(),
server: z.enum(['live', 'ptr']).default('live'), server: z.enum(['live', 'ptr']).default('live'),
@@ -57,21 +44,7 @@ export const PreferencesSchema = z.object({
lastPatchedLauncherVersion: z.string().optional(), lastPatchedLauncherVersion: z.string().optional(),
expectedPatchedWowHash: z.string().optional(), expectedPatchedWowHash: z.string().optional(),
minimizeToTrayOnPlay: f.boolean(true), minimizeToTrayOnPlay: f.boolean(true),
cleanWdb: f.boolean(true), cleanWdb: f.boolean(),
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(), rememberPosition: f.boolean(),
windowPosition: z windowPosition: z
.object({ .object({
@@ -82,9 +55,7 @@ export const PreferencesSchema = z.object({
}) })
.nullish(), .nullish(),
config: ConfigWtfSchema.default({}), config: ConfigWtfSchema.default({}),
mods: z.record(ModStateSchema).default({}), mods: z.record(ModStateSchema).default({})
hardware: HardwareInfoSchema.optional(),
farClipUserSet: z.boolean().optional()
}); });
export type PreferencesSchema = z.infer<typeof PreferencesSchema>; export type PreferencesSchema = z.infer<typeof PreferencesSchema>;
@@ -129,7 +100,7 @@ export const NewsItemSchema = z.object({
date: z.string(), date: z.string(),
body: z.string(), body: z.string(),
url: z.string().url().optional(), url: z.string().url().optional(),
author: z.string().nullish() author: z.string().optional()
}); });
export type NewsItem = z.infer<typeof NewsItemSchema>; export type NewsItem = z.infer<typeof NewsItemSchema>;
@@ -137,13 +108,3 @@ export const NewsFeedSchema = z.object({
items: z.array(NewsItemSchema) items: z.array(NewsItemSchema)
}); });
export type NewsFeed = z.infer<typeof NewsFeedSchema>; 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>;
+3 -11
View File
@@ -1,18 +1,10 @@
type Path = readonly (string | number)[]; type Path = readonly (string | number)[];
const isUnsafeKey = (key: string | number) =>
key === '__proto__' || key === 'constructor' || key === 'prototype';
export const nestedGet = <T>(object: unknown, path: Path) => export const nestedGet = <T>(object: unknown, path: Path) =>
path.reduce( path.reduce((obj, key) => obj?.[key], object) as T;
(obj, key) => (isUnsafeKey(key) ? undefined : obj?.[key]),
object
) as T;
export const nestedSet = (obj: any, path: Path, value: unknown) => { export const nestedSet = (obj: any, path: Path, value: unknown) => {
const [key, ...rest] = path; const [key, ...rest] = path;
if (isUnsafeKey(key)) return;
if (path.length === 1) { if (path.length === 1) {
obj[key] = value; obj[key] = value;
return; return;
@@ -47,7 +39,7 @@ export const asyncMap = async <T, U>(
export const isNotUndef = <T>(obj: T): obj is Exclude<T, undefined> => export const isNotUndef = <T>(obj: T): obj is Exclude<T, undefined> =>
obj !== undefined; obj !== undefined;
export const formatFileSize = (bytes: number, decimals = 2) => { export const formatFileSize = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB']; const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes; let size = bytes;
let unitIndex = 0; let unitIndex = 0;
@@ -57,7 +49,7 @@ export const formatFileSize = (bytes: number, decimals = 2) => {
unitIndex++; unitIndex++;
} }
return `${parseFloat(size.toFixed(decimals))} ${units[unitIndex]}`; return `${size.toFixed(2)} ${units[unitIndex]}`;
}; };
export const formatDuration = (remaining: number) => { export const formatDuration = (remaining: number) => {
-2
View File
@@ -6,7 +6,6 @@ import { patcherRouter } from './routers/patcher';
import { generalRouter } from './routers/general'; import { generalRouter } from './routers/general';
import { preferencesRouter } from './routers/preferences'; import { preferencesRouter } from './routers/preferences';
import { newsRouter } from './routers/news'; import { newsRouter } from './routers/news';
import { forumRouter } from './routers/forum';
import { modsRouter } from './routers/mods'; import { modsRouter } from './routers/mods';
import { selfUpdaterRouter } from './routers/selfUpdater'; import { selfUpdaterRouter } from './routers/selfUpdater';
@@ -18,7 +17,6 @@ export const appRouter = createTRPCRouter({
patcher: patcherRouter, patcher: patcherRouter,
updater: updaterRouter, updater: updaterRouter,
news: newsRouter, news: newsRouter,
forum: forumRouter,
mods: modsRouter, mods: modsRouter,
selfUpdater: selfUpdaterRouter selfUpdater: selfUpdaterRouter
}); });
-47
View File
@@ -1,47 +0,0 @@
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;
}
})
});
+2 -21
View File
@@ -1,46 +1,27 @@
import path from 'node:path';
import { app, dialog, shell } from 'electron'; import { app, dialog, shell } from 'electron';
import Logger from 'electron-log/main'; import Logger from 'electron-log/main';
import { z } from 'zod'; import { z } from 'zod';
import { mainWindow } from '~main/index'; import { mainWindow } from '~main/index';
import Preferences from '~main/modules/preferences'; 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'; import { createTRPCRouter, publicProcedure } from '../trpc';
export const generalRouter = createTRPCRouter({ export const generalRouter = createTRPCRouter({
appVersion: publicProcedure.query(() => app.getVersion()), 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()), quit: publicProcedure.mutation(() => app.quit()),
minimize: publicProcedure.mutation(() => mainWindow?.minimize()), minimize: publicProcedure.mutation(() => mainWindow?.minimize()),
openLink: publicProcedure openLink: publicProcedure
.input(z.string().url()) .input(z.string().url())
.mutation(({ input }) => shell.openExternal(input)), .mutation(({ input }) => shell.openExternal(input)),
openInstallFolder: publicProcedure.mutation(() => { openInstallFolder: publicProcedure.mutation(() => {
// Explorer needs native separators; a stored forward-slash path fails to open.
const dir = Preferences.data.clientDir; const dir = Preferences.data.clientDir;
if (dir) shell.openPath(path.normalize(dir)); if (dir) shell.openPath(dir);
}), }),
openLogFile: publicProcedure.mutation(() => { openLogFile: publicProcedure.mutation(() => {
const file = Logger.transports.file.getFile().path; const file = Logger.transports.file.getFile().path;
shell.openPath(path.normalize(file)); shell.openPath(file);
}), }),
addDefenderExclusion: publicProcedure.mutation(() => addDefenderExclusions()),
antivirusBlocks: publicProcedure.query(() => detectAntivirusBlocks()),
filePicker: publicProcedure filePicker: publicProcedure
.input( .input(
z.object({ z.object({
+57 -125
View File
@@ -2,171 +2,103 @@ import path from 'path';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { inject } from 'dll-inject';
import Logger from 'electron-log/main'; import Logger from 'electron-log/main';
import Preferences from '~main/modules/preferences'; import Preferences from '~main/modules/preferences';
import Mods from '~main/modules/mods'; import Mods from '~main/modules/mods';
import { mainWindow } from '~main/index'; import { mainWindow } from '~main/index';
import Updater, { isGameRunning } from '~main/modules/updater'; import { isGameRunning } from '~main/modules/updater';
import { import { patchConfig } from '~main/modules/patcher';
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 { minimizeToTray, restoreFromTray } from '~main/modules/tray';
import { getMod } from '~common/mods'; import { getMod } from '~common/mods';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
const chainloaderNeeded = async (clientDir: string): Promise<boolean> => { const ensureChainloaderTweak = async (clientDir: string): Promise<boolean> => {
const installed = Mods.status.mods.filter(r => r.installedVersion); if (Preferences.data.config.vanillaFixes) return true;
if (installed.some(r => r.id === 'vanillaFixes')) return true;
if (installed.some(r => getMod(r.id)?.requires?.includes('vanillaFixes')))
return true;
const installedMods = Mods.status.mods.filter(r => r.installedVersion);
const anyDependsOnVf = installedMods.some(r =>
getMod(r.id)?.requires?.includes('vanillaFixes')
);
let dllsTxtHasEntries = false;
const dllsPath = path.join(clientDir, 'dlls.txt'); const dllsPath = path.join(clientDir, 'dlls.txt');
if (await fs.pathExists(dllsPath)) { if (await fs.pathExists(dllsPath)) {
const raw = await fs.readFile(dllsPath, 'utf8'); const raw = await fs.readFile(dllsPath, 'utf8');
return raw.split(/\r?\n/).some(l => l.trim() && !l.trim().startsWith('#')); dllsTxtHasEntries = raw
.split(/\r?\n/)
.some(l => l.trim() && !l.trim().startsWith('#'));
} }
return false;
if (!anyDependsOnVf && !dllsTxtHasEntries) return false;
Logger.info(
`Auto-enabling vanillaFixes Tweak (chainloader required): ${
anyDependsOnVf ? 'a dependent mod is installed' : ''
}${anyDependsOnVf && dllsTxtHasEntries ? ' + ' : ''}${
dllsTxtHasEntries ? 'dlls.txt has user entries' : ''
}.`
);
Preferences.data = {
config: { ...Preferences.data.config, vanillaFixes: true }
};
return true;
}; };
type StartResult = { ok: boolean; error?: string };
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
let starting = false;
export const launcherRouter = createTRPCRouter({ export const launcherRouter = createTRPCRouter({
start: publicProcedure.mutation(async (): Promise<StartResult> => { start: publicProcedure.mutation(async () => {
if (starting) return { ok: false, error: 'The game is already launching.' }; const { cleanWdb, minimizeToTrayOnPlay, config, clientDir } =
starting = true; Preferences.data;
try { if (!clientDir) return false;
const { cleanWdb, minimizeToTrayOnPlay, clientDir } = Preferences.data;
if (!clientDir) return { ok: false, error: 'No game folder is set.' };
const exePath = path.join(clientDir, 'WoW.exe'); const clientPath = path.join(clientDir, 'WoW.exe');
if (!(await fs.pathExists(exePath))) Logger.log(`Launching ${clientPath}...`);
return { if (await isGameRunning(clientPath)) return false;
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) { if (cleanWdb) {
Logger.log('Cleaning up WDB...'); Logger.log('Cleaning up WDB...');
await fs.remove(path.join(clientDir, 'WDB')); await fs.remove(path.join(clientPath, 'WDB'));
} }
Logger.log('Syncing preferred monitor...');
await Mods.verify();
Logger.log('Checking Config.wtf...'); Logger.log('Checking Config.wtf...');
await patchConfig(); await patchConfig();
await ensureDxvkConf(clientDir);
await removeLegacyLocalePatches(clientDir); Logger.log('Launching WoW...');
const process = spawn(clientPath, { detached: !minimizeToTrayOnPlay });
if (Preferences.data.patchedLocale !== Preferences.data.locale) { const wantChainloader = await ensureChainloaderTweak(clientDir);
Logger.log( if (wantChainloader) {
`Applying the client language (${Preferences.data.locale})...` Logger.log('Injecting VanillaFixes...');
); const vfPath = path.join(clientDir, 'VfPatcher.dll');
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'); if (!(await fs.pathExists(vfPath))) {
const needsLoader = await chainloaderNeeded(clientDir);
const useLoader = needsLoader && (await fs.pathExists(loaderPath));
if (useLoader) await syncVanillaFixesCache(clientDir);
if (needsLoader && !useLoader)
Logger.warn( Logger.warn(
'VanillaFixes.exe is missing but mods/dlls.txt expect a chainloader; ' + `VfPatcher.dll missing at ${vfPath} — chainloader needed but ` +
'launching WoW.exe directly (mods will not load).' 'the vanillaFixes mod is not installed. Skipping inject; ' +
'dlls.txt entries and dependent mods will not load. Install ' +
"vanillaFixes from the Mods tab to fix."
); );
} else {
Logger.log( const status = inject('WoW.exe', vfPath);
useLoader ? 'Launching via VanillaFixes...' : `Launching ${exePath}...` if (status) {
); Logger.error(`Injecting failed with error code ${status}...`);
const child = useLoader return true;
? 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) { if (!minimizeToTrayOnPlay) {
mainWindow?.close(); mainWindow?.close();
return { ok: true }; return true;
} }
minimizeToTray(); minimizeToTray();
if (useLoader) { process.on('exit', () => {
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'); Logger.log('WoW stopped');
restoreFromTray(); restoreFromTray();
}); });
} return true;
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;
}
}) })
}); });
-19
View File
@@ -1,10 +1,6 @@
import path from 'path';
import { z } from 'zod'; import { z } from 'zod';
import Mods from '~main/modules/mods'; import Mods from '~main/modules/mods';
import Preferences from '~main/modules/preferences';
import { isGameRunning } from '~main/modules/updater';
import { ModIdSchema } from '~common/mods'; import { ModIdSchema } from '~common/mods';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -15,24 +11,9 @@ export const modsRouter = createTRPCRouter({
toggle: publicProcedure toggle: publicProcedure
.input(z.object({ id: ModIdSchema, enabled: z.boolean() })) .input(z.object({ id: ModIdSchema, enabled: z.boolean() }))
.mutation(({ input }) => Mods.toggle(input.id, input.enabled)), .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 setIgnoreUpdates: publicProcedure
.input(z.object({ id: ModIdSchema, ignore: z.boolean() })) .input(z.object({ id: ModIdSchema, ignore: z.boolean() }))
.mutation(({ input }) => Mods.setIgnoreUpdates(input.id, input.ignore)), .mutation(({ input }) => Mods.setIgnoreUpdates(input.id, input.ignore)),
applyAll: publicProcedure.mutation(() => Mods.applyAll()), 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()) observe: publicProcedure.subscription(() => Mods.observe())
}); });
+5 -17
View File
@@ -1,4 +1,3 @@
import { z } from 'zod';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import Logger from 'electron-log/main'; import Logger from 'electron-log/main';
@@ -8,14 +7,8 @@ import { createTRPCRouter, publicProcedure } from '../trpc';
const FETCH_TIMEOUT_MS = 8_000; const FETCH_TIMEOUT_MS = 8_000;
// Boards octonews.php exposes as a list: 2 = Announcements, 4 = Patch Notes. const fetchNews = async (): Promise<NewsItem[]> => {
const FEED_FORUMS = [2, 4]; const url = `${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/news.json`;
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 controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try { try {
@@ -23,10 +16,7 @@ const fetchNews = async (forum: number): Promise<NewsItem[]> => {
if (!res.ok) throw Error(`HTTP ${res.status}`); if (!res.ok) throw Error(`HTTP ${res.status}`);
const parsed = NewsFeedSchema.safeParse(await res.json()); const parsed = NewsFeedSchema.safeParse(await res.json());
if (!parsed.success) { if (!parsed.success) {
Logger.error( Logger.error('News feed failed schema validation', parsed.error.flatten());
'News feed failed schema validation',
parsed.error.flatten()
);
throw Error('Malformed news feed'); throw Error('Malformed news feed');
} }
return parsed.data.items; return parsed.data.items;
@@ -36,11 +26,9 @@ const fetchNews = async (forum: number): Promise<NewsItem[]> => {
}; };
export const newsRouter = createTRPCRouter({ export const newsRouter = createTRPCRouter({
list: publicProcedure list: publicProcedure.query(async () => {
.input(z.object({ forum: z.number() }).optional())
.query(async ({ input }) => {
try { try {
return await fetchNews(input?.forum ?? 2); return await fetchNews();
} catch (e) { } catch (e) {
Logger.error('Failed to fetch news', e); Logger.error('Failed to fetch news', e);
throw e; throw e;
+1 -10
View File
@@ -1,22 +1,13 @@
import { patchConfig, patchExecutable } from '~main/modules/patcher'; import { patchConfig, patchExecutable } from '~main/modules/patcher';
import Preferences from '~main/modules/preferences'; import Preferences from '~main/modules/preferences';
import Updater from '~main/modules/updater';
import { getClientVersion } from '~main/utils'; import { getClientVersion } from '~main/utils';
import { stopSeeding } from '~main/modules/aria2';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
export const patcherRouter = createTRPCRouter({ export const patcherRouter = createTRPCRouter({
apply: publicProcedure.mutation(async () => { apply: publicProcedure.mutation(async () => {
// release the seeder's file handles so the patchers can write
stopSeeding();
try {
await patchExecutable(); await patchExecutable();
await patchConfig(true); await patchConfig();
await Updater.recordPatchedWow();
Preferences.data = { version: await getClientVersion() }; Preferences.data = { version: await getClientVersion() };
} finally {
await Updater.refreshSeeding();
}
}) })
}); });
-4
View File
@@ -2,7 +2,6 @@ import { z } from 'zod';
import { PreferencesSchema } from '~common/schemas'; import { PreferencesSchema } from '~common/schemas';
import Preferences from '~main/modules/preferences'; import Preferences from '~main/modules/preferences';
import Updater from '~main/modules/updater';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -11,10 +10,7 @@ export const preferencesRouter = createTRPCRouter({
set: publicProcedure set: publicProcedure
.input(PreferencesSchema.partial()) .input(PreferencesSchema.partial())
.mutation(async ({ input }) => { .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; Preferences.data = input;
if (input.shareDownloads !== undefined) void Updater.refreshSeeding();
return Preferences.data; return Preferences.data;
}), }),
isValidClientDir: publicProcedure isValidClientDir: publicProcedure
-1
View File
@@ -6,7 +6,6 @@ import { createTRPCRouter, publicProcedure } from '../trpc';
export const updaterRouter = createTRPCRouter({ export const updaterRouter = createTRPCRouter({
verify: publicProcedure.mutation(() => Updater.verify()), verify: publicProcedure.mutation(() => Updater.verify()),
syncRaidVisuals: publicProcedure.mutation(() => Updater.syncRaidVisuals()),
update: publicProcedure update: publicProcedure
.input(z.boolean().optional()) .input(z.boolean().optional())
.mutation(async ({ input }) => Updater.update(input)), .mutation(async ({ input }) => Updater.update(input)),
+7 -118
View File
@@ -1,62 +1,31 @@
import { join } from 'path'; import { join } from 'path';
import { app, shell, session, BrowserWindow, screen } from 'electron'; import { app, shell, BrowserWindow } from 'electron';
import { electronApp, optimizer, is } from '@electron-toolkit/utils'; import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import { createIPCHandler } from 'electron-trpc/main'; import { createIPCHandler } from 'electron-trpc/main';
import Logger from 'electron-log/main'; import Logger from 'electron-log/main';
import icon from '~build/icon.png?asset'; import icon from '~build/icon.png?asset';
import { PreferencesSchema } from '~common/schemas';
import { appRouter } from './api/root'; import { appRouter } from './api/root';
import { stopSyncing, stopSeeding } from './modules/aria2';
import Preferences from './modules/preferences'; import Preferences from './modules/preferences';
import Updater from './modules/updater'; import Updater from './modules/updater';
import Addons from './modules/addons'; import Addons from './modules/addons';
import Mods from './modules/mods'; import Mods from './modules/mods';
import { initSelfUpdater } from './modules/selfUpdater'; import { initSelfUpdater } from './modules/selfUpdater';
import {
detectHardware,
recommendFarClip,
HARDWARE_SCHEMA_VERSION
} from './modules/hardware';
Logger.initialize(); Logger.initialize();
Logger.errorHandler.startCatching(); Logger.errorHandler.startCatching();
Logger.transports.ipc.level = false;
Logger.info('Launcher starting...'); Logger.info('Launcher starting...');
app.disableHardwareAcceleration(); app.disableHardwareAcceleration();
export let mainWindow: BrowserWindow | null = null; 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 createWindow = async () => {
const saved = const position = Preferences.data.rememberPosition
Preferences.data.rememberPosition &&
isOnScreen(Preferences.data.windowPosition)
? Preferences.data.windowPosition ? Preferences.data.windowPosition
: undefined; : { width: 1000, height: 700 };
const position = saved ?? { width: 1000, height: 700 };
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
...position, ...position,
@@ -81,22 +50,16 @@ const createWindow = async () => {
Logger.error('Renderer unresponsive'); Logger.error('Renderer unresponsive');
}); });
mainWindow.webContents.on( mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => {
'console-message',
(_e, level, message, line, sourceId) => {
const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info'; const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info';
Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`); Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`);
} });
);
mainWindow.webContents.on('before-input-event', (_e, input) => { mainWindow.webContents.on('before-input-event', (_e, input) => {
if (input.type !== 'keyDown') return; if (input.type !== 'keyDown') return;
if (input.key === 'F12') { if (input.key === 'F12') {
mainWindow?.webContents.toggleDevTools(); mainWindow?.webContents.toggleDevTools();
return;
} }
if ((input.control || input.meta) && input.key.toLowerCase() === 'c')
mainWindow?.webContents.copy();
}); });
createIPCHandler({ router: appRouter, windows: [mainWindow] }); createIPCHandler({ router: appRouter, windows: [mainWindow] });
@@ -122,76 +85,15 @@ const createWindow = async () => {
} }
}; };
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 () => { app.whenReady().then(async () => {
// defaults on failure so createWindow() below still runs
try {
Preferences.data = await Preferences.load(); Preferences.data = await Preferences.load();
} catch (e) {
Logger.error('Preferences.load() failed; starting on defaults', e);
Preferences.data = PreferencesSchema.parse({});
}
Addons.verify(); Addons.verify();
Updater.verify(); Updater.verify();
Mods.verify(); Mods.verify();
initSelfUpdater(); initSelfUpdater();
void (async () => { electronApp.setAppUserModelId('com.electron');
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) => { app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window); optimizer.watchWindowShortcuts(window);
@@ -200,19 +102,6 @@ if (!gotSingleInstanceLock) {
await createWindow(); await createWindow();
}); });
let settingsFlushed = false; app.on('window-all-closed', async () => {
app.on('before-quit', event => {
stopSyncing();
stopSeeding();
if (settingsFlushed) return;
settingsFlushed = true;
event.preventDefault();
Promise.race([Preferences.save(), new Promise(r => setTimeout(r, 3000))])
.catch(e => Logger.error('Failed to flush settings before quit', e))
.finally(() => app.quit());
});
app.on('window-all-closed', () => {
app.quit(); app.quit();
}); });
}
+21 -71
View File
@@ -35,50 +35,22 @@ type AddonsList = {
}[]; }[];
const readTocData = (content: string) => const readTocData = (content: string) =>
(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content) content
.split('\n') .split('\n')
.filter(l => l.startsWith('## ')) .filter(l => l.startsWith('## '))
.map(l => l.slice(3)) .map(l => l.slice(3))
.map(l => { .map(l => {
const idx = l.indexOf(':'); const [key, value] = l.split(':');
if (idx === -1) return null; return [key.trim(), value.trim()];
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]) => { .reduce((acc, [key, value]) => {
acc[key] = value; acc[key] = value;
return acc; return acc;
}, {} as TocData); }, {} 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 () => { const fetchAddons = async () => {
try { try {
const response = await fetch( const response = await fetch(`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/addons.json`);
`${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/api/addons.json`
);
return (await response.json()) as AddonsList; return (await response.json()) as AddonsList;
} catch (e) { } catch (e) {
Logger.error('Failed to reach update server', e); Logger.error('Failed to reach update server', e);
@@ -131,35 +103,35 @@ class AddonsClass extends Observable<AddonsStatus> {
}; };
async checkGitUrl(url: string) { async checkGitUrl(url: string) {
const clean = url.trim().replace(/\/+$/, ''); const gitUrl = url.endsWith('.git') ? url : `${url}.git`;
const gitUrl = clean.endsWith('.git') ? clean : `${clean}.git`;
if (!isAllowedGitUrl(gitUrl)) return undefined;
try { try {
await git.getRemoteInfo({ await git.getRemoteInfo({
http, http,
url: gitUrl url: gitUrl
}); });
// Only fetch preview from known public git hosts to prevent SSRF.
const allowed = ['github.com', 'gitlab.com', 'gitea.com', 'codeberg.org'];
let preview: string | undefined; let preview: string | undefined;
try { try {
if (isAllowedGitUrl(url)) { const host = new URL(url).hostname.toLowerCase();
if (allowed.some(h => host === h || host.endsWith('.' + h))) {
const response = await fetch(url).then(r => r.text()); const response = await fetch(url).then(r => r.text());
preview = response.match( preview = response.match(
/property="og:image" content="([^"]*)"/ /property="og:image" content="([^"]*)"/
)?.[1]; )?.[1];
} }
} catch { } catch {
// preview stays undefined
} }
const folder = gitUrl.slice(0, -4).split('/').at(-1);
if (isUnsafeFolder(folder)) return undefined;
return { return {
status: 'available', status: 'available',
folder, folder: gitUrl.slice(0, -4).split('/').at(-1),
git: gitUrl, git: gitUrl,
preview preview
} as AddonData; } as AddonData;
} catch { } catch (e) {
return undefined; return undefined;
} }
} }
@@ -190,12 +162,12 @@ class AddonsClass extends Observable<AddonsStatus> {
} }
const addonsPath = path.join(clientPath, 'Interface', 'Addons'); const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dirs = (await fs.pathExists(addonsPath)) const dirs = await fs.pathExists(addonsPath)
? await fs.readdir(addonsPath) ? await fs.readdir(addonsPath)
: []; : [];
const addons: AddonsStatus['addons'] = Object.fromEntries( const addons: AddonsStatus['addons'] = Object.fromEntries(
dirs dirs
.filter(d => !d.startsWith('Blizzard_') && !/\.(tmp|bak)$/.test(d)) .filter(d => !d.startsWith('Blizzard_'))
.map(name => [name, { status: 'fetching' as const, folder: name }]) .map(name => [name, { status: 'fetching' as const, folder: name }])
); );
@@ -251,7 +223,9 @@ class AddonsClass extends Observable<AddonsStatus> {
.catch(() => null); .catch(() => null);
const remoteCommit = avail?.ref const remoteCommit = avail?.ref
? await git.resolveRef({ fs, dir, ref: avail.ref }).catch(() => null) ? await git
.resolveRef({ fs, dir, ref: avail.ref })
.catch(() => null)
: await git : await git
.log({ fs, dir, ref: `${remote.remote}/${branch}`, depth: 1 }) .log({ fs, dir, ref: `${remote.remote}/${branch}`, depth: 1 })
.then(r => r[0].oid) .then(r => r[0].oid)
@@ -275,9 +249,7 @@ class AddonsClass extends Observable<AddonsStatus> {
Logger.log( Logger.log(
isUpToDate isUpToDate
? `Addon "${folder}" is up to date${ ? `Addon "${folder}" is up to date${avail?.ref ? ` (pinned ${avail.ref})` : ''}`
avail?.ref ? ` (pinned ${avail.ref})` : ''
}`
: `Addon "${folder}" has an update available` : `Addon "${folder}" has an update available`
); );
} catch (e) { } catch (e) {
@@ -296,16 +268,13 @@ class AddonsClass extends Observable<AddonsStatus> {
const VERIFY_CONCURRENCY = 6; const VERIFY_CONCURRENCY = 6;
let idx = 0; let idx = 0;
await Promise.all( await Promise.all(
Array.from( Array.from({ length: Math.min(VERIFY_CONCURRENCY, folders.length) }, async () => {
{ length: Math.min(VERIFY_CONCURRENCY, folders.length) },
async () => {
while (true) { while (true) {
const i = idx++; const i = idx++;
if (i >= folders.length) return; if (i >= folders.length) return;
await verifyOne(folders[i]); await verifyOne(folders[i]);
} }
} })
)
); );
this.status = { ...this.status, state: 'done' }; this.status = { ...this.status, state: 'done' };
@@ -361,7 +330,7 @@ class AddonsClass extends Observable<AddonsStatus> {
{ onProgress: this.#onProgress(folder, data) } { onProgress: this.#onProgress(folder, data) }
); );
} }
const toc = readTocData( const toc = await readTocData(
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8') await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
); );
@@ -394,25 +363,6 @@ class AddonsClass extends Observable<AddonsStatus> {
async install(data: AddonData) { async install(data: AddonData) {
const clientPath = Preferences.data.clientDir; const clientPath = Preferences.data.clientDir;
if (!clientPath) return; 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 addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dir = path.join(addonsPath, data.folder); const dir = path.join(addonsPath, data.folder);
-514
View File
@@ -1,514 +0,0 @@
import crypto from 'crypto';
import path from 'path';
import { spawn, type ChildProcess } from 'child_process';
import { app } from 'electron';
import fs from 'fs-extra';
import Logger from 'electron-log/main';
import { mapPort, type PortMapping } from './upnp';
const TORRENT_NAME = 'client';
const bin = () =>
app.isPackaged
? path.join(process.resourcesPath, 'aria2c.exe')
: path.join(app.getAppPath(), 'resources', 'aria2c.exe');
type SyncOpts = {
torrentUrl: string;
clientDir: string;
totalBytes?: number;
checkIntegrity?: boolean;
seedTime?: number;
selectFiles?: number[];
onProgress?: (p: SyncProgress) => void;
signal?: AbortSignal;
};
export type SyncProgress = {
progress: number;
bytesDone: number;
bytesTotal: number;
bytesPerSecond: number;
};
const ensureJunction = async (clientDir: string): Promise<string> => {
await fs.ensureDir(clientDir);
const staging = path.join(app.getPath('userData'), 'torrent-root');
await fs.ensureDir(staging);
const link = path.join(staging, TORRENT_NAME);
const target = path.resolve(clientDir);
try {
const cur = await fs.lstat(link);
if (cur.isSymbolicLink() || cur.isDirectory()) {
const resolved = await fs.realpath(link).catch(() => '');
if (path.resolve(resolved) === target) return staging;
}
await fs.remove(link);
} catch {}
await fs.symlink(target, link, 'junction');
return staging;
};
const SIZE_UNITS: Record<string, number> = {
B: 1,
KiB: 1024,
MiB: 1024 ** 2,
GiB: 1024 ** 3,
TiB: 1024 ** 4
};
const toBytes = (s: string): number => {
const m = /^([\d.]+)(B|KiB|MiB|GiB|TiB)$/.exec(s.trim());
if (!m) return 0;
return parseFloat(m[1]) * (SIZE_UNITS[m[2]] ?? 1);
};
const parseProgress = (
line: string,
totalHint: number
): SyncProgress | undefined => {
const frac =
/([\d.]+(?:B|KiB|MiB|GiB|TiB))\/([\d.]+(?:B|KiB|MiB|GiB|TiB))\((\d+)%\)/.exec(
line
);
if (!frac) return undefined;
const dl = /DL:([\d.]+(?:B|KiB|MiB|GiB|TiB))/.exec(line);
return {
progress: parseInt(frac[3], 10) / 100,
bytesDone: toBytes(frac[1]),
bytesTotal: toBytes(frac[2]) || totalHint,
bytesPerSecond: dl ? toBytes(dl[1]) : 0
};
};
let syncChild: ChildProcess | undefined;
// aria2's --stop-with-process is unreliable on Windows; kill the download
// child explicitly on quit or it keeps running headless
export const stopSyncing = (): void => {
syncChild?.kill();
syncChild = undefined;
};
export const syncClient = (opts: SyncOpts): Promise<void> =>
new Promise<void>((resolve, reject) => {
let child: ChildProcess | undefined;
ensureJunction(opts.clientDir)
.then(dir => {
const args = [
`--dir=${dir}`,
`--seed-time=${opts.seedTime ?? 0}`,
`--check-integrity=${opts.checkIntegrity ? 'true' : 'false'}`,
'--bt-save-metadata=true',
'--bt-remove-unselected-file=false',
'--continue=true',
'--allow-overwrite=true',
'--auto-file-renaming=false',
'--file-allocation=none',
'--disk-cache=128M',
'--stream-piece-selector=inorder',
'--max-tries=0',
'--retry-wait=5',
'--bt-stop-timeout=120',
'--auto-save-interval=15',
'--summary-interval=1',
'--console-log-level=warn',
'--enable-dht=true',
'--bt-enable-lpd=true',
'--max-connection-per-server=8',
'--split=16',
'--min-split-size=1M',
...(opts.selectFiles?.length
? [`--select-file=${opts.selectFiles.join(',')}`]
: []),
'--stop-with-process=' + process.pid,
opts.torrentUrl
];
Logger.log(`aria2c ${args.join(' ')}`);
child = spawn(bin(), args, { windowsHide: true });
syncChild = child;
const onLine = (buf: Buffer) => {
for (const line of buf.toString().split(/\r?\n/)) {
if (!line.trim()) continue;
const p = parseProgress(line, opts.totalBytes ?? 0);
if (p) opts.onProgress?.(p);
else Logger.log(`[aria2] ${line}`);
}
};
child.stdout?.on('data', onLine);
child.stderr?.on('data', onLine);
opts.signal?.addEventListener('abort', () => child?.kill());
child.on('error', reject);
child.on('close', code => {
if (syncChild === child) syncChild = undefined;
if (code === 0) resolve();
else reject(new Error(`aria2c exited with code ${code}`));
});
})
.catch(reject);
});
export const aria2Available = () => fs.pathExists(bin());
export const downloadIsComplete = async (): Promise<boolean> => {
const control = path.join(
app.getPath('userData'),
'torrent-root',
`${TORRENT_NAME}.aria2`
);
return !(await fs.pathExists(control));
};
export const clearTorrentResumeState = async (): Promise<void> => {
const dir = path.join(app.getPath('userData'), 'torrent-root');
await Promise.all([
fs.remove(path.join(dir, `${TORRENT_NAME}.aria2`)),
fs.remove(path.join(dir, `${TORRENT_NAME}.torrent`))
]);
};
export const torrentUrl = (): string | undefined =>
import.meta.env.MAIN_VITE_CLIENT_TORRENT_URL || undefined;
export const isTorrentMode = (): boolean => !!torrentUrl();
export const raidVisualsUrl = (): string | undefined =>
import.meta.env.MAIN_VITE_RAID_VISUALS_URL || undefined;
export const clientPatchUrl = (): string | undefined =>
import.meta.env.MAIN_VITE_CLIENT_PATCH_URL || undefined;
export const fetchTorrentSha = async (url: string): Promise<string> => {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const buf = Buffer.from(await r.arrayBuffer());
return crypto.createHash('sha1').update(buf).digest('hex');
};
const bdecode = (buf: Buffer, pos = 0): [unknown, number] => {
if (pos >= buf.length) throw new Error('bencode: unexpected end of data');
const ch = buf[pos];
if (ch === 0x69) {
const end = buf.indexOf(0x65, pos);
if (end === -1) throw new Error('bencode: unterminated integer');
return [parseInt(buf.toString('latin1', pos + 1, end), 10), end + 1];
}
if (ch === 0x6c) {
const list: unknown[] = [];
let p = pos + 1;
while (buf[p] !== 0x65) {
if (p >= buf.length) throw new Error('bencode: unterminated list');
const [v, np] = bdecode(buf, p);
list.push(v);
p = np;
}
return [list, p + 1];
}
if (ch === 0x64) {
const dict: Record<string, unknown> = {};
let p = pos + 1;
while (buf[p] !== 0x65) {
if (p >= buf.length) throw new Error('bencode: unterminated dict');
const [k, kp] = bdecode(buf, p);
const [v, vp] = bdecode(buf, kp);
dict[k as string] = v;
p = vp;
}
return [dict, p + 1];
}
const colon = buf.indexOf(0x3a, pos);
if (colon === -1) throw new Error('bencode: unterminated string length');
const len = parseInt(buf.toString('latin1', pos, colon), 10);
if (!Number.isInteger(len) || len < 0 || colon + 1 + len > buf.length)
throw new Error('bencode: invalid string length');
const start = colon + 1;
return [buf.toString('latin1', start, start + len), start + len];
};
const torrentDataArchives = (torrentBytes: Buffer): Set<string> => {
const [torrent] = bdecode(torrentBytes) as [
{ info?: { files?: { path?: string[] }[] } },
number
];
const files = torrent?.info?.files ?? [];
return new Set(
files
.filter(
f =>
f.path?.length === 2 &&
f.path[0] === 'Data' &&
/\.mpq$/i.test(f.path[1])
)
.map(f => f.path![1].toLowerCase())
);
};
const LOCALE_DIRS = new Set([
'enus',
'engb',
'encn',
'entw',
'kokr',
'frfr',
'dede',
'zhcn',
'zhtw',
'eses',
'esmx',
'ruru',
'ptbr',
'ptpt',
'itit'
]);
const torrentDataDirs = (torrentBytes: Buffer): Set<string> => {
const [torrent] = bdecode(torrentBytes) as [
{ info?: { files?: { path?: string[] }[] } },
number
];
const files = torrent?.info?.files ?? [];
return new Set(
files
.filter(f => (f.path?.length ?? 0) >= 3 && f.path![0] === 'Data')
.map(f => f.path![1].toLowerCase())
);
};
// archives the old client shipped under names the current one no longer uses;
// matched by name AND exact size so player mods reusing a name are never touched
const LEGACY_ARCHIVES: Record<string, number> = {
'patch-6.mpq': 451195806,
'patch-7.mpq': 175256564,
'patch-8.mpq': 484649870,
'patch-9.mpq': 506808141,
'patch-a.mpq': 241751337
};
export const pruneStaleArchives = async (
clientDir: string,
url: string,
_owned: Set<string>
): Promise<string[]> => {
try {
const r = await fetch(url);
if (!r.ok) return [];
const bytes = Buffer.from(await r.arrayBuffer());
const expected = torrentDataArchives(bytes);
if (!expected.size) return [];
for (const u of [clientPatchUrl(), raidVisualsUrl()])
if (u) expected.add(path.basename(u).toLowerCase());
const usedDirs = torrentDataDirs(bytes);
const dataDir = path.join(clientDir, 'Data');
const onDisk = await fs.readdir(dataDir).catch(() => []);
const removed: string[] = [];
for (const name of onDisk) {
const lc = name.toLowerCase();
const full = path.join(dataDir, name);
const st = await fs.stat(full).catch(() => null);
if (!st) continue;
if (st.isDirectory()) {
if (LOCALE_DIRS.has(lc) && !usedDirs.has(lc)) {
await fs.remove(full);
removed.push(name + '/');
}
continue;
}
if (!/\.mpq$/i.test(name) || expected.has(lc)) continue;
if (LEGACY_ARCHIVES[lc] !== st.size) continue;
await fs.remove(full);
removed.push(name);
}
return removed;
} catch (e) {
Logger.warn('Prune of stale archives failed', e);
return [];
}
};
// files the torrent ships but a mod toggle owns; the sync must not re-add
// them or count their absence as an incomplete tree
const LAUNCHER_OWNED_FILES = new Set(['d3d9.dll']);
const isLauncherOwned = (parts: string[]) =>
parts.length === 1 && LAUNCHER_OWNED_FILES.has(parts[0].toLowerCase());
export const torrentDownloadSelection = async (
clientDir: string,
url: string,
dropMismatched = false
): Promise<number[] | null> => {
try {
const r = await fetch(url);
if (!r.ok) return null;
const [torrent] = bdecode(Buffer.from(await r.arrayBuffer())) as [
{ info?: { files?: { path?: string[]; length?: number }[] } },
number
];
const files = torrent?.info?.files ?? [];
if (!files.length) return null;
const need: number[] = [];
let missing = false;
for (let i = 0; i < files.length; i++) {
const f = files[i];
if (!f.path?.length || typeof f.length !== 'number') return null;
if (isLauncherOwned(f.path)) continue;
const dest = path.join(clientDir, ...f.path);
const st = await fs.stat(dest).catch(() => null);
if (!st) {
missing = true;
need.push(i + 1);
continue;
}
if (st.size !== f.length) {
if (dropMismatched || st.size > f.length)
await fs.remove(dest).catch(() => {});
need.push(i + 1);
}
}
// a deleted file poisons the resume state (its pieces are marked
// done, so aria2 skips them forever); partial files keep it so an
// interrupted download resumes instead of restarting
if (missing) await clearTorrentResumeState().catch(() => undefined);
return need;
} catch (e) {
Logger.warn('Torrent selection computation failed', e);
return null;
}
};
export const torrentTreeIntact = async (
clientDir: string,
url: string
): Promise<boolean> => {
try {
const r = await fetch(url);
if (!r.ok) return false;
const [torrent] = bdecode(Buffer.from(await r.arrayBuffer())) as [
{ info?: { files?: { path?: string[]; length?: number }[] } },
number
];
const files = torrent?.info?.files ?? [];
if (!files.length) return false;
for (const f of files) {
if (!f.path?.length || typeof f.length !== 'number') return false;
if (isLauncherOwned(f.path)) continue;
const st = await fs
.stat(path.join(clientDir, ...f.path))
.catch(() => null);
if (!st || st.size !== f.length) return false;
}
return true;
} catch (e) {
Logger.warn('Torrent tree check failed', e);
return false;
}
};
const LOCALE_ASSERT_OFFSET = 0x1b2115;
const pristineWowPath = () =>
path.join(app.getPath('userData'), 'base-WoW.exe');
export const refreshPristineWow = async (clientDir: string): Promise<void> => {
const exe = path.join(clientDir, 'WoW.exe');
if (!(await fs.pathExists(exe))) return;
const fd = await fs.open(exe, 'r');
try {
const b = Buffer.alloc(1);
await fs.read(fd, b, 0, 1, LOCALE_ASSERT_OFFSET);
if (b[0] === 0xa1) {
await fs.copy(exe, pristineWowPath(), { overwrite: true });
Logger.log('Cached pristine WoW.exe base');
}
} finally {
await fs.close(fd);
}
};
export const readPristineWow = async (clientDir: string): Promise<Buffer> => {
const cache = pristineWowPath();
if (await fs.pathExists(cache)) return fs.readFile(cache);
return fs.readFile(path.join(clientDir, 'WoW.exe'));
};
let seeder: ChildProcess | undefined;
let mapping: Promise<PortMapping> | undefined;
let wantSeeding = false;
let starting = false;
const SEED_PORT = 6881;
const SEED_TIME_MINUTES = 525600;
export const isSeeding = (): boolean => !!seeder;
const releaseMapping = (): void => {
const m = mapping;
mapping = undefined;
if (m) void m.then(x => x.stop()).catch(() => {});
};
export const stopSeeding = (): void => {
wantSeeding = false;
seeder?.kill();
seeder = undefined;
releaseMapping();
};
export const startSeeding = async (
clientDir: string,
uploadLimit = '2M'
): Promise<void> => {
wantSeeding = true;
if (seeder || starting) return;
starting = true;
try {
const url = torrentUrl();
if (!url) return;
const dir = await ensureJunction(clientDir);
if (!wantSeeding || seeder) return;
const args = [
`--dir=${dir}`,
'--bt-seed-unverified=true',
`--seed-time=${SEED_TIME_MINUTES}`,
'--check-integrity=false',
// no prealloc: the seeder must not recreate missing files as zeros
'--file-allocation=none',
'--continue=true',
'--bt-save-metadata=true',
'--enable-dht=true',
'--bt-enable-lpd=true',
`--listen-port=${SEED_PORT}`,
`--dht-listen-port=${SEED_PORT}`,
`--max-overall-upload-limit=${uploadLimit}`,
'--summary-interval=0',
'--console-log-level=warn',
'--stop-with-process=' + process.pid,
url
];
Logger.log('aria2c (seed) ' + args.join(' '));
const child = spawn(bin(), args, { windowsHide: true });
seeder = child;
const onExit = () => {
if (seeder === child) {
seeder = undefined;
releaseMapping();
}
};
child.on('close', onExit);
child.on('error', e => {
Logger.warn('Seeder failed', e);
onExit();
});
mapping = mapPort(SEED_PORT, { description: 'OctoWoW' });
void mapping.catch(() => undefined);
} catch (e) {
Logger.warn('startSeeding failed', e);
} finally {
starting = false;
}
};
-199
View File
@@ -1,199 +0,0 @@
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) {
// d3d9.dll is deliberately parked while dxvk is off, not blocked
if (
name === 'd3d9.dll' &&
Preferences.data.mods?.dxvk?.enabled === false
)
continue;
if (!fs.existsSync(path.join(clientDir, name))) blocked.add(name);
}
const script =
'Get-MpThreatDetection | Where-Object ' +
'{ $_.InitialDetectionTime -gt (Get-Date).AddHours(-12) } | ' +
'Select-Object -ExpandProperty Resources';
await new Promise<void>(resolve => {
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', script],
{ windowsHide: true }
);
let out = '';
let settled = false;
const timer = setTimeout(() => {
try {
child.kill();
} catch {}
}, 15_000);
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve();
};
child.stdout.on('data', d => (out += String(d)));
child.on('error', finish);
child.on('exit', () => {
for (const line of out.split(/\r?\n/)) {
const m = /^file:_?(.+)$/.exec(line.trim());
if (!m) continue;
const full = m[1];
if (
roots.some(r => full.toLowerCase().startsWith(r)) &&
!fs.existsSync(full)
)
blocked.add(path.basename(full));
}
finish();
});
});
return [...blocked];
};
-146
View File
@@ -1,146 +0,0 @@
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;
};
-24
View File
@@ -19,37 +19,16 @@ const readLines = async (clientDir: string): Promise<string[]> => {
return text.split(/\r?\n/); 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 writeLines = async (clientDir: string, lines: string[]) => {
const file = dllsPath(clientDir); const file = dllsPath(clientDir);
const trimmed = lines.join('\n').replace(/\n+$/, ''); const trimmed = lines.join('\n').replace(/\n+$/, '');
if (!trimmed.trim()) { if (!trimmed.trim()) {
if (await fs.pathExists(file)) await fs.remove(file); if (await fs.pathExists(file)) await fs.remove(file);
await writeCache(clientDir, []);
return; return;
} }
await fs.writeFile(file, trimmed + '\n', 'utf8'); 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) => const matches = (line: string, name: string) =>
line.trim().toLowerCase() === name.toLowerCase(); line.trim().toLowerCase() === name.toLowerCase();
@@ -74,6 +53,3 @@ export const hasDll = (clientDir: string, name: string) =>
const lines = await readLines(clientDir); const lines = await readLines(clientDir);
return lines.some(l => matches(l, name)); return lines.some(l => matches(l, name));
}); });
export const listDlls = (clientDir: string): Promise<string[]> =>
serial(async () => dllNames(await readLines(clientDir)));
-132
View File
@@ -1,132 +0,0 @@
import os from 'node:os';
import { spawn } from 'node:child_process';
import { app } from 'electron';
import Logger from 'electron-log/main';
import type { HardwareInfo } from '~common/schemas';
export const HARDWARE_SCHEMA_VERSION = 1;
export const FARCLIP_FLOOR = 777;
export const FARCLIP_CEILING = 3000;
const VIDEO_CLASS_GUID = '{4d36e968-e325-11ce-bfc1-08002be10318}';
const getVramMb = (): Promise<{
mb: number | null;
source: HardwareInfo['vramSource'];
}> => {
if (os.platform() !== 'win32')
return Promise.resolve({ mb: null, source: 'none' });
const script = [
'$ErrorActionPreference = "Stop"',
`$base = 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\${VIDEO_CLASS_GUID}'`,
'$max = [int64]0',
'Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {',
' $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue',
' $v = [int64]0',
" if ($p.'HardwareInformation.qwMemorySize') { $v = [int64]$p.'HardwareInformation.qwMemorySize' }",
" elseif ($p.'HardwareInformation.MemorySize') {",
" $m = $p.'HardwareInformation.MemorySize'",
' if ($m -is [byte[]]) { $v = [int64][System.BitConverter]::ToUInt32($m, 0) } else { $v = [int64]$m }',
' }',
' if ($v -gt $max) { $max = $v }',
'}',
'Write-Output $max'
].join('\n');
const encoded = Buffer.from(script, 'utf16le').toString('base64');
return new Promise(resolve => {
let settled = false;
const finish = (mb: number | null, source: HardwareInfo['vramSource']) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ mb, source });
};
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
{ windowsHide: true }
);
const timer = setTimeout(() => {
child.kill();
Logger.warn('VRAM detection timed out');
finish(null, 'none');
}, 8000);
let stdout = '';
child.stdout.on('data', d => (stdout += String(d)));
child.on('error', e => {
Logger.warn('VRAM detection failed to launch PowerShell', e);
finish(null, 'none');
});
child.on('exit', code => {
const bytes = Number(stdout.trim());
if (code === 0 && Number.isFinite(bytes) && bytes > 0)
finish(Math.round(bytes / 1024 / 1024), 'registry');
else finish(null, 'none');
});
});
};
const getGpuModel = async (): Promise<string> => {
try {
const info = (await app.getGPUInfo('complete')) as {
auxAttributes?: { glRenderer?: string };
gpuDevice?: { active?: boolean; vendorId?: number; deviceId?: number }[];
};
const renderer = info?.auxAttributes?.glRenderer?.trim();
if (renderer) return renderer;
const active = info?.gpuDevice?.find(d => d.active) ?? info?.gpuDevice?.[0];
if (active) return `vendor ${active.vendorId} device ${active.deviceId}`;
} catch (e) {
Logger.warn('GPU info detection failed', e);
}
return 'unknown';
};
export const detectHardware = async (): Promise<HardwareInfo> => {
const cpus = os.cpus();
const [vram, gpuModel] = await Promise.all([getVramMb(), getGpuModel()]);
const info: HardwareInfo = {
totalRamMb: Math.round(os.totalmem() / 1024 / 1024),
cpuCores: cpus.length,
cpuModel: cpus[0]?.model?.trim() || 'unknown',
gpuModel,
vramMb: vram.mb,
vramSource: vram.source,
detectedAt: new Date().toISOString(),
schemaVersion: HARDWARE_SCHEMA_VERSION
};
Logger.info('Detected hardware', info);
return info;
};
const clampFarClip = (n: number) =>
Math.min(FARCLIP_CEILING, Math.max(FARCLIP_FLOOR, Math.round(n)));
export const recommendFarClip = (hw: HardwareInfo | null): number => {
if (!hw) return clampFarClip(1000);
const ramGb = hw.totalRamMb / 1024;
const cores = hw.cpuCores;
const vramTrusted = hw.vramSource !== 'none' && hw.vramMb != null;
const vramGb = vramTrusted ? (hw.vramMb as number) / 1024 : null;
if (ramGb < 6 || cores <= 2) return clampFarClip(FARCLIP_FLOOR);
if (vramGb === null)
return clampFarClip(ramGb >= 8 && cores >= 4 ? 1500 : 1000);
if (ramGb < 8 || vramGb < 2) return clampFarClip(1000);
if (ramGb >= 32 && vramGb >= 8 && cores >= 8) return clampFarClip(3000);
if (ramGb >= 16 && vramGb >= 4 && cores >= 6) return clampFarClip(2200);
if (ramGb >= 8 && vramGb >= 2 && cores >= 4) return clampFarClip(1500);
return clampFarClip(1000);
};
-60
View File
@@ -1,60 +0,0 @@
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
};
};
+71 -450
View File
@@ -1,5 +1,5 @@
import path from 'path'; import path from 'path';
import { createHash } from 'crypto'; import os from 'os';
import fs from 'fs-extra'; import fs from 'fs-extra';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
@@ -7,73 +7,12 @@ import AdmZip from 'adm-zip';
import * as tar from 'tar'; import * as tar from 'tar';
import Logger from 'electron-log/main'; import Logger from 'electron-log/main';
import { import { MODS, type ModEntry, type ModId, getMod } from '~common/mods';
MODS,
DEFAULT_ENABLED_MODS,
type ModEntry,
type ModId,
getMod
} from '~common/mods';
import { type ModState } from '~common/schemas'; import { type ModState } from '~common/schemas';
import Preferences from './preferences'; import Preferences from './preferences';
import { isTorrentMode, stopSeeding } from './aria2';
import Observable from './observable'; import Observable from './observable';
import Updater from './updater'; import { addDll, removeDll } from './dllsTxt';
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.';
// pinned dxvk-gplasync v2.7.1-1 x32 d3d9.dll (same build the client ships)
const DXVK_DLL_SHA256 =
'a2cd6841e102f37189527c118ec416fa5071ac4d3120762973d9a0c6c5fd067e';
const fileSha256 = async (p: string): Promise<string | null> => {
try {
return createHash('sha256')
.update(await fs.readFile(p))
.digest('hex');
} catch {
return null;
}
};
const looksLikeAvBlock = (msg: string) =>
/windows defender|virus|potentially unwanted/i.test(msg);
export type ModRowStatus = { export type ModRowStatus = {
id: ModId; id: ModId;
@@ -91,30 +30,35 @@ export type ModRowStatus = {
error?: string; error?: string;
}; };
export type CustomMod = { name: string; enabled: boolean };
export type ModsStatus = { export type ModsStatus = {
state: 'verifying' | 'idle' | 'busy'; state: 'verifying' | 'idle' | 'busy';
dirty: boolean; dirty: boolean;
mods: ModRowStatus[]; mods: ModRowStatus[];
custom: CustomMod[];
// enabled mods whose files are missing (AV quarantine or incomplete sync)
missingFiles: string[];
}; };
const VERSION_CACHE_MS = 10 * 60 * 1000;
class ModsClass extends Observable<ModsStatus> { class ModsClass extends Observable<ModsStatus> {
protected _value: ModsStatus = { protected _value: ModsStatus = {
state: 'verifying', state: 'verifying',
dirty: false, dirty: false,
mods: [], mods: []
custom: [],
missingFiles: []
}; };
// staged custom-DLL toggles, keyed lower-case; #customApplied mirrors dlls.txt #latestCache = new Map<ModId, { v: string; ts: number }>();
#customDesired = new Map<string, boolean>();
#customApplied = new Map<string, boolean>(); installedFilePaths(): Set<string> {
#customNames = new Map<string, string>(); const set = new Set<string>();
const mods = Preferences.data?.mods ?? {};
for (const id of Object.keys(mods) as ModId[]) {
const state = mods[id];
if (!state?.installedFiles?.length) continue;
for (const rel of state.installedFiles) {
set.add(rel.replace(/\\/g, '/').toLowerCase());
}
}
return set;
}
get status(): ModsStatus { get status(): ModsStatus {
return this._value; return this._value;
@@ -147,7 +91,6 @@ class ModsClass extends Observable<ModsStatus> {
} }
#computeDirty(): boolean { #computeDirty(): boolean {
if (this.#customDesired.size > 0) return true;
return this._value.mods.some(r => { return this._value.mods.some(r => {
const wantInstalled = r.enabled; const wantInstalled = r.enabled;
const isInstalled = !!r.installedVersion; const isInstalled = !!r.installedVersion;
@@ -166,206 +109,20 @@ class ModsClass extends Observable<ModsStatus> {
this._value = { this._value = {
state: 'verifying', state: 'verifying',
dirty: false, dirty: false,
mods: MODS.filter(m => !m.disabled).map(m => this.#initialRow(m)), mods: MODS.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() { async verify() {
this.load(); this.load();
this._notifyObservers(); this._notifyObservers();
const clientDir = Preferences.data?.clientDir; const clientDir = Preferences.data?.clientDir;
if (clientDir) {
await this.#syncPreferredMonitor(clientDir);
}
const missing: string[] = [];
let dxvkRepair = false;
for (const m of MODS) { 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]; const state = Preferences.data?.mods?.[m.id];
let installedVersion = state?.installedVersion; let installedVersion = state?.installedVersion;
// torrent mode: DLLs ship in the client; a missing file goes to `missing`, not dirty
if (isTorrentMode()) {
const enabled = state?.enabled ?? DEFAULT_ENABLED_MODS.includes(m.id);
// dxvk loads by file presence and torrent piece spillover can
// corrupt it; hash-verify every state: park/restore verified
// copies only, delete junk, re-download the pin when needed
if (m.id === 'dxvk' && clientDir) {
const live = path.join(clientDir, 'd3d9.dll');
const off = path.join(clientDir, 'd3d9.dll.off');
const liveSha = await fileSha256(live);
if (!enabled) {
if (
liveSha === DXVK_DLL_SHA256 &&
!(await fs.pathExists(off))
) {
await fs
.move(live, off)
.then(() => Logger.info('dxvk disabled: parked d3d9.dll'))
.catch(e => Logger.warn('Could not park d3d9.dll', e));
} else if (liveSha !== null) {
await fs.remove(live).catch(() => undefined);
}
} else if (liveSha !== DXVK_DLL_SHA256) {
if (liveSha !== null) {
Logger.warn('dxvk: d3d9.dll failed verification; replacing');
await fs.remove(live).catch(() => undefined);
}
const offSha = await fileSha256(off);
if (offSha === DXVK_DLL_SHA256) {
await fs
.move(off, live)
.then(() => Logger.info('dxvk enabled: restored d3d9.dll'))
.catch(e => Logger.warn('Could not restore d3d9.dll', e));
} else {
if (offSha !== null)
await fs.remove(off).catch(() => undefined);
dxvkRepair = true;
}
}
}
const files = modTargetFiles(m);
const present =
!!clientDir &&
files.length > 0 &&
(
await Promise.all(
files.map(rel => fs.pathExists(path.join(clientDir, rel)))
)
).every(Boolean);
installedVersion = enabled ? m.version : undefined;
if (enabled && files.length > 0 && !present) missing.push(m.name);
// only point dlls.txt at a file actually on disk
if (clientDir && m.registerInDllsTxt)
await (present && enabled
? addDll(clientDir, m.registerInDllsTxt)
: removeDll(clientDir, m.registerInDllsTxt)
).catch(e => Logger.warn(`dlls.txt update failed for ${m.id}`, e));
this.#patchRow(m.id, {
installedVersion,
latestVersion: m.version,
enabled,
ignoreUpdates: true
});
continue;
}
if (clientDir && installedVersion) { if (clientDir && installedVersion) {
const filesPresent = await Promise.all( const filesPresent = await Promise.all(
(state?.installedFiles ?? []).map(rel => (state?.installedFiles ?? []).map(rel =>
@@ -383,84 +140,52 @@ class ModsClass extends Observable<ModsStatus> {
} }
} }
if (clientDir && m.registerInDllsTxt) const latest = await this.#fetchLatestVersion(m).catch(() => m.version);
await (installedVersion
? addDll(clientDir, m.registerInDllsTxt)
: removeDll(clientDir, m.registerInDllsTxt)
).catch(() => {});
this.#patchRow(m.id, { this.#patchRow(m.id, {
installedVersion, installedVersion,
latestVersion: m.version, latestVersion: latest,
enabled: !!state?.enabled, enabled: !!state?.enabled,
ignoreUpdates: !!state?.ignoreUpdates ignoreUpdates: !!state?.ignoreUpdates
}); });
} }
if (dxvkRepair) { this._value = { ...this._value, state: 'idle', dirty: this.#computeDirty() };
const dm = getMod('dxvk');
if (dm)
await this.#install(dm).catch(e =>
Logger.warn('dxvk repair download failed', e)
);
}
this._value = {
...this._value,
state: 'idle',
dirty: this.#computeDirty(),
custom: clientDir ? await this.#detectCustomDlls(clientDir) : [],
missingFiles: missing
};
this._notifyObservers(); this._notifyObservers();
} }
async toggleCustom(name: string, enabled: boolean) { async #fetchLatestVersion(m: ModEntry): Promise<string> {
const clientDir = Preferences.data?.clientDir; if (m.source.kind === 'managed') return m.version;
if (!clientDir) return; const cached = this.#latestCache.get(m.id);
// stage; matching dlls.txt clears the pending change if (cached && Date.now() - cached.ts < VERSION_CACHE_MS) return cached.v;
const lc = name.toLowerCase();
if (enabled === !!this.#customApplied.get(lc)) const apiUrl =
this.#customDesired.delete(lc); 'apiUrl' in m.source && m.source.apiUrl ? m.source.apiUrl : undefined;
else this.#customDesired.set(lc, enabled); const parser =
this._value = { 'parseLatest' in m.source && m.source.parseLatest
...this._value, ? m.source.parseLatest
custom: await this.#detectCustomDlls(clientDir) : undefined;
};
this._value = { ...this._value, dirty: this.#computeDirty() }; if (!apiUrl || !parser) {
this._notifyObservers(); const v = ('pinnedTag' in m.source && m.source.pinnedTag) || m.version;
this.#latestCache.set(m.id, { v, ts: Date.now() });
return v;
} }
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 { try {
const dest = path.join(clientDir, name); const res = await fetch(apiUrl, {
if (path.resolve(srcPath) !== path.resolve(dest)) headers: { 'User-Agent': 'OctoLauncher' }
await fs.copy(srcPath, dest, { overwrite: true }); });
if (!res.ok) throw new Error(`${apiUrl}${res.status}`);
const json = (await res.json()) as { tag_name?: string };
const tag = json.tag_name ?? m.version;
this.#latestCache.set(m.id, { v: tag, ts: Date.now() });
return tag;
} catch (e) { } catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }; Logger.warn(`Could not check latest version for ${m.id}:`, e);
const v = ('pinnedTag' in m.source && m.source.pinnedTag) || m.version;
return v;
} }
// 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) { async toggle(id: ModId, enabled: boolean) {
@@ -485,49 +210,12 @@ class ModsClass extends Observable<ModsStatus> {
this.#patchRow(id, { ignoreUpdates: ignore }); this.#patchRow(id, { ignoreUpdates: ignore });
} }
async applyAll(opts: { repairOnly?: boolean } = {}) { async applyAll() {
const clientDir = Preferences.data?.clientDir; const clientDir = Preferences.data?.clientDir;
if (!clientDir) { if (!clientDir) {
Logger.warn('No clientDir set; cannot apply mods.'); Logger.warn('No clientDir set; cannot apply mods.');
return; return;
} }
// don't commit a mod set with an unmet dependency; dirty stays set. repair is exempt.
if (!opts.repairOnly) {
const enabledIds = new Set(
this._value.mods.filter(r => r.enabled).map(r => r.id)
);
const missingDeps = [
...new Set(
this._value.mods
.filter(r => r.enabled)
.flatMap(r => r.requires.filter(dep => !enabledIds.has(dep)))
)
];
if (missingDeps.length) {
Logger.warn(
`Not applying mods: unmet dependencies ${missingDeps.join(', ')}`
);
return;
}
}
// commit the player's own DLL toggles first
await this.#applyCustomDlls(clientDir);
// torrent mode: mods ship in the client; reconcile dlls.txt. The
// seeder holds files open, so release it for the dxvk park/restore.
if (isTorrentMode()) {
stopSeeding();
try {
await this.verify();
} finally {
await Updater.refreshSeeding().catch(() => undefined);
}
return;
}
if (this._value.state === 'busy') {
Logger.warn('applyAll already running; ignoring re-entrant call.');
return;
}
await this.verify();
this._value = { ...this._value, state: 'busy' }; this._value = { ...this._value, state: 'busy' };
this._notifyObservers(); this._notifyObservers();
@@ -538,7 +226,6 @@ class ModsClass extends Observable<ModsStatus> {
return 0; return 0;
}); });
const failures = new Map<ModId, string>();
for (const row of queue) { for (const row of queue) {
const m = getMod(row.id); const m = getMod(row.id);
if (!m) continue; if (!m) continue;
@@ -555,77 +242,43 @@ class ModsClass extends Observable<ModsStatus> {
await this.#install(m); await this.#install(m);
} else if (!wantInstalled && isInstalled) { } else if (!wantInstalled && isInstalled) {
await this.#uninstall(m); await this.#uninstall(m);
} else if (wantInstalled && updateAvailable && !opts.repairOnly) { } else if (wantInstalled && updateAvailable) {
await this.#uninstall(m); await this.#uninstall(m);
await this.#install(m); await this.#install(m);
} }
} catch (e) { } catch (e) {
Logger.error(`Failed to apply ${m.id}:`, e); Logger.error(`Failed to apply ${m.id}:`, e);
const msg = e instanceof Error ? e.message : String(e); this.#patchRow(m.id, {
failures.set(m.id, looksLikeAvBlock(msg) ? AV_ERROR : msg); state: 'error',
error: e instanceof Error ? e.message : String(e)
});
} }
} }
this._value = { ...this._value, state: 'idle' }; this._value = { ...this._value, state: 'idle' };
await this.verify(); await this.verify();
for (const [id, error] of failures)
this.#patchRow(id, { state: 'error', error });
await Updater.verify();
} }
async #install(m: ModEntry) { async #install(m: ModEntry) {
const clientDir = Preferences.data?.clientDir; const clientDir = Preferences.data?.clientDir;
// dxvk: restoring a parked copy is the only enable path that works in
// torrent mode (nothing is fetched there, the sync ignores d3d9.dll)
if (m.id === 'dxvk' && clientDir) {
const live = path.join(clientDir, 'd3d9.dll');
const off = path.join(clientDir, 'd3d9.dll.off');
if (!(await fs.pathExists(live)) && (await fs.pathExists(off))) {
Logger.info('Restoring parked d3d9.dll for dxvk');
await fs.move(off, live);
await this.#savePref(m.id, {
enabled: true,
installedVersion: m.version,
installedFiles: ['d3d9.dll'],
ignoreUpdates:
Preferences.data?.mods?.[m.id]?.ignoreUpdates ?? false
});
this.#patchRow(m.id, {
state: 'idle',
installedVersion: m.version,
progress: 1
});
return;
}
}
// torrent mode ships mod binaries with the client; dxvk is the
// exception (unsynced), a fresh enable with no parked copy downloads
if (isTorrentMode() && m.id !== 'dxvk') return;
if (!clientDir) throw new Error('No client dir'); if (!clientDir) throw new Error('No client dir');
if (m.source.kind === 'managed') return; if (m.source.kind === 'managed') return;
Logger.info(`Installing mod ${m.id}...`); Logger.info(`Installing mod ${m.id}...`);
this.#patchRow(m.id, { this.#patchRow(m.id, { state: 'downloading', progress: 0, error: undefined });
state: 'downloading',
progress: 0,
error: undefined
});
const written: string[] = []; const written: string[] = [];
const missing: string[] = [];
if (m.source.kind === 'directFile') { if (m.source.kind === 'directFile') {
const dest = path.join(clientDir, m.source.assetName); const dest = path.join(clientDir, m.source.assetName);
await this.#downloadTo(m.source.url, dest, m.source.sha256); await this.#downloadTo(m.source.url, dest);
written.push(m.source.assetName); written.push(m.source.assetName);
} else if (m.source.kind === 'archive') { } else if (m.source.kind === 'archive') {
const scratch = path.join(clientDir, '.octolauncher-tmp');
await fs.ensureDir(scratch);
const tmp = path.join( const tmp = path.join(
scratch, os.tmpdir(),
`${m.id}-${Date.now()}.${m.source.format}` `octolauncher-${m.id}-${Date.now()}.${m.source.format}`
); );
await this.#downloadTo(m.source.url, tmp, m.source.sha256); await this.#downloadTo(m.source.url, tmp);
this.#patchRow(m.id, { state: 'installing' }); this.#patchRow(m.id, { state: 'installing' });
const map = m.source.extractMap; const map = m.source.extractMap;
@@ -635,7 +288,7 @@ class ModsClass extends Observable<ModsStatus> {
for (const [src, dst] of Object.entries(map)) { for (const [src, dst] of Object.entries(map)) {
const entry = entries.find(e => e.entryName === src); const entry = entries.find(e => e.entryName === src);
if (!entry) { if (!entry) {
missing.push(src); Logger.warn(`Mod ${m.id}: zip entry ${src} not found.`);
continue; continue;
} }
const target = path.join(clientDir, dst); const target = path.join(clientDir, dst);
@@ -644,13 +297,16 @@ class ModsClass extends Observable<ModsStatus> {
written.push(dst); written.push(dst);
} }
} else { } else {
const stagingDir = path.join(scratch, `${m.id}-${Date.now()}-extract`); const stagingDir = path.join(
os.tmpdir(),
`octolauncher-${m.id}-${Date.now()}-extract`
);
await fs.ensureDir(stagingDir); await fs.ensureDir(stagingDir);
await tar.x({ file: tmp, cwd: stagingDir }); await tar.x({ file: tmp, cwd: stagingDir });
for (const [src, dst] of Object.entries(map)) { for (const [src, dst] of Object.entries(map)) {
const srcPath = path.join(stagingDir, src); const srcPath = path.join(stagingDir, src);
if (!(await fs.pathExists(srcPath))) { if (!(await fs.pathExists(srcPath))) {
missing.push(src); Logger.warn(`Mod ${m.id}: tar entry ${src} not found.`);
continue; continue;
} }
const target = path.join(clientDir, dst); const target = path.join(clientDir, dst);
@@ -663,11 +319,6 @@ class ModsClass extends Observable<ModsStatus> {
await fs.remove(tmp).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) { if (m.registerInDllsTxt) {
await addDll(clientDir, m.registerInDllsTxt); await addDll(clientDir, m.registerInDllsTxt);
} }
@@ -695,20 +346,7 @@ class ModsClass extends Observable<ModsStatus> {
this.#patchRow(m.id, { state: 'uninstalling', error: undefined }); this.#patchRow(m.id, { state: 'uninstalling', error: undefined });
const cur = Preferences.data?.mods?.[m.id]; const cur = Preferences.data?.mods?.[m.id];
// dxvk: park instead of delete so re-enable is instant and offline const files = cur?.installedFiles ?? [];
const files = [...(cur?.installedFiles ?? [])].filter(
f => !(m.id === 'dxvk' && /d3d9\.dll$/i.test(f))
);
if (m.id === 'dxvk') {
const live = path.join(clientDir, 'd3d9.dll');
const off = path.join(clientDir, 'd3d9.dll.off');
if (await fs.pathExists(live)) {
await fs.remove(off).catch(() => undefined);
await fs
.move(live, off)
.catch(err => Logger.warn(`Couldn't park ${live}:`, err));
}
}
for (const rel of files) { for (const rel of files) {
const fullPath = path.join(clientDir, rel); const fullPath = path.join(clientDir, rel);
@@ -731,31 +369,14 @@ class ModsClass extends Observable<ModsStatus> {
this.#patchRow(m.id, { state: 'idle', installedVersion: undefined }); this.#patchRow(m.id, { state: 'idle', installedVersion: undefined });
} }
async #downloadTo(url: string, dest: string, sha256?: string) { async #downloadTo(url: string, dest: string) {
const res = await fetch(url, { const res = await fetch(url, {
headers: { 'User-Agent': 'OctoLauncher' }, headers: { 'User-Agent': 'OctoLauncher' }
timeout: MOD_DOWNLOAD_TIMEOUT_MS
}); });
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`); if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
await fs.ensureDir(path.dirname(dest)); await fs.ensureDir(path.dirname(dest));
const buf = await res.arrayBuffer(); 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)); 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) { async #savePref(id: ModId, state: ModState) {
+1 -2
View File
@@ -12,8 +12,7 @@ abstract class Observable<T> {
try { try {
l(v); l(v);
return true; return true;
} catch (err) { } catch {
console.error('Observer threw, removing listener', err);
return false; return false;
} }
}); });
+76 -391
View File
@@ -7,8 +7,7 @@ import Logger from 'electron-log/main';
import Preferences from '~main/modules/preferences'; import Preferences from '~main/modules/preferences';
import { ConfigWtfSchema, type PreferencesSchema } from '~common/schemas'; import { ConfigWtfSchema, type PreferencesSchema } from '~common/schemas';
import { isNotUndef } from '~common/utils'; import { isNotUndef } from '~common/utils';
import { readPristineWow } from '~main/modules/aria2'; import { fetchFile } from '~main/modules/updater';
import { enumerateDisplays } from '~main/modules/displays';
const Servers = { const Servers = {
live: { live: {
@@ -17,50 +16,16 @@ const Servers = {
realmName: 'OctoWoW' realmName: 'OctoWoW'
}, },
ptr: { ptr: {
realmList: import.meta.env.MAIN_VITE_PTR_REALMLIST || 'octowow.st', realmList: 'octowow.st',
patchList: import.meta.env.MAIN_VITE_PTR_REALMLIST || 'octowow.st', patchList: 'octowow.st',
realmName: 'OctoWoW PTR' realmName: 'OctoWoW PTR'
} }
} as const; } as const;
const LOCALES = { type Tweak = { key: keyof PreferencesSchema['config']; default?: unknown; forced?: boolean } & (
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'; type: 'bytes';
tweaks: [number, number[], number[]?][]; tweaks: [number, number[]][];
} }
| { | {
type: 'int8' | 'uint16' | 'float'; type: 'int8' | 'uint16' | 'float';
@@ -69,57 +34,17 @@ type Tweak = TweakKey & {
} }
); );
const hex = (bytes: number[]) =>
bytes.map(b => b.toString(16).padStart(2, '0')).join(' ');
export const patchExecutable = async () => { export const patchExecutable = async () => {
Logger.log('Patching WoW.exe...'); Logger.log('Patching WoW.exe...');
const { clientDir, config, locale } = Preferences.data; const { clientDir, config } = Preferences.data;
if (!clientDir) return; if (!clientDir) return;
const exePath = path.join(clientDir, 'WoW.exe'); const exePath = path.join(clientDir, 'WoW.exe');
try { try {
Logger.log('Reading clean WoW.exe base...'); Logger.log('Fetching clean WoW.exe...');
const buffer = await readPristineWow(clientDir); const file = await fetchFile('WoW.exe');
const buffer = Buffer.from(file);
const loc = LOCALES[locale];
// revert any previous locale patch to the pristine bytes first, so a
// language switch (or an adopted pre-patched exe) can re-patch cleanly
const TAG_OFFSET = 0x1b2115;
const INDEX_OFFSET = 0x253c;
const PRISTINE_TAG = [0xa1, 0xa4, 0xa2, 0xc2, 0x00];
const PRISTINE_INDEX = [0x33, 0xf6, 0x8b, 0xff, 0x8b, 0x04, 0xb5];
if (
buffer[TAG_OFFSET] === 0xb8 &&
buffer[INDEX_OFFSET] === 0xbe &&
buffer[INDEX_OFFSET + 5] === 0xeb
) {
const prevIndex = buffer[INDEX_OFFSET + 1];
const prevCarrier = LOCALE_NAMES[prevIndex] as string | undefined;
const prevTag = prevCarrier
? Buffer.from([
0xb8,
...Buffer.from(prevCarrier, 'latin1').reverse()
])
: undefined;
if (
prevCarrier &&
prevTag &&
buffer.subarray(TAG_OFFSET, TAG_OFFSET + 5).equals(prevTag)
) {
Logger.log(
`Reverting previous locale patch (index ${prevIndex}) to the clean base`
);
Buffer.from(PRISTINE_TAG).copy(buffer, TAG_OFFSET);
Buffer.from(PRISTINE_INDEX).copy(buffer, INDEX_OFFSET);
Buffer.from(prevCarrier, 'latin1').copy(
buffer,
localeNameOffset(prevIndex)
);
}
}
const Tweaks = [ const Tweaks = [
{ {
@@ -146,19 +71,26 @@ export const patchExecutable = async () => {
default: false default: false
}, },
{ {
// shipped exe carries the enabled bytes; off must write 0x74 back
key: 'alwaysAutoLoot', key: 'alwaysAutoLoot',
type: 'bytes', type: 'bytes',
tweaks: [ tweaks: [
[0x0c1ecf, [0x75], [0x74]], [0x0c1ecf, [0x75]],
[0x0c2b25, [0x75], [0x74]] [0x0c2b25, [0x75]]
] ]
}, },
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 }, { key: 'nameplateRange', type: 'float', offset: 0x40c448 },
{ key: 'cameraDistance', type: 'float', offset: 0x4089a4 }, { key: 'cameraDistance', type: 'float', offset: 0x4089a4 },
{ {
synthetic: true, key: 'crossFactionResurrect' as never,
key: 'skillUiGateHijack', type: 'bytes',
default: true,
tweaks: [
[0x006e5fb8, [0x006e5fb9]],
[0x006e62a8, [0x006e62a9]]
]
},
{
key: 'skillUiGateHijack' as never,
type: 'bytes', type: 'bytes',
default: true, default: true,
forced: true, forced: true,
@@ -166,286 +98,89 @@ export const patchExecutable = async () => {
[ [
0x002ddf90, 0x002ddf90,
[ [
0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56, 0x57, 0x8b, 0x3d, 0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56,
0x60, 0xab, 0xce, 0x00, 0x83, 0xff, 0xff, 0x89, 0x55, 0xfc, 0x89, 0x57, 0x8b, 0x3d, 0x60, 0xab, 0xce, 0x00, 0x83,
0x4d, 0xf8, 0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58, 0xab, 0xff, 0xff, 0x89, 0x55, 0xfc, 0x89, 0x4d, 0xf8,
0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8b, 0x4c, 0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58,
0x82, 0x08, 0xf6, 0xc1, 0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04, 0xab, 0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d,
0x85, 0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00, 0xf6, 0xc1, 0x04, 0x40, 0x8b, 0x4c, 0x82, 0x08, 0xf6, 0xc1,
0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74, 0x4a, 0x39, 0x31, 0x74, 0x13, 0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04, 0x85,
0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b, 0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00,
0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0, 0x8b, 0x59, 0x1c, 0xf6, 0xc1, 0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74,
0x8b, 0x71, 0x18, 0x33, 0xff, 0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64, 0x4a, 0x39, 0x31, 0x74, 0x13, 0x8b, 0xc7, 0x23,
0x24, 0x00, 0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00, 0x6a, 0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b,
0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b, 0x55, 0xfc, 0xe8, 0xb9, 0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0,
0xfd, 0xff, 0xff, 0x84, 0xc0, 0x75, 0x13, 0x47, 0x83, 0xc6, 0x20, 0x8b, 0x59, 0x1c, 0x8b, 0x71, 0x18, 0x33, 0xff,
0x3b, 0xfb, 0x7c, 0xdd, 0x5f, 0x5e, 0x33, 0xc0, 0x5b, 0x8b, 0xe5, 0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64, 0x24, 0x00,
0x5d, 0xc2, 0x04, 0x00, 0x5f, 0x8b, 0xc6, 0x5e, 0x5b, 0x8b, 0xe5, 0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00,
0x5d, 0xc2, 0x04, 0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 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[]; ] satisfies Tweak[];
// Apply patches
Tweaks.forEach(t => { Tweaks.forEach(t => {
const val = t.synthetic const val =
? t.default config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key];
: config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key];
Logger.log(`Applying "${t.key}" patch with value: ${val}`); Logger.log(`Applying "${t.key}" patch with value: ${val}`);
if (t.type === 'float') { if (t.type === 'float') {
buffer.writeFloatLE(t.value ?? (val as number), t.offset); buffer.writeFloatLE(t.value ?? (val as never), t.offset);
} else if (t.type === 'int8') { } else if (t.type === 'int8') {
buffer.writeInt8(t.value ?? (val as number), t.offset); buffer.writeInt8(t.value ?? (val as never), t.offset);
} else if (t.type === 'uint16') { } else if (t.type === 'uint16') {
if (!t.forced && !val) return; if (!t.forced && !val) return;
buffer.writeUInt16LE(t.value ?? (val as number), t.offset); buffer.writeUInt16LE(t.value ?? (val as never), t.offset);
} else if (t.type === 'bytes') { } else if (t.type === 'bytes') {
if (!t.forced && !val) { if (!t.forced && !val) return;
// disabled: revert sites carrying the enabled bytes to the t.tweaks.forEach(([offset, bytes]) =>
// stock bytes when known; unknown bytes stay untouched Buffer.from(bytes).copy(buffer, offset)
t.tweaks.forEach(
([offset, bytes, expect]: [number, number[], number[]?]) => {
if (!expect) return;
const current = buffer.subarray(
offset,
offset + bytes.length
);
if (current.equals(Buffer.from(bytes)))
Buffer.from(expect).copy(buffer, offset);
}
);
return;
}
t.tweaks.forEach(
([offset, bytes, expect]: [number, number[], number[]?]) => {
if (expect) {
const current = buffer.subarray(offset, offset + expect.length);
if (current.equals(Buffer.from(bytes))) return;
if (!current.equals(Buffer.from(expect)))
throw new Error(
`"${t.key}" expected [${hex(expect)}] at 0x${offset.toString(
16
)} ` +
`but found [${hex([
...current
])}]; refusing to patch WoW.exe`
);
}
const written = Buffer.from(bytes).copy(buffer, offset);
if (written !== bytes.length)
Logger.error(
`"${t.key}" wrote ${written}/${bytes.length} bytes at ` +
`0x${offset.toString(16)}: past end of file (${
buffer.length
} bytes). ` +
'This tweak is a no-op; the offset is probably a virtual address.'
);
}
); );
} }
}); });
await fs.writeFile(exePath, buffer); await fs.writeFile(exePath, buffer);
Preferences.data = { patchedLocale: locale }; Logger.log('WoW.exe successfully patched');
Logger.log(`WoW.exe successfully patched (language: ${locale})`);
} catch (e) { } catch (e) {
Logger.error('Failed to patch WoW.exe', e); Logger.error('Failed to patch WoW.exe', e);
throw e instanceof Error ? e : new Error('Failed to patch WoW.exe');
} }
}; };
const repairResolution = async ( export const patchConfig = async () => {
clientDir: string, const { clientDir, server, config } = Preferences.data;
current: string | undefined,
lastWritten: string | undefined
): Promise<{ gxResolution?: string }> => {
const devices = await enumerateDisplays();
if (!devices?.length) return {};
const pinnedRaw = await fs
.readFile(path.join(clientDir, 'VMMFix_preferred_monitor.txt'), 'utf8')
.then(s => s.trim())
.catch(() => '');
const pinned = pinnedRaw ? Number(pinnedRaw) : NaN;
const target =
devices.find(d => d.index === pinned && d.attached) ??
devices.find(d => d.primary && d.attached);
if (!target?.modes.length) return {};
const native = `${target.width}x${target.height}`;
if (!target.modes.includes(native)) return {};
let owned = !current || current === lastWritten;
if (!owned && lastWritten === undefined && current) {
const width = Number(current.split('x')[0]);
if (Number.isFinite(width) && width * 2 < target.width) {
Logger.warn(
`gxResolution ${current} is far below ${target.deviceName}'s ${native} and predates resolution tracking; treating it as a client fallback`
);
owned = true;
}
}
if (!owned) return {};
if (current === native) return {};
Logger.warn(
`gxResolution ${current ?? '<unset>'} is launcher-owned; correcting to ${
target.deviceName
}'s ${native}`
);
return { gxResolution: native };
};
const applyRealmlist = async (clientDir: string, host: string) => {
const body = `set realmlist "${host}"\n`;
const write = async (target: string) => {
// already correct: leave it alone (the seeder may hold the file open)
const current = await fs
.readFile(target, { encoding: 'utf-8' })
.catch(() => null);
if (current === body) return;
const tmp = `${target}.tmp`;
try {
await fs.writeFile(tmp, body);
await fs.move(tmp, target, { overwrite: true });
} catch (e) {
await fs.remove(tmp).catch(() => undefined);
throw e;
}
};
await write(path.join(clientDir, 'realmlist.wtf'));
const dataDir = path.join(clientDir, 'Data');
if (await fs.pathExists(dataDir))
for (const entry of await fs.readdir(dataDir)) {
const scoped = path.join(dataDir, entry, 'realmlist.wtf');
if (!(await fs.pathExists(scoped))) continue;
try {
await write(scoped);
} catch (e) {
Logger.warn(`Could not rewrite ${scoped}: ${String(e)}`);
}
}
};
// rewrite realmlist.wtf when missing, empty, or wrong; an interrupted sync
// can leave a 0-byte placeholder that disconnects direct game launches
export const healRealmlist = async (clientDir: string) => {
const server: keyof typeof Servers = import.meta.env.MAIN_VITE_PTR_REALMLIST
? 'ptr'
: 'live';
const expected = `set realmlist "${Servers[server].realmList}"\n`;
const target = path.join(clientDir, 'realmlist.wtf');
const current = await fs
.readFile(target, { encoding: 'utf-8' })
.catch(() => null);
if (current === expected) return;
Logger.log(
`realmlist.wtf ${
current === null ? 'missing' : current.trim() ? 'wrong' : 'empty'
}; rewriting`
);
await applyRealmlist(clientDir, Servers[server].realmList);
};
export const patchConfig = async (forceTweaks = false) => {
const { clientDir, config, locale } = Preferences.data;
if (!clientDir) return; 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'); const configPath = path.join(clientDir, 'WTF', 'Config.wtf');
await fs.ensureDir(path.dirname(configPath)); await fs.ensureDir(path.dirname(configPath));
const raw = (await fs.pathExists(configPath)) const raw = (await fs.pathExists(configPath))
? await fs.readFile(configPath, { encoding: 'utf-8' }) ? await fs.readFile(configPath, { encoding: 'utf-8' })
: ''; : '';
if (raw) await fs.remove(configPath);
const configWtf = Object.fromEntries( const configWtf = Object.fromEntries(
raw raw
.split(/\r?\n/) .split('\n')
.map(l => { .map(l => {
const [, k, v] = l.match(/SET (\w+) "(.*)"/) ?? []; const [_, k, v] = l.match(/SET (\w+) "(.+)"/) ?? [];
return !k || v === undefined ? undefined : [k, v]; return !k || !v ? undefined : [k, v];
}) })
.filter(isNotUndef) .filter(isNotUndef)
); );
const isFirstRun = Object.keys(configWtf).length === 0;
const primaryDisplay = screen.getPrimaryDisplay(); const primaryDisplay = screen.getPrimaryDisplay();
const scale = primaryDisplay.scaleFactor || 1; const { width, height } = primaryDisplay.bounds;
const width = Math.round(primaryDisplay.bounds.width * scale);
const height = Math.round(primaryDisplay.bounds.height * scale);
const seededResolution = `${width}x${height}`; const parsed = {
const seed = isFirstRun
? {
scriptMemory: 512000, scriptMemory: 512000,
gxResolution: seededResolution, gxResolution: `${width}x${height}`,
gxColorBits: primaryDisplay.colorDepth, gxColorBits: primaryDisplay.colorDepth,
gxDepthBits: primaryDisplay.colorDepth, gxDepthBits: primaryDisplay.colorDepth,
gxRefresh: 60, gxRefresh: 60,
@@ -462,7 +197,6 @@ export const patchConfig = async (forceTweaks = false) => {
specular: 1, specular: 1,
pixelShaders: 1, pixelShaders: 1,
M2UsePixelShaders: 1, M2UsePixelShaders: 1,
M2UseShaders: 1,
particleDensity: 1, particleDensity: 1,
unitDrawDist: 300, unitDrawDist: 300,
weatherDensity: 3, weatherDensity: 3,
@@ -470,75 +204,26 @@ export const patchConfig = async (forceTweaks = false) => {
minimapZoom: 0, minimapZoom: 0,
minimapInsideZoom: 0, minimapInsideZoom: 0,
SoundZoneMusicNoDelay: 1, 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, patchList: configWtf['patchList'] ?? Servers[server].patchList,
realmName: configWtf['realmName'] ?? Servers[server].realmName, realmName: configWtf['realmName'] ?? Servers[server].realmName,
hwDetect: 0, gxWindow: configWtf['gxWindow'] ?? 1,
BackgroundSound: config.soundInBackground ? 1 : 0 gxMaximize: configWtf['gxMaximize'] ?? 1,
}; gxCursor: configWtf['gxCursor'] ?? 1,
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0,
const repaired = await repairResolution(
clientDir,
configWtf['gxResolution'],
Preferences.data.lastWrittenResolution
);
const parsed = {
...seed,
...configWtf, ...configWtf,
...repaired, CameraDistanceMax: config.cameraDistance,
...owned, farClip: config.farClip,
...(forceTweaks realmList: Servers[server].realmList,
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance } hwDetect: 0,
: {}) M2UseShaders: 1
}; };
const body = Object.entries(parsed) await fs.writeFile(
configPath,
Object.entries(parsed)
.filter(v => v[1] !== undefined && v[1] !== null) .filter(v => v[1] !== undefined && v[1] !== null)
.filter(([k]) => !/^realmlist$/i.test(k))
.map(l => `SET ${l[0]} "${l[1]}"`) .map(l => `SET ${l[0]} "${l[1]}"`)
.join('\n'); .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'); 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');
};
+14 -225
View File
@@ -3,196 +3,36 @@ import path from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { type z } from 'zod'; import { type z } from 'zod';
import { app } from 'electron'; import { app } from 'electron';
import Logger from 'electron-log/main';
import { PreferencesSchema } from '~common/schemas'; import { PreferencesSchema } from '~common/schemas';
import { DEFAULT_ENABLED_MODS } from '~common/mods';
import { omit } from '~common/utils'; import { omit } from '~common/utils';
import { isTorrentMode } from '~main/modules/aria2';
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR; 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 { abstract class Preferences {
static #data: z.infer<typeof PreferencesSchema>; 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 static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher') ? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
: app.getPath('userData'); : app.getPath('userData');
static readonly #settingsPath = path.join(
Preferences.userDataDir,
'settings.json'
);
static readonly #installPath = path.join(
Preferences.userDataDir,
'install.json'
);
static get isFreshInstall() {
return this.#freshInstall;
}
static async #detectFreshInstall() {
const [settings, install, pending] = await Promise.all([
fs.pathExists(this.#settingsPath),
fs.pathExists(this.#installPath),
fs.pathExists(`${this.#settingsPath}.tmp`)
]);
return !settings && !install && !pending;
}
static #withFreshInstallDefaults(data: PreferencesSchema): PreferencesSchema {
if (!this.#freshInstall || Object.keys(data.mods).length) return data;
// fresh installs seed every mod EXPLICITLY off; a missing row falls
// back to enabled, which keeps legacy profiles untouched
const mods = { ...data.mods };
for (const id of DEFAULT_ENABLED_MODS)
mods[id] = { enabled: false, installedFiles: [], ignoreUpdates: false };
Logger.info('Fresh install: all mods start disabled (opt-in)');
return { ...data, mods };
}
static async load() { static async load() {
this.#freshInstall = await this.#detectFreshInstall();
await fs.ensureDir(this.userDataDir); await fs.ensureDir(this.userDataDir);
const settingsPath = this.#settingsPath;
let json: Record<string, unknown> = {}; const userDataPath = path.join(this.userDataDir, 'settings.json');
try { try {
json = await readJsonRetrying(settingsPath); const json = await fs.readJSON(userDataPath);
} catch (e) { return PreferencesSchema.parse({
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, ...json,
isPortable: !!portableDir, isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir clientDir: portableDir ?? json.clientDir
}); });
} catch (e) {
const parsed = PreferencesSchema.safeParse(merged); return PreferencesSchema.parse({
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, isPortable: !!portableDir,
// coerce to string/undefined; the shape loop never clears a set key, so a clientDir: portableDir
// 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 { static get data(): PreferencesSchema {
@@ -201,69 +41,18 @@ abstract class Preferences {
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) { static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
this.#data = { ...this.#data, ...newData }; this.#data = { ...this.#data, ...newData };
fs.writeJSON(
if (this.#readOnly) return; path.join(this.userDataDir, 'settings.json'),
omit(
const settingsPath = this.#settingsPath; this.#data,
const dropped = portableDir ? ['isPortable', 'clientDir'] : ['isPortable']; portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
const delta = dropUndefined( ),
omit(newData, dropped as (keyof typeof newData)[]) { spaces: 2 }
); );
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) { static async isValidClientDir(clientDir?: string) {
if (!clientDir) return false; return !!clientDir && (await fs.exists(path.join(clientDir, 'WoW.exe')));
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;
} }
} }
+5 -12
View File
@@ -10,12 +10,7 @@ export type SelfUpdaterStatus =
| { state: 'checking'; currentVersion: string } | { state: 'checking'; currentVersion: string }
| { state: 'unavailable'; currentVersion: string } | { state: 'unavailable'; currentVersion: string }
| { state: 'available'; currentVersion: string; nextVersion: string } | { state: 'available'; currentVersion: string; nextVersion: string }
| { | { state: 'downloading'; currentVersion: string; nextVersion: string; progress: number }
state: 'downloading';
currentVersion: string;
nextVersion: string;
progress: number;
}
| { state: 'ready'; currentVersion: string; nextVersion: string } | { state: 'ready'; currentVersion: string; nextVersion: string }
| { state: 'error'; currentVersion: string; message: string }; | { state: 'error'; currentVersion: string; message: string };
@@ -42,7 +37,7 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
this.#initialized = true; this.#initialized = true;
if (is.dev) { if (is.dev) {
Logger.info('[selfUpdater] dev mode, skipping'); Logger.info('[selfUpdater] dev mode skipping');
return; return;
} }
@@ -88,7 +83,7 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
}); });
autoUpdater.on('update-downloaded', info => { autoUpdater.on('update-downloaded', info => {
Logger.info( Logger.info(
`[selfUpdater] downloaded ${info.version}, awaiting user click` `[selfUpdater] downloaded ${info.version} awaiting user click`
); );
this.status = { this.status = {
state: 'ready', state: 'ready',
@@ -105,13 +100,11 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
triggerInstall() { triggerInstall() {
if (this._value.state !== 'ready') { if (this._value.state !== 'ready') {
Logger.warn( Logger.warn(
`[selfUpdater] triggerInstall called in state ${this._value.state}, ignoring` `[selfUpdater] triggerInstall called in state ${this._value.state} ignoring`
); );
return; return;
} }
Logger.info( Logger.info('[selfUpdater] user clicked install — quitting + running installer');
'[selfUpdater] user clicked install, quitting + running installer'
);
autoUpdater.quitAndInstall(false, true); autoUpdater.quitAndInstall(false, true);
} }
} }
File diff suppressed because it is too large Load Diff
-374
View File
@@ -1,374 +0,0 @@
import dgram from 'dgram';
import http from 'http';
import os from 'os';
import Logger from 'electron-log/main';
// UPnP-IGD port mapping (best effort) for a NAT'd seeder; node builtins only, no-ops on failure.
export type PortMapping = { stop: () => Promise<void> };
const NOOP: PortMapping = { stop: async () => {} };
const SSDP_ADDR = '239.255.255.250';
const SSDP_PORT = 1900;
const SEARCH = Buffer.from(
[
'M-SEARCH * HTTP/1.1',
`HOST: ${SSDP_ADDR}:${SSDP_PORT}`,
'MAN: "ssdp:discover"',
'MX: 2',
'ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1',
'',
''
].join('\r\n')
);
// exposes AddPortMapping, newest first
const WAN_SERVICES = [
'urn:schemas-upnp-org:service:WANIPConnection:2',
'urn:schemas-upnp-org:service:WANIPConnection:1',
'urn:schemas-upnp-org:service:WANPPPConnection:1'
];
type Gateway = { location: string; address: string; localAddress: string };
type WanService = { controlUrl: string; serviceType: string };
class SoapError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.code = code;
}
}
const candidateAddresses = (): string[] =>
Object.values(os.networkInterfaces())
.flat()
.filter(
(a): a is os.NetworkInterfaceInfo =>
!!a &&
a.family === 'IPv4' &&
!a.internal &&
!a.address.startsWith('169.254.')
)
.map(a => a.address);
// a 0.0.0.0/empty host in LOCATION is really the address the datagram came from
const fixLocation = (location: string, responder: string): string => {
try {
const u = new URL(location);
if (u.hostname === '0.0.0.0' || u.hostname === '') u.hostname = responder;
return u.toString();
} catch {
return location;
}
};
// M-SEARCH one interface; collect every responder (more than one can answer)
const searchInterface = (
localAddress: string,
timeoutMs: number
): Promise<Gateway[]> =>
new Promise(resolve => {
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
const found = new Map<string, Gateway>();
let retry: ReturnType<typeof setInterval> | undefined;
let done = false;
const finish = () => {
if (done) return;
done = true;
if (retry) clearInterval(retry);
try {
socket.close();
} catch {
// already closed
}
resolve([...found.values()]);
};
socket.on('message', (msg, rinfo) => {
const m = /^location:\s*(\S+)/im.exec(msg.toString('utf8'));
if (!m) return;
const location = fixLocation(m[1].trim(), rinfo.address);
if (!found.has(location))
found.set(location, { location, address: rinfo.address, localAddress });
});
socket.on('error', () => finish());
socket.bind(0, localAddress, () => {
try {
socket.setMulticastInterface(localAddress);
} catch {
// fall back to the default multicast interface
}
const send = () =>
socket.send(SEARCH, SSDP_PORT, SSDP_ADDR, () => {
/* fire-and-forget */
});
send();
// Routers sometimes miss the first datagram; re-ask until the window closes.
retry = setInterval(send, 700);
setTimeout(finish, timeoutMs);
});
});
// search all interfaces: a VPN often owns the default route
const discoverGateways = async (timeoutMs: number): Promise<Gateway[]> => {
const perInterface = await Promise.all(
candidateAddresses().map(a => searchInterface(a, timeoutMs))
);
const seen = new Set<string>();
const gateways: Gateway[] = [];
for (const list of perInterface)
for (const gw of list)
if (!seen.has(gw.location)) {
seen.add(gw.location);
gateways.push(gw);
}
return gateways;
};
// build the control URL from the host we reached; routers advertise a bogus URLBase
const controlUrlFrom = (descriptorUrl: string, controlPath: string): string => {
const desc = new URL(descriptorUrl);
let path: string;
try {
const c = new URL(controlPath, descriptorUrl);
path = `${c.pathname}${c.search}`;
} catch {
path = controlPath.startsWith('/') ? controlPath : `/${controlPath}`;
}
return `${desc.protocol}//${desc.host}${path}`;
};
// raw http, not fetch: many UPnP servers are non-compliant and undici rejects them
const httpRequest = (
url: string,
opts: {
method?: string;
headers?: Record<string, string>;
body?: string;
timeoutMs?: number;
} = {}
): Promise<{ status: number; body: string }> =>
new Promise((resolve, reject) => {
let u: URL;
try {
u = new URL(url);
} catch (e) {
reject(e as Error);
return;
}
const headers = { ...(opts.headers ?? {}) };
const body = opts.body ? Buffer.from(opts.body, 'utf8') : undefined;
if (body) headers['Content-Length'] = String(body.length);
const req = http.request(
{
hostname: u.hostname,
port: u.port || 80,
path: `${u.pathname}${u.search}`,
method: opts.method ?? 'GET',
headers
},
res => {
const chunks: Buffer[] = [];
res.on('data', c => chunks.push(c));
res.on('end', () =>
resolve({
status: res.statusCode ?? 0,
body: Buffer.concat(chunks).toString('utf8')
})
);
}
);
req.on('error', reject);
req.setTimeout(opts.timeoutMs ?? 5000, () =>
req.destroy(new Error('request timed out'))
);
if (body) req.write(body);
req.end();
});
// first WAN service + control URL from a device descriptor
const findWanService = (
xml: string,
descriptorUrl: string
): WanService | undefined => {
for (const block of xml.split(/<service>/i).slice(1)) {
const type = /<serviceType>\s*([^<]+?)\s*<\/serviceType>/i
.exec(block)?.[1]
?.trim();
const ctrl = /<controlURL>\s*([^<]+?)\s*<\/controlURL>/i
.exec(block)?.[1]
?.trim();
if (
type &&
ctrl &&
WAN_SERVICES.some(w => w.toLowerCase() === type.toLowerCase())
)
return {
controlUrl: controlUrlFrom(descriptorUrl, ctrl),
serviceType: type
};
}
return undefined;
};
const xmlEscape = (s: string): string =>
s.replace(
/[<>&'"]/g,
c =>
({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[
c
] as string)
);
const arg = (name: string, value: string | number): string =>
`<${name}>${value}</${name}>`;
const soap = async (
svc: WanService,
action: string,
body: string
): Promise<void> => {
const envelope =
'<?xml version="1.0"?>' +
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' +
'<s:Body>' +
`<u:${action} xmlns:u="${svc.serviceType}">${body}</u:${action}>` +
'</s:Body></s:Envelope>';
const res = await httpRequest(svc.controlUrl, {
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPAction': `"${svc.serviceType}#${action}"`
},
body: envelope
});
if (res.status < 200 || res.status >= 300) {
const code = /<errorCode>\s*(\d+)/i.exec(res.body)?.[1];
throw new SoapError(
`${action} failed: HTTP ${res.status}${code ? ` (UPnP ${code})` : ''}`,
code
);
}
};
const addMapping = (
svc: WanService,
port: number,
protocol: 'TCP' | 'UDP',
client: string,
description: string,
lease: number
): Promise<void> =>
soap(
svc,
'AddPortMapping',
arg('NewRemoteHost', '') +
arg('NewExternalPort', port) +
arg('NewProtocol', protocol) +
arg('NewInternalPort', port) +
arg('NewInternalClient', client) +
arg('NewEnabled', 1) +
arg('NewPortMappingDescription', xmlEscape(description)) +
arg('NewLeaseDuration', lease)
);
const deleteMapping = (
svc: WanService,
port: number,
protocol: 'TCP' | 'UDP'
): Promise<void> =>
soap(
svc,
'DeletePortMapping',
arg('NewRemoteHost', '') +
arg('NewExternalPort', port) +
arg('NewProtocol', protocol)
);
// map port (TCP+UDP), kept alive until stop(); returns a no-op handle when no gateway
export const mapPort = async (
port: number,
opts: { description?: string; ttlSeconds?: number } = {}
): Promise<PortMapping> => {
const description = opts.description ?? 'OctoWoW';
try {
const gateways = await discoverGateways(4000);
if (!gateways.length) {
Logger.log('UPnP: no gateway found; seeding without a port mapping');
return NOOP;
}
// take the first responder that exposes a WAN service
const probed = await Promise.all(
gateways.map(async gw => {
const res = await httpRequest(gw.location).catch(() => undefined);
const svc =
res && res.status < 400
? findWanService(res.body, gw.location)
: undefined;
return svc ? { svc, client: gw.localAddress } : undefined;
})
);
const target = probed.find(Boolean);
if (!target) {
Logger.log('UPnP: no gateway exposes a WAN service; skipping mapping');
return NOOP;
}
const { svc, client } = target;
// Some routers only grant permanent leases (UPnP error 725); fall back to one.
let lease = opts.ttlSeconds ?? 3600;
const mapped: ('TCP' | 'UDP')[] = [];
const mapOne = async (protocol: 'TCP' | 'UDP') => {
try {
await addMapping(svc, port, protocol, client, description, lease);
} catch (e) {
if (e instanceof SoapError && e.code === '725' && lease !== 0) {
lease = 0;
await addMapping(svc, port, protocol, client, description, lease);
} else throw e;
}
mapped.push(protocol);
};
try {
await mapOne('TCP');
await mapOne('UDP');
} catch (e) {
for (const p of mapped) await deleteMapping(svc, port, p).catch(() => {});
throw e;
}
// A finite lease self-heals if we exit uncleanly; renew ahead of expiry.
let renew: ReturnType<typeof setInterval> | undefined;
if (lease > 0) {
const period = Math.max(60_000, (lease - 60) * 1000);
renew = setInterval(() => {
addMapping(svc, port, 'TCP', client, description, lease).catch(
() => {}
);
addMapping(svc, port, 'UDP', client, description, lease).catch(
() => {}
);
}, period);
renew.unref?.();
}
Logger.log(
`UPnP: mapped ${port} TCP+UDP to ${client} (lease ${
lease || 'permanent'
})`
);
return {
stop: async () => {
if (renew) clearInterval(renew);
await deleteMapping(svc, port, 'TCP').catch(() => {});
await deleteMapping(svc, port, 'UDP').catch(() => {});
}
};
} catch (e) {
Logger.warn('UPnP: port mapping failed; seeding without it', e);
return NOOP;
}
};
+2 -7
View File
@@ -3,11 +3,6 @@ export { type UpdaterStatus } from './modules/updater';
export { type AddonsStatus, type AddonData } from './modules/addons'; export { type AddonsStatus, type AddonData } from './modules/addons';
export { export {
type ModsStatus, type ModsStatus,
type ModRowStatus, type ModRowStatus
type CustomMod
} from './modules/mods'; } from './modules/mods';
export { export { type NewsItem, type NewsFeed } from '../common/schemas';
type NewsItem,
type NewsFeed,
type ForumAnnouncement
} from '../common/schemas';
+7 -33
View File
@@ -6,15 +6,8 @@ import fs from 'fs-extra';
import Preferences from './modules/preferences'; import Preferences from './modules/preferences';
const isCallbackResponse = ( const isCallbackResponse = (data: any): data is { cb: string; args: any[] } =>
data: unknown data && typeof data === 'object' && 'cb' in data && 'args' in data;
): 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>( export const runWorker = <T>(
worker: (o: WorkerOptions) => Worker, worker: (o: WorkerOptions) => Worker,
@@ -23,16 +16,10 @@ export const runWorker = <T>(
) => ) =>
new Promise<T>((resolve, reject) => new Promise<T>((resolve, reject) =>
worker({ workerData }) worker({ workerData })
.on('message', (m: unknown) => { .on('message', m =>
if (!isCallbackResponse(m)) return resolve(m as T); isCallbackResponse(m) ? callbacks?.[m.cb](...m.args) : resolve(m)
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`))
) )
.on('error', reject)
); );
export const getClientVersion = async () => { export const getClientVersion = async () => {
@@ -48,21 +35,8 @@ export const getClientVersion = async () => {
const file = await fs.readFile(exePath); const file = await fs.readFile(exePath);
const buffer = Buffer.from(file); const buffer = Buffer.from(file);
const VERSION_OFFSET = 0x00437c04; const version = buffer.toString('utf-8', 0x00437c04, 0x00437c04 + 6);
const VERSION_LEN = 6; const build = buffer.toString('utf-8', 0x00437bfc, 0x00437bfc + 4);
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})`); Logger.log(`Client version is: ${version} (${build})`);
return `${version} (${build})`; return `${version} (${build})`;
+6 -29
View File
@@ -9,38 +9,15 @@ if (!port) throw new Error('IllegalState');
const { dir, url, ref } = workerData; const { dir, url, ref } = workerData;
const tmpDir = `${dir}.tmp`; fs.removeSync(dir);
const bakDir = `${dir}.bak`; git
.clone({
const run = async () => { dir,
await fs.remove(tmpDir);
await git.clone({
dir: tmpDir,
fs, fs,
http, http,
url, url,
ref, ref,
singleBranch: !ref || ref === 'master' || ref === 'main', singleBranch: !ref || ref === 'master' || ref === 'main',
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args }) onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
}); })
.then(() => port.postMessage(true));
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;
});
+14 -14
View File
@@ -5,7 +5,7 @@ import http from 'isomorphic-git/http/node';
import fs from 'fs-extra'; import fs from 'fs-extra';
const port = parentPort; const port = parentPort;
if (!port) throw new Error('gitPull worker has no parentPort'); if (!port) throw new Error('IllegalState');
const { dir, remote, branch, ref } = workerData as { const { dir, remote, branch, ref } = workerData as {
dir: string; dir: string;
@@ -17,17 +17,20 @@ const { dir, remote, branch, ref } = workerData as {
const onProgress = (...args: unknown[]) => const onProgress = (...args: unknown[]) =>
port.postMessage({ cb: 'onProgress', args }); port.postMessage({ cb: 'onProgress', args });
const removeUntrackedFiles = async () => {
const status = await git.statusMatrix({ fs, dir });
await Promise.all(
status
.filter(([, HEAD]) => HEAD === 0)
.map(([filepath]) => fs.remove(`${dir}/${filepath}`))
);
};
const run = async () => { const run = async () => {
if (ref) { if (ref) {
await git.fetch({ await git.fetch({ fs, http, dir, tags: true, singleBranch: false, onProgress });
fs,
http,
dir,
tags: true,
singleBranch: false,
onProgress
});
await git.checkout({ fs, dir, force: true, ref, onProgress }); await git.checkout({ fs, dir, force: true, ref, onProgress });
await removeUntrackedFiles();
return; return;
} }
@@ -38,6 +41,7 @@ const run = async () => {
ref: `${remote}/${branch}`, ref: `${remote}/${branch}`,
onProgress onProgress
}); });
await removeUntrackedFiles();
await git.pull({ await git.pull({
fs, fs,
http, http,
@@ -49,8 +53,4 @@ const run = async () => {
}); });
}; };
run() run().then(() => port.postMessage(true));
.then(() => port.postMessage(true))
.catch(err => {
throw err;
});
+2 -4
View File
@@ -2,7 +2,6 @@ import { useState } from 'react';
import { api } from './utils/api'; import { api } from './utils/api';
import PageBackground from './assets/background.png'; import PageBackground from './assets/background.png';
import AntivirusModal from './components/AntivirusModal';
import Header from './components/Header'; import Header from './components/Header';
import LaunchPanel from './components/LaunchPanel'; import LaunchPanel from './components/LaunchPanel';
import SelfUpdateBanner from './components/SelfUpdateBanner'; import SelfUpdateBanner from './components/SelfUpdateBanner';
@@ -39,13 +38,12 @@ const App = () => {
</> </>
)} )}
{/* Launcher build label, anchored bottom-right.*/}
{appVersion && ( {appVersion && (
<span className="pointer-events-none absolute bottom-2 right-3 select-none font-mono text-[10px] uppercase tracking-wider text-white/40"> <span className="pointer-events-none absolute bottom-2 right-3 text-[10px] font-mono uppercase tracking-wider text-white/40 select-none">
v{appVersion} v{appVersion}
</span> </span>
)} )}
<AntivirusModal />
</div> </div>
); );
}; };
+42 -56
View File
@@ -2,8 +2,6 @@ import { Clipboard, RefreshCw, ServerCrash } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react'; import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer'; import log from 'electron-log/renderer';
import { useT } from '~renderer/i18n';
import PageBackground from './assets/background.png'; import PageBackground from './assets/background.png';
import TextButton from './components/styled/TextButton'; import TextButton from './components/styled/TextButton';
@@ -17,59 +15,6 @@ type Props = {
children: ReactNode; 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> { class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
@@ -84,7 +29,48 @@ class ErrorBoundary extends Component<Props, State> {
render() { render() {
if (!this.state.didCatch) return this.props.children; if (!this.state.didCatch) return this.props.children;
const { error, errorInfo } = this.state; const { error, errorInfo } = this.state;
return <ErrorFallback error={error} errorInfo={errorInfo} />; const title = `Uncaught ${error?.name}: ${
error?.message ?? 'Unknown error'
}`;
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">Something went wrong!</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\`\`\``
)
}
>
Copy error
</TextButton>
<TextButton
icon={RefreshCw}
onClick={() => window.location.reload()}
className="text-warmGreen"
>
Reload
</TextButton>
</div>
</div>
</div>
);
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

-230
View File
@@ -1,230 +0,0 @@
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;
+20 -44
View File
@@ -1,20 +1,17 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { PreferencesSchema } from '~common/schemas'; import { PreferencesSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver'; import zodResolver from '~renderer/utils/zodResolver';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton'; import TextButton from './styled/TextButton';
import FilePickerInput from './form/FilePickerInput'; import FilePickerInput from './form/FilePickerInput';
import CheckboxInput from './form/CheckboxInput';
import CloseButton from './styled/CloseButton'; import CloseButton from './styled/CloseButton';
type Props = { close: () => void }; type Props = { close: () => void };
const ClientDirDialog = ({ close }: Props) => { const ClientDirDialog = ({ close }: Props) => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery(); const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation(); const setPref = api.preferences.set.useMutation();
const isValidClientDir = api.preferences.isValidClientDir.useQuery( const isValidClientDir = api.preferences.isValidClientDir.useQuery(
@@ -37,19 +34,6 @@ const ClientDirDialog = ({ close }: Props) => {
resolver: zodResolver(PreferencesSchema.pick({ clientDir: true })) 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(() => { useEffect(() => {
pref && reset(pref); pref && reset(pref);
}, [reset, pref]); }, [reset, pref]);
@@ -58,14 +42,16 @@ const ClientDirDialog = ({ close }: Props) => {
return ( return (
<form className="tw-dialog"> <form className="tw-dialog">
<CloseButton close={close} /> <CloseButton close={close} />
<h2 className="color mb-2 text-xl"> <h2 className="color mb-2 text-xl">Install location</h2>
{t('prefs.installLocationTitle')} <p>
</h2> You are using the portable version of the launcher. Install location
<p>{t('prefs.portableInfo')}</p> is determined by the location of the launcher executable.
</p>
{!isValidClientDir.isLoading && !isValidClientDir.data && ( {!isValidClientDir.isLoading && !isValidClientDir.data && (
<p> <p>
<span className="text-secondary">{t('prefs.errorLabel')}</span> <span className="text-secondary">Error: </span>
{t('prefs.wowExeNotFound', { exe: 'WoW.exe' })} WoW.exe not found in current folder. Please close the launcher and
move it to your WoW 1.12 client directory.
</p> </p>
)} )}
</form> </form>
@@ -76,10 +62,9 @@ const ClientDirDialog = ({ close }: Props) => {
<form <form
className="tw-dialog" className="tw-dialog"
onSubmit={handleSubmit(async ({ clientDir }) => { onSubmit={handleSubmit(async ({ clientDir }) => {
if (needsEmptyConfirm && !acceptEmpty) return;
try { try {
await setPref.mutateAsync({ clientDir }); await setPref.mutateAsync({ clientDir });
verify.mutate(); verify.mutateAsync();
close(); close();
} catch (e) { } catch (e) {
setError('clientDir', { setError('clientDir', {
@@ -94,13 +79,18 @@ const ClientDirDialog = ({ close }: Props) => {
close(); close();
}} }}
/> />
<h3 className="tw-color">{t('prefs.installLocationTitle')}</h3> <h3 className="tw-color">Install location</h3>
<hr /> <hr />
<p className="text-blueGray">{t('prefs.selectDirectory')}</p> <p className="text-blueGray">
<p className="text-blueGray">{t('prefs.upgradeExisting')}</p> Select a directory for the game client installation.
</p>
<p className="text-blueGray">
You may also choose a directory with an existing Turtle WoW or Vanilla
WoW installation, and it will be automatically upgraded.
</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<label htmlFor="clientDir">{t('prefs.installDirectory')}</label> <label htmlFor="clientDir">Install directory:</label>
<FilePickerInput <FilePickerInput
{...register('clientDir')} {...register('clientDir')}
title={watch('clientDir') ?? undefined} title={watch('clientDir') ?? undefined}
@@ -120,26 +110,12 @@ const ClientDirDialog = ({ close }: Props) => {
</p> </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 <TextButton
type="submit" type="submit"
loading={formState.isSubmitting} loading={formState.isSubmitting}
disabled={needsEmptyConfirm && !acceptEmpty}
className="self-end text-green" className="self-end text-green"
> >
{t('prefs.confirm')} Confirm
</TextButton> </TextButton>
</form> </form>
); );
+6 -11
View File
@@ -1,7 +1,5 @@
import OctoLogo from '~renderer/assets/logo.png'; import OctoLogo from '~renderer/assets/logo.png';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton'; import TextButton from './styled/TextButton';
import { TabNames, type TabType } from './TabsPanel'; import { TabNames, type TabType } from './TabsPanel';
@@ -10,9 +8,7 @@ type Props = {
setActiveTab: (tab?: TabType) => void; setActiveTab: (tab?: TabType) => void;
}; };
const Header = ({ activeTab, setActiveTab }: Props) => { const Header = ({ activeTab, setActiveTab }: Props) => (
const t = useT();
return (
<div className="-mb-3 flex select-none items-center gap-1"> <div className="-mb-3 flex select-none items-center gap-1">
<button <button
onClick={() => setActiveTab(undefined)} onClick={() => setActiveTab(undefined)}
@@ -20,18 +16,17 @@ const Header = ({ activeTab, setActiveTab }: Props) => {
> >
<img src={OctoLogo} alt="OctoWoW" className="pointer-events-none" /> <img src={OctoLogo} alt="OctoWoW" className="pointer-events-none" />
</button> </button>
{TabNames.map(tab => ( {TabNames.map(t => (
<TextButton <TextButton
key={tab} key={t}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(t)}
active={activeTab === tab} active={activeTab === t}
className="uppercase" className="uppercase"
> >
{t(`tab.${tab}`)} {t}
</TextButton> </TextButton>
))} ))}
</div> </div>
); );
};
export default Header; export default Header;
@@ -1,91 +0,0 @@
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;
+40 -90
View File
@@ -1,11 +1,9 @@
import { useState, type ReactElement } from 'react'; import { useState, type ReactElement } from 'react';
import cls from 'classnames'; import cls from 'classnames';
import log from 'electron-log/renderer';
import { type UpdaterStatus, type ModsStatus } from '~main/types'; import { type UpdaterStatus, type ModsStatus } from '~main/types';
import { formatFileSize } from '~common/utils'; import { formatFileSize } from '~common/utils';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import Button from './styled/Button'; import Button from './styled/Button';
import DialogButton from './styled/DialogButton'; import DialogButton from './styled/DialogButton';
@@ -22,44 +20,40 @@ const formatDuration = (seconds: number) => {
return minRem ? `${h}h ${minRem}m` : `${h}h`; return minRem ? `${h}h ${minRem}m` : `${h}h`;
}; };
const formatPercent = (progress: number) =>
`${parseFloat((progress * 100).toFixed(1))}%`;
const ProgressDetails = ({ status }: { status: UpdaterStatus }) => { const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
const t = useT(); const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } = status;
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } =
status;
if (bytesTotal === undefined || bytesDone === undefined) return null; if (bytesTotal === undefined || bytesDone === undefined) return null;
const pct = const pct = progress !== undefined && progress >= 0
progress !== undefined && progress >= 0 ? formatPercent(progress) : '—'; ? `${(progress * 100).toFixed(1)}%`
: '—';
return ( return (
<p className="s1 text-blueGray"> <p className="s1 text-blueGray">
<span className="tw-color">{pct}</span> <span className="tw-color">{pct}</span>
<span> <span> · {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}</span>
{' '}
· {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}
</span>
{bytesPerSecond !== undefined && bytesPerSecond > 0 && ( {bytesPerSecond !== undefined && bytesPerSecond > 0 && (
<span> · {formatFileSize(bytesPerSecond, 1)}/s</span> <span> · {formatFileSize(bytesPerSecond)}/s</span>
)} )}
<span> <span>
{' · '} {' · '}
{etaSeconds !== undefined {etaSeconds !== undefined
? `~${formatDuration(etaSeconds)} ${t('launch.remaining')}` ? `~${formatDuration(etaSeconds)} remaining`
: t('launch.calculating')} : 'calculating'}
</span> </span>
</p> </p>
); );
}; };
const LaunchPanel = () => { const LaunchPanel = () => {
const t = useT();
const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' }); const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' });
api.updater.observe.useSubscription(undefined, { api.updater.observe.useSubscription(undefined, {
onData: setStatus, onData: data => {
onError: err => log.error('Updater subscription error:', err) console.log({ data });
setStatus(data);
},
onError: err => console.log({ err }),
onStarted: () => console.log('Started')
}); });
const { data: pref } = api.preferences.get.useQuery(); const { data: pref } = api.preferences.get.useQuery();
@@ -74,42 +68,27 @@ const LaunchPanel = () => {
const start = api.launcher.start.useMutation(); const start = api.launcher.start.useMutation();
const applyMods = api.mods.applyAll.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< const props: Record<
UpdaterStatus['state'], UpdaterStatus['state'],
{ button: ReactElement; helperText?: ReactElement } { button: ReactElement; helperText?: ReactElement }
> = { > = {
verifying: { button: <Button disabled>{t('launch.verifying')}</Button> }, verifying: { button: <Button disabled>Verifying</Button> },
serverUnreachable: { serverUnreachable: {
button: pref?.version ? ( button: pref?.version ? (
<Button disabled={start.isLoading} onClick={() => start.mutateAsync()}> <Button onClick={() => start.mutateAsync()}>Play</Button>
{t('launch.play')}
</Button>
) : ( ) : (
<Button onClick={() => verify.mutateAsync()}> <Button onClick={() => verify.mutateAsync()}>Retry</Button>
{t('launch.retry')}
</Button>
), ),
helperText: ( helperText: (
<div className="-mb-2"> <div className="-mb-2">
<p> <p>
<span className="text-orange">{t('launch.errorLabel')}</span>{' '} <span className="text-orange">Error: </span> Failed to reach update
{t('launch.serverFail')} server
</p> </p>
<p className="s1 text-blueGray"> <p className="s1 text-blueGray">
{pref?.version {pref?.version
? t('launch.localVersion', { version: pref.version }) ? `You can launch local version ${pref?.version}`
: t('launch.tryLater')} : 'Please try again later'}
</p> </p>
</div> </div>
) )
@@ -122,43 +101,39 @@ const LaunchPanel = () => {
> >
{open => ( {open => (
<Button primary onClick={open}> <Button primary onClick={open}>
{t('launch.install')} Install
</Button> </Button>
)} )}
</DialogButton> </DialogButton>
) )
}, },
updateAvailable: { updateAvailable: {
button: ( button: <Button onClick={() => update.mutateAsync()}>Update</Button>,
<Button onClick={() => update.mutateAsync()}>
{t('launch.update')}
</Button>
),
helperText: ( helperText: (
<div className="-mb-2 flex flex-col gap-1"> <div className="-mb-2 flex flex-col gap-1">
<p>{t('launch.updateAvailable')}</p> <p>Update available!</p>
<p className="s1 text-blueGray"> <p className="s1 text-blueGray">
{status.progress !== undefined && {status.progress !== undefined &&
status.bytesDone !== undefined && status.bytesDone !== undefined &&
status.bytesTotal !== undefined && ( status.bytesTotal !== undefined && (
<> <>
<span className="tw-color"> <span className="tw-color">
{formatPercent(status.progress)} {(status.progress * 100).toFixed(1)}%
</span> </span>
<span> <span>
{' '} {' '}
· {formatFileSize(status.bytesDone)} /{' '} · {formatFileSize(status.bytesDone)} /{' '}
{formatFileSize(status.bytesTotal)} {t('launch.onDisk')} ·{' '} {formatFileSize(status.bytesTotal)} on disk ·{' '}
</span> </span>
</> </>
)} )}
<span className="break-all">{status.message}</span> <span className="break-all">{status.message}</span> remaining
</p> </p>
</div> </div>
) )
}, },
updating: { updating: {
button: <Button disabled>{t('launch.updating')}</Button>, button: <Button disabled>Updating</Button>,
helperText: ( helperText: (
<div className="-mb-2 flex flex-col gap-1"> <div className="-mb-2 flex flex-col gap-1">
{status.message && ( {status.message && (
@@ -173,59 +148,37 @@ const LaunchPanel = () => {
<Button <Button
primary primary
onClick={() => applyMods.mutateAsync()} onClick={() => applyMods.mutateAsync()}
disabled={ disabled={applyMods.isLoading || modsStatus?.state === 'busy'}
applyMods.isLoading ||
modsStatus?.state === 'busy' ||
missingDeps.length > 0
}
> >
{modsStatus?.state === 'busy' {modsStatus?.state === 'busy' ? 'Applying' : 'Update'}
? t('launch.applying')
: t('mods.apply')}
</Button> </Button>
) : ( ) : (
<Button <Button primary onClick={() => start.mutateAsync()}>
primary Play
disabled={start.isLoading}
onClick={() => start.mutateAsync()}
>
{t('launch.play')}
</Button> </Button>
), ),
helperText: ( helperText: (
<div className="-mb-2"> <div className="-mb-2">
{modsStatus?.dirty ? ( {modsStatus?.dirty ? (
missingDeps.length ? ( <p>Mods changed apply before playing</p>
<p className="text-orange">
{t('mods.enableRequired', {
mods: missingDeps.map(modName).join(', ')
})}
</p>
) : ( ) : (
<p>{t('launch.modsChanged')}</p> <p>Everything up to date!</p>
)
) : (
<p>{t('launch.upToDate')}</p>
)} )}
<p className="s1 text-blueGray"> <p className="s1 text-blueGray">Version: {pref?.version}</p>
{t('launch.version', { version: pref?.version ?? '' })}
</p>
</div> </div>
) )
}, },
failed: { failed: {
button: ( button: <Button onClick={() => verify.mutateAsync()}>Retry</Button>,
<Button onClick={() => verify.mutateAsync()}>
{t('launch.retry')}
</Button>
),
helperText: ( helperText: (
<div className="-mb-2"> <div className="-mb-2">
<p> <p>
<span className="text-orange">{t('launch.errorLabel')}</span>{' '} <span className="text-orange">Error: </span>
{status.message} {status.message}
</p> </p>
<p className="s1 text-blueGray">{t('launch.verifyHint')}</p> <p className="s1 text-blueGray">
Verify your game data by clicking Retry.
</p>
</div> </div>
) )
} }
@@ -238,9 +191,6 @@ const LaunchPanel = () => {
(status.message && ( (status.message && (
<p className="s1 -mb-2 text-blueGray">{status.message}</p> <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"> <div className="tw-loading-wrapper">
{status.progress !== undefined && ( {status.progress !== undefined && (
<div <div
+26 -84
View File
@@ -3,17 +3,14 @@ import { useEffect, useState } from 'react';
import { import {
FilePen, FilePen,
FolderOpen, FolderOpen,
HelpCircle,
RefreshCw, RefreshCw,
ScrollText, ScrollText,
ShieldAlert,
ShieldCheck ShieldCheck
} from 'lucide-react'; } from 'lucide-react';
import { PreferencesSchema } from '~common/schemas'; import { PreferencesSchema } from '~common/schemas';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import zodResolver from '~renderer/utils/zodResolver'; import zodResolver from '~renderer/utils/zodResolver';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton'; import TextButton from './styled/TextButton';
import CheckboxInput from './form/CheckboxInput'; import CheckboxInput from './form/CheckboxInput';
@@ -22,39 +19,32 @@ import ClientDirDialog from './ClientDirDialog';
import CloseButton from './styled/CloseButton'; import CloseButton from './styled/CloseButton';
const MirrorStatus = () => { const MirrorStatus = () => {
const t = useT();
const [state, setState] = useState<string>('verifying'); const [state, setState] = useState<string>('verifying');
api.updater.observe.useSubscription(undefined, { api.updater.observe.useSubscription(undefined, {
onData: ({ state }) => setState(state) onData: ({ state }) => setState(state)
}); });
if (state === 'serverUnreachable') if (state === 'serverUnreachable')
return <span className="s1 text-red">{t('prefs.mirrorOffline')}</span>; return <span className="s1 text-red">offline</span>;
if (state === 'verifying' || state === 'updating') if (state === 'verifying' || state === 'updating')
return ( return <span className="s1 text-blueGray">checking</span>;
<span className="s1 text-blueGray">{t('prefs.mirrorChecking')}</span> return <span className="s1 text-warmGreen">online</span>;
);
return <span className="s1 text-warmGreen">{t('prefs.mirrorOnline')}</span>;
}; };
type Props = { close: () => void }; type Props = { close: () => void };
const PreferencesDialog = ({ close }: Props) => { const PreferencesDialog = ({ close }: Props) => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery(); const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation(); const setPref = api.preferences.set.useMutation();
const verify = api.updater.verify.useMutation(); const verify = api.updater.verify.useMutation();
const repair = api.mods.repair.useMutation();
const openInstallFolder = api.general.openInstallFolder.useMutation(); const openInstallFolder = api.general.openInstallFolder.useMutation();
const openLogFile = api.general.openLogFile.useMutation(); const openLogFile = api.general.openLogFile.useMutation();
const addExclusion = api.general.addDefenderExclusion.useMutation();
const { handleSubmit, watch, setValue, reset } = useForm({ const { handleSubmit, watch, setValue, reset } = useForm({
defaultValues: pref ?? {}, defaultValues: pref ?? {},
resolver: zodResolver(PreferencesSchema) resolver: zodResolver(PreferencesSchema)
}); });
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
pref && reset(pref); pref && reset(pref);
@@ -69,19 +59,10 @@ const PreferencesDialog = ({ close }: Props) => {
return ( return (
<form <form
className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1" className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1 whitespace-nowrap"
onSubmit={handleSubmit(async v => { onSubmit={handleSubmit(async v => {
setSaveError(null); await setPref.mutateAsync(v);
try {
await setPref.mutateAsync({
cleanWdb: v.cleanWdb,
minimizeToTrayOnPlay: v.minimizeToTrayOnPlay,
shareDownloads: v.shareDownloads
});
close(); close();
} catch (e) {
setSaveError(e instanceof Error ? e.message : String(e));
}
})} })}
> >
<CloseButton <CloseButton
@@ -90,26 +71,26 @@ const PreferencesDialog = ({ close }: Props) => {
close(); close();
}} }}
/> />
<h3 className="tw-color">{t('prefs.title')}</h3> <h3 className="tw-color">SETTINGS</h3>
<hr className="mb-1" /> <hr className="mb-1" />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h4 className="tw-color">{t('prefs.installLocation')}</h4> <h4 className="tw-color">INSTALL LOCATION:</h4>
<TextButton <TextButton
icon={FolderOpen} icon={FolderOpen}
size={14} size={14}
onClick={() => openInstallFolder.mutateAsync()} onClick={() => openInstallFolder.mutateAsync()}
className="!p-1 text-blueGray" className="!p-1 text-blueGray"
> >
{t('prefs.openFolder')} Open folder
</TextButton> </TextButton>
</div> </div>
<div className="flex items-center gap-2 border border-blueGray/20 bg-darkGray/40 px-3 py-1"> <div className="flex items-center gap-2 border border-blueGray/20 bg-darkGray/40 px-3 py-1">
<span <span
title={pref?.clientDir} title={pref?.clientDir}
className="min-w-0 shrink grow overflow-hidden text-ellipsis whitespace-nowrap" className="min-w-0 shrink grow overflow-hidden text-ellipsis"
> >
{pref?.clientDir ?? t('prefs.notSelected')} {pref?.clientDir ?? 'Not selected'}
</span> </span>
<DialogButton <DialogButton
dialog={closeInner => ( dialog={closeInner => (
@@ -123,20 +104,15 @@ const PreferencesDialog = ({ close }: Props) => {
clickAway={pref?.isPortable} clickAway={pref?.isPortable}
> >
{open => ( {open => (
<TextButton <TextButton icon={FilePen} size={14} onClick={open} className="!p-1">
icon={FilePen} Change
size={14}
onClick={open}
className="!p-1"
>
{t('prefs.change')}
</TextButton> </TextButton>
)} )}
</DialogButton> </DialogButton>
</div> </div>
<div className="mt-1 flex items-center gap-3"> <div className="mt-1 flex items-center gap-3">
<h4 className="tw-color">{t('prefs.downloadMirror')}</h4> <h4 className="tw-color">DOWNLOAD MIRROR:</h4>
</div> </div>
<div className="flex items-center gap-2 pl-2"> <div className="flex items-center gap-2 pl-2">
<input type="radio" checked readOnly className="accent-warmGreen" /> <input type="radio" checked readOnly className="accent-warmGreen" />
@@ -146,81 +122,47 @@ const PreferencesDialog = ({ close }: Props) => {
icon={RefreshCw} icon={RefreshCw}
size={12} size={12}
onClick={() => verify.mutateAsync()} onClick={() => verify.mutateAsync()}
title={t('prefs.recheck')} title="Re-check"
className="!p-0 text-blueGray" className="!p-0 text-blueGray"
/> />
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex min-w-0 flex-col"> <div className="flex flex-col">
<h4 className="tw-color">{t('prefs.troubleshooting')}</h4> <h4 className="tw-color">TROUBLESHOOTING:</h4>
<TextButton <TextButton
icon={ShieldCheck} icon={ShieldCheck}
onClick={() => repair.mutateAsync().then(close)} onClick={() => verify.mutateAsync().then(close)}
className="!items-start text-left text-warmGreen" className="text-warmGreen"
> >
{t('prefs.verifyGameFiles')} Verify game files
</TextButton> </TextButton>
<TextButton <TextButton
icon={ScrollText} icon={ScrollText}
onClick={() => openLogFile.mutateAsync()} onClick={() => openLogFile.mutateAsync()}
className="!items-start text-left text-pink" className="text-pink"
> >
{t('prefs.openLogFile')} Open log file
</TextButton> </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>
<div className="flex min-w-0 flex-col"> <div className="flex flex-col">
<h4 className="tw-color">{t('prefs.generalSettings')}</h4> <h4 className="tw-color">GENERAL SETTINGS:</h4>
<CheckboxInput <CheckboxInput
value={!!watch('cleanWdb')} value={!!watch('cleanWdb')}
setValue={setBool('cleanWdb')} setValue={setBool('cleanWdb')}
label={t('prefs.cleanWdb')} label="Clean WDB on each launch"
/> />
<CheckboxInput <CheckboxInput
value={!!watch('minimizeToTrayOnPlay')} value={!!watch('minimizeToTrayOnPlay')}
setValue={setBool('minimizeToTrayOnPlay')} setValue={setBool('minimizeToTrayOnPlay')}
label={t('prefs.minimizeToTray')} label="Minimize to tray while playing"
/>
<CheckboxInput
value={watch('shareDownloads') !== false}
setValue={setBool('shareDownloads')}
label={t('prefs.shareDownloads')}
/> />
</div> </div>
</div> </div>
{saveError && (
<span className="s1 self-end text-orange">{saveError}</span>
)}
<TextButton type="submit" className="mt-1 self-end text-green"> <TextButton type="submit" className="mt-1 self-end text-green">
{t('prefs.save')} Save
</TextButton> </TextButton>
</form> </form>
); );
+7 -12
View File
@@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import Button from './styled/Button'; import Button from './styled/Button';
@@ -20,7 +19,6 @@ type Status =
| { state: 'error'; currentVersion: string; message: string }; | { state: 'error'; currentVersion: string; message: string };
const SelfUpdateBanner = () => { const SelfUpdateBanner = () => {
const t = useT();
const [status, setStatus] = useState<Status>({ const [status, setStatus] = useState<Status>({
state: 'idle', state: 'idle',
currentVersion: '' currentVersion: ''
@@ -41,18 +39,15 @@ const SelfUpdateBanner = () => {
const tone = status.state === 'error' ? 'border-red/40' : 'border-tw/40'; const tone = status.state === 'error' ? 'border-red/40' : 'border-tw/40';
const label = const label =
status.state === 'error' status.state === 'error'
? t('misc.selfUpdateCheckFailed', { message: status.message }) ? `Update check failed: ${status.message}`
: status.state === 'available' : status.state === 'available'
? t('misc.selfUpdateAvailable', { ? `Launcher update ${'nextVersion' in status ? status.nextVersion : ''} available — preparing download…`
version: status.nextVersion
})
: status.state === 'downloading' : status.state === 'downloading'
? t('misc.selfUpdateDownloading', { ? `Downloading update ${status.nextVersion} · ${Math.round(
version: status.nextVersion, status.progress * 100
percent: Math.round(status.progress * 100) )}%`
})
: status.state === 'ready' : status.state === 'ready'
? t('misc.selfUpdateReady', { version: status.nextVersion }) ? `Launcher update ${status.nextVersion} ready to install`
: ''; : '';
return ( return (
@@ -66,7 +61,7 @@ const SelfUpdateBanner = () => {
onClick={() => install.mutateAsync()} onClick={() => install.mutateAsync()}
disabled={install.isLoading} disabled={install.isLoading}
> >
{t('misc.selfUpdateInstallNow')} Install now
</Button> </Button>
)} )}
</div> </div>
+23 -49
View File
@@ -2,8 +2,6 @@ import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react'; import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer'; import log from 'electron-log/renderer';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton'; import TextButton from './styled/TextButton';
type Props = { type Props = {
@@ -16,47 +14,6 @@ type State = {
componentStack?: string; 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> { class TabErrorBoundary extends Component<Props, State> {
state: State = {}; state: State = {};
@@ -81,12 +38,29 @@ class TabErrorBoundary extends Component<Props, State> {
if (!this.state.error) return this.props.children; if (!this.state.error) return this.props.children;
const { error, componentStack } = this.state; const { error, componentStack } = this.state;
return ( return (
<TabErrorFallback <div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
tabName={this.props.tabName} <div className="flex items-center gap-2">
error={error} <AlertTriangle size={22} className="text-red" />
componentStack={componentStack} <h4 className="text-red">{this.props.tabName} crashed</h4>
onReset={this.#reset} </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={this.#reset}
className="self-end text-warmGreen"
>
Try again
</TextButton>
</div>
); );
} }
} }
+4 -4
View File
@@ -5,10 +5,10 @@ import TweaksTab from './tabs/TweaksTab';
import TabErrorBoundary from './TabErrorBoundary'; import TabErrorBoundary from './TabErrorBoundary';
const Tabs = { const Tabs = {
news: NewsTab, 'news': NewsTab,
tweaks: TweaksTab, 'tweaks': TweaksTab,
addons: AddonsTab, 'addons': AddonsTab,
mods: ModsTab 'mods': ModsTab
} as const; } as const;
export const TabNames = Object.keys(Tabs) as TabType[]; export const TabNames = Object.keys(Tabs) as TabType[];
+11 -15
View File
@@ -2,15 +2,12 @@ import { Settings, Minus, X } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import DialogButton from './styled/DialogButton'; import DialogButton from './styled/DialogButton';
import PreferencesDialog from './PreferencesDialog'; import PreferencesDialog from './PreferencesDialog';
import TextButton from './styled/TextButton'; import TextButton from './styled/TextButton';
import LanguageDropdown from './LanguageDropdown';
const TopBar = () => { const TopBar = () => {
const t = useT();
const [safeToQuit, setSafeToQuit] = useState(true); const [safeToQuit, setSafeToQuit] = useState(true);
api.updater.observe.useSubscription(undefined, { api.updater.observe.useSubscription(undefined, {
onData: ({ state }) => onData: ({ state }) =>
@@ -24,16 +21,12 @@ const TopBar = () => {
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties} style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
className="absolute left-0 right-0 top-0 flex justify-end pr-2 pt-2 opacity-50" className="absolute left-0 right-0 top-0 flex justify-end pr-2 pt-2 opacity-50"
> >
<div <div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties} className="flex">
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
className="flex items-center"
>
<LanguageDropdown />
<DialogButton dialog={close => <PreferencesDialog close={close} />}> <DialogButton dialog={close => <PreferencesDialog close={close} />}>
{open => ( {open => (
<TextButton <TextButton
icon={Settings} icon={Settings}
title={t('topbar.settings')} title="Settings"
onClick={open} onClick={open}
size={16} size={16}
className="!p-1" className="!p-1"
@@ -42,7 +35,7 @@ const TopBar = () => {
</DialogButton> </DialogButton>
<TextButton <TextButton
icon={Minus} icon={Minus}
title={t('topbar.minimize')} title="Minimize"
onClick={() => minimize.mutateAsync()} onClick={() => minimize.mutateAsync()}
size={16} size={16}
className="!p-1" className="!p-1"
@@ -50,16 +43,19 @@ const TopBar = () => {
<DialogButton <DialogButton
dialog={close => ( dialog={close => (
<div className="tw-dialog"> <div className="tw-dialog">
<h3 className="tw-color">{t('quit.title')}</h3> <h3 className="tw-color">Quit?</h3>
<hr /> <hr />
<p className="text-blueGray">{t('quit.warn')}</p> <p className="text-blueGray">
Your game is currently being updated. Quitting now may cause
problems.
</p>
<div className="flex gap-2 self-end"> <div className="flex gap-2 self-end">
<TextButton onClick={close}>{t('quit.return')}</TextButton> <TextButton onClick={close}>Return</TextButton>
<TextButton <TextButton
onClick={() => quit.mutateAsync()} onClick={() => quit.mutateAsync()}
className="text-red" className="text-red"
> >
{t('topbar.quit')} Quit
</TextButton> </TextButton>
</div> </div>
</div> </div>
@@ -68,7 +64,7 @@ const TopBar = () => {
{open => ( {open => (
<TextButton <TextButton
icon={X} icon={X}
title={t('topbar.quit')} title="Quit"
onClick={() => (!safeToQuit ? open() : quit.mutateAsync())} onClick={() => (!safeToQuit ? open() : quit.mutateAsync())}
size={16} size={16}
className="!p-1 hocus:text-red" className="!p-1 hocus:text-red"
+3 -10
View File
@@ -3,7 +3,6 @@ import { type ReactNode } from 'react';
import TextButton from '../styled/TextButton'; import TextButton from '../styled/TextButton';
// mt centers this 16px box on the 26px label line box
const Checkbox = () => ( const Checkbox = () => (
<svg <svg
width={16} width={16}
@@ -11,7 +10,7 @@ const Checkbox = () => (
viewBox="0 0 12 12" viewBox="0 0 12 12"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className="mt-[5px] shrink-0" className="shrink-0"
> >
<rect <rect
x="1" x="1"
@@ -34,18 +33,12 @@ type Props = {
className?: cls.Value; className?: cls.Value;
}; };
const CheckboxInput = ({ const CheckboxInput = ({ label, value, setValue, disabled, className }: Props) => (
label,
value,
setValue,
disabled,
className
}: Props) => (
<TextButton <TextButton
onClick={() => !disabled && setValue(!value)} onClick={() => !disabled && setValue(!value)}
icon={Checkbox} icon={Checkbox}
className={cls( className={cls(
'!items-start text-left text-blueGray', 'text-blueGray',
{ '[&_*]:fill-none': !value, 'pointer-events-none opacity-40': disabled }, { '[&_*]:fill-none': !value, 'pointer-events-none opacity-40': disabled },
className className
)} )}
+9 -27
View File
@@ -1,39 +1,21 @@
type Run = { text: string; color?: string }; 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 tokenize = (s: string): Run[] => {
const runs: Run[] = []; const runs: Run[] = [];
let color: string | undefined; const re = /\|c([0-9a-fA-F]{8})|\|r/g;
let buf = '';
let i = 0; let i = 0;
let color: string | undefined;
const flush = () => {
if (buf) runs.push({ text: buf, color });
buf = '';
};
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = ESCAPE_RE.exec(s)) !== null) { while ((m = re.exec(s)) !== null) {
buf += s.slice(i, m.index); if (m.index > i) runs.push({ text: s.slice(i, m.index), color });
i = ESCAPE_RE.lastIndex; if (m[0].toLowerCase() === '|r') {
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; color = undefined;
} else if (m[1]) {
color = `#${m[1].slice(2).toLowerCase()}`;
} }
i = re.lastIndex;
} }
buf += s.slice(i); if (i < s.length) runs.push({ text: s.slice(i), color });
flush();
return runs.filter(r => r.text.length > 0); return runs.filter(r => r.text.length > 0);
}; };
@@ -39,6 +39,7 @@ const DialogButton = ({
ref.current?.close(); ref.current?.close();
}, []); }, []);
// Click away
useEffect(() => { useEffect(() => {
if (!clickAway) return; if (!clickAway) return;
const callback = (e: MouseEvent) => e.target === ref.current && close(); const callback = (e: MouseEvent) => e.target === ref.current && close();
@@ -53,7 +53,7 @@ const TextButton = ({
{loading ? ( {loading ? (
<IconSpinner size={size ?? 24} strokeWidth={1.5} /> <IconSpinner size={size ?? 24} strokeWidth={1.5} />
) : ( ) : (
Icon && <Icon size={size} className="shrink-0" /> Icon && <Icon size={size} />
)} )}
{children && ( {children && (
<span className="cursor-pointer select-none tracking-wide text-inherit [font-size:_inherit]"> <span className="cursor-pointer select-none tracking-wide text-inherit [font-size:_inherit]">
+10 -34
View File
@@ -5,7 +5,6 @@ import { type AddonData, type AddonsStatus } from '~main/types';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import TextButton from '~renderer/components/styled/TextButton'; import TextButton from '~renderer/components/styled/TextButton';
import useScrollHint from '~renderer/utils/useScrollHint'; import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import DialogButton from '../styled/DialogButton'; import DialogButton from '../styled/DialogButton';
import IconSpinner from '../styled/IconSpinner'; import IconSpinner from '../styled/IconSpinner';
@@ -14,17 +13,6 @@ import AddonList from './addons/AddonList';
import { type Dependencies } from './addons/AddonListItem'; import { type Dependencies } from './addons/AddonListItem';
import CustomAddonDialog from './addons/CustomAddonDialog'; import CustomAddonDialog from './addons/CustomAddonDialog';
const RECOMMENDED = new Set([
'AtlasLoot',
'pfExtend',
'pfQuest',
'pfQuest-turtle',
'SellValue',
'ShaguTweaks',
'ShaguTweaks-extras',
'TurtleMail'
]);
const localeFilter = (l: AddonData[], filter: string) => { const localeFilter = (l: AddonData[], filter: string) => {
const seen = new Set<string>(); const seen = new Set<string>();
const deduped = l.filter(a => { const deduped = l.filter(a => {
@@ -33,14 +21,14 @@ const localeFilter = (l: AddonData[], filter: string) => {
return true; return true;
}); });
return deduped return deduped
.filter(a => .filter(
a.folder.toLocaleLowerCase().includes(filter.toLocaleLowerCase()) a =>
a.folder.toLocaleLowerCase().indexOf(filter.toLocaleLowerCase()) !== -1
) )
.sort((a, b) => a.folder.localeCompare(b.folder)); .sort((a, b) => a.folder.localeCompare(b.folder));
}; };
const AddonsTab = () => { const AddonsTab = () => {
const t = useT();
const [data, setData] = useState<AddonsStatus>({ const [data, setData] = useState<AddonsStatus>({
state: 'verifying', state: 'verifying',
addons: {}, addons: {},
@@ -73,26 +61,14 @@ const AddonsTab = () => {
className="relative -m-4 -mb-3 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pb-3" className="relative -m-4 -mb-3 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pb-3"
> >
<AddonList <AddonList
title={t('addons.sectionInstalled')} title="Installed"
addons={localeFilter(Object.values(data.addons), filter)} addons={localeFilter(Object.values(data.addons), filter)}
dependencies={dependencies} dependencies={dependencies}
/> />
<AddonList <AddonList
title={t('addons.sectionRecommended')} title="Available"
addons={localeFilter( addons={localeFilter(
data.available.filter( data.available.filter(a => !(a.folder in data.addons)),
a => !(a.folder in data.addons) && RECOMMENDED.has(a.folder)
),
filter
)}
dependencies={dependencies}
/>
<AddonList
title={t('addons.sectionAvailable')}
addons={localeFilter(
data.available.filter(
a => !(a.folder in data.addons) && !RECOMMENDED.has(a.folder)
),
filter filter
)} )}
dependencies={dependencies} dependencies={dependencies}
@@ -107,7 +83,7 @@ const AddonsTab = () => {
size={18} size={18}
loading={data.state !== 'done'} loading={data.state !== 'done'}
> >
{t('addons.checkForUpdates')} Check for updates
</TextButton> </TextButton>
<DialogButton <DialogButton
clickAway clickAway
@@ -120,7 +96,7 @@ const AddonsTab = () => {
onClick={open} onClick={open}
className="s1 text-pink" className="s1 text-pink"
> >
{t('addons.addCustomGitAddon')} Add custom git addon
</TextButton> </TextButton>
)} )}
</DialogButton> </DialogButton>
@@ -131,11 +107,11 @@ const AddonsTab = () => {
onClick={() => update.mutateAsync({})} onClick={() => update.mutateAsync({})}
className="justify-self-end text-warmGreen" className="justify-self-end text-warmGreen"
> >
{t('addons.updateAll')} Update all
</TextButton> </TextButton>
) : ( ) : (
<p className="s1 justify-self-end text-blueGray"> <p className="s1 justify-self-end text-blueGray">
{t('addons.everythingUpToDate')} Everything is up to date.
</p> </p>
)} )}
</div> </div>
@@ -1,12 +1,7 @@
import { useT } from '~renderer/i18n'; const ComingSoonTab = () => (
const ComingSoonTab = () => {
const t = useT();
return (
<div className="tw-surface flex flex-grow flex-col items-center justify-center gap-2"> <div className="tw-surface flex flex-grow flex-col items-center justify-center gap-2">
<p className="italic text-blueGray">{t('misc.comingSoon')}</p> <p className="italic text-blueGray">Coming soon...</p>
</div> </div>
); );
};
export default ComingSoonTab; export default ComingSoonTab;
+23 -204
View File
@@ -1,24 +1,19 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { ExternalLink, AlertTriangle, Sparkles } from 'lucide-react'; import { ExternalLink, AlertTriangle, Sparkles } from 'lucide-react';
import { createPortal } from 'react-dom';
import cls from 'classnames'; import cls from 'classnames';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import useScrollHint from '~renderer/utils/useScrollHint'; import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n'; import { type ModRowStatus, type ModsStatus } from '~main/types';
import {
type ModRowStatus,
type ModsStatus,
type CustomMod
} from '~main/types';
import TextButton from '../styled/TextButton'; import TextButton from '../styled/TextButton';
import CheckboxInput from '../form/CheckboxInput'; import CheckboxInput from '../form/CheckboxInput';
import IconSpinner from '../styled/IconSpinner'; import IconSpinner from '../styled/IconSpinner';
const RowState = ({ row }: { row: ModRowStatus }) => { const RowState = ({ row }: { row: ModRowStatus }) => {
const t = useT(); if (row.state === 'downloading' || row.state === 'installing')
if (['downloading', 'installing', 'uninstalling'].includes(row.state)) return <IconSpinner className="text-blueGray" />;
if (row.state === 'uninstalling')
return <IconSpinner className="text-blueGray" />; return <IconSpinner className="text-blueGray" />;
if (row.state === 'error') if (row.state === 'error')
return ( return (
@@ -26,17 +21,12 @@ const RowState = ({ row }: { row: ModRowStatus }) => {
<AlertTriangle size={14} className="text-red" /> <AlertTriangle size={14} className="text-red" />
</span> </span>
); );
if ( if (row.installedVersion && row.installedVersion !== row.latestVersion && !row.ignoreUpdates)
row.installedVersion && return <span className="s1 text-pink">update</span>;
row.installedVersion !== row.latestVersion &&
!row.ignoreUpdates
)
return <span className="s1 text-pink">{t('mods.update')}</span>;
return null; return null;
}; };
const ModRow = ({ row }: { row: ModRowStatus }) => { const ModRow = ({ row }: { row: ModRowStatus }) => {
const t = useT();
const toggle = api.mods.toggle.useMutation(); const toggle = api.mods.toggle.useMutation();
const setIgnore = api.mods.setIgnoreUpdates.useMutation(); const setIgnore = api.mods.setIgnoreUpdates.useMutation();
const openLink = api.general.openLink.useMutation(); const openLink = api.general.openLink.useMutation();
@@ -47,9 +37,7 @@ const ModRow = ({ row }: { row: ModRowStatus }) => {
{row.recommended && ( {row.recommended && (
<Sparkles size={12} className="shrink-0 text-warmGreen" /> <Sparkles size={12} className="shrink-0 text-warmGreen" />
)} )}
<span className={cls(row.recommended && 'text-warmGreen')}> <span className={cls(row.recommended && 'text-warmGreen')}>{row.name}</span>
{row.name}
</span>
<span className="s1 text-warmGreen">{row.latestVersion}</span> <span className="s1 text-warmGreen">{row.latestVersion}</span>
</div> </div>
<CheckboxInput <CheckboxInput
@@ -71,60 +59,13 @@ const ModRow = ({ row }: { row: ModRowStatus }) => {
<CheckboxInput <CheckboxInput
value={row.ignoreUpdates} value={row.ignoreUpdates}
setValue={v => setIgnore.mutate({ id: row.id, ignore: v })} setValue={v => setIgnore.mutate({ id: row.id, ignore: v })}
label={<span className="s1">{t('mods.ignoreUpdates')}</span>} label={<span className="s1">Ignore updates</span>}
/> />
</> </>
); );
}; };
const CustomRow = ({ row }: { row: CustomMod }) => {
const toggle = api.mods.toggleCustom.useMutation();
return (
<>
<span className="break-all">{row.name}</span>
<CheckboxInput
value={row.enabled}
setValue={v => toggle.mutate({ name: row.name, enabled: v })}
className="justify-self-center"
/>
</>
);
};
const AddDllButton = () => {
const t = useT();
const pick = api.general.filePicker.useMutation();
const add = api.mods.addCustomDll.useMutation();
const [error, setError] = useState<string | null>(null);
const onClick = async () => {
setError(null);
const res = await pick.mutateAsync({
title: t('mods.addDllTitle'),
filters: [{ name: 'DLL', extensions: ['dll'] }],
properties: ['openFile']
});
if (res.canceled) return;
const result = await add.mutateAsync({ path: res.path[0] });
if (!result.ok) setError(result.error ?? t('mods.addDllFailed'));
};
return (
<div className="flex items-center gap-2">
{error && <span className="s1 text-orange">{error}</span>}
<TextButton
onClick={onClick}
loading={pick.isLoading || add.isLoading}
className="text-green"
>
{t('mods.addDll')}
</TextButton>
</div>
);
};
const ModsTab = () => { const ModsTab = () => {
const t = useT();
const [status, setStatus] = useState<ModsStatus>(); const [status, setStatus] = useState<ModsStatus>();
api.mods.observe.useSubscription(undefined, { api.mods.observe.useSubscription(undefined, {
onData: setStatus onData: setStatus
@@ -138,165 +79,43 @@ const ModsTab = () => {
}, [list.data, status]); }, [list.data, status]);
const apply = api.mods.applyAll.useMutation(); const apply = api.mods.applyAll.useMutation();
const resync = api.updater.update.useMutation();
const revalidate = api.mods.verify.useMutation();
const scrollRef = useScrollHint<HTMLDivElement>(); const scrollRef = useScrollHint<HTMLDivElement>();
const mods = status?.mods ?? [];
const enabledIds = new Set(mods.filter(m => m.enabled).map(m => m.id));
const modName = (id: string) => mods.find(m => m.id === id)?.name ?? id;
const missingDeps = [
...new Set(
mods
.filter(m => m.enabled)
.flatMap(m => m.requires.filter(d => !enabledIds.has(d)))
)
];
const pendingDepMessage = missingDeps
.map(dep => {
const requiredBy = mods
.filter(m => m.enabled && m.requires.includes(dep))
.map(m => m.name)
.join(', ');
return t('mods.depRequired', { mod: modName(dep), requiredBy });
})
.join('\n');
const dialogRef = useRef<HTMLDialogElement>(null);
const [shownDepMessage, setShownDepMessage] = useState<string | null>(null);
useEffect(() => {
if (shownDepMessage) {
if (!dialogRef.current?.open) dialogRef.current?.showModal();
(document.activeElement as HTMLElement | null)?.blur();
} else dialogRef.current?.close();
}, [shownDepMessage]);
const [applied, setApplied] = useState(false);
const appliedTimer = useRef<number>();
useEffect(() => () => window.clearTimeout(appliedTimer.current), []);
const onApply = async () => {
if (missingDeps.length) {
setShownDepMessage(pendingDepMessage);
return;
}
setApplied(false);
await apply.mutateAsync();
setApplied(true);
window.clearTimeout(appliedTimer.current);
appliedTimer.current = window.setTimeout(() => setApplied(false), 2500);
};
const showApply =
!!status?.dirty || apply.isLoading || status?.state === 'busy';
return ( return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3"> <div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<h4 className="tw-color">{t('mods.title')}</h4> <h4 className="tw-color">CUSTOM MODS</h4>
{status?.dirty ? ( {status?.dirty && (
<span className="s1 text-pink">{t('mods.unsavedChanges')}</span> <span className="s1 text-pink">unsaved changes</span>
) : applied ? ( )}
<span className="s1 text-warmGreen">{t('mods.applied')}</span>
) : null}
</div> </div>
<p className="s1 text-blueGray"> <p className="s1 text-blueGray">
<span className="text-orange"></span> {t('mods.warning')} <span className="text-orange"></span> Enabling custom mods may not provide
any performance benefits or may even cause game crashes depending on your
system. Please try disabling them if you experience any issues.
</p> </p>
{missingDeps.length > 0 && (
<p className="s1 text-orange">
{' '}
{t('mods.enableRequired', {
mods: missingDeps.map(modName).join(', ')
})}
</p>
)}
{!!status?.missingFiles?.length && (
<div className="s1 flex flex-col items-start gap-1 text-orange">
<span>
{t('mods.missingFiles', { mods: status.missingFiles.join(', ') })}
</span>
<TextButton
onClick={async () => {
await resync.mutateAsync();
await revalidate.mutateAsync();
}}
loading={resync.isLoading || revalidate.isLoading}
className="text-warmGreen"
>
{t('mods.reverify')}
</TextButton>
</div>
)}
<hr /> <hr />
<div <div
ref={scrollRef} ref={scrollRef}
className="relative -m-4 -mt-0 flex flex-grow flex-col gap-3 overflow-y-auto p-4 pt-0" className="relative -m-4 -mt-0 grid flex-grow grid-cols-[auto_auto_1fr_auto] content-start items-center gap-x-4 gap-y-2 overflow-y-auto p-4 pt-0"
> >
<div className="grid grid-cols-[auto_auto_1fr_auto] content-start items-center gap-x-4 gap-y-2"> {status?.mods.map(row => <ModRow key={row.id} row={row} />)}
{status?.mods.map(row => (
<ModRow key={row.id} row={row} />
))}
</div>
<hr />
<div className="flex items-baseline justify-between gap-2">
<h4 className="tw-color">{t('mods.yourDlls')}</h4>
<AddDllButton />
</div>
{status?.custom?.length ? (
<div className="grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-1">
{status.custom.map(c => (
<CustomRow key={c.name} row={c} />
))}
</div>
) : (
<p className="s1 text-blueGray">{t('mods.yourDllsEmpty')}</p>
)}
</div> </div>
<hr /> <hr />
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2"> <div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
<p className="s1 flex-grow text-blueGray"> <p className="s1 flex-grow text-blueGray">
{status?.dirty ? ( <span className="text-warmGreen">Highlighted</span> mods are recommended.
<span className="text-pink">{t('mods.unsavedChanges')}</span>
) : applied ? (
<span className="text-warmGreen">{t('mods.applied')}</span>
) : (
<>
<span className="text-warmGreen">{t('mods.highlighted')}</span>{' '}
{t('mods.highlightedRecommended')}
</>
)}
</p> </p>
<TextButton <TextButton
type="button"
loading={apply.isLoading || status?.state === 'busy'} loading={apply.isLoading || status?.state === 'busy'}
onClick={onApply} onClick={() => apply.mutateAsync()}
className={cls('text-green', !showApply && 'invisible')} className={cls(status?.dirty && 'text-green')}
> >
{t('mods.apply')} Apply
</TextButton> </TextButton>
</div> </div>
{createPortal(
<dialog
ref={dialogRef}
onClose={() => setShownDepMessage(null)}
className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex"
>
{shownDepMessage && (
<div className="tw-dialog !w-fit min-w-[360px] max-w-[460px] !gap-3">
<h3 className="tw-color">{t('mods.cantApplyYet')}</h3>
<p className="s1 whitespace-pre-line">{shownDepMessage}</p>
<TextButton
onClick={() => setShownDepMessage(null)}
className="self-end text-green"
>
{t('mods.close')}
</TextButton>
</div>
)}
</dialog>,
document.body
)}
</div> </div>
); );
}; };
+13 -33
View File
@@ -2,7 +2,6 @@ import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react';
import { type NewsItem } from '~main/types'; import { type NewsItem } from '~main/types';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import useScrollHint from '~renderer/utils/useScrollHint'; import useScrollHint from '~renderer/utils/useScrollHint';
import IconSpinner from '../styled/IconSpinner'; import IconSpinner from '../styled/IconSpinner';
@@ -19,20 +18,15 @@ const formatDate = (raw: string) => {
}; };
const NewsEntry = ({ item }: { item: NewsItem }) => { const NewsEntry = ({ item }: { item: NewsItem }) => {
const t = useT();
const openLink = api.general.openLink.useMutation(); const openLink = api.general.openLink.useMutation();
return ( return (
<article className="flex flex-col gap-1 border-b border-blueGray/30 pb-3 last:border-0"> <article className="flex flex-col gap-1 border-b border-blueGray/30 pb-3 last:border-0">
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">
<h5 className="tw-color">{item.title}</h5> <h5 className="tw-color">{item.title}</h5>
<span className="s1 shrink-0 text-blueGray"> <span className="s1 shrink-0 text-blueGray">{formatDate(item.date)}</span>
{formatDate(item.date)}
</span>
</div> </div>
{item.author && ( {item.author && (
<span className="s1 italic text-blueGray"> <span className="s1 italic text-blueGray">by {item.author}</span>
{t('misc.newsByAuthor', { author: item.author })}
</span>
)} )}
<p className="whitespace-pre-wrap text-sm leading-relaxed">{item.body}</p> <p className="whitespace-pre-wrap text-sm leading-relaxed">{item.body}</p>
{item.url && ( {item.url && (
@@ -42,36 +36,32 @@ const NewsEntry = ({ item }: { item: NewsItem }) => {
className="-ml-2 self-start text-pink" className="-ml-2 self-start text-pink"
onClick={() => openLink.mutateAsync(item.url!)} onClick={() => openLink.mutateAsync(item.url!)}
> >
{t('misc.newsReadMore')} Read more
</TextButton> </TextButton>
)} )}
</article> </article>
); );
}; };
const NewsColumn = ({ forum, title }: { forum: number; title: string }) => { const NewsTab = () => {
const t = useT(); const query = api.news.list.useQuery(undefined, {
const query = api.news.list.useQuery(
{ forum },
{
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: 1 retry: 1
} });
);
const scrollRef = useScrollHint<HTMLDivElement>(); const scrollRef = useScrollHint<HTMLDivElement>();
return ( return (
<div className="tw-surface flex min-h-0 flex-1 flex-col gap-3"> <div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="tw-color">{title}</h4> <h4 className="tw-color">News</h4>
<TextButton <TextButton
icon={RefreshCw} icon={RefreshCw}
size={18} size={18}
className="-mr-2 text-blueGray" className="-mr-2 text-blueGray"
loading={query.isFetching} loading={query.isFetching}
onClick={() => query.refetch()} onClick={() => query.refetch()}
title={t('misc.refresh')} title="Refresh"
/> />
</div> </div>
<hr /> <hr />
@@ -82,24 +72,24 @@ const NewsColumn = ({ forum, title }: { forum: number; title: string }) => {
{query.isLoading ? ( {query.isLoading ? (
<div className="flex flex-grow flex-col items-center justify-center gap-2"> <div className="flex flex-grow flex-col items-center justify-center gap-2">
<IconSpinner className="text-blueGray" /> <IconSpinner className="text-blueGray" />
<p className="italic text-blueGray">{t('misc.newsLoading')}</p> <p className="italic text-blueGray">Loading news...</p>
</div> </div>
) : query.isError ? ( ) : query.isError ? (
<div className="flex flex-grow flex-col items-center justify-center gap-3"> <div className="flex flex-grow flex-col items-center justify-center gap-3">
<AlertTriangle size={32} className="text-red" /> <AlertTriangle size={32} className="text-red" />
<p className="italic text-blueGray">{t('misc.newsError')}</p> <p className="italic text-blueGray">Couldn't reach the news feed.</p>
<TextButton <TextButton
icon={RefreshCw} icon={RefreshCw}
size={18} size={18}
className="text-pink" className="text-pink"
onClick={() => query.refetch()} onClick={() => query.refetch()}
> >
{t('misc.tryAgain')} Try again
</TextButton> </TextButton>
</div> </div>
) : !query.data?.length ? ( ) : !query.data?.length ? (
<div className="flex flex-grow flex-col items-center justify-center"> <div className="flex flex-grow flex-col items-center justify-center">
<p className="italic text-blueGray">{t('misc.newsEmpty')}</p> <p className="italic text-blueGray">No news yet check back later.</p>
</div> </div>
) : ( ) : (
query.data.map(item => <NewsEntry key={item.id} item={item} />) query.data.map(item => <NewsEntry key={item.id} item={item} />)
@@ -109,14 +99,4 @@ const NewsColumn = ({ forum, title }: { forum: number; title: string }) => {
); );
}; };
const NewsTab = () => {
const t = useT();
return (
<div className="flex min-h-0 flex-grow gap-3">
<NewsColumn forum={2} title={t('misc.announcementsTitle')} />
<NewsColumn forum={4} title={t('misc.patchNotesTitle')} />
</div>
);
};
export default NewsTab; export default NewsTab;
+29 -81
View File
@@ -6,7 +6,6 @@ import { api } from '~renderer/utils/api';
import { ConfigWtfSchema } from '~common/schemas'; import { ConfigWtfSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver'; import zodResolver from '~renderer/utils/zodResolver';
import useScrollHint from '~renderer/utils/useScrollHint'; import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import TextButton from '../styled/TextButton'; import TextButton from '../styled/TextButton';
import CheckboxInput from '../form/CheckboxInput'; import CheckboxInput from '../form/CheckboxInput';
@@ -65,30 +64,17 @@ const Item = ({
}; };
const TweaksTab = () => { const TweaksTab = () => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery(); const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation(); const setPref = api.preferences.set.useMutation();
const applyPatch = api.patcher.apply.useMutation(); const applyPatch = api.patcher.apply.useMutation();
const syncRaidVisuals = api.updater.syncRaidVisuals.useMutation(); const verify = api.updater.verify.useMutation();
const form = useForm<ConfigWtfSchema>({ const form = useForm<ConfigWtfSchema>({
defaultValues: pref?.config ?? {}, defaultValues: pref?.config ?? {},
resolver: zodResolver(ConfigWtfSchema) resolver: zodResolver(ConfigWtfSchema)
}); });
const { handleSubmit, reset, formState } = form; const { handleSubmit, reset } = form;
const { data: hw } = api.general.hardware.useQuery();
const recommendedFarClip = hw?.recommendedFarClip;
const farClipValue = form.watch('farClip');
const farClipText =
t('tweaks.farClip.text') +
(recommendedFarClip != null
? ' ' + t('tweaks.farClip.recommendedHint', { value: recommendedFarClip })
: '');
const isApplying =
setPref.isLoading || applyPatch.isLoading || syncRaidVisuals.isLoading;
useEffect(() => { useEffect(() => {
pref && reset(pref.config); pref && reset(pref.config);
@@ -99,9 +85,9 @@ const TweaksTab = () => {
return ( return (
<form <form
onSubmit={handleSubmit(async config => { onSubmit={handleSubmit(async config => {
await setPref.mutateAsync({ config, farClipUserSet: true }); await setPref.mutateAsync({ config });
await applyPatch.mutateAsync(); await applyPatch.mutateAsync();
await syncRaidVisuals.mutateAsync(); await verify.mutateAsync();
reset(config); reset(config);
})} })}
@@ -114,41 +100,33 @@ const TweaksTab = () => {
<Item <Item
form={form} form={form}
id="alwaysAutoLoot" id="alwaysAutoLoot"
label={t('tweaks.alwaysAutoLoot.label')} label="Always auto-loot"
text={t('tweaks.alwaysAutoLoot.text')} text="Reverses auto-loot behavior to always auto-loot and disable auto-with bound key."
/>
<Item
form={form}
id="raidVisuals"
label={t('tweaks.raidVisuals.label')}
text={t('tweaks.raidVisuals.text')}
/> />
<Item <Item
form={form} form={form}
id="largeAddress" id="largeAddress"
label={t('tweaks.largeAddress.label')} label="Large Address Aware"
text={t('tweaks.largeAddress.text')} text="Allows the game to use more than 2GB of memory."
recommended recommended
/> />
<Item <Item
form={form} form={form}
type="number" type="number"
id="nameplateRange" id="nameplateRange"
label={t('tweaks.nameplateRange.label')} label="Nameplate range"
text={t('tweaks.nameplateRange.text')} text="Increases distance at which nameplates are visible. [Vanilla: 20] [Classic: 41]"
min={0} min={0}
max={41} max={41}
/> />
<h4 className="tw-color col-span-3 mt-3"> <h4 className="tw-color col-span-3 mt-3">Camera</h4>
{t('tweaks.cameraHeading')}
</h4>
<Item <Item
form={form} form={form}
id="fieldOfView" id="fieldOfView"
label={t('tweaks.fieldOfView.label')} label="Field of View"
type="number" type="number"
text={t('tweaks.fieldOfView.text')} text="Recommended for widescreen window resolutions. [Vanilla: 90] [Tweaks: 110]"
min={90} min={90}
max={180} max={180}
step={5} step={5}
@@ -156,13 +134,9 @@ const TweaksTab = () => {
<Item <Item
form={form} form={form}
id="farClip" id="farClip"
label={t('tweaks.farClip.label')} label="Render distance"
type="number" type="number"
text={farClipText} text="Increases maximum render distance. [Vanilla: 777] [Tweaks: 10000]"
recommended={
recommendedFarClip != null &&
Number(farClipValue) === recommendedFarClip
}
min={100} min={100}
max={10000} max={10000}
sensitivity={3} sensitivity={3}
@@ -170,9 +144,9 @@ const TweaksTab = () => {
<Item <Item
form={form} form={form}
id="frillDistance" id="frillDistance"
label={t('tweaks.frillDistance.label')} label="Ground clutter distance"
type="number" type="number"
text={t('tweaks.frillDistance.text')} text="Changes ground clutter render distance. [Vanilla: 70] [Tweaks: 300]"
min={0} min={0}
max={300} max={300}
sensitivity={0.3} sensitivity={0.3}
@@ -180,66 +154,40 @@ const TweaksTab = () => {
<Item <Item
form={form} form={form}
id="cameraDistance" id="cameraDistance"
label={t('tweaks.cameraDistance.label')} label="Camera distance"
type="number" type="number"
text={t('tweaks.cameraDistance.text')} text="Increases maximum camera (zoom out) distance. [Vanilla: 50] [Max:100]"
min={50} min={50}
max={100} max={100}
/> />
<h4 className="tw-color col-span-3 mt-3"> <h4 className="tw-color col-span-3 mt-3">Sounds</h4>
{t('tweaks.soundsHeading')}
</h4>
<Item <Item
form={form} form={form}
id="soundInBackground" id="soundInBackground"
label={t('tweaks.soundInBackground.label')} label="Background sounds"
text={t('tweaks.soundInBackground.text')} text="Allows game sounds to play while the game is minimized."
recommended recommended
/> />
</div> </div>
<hr /> <hr />
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2"> <div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
<p className="s1 flex-grow text-blueGray"> <p className="s1 flex-grow text-blueGray">
{applyPatch.isError ? ( <span className="s1 text-warmGreen">Highlighted</span> options are
<span className="text-orange"> recommended and enabled by default
{t('tweaks.applyFailed', {
message: applyPatch.error?.message ?? ''
})}
</span>
) : (
<>
<span className="s1 text-warmGreen">
{t('tweaks.highlighted')}
</span>{' '}
{t('tweaks.recommendedNote')}
</>
)}
</p> </p>
<TextButton <TextButton
onClick={async () => { onClick={async () => {
const config = const config = ConfigWtfSchema.parse({});
recommendedFarClip != null await setPref.mutateAsync({ config });
? {
...ConfigWtfSchema.parse({}),
farClip: recommendedFarClip,
raidVisuals: form.getValues('raidVisuals')
}
: {
...ConfigWtfSchema.parse({}),
raidVisuals: form.getValues('raidVisuals')
};
await setPref.mutateAsync({ config, farClipUserSet: false });
reset(config); reset(config);
}} }}
> >
{t('tweaks.reset')} Reset
</TextButton> </TextButton>
{(formState.isDirty || isApplying) && ( <TextButton type="submit" className="text-green">
<TextButton type="submit" loading={isApplying} className="text-green"> Apply
{t('tweaks.apply')}
</TextButton> </TextButton>
)}
</div> </div>
</form> </form>
); );
@@ -16,7 +16,6 @@ import { ColoredText } from '~renderer/components/styled/ColoredText';
import useScrollHint from '~renderer/utils/useScrollHint'; import useScrollHint from '~renderer/utils/useScrollHint';
import IconSpinner from '~renderer/components/styled/IconSpinner'; import IconSpinner from '~renderer/components/styled/IconSpinner';
import CloseButton from '~renderer/components/styled/CloseButton'; import CloseButton from '~renderer/components/styled/CloseButton';
import { useT } from '~renderer/i18n';
import { type LocalDependencies } from './AddonListItem'; import { type LocalDependencies } from './AddonListItem';
@@ -42,7 +41,6 @@ type Props = AddonData & {
}; };
const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => { const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
const t = useT();
const openLink = api.general.openLink.useMutation(); const openLink = api.general.openLink.useMutation();
const update = api.addons.update.useMutation(); const update = api.addons.update.useMutation();
@@ -73,26 +71,26 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
<ColoredText>{addon.toc?.Notes ?? addon.description ?? ''}</ColoredText> <ColoredText>{addon.toc?.Notes ?? addon.description ?? ''}</ColoredText>
)} )}
<div> <div>
<AddonDetailItem name={t('addons.detailSource')}> <AddonDetailItem name="Source">
{addon.git && ( {addon.git && (
<TextButton <TextButton
onClick={() => openLink.mutateAsync(addon.git)} onClick={() => openLink.mutateAsync(addon.git)}
className="s1 -m-2 !inline" className="s1 -m-2 !inline"
> >
{t('addons.openOnGithubShort')} Open on GitHub
<ExternalLink size={12} className="ml-1 inline" /> <ExternalLink size={12} className="ml-1 inline" />
</TextButton> </TextButton>
)} )}
</AddonDetailItem> </AddonDetailItem>
{addon.toc && ( {addon.toc && (
<> <>
<AddonDetailItem name={t('addons.detailContributions')}> <AddonDetailItem name="Contributions">
{addon.toc.Author} {addon.toc.Author}
</AddonDetailItem> </AddonDetailItem>
<AddonDetailItem name={t('addons.detailAddonVersion')}> <AddonDetailItem name="Addon version">
{addon.toc.Version} {addon.toc.Version}
</AddonDetailItem> </AddonDetailItem>
<AddonDetailItem name={t('addons.detailDependencies')}> <AddonDetailItem name="Dependencies">
{!!dependencies.length && ( {!!dependencies.length && (
<ul className="pl-2"> <ul className="pl-2">
{dependencies.map(({ name, optional, status }) => ( {dependencies.map(({ name, optional, status }) => (
@@ -101,7 +99,7 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
<Check size={16} className="inline text-darkGreen" /> <Check size={16} className="inline text-darkGreen" />
) : status === 'available' ? ( ) : status === 'available' ? (
<TextButton <TextButton
title={t('addons.download')} title="Download"
icon={DownloadCloud} icon={DownloadCloud}
size={16} size={16}
onClick={() => onClick={() =>
@@ -124,9 +122,7 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
) ? ( ) ? (
<p className="s1 inline text-blueGray">{status}</p> <p className="s1 inline text-blueGray">{status}</p>
) : optional ? ( ) : optional ? (
<p className="s1 inline text-blueGray"> <p className="s1 inline text-blueGray">(optional)</p>
{t('addons.optional')}
</p>
) : null} ) : null}
</li> </li>
))} ))}
@@ -16,7 +16,6 @@ import IconSpinner from '~renderer/components/styled/IconSpinner';
import DialogButton from '~renderer/components/styled/DialogButton'; import DialogButton from '~renderer/components/styled/DialogButton';
import { isNotUndef } from '~common/utils'; import { isNotUndef } from '~common/utils';
import CloseButton from '~renderer/components/styled/CloseButton'; import CloseButton from '~renderer/components/styled/CloseButton';
import { useT } from '~renderer/i18n';
import AddonDetail from './AddonDetail'; import AddonDetail from './AddonDetail';
@@ -40,7 +39,6 @@ const toRepoUrl = (git?: string) =>
git ? git.replace(/\.git$/, '') : undefined; git ? git.replace(/\.git$/, '') : undefined;
const AddonListItem = ({ row, dependencies, ...addon }: Props) => { const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
const t = useT();
const update = api.addons.update.useMutation(); const update = api.addons.update.useMutation();
const remove = api.addons.remove.useMutation(); const remove = api.addons.remove.useMutation();
const openLink = api.general.openLink.useMutation(); const openLink = api.general.openLink.useMutation();
@@ -60,21 +58,17 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
const warnings = [ const warnings = [
addon.toc && addon.toc?.Interface !== '11200' addon.toc && addon.toc?.Interface !== '11200'
? { ? {
full: t('addons.warnIncorrectVersionFull', { full: `This addon seems to be made for different game version (${addon.toc?.Interface}) and it may not function correctly`,
version: addon.toc?.Interface ?? '' short: 'Incorrect version'
}),
short: t('addons.warnIncorrectVersionShort')
} }
: undefined, : undefined,
localDependencies.some(d => d.status !== 'installed' && !d.optional) localDependencies.some(d => d.status !== 'installed' && !d.optional)
? { ? {
full: t('addons.warnMissingDependenciesFull', { full: `This addon has missing dependencies: ${localDependencies
deps: localDependencies
.filter(d => d.status !== 'installed' && !d.optional) .filter(d => d.status !== 'installed' && !d.optional)
.map(d => d.name) .map(d => d.name)
.join(', ') .join(', ')}`,
}), short: 'Missing dependencies'
short: t('addons.warnMissingDependenciesShort')
} }
: undefined : undefined
].filter(isNotUndef); ].filter(isNotUndef);
@@ -113,7 +107,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
: HelpCircle : HelpCircle
} }
onClick={open} onClick={open}
title={t('addons.details')} title="Details"
size={18} size={18}
className={cls( className={cls(
'-mx-2', '-mx-2',
@@ -138,7 +132,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
<TextButton <TextButton
icon={Github} icon={Github}
size={14} size={14}
title={t('addons.openOnGithub', { url: repoUrl })} title={`Open ${repoUrl} on GitHub`}
onClick={() => openLink.mutateAsync(repoUrl)} onClick={() => openLink.mutateAsync(repoUrl)}
className="!p-1 text-blueGray/60 hocus:text-pink" className="!p-1 text-blueGray/60 hocus:text-pink"
/> />
@@ -168,9 +162,9 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
) : ( ) : (
<p className="s1 text-blueGray/50"> <p className="s1 text-blueGray/50">
{addon.status === 'upToDate' {addon.status === 'upToDate'
? t('addons.upToDate') ? 'Up to date'
: !addon.git : !addon.git
? t('addons.notVersioned') ? 'Not versioned'
: ''} : ''}
</p> </p>
)} )}
@@ -179,16 +173,17 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })} onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
className="s1 -mx-2 justify-self-end" className="s1 -mx-2 justify-self-end"
> >
{t('addons.update')} Update
</TextButton> </TextButton>
)} )}
{addon.status === 'available' ? ( {addon.status === 'available' ? (
<TextButton <TextButton
// TODO: With dependencies checkbox
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })} onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
className="text-warmGreen" className="text-warmGreen"
icon={DownloadCloud} icon={DownloadCloud}
size={18} size={18}
title={t('addons.download')} title="Download"
/> />
) : ( ) : (
<DialogButton <DialogButton
@@ -196,13 +191,14 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
dialog={close => ( dialog={close => (
<div className="tw-dialog"> <div className="tw-dialog">
<CloseButton close={close} /> <CloseButton close={close} />
<h4 className="tw-color">{t('addons.deleteConfirmTitle')}</h4> <h4 className="tw-color">Are you sure?</h4>
<hr /> <hr />
<p className="text-blueGray"> <p className="text-blueGray">
{t('addons.deleteConfirmBody', { folder: addon.folder })} Are you sure you want to delete <span>{addon.folder}</span>{' '}
addon?
</p> </p>
<p className="text-blueGray"> <p className="text-blueGray">
{t('addons.deleteConfirmFiles')} This will delete all files in the addon folder.
</p> </p>
<TextButton <TextButton
icon={Trash2} icon={Trash2}
@@ -213,7 +209,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
disabled={remove.isLoading} disabled={remove.isLoading}
className="self-end text-red" className="self-end text-red"
> >
{t('addons.delete')} Delete
</TextButton> </TextButton>
</div> </div>
)} )}
@@ -224,7 +220,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
className="text-red/50" className="text-red/50"
icon={Trash2} icon={Trash2}
size={18} size={18}
title={t('addons.remove')} title="Remove"
/> />
)} )}
</DialogButton> </DialogButton>
@@ -5,7 +5,6 @@ import CloseButton from '~renderer/components/styled/CloseButton';
import IconSpinner from '~renderer/components/styled/IconSpinner'; import IconSpinner from '~renderer/components/styled/IconSpinner';
import TextButton from '~renderer/components/styled/TextButton'; import TextButton from '~renderer/components/styled/TextButton';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
const useDebounced = (value: string, delay: number) => { const useDebounced = (value: string, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value); const [debouncedValue, setDebouncedValue] = useState(value);
@@ -18,7 +17,6 @@ const useDebounced = (value: string, delay: number) => {
}; };
const CustomAddonDialog = ({ close }: { close: () => void }) => { const CustomAddonDialog = ({ close }: { close: () => void }) => {
const t = useT();
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const debouncedUrl = useDebounced(url, 500); const debouncedUrl = useDebounced(url, 500);
const response = api.addons.checkGitUrl.useQuery(debouncedUrl, { const response = api.addons.checkGitUrl.useQuery(debouncedUrl, {
@@ -29,14 +27,10 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {
return ( return (
<div className="tw-dialog"> <div className="tw-dialog">
<CloseButton close={close} /> <CloseButton close={close} />
<h3 className="tw-color">{t('addons.installAddon')}</h3> <h3 className="tw-color">Install addon</h3>
<hr /> <hr />
{response.data ? ( {response.data ? (
<img <img src={response.data?.preview} alt="Preview" className="w-full" />
src={response.data?.preview}
alt={t('addons.previewAlt')}
className="w-full"
/>
) : ( ) : (
<div className="flex h-[191px] w-full items-center justify-center bg-darkPurple"> <div className="flex h-[191px] w-full items-center justify-center bg-darkPurple">
{response.isFetching && <IconSpinner />} {response.isFetching && <IconSpinner />}
@@ -59,8 +53,8 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<p className="s1 text-blueGray"> <p className="s1 text-blueGray">
{response.data {response.data
? t('addons.readyToInstall') ? 'Ready to install'
: t('addons.invalidGitUrl')} : 'Not a valid git repository URL'}
</p> </p>
<TextButton <TextButton
onClick={() => { onClick={() => {
@@ -72,7 +66,7 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {
className={response.data ? 'text-warmGreen' : 'text-blueGray'} className={response.data ? 'text-warmGreen' : 'text-blueGray'}
disabled={!response.data || response.isLoading} disabled={!response.data || response.isLoading}
> >
{t('addons.install')} Install
</TextButton> </TextButton>
</div> </div>
</div> </div>
-10
View File
@@ -2,15 +2,5 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly MAIN_VITE_SERVER_URL: string; readonly MAIN_VITE_SERVER_URL: string;
// forum base for the news feed; defaults to the live forum so PTR shows real posts
readonly MAIN_VITE_FORUM_URL: string;
readonly MAIN_VITE_CLIENT_VERSION: string; readonly MAIN_VITE_CLIENT_VERSION: string;
// PTR realm/patch host; only set for PTR builds, live falls back to octowow.st.
readonly MAIN_VITE_PTR_REALMLIST: string;
// When set, sync the client from this web-seeded .torrent instead of the manifest.
readonly MAIN_VITE_CLIENT_TORRENT_URL: string;
// optional raid-visuals patch (patch-O.mpq); a ".sha256" sidecar drives change detection
readonly MAIN_VITE_RAID_VISUALS_URL: string;
// the content patch (patch-5.mpq), served outside the torrent; kept current by a ".sha256" sidecar
readonly MAIN_VITE_CLIENT_PATCH_URL: string;
} }
-52
View File
@@ -1,52 +0,0 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode
} from 'react';
import { api } from '~renderer/utils/api';
import { translations, type Lang } from './translations';
type Params = Record<string, string | number>;
type Translate = (key: string, params?: Params) => string;
type LocaleCtx = {
lang: Lang;
setLang: (lang: Lang) => void;
t: Translate;
};
const LocaleContext = createContext<LocaleCtx>({
lang: 'enUS',
setLang: () => {},
t: key => key
});
export const LocaleProvider = ({ children }: { children: ReactNode }) => {
const { data: pref } = api.preferences.get.useQuery();
const [lang, setLang] = useState<Lang>('enUS');
useEffect(() => {
if (pref?.locale) setLang(pref.locale);
}, [pref?.locale]);
const t: Translate = (key, params) => {
let s = translations[lang]?.[key] ?? translations.enUS[key] ?? key;
if (params)
for (const [k, v] of Object.entries(params))
s = s.replace(`{${k}}`, String(v));
return s;
};
return (
<LocaleContext.Provider value={{ lang, setLang, t }}>
{children}
</LocaleContext.Provider>
);
};
export const useLocale = () => useContext(LocaleContext);
export const useT = () => useContext(LocaleContext).t;
File diff suppressed because it is too large Load Diff
-123
View File
@@ -25,16 +25,6 @@ body {
height: 100vh; height: 100vh;
} }
body {
-webkit-user-select: text;
user-select: text;
}
::selection {
background: rgb(248 156 66 / 0.45);
color: #fff;
}
#root { #root {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -202,119 +192,6 @@ input[type='number'] {
} }
} }
.parchment-post {
position: relative;
color: #2b1a0c;
background-color: #e9dab5;
background-size: cover;
background-position: center;
@apply border p-4;
border-color: rgba(60, 40, 15, 0.4);
box-shadow: rgb(0 0 0 / 45%) 0px 25px 20px -20px;
& .parchment-post-title {
@apply font-fontin uppercase;
font-size: 18px;
line-height: 24px;
letter-spacing: 0.03em;
color: #3a230f;
}
& .parchment-post-meta {
font-size: 14px;
line-height: 20px;
font-style: italic;
color: #6b5836;
}
& .parchment-post-muted {
color: #7a6a47;
}
& hr {
@apply -mx-4;
border: 0;
border-top: 1px solid rgba(60, 40, 15, 0.25);
}
& button {
color: #6b3e15;
}
& button:hover,
& button:focus {
color: #9c4a12;
}
& .parchment-post-body,
& .parchment-post-body * {
color: #2b1a0c;
}
& .parchment-post-body {
font-size: 15px;
line-height: 24px;
word-break: break-word;
overflow-wrap: anywhere;
}
& .parchment-post-body a {
color: #6b3e15;
text-decoration: underline;
cursor: pointer;
}
& .parchment-post-body a:hover {
color: #9c4a12;
}
& .parchment-post-body p {
margin: 6px 0;
}
& .parchment-post-body strong,
& .parchment-post-body b {
font-weight: 700;
}
& .parchment-post-body em,
& .parchment-post-body i {
font-style: italic;
}
& .parchment-post-body ul {
list-style: disc;
padding-left: 20px;
margin: 6px 0;
}
& .parchment-post-body ol {
list-style: decimal;
padding-left: 20px;
margin: 6px 0;
}
& .parchment-post-body blockquote {
border-left: 3px solid #b08a57;
background: rgba(120, 80, 30, 0.08);
padding: 8px 12px;
margin: 8px 0;
font-style: italic;
}
& .parchment-post-body code,
& .parchment-post-body pre,
& .parchment-post-body .codebox {
font-family: monospace;
background: rgba(60, 40, 15, 0.1);
border: 1px solid rgba(60, 40, 15, 0.2);
border-radius: 2px;
padding: 8px;
display: block;
white-space: pre-wrap;
word-break: break-word;
}
& .parchment-post-body img {
max-width: 100%;
height: auto;
}
& .parchment-post-body h1,
& .parchment-post-body h2,
& .parchment-post-body h3,
& .parchment-post-body h4 {
color: #3a230f;
}
}
.tw-hocus { .tw-hocus {
@apply hocus:text-orange hocus:drop-shadow-[0px_0px_15px_white]; @apply hocus:text-orange hocus:drop-shadow-[0px_0px_15px_white];
} }
-3
View File
@@ -11,7 +11,6 @@ import log from 'electron-log/renderer';
import { api } from './utils/api'; import { api } from './utils/api';
import App from './App'; import App from './App';
import ErrorBoundary from './ErrorBoundary'; import ErrorBoundary from './ErrorBoundary';
import { LocaleProvider } from './i18n';
import './index.css'; import './index.css';
@@ -57,9 +56,7 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<ErrorBoundary> <ErrorBoundary>
<api.Provider client={trpcClient} queryClient={queryClient}> <api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<LocaleProvider>
<App /> <App />
</LocaleProvider>
{import.meta.env.DEV && <ReactQueryDevtools />} {import.meta.env.DEV && <ReactQueryDevtools />}
</QueryClientProvider> </QueryClientProvider>
</api.Provider> </api.Provider>
+10 -5
View File
@@ -2,24 +2,29 @@ import { useEffect } from 'react';
const allowedElements = ['INPUT', 'TEXTAREA']; const allowedElements = ['INPUT', 'TEXTAREA'];
const isClipboardShortcut = (e: KeyboardEvent) =>
(e.ctrlKey || e.metaKey) &&
['a', 'c', 'v', 'x'].includes(e.key.toLowerCase());
const usePreventDefaultEvents = () => { const usePreventDefaultEvents = () => {
useEffect(() => { useEffect(() => {
const disableKeyboardEvents = (e: KeyboardEvent) => { const disableKeyboardEvents = (e: KeyboardEvent) => {
if (allowedElements.includes((e.target as HTMLElement).tagName)) return; if (allowedElements.includes((e.target as HTMLElement).tagName)) return;
if (isClipboardShortcut(e)) return; e.preventDefault();
};
const disableFocus = (e: FocusEvent) => {
if (allowedElements.includes((e.target as HTMLElement).tagName)) return;
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
e.preventDefault(); e.preventDefault();
}; };
window.addEventListener('keydown', disableKeyboardEvents, true); window.addEventListener('keydown', disableKeyboardEvents, true);
window.addEventListener('keyup', disableKeyboardEvents, true); window.addEventListener('keyup', disableKeyboardEvents, true);
window.addEventListener('focusin', disableFocus, true);
return () => { return () => {
window.removeEventListener('keydown', disableKeyboardEvents, true); window.removeEventListener('keydown', disableKeyboardEvents, true);
window.removeEventListener('keyup', disableKeyboardEvents, true); window.removeEventListener('keyup', disableKeyboardEvents, true);
window.removeEventListener('focusin', disableFocus, true);
}; };
}, []); }, []);
}; };