Compare commits

..

27 Commits

Author SHA1 Message Date
kitbyte 643c8f8b62 fix: bump version to 1.0.9.4, update changelog 2026-07-22 00:03:38 +03:00
k1tbyte e7c7beb621 Merge pull request #143 from divya0795/fix/asar-extraction-safety-and-process-kill
fix(asar): path traversal (zip-slip) on extract + Pickle buffer overrun
2026-07-21 23:22:42 +03:00
k1tbyte 710e717677 Merge pull request #145 from divya0795/fix/trykillprocess-retry-loop
fix: correct TryKillProcess retry loop condition
2026-07-21 23:22:04 +03:00
dchukkapalli-dev 7e8cadff24 fix: correct TryKillProcess retry loop condition
The loop "for (int i = 0; processes.Length > i || i < 5; i++)" compared the
process count to the loop index and, because of the "|| i < 5", always ran
at least 5 iterations of Thread.Sleep(250) -- stalling ~1.25s before every
patch/restore even when WeMod was not running.

Use "processes.Length > 0 && i < 5" so it retries only while a target
process is still alive, capped at 5 attempts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:21:07 +00:00
dchukkapalli-dev 7d28eb7d52 fix(asar): correct Pickle payload buffer allocation
Pickle.Resize allocated the backing array as _header.Length + newCapacity
but advertised _capacityAfterHeader = newCapacity. On the first growth
_header is still empty, so the array ended up _headerSize (4) bytes short of
the header + capacity it claimed. A write that fills the payload then
overran the buffer, throwing an ArgumentException when the serialised asar
header was 4089-4092 bytes.

Allocate _headerSize + newCapacity instead, matching Chromium's
realloc(header_size_ + new_capacity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:59:06 +00:00
dchukkapalli-dev 413c38dbcb fix(asar): prevent path traversal (zip-slip) during extraction
AsarExtractor's path-traversal guard called Extensions.GetRelativePath,
whose fast path strips the destination prefix literally without resolving
".." segments. A crafted archive entry such as "a/../../evil" produced a
relative path that did not start with "..", so the guard passed and the
file was written outside the extraction directory once the OS resolved the
"..". The out-of-package symlink guard shared the same weakness.

Add Extensions.IsPathInside, which normalises both paths with
Path.GetFullPath before the containment check, and use it for both the
file/directory and symlink guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:59:06 +00:00
kitbyte 3b776c52fc fix: bump version to 1.0.9.3 and update changelog with recent fixes 2026-07-04 11:36:51 +03:00
k1tbyte 9c88caf49b Update CHANGELOG for version 1.0.9.2
Updated changelog to reflect changes in version 1.0.9.2, including removal of downloadable .exe files and the built-in updater.
2026-06-28 20:14:43 +03:00
kitbyte 537608c381 fix: update version to 1.0.9.2, remove updater functionality, and adjust bug report template 2026-06-28 13:20:36 +03:00
kitbyte b0279ee812 docs: update README with important notices and instructions for building executables, disable auto updates 2026-06-28 12:58:43 +03:00
kitbyte c007e11cce refactor(build): remove self-signing code from build script and update release notes process 2026-06-28 12:08:19 +03:00
kitbyte a7f0eae670 fix: Assignment to constant variable error in the enhancer patcher, bump version to 1.0.9.1 2026-06-24 22:13:57 +03:00
k1tbyte 1c9a8fb780 Merge pull request #110 from Kava-4/fix/pro-account-reducer-guard
fix(pro): guard ACTION_SET_ACCOUNT reducer against subscription loss
2026-06-24 21:52:36 +03:00
kava4 ec07dc63f0 fix(pro): guard ACTION_SET_ACCOUNT reducer against subscription loss
Periodic refreshAccount and other store writes could replace the account without subscription, causing Pro to drop after a day or two (#106).
2026-06-24 18:00:42 +03:00
kitbyte 88556ec70f feat(funding): add funding options to README and create FUNDING.yml 2026-06-16 18:00:18 +03:00
kitbyte c02bad919d fix(build): use CMake default VS generator instead of a derived name
vswhere catalog_productLineVersion returns the major (e.g. 18) on the
new runner, so the derived '-G Visual Studio 18 18' was rejected. Drop
-G entirely and let CMake select its default Visual Studio generator
(-A x64 still pins the architecture); clear CMAKE_GENERATOR so a non-VS
override can't break the architecture flag. MSBuild stays version-agnostic.
2026-06-15 01:08:16 +03:00
kitbyte b9faf80f86 fix(build): resolve Visual Studio version-agnostically
The runner's windows-latest image bumped Visual Studio past 2022, so the
hardcoded vswhere range [17.0,18.0) and the 'Visual Studio 17 2022' CMake
generator no longer matched, failing the release build. Pick the latest
installed VS and derive the generator from its version/product line.
2026-06-15 00:54:43 +03:00
kitbyte 6395ca3a27 release: 1.0.9.0 — panel i18n + self-signed build signing
- Bump AssemblyInfo to 1.0.9.0 and add the CHANGELOG section.
- build.ps1 now code-signs the Release exe with a self-signed
  certificate (reused across builds, generated on first use, no env
  or secrets needed) to lower false-positive AV/VirusTotal detections.
2026-06-15 00:49:47 +03:00
kitbyte 4ce47dc6d2 feat(web-panel): multi-locale i18n with selector + browser autodetect
- Add 6 locales (en-US source + ru-RU, de-DE, fr-FR, es-ES, zh-CN)
  matching the patcher's WPF UI; full .po catalogs incl. plural forms.
- Language selector in the left settings drawer; persist choice and
  auto-detect from navigator language on first load.
- Load .po directly via @lingui/vite-plugin (drop the compile step);
  gitignore generated catalog .js as build artifacts.
2026-06-15 00:49:47 +03:00
kitbyte 8c6d87671c Merge origin/master: cheat i18n already ported into refactored structure (-s ours) 2026-06-15 00:10:59 +03:00
kitbyte a0b3968d33 refactor(web-panel): update architecture + cheat i18n
Reorganize the panel around product capabilities and integrate the
remote i18n feature from origin/master into the new structure.

- Restructure src into capability features (app, trainer, library,
  remote-session, appearance, shared); move the bridge to bridge/src.
- Add lingui-based UI localization and wrap remaining user-facing
  strings (Trans / msg macros); fix the 'END В·' mojibake.
- Port WeMod cheat-metadata i18n: capture the access token from the
  snapshot's renderer in the bridge and fetch localized trainer_meta
  in remote-session.i18n; guarantee snapshot sync on token failure.
- Wire vitest to the app's lingui/preact pipeline (mergeConfig).
2026-06-15 00:10:49 +03:00
k1tbyte 6906a67a2e Merge pull request #98 from YifePlayte/master
feat: initial i18n support for cheat information strings in remote web panel
2026-06-14 22:18:42 +03:00
YifePlayte 37ce6b3f4a feat: initial i18n support for cheat information in remote web panel 2026-06-15 00:33:41 +08:00
kitbyte 8756e41fb9 feat: bump version to 1.0.8.4 - fix QR code pairing and post-update hang 2026-06-10 17:07:04 +03:00
kitbyte 544b9f0fb0 feat: update changelog for version 1.0.8.3, enhance Pro activation handling, minor fixes and improve renderer script management 2026-06-06 10:56:29 +03:00
kitbyte a4f3a57f97 feat: revert patch config regex, update changelog for version 1.0.8.2 2026-05-15 19:43:48 +03:00
kitbyte 1f5ba9fc95 patch: fix a syntax error in the Disable Updates, websocket connection wouldn't automatically reconnect, ui rerender optimizations
bump version to 1.0.8.1
2026-05-15 14:46:04 +03:00
158 changed files with 8801 additions and 2475 deletions
+4
View File
@@ -0,0 +1,4 @@
# The web panel is the bundled frontend shipped inside the C# patcher.
# Mark it as vendored so GitHub Linguist keeps it out of the repository's
# language statistics — the project is a C# app, not a TypeScript one.
web-panel/** linguist-vendored
+8
View File
@@ -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"
]
+6 -6
View File
@@ -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.
+34
View File
@@ -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
+4 -1
View File
@@ -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
+5 -7
View File
@@ -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
+10 -8
View File
@@ -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,6 +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`). 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
@@ -32,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`.
+7 -4
View File
@@ -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}\"");
+8 -2
View File
@@ -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;
}
+21
View File
@@ -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))
+90 -1
View File
@@ -3,6 +3,95 @@
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
- The Remote Web Panel now shows mod names, descriptions, and instructions translated to your WeMod account language by @YifePlayte in #98. Related issue: #85
- Added a language selector to the Remote Web Panel (English, Russian, German, French, Spanish, Simplified Chinese) with automatic detection from the browser language.
### Improvements
- Release builds are now code-signed, which reduces false-positive antivirus and VirusTotal detections.
- Reworked the Remote Web Panel internals around feature capabilities for easier maintenance, with no change to existing behavior.
## [1.0.8.4] - 2026-06-10
### Fixes
- Fixed QR code issues on the latest Wand version.
- Fixed application hang that occurred after Wand updates with pending patches.
## [1.0.8.3] - 2026-06-06
### Fixes
- Fixed the Remote Web Panel patches so they reliably apply on newer Wand builds by making the remote bridge patch anchors version-resilient.
- Fixed Pro activation being lost after changing the app language; the account language endpoint now keeps the patched subscription.
- Fixed "WeMod directory not found" when Wand/WeMod is installed outside the default location or only one brand folder exists. The patcher now also resolves the install directory from a running Wand/WeMod process. #82
- Hid the Pro "Remote" onboarding card in the Explore Pro benefits dialog. #86
## [1.0.8.2] - 2026-05-15
### Fixes
- Rolled back an incorrect Disable Updates patch fix that introduced a `SyntaxError` preventing Wand from launching.
## [1.0.8.1] - 2026-05-15
### Fixes
- Fixed a syntax error in the Disable Updates patch that prevented Wand from launching. #70
- Fixed an issue where the Remote Web Panel WebSocket connection wouldn't automatically reconnect when turning returning to the app or turning on the screen.
- Reduced battery consumption and device heating on mobile device by optimizing heavy UI blur effects and eliminating unnecessary React re-renders in the Remote Web Panel. #67
## [1.0.8.0] - 2026-05-06
### Features
@@ -112,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
View File
@@ -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
+101 -14
View File
@@ -5,18 +5,17 @@
# WandEnhancer
[![GitLab Mirror](https://img.shields.io/badge/GitLab-mirror-fc6d26?logo=gitlab)](https://gitlab.com/kitbyte/wand-enhancer)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-0/72-brightgreen?logo=virustotal)](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?
@@ -35,23 +34,104 @@ WandEnhancer includes a built-in **Remote Web Panel** allowing you to control ap
3. Scan the displayed **QR code** with your phone's camera.
### Troubleshooting & Remote Access:
- **Page isn't loading?** First, ensure both your PC and phone are connected to the **exact same Wi-Fi network**. Next, make sure **Network Discovery** is turned on in your Windows network settings. If it still doesn't work, Windows Firewall might be blocking the connection—you may need to manually allow inbound traffic on TCP port `3223`.
- **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 source or binary.
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
## 🧩 Custom scripts
You can inject your own JavaScript into Wand at patch time to tweak or fix things in the client UI. This reuses the same renderer injection the Remote Web Panel uses, so it requires the **Remote Web Panel** patch to be enabled.
**How to add a script**
- In the patch dialog, add one or more `.js` files (only existing `.js` files are accepted), **or**
- Drop `.js` files into a `renderer-scripts/` folder placed next to the patcher executable.
Then patch as usual — your scripts are bundled into the client and run inside Wand's window.
**How it runs**
- Each script runs inside Wand's renderer (full DOM access, plus Node `require`).
- It is wrapped so a thrown error is logged and never crashes Wand.
- It may run **more than once** per launch (on load and again shortly after), so guard onetime work behind a global flag.
- A small `WandEnhancer` helper is available: `WandEnhancer.log(...)`, `WandEnhancer.remoteUrl`, `WandEnhancer.apiVersion`.
**Minimal example** (`hello.js`)
```js
// Injected scripts can run multiple times — guard one-time setup.
if (!globalThis.__helloScriptInstalled) {
globalThis.__helloScriptInstalled = true;
WandEnhancer.log("Hello from my custom script!", WandEnhancer.remoteUrl);
new MutationObserver(() => {
const dialog = document.querySelector("ux-dialog:not([data-seen])");
if (dialog) {
dialog.setAttribute("data-seen", "1");
WandEnhancer.log("A dialog opened.");
}
}).observe(document.documentElement, { childList: true, subtree: true });
}
```
> Scripts run with the same privileges as the Wand client. Only add scripts you trust and understand.
## 🛠️ How to build from source
Building from source on Windows requires a local development environment.
### Requirements
- `CMake`
- `Node.js` and `pnpm`
- `Visual Studio 2022` or `Build Tools for Visual Studio 2022` with `MSBuild`
- Visual Studio `Desktop development with C++` workload
- .NET Framework 4.8 desktop build tools / targeting pack
### Build steps
1. Clone this repository.
2. Install the requirements above and make sure `cmake`, `pnpm`, and `MSBuild` are available.
3. Run `build.cmd` from Command Prompt or PowerShell.
The build script installs the web panel dependencies, builds the frontend, compiles the native helper with CMake, restores NuGet packages, and builds the WPF solution.
---
## ❓ 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
@@ -68,7 +148,14 @@ This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) fi
---
## ❤️ Support
[![ko-fi](https://www.ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/kitbyte)
If you find this project useful, you can support its development using any of the options below 🙌
[![Patreon](https://img.shields.io/badge/Patreon-donate-f96854.svg?logo=patreon)](https://www.patreon.com/kitbyte/gift)
[![USDT TRC20](https://img.shields.io/badge/USDT--TRC20-donate-26a17b.svg?logo=tether)](https://tronscan.org/#/address/TQdvau8pAy5Tg1Aa588tTcPCFgbcHtuoxc)
[![BTC](https://img.shields.io/badge/BTC-donate-f7931a.svg?logo=bitcoin)](https://www.blockchain.com/explorer/addresses/btc/1EZKDcyU8REm9JW5xwXJqSpn5Xaq5yAWWX)
[![ETH](https://img.shields.io/badge/ETH-donate-3c3c3d.svg?logo=ethereum)](https://etherscan.io/address/0xd904d9d0557f88bbb1c4ab3582b4ca0d8a730e8d)
---
@@ -77,4 +164,4 @@ This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) fi
---
[![Star History Chart](https://api.star-history.com/svg?repos=k1tbyte/Wand-Enhancer&type=Date)](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=k1tbyte/Wand-Enhancer&type=Date)](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date)
+20 -7
View File
@@ -81,7 +81,9 @@ namespace WandEnhancer.Core
$"{prefix} Patch failed. Multiple target functions found. Looks like the version is not supported");
}
string patchSource = patch.Patch;
string patchSource = patch.PatchFactory != null
? patch.PatchFactory(match)
: patch.Patch;
if (patch.Resolver != null)
{
@@ -95,10 +97,21 @@ namespace WandEnhancer.Core
}
_logger($"{prefix} Found target function in: " + Path.GetFileName(fileName), ELogType.Info);
string newJs = patch.SingleMatch
? patch.Target.Replace(js, patchSource, 1)
: patch.Target.Replace(js, patchSource);
string newJs;
if (patch.PatchFactory != null)
{
newJs = patch.SingleMatch
? patch.Target.Replace(js, _ => patchSource, 1)
: patch.Target.Replace(js, _ => patchSource);
}
else
{
newJs = patch.SingleMatch
? patch.Target.Replace(js, patchSource, 1)
: patch.Target.Replace(js, patchSource);
}
_logger($"{prefix} Patch applied", ELogType.Success);
patch.Applied = true;
patchApplied = true;
@@ -231,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);
@@ -490,4 +503,4 @@ namespace WandEnhancer.Core
_logger("[ENHANCER] Done!", ELogType.Success);
}
}
}
}
+135 -34
View File
@@ -7,9 +7,6 @@ namespace WandEnhancer.Core
{
public static class EnhancerConfig
{
private const int RemoteWebPanelDefaultPort = 3223;
private static readonly string RemoteWebPanelFallbackUrl = $"http://localhost:{RemoteWebPanelDefaultPort}/remote/";
public class ResolveContext
{
public string Placeholder { get; set; }
@@ -20,6 +17,7 @@ namespace WandEnhancer.Core
{
public Regex Target { get; set; }
public string Patch { get; set; }
public Func<Match, string> PatchFactory { get; set; }
public string Name { get; set; }
public bool Applied { get; set; }
public bool SingleMatch { get; set; } = true;
@@ -28,6 +26,77 @@ namespace WandEnhancer.Core
public ResolveContext Resolver { get; set; }
}
private static string RequireGroup(Match match, string groupName, string patchName)
{
var group = match.Groups[groupName];
if (!group.Success || string.IsNullOrEmpty(group.Value))
{
throw new Exception($"{patchName} failed to resolve {groupName}");
}
return group.Value;
}
private static string RequirePattern(string source, string pattern, string groupName, string patchName)
{
var match = Regex.Match(source, pattern, RegexOptions.Singleline);
return RequireGroup(match, groupName, patchName);
}
private static string BuildSetAccountLanguagePatch(Match match)
{
var parameters = RequireGroup(match, "params", "setAccountLanguage");
var expr = RequireGroup(match, "expr", "setAccountLanguage");
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;
var method = RequireGroup(match, "method", "remoteBridgeReset");
var disposableField = RequirePattern(source, @"this\.(?<disposable>#[\w$]+)\s*&&\s*\(\s*this\.\k<disposable>\.dispose\(\)", "disposable", "remoteBridgeReset");
var instanceField = RequirePattern(source, @"this\.(?<instance>#[\w$]+)\s*=\s*Date\.now\(\)\.toString\(\)", "instance", "remoteBridgeReset");
var trainerIdField = RequirePattern(source, @"Date\.now\(\)\.toString\(\)\s*\)?\s*,\s*\(?\s*this\.(?<trainerId>#[\w$]+)\s*=\s*null", "trainerId", "remoteBridgeReset");
var supportedVersionsField = RequirePattern(source, @"this\.(?<versions>#[\w$]+)\s*=\s*\[\]", "versions", "remoteBridgeReset");
var trainerField = RequirePattern(source, @"this\.(?<versions>#[\w$]+)\s*=\s*\[\]\s*\)?\s*,\s*\(?\s*this\.(?<trainer>#[\w$]+)\s*=\s*null", "trainer", "remoteBridgeReset");
return $"{method}(){{this.{disposableField}&&(this.{disposableField}.dispose(),this.{disposableField}=null),this.{instanceField}=Date.now().toString(),this.{trainerIdField}=null,this.{supportedVersionsField}=[],this.{trainerField}=null,this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null)}}";
}
private static string BuildRemoteBridgeSyncSnapshotPatch(Match match)
{
var source = match.Value;
var method = RequireGroup(match, "method", "remoteBridgeSyncSnapshot");
var statusAlias = RequirePattern(source, @"this\.status\s*===\s*(?<value>[\w$]+)\.Connected", "value", "remoteBridgeSyncSnapshot");
var trainerField = RequirePattern(source, @"this\.(?<trainer>#[\w$]+)\?\.\s*getMetadata\s*\(\s*(?<metadata>[\w$]+\.[\w$]+)\s*\)\?\.\s*gameVersion", "trainer", "remoteBridgeSyncSnapshot");
var metadataExport = RequirePattern(source, @"this\.(?<trainer>#[\w$]+)\?\.\s*getMetadata\s*\(\s*(?<metadata>[\w$]+\.[\w$]+)\s*\)\?\.\s*gameVersion", "metadata", "remoteBridgeSyncSnapshot");
var notesField = RequirePattern(source, @"this\.(?<notes>#[\w$]+)\s*\[\s*this\.(?<trainerId>#[\w$]+)\s*\?\?\s*""""\s*\]", "notes", "remoteBridgeSyncSnapshot");
var trainerIdField = RequirePattern(source, @"this\.(?<notes>#[\w$]+)\s*\[\s*this\.(?<trainerId>#[\w$]+)\s*\?\?\s*""""\s*\]", "trainerId", "remoteBridgeSyncSnapshot");
var gameField = RequirePattern(source, @"this\.(?<game>#[\w$]+)\s*&&.*?getPreferredInstallationInfo\s*\(\s*this\.\k<game>\s*\)", "game", "remoteBridgeSyncSnapshot");
var installationField = RequirePattern(source, @"this\.(?<game>#[\w$]+)\s*&&.*?this\.(?<installation>#[\w$]+)\.getPreferredInstallationInfo\s*\(\s*this\.\k<game>\s*\)", "installation", "remoteBridgeSyncSnapshot");
var supportedVersionsField = RequirePattern(source, @"!\s*this\.(?<versions>#[\w$]+)\.includes\s*\(\s*[\w$]+\.version\s*\)", "versions", "remoteBridgeSyncSnapshot");
var remoteChannelField = RequirePattern(source, @"this\.(?<remote>#[\w$]+)\?\.\s*send\s*\(\s*""client-state""", "remote", "remoteBridgeSyncSnapshot");
var valuesMethod = RequirePattern(source, @"values\s*:\s*this\.(?<values>#[\w$]+)\s*\(\s*\)", "values", "remoteBridgeSyncSnapshot");
var instanceField = RequirePattern(source, @"instanceId\s*:\s*this\.(?<instance>#[\w$]+)", "instance", "remoteBridgeSyncSnapshot");
var themeField = RequirePattern(source, @"themeId\s*:\s*this\.(?<theme>#[\w$]+)", "theme", "remoteBridgeSyncSnapshot");
var settingsHelper = RequirePattern(source, @"settings\s*:\s*(?<settings>[\w$]+)\s*\(\s*this\.settings\s*\)", "settings", "remoteBridgeSyncSnapshot");
var languageField = RequirePattern(source, @"language\s*:\s*this\.(?<language>#[\w$]+)", "language", "remoteBridgeSyncSnapshot");
var timerField = RequirePattern(source, @"isTimeLimitExpired\s*:\s*""expired""\s*===\s*this\.(?<timer>#[\w$]+)\.timerState", "timer", "remoteBridgeSyncSnapshot");
return $"{method}(){{let e,t=!1,s=this.{trainerField}?.getMetadata({metadataExport})?.gameVersion??null,o=!1;const n=this.{notesField}[this.{trainerIdField}??\"\"]||null;this.{gameField}&&(e=this.{installationField}.getPreferredInstallationInfo(this.{gameField}),e.app&&(t=!0,s??=e.version??null,o=\"number\"==typeof e.version&&!this.{supportedVersionsField}.includes(e.version)));this.status==={statusAlias}.Connected&&this.{remoteChannelField}?.send(\"client-state\",{{instanceId:this.{instanceField},trainerId:this.{trainerIdField},trainerLoading:this.{trainerField}?.isLoading(),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,values:this.{valuesMethod}(),themeId:this.{themeField},settings:{settingsHelper}(this.settings),language:this.{languageField},accountUuid:this.account.uuid,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.{timerField}.timerState}});this.__wandRemoteBridge?.sync({{instanceId:this.{instanceField},trainerId:this.{trainerIdField},trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.{trainerField}?.getMetadata({metadataExport})??null,trainerLoading:this.{trainerField}?.isLoading()??false,gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,language:this.{languageField},themeId:this.{themeField},notesReadHash:n,isTimeLimitExpired:\"expired\"===this.{timerField}.timerState,values:this.{valuesMethod}()}})}}";
}
public static Dictionary<EPatchType, PatchEntry[]> GetInstance()
{
return new Dictionary<EPatchType, PatchEntry[]>()
@@ -72,6 +141,46 @@ namespace WandEnhancer.Core
RegexOptions.Singleline),
Patch =
"setAccountWandBrandExperience(){return this.#<service_name>.post(\"/v3/account/brand_experience_wand\").then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}"
},
new PatchEntry
{
// Account-returning endpoint the original patches missed: changing
// language dispatches its (non-Pro) response into the store and
// wiped Pro. Wrap the result the same way. Param names are captured
// so the rewritten body keeps the real argument identifiers.
Name = "setAccountLanguage",
SearchHints = new[] { "setAccountLanguage(", "/v3/account/language" },
Target = new Regex(
@"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\"))}"
}
}
},
@@ -79,6 +188,8 @@ namespace WandEnhancer.Core
EPatchType.DisableUpdates,
new[]
{
// Regex consumes 4 closing parens (`)))) `); the 5th (registerHandler's own close)
// remains in the original file after replacement. Patch must end with 3 parens — NOT 4.
new PatchEntry
{
CandidateFileNames = new[] { "index.js" },
@@ -126,52 +237,42 @@ namespace WandEnhancer.Core
{
Name = "remoteBridgeReset",
SearchHints = new[] { "client-state" },
Target = new Regex(@"#Je\(\)\{this\.#Oe&&\(this\.#Oe\.dispose\(\),this\.#Oe=null\),this\.#Pe=Date\.now\(\)\.toString\(\),this\.#ke=null,this\.#_e=\[],this\.#Ee=null\}"),
Patch = "#Je(){this.#Oe&&(this.#Oe.dispose(),this.#Oe=null),this.#Pe=Date.now().toString(),this.#ke=null,this.#_e=[],this.#Ee=null,this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null)}"
Target = new Regex(@"(?<method>#[\w$]+)\(\)\s*\{\s*(?<body>(?:(?!__wandRemoteBridge|}\s*#[\w$]+\(\)).)*?Date\.now\(\)\.toString\(\)(?:(?!__wandRemoteBridge|}\s*#[\w$]+\(\)).)*?\[\](?:(?!__wandRemoteBridge|}\s*#[\w$]+\(\)).)*?)\s*\}\s*(?=#[\w$]+\(\)\s*\{\s*if\s*\(\s*this\.status\s*===\s*[\w$]+\.Connected\s*\).*?""client-state"")",
RegexOptions.Singleline),
PatchFactory = BuildRemoteBridgeResetPatch
},
new PatchEntry
{
Name = "remoteBridgeSyncSnapshot",
SearchHints = new[] { "client-state" },
Target = new Regex(@"#Be\(\)\{if\(this\.status===i\.Connected\)\{let e,t=!1,s=this\.#Ee\?\.getMetadata\(h\.vO\)\?\.gameVersion\?\?null,i=!1;const n=this\.#Ve\[this\.#ke\?\?""""\]\|\|null;this\.#Re&&\(e=this\.#Ae\.getPreferredInstallationInfo\(this\.#Re\),e\.app&&\(t=!0,s\?\?=e\.version\?\?null,i=""number""==typeof e\.version&&!this\.#_e\.includes\(e\.version\)\)\),this\.#Me\?\.send\(""client-state"",\{instanceId:this\.#Pe,trainerId:this\.#ke,trainerLoading:this\.#Ee\?\.isLoading\(\),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:i,values:this\.#Ke\(\),themeId:this\.#We,settings:R\(this\.settings\),language:this\.#Ne,accountUuid:this\.account\.uuid,notesReadHash:n,isTimeLimitExpired:""expired""===this\.#Fe\.timerState\}\)\}\}"),
Patch = "#Be(){let e,t=!1,s=this.#Ee?.getMetadata(h.vO)?.gameVersion??null,o=!1;const n=this.#Ve[this.#ke??\"\"]||null;this.#Re&&(e=this.#Ae.getPreferredInstallationInfo(this.#Re),e.app&&(t=!0,s??=e.version??null,o=\"number\"==typeof e.version&&!this.#_e.includes(e.version)));this.status===i.Connected&&this.#Me?.send(\"client-state\",{instanceId:this.#Pe,trainerId:this.#ke,trainerLoading:this.#Ee?.isLoading(),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,values:this.#Ke(),themeId:this.#We,settings:R(this.settings),language:this.#Ne,accountUuid:this.account.uuid,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.#Fe.timerState});this.__wandRemoteBridge?.sync({instanceId:this.#Pe,trainerId:this.#ke,trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.#Ee?.getMetadata(h.vO)??null,trainerLoading:this.#Ee?.isLoading()??false,gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,language:this.#Ne,themeId:this.#We,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.#Fe.timerState,values:this.#Ke()})}"
Target = new Regex(@"(?<method>#[\w$]+)\(\)\s*\{\s*if\s*\(\s*this\.status\s*===\s*[\w$]+\.Connected\s*\)\s*\{(?<body>.*?""client-state"".*?isTimeLimitExpired\s*:\s*""expired""\s*===\s*this\.\#[\w$]+\.timerState.*?\)\s*;?\s*\)?\s*;?)\s*\}\s*\}(?=\s*#[\w$]+\(\)\s*\{\s*if\s*\(\s*!this\.\#[\w$]+\?\.\s*isActive\(\)\s*\)\s*return\s*null)",
RegexOptions.Singleline),
PatchFactory = BuildRemoteBridgeSyncSnapshotPatch
},
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()}"
},
new PatchEntry
{
Name = "remoteTooltipPreviewUrl",
SearchHints = new[] { "remote_tooltip.scan_the_qr_code_or_visit_the_site", "remote_tooltip.connect_to_wand_remote" },
Target = new Regex(@"remoteUrl=""wemodwebsite://remote"""),
Patch = "remoteUrl=globalThis.__wandRemoteBridgeUrl||\"" + RemoteWebPanelFallbackUrl + "\""
},
new PatchEntry
{
Name = "remoteQrPreviewUrl",
SearchHints = new[] { "resources/elements/remote-qr-code" },
Resolver = new ResolveContext
{
Handler = (matchContent) =>
{
var match = Regex.Match(matchContent, @"this\.canvasElement&&(\w+)\.mo");
return match.Success ? match.Groups[1].Value : null;
},
Placeholder = "<qr_writer>"
},
Target = new Regex(@"this\.canvasElement&&\w+\.mo\(this\.canvasElement,`\$\{\w+\.A\.wemodWebsiteUrl\}/remote`,this\.options\)"),
Patch = "this.canvasElement&&<qr_writer>.mo(this.canvasElement,globalThis.__wandRemoteBridgeUrl||\"" + RemoteWebPanelFallbackUrl + "\",this.options)"
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}"
}
}
}
-15
View File
@@ -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>
-15
View File
@@ -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>
-15
View File
@@ -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>
-15
View File
@@ -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>
-15
View File
@@ -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>
-15
View File
@@ -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>
-15
View File
@@ -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>
-15
View File
@@ -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>
-15
View File
@@ -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>
-15
View File
@@ -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>
-15
View File
@@ -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>
-15
View File
@@ -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>
+2 -2
View File
@@ -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.8.0")]
[assembly: AssemblyFileVersion("1.0.8.0")]
[assembly: AssemblyVersion("1.0.9.4")]
[assembly: AssemblyFileVersion("1.0.9.4")]
+6 -1
View File
@@ -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)
{
+66 -5
View File
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
@@ -39,13 +40,73 @@ namespace WandEnhancer.Utils
public static WeModConfig FindWeMod()
{
string localAppDataPath = Environment.GetEnvironmentVariable("LOCALAPPDATA");
foreach (var folder in Constants.WeModBrandNames)
if (!string.IsNullOrEmpty(localAppDataPath))
{
var weModDir = Path.Combine(localAppDataPath ?? "", folder);
if(Directory.Exists(weModDir))
foreach (var folder in Constants.WeModBrandNames)
{
return FindLatestWeMod(weModDir);
var weModDir = Path.Combine(localAppDataPath, folder);
if (!Directory.Exists(weModDir))
{
continue;
}
// Keep scanning the other brand folders if this one has no valid
// install instead of giving up on the first folder that exists.
var config = FindLatestWeMod(weModDir);
if (config != null)
{
return config;
}
}
}
// Fallback: a running Wand/WeMod process reveals the install directory
// wherever it lives (non-default LOCALAPPDATA, moved install, other drive).
return FindWeModFromRunningProcess();
}
private static WeModConfig FindWeModFromRunningProcess()
{
foreach (var name in Constants.WeModBrandNames)
{
Process[] processes;
try
{
processes = Process.GetProcessesByName(name);
}
catch
{
continue;
}
foreach (var process in processes)
{
try
{
var exePath = process.MainModule?.FileName;
if (string.IsNullOrEmpty(exePath))
{
continue;
}
// Process may be the versioned exe (dir is the install root) or
// the launcher stub at the parent (dir holds `app-*` subfolders).
var processDir = Path.GetDirectoryName(exePath);
var config = CheckWeModPath(processDir) ?? FindLatestWeMod(processDir);
if (config != null)
{
return config;
}
}
catch
{
// MainModule throws on access-denied / bitness mismatch; skip.
}
finally
{
process.Dispose();
}
}
}
-281
View File
@@ -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());
}
}
}
+1 -7
View File
@@ -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>
+23 -62
View File
@@ -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
}
}
}
}
}
-104
View File
@@ -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;
}
}
}
+1 -7
View File
@@ -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="&quot;$(ILRepackExe)&quot; /allowMultiple /copyattrs /out:&quot;$(OutputPath)$(AssemblyName).exe&quot; &quot;$(MainAssembly)&quot; $(DllList)" />
<Delete Files="@(AssemblyList)" ContinueOnError="true" />
</Target>
</Project>
</Project>
Binary file not shown.
+43 -31
View File
@@ -23,38 +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"
}
$installationPath = & $vswhere -latest -version '[17.0,18.0)' -requires Microsoft.Component.MSBuild -property installationPath
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($installationPath)) {
throw 'Visual Studio 2022 with MSBuild was not found.'
$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"
}
@@ -62,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,
@@ -76,10 +79,10 @@ function Invoke-Step {
}
$cmake = Resolve-CommandPath 'cmake'
$nuget = Resolve-NuGetPath
$pnpm = Resolve-CommandPath 'pnpm'
$msbuild = Resolve-MSBuildPath
$generator = 'Visual Studio 17 2022'
$visualStudio = Resolve-VisualStudioPath
$msbuild = Resolve-MSBuildPath $visualStudio
$dumpBin = Resolve-DumpBinPath $visualStudio
Invoke-Step 'Install web-panel dependencies' {
& $pnpm --dir $webPanelDir install --frozen-lockfile
@@ -90,15 +93,24 @@ Invoke-Step 'Build web-panel' {
}
Invoke-Step 'Configure asar-fuses-bypass' {
& $cmake -S $asarFusesSourceDir -B $asarFusesBuildDir -G $generator -A x64
Remove-Item Env:CMAKE_GENERATOR -ErrorAction SilentlyContinue
& $cmake -S $asarFusesSourceDir -B $asarFusesBuildDir -A x64
}
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' {
@@ -106,4 +118,4 @@ Invoke-Step 'Build solution' {
}
Write-Host ''
Write-Host "Build completed successfully ($Configuration)." -ForegroundColor Green
Write-Host "Build completed successfully ($Configuration)." -ForegroundColor Green
+8 -4
View File
@@ -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()
+4
View File
@@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Compiled lingui catalogs — generated from .po by `lingui compile`; the app
# loads .po directly via @lingui/vite-plugin, so these are build artifacts.
src/locales/**/*.js
+254
View File
@@ -0,0 +1,254 @@
Use these rules as defaults, not as a reason to add ceremonial folders or wrapper layers.
## Core Principles
- Organize code around product capabilities, not framework vocabulary.
- Keep related UI, state, rules, and data access close until a real boundary justifies moving
them apart.
- Dependencies point from composition and UI toward stable rules and narrow capabilities.
- Protect rendering code from business, state-management, and infrastructure complexity.
- Keep one source of truth and derive everything else.
- Apply KISS, YAGNI, and DRY together. Remove duplicated knowledge, not merely similar syntax.
- Prefer explicit, readable flow over clever abstractions and hidden behavior.
## Screaming Architecture
The repository structure and public APIs should reveal what the product does.
Prefer:
```text
features/
checkout/
search/
account-security/
```
Avoid making the application read primarily as:
```text
components/
hooks/
services/
stores/
utils/
```
Technical folders are useful inside a capability, where their owner is clear. Generic top-level
folders easily become dependency magnets with unclear ownership.
Names should use product language. Prefer `useCheckoutSummary`, `reserveStock`, and
`AccountSecurityPanel` over `useData`, `processItems`, and `GenericPanel`.
## Suggested Structure
Start with the smallest structure that makes ownership obvious:
```text
src/
app/ startup, providers, router, global composition
pages/ route-level composition
features/
<capability>/
index.ts optional public API
ui/ optional rendering components
model/ optional state, view models, decisions
api/ optional external data access
lib/ optional feature-local pure helpers
domains/ optional shared product rules and types
shared/
ui/ domain-free visual primitives
api/ generic transport/query infrastructure
lib/ genuinely generic pure helpers
```
Folders are created when they contain a real responsibility. A small feature may be one cohesive
file. Do not create empty layers in anticipation of future complexity.
## Dependency Direction
- `app` installs providers, constructs dependencies, and composes the application.
- `pages` compose capabilities for a route. They do not own business rules or data protocols.
- A feature owns one user-recognizable capability end to end.
- Feature UI consumes its own model/view-model API, not raw infrastructure.
- Shared domain code contains reusable product rules and stays independent of React and I/O.
- `shared` contains only domain-free code. Product-specific code is not shared merely because
two files use it.
- Avoid feature-to-feature imports. Compose features in a page, promote truly shared rules to a
domain module, or introduce a named workflow when coordination is the actual responsibility.
- Cyclic imports are an architecture problem, not something to solve with a tooling workaround.
For a simple feature, direct `ui -> model -> api` dependencies are sufficient. Introduce ports,
facades, dependency injection, or workflows only when they hide real complexity, enable
important tests, or separate unstable infrastructure.
## Make Composition Read Like The Product
Pages and other composition boundaries should use capability-level APIs.
Prefer:
```tsx
<CheckoutSummary />
<PlaceOrderButton />
```
Over:
```tsx
<Card>
<Select options={paymentOptions} onChange={handlePaymentChange} />
<Button onClick={handleSubmit}>Submit</Button>
</Card>
```
The second version makes the page understand checkout behavior and low-level UI configuration.
That knowledge belongs to the checkout capability.
This does not mean wrapping every native element or design-system primitive. Semantic HTML and
visual primitives are correct inside feature UI. Create a capability component when it hides
product behavior or gives composition code a clearer product-level API.
Avoid "raw components" whose consumers must know internal options, state transitions, query
shapes, or protocol details. Avoid generic configuration-driven components that combine
unrelated product modes behind dozens of props.
## UI Boundary
- Components render data and translate DOM events into named user intents.
- Keep business decisions, data mapping, persistence, protocol handling, and multi-step async
flows outside rendering components.
- UI receives render-ready values. It should not reconstruct domain meaning from raw DTOs.
- Prefer intent props and commands such as `onApprove`, `renameProject`, or `submitOrder` over
generic `onChange`, `setState`, or `patch` APIs at capability boundaries.
- Keep ephemeral visual state local: focus, hover, open/closed, and uncommitted input usually
belong in the component.
- Split components by responsibility and API clarity, not by arbitrary line limits.
- Prefer slots and composition over components with many layout modes and boolean props.
- Use semantic HTML and preserve accessibility behavior.
A view-model hook is useful when it protects UI from state shape, async coordination, or business
decisions. Do not create a pass-through hook that only renames one value to satisfy a diagram.
## State Ownership
Choose the smallest correct owner:
| State | Preferred owner |
| --- | --- |
| Ephemeral visual state | local component state |
| Uncommitted form state | the form or feature |
| URL/shareable navigation state | the router/URL |
| Remote server resource and cache | a query/cache layer |
| Shared capability state | that feature's model/store |
| Cross-capability process | a named workflow or app-level model |
- A store is not a bucket for every value used by several components.
- Split state by capability and lifecycle, not by data type.
- Expose narrow selectors, hooks, or commands. Do not expose a complete mutable store to all UI.
- Store transitions should express user or domain intent, not generic object mutation.
- Derive values instead of storing synchronized copies.
- Do not use effects to keep two pieces of application state synchronized.
- React Context is suitable for dependency injection or stable scoped state. Avoid one broad
app context whose every update rerenders unrelated consumers.
State-library choice is an implementation detail. Architecture should survive replacing it
without rewriting pages and rendering components.
## Effects And Async Work
- Use effects to synchronize with external systems, not to calculate render data or handle user
events.
- Start event-driven work from the event or model command that owns it.
- Every subscription, timer, listener, or in-flight operation must have a clear owner and
cleanup path.
- The owning feature/model defines pending, success, empty, error, retry, and cancellation
semantics.
- Prevent stale async results and race conditions where users can trigger overlapping work.
- Do not hide failures with broad `catch` blocks or silently convert errors into empty data.
## Data And Infrastructure
- Treat network responses, storage, URL input, files, and third-party SDK output as untrusted.
- Validate and normalize data at the boundary where it enters the application.
- Map transport DTOs and external errors into product-oriented values before they reach UI.
- Keep raw `fetch`, storage APIs, SDK calls, and protocol details out of rendering components.
- Keep a feature-specific API adapter inside the feature until it has a real shared consumer.
- Introduce a client, repository, gateway, service, or facade only when its responsibility is
distinct and useful.
- Avoid wrapper chains that only forward calls. One clear adapter is better than
`Client -> Service -> Facade` without separate responsibilities.
- Inject infrastructure when tests, multiple implementations, lifecycle, or unstable external
APIs justify it. Do not introduce dependency injection for every pure helper.
## Component And Hook APIs
- Component and hook APIs describe product intent, not internal implementation.
- Avoid boolean prop combinations that create unclear or invalid modes. Prefer explicit variants
or separate components.
- Avoid passing raw query results, stores, SDK clients, or large configuration objects through
component trees.
- Keep public props small and cohesive. A component that needs unrelated groups of props likely
owns too many responsibilities.
- Custom hooks encapsulate React state, lifecycle, or reusable reactive behavior. Pure
calculations remain plain functions.
- Do not use `useEffect`, `useMemo`, `useCallback`, or `memo` by habit. Use them for correctness
or measured performance needs.
- Do not duplicate server or domain state into component state merely to make it editable.
Create an explicit draft only when the UX requires commit/cancel semantics.
## Public Boundaries
- Export the smallest useful public surface of a feature.
- Consumers should use a feature's public components, hooks, commands, and types, not deep
internal paths.
- Keep implementation-only state, DTOs, adapters, and helpers private.
- Do not create barrel files everywhere. Use a public entry point only where a real boundary
exists.
- A reusable abstraction should have a clear owner and at least one current reason to exist.
- Avoid generic `core`, `common`, `helpers`, `services`, or `utils` modules that collect
unrelated responsibilities.
## Growing The Architecture
Start local and promote code only after pressure appears:
- A second consumer may justify shared domain code, but similar code is not automatically the
same knowledge.
- Repeated external integration logic may justify a shared adapter.
- A process coordinating several capabilities may justify a named workflow.
- A large feature may split into smaller capabilities when they have distinct responsibilities
and lifecycles.
- Separate packages are useful when an enforceable boundary, independent reuse, or independent
lifecycle outweighs their maintenance cost.
Do not begin a small application with every possible layer, package, provider, repository,
facade, and design pattern. Strong architecture makes growth cheaper; it does not predict every
future requirement.
## Testing
- Test product behavior and public contracts, not implementation trivia.
- Test pure rules with unit tests.
- Test feature models and async transitions without rendering where practical.
- Test components through accessible user behavior.
- Test infrastructure mapping and validation at external boundaries.
- Keep end-to-end tests for critical user journeys.
- Mock external systems and unstable boundaries, not every internal function.
- Add tests proportional to risk, especially for validation, permissions, races, retries,
cancellation, and regressions.
## Review Checklist
Before finishing a change, ask:
- Does the file location make its owner obvious?
- Does composition code read in product language?
- Is UI protected from raw state, DTOs, infrastructure, and business decisions?
- Is there one source of truth?
- Are effects only synchronizing external systems?
- Is new shared code genuinely domain-free or genuinely shared?
- Does every abstraction remove current complexity?
- Can important behavior be tested without rendering the whole app?
- Did the change preserve accessibility, error handling, and cleanup?
- Is this the least code that clearly solves the current problem?
+6 -11
View File
@@ -1,17 +1,18 @@
# Wand Web Panel
Local mobile-friendly web panel scaffold for Wand.
Local mobile-friendly web panel for Wand.
## Commands
```bash
pnpm install
pnpm run dev
pnpm dev
pnpm bridge:demo
```
Hosted access on the local machine:
- `http://localhost:4173/?mock=1`
- `http://localhost:4173/`
Hosted access on the LAN:
@@ -21,11 +22,5 @@ pnpm run dev:host
Then open the machine IP on port `4173`.
## Modes
- `?mock=1`
- dev server only; loads the demo trainer and values through a debug-only import
- `?ws=ws://host:port/remote/ws`
- connects to a real bridge once the desktop layer exists
Production builds exclude the debug route and demo JSON from the shipped bundle.
Use `?ws=ws://host:port/remote/ws` to override the bridge URL. The fixture bridge is dev-only;
production is bundled to `dist/bridge.cjs`.
+36 -29
View File
@@ -6,51 +6,58 @@ import { fileURLToPath } from "node:url"
const bridgeRoot = dirname(fileURLToPath(import.meta.url))
const webPanelRoot = resolve(bridgeRoot, "..")
const distRoot = resolve(webPanelRoot, "dist")
const bridgeEntryPoint = resolve(bridgeRoot, "source.cjs")
const bridgeEntryPoint = resolve(bridgeRoot, "src", "index.ts")
const bridgeOutfile = resolve(distRoot, "bridge.cjs")
const rendererScriptsRoot = resolve(bridgeRoot, "scripts", "default")
const rendererScriptsOutdir = resolve(distRoot, "renderer-scripts")
await build({
banner: {
js: "// Generated by bridge/build.mjs. Do not edit this bundle by hand.",
},
bundle: true,
entryPoints: [bridgeEntryPoint],
format: "cjs",
legalComments: "none",
minify: true,
outfile: bridgeOutfile,
platform: "node",
target: "node16",
banner: {
js: "// Generated by bridge/build.mjs. Do not edit this bundle by hand.",
},
bundle: true,
entryPoints: [bridgeEntryPoint],
format: "cjs",
legalComments: "none",
minify: true,
outfile: bridgeOutfile,
platform: "node",
target: "node16",
})
const EXCLUDED_RENDERER_SCRIPTS = new Set(["activate-pro.js"])
const rendererEntries = (
await readdir(rendererScriptsRoot, { withFileTypes: true })
await readdir(rendererScriptsRoot, { withFileTypes: true })
)
.filter((entry) => entry.isFile() && entry.name.endsWith(".js"))
.map((entry) => resolve(rendererScriptsRoot, entry.name))
.filter(
(entry) =>
entry.isFile() &&
entry.name.endsWith(".js") &&
!EXCLUDED_RENDERER_SCRIPTS.has(entry.name)
)
.map((entry) => resolve(rendererScriptsRoot, entry.name))
if (rendererEntries.length === 0) {
throw new Error(`No renderer script entries found in ${rendererScriptsRoot}`)
throw new Error(`No renderer script entries found in ${rendererScriptsRoot}`)
}
await build({
banner: {
js: "// Generated by bridge/build.mjs. Do not edit this bundle by hand.",
},
bundle: true,
entryNames: "[name]",
entryPoints: rendererEntries,
format: "iife",
legalComments: "none",
minify: true,
outdir: rendererScriptsOutdir,
platform: "browser",
target: "es2020",
banner: {
js: "// Generated by bridge/build.mjs. Do not edit this bundle by hand.",
},
bundle: true,
entryNames: "[name]",
entryPoints: rendererEntries,
format: "iife",
legalComments: "none",
minify: true,
outdir: rendererScriptsOutdir,
platform: "browser",
target: "es2020",
})
console.log(`Built ${bridgeOutfile}`)
console.log(
`Built ${rendererEntries.length} renderer script(s) in ${rendererScriptsOutdir}`
`Built ${rendererEntries.length} renderer script(s) in ${rendererScriptsOutdir}`
)
@@ -4,17 +4,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { WebSocketServer } from 'ws';
import demoSession from '../fixtures/demo-session.json' with { type: 'json' };
import webContract from '../protocol/web-contract.json' with { type: 'json' };
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const distDir = path.join(rootDir, 'dist');
const DEFAULT_REMOTE_PORT = 3223;
const DEFAULT_REMOTE_HOST = '0.0.0.0';
const REMOTE_BASE_PATH = '/remote/';
const REMOTE_WS_PATH = '/remote/ws';
const REMOTE_HEALTH_PATH = '/remote/api/health';
const REMOTE_ASSETS_PREFIX = '/remote/assets/';
const DEFAULT_REMOTE_PORT = webContract.defaultRemotePort;
const DEFAULT_REMOTE_HOST = webContract.defaultRemoteHost;
const REMOTE_BASE_PATH = webContract.basePath;
const REMOTE_WS_PATH = webContract.webSocketPath;
const REMOTE_HEALTH_PATH = webContract.healthPath;
const REMOTE_ASSETS_PREFIX = webContract.assetsPath;
const host = process.env.HOST || DEFAULT_REMOTE_HOST;
const port = Number(process.env.PORT || DEFAULT_REMOTE_PORT);
@@ -26,7 +27,7 @@ const wss = new WebSocketServer({ noServer: true });
function jsonMessage(type, payload, requestId = null) {
return JSON.stringify({
type,
version: 1,
version: webContract.protocolVersion,
requestId,
payload,
});
@@ -145,13 +146,20 @@ wss.on('connection', (ws) => {
ws.on('message', (raw) => {
try {
const message = JSON.parse(String(raw));
if (message?.version !== webContract.protocolVersion || typeof message?.type !== 'string' || !message?.payload) {
ws.send(jsonMessage('error', {
code: 'invalid_message',
message: 'Expected a compatible protocol envelope.',
}, message?.requestId ?? null));
return;
}
if (message?.type === 'hello') {
ws.send(
jsonMessage('hello_ack', {
sessionId: `sess_${Date.now()}`,
accepted: true,
serverVersion: '0.1.0-demo',
protocolVersion: 1,
protocolVersion: webContract.protocolVersion,
}, message.requestId ?? null)
);
sendSnapshot(ws);
@@ -160,7 +168,7 @@ wss.on('connection', (ws) => {
if (message?.type === 'set_value') {
const target = message.payload?.target;
if (typeof target !== 'string' || !(target in trainerValues.values)) {
if (message.payload?.trainerId !== trainerMeta.trainer.trainerId || typeof target !== 'string' || !(target in trainerValues.values)) {
ws.send(
jsonMessage('set_value_result', {
ok: false,
@@ -209,4 +217,4 @@ wss.on('connection', (ws) => {
server.listen(port, host, () => {
console.log(`Wand web panel bridge listening on http://${host === DEFAULT_REMOTE_HOST ? 'localhost' : host}:${port}${REMOTE_BASE_PATH}`);
});
});
+3
View File
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
+7
View File
@@ -0,0 +1,7 @@
// NOTE: Not wired into the build. Pro activation currently lives in the C# asar
// patch (EPatchType.ActivatePro). This renderer-side variant is kept for future
// use and is excluded from bridge/build.mjs (EXCLUDED_RENDERER_SCRIPTS), so it is
// neither bundled nor injected. To re-enable, remove it from that exclusion list.
import { installActivatePro } from "./activate-pro/index.js"
installActivatePro(globalThis.WandEnhancer)
+140
View File
@@ -0,0 +1,140 @@
// NOTE: Currently unused. Pro activation lives in the C# asar patch
// (EPatchType.ActivatePro). This renderer-side variant patches the account service
// prototype to inject the Pro subscription at the source. Kept for future use;
// the entry `../activate-pro.js` is excluded from bridge/build.mjs.
import { createLogger } from "../installed-apps-sync/logger.js"
import {
findExportedConstructor,
getWebpackRequire,
isRecord,
} from "../installed-apps-sync/runtime.js"
const GLOBAL_FLAG = "__wandActivateProInstalled"
const SERVICE_PATCH_KEY = "__wandEnhancerProAccountServicePatched"
const ACCOUNT_SERVICE_METHODS = [
"getUserAccount",
"setAccountLanguage",
"setAccountWandBrandExperience",
]
const RETRY_DELAY_MS = 400
const MAX_ATTEMPTS = 90
const DEFAULT_SUBSCRIPTION = Object.freeze({ period: "yearly", state: "active" })
export function installActivatePro(WandEnhancer) {
if (globalThis[GLOBAL_FLAG]) {
return
}
globalThis[GLOBAL_FLAG] = true
const state = {
attempts: 0,
log: createLogger(WandEnhancer),
}
state.log("info", "Activate Pro bootstrap starting.")
retryBootstrap(state)
}
function retryBootstrap(state) {
if (patchAccountService(state)) {
return
}
state.attempts += 1
if (state.attempts < MAX_ATTEMPTS) {
setTimeout(() => retryBootstrap(state), RETRY_DELAY_MS)
return
}
state.log("error", "Activate Pro bootstrap exhausted; account service not found.")
}
function patchAccountService(state) {
const webpackRequire = getWebpackRequire()
if (!webpackRequire) {
return false
}
const ctor = findExportedConstructor(
webpackRequire,
(prototype) =>
typeof prototype.getUserAccount === "function" &&
typeof prototype.setAccountLanguage === "function" &&
typeof prototype.setAccountWandBrandExperience === "function"
)
if (!ctor?.prototype) {
return false
}
const prototype = ctor.prototype
if (prototype[SERVICE_PATCH_KEY]) {
return true
}
try {
for (const name of ACCOUNT_SERVICE_METHODS) {
const original = prototype[name]
if (typeof original !== "function") {
continue
}
prototype[name] = function patchedAccountMethod(...args) {
return Promise.resolve(original.apply(this, args)).then((account) =>
normalizeProAccount(account)
)
}
}
Object.defineProperty(prototype, SERVICE_PATCH_KEY, { value: true })
state.log("info", "Pro account service patched.")
return true
} catch (error) {
state.log(
"warn",
"Failed to patch account service.",
error?.stack || String(error)
)
return false
}
}
function normalizeProAccount(account) {
if (!isRecord(account)) {
return account
}
const nextSubscription = normalizeProSubscription(account.subscription)
if (nextSubscription === account.subscription) {
return account
}
return {
...account,
subscription: nextSubscription,
}
}
function normalizeProSubscription(subscription) {
if (!isRecord(subscription)) {
return { ...DEFAULT_SUBSCRIPTION }
}
const nextSubscription = { ...subscription }
let changed = false
if (
typeof nextSubscription.period !== "string" ||
!nextSubscription.period.trim()
) {
nextSubscription.period = DEFAULT_SUBSCRIPTION.period
changed = true
}
if (nextSubscription.state !== "active") {
nextSubscription.state = DEFAULT_SUBSCRIPTION.state
changed = true
}
return changed ? nextSubscription : subscription
}
@@ -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.",
})
}
}
+73 -12
View File
@@ -1,3 +1,8 @@
import {
getWebpackRequire,
} from "./installed-apps-sync/runtime.js"
import { resolveQrRenderer as findWandQrRenderer } from "./remote-popup-cleanup/qr-renderer.js"
;(function installRemotePopupCleanup(WandEnhancer) {
if (globalThis.__wandRemotePopupCleanupInstalled) {
return
@@ -8,10 +13,13 @@
const style = document.createElement("style")
style.id = "wand-remote-popup-cleanup-style"
style.textContent = `
article.pro-onboarding-card--remote {
display: none !important;
}
remote-tooltip .remote-tooltip .top-wrapper,
remote-tooltip .remote-tooltip .remote-tooltip-section-divider,
remote-tooltip .remote-tooltip .instructions .header,
remote-tooltip .remote-tooltip .instructions .content .text,
remote-tooltip .remote-tooltip .instructions .platforms {
display: none !important;
}
@@ -25,15 +33,15 @@
remote-tooltip .remote-tooltip .instructions,
remote-tooltip .remote-tooltip .instructions .content {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
gap: 0 !important;
gap: 12px !important;
}
remote-tooltip .remote-tooltip .instructions remote-qr-code {
all: unset !important;
--wand-qr-size: clamp(220px, 100vw, 300px);
--wand-qr-size: clamp(180px, 70vw, 240px);
width: var(--wand-qr-size) !important;
height: var(--wand-qr-size) !important;
min-width: var(--wand-qr-size) !important;
@@ -49,6 +57,12 @@
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35) !important;
}
remote-tooltip .remote-tooltip .instructions .content .text {
display: block !important;
max-width: 250px !important;
overflow-wrap: anywhere !important;
}
remote-tooltip .remote-tooltip .instructions remote-qr-code canvas {
width: 100% !important;
height: 100% !important;
@@ -60,6 +74,8 @@
transform: none !important;
}
`
let qrRenderer = null
let refreshScheduled = false
const installStyle = () => {
if (!document.getElementById(style.id)) {
@@ -67,9 +83,19 @@
}
}
const updateLinks = () => {
const remoteUrl =
globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl
const getRemoteUrl = () =>
globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl
const resolveQrRenderer = () => {
if (qrRenderer) {
return qrRenderer
}
qrRenderer = findWandQrRenderer(getWebpackRequire())
return qrRenderer
}
const updateLinks = (remoteUrl) => {
if (!remoteUrl) {
return
}
@@ -80,13 +106,48 @@
}
}
installStyle()
updateLinks()
const updateQrCodes = async (remoteUrl) => {
const renderQr = remoteUrl && resolveQrRenderer()
if (!renderQr) {
return
}
const observer = new MutationObserver(() => {
for (const canvas of document.querySelectorAll("remote-qr-code canvas")) {
if (canvas.dataset.wandRemoteUrl === remoteUrl) {
continue
}
try {
await renderQr(canvas, remoteUrl)
canvas.dataset.wandRemoteUrl = remoteUrl
} catch (error) {
WandEnhancer?.log("Failed to render local remote QR code", error)
}
}
}
const refresh = () => {
const remoteUrl = getRemoteUrl()
installStyle()
updateLinks()
})
updateLinks(remoteUrl)
void updateQrCodes(remoteUrl)
}
const scheduleRefresh = () => {
if (refreshScheduled) {
return
}
refreshScheduled = true
setTimeout(() => {
refreshScheduled = false
refresh()
}, 0)
}
refresh()
const observer = new MutationObserver(scheduleRefresh)
observer.observe(document.documentElement, {
childList: true,
@@ -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
}
+148
View File
@@ -0,0 +1,148 @@
const {
gameStatusSignature,
installedAppsSignature,
normalizeGameStatusSnapshot,
normalizeInstalledAppsSnapshot,
normalizeSnapshot,
normalizeTrainerValue,
summarizeInstalledAppsSource,
} = require('./normalizers');
const { cloneValue, isRecord, safeString } = require('./utils');
const { sendJson } = require('./websocket-codec');
function createBridgeState({ clients, log, getServerInfo }) {
let currentSnapshot: any = null;
let currentInstalledApps: any = null;
let currentInstalledAppsSignature: string | null = null;
let currentGameStatus: any = null;
let currentGameStatusSignature: string | null = null;
function broadcast(type, payload, requestId = null) {
for (const client of clients) {
if (client.handshaken) {
sendJson(client, type, payload, requestId);
}
}
}
function sendSnapshot(client) {
if (!currentSnapshot) {
sendJson(client, 'trainer_changed', { previousTrainerId: null, trainerId: '' });
} else {
sendJson(client, 'trainer_meta', currentSnapshot.trainerMeta);
sendJson(client, 'trainer_values', currentSnapshot.trainerValues);
}
if (currentGameStatus) sendJson(client, 'game_status', currentGameStatus);
if (currentInstalledApps) sendJson(client, 'installed_apps', currentInstalledApps);
}
function sync(rawSnapshot) {
const nextSnapshot = rawSnapshot ? normalizeSnapshot(rawSnapshot) : null;
const previousTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
const nextTrainerId = nextSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
currentSnapshot = nextSnapshot;
if (previousTrainerId !== nextTrainerId) {
broadcast('trainer_changed', { previousTrainerId, trainerId: nextTrainerId || '' });
}
if (currentSnapshot) {
broadcast('trainer_meta', currentSnapshot.trainerMeta);
broadcast('trainer_values', currentSnapshot.trainerValues);
}
}
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);
if (!target) return;
const value = normalizeTrainerValue(currentSnapshot, target, change.value);
currentSnapshot.trainerValues.values[target] = value;
broadcast('value_changed', {
trainerId: safeString(change.trainerId, currentSnapshot.trainerMeta.trainer.trainerId),
target,
value,
oldValue: cloneValue(change.oldValue),
source: safeString(change.source, 'desktop'),
cheatId: typeof change.cheatId === 'string' ? change.cheatId : undefined,
});
}
function syncInstalledApps(rawInstalledApps) {
const sourceSummary = summarizeInstalledAppsSource(rawInstalledApps);
const nextInstalledApps = normalizeInstalledAppsSnapshot(rawInstalledApps);
if (!nextInstalledApps) {
log('warn', `Ignored invalid installed apps snapshot.${sourceSummary ? ` ${sourceSummary}` : ''}`);
return;
}
const nextSignature = installedAppsSignature(nextInstalledApps);
if (nextSignature === currentInstalledAppsSignature) return;
currentInstalledApps = nextInstalledApps;
currentInstalledAppsSignature = nextSignature;
log('info', `Installed apps snapshot accepted (${currentInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
broadcast('installed_apps', currentInstalledApps);
}
function syncGameStatus(rawGameStatus) {
const nextGameStatus = normalizeGameStatusSnapshot(rawGameStatus);
if (!nextGameStatus) {
log('warn', 'Ignored invalid game status snapshot.');
return;
}
const nextSignature = gameStatusSignature(nextGameStatus);
if (nextSignature === currentGameStatusSignature) return;
currentGameStatus = nextGameStatus;
currentGameStatusSignature = nextSignature;
log('info', `Game status snapshot accepted (${currentGameStatus.session.state}/${currentGameStatus.session.event}).`);
broadcast('game_status', currentGameStatus);
}
function buildHealthPayload() {
const serverInfo = getServerInfo();
return {
ok: serverInfo.listening,
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || null,
gameSessionState: currentGameStatus?.session?.state || 'idle',
gameSessionEvent: currentGameStatus?.session?.event || 'snapshot',
runningTrainerId: currentGameStatus?.trainer?.trainerId || null,
installedAppsCount: currentInstalledApps?.apps?.length ?? 0,
remoteUrl: serverInfo.remoteUrl,
advertisedUrls: serverInfo.advertisedUrls,
};
}
function clear() {
currentSnapshot = null;
currentInstalledApps = null;
currentInstalledAppsSignature = null;
currentGameStatus = null;
currentGameStatusSignature = null;
}
return {
get snapshot() { return currentSnapshot; },
buildHealthPayload,
clear,
sendSnapshot,
sync,
syncTrainerMeta,
syncGameStatus,
syncInstalledApps,
valueChanged,
};
}
module.exports = {
createBridgeState,
};
@@ -1,4 +1,5 @@
const KNOWN_CHEAT_TYPES = new Set(['slider', 'number', 'toggle', 'button', 'selection', 'scalar', 'incremental']);
const WEB_CONTRACT = require('../../protocol/web-contract.json');
const WS_OPCODE = Object.freeze({
TEXT: 1,
@@ -22,23 +23,24 @@ const IPC_CHANNEL = Object.freeze({
module.exports = {
BRIDGE_LOG_FILE_NAME: 'wand-remote-bridge.log',
BRIDGE_PROTOCOL_VERSION: 1,
BRIDGE_SERVER_VERSION: '0.2.0-wand',
DEFAULT_REMOTE_HOST: '0.0.0.0',
DEFAULT_REMOTE_PORT: 3223,
BRIDGE_PROTOCOL_VERSION: WEB_CONTRACT.protocolVersion,
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,
PORT_SCAN_RANGE: 30,
REMOTE_ASSETS_PREFIX: '/remote/assets/',
REMOTE_BASE_PATH: '/remote/',
MAX_WS_FRAME_BYTES: 1024 * 1024,
PORT_SCAN_RANGE: WEB_CONTRACT.portScanRange,
REMOTE_ASSETS_PREFIX: WEB_CONTRACT.assetsPath,
REMOTE_BASE_PATH: WEB_CONTRACT.basePath,
REMOTE_COMMAND_REQUEST_CHANNEL: IPC_CHANNEL.COMMAND_REQUEST,
REMOTE_COMMAND_RESPONSE_CHANNEL: IPC_CHANNEL.COMMAND_RESPONSE,
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS: 15000,
REMOTE_GAME_STATUS_CHANNEL: IPC_CHANNEL.GAME_STATUS,
REMOTE_HEALTH_PATH: '/remote/api/health',
REMOTE_INSTALLED_APPS_API_PATH: '/remote/api/installed-apps',
REMOTE_HEALTH_PATH: WEB_CONTRACT.healthPath,
REMOTE_INSTALLED_APPS_CHANNEL: IPC_CHANNEL.INSTALLED_APPS,
REMOTE_WS_PATH: '/remote/ws',
REMOTE_WS_PATH: WEB_CONTRACT.webSocketPath,
RENDERER_INJECTION_DELAYS_MS: Object.freeze([500, 2000]),
RENDERER_SCRIPT_API_VERSION: 1,
RENDERER_SCRIPTS_DIR: 'renderer-scripts',
@@ -1,7 +1,8 @@
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./bridge-modules/runtime.cjs');
const { installWandRuntime: installRuntime } = require('./bridge-modules/wand-runtime.cjs');
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./runtime');
const { installWandRuntime: installRuntime } = require('./wand/runtime');
import type { BridgeOptions, ElectronPort } from './types';
function withDefaultPanelRoot(options = {}) {
function withDefaultPanelRoot(options: BridgeOptions = {}): BridgeOptions {
if (options.panelRoot) {
return options;
}
@@ -12,15 +13,15 @@ function withDefaultPanelRoot(options = {}) {
};
}
function createBridgeRuntime(options = {}) {
function createBridgeRuntime(options: BridgeOptions = {}) {
return createRuntime(withDefaultPanelRoot(options));
}
function ensureBridge(options = {}) {
function ensureBridge(options: BridgeOptions = {}) {
return ensureRuntime(withDefaultPanelRoot(options));
}
function installWandRuntime(electron, options = {}) {
function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) {
return installRuntime(electron, withDefaultPanelRoot(options));
}
@@ -2,7 +2,8 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { BRIDGE_LOG_FILE_NAME } = require('./constants.cjs');
const { BRIDGE_LOG_FILE_NAME } = require('./constants');
import type { BridgeOptions } from './types';
function writeLogLine(logFile, level, message, error) {
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
@@ -18,7 +19,7 @@ function writeLogLine(logFile, level, message, error) {
} catch { }
}
function createBridgeLogger(options = {}) {
function createBridgeLogger(options: BridgeOptions = {}) {
const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME);
const log = (level, message, error) => writeLogLine(logFile, level, message, error);
log.file = logFile;
+32
View File
@@ -0,0 +1,32 @@
const { isRecord, safeString, toStringId } = require('../utils');
function normalizeRemoteCommandAction(value) {
return value === 'launch' || value === 'stop' ? value : null;
}
function normalizeRemoteCommandResult(rawResult, fallback) {
const action = normalizeRemoteCommandAction(isRecord(rawResult) ? rawResult.action : null) || fallback.action;
const gameId = isRecord(rawResult) ? toStringId(rawResult.gameId) || fallback.gameId || null : fallback.gameId || null;
const titleId = isRecord(rawResult) ? toStringId(rawResult.titleId) || fallback.titleId || null : fallback.titleId || null;
const ok = rawResult === true || Boolean(isRecord(rawResult) && rawResult.ok === true);
const payload = { ok, action, gameId, titleId };
if (ok) return payload;
if (!isRecord(rawResult) || !isRecord(rawResult.error)) {
return {
...payload,
error: { code: 'command_rejected', message: 'The renderer rejected the remote command.' },
};
}
return {
...payload,
error: {
code: safeString(rawResult.error.code, 'command_rejected'),
message: safeString(rawResult.error.message, 'The renderer rejected the remote command.'),
},
};
}
module.exports = {
normalizeRemoteCommandAction,
normalizeRemoteCommandResult,
};
+55
View File
@@ -0,0 +1,55 @@
const { isRecord, safeString, toStringId } = require('../utils');
function normalizeGameStatusSnapshot(rawSnapshot) {
if (!isRecord(rawSnapshot)) return null;
const rawSession = isRecord(rawSnapshot.session) ? rawSnapshot.session : {};
const rawTrainer = isRecord(rawSnapshot.trainer) ? rawSnapshot.trainer : {};
return {
instanceId: safeString(rawSnapshot.instanceId, 'wand-game-status'),
updatedAt: typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
session: {
state: rawSession.state === 'running' ? 'running' : 'idle',
event: safeString(rawSession.event, 'snapshot'),
processId: typeof rawSession.processId === 'number' ? rawSession.processId : null,
gameId: toStringId(rawSession.gameId),
titleId: toStringId(rawSession.titleId),
titleName: typeof rawSession.titleName === 'string' ? rawSession.titleName : null,
sessionDurationSeconds: typeof rawSession.sessionDurationSeconds === 'number' ? rawSession.sessionDurationSeconds : null,
startedAt: typeof rawSession.startedAt === 'string' ? rawSession.startedAt : null,
endedAt: typeof rawSession.endedAt === 'string' ? rawSession.endedAt : null,
},
trainer: {
state: rawTrainer.state === 'running' ? 'running' : 'idle',
event: safeString(rawTrainer.event, 'snapshot'),
trainerId: toStringId(rawTrainer.trainerId),
displayName: typeof rawTrainer.displayName === 'string' ? rawTrainer.displayName : null,
gameId: toStringId(rawTrainer.gameId),
titleId: toStringId(rawTrainer.titleId),
},
};
}
function gameStatusSignature(snapshot) {
return [
snapshot.session.state,
snapshot.session.event,
snapshot.session.processId || '',
snapshot.session.gameId || '',
snapshot.session.titleId || '',
snapshot.session.titleName || '',
snapshot.session.sessionDurationSeconds || '',
snapshot.session.startedAt || '',
snapshot.session.endedAt || '',
snapshot.trainer.state,
snapshot.trainer.event,
snapshot.trainer.trainerId || '',
snapshot.trainer.displayName || '',
snapshot.trainer.gameId || '',
snapshot.trainer.titleId || '',
].join('|');
}
module.exports = {
gameStatusSignature,
normalizeGameStatusSnapshot,
};
@@ -1,5 +1,8 @@
const { KNOWN_CHEAT_TYPES } = require('./constants.cjs');
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('./utils.cjs');
const { KNOWN_CHEAT_TYPES } = require('../constants');
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('../utils');
const { normalizeRemoteCommandAction, normalizeRemoteCommandResult } = require('./command-results');
const { gameStatusSignature, normalizeGameStatusSnapshot } = require('./game-status');
const { normalizeTrainerValue } = require('./trainer');
function normalizeOption(option) {
if (typeof option === 'string' || typeof option === 'number') {
@@ -29,7 +32,7 @@ function normalizeArgs(args) {
return {};
}
const next = {};
const next: Record<string, unknown> = {};
if (typeof args.min === 'number') next.min = args.min;
if (typeof args.max === 'number') next.max = args.max;
if (typeof args.step === 'number') next.step = args.step;
@@ -60,7 +63,7 @@ function normalizeCheat(cheat, index) {
return null;
}
const normalized = {
const normalized: Record<string, unknown> = {
uuid: safeString(cheat.uuid, `${target}-${index}`),
target,
type,
@@ -85,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) {
@@ -116,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,
@@ -134,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,
@@ -149,91 +155,10 @@ 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 normalizeGameStatusSnapshot(rawSnapshot) {
if (!isRecord(rawSnapshot)) {
return null;
}
const rawSession = isRecord(rawSnapshot.session) ? rawSnapshot.session : {};
const rawTrainer = isRecord(rawSnapshot.trainer) ? rawSnapshot.trainer : {};
return {
instanceId: safeString(rawSnapshot.instanceId, 'wand-game-status'),
updatedAt: typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
session: {
state: rawSession.state === 'running' ? 'running' : 'idle',
event: safeString(rawSession.event, 'snapshot'),
processId: typeof rawSession.processId === 'number' ? rawSession.processId : null,
gameId: toStringId(rawSession.gameId),
titleId: toStringId(rawSession.titleId),
titleName: typeof rawSession.titleName === 'string' ? rawSession.titleName : null,
sessionDurationSeconds: typeof rawSession.sessionDurationSeconds === 'number' ? rawSession.sessionDurationSeconds : null,
startedAt: typeof rawSession.startedAt === 'string' ? rawSession.startedAt : null,
endedAt: typeof rawSession.endedAt === 'string' ? rawSession.endedAt : null,
},
trainer: {
state: rawTrainer.state === 'running' ? 'running' : 'idle',
event: safeString(rawTrainer.event, 'snapshot'),
trainerId: toStringId(rawTrainer.trainerId),
displayName: typeof rawTrainer.displayName === 'string' ? rawTrainer.displayName : null,
gameId: toStringId(rawTrainer.gameId),
titleId: toStringId(rawTrainer.titleId),
},
};
}
function normalizeRemoteCommandAction(value) {
if (value === 'launch' || value === 'stop') {
return value;
}
return null;
}
function normalizeRemoteCommandResult(rawResult, fallback) {
const action = normalizeRemoteCommandAction(isRecord(rawResult) ? rawResult.action : null) || fallback.action;
const gameId = isRecord(rawResult) ? toStringId(rawResult.gameId) || fallback.gameId || null : fallback.gameId || null;
const titleId = isRecord(rawResult) ? toStringId(rawResult.titleId) || fallback.titleId || null : fallback.titleId || null;
const ok = rawResult === true || Boolean(isRecord(rawResult) && rawResult.ok === true);
const payload = {
ok,
action,
gameId,
titleId,
};
if (ok) {
return payload;
}
if (!isRecord(rawResult) || !isRecord(rawResult.error)) {
return {
...payload,
error: {
code: 'command_rejected',
message: 'The renderer rejected the remote command.',
},
};
}
return {
...payload,
error: {
code: safeString(rawResult.error.code, 'command_rejected'),
message: safeString(rawResult.error.message, 'The renderer rejected the remote command.'),
},
};
}
@@ -242,7 +167,7 @@ function summarizeInstalledAppsSource(rawSnapshot) {
return '';
}
const parts = [];
const parts: string[] = [];
for (const key of ['rawInstalledApps', 'catalogGames', 'catalogTitles']) {
const value = rawSnapshot.diagnostics[key];
if (typeof value === 'number') {
@@ -261,7 +186,6 @@ function installedAppsSignature(snapshot) {
app.displayName,
app.gameId || '',
app.titleId || '',
app.location,
app.imageUrl || '',
app.platformLastPlayedTimestamp || '',
app.platformTotalPlaytimeMinutes || '',
@@ -269,115 +193,6 @@ function installedAppsSignature(snapshot) {
.join('\n');
}
function gameStatusSignature(snapshot) {
return [
snapshot.session.state,
snapshot.session.event,
snapshot.session.processId || '',
snapshot.session.gameId || '',
snapshot.session.titleId || '',
snapshot.session.titleName || '',
snapshot.session.sessionDurationSeconds || '',
snapshot.session.startedAt || '',
snapshot.session.endedAt || '',
snapshot.trainer.state,
snapshot.trainer.event,
snapshot.trainer.trainerId || '',
snapshot.trainer.displayName || '',
snapshot.trainer.gameId || '',
snapshot.trainer.titleId || '',
].join('|');
}
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;
@@ -437,6 +252,11 @@ function normalizeSnapshot(rawSnapshot) {
trainerId,
values: isRecord(rawSnapshot.values) ? cloneValue(rawSnapshot.values) : {},
};
for (const cheat of cheats) {
if (cheat.target in trainerValues.values) {
trainerValues.values[cheat.target] = normalizeTrainerValue({ trainerMeta }, cheat.target, trainerValues.values[cheat.target]);
}
}
return {
trainerMeta,
@@ -458,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,
@@ -479,5 +286,6 @@ module.exports = {
normalizeRemoteCommandAction,
normalizeRemoteCommandResult,
normalizeSnapshot,
normalizeTrainerValue,
summarizeInstalledAppsSource,
};
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { normalizeTrainerValue } from './trainer';
describe('trainer normalization', () => {
it('normalizes toggle values before they reach clients or Wand', () => {
const snapshot = {
trainerMeta: {
schema: { cheats: [{ target: 'god', type: 'toggle' }] },
},
};
expect(normalizeTrainerValue(snapshot, 'god', 1)).toBe(true);
expect(normalizeTrainerValue(snapshot, 'god', 0)).toBe(false);
expect(normalizeTrainerValue(snapshot, 'speed', 2)).toBe(2);
});
});
+10
View File
@@ -0,0 +1,10 @@
export function normalizeTrainerValue(snapshot, target, value) {
const cheat = snapshot?.trainerMeta?.schema?.cheats?.find((entry) => entry.target === target);
return cheat?.type === 'toggle' ? Boolean(value) : cloneValue(value);
}
function cloneValue(value) {
if (Array.isArray(value)) return value.map(cloneValue);
if (typeof value !== 'object' || value === null) return value;
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)]));
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { validateClientMessage, validateSetValueTarget } from './protocol-router';
const snapshot = {
trainerMeta: {
trainer: { trainerId: 'active' },
schema: { cheats: [{ target: 'god', type: 'toggle' }] },
},
trainerValues: { values: { god: false } },
};
describe('bridge protocol router', () => {
it('requires a compatible hello before commands', () => {
const command = {
type: 'set_value',
version: 1,
requestId: 'set',
payload: { trainerId: 'active', target: 'god', value: true },
};
expect(validateClientMessage(command, false)).toMatchObject({
ok: false,
error: { code: 'handshake_required' },
});
expect(validateClientMessage({ ...command, version: 2 }, true)).toMatchObject({
ok: false,
error: { code: 'protocol_mismatch' },
});
});
it('validates trainer and target while normalizing toggle values', () => {
expect(validateSetValueTarget({
payload: { trainerId: 'other', target: 'god', value: 1 },
}, snapshot)).toMatchObject({ ok: false, error: { code: 'trainer_mismatch' } });
expect(validateSetValueTarget({
payload: { trainerId: 'active', target: 'god', value: 1 },
}, snapshot)).toMatchObject({ ok: true, value: true });
});
});
+78
View File
@@ -0,0 +1,78 @@
import webContract from '../../protocol/web-contract.json';
const BRIDGE_PROTOCOL_VERSION = webContract.protocolVersion;
export function validateClientMessage(message, handshaken) {
if (!isRecord(message) || typeof message.type !== 'string' || !isRecord(message.payload)) {
return invalid('invalid_message', 'Expected a protocol envelope with an object payload.');
}
if (message.version !== BRIDGE_PROTOCOL_VERSION) {
return invalid('protocol_mismatch', `Unsupported protocol version ${String(message.version)}.`);
}
if (message.requestId !== null && typeof message.requestId !== 'string') {
return invalid('invalid_request_id', 'requestId must be a string or null.');
}
if (message.type === 'hello') {
if (message.payload.client !== 'mobile-web' || typeof message.payload.clientVersion !== 'string' || !isRecord(message.payload.capabilities)) {
return invalid('invalid_hello', 'The hello payload is incomplete.');
}
return { ok: true };
}
if (!handshaken) {
return invalid('handshake_required', 'Send a compatible hello message before commands.');
}
if (message.type === 'set_value') {
if (!safeString(message.payload.trainerId) || !safeString(message.payload.target) || !('value' in message.payload)) {
return invalid('invalid_set_value', 'trainerId, target and value are required.');
}
return { ok: true };
}
if (message.type === 'remote_command') {
if (message.payload.action !== 'launch' && message.payload.action !== 'stop') {
return invalid('invalid_command', 'Unknown remote command.');
}
return { ok: true };
}
return invalid('unknown_message', 'Unknown protocol message type.');
}
export function validateSetValueTarget(message, snapshot) {
const target = safeString(message.payload?.target);
const requestedTrainerId = safeString(message.payload?.trainerId);
const activeTrainerId = snapshot?.trainerMeta?.trainer?.trainerId || '';
if (!snapshot || requestedTrainerId !== activeTrainerId) {
return invalid('trainer_mismatch', 'The requested trainer is not active.');
}
const cheat = snapshot.trainerMeta.schema.cheats.find((entry) => entry.target === target);
if (!target || !cheat || !(target in snapshot.trainerValues.values)) {
return invalid('invalid_target', 'Unknown cheat target.');
}
return {
ok: true,
trainerId: activeTrainerId,
target,
cheat,
value: cheat.type === 'toggle' ? Boolean(message.payload.value) : message.payload.value,
};
}
function invalid(code, message) {
return { ok: false, error: { code, message } };
}
function isRecord(value) {
return typeof value === 'object' && value !== null;
}
function safeString(value) {
return typeof value === 'string' && value.length > 0 ? value : '';
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import {
findSteamAppId,
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', () => {
expect(normalizeImageUrl({ cover: { imageUrl: '//cdn.example/game.webp' } }))
.toBe('https://cdn.example/game.webp');
expect(normalizeImageUrl('file:///local/image.png')).toBeNull();
});
it('finds nested Steam metadata and builds the Wand client icon URL', () => {
const fixture = {
game: {
metadata: {
steam: {
appId: 1245620,
},
},
},
};
expect(findSteamAppId(fixture)).toBe('1245620');
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);
});
});
+230
View File
@@ -0,0 +1,230 @@
import { connect, createServer } from 'node:net';
import { describe, expect, it } from 'vitest';
import { WebSocket as NodeWebSocket } from 'ws';
describe('production bridge runtime', () => {
it('preserves the public API and sends cached snapshots after hello', async () => {
const bridge = require('../../dist/bridge.cjs');
expect(Object.keys(bridge).sort()).toEqual(['createBridgeRuntime', 'ensureBridge', 'installWandRuntime']);
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, 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();
}
});
});
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close((error) => error ? reject(error) : resolve(port));
});
});
}
async function connectAndCollect(port: number, count: number): Promise<any[]> {
return await new Promise((resolve, reject) => {
const messages: any[] = [];
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`);
socket.once('error', reject);
socket.once('open', () => socket.send(JSON.stringify({
type: 'hello',
version: 1,
requestId: 'hello',
payload: {
client: 'mobile-web',
clientVersion: 'test',
capabilities: { supportsDeltaValues: true, supportsTrainerSwitch: true },
},
})));
socket.on('message', (raw) => {
messages.push(JSON.parse(String(raw)));
if (messages.length === count) {
socket.close();
resolve(messages);
}
});
});
}
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()) {
if (Date.now() > deadline) throw new Error('Bridge did not start listening.');
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
function rawTrainerSnapshot() {
return {
instanceId: 'instance',
accessToken: 'wand-secret',
trainerId: 'trainer',
trainerInfo: { gameId: 'game', displayName: 'Game' },
metadata: {
info: {
blueprint: {
cheats: [{
uuid: 'god',
target: 'god',
type: 'toggle',
name: 'God mode',
category: 'player',
args: {},
}],
},
},
},
values: { god: 1 },
};
}
+19
View File
@@ -0,0 +1,19 @@
const { createBridgeServer } = require('./server');
import type { BridgeOptions } from './types';
function createBridgeRuntime(options: BridgeOptions = {}) {
return createBridgeServer(options);
}
function ensureBridge(options: BridgeOptions = {}) {
if (!globalThis.__wandRemoteBridgeRuntime) {
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
}
return globalThis.__wandRemoteBridgeRuntime;
}
module.exports = {
createBridgeRuntime,
ensureBridge,
};
@@ -2,7 +2,7 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { REMOTE_BASE_PATH } = require('./constants.cjs');
const { REMOTE_BASE_PATH } = require('./constants');
const IPV4_OCTET_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const PHYSICAL_INTERFACE_NAME_PATTERN = /(?:ethernet|wi-?fi|wireless|wlan|lan|local area)/i;
@@ -38,11 +38,11 @@ function contentTypeFor(filePath) {
}
function getAdvertisedUrls(port) {
const candidates = [];
const candidates: any[] = [];
const interfaces = os.networkInterfaces();
let index = 0;
for (const [name, entries] of Object.entries(interfaces)) {
for (const [name, entries] of Object.entries(interfaces) as [string, any[] | undefined][]) {
if (!entries) {
continue;
}
@@ -78,7 +78,7 @@ function isIpv4Family(family) {
}
function scoreIpv4Entry(name, entry) {
const octets = parseIpv4(entry.address);
const octets = parseIpv4(entry.address) as number[];
let score = 0;
if (isPrivateIpv4(octets)) {
@@ -116,7 +116,7 @@ function scoreIpv4Entry(name, entry) {
return score;
}
function parseIpv4(address) {
function parseIpv4(address): number[] | null {
if (typeof address !== 'string') {
return null;
}
@@ -6,47 +6,54 @@ 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.cjs');
const { createBridgeLogger } = require('./logger.cjs');
} = require('./constants');
const { createBridgeLogger } = require('./logger');
const {
buildInstalledAppsDebugPayload,
gameStatusSignature,
installedAppsSignature,
normalizeGameStatusSnapshot,
normalizeInstalledAppsSnapshot,
normalizeRemoteCommandAction,
normalizeRemoteCommandResult,
normalizeSnapshot,
summarizeInstalledAppsSource,
} = require('./normalizers.cjs');
const { getAdvertisedUrls, serveFile } = require('./static-server.cjs');
const { cloneValue, isRecord, isValidPort, safeString } = require('./utils.cjs');
const { closeClient, createAcceptKey, makeFrame, parseFrame, sendJson } = require('./websocket.cjs');
} = require('./normalizers');
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,
FRAME_TOO_LARGE_ERROR,
makeFrame,
parseFrame,
sendJson,
} = require('./websocket-codec');
import type { BridgeOptions } from './types';
function createBridgeRuntime(options = {}) {
function createBridgeServer(options: BridgeOptions = {}) {
const preferredPort = Number(options.port || process.env.WAND_REMOTE_PORT || DEFAULT_REMOTE_PORT);
let port = isValidPort(preferredPort) ? preferredPort : DEFAULT_REMOTE_PORT;
const maxPort = Number(options.maxPort || process.env.WAND_REMOTE_MAX_PORT || port + PORT_SCAN_RANGE);
const host = options.host || process.env.WAND_REMOTE_HOST || DEFAULT_REMOTE_HOST;
const panelRoot = options.panelRoot || path.dirname(__dirname);
const clients = new Set();
const clients = new Set<any>();
const log = createBridgeLogger(options);
let advertisedUrls = [];
let currentSnapshot = null;
let currentInstalledApps = null;
let currentInstalledAppsSignature = null;
let currentGameStatus = null;
let currentGameStatusSignature = null;
let setValueHandler = null;
let commandHandler = null;
let advertisedUrls: string[] = [];
let setValueHandler: any = null;
let commandHandler: any = null;
let listening = false;
const bridgeState = createBridgeState({
clients,
log,
getServerInfo: () => ({
advertisedUrls,
listening,
remoteUrl: globalThis.__wandRemoteBridgeUrl,
}),
});
function setAdvertisedPort(nextPort) {
port = nextPort;
@@ -54,112 +61,6 @@ function createBridgeRuntime(options = {}) {
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry) => !entry.includes('localhost')) || advertisedUrls[0];
}
function broadcast(type, payload, requestId = null) {
for (const client of clients) {
sendJson(client, type, payload, requestId);
}
}
function sendSnapshot(client) {
if (!currentSnapshot) {
sendJson(client, 'trainer_changed', {
previousTrainerId: null,
trainerId: '',
});
} else {
sendJson(client, 'trainer_meta', currentSnapshot.trainerMeta);
sendJson(client, 'trainer_values', currentSnapshot.trainerValues);
}
if (currentGameStatus) {
sendJson(client, 'game_status', currentGameStatus);
}
if (currentInstalledApps) {
sendJson(client, 'installed_apps', currentInstalledApps);
}
}
function sync(rawSnapshot) {
const nextSnapshot = rawSnapshot ? normalizeSnapshot(rawSnapshot) : null;
const previousTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
const nextTrainerId = nextSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
currentSnapshot = nextSnapshot;
if (previousTrainerId !== nextTrainerId) {
broadcast('trainer_changed', {
previousTrainerId,
trainerId: nextTrainerId || '',
});
}
if (!currentSnapshot) {
return;
}
broadcast('trainer_meta', currentSnapshot.trainerMeta);
broadcast('trainer_values', currentSnapshot.trainerValues);
}
function valueChanged(change) {
if (!currentSnapshot || !isRecord(change)) {
return;
}
const target = safeString(change.target);
if (!target) {
return;
}
currentSnapshot.trainerValues.values[target] = cloneValue(change.value);
broadcast('value_changed', {
trainerId: safeString(change.trainerId, currentSnapshot.trainerMeta.trainer.trainerId),
target,
value: cloneValue(change.value),
oldValue: cloneValue(change.oldValue),
source: safeString(change.source, 'desktop'),
cheatId: typeof change.cheatId === 'string' ? change.cheatId : undefined,
});
}
function syncInstalledApps(rawInstalledApps) {
const sourceSummary = summarizeInstalledAppsSource(rawInstalledApps);
const nextInstalledApps = normalizeInstalledAppsSnapshot(rawInstalledApps);
if (!nextInstalledApps) {
log('warn', `Ignored invalid installed apps snapshot.${sourceSummary ? ` ${sourceSummary}` : ''}`);
return;
}
const nextSignature = installedAppsSignature(nextInstalledApps);
if (nextSignature === currentInstalledAppsSignature) {
log('info', `Installed apps snapshot unchanged (${nextInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
return;
}
currentInstalledApps = nextInstalledApps;
currentInstalledAppsSignature = nextSignature;
log('info', `Installed apps snapshot accepted (${currentInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
broadcast('installed_apps', currentInstalledApps);
}
function syncGameStatus(rawGameStatus) {
const nextGameStatus = normalizeGameStatusSnapshot(rawGameStatus);
if (!nextGameStatus) {
log('warn', 'Ignored invalid game status snapshot.');
return;
}
const nextSignature = gameStatusSignature(nextGameStatus);
if (nextSignature === currentGameStatusSignature) {
return;
}
currentGameStatus = nextGameStatus;
currentGameStatusSignature = nextSignature;
log('info', `Game status snapshot accepted (${currentGameStatus.session.state}/${currentGameStatus.session.event}).`);
broadcast('game_status', currentGameStatus);
}
function setHandler(handler) {
setValueHandler = typeof handler === 'function' ? handler : null;
}
@@ -168,30 +69,16 @@ function createBridgeRuntime(options = {}) {
commandHandler = typeof handler === 'function' ? handler : null;
}
function buildHealthPayload() {
const installedAppsDebug = buildInstalledAppsDebugPayload(currentInstalledApps);
return {
ok: listening,
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || null,
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: REMOTE_INSTALLED_APPS_API_PATH,
remoteUrl: globalThis.__wandRemoteBridgeUrl,
advertisedUrls,
};
}
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;
}
@@ -209,13 +96,7 @@ function createBridgeRuntime(options = {}) {
if (url.pathname === REMOTE_HEALTH_PATH) {
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
response.end(JSON.stringify(buildHealthPayload()));
return;
}
if (url.pathname === REMOTE_INSTALLED_APPS_API_PATH) {
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
response.end(JSON.stringify(buildInstalledAppsDebugPayload(currentInstalledApps), null, 2));
response.end(JSON.stringify(bridgeState.buildHealthPayload()));
return;
}
@@ -272,30 +153,30 @@ function createBridgeRuntime(options = {}) {
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);
}
}
async function handleSetValueMessage(client, message) {
const target = safeString(message.payload?.target);
if (!currentSnapshot || !target || !(target in currentSnapshot.trainerValues.values)) {
const currentSnapshot = bridgeState.snapshot;
const validation = validateSetValueTarget(message, currentSnapshot);
if (!validation.ok) {
sendJson(client, 'set_value_result', {
ok: false,
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || '',
target,
error: {
code: 'invalid_target',
message: 'Unknown cheat target.',
},
target: safeString(message.payload?.target),
error: validation.error,
}, message.requestId ?? null);
return;
}
const { target } = validation;
if (!setValueHandler) {
sendJson(client, 'set_value_result', {
@@ -315,17 +196,18 @@ function createBridgeRuntime(options = {}) {
result = await Promise.resolve(setValueHandler({
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
target,
value: cloneValue(message.payload?.value),
value: cloneValue(validation.value),
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;
@@ -352,7 +234,14 @@ function createBridgeRuntime(options = {}) {
}
async function handleClientMessage(client, message) {
const validation = validateClientMessage(message, client.handshaken);
if (!validation.ok) {
sendJson(client, 'error', validation.error, message?.requestId ?? null);
return;
}
if (message?.type === 'hello') {
client.handshaken = true;
sendJson(client, 'hello_ack', {
sessionId: `sess_${Date.now()}`,
accepted: true,
@@ -361,7 +250,7 @@ function createBridgeRuntime(options = {}) {
remoteUrl: globalThis.__wandRemoteBridgeUrl,
advertisedUrls,
}, message.requestId ?? null);
sendSnapshot(client);
bridgeState.sendSnapshot(client);
return;
}
@@ -380,6 +269,7 @@ function createBridgeRuntime(options = {}) {
socket,
buffer: Buffer.alloc(0),
closed: false,
handshaken: false,
};
clients.add(client);
@@ -418,6 +308,10 @@ function createBridgeRuntime(options = {}) {
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.',
@@ -443,15 +337,24 @@ function createBridgeRuntime(options = {}) {
}
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;
}
@@ -490,7 +393,7 @@ function createBridgeRuntime(options = {}) {
});
server.on('listening', () => {
listening = true;
log('info', `Listening on ${globalThis.__wandRemoteBridgeUrl}`);
log('info', `Listening on ${host}:${port}.`);
});
listen(port);
@@ -510,32 +413,66 @@ function createBridgeRuntime(options = {}) {
closeClient(client);
}
clients.clear();
currentSnapshot = null;
currentInstalledApps = null;
currentInstalledAppsSignature = null;
currentGameStatus = null;
currentGameStatusSignature = null;
bridgeState.clear();
listening = false;
server.close();
},
setCommandHandler,
setHandler,
sync,
syncGameStatus,
syncInstalledApps,
valueChanged,
sync: bridgeState.sync,
syncTrainerMeta: bridgeState.syncTrainerMeta,
syncGameStatus: bridgeState.syncGameStatus,
syncInstalledApps: bridgeState.syncInstalledApps,
valueChanged: bridgeState.valueChanged,
};
}
function ensureBridge(options = {}) {
if (!globalThis.__wandRemoteBridgeRuntime) {
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
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;
}
return globalThis.__wandRemoteBridgeRuntime;
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 = {
createBridgeRuntime,
ensureBridge,
createBridgeServer,
};
+24
View File
@@ -0,0 +1,24 @@
export type BridgeOptions = {
host?: string;
logFile?: string;
maxPort?: number | string;
panelRoot?: string;
port?: number | string;
scriptsRoot?: string;
};
export type WebContentsPort = {
executeJavaScript(source: string, userGesture?: boolean): Promise<unknown>;
isDestroyed(): boolean;
on(event: string, listener: () => void): void;
send(channel: string, payload: unknown): void;
};
export type ElectronPort = {
app: {
on(event: 'web-contents-created', listener: (event: unknown, contents: WebContentsPort) => void): void;
};
ipcMain: {
handle(channel: string, handler: (event: { sender?: WebContentsPort }, payload?: unknown) => unknown): void;
};
};
@@ -1,8 +1,9 @@
const fs = require('node:fs');
const path = require('node:path');
const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('./constants.cjs');
const { writeInstallLog } = require('./logger.cjs');
const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('../constants');
const { writeInstallLog } = require('../logger');
import type { BridgeOptions, ElectronPort } from '../types';
function loadRendererScripts(panelRoot, scriptsRoot) {
const root = scriptsRoot || path.join(panelRoot, RENDERER_SCRIPTS_DIR);
@@ -51,7 +52,7 @@ function buildRendererBootstrap(remoteUrl, scripts) {
return `(() => {\n${header}\n${body}\n})();`;
}
function installRendererScripts(electron, runtime, options = {}) {
function installRendererScripts(electron: ElectronPort, runtime, options: BridgeOptions = {}) {
if (globalThis.__wandRemoteBridgeRendererScriptsInstalled) {
return;
}
@@ -8,19 +8,24 @@ const {
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS,
REMOTE_GAME_STATUS_CHANNEL,
REMOTE_INSTALLED_APPS_CHANNEL,
} = require('./constants.cjs');
const { writeInstallLog } = require('./logger.cjs');
const { ensureBridge } = require('./runtime.cjs');
const { installRendererScripts } = require('./renderer-scripts.cjs');
const { safeString } = require('./utils.cjs');
} = require('../constants');
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';
function installWandRuntime(electron, options = {}) {
const WEMOD_ACCESS_TOKEN_SCRIPT =
'JSON.parse(localStorage.getItem("infinity:globalStore") || "{}")?.token?.accessToken ?? null';
function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) {
const runtime = ensureBridge(options);
if (!electron || !electron.ipcMain || !electron.app) {
throw new Error('Electron main-process API is required to install Wand runtime hooks.');
}
const boundRenderers = globalThis.__wandRemoteBridgeBoundRenderers || new Set();
const boundRenderers: Set<WebContentsPort> = globalThis.__wandRemoteBridgeBoundRenderers || new Set();
const pendingCommandResponses = globalThis.__wandRemoteBridgePendingCommandResponses || new Map();
globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers;
globalThis.__wandRemoteBridgePendingCommandResponses = pendingCommandResponses;
@@ -77,8 +82,17 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
}
globalThis.__wandRemoteBridgeIpcInstalled = true;
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (_event, snapshot) => {
let trainerSnapshotRevision = 0;
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (event, 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) => {
@@ -113,6 +127,25 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
electron.ipcMain.handle(IPC_CHANNEL.REMOTE_URL, () => runtime.remoteUrl);
}
async function localizeSnapshot(sender, snapshot) {
const accessToken = await readWemodAccessToken(sender);
return localizeTrainerSnapshot(snapshot, accessToken);
}
async function readWemodAccessToken(sender) {
if (!sender || typeof sender.executeJavaScript !== 'function' || sender.isDestroyed?.()) {
return null;
}
try {
const token = await sender.executeJavaScript(WEMOD_ACCESS_TOKEN_SCRIPT);
return typeof token === 'string' && token ? token : null;
} catch (error) {
writeInstallLog('warn', 'Failed to read WeMod access token from renderer.', error);
return null;
}
}
function dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses) {
return new Promise((resolve, reject) => {
const requestId = `remote_command_${typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : Date.now().toString(36)}`;
+98
View File
@@ -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
View File
@@ -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 : ""
}
@@ -1,8 +1,15 @@
const crypto = require('node:crypto');
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants.cjs');
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({
@@ -15,7 +22,7 @@ function jsonMessage(type, payload, requestId = null) {
function makeFrame(opcode, payload) {
const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
const header = [];
const header: number[] = [];
header.push(0x80 | (opcode & 0x0f));
if (source.length < 126) {
@@ -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,
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noImplicitAny": false,
"types": ["node"]
},
"include": ["src"]
}
+12 -1
View File
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['dist', 'src/locales']),
{
files: ['**/*.{ts,tsx}'],
extends: [
@@ -19,5 +19,16 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'react-hooks/set-state-in-effect': 'off',
},
},
{
files: ['bridge/src/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'no-empty': 'off',
},
},
])
+2 -2
View File
@@ -7,6 +7,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.tsx"></script>
<script type="module" src="./src/app/main.tsx"></script>
</body>
</html>
</html>
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from '@lingui/cli';
import { formatter } from '@lingui/format-po';
export default defineConfig({
sourceLocale: 'en-US',
locales: ['en-US', 'ru-RU', 'de-DE', 'fr-FR', 'es-ES', 'zh-CN'],
catalogs: [
{
path: '<rootDir>/src/locales/{locale}/messages',
include: ['src'],
},
],
format: formatter({ lineNumbers: false }),
});
+20 -2
View File
@@ -2,23 +2,38 @@
"name": "wand-web-panel",
"private": true,
"version": "0.1.0",
"packageManager": "pnpm@10.17.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:host": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build && pnpm run build:bridge",
"build:bridge": "node ./bridge/build.mjs",
"lint": "eslint src protocol bridge/src --max-warnings=0",
"typecheck:web": "tsc --noEmit",
"typecheck:bridge": "tsc -p bridge/tsconfig.json --noEmit",
"typecheck": "pnpm typecheck:web && pnpm typecheck:bridge",
"test": "pnpm build:bridge && vitest run",
"i18n:extract": "lingui extract",
"i18n:compile": "lingui compile",
"preview": "vite preview",
"preview:host": "vite preview --host 0.0.0.0",
"bridge": "node ./bridge/server.mjs"
"bridge:demo": "node ./bridge/dev-server.mjs"
},
"dependencies": {
"@lingui/core": "^6.3.0",
"@lingui/react": "^6.3.0",
"preact": "^10.27.2",
"ws": "^8.18.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@lingui/babel-plugin-lingui-macro": "^6.3.0",
"@lingui/cli": "^6.3.0",
"@lingui/format-po": "^6.3.0",
"@lingui/vite-plugin": "^6.3.0",
"@tailwindcss/vite": "^4.2.1",
"@testing-library/preact": "^3.2.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -28,10 +43,13 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^16.5.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^7.3.2"
"typescript-eslint": "^8.61.0",
"vite": "^7.3.2",
"vitest": "^4.1.8"
}
}
Generated Vendored
+2261
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
import contract from './web-contract.json';
export const WEB_CONTRACT = contract;
export const PROTOCOL_VERSION = contract.protocolVersion;
@@ -1,5 +1,4 @@
export const PROTOCOL_VERSION = 1;
const NUMBER_GROUP_SEPARATOR_PATTERN = /[,\s]/g;
export { PROTOCOL_VERSION } from './contract';
// String values mirror the wire protocol; do not rename the right-hand side.
export enum ECheatType {
@@ -50,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;
@@ -193,7 +190,6 @@ export type HelloMessage = MessageEnvelope<
{
client: 'mobile-web';
clientVersion: string;
pairingToken?: string;
capabilities: {
supportsDeltaValues: boolean;
supportsTrainerSwitch: boolean;
@@ -236,51 +232,3 @@ export type IncomingMessage =
| ErrorMessage;
export type OutgoingMessage = HelloMessage | SetValueMessage | RemoteCommandMessage;
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function isIncomingMessage(value: unknown): value is IncomingMessage {
return isRecord(value) && typeof value.type === 'string' && typeof value.version === 'number' && 'payload' in value;
}
export function resolveOption(option: CheatOptionLike): CheatOption {
if (typeof option === 'string' || typeof option === 'number') {
return { label: String(option), value: option };
}
return {
label: option.label ?? String(option.value),
value: option.value,
};
}
export function normalizeIncomingValue(cheat: CheatSchema, value: unknown): unknown {
if (cheat.type === ECheatType.Toggle) {
return Boolean(value);
}
return value;
}
export function normalizeOutgoingValue(cheat: CheatSchema, value: unknown): unknown {
if (cheat.type === ECheatType.Toggle) {
return Boolean(value);
}
if (cheat.type !== ECheatType.Slider && cheat.type !== ECheatType.Number) {
return value;
}
if (typeof value !== 'string') {
return value;
}
const trimmedValue = value.trim();
if (!trimmedValue) {
return value;
}
return Number(trimmedValue.replace(NUMBER_GROUP_SEPARATOR_PATTERN, ''));
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { PROTOCOL_VERSION } from './contract';
import { isIncomingMessage, isOutgoingMessage } from './validation';
describe('web protocol validation', () => {
it('rejects messages with another envelope version', () => {
expect(isIncomingMessage({
type: 'error',
version: PROTOCOL_VERSION + 1,
requestId: null,
payload: { code: 'bad', message: 'bad' },
})).toBe(false);
});
it('rejects incomplete required payloads', () => {
expect(isIncomingMessage({
type: 'hello_ack',
version: PROTOCOL_VERSION,
requestId: null,
payload: { accepted: true },
})).toBe(false);
expect(isOutgoingMessage({
type: 'set_value',
version: PROTOCOL_VERSION,
requestId: 'set',
payload: { target: 'speed', value: 1 },
})).toBe(false);
});
});
+97
View File
@@ -0,0 +1,97 @@
import { PROTOCOL_VERSION } from './contract';
import type { IncomingMessage, OutgoingMessage } from './messages';
const INCOMING_TYPES = new Set<IncomingMessage['type']>([
'hello_ack',
'trainer_meta',
'trainer_values',
'game_status',
'installed_apps',
'value_changed',
'trainer_changed',
'set_value_result',
'remote_command_result',
'error',
]);
const OUTGOING_TYPES = new Set<OutgoingMessage['type']>(['hello', 'set_value', 'remote_command']);
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function isIncomingMessage(value: unknown): value is IncomingMessage {
if (!isEnvelope(value) || !INCOMING_TYPES.has(value.type as IncomingMessage['type'])) {
return false;
}
const payload = value.payload;
switch (value.type) {
case 'hello_ack':
return hasString(payload, 'sessionId') && hasBoolean(payload, 'accepted') && hasString(payload, 'serverVersion')
&& hasNumber(payload, 'protocolVersion');
case 'trainer_meta':
return isRecord(payload.session) && hasString(payload.session, 'instanceId')
&& isRecord(payload.trainer) && hasString(payload.trainer, 'trainerId')
&& isRecord(payload.schema) && Array.isArray(payload.schema.categories) && Array.isArray(payload.schema.cheats);
case 'trainer_values':
return hasString(payload, 'trainerId') && isRecord(payload.values);
case 'installed_apps':
return hasString(payload, 'instanceId') && hasString(payload, 'updatedAt') && Array.isArray(payload.apps);
case 'game_status':
return hasString(payload, 'instanceId') && hasString(payload, 'updatedAt')
&& isRecord(payload.session) && isRecord(payload.trainer);
case 'value_changed':
return hasString(payload, 'trainerId') && hasString(payload, 'target') && 'value' in payload;
case 'trainer_changed':
return hasString(payload, 'trainerId');
case 'set_value_result':
return hasBoolean(payload, 'ok') && hasString(payload, 'trainerId') && hasString(payload, 'target');
case 'remote_command_result':
return hasBoolean(payload, 'ok') && (payload.action === 'launch' || payload.action === 'stop');
case 'error':
return hasString(payload, 'code') && hasString(payload, 'message');
default:
return false;
}
}
export function isOutgoingMessage(value: unknown): value is OutgoingMessage {
if (!isEnvelope(value) || !OUTGOING_TYPES.has(value.type as OutgoingMessage['type'])) {
return false;
}
const payload = value.payload;
if (value.type === 'hello') {
return payload.client === 'mobile-web' && hasString(payload, 'clientVersion') && isRecord(payload.capabilities);
}
if (value.type === 'set_value') {
return hasString(payload, 'trainerId') && hasString(payload, 'target') && 'value' in payload;
}
return (payload.action === 'launch' || payload.action === 'stop');
}
function isEnvelope(value: unknown): value is Record<string, unknown> & {
type: string;
version: number;
requestId: string | null;
payload: Record<string, unknown>;
} {
return isRecord(value)
&& typeof value.type === 'string'
&& value.version === PROTOCOL_VERSION
&& (value.requestId === null || typeof value.requestId === 'string')
&& isRecord(value.payload);
}
function hasString(value: Record<string, unknown>, key: string): boolean {
return typeof value[key] === 'string';
}
function hasNumber(value: Record<string, unknown>, key: string): boolean {
return typeof value[key] === 'number';
}
function hasBoolean(value: Record<string, unknown>, key: string): boolean {
return typeof value[key] === 'boolean';
}
+13
View File
@@ -0,0 +1,13 @@
{
"protocolVersion": 1,
"clientVersion": "0.2.0",
"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"
}
-341
View File
@@ -1,341 +0,0 @@
import { useEffect, useMemo, useReducer, useRef, useState, type UIEvent } from 'react';
import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from '@/features/remote-panel/category';
import { CategorySection } from '@/features/remote-panel/components/CategorySection';
import { Drawer } from '@/features/remote-panel/components/Drawer';
import { FloatingDock } from '@/features/remote-panel/components/FloatingDock';
import { LibraryDrawer } from '@/features/remote-panel/components/LibraryDrawer';
import { PlaceholderState } from '@/features/remote-panel/components/PlaceholderState';
import { QuickActions } from '@/features/remote-panel/components/QuickActions';
import { SearchInput } from '@/features/remote-panel/components/SearchInput';
import { SettingsDrawer } from '@/features/remote-panel/components/SettingsDrawer';
import { TopBar } from '@/features/remote-panel/components/TopBar';
import { TrainerHeader } from '@/features/remote-panel/components/TrainerHeader';
import { buildLibraryGames, getCurrentGame, type LibraryGame } from '@/features/remote-panel/game-library';
import { loadPinnedGameIds, savePinnedGameIds, togglePinnedGame } from '@/features/remote-panel/game-pin-storage';
import { handleProtocolMessage } from '@/features/remote-panel/message-handler';
import { getPinnedStorageKey, loadPinnedTargets, savePinnedTargets } from '@/features/remote-panel/pinned-storage';
import { capturePresetValues, createPreset, getPresetStorageKey, loadPresets, savePresets, type RemotePreset } from '@/features/remote-panel/preset-storage';
import { normalizeOutgoingValue, type CheatSchema, type InstalledAppSummary, type TrainerMetaPayload } from '@/features/remote-panel/protocol';
import { ECheatType } from '@/features/remote-panel/protocol';
import { PanelSocketClient } from '@/features/remote-panel/socket-client';
import { createInitialPanelState, EConnectionStatus, panelReducer } from '@/features/remote-panel/state';
const SCROLL_HIDE_THRESHOLD_PX = 60;
const SCROLL_REVEAL_DEAD_ZONE_PX = 4;
export const App = () => {
const [state, dispatch] = useReducer(panelReducer, createInitialPanelState());
const [cheatQuery, setCheatQuery] = useState('');
const [gameQuery, setGameQuery] = useState('');
const [leftOpen, setLeftOpen] = useState(false);
const [rightOpen, setRightOpen] = useState(false);
const [hideDock, setHideDock] = useState(false);
const [pinnedGameIds, setPinnedGameIds] = useState<Record<string, true>>({});
const [presets, setPresets] = useState<RemotePreset[]>([]);
const lastScrollRef = useRef(0);
const clientRef = useRef<PanelSocketClient | null>(null);
const trainerMetaRef = useRef<TrainerMetaPayload | null>(state.trainerMeta);
useEffect(() => {
setPinnedGameIds(loadPinnedGameIds());
return () => {
clientRef.current?.disconnect();
clientRef.current = null;
};
}, []);
useEffect(() => {
trainerMetaRef.current = state.trainerMeta;
}, [state.trainerMeta]);
const activeTrainer = state.trainerMeta?.trainer ?? null;
const libraryGames = useMemo(
() => buildLibraryGames(state.installedApps, state.gameStatus, activeTrainer, pinnedGameIds),
[activeTrainer, pinnedGameIds, state.gameStatus, state.installedApps],
);
const currentGame = useMemo(() => getCurrentGame(libraryGames), [libraryGames]);
const groups = useMemo(() => groupCheatsByCategory(state.trainerMeta), [state.trainerMeta]);
const pinnedGroup = useMemo(() => buildPinnedGroup(state.trainerMeta, state.pinnedTargets), [state.trainerMeta, state.pinnedTargets]);
const filteredGroups = useMemo(() => filterGroups(groups, cheatQuery), [cheatQuery, groups]);
const filteredPinnedGroup = useMemo(
() => (pinnedGroup ? filterGroups([pinnedGroup], cheatQuery)[0] ?? null : null),
[cheatQuery, pinnedGroup],
);
const pinnedStorageKey = useMemo(() => getPinnedStorageKey(activeTrainer), [activeTrainer]);
const presetStorageKey = useMemo(() => getPresetStorageKey(activeTrainer), [activeTrainer]);
const socketReady = clientRef.current?.isOpen() ?? false;
const connected = state.connectionStatus === EConnectionStatus.Connected;
const controlsDisabled = Boolean(activeTrainer?.trainerLoading || activeTrainer?.isTimeLimitExpired);
const totalVisibleCheats = filteredGroups.reduce((count, group) => count + group.cheats.length, filteredPinnedGroup?.cheats.length ?? 0);
useEffect(() => {
dispatch({ type: 'setPinnedTargets', pinned: loadPinnedTargets(pinnedStorageKey) });
}, [pinnedStorageKey]);
useEffect(() => {
setPresets(loadPresets(presetStorageKey));
}, [presetStorageKey]);
useEffect(() => {
if (state.wsUrl.trim()) {
handleConnect();
}
}, []);
function handleConnect(): void {
clientRef.current?.disconnect();
const wsUrl = state.wsUrl.trim();
if (!wsUrl) {
dispatch({ type: 'error', message: 'Enter a WebSocket URL first.' });
return;
}
const nextClient = new PanelSocketClient(wsUrl, {
onConnecting: () => dispatch({ type: 'connecting' }),
onOpen: () => dispatch({ type: 'connected' }),
onMessage: (message) => handleProtocolMessage(dispatch, message, trainerMetaRef.current),
onClose: () => dispatch({ type: 'error', message: 'The WebSocket connection closed.' }),
onError: (message) => dispatch({ type: 'error', message }),
});
clientRef.current = nextClient;
nextClient.connect();
}
function handleDisconnect(): void {
clientRef.current?.disconnect();
clientRef.current = null;
dispatch({ type: 'disconnected' });
}
function handleCheatChange(cheat: CheatSchema, nextValue: unknown): void {
const normalizedValue = normalizeOutgoingValue(cheat, nextValue);
dispatch({ type: 'setPending', target: cheat.target, pending: true });
dispatch({ type: 'valueChanged', target: cheat.target, value: normalizedValue });
if (state.connectionStatus !== EConnectionStatus.Connected || !state.trainerMeta || !clientRef.current) {
dispatch({ type: 'setPending', target: cheat.target, pending: false });
return;
}
const sent = clientRef.current.setValue(state.trainerMeta.trainer.trainerId, cheat.target, normalizedValue, cheat.uuid);
if (!sent) {
dispatch({ type: 'setPending', target: cheat.target, pending: false });
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
}
}
function handleToggleCheatPin(cheat: CheatSchema): void {
const next = { ...state.pinnedTargets };
if (next[cheat.target]) {
delete next[cheat.target];
} else {
next[cheat.target] = true;
}
dispatch({ type: 'togglePinnedTarget', target: cheat.target });
savePinnedTargets(pinnedStorageKey, next);
}
function handleToggleGamePin(game: LibraryGame): void {
const next = togglePinnedGame(game, pinnedGameIds);
setPinnedGameIds(next);
savePinnedGameIds(next);
}
function handleLaunchGame(app: InstalledAppSummary): void {
const client = clientRef.current;
if (!app.gameId) {
dispatch({ type: 'error', message: 'This My Games entry does not expose a Wand game id.' });
return;
}
if (!client?.isOpen()) {
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
return;
}
if (!client.launchGame(app.gameId, app.titleId ?? undefined)) {
dispatch({ type: 'error', message: 'Failed to send the launch command to the bridge.' });
return;
}
setRightOpen(false);
}
function handlePlayGame(game: LibraryGame): void {
handleLaunchGame(game.app);
}
function handleStopPlaying(): void {
const client = clientRef.current;
if (!client?.isOpen()) {
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
return;
}
const activeGameId = state.gameStatus?.session.gameId ?? state.gameStatus?.trainer.gameId ?? undefined;
const activeTitleId = state.gameStatus?.session.titleId ?? state.gameStatus?.trainer.titleId ?? undefined;
if (!client.stopPlaying(activeGameId ?? undefined, activeTitleId ?? undefined)) {
dispatch({ type: 'error', message: 'Failed to send the stop command to the bridge.' });
}
}
function handlePanic(): void {
if (!state.trainerMeta) {
return;
}
for (const cheat of state.trainerMeta.schema.cheats) {
if (cheat.type === ECheatType.Toggle && Boolean(state.values[cheat.target])) {
handleCheatChange(cheat, false);
}
}
}
function handleAddPreset(name: string): boolean {
if (!state.trainerMeta) {
dispatch({ type: 'error', message: 'No active trainer to save as a preset.' });
return false;
}
const values = capturePresetValues(state.trainerMeta.schema.cheats, state.values);
if (Object.keys(values).length === 0) {
dispatch({ type: 'error', message: 'There are no mod values to save yet.' });
return false;
}
const nextPresets = [...presets, createPreset(name, values)];
setPresets(nextPresets);
savePresets(presetStorageKey, nextPresets);
return true;
}
function handleApplyPreset(preset: RemotePreset): void {
if (!state.trainerMeta) {
return;
}
for (const cheat of state.trainerMeta.schema.cheats) {
if (!(cheat.target in preset.values)) {
continue;
}
handleCheatChange(cheat, preset.values[cheat.target]);
}
}
function handleDeletePreset(presetId: string): void {
const nextPresets = presets.filter((preset) => preset.id !== presetId);
setPresets(nextPresets);
savePresets(presetStorageKey, nextPresets);
}
function handleScroll(event: UIEvent<HTMLDivElement>): void {
const y = event.currentTarget.scrollTop;
if (y > lastScrollRef.current && y > SCROLL_HIDE_THRESHOLD_PX) {
setHideDock(true);
} else if (y < lastScrollRef.current - SCROLL_REVEAL_DEAD_ZONE_PX) {
setHideDock(false);
}
lastScrollRef.current = y;
}
return (
<main className="min-h-svh bg-[#050608] text-(--deck-fg)">
<div className="flex min-h-svh w-full p-0">
<section className="relative h-svh w-full overflow-hidden bg-(--deck-bg) shadow-[0_40px_100px_-20px_rgba(0,0,0,.7),0_0_0_1px_rgba(255,255,255,.06)]">
<div className="pointer-events-none absolute -inset-12 z-0 bg-[radial-gradient(circle_at_30%_15%,color-mix(in_oklab,var(--deck-accent)_22%,transparent),transparent_45%),radial-gradient(circle_at_80%_85%,color-mix(in_oklab,var(--deck-accent)_16%,transparent),transparent_45%),radial-gradient(circle_at_20%_80%,color-mix(in_oklab,var(--deck-accent)_8%,transparent),transparent_50%)] blur-[50px]" />
<div className="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(ellipse_100%_60%_at_50%_0%,rgba(255,255,255,0.025),transparent)]" />
<div className="relative z-10 flex h-full flex-col">
<TopBar status={state.connectionStatus} currentGame={currentGame} runningTrainer={activeTrainer} onOpenSettings={() => setLeftOpen(true)} />
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 pb-[110px]" onScroll={handleScroll}>
{!connected ? (
<PlaceholderState icon="plug" title="Bridge offline" sub="Open Settings to point Wand at your trainer bridge over WebSocket." action="Open Settings" onAction={() => setLeftOpen(true)} />
) : !activeTrainer ? (
<PlaceholderState icon="gamepad-variant-outline" title="Select a game" sub="No game is running yet. Open the library and launch one to start tweaking." action="Browse library" onAction={() => setRightOpen(true)} />
) : (
<>
<TrainerHeader trainer={activeTrainer} game={currentGame} isPinned={Boolean(currentGame && pinnedGameIds[currentGame.id])} onPin={() => currentGame && handleToggleGamePin(currentGame)} />
<QuickActions presets={presets} onAddPreset={handleAddPreset} onApplyPreset={handleApplyPreset} onDeletePreset={handleDeletePreset} onPanic={handlePanic} />
<div className="sticky top-0 z-10 -mx-3.5 mb-2.5 px-3.5 py-0.5">
<SearchInput value={cheatQuery} placeholder="Search mods" onChange={setCheatQuery} />
</div>
{filteredPinnedGroup ? (
<CategorySection
forceOpen={Boolean(cheatQuery)}
group={filteredPinnedGroup}
values={state.values}
pendingTargets={state.pendingTargets}
pinnedTargets={state.pinnedTargets}
disabled={controlsDisabled}
onCheatChange={handleCheatChange}
onTogglePin={handleToggleCheatPin}
/>
) : null}
{filteredGroups.map((group, index) => (
<CategorySection
key={group.id}
forceOpen={Boolean(cheatQuery)}
group={group}
openByDefault={index < 2}
values={state.values}
pendingTargets={state.pendingTargets}
pinnedTargets={state.pinnedTargets}
disabled={controlsDisabled}
onCheatChange={handleCheatChange}
onTogglePin={handleToggleCheatPin}
/>
))}
{cheatQuery && totalVisibleCheats === 0 ? <p className="px-8 py-8 text-center text-[13px] text-(--deck-fg-4)">No mods match "{cheatQuery}"</p> : null}
<div className="mt-4 text-center font-mono text-[10px] uppercase tracking-[0.08em] text-(--deck-fg-4)">
{cheatQuery ? `${totalVisibleCheats} matches` : `END · ${state.trainerMeta?.schema.cheats.length ?? 0} MODS`}
</div>
</>
)}
</div>
</div>
<FloatingDock
status={state.connectionStatus}
runningGameTitle={currentGame?.title ?? null}
hidden={hideDock}
leftHasBadge={!connected}
rightHasBadge={connected && !currentGame}
onOpenSettings={() => setLeftOpen(true)}
onOpenLibrary={() => setRightOpen(true)}
/>
<Drawer open={leftOpen} side="left" onClose={() => setLeftOpen(false)}>
<SettingsDrawer
status={state.connectionStatus}
wsUrl={state.wsUrl}
currentGame={currentGame}
currentTrainer={activeTrainer}
lastError={state.lastError}
onClose={() => setLeftOpen(false)}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onWsUrlChange={(wsUrl) => dispatch({ type: 'setWsUrl', wsUrl })}
/>
</Drawer>
<Drawer open={rightOpen} side="right" onClose={() => setRightOpen(false)}>
<LibraryDrawer
games={libraryGames}
query={gameQuery}
canLaunch={socketReady}
onClose={() => setRightOpen(false)}
onPin={handleToggleGamePin}
onPlay={handlePlayGame}
onStop={handleStopPlaying}
onQueryChange={setGameQuery}
/>
</Drawer>
</section>
</div>
</main>
);
};
+122
View File
@@ -0,0 +1,122 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react';
import { FloatingDock } from '@/app/ui/FloatingDock';
import { SessionPlaceholder } from '@/app/ui/SessionPlaceholder';
import { SettingsDrawer } from '@/app/ui/SettingsDrawer';
import { TopBar } from '@/app/ui/TopBar';
import { LibraryDrawer } from '@/library/ui/LibraryDrawer';
import { Drawer } from '@/shared/ui/Drawer';
import { SearchInput } from '@/shared/ui/SearchInput';
import { CategorySection } from '@/trainer/ui/CategorySection';
import { QuickActions } from '@/trainer/ui/QuickActions';
import { TrainerHeader } from '@/trainer/ui/TrainerHeader';
import { useRemotePanel } from './use-remote-panel';
export const App = () => {
const { _ } = useLingui();
const panel = useRemotePanel();
const { session, trainer, library, shell } = panel;
return (
<main className="min-h-svh bg-[#050608] text-(--deck-fg)">
<div className="flex min-h-svh w-full p-0">
<section className="relative h-svh w-full overflow-hidden bg-(--deck-bg) shadow-[0_40px_100px_-20px_rgba(0,0,0,.7),0_0_0_1px_rgba(255,255,255,.06)]">
<div className="pointer-events-none absolute -inset-12 z-0 bg-[radial-gradient(circle_at_30%_15%,color-mix(in_oklab,var(--deck-accent)_22%,transparent),transparent_45%),radial-gradient(circle_at_80%_85%,color-mix(in_oklab,var(--deck-accent)_16%,transparent),transparent_45%),radial-gradient(circle_at_20%_80%,color-mix(in_oklab,var(--deck-accent)_8%,transparent),transparent_50%)]" />
<div className="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(ellipse_100%_60%_at_50%_0%,rgba(255,255,255,0.025),transparent)]" />
<div className="relative z-10 flex h-full flex-col">
<TopBar status={session.status} currentGame={library.currentGame} runningTrainer={trainer.activeTrainer} onOpenSettings={shell.openSettings} />
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 pb-27.5" onScroll={shell.onScroll}>
{!session.connected || !trainer.activeTrainer ? (
<SessionPlaceholder
connected={session.connected}
activeTrainer={trainer.activeTrainer}
onOpenLibrary={shell.openLibrary}
onOpenSettings={shell.openSettings}
/>
) : (
<>
<TrainerHeader trainer={trainer.activeTrainer} game={library.currentGame} isPinned={Boolean(library.currentGame && library.pinnedGameIds[library.currentGame.id])} onPin={() => library.currentGame && library.togglePin(library.currentGame)} />
<QuickActions presets={trainer.presets} onAddPreset={trainer.addPreset} onApplyPreset={trainer.applyPreset} onDeletePreset={trainer.deletePreset} onPanic={trainer.panic} />
<div className="sticky top-0 z-10 -mx-3.5 mb-2.5 px-3.5 py-0.5">
<SearchInput value={trainer.query} placeholder={_(msg`Search mods`)} onChange={trainer.setQuery} />
</div>
{trainer.filteredPinnedGroup ? (
<CategorySection
forceOpen={Boolean(trainer.query)}
group={trainer.filteredPinnedGroup}
values={session.values}
pendingTargets={session.pendingTargets}
pinnedTargets={trainer.pinnedTargets}
disabled={trainer.controlsDisabled}
onCheatChange={trainer.changeCheat}
onTogglePin={trainer.togglePin}
/>
) : null}
{trainer.filteredGroups.map((group, index) => (
<CategorySection
key={group.id}
forceOpen={Boolean(trainer.query)}
group={group}
openByDefault={index < 2}
values={session.values}
pendingTargets={session.pendingTargets}
pinnedTargets={trainer.pinnedTargets}
disabled={trainer.controlsDisabled}
onCheatChange={trainer.changeCheat}
onTogglePin={trainer.togglePin}
/>
))}
{trainer.query && trainer.totalVisibleCheats === 0 ? <p className="px-8 py-8 text-center text-[13px] text-(--deck-fg-4)"><Trans>No mods match "{trainer.query}"</Trans></p> : null}
<div className="mt-4 text-center font-mono text-[10px] uppercase tracking-[0.08em] text-(--deck-fg-4)">
{trainer.query
? _(msg`${trainer.totalVisibleCheats} matches`)
: _(msg`END · ${trainer.totalCheats} MODS`)}
</div>
</>
)}
</div>
</div>
<FloatingDock
status={session.status}
runningGameTitle={library.currentGame?.title ?? null}
hidden={shell.dockHidden}
leftHasBadge={!session.connected}
rightHasBadge={session.connected && !library.currentGame}
onOpenSettings={shell.openSettings}
onOpenLibrary={shell.openLibrary}
/>
<Drawer open={shell.leftOpen} side="left" onClose={shell.closeSettings}>
<SettingsDrawer
status={session.status}
wsUrl={session.wsUrl}
currentGame={library.currentGame}
currentTrainer={trainer.activeTrainer}
lastError={session.lastError}
onClose={shell.closeSettings}
onConnect={session.connect}
onDisconnect={session.disconnect}
onWsUrlChange={session.setWsUrl}
/>
</Drawer>
<Drawer open={shell.rightOpen} side="right" onClose={shell.closeLibrary}>
<LibraryDrawer
games={library.games}
query={library.query}
canLaunch={session.socketReady}
onClose={shell.closeLibrary}
onPin={library.togglePin}
onPlay={library.playGame}
onStop={library.stopPlaying}
onQueryChange={library.setQuery}
/>
</Drawer>
</section>
</div>
</main>
);
};
+70
View File
@@ -0,0 +1,70 @@
import { i18n, type Messages } from '@lingui/core';
export const DEFAULT_LOCALE = 'en-US';
export const SUPPORTED_LOCALES = [
{ code: 'en-US', label: 'English' },
{ code: 'ru-RU', label: 'Русский' },
{ code: 'de-DE', label: 'Deutsch' },
{ code: 'fr-FR', label: 'Français' },
{ code: 'es-ES', label: 'Español' },
{ code: 'zh-CN', label: '简体中文' },
] as const;
export type LocaleCode = (typeof SUPPORTED_LOCALES)[number]['code'];
const LOCALE_STORAGE_KEY = 'wand:locale';
type CatalogModule = { messages: Messages };
const catalogs = import.meta.glob<CatalogModule>('../locales/*/messages.po');
export async function activateLocale(locale: LocaleCode): Promise<void> {
const loadCatalog = catalogs[`../locales/${locale}/messages.po`];
if (!loadCatalog) {
throw new Error(`Locale catalog not found: ${locale}`);
}
const { messages } = await loadCatalog();
i18n.load(locale, messages);
i18n.activate(locale);
persistLocale(locale);
}
export function detectInitialLocale(): LocaleCode {
return readStoredLocale() ?? matchBrowserLocale() ?? DEFAULT_LOCALE;
}
function readStoredLocale(): LocaleCode | null {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
return isSupportedLocale(stored) ? stored : null;
} catch {
return null;
}
}
function matchBrowserLocale(): LocaleCode | null {
const candidates = typeof navigator === 'undefined' ? [] : (navigator.languages ?? [navigator.language]);
for (const candidate of candidates) {
const base = candidate.split('-')[0];
const match = SUPPORTED_LOCALES.find(({ code }) => code === candidate || code.split('-')[0] === base);
if (match) {
return match.code;
}
}
return null;
}
function persistLocale(locale: LocaleCode): void {
try {
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
} catch {
// Ignore storage failures (private mode, blocked cookies, etc.).
}
}
function isSupportedLocale(value: string | null): value is LocaleCode {
return value !== null && SUPPORTED_LOCALES.some(({ code }) => code === value);
}
+28
View File
@@ -0,0 +1,28 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { applySavedAccentColor } from '@/appearance/appearance-storage';
import { App } from './app';
import { activateLocale, detectInitialLocale } from './i18n';
import '../index.css';
const root = document.getElementById('root') ?? document.getElementById('app');
if (!root) {
throw new Error('App root not found.');
}
applySavedAccentColor();
activateLocale(detectInitialLocale()).then(() => {
createRoot(root).render(
<StrictMode>
<I18nProvider i18n={i18n}>
<App />
</I18nProvider>
</StrictMode>,
);
});
@@ -1,7 +1,10 @@
import { Icon, type IconName } from '@/components/ui/icon';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { cn } from '@/lib/utils';
import { EConnectionStatus } from '../state';
import { Icon, type IconName } from '@/shared/ui/Icon';
import { cn } from '@/shared/lib/ui';
import { EConnectionStatus } from '@/remote-session/remote-session.reducer';
type FloatingDockProps = {
status: EConnectionStatus;
@@ -22,18 +25,19 @@ export const FloatingDock = ({
onOpenSettings,
onOpenLibrary,
}: FloatingDockProps) => {
const { _ } = useLingui();
const live = status === EConnectionStatus.Connected;
return (
<div className={cn('absolute bottom-4.5 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-white/10 bg-[#0e1016]/80 p-1.5 shadow-[0_12px_40px_-10px_rgba(0,0,0,.65),inset_0_1px_0_rgba(255,255,255,.05)] backdrop-blur-2xl transition duration-300', hidden ? 'translate-y-20 opacity-0' : 'translate-y-0 opacity-100')}>
<DockButton badge={leftHasBadge} icon="settings" label="Settings" onClick={onOpenSettings} />
<DockButton badge={leftHasBadge} icon="settings" label={_(msg`Settings`)} onClick={onOpenSettings} />
<div className="flex h-9.5 items-center gap-2 border-x border-white/10 px-3">
<span className={cn('size-1.5 rounded-full', live ? 'bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)] motion-safe:animate-[breathe_2s_ease-in-out_infinite]' : 'bg-(--deck-fg-4)')} />
<span className="max-w-30 truncate text-[11px] font-semibold text-(--deck-fg-2)">
{runningGameTitle || 'No session'}
{runningGameTitle || _(msg`No session`)}
</span>
</div>
<DockButton badge={rightHasBadge} icon="list" label="Library" onClick={onOpenLibrary} />
<DockButton badge={rightHasBadge} icon="list" label={_(msg`Library`)} onClick={onOpenLibrary} />
</div>
);
};
@@ -1,4 +1,4 @@
import { Icon, type IconName } from '@/components/ui/icon';
import { Icon, type IconName } from '@/shared/ui/Icon';
type PlaceholderStateProps = {
icon: IconName;
+47
View File
@@ -0,0 +1,47 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { TrainerSummary } from '../../../protocol/messages';
import { PlaceholderState } from './PlaceholderState';
type SessionPlaceholderProps = {
connected: boolean;
activeTrainer: TrainerSummary | null;
onOpenLibrary: () => void;
onOpenSettings: () => void;
};
export const SessionPlaceholder = ({
connected,
activeTrainer,
onOpenLibrary,
onOpenSettings,
}: SessionPlaceholderProps) => {
const { _ } = useLingui();
if (!connected) {
return (
<PlaceholderState
icon="plug"
title={_(msg`Bridge offline`)}
sub={_(msg`Open Settings to point Wand at your trainer bridge over WebSocket.`)}
action={_(msg`Open Settings`)}
onAction={onOpenSettings}
/>
);
}
if (!activeTrainer) {
return (
<PlaceholderState
icon="gamepad-variant-outline"
title={_(msg`Select a game`)}
sub={_(msg`No game is running yet. Open the library and launch one to start tweaking.`)}
action={_(msg`Browse library`)}
onAction={onOpenLibrary}
/>
);
}
return null;
};
@@ -1,23 +1,28 @@
import { useState, type FormEvent } from 'react';
import { msg } from '@lingui/core/macro';
import type { MessageDescriptor } from '@lingui/core';
import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react';
import { Icon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { Icon } from '@/shared/ui/Icon';
import { cn } from '@/shared/lib/ui';
import { activateLocale, type LocaleCode, SUPPORTED_LOCALES } from '@/app/i18n';
import { DEFAULT_ACCENT_COLOR, loadAccentColor, setAccentColor } from '@/appearance/appearance-storage';
import type { LibraryGame } from '@/library/model/games';
import { EConnectionStatus } from '@/remote-session/remote-session.reducer';
import { WEB_CONTRACT } from '../../../protocol/contract';
import type { TrainerSummary } from '../../../protocol/messages';
import { DEFAULT_ACCENT_COLOR, loadAccentColor, setAccentColor } from '../accent-storage';
import { DEFAULT_REMOTE_PORT } from '../constants';
import type { LibraryGame } from '../game-library';
import type { TrainerSummary } from '../protocol';
import { EConnectionStatus } from '../state';
import { StatusPill } from './StatusPill';
const ACCENT_OPTIONS = [
{ value: '#3B82F6', label: 'Cobalt', swatchClass: 'bg-[#3B82F6]' },
{ value: DEFAULT_ACCENT_COLOR, label: 'Cyan', swatchClass: 'bg-[#00FFD5]' },
{ value: '#FF2E63', label: 'Crimson', swatchClass: 'bg-[#FF2E63]' },
{ value: '#A78BFA', label: 'Violet', swatchClass: 'bg-[#A78BFA]' },
{ value: '#7CFF5B', label: 'Lime', swatchClass: 'bg-[#7CFF5B]' },
{ value: '#FFB12E', label: 'Amber', swatchClass: 'bg-[#FFB12E]' },
{ value: '#ee00ff', label: 'Magenta', swatchClass: 'bg-[#ee00ff]' },
const ACCENT_OPTIONS: { value: string; label: MessageDescriptor; swatchClass: string }[] = [
{ value: '#3B82F6', label: msg`Cobalt`, swatchClass: 'bg-[#3B82F6]' },
{ value: DEFAULT_ACCENT_COLOR, label: msg`Cyan`, swatchClass: 'bg-[#00FFD5]' },
{ value: '#FF2E63', label: msg`Crimson`, swatchClass: 'bg-[#FF2E63]' },
{ value: '#A78BFA', label: msg`Violet`, swatchClass: 'bg-[#A78BFA]' },
{ value: '#7CFF5B', label: msg`Lime`, swatchClass: 'bg-[#7CFF5B]' },
{ value: '#FFB12E', label: msg`Amber`, swatchClass: 'bg-[#FFB12E]' },
{ value: '#ee00ff', label: msg`Magenta`, swatchClass: 'bg-[#ee00ff]' },
];
type SettingsDrawerProps = {
@@ -43,14 +48,20 @@ export const SettingsDrawer = ({
onDisconnect,
onWsUrlChange,
}: SettingsDrawerProps) => {
const { _ } = useLingui();
return (
<div className="flex h-full flex-col">
<header className="remote-glass-header flex items-center justify-between border-b px-3.5 py-3.5">
<div>
<h2 className="text-lg font-bold text-(--deck-fg)">Settings</h2>
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">wand remote · port {DEFAULT_REMOTE_PORT}</p>
<h2 className="text-lg font-bold text-(--deck-fg)">
<Trans>Settings</Trans>
</h2>
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">
<Trans>wand remote · port {WEB_CONTRACT.defaultRemotePort}</Trans>
</p>
</div>
<button type="button" aria-label="Close settings" className="remote-glass-control flex size-8 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
<button type="button" aria-label={_(msg`Close settings`)} className="remote-glass-control flex size-8 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
<Icon className="size-4" name="x" />
</button>
</header>
@@ -58,10 +69,13 @@ export const SettingsDrawer = ({
<BridgeControl status={status} wsUrl={wsUrl} onConnect={onConnect} onDisconnect={onDisconnect} onWsUrlChange={onWsUrlChange} />
{lastError ? <ErrorPanel message={lastError} /> : null}
<SectionHeader title="Session" />
<SectionHeader title={_(msg`Session`)} />
<SessionPanel currentGame={currentGame} currentTrainer={currentTrainer} />
<SectionHeader title="Accent Color" />
<SectionHeader title={_(msg`Language`)} />
<LanguagePicker />
<SectionHeader title={_(msg`Accent Color`)} />
<AccentPicker />
</div>
</div>
@@ -77,20 +91,24 @@ type BridgeControlProps = {
};
const BridgeControl = ({ status, wsUrl, onConnect, onDisconnect, onWsUrlChange }: BridgeControlProps) => {
const { _ } = useLingui();
const live = status === EConnectionStatus.Connected;
const connecting = status === EConnectionStatus.Connecting;
const connecting = status === EConnectionStatus.Connecting || status === EConnectionStatus.Reconnecting;
const handleInput = (event: FormEvent<HTMLInputElement>) => onWsUrlChange(event.currentTarget.value);
const buttonLabel = connecting ? '...' : _(live ? msg`STOP` : msg`GO`);
return (
<section>
<div className="mb-2 flex items-center justify-between">
<h3 className="font-mono text-[10px] font-bold uppercase tracking-[0.18em] text-(--deck-fg-4)">Bridge</h3>
<h3 className="font-mono text-[10px] font-bold uppercase tracking-[0.18em] text-(--deck-fg-4)">
<Trans>Bridge</Trans>
</h3>
<StatusPill status={status} />
</div>
<div className="remote-glass-control flex h-10 items-stretch overflow-hidden rounded-[10px] border">
<input
value={wsUrl}
placeholder={`ws://127.0.0.1:${DEFAULT_REMOTE_PORT}/remote/ws`}
placeholder={`ws://127.0.0.1:${WEB_CONTRACT.defaultRemotePort}${WEB_CONTRACT.webSocketPath}`}
spellCheck={false}
className="min-w-0 flex-1 bg-transparent px-3 font-mono text-[12.5px] text-(--deck-fg) outline-none placeholder:text-(--deck-fg-4)"
onInput={handleInput}
@@ -101,7 +119,7 @@ const BridgeControl = ({ status, wsUrl, onConnect, onDisconnect, onWsUrlChange }
className={cn('px-4 text-[11px] font-bold tracking-[0.08em] disabled:cursor-wait disabled:opacity-70', live ? 'bg-red-500/15 text-red-300' : 'bg-(--deck-accent) text-black')}
onClick={live ? onDisconnect : onConnect}
>
{getBridgeButtonLabel(status)}
{buttonLabel}
</button>
</div>
</section>
@@ -119,7 +137,11 @@ const ErrorPanel = ({ message }: { message: string }) => {
const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGame | null; currentTrainer: TrainerSummary | null }) => {
if (!currentGame) {
return <div className="remote-glass-control rounded-[10px] border p-3 text-[12px] text-(--deck-fg-3)">No active game session.</div>;
return (
<div className="remote-glass-control rounded-[10px] border p-3 text-[12px] text-(--deck-fg-3)">
<Trans>No active game session.</Trans>
</div>
);
}
const subtitleBase = currentTrainer?.displayName ?? currentGame.platform;
@@ -130,7 +152,9 @@ const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGam
<div className="remote-glass-control rounded-[10px] border p-3">
<div className="mb-2 flex items-center gap-2">
<span className="size-1.5 rounded-full bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)]" />
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.12em] text-(--deck-accent)">Active Session</span>
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.12em] text-(--deck-accent)">
<Trans>Active Session</Trans>
</span>
</div>
<h3 className="truncate text-sm font-semibold text-(--deck-fg)">{currentGame.title}</h3>
<p className="mt-0.5 truncate font-mono text-[11px] text-(--deck-fg-3)">
@@ -140,7 +164,34 @@ const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGam
);
};
const LanguagePicker = () => {
const { i18n } = useLingui();
const handleSelect = (locale: LocaleCode) => {
void activateLocale(locale);
};
return (
<div className="grid grid-cols-2 gap-1.5">
{SUPPORTED_LOCALES.map(({ code, label }) => {
const active = i18n.locale === code;
return (
<button
key={code}
type="button"
className={cn('remote-glass-control flex items-center justify-center rounded-[9px] border px-2 py-2 text-[12px] font-medium', active ? 'border-(--deck-accent) text-(--deck-fg)' : 'text-(--deck-fg-3)')}
onClick={() => handleSelect(code)}
>
{label}
</button>
);
})}
</div>
);
};
const AccentPicker = () => {
const { _ } = useLingui();
const [current, setCurrent] = useState(loadAccentColor);
const applyAccent = (value: string) => {
@@ -155,13 +206,15 @@ const AccentPicker = () => {
return (
<button key={option.value} type="button" className={cn('remote-glass-control flex items-center gap-1.5 rounded-[9px] border px-2 py-2 text-[12px] font-medium', active ? 'border-(--deck-accent) text-(--deck-fg)' : 'text-(--deck-fg-3)')} onClick={() => applyAccent(option.value)}>
<span className={cn('size-3.5 shrink-0 rounded-lg border border-white/10', option.swatchClass)} />
{option.label}
{_(option.label)}
</button>
);
})}
</div>
<label className="remote-glass-control flex h-9.5 items-center gap-2 rounded-[9px] border px-2.5">
<span className="flex-1 font-mono text-[11px] font-semibold uppercase tracking-[0.08em] text-(--deck-fg-3)">Custom</span>
<span className="flex-1 font-mono text-[11px] font-semibold uppercase tracking-[0.08em] text-(--deck-fg-3)">
<Trans>Custom</Trans>
</span>
<span className="font-mono text-[11px] text-(--deck-fg-4)">{current}</span>
<input type="color" value={current} className="size-5 rounded border-0 bg-transparent p-0" onChange={(event) => applyAccent(event.currentTarget.value)} />
</label>
@@ -177,15 +230,3 @@ const SectionHeader = ({ title }: { title: string }) => {
</div>
);
};
function getBridgeButtonLabel(status: EConnectionStatus): string {
if (status === EConnectionStatus.Connected) {
return 'STOP';
}
if (status === EConnectionStatus.Connecting) {
return '...';
}
return 'GO';
}
@@ -1,28 +1,37 @@
import { Icon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { EConnectionStatus } from '../state';
import { msg } from '@lingui/core/macro';
import type { MessageDescriptor } from '@lingui/core';
import { useLingui } from '@lingui/react';
const STATUS_LABELS: Record<EConnectionStatus, string> = {
[EConnectionStatus.Connected]: 'LIVE',
[EConnectionStatus.Connecting]: 'LINKING',
[EConnectionStatus.Error]: 'OFFLINE',
[EConnectionStatus.Idle]: 'OFFLINE',
import { Icon } from '@/shared/ui/Icon';
import { cn } from '@/shared/lib/ui';
import { EConnectionStatus } from '@/remote-session/remote-session.reducer';
const STATUS_LABELS: Record<EConnectionStatus, MessageDescriptor> = {
[EConnectionStatus.Connected]: msg`LIVE`,
[EConnectionStatus.Connecting]: msg`LINKING`,
[EConnectionStatus.Reconnecting]: msg`LINKING`,
[EConnectionStatus.Error]: msg`OFFLINE`,
[EConnectionStatus.Idle]: msg`OFFLINE`,
};
const STATUS_CLASSES: Record<EConnectionStatus, string> = {
[EConnectionStatus.Connected]: 'border-[color-mix(in_oklab,var(--deck-accent)_30%,transparent)] text-(--deck-accent)',
[EConnectionStatus.Connecting]: 'border-amber-300/30 text-amber-300',
[EConnectionStatus.Reconnecting]: 'border-amber-300/30 text-amber-300',
[EConnectionStatus.Error]: 'border-white/10 text-(--deck-fg-4)',
[EConnectionStatus.Idle]: 'border-white/10 text-(--deck-fg-4)',
};
export const StatusPill = ({ status }: { status: EConnectionStatus }) => {
const live = status === EConnectionStatus.Connected || status === EConnectionStatus.Connecting;
const { _ } = useLingui();
const live = status === EConnectionStatus.Connected
|| status === EConnectionStatus.Connecting
|| status === EConnectionStatus.Reconnecting;
return (
<div className={cn('inline-flex items-center gap-1.5 rounded-full border bg-white/[0.04] px-2.5 py-1 font-mono text-[9.5px] font-bold tracking-[0.12em] backdrop-blur-md', STATUS_CLASSES[status])}>
{live ? <span className="size-1.5 rounded-full bg-current shadow-[0_0_6px_currentColor] motion-safe:animate-[breathe_1.6s_ease-in-out_infinite]" /> : null}
{STATUS_LABELS[status]}
{_(STATUS_LABELS[status])}
{status === EConnectionStatus.Error ? <Icon className="size-3" name="alert" /> : null}
</div>
);
@@ -1,8 +1,12 @@
import { Icon } from '@/components/ui/icon';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react';
import type { LibraryGame } from '../game-library';
import type { TrainerSummary } from '../protocol';
import type { EConnectionStatus } from '../state';
import { Icon } from '@/shared/ui/Icon';
import type { LibraryGame } from '@/library/model/games';
import type { TrainerSummary } from '../../../protocol/messages';
import type { EConnectionStatus } from '@/remote-session/remote-session.reducer';
import { StatusPill } from './StatusPill';
type TopBarProps = {
@@ -13,17 +17,19 @@ type TopBarProps = {
};
export const TopBar = ({ status, currentGame, runningTrainer, onOpenSettings }: TopBarProps) => {
const { _ } = useLingui();
return (
<header className="remote-glass-header sticky top-0 z-20 border-b px-3.5 pb-2.5 pt-3">
<div className="flex items-center gap-2.5">
<button type="button" aria-label="Settings" className="remote-glass-control flex size-[34px] shrink-0 items-center justify-center rounded-[9px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onOpenSettings}>
<button type="button" aria-label={_(msg`Settings`)} className="remote-glass-control flex size-[34px] shrink-0 items-center justify-center rounded-[9px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onOpenSettings}>
<Icon className="size-[18px]" name="menu" />
</button>
<div className="min-w-0 flex-1">
<div className="font-mono text-[9.5px] font-bold tracking-[0.16em] text-(--deck-fg-4)">WAND · REMOTE DECK</div>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate text-sm font-semibold text-(--deck-fg)">
{currentGame ? currentGame.title : 'Idle · no game'}
{currentGame ? currentGame.title : <Trans>Idle · no game</Trans>}
</span>
{currentGame && runningTrainer?.gameVersion ? (
<span className="shrink-0 rounded-[4px] bg-[color-mix(in_oklab,var(--deck-accent)_12%,transparent)] px-1.5 py-0.5 font-mono text-[9.5px] font-bold tracking-[0.06em] text-(--deck-accent)">
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react';
import { fireEvent, render, screen } from '@testing-library/preact';
import { describe, expect, it, vi } from 'vitest';
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import type { TrainerSummary } from '../../../protocol/messages';
import { TrainerHeader } from '../../trainer/ui/TrainerHeader';
import { SessionPlaceholder } from './SessionPlaceholder';
i18n.load('en', {});
i18n.activate('en');
const renderWithI18n = (ui: ReactNode) => render(<I18nProvider i18n={i18n}>{ui}</I18nProvider>);
const trainer: TrainerSummary = {
trainerId: 'trainer',
gameId: 'game',
displayName: 'Test Trainer',
trainerLoading: false,
gameInstalled: true,
needsCompatibilityWarning: false,
isTimeLimitExpired: false,
};
describe('session state components', () => {
it('renders the offline intent', () => {
const openSettings = vi.fn();
renderWithI18n(<SessionPlaceholder connected={false} activeTrainer={null} onOpenLibrary={() => undefined} onOpenSettings={openSettings} />);
fireEvent.click(screen.getByRole('button', { name: 'Open Settings' }));
expect(screen.getByText('Bridge offline')).toBeTruthy();
expect(openSettings).toHaveBeenCalledOnce();
});
it('renders the no-trainer intent', () => {
renderWithI18n(<SessionPlaceholder connected activeTrainer={null} onOpenLibrary={() => undefined} onOpenSettings={() => undefined} />);
expect(screen.getByText('Select a game')).toBeTruthy();
});
it('renders an active trainer header', () => {
renderWithI18n(<TrainerHeader trainer={trainer} game={null} isPinned={false} onPin={() => undefined} />);
expect(screen.getByText('Test Trainer')).toBeTruthy();
expect(screen.getByText('Trainer Active')).toBeTruthy();
});
});
+22
View File
@@ -0,0 +1,22 @@
import { useCallback, useRef, useState, type UIEvent } from 'react';
const SCROLL_HIDE_THRESHOLD_PX = 60;
const SCROLL_REVEAL_DEAD_ZONE_PX = 4;
export function useDockAutoHide() {
const [hidden, setHidden] = useState(false);
const lastScrollRef = useRef(0);
const onScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
const y = event.currentTarget.scrollTop;
if (y > lastScrollRef.current && y > SCROLL_HIDE_THRESHOLD_PX) {
setHidden(true);
} else if (y < lastScrollRef.current - SCROLL_REVEAL_DEAD_ZONE_PX) {
setHidden(false);
}
lastScrollRef.current = y;
}, []);
return { hidden, onScroll };
}
+134
View File
@@ -0,0 +1,134 @@
import { useCallback, useMemo, useState } from 'react';
import { ECheatType } from '../../protocol/messages';
import { buildLibraryGames, getCurrentGame, type LibraryGame } from '../library/model/games';
import { useGamePins } from '../library/pinned-games/use-game-pins';
import { useRemoteSession } from '../remote-session/use-remote-session';
import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from '../trainer/model/categories';
import { getPinnedStorageKey } from '../trainer/pinned-cheats/pinned-cheat-storage';
import { usePinnedCheats } from '../trainer/pinned-cheats/use-pinned-cheats';
import { getPresetStorageKey, type RemotePreset } from '../trainer/presets/preset-storage';
import { usePresets } from '../trainer/presets/use-presets';
import { useDockAutoHide } from './use-dock-auto-hide';
export function useRemotePanel() {
const session = useRemoteSession();
const [cheatQuery, setCheatQuery] = useState('');
const [gameQuery, setGameQuery] = useState('');
const [leftOpen, setLeftOpen] = useState(false);
const [rightOpen, setRightOpen] = useState(false);
const dock = useDockAutoHide();
const activeTrainer = session.state.trainerMeta?.trainer ?? null;
const { pinnedGameIds, togglePin: toggleGamePin } = useGamePins();
const libraryGames = useMemo(
() => buildLibraryGames(session.state.installedApps, session.state.gameStatus, activeTrainer, pinnedGameIds),
[activeTrainer, pinnedGameIds, session.state.gameStatus, session.state.installedApps],
);
const currentGame = useMemo(() => getCurrentGame(libraryGames), [libraryGames]);
const pinnedStorageKey = useMemo(() => getPinnedStorageKey(activeTrainer), [activeTrainer]);
const { pinnedTargets, toggle: togglePinnedCheat } = usePinnedCheats({ pinnedStorageKey });
const groups = useMemo(() => groupCheatsByCategory(session.state.trainerMeta), [session.state.trainerMeta]);
const pinnedGroup = useMemo(
() => buildPinnedGroup(session.state.trainerMeta, pinnedTargets),
[pinnedTargets, session.state.trainerMeta],
);
const filteredGroups = useMemo(() => filterGroups(groups, cheatQuery), [cheatQuery, groups]);
const filteredPinnedGroup = useMemo(
() => (pinnedGroup ? filterGroups([pinnedGroup], cheatQuery)[0] ?? null : null),
[cheatQuery, pinnedGroup],
);
const presetStorageKey = useMemo(() => getPresetStorageKey(activeTrainer), [activeTrainer]);
const presets = usePresets({
presetStorageKey,
trainerMeta: session.state.trainerMeta,
values: session.state.values,
onError: session.reportError,
});
const panic = useCallback(() => {
const trainerMeta = session.state.trainerMeta;
if (!trainerMeta) return;
for (const cheat of trainerMeta.schema.cheats) {
if (cheat.type === ECheatType.Toggle && Boolean(session.state.values[cheat.target])) {
session.changeCheat(cheat, false);
}
}
}, [session]);
const applyPreset = useCallback((preset: RemotePreset) => {
const trainerMeta = session.state.trainerMeta;
if (!trainerMeta) return;
for (const cheat of trainerMeta.schema.cheats) {
if (cheat.target in preset.values) {
session.changeCheat(cheat, preset.values[cheat.target]);
}
}
}, [session]);
const playGame = useCallback((game: LibraryGame) => {
if (session.launchGame(game.app)) {
setRightOpen(false);
}
}, [session]);
const totalVisibleCheats = filteredGroups.reduce(
(count, group) => count + group.cheats.length,
filteredPinnedGroup?.cheats.length ?? 0,
);
return {
session: {
status: session.state.connectionStatus,
wsUrl: session.state.wsUrl,
lastError: session.state.lastError,
values: session.state.values,
pendingTargets: session.pendingTargets,
connected: session.connected,
socketReady: session.socketReady,
connect: session.connect,
disconnect: session.disconnect,
setWsUrl: session.setWsUrl,
},
trainer: {
activeTrainer,
query: cheatQuery,
setQuery: setCheatQuery,
filteredGroups,
filteredPinnedGroup,
pinnedTargets,
controlsDisabled: Boolean(activeTrainer?.trainerLoading || activeTrainer?.isTimeLimitExpired),
totalVisibleCheats,
totalCheats: session.state.trainerMeta?.schema.cheats.length ?? 0,
changeCheat: session.changeCheat,
togglePin: togglePinnedCheat,
panic,
presets: presets.presets,
addPreset: presets.addPreset,
applyPreset,
deletePreset: presets.deletePreset,
},
library: {
games: libraryGames,
currentGame,
pinnedGameIds,
query: gameQuery,
setQuery: setGameQuery,
togglePin: toggleGamePin,
playGame,
stopPlaying: session.stopPlaying,
},
shell: {
leftOpen,
rightOpen,
openSettings: () => setLeftOpen(true),
closeSettings: () => setLeftOpen(false),
openLibrary: () => setRightOpen(true),
closeLibrary: () => setRightOpen(false),
dockHidden: dock.hidden,
onScroll: dock.onScroll,
},
};
}
@@ -1,4 +1,4 @@
import { loadJson, saveJson } from './storage';
import { loadJson, saveJson } from '../shared/storage';
export const DEFAULT_ACCENT_COLOR = '#00ffd5';
@@ -39,4 +39,4 @@ function normalizeAccentColor(value: unknown): string | null {
const normalizedValue = value.trim().toLowerCase();
return HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : null;
}
}
-37
View File
@@ -1,37 +0,0 @@
export const DEFAULT_REMOTE_PORT = 3223;
export const REMOTE_BASE_PATH = '/remote/';
export const REMOTE_WS_PATH = '/remote/ws';
export const CLIENT_VERSION = '0.2.0';
export const WS_QUERY_PARAM = 'ws';
const DEV_SERVER_PORTS = new Set(['4173', '5173']);
function protocolForWebSocket(): 'ws' | 'wss' {
return window.location.protocol === 'https:' ? 'wss' : 'ws';
}
function isServedByRemoteBridge(): boolean {
return window.location.pathname.startsWith(REMOTE_BASE_PATH) && !DEV_SERVER_PORTS.has(window.location.port);
}
export function readInitialRemoteUrl(): string {
if (isServedByRemoteBridge()) {
return `${window.location.protocol}//${window.location.host}${REMOTE_BASE_PATH}`;
}
return `http://127.0.0.1:${DEFAULT_REMOTE_PORT}${REMOTE_BASE_PATH}`;
}
export function readInitialWebSocketUrl(): string {
const params = new URLSearchParams(window.location.search);
const explicitUrl = params.get(WS_QUERY_PARAM)?.trim();
if (explicitUrl) {
return explicitUrl;
}
if (isServedByRemoteBridge()) {
return `${protocolForWebSocket()}://${window.location.host}${REMOTE_WS_PATH}`;
}
return `ws://127.0.0.1:${DEFAULT_REMOTE_PORT}${REMOTE_WS_PATH}`;
}

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