diff --git a/WandEnhancer/Core/Enhancer.cs b/WandEnhancer/Core/Enhancer.cs index de59311..28411c0 100644 --- a/WandEnhancer/Core/Enhancer.cs +++ b/WandEnhancer/Core/Enhancer.cs @@ -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 _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; + /// For , which needs the install paths but no patch selection. + public Enhancer(WeModConfig weModConfig, Action logger) + : this(weModConfig, logger, null) + { + } + public Enhancer(WeModConfig weModConfig, Action 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) + + /// + /// Both halves of the backup must exist. Accepting either one on its own reported a + /// half-written backup as patched, which blocked patching while + /// refused to run - leaving the user with no way forward. + /// + 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(_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 remainingPatches, Dictionary 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 remainingPatches, Dictionary 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); + } + } + + /// Reads the patch selection saved next to the launcher, or null when absent or unreadable. + 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(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); + } } } diff --git a/WandEnhancer/Core/EnhancerConfig.cs b/WandEnhancer/Core/EnhancerConfig.cs index ab23c49..594283a 100644 --- a/WandEnhancer/Core/EnhancerConfig.cs +++ b/WandEnhancer/Core/EnhancerConfig.cs @@ -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 + /// + /// 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. + /// + internal static class EnhancerConfig { - public class ResolveContext - { - public string Placeholder { get; set; } - public Func Handler { get; set; } - } + /// Locates the edits a patch must make, or null when the anchor is absent from this file. + 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 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}"); - } + /// Marks the patch optional: builds without these strings lack the feature entirely. + 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\.(?#[\w$]+)\s*&&\s*\(\s*this\.\k\.dispose\(\)", "disposable", "remoteBridgeReset"); - var instanceField = RequirePattern(source, @"this\.(?#[\w$]+)\s*=\s*Date\.now\(\)\.toString\(\)", "instance", "remoteBridgeReset"); - var trainerIdField = RequirePattern(source, @"Date\.now\(\)\.toString\(\)\s*\)?\s*,\s*\(?\s*this\.(?#[\w$]+)\s*=\s*null", "trainerId", "remoteBridgeReset"); - var supportedVersionsField = RequirePattern(source, @"this\.(?#[\w$]+)\s*=\s*\[\]", "versions", "remoteBridgeReset"); - var trainerField = RequirePattern(source, @"this\.(?#[\w$]+)\s*=\s*\[\]\s*\)?\s*,\s*\(?\s*this\.(?#[\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*(?[\w$]+)\.Connected", "value", "remoteBridgeSyncSnapshot"); - var trainerField = RequirePattern(source, @"this\.(?#[\w$]+)\?\.\s*getMetadata\s*\(\s*(?[\w$]+\.[\w$]+)\s*\)\?\.\s*gameVersion", "trainer", "remoteBridgeSyncSnapshot"); - var metadataExport = RequirePattern(source, @"this\.(?#[\w$]+)\?\.\s*getMetadata\s*\(\s*(?[\w$]+\.[\w$]+)\s*\)\?\.\s*gameVersion", "metadata", "remoteBridgeSyncSnapshot"); - var notesField = RequirePattern(source, @"this\.(?#[\w$]+)\s*\[\s*this\.(?#[\w$]+)\s*\?\?\s*""""\s*\]", "notes", "remoteBridgeSyncSnapshot"); - var trainerIdField = RequirePattern(source, @"this\.(?#[\w$]+)\s*\[\s*this\.(?#[\w$]+)\s*\?\?\s*""""\s*\]", "trainerId", "remoteBridgeSyncSnapshot"); - var gameField = RequirePattern(source, @"this\.(?#[\w$]+)\s*&&.*?getPreferredInstallationInfo\s*\(\s*this\.\k\s*\)", "game", "remoteBridgeSyncSnapshot"); - var installationField = RequirePattern(source, @"this\.(?#[\w$]+)\s*&&.*?this\.(?#[\w$]+)\.getPreferredInstallationInfo\s*\(\s*this\.\k\s*\)", "installation", "remoteBridgeSyncSnapshot"); - var supportedVersionsField = RequirePattern(source, @"!\s*this\.(?#[\w$]+)\.includes\s*\(\s*[\w$]+\.version\s*\)", "versions", "remoteBridgeSyncSnapshot"); - var remoteChannelField = RequirePattern(source, @"this\.(?#[\w$]+)\?\.\s*send\s*\(\s*""client-state""", "remote", "remoteBridgeSyncSnapshot"); - var valuesMethod = RequirePattern(source, @"values\s*:\s*this\.(?#[\w$]+)\s*\(\s*\)", "values", "remoteBridgeSyncSnapshot"); - var instanceField = RequirePattern(source, @"instanceId\s*:\s*this\.(?#[\w$]+)", "instance", "remoteBridgeSyncSnapshot"); - var themeField = RequirePattern(source, @"themeId\s*:\s*this\.(?#[\w$]+)", "theme", "remoteBridgeSyncSnapshot"); - var settingsHelper = RequirePattern(source, @"settings\s*:\s*(?[\w$]+)\s*\(\s*this\.settings\s*\)", "settings", "remoteBridgeSyncSnapshot"); - var languageField = RequirePattern(source, @"language\s*:\s*this\.(?#[\w$]+)", "language", "remoteBridgeSyncSnapshot"); - var timerField = RequirePattern(source, @"isTimeLimitExpired\s*:\s*""expired""\s*===\s*this\.(?#[\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}()}})}}"; + /// True once the patch is applied, or once a scan proved the feature is absent. + public bool IsResolved => Applied || (IsOptional && !CapabilityDetected); } public static Dictionary GetInstance() { - return new Dictionary() + return new Dictionary { { 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 = "" - }, Name = "getUserAccount", - Target = new Regex(@"getUserAccount\(\)\{.*?return\s+this\.#\w+\.fetch\(\{.*?\}\)\}", - RegexOptions.Singleline), - Patch = - "getUserAccount(){return this.#.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 = "" - }, Name = "setAccountWandBrandExperience", - Target = new Regex( - @"setAccountWandBrandExperience\(\)\{.*?return\s+this\.#\w+\.post\(""/v3/account/brand_experience_wand""\)\}", - RegexOptions.Singleline), - Patch = - "setAccountWandBrandExperience(){return this.#.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\((?[^)]*)\)\{\s*return\s+(?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 (?\w+)=""ACTION_SET_ACCOUNT"";function (?\w+)\((?[^)]*)\)\{return\{\.\.\.(?\w+),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 `.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(@"(?\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(@"(?\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(@"(?#[\w$]+)\(\)\s*\{\s*(?(?:(?!__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(@"(?#[\w$]+)\(\)\s*\{\s*if\s*\(\s*this\.status\s*===\s*[\w$]+\.Connected\s*\)\s*\{(?.*?""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(@"(?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\.(?#[\w$]+)\)return;)(?=.*?e\.source!==(?[\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(@"(?#[\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\}\))(?\}\)\),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 } } } }; } + + /// Wraps the account-returning promise so the resolved account always reports an active subscription. + 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*(?[\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)); + } + + /// Clears the bridge alongside the session fields the reset method already nulls out. + 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"))); + } + + /// + /// 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. + /// + 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\.(?#[\w$]+)\s*\?\.\s*getMetadata", "trainer"), + "metadata", method.Resolve(@"getMetadata\(\s*(?[\w$]+\.[\w$]+)\s*\)", "metadata")); + + var edits = new List { new JsEdit(js.MatchClose(sendOpen) + 1, payload) }; + edits.AddRange(HoistConnectedGuard(js, sendOpen)); + return edits.ToArray(); + } + + /// + /// Some builds wrap the whole snapshot method in if (status === Connected). 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. + /// + private static IEnumerable 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 == '?'; + } + + /// Match that must be unambiguous: zero or several hits mean an unsupported build. + 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(@"(?[\w$]+)\.whenReady\(\)\.then\("); + private static readonly Regex WhenReadyThenRun = new Regex(@"(?[\w$]+)\.whenReady\(\)\.then\(run\)"); + private static readonly Regex RemoteSetValue = + new Regex(@"this\.(?#[\w$]+)\.setValue\(\s*e\.name\s*,\s*e\.value\s*,\s*(?[^,]+?)\s*,"); } } diff --git a/WandEnhancer/Core/JavaScriptPatchApplier.cs b/WandEnhancer/Core/JavaScriptPatchApplier.cs new file mode 100644 index 0000000..2e78286 --- /dev/null +++ b/WandEnhancer/Core/JavaScriptPatchApplier.cs @@ -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 _logger; + + public JavaScriptPatchApplier(Action 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); + } + } +} diff --git a/WandEnhancer/Core/Js/JsCursor.cs b/WandEnhancer/Core/Js/JsCursor.cs new file mode 100644 index 0000000..e4e5aa3 --- /dev/null +++ b/WandEnhancer/Core/Js/JsCursor.cs @@ -0,0 +1,409 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace WandEnhancer.Core.Js +{ + /// + /// 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. + /// + 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(@"(? BlockKeywords = + new HashSet(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 RegexPrecedingKeywords = + new HashSet(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); + } + + /// Index of the delimiter closing the one at , or -1. + 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; + } + + /// Open delimiters enclosing , innermost first. + public List OpenerStack(int index) + { + var stack = new List(); + 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; + } + + /// Innermost enclosing delimiter of the given kind, or -1. + public int EnclosingOpener(int index, char kind) + { + foreach (int opener in OpenerStack(index)) + { + if (_text[opener] == kind) + { + return opener; + } + } + + return -1; + } + + /// Innermost named function or method whose body contains . + 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; + } + + /// The named function whose body closes at , or null. + 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]); + } + + /// First function declared as name(...), ignoring property and call sites. + public JsFunction FindFunction(string name) + { + var pattern = new Regex($@"(?First function name(...) { } declared at or after . + 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; + } + + /// + /// Index of the opening parenthesis of callee(... "literal" ...), or -1. Wand reuses the + /// same channel names for inbound listeners and outbound sends, so the callee disambiguates. + /// + 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; + } + + /// Trailing identifier directly before , e.g. send of a?.send(. + public string NameBefore(int index) + { + int end = SkipWhitespaceBack(index - 1) + 1; + var match = MatchNameEndingAt(end); + return match.Success ? match.Value.TrimStart('#') : null; + } + + /// Identifier ending at , searched in a bounded window so + /// multi-megabyte bundles are not copied on every lookup. + 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)); + } + + /// The identifier ending at inclusive, or "" when there is none. + 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)); + } + } + } +} diff --git a/WandEnhancer/Core/Js/JsFunction.cs b/WandEnhancer/Core/Js/JsFunction.cs new file mode 100644 index 0000000..d5335c4 --- /dev/null +++ b/WandEnhancer/Core/Js/JsFunction.cs @@ -0,0 +1,129 @@ +using System; +using System.Text.RegularExpressions; + +namespace WandEnhancer.Core.Js +{ + /// A named function or class method located in a bundle, addressed by delimiter position. + internal sealed class JsFunction + { + private static readonly Regex ReturnKeyword = new Regex(@"(? _source.Substring(BodyOpen + 1, BodyClose - BodyOpen - 1); + + private JsCursor BodyCursor => _body ?? (_body = new JsCursor(Body)); + + /// Captures a group from a pattern matched against this body only, not the whole bundle. + 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; + } + + /// Rewrites the first match of a pattern scoped to this body; ${group} back-references work. + 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); + + /// + /// Rewrites the last top-level return X as return WRAPPER, where the wrapper's + /// $0 placeholder receives the original expression. + /// + 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; + } + } + + /// A splice: replace [Start, End) of the bundle with . + internal sealed class JsEdit + { + public JsEdit(int start, int end, string text) + { + Start = start; + End = end; + Text = text; + } + + /// An insertion at , replacing nothing. + 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); + } +} diff --git a/WandEnhancer/Core/Js/PatchPayload.cs b/WandEnhancer/Core/Js/PatchPayload.cs new file mode 100644 index 0000000..742c7e8 --- /dev/null +++ b/WandEnhancer/Core/Js/PatchPayload.cs @@ -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 +{ + /// + /// Loads injected JavaScript from embedded Patches/*.js files so payloads stay + /// lintable source rather than escaped C# string literals. + /// + internal static class PatchPayload + { + private const string ResourcePrefix = "patches/"; + + private static readonly ConcurrentDictionary Cache = + new ConcurrentDictionary(StringComparer.Ordinal); + + private static readonly Regex Placeholder = new Regex(@"\$\{(?\w+)\}"); + + /// + /// Loads a payload, replacing each ${name} 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. + /// + 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(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(); + } + } + } + } +} diff --git a/WandEnhancer/Models/PatchConfig.cs b/WandEnhancer/Models/PatchConfig.cs index c050324..2d9811c 100644 --- a/WandEnhancer/Models/PatchConfig.cs +++ b/WandEnhancer/Models/PatchConfig.cs @@ -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 PatchTypes { get; set; } public List CustomScriptPaths { get; set; } = new List(); - - 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"); - } - } + /// When set, the patch selection is saved so the launcher re-applies it after a Wand update. + public bool AutoApplyAfterUpdate { get; set; } } - -} \ No newline at end of file +} diff --git a/WandEnhancer/Models/Signature.cs b/WandEnhancer/Models/Signature.cs deleted file mode 100644 index 789d3a4..0000000 --- a/WandEnhancer/Models/Signature.cs +++ /dev/null @@ -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; - } - } - } -} diff --git a/WandEnhancer/Patches/devtools-f12.js b/WandEnhancer/Patches/devtools-f12.js new file mode 100644 index 0000000..037fff4 --- /dev/null +++ b/WandEnhancer/Patches/devtools-f12.js @@ -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){}})), diff --git a/WandEnhancer/Patches/disable-native-pairing.js b/WandEnhancer/Patches/disable-native-pairing.js new file mode 100644 index 0000000..36aec00 --- /dev/null +++ b/WandEnhancer/Patches/disable-native-pairing.js @@ -0,0 +1 @@ +return Promise.reject(new Error("wand-enhancer: native mobile pairing disabled")) diff --git a/WandEnhancer/Patches/disable-updates.js b/WandEnhancer/Patches/disable-updates.js new file mode 100644 index 0000000..ffbc3ae --- /dev/null +++ b/WandEnhancer/Patches/disable-updates.js @@ -0,0 +1 @@ +"ACTION_CHECK_FOR_UPDATE",(e=>expectUpdateFeedUrl(e,(e=>null))) diff --git a/WandEnhancer/Patches/pro-account-reducer.js b/WandEnhancer/Patches/pro-account-reducer.js new file mode 100644 index 0000000..674add6 --- /dev/null +++ b/WandEnhancer/Patches/pro-account-reducer.js @@ -0,0 +1 @@ +account:((account)=>account&&"object"==typeof account?{...account,subscription:{period:"yearly",state:"active"}}:account)(${account}) diff --git a/WandEnhancer/Patches/pro-subscription.js b/WandEnhancer/Patches/pro-subscription.js new file mode 100644 index 0000000..36397ef --- /dev/null +++ b/WandEnhancer/Patches/pro-subscription.js @@ -0,0 +1 @@ +$0.then((response)=>{response&&"object"==typeof response&&(response.subscription={period:"yearly",state:"active"});return response}) diff --git a/WandEnhancer/Patches/remote-bridge-boot.js b/WandEnhancer/Patches/remote-bridge-boot.js new file mode 100644 index 0000000..61f08b7 --- /dev/null +++ b/WandEnhancer/Patches/remote-bridge-boot.js @@ -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()}) diff --git a/WandEnhancer/Patches/remote-bridge-renderer.js b/WandEnhancer/Patches/remote-bridge-renderer.js new file mode 100644 index 0000000..3f99f3d --- /dev/null +++ b/WandEnhancer/Patches/remote-bridge-renderer.js @@ -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; diff --git a/WandEnhancer/Patches/remote-bridge-reset.js b/WandEnhancer/Patches/remote-bridge-reset.js new file mode 100644 index 0000000..373f709 --- /dev/null +++ b/WandEnhancer/Patches/remote-bridge-reset.js @@ -0,0 +1 @@ +;this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null) diff --git a/WandEnhancer/Patches/remote-bridge-sync.js b/WandEnhancer/Patches/remote-bridge-sync.js new file mode 100644 index 0000000..aa8cecb --- /dev/null +++ b/WandEnhancer/Patches/remote-bridge-sync.js @@ -0,0 +1 @@ +,this.__wandRemoteBridge?.sync({${snapshot},trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.${trainer}?.getMetadata(${metadata})??null}) diff --git a/WandEnhancer/Patches/remote-bridge-value-delta.js b/WandEnhancer/Patches/remote-bridge-value-delta.js new file mode 100644 index 0000000..a47a0f6 --- /dev/null +++ b/WandEnhancer/Patches/remote-bridge-value-delta.js @@ -0,0 +1 @@ +,this.__wandRemoteBridge?.valueChanged({target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??"desktop"),cheatId:e.cheatId}) diff --git a/WandEnhancer/Utils/Common.cs b/WandEnhancer/Utils/Common.cs deleted file mode 100644 index ca9e49e..0000000 --- a/WandEnhancer/Utils/Common.cs +++ /dev/null @@ -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(); - } - } - } -} \ No newline at end of file diff --git a/WandEnhancer/Utils/ProcessTerminator.cs b/WandEnhancer/Utils/ProcessTerminator.cs new file mode 100644 index 0000000..4790415 --- /dev/null +++ b/WandEnhancer/Utils/ProcessTerminator.cs @@ -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(processes.Length); + foreach (var process in processes) + { + if (process.Id == selfId) + { + process.Dispose(); + continue; + } + + result.Add(process); + } + + return result.ToArray(); + } + } +} diff --git a/WandEnhancer/Utils/Extensions.cs b/WandEnhancer/Utils/WeModInstalls.cs similarity index 88% rename from WandEnhancer/Utils/Extensions.cs rename to WandEnhancer/Utils/WeModInstalls.cs index f2b3936..c491a23 100644 --- a/WandEnhancer/Utils/Extensions.cs +++ b/WandEnhancer/Utils/WeModInstalls.cs @@ -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)