mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-29 06:01:14 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 643c8f8b62 | |||
| e7c7beb621 | |||
| 710e717677 | |||
| 7e8cadff24 | |||
| 7d28eb7d52 | |||
| 413c38dbcb | |||
| 3b776c52fc | |||
| 9c88caf49b | |||
| 537608c381 | |||
| b0279ee812 | |||
| c007e11cce | |||
| a7f0eae670 | |||
| 1c9a8fb780 | |||
| ec07dc63f0 | |||
| 88556ec70f |
@@ -0,0 +1,8 @@
|
||||
patreon: kitbyte
|
||||
|
||||
custom: [
|
||||
"https://www.patreon.com/kitbyte/gift",
|
||||
"https://tronscan.org/#/address/TQdvau8pAy5Tg1Aa588tTcPCFgbcHtuoxc",
|
||||
"https://www.blockchain.com/explorer/addresses/btc/1EZKDcyU8REm9JW5xwXJqSpn5Xaq5yAWWX",
|
||||
"https://etherscan.io/address/0xd904d9d0557f88bbb1c4ab3582b4ca0d8a730e8d"
|
||||
]
|
||||
@@ -8,16 +8,16 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**🚨 STOP BEFORE YOU POST: THIS PROJECT HAS NO OFFICIAL YOUTUBE TUTORIALS. 🚨**
|
||||
If you downloaded an executable from a YouTube video link, you downloaded a virus/password stealer from a scammer. **Run an antivirus immediately and change your passwords.** Do not open issues about stolen accounts here — this project is not related to those videos.
|
||||
**STOP BEFORE YOU POST: THIS PROJECT DOES NOT PUBLISH OFFICIAL EXECUTABLE DOWNLOADS.**
|
||||
Build WandEnhancer yourself from your own fork or local source. If you downloaded an executable from YouTube, Discord, a mirror, or any other third-party website, treat it as untrusted. Do not open issues about third-party binaries or stolen accounts here - this project is not related to those downloads.
|
||||
|
||||
- type: checkboxes
|
||||
id: scam_check
|
||||
id: build_source_check
|
||||
attributes:
|
||||
label: ⚠️ Download Source Confirmation (REQUIRED)
|
||||
label: Build Source Confirmation (REQUIRED)
|
||||
description: You must check this box to proceed.
|
||||
options:
|
||||
- label: I confirm that I downloaded this tool DIRECTLY from this official GitHub repository, and NOT from a YouTube video, Discord, or any other third-party website.
|
||||
- label: I confirm that I built WandEnhancer myself from my own fork or local source, and did not download an executable from YouTube, Discord, mirrors, issue comments, or any other third-party website.
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
@@ -67,4 +67,4 @@ body:
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: Screenshots & Additional context
|
||||
description: Drag and drop screenshots here, or add any other context about the problem.
|
||||
description: Drag and drop screenshots here, or add any other context about the problem.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Build executable
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.17.0
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web-panel/pnpm-lock.yaml
|
||||
|
||||
- name: Build unsigned executable
|
||||
shell: pwsh
|
||||
run: ./build.ps1 -Configuration Release
|
||||
|
||||
- name: Upload unsigned executable
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: WandEnhancer-unsigned
|
||||
path: WandEnhancer/bin/Release/WandEnhancer.exe
|
||||
if-no-files-found: error
|
||||
@@ -10,9 +10,12 @@ on:
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
if: github.repository == 'k1tbyte/Wand-Enhancer'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -16,15 +16,15 @@ jobs:
|
||||
RELEASE_VERSION: ${{ github.ref_name }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
@@ -48,7 +48,5 @@ jobs:
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
body_path: release-notes.md
|
||||
files: |
|
||||
WandEnhancer/bin/Release/WandEnhancer.exe
|
||||
CHANGELOG.md
|
||||
fail_on_unmatched_files: true
|
||||
files: CHANGELOG.md
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
@@ -9,10 +9,12 @@ on:
|
||||
jobs:
|
||||
validate-release-metadata:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Validate version and changelog sync
|
||||
shell: pwsh
|
||||
run: ./scripts/validate-release-metadata.ps1
|
||||
run: ./scripts/validate-release-metadata.ps1
|
||||
|
||||
@@ -6,13 +6,14 @@ This repository patches the Wand Electron app from a .NET Framework WPF desktop
|
||||
|
||||
## Remote Web Panel
|
||||
|
||||
- The default local remote port is `3223`. Keep C# and frontend constants aligned.
|
||||
- The default local remote port is `3223`. Keep bridge and frontend constants aligned; C# must not duplicate the presentation URL or port.
|
||||
- The embedded panel must stay small because the desktop patcher embeds it and then injects it into Wand's `app.asar`.
|
||||
- Remote tooltip links and every rendered `remote-qr-code` are redirected by `web-panel/bridge/scripts/default/remote-popup-cleanup.js`. It reuses Wand's loaded QR renderer through the webpack runtime, keeps the local URL visible as a fallback, and hides the Pro onboarding remote mobile app card. Do not reintroduce C# ASAR patches for the tooltip URL or QR component; a changed UI bundle must not make the whole remote-panel patch fail.
|
||||
- Production builds must not include mock data, debug routes, sourcemaps, local fonts, heavy icon libraries, or runtime class helper packages.
|
||||
- The Electron bridge is authored as modular CommonJS source under `web-panel/bridge/source.cjs` and `web-panel/bridge/bridge-modules/`, but production runtime must be bundled/minified into `web-panel/dist/bridge.cjs` by `pnpm run build:bridge`. Do not copy `bridge-modules` into Wand or embed them as ASAR resources.
|
||||
- The Electron bridge is authored as TypeScript under `web-panel/bridge/src/`, but production runtime must be bundled/minified into `web-panel/dist/bridge.cjs` by `pnpm run build:bridge`. Do not copy bridge source into Wand or embed it as ASAR resources.
|
||||
- Mock/demo data is dev-only and must be reached through `import.meta.env.DEV` dynamic imports.
|
||||
- Source can use React-compatible imports, but production runtime resolves them to Preact aliases in `web-panel/vite.config.ts`.
|
||||
- UI uses Tailwind CSS and lightweight shadcn-style local primitives under `web-panel/src/components/ui/`.
|
||||
- UI uses Tailwind CSS and lightweight local primitives under `web-panel/src/shared/ui/`.
|
||||
- Default renderer script sources live in `web-panel/bridge/scripts/default/` and are bundled/minified into `web-panel/dist/renderer-scripts/` by `pnpm run build:bridge`. Custom user scripts are selected in the WPF patch modal and copied from `PatchConfig.CustomScriptPaths`; only existing `.js` files are accepted. A local `renderer-scripts/` folder next to the patcher exe is still copied as an advanced fallback.
|
||||
- `web-panel/bridge/scripts/default/installed-apps-sync.js` resolves Wand's renderer services/store and publishes `My Games` snapshots through the `wand-remote-installed-apps` IPC channel. The synced list must mirror Wand's `my_games` source criteria: catalog games come from `installedGameVersions`, and extra installed unsupported titles come from `correlatedUnavailableTitles` whose `games[].correlationIds` match `installedApps`.
|
||||
- If the injected renderer cannot read a populated `correlatedUnavailableTitles` slice from the live store, `installed-apps-sync.js` must fall back to Wand's `/v3/unavailable_titles` correlation lookup through the renderer API client instead of degrading to raw install entries or an empty `My Games` list.
|
||||
@@ -22,7 +23,7 @@ This repository patches the Wand Electron app from a .NET Framework WPF desktop
|
||||
- The websocket `hello` snapshot must still send cached `installed_apps` and `game_status` even when no trainer snapshot is active yet; do not reintroduce a handshake path that returns early after `trainer_changed`.
|
||||
- Remote Play/Stop uses the websocket `remote_command` message. The bridge forwards it over `wand-remote-command` / `wand-remote-command-response`, and `installed-apps-sync.js` resolves Wand's trainer API + trainer service to launch a trainer for a `gameId` or end the current trainer.
|
||||
- Remote Play must construct Wand's real trainer launch request class (`69482.vO`) before calling `trainerService.launch(...)`. Passing a plain object launches the game process but breaks Wand's `getMetadata(vO)`-based trainer state, causing missing status, disappearing play/close buttons, and stuck loading behavior.
|
||||
- Pro activation is a C# asar patch (`EPatchType.ActivatePro`, independent of the remote panel / bridge). It rewrites three account-returning service methods to inject `subscription:{period:"yearly",state:"active"}` into the response before it reaches the store: `getUserAccount` and `setAccountWandBrandExperience` (Resolver-style, service field via `<service_name>` placeholder) and `setAccountLanguage` (`BuildSetAccountLanguagePatch` PatchFactory — captures the real param names + the original `post("/v3/account/language",{...})` expr and wraps `.then`). Pro is `am(account) = !!account.subscription` (flags/512 are irrelevant). `setAccountLanguage` is the one the original two patches missed, which is why Pro dropped on language change. If a future Wand build changes these method bodies, re-derive the regexes against the live `app-*.bundle.js` (do NOT trust `.source/new` — it is a different version).
|
||||
- Pro activation is a C# asar patch (`EPatchType.ActivatePro`, independent of the remote panel / bridge). It rewrites three account-returning service methods to inject `subscription:{period:"yearly",state:"active"}` into the response before it reaches the store: `getUserAccount` and `setAccountWandBrandExperience` (Resolver-style, service field via `<service_name>` placeholder) and `setAccountLanguage` (`BuildSetAccountLanguagePatch` PatchFactory — captures the real param names + the original `post("/v3/account/language",{...})` expr and wraps `.then`). A fourth patch (`setAccountReducer`) rewrites the `ACTION_SET_ACCOUNT` store reducer so any account write (periodic `refreshAccount`, push/profile updates, etc.) keeps Pro even when it bypasses those API methods. Pro is `am(account) = !!account.subscription` (flags/512 are irrelevant). `setAccountLanguage` is the one the original two patches missed, which is why Pro dropped on language change. If a future Wand build changes these method bodies, re-derive the regexes against the live `app-*.bundle.js` (do NOT trust `.source/new` — it is a different version).
|
||||
|
||||
## ASAR Patch Pipeline
|
||||
|
||||
@@ -33,14 +34,14 @@ This repository patches the Wand Electron app from a .NET Framework WPF desktop
|
||||
- The `DevToolsOnF12` patch anchors on the Electron main-process `<app>.whenReady().then(` site and attaches a `before-input-event` hook to every `BrowserWindow.webContents`. Do not patch the renderer keydown listener — the minified `ACTION_OPEN_DEV_TOOLS` dispatch site is not stable across Wand releases.
|
||||
- Cheats can be pinned per game in the web panel via `pinned-storage.ts` (`localStorage` key `wand-remote.pinned-cheats.v1:<gameId>`). Pinned cheats render as a virtual `pinned` category at the top of the list; their normal category placement is preserved.
|
||||
- Custom quick presets are per trainer/game and stored by `preset-storage.ts` under `localStorage` key `wand-remote.presets.v1:<gameId-or-trainerId>`. Presets capture persistent cheat values only; do not include `button` one-shot cheats in saved presets.
|
||||
- All `localStorage` access in `web-panel/src/features/remote-panel/` MUST go through the shared helpers in `storage.ts` (`loadJson` / `saveJson` / `loadStringSet` / `saveStringSet`). Do not reintroduce per-module `try/catch` + `JSON.parse` duplication in `pinned-storage`, `preset-storage`, or `game-pin-storage`. Trainer/game storage IDs are derived through the shared `getTrainerStorageId(trainer)` helper in `storage.ts`; do not re-implement the `gameId → titleId → trainerId → 'global'` precedence inline.
|
||||
- All shared bridge port/path/IPC channel/WS-opcode/protocol-version constants live in `web-panel/bridge/bridge-modules/constants.cjs` (exports `IPC_CHANNEL`, `WS_OPCODE`, `BRIDGE_PROTOCOL_VERSION`, `BRIDGE_SERVER_VERSION`, `RENDERER_INJECTION_DELAYS_MS`). Do not redeclare `3223`, `/remote/*`, IPC channel strings, raw WS opcode numbers (1/8/9/10), or the 500/2000 ms injection delays inline. The renderer-script-side equivalents (e.g. `vO`/`TRAINER_LAUNCH_REQUEST_EXPORT_KEY`, snapshot key prefixes, bootstrap log throttle) live in `web-panel/bridge/scripts/default/installed-apps-sync/constants.js`.
|
||||
- UI string-union types follow the `E*` enum convention from `.claude/rules/frontend-conventions.md` (currently `ECheatType` in `protocol.ts`, `EConnectionStatus` in `state.ts`); the wire string values must remain on the right-hand side of the enum members. Reducer `PanelAction` `type` tags stay as discriminated-union string literals (the union itself provides the discrimination — converting it to an enum loses pattern matching).
|
||||
- Cheat input controls live one-per-file under `web-panel/src/features/remote-panel/controls/` (`ToggleControl`, `SliderControl`, `ScalarControl`, `NumberControl`, `ActionButton`, `SelectionControl`, `IncrementalControl`); shared `SliderTrack` / `StepButton` / `ControlInternalProps` are in `controls/shared.tsx` and number formatting helpers in `controls/format-number.ts`. `controls/CheatControl.tsx` is a thin dispatcher map keyed by `ECheatType` — do not inline new control bodies into it.
|
||||
- All `localStorage` access in `web-panel/src/` MUST go through `web-panel/src/shared/storage.ts` (`loadJson` / `saveJson` / `loadStringSet` / `saveStringSet`). Do not reintroduce per-capability `try/catch` + `JSON.parse` duplication. Trainer/game storage IDs are derived through the shared `getTrainerStorageId(trainer)` helper; do not re-implement the `gameId → titleId → trainerId → 'global'` precedence inline.
|
||||
- Shared web protocol version, port, and HTTP/WS paths live in `web-panel/protocol/web-contract.json`. Bridge-only IPC channels, WS opcodes, and renderer injection delays live in `web-panel/bridge/src/constants.ts`. Do not redeclare these values inline.
|
||||
- UI string-union types follow the `E*` enum convention from `.claude/rules/frontend-conventions.md` (currently `ECheatType` in `protocol/messages.ts`, `EConnectionStatus` in `remote-session/remote-session.reducer.ts`); wire string values must remain on the right-hand side of enum members. Reducer action tags stay as discriminated-union string literals.
|
||||
- Cheat input controls live one-per-file under `web-panel/src/trainer/controls/`; shared `SliderTrack` / `StepButton` / `ControlInternalProps` are in `controls/shared.tsx` and number formatting helpers in `controls/format-number.ts`. `controls/CheatControl.tsx` remains a thin dispatcher keyed by `ECheatType`.
|
||||
- Mobile drawer performance is sensitive to `backdrop-filter`. Keep drawer panels and nested glass controls blur-free under coarse pointers, and do not add per-row `backdrop-blur-*` inside drawer lists.
|
||||
|
||||
## Validation
|
||||
|
||||
- Web panel build: `cd web-panel && pnpm run build` (runs type-check, Vite build, then `build:bridge` into `dist`).
|
||||
- Bridge/script syntax checks after build: `node --check web-panel/dist/bridge.cjs` and `node --check web-panel/dist/renderer-scripts/remote-popup-cleanup.js`.
|
||||
- Production dist should contain only static assets and should not contain `mock-instance`, `Mock Adventure`, `Simulation`, `Debug session`, `mock=1`, `demo-session`, `vite.svg`, `tailwind-merge`, `class-variance-authority`, or `clsx`.
|
||||
- Production dist should contain only static assets and should not contain `mock-instance`, `Mock Adventure`, `Simulation`, `Debug session`, `mock=1`, `demo-session`, `vite.svg`, `tailwind-merge`, `class-variance-authority`, or `clsx`.
|
||||
|
||||
@@ -41,9 +41,12 @@ namespace AsarSharp
|
||||
var destFilename = Path.Combine(dest, filename);
|
||||
var file = filesystem.GetFile(filename, followLinks);
|
||||
|
||||
// Path-traversal guard.
|
||||
string relativePath = Extensions.GetRelativePath(dest, destFilename);
|
||||
if (relativePath.StartsWith(".."))
|
||||
// Path-traversal (zip-slip) guard. Uses the normalising
|
||||
// containment check: GetRelativePath's fast path strips the
|
||||
// prefix literally without resolving "..", so a crafted entry
|
||||
// such as "a/../../evil" would otherwise pass this check and be
|
||||
// written outside "dest".
|
||||
if (!Extensions.IsPathInside(dest, destFilename))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{fullPath}: file \"{destFilename}\" writes out of the package");
|
||||
@@ -164,7 +167,7 @@ namespace AsarSharp
|
||||
|
||||
var linkTo = Path.Combine(relativeLinkPath, Path.GetFileName(file.Link));
|
||||
|
||||
if (Extensions.GetRelativePath(dest, linkSrcPath).StartsWith(".."))
|
||||
if (!Extensions.IsPathInside(dest, linkSrcPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{fullPath}: file \"{file.Link}\" links out of the package to \"{linkSrcPath}\"");
|
||||
|
||||
@@ -231,8 +231,14 @@ namespace AsarSharp.PickleTools
|
||||
private void Resize(int newCapacity)
|
||||
{
|
||||
newCapacity = AlignInt(newCapacity, PAYLOAD_UNIT);
|
||||
byte[] newHeader = new byte[_header.Length + newCapacity];
|
||||
Buffer.BlockCopy(_header, 0, newHeader, 0, _header.Length);
|
||||
// The backing array must hold the header plus the full advertised
|
||||
// payload capacity (matches Chromium's realloc(header_size_ + new_capacity)).
|
||||
// Sizing it from _header.Length under-allocates by _headerSize on the
|
||||
// first growth (when _header is still empty), leaving the payload region
|
||||
// _headerSize bytes short of _capacityAfterHeader and overrunning the
|
||||
// buffer when a write fills the payload.
|
||||
byte[] newHeader = new byte[_headerSize + newCapacity];
|
||||
Buffer.BlockCopy(_header, 0, newHeader, 0, Math.Min(_header.Length, newHeader.Length));
|
||||
_header = newHeader;
|
||||
_capacityAfterHeader = newCapacity;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,27 @@ namespace AsarSharp.Utils
|
||||
|
||||
private static bool IsSeparator(char c) => c == '/' || c == '\\';
|
||||
|
||||
/// <summary>
|
||||
/// Security check for archive extraction: returns true only when
|
||||
/// <paramref name="candidate"/> resolves to a location inside
|
||||
/// <paramref name="root"/>. Both paths are fully normalised first, so
|
||||
/// embedded ".." segments cannot escape the root (zip-slip). The
|
||||
/// <see cref="GetRelativePath"/> fast path must not be used here because
|
||||
/// it strips the prefix literally without resolving "..".
|
||||
/// </summary>
|
||||
public static bool IsPathInside(string root, string candidate)
|
||||
{
|
||||
string fullRoot = TrimTrailingSeparators(Path.GetFullPath(root));
|
||||
string fullCandidate = TrimTrailingSeparators(Path.GetFullPath(candidate));
|
||||
|
||||
if (string.Equals(fullRoot, fullCandidate, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return fullCandidate.Length > fullRoot.Length
|
||||
&& fullCandidate.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase)
|
||||
&& IsSeparator(fullCandidate[fullRoot.Length]);
|
||||
}
|
||||
|
||||
public static string GetDirectoryName(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
|
||||
+48
-1
@@ -3,6 +3,53 @@
|
||||
This file is the source of truth for release notes.
|
||||
The newest entry must match the version in `WandEnhancer/Properties/AssemblyInfo.cs`.
|
||||
|
||||
## [1.0.9.4] - 2026-07-21
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed the Remote Web Panel QR code still opening the official Wand mobile client after Wand changed its bundled QR renderer export. The renderer bridge now resolves the current export without adding a fragile C# ASAR patch. [Discussion #140](https://github.com/k1tbyte/Wand-Enhancer/discussions/140)
|
||||
- Fixed Quick Presets reporting that a preset was saved when browser local storage rejected the write. Failed writes now leave the existing preset list unchanged and show an error, and the save dialog now stays above the bottom navigation dock.
|
||||
- Fixed the patcher giving up on process termination because it reused a stale process snapshot by @divya0795 in [#145](https://github.com/k1tbyte/Wand-Enhancer/pull/145). Related issue: [#136](https://github.com/k1tbyte/Wand-Enhancer/issues/136)
|
||||
- Fixed ASAR extraction path traversal and corrupt Pickle payload allocation by @divya0795 in [#143](https://github.com/k1tbyte/Wand-Enhancer/pull/143).
|
||||
- Fixed backup restore so `app.asar.unpacked` is restored together with `app.asar`, and the injected `version.dll` is removed after a successful restore.
|
||||
- Fixed `version.dll` requiring Visual C++ runtime DLLs on some systems by statically linking the runtime. Release builds now reject accidental dynamic VCRUNTIME, MSVCP, or UCRT dependencies. [#128](https://github.com/k1tbyte/Wand-Enhancer/issues/128)
|
||||
|
||||
### Security and Privacy
|
||||
|
||||
- Removed bearer credentials and local installation paths from the Remote Web Panel WebSocket protocol. Trainer localization now stays inside the Electron bridge.
|
||||
- Hardened the local bridge against malformed HTTP URLs, invalid Host headers, and oversized WebSocket frames, and removed the production installed-apps debug endpoint.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- GitLab mirror jobs are now skipped in forks instead of failing when the upstream mirror credentials are unavailable.
|
||||
|
||||
## [1.0.9.3] - 2026-07-04
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed the Remote Web Panel no longer applying on newer Wand builds and reporting "unsupported version". The remote bridge patches now resolve Wand's minified internal names dynamically instead of relying on hardcoded ones that broke on Wand updates. #118 #123 #124 #126
|
||||
- Fixed Pro reverting to Free (with random sign-outs and the return of ads and the time limit) after linking a phone with Wand's mobile activation code. That native pairing triggers a server-side sign-out on a patched client, so the patcher now disables it; use the built-in Remote Web Panel to control Wand from another device instead. #120
|
||||
|
||||
## [1.0.9.2] - 2026-06-28
|
||||
|
||||
### Important
|
||||
|
||||
- Official releases no longer include downloadable `.exe` files. To update, sync your fork and rerun the `Build executable` workflow, or follow the instructions in [How to use](https://github.com/k1tbyte/Wand-Enhancer#-how-to-use).
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the built-in WandEnhancer updater. Official GitHub releases no longer ship executable assets.
|
||||
- Removed System.Net.Http
|
||||
- Removed self-signed certificate generation to prevent AV false positives.
|
||||
- Switched official releases to publish release notes only.
|
||||
|
||||
## [1.0.9.1] - 2026-06-24
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed Pro features disappearing after a day or two when Wand refreshed account data in the background; account store updates now preserve the patched active subscription by @Kava-4 in #110. Related issue #106
|
||||
- Fixed the new Pro account reducer guard so normal account updates do not fail while keeping Pro active.
|
||||
|
||||
## [1.0.9.0] - 2026-06-15
|
||||
|
||||
### Features
|
||||
@@ -154,4 +201,4 @@ The newest entry must match the version in `WandEnhancer/Properties/AssemblyInfo
|
||||
|
||||
### Changes
|
||||
|
||||
- Basic ElectronJS wrapper over the original script.
|
||||
- Basic ElectronJS wrapper over the original script.
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ Suggestions for new features or improvements are welcome! Create an Issue descri
|
||||
git tag 1.0.8.0
|
||||
git push origin 1.0.8.0
|
||||
```
|
||||
6. GitHub Actions will validate the version, build the project, extract the matching changelog section, and publish the release automatically.
|
||||
6. GitHub Actions will validate the version, build the project, extract the matching changelog section, and publish a notes-only release automatically. Official releases do not attach compiled binaries.
|
||||
|
||||
## Code Style
|
||||
|
||||
|
||||
@@ -5,18 +5,17 @@
|
||||
# WandEnhancer
|
||||
|
||||
[](https://gitlab.com/kitbyte/wand-enhancer)
|
||||
[](https://www.virustotal.com/gui/file/f6897cf583e9f8ea11e0ee4c3fb99b86c50336b28de706e3e0b9181b4e3cf223)
|
||||
|
||||
</div>
|
||||
|
||||
<h4>An open-source interoperability tool designed to extend local client-side configurations and improve the UX of the Wand application.</h4>
|
||||
|
||||
**🚨 IMPORTANT NOTICE: THIS PROJECT HAS NO OFFICIAL YOUTUBE TUTORIALS OR GUIDES. 🚨
|
||||
There are no official videos showing how to install or use this tool. Scammers are creating fake tutorials using this project's name and placing malware/password stealers in the video descriptions. If you downloaded an .exe or archive from a YouTube link, YOU HAVE DOWNLOADED MALWARE. The only official, safe, and original source for this project is this exact GitHub repository. We are not responsible for third-party downloads.**
|
||||
**🚨 IMPORTANT NOTICE: THIS PROJECT HAS NO OFFICIAL YOUTUBE TUTORIALS, GUIDES, OR PREBUILT EXECUTABLE DOWNLOADS. 🚨
|
||||
There are no official videos showing how to install or use this tool. Scammers are creating fake tutorials using this project's name and placing malware/password stealers in the video descriptions. Official GitHub releases contain release notes only, not `.exe` files. If you downloaded an `.exe` or archive from a YouTube link, a random website, or a third-party mirror, you did not get it from this project. We are not responsible for third-party downloads.**
|
||||
|
||||
## 👾 Is it safe to use?
|
||||
## 👾 What does it access?
|
||||
|
||||
Yes. This project is entirely open-source, allowing anyone to audit the code. It operates strictly locally, does not require internet access, and makes zero network requests. It simply adjusts local client settings to enhance your user experience.
|
||||
The .NET patcher modifies files in the selected local Wand installation and does not contact an update or telemetry service. The bundled `version.dll` proxy is loaded by Wand and changes Electron's ASAR-integrity fuse byte inside Wand's own process; it does not inject into another process. Wand itself remains an online application, build tools restore declared dependencies, and the optional Remote Web Panel deliberately starts a LAN HTTP/WebSocket server and uses Wand API/CDN data. Review the source and build the executable from your own fork; unsigned patching tools can trigger generic antivirus heuristics.
|
||||
|
||||
## 💫 What features are improved?
|
||||
|
||||
@@ -37,14 +36,26 @@ WandEnhancer includes a built-in **Remote Web Panel** allowing you to control ap
|
||||
### Troubleshooting & Remote Access:
|
||||
- **Page isn't loading?** First, ensure both your PC and phone are connected to the **same local network**. Some routers and guest Wi-Fi networks enable client isolation/AP isolation, which blocks devices on the same SSID from reaching each other. If it still does not load, check Windows Firewall and allow inbound traffic on TCP port `3223` for your local network. If Windows marked your connection as **Public**, switching it to **Private** can also help.
|
||||
- **Using mobile data or a different network?** If you want to use the panel over mobile data (LTE/5G) or from an entirely different network, you can use [Tailscale](https://tailscale.com/) or similar VPN tools.
|
||||
- The panel uses plain HTTP on port `3223` and has no pairing code. Anyone who can reach that port can view the panel and control the active trainer, so use it only on a trusted LAN/VPN and never expose the port directly to the internet.
|
||||
- The panel protocol does not include your Wand bearer token or installation-path fields.
|
||||
|
||||
## 👀 How to use?
|
||||
|
||||
1. Go to the [Releases](https://github.com/k1tbyte/Wand-Enhancer/releases) page.
|
||||
2. Download the latest binary release.
|
||||
3. Run the enhancer to apply local client modifications.
|
||||
This repository does not publish official compiled binaries. Build your own executable from your own fork using GitHub Actions.
|
||||
|
||||
1. Sign in to GitHub and fork this repository.
|
||||
2. Use **Sync fork** before each build so your fork contains the latest fixes.
|
||||
3. Open your fork, go to the **Actions** tab, and enable workflows if GitHub asks you to.
|
||||
4. Select the **Build executable** workflow.
|
||||
5. Click **Run workflow**, keep the default branch, and start the run.
|
||||
6. Wait for the workflow to finish, open the completed run, and download the artifact.
|
||||
7. Extract the artifact zip and run `WandEnhancer.exe` to apply local client modifications.
|
||||
|
||||
*Here how you do it:*
|
||||
|
||||
https://github.com/user-attachments/assets/7966cabe-0aa6-424d-8c2f-981ad91e0f91
|
||||
|
||||
|
||||
> Source archives are intended for developers who want to build the project locally. They are not prebuilt binaries.
|
||||
|
||||
## 🧩 Custom scripts
|
||||
|
||||
@@ -109,10 +120,18 @@ The build script installs the web panel dependencies, builds the frontend, compi
|
||||
|
||||
## ❓ Q&A
|
||||
|
||||
- **I applied the configuration but get stuck on 'Loading...'**
|
||||
- Just close the application completely and restart it.
|
||||
- **Why is there no `.exe` in GitHub Releases?**
|
||||
- Official releases are notes-only on purpose. The project no longer distributes prebuilt executables because unsigned or self-built patching tools are repeatedly reuploaded, mislabeled, and flagged by third-party scanners. Build the executable from your own fork using GitHub Actions instead.
|
||||
- **Where do I download the executable?**
|
||||
- From your own fork's **Actions** artifact after running the **Build executable** workflow. Do not download `.exe` files from YouTube descriptions, random mirrors, Discord attachments, or issue comments.
|
||||
- **Why does Windows Defender or SmartScreen warn about my build?**
|
||||
- The GitHub Actions artifact is unsigned and uncommon, so Windows may warn even when the code was built directly from your fork. Review the source, verify the workflow logs, and only run binaries you built yourself.
|
||||
- **Can I use a binary built by someone else?**
|
||||
- You can, but you should treat it as untrusted. This repository cannot verify or support third-party builds.
|
||||
- **Does this send data anywhere?**
|
||||
- No. All operations are strictly offline and local to your machine.
|
||||
- The .NET patching step is local. The optional Remote Web Panel listens on your LAN and may request trainer translations/artwork through Wand's existing API/CDN paths; it does not include an updater or project telemetry.
|
||||
- **How do I learn about a new version without an in-app update check?**
|
||||
- On GitHub choose **Watch → Custom → Releases**, then sync your fork and run **Build executable** when a release is published.
|
||||
|
||||
---
|
||||
## 🖼️ Screenshots
|
||||
@@ -129,7 +148,14 @@ This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) fi
|
||||
|
||||
---
|
||||
## ❤️ Support
|
||||
[](https://ko-fi.com/kitbyte)
|
||||
|
||||
If you find this project useful, you can support its development using any of the options below 🙌
|
||||
|
||||
[](https://www.patreon.com/kitbyte/gift)
|
||||
[](https://tronscan.org/#/address/TQdvau8pAy5Tg1Aa588tTcPCFgbcHtuoxc)
|
||||
[](https://www.blockchain.com/explorer/addresses/btc/1EZKDcyU8REm9JW5xwXJqSpn5Xaq5yAWWX)
|
||||
[](https://etherscan.io/address/0xd904d9d0557f88bbb1c4ab3582b4ca0d8a730e8d)
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -138,4 +164,4 @@ This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) fi
|
||||
|
||||
---
|
||||
|
||||
[](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date)
|
||||
[](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date)
|
||||
|
||||
@@ -244,7 +244,7 @@ namespace WandEnhancer.Core
|
||||
throw new FileNotFoundException($"Required workspace artifact not found: {Path.Combine(segments)}");
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string sourceDir, string destinationDir)
|
||||
internal static void CopyDirectory(string sourceDir, string destinationDir)
|
||||
{
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
|
||||
@@ -50,6 +50,17 @@ namespace WandEnhancer.Core
|
||||
return $"setAccountLanguage({parameters}){{return ({expr}).then(response=>{{response&&\"object\"==typeof response&&(response.subscription={{period:\"yearly\",state:\"active\"}});return response;}})}}";
|
||||
}
|
||||
|
||||
private static string BuildSetAccountReducerPatch(Match match)
|
||||
{
|
||||
var decl = RequireGroup(match, "decl", "setAccountReducer");
|
||||
var fn = RequireGroup(match, "fn", "setAccountReducer");
|
||||
var parameters = RequireGroup(match, "params", "setAccountReducer");
|
||||
var state = RequireGroup(match, "state", "setAccountReducer");
|
||||
var account = RequireGroup(match, "account", "setAccountReducer");
|
||||
return
|
||||
$"const {decl}=\"ACTION_SET_ACCOUNT\";function {fn}({parameters}){{const a={account}&&\"object\"==typeof {account}?{{...{account},subscription:{{period:\"yearly\",state:\"active\"}}}}:{account};return{{...{state},account:a}}}}";
|
||||
}
|
||||
|
||||
private static string BuildRemoteBridgeResetPatch(Match match)
|
||||
{
|
||||
var source = match.Value;
|
||||
@@ -143,6 +154,33 @@ namespace WandEnhancer.Core
|
||||
@"setAccountLanguage\((?<params>[^)]*)\)\{\s*return\s+(?<expr>this\.#\w+\.post\(""/v3/account/language"",\{[^}]*\}\))\s*;?\s*\}",
|
||||
RegexOptions.Singleline),
|
||||
PatchFactory = BuildSetAccountLanguagePatch
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
// Last-resort guard: any code path that dispatches ACTION_SET_ACCOUNT
|
||||
// (periodic refreshAccount, push updates, profile edits, etc.) must keep
|
||||
// subscription on the store object even when it bypasses the account API
|
||||
// service methods patched above.
|
||||
Name = "setAccountReducer",
|
||||
SearchHints = new[] { "ACTION_SET_ACCOUNT" },
|
||||
Target = new Regex(
|
||||
@"const (?<decl>\w+)=""ACTION_SET_ACCOUNT"";function (?<fn>\w+)\((?<params>[^)]*)\)\{return\{\.\.\.(?<state>\w+),account:(?<account>\w+)\}\}",
|
||||
RegexOptions.Singleline),
|
||||
PatchFactory = BuildSetAccountReducerPatch
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
// Wand's native "connect phone" pairing (POST /v3/auth/remote_code)
|
||||
// triggers a server-side device handoff that deauthorizes this desktop
|
||||
// session - the reported "entered the mobile activation key and got
|
||||
// signed out" bug. Neutralize the code issuer so native pairing can
|
||||
// never start. The injected remote panel is independent of this flow
|
||||
// (IPC bridge, not Wand's Pusher pairing) and keeps working. The
|
||||
// rejection is swallowed by the caller's try/catch (renders no code).
|
||||
Name = "disableNativeRemotePairing",
|
||||
SearchHints = new[] { "requestRemoteAuthCode", "/v3/auth/remote_code" },
|
||||
Target = new Regex(@"requestRemoteAuthCode\(\)\{return this\.#[\w$]+\.post\(""/v3/auth/remote_code""\)\}"),
|
||||
Patch = "requestRemoteAuthCode(){return Promise.reject(new Error(\"wand-enhancer: native mobile pairing disabled\"))}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -213,17 +251,28 @@ namespace WandEnhancer.Core
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
// Inject the bridge init + setHandler right after the method's opening
|
||||
// brace; the rest of setCurrentTrainer is left untouched. Only `${trainer}`
|
||||
// (active-trainer field) and `${remoteSource}` (value-source enum, taken
|
||||
// via lookahead from the sole `e.source!==` site) vary between builds and
|
||||
// are resolved from the match — nothing is hardcoded.
|
||||
Name = "remoteBridgeBindHandler",
|
||||
SearchHints = new[] { "client-state" },
|
||||
Target = new Regex(@"setCurrentTrainer\(e,t=null\)\{const s=e\?\.trainerId\|\|null,i=\(s\?e\?\.gameId:null\)\|\|null,n=\(s\?e\?\.supportedVersions:null\)\|\|\[];if\(s===this\.#ke&&t===this\.#Ee\)return;"),
|
||||
Patch = "setCurrentTrainer(e,t=null){this.__wandRemoteBridge||(this.__wandRemoteBridge=(()=>{try{const r=globalThis.require||require;const{ipcRenderer:c}=r(\"electron\");try{c.invoke(\"wand-remote-url\").then((u=>{u&&(globalThis.__wandRemoteBridgeUrl=u)}))}catch(e){}const send=(ch,p)=>{try{return c.invoke(ch,p&&JSON.parse(JSON.stringify(p)))}catch(e){}};return{sync:(s)=>send(\"wand-remote-sync\",s),valueChanged:(s)=>send(\"wand-remote-value-changed\",s),setHandler:(h)=>{if(this.__wandRemoteBridgeBound)return;this.__wandRemoteBridgeBound=true;try{c.invoke(\"wand-remote-set-handler-bind\")}catch(e){}c.on(\"wand-remote-set-value\",(_e,req)=>{try{h(req)}catch(e){}})}}}catch(e){try{const r=globalThis.require||require,fs=r(\"node:fs\"),os=r(\"node:os\"),p=r(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [renderer-bind-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}return null}})());this.__wandRemoteBridge?.setHandler((e=>{if(!this.#Ee||!e?.target)return!1;return this.#Ee.isActive()?this.#Ee.setValue(e.target,e.value,g.kL.Remote,e.cheatId):!1}));this.__wandRemoteTrainerInfo=e??null;const s=e?.trainerId||null,i=(s?e?.gameId:null)||null,n=(s?e?.supportedVersions:null)||[];if(s===this.#ke&&t===this.#Ee)return;"
|
||||
Target = new Regex(@"(?<head>setCurrentTrainer\(e,t=null\)\{)(?=const s=e\?\.trainerId\|\|null,i=\(s\?e\?\.gameId:null\)\|\|null,n=\(s\?e\?\.supportedVersions:null\)\|\|\[\];if\(s===this\.#[\w$]+&&t===this\.(?<trainer>#[\w$]+)\)return;)(?=.*?e\.source!==(?<remoteSource>[\w$]+\.[\w$]+\.Remote))",
|
||||
RegexOptions.Singleline),
|
||||
Patch = "${head}this.__wandRemoteBridge||(this.__wandRemoteBridge=(()=>{try{const r=globalThis.require||require;const{ipcRenderer:c}=r(\"electron\");try{c.invoke(\"wand-remote-url\").then((u=>{u&&(globalThis.__wandRemoteBridgeUrl=u)}))}catch(e){}const send=(ch,p)=>{try{return c.invoke(ch,p&&JSON.parse(JSON.stringify(p)))}catch(e){}};return{sync:(s)=>send(\"wand-remote-sync\",s),valueChanged:(s)=>send(\"wand-remote-value-changed\",s),setHandler:(h)=>{if(this.__wandRemoteBridgeBound)return;this.__wandRemoteBridgeBound=true;try{c.invoke(\"wand-remote-set-handler-bind\")}catch(e){}c.on(\"wand-remote-set-value\",(_e,req)=>{try{h(req)}catch(e){}})}}}catch(e){try{const r=globalThis.require||require,fs=r(\"node:fs\"),os=r(\"node:os\"),p=r(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [renderer-bind-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}return null}})());this.__wandRemoteBridge?.setHandler((e=>{if(!this.${trainer}||!e?.target)return!1;return this.${trainer}.isActive()?this.${trainer}.setValue(e.target,e.value,${remoteSource},e.cheatId):!1}));this.__wandRemoteTrainerInfo=e??null;"
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
// Pure insertion: splice one `valueChanged` bridge call in after the
|
||||
// existing `client-value-changed` send, before the onValueSet callback
|
||||
// closes. Resolves no private names — `${head}`/`${tail}` carry the
|
||||
// original text verbatim. trainerId is omitted from the payload;
|
||||
// bridge-state falls back to the active snapshot trainer.
|
||||
Name = "remoteBridgeValueDelta",
|
||||
SearchHints = new[] { "client-value-changed" },
|
||||
Target = new Regex(@"#ct\(e,t\)\{t\.push\(e\.onValueSet\(e=>\{this\.status===i\.Connected&&e\.source!==g\.kL\.Remote&&this\.#Me\?\.send\(""client-value-changed"",\{instanceId:this\.#Pe,name:e\.name,value:e\.value,cheatId:e\.cheatId\}\)\}\)\),this\.#Be\(\)\}"),
|
||||
Patch = "#ct(e,t){t.push(e.onValueSet(e=>{this.status===i.Connected&&e.source!==g.kL.Remote&&this.#Me?.send(\"client-value-changed\",{instanceId:this.#Pe,name:e.name,value:e.value,cheatId:e.cheatId}),this.__wandRemoteBridge?.valueChanged({trainerId:this.#ke,target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??\"desktop\"),cheatId:e.cheatId})})),this.#Be()}"
|
||||
Target = new Regex(@"(?<head>#[\w$]+\(e,t\)\{t\.push\(e\.onValueSet\(e=>\{this\.status===[\w$]+\.Connected&&e\.source!==[\w$]+\.[\w$]+\.Remote&&this\.#[\w$]+\?\.send\(""client-value-changed"",\{instanceId:this\.#[\w$]+,name:e\.name,value:e\.value,cheatId:e\.cheatId\}\))(?<tail>\}\)\),this\.#[\w$]+\(\)\})"),
|
||||
Patch = "${head},this.__wandRemoteBridge?.valueChanged({target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??\"desktop\"),cheatId:e.cheatId})${tail}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Eine neue Version ist verfügbar</s:String>
|
||||
<s:String x:Key="mw_folder_path">Ordnerpfad</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Ordner nicht gefunden</s:String>
|
||||
<s:String x:Key="mw_patch">Anwenden</s:String>
|
||||
@@ -39,18 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Was werden wir verbessern?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Vor dem Update wird dringend empfohlen, Änderungen rückgängig zu machen, falls sie angewendet wurden</s:String>
|
||||
<s:String x:Key="up_current_version">Aktuelle Version</s:String>
|
||||
<s:String x:Key="up_latest_version">Neueste Version</s:String>
|
||||
<s:String x:Key="up_release_notes">Versionshinweise</s:String>
|
||||
<s:String x:Key="up_release_notes_unavailable">Für diese Version sind keine Versionshinweise verfügbar.</s:String>
|
||||
<s:String x:Key="up_show_more">Gesamtes Changelog anzeigen</s:String>
|
||||
<s:String x:Key="up_show_less">Nur aktuelle Hinweise anzeigen</s:String>
|
||||
<s:String x:Key="up_loading_changelog">Changelog wird geladen...</s:String>
|
||||
<s:String x:Key="up_changelog_failed">Das vollständige Changelog konnte nicht geladen werden. Stattdessen werden die aktuellen Hinweise angezeigt.</s:String>
|
||||
<s:String x:Key="up_update_now">Jetzt aktualisieren</s:String>
|
||||
<s:String x:Key="up_popup_title">Update verfügbar!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">A new version is available</s:String>
|
||||
<s:String x:Key="mw_folder_path">Folder path</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Folder not found</s:String>
|
||||
<s:String x:Key="mw_patch">Enhance</s:String>
|
||||
@@ -39,18 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">What are we gonna enhance?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Before updating, it is strongly recommended to roll back modifications if they have been applied</s:String>
|
||||
<s:String x:Key="up_current_version">Current version</s:String>
|
||||
<s:String x:Key="up_latest_version">Latest version</s:String>
|
||||
<s:String x:Key="up_release_notes">Release notes</s:String>
|
||||
<s:String x:Key="up_release_notes_unavailable">Release notes are unavailable for this release.</s:String>
|
||||
<s:String x:Key="up_show_more">Show full changelog</s:String>
|
||||
<s:String x:Key="up_show_less">Show latest notes</s:String>
|
||||
<s:String x:Key="up_loading_changelog">Loading changelog...</s:String>
|
||||
<s:String x:Key="up_changelog_failed">Failed to load the full changelog. The latest notes are shown instead.</s:String>
|
||||
<s:String x:Key="up_update_now">Update now</s:String>
|
||||
<s:String x:Key="up_popup_title">Update available!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Una nueva versión está disponible</s:String>
|
||||
<s:String x:Key="mw_folder_path">Ruta de la carpeta</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Carpeta no encontrada</s:String>
|
||||
<s:String x:Key="mw_patch">Aplicar</s:String>
|
||||
@@ -39,18 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">¿Qué vamos a mejorar?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Antes de actualizar, se recomienda encarecidamente revertir las modificaciones si se han aplicado</s:String>
|
||||
<s:String x:Key="up_current_version">Versión actual</s:String>
|
||||
<s:String x:Key="up_latest_version">Última versión</s:String>
|
||||
<s:String x:Key="up_release_notes">Notas de la versión</s:String>
|
||||
<s:String x:Key="up_release_notes_unavailable">Las notas de la versión no están disponibles para esta versión.</s:String>
|
||||
<s:String x:Key="up_show_more">Mostrar changelog completo</s:String>
|
||||
<s:String x:Key="up_show_less">Mostrar solo las notas actuales</s:String>
|
||||
<s:String x:Key="up_loading_changelog">Cargando changelog...</s:String>
|
||||
<s:String x:Key="up_changelog_failed">No se pudo cargar el changelog completo. Se muestran las notas actuales.</s:String>
|
||||
<s:String x:Key="up_update_now">Actualizar ahora</s:String>
|
||||
<s:String x:Key="up_popup_title">¡Actualización disponible!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Une nouvelle version est disponible</s:String>
|
||||
<s:String x:Key="mw_folder_path">Chemin du dossier</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Dossier non trouvé</s:String>
|
||||
<s:String x:Key="mw_patch">Appliquer</s:String>
|
||||
@@ -39,18 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Qu'allons-nous modifier ?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Avant la mise à jour, il est fortement recommandé d'annuler les modifications si elles ont été appliquées</s:String>
|
||||
<s:String x:Key="up_current_version">Version actuelle</s:String>
|
||||
<s:String x:Key="up_latest_version">Dernière version</s:String>
|
||||
<s:String x:Key="up_release_notes">Notes de version</s:String>
|
||||
<s:String x:Key="up_release_notes_unavailable">Les notes de version ne sont pas disponibles pour cette version.</s:String>
|
||||
<s:String x:Key="up_show_more">Afficher le changelog complet</s:String>
|
||||
<s:String x:Key="up_show_less">Afficher uniquement les notes actuelles</s:String>
|
||||
<s:String x:Key="up_loading_changelog">Chargement du changelog...</s:String>
|
||||
<s:String x:Key="up_changelog_failed">Impossible de charger le changelog complet. Les notes actuelles sont affichées à la place.</s:String>
|
||||
<s:String x:Key="up_update_now">Mettre à jour maintenant</s:String>
|
||||
<s:String x:Key="up_popup_title">Mise à jour disponible !</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">È disponibile una nuova versione</s:String>
|
||||
<s:String x:Key="mw_folder_path">Percorso cartella</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Cartella non trovata</s:String>
|
||||
<s:String x:Key="mw_patch">Applica</s:String>
|
||||
@@ -39,18 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Cosa modificheremo?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Prima dell'aggiornamento, si consiglia vivamente di annullare le modifiche se sono state applicate</s:String>
|
||||
<s:String x:Key="up_current_version">Versione corrente</s:String>
|
||||
<s:String x:Key="up_latest_version">Ultima versione</s:String>
|
||||
<s:String x:Key="up_release_notes">Note di rilascio</s:String>
|
||||
<s:String x:Key="up_release_notes_unavailable">Le note di rilascio non sono disponibili per questa versione.</s:String>
|
||||
<s:String x:Key="up_show_more">Mostra il changelog completo</s:String>
|
||||
<s:String x:Key="up_show_less">Mostra solo le note correnti</s:String>
|
||||
<s:String x:Key="up_loading_changelog">Caricamento del changelog...</s:String>
|
||||
<s:String x:Key="up_changelog_failed">Impossibile caricare il changelog completo. Vengono mostrate solo le note correnti.</s:String>
|
||||
<s:String x:Key="up_update_now">Aggiorna ora</s:String>
|
||||
<s:String x:Key="up_popup_title">Aggiornamento disponibile!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">新しいバージョンが利用可能です</s:String>
|
||||
<s:String x:Key="mw_folder_path">フォルダパス</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">フォルダが見つかりません</s:String>
|
||||
<s:String x:Key="mw_patch">適用</s:String>
|
||||
@@ -39,18 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">何を改善しますか?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">アップデート前に、変更が適用されている場合はロールバックすることを強くお勧めします</s:String>
|
||||
<s:String x:Key="up_current_version">現在のバージョン</s:String>
|
||||
<s:String x:Key="up_latest_version">最新バージョン</s:String>
|
||||
<s:String x:Key="up_release_notes">リリースノート</s:String>
|
||||
<s:String x:Key="up_release_notes_unavailable">このリリースのリリースノートは利用できません。</s:String>
|
||||
<s:String x:Key="up_show_more">完全な変更履歴を表示</s:String>
|
||||
<s:String x:Key="up_show_less">最新のリリースノートのみ表示</s:String>
|
||||
<s:String x:Key="up_loading_changelog">変更履歴を読み込み中...</s:String>
|
||||
<s:String x:Key="up_changelog_failed">完全な変更履歴を読み込めませんでした。代わりに最新のリリースノートを表示しています。</s:String>
|
||||
<s:String x:Key="up_update_now">今すぐ更新</s:String>
|
||||
<s:String x:Key="up_popup_title">アップデート利用可能!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Dostępna jest nowa wersja</s:String>
|
||||
<s:String x:Key="mw_folder_path">Ścieżka folderu</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Folder nie znaleziony</s:String>
|
||||
<s:String x:Key="mw_patch">Zastosuj</s:String>
|
||||
@@ -39,18 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Co będziemy ulepszać?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Przed aktualizacją zdecydowanie zaleca się cofnięcie zmian, jeśli zostały zastosowane</s:String>
|
||||
<s:String x:Key="up_current_version">Aktualna wersja</s:String>
|
||||
<s:String x:Key="up_latest_version">Najnowsza wersja</s:String>
|
||||
<s:String x:Key="up_release_notes">Informacje o wydaniu</s:String>
|
||||
<s:String x:Key="up_release_notes_unavailable">Informacje o wydaniu są niedostępne dla tej wersji.</s:String>
|
||||
<s:String x:Key="up_show_more">Pokaż cały changelog</s:String>
|
||||
<s:String x:Key="up_show_less">Pokaż tylko bieżące zmiany</s:String>
|
||||
<s:String x:Key="up_loading_changelog">Ładowanie changeloga...</s:String>
|
||||
<s:String x:Key="up_changelog_failed">Nie udało się załadować pełnego changeloga. Zamiast tego wyświetlono bieżące zmiany.</s:String>
|
||||
<s:String x:Key="up_update_now">Aktualizuj teraz</s:String>
|
||||
<s:String x:Key="up_popup_title">Dostępna aktualizacja!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Uma nova versão está disponível</s:String>
|
||||
<s:String x:Key="mw_folder_path">Caminho da pasta</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Pasta não encontrada</s:String>
|
||||
<s:String x:Key="mw_patch">Aplicar</s:String>
|
||||
@@ -39,18 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">O que vamos melhorar?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Antes de atualizar, é altamente recomendável reverter as modificações se elas foram aplicadas</s:String>
|
||||
<s:String x:Key="up_current_version">Versão atual</s:String>
|
||||
<s:String x:Key="up_latest_version">Versão mais recente</s:String>
|
||||
<s:String x:Key="up_release_notes">Notas da versão</s:String>
|
||||
<s:String x:Key="up_release_notes_unavailable">As notas da versão não estão disponíveis para esta versão.</s:String>
|
||||
<s:String x:Key="up_show_more">Mostrar changelog completo</s:String>
|
||||
<s:String x:Key="up_show_less">Mostrar apenas as notas atuais</s:String>
|
||||
<s:String x:Key="up_loading_changelog">Carregando changelog...</s:String>
|
||||
<s:String x:Key="up_changelog_failed">Falha ao carregar o changelog completo. As notas atuais estão sendo exibidas.</s:String>
|
||||
<s:String x:Key="up_update_now">Atualizar agora</s:String>
|
||||
<s:String x:Key="up_popup_title">Atualização disponível!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Доступна новая версия</s:String>
|
||||
<s:String x:Key="mw_folder_path">Путь к папке</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Папка не найдена</s:String>
|
||||
<s:String x:Key="mw_patch">Применить</s:String>
|
||||
@@ -39,18 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Что будем улучшать?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Перед обновлением настоятельно рекомендуется откатить изменения, если они были применены</s:String>
|
||||
<s:String x:Key="up_current_version">Текущая версия</s:String>
|
||||
<s:String x:Key="up_latest_version">Новая версия</s:String>
|
||||
<s:String x:Key="up_release_notes">Что нового</s:String>
|
||||
<s:String x:Key="up_release_notes_unavailable">Для этого релиза патчноуты недоступны.</s:String>
|
||||
<s:String x:Key="up_show_more">Показать весь changelog</s:String>
|
||||
<s:String x:Key="up_show_less">Показать только актуальные изменения</s:String>
|
||||
<s:String x:Key="up_loading_changelog">Загрузка changelog...</s:String>
|
||||
<s:String x:Key="up_changelog_failed">Не удалось загрузить полный changelog. Показаны только актуальные изменения.</s:String>
|
||||
<s:String x:Key="up_update_now">Обновить сейчас</s:String>
|
||||
<s:String x:Key="up_popup_title">Доступно обновление!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Yeni bir sürüm mevcut</s:String>
|
||||
<s:String x:Key="mw_folder_path">Klasör yolu</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Klasör bulunamadı</s:String>
|
||||
<s:String x:Key="mw_patch">Uygula</s:String>
|
||||
@@ -39,18 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Neyi geliştireceğiz?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Güncellemeden önce, değişiklikler uygulandıysa geri almak şiddetle tavsiye edilir</s:String>
|
||||
<s:String x:Key="up_current_version">Geçerli sürüm</s:String>
|
||||
<s:String x:Key="up_latest_version">En son sürüm</s:String>
|
||||
<s:String x:Key="up_release_notes">Sürüm notları</s:String>
|
||||
<s:String x:Key="up_release_notes_unavailable">Bu sürüm için sürüm notları kullanılamıyor.</s:String>
|
||||
<s:String x:Key="up_show_more">Tüm changelog'u göster</s:String>
|
||||
<s:String x:Key="up_show_less">Yalnızca güncel notları göster</s:String>
|
||||
<s:String x:Key="up_loading_changelog">Changelog yükleniyor...</s:String>
|
||||
<s:String x:Key="up_changelog_failed">Tam changelog yüklenemedi. Bunun yerine güncel notlar gösteriliyor.</s:String>
|
||||
<s:String x:Key="up_update_now">Şimdi güncelle</s:String>
|
||||
<s:String x:Key="up_popup_title">Güncelleme mevcut!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Доступна нова версія</s:String>
|
||||
<s:String x:Key="mw_folder_path">Шлях до папки</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Папку не знайдено</s:String>
|
||||
<s:String x:Key="mw_patch">Застосувати</s:String>
|
||||
@@ -39,18 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Що будемо покращувати?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Перед оновленням наполегливо рекомендується відкотити зміни, якщо вони були застосовані</s:String>
|
||||
<s:String x:Key="up_current_version">Поточна версія</s:String>
|
||||
<s:String x:Key="up_latest_version">Остання версія</s:String>
|
||||
<s:String x:Key="up_release_notes">Нотатки до релізу</s:String>
|
||||
<s:String x:Key="up_release_notes_unavailable">Нотатки до цього релізу недоступні.</s:String>
|
||||
<s:String x:Key="up_show_more">Показати весь список змін</s:String>
|
||||
<s:String x:Key="up_show_less">Показати лише актуальні зміни</s:String>
|
||||
<s:String x:Key="up_loading_changelog">Завантаження списку змін...</s:String>
|
||||
<s:String x:Key="up_changelog_failed">Не вдалося завантажити повний список змін. Натомість показано лише актуальні зміни.</s:String>
|
||||
<s:String x:Key="up_update_now">Оновити зараз</s:String>
|
||||
<s:String x:Key="up_popup_title">Доступне оновлення!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">有新版本可用</s:String>
|
||||
<s:String x:Key="mw_folder_path">文件夹路径</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">未找到文件夹</s:String>
|
||||
<s:String x:Key="mw_patch">增强</s:String>
|
||||
@@ -39,18 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">我们要增强什么?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">在更新之前,强烈建议回滚已应用的修改</s:String>
|
||||
<s:String x:Key="up_current_version">当前版本</s:String>
|
||||
<s:String x:Key="up_latest_version">最新版本</s:String>
|
||||
<s:String x:Key="up_release_notes">更新说明</s:String>
|
||||
<s:String x:Key="up_release_notes_unavailable">此版本的更新说明不可用。</s:String>
|
||||
<s:String x:Key="up_show_more">显示完整更新日志</s:String>
|
||||
<s:String x:Key="up_show_less">仅显示当前说明</s:String>
|
||||
<s:String x:Key="up_loading_changelog">正在加载更新日志...</s:String>
|
||||
<s:String x:Key="up_changelog_failed">无法加载完整更新日志。当前仅显示本次说明。</s:String>
|
||||
<s:String x:Key="up_update_now">立即更新</s:String>
|
||||
<s:String x:Key="up_popup_title">有更新可用!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -51,5 +51,5 @@ using System.Windows;
|
||||
// You can specify all the values or you can default the Build and Revision Numbers
|
||||
// by using the '*' as shown below:
|
||||
// [assembly: AssemblyVersion("1.0.*")]
|
||||
[assembly: AssemblyVersion("1.0.9.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.9.0")]
|
||||
[assembly: AssemblyVersion("1.0.9.4")]
|
||||
[assembly: AssemblyFileVersion("1.0.9.4")]
|
||||
|
||||
@@ -11,7 +11,12 @@ namespace WandEnhancer.Utils
|
||||
public static void TryKillProcess(string processName)
|
||||
{
|
||||
Process[] processes = Process.GetProcessesByName(processName);
|
||||
for (int i = 0; processes.Length > i || i < 5; i++)
|
||||
// Retry while any target process is still alive, capped at 5 attempts.
|
||||
// The previous condition (processes.Length > i || i < 5) compared the
|
||||
// process count to the loop index and, because of the "|| i < 5", always
|
||||
// ran at least 5 iterations — sleeping ~1.25s even when the process was
|
||||
// never running.
|
||||
for (int i = 0; processes.Length > 0 && i < 5; i++)
|
||||
{
|
||||
foreach (var process in processes)
|
||||
{
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Net.Http;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace WandEnhancer.Utils
|
||||
{
|
||||
public class UpdateReleaseInfo
|
||||
{
|
||||
public string Version { get; set; }
|
||||
|
||||
public string LatestNotes { get; set; }
|
||||
}
|
||||
|
||||
public class GitHubRelease
|
||||
{
|
||||
public class AssetsType
|
||||
{
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("browser_download_url")]
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
[JsonProperty("tag_name")]
|
||||
public string TagName { get; set; }
|
||||
|
||||
[JsonProperty("assets")]
|
||||
public AssetsType[] Assets { get; set; }
|
||||
|
||||
[JsonProperty("body")]
|
||||
public string Body { get; set; }
|
||||
|
||||
[JsonProperty("published_at")]
|
||||
public DateTimeOffset PublishedAt { get; set; }
|
||||
|
||||
}
|
||||
|
||||
public class Updater
|
||||
{
|
||||
private GitHubRelease _release = null;
|
||||
private UpdateReleaseInfo _updateInfo = null;
|
||||
private string _fullChangelog = null;
|
||||
private static readonly HttpClient _httpClient = new HttpClient()
|
||||
{
|
||||
DefaultRequestHeaders =
|
||||
{
|
||||
{ "User-Agent", "GitHub-Updater" }
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly string ApiUrl = $"https://api.github.com/repos/{Constants.Owner}/{Constants.RepoName}/releases/latest";
|
||||
private static readonly string ReleasesApiUrl = $"https://api.github.com/repos/{Constants.Owner}/{Constants.RepoName}/releases?per_page=20";
|
||||
public async Task<bool> CheckForUpdates()
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentVersion = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
var response = await _httpClient.GetAsync(ApiUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
_release = JsonConvert.DeserializeObject<GitHubRelease>(await response.Content.ReadAsStringAsync());
|
||||
_updateInfo = null;
|
||||
_fullChangelog = null;
|
||||
|
||||
if (_release == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var latestVersion = ParseVersion(_release.TagName);
|
||||
|
||||
if (latestVersion <= currentVersion)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_updateInfo = new UpdateReleaseInfo
|
||||
{
|
||||
Version = NormalizeVersion(_release.TagName),
|
||||
LatestNotes = NormalizeText(_release.Body)
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UpdateReleaseInfo> GetUpdateInfoAsync()
|
||||
{
|
||||
if (_updateInfo != null)
|
||||
{
|
||||
return _updateInfo;
|
||||
}
|
||||
|
||||
return await CheckForUpdates()
|
||||
? _updateInfo
|
||||
: null;
|
||||
}
|
||||
|
||||
public async Task<string> GetFullChangelogAsync()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_fullChangelog))
|
||||
{
|
||||
return NormalizeText(_fullChangelog);
|
||||
}
|
||||
|
||||
_fullChangelog = await TryLoadFullChangelogAsync();
|
||||
|
||||
return NormalizeText(_fullChangelog);
|
||||
}
|
||||
|
||||
public async Task Update()
|
||||
{
|
||||
if (_release == null)
|
||||
{
|
||||
throw new Exception("No release found");
|
||||
}
|
||||
|
||||
var asset = _release.Assets.FirstOrDefault(o => o.Name.EndsWith(".exe"));
|
||||
if(asset == null)
|
||||
{
|
||||
throw new Exception("No asset found");
|
||||
}
|
||||
|
||||
// download to temp
|
||||
var downloadPath = Path.Combine(Path.GetTempPath(), asset.Name);
|
||||
|
||||
using(var response = await _httpClient.GetAsync(asset.Url))
|
||||
using(var fileStream = File.Create(downloadPath))
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
await response.Content.CopyToAsync(fileStream);
|
||||
}
|
||||
|
||||
ApplyUpdate(downloadPath);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static void ApplyUpdate(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentExecutable = Assembly.GetExecutingAssembly().Location;
|
||||
|
||||
var psCommand = $"Start-Sleep -Seconds 2; " +
|
||||
$"Copy-Item -Path '{filePath}' -Destination '{currentExecutable}' -Force; " +
|
||||
$"Remove-Item -Path '{filePath}' -Force; " +
|
||||
$"Start-Sleep -Seconds 1; " +
|
||||
$"Start-Process -FilePath '{currentExecutable}';";
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-WindowStyle Hidden -ExecutionPolicy Bypass -Command \"{psCommand}\"",
|
||||
UseShellExecute = true,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
|
||||
Process.Start(startInfo);
|
||||
|
||||
Task.Delay(500).ContinueWith(_ =>
|
||||
{
|
||||
App.Shutdown();
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Update failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Version ParseVersion(string versionTag)
|
||||
{
|
||||
return new Version(NormalizeVersion(versionTag));
|
||||
}
|
||||
|
||||
private static string NormalizeVersion(string versionTag)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(versionTag))
|
||||
{
|
||||
throw new ArgumentException("Version tag cannot be empty.", nameof(versionTag));
|
||||
}
|
||||
|
||||
return versionTag.Trim().TrimStart('v', 'V');
|
||||
}
|
||||
|
||||
private static string NormalizeText(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return NormalizeLineEndings(text).Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeLineEndings(string text)
|
||||
{
|
||||
return text
|
||||
.Replace("\r\n", "\n")
|
||||
.Replace('\r', '\n');
|
||||
}
|
||||
|
||||
private static async Task<string> TryLoadFullChangelogAsync()
|
||||
{
|
||||
return await TryBuildReleaseHistoryAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> TryBuildReleaseHistoryAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(ReleasesApiUrl);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var releases = JsonConvert.DeserializeObject<GitHubRelease[]>(await response.Content.ReadAsStringAsync());
|
||||
if (releases == null || releases.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildReleaseHistory(releases);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildReleaseHistory(GitHubRelease[] releases)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
foreach (var release in releases.Where(item => !string.IsNullOrWhiteSpace(item?.TagName)))
|
||||
{
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.Append("## [")
|
||||
.Append(NormalizeVersion(release.TagName))
|
||||
.Append("]");
|
||||
|
||||
if (release.PublishedAt != default(DateTimeOffset))
|
||||
{
|
||||
builder.Append(" - ")
|
||||
.Append(release.PublishedAt.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
var notes = NormalizeText(release.Body);
|
||||
if (string.IsNullOrWhiteSpace(notes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine();
|
||||
builder.Append(notes);
|
||||
}
|
||||
|
||||
return NormalizeText(builder.ToString());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,6 @@
|
||||
v 1.0.0
|
||||
</TextBlock>
|
||||
|
||||
<Button Background="SpringGreen" Foreground="{DynamicResource Muted}"
|
||||
FontWeight="Medium" Padding="20 0" Margin="10 5 20 5"
|
||||
ToolTip="Click to update"
|
||||
Command="{Binding UpdateCommand}"
|
||||
Visibility="{Binding IsUpdateAvailable, Converter={StaticResource ToVisibilityConverter}}"
|
||||
Content="{DynamicResource mw_update_available}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
|
||||
@@ -227,4 +221,4 @@
|
||||
<controls:PopupHost x:Name="PopupHost"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
</Window>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
@@ -18,8 +17,6 @@ namespace WandEnhancer.View.MainWindow
|
||||
{
|
||||
private readonly MainWindow _view;
|
||||
public ObservableCollection<LogEntry> LogList { get; set; } = new ObservableCollection<LogEntry>();
|
||||
private static Updater _updater = new Updater();
|
||||
|
||||
private WeModConfig _weModConfig;
|
||||
|
||||
public WeModConfig WeModInfo
|
||||
@@ -31,7 +28,9 @@ namespace WandEnhancer.View.MainWindow
|
||||
if (value == null) return;
|
||||
|
||||
Log($"WeMod directory found at '{_weModConfig}' ({_weModConfig.ExecutableName})", ELogType.Success);
|
||||
if (File.Exists(Path.Combine(_weModConfig.RootDirectory, "resources", "app.asar.backup")))
|
||||
var resourcesPath = Path.Combine(_weModConfig.RootDirectory, "resources");
|
||||
if (File.Exists(Path.Combine(resourcesPath, "app.asar.backup")) ||
|
||||
Directory.Exists(Path.Combine(resourcesPath, "app.asar.unpacked.backup")))
|
||||
{
|
||||
Log("WeMod already patched. If you want to patch again, please restore the backup first.",
|
||||
ELogType.Warn);
|
||||
@@ -61,18 +60,9 @@ namespace WandEnhancer.View.MainWindow
|
||||
set => SetProperty(ref _alreadyPatched, value);
|
||||
}
|
||||
|
||||
private bool _isUpdateAvailable;
|
||||
|
||||
public bool IsUpdateAvailable
|
||||
{
|
||||
get => _isUpdateAvailable;
|
||||
set => SetProperty(ref _isUpdateAvailable, value);
|
||||
}
|
||||
|
||||
public RelayCommand SetFolderPathCommand { get; }
|
||||
public RelayCommand ApplyPatchCommand { get; }
|
||||
public RelayCommand RestoreBackupCommand { get; }
|
||||
public RelayCommand UpdateCommand { get; }
|
||||
public RelayCommand OpenSettingsCommand { get; }
|
||||
public RelayCommand CopyLogsCommand { get; }
|
||||
public RelayCommand ExportLogsCommand { get; }
|
||||
@@ -107,35 +97,42 @@ namespace WandEnhancer.View.MainWindow
|
||||
|
||||
private void OnBackupRestoring(object param)
|
||||
{
|
||||
var backupPath = Path.Combine(WeModInfo.RootDirectory, "resources", "app.asar.backup");
|
||||
if (!File.Exists(backupPath))
|
||||
var resourcesPath = Path.Combine(WeModInfo.RootDirectory, "resources");
|
||||
var backupPath = Path.Combine(resourcesPath, "app.asar.backup");
|
||||
var unpackedBackupPath = Path.Combine(resourcesPath, "app.asar.unpacked.backup");
|
||||
if (!File.Exists(backupPath) || !Directory.Exists(unpackedBackupPath))
|
||||
{
|
||||
Log("Backup not found. Please dont delete it manually", ELogType.Error);
|
||||
Log("Backup is incomplete. Restore the original Wand installation files or reinstall Wand.", ELogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Try to lock the file to see if it's in use
|
||||
using (File.Open(backupPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
|
||||
var asarPath = Path.Combine(resourcesPath, "app.asar");
|
||||
var unpackedPath = Path.Combine(resourcesPath, "app.asar.unpacked");
|
||||
File.Copy(backupPath, asarPath, true);
|
||||
|
||||
if (Directory.Exists(unpackedPath))
|
||||
{
|
||||
Directory.Delete(unpackedPath, true);
|
||||
}
|
||||
|
||||
Enhancer.CopyDirectory(unpackedBackupPath, unpackedPath);
|
||||
|
||||
var proxyDllPath = Path.Combine(WeModInfo.RootDirectory, "version.dll");
|
||||
|
||||
if(File.Exists(proxyDllPath))
|
||||
if (File.Exists(proxyDllPath))
|
||||
{
|
||||
File.Delete(proxyDllPath);
|
||||
}
|
||||
|
||||
File.Delete(backupPath);
|
||||
Directory.Delete(unpackedBackupPath, true);
|
||||
}
|
||||
catch
|
||||
catch (Exception e)
|
||||
{
|
||||
Log("Backup file is locked. Please close the WeMod and try again.", ELogType.Error);
|
||||
Log($"Failed to restore backup: {e.Message}", ELogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
File.Copy(backupPath, Path.Combine(WeModInfo.RootDirectory, "resources", "app.asar"), true);
|
||||
File.Delete(backupPath);
|
||||
Log("Backup restored successfully.", ELogType.Success);
|
||||
AlreadyPatched = false;
|
||||
IsPatchEnabled = true;
|
||||
@@ -185,36 +182,6 @@ namespace WandEnhancer.View.MainWindow
|
||||
});
|
||||
}
|
||||
|
||||
private async void OnUpdate(object param)
|
||||
{
|
||||
var updateInfo = await _updater.GetUpdateInfoAsync();
|
||||
if (updateInfo == null)
|
||||
{
|
||||
Log("No update details are available right now.", ELogType.Warn);
|
||||
return;
|
||||
}
|
||||
|
||||
MainWindow.Instance.OpenPopup(new UpdatePopup(Constants.Version.ToString(), updateInfo.Version,
|
||||
updateInfo.LatestNotes, () =>
|
||||
{
|
||||
MainWindow.Instance.ClosePopup();
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _updater.Update();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log($"Failed to update: {e.Message}", ELogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
Log("WandEnhancer updated successfully. Restarting...", ELogType.Success);
|
||||
});
|
||||
}, () => _updater.GetFullChangelogAsync()), Application.Current.FindResource("up_popup_title") as string);
|
||||
}
|
||||
|
||||
private void OnOpenSettings(object param)
|
||||
{
|
||||
MainWindow.Instance.OpenPopup(new SettingsPopup(), Application.Current.FindResource("settings_title") as string);
|
||||
@@ -280,16 +247,10 @@ namespace WandEnhancer.View.MainWindow
|
||||
|
||||
public MainWindowVm(MainWindow view)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
var isUpdateAvailable = await _updater.CheckForUpdates();
|
||||
Application.Current.Dispatcher.Invoke(() => IsUpdateAvailable = isUpdateAvailable);
|
||||
});
|
||||
_view = view;
|
||||
SetFolderPathCommand = new RelayCommand(OnFolderPathSelection);
|
||||
ApplyPatchCommand = new RelayCommand(OnPatching);
|
||||
RestoreBackupCommand = new RelayCommand(OnBackupRestoring);
|
||||
UpdateCommand = new RelayCommand(OnUpdate);
|
||||
OpenSettingsCommand = new RelayCommand(OnOpenSettings);
|
||||
CopyLogsCommand = new RelayCommand(OnCopyLogs);
|
||||
ExportLogsCommand = new RelayCommand(OnExportLogs);
|
||||
@@ -301,4 +262,4 @@ namespace WandEnhancer.View.MainWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
<UserControl x:Class="WandEnhancer.View.Popups.UpdatePopup"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
mc:Ignorable="d"
|
||||
d:DesignHeight="Auto" d:DesignWidth="Auto"
|
||||
Background="{DynamicResource Background}"
|
||||
Foreground="{DynamicResource MutedForeground}"
|
||||
FontWeight="Medium"
|
||||
FontSize="13">
|
||||
<Grid MinWidth="500" MaxWidth="620">
|
||||
<Grid.Resources>
|
||||
<Style x:Key="UpdateActionButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Background" Value="{DynamicResource Primary}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Primary}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryForeground}" />
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource Primary}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Primary}" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Grid.Resources>
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Grid.Row="0" HorizontalAlignment="Left" MaxWidth="560"
|
||||
Padding="9 4" CornerRadius="4"
|
||||
Background="{DynamicResource Muted}">
|
||||
<DockPanel LastChildFill="True">
|
||||
<Ellipse Width="7" Height="7" Fill="{DynamicResource Destructive}"
|
||||
Margin="0 0 8 0" VerticalAlignment="Center" />
|
||||
<TextBlock Foreground="{DynamicResource Foreground}" FontSize="11.5"
|
||||
Text="{DynamicResource up_warning}" TextWrapping="Wrap" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<WrapPanel Grid.Row="1" Margin="0 10 0 0" Orientation="Horizontal">
|
||||
<Border Padding="8 4" Margin="0 0 8 0" CornerRadius="4"
|
||||
Background="{DynamicResource Muted}" BorderBrush="{DynamicResource Border}" BorderThickness="1">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Margin="0 0 7 0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MutedForeground}" FontSize="10.5"
|
||||
Text="{DynamicResource up_current_version}" />
|
||||
<TextBlock x:Name="CurrentVersionValue" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource Foreground}" FontSize="12.5" FontWeight="Bold" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="8 4" CornerRadius="4"
|
||||
Background="{DynamicResource Primary}" BorderThickness="1">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Margin="0 0 7 0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource PrimaryForeground}" FontSize="10.5"
|
||||
Text="{DynamicResource up_latest_version}" />
|
||||
<TextBlock x:Name="LatestVersionValue" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource PrimaryForeground}" FontSize="12.5" FontWeight="Bold" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</WrapPanel>
|
||||
|
||||
<TextBlock Grid.Row="2" Margin="0 12 0 6" Foreground="{DynamicResource Foreground}"
|
||||
FontSize="14" FontWeight="Bold" Text="{DynamicResource up_release_notes}" />
|
||||
|
||||
<Border Grid.Row="3"
|
||||
Background="{DynamicResource Muted}" BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="4">
|
||||
<ScrollViewer x:Name="NotesScrollViewer"
|
||||
VerticalScrollBarVisibility="Hidden"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
CanContentScroll="False">
|
||||
<TextBox x:Name="NotesTextBlock" Background="Transparent"
|
||||
BorderBrush="Transparent"
|
||||
Padding="12"
|
||||
BorderThickness="0" IsReadOnly="True"
|
||||
Foreground="{DynamicResource Foreground}"
|
||||
TextWrapping="Wrap" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="4" Margin="0 12 0 0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Button x:Name="ShowMoreButton" Grid.Column="1" Margin="0 0 10 0"
|
||||
Padding="12 5" Content="{DynamicResource up_show_more}"
|
||||
Click="OnShowMoreClick" />
|
||||
|
||||
<Button Grid.Column="2" Padding="18 5" Style="{StaticResource UpdateActionButton}"
|
||||
Content="{DynamicResource up_update_now}"
|
||||
Click="OnUpdateClick" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -1,95 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace WandEnhancer.View.Popups
|
||||
{
|
||||
public partial class UpdatePopup : UserControl
|
||||
{
|
||||
private readonly Action _onUpdate;
|
||||
private readonly Func<Task<string>> _loadFullChangelog;
|
||||
private readonly string _latestNotes;
|
||||
private string _fullChangelog;
|
||||
private bool _showingFullChangelog;
|
||||
|
||||
public UpdatePopup(string currentVersion, string latestVersion, string latestNotes, Action onUpdate,
|
||||
Func<Task<string>> loadFullChangelog)
|
||||
{
|
||||
_onUpdate = onUpdate;
|
||||
_loadFullChangelog = loadFullChangelog;
|
||||
InitializeComponent();
|
||||
|
||||
CurrentVersionValue.Text = currentVersion;
|
||||
LatestVersionValue.Text = latestVersion;
|
||||
_latestNotes = string.IsNullOrWhiteSpace(latestNotes)
|
||||
? GetResourceText("up_release_notes_unavailable")
|
||||
: latestNotes;
|
||||
|
||||
SetNotesText(_latestNotes);
|
||||
ShowMoreButton.Visibility = loadFullChangelog == null ? Visibility.Collapsed : Visibility.Visible;
|
||||
}
|
||||
|
||||
private void OnUpdateClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_onUpdate();
|
||||
}
|
||||
|
||||
private async void OnShowMoreClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_loadFullChangelog == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_showingFullChangelog)
|
||||
{
|
||||
SetNotesText(_latestNotes);
|
||||
ShowMoreButton.Content = GetResourceText("up_show_more");
|
||||
_showingFullChangelog = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_fullChangelog))
|
||||
{
|
||||
ShowMoreButton.IsEnabled = false;
|
||||
ShowMoreButton.Content = GetResourceText("up_loading_changelog");
|
||||
|
||||
try
|
||||
{
|
||||
_fullChangelog = await _loadFullChangelog();
|
||||
}
|
||||
finally
|
||||
{
|
||||
ShowMoreButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_fullChangelog))
|
||||
{
|
||||
ShowMoreButton.Content = GetResourceText("up_show_more");
|
||||
SetNotesText(string.Concat(
|
||||
_latestNotes,
|
||||
Environment.NewLine,
|
||||
Environment.NewLine,
|
||||
GetResourceText("up_changelog_failed")));
|
||||
return;
|
||||
}
|
||||
|
||||
SetNotesText(_fullChangelog);
|
||||
ShowMoreButton.Content = GetResourceText("up_show_less");
|
||||
_showingFullChangelog = true;
|
||||
}
|
||||
|
||||
private void SetNotesText(string text)
|
||||
{
|
||||
NotesTextBlock.Text = text ?? string.Empty;
|
||||
NotesScrollViewer.ScrollToTop();
|
||||
}
|
||||
|
||||
private static string GetResourceText(string key)
|
||||
{
|
||||
return Application.Current.TryFindResource(key) as string ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,6 @@
|
||||
<Reference Include="System.Data" />
|
||||
<Reference Include="System.Windows.Forms" />
|
||||
<Reference Include="System.Xml" />
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.Xaml">
|
||||
<RequiredTargetFramework>4.0</RequiredTargetFramework>
|
||||
</Reference>
|
||||
@@ -84,7 +83,6 @@
|
||||
<Compile Include="ReactiveUICore\RelayCommand.cs" />
|
||||
<Compile Include="Utils\Common.cs" />
|
||||
<Compile Include="Utils\Extensions.cs" />
|
||||
<Compile Include="Utils\Updater.cs" />
|
||||
<Compile Include="Utils\Win32\Shortcut.cs" />
|
||||
<Compile Include="View\Controls\InfoItem.xaml.cs">
|
||||
<DependentUpon>InfoItem.xaml</DependentUpon>
|
||||
@@ -103,9 +101,6 @@
|
||||
<DependentUpon>App.xaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Include="View\Popups\UpdatePopup.xaml.cs">
|
||||
<DependentUpon>UpdatePopup.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Page Include="Locale\lang.en-US.xaml" />
|
||||
<Page Include="Locale\lang.zh-CN.xaml" />
|
||||
<Page Include="Locale\lang.de-DE.xaml" />
|
||||
@@ -126,7 +121,6 @@
|
||||
<Page Include="View\MainWindow\MainWindow.xaml" />
|
||||
<Page Include="View\Popups\PatchVectorsPopup.xaml" />
|
||||
<Page Include="View\Popups\SettingsPopup.xaml" />
|
||||
<Page Include="View\Popups\UpdatePopup.xaml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Properties\AssemblyInfo.cs">
|
||||
@@ -203,4 +197,4 @@
|
||||
<Exec Command=""$(ILRepackExe)" /allowMultiple /copyattrs /out:"$(OutputPath)$(AssemblyName).exe" "$(MainAssembly)" $(DllList)" />
|
||||
<Delete Files="@(AssemblyList)" ContinueOnError="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
Binary file not shown.
@@ -23,40 +23,24 @@ function Resolve-CommandPath {
|
||||
return $command.Source
|
||||
}
|
||||
|
||||
function Resolve-NuGetPath {
|
||||
$nugetCommand = Get-Command 'nuget.exe' -ErrorAction SilentlyContinue
|
||||
if (-not $nugetCommand) {
|
||||
$nugetCommand = Get-Command 'nuget' -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($nugetCommand) {
|
||||
return $nugetCommand.Source
|
||||
}
|
||||
|
||||
$toolsDir = Join-Path $repoRoot '.tmp/tools'
|
||||
$nugetPath = Join-Path $toolsDir 'nuget.exe'
|
||||
if (-not (Test-Path $nugetPath)) {
|
||||
New-Item -ItemType Directory -Path $toolsDir -Force | Out-Null
|
||||
Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetPath
|
||||
}
|
||||
|
||||
return $nugetPath
|
||||
}
|
||||
|
||||
function Resolve-MSBuildPath {
|
||||
function Resolve-VisualStudioPath {
|
||||
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
|
||||
if (-not (Test-Path $vswhere)) {
|
||||
throw "vswhere.exe not found: $vswhere"
|
||||
}
|
||||
|
||||
# No version pin: pick whatever VS the host has (2022/2026/newer) so CI
|
||||
# keeps working when the runner image bumps its Visual Studio major.
|
||||
$installationPath = & $vswhere -latest -prerelease -products '*' -requires Microsoft.Component.MSBuild -property installationPath
|
||||
if ([string]::IsNullOrWhiteSpace($installationPath)) {
|
||||
throw 'Visual Studio with MSBuild was not found.'
|
||||
}
|
||||
|
||||
$msbuildPath = Join-Path $installationPath 'MSBuild\Current\Bin\MSBuild.exe'
|
||||
return $installationPath
|
||||
}
|
||||
|
||||
function Resolve-MSBuildPath {
|
||||
param([string]$VisualStudioPath)
|
||||
|
||||
$msbuildPath = Join-Path $VisualStudioPath 'MSBuild\Current\Bin\MSBuild.exe'
|
||||
if (-not (Test-Path $msbuildPath)) {
|
||||
throw "MSBuild.exe not found: $msbuildPath"
|
||||
}
|
||||
@@ -64,6 +48,23 @@ function Resolve-MSBuildPath {
|
||||
return $msbuildPath
|
||||
}
|
||||
|
||||
function Resolve-DumpBinPath {
|
||||
param([string]$VisualStudioPath)
|
||||
|
||||
$versionFile = Join-Path $VisualStudioPath 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt'
|
||||
if (-not (Test-Path $versionFile)) {
|
||||
throw "MSVC tools version file not found: $versionFile"
|
||||
}
|
||||
|
||||
$toolsVersion = (Get-Content $versionFile -Raw).Trim()
|
||||
$dumpBinPath = Join-Path $VisualStudioPath "VC\Tools\MSVC\$toolsVersion\bin\Hostx64\x64\dumpbin.exe"
|
||||
if (-not (Test-Path $dumpBinPath)) {
|
||||
throw "dumpbin.exe not found: $dumpBinPath"
|
||||
}
|
||||
|
||||
return $dumpBinPath
|
||||
}
|
||||
|
||||
function Invoke-Step {
|
||||
param(
|
||||
[string]$Label,
|
||||
@@ -78,9 +79,10 @@ function Invoke-Step {
|
||||
}
|
||||
|
||||
$cmake = Resolve-CommandPath 'cmake'
|
||||
$nuget = Resolve-NuGetPath
|
||||
$pnpm = Resolve-CommandPath 'pnpm'
|
||||
$msbuild = Resolve-MSBuildPath
|
||||
$visualStudio = Resolve-VisualStudioPath
|
||||
$msbuild = Resolve-MSBuildPath $visualStudio
|
||||
$dumpBin = Resolve-DumpBinPath $visualStudio
|
||||
|
||||
Invoke-Step 'Install web-panel dependencies' {
|
||||
& $pnpm --dir $webPanelDir install --frozen-lockfile
|
||||
@@ -91,10 +93,6 @@ Invoke-Step 'Build web-panel' {
|
||||
}
|
||||
|
||||
Invoke-Step 'Configure asar-fuses-bypass' {
|
||||
# Let CMake choose its default Visual Studio generator (matches the host VS),
|
||||
# avoiding a hardcoded/derived name that breaks when the runner bumps VS.
|
||||
# Clearing CMAKE_GENERATOR ensures the default isn't overridden to a non-VS
|
||||
# generator that would reject the -A architecture flag.
|
||||
Remove-Item Env:CMAKE_GENERATOR -ErrorAction SilentlyContinue
|
||||
& $cmake -S $asarFusesSourceDir -B $asarFusesBuildDir -A x64
|
||||
}
|
||||
@@ -103,53 +101,21 @@ Invoke-Step 'Build asar-fuses-bypass' {
|
||||
& $cmake --build $asarFusesBuildDir --config $Configuration
|
||||
}
|
||||
|
||||
Invoke-Step 'Verify native runtime dependencies' {
|
||||
$nativeDll = Join-Path $asarFusesBuildDir "$Configuration\version.dll"
|
||||
$dependencies = & $dumpBin /dependents $nativeDll
|
||||
if ($dependencies -match '(?im)^\s*(VCRUNTIME|MSVCP|api-ms-win-crt-)[^\s]*\.dll\s*$') {
|
||||
throw 'version.dll depends on the dynamic Visual C++ runtime.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Step 'Restore NuGet packages' {
|
||||
& $nuget restore $solutionPath -NonInteractive
|
||||
& $msbuild $solutionPath /m /t:Restore /p:RestorePackagesConfig=true
|
||||
}
|
||||
|
||||
Invoke-Step 'Build solution' {
|
||||
& $msbuild $solutionPath /m /p:Configuration=$Configuration '/p:Platform=Any CPU' /t:Build
|
||||
}
|
||||
|
||||
# Code-sign the release executable. A self-signed signature notably lowers
|
||||
# false-positive AV/VirusTotal detections. The cert is reused across builds and
|
||||
# generated on first use, so no secrets or env configuration are required.
|
||||
if ($Configuration -eq 'Release') {
|
||||
Write-Host '==> Sign WandEnhancer.exe' -ForegroundColor Cyan
|
||||
|
||||
$exePath = Join-Path $repoRoot "WandEnhancer/bin/$Configuration/WandEnhancer.exe"
|
||||
if (-not (Test-Path $exePath)) {
|
||||
throw "Executable not found for signing: $exePath"
|
||||
}
|
||||
|
||||
$signingSubject = 'CN=Wand-Enhancer'
|
||||
$cert = Get-ChildItem Cert:\CurrentUser\My |
|
||||
Where-Object { $_.Subject -eq $signingSubject -and $_.HasPrivateKey } |
|
||||
Select-Object -First 1
|
||||
if (-not $cert) {
|
||||
$cert = New-SelfSignedCertificate `
|
||||
-Subject $signingSubject `
|
||||
-Type CodeSigningCert `
|
||||
-CertStoreLocation Cert:\CurrentUser\My `
|
||||
-KeyExportPolicy Exportable `
|
||||
-KeyUsage DigitalSignature `
|
||||
-KeyAlgorithm RSA `
|
||||
-KeyLength 2048 `
|
||||
-HashAlgorithm SHA256 `
|
||||
-NotAfter (Get-Date).AddYears(5)
|
||||
Write-Host "Generated self-signed code-signing certificate: $($cert.Subject) [$($cert.Thumbprint)]"
|
||||
}
|
||||
|
||||
$signature = Set-AuthenticodeSignature -FilePath $exePath -Certificate $cert -HashAlgorithm SHA256
|
||||
# A self-signed root is intentionally untrusted, so the status is
|
||||
# 'UnknownError' (untrusted root) even though the signature is embedded.
|
||||
# Only a missing SignerCertificate means signing actually failed.
|
||||
if (-not $signature.SignerCertificate) {
|
||||
throw "Signing failed: $($signature.Status) - $($signature.StatusMessage)"
|
||||
}
|
||||
|
||||
Write-Host "Signed $exePath [$($cert.Thumbprint)] (status: $($signature.Status))"
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Build completed successfully ($Configuration)." -ForegroundColor Green
|
||||
Write-Host "Build completed successfully ($Configuration)." -ForegroundColor Green
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
cmake_policy(SET CMP0091 NEW)
|
||||
project(asar_fuses_bypass C)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
@@ -10,8 +11,11 @@ add_executable(asar_fuses_bypass main.c)
|
||||
set(CMAKE_SHARED_LIBRARY_PREFIX "")
|
||||
set(CMAKE_STATIC_LIBRARY_PREFIX "")
|
||||
|
||||
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||
add_link_options(-static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
add_library(version SHARED library.c library.def fuses.c)
|
||||
|
||||
add_library(version SHARED library.c library.def fuses.c)
|
||||
if(MSVC)
|
||||
set_property(TARGET version PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
elseif(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_link_options(version PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
|
||||
@@ -128,12 +128,14 @@ async function executeRemoteLaunchCommand(state, request) {
|
||||
void syncGameStatus(state, true)
|
||||
return buildCommandResponse(request, true)
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Remote trainer launch failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "launch_failed",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to launch the trainer.",
|
||||
message: "Failed to launch the trainer.",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -171,12 +173,14 @@ async function executeRemoteStopCommand(state, request) {
|
||||
clearTrainerSnapshot(state, REMOTE_STOP_EVENT, true)
|
||||
return buildCommandResponse(request, true)
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Remote trainer stop failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "stop_failed",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to stop the running trainer.",
|
||||
message: "Failed to stop the running trainer.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+3
-15
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
getWebpackRequire,
|
||||
isRecord,
|
||||
} from "./installed-apps-sync/runtime.js"
|
||||
import { resolveQrRenderer as findWandQrRenderer } from "./remote-popup-cleanup/qr-renderer.js"
|
||||
|
||||
;(function installRemotePopupCleanup(WandEnhancer) {
|
||||
if (globalThis.__wandRemotePopupCleanupInstalled) {
|
||||
@@ -91,20 +91,8 @@ import {
|
||||
return qrRenderer
|
||||
}
|
||||
|
||||
const webpackRequire = getWebpackRequire()
|
||||
for (const record of Object.values(webpackRequire?.c || {})) {
|
||||
const exports = record?.exports
|
||||
if (
|
||||
isRecord(exports) &&
|
||||
typeof exports.create === "function" &&
|
||||
typeof exports.mo === "function"
|
||||
) {
|
||||
qrRenderer = exports.mo
|
||||
return qrRenderer
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
qrRenderer = findWandQrRenderer(getWebpackRequire())
|
||||
return qrRenderer
|
||||
}
|
||||
|
||||
const updateLinks = (remoteUrl) => {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isRecord } from "../installed-apps-sync/runtime.js"
|
||||
|
||||
const WAND_QR_RENDERER_EXPORT = "mo"
|
||||
|
||||
export function resolveQrRenderer(webpackRequire) {
|
||||
for (const record of Object.values(webpackRequire?.c || {})) {
|
||||
const exports = record?.exports
|
||||
if (
|
||||
isRecord(exports) &&
|
||||
typeof exports[WAND_QR_RENDERER_EXPORT] === "function"
|
||||
) {
|
||||
return exports[WAND_QR_RENDERER_EXPORT]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Vendored
+16
-10
@@ -1,5 +1,4 @@
|
||||
const {
|
||||
buildInstalledAppsDebugPayload,
|
||||
gameStatusSignature,
|
||||
installedAppsSignature,
|
||||
normalizeGameStatusSnapshot,
|
||||
@@ -20,7 +19,9 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
|
||||
function broadcast(type, payload, requestId = null) {
|
||||
for (const client of clients) {
|
||||
sendJson(client, type, payload, requestId);
|
||||
if (client.handshaken) {
|
||||
sendJson(client, type, payload, requestId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +51,17 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
}
|
||||
}
|
||||
|
||||
function syncTrainerMeta(rawSnapshot) {
|
||||
const localizedSnapshot = normalizeSnapshot(rawSnapshot);
|
||||
const activeTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId;
|
||||
if (!localizedSnapshot || localizedSnapshot.trainerMeta.trainer.trainerId !== activeTrainerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSnapshot.trainerMeta = localizedSnapshot.trainerMeta;
|
||||
broadcast('trainer_meta', currentSnapshot.trainerMeta);
|
||||
}
|
||||
|
||||
function valueChanged(change) {
|
||||
if (!currentSnapshot || !isRecord(change)) return;
|
||||
const target = safeString(change.target);
|
||||
@@ -97,7 +109,6 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
}
|
||||
|
||||
function buildHealthPayload() {
|
||||
const installedAppsDebug = buildInstalledAppsDebugPayload(currentInstalledApps);
|
||||
const serverInfo = getServerInfo();
|
||||
return {
|
||||
ok: serverInfo.listening,
|
||||
@@ -105,12 +116,7 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
gameSessionState: currentGameStatus?.session?.state || 'idle',
|
||||
gameSessionEvent: currentGameStatus?.session?.event || 'snapshot',
|
||||
runningTrainerId: currentGameStatus?.trainer?.trainerId || null,
|
||||
installedAppsCount: installedAppsDebug.counts.myGamesEntries,
|
||||
installedRawAppsCount: installedAppsDebug.counts.rawInstallEntries,
|
||||
installedTitlesCount: installedAppsDebug.counts.groupedTitles,
|
||||
installedUniqueTitleIdsCount: installedAppsDebug.counts.uniqueTitleIds,
|
||||
installedUniqueGameIdsCount: installedAppsDebug.counts.uniqueGameIds,
|
||||
installedAppsApiPath: serverInfo.installedAppsApiPath,
|
||||
installedAppsCount: currentInstalledApps?.apps?.length ?? 0,
|
||||
remoteUrl: serverInfo.remoteUrl,
|
||||
advertisedUrls: serverInfo.advertisedUrls,
|
||||
};
|
||||
@@ -127,10 +133,10 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
return {
|
||||
get snapshot() { return currentSnapshot; },
|
||||
buildHealthPayload,
|
||||
buildInstalledAppsDebugPayload: () => buildInstalledAppsDebugPayload(currentInstalledApps),
|
||||
clear,
|
||||
sendSnapshot,
|
||||
sync,
|
||||
syncTrainerMeta,
|
||||
syncGameStatus,
|
||||
syncInstalledApps,
|
||||
valueChanged,
|
||||
|
||||
Vendored
+2
-1
@@ -27,8 +27,10 @@ module.exports = {
|
||||
BRIDGE_SERVER_VERSION: WEB_CONTRACT.serverVersion,
|
||||
DEFAULT_REMOTE_HOST: WEB_CONTRACT.defaultRemoteHost,
|
||||
DEFAULT_REMOTE_PORT: WEB_CONTRACT.defaultRemotePort,
|
||||
DEV_SERVER_PORTS: Object.freeze(WEB_CONTRACT.devServerPorts.map(String)),
|
||||
IPC_CHANNEL,
|
||||
KNOWN_CHEAT_TYPES,
|
||||
MAX_WS_FRAME_BYTES: 1024 * 1024,
|
||||
PORT_SCAN_RANGE: WEB_CONTRACT.portScanRange,
|
||||
REMOTE_ASSETS_PREFIX: WEB_CONTRACT.assetsPath,
|
||||
REMOTE_BASE_PATH: WEB_CONTRACT.basePath,
|
||||
@@ -37,7 +39,6 @@ module.exports = {
|
||||
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS: 15000,
|
||||
REMOTE_GAME_STATUS_CHANNEL: IPC_CHANNEL.GAME_STATUS,
|
||||
REMOTE_HEALTH_PATH: WEB_CONTRACT.healthPath,
|
||||
REMOTE_INSTALLED_APPS_API_PATH: WEB_CONTRACT.installedAppsPath,
|
||||
REMOTE_INSTALLED_APPS_CHANNEL: IPC_CHANNEL.INSTALLED_APPS,
|
||||
REMOTE_WS_PATH: WEB_CONTRACT.webSocketPath,
|
||||
RENDERER_INJECTION_DELAYS_MS: Object.freeze([500, 2000]),
|
||||
|
||||
+10
-117
@@ -88,7 +88,16 @@ function normalizeCheat(cheat, index) {
|
||||
|
||||
function normalizeImageUrl(...values) {
|
||||
const value = firstString(...values);
|
||||
return value || null;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRawInstalledApps(rawSnapshot) {
|
||||
@@ -119,10 +128,6 @@ function normalizeInstalledApp(app) {
|
||||
}
|
||||
|
||||
const location = typeof app.location === 'string' ? app.location : '';
|
||||
const alternateLocations = Array.isArray(app.alternateLocations)
|
||||
? app.alternateLocations.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim())
|
||||
: [];
|
||||
|
||||
return {
|
||||
platform,
|
||||
sku,
|
||||
@@ -137,8 +142,6 @@ function normalizeInstalledApp(app) {
|
||||
),
|
||||
gameId: toStringId(app.gameId),
|
||||
titleId: toStringId(app.titleId),
|
||||
location,
|
||||
alternateLocations,
|
||||
imageUrl: normalizeImageUrl(app.imageUrl, app.iconUrl, app.coverUrl, app.thumbnailUrl, app.logoUrl, app.headerImageUrl),
|
||||
platformLastPlayedTimestamp: typeof app.platformLastPlayedTimestamp === 'number' ? app.platformLastPlayedTimestamp : null,
|
||||
platformTotalPlaytimeMinutes: typeof app.platformTotalPlaytimeMinutes === 'number' ? app.platformTotalPlaytimeMinutes : null,
|
||||
@@ -152,19 +155,13 @@ function normalizeInstalledAppsSnapshot(rawSnapshot) {
|
||||
}
|
||||
|
||||
const apps = rawApps.map(normalizeInstalledApp).filter(Boolean).sort(compareInstalledApps);
|
||||
const diagnostics = isRecord(rawSnapshot) && isRecord(rawSnapshot.diagnostics)
|
||||
? cloneValue(rawSnapshot.diagnostics)
|
||||
: null;
|
||||
|
||||
return {
|
||||
instanceId: isRecord(rawSnapshot) ? safeString(rawSnapshot.instanceId, 'wand-installed-apps') : 'wand-installed-apps',
|
||||
updatedAt: isRecord(rawSnapshot) && typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
|
||||
apps,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function summarizeInstalledAppsSource(rawSnapshot) {
|
||||
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) {
|
||||
return '';
|
||||
@@ -189,7 +186,6 @@ function installedAppsSignature(snapshot) {
|
||||
app.displayName,
|
||||
app.gameId || '',
|
||||
app.titleId || '',
|
||||
app.location,
|
||||
app.imageUrl || '',
|
||||
app.platformLastPlayedTimestamp || '',
|
||||
app.platformTotalPlaytimeMinutes || '',
|
||||
@@ -197,95 +193,6 @@ function installedAppsSignature(snapshot) {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildInstalledAppsDebugPayload(snapshot) {
|
||||
if (!snapshot) {
|
||||
return {
|
||||
ok: false,
|
||||
instanceId: null,
|
||||
updatedAt: null,
|
||||
counts: {
|
||||
myGamesEntries: 0,
|
||||
rawInstallEntries: 0,
|
||||
groupedTitles: 0,
|
||||
uniqueTitleIds: 0,
|
||||
uniqueGameIds: 0,
|
||||
},
|
||||
diagnostics: null,
|
||||
byPlatform: {},
|
||||
titles: [],
|
||||
apps: [],
|
||||
};
|
||||
}
|
||||
|
||||
const diagnostics = isRecord(snapshot.diagnostics) ? snapshot.diagnostics : null;
|
||||
const byPlatform = {};
|
||||
const uniqueTitleIds = new Set();
|
||||
const uniqueGameIds = new Set();
|
||||
const titleGroups = new Map();
|
||||
|
||||
for (const app of snapshot.apps) {
|
||||
byPlatform[app.platform] = (byPlatform[app.platform] || 0) + 1;
|
||||
|
||||
if (app.titleId) {
|
||||
uniqueTitleIds.add(app.titleId);
|
||||
}
|
||||
|
||||
if (app.gameId) {
|
||||
uniqueGameIds.add(app.gameId);
|
||||
}
|
||||
|
||||
const groupKey = resolveInstalledAppGroupKey(app);
|
||||
let group = titleGroups.get(groupKey);
|
||||
if (!group) {
|
||||
group = {
|
||||
key: groupKey,
|
||||
titleId: app.titleId,
|
||||
displayName: app.displayName,
|
||||
gameIds: new Set(),
|
||||
platforms: new Set(),
|
||||
apps: [],
|
||||
};
|
||||
titleGroups.set(groupKey, group);
|
||||
}
|
||||
|
||||
if (app.gameId) {
|
||||
group.gameIds.add(app.gameId);
|
||||
}
|
||||
|
||||
group.platforms.add(app.platform);
|
||||
group.apps.push(app);
|
||||
}
|
||||
|
||||
const titles = Array.from(titleGroups.values())
|
||||
.map((group) => ({
|
||||
key: group.key,
|
||||
titleId: group.titleId,
|
||||
displayName: group.displayName,
|
||||
gameIds: Array.from(group.gameIds).sort(),
|
||||
platforms: Array.from(group.platforms).sort(),
|
||||
appEntries: group.apps.length,
|
||||
apps: group.apps,
|
||||
}))
|
||||
.sort((left, right) => left.displayName.localeCompare(right.displayName));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
instanceId: snapshot.instanceId,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
counts: {
|
||||
myGamesEntries: snapshot.apps.length,
|
||||
rawInstallEntries: typeof diagnostics?.rawInstalledApps === 'number' ? diagnostics.rawInstalledApps : snapshot.apps.length,
|
||||
groupedTitles: titles.length,
|
||||
uniqueTitleIds: uniqueTitleIds.size,
|
||||
uniqueGameIds: uniqueGameIds.size,
|
||||
},
|
||||
diagnostics,
|
||||
byPlatform,
|
||||
titles,
|
||||
apps: snapshot.apps,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSnapshot(rawSnapshot) {
|
||||
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.metadata) || !isRecord(rawSnapshot.metadata.info)) {
|
||||
return null;
|
||||
@@ -320,7 +227,6 @@ function normalizeSnapshot(rawSnapshot) {
|
||||
const trainerMeta = {
|
||||
session: {
|
||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
|
||||
accessToken: safeString(rawSnapshot.accessToken),
|
||||
},
|
||||
trainer: {
|
||||
trainerId,
|
||||
@@ -372,20 +278,7 @@ function compareInstalledApps(left, right) {
|
||||
return left.sku.localeCompare(right.sku);
|
||||
}
|
||||
|
||||
function resolveInstalledAppGroupKey(app) {
|
||||
if (app.titleId) {
|
||||
return `title:${app.titleId}`;
|
||||
}
|
||||
|
||||
if (app.gameId) {
|
||||
return `game:${app.gameId}`;
|
||||
}
|
||||
|
||||
return `app:${app.correlationId}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildInstalledAppsDebugPayload,
|
||||
gameStatusSignature,
|
||||
installedAppsSignature,
|
||||
normalizeGameStatusSnapshot,
|
||||
|
||||
+12
@@ -5,6 +5,7 @@ import {
|
||||
getSteamClientIconUrl,
|
||||
normalizeImageUrl,
|
||||
} from '../scripts/default/installed-apps-sync/artwork.js';
|
||||
import { resolveQrRenderer } from '../scripts/default/remote-popup-cleanup/qr-renderer.js';
|
||||
|
||||
describe('installed-apps renderer script models', () => {
|
||||
it('normalizes captured artwork shapes without a Wand runtime', () => {
|
||||
@@ -28,4 +29,15 @@ describe('installed-apps renderer script models', () => {
|
||||
expect(getSteamClientIconUrl(findSteamAppId(fixture)))
|
||||
.toBe('https://api-cdn.wemod.com/steam_community/1245620/client_icon/96.webp');
|
||||
});
|
||||
|
||||
it('resolves the tree-shaken Wand QR renderer without a create export', () => {
|
||||
const renderer = () => undefined;
|
||||
const webpackRequire = {
|
||||
c: {
|
||||
qrCode: { exports: { mo: renderer } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveQrRenderer(webpackRequire)).toBe(renderer);
|
||||
});
|
||||
});
|
||||
|
||||
+142
-3
@@ -1,4 +1,4 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { connect, createServer } from 'node:net';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WebSocket as NodeWebSocket } from 'ws';
|
||||
|
||||
@@ -10,12 +10,80 @@ describe('production bridge runtime', () => {
|
||||
const port = await getFreePort();
|
||||
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
|
||||
runtime.sync(rawTrainerSnapshot());
|
||||
runtime.syncInstalledApps({
|
||||
apps: [{
|
||||
platform: 'steam',
|
||||
sku: '123',
|
||||
displayName: 'Game',
|
||||
location: 'C:\\private\\Game',
|
||||
alternateLocations: ['D:\\also-private\\Game'],
|
||||
}],
|
||||
});
|
||||
|
||||
try {
|
||||
await waitUntil(() => runtime.listening);
|
||||
const messages = await connectAndCollect(port, 3);
|
||||
expect(messages.map((message) => message.type)).toEqual(['hello_ack', 'trainer_meta', 'trainer_values']);
|
||||
const messages = await connectAndCollect(port, 4);
|
||||
expect(messages.map((message) => message.type)).toEqual(['hello_ack', 'trainer_meta', 'trainer_values', 'installed_apps']);
|
||||
expect(messages[2].payload.values.god).toBe(true);
|
||||
expect(JSON.stringify(messages)).not.toContain('wand-secret');
|
||||
expect(JSON.stringify(messages)).not.toContain('private');
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a browser WebSocket from a different origin', async () => {
|
||||
const bridge = require('../../dist/bridge.cjs');
|
||||
const port = await getFreePort();
|
||||
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
|
||||
|
||||
try {
|
||||
await waitUntil(() => runtime.listening);
|
||||
await expectUpgradeStatus(port, 403);
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows the local Vite panel to connect to the loopback bridge', async () => {
|
||||
const bridge = require('../../dist/bridge.cjs');
|
||||
const port = await getFreePort();
|
||||
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
|
||||
|
||||
try {
|
||||
await waitUntil(() => runtime.listening);
|
||||
await expectUpgradeAccepted(port, 'http://127.0.0.1:4173');
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes an oversized WebSocket frame without buffering its payload', async () => {
|
||||
const bridge = require('../../dist/bridge.cjs');
|
||||
const port = await getFreePort();
|
||||
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
|
||||
|
||||
try {
|
||||
await waitUntil(() => runtime.listening);
|
||||
await expectSocketClose(port, Buffer.alloc(1024 * 1024 + 1), 1009);
|
||||
await expectDeclaredHugeFrameClose(port, 1009);
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not trust the HTTP Host header as a URL base', async () => {
|
||||
const bridge = require('../../dist/bridge.cjs');
|
||||
const port = await getFreePort();
|
||||
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
|
||||
|
||||
try {
|
||||
await waitUntil(() => runtime.listening);
|
||||
const malformedHost = await sendRawHttp(port, 'GET /remote/api/health HTTP/1.1\r\nHost: [\r\nConnection: close\r\n\r\n');
|
||||
expect(malformedHost).toContain('HTTP/1.1 200 OK');
|
||||
|
||||
const malformedTarget = await sendRawHttp(port, 'GET //[ HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
|
||||
expect(malformedTarget).toContain('HTTP/1.1 400 Bad Request');
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
@@ -59,6 +127,76 @@ async function connectAndCollect(port: number, count: number): Promise<any[]> {
|
||||
});
|
||||
}
|
||||
|
||||
async function expectUpgradeStatus(port: number, expectedStatus: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`, {
|
||||
headers: { Origin: 'https://example.com' },
|
||||
});
|
||||
socket.once('open', () => reject(new Error('Cross-origin WebSocket was accepted.')));
|
||||
socket.once('error', () => undefined);
|
||||
socket.once('unexpected-response', (_request, response) => {
|
||||
response.resume();
|
||||
if (response.statusCode === expectedStatus) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Expected HTTP ${expectedStatus}, got ${response.statusCode}.`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function expectUpgradeAccepted(port: number, origin: string): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`, {
|
||||
headers: { Origin: origin },
|
||||
});
|
||||
socket.once('open', () => {
|
||||
socket.close();
|
||||
resolve();
|
||||
});
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSocketClose(port: number, payload: Buffer, expectedCode: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`);
|
||||
socket.once('open', () => socket.send(payload));
|
||||
socket.once('close', (code) => code === expectedCode
|
||||
? resolve()
|
||||
: reject(new Error(`Expected close code ${expectedCode}, got ${code}.`)));
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectDeclaredHugeFrameClose(port: number, expectedCode: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`);
|
||||
socket.once('open', () => {
|
||||
const header = Buffer.alloc(10);
|
||||
header[0] = 0x81;
|
||||
header[1] = 0xff;
|
||||
header.writeUInt32BE(1, 2);
|
||||
(socket as any)._socket.write(header);
|
||||
});
|
||||
socket.once('close', (code) => code === expectedCode
|
||||
? resolve()
|
||||
: reject(new Error(`Expected close code ${expectedCode}, got ${code}.`)));
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function sendRawHttp(port: number, request: string): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const socket = connect(port, '127.0.0.1');
|
||||
socket.once('connect', () => socket.end(request));
|
||||
socket.on('data', (chunk) => chunks.push(chunk));
|
||||
socket.once('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean): Promise<void> {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!predicate()) {
|
||||
@@ -70,6 +208,7 @@ async function waitUntil(predicate: () => boolean): Promise<void> {
|
||||
function rawTrainerSnapshot() {
|
||||
return {
|
||||
instanceId: 'instance',
|
||||
accessToken: 'wand-secret',
|
||||
trainerId: 'trainer',
|
||||
trainerInfo: { gameId: 'game', displayName: 'Game' },
|
||||
metadata: {
|
||||
|
||||
Vendored
+84
-17
@@ -6,11 +6,11 @@ const {
|
||||
BRIDGE_SERVER_VERSION,
|
||||
DEFAULT_REMOTE_HOST,
|
||||
DEFAULT_REMOTE_PORT,
|
||||
DEV_SERVER_PORTS,
|
||||
PORT_SCAN_RANGE,
|
||||
REMOTE_ASSETS_PREFIX,
|
||||
REMOTE_BASE_PATH,
|
||||
REMOTE_HEALTH_PATH,
|
||||
REMOTE_INSTALLED_APPS_API_PATH,
|
||||
REMOTE_WS_PATH,
|
||||
WS_OPCODE,
|
||||
} = require('./constants');
|
||||
@@ -23,7 +23,14 @@ const { createBridgeState } = require('./bridge-state');
|
||||
const { validateClientMessage, validateSetValueTarget } = require('./protocol-router');
|
||||
const { getAdvertisedUrls, serveFile } = require('./server-files');
|
||||
const { cloneValue, isValidPort, safeString } = require('./utils');
|
||||
const { closeClient, createAcceptKey, makeFrame, parseFrame, sendJson } = require('./websocket-codec');
|
||||
const {
|
||||
closeClient,
|
||||
createAcceptKey,
|
||||
FRAME_TOO_LARGE_ERROR,
|
||||
makeFrame,
|
||||
parseFrame,
|
||||
sendJson,
|
||||
} = require('./websocket-codec');
|
||||
import type { BridgeOptions } from './types';
|
||||
|
||||
function createBridgeServer(options: BridgeOptions = {}) {
|
||||
@@ -43,7 +50,6 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
log,
|
||||
getServerInfo: () => ({
|
||||
advertisedUrls,
|
||||
installedAppsApiPath: REMOTE_INSTALLED_APPS_API_PATH,
|
||||
listening,
|
||||
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||
}),
|
||||
@@ -64,10 +70,15 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
}
|
||||
|
||||
function handleRequest(request, response) {
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||
const url = parseRequestUrl(request.url);
|
||||
if (!url) {
|
||||
response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.end('Bad Request');
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/' || url.pathname === '') {
|
||||
response.writeHead(302, { Location: '/remote/' });
|
||||
response.writeHead(302, { Location: REMOTE_BASE_PATH });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
@@ -89,12 +100,6 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === REMOTE_INSTALLED_APPS_API_PATH) {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
response.end(JSON.stringify(bridgeState.buildInstalledAppsDebugPayload(), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith(REMOTE_ASSETS_PREFIX)) {
|
||||
serveFile(response, path.join(panelRoot, url.pathname.replace(REMOTE_BASE_PATH, '')));
|
||||
return;
|
||||
@@ -148,11 +153,12 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
const result = await Promise.resolve(commandHandler({ action, gameId, titleId }));
|
||||
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult(result, fallback), message.requestId ?? null);
|
||||
} catch (error) {
|
||||
log('warn', 'Remote command handler failed.', error);
|
||||
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'command_failed',
|
||||
message: error instanceof Error ? error.message : 'Failed to execute the remote command.',
|
||||
message: 'Failed to execute the remote command.',
|
||||
},
|
||||
}, fallback), message.requestId ?? null);
|
||||
}
|
||||
@@ -194,13 +200,14 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
cheatId: typeof message.payload?.cheatId === 'string' ? message.payload.cheatId : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
log('warn', 'Set-value handler failed.', error);
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: false,
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
error: {
|
||||
code: 'set_failed',
|
||||
message: error instanceof Error ? error.message : 'Failed to set trainer value.',
|
||||
message: 'Failed to set trainer value.',
|
||||
},
|
||||
}, message.requestId ?? null);
|
||||
return;
|
||||
@@ -301,6 +308,10 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
await handleClientMessage(client, JSON.parse(frame.payload.toString('utf8')));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === FRAME_TOO_LARGE_ERROR) {
|
||||
closeClient(client, 1009, error.message);
|
||||
return;
|
||||
}
|
||||
sendJson(client, 'error', {
|
||||
code: 'invalid_message',
|
||||
message: error instanceof Error ? error.message : 'Failed to process client message.',
|
||||
@@ -326,15 +337,24 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
}
|
||||
|
||||
function handleUpgrade(request, socket) {
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||
const url = parseRequestUrl(request.url);
|
||||
if (!url) {
|
||||
rejectUpgrade(socket, 400, 'Bad Request');
|
||||
return;
|
||||
}
|
||||
if (url.pathname !== REMOTE_WS_PATH) {
|
||||
socket.destroy();
|
||||
rejectUpgrade(socket, 404, 'Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAllowedWebSocketOrigin(request.headers.origin, request.headers.host)) {
|
||||
rejectUpgrade(socket, 403, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const key = request.headers['sec-websocket-key'];
|
||||
if (typeof key !== 'string' || !key) {
|
||||
socket.destroy();
|
||||
rejectUpgrade(socket, 400, 'Bad Request');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -373,7 +393,7 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
});
|
||||
server.on('listening', () => {
|
||||
listening = true;
|
||||
log('info', `Listening on ${globalThis.__wandRemoteBridgeUrl}`);
|
||||
log('info', `Listening on ${host}:${port}.`);
|
||||
});
|
||||
|
||||
listen(port);
|
||||
@@ -400,12 +420,59 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
setCommandHandler,
|
||||
setHandler,
|
||||
sync: bridgeState.sync,
|
||||
syncTrainerMeta: bridgeState.syncTrainerMeta,
|
||||
syncGameStatus: bridgeState.syncGameStatus,
|
||||
syncInstalledApps: bridgeState.syncInstalledApps,
|
||||
valueChanged: bridgeState.valueChanged,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRequestUrl(requestUrl) {
|
||||
try {
|
||||
return new URL(requestUrl || '/', 'http://localhost');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedWebSocketOrigin(origin, host) {
|
||||
if (origin === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (typeof origin !== 'string' || typeof host !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
const requested = new URL(`http://${host}`);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sameHostname = parsed.hostname.toLowerCase() === requested.hostname.toLowerCase();
|
||||
const compatibleLoopback = isLoopback(parsed.hostname) && isLoopback(requested.hostname);
|
||||
return parsed.host.toLowerCase() === host.toLowerCase()
|
||||
|| DEV_SERVER_PORTS.includes(parsed.port) && (sameHostname || compatibleLoopback);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopback(hostname) {
|
||||
return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(hostname.toLowerCase());
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket, statusCode, statusText) {
|
||||
socket.end([
|
||||
`HTTP/1.1 ${statusCode} ${statusText}`,
|
||||
'Connection: close',
|
||||
'Content-Length: 0',
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeServer,
|
||||
};
|
||||
|
||||
Vendored
+13
-9
@@ -12,11 +12,10 @@ const {
|
||||
const { writeInstallLog } = require('../logger');
|
||||
const { ensureBridge } = require('../runtime');
|
||||
const { installRendererScripts } = require('./renderer-scripts');
|
||||
const { localizeTrainerSnapshot } = require('./trainer-localization');
|
||||
const { safeString } = require('../utils');
|
||||
import type { BridgeOptions, ElectronPort, WebContentsPort } from '../types';
|
||||
|
||||
// Reads the signed-in WeMod access token from the renderer's localStorage so the
|
||||
// panel can request localized cheat metadata from the WeMod API.
|
||||
const WEMOD_ACCESS_TOKEN_SCRIPT =
|
||||
'JSON.parse(localStorage.getItem("infinity:globalStore") || "{}")?.token?.accessToken ?? null';
|
||||
|
||||
@@ -83,8 +82,17 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
|
||||
}
|
||||
|
||||
globalThis.__wandRemoteBridgeIpcInstalled = true;
|
||||
let trainerSnapshotRevision = 0;
|
||||
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (event, snapshot) => {
|
||||
void syncSnapshotWithAccessToken(runtime, event?.sender, snapshot);
|
||||
const revision = ++trainerSnapshotRevision;
|
||||
runtime.sync(snapshot);
|
||||
void localizeSnapshot(event?.sender, snapshot).then((localizedSnapshot) => {
|
||||
if (localizedSnapshot !== snapshot && revision === trainerSnapshotRevision) {
|
||||
runtime.syncTrainerMeta(localizedSnapshot);
|
||||
}
|
||||
}).catch((error) => {
|
||||
writeInstallLog('warn', 'Failed to localize trainer metadata.', error);
|
||||
});
|
||||
return true;
|
||||
});
|
||||
electron.ipcMain.handle(REMOTE_INSTALLED_APPS_CHANNEL, (_event, snapshot) => {
|
||||
@@ -119,13 +127,9 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
|
||||
electron.ipcMain.handle(IPC_CHANNEL.REMOTE_URL, () => runtime.remoteUrl);
|
||||
}
|
||||
|
||||
async function syncSnapshotWithAccessToken(runtime, sender, snapshot) {
|
||||
async function localizeSnapshot(sender, snapshot) {
|
||||
const accessToken = await readWemodAccessToken(sender);
|
||||
if (accessToken && snapshot && typeof snapshot === 'object') {
|
||||
snapshot.accessToken = accessToken;
|
||||
}
|
||||
|
||||
runtime.sync(snapshot);
|
||||
return localizeTrainerSnapshot(snapshot, accessToken);
|
||||
}
|
||||
|
||||
async function readWemodAccessToken(sender) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { EventEmitter } from "node:events"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { localizeTrainerSnapshot } from "./trainer-localization"
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe("trainer localization", () => {
|
||||
it("uses the bearer token only inside the bridge and returns localized metadata", async () => {
|
||||
const snapshot = rawTrainerSnapshot()
|
||||
const loadStrings = vi.fn(async (request) => {
|
||||
expect(request).toMatchObject({
|
||||
accessToken: "wand-secret",
|
||||
gameId: "game",
|
||||
gameVersion: "1.0",
|
||||
language: "de-DE",
|
||||
})
|
||||
return { cheat_name: "Unverwundbar", cheat_description: "Kein Schaden" }
|
||||
})
|
||||
|
||||
const localized = await localizeTrainerSnapshot(
|
||||
snapshot,
|
||||
"wand-secret",
|
||||
loadStrings
|
||||
)
|
||||
|
||||
expect(localized).not.toBe(snapshot)
|
||||
expect(localized.metadata.info.blueprint.cheats[0]).toMatchObject({
|
||||
name: "Unverwundbar",
|
||||
description: "Kein Schaden",
|
||||
})
|
||||
expect(JSON.stringify(localized)).not.toContain("wand-secret")
|
||||
expect(snapshot.metadata.info.blueprint.cheats[0].name).toBe("cheat_name")
|
||||
})
|
||||
|
||||
it("deduplicates in-flight translation requests and caches successful strings", async () => {
|
||||
const https = require("node:https")
|
||||
const get = vi.spyOn(https, "get").mockImplementation((_url, _options, onResponse) => {
|
||||
const request = new EventEmitter() as any
|
||||
request.setTimeout = vi.fn()
|
||||
request.destroy = vi.fn()
|
||||
|
||||
queueMicrotask(() => {
|
||||
const response = new EventEmitter() as any
|
||||
response.statusCode = 200
|
||||
response.resume = vi.fn()
|
||||
response.setEncoding = vi.fn()
|
||||
const respond = onResponse as (response: any) => void
|
||||
respond(response)
|
||||
response.emit("data", JSON.stringify({
|
||||
i18n: { strings: { cheat_name: "Cached name" } },
|
||||
}))
|
||||
response.emit("end")
|
||||
})
|
||||
|
||||
return request
|
||||
})
|
||||
|
||||
const snapshot = {
|
||||
...rawTrainerSnapshot(),
|
||||
trainerInfo: { gameId: "cache-test-game" },
|
||||
}
|
||||
const pending = [
|
||||
localizeTrainerSnapshot(snapshot, "cache-test-token"),
|
||||
localizeTrainerSnapshot(snapshot, "cache-test-token"),
|
||||
]
|
||||
const localized = await Promise.all(pending)
|
||||
const cached = await localizeTrainerSnapshot(snapshot, "cache-test-token")
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(1)
|
||||
expect(localized[0].metadata.info.blueprint.cheats[0].name).toBe("Cached name")
|
||||
expect(cached.metadata.info.blueprint.cheats[0].name).toBe("Cached name")
|
||||
})
|
||||
})
|
||||
|
||||
function rawTrainerSnapshot() {
|
||||
return {
|
||||
instanceId: "instance",
|
||||
trainerId: "trainer",
|
||||
trainerInfo: { gameId: "game" },
|
||||
gameVersion: "1.0",
|
||||
language: "de-DE",
|
||||
metadata: {
|
||||
info: {
|
||||
blueprint: {
|
||||
cheats: [
|
||||
{
|
||||
target: "god",
|
||||
type: "toggle",
|
||||
name: "cheat_name",
|
||||
description: "cheat_description",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
const https = require("node:https")
|
||||
|
||||
const WEMOD_TRAINER_ENDPOINT = "https://api.wemod.com/v3/games"
|
||||
const RESPONSE_LIMIT_BYTES = 2 * 1024 * 1024
|
||||
const REQUEST_TIMEOUT_MS = 5000
|
||||
let cachedRequestKey = ""
|
||||
let cachedStrings: Record<string, string> | null = null
|
||||
let inFlightRequestKey = ""
|
||||
let inFlightRequest: Promise<Record<string, string> | null> | null = null
|
||||
|
||||
export async function localizeTrainerSnapshot(
|
||||
rawSnapshot,
|
||||
accessToken,
|
||||
loadStrings = fetchTrainerStrings
|
||||
) {
|
||||
const request = buildTrainerRequest(rawSnapshot, accessToken)
|
||||
if (!request) {
|
||||
return rawSnapshot
|
||||
}
|
||||
|
||||
let strings
|
||||
try {
|
||||
strings = await loadStrings(request)
|
||||
} catch {
|
||||
return rawSnapshot
|
||||
}
|
||||
if (!strings) {
|
||||
return rawSnapshot
|
||||
}
|
||||
|
||||
const info = rawSnapshot.metadata.info
|
||||
const blueprint = info.blueprint
|
||||
if (!Array.isArray(blueprint?.cheats)) {
|
||||
return rawSnapshot
|
||||
}
|
||||
|
||||
return {
|
||||
...rawSnapshot,
|
||||
metadata: {
|
||||
...rawSnapshot.metadata,
|
||||
info: {
|
||||
...info,
|
||||
blueprint: {
|
||||
...blueprint,
|
||||
cheats: blueprint.cheats.map((cheat) =>
|
||||
localizeCheat(cheat, strings)
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildTrainerRequest(rawSnapshot, accessToken) {
|
||||
if (!accessToken || !rawSnapshot || typeof rawSnapshot !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const gameId = stringValue(
|
||||
rawSnapshot.trainerInfo?.gameId || rawSnapshot.metadata?.info?.gameId
|
||||
)
|
||||
if (!gameId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
gameId,
|
||||
gameVersion: stringValue(rawSnapshot.gameVersion),
|
||||
language: stringValue(rawSnapshot.language),
|
||||
}
|
||||
}
|
||||
|
||||
function fetchTrainerStrings({ accessToken, gameId, gameVersion, language }) {
|
||||
const requestKey = [accessToken, gameId, gameVersion, language].join("\0")
|
||||
if (requestKey === cachedRequestKey) {
|
||||
return Promise.resolve(cachedStrings)
|
||||
}
|
||||
if (requestKey === inFlightRequestKey && inFlightRequest) {
|
||||
return inFlightRequest
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
`${WEMOD_TRAINER_ENDPOINT}/${encodeURIComponent(gameId)}/trainer`
|
||||
)
|
||||
if (gameVersion) url.searchParams.set("gameVersions", gameVersion)
|
||||
if (language) url.searchParams.set("locale", language)
|
||||
|
||||
const request = requestJson(url, accessToken)
|
||||
.then((payload) => normalizeStrings(payload?.i18n?.strings))
|
||||
.then((strings) => {
|
||||
if (strings) {
|
||||
cachedRequestKey = requestKey
|
||||
cachedStrings = strings
|
||||
}
|
||||
return strings
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightRequestKey === requestKey) {
|
||||
inFlightRequestKey = ""
|
||||
inFlightRequest = null
|
||||
}
|
||||
})
|
||||
|
||||
inFlightRequestKey = requestKey
|
||||
inFlightRequest = request
|
||||
return request
|
||||
}
|
||||
|
||||
function requestJson(url, accessToken): Promise<any> {
|
||||
return new Promise<any>((resolve) => {
|
||||
let settled = false
|
||||
const finish = (value) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const request = https.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
response.resume()
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
|
||||
let body = ""
|
||||
let receivedBytes = 0
|
||||
response.setEncoding("utf8")
|
||||
response.on("data", (chunk) => {
|
||||
receivedBytes += Buffer.byteLength(chunk)
|
||||
if (receivedBytes > RESPONSE_LIMIT_BYTES) {
|
||||
request.destroy()
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
body += chunk
|
||||
})
|
||||
response.on("end", () => {
|
||||
try {
|
||||
finish(JSON.parse(body))
|
||||
} catch {
|
||||
finish(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy())
|
||||
request.on("error", () => finish(null))
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeStrings(value): Record<string, string> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const strings = Object.fromEntries(
|
||||
Object.entries(value).filter((entry) => typeof entry[1] === "string")
|
||||
) as Record<string, string>
|
||||
return Object.keys(strings).length > 0 ? strings : null
|
||||
}
|
||||
|
||||
function localizeCheat(cheat, strings) {
|
||||
if (!cheat || typeof cheat !== "object") {
|
||||
return cheat
|
||||
}
|
||||
|
||||
return {
|
||||
...cheat,
|
||||
name: translate(cheat.name, strings),
|
||||
description: translate(cheat.description, strings),
|
||||
instructions: translate(cheat.instructions, strings),
|
||||
}
|
||||
}
|
||||
|
||||
function translate(value, strings) {
|
||||
return typeof value === "string" ? (strings[value] ?? value) : value
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return typeof value === "string" && value ? value : ""
|
||||
}
|
||||
+14
-2
@@ -1,8 +1,15 @@
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants');
|
||||
const { BRIDGE_PROTOCOL_VERSION, MAX_WS_FRAME_BYTES, WS_OPCODE } = require('./constants');
|
||||
|
||||
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
const FRAME_TOO_LARGE_ERROR = 'WS_FRAME_TOO_LARGE';
|
||||
|
||||
function frameTooLarge() {
|
||||
const error: any = new RangeError(`WebSocket frames are limited to ${MAX_WS_FRAME_BYTES} bytes.`);
|
||||
error.code = FRAME_TOO_LARGE_ERROR;
|
||||
return error;
|
||||
}
|
||||
|
||||
function jsonMessage(type, payload, requestId = null) {
|
||||
return JSON.stringify({
|
||||
@@ -88,13 +95,17 @@ function parseFrame(buffer) {
|
||||
const high = buffer.readUInt32BE(offset);
|
||||
const low = buffer.readUInt32BE(offset + 4);
|
||||
if (high !== 0) {
|
||||
throw new Error('Large websocket frames are not supported.');
|
||||
throw frameTooLarge();
|
||||
}
|
||||
|
||||
length = low;
|
||||
offset += 8;
|
||||
}
|
||||
|
||||
if (length > MAX_WS_FRAME_BYTES) {
|
||||
throw frameTooLarge();
|
||||
}
|
||||
|
||||
let mask = null;
|
||||
if (masked) {
|
||||
if (buffer.length < offset + 4) {
|
||||
@@ -131,6 +142,7 @@ function createAcceptKey(key) {
|
||||
module.exports = {
|
||||
closeClient,
|
||||
createAcceptKey,
|
||||
FRAME_TOO_LARGE_ERROR,
|
||||
jsonMessage,
|
||||
makeFrame,
|
||||
parseFrame,
|
||||
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
"name": "wand-web-panel",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"packageManager": "pnpm@10.17.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Vendored
-5
@@ -49,8 +49,6 @@ export interface InstalledAppSummary {
|
||||
displayName: string;
|
||||
gameId?: string | null;
|
||||
titleId?: string | null;
|
||||
location: string;
|
||||
alternateLocations: string[];
|
||||
imageUrl?: string | null;
|
||||
platformLastPlayedTimestamp?: number | null;
|
||||
platformTotalPlaytimeMinutes?: number | null;
|
||||
@@ -108,7 +106,6 @@ export interface TrainerSummary {
|
||||
export interface TrainerMetaPayload {
|
||||
session: {
|
||||
instanceId: string;
|
||||
accessToken?: string;
|
||||
};
|
||||
trainer: TrainerSummary;
|
||||
schema: {
|
||||
@@ -193,7 +190,6 @@ export type HelloMessage = MessageEnvelope<
|
||||
{
|
||||
client: 'mobile-web';
|
||||
clientVersion: string;
|
||||
pairingToken?: string;
|
||||
capabilities: {
|
||||
supportsDeltaValues: boolean;
|
||||
supportsTrainerSwitch: boolean;
|
||||
@@ -236,4 +232,3 @@ export type IncomingMessage =
|
||||
| ErrorMessage;
|
||||
|
||||
export type OutgoingMessage = HelloMessage | SetValueMessage | RemoteCommandMessage;
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -4,10 +4,10 @@
|
||||
"serverVersion": "0.2.0-wand",
|
||||
"defaultRemoteHost": "0.0.0.0",
|
||||
"defaultRemotePort": 3223,
|
||||
"devServerPorts": [4173, 5173],
|
||||
"portScanRange": 30,
|
||||
"basePath": "/remote/",
|
||||
"assetsPath": "/remote/assets/",
|
||||
"webSocketPath": "/remote/ws",
|
||||
"healthPath": "/remote/api/health",
|
||||
"installedAppsPath": "/remote/api/installed-apps"
|
||||
"healthPath": "/remote/api/health"
|
||||
}
|
||||
|
||||
-4
@@ -11,8 +11,6 @@ const apps: InstalledAppSummary[] = [
|
||||
correlationId: 'steam:one',
|
||||
displayName: 'Alpha Game',
|
||||
gameId: 'game-one',
|
||||
location: 'C:\\Games\\Alpha',
|
||||
alternateLocations: [],
|
||||
},
|
||||
{
|
||||
platform: 'epic',
|
||||
@@ -20,8 +18,6 @@ const apps: InstalledAppSummary[] = [
|
||||
correlationId: 'epic:two',
|
||||
displayName: 'Beta Game',
|
||||
gameId: 'game-two',
|
||||
location: 'C:\\Games\\Beta',
|
||||
alternateLocations: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Vendored
-16
@@ -6,7 +6,6 @@ export type LibraryGame = {
|
||||
title: string;
|
||||
platform: string;
|
||||
hours: number | null;
|
||||
path: string;
|
||||
imageUrl: string | null;
|
||||
app: InstalledAppSummary;
|
||||
gameId: string | null;
|
||||
@@ -37,7 +36,6 @@ export function buildLibraryGames(
|
||||
title: app.displayName,
|
||||
platform: formatHumanLabel(app.platform),
|
||||
hours: minutesToHours(app.platformTotalPlaytimeMinutes),
|
||||
path: app.location,
|
||||
imageUrl: app.imageUrl ?? null,
|
||||
app,
|
||||
gameId: app.gameId ?? null,
|
||||
@@ -71,7 +69,6 @@ export function filterLibraryGames(games: LibraryGame[], query: string): Library
|
||||
if (game.title.toLowerCase().includes(normalized)) return true;
|
||||
if (game.id.toLowerCase().includes(normalized)) return true;
|
||||
if (game.platform.toLowerCase().includes(normalized)) return true;
|
||||
if (game.path.toLowerCase().includes(normalized)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -88,19 +85,6 @@ export function formatHours(hours: number | null): string | null {
|
||||
return `${Math.round(hours)}h`;
|
||||
}
|
||||
|
||||
export function shortPath(path: string): string {
|
||||
if (!path.trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
if (parts.length <= 2) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
}
|
||||
|
||||
export function getGameCoverLabel(game: LibraryGame): string {
|
||||
return game.title.split(/[:\s]/).filter(Boolean)[0]?.slice(0, 8).toUpperCase() || 'GAME';
|
||||
}
|
||||
|
||||
+1
-2
@@ -8,7 +8,7 @@ import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import { SearchInput } from '@/shared/ui/SearchInput';
|
||||
|
||||
import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../model/games';
|
||||
import { filterLibraryGames, formatHours, getLibrarySections, type LibraryGame } from '../model/games';
|
||||
import { GameCover } from './GameCover';
|
||||
|
||||
type LibraryDrawerProps = {
|
||||
@@ -118,7 +118,6 @@ const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 font-mono text-[10.5px] tracking-[0.02em] text-(--deck-fg-3)">
|
||||
<span className="shrink-0">{game.platform.toUpperCase()}</span>
|
||||
{hours ? <span className="shrink-0">· {hours}</span> : null}
|
||||
{game.path ? <span className="min-w-0 truncate opacity-60">· {shortPath(game.path)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
|
||||
+3
-4
@@ -28,7 +28,7 @@ export class RemoteSessionClient {
|
||||
private readonly handlers: SocketHandlers,
|
||||
) {}
|
||||
|
||||
connect(pairingToken?: string): void {
|
||||
connect(): void {
|
||||
this.disconnect();
|
||||
this.intentionalDisconnect = false;
|
||||
this.handlers.onConnecting();
|
||||
@@ -38,7 +38,7 @@ export class RemoteSessionClient {
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.handlers.onTransportOpen();
|
||||
this.send(this.createHelloMessage(pairingToken));
|
||||
this.send(this.createHelloMessage());
|
||||
});
|
||||
socket.addEventListener('message', (event) => this.handleMessage(event));
|
||||
socket.addEventListener('close', () => {
|
||||
@@ -100,7 +100,7 @@ export class RemoteSessionClient {
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
private createHelloMessage(pairingToken?: string): HelloMessage {
|
||||
private createHelloMessage(): HelloMessage {
|
||||
return {
|
||||
type: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
@@ -108,7 +108,6 @@ export class RemoteSessionClient {
|
||||
payload: {
|
||||
client: 'mobile-web',
|
||||
clientVersion: WEB_CONTRACT.clientVersion,
|
||||
pairingToken,
|
||||
capabilities: { supportsDeltaValues: true, supportsTrainerSwitch: true },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { CheatSchema, TrainerMetaPayload } from '../../protocol/messages';
|
||||
|
||||
const WEMOD_TRAINER_ENDPOINT = 'https://api.wemod.com/v3/games';
|
||||
|
||||
type WemodTrainerResponse = {
|
||||
i18n?: { strings?: Record<string, string> };
|
||||
};
|
||||
|
||||
export async function localizeTrainerMeta(payload: TrainerMetaPayload): Promise<TrainerMetaPayload> {
|
||||
const strings = await fetchTrainerStrings(payload);
|
||||
if (!strings) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const cheats = payload.schema.cheats.map((cheat) => localizeCheat(cheat, strings));
|
||||
return { ...payload, schema: { ...payload.schema, cheats } };
|
||||
}
|
||||
|
||||
async function fetchTrainerStrings(payload: TrainerMetaPayload): Promise<Record<string, string> | null> {
|
||||
const { accessToken } = payload.session;
|
||||
const { gameId, gameVersion, language } = payload.trainer;
|
||||
if (!accessToken || !gameId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (gameVersion) params.set('gameVersions', gameVersion);
|
||||
if (language) params.set('locale', language);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WEMOD_TRAINER_ENDPOINT}/${gameId}/trainer?${params}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trainer = (await response.json()) as WemodTrainerResponse;
|
||||
return trainer.i18n?.strings ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function localizeCheat(cheat: CheatSchema, strings: Record<string, string>): CheatSchema {
|
||||
return {
|
||||
...cheat,
|
||||
name: strings[cheat.name] ?? cheat.name,
|
||||
description: translate(cheat.description, strings),
|
||||
instructions: translate(cheat.instructions, strings),
|
||||
};
|
||||
}
|
||||
|
||||
function translate(value: string | null | undefined, strings: Record<string, string>): string | null {
|
||||
if (!value) {
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
return strings[value] ?? value;
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { WEB_CONTRACT } from '../../protocol/contract';
|
||||
|
||||
export const WS_QUERY_PARAM = 'ws';
|
||||
|
||||
const DEV_SERVER_PORTS = new Set(['4173', '5173']);
|
||||
const DEV_SERVER_PORTS = new Set(WEB_CONTRACT.devServerPorts.map(String));
|
||||
|
||||
function protocolForWebSocket(): 'ws' | 'wss' {
|
||||
return window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { type CheatSchema, type InstalledAppSummary } from '../../protocol/messages';
|
||||
import { normalizeCheatValue } from '../trainer/model/values';
|
||||
import { RemoteSessionClient } from './remote-session.client';
|
||||
import { localizeTrainerMeta } from './remote-session.i18n';
|
||||
import {
|
||||
createInitialRemoteSessionState,
|
||||
EConnectionStatus,
|
||||
@@ -63,10 +62,6 @@ export function useRemoteSession() {
|
||||
onMessage: (message) => {
|
||||
const action = protocolAction(message);
|
||||
if (!action) return;
|
||||
if (action.type === 'trainerMeta') {
|
||||
void localizeTrainerMeta(action.payload).then((payload) => dispatch({ ...action, payload }));
|
||||
return;
|
||||
}
|
||||
dispatch(action);
|
||||
},
|
||||
onClose: () => {
|
||||
|
||||
Vendored
+21
-3
@@ -1,9 +1,10 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { loadStringSet, saveStringSet } from './storage';
|
||||
|
||||
describe('storage revival', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('revives only valid string ids', () => {
|
||||
localStorage.setItem('pins', JSON.stringify(['one', '', 2, 'two']));
|
||||
@@ -11,9 +12,26 @@ describe('storage revival', () => {
|
||||
});
|
||||
|
||||
it('removes empty sets', () => {
|
||||
saveStringSet('pins', { one: true });
|
||||
expect(saveStringSet('pins', { one: true })).toBe(true);
|
||||
expect(localStorage.getItem('pins')).toBe(JSON.stringify(['one']));
|
||||
saveStringSet('pins', {});
|
||||
expect(saveStringSet('pins', {})).toBe(true);
|
||||
expect(localStorage.getItem('pins')).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a failed browser storage write', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('Storage disabled', 'SecurityError');
|
||||
});
|
||||
|
||||
expect(saveStringSet('pins', { one: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('handles browsers that block access to local storage', () => {
|
||||
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
|
||||
throw new DOMException('Storage disabled', 'SecurityError');
|
||||
});
|
||||
|
||||
expect(saveStringSet('pins', { one: true })).toBe(false);
|
||||
expect(loadStringSet('pins')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+12
-7
@@ -3,7 +3,11 @@ import type { TrainerSummary } from '../../protocol/messages';
|
||||
type Reviver<T> = (raw: unknown) => T | null;
|
||||
|
||||
function getStore(): Storage | null {
|
||||
return typeof window === 'undefined' ? null : window.localStorage;
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTrainerStorageId(trainer: TrainerSummary | null | undefined): string | null {
|
||||
@@ -33,21 +37,22 @@ export function loadJson<T>(key: string | null, revive: Reviver<T>, fallback: T)
|
||||
}
|
||||
}
|
||||
|
||||
export function saveJson(key: string | null, value: unknown, isEmpty: (value: unknown) => boolean): void {
|
||||
export function saveJson(key: string | null, value: unknown, isEmpty: (value: unknown) => boolean): boolean {
|
||||
const store = getStore();
|
||||
if (!key || !store) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEmpty(value)) {
|
||||
store.removeItem(key);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
store.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch {
|
||||
// localStorage quota / serialization errors are non-critical for this UI.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +77,7 @@ export function loadStringSet(key: string | null): Record<string, true> {
|
||||
);
|
||||
}
|
||||
|
||||
export function saveStringSet(key: string | null, value: Record<string, true>): void {
|
||||
export function saveStringSet(key: string | null, value: Record<string, true>): boolean {
|
||||
const ids = Object.keys(value);
|
||||
saveJson(key, ids, () => ids.length === 0);
|
||||
return saveJson(key, ids, () => ids.length === 0);
|
||||
}
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ export function loadPresets(storageKey: string): RemotePreset[] {
|
||||
);
|
||||
}
|
||||
|
||||
export function savePresets(storageKey: string, presets: RemotePreset[]): void {
|
||||
saveJson(storageKey, presets, (value) => Array.isArray(value) && value.length === 0);
|
||||
export function savePresets(storageKey: string, presets: RemotePreset[]): boolean {
|
||||
return saveJson(storageKey, presets, (value) => Array.isArray(value) && value.length === 0);
|
||||
}
|
||||
|
||||
export function createPreset(name: string, values: Record<string, unknown>): RemotePreset {
|
||||
|
||||
+9
-3
@@ -31,8 +31,11 @@ export function usePresets({ presetStorageKey, trainerMeta, values, onError }: P
|
||||
}
|
||||
|
||||
const next = [...presets, createPreset(name, captured)];
|
||||
if (!savePresets(presetStorageKey, next)) {
|
||||
onError('Could not save the preset in this browser. Check site storage permissions and available space.');
|
||||
return false;
|
||||
}
|
||||
setPresets(next);
|
||||
savePresets(presetStorageKey, next);
|
||||
return true;
|
||||
},
|
||||
[onError, presets, presetStorageKey, trainerMeta, values],
|
||||
@@ -41,10 +44,13 @@ export function usePresets({ presetStorageKey, trainerMeta, values, onError }: P
|
||||
const deletePreset = useCallback(
|
||||
(presetId: string) => {
|
||||
const next = presets.filter((preset) => preset.id !== presetId);
|
||||
if (!savePresets(presetStorageKey, next)) {
|
||||
onError('Could not update presets in this browser. Check site storage permissions and available space.');
|
||||
return;
|
||||
}
|
||||
setPresets(next);
|
||||
savePresets(presetStorageKey, next);
|
||||
},
|
||||
[presets, presetStorageKey],
|
||||
[onError, presets, presetStorageKey],
|
||||
);
|
||||
|
||||
return { presets, addPreset, deletePreset };
|
||||
|
||||
+12
-8
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -47,14 +48,17 @@ export const QuickActions = ({ presets, onPanic, onAddPreset, onApplyPreset, onD
|
||||
))}
|
||||
<Chip icon="plus" label={_(msg`Add`)} variant="add" onClick={handleOpenModal} />
|
||||
</div>
|
||||
{modalOpen ? (
|
||||
<PresetModal
|
||||
draftName={draftName}
|
||||
onClose={handleCloseModal}
|
||||
onDraftNameChange={setDraftName}
|
||||
onSubmit={handleSubmitPreset}
|
||||
/>
|
||||
) : null}
|
||||
{modalOpen
|
||||
? createPortal(
|
||||
<PresetModal
|
||||
draftName={draftName}
|
||||
onClose={handleCloseModal}
|
||||
onDraftNameChange={setDraftName}
|
||||
onSubmit={handleSubmitPreset}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user