Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 530ec7a144 |
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
dist*/
|
dist/
|
||||||
out/
|
out/
|
||||||
release/
|
release/
|
||||||
|
|
||||||
|
|||||||
@@ -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 1–4 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 VS2017–2022 — 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 VS2017–2022 |
|
||||||
| 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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()]
|
||||||
}
|
}
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+2
-19
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const os = require('os');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const FILL = 0x78;
|
|
||||||
|
|
||||||
function pathVariants(p) {
|
|
||||||
const set = new Set([p, p.replace(/\\/g, '/'), p.replace(/\//g, '\\')]);
|
|
||||||
return [...set].filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = process.cwd();
|
|
||||||
const home = os.homedir();
|
|
||||||
let username = '';
|
|
||||||
try {
|
|
||||||
username = os.userInfo().username;
|
|
||||||
} catch {
|
|
||||||
username = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const secrets = [];
|
|
||||||
if (root.length > 2) secrets.push(...pathVariants(root));
|
|
||||||
if (home.length > 2 && home !== root) secrets.push(...pathVariants(home));
|
|
||||||
if (username.length >= 4) secrets.push(username);
|
|
||||||
|
|
||||||
const needles = [...new Set(secrets)]
|
|
||||||
.filter(s => s.length > 0)
|
|
||||||
.map(s => s.toLowerCase())
|
|
||||||
.sort((a, b) => b.length - a.length);
|
|
||||||
|
|
||||||
function scanAndFill(buf, s, stride) {
|
|
||||||
const n = s.length;
|
|
||||||
const span = n * stride;
|
|
||||||
if (span === 0 || span > buf.length) return 0;
|
|
||||||
let hits = 0;
|
|
||||||
outer: for (let i = 0; i + span <= buf.length; i++) {
|
|
||||||
for (let j = 0; j < n; j++) {
|
|
||||||
const at = i + j * stride;
|
|
||||||
let b = buf[at];
|
|
||||||
if (b >= 0x41 && b <= 0x5a) b += 0x20;
|
|
||||||
if (b !== s.charCodeAt(j)) continue outer;
|
|
||||||
if (stride === 2 && buf[at + 1] !== 0x00) continue outer;
|
|
||||||
}
|
|
||||||
buf.fill(FILL, i, i + span);
|
|
||||||
hits++;
|
|
||||||
i += span - 1;
|
|
||||||
}
|
|
||||||
return hits;
|
|
||||||
}
|
|
||||||
|
|
||||||
function redact(buf) {
|
|
||||||
let hits = 0;
|
|
||||||
for (const s of needles) {
|
|
||||||
hits += scanAndFill(buf, s, 1);
|
|
||||||
hits += scanAndFill(buf, s, 2);
|
|
||||||
}
|
|
||||||
return hits;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collect(dir, out) {
|
|
||||||
let entries;
|
|
||||||
try {
|
|
||||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const e of entries) {
|
|
||||||
const p = path.join(dir, e.name);
|
|
||||||
if (e.isDirectory()) collect(p, out);
|
|
||||||
else if (e.isFile() && p.endsWith('.node')) out.push(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addons = [];
|
|
||||||
collect(path.join(root, 'node_modules'), addons);
|
|
||||||
|
|
||||||
let files = 0;
|
|
||||||
let total = 0;
|
|
||||||
const redacted = [];
|
|
||||||
for (const file of addons) {
|
|
||||||
try {
|
|
||||||
const buf = fs.readFileSync(file);
|
|
||||||
const hits = redact(buf);
|
|
||||||
if (hits > 0) {
|
|
||||||
fs.writeFileSync(file, buf);
|
|
||||||
files++;
|
|
||||||
total += hits;
|
|
||||||
redacted.push(`${path.relative(root, file)} (${hits})`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`scrub-native-paths: skipped ${path.basename(file)} (${err.message})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`scrub-native-paths: redacted ${total} path reference(s) across ${files} addon(s)`);
|
|
||||||
for (const r of redacted) console.log(` ${r}`);
|
|
||||||
@@ -1,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"]
|
|
||||||
Generated
-545
@@ -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
@@ -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
@@ -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 => {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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())
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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();
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
+10
-121
@@ -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,97 +85,23 @@ const createWindow = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const gotSingleInstanceLock = is.dev || app.requestSingleInstanceLock();
|
app.whenReady().then(async () => {
|
||||||
|
|
||||||
if (!gotSingleInstanceLock) {
|
|
||||||
app.quit();
|
|
||||||
} else {
|
|
||||||
app.on('second-instance', () => {
|
|
||||||
if (!mainWindow) return;
|
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
||||||
if (!mainWindow.isVisible()) mainWindow.show();
|
|
||||||
mainWindow.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
|
||||||
// defaults on failure so createWindow() below still runs
|
|
||||||
try {
|
|
||||||
Preferences.data = await Preferences.load();
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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];
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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)));
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+77
-392
@@ -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,109 +16,35 @@ 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';
|
||||||
offset: number;
|
offset: number;
|
||||||
value?: number;
|
value?: number;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
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
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+770
-359
File diff suppressed because it is too large
Load Diff
@@ -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 =>
|
|
||||||
({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Vendored
+2
-7
@@ -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
@@ -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})`;
|
||||||
|
|||||||
@@ -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
@@ -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,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 |
@@ -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;
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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,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
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Vendored
-10
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user