mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-29 09:01:13 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d2d8bde5d | |||
| 544b9f0fb0 | |||
| a4f3a57f97 | |||
| 1f5ba9fc95 |
@@ -22,6 +22,7 @@ This repository patches the Wand Electron app from a .NET Framework WPF desktop
|
||||
- The websocket `hello` snapshot must still send cached `installed_apps` and `game_status` even when no trainer snapshot is active yet; do not reintroduce a handshake path that returns early after `trainer_changed`.
|
||||
- Remote Play/Stop uses the websocket `remote_command` message. The bridge forwards it over `wand-remote-command` / `wand-remote-command-response`, and `installed-apps-sync.js` resolves Wand's trainer API + trainer service to launch a trainer for a `gameId` or end the current trainer.
|
||||
- Remote Play must construct Wand's real trainer launch request class (`69482.vO`) before calling `trainerService.launch(...)`. Passing a plain object launches the game process but breaks Wand's `getMetadata(vO)`-based trainer state, causing missing status, disappearing play/close buttons, and stuck loading behavior.
|
||||
- Pro activation is a C# asar patch (`EPatchType.ActivatePro`, independent of the remote panel / bridge). It rewrites three account-returning service methods to inject `subscription:{period:"yearly",state:"active"}` into the response before it reaches the store: `getUserAccount` and `setAccountWandBrandExperience` (Resolver-style, service field via `<service_name>` placeholder) and `setAccountLanguage` (`BuildSetAccountLanguagePatch` PatchFactory — captures the real param names + the original `post("/v3/account/language",{...})` expr and wraps `.then`). Pro is `am(account) = !!account.subscription` (flags/512 are irrelevant). `setAccountLanguage` is the one the original two patches missed, which is why Pro dropped on language change. If a future Wand build changes these method bodies, re-derive the regexes against the live `app-*.bundle.js` (do NOT trust `.source/new` — it is a different version).
|
||||
|
||||
## ASAR Patch Pipeline
|
||||
|
||||
|
||||
@@ -3,6 +3,36 @@
|
||||
This file is the source of truth for release notes.
|
||||
The newest entry must match the version in `WandEnhancer/Properties/AssemblyInfo.cs`.
|
||||
|
||||
## [1.0.8.4] - 2026-06-10
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed QR code pairing issues on newer Windows versions.
|
||||
- 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
|
||||
|
||||
@@ -35,15 +35,76 @@ 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.
|
||||
|
||||
## 👀 How to use?
|
||||
|
||||
1. Go to the [Releases](https://github.com/k1tbyte/Wand-Enhancer/releases) page.
|
||||
2. Download the latest source or binary.
|
||||
2. Download the latest binary release.
|
||||
3. Run the enhancer to apply local client modifications.
|
||||
|
||||
> Source archives are intended for developers who want to build the project locally. They are not prebuilt binaries.
|
||||
|
||||
## 🧩 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 one‑time 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
|
||||
|
||||
@@ -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;
|
||||
@@ -490,4 +503,4 @@ namespace WandEnhancer.Core
|
||||
_logger("[ENHANCER] Done!", ELogType.Success);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,66 @@ 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 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 +130,19 @@ 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
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -79,6 +150,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,15 +199,17 @@ 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
|
||||
{
|
||||
@@ -149,29 +224,6 @@ namespace WandEnhancer.Core
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.8.4")]
|
||||
[assembly: AssemblyFileVersion("1.0.8.4")]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-28
@@ -12,45 +12,52 @@ 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}`
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
getWebpackRequire,
|
||||
isRecord,
|
||||
} from "./installed-apps-sync/runtime.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,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
const updateLinks = () => {
|
||||
const remoteUrl =
|
||||
globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl
|
||||
const getRemoteUrl = () =>
|
||||
globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl
|
||||
|
||||
const resolveQrRenderer = () => {
|
||||
if (qrRenderer) {
|
||||
return qrRenderer
|
||||
}
|
||||
|
||||
const webpackRequire = getWebpackRequire()
|
||||
for (const record of Object.values(webpackRequire?.c || {})) {
|
||||
const exports = record?.exports
|
||||
if (
|
||||
isRecord(exports) &&
|
||||
typeof exports.create === "function" &&
|
||||
typeof exports.mo === "function"
|
||||
) {
|
||||
qrRenderer = exports.mo
|
||||
return qrRenderer
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const updateLinks = (remoteUrl) => {
|
||||
if (!remoteUrl) {
|
||||
return
|
||||
}
|
||||
@@ -80,13 +118,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,
|
||||
|
||||
+54
-17
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useReducer, useRef, useState, type UIEvent } from 'react';
|
||||
import { useCallback, 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';
|
||||
@@ -16,7 +16,7 @@ import { loadPinnedGameIds, savePinnedGameIds, togglePinnedGame } from '@/featur
|
||||
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 { normalizeOutgoingValue, type CheatSchema, type InstalledAppSummary } 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';
|
||||
@@ -35,19 +35,27 @@ export const App = () => {
|
||||
const [presets, setPresets] = useState<RemotePreset[]>([]);
|
||||
const lastScrollRef = useRef(0);
|
||||
const clientRef = useRef<PanelSocketClient | null>(null);
|
||||
const trainerMetaRef = useRef<TrainerMetaPayload | null>(state.trainerMeta);
|
||||
const stateRef = useRef(state);
|
||||
const handleConnectRef = useRef<() => void>(() => {});
|
||||
const pinnedStorageKeyRef = useRef<string | null>('');
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedGameIds(loadPinnedGameIds());
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
trainerMetaRef.current = state.trainerMeta;
|
||||
}, [state.trainerMeta]);
|
||||
stateRef.current = state;
|
||||
handleConnectRef.current = handleConnect;
|
||||
pinnedStorageKeyRef.current = pinnedStorageKey;
|
||||
});
|
||||
|
||||
const activeTrainer = state.trainerMeta?.trainer ?? null;
|
||||
const libraryGames = useMemo(
|
||||
@@ -83,8 +91,22 @@ export const App = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && !clientRef.current?.isOpen()) {
|
||||
handleConnectRef.current();
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}, []);
|
||||
|
||||
function handleConnect(): void {
|
||||
clientRef.current?.disconnect();
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const wsUrl = state.wsUrl.trim();
|
||||
if (!wsUrl) {
|
||||
@@ -95,8 +117,17 @@ export const App = () => {
|
||||
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.' }),
|
||||
onMessage: (message) => handleProtocolMessage(dispatch, message, stateRef.current.trainerMeta),
|
||||
onClose: () => {
|
||||
dispatch({ type: 'error', message: 'The WebSocket connection closed.' });
|
||||
if (document.visibilityState === 'visible') {
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
if (document.visibilityState === 'visible' && stateRef.current.wsUrl.trim()) {
|
||||
handleConnectRef.current();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
onError: (message) => dispatch({ type: 'error', message }),
|
||||
});
|
||||
|
||||
@@ -105,30 +136,36 @@ export const App = () => {
|
||||
}
|
||||
|
||||
function handleDisconnect(): void {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
dispatch({ type: 'disconnected' });
|
||||
}
|
||||
|
||||
function handleCheatChange(cheat: CheatSchema, nextValue: unknown): void {
|
||||
const handleCheatChange = useCallback((cheat: CheatSchema, nextValue: unknown): void => {
|
||||
const { connectionStatus, trainerMeta } = stateRef.current;
|
||||
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) {
|
||||
if (connectionStatus !== EConnectionStatus.Connected || !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);
|
||||
const sent = clientRef.current.setValue(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 };
|
||||
const handleToggleCheatPin = useCallback((cheat: CheatSchema): void => {
|
||||
const { pinnedTargets } = stateRef.current;
|
||||
const next = { ...pinnedTargets };
|
||||
if (next[cheat.target]) {
|
||||
delete next[cheat.target];
|
||||
} else {
|
||||
@@ -136,8 +173,8 @@ export const App = () => {
|
||||
}
|
||||
|
||||
dispatch({ type: 'togglePinnedTarget', target: cheat.target });
|
||||
savePinnedTargets(pinnedStorageKey, next);
|
||||
}
|
||||
savePinnedTargets(pinnedStorageKeyRef.current, next);
|
||||
}, []);
|
||||
|
||||
function handleToggleGamePin(game: LibraryGame): void {
|
||||
const next = togglePinnedGame(game, pinnedGameIds);
|
||||
@@ -248,11 +285,11 @@ export const App = () => {
|
||||
<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-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={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}>
|
||||
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 pb-27.5" 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 ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
|
||||
@@ -20,7 +20,7 @@ type CategorySectionProps = {
|
||||
onTogglePin: (cheat: CheatSchema) => void;
|
||||
};
|
||||
|
||||
export const CategorySection = ({
|
||||
const CategorySectionBase = ({
|
||||
group,
|
||||
values,
|
||||
pendingTargets,
|
||||
@@ -36,6 +36,15 @@ export const CategorySection = ({
|
||||
const toggleCount = useMemo(() => getToggleCount(group.cheats), [group.cheats]);
|
||||
const handleToggle = () => setOpen((current) => !current);
|
||||
|
||||
const cheatHandlers = useMemo(
|
||||
() =>
|
||||
group.cheats.map((cheat) => ({
|
||||
onChange: (nextValue: unknown) => onCheatChange(cheat, nextValue),
|
||||
onTogglePin: () => onTogglePin(cheat),
|
||||
})),
|
||||
[group.cheats, onCheatChange, onTogglePin],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceOpen) {
|
||||
setOpen(true);
|
||||
@@ -45,17 +54,17 @@ export const CategorySection = ({
|
||||
return (
|
||||
<section className="mb-2.5 overflow-hidden rounded-[14px] border border-white/10 bg-white/[0.035] shadow-[inset_0_1px_0_rgba(255,255,255,.05)] backdrop-blur-2xl">
|
||||
<button type="button" className="flex w-full items-center gap-2.5 px-3.5 py-3 text-left text-(--deck-fg)" onClick={handleToggle}>
|
||||
<span className="flex size-[30px] shrink-0 items-center justify-center rounded-[8px] border border-[color-mix(in_oklab,var(--deck-accent)_22%,transparent)] bg-white/[0.04] text-(--deck-accent)">
|
||||
<CategoryIcon category={group.id} className="size-[15px]" />
|
||||
<span className="flex size-7.5 shrink-0 items-center justify-center rounded-[8px] border border-[color-mix(in_oklab,var(--deck-accent)_22%,transparent)] bg-white/4 text-(--deck-accent)">
|
||||
<CategoryIcon category={group.id} className="size-3.75" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">{group.label}</span>
|
||||
<span className="mt-0.5 block font-mono text-[10.5px] text-(--deck-fg-4)">{formatSummary(group.cheats.length, enabledCount, toggleCount)}</span>
|
||||
</span>
|
||||
{enabledCount > 0 ? <span className="inline-flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-full bg-(--deck-accent) px-1.5 text-center font-mono text-[10px] font-bold leading-none tabular-nums text-black">{enabledCount}</span> : null}
|
||||
{enabledCount > 0 ? <span className="inline-flex h-4.5 min-w-4.5 shrink-0 items-center justify-center rounded-full bg-(--deck-accent) px-1.5 text-center font-mono text-[10px] font-bold leading-none tabular-nums text-black">{enabledCount}</span> : null}
|
||||
<Icon className={cn('size-4 text-(--deck-fg-3) transition-transform', open ? 'rotate-0' : '-rotate-90')} name="chevron-down" />
|
||||
</button>
|
||||
<div className={cn('overflow-hidden transition-[max-height] duration-300', open ? 'max-h-[4000px]' : 'max-h-0')}>
|
||||
<div className={cn('overflow-hidden transition-[max-height] duration-300', open ? 'max-h-1000' : 'max-h-0')}>
|
||||
{group.cheats.map((cheat, index) => (
|
||||
<CheatTile
|
||||
key={cheat.uuid}
|
||||
@@ -65,8 +74,8 @@ export const CategorySection = ({
|
||||
pinned={Boolean(pinnedTargets[cheat.target])}
|
||||
disabled={disabled}
|
||||
first={index === 0}
|
||||
onChange={(nextValue) => onCheatChange(cheat, nextValue)}
|
||||
onTogglePin={() => onTogglePin(cheat)}
|
||||
onChange={cheatHandlers[index].onChange}
|
||||
onTogglePin={cheatHandlers[index].onTogglePin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -74,6 +83,18 @@ export const CategorySection = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const CategorySection = memo(CategorySectionBase, (prev, next) => {
|
||||
if (prev.group !== next.group) return false;
|
||||
if (prev.disabled !== next.disabled) return false;
|
||||
if (prev.forceOpen !== next.forceOpen) return false;
|
||||
for (const cheat of next.group.cheats) {
|
||||
if (prev.values[cheat.target] !== next.values[cheat.target]) return false;
|
||||
if (prev.pendingTargets[cheat.target] !== next.pendingTargets[cheat.target]) return false;
|
||||
if (prev.pinnedTargets[cheat.target] !== next.pinnedTargets[cheat.target]) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function getEnabledToggleCount(cheats: CheatSchema[], values: Record<string, unknown>): number {
|
||||
return cheats.filter((cheat) => cheat.type === ECheatType.Toggle && Boolean(values[cheat.target])).length;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { memo, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -23,7 +23,7 @@ const SWIPE_TRIGGER = 56;
|
||||
const SWIPE_DEAD_ZONE = 8;
|
||||
const SWIPE_ANIMATION_MS = 220;
|
||||
|
||||
export const CheatTile = ({ cheat, value, pending, disabled, pinned, first, onChange, onTogglePin }: CheatTileProps) => {
|
||||
const CheatTileBase = ({ cheat, value, pending, disabled, pinned, first, onChange, onTogglePin }: CheatTileProps) => {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [armed, setArmed] = useState(false);
|
||||
@@ -127,6 +127,8 @@ export const CheatTile = ({ cheat, value, pending, disabled, pinned, first, onCh
|
||||
);
|
||||
};
|
||||
|
||||
export const CheatTile = memo(CheatTileBase);
|
||||
|
||||
const PinReveal = ({ pinned, armed }: { pinned: boolean; armed: boolean }) => {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center justify-center px-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { memo, useMemo, type ReactNode } from 'react';
|
||||
|
||||
import { Icon, type IconName } from '@/components/ui/icon';
|
||||
|
||||
@@ -18,9 +18,9 @@ type LibraryDrawerProps = {
|
||||
onQueryChange: (query: string) => void;
|
||||
};
|
||||
|
||||
export const LibraryDrawer = ({ games, query, canLaunch, onClose, onPin, onPlay, onStop, onQueryChange }: LibraryDrawerProps) => {
|
||||
const filteredGames = filterLibraryGames(games, query);
|
||||
const sections = getLibrarySections(filteredGames);
|
||||
const LibraryDrawerBase = ({ games, query, canLaunch, onClose, onPin, onPlay, onStop, onQueryChange }: LibraryDrawerProps) => {
|
||||
const filteredGames = useMemo(() => filterLibraryGames(games, query), [games, query]);
|
||||
const sections = useMemo(() => getLibrarySections(filteredGames), [filteredGames]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -60,6 +60,8 @@ export const LibraryDrawer = ({ games, query, canLaunch, onClose, onPin, onPlay,
|
||||
);
|
||||
};
|
||||
|
||||
export const LibraryDrawer = memo(LibraryDrawerBase);
|
||||
|
||||
type GameSectionProps = {
|
||||
title: string;
|
||||
count?: number;
|
||||
|
||||
@@ -166,6 +166,16 @@
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.remote-glass-header {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.remote-glass-control {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.remote-glass-control {
|
||||
|
||||
Reference in New Issue
Block a user