feat(patch-engine): locate patches structurally instead of by signature

Anchor each patch on a stable string (API endpoint, IPC channel, method name)
and walk the delimiter structure via JsCursor to the edit site, reading
minified identifiers out of the located region.

Move injected JavaScript into WandEnhancer/Patches/*.js as embedded resources.
Declare patches as PatchEntry rows in EnhancerConfig with CandidateFileNames,
SearchHints and optional CapabilityHints.

Recognise keyword-preceded regex literals in JsCursor so `return/re/.test(x)`
no longer desynchronises the scan.
Require the remote setValue anchor to match exactly once; Wand ships sibling
call sites for other sources.
Require both backup halves in IsPatched so a partial backup no longer blocks
patch and restore at the same time.
Chain inner exceptions when unpack or pack fails.
Rename Common to ProcessTerminator and Utils.Extensions to WeModInstalls.
This commit is contained in:
kitbyte
2026-08-29 16:56:09 +03:00
parent 20956c3228
commit 6716da5c80
21 changed files with 1249 additions and 486 deletions
+185 -168
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -18,6 +18,8 @@ namespace WandEnhancer.Core
private const string AppAsarUnpackedDirectoryName = "app.asar.unpacked";
private const string AppAsarBackupFileName = "app.asar.backup";
private const string AppAsarUnpackedBackupDirectoryName = "app.asar.unpacked.backup";
private const string ProxyDllFileName = "version.dll";
private const string StubBackupSuffix = ".stub";
private const string WebPanelDirectoryName = "web-panel";
private const string WebPanelDistDirectoryName = "dist";
private const string LocalCustomScriptsDirectoryName = "renderer-scripts";
@@ -28,7 +30,6 @@ namespace WandEnhancer.Core
private const string AppBundleFilePrefix = "app-";
private const string AppBundleFileSuffix = ".bundle.js";
private const string IndexBundleFileName = "index.js";
private const string JavaScriptFileExtension = ".js";
private const string JavaScriptFileSearchPattern = "*.js";
private const string DuplicateScriptSuffix = ".custom";
private const int FirstDuplicateScriptIndex = 1;
@@ -36,92 +37,46 @@ namespace WandEnhancer.Core
private readonly WeModConfig _weModConfig;
private readonly Action<string, ELogType> _logger;
private readonly PatchConfig _config;
private readonly JavaScriptPatchApplier _jsPatchApplier;
private readonly string _asarPath;
private readonly string _backupPath;
private readonly string _unpackedPath;
private readonly string _unpackedBackupPath;
/// <summary>For <see cref="Restore"/>, which needs the install paths but no patch selection.</summary>
public Enhancer(WeModConfig weModConfig, Action<string, ELogType> logger)
: this(weModConfig, logger, null)
{
}
public Enhancer(WeModConfig weModConfig, Action<string, ELogType> logger, PatchConfig config)
{
_weModConfig = weModConfig;
_logger = logger;
_config = config;
_jsPatchApplier = new JavaScriptPatchApplier(logger);
_asarPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarFileName);
_unpackedPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedDirectoryName);
_backupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarBackupFileName);
_unpackedBackupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedBackupDirectoryName);
}
private string ApplyJsPatch(string fileName, string js, EnhancerConfig.PatchEntry patch, EPatchType patchType, out bool patchApplied)
/// <summary>
/// Both halves of the backup must exist. Accepting either one on its own reported a
/// half-written backup as patched, which blocked patching while <see cref="Restore"/>
/// refused to run - leaving the user with no way forward.
/// </summary>
public static bool IsPatched(string rootDirectory)
{
patchApplied = false;
if (patch.Applied)
{
return js;
}
if (!CanSearchPatchInFile(fileName, patch) || !ContainsSearchHint(js, patch.SearchHints))
{
return js;
}
var match = patch.Target.Match(js);
if (!match.Success)
{
return js;
}
var prefix = $"[ENHANCER] [{patchType} -> {patch.Name}]";
if(patch.SingleMatch && match.NextMatch().Success)
{
throw new Exception(
$"{prefix} Patch failed. Multiple target functions found. Looks like the version is not supported");
}
string patchSource = patch.PatchFactory != null
? patch.PatchFactory(match)
: patch.Patch;
if (patch.Resolver != null)
{
string resolvedField = patch.Resolver.Handler(match.Value);
if (string.IsNullOrEmpty(resolvedField))
{
throw new Exception($"{prefix} Resolver failed to find field name");
}
patchSource = patchSource.Replace(patch.Resolver.Placeholder, resolvedField);
}
_logger($"{prefix} Found target function in: " + Path.GetFileName(fileName), ELogType.Info);
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;
return newJs;
var resources = Path.Combine(rootDirectory, ResourcesDirectoryName);
return File.Exists(Path.Combine(resources, AppAsarBackupFileName))
&& Directory.Exists(Path.Combine(resources, AppAsarUnpackedBackupDirectoryName));
}
private void PatchAsar()
{
var items = Directory.EnumerateFiles(_unpackedPath, $"*{JavaScriptFileExtension}", SearchOption.TopDirectoryOnly)
var items = Directory.EnumerateFiles(_unpackedPath, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly)
.Where(IsCandidateBundleFile)
.ToList();
@@ -129,7 +84,7 @@ namespace WandEnhancer.Core
{
throw new Exception("[ENHANCER] No app bundle found");
}
var remainingPatches = new HashSet<EPatchType>(_config.PatchTypes);
var enhancerConfig = EnhancerConfig.GetInstance();
@@ -144,20 +99,22 @@ namespace WandEnhancer.Core
{
continue;
}
string data = File.ReadAllText(item);
bool fileChanged = false;
foreach (var entry in remainingPatches.ToList())
{
var entries = enhancerConfig[entry];
foreach (var patchEntry in entries)
{
bool patchApplied;
data = ApplyJsPatch(item, data, patchEntry, entry, out patchApplied);
data = _jsPatchApplier.Apply(item, data, patchEntry, entry, out patchApplied);
fileChanged = fileChanged || patchApplied;
}
// Optional patches stay in the scan until every file has been checked, because
// their capability may still show up in a bundle we have not read yet.
if (entries.All(x => x.Applied))
{
remainingPatches.Remove(entry);
@@ -169,11 +126,27 @@ namespace WandEnhancer.Core
File.WriteAllText(item, data);
}
}
if(remainingPatches.Count > 0)
ReportUnappliedPatches(remainingPatches, enhancerConfig);
}
private void ReportUnappliedPatches(IEnumerable<EPatchType> remainingPatches, Dictionary<EPatchType, EnhancerConfig.PatchEntry[]> enhancerConfig)
{
var unapplied = remainingPatches
.SelectMany(patchType => enhancerConfig[patchType]
.Where(patch => !patch.Applied)
.Select(patch => new { Label = JavaScriptPatchApplier.FormatLabel(patchType, patch), Patch = patch }))
.ToList();
foreach (var skipped in unapplied.Where(entry => entry.Patch.IsResolved))
{
var failedPatches = string.Join(", ", remainingPatches.Select(p => p.ToString()));
throw new Exception($"[ENHANCER] Failed to apply patches: {failedPatches}. The version may not be supported.");
_logger($"[ENHANCER] [{skipped.Label}] Capability not present, skipping", ELogType.Info);
}
var failed = unapplied.Where(entry => !entry.Patch.IsResolved).Select(entry => entry.Label).ToList();
if (failed.Count > 0)
{
throw new Exception($"[ENHANCER] Failed to apply patches: {string.Join(", ", failed)}. The version may not be supported.");
}
}
@@ -187,44 +160,9 @@ namespace WandEnhancer.Core
private static bool CouldFileContainRemainingPatch(string filePath, IEnumerable<EPatchType> remainingPatches, Dictionary<EPatchType, EnhancerConfig.PatchEntry[]> enhancerConfig)
{
foreach (var patchType in remainingPatches)
{
foreach (var patchEntry in enhancerConfig[patchType])
{
if (patchEntry.Applied)
{
continue;
}
if (CanSearchPatchInFile(filePath, patchEntry))
{
return true;
}
}
}
return false;
}
private static bool CanSearchPatchInFile(string filePath, EnhancerConfig.PatchEntry patch)
{
if (patch.CandidateFileNames == null || patch.CandidateFileNames.Length == 0)
{
return true;
}
string fileName = Path.GetFileName(filePath);
return patch.CandidateFileNames.Any(candidate => fileName.Equals(candidate, StringComparison.OrdinalIgnoreCase));
}
private static bool ContainsSearchHint(string source, string[] searchHints)
{
if (searchHints == null || searchHints.Length == 0)
{
return true;
}
return searchHints.Any(searchHint => source.IndexOf(searchHint, StringComparison.Ordinal) >= 0);
return remainingPatches
.SelectMany(patchType => enhancerConfig[patchType])
.Any(patchEntry => !patchEntry.Applied && JavaScriptPatchApplier.CanSearchFile(filePath, patchEntry));
}
private static string FindWorkspacePath(params string[] segments)
@@ -244,25 +182,6 @@ namespace WandEnhancer.Core
throw new FileNotFoundException($"Required workspace artifact not found: {Path.Combine(segments)}");
}
internal static void CopyDirectory(string sourceDir, string destinationDir)
{
Directory.CreateDirectory(destinationDir);
foreach (var directory in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
{
var relativePath = directory.Substring(sourceDir.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
}
foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
{
var relativePath = file.Substring(sourceDir.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var destinationPath = Path.Combine(destinationDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath) ?? destinationDir);
File.Copy(file, destinationPath, true);
}
}
private static int CopyJavaScriptFiles(string sourceDir, string destinationDir)
{
if (string.IsNullOrEmpty(sourceDir) || !Directory.Exists(sourceDir))
@@ -270,16 +189,9 @@ namespace WandEnhancer.Core
return 0;
}
Directory.CreateDirectory(destinationDir);
int copied = 0;
foreach (var file in Directory.GetFiles(sourceDir, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly))
{
File.Copy(file, GetAvailableScriptPath(destinationDir, Path.GetFileName(file)));
copied++;
}
return copied;
return CopySelectedJavaScriptFiles(
Directory.GetFiles(sourceDir, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly),
destinationDir);
}
private static string GetAvailableScriptPath(string destinationDir, string fileName)
@@ -363,7 +275,7 @@ namespace WandEnhancer.Core
Directory.CreateDirectory(destinationDir);
int copied = 0;
foreach (var file in files.Where(IsJavaScriptFile).Distinct(StringComparer.OrdinalIgnoreCase))
foreach (var file in files.Where(WeModInstalls.IsJavaScriptFile).Distinct(StringComparer.OrdinalIgnoreCase))
{
File.Copy(file, GetAvailableScriptPath(destinationDir, Path.GetFileName(file)));
copied++;
@@ -372,11 +284,6 @@ namespace WandEnhancer.Core
return copied;
}
private static bool IsJavaScriptFile(string file)
{
return File.Exists(file) && string.Equals(Path.GetExtension(file), JavaScriptFileExtension, StringComparison.OrdinalIgnoreCase);
}
private void InjectRemotePanelFiles()
{
if (!_config.PatchTypes.Contains(EPatchType.RemoteWebPanelPreview))
@@ -396,7 +303,7 @@ namespace WandEnhancer.Core
if (CopyEmbeddedDirectory(EmbeddedRemotePanelDistPrefix, targetRoot) == 0)
{
CopyDirectory(FindWorkspacePath(WebPanelDirectoryName, WebPanelDistDirectoryName), targetRoot);
AsarSharp.Utils.Extensions.CopyDirectory(FindWorkspacePath(WebPanelDirectoryName, WebPanelDistDirectoryName), targetRoot);
}
if (!File.Exists(targetBridgePath))
@@ -418,25 +325,79 @@ namespace WandEnhancer.Core
_logger($"[ENHANCER] Injected remote panel assets and renderer scripts into app.asar (default: {defaultScriptCount}, selected: {selectedScriptCount}, local: {localScriptCount})", ELogType.Info);
}
private void AttachProxyDll()
private string SquirrelRoot
{
var assembly = Assembly.GetExecutingAssembly();
var dll = assembly.GetManifestResourceStream(Constants.ProxyDllResouceName);
if (dll == null)
get
{
throw new Exception("[ENHANCER] Proxy DLL resource not found");
string root = Directory.GetParent(_weModConfig.RootDirectory)?.FullName;
if (string.IsNullOrEmpty(root))
{
throw new Exception("[ENHANCER] Cannot determine Squirrel root directory");
}
return root;
}
var destPath = Path.Combine(_weModConfig.RootDirectory, "version.dll");
using (var fileStream = File.Create(destPath))
}
private void DeployLauncher()
{
string stubPath = Path.Combine(SquirrelRoot, _weModConfig.ExecutableName);
string stubBackup = stubPath + StubBackupSuffix;
string self = Assembly.GetExecutingAssembly().Location;
// Auto-patch runs from inside the deployed launcher: it cannot overwrite its own
// running image, and does not need to - it is already in place.
if (string.Equals(self, stubPath, StringComparison.OrdinalIgnoreCase))
{
dll.CopyTo(fileStream);
return;
}
if (File.Exists(stubPath) && !File.Exists(stubBackup))
{
File.Copy(stubPath, stubBackup);
}
File.Copy(self, stubPath, true);
_logger("[ENHANCER] Launcher deployed to root directory", ELogType.Info);
}
private void SaveAutoPatchConfig()
{
string path = Path.Combine(SquirrelRoot, Constants.AutoPatchConfigFileName);
File.WriteAllText(path, Newtonsoft.Json.JsonConvert.SerializeObject(_config, Newtonsoft.Json.Formatting.Indented));
}
private void DeleteAutoPatchConfig()
{
string path = Path.Combine(SquirrelRoot, Constants.AutoPatchConfigFileName);
if (File.Exists(path))
{
File.Delete(path);
}
}
/// <summary>Reads the patch selection saved next to the launcher, or null when absent or unreadable.</summary>
public static PatchConfig LoadAutoPatchConfig(string launcherDirectory)
{
try
{
string path = Path.Combine(launcherDirectory, Constants.AutoPatchConfigFileName);
if (!File.Exists(path))
{
return null;
}
return Newtonsoft.Json.JsonConvert.DeserializeObject<PatchConfig>(File.ReadAllText(path));
}
catch (Exception e) when (e is IOException || e is Newtonsoft.Json.JsonException || e is UnauthorizedAccessException)
{
return null;
}
_logger("[ENHANCER] Proxy DLL attached", ELogType.Info);
}
public void Patch()
{
Common.TryKillProcess(_weModConfig.BrandName);
ProcessTerminator.TryKillProcess(_weModConfig.BrandName);
if (!File.Exists(_backupPath))
{
_logger("[ENHANCER] Creating backup...", ELogType.Info);
@@ -451,7 +412,7 @@ namespace WandEnhancer.Core
if (!Directory.Exists(_unpackedBackupPath) && Directory.Exists(_unpackedPath))
{
_logger("[ENHANCER] Creating backup of app.asar.unpacked...", ELogType.Info);
CopyDirectory(_unpackedPath, _unpackedBackupPath);
AsarSharp.Utils.Extensions.CopyDirectory(_unpackedPath, _unpackedBackupPath);
}
else if (Directory.Exists(_unpackedBackupPath))
{
@@ -461,14 +422,14 @@ namespace WandEnhancer.Core
Directory.Delete(_unpackedPath, true);
}
CopyDirectory(_unpackedBackupPath, _unpackedPath);
AsarSharp.Utils.Extensions.CopyDirectory(_unpackedBackupPath, _unpackedPath);
}
else if (!Directory.Exists(_unpackedPath))
{
throw new Exception("[ENHANCER] app.asar.unpacked is missing and no backup exists. Restore the original Wand installation files or reinstall Wand, then patch again.");
}
if(!File.Exists(_asarPath))
if (!File.Exists(_asarPath))
{
throw new Exception("app.asar not found");
}
@@ -480,9 +441,9 @@ namespace WandEnhancer.Core
}
catch (Exception e)
{
throw new Exception($"[ENHANCER] Failed to unpack app.asar: {e.Message}");
throw new Exception($"[ENHANCER] Failed to unpack app.asar: {e.Message}", e);
}
PatchAsar();
InjectRemotePanelFiles();
@@ -495,12 +456,68 @@ namespace WandEnhancer.Core
}
catch (Exception e)
{
throw new Exception($"[ENHANCER] Failed to pack app.asar: {e.Message}");
throw new Exception($"[ENHANCER] Failed to pack app.asar: {e.Message}", e);
}
AttachProxyDll();
DeployLauncher();
// enhancer.json only exists to drive auto-patch. Without it the launcher still
// runs Wand (fuse patch only), so drop it when the user opts out.
if (_config.AutoApplyAfterUpdate)
{
SaveAutoPatchConfig();
}
else
{
DeleteAutoPatchConfig();
}
_logger("[ENHANCER] Done!", ELogType.Success);
}
public void Restore()
{
if (!File.Exists(_backupPath) || !Directory.Exists(_unpackedBackupPath))
{
throw new Exception("[ENHANCER] Backup is incomplete. Restore the original Wand installation files or reinstall Wand.");
}
ProcessTerminator.TryKillProcess(_weModConfig.BrandName);
File.Copy(_backupPath, _asarPath, true);
if (Directory.Exists(_unpackedPath))
{
Directory.Delete(_unpackedPath, true);
}
AsarSharp.Utils.Extensions.CopyDirectory(_unpackedBackupPath, _unpackedPath);
// Clean up legacy proxy DLL
var proxyDllPath = Path.Combine(_weModConfig.RootDirectory, ProxyDllFileName);
if (File.Exists(proxyDllPath))
{
File.Delete(proxyDllPath);
}
// Restore original Squirrel stub and drop the auto-patch config
string squirrelRoot = SquirrelRoot;
string stubPath = Path.Combine(squirrelRoot, _weModConfig.ExecutableName);
string stubBackup = stubPath + StubBackupSuffix;
if (File.Exists(stubBackup))
{
File.Copy(stubBackup, stubPath, true);
File.Delete(stubBackup);
}
string autoPatchConfig = Path.Combine(squirrelRoot, Constants.AutoPatchConfigFileName);
if (File.Exists(autoPatchConfig))
{
File.Delete(autoPatchConfig);
}
File.Delete(_backupPath);
Directory.Delete(_unpackedBackupPath, true);
_logger("[ENHANCER] Backup restored successfully.", ELogType.Success);
}
}
}
+274 -175
View File
@@ -1,105 +1,44 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using WandEnhancer.Core.Js;
using WandEnhancer.Models;
namespace WandEnhancer.Core
{
public static class EnhancerConfig
/// <summary>
/// Patch definitions. Each entry anchors on something Wand does not rename between builds -
/// an API endpoint, an IPC channel name or a public method name - and then navigates the
/// delimiter structure to the edit site. Minified identifiers are read out of the located
/// region rather than baked into a pattern, so a rebuild does not invalidate a patch.
/// </summary>
internal static class EnhancerConfig
{
public class ResolveContext
{
public string Placeholder { get; set; }
public Func<string, string> Handler { get; set; }
}
/// <summary>Locates the edits a patch must make, or null when the anchor is absent from this file.</summary>
public delegate JsEdit[] PatchLocator(JsCursor js);
public class PatchEntry
public sealed class PatchEntry
{
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;
public PatchLocator Locate { get; set; }
public string[] CandidateFileNames { get; set; }
public string[] SearchHints { get; set; }
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}");
}
/// <summary>Marks the patch optional: builds without these strings lack the feature entirely.</summary>
public string[] CapabilityHints { get; set; }
return group.Value;
}
public bool Applied { get; set; }
public bool CapabilityDetected { get; set; }
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);
}
public bool IsOptional => CapabilityHints != null && CapabilityHints.Length > 0;
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}()}})}}";
/// <summary>True once the patch is applied, or once a scan proved the feature is absent.</summary>
public bool IsResolved => Applied || (IsOptional && !CapabilityDetected);
}
public static Dictionary<EPatchType, PatchEntry[]> GetInstance()
{
return new Dictionary<EPatchType, PatchEntry[]>()
return new Dictionary<EPatchType, PatchEntry[]>
{
{
EPatchType.ActivatePro,
@@ -107,80 +46,41 @@ namespace WandEnhancer.Core
{
new PatchEntry
{
SearchHints = new[] { "getUserAccount()", "/v3/account" },
Resolver = new ResolveContext
{
Handler = (targetFunction) =>
{
var fetchMatch = Regex.Match(targetFunction, @"return\s+this\.#(\w+)\.fetch");
return fetchMatch.Success ? fetchMatch.Groups[1].Value : null;
},
Placeholder = "<service_name>"
},
Name = "getUserAccount",
Target = new Regex(@"getUserAccount\(\)\{.*?return\s+this\.#\w+\.fetch\(\{.*?\}\)\}",
RegexOptions.Singleline),
Patch =
"getUserAccount(){return this.#<service_name>.fetch({endpoint:\"/v3/account\",method:\"GET\",name:\"/v3/account\",collectMetrics:0}).then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}"
SearchHints = new[] { "getUserAccount(" },
Locate = js => ForceProSubscription(js, "getUserAccount")
},
new PatchEntry
{
SearchHints = new[] { "setAccountWandBrandExperience()", "/v3/account/brand_experience_wand" },
Resolver = new ResolveContext
{
Handler = (targetFunction) =>
{
var match = Regex.Match(targetFunction, @"return\s+this\.#(\w+)\.post");
return match.Success ? match.Groups[1].Value : null;
},
Placeholder = "<service_name>"
},
Name = "setAccountWandBrandExperience",
Target = new Regex(
@"setAccountWandBrandExperience\(\)\{.*?return\s+this\.#\w+\.post\(""/v3/account/brand_experience_wand""\)\}",
RegexOptions.Singleline),
Patch =
"setAccountWandBrandExperience(){return this.#<service_name>.post(\"/v3/account/brand_experience_wand\").then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}"
SearchHints = new[] { "setAccountWandBrandExperience(" },
CapabilityHints = new[] { "/v3/account/brand_experience_wand" },
Locate = js => ForceProSubscription(js, "setAccountWandBrandExperience")
},
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.
// Changing language returns a fresh account object that would otherwise
// overwrite the patched subscription in the store.
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
SearchHints = new[] { "setAccountLanguage(" },
Locate = js => ForceProSubscription(js, "setAccountLanguage")
},
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.
// Catches every path that dispatches ACTION_SET_ACCOUNT without going
// through the account API methods above (refresh, push, profile edits).
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
Locate = LocateAccountReducer
},
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).
// Wand's own phone pairing performs a server-side device handoff that
// signs this desktop session out. The injected panel does not use it.
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\"))}"
SearchHints = new[] { "requestRemoteAuthCode" },
Locate = js => Edits(js.FindFunction("requestRemoteAuthCode")?
.ReplaceBody(PatchPayload.Load("disable-native-pairing")))
}
}
},
@@ -188,15 +88,12 @@ 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
{
Name = "disableUpdateCheck",
CandidateFileNames = new[] { "index.js" },
SearchHints = new[] { "ACTION_CHECK_FOR_UPDATE" },
Target = new Regex(@"registerHandler\(""ACTION_CHECK_FOR_UPDATE"".*?\)\)\)\)",
RegexOptions.Singleline),
Patch = "registerHandler(\"ACTION_CHECK_FOR_UPDATE\",(e=>expectUpdateFeedUrl(e,(e=>null)))"
Locate = LocateUpdateHandler
}
}
},
@@ -206,18 +103,12 @@ namespace WandEnhancer.Core
{
new PatchEntry
{
// Hooked in the main process: the renderer's keydown dispatcher is
// reshaped on every Wand release, the Electron app API is not.
Name = "devToolsBeforeInputEvent",
CandidateFileNames = new[] { "index.js" },
SearchHints = new[] { "whenReady().then(" },
// Anchor on the Electron main-process `<app>.whenReady().then(`
// call. This site is far more stable than the minified renderer
// keydown listener that previously held the F12 -> ACTION_OPEN_DEV_TOOLS
// dispatch (its identifiers and shape change on every Wand release).
// We attach a `before-input-event` hook to every BrowserWindow's
// webContents which toggles DevTools on F12 directly from the main
// process, bypassing the renderer dispatcher entirely.
Target = new Regex(@"(?<app>\w+)\.whenReady\(\)\.then\("),
Patch = "${app}.on(\"browser-window-created\",((_,w)=>{try{w.webContents.on(\"before-input-event\",((_,i)=>{if(\"F12\"===i.key&&\"keyDown\"===i.type){w.webContents.isDevToolsOpened()?w.webContents.closeDevTools():w.webContents.openDevTools({mode:\"detach\"})}}))}catch(e){}})),${app}.whenReady().then("
Locate = LocateDevToolsHook
}
}
},
@@ -230,53 +121,261 @@ namespace WandEnhancer.Core
Name = "remoteBridgeMainBoot",
CandidateFileNames = new[] { "index.js" },
SearchHints = new[] { "whenReady().then(run)" },
Target = new Regex(@"(?<app>\w+)\.whenReady\(\)\.then\(run\)"),
Patch = "${app}.whenReady().then(()=>{try{const p=require(\"node:path\");require(p.join(__dirname,\"remote-panel\",\"bridge.cjs\")).installWandRuntime(require(\"electron\"));}catch(e){try{const fs=require(\"node:fs\"),os=require(\"node:os\"),p=require(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [boot-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}}return run()})"
Locate = LocateBridgeBoot
},
new PatchEntry
{
Name = "remoteBridgeReset",
SearchHints = new[] { "client-state" },
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
Locate = LocateBridgeReset
},
new PatchEntry
{
Name = "remoteBridgeSyncSnapshot",
SearchHints = new[] { "client-state" },
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
Locate = LocateBridgeSync
},
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(@"(?<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;"
SearchHints = new[] { "setCurrentTrainer(" },
Locate = LocateBridgeBindHandler
},
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(@"(?<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}"
Locate = LocateBridgeValueDelta
}
}
}
};
}
/// <summary>Wraps the account-returning promise so the resolved account always reports an active subscription.</summary>
private static JsEdit[] ForceProSubscription(JsCursor js, string methodName)
{
return Edits(js.FindFunction(methodName)?.WrapReturn(PatchPayload.Load("pro-subscription")));
}
private static JsEdit[] LocateAccountReducer(JsCursor js)
{
int anchor = js.IndexOf("\"ACTION_SET_ACCOUNT\"");
var reducer = anchor < 0 ? null : js.FindFunctionAfter(anchor);
if (reducer == null)
{
return null;
}
// The payload's ${account} survives PatchPayload untouched and is resolved by the
// regex replacement below, which is what carries the original identifier through.
return Edits(reducer.ReplaceInBody(
@"account:\s*(?<account>[\w$]+)",
PatchPayload.Load("pro-account-reducer")));
}
private static JsEdit[] LocateUpdateHandler(JsCursor js)
{
int callOpen = js.FindCall("registerHandler", "\"ACTION_CHECK_FOR_UPDATE\"");
if (callOpen < 0)
{
return null;
}
return Edits(new JsEdit(callOpen + 1, js.MatchClose(callOpen), PatchPayload.Load("disable-updates")));
}
private static JsEdit[] LocateDevToolsHook(JsCursor js)
{
var match = WhenReady.Match(js.Text);
if (!match.Success)
{
return null;
}
var payload = PatchPayload.Load("devtools-f12", "app", match.Groups["app"].Value);
return Edits(new JsEdit(match.Index, match.Index, payload));
}
private static JsEdit[] LocateBridgeBoot(JsCursor js)
{
var match = WhenReadyThenRun.Match(js.Text);
if (!match.Success)
{
return null;
}
var payload = PatchPayload.Load("remote-bridge-boot", "app", match.Groups["app"].Value);
return Edits(new JsEdit(match.Index, match.Index + match.Length, payload));
}
/// <summary>Clears the bridge alongside the session fields the reset method already nulls out.</summary>
private static JsEdit[] LocateBridgeReset(JsCursor js)
{
var sync = FindClientStateMethod(js);
var reset = sync == null ? null : js.FunctionEndingAt(js.SkipWhitespaceBack(sync.Start - 1));
if (reset == null || reset.Body.IndexOf("Date.now()", StringComparison.Ordinal) < 0)
{
return null;
}
return Edits(reset.InsertAtEnd(PatchPayload.Load("remote-bridge-reset")));
}
/// <summary>
/// Mirrors Wand's own client-state payload to the bridge by copying the object literal
/// verbatim, so fields Wand adds or drops between builds carry over untouched.
/// </summary>
private static JsEdit[] LocateBridgeSync(JsCursor js)
{
int sendOpen = js.FindCall("send", "\"client-state\"");
if (sendOpen < 0)
{
return null;
}
var method = js.EnclosingFunction(sendOpen);
int snapshotOpen = js.IndexOf("{", sendOpen);
int snapshotClose = js.MatchClose(snapshotOpen);
if (method == null || snapshotOpen < 0 || snapshotClose < 0)
{
throw new Exception("client-state payload object could not be located");
}
// Prettified builds leave a trailing comma inside the literal; appending after it
// would produce an illegal hole.
string snapshot = js.Text.Substring(snapshotOpen + 1, snapshotClose - snapshotOpen - 1)
.Trim()
.TrimEnd(',');
var payload = PatchPayload.Load(
"remote-bridge-sync",
"snapshot", snapshot,
"trainer", method.Resolve(@"this\.(?<trainer>#[\w$]+)\s*\?\.\s*getMetadata", "trainer"),
"metadata", method.Resolve(@"getMetadata\(\s*(?<metadata>[\w$]+\.[\w$]+)\s*\)", "metadata"));
var edits = new List<JsEdit> { new JsEdit(js.MatchClose(sendOpen) + 1, payload) };
edits.AddRange(HoistConnectedGuard(js, sendOpen));
return edits.ToArray();
}
/// <summary>
/// Some builds wrap the whole snapshot method in <c>if (status === Connected)</c>. The bridge
/// must publish regardless of Wand's own remote status, so the guard is moved onto the send
/// itself, leaving the block - and the locals the payload reads - intact.
/// </summary>
private static IEnumerable<JsEdit> HoistConnectedGuard(JsCursor js, int sendOpen)
{
int blockOpen = js.EnclosingOpener(sendOpen, '{');
int closeParen = blockOpen < 0 ? -1 : js.SkipWhitespaceBack(blockOpen - 1);
if (closeParen < 0 || js.Text[closeParen] != ')')
{
yield break;
}
var stack = js.OpenerStack(closeParen);
if (stack.Count == 0 || js.NameBefore(stack[0]) != "if")
{
yield break;
}
int openParen = stack[0];
string test = js.Text.Substring(openParen + 1, closeParen - openParen - 1);
// Only the connection guard may be hoisted. A nested unrelated `if` would otherwise
// have its condition moved onto the send, and an `else` branch would be orphaned by
// turning the block into a bare one.
if (test.IndexOf("this.status", StringComparison.Ordinal) < 0 || HasElseBranch(js, blockOpen))
{
yield break;
}
int guardStart = js.SkipWhitespaceBack(openParen - 1) - 1;
int calleeStart = sendOpen;
while (calleeStart > 0 && IsCalleeChar(js.Text[calleeStart - 1]))
{
calleeStart--;
}
yield return new JsEdit(calleeStart, calleeStart, $"({test})&&");
yield return new JsEdit(guardStart, blockOpen, string.Empty);
}
private static bool HasElseBranch(JsCursor js, int blockOpen)
{
int afterBlock = js.SkipWhitespaceForward(js.MatchClose(blockOpen) + 1);
return string.CompareOrdinal(js.Text, afterBlock, "else", 0, 4) == 0;
}
private static JsEdit[] LocateBridgeBindHandler(JsCursor js)
{
var method = js.FindFunction("setCurrentTrainer");
if (method == null)
{
return null;
}
// The same call reveals both the active-trainer field and the numeric or enum value
// Wand uses for a remote-originated write. Wand has sibling call sites for other
// sources (Overlay), so an ambiguous match would silently bind the wrong one.
var setValue = MatchExactlyOnce(RemoteSetValue, js.Text, "Remote setValue call");
return Edits(method.InsertAtStart(PatchPayload.Load(
"remote-bridge-renderer",
"trainer", setValue.Groups["trainer"].Value,
"remoteSource", setValue.Groups["source"].Value)));
}
private static JsEdit[] LocateBridgeValueDelta(JsCursor js)
{
int sendOpen = js.FindCall("send", "\"client-value-changed\"");
if (sendOpen < 0)
{
return null;
}
int sendClose = js.MatchClose(sendOpen);
return Edits(new JsEdit(sendClose + 1, PatchPayload.Load("remote-bridge-value-delta")));
}
private static JsFunction FindClientStateMethod(JsCursor js)
{
int sendOpen = js.FindCall("send", "\"client-state\"");
return sendOpen < 0 ? null : js.EnclosingFunction(sendOpen);
}
private static JsEdit[] Edits(JsEdit edit)
{
return edit == null ? null : new[] { edit };
}
private static bool IsCalleeChar(char value)
{
return char.IsLetterOrDigit(value) || value == '_' || value == '$' || value == '#'
|| value == '.' || value == '?';
}
/// <summary>Match that must be unambiguous: zero or several hits mean an unsupported build.</summary>
private static Match MatchExactlyOnce(Regex pattern, string text, string what)
{
var match = pattern.Match(text);
if (!match.Success)
{
throw new Exception($"{what} could not be located");
}
if (match.NextMatch().Success)
{
throw new Exception($"{what} matched more than once; cannot tell which call site is the right one");
}
return match;
}
private static readonly Regex WhenReady = new Regex(@"(?<app>[\w$]+)\.whenReady\(\)\.then\(");
private static readonly Regex WhenReadyThenRun = new Regex(@"(?<app>[\w$]+)\.whenReady\(\)\.then\(run\)");
private static readonly Regex RemoteSetValue =
new Regex(@"this\.(?<trainer>#[\w$]+)\.setValue\(\s*e\.name\s*,\s*e\.value\s*,\s*(?<source>[^,]+?)\s*,");
}
}
@@ -0,0 +1,82 @@
using System;
using System.IO;
using System.Linq;
using WandEnhancer.Core.Js;
using WandEnhancer.Models;
using WandEnhancer.View.MainWindow;
namespace WandEnhancer.Core
{
internal sealed class JavaScriptPatchApplier
{
private readonly Action<string, ELogType> _logger;
public JavaScriptPatchApplier(Action<string, ELogType> logger)
{
_logger = logger;
}
public string Apply(string fileName, string source, EnhancerConfig.PatchEntry patch, EPatchType patchType, out bool patchApplied)
{
patchApplied = false;
if (patch.Applied || !CanSearchFile(fileName, patch))
{
return source;
}
patch.CapabilityDetected |= ContainsAny(source, patch.CapabilityHints);
if (!ContainsAny(source, patch.SearchHints))
{
return source;
}
string label = FormatLabel(patchType, patch);
JsEdit[] edits;
try
{
edits = patch.Locate(new JsCursor(source));
}
catch (Exception e)
{
throw new Exception($"[ENHANCER] [{label}] {e.Message}. The version may not be supported.", e);
}
if (edits == null || edits.Length == 0)
{
return source;
}
_logger($"[ENHANCER] [{label}] Found target in: {Path.GetFileName(fileName)}", ELogType.Info);
foreach (var edit in edits.OrderByDescending(edit => edit.Start))
{
source = edit.ApplyTo(source);
}
_logger($"[ENHANCER] [{label}] Patch applied", ELogType.Success);
patch.Applied = true;
patchApplied = true;
return source;
}
public static string FormatLabel(EPatchType patchType, EnhancerConfig.PatchEntry patch)
{
return string.IsNullOrEmpty(patch.Name) ? patchType.ToString() : $"{patchType} -> {patch.Name}";
}
public static bool CanSearchFile(string filePath, EnhancerConfig.PatchEntry patch)
{
if (patch.CandidateFileNames == null || patch.CandidateFileNames.Length == 0)
{
return true;
}
string fileName = Path.GetFileName(filePath);
return patch.CandidateFileNames.Any(candidate => fileName.Equals(candidate, StringComparison.OrdinalIgnoreCase));
}
private static bool ContainsAny(string source, string[] hints)
{
return hints != null && hints.Any(hint => source.IndexOf(hint, StringComparison.Ordinal) >= 0);
}
}
}
+409
View File
@@ -0,0 +1,409 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace WandEnhancer.Core.Js
{
/// <summary>
/// Navigates minified JavaScript by matching delimiters rather than by matching shape.
/// Wand renames identifiers on every build but never renames its API endpoints, IPC
/// channel names or public method names, so anchoring on those and walking the
/// delimiter structure keeps a patch valid across builds.
/// </summary>
internal sealed class JsCursor
{
private const string RegexPrecedingChars = "(,=:[!&|?{};+-*%~^<>";
private const int NameLookbackChars = 128;
private static readonly Regex NameBeforeParen = new Regex(@"[#\w$]+$");
private static readonly Regex FunctionKeyword = new Regex(@"(?<![\w$.])function\s*\*?\s*[\w$]*\s*\(");
private static readonly HashSet<string> BlockKeywords =
new HashSet<string>(StringComparer.Ordinal) { "if", "for", "while", "switch", "catch", "with", "do", "else" };
// A slash after one of these is a regex literal, not division. Minifiers emit
// `return/re/.test(x)` with no space, so missing these desyncs the whole scan.
private static readonly HashSet<string> RegexPrecedingKeywords =
new HashSet<string>(StringComparer.Ordinal)
{
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
"throw", "case", "do", "else", "yield", "await"
};
private readonly string _text;
public JsCursor(string text)
{
_text = text;
}
public string Text => _text;
public int IndexOf(string value, int from = 0)
{
return from >= _text.Length ? -1 : _text.IndexOf(value, from, StringComparison.Ordinal);
}
/// <summary>Index of the delimiter closing the one at <paramref name="openIndex"/>, or -1.</summary>
public int MatchClose(int openIndex)
{
char open = _text[openIndex];
char close = CloserOf(open);
int depth = 0;
for (int index = openIndex; index < _text.Length;)
{
char current = _text[index];
if (current == open)
{
depth++;
index++;
}
else if (current == close)
{
if (--depth == 0)
{
return index;
}
index++;
}
else
{
index = SkipToken(index);
}
}
return -1;
}
/// <summary>Open delimiters enclosing <paramref name="index"/>, innermost first.</summary>
public List<int> OpenerStack(int index)
{
var stack = new List<int>();
for (int cursor = 0; cursor < index && cursor < _text.Length;)
{
char current = _text[cursor];
if (current == '{' || current == '(' || current == '[')
{
stack.Add(cursor);
cursor++;
}
else if (current == '}' || current == ')' || current == ']')
{
if (stack.Count > 0)
{
stack.RemoveAt(stack.Count - 1);
}
cursor++;
}
else
{
cursor = SkipToken(cursor);
}
}
stack.Reverse();
return stack;
}
/// <summary>Innermost enclosing delimiter of the given kind, or -1.</summary>
public int EnclosingOpener(int index, char kind)
{
foreach (int opener in OpenerStack(index))
{
if (_text[opener] == kind)
{
return opener;
}
}
return -1;
}
/// <summary>Innermost named function or method whose body contains <paramref name="index"/>.</summary>
public JsFunction EnclosingFunction(int index)
{
foreach (int opener in OpenerStack(index))
{
if (_text[opener] != '{')
{
continue;
}
var function = ReadFunctionAt(opener);
if (function != null)
{
return function;
}
}
return null;
}
/// <summary>The named function whose body closes at <paramref name="closeIndex"/>, or null.</summary>
public JsFunction FunctionEndingAt(int closeIndex)
{
if (closeIndex < 0 || closeIndex >= _text.Length || _text[closeIndex] != '}')
{
return null;
}
var stack = OpenerStack(closeIndex);
return stack.Count == 0 ? null : ReadFunctionAt(stack[0]);
}
/// <summary>First function declared as <c>name(...)</c>, ignoring property and call sites.</summary>
public JsFunction FindFunction(string name)
{
var pattern = new Regex($@"(?<![#\w$.]){Regex.Escape(name)}\s*\(");
for (var match = pattern.Match(_text); match.Success; match = match.NextMatch())
{
int closeParen = MatchClose(match.Index + match.Length - 1);
if (closeParen < 0)
{
continue;
}
int bodyOpen = SkipWhitespaceForward(closeParen + 1);
if (bodyOpen < _text.Length && _text[bodyOpen] == '{')
{
var function = ReadFunctionAt(bodyOpen);
if (function != null && function.Name == name)
{
return function;
}
}
}
return null;
}
/// <summary>First <c>function name(...) { }</c> declared at or after <paramref name="index"/>.</summary>
public JsFunction FindFunctionAfter(int index)
{
var match = FunctionKeyword.Match(_text, index);
if (!match.Success)
{
return null;
}
int closeParen = MatchClose(match.Index + match.Length - 1);
if (closeParen < 0)
{
return null;
}
int bodyOpen = SkipWhitespaceForward(closeParen + 1);
return bodyOpen < _text.Length && _text[bodyOpen] == '{' ? ReadFunctionAt(bodyOpen) : null;
}
/// <summary>
/// Index of the opening parenthesis of <c>callee(... "literal" ...)</c>, or -1. Wand reuses the
/// same channel names for inbound listeners and outbound sends, so the callee disambiguates.
/// </summary>
public int FindCall(string callee, string literal)
{
for (int anchor = IndexOf(literal); anchor >= 0; anchor = IndexOf(literal, anchor + 1))
{
int open = EnclosingOpener(anchor, '(');
if (open >= 0 && NameBefore(open) == callee)
{
return open;
}
}
return -1;
}
/// <summary>Trailing identifier directly before <paramref name="index"/>, e.g. <c>send</c> of <c>a?.send(</c>.</summary>
public string NameBefore(int index)
{
int end = SkipWhitespaceBack(index - 1) + 1;
var match = MatchNameEndingAt(end);
return match.Success ? match.Value.TrimStart('#') : null;
}
/// <summary>Identifier ending at <paramref name="end"/>, searched in a bounded window so
/// multi-megabyte bundles are not copied on every lookup.</summary>
private Match MatchNameEndingAt(int end)
{
int windowStart = Math.Max(0, end - NameLookbackChars);
return NameBeforeParen.Match(_text.Substring(windowStart, end - windowStart));
}
public int SkipWhitespaceBack(int index)
{
while (index >= 0 && char.IsWhiteSpace(_text[index]))
{
index--;
}
return index;
}
public int SkipWhitespaceForward(int index)
{
while (index < _text.Length && char.IsWhiteSpace(_text[index]))
{
index++;
}
return index;
}
private JsFunction ReadFunctionAt(int bodyOpen)
{
int closeParen = SkipWhitespaceBack(bodyOpen - 1);
if (closeParen < 0 || _text[closeParen] != ')')
{
return null;
}
var stack = OpenerStack(closeParen);
if (stack.Count == 0 || _text[stack[0]] != '(')
{
return null;
}
int nameEnd = SkipWhitespaceBack(stack[0] - 1) + 1;
var nameMatch = MatchNameEndingAt(nameEnd);
if (!nameMatch.Success || BlockKeywords.Contains(nameMatch.Value))
{
return null;
}
int bodyClose = MatchClose(bodyOpen);
return bodyClose < 0
? null
: new JsFunction(nameMatch.Value, nameEnd - nameMatch.Length, bodyOpen, bodyClose, _text);
}
private int SkipToken(int index)
{
char current = _text[index];
if (current == '"' || current == '\'' || current == '`')
{
return SkipString(index, current);
}
if (current != '/' || index + 1 >= _text.Length)
{
return index + 1;
}
char next = _text[index + 1];
if (next == '/')
{
int lineEnd = _text.IndexOf('\n', index);
return lineEnd < 0 ? _text.Length : lineEnd + 1;
}
if (next == '*')
{
int commentEnd = _text.IndexOf("*/", index + 2, StringComparison.Ordinal);
return commentEnd < 0 ? _text.Length : commentEnd + 2;
}
return StartsRegexLiteral(index) ? SkipRegexLiteral(index) : index + 1;
}
private int SkipString(int index, char quote)
{
for (int cursor = index + 1; cursor < _text.Length; cursor++)
{
char current = _text[cursor];
if (current == '\\')
{
cursor++;
}
else if (current == quote)
{
return cursor + 1;
}
else if (quote == '`' && current == '$' && cursor + 1 < _text.Length && _text[cursor + 1] == '{')
{
int interpolationEnd = MatchClose(cursor + 1);
cursor = interpolationEnd < 0 ? _text.Length : interpolationEnd;
}
}
return _text.Length;
}
private int SkipRegexLiteral(int index)
{
bool inCharacterClass = false;
for (int cursor = index + 1; cursor < _text.Length; cursor++)
{
char current = _text[cursor];
if (current == '\\')
{
cursor++;
}
else if (current == '[')
{
inCharacterClass = true;
}
else if (current == ']')
{
inCharacterClass = false;
}
else if (current == '\n')
{
return index + 1;
}
else if (current == '/' && !inCharacterClass)
{
return cursor + 1;
}
}
return _text.Length;
}
private bool StartsRegexLiteral(int index)
{
int previous = SkipWhitespaceBack(index - 1);
if (previous < 0 || RegexPrecedingChars.IndexOf(_text[previous]) >= 0)
{
return true;
}
return IsIdentifierChar(_text[previous]) && RegexPrecedingKeywords.Contains(WordEndingAt(previous));
}
/// <summary>The identifier ending at <paramref name="end"/> inclusive, or "" when there is none.</summary>
private string WordEndingAt(int end)
{
int start = end;
while (start >= 0 && IsIdentifierChar(_text[start]))
{
start--;
}
// A preceding '.' makes it a member name (`x.in`), never a keyword.
if (start >= 0 && _text[start] == '.')
{
return string.Empty;
}
return _text.Substring(start + 1, end - start);
}
private static bool IsIdentifierChar(char value)
{
return char.IsLetterOrDigit(value) || value == '_' || value == '$';
}
private static char CloserOf(char open)
{
switch (open)
{
case '{': return '}';
case '(': return ')';
case '[': return ']';
default: throw new ArgumentException($"Not an opening delimiter: {open}", nameof(open));
}
}
}
}
+129
View File
@@ -0,0 +1,129 @@
using System;
using System.Text.RegularExpressions;
namespace WandEnhancer.Core.Js
{
/// <summary>A named function or class method located in a bundle, addressed by delimiter position.</summary>
internal sealed class JsFunction
{
private static readonly Regex ReturnKeyword = new Regex(@"(?<![\w$])return(?![\w$])");
private readonly string _source;
private JsCursor _body;
public JsFunction(string name, int start, int bodyOpen, int bodyClose, string source)
{
Name = name;
Start = start;
BodyOpen = bodyOpen;
BodyClose = bodyClose;
_source = source;
}
public string Name { get; }
public int Start { get; }
public int BodyOpen { get; }
public int BodyClose { get; }
public string Body => _source.Substring(BodyOpen + 1, BodyClose - BodyOpen - 1);
private JsCursor BodyCursor => _body ?? (_body = new JsCursor(Body));
/// <summary>Captures a group from a pattern matched against this body only, not the whole bundle.</summary>
public string Resolve(string pattern, string group)
{
var match = Regex.Match(Body, pattern, RegexOptions.Singleline);
if (!match.Success || string.IsNullOrEmpty(match.Groups[group].Value))
{
throw new Exception($"Could not resolve '{group}' inside {Name}()");
}
return match.Groups[group].Value;
}
/// <summary>Rewrites the first match of a pattern scoped to this body; <c>${group}</c> back-references work.</summary>
public JsEdit ReplaceInBody(string pattern, string replacement)
{
var match = Regex.Match(Body, pattern, RegexOptions.Singleline);
if (!match.Success)
{
throw new Exception($"Pattern '{pattern}' not found inside {Name}()");
}
int start = BodyOpen + 1 + match.Index;
return new JsEdit(start, start + match.Length, match.Result(replacement));
}
public JsEdit InsertAtStart(string code) => new JsEdit(BodyOpen + 1, BodyOpen + 1, code);
public JsEdit InsertAtEnd(string code) => new JsEdit(BodyClose, BodyClose, code);
public JsEdit ReplaceBody(string code) => new JsEdit(BodyOpen + 1, BodyClose, code);
/// <summary>
/// Rewrites the last top-level <c>return X</c> as <c>return WRAPPER</c>, where the wrapper's
/// <c>$0</c> placeholder receives the original expression.
/// </summary>
public JsEdit WrapReturn(string wrapper)
{
var body = BodyCursor;
int keywordEnd = -1;
for (var match = ReturnKeyword.Match(body.Text); match.Success; match = match.NextMatch())
{
if (body.OpenerStack(match.Index).Count == 0)
{
keywordEnd = match.Index + match.Length;
}
}
if (keywordEnd < 0)
{
throw new Exception($"No top-level return statement in {Name}()");
}
int expressionStart = body.SkipWhitespaceForward(keywordEnd);
int expressionEnd = FindStatementEnd(body, expressionStart);
string expression = body.Text.Substring(expressionStart, expressionEnd - expressionStart);
return new JsEdit(
BodyOpen + 1 + expressionStart,
BodyOpen + 1 + expressionEnd,
wrapper.Replace("$0", $"({expression})"));
}
private static int FindStatementEnd(JsCursor body, int start)
{
for (int cursor = start; cursor < body.Text.Length; cursor++)
{
if (body.Text[cursor] == ';' && body.OpenerStack(cursor).Count == 0)
{
return cursor;
}
}
return body.Text.Length;
}
}
/// <summary>A splice: replace <c>[Start, End)</c> of the bundle with <see cref="Text"/>.</summary>
internal sealed class JsEdit
{
public JsEdit(int start, int end, string text)
{
Start = start;
End = end;
Text = text;
}
/// <summary>An insertion at <paramref name="at"/>, replacing nothing.</summary>
public JsEdit(int at, string text) : this(at, at, text)
{
}
public int Start { get; }
public int End { get; }
public string Text { get; }
public string ApplyTo(string source) => source.Substring(0, Start) + Text + source.Substring(End);
}
}
+63
View File
@@ -0,0 +1,63 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.RegularExpressions;
namespace WandEnhancer.Core.Js
{
/// <summary>
/// Loads injected JavaScript from embedded <c>Patches/*.js</c> files so payloads stay
/// lintable source rather than escaped C# string literals.
/// </summary>
internal static class PatchPayload
{
private const string ResourcePrefix = "patches/";
private static readonly ConcurrentDictionary<string, string> Cache =
new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
private static readonly Regex Placeholder = new Regex(@"\$\{(?<name>\w+)\}");
/// <summary>
/// Loads a payload, replacing each <c>${name}</c> placeholder from alternating name/value pairs.
/// Substitution is a single pass, so injected bundle text is never rescanned for placeholders.
/// Unknown placeholders are left intact for the caller's own regex replacement to resolve.
/// </summary>
public static string Load(string name, params string[] placeholders)
{
if (placeholders.Length % 2 != 0)
{
throw new ArgumentException("Placeholders must be name/value pairs", nameof(placeholders));
}
var values = new Dictionary<string, string>(StringComparer.Ordinal);
for (int index = 0; index < placeholders.Length; index += 2)
{
values[placeholders[index]] = placeholders[index + 1];
}
return Placeholder.Replace(
Cache.GetOrAdd(name, ReadResource),
match => values.TryGetValue(match.Groups["name"].Value, out var value) ? value : match.Value);
}
private static string ReadResource(string name)
{
string resourceName = $"{ResourcePrefix}{name}.js";
using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName))
{
if (stream == null)
{
throw new FileNotFoundException($"Embedded patch payload not found: {resourceName}");
}
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd().Trim();
}
}
}
}
}
+4 -23
View File
@@ -1,41 +1,22 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using WandEnhancer.Utils;
namespace WandEnhancer.Models
{
public enum EPatchType
{
ActivatePro = 1,
DisableUpdates = 2,
DisableTelemetry = 4,
DevToolsOnF12 = 8,
RemoteWebPanelPreview = 16
}
public sealed class PatchConfig
{
private string _path;
public HashSet<EPatchType> PatchTypes { get; set; }
public List<string> CustomScriptPaths { get; set; } = new List<string>();
public bool AutoApplyPatches { get; set; }
[JsonIgnore]
public WeModConfig AppProps { get; private set; }
public string Path
{
get => _path;
set
{
_path = value;
AppProps = Extensions.CheckWeModPath(_path) ?? throw new Exception("Invalid WeMod path");
}
}
/// <summary>When set, the patch selection is saved so the launcher re-applies it after a Wand update.</summary>
public bool AutoApplyAfterUpdate { get; set; }
}
}
}
-48
View File
@@ -1,48 +0,0 @@
using System;
namespace WandEnhancer.Models
{
public sealed class Signature
{
public readonly byte[] OriginalBytes;
public readonly byte[] PatchBytes;
public readonly byte[] Sequence;
public readonly byte[] Mask;
public readonly int Offset;
public int Length => Sequence.Length;
public static implicit operator byte[](Signature signature) => signature.Sequence;
public Signature(string signature, int offset, byte[] patchBytes, byte[] originalBytes)
{
Parse(signature, out Sequence, out Mask);
PatchBytes = patchBytes;
OriginalBytes = originalBytes;
Offset = offset;
}
private static void Parse(string signatureStr, out byte[] pattern, out byte[] mask)
{
var parts = signatureStr.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
var length = parts.Length;
pattern = new byte[length];
mask = new byte[length];
for (var i = 0; i < length; i++)
{
if (parts[i] == "??" || parts[i] == "?")
{
pattern[i] = 0;
// wildcard byte
mask[i] = 0;
continue;
}
pattern[i] = Convert.ToByte(parts[i], 16);
mask[i] = 1;
}
}
}
}
+1
View File
@@ -0,0 +1 @@
${app}.on("browser-window-created",((_,w)=>{try{w.webContents.on("before-input-event",((_,i)=>{if("F12"===i.key&&"keyDown"===i.type){w.webContents.isDevToolsOpened()?w.webContents.closeDevTools():w.webContents.openDevTools({mode:"detach"})}}))}catch(e){}})),
@@ -0,0 +1 @@
return Promise.reject(new Error("wand-enhancer: native mobile pairing disabled"))
+1
View File
@@ -0,0 +1 @@
"ACTION_CHECK_FOR_UPDATE",(e=>expectUpdateFeedUrl(e,(e=>null)))
@@ -0,0 +1 @@
account:((account)=>account&&"object"==typeof account?{...account,subscription:{period:"yearly",state:"active"}}:account)(${account})
+1
View File
@@ -0,0 +1 @@
$0.then((response)=>{response&&"object"==typeof response&&(response.subscription={period:"yearly",state:"active"});return response})
@@ -0,0 +1 @@
${app}.whenReady().then(()=>{try{const p=require("node:path");require(p.join(__dirname,"remote-panel","bridge.cjs")).installWandRuntime(require("electron"))}catch(e){try{const fs=require("node:fs"),os=require("node:os"),p=require("node:path");fs.appendFileSync(p.join(os.tmpdir(),"wand-remote-bridge.log"),"["+new Date().toISOString()+"] [boot-error] "+(e&&e.stack||e)+"\n")}catch(_){}}return run()})
@@ -0,0 +1 @@
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;
@@ -0,0 +1 @@
;this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null)
@@ -0,0 +1 @@
,this.__wandRemoteBridge?.sync({${snapshot},trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.${trainer}?.getMetadata(${metadata})??null})
@@ -0,0 +1 @@
,this.__wandRemoteBridge?.valueChanged({target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??"desktop"),cheatId:e.cheatId})
-59
View File
@@ -1,59 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Threading;
namespace WandEnhancer.Utils
{
public static class Common
{
public static void TryKillProcess(string processName)
{
Process[] processes = Process.GetProcessesByName(processName);
// 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)
{
try
{
process.Kill();
}
catch
{
// ignored
}
}
processes = Process.GetProcessesByName(processName);
Thread.Sleep(250);
}
if (processes.Length > 0)
{
throw new Exception("Failed to kill WeMod");
}
}
public static string GetCurrentDir()
{
var assemblyLocation = Assembly.GetExecutingAssembly().Location;
return Path.GetDirectoryName(assemblyLocation) ?? throw new InvalidOperationException();
}
public static string ComputeSha256Hash(string input)
{
using (var sha256 = System.Security.Cryptography.SHA256.Create())
{
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
var hashBytes = sha256.ComputeHash(bytes);
return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
}
}
}
}
+85
View File
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
namespace WandEnhancer.Utils
{
public static class ProcessTerminator
{
private const int KillAttempts = 5;
private const int KillRetryDelayMs = 250;
public static void TryKillProcess(string processName)
{
// The launcher itself runs as Wand.exe; never target our own process.
int selfId = Process.GetCurrentProcess().Id;
for (int attempt = 0; attempt < KillAttempts; attempt++)
{
var processes = Others(Process.GetProcessesByName(processName), selfId);
try
{
if (processes.Length == 0)
{
return;
}
foreach (var process in processes)
{
try
{
process.Kill();
}
catch (Exception e) when (e is InvalidOperationException || e is System.ComponentModel.Win32Exception)
{
// Already exited, or protected: the post-loop check decides the outcome.
}
}
}
finally
{
foreach (var process in processes)
{
process.Dispose();
}
}
Thread.Sleep(KillRetryDelayMs);
}
var survivors = Others(Process.GetProcessesByName(processName), selfId);
try
{
if (survivors.Length > 0)
{
throw new InvalidOperationException($"Failed to close {processName}. Close it manually and try again.");
}
}
finally
{
foreach (var process in survivors)
{
process.Dispose();
}
}
}
private static Process[] Others(Process[] processes, int selfId)
{
var result = new List<Process>(processes.Length);
foreach (var process in processes)
{
if (process.Id == selfId)
{
process.Dispose();
continue;
}
result.Add(process);
}
return result.ToArray();
}
}
}
@@ -7,13 +7,14 @@ using WandEnhancer.Models;
namespace WandEnhancer.Utils
{
public static class Extensions
public static class WeModInstalls
{
public const string JavaScriptFileExtension = ".js";
public static WeModConfig CheckWeModPath(string versionRoot)
{
try
{
foreach (var name in Constants.WeModBrandNames)
{
var exeName = $"{name}.exe";
@@ -29,9 +30,9 @@ namespace WandEnhancer.Utils
}
}
}
catch
catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || e is ArgumentException)
{
// ignored
// An unreadable or malformed candidate directory is not this install.
}
return null;
@@ -113,16 +114,10 @@ namespace WandEnhancer.Utils
return null;
}
public static string Base64Decode(string base64EncodedData)
public static bool IsJavaScriptFile(string path)
{
var base64EncodedBytes = System.Convert.FromBase64String(base64EncodedData);
return System.Text.Encoding.UTF8.GetString(base64EncodedBytes);
}
public static string Base64Encode(string plainText)
{
var plainTextBytes = System.Text.Encoding.UTF8.GetBytes(plainText);
return System.Convert.ToBase64String(plainTextBytes);
return File.Exists(path)
&& string.Equals(Path.GetExtension(path), JavaScriptFileExtension, StringComparison.OrdinalIgnoreCase);
}
public static WeModConfig FindLatestWeMod(string root)