From fa753b4a346d70f0ca0ef2453c3d6de87c7ac137 Mon Sep 17 00:00:00 2001 From: kitbyte Date: Fri, 1 May 2026 22:23:27 +0300 Subject: [PATCH] Release 1.0.7.0: Remote Web Panel & Stability Fixes --- .github/workflows/mirror.yml | 26 + .gitignore | 1 + AGENTS.md | 30 + AsarSharp/AsarExtractor.cs | 41 +- README.md | 20 +- WandEnhancer/Core/Enhancer.cs | 272 +- WandEnhancer/Core/EnhancerConfig.cs | 70 +- WandEnhancer/Locale/lang.de-DE.xaml | 7 + WandEnhancer/Locale/lang.en-US.xaml | 7 + WandEnhancer/Locale/lang.es-ES.xaml | 7 + WandEnhancer/Locale/lang.fr-FR.xaml | 7 + WandEnhancer/Locale/lang.it-IT.xaml | 7 + WandEnhancer/Locale/lang.ja-JP.xaml | 7 + WandEnhancer/Locale/lang.pl-PL.xaml | 7 + WandEnhancer/Locale/lang.pt-BR.xaml | 7 + WandEnhancer/Locale/lang.ru-RU.xaml | 7 + WandEnhancer/Locale/lang.tr-TR.xaml | 7 + WandEnhancer/Locale/lang.uk-UA.xaml | 7 + WandEnhancer/Locale/lang.zh-CN.xaml | 7 + WandEnhancer/Models/PatchConfig.cs | 6 +- WandEnhancer/Properties/AssemblyInfo.cs | 4 +- WandEnhancer/Style/Icons.xaml | 8 + WandEnhancer/View/MainWindow/MainWindow.xaml | 17 +- WandEnhancer/View/MainWindow/MainWindowVm.cs | 63 + .../View/Popups/PatchVectorsPopup.xaml | 84 +- .../View/Popups/PatchVectorsPopup.xaml.cs | 95 +- WandEnhancer/WandEnhancer.csproj | 11 + assets/screenshots/app2.png | Bin 117637 -> 39288 bytes web-panel/.gitignore | 24 + web-panel/.prettierignore | 7 + web-panel/.prettierrc | 11 + web-panel/README.md | 31 + web-panel/bridge/server.mjs | 212 ++ web-panel/bridge/wand-remote-bridge.cjs | 896 +++++++ web-panel/components.json | 25 + web-panel/eslint.config.js | 23 + web-panel/fixtures/demo-session.json | 140 + web-panel/index.html | 12 + web-panel/package.json | 35 + web-panel/pnpm-lock.yaml | 2251 +++++++++++++++++ web-panel/scripts/custom/README.md | 15 + .../scripts/custom/example.user.js.example | 8 + .../scripts/custom/remote-access-ping.js | 8 + .../scripts/default/remote-popup-cleanup.js | 94 + web-panel/src/app.tsx | 221 ++ web-panel/src/components/ui/badge.tsx | 29 + web-panel/src/components/ui/button.tsx | 36 + web-panel/src/components/ui/card.tsx | 70 + web-panel/src/components/ui/icon.tsx | 99 + web-panel/src/components/ui/input.tsx | 19 + web-panel/src/components/ui/label.tsx | 18 + web-panel/src/components/ui/slider.tsx | 40 + web-panel/src/components/ui/switch.tsx | 46 + .../src/features/remote-panel/category.tsx | 155 ++ .../components/CategorySection.tsx | 59 + .../remote-panel/components/CheatTile.tsx | 53 + .../components/ConnectionPanel.tsx | 70 + .../remote-panel/components/DeckHeader.tsx | 48 + .../remote-panel/components/EmptyDeck.tsx | 18 + .../components/TrainerOverview.tsx | 65 + .../src/features/remote-panel/constants.ts | 37 + .../remote-panel/controls/CheatControl.tsx | 182 ++ .../features/remote-panel/debug-session.ts | 15 + .../features/remote-panel/message-handler.ts | 50 + .../src/features/remote-panel/mock-data.ts | 5 + .../features/remote-panel/pinned-storage.ts | 59 + .../src/features/remote-panel/protocol.ts | 208 ++ .../features/remote-panel/socket-client.ts | 119 + web-panel/src/features/remote-panel/state.ts | 131 + web-panel/src/index.css | 63 + web-panel/src/lib/utils.ts | 32 + web-panel/src/main.tsx | 16 + web-panel/tsconfig.app.json | 41 + web-panel/tsconfig.json | 37 + web-panel/tsconfig.node.json | 26 + web-panel/vite.config.ts | 35 + 76 files changed, 6676 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/mirror.yml create mode 100644 AGENTS.md create mode 100644 web-panel/.gitignore create mode 100644 web-panel/.prettierignore create mode 100644 web-panel/.prettierrc create mode 100644 web-panel/README.md create mode 100644 web-panel/bridge/server.mjs create mode 100644 web-panel/bridge/wand-remote-bridge.cjs create mode 100644 web-panel/components.json create mode 100644 web-panel/eslint.config.js create mode 100644 web-panel/fixtures/demo-session.json create mode 100644 web-panel/index.html create mode 100644 web-panel/package.json create mode 100644 web-panel/pnpm-lock.yaml create mode 100644 web-panel/scripts/custom/README.md create mode 100644 web-panel/scripts/custom/example.user.js.example create mode 100644 web-panel/scripts/custom/remote-access-ping.js create mode 100644 web-panel/scripts/default/remote-popup-cleanup.js create mode 100644 web-panel/src/app.tsx create mode 100644 web-panel/src/components/ui/badge.tsx create mode 100644 web-panel/src/components/ui/button.tsx create mode 100644 web-panel/src/components/ui/card.tsx create mode 100644 web-panel/src/components/ui/icon.tsx create mode 100644 web-panel/src/components/ui/input.tsx create mode 100644 web-panel/src/components/ui/label.tsx create mode 100644 web-panel/src/components/ui/slider.tsx create mode 100644 web-panel/src/components/ui/switch.tsx create mode 100644 web-panel/src/features/remote-panel/category.tsx create mode 100644 web-panel/src/features/remote-panel/components/CategorySection.tsx create mode 100644 web-panel/src/features/remote-panel/components/CheatTile.tsx create mode 100644 web-panel/src/features/remote-panel/components/ConnectionPanel.tsx create mode 100644 web-panel/src/features/remote-panel/components/DeckHeader.tsx create mode 100644 web-panel/src/features/remote-panel/components/EmptyDeck.tsx create mode 100644 web-panel/src/features/remote-panel/components/TrainerOverview.tsx create mode 100644 web-panel/src/features/remote-panel/constants.ts create mode 100644 web-panel/src/features/remote-panel/controls/CheatControl.tsx create mode 100644 web-panel/src/features/remote-panel/debug-session.ts create mode 100644 web-panel/src/features/remote-panel/message-handler.ts create mode 100644 web-panel/src/features/remote-panel/mock-data.ts create mode 100644 web-panel/src/features/remote-panel/pinned-storage.ts create mode 100644 web-panel/src/features/remote-panel/protocol.ts create mode 100644 web-panel/src/features/remote-panel/socket-client.ts create mode 100644 web-panel/src/features/remote-panel/state.ts create mode 100644 web-panel/src/index.css create mode 100644 web-panel/src/lib/utils.ts create mode 100644 web-panel/src/main.tsx create mode 100644 web-panel/tsconfig.app.json create mode 100644 web-panel/tsconfig.json create mode 100644 web-panel/tsconfig.node.json create mode 100644 web-panel/vite.config.ts diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml new file mode 100644 index 0000000..12d5d67 --- /dev/null +++ b/.github/workflows/mirror.yml @@ -0,0 +1,26 @@ +name: Mirror to GitLab + +on: + push: + branches: [ "master", "main" ] + tags: + - '*' + workflow_dispatch: + + +jobs: + mirror: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Push to GitLab + env: + GITLAB_USERNAME: kitbyte + GITLAB_REPO: Wand-Enhancer + run: | + git remote add gitlab https://oauth2:${{ secrets.GITLAB_TOKEN }}@gitlab.com/$GITLAB_USERNAME/$GITLAB_REPO.git + git push gitlab --all --force + git push gitlab --tags --force diff --git a/.gitignore b/.gitignore index 8651c69..8e518d7 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,4 @@ packages # App settings (user preferences) appsettings.json +*DotSettings.user \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1c1de59 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +INFO ./docs/* + +# Wand Enhancer Agent Notes + +This repository patches the Wand Electron app from a .NET Framework WPF desktop tool. Keep changes narrow and preserve the patch pipeline invariants. + +## Remote Web Panel + +- The default local remote port is `3223`. Keep C# and frontend constants aligned. +- The embedded panel must stay small because the desktop patcher embeds it and then injects it into Wand's `app.asar`. +- Production builds must not include mock data, debug routes, sourcemaps, local fonts, heavy icon libraries, or runtime class helper packages. +- Mock/demo data is dev-only and must be reached through `import.meta.env.DEV` dynamic imports. +- Source can use React-compatible imports, but production runtime resolves them to Preact aliases in `web-panel/vite.config.ts`. +- UI uses Tailwind CSS and lightweight shadcn-style local primitives under `web-panel/src/components/ui/`. +- Default renderer scripts live in `web-panel/scripts/default/` and are embedded. Custom user scripts are selected in the WPF patch modal and copied from `PatchConfig.CustomScriptPaths`; only existing `.js` files are accepted. A local `renderer-scripts/` folder next to the patcher exe is still copied as an advanced fallback. + +## ASAR Patch Pipeline + +- Preserve and restore both `resources/app.asar` and `resources/app.asar.unpacked` backups. +- Inject `web-panel/dist` as `remote-panel/`, `web-panel/bridge/wand-remote-bridge.cjs` as `remote-panel/bridge.cjs`, and default/selected/local renderer scripts under `remote-panel/renderer-scripts`. +- Do not commit extracted `.sources/` output. Recreate it only for reverse-engineering sessions. +- `AsarSharp.AsarExtractor.ExtractAll` must skip unpacked entries when their source path equals the destination (in-place extraction is a self-copy that fails on locked files like `TrainerLib_x64.dll`) and silently skip unpacked entries whose source is missing on disk (e.g. `auxiliary/GameLauncher.exe` removed by an installer). Do not reintroduce hard failure on either case. +- The `DevToolsOnF12` patch anchors on the Electron main-process `.whenReady().then(` site and attaches a `before-input-event` hook to every `BrowserWindow.webContents`. Do not patch the renderer keydown listener — the minified `ACTION_OPEN_DEV_TOOLS` dispatch site is not stable across Wand releases. +- Cheats can be pinned per game in the web panel via `pinned-storage.ts` (`localStorage` key `wand-remote.pinned-cheats.v1:`). Pinned cheats render as a virtual `pinned` category at the top of the list; their normal category placement is preserved. + +## Validation + +- Web panel build: `cd web-panel && pnpm run build`. +- Bridge syntax checks: `node --check web-panel/bridge/wand-remote-bridge.cjs` and `node --check web-panel/scripts/default/remote-popup-cleanup.js`. +- Production dist should contain only static assets and should not contain `mock-instance`, `Mock Adventure`, `Simulation`, `Debug session`, `mock=1`, `demo-session`, `vite.svg`, `tailwind-merge`, `class-variance-authority`, or `clsx`. \ No newline at end of file diff --git a/AsarSharp/AsarExtractor.cs b/AsarSharp/AsarExtractor.cs index 0dc1833..422b8a5 100644 --- a/AsarSharp/AsarExtractor.cs +++ b/AsarSharp/AsarExtractor.cs @@ -96,12 +96,41 @@ namespace AsarSharp // it's a file, try to extract it try { - byte[] content; - - content = Disk.ReadFileSync(filesystem, filename, file); - - File.WriteAllBytes(destFilename, content); - + // Unpacked entries already live on disk next to the archive in + // ".unpacked". When the caller extracts INTO that same + // directory (e.g. re-extracting in place to repack later) reading + + // writing the file is a self-copy that needlessly fails when the + // file is locked by another process (TrainerLib_x64.dll) or has been + // removed from disk by an installer (auxiliary/GameLauncher.exe). + if (file.Unpacked == true) + { + string unpackedSourcePath = Path.GetFullPath( + Path.Combine($"{filesystem.GetRootPath()}.unpacked", filename)); + string unpackedDestPath = Path.GetFullPath(destFilename); + + if (string.Equals(unpackedSourcePath, unpackedDestPath, StringComparison.OrdinalIgnoreCase)) + { + // Nothing to do – the file is already at the destination. + continue; + } + + if (!File.Exists(unpackedSourcePath)) + { + // The header references an unpacked file that no longer + // exists on disk; skip it instead of aborting the whole + // extraction so the rest of the asar can still be repacked. + continue; + } + + Directory.CreateDirectory(Extensions.GetDirectoryName(destFilename)); + File.Copy(unpackedSourcePath, destFilename, true); + } + else + { + byte[] content = Disk.ReadFileSync(filesystem, filename, file); + File.WriteAllBytes(destFilename, content); + } + if (file.Executable == true && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { Extensions.SetUnixFilePermission(destFilename, "755"); diff --git a/README.md b/README.md index d6ca67e..816a45c 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,20 @@ Yes. This project is entirely open-source, allowing anyone to audit the code. It ✅ Local environment configuration management
✅ Automated compatibility adjustments for new client versions
✅ Advanced layout and theme customization (Client-side only)
-✅ AI Features -❌ Remote/Mobile connectivity features
+✅ AI Features
+✅ Remote web panel (Remote Connect on mobile)
+ +## 🌐 Remote Web Panel +WandEnhancer includes a built-in **Remote Web Panel** allowing you to control app features directly from your phone. + +### Quick Start: +1. Ensure both your PC and phone are on the **same Wi-Fi network**. +2. Hover over the **Connect** button in the top bar of WandEnhancer. +3. Scan the displayed **QR code** with your phone's camera. + +### Troubleshooting & Remote Access: +- **Page isn't loading?** First, ensure both your PC and phone are connected to the **exact same Wi-Fi network**. Next, make sure **Network Discovery** is turned on in your Windows network settings. If it still doesn't work, Windows Firewall might be blocking the connection—you may need to manually allow inbound traffic on TCP port `3223`. +- **Using mobile data or a different network?** If you want to use the panel over mobile data (LTE/5G) or from an entirely different network, you can use [Tailscale](https://tailscale.com/) or similar VPN tools. ## 👀 How to use? @@ -41,7 +53,11 @@ Yes. This project is entirely open-source, allowing anyone to audit the code. It --- ## 🖼️ Screenshots ![1](./assets/screenshots/app1.png) +
+ ![2](./assets/screenshots/app2.png) +
+ --- ## 📜 License diff --git a/WandEnhancer/Core/Enhancer.cs b/WandEnhancer/Core/Enhancer.cs index 5a72a30..9a330df 100644 --- a/WandEnhancer/Core/Enhancer.cs +++ b/WandEnhancer/Core/Enhancer.cs @@ -4,27 +4,45 @@ using System.IO; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; -using System.Windows.Forms; using AsarSharp; -using Newtonsoft.Json; using WandEnhancer.Models; using WandEnhancer.Utils; using WandEnhancer.View.MainWindow; -using Application = System.Windows.Application; namespace WandEnhancer.Core { public class Enhancer { + private const string ResourcesDirectoryName = "resources"; + private const string AppAsarFileName = "app.asar"; + 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 WebPanelDirectoryName = "web-panel"; + private const string WebPanelDistDirectoryName = "dist"; + private const string WebPanelBridgeDirectoryName = "bridge"; + private const string WebPanelScriptsDirectoryName = "scripts"; + private const string DefaultScriptsDirectoryName = "default"; + private const string LocalCustomScriptsDirectoryName = "renderer-scripts"; + private const string RemotePanelDirectoryName = "remote-panel"; + private const string RemoteBridgeSourceFileName = "wand-remote-bridge.cjs"; + private const string RemoteBridgeTargetFileName = "bridge.cjs"; + private const string RemoteRendererScriptsDirectoryName = "renderer-scripts"; + private const string EmbeddedRemotePanelDistPrefix = "remote-panel/dist/"; + private const string EmbeddedRemotePanelBridgeResourceName = "remote-panel/bridge.cjs"; + private const string EmbeddedRemotePanelDefaultScriptsPrefix = "remote-panel/renderer-scripts/"; + private const string JavaScriptFileExtension = ".js"; + private const string JavaScriptFileSearchPattern = "*.js"; + private const string DuplicateScriptSuffix = ".custom"; + private const int FirstDuplicateScriptIndex = 1; - - private readonly WeModConfig _weModConfig; private readonly Action _logger; private readonly PatchConfig _config; private readonly string _asarPath; private readonly string _backupPath; private readonly string _unpackedPath; + private readonly string _unpackedBackupPath; public Enhancer(WeModConfig weModConfig, Action logger, PatchConfig config) { @@ -32,9 +50,10 @@ namespace WandEnhancer.Core _logger = logger; _config = config; - _asarPath = Path.Combine(weModConfig.RootDirectory, "resources", "app.asar"); - _unpackedPath = Path.Combine(weModConfig.RootDirectory, "resources", "app.asar.unpacked"); - _backupPath = Path.Combine(weModConfig.RootDirectory, "resources", "app.asar.backup"); + _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) @@ -90,7 +109,6 @@ namespace WandEnhancer.Core throw new Exception("[ENHANCER] No app bundle found"); } - // Track patches that still need to be completed var remainingPatches = new HashSet(_config.PatchTypes); var enhancerConfig = EnhancerConfig.GetInstance(); @@ -103,17 +121,14 @@ namespace WandEnhancer.Core string data = File.ReadAllText(item); - // Iterate over a copy of the list so we can modify the HashSet foreach (var entry in remainingPatches.ToList()) { var entries = enhancerConfig[entry]; foreach (var patchEntry in entries) { - // Update data in memory so subsequent patches in the same file work on latest content data = ApplyJsPatch(item, data, patchEntry, entry); } - // Check if all entries for this patch type are applied if (entries.All(x => x.Applied)) { remainingPatches.Remove(entry); @@ -128,6 +143,215 @@ namespace WandEnhancer.Core } } + private static string FindWorkspacePath(params string[] segments) + { + string current = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + while (!string.IsNullOrEmpty(current)) + { + string candidate = Path.Combine(new[] { current }.Concat(segments).ToArray()); + if (Directory.Exists(candidate) || File.Exists(candidate)) + { + return candidate; + } + + current = Directory.GetParent(current)?.FullName; + } + + throw new FileNotFoundException($"Required workspace artifact not found: {Path.Combine(segments)}"); + } + + private 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)) + { + 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; + } + + private static string GetAvailableScriptPath(string destinationDir, string fileName) + { + string destinationPath = Path.Combine(destinationDir, fileName); + if (!File.Exists(destinationPath)) + { + return destinationPath; + } + + string name = Path.GetFileNameWithoutExtension(fileName); + string extension = Path.GetExtension(fileName); + for (int index = FirstDuplicateScriptIndex; ; index++) + { + destinationPath = Path.Combine(destinationDir, $"{name}{DuplicateScriptSuffix}{index}{extension}"); + if (!File.Exists(destinationPath)) + { + return destinationPath; + } + } + } + + private static int CopyEmbeddedDirectory(string resourcePrefix, string destinationDir) + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceNames = assembly.GetManifestResourceNames() + .Where(name => name.StartsWith(resourcePrefix, StringComparison.Ordinal)) + .ToList(); + + if (resourceNames.Count == 0) + { + return 0; + } + + Directory.CreateDirectory(destinationDir); + + foreach (var resourceName in resourceNames) + { + var relativePath = resourceName.Substring(resourcePrefix.Length) + .Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar); + var destinationPath = Path.Combine(destinationDir, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath) ?? destinationDir); + + using (var resource = assembly.GetManifestResourceStream(resourceName)) + { + if (resource == null) + { + throw new FileNotFoundException($"Embedded resource not found: {resourceName}"); + } + + using (var output = File.Create(destinationPath)) + { + resource.CopyTo(output); + } + } + } + + return resourceNames.Count; + } + + private static bool CopyEmbeddedFile(string resourceName, string destinationPath) + { + var assembly = Assembly.GetExecutingAssembly(); + using (var resource = assembly.GetManifestResourceStream(resourceName)) + { + if (resource == null) + { + return false; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath) ?? "."); + using (var output = File.Create(destinationPath)) + { + resource.CopyTo(output); + } + } + + return true; + } + + private static string FindLocalCustomScriptsPath() + { + string executableDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + if (string.IsNullOrEmpty(executableDirectory)) + { + return null; + } + + string localScripts = Path.Combine(executableDirectory, LocalCustomScriptsDirectoryName); + return Directory.Exists(localScripts) ? localScripts : null; + } + + private static int CopySelectedJavaScriptFiles(IEnumerable files, string destinationDir) + { + if (files == null) + { + return 0; + } + + Directory.CreateDirectory(destinationDir); + + int copied = 0; + foreach (var file in files.Where(IsJavaScriptFile).Distinct(StringComparer.OrdinalIgnoreCase)) + { + File.Copy(file, GetAvailableScriptPath(destinationDir, Path.GetFileName(file))); + copied++; + } + + 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)) + { + return; + } + + string localCustomScriptsRoot = FindLocalCustomScriptsPath(); + string targetRoot = Path.Combine(_unpackedPath, RemotePanelDirectoryName); + string targetScriptsRoot = Path.Combine(targetRoot, RemoteRendererScriptsDirectoryName); + string targetBridgePath = Path.Combine(targetRoot, RemoteBridgeTargetFileName); + + if (Directory.Exists(targetRoot)) + { + Directory.Delete(targetRoot, true); + } + + if (CopyEmbeddedDirectory(EmbeddedRemotePanelDistPrefix, targetRoot) == 0) + { + CopyDirectory(FindWorkspacePath(WebPanelDirectoryName, WebPanelDistDirectoryName), targetRoot); + } + + if (!CopyEmbeddedFile(EmbeddedRemotePanelBridgeResourceName, targetBridgePath)) + { + File.Copy(FindWorkspacePath(WebPanelDirectoryName, WebPanelBridgeDirectoryName, RemoteBridgeSourceFileName), targetBridgePath, true); + } + + int defaultScriptCount = CopyEmbeddedDirectory(EmbeddedRemotePanelDefaultScriptsPrefix, targetScriptsRoot); + if (defaultScriptCount == 0) + { + defaultScriptCount = CopyJavaScriptFiles(FindWorkspacePath(WebPanelDirectoryName, WebPanelScriptsDirectoryName, DefaultScriptsDirectoryName), targetScriptsRoot); + } + + int selectedScriptCount = CopySelectedJavaScriptFiles(_config.CustomScriptPaths, targetScriptsRoot); + int localScriptCount = CopyJavaScriptFiles(localCustomScriptsRoot, targetScriptsRoot); + + _logger($"[ENHANCER] Injected remote panel assets and renderer scripts into app.asar (default: {defaultScriptCount}, selected: {selectedScriptCount}, local: {localScriptCount})", ELogType.Info); + } + private void AttachProxyDll() { var assembly = Assembly.GetExecutingAssembly(); @@ -154,7 +378,28 @@ namespace WandEnhancer.Core } else { - _logger("[ENHANCER] Backup already exists", ELogType.Warn); + _logger("[ENHANCER] Backup found, restoring pristine app.asar before patching...", ELogType.Info); + File.Copy(_backupPath, _asarPath, true); + } + + if (!Directory.Exists(_unpackedBackupPath) && Directory.Exists(_unpackedPath)) + { + _logger("[ENHANCER] Creating backup of app.asar.unpacked...", ELogType.Info); + CopyDirectory(_unpackedPath, _unpackedBackupPath); + } + else if (Directory.Exists(_unpackedBackupPath)) + { + _logger("[ENHANCER] Restoring pristine app.asar.unpacked before patching...", ELogType.Info); + if (Directory.Exists(_unpackedPath)) + { + Directory.Delete(_unpackedPath, true); + } + + 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)) @@ -173,6 +418,7 @@ namespace WandEnhancer.Core } PatchAsar(); + InjectRemotePanelFiles(); try { diff --git a/WandEnhancer/Core/EnhancerConfig.cs b/WandEnhancer/Core/EnhancerConfig.cs index 47a5511..92f6ddb 100644 --- a/WandEnhancer/Core/EnhancerConfig.cs +++ b/WandEnhancer/Core/EnhancerConfig.cs @@ -7,6 +7,9 @@ namespace WandEnhancer.Core { public static class EnhancerConfig { + private const int RemoteWebPanelDefaultPort = 3223; + private static readonly string RemoteWebPanelFallbackUrl = $"http://localhost:{RemoteWebPanelDefaultPort}/remote/"; + public class ResolveContext { public string Placeholder { get; set; } @@ -86,16 +89,73 @@ namespace WandEnhancer.Core { new PatchEntry { + Name = "devToolsBeforeInputEvent", + // 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(" + } + } + }, + { + EPatchType.RemoteWebPanelPreview, + new[] + { + new PatchEntry + { + Name = "remoteBridgeMainBoot", + 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()})" + }, + new PatchEntry + { + Name = "remoteBridgeReset", + Target = new Regex(@"#Je\(\)\{this\.#Oe&&\(this\.#Oe\.dispose\(\),this\.#Oe=null\),this\.#Pe=Date\.now\(\)\.toString\(\),this\.#ke=null,this\.#_e=\[],this\.#Ee=null\}"), + Patch = "#Je(){this.#Oe&&(this.#Oe.dispose(),this.#Oe=null),this.#Pe=Date.now().toString(),this.#ke=null,this.#_e=[],this.#Ee=null,this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null)}" + }, + new PatchEntry + { + Name = "remoteBridgeSyncSnapshot", + Target = new Regex(@"#Be\(\)\{if\(this\.status===i\.Connected\)\{let e,t=!1,s=this\.#Ee\?\.getMetadata\(h\.vO\)\?\.gameVersion\?\?null,i=!1;const n=this\.#Ve\[this\.#ke\?\?""""\]\|\|null;this\.#Re&&\(e=this\.#Ae\.getPreferredInstallationInfo\(this\.#Re\),e\.app&&\(t=!0,s\?\?=e\.version\?\?null,i=""number""==typeof e\.version&&!this\.#_e\.includes\(e\.version\)\)\),this\.#Me\?\.send\(""client-state"",\{instanceId:this\.#Pe,trainerId:this\.#ke,trainerLoading:this\.#Ee\?\.isLoading\(\),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:i,values:this\.#Ke\(\),themeId:this\.#We,settings:R\(this\.settings\),language:this\.#Ne,accountUuid:this\.account\.uuid,notesReadHash:n,isTimeLimitExpired:""expired""===this\.#Fe\.timerState\}\)\}\}"), + Patch = "#Be(){let e,t=!1,s=this.#Ee?.getMetadata(h.vO)?.gameVersion??null,o=!1;const n=this.#Ve[this.#ke??\"\"]||null;this.#Re&&(e=this.#Ae.getPreferredInstallationInfo(this.#Re),e.app&&(t=!0,s??=e.version??null,o=\"number\"==typeof e.version&&!this.#_e.includes(e.version)));this.status===i.Connected&&this.#Me?.send(\"client-state\",{instanceId:this.#Pe,trainerId:this.#ke,trainerLoading:this.#Ee?.isLoading(),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,values:this.#Ke(),themeId:this.#We,settings:R(this.settings),language:this.#Ne,accountUuid:this.account.uuid,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.#Fe.timerState});this.__wandRemoteBridge?.sync({instanceId:this.#Pe,trainerId:this.#ke,trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.#Ee?.getMetadata(h.vO)??null,trainerLoading:this.#Ee?.isLoading()??false,gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,language:this.#Ne,themeId:this.#We,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.#Fe.timerState,values:this.#Ke()})}" + }, + new PatchEntry + { + Name = "remoteBridgeBindHandler", + Target = new Regex(@"setCurrentTrainer\(e,t=null\)\{const s=e\?\.trainerId\|\|null,i=\(s\?e\?\.gameId:null\)\|\|null,n=\(s\?e\?\.supportedVersions:null\)\|\|\[];if\(s===this\.#ke&&t===this\.#Ee\)return;"), + Patch = "setCurrentTrainer(e,t=null){this.__wandRemoteBridge||(this.__wandRemoteBridge=(()=>{try{const r=globalThis.require||require;const{ipcRenderer:c}=r(\"electron\");try{c.invoke(\"wand-remote-url\").then((u=>{u&&(globalThis.__wandRemoteBridgeUrl=u)}))}catch(e){}const send=(ch,p)=>{try{return c.invoke(ch,p&&JSON.parse(JSON.stringify(p)))}catch(e){}};return{sync:(s)=>send(\"wand-remote-sync\",s),valueChanged:(s)=>send(\"wand-remote-value-changed\",s),setHandler:(h)=>{if(this.__wandRemoteBridgeBound)return;this.__wandRemoteBridgeBound=true;try{c.invoke(\"wand-remote-set-handler-bind\")}catch(e){}c.on(\"wand-remote-set-value\",(_e,req)=>{try{h(req)}catch(e){}})}}}catch(e){try{const r=globalThis.require||require,fs=r(\"node:fs\"),os=r(\"node:os\"),p=r(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [renderer-bind-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}return null}})());this.__wandRemoteBridge?.setHandler((e=>{if(!this.#Ee||!e?.target)return!1;return this.#Ee.isActive()?this.#Ee.setValue(e.target,e.value,g.kL.Remote,e.cheatId):!1}));this.__wandRemoteTrainerInfo=e??null;const s=e?.trainerId||null,i=(s?e?.gameId:null)||null,n=(s?e?.supportedVersions:null)||[];if(s===this.#ke&&t===this.#Ee)return;" + }, + new PatchEntry + { + Name = "remoteBridgeValueDelta", + Target = new Regex(@"#ct\(e,t\)\{t\.push\(e\.onValueSet\(e=>\{this\.status===i\.Connected&&e\.source!==g\.kL\.Remote&&this\.#Me\?\.send\(""client-value-changed"",\{instanceId:this\.#Pe,name:e\.name,value:e\.value,cheatId:e\.cheatId\}\)\}\)\),this\.#Be\(\)\}"), + Patch = "#ct(e,t){t.push(e.onValueSet(e=>{this.status===i.Connected&&e.source!==g.kL.Remote&&this.#Me?.send(\"client-value-changed\",{instanceId:this.#Pe,name:e.name,value:e.value,cheatId:e.cheatId}),this.__wandRemoteBridge?.valueChanged({trainerId:this.#ke,target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??\"desktop\"),cheatId:e.cheatId})})),this.#Be()}" + }, + new PatchEntry + { + Name = "remoteTooltipPreviewUrl", + Target = new Regex(@"remoteUrl=""wemodwebsite://remote"""), + Patch = "remoteUrl=globalThis.__wandRemoteBridgeUrl||\"" + RemoteWebPanelFallbackUrl + "\"" + }, + new PatchEntry + { + Name = "remoteQrPreviewUrl", Resolver = new ResolveContext { - Handler = (matchContent) => { - var match = Regex.Match(matchContent, @"this\.#(\w+)\(""ACTION_OPEN_DEV_TOOLS""\)"); + Handler = (matchContent) => + { + var match = Regex.Match(matchContent, @"this\.canvasElement&&(\w+)\.mo"); return match.Success ? match.Groups[1].Value : null; }, - Placeholder = "" + Placeholder = "" }, - Target = new Regex(@"document\.addEventListener\(""keydown"",\s*\((?\w+)\s*=>\s*\{[^}]*?""ACTION_OPEN_DEV_TOOLS""[^}]*?\}\)\)", RegexOptions.Singleline), - Patch = "document.addEventListener(\"keydown\",(${arg}=>{\"F12\"!==${arg}.key||this.#(\"ACTION_OPEN_DEV_TOOLS\")}))" + Target = new Regex(@"this\.canvasElement&&\w+\.mo\(this\.canvasElement,`\$\{\w+\.A\.wemodWebsiteUrl\}/remote`,this\.options\)"), + Patch = "this.canvasElement&&.mo(this.canvasElement,globalThis.__wandRemoteBridgeUrl||\"" + RemoteWebPanelFallbackUrl + "\",this.options)" } } } diff --git a/WandEnhancer/Locale/lang.de-DE.xaml b/WandEnhancer/Locale/lang.de-DE.xaml index ce2ef0e..6094781 100644 --- a/WandEnhancer/Locale/lang.de-DE.xaml +++ b/WandEnhancer/Locale/lang.de-DE.xaml @@ -16,6 +16,8 @@ Quellcode Mit ❤️ von k1tbyte erstellt Gib einen Stern, wenn dir das geholfen hat ;) + Logs in die Zwischenablage kopieren + Logs in Datei exportieren @@ -28,6 +30,11 @@ WeMod Pro aktivieren DevTools mit F12 Updates deaktivieren + Remote-Zugriff aktivieren (Beta) + Benutzerdefinierte Skripte + .js hinzufügen + Ausgewählte .js-Dateien werden in Wand gepackt und im Renderer geladen. + Keine Skripte ausgewählt Starten Was werden wir verbessern? diff --git a/WandEnhancer/Locale/lang.en-US.xaml b/WandEnhancer/Locale/lang.en-US.xaml index 40e690d..288fb55 100644 --- a/WandEnhancer/Locale/lang.en-US.xaml +++ b/WandEnhancer/Locale/lang.en-US.xaml @@ -16,6 +16,8 @@ Source code Made with ❤️ by k1tbyte Put a star if you found this helpful ;) + Copy logs to clipboard + Export logs to file @@ -28,6 +30,11 @@ Activate WeMod Pro DevTools on F12 Disable updates + Enable remote access (beta) + Custom scripts + Add .js + Selected .js files are packed into Wand and loaded in the renderer. + No scripts selected Start What are we gonna enhance? diff --git a/WandEnhancer/Locale/lang.es-ES.xaml b/WandEnhancer/Locale/lang.es-ES.xaml index 8b39a8b..2c54806 100644 --- a/WandEnhancer/Locale/lang.es-ES.xaml +++ b/WandEnhancer/Locale/lang.es-ES.xaml @@ -16,6 +16,8 @@ Código fuente Hecho con ❤️ por k1tbyte Pon una estrella si te fue útil ;) + Copiar registros al portapapeles + Exportar registros a un archivo @@ -28,6 +30,11 @@ Activar WeMod Pro DevTools en F12 Desactivar actualizaciones + Habilitar acceso remoto (beta) + Scripts personalizados + Agregar .js + Los archivos .js seleccionados se empaquetan en Wand y se cargan en el renderer. + No hay scripts seleccionados Iniciar ¿Qué vamos a mejorar? diff --git a/WandEnhancer/Locale/lang.fr-FR.xaml b/WandEnhancer/Locale/lang.fr-FR.xaml index 8f1877b..2df2dd8 100644 --- a/WandEnhancer/Locale/lang.fr-FR.xaml +++ b/WandEnhancer/Locale/lang.fr-FR.xaml @@ -16,6 +16,8 @@ Code source Fait avec ❤️ par k1tbyte Mettez une étoile si cela vous a aidé ;) + Copier les logs dans le presse-papiers + Exporter les logs dans un fichier @@ -28,6 +30,11 @@ Activer WeMod Pro DevTools sur F12 Désactiver les mises à jour + Activer l'accès à distance (bêta) + Scripts personnalisés + Ajouter .js + Les fichiers .js sélectionnés sont intégrés dans Wand et chargés dans le renderer. + Aucun script sélectionné Démarrer Qu'allons-nous modifier ? diff --git a/WandEnhancer/Locale/lang.it-IT.xaml b/WandEnhancer/Locale/lang.it-IT.xaml index 7b553fe..6abda7d 100644 --- a/WandEnhancer/Locale/lang.it-IT.xaml +++ b/WandEnhancer/Locale/lang.it-IT.xaml @@ -16,6 +16,8 @@ Codice sorgente Creato con ❤️ da k1tbyte Metti una stella se ti è stato utile ;) + Copia i log negli appunti + Esporta i log su file @@ -28,6 +30,11 @@ Attiva WeMod Pro DevTools su F12 Disattiva aggiornamenti + Abilita accesso remoto (beta) + Script personalizzati + Aggiungi .js + I file .js selezionati vengono inseriti in Wand e caricati nel renderer. + Nessuno script selezionato Avvia Cosa modificheremo? diff --git a/WandEnhancer/Locale/lang.ja-JP.xaml b/WandEnhancer/Locale/lang.ja-JP.xaml index 9d4390a..4b07eca 100644 --- a/WandEnhancer/Locale/lang.ja-JP.xaml +++ b/WandEnhancer/Locale/lang.ja-JP.xaml @@ -16,6 +16,8 @@ ソースコード k1tbyte が ❤️ を込めて作成 役に立ったらスターをつけてください ;) + ログをクリップボードにコピー + ログをファイルにエクスポート @@ -28,6 +30,11 @@ WeMod Pro を有効化 F12でDevTools アップデートを無効化 + リモートアクセスを有効化(ベータ) + カスタムスクリプト + .js を追加 + 選択した .js ファイルは Wand に組み込まれ、レンダラーで読み込まれます。 + スクリプトが選択されていません 開始 何を改善しますか? diff --git a/WandEnhancer/Locale/lang.pl-PL.xaml b/WandEnhancer/Locale/lang.pl-PL.xaml index 4835eb2..1fd6847 100644 --- a/WandEnhancer/Locale/lang.pl-PL.xaml +++ b/WandEnhancer/Locale/lang.pl-PL.xaml @@ -16,6 +16,8 @@ Kod źródłowy Wykonane z ❤️ przez k1tbyte Daj gwiazdkę, jeśli ci pomogło ;) + Skopiuj logi do schowka + Eksportuj logi do pliku @@ -28,6 +30,11 @@ Aktywuj WeMod Pro DevTools na F12 Wyłącz aktualizacje + Włącz zdalny dostęp (beta) + Skrypty niestandardowe + Dodaj .js + Wybrane pliki .js są pakowane do Wand i ładowane w rendererze. + Nie wybrano skryptów Rozpocznij Co będziemy ulepszać? diff --git a/WandEnhancer/Locale/lang.pt-BR.xaml b/WandEnhancer/Locale/lang.pt-BR.xaml index 8e8fc05..ddff038 100644 --- a/WandEnhancer/Locale/lang.pt-BR.xaml +++ b/WandEnhancer/Locale/lang.pt-BR.xaml @@ -16,6 +16,8 @@ Código fonte Feito com ❤️ por k1tbyte Dê uma estrela se isso te ajudou ;) + Copiar logs para a área de transferência + Exportar logs para arquivo @@ -28,6 +30,11 @@ Ativar WeMod Pro DevTools no F12 Desativar atualizações + Ativar acesso remoto (beta) + Scripts personalizados + Adicionar .js + Os arquivos .js selecionados são empacotados no Wand e carregados no renderer. + Nenhum script selecionado Iniciar O que vamos melhorar? diff --git a/WandEnhancer/Locale/lang.ru-RU.xaml b/WandEnhancer/Locale/lang.ru-RU.xaml index ad3aa35..9668494 100644 --- a/WandEnhancer/Locale/lang.ru-RU.xaml +++ b/WandEnhancer/Locale/lang.ru-RU.xaml @@ -16,6 +16,8 @@ Исходный код Сделано с ❤️ by k1tbyte Поставьте звезду, если это было полезно ;) + Скопировать логи в буфер обмена + Экспортировать логи в файл @@ -28,6 +30,11 @@ Активировать WeMod Pro DevTools на F12 Отключить обновления + Remote-доступ (beta) + Свои скрипты + Добавить .js + Выбранные .js попадут в Wand и загрузятся в renderer. + Скрипты не выбраны Начать Что будем улучшать? diff --git a/WandEnhancer/Locale/lang.tr-TR.xaml b/WandEnhancer/Locale/lang.tr-TR.xaml index 2f73074..3bc5b5b 100644 --- a/WandEnhancer/Locale/lang.tr-TR.xaml +++ b/WandEnhancer/Locale/lang.tr-TR.xaml @@ -16,6 +16,8 @@ Kaynak kodu k1tbyte tarafından ❤️ ile yapıldı Yardımcı olduysa yıldız verin ;) + Günlükleri panoya kopyala + Günlükleri dosyaya aktar @@ -28,6 +30,11 @@ WeMod Pro'yu Etkinleştir F12 ile DevTools Güncellemeleri devre dışı bırak + Uzaktan erişimi etkinleştir (beta) + Özel betikler + .js ekle + Seçilen .js dosyaları Wand içine paketlenir ve renderer'da yüklenir. + Betik seçilmedi Başlat Neyi geliştireceğiz? diff --git a/WandEnhancer/Locale/lang.uk-UA.xaml b/WandEnhancer/Locale/lang.uk-UA.xaml index 5498ef1..1096452 100644 --- a/WandEnhancer/Locale/lang.uk-UA.xaml +++ b/WandEnhancer/Locale/lang.uk-UA.xaml @@ -16,6 +16,8 @@ Вихідний код Зроблено з ❤️ by k1tbyte Поставте зірку, якщо це було корисно ;) + Скопіювати логи до буфера обміну + Експортувати логи у файл @@ -28,6 +30,11 @@ Активувати WeMod Pro DevTools на F12 Вимкнути оновлення + Увімкнути віддалений доступ (бета) + Користувацькі скрипти + Додати .js + Вибрані файли .js пакуються у Wand і завантажуються в рендерері. + Скрипти не вибрано Почати Що будемо покращувати? diff --git a/WandEnhancer/Locale/lang.zh-CN.xaml b/WandEnhancer/Locale/lang.zh-CN.xaml index 2e25779..b87cb71 100644 --- a/WandEnhancer/Locale/lang.zh-CN.xaml +++ b/WandEnhancer/Locale/lang.zh-CN.xaml @@ -16,6 +16,8 @@ 源代码 由 k1tbyte 用 ❤️ 制作 如果这对您有帮助,请给个星标 ;) + 复制日志到剪贴板 + 将日志导出到文件 @@ -28,6 +30,11 @@ 激活 WeMod Pro 按 F12 打开开发者工具 禁用更新 + 启用远程访问(beta) + 自定义脚本 + 添加 .js + 选中的 .js 文件会打包到 Wand 并在渲染器中加载。 + 未选择脚本 开始 我们要增强什么? diff --git a/WandEnhancer/Models/PatchConfig.cs b/WandEnhancer/Models/PatchConfig.cs index ca967d2..c050324 100644 --- a/WandEnhancer/Models/PatchConfig.cs +++ b/WandEnhancer/Models/PatchConfig.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using Newtonsoft.Json; using WandEnhancer.Utils; @@ -12,13 +11,16 @@ namespace WandEnhancer.Models ActivatePro = 1, DisableUpdates = 2, DisableTelemetry = 4, - DevToolsOnF12 = 8 + 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; } diff --git a/WandEnhancer/Properties/AssemblyInfo.cs b/WandEnhancer/Properties/AssemblyInfo.cs index 03fc546..829ffc4 100644 --- a/WandEnhancer/Properties/AssemblyInfo.cs +++ b/WandEnhancer/Properties/AssemblyInfo.cs @@ -51,5 +51,5 @@ using System.Windows; // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.6.0")] -[assembly: AssemblyFileVersion("1.0.6.0")] \ No newline at end of file +[assembly: AssemblyVersion("1.0.7.0")] +[assembly: AssemblyFileVersion("1.0.7.0")] \ No newline at end of file diff --git a/WandEnhancer/Style/Icons.xaml b/WandEnhancer/Style/Icons.xaml index 79243b2..0234db9 100644 --- a/WandEnhancer/Style/Icons.xaml +++ b/WandEnhancer/Style/Icons.xaml @@ -27,6 +27,14 @@ M5.05 11.94l5-5v3.99H19l-.03 2.01H10.05v4Z + + + M19 21H8V7h11m0-2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2m-3-4H4a2 2 0 0 0-2 2v14h2V3h12V1Z + + + + M14 13h-3v3H9v-3H6v-2h3V8h2v3h3m-1-9H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9l-7-7M5 4h7v5h5v11H5V4Z + + + - + ) : null} + +
+ {filteredPinnedGroup ? ( + + ) : null} + {filteredGroups.map((group) => ( + + ))} + {searchQuery && filteredGroups.length === 0 && !filteredPinnedGroup ? ( +

+ No cheats match "{searchQuery}". +

+ ) : null} +
+ + ) : ( + + )} + + + + + ); +} diff --git a/web-panel/src/components/ui/badge.tsx b/web-panel/src/components/ui/badge.tsx new file mode 100644 index 0000000..5b12e3a --- /dev/null +++ b/web-panel/src/components/ui/badge.tsx @@ -0,0 +1,29 @@ +import type { ComponentProps } from "react" + +import { cn } from "@/lib/utils" + +type BadgeVariant = "default" | "outline" + +const BADGE_BASE = "inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap" + +const BADGE_VARIANTS: Record = { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + outline: "border-border bg-input/20 text-foreground", +} + +function Badge({ + className, + variant = "default", + ...props +}: ComponentProps<"span"> & { variant?: BadgeVariant }) { + return ( + + ) +} + +export { Badge } diff --git a/web-panel/src/components/ui/button.tsx b/web-panel/src/components/ui/button.tsx new file mode 100644 index 0000000..8db20ee --- /dev/null +++ b/web-panel/src/components/ui/button.tsx @@ -0,0 +1,36 @@ +import type { ComponentProps } from "react" + +import { cn } from "@/lib/utils" + +type ButtonVariant = "default" | "outline" +type ButtonSize = "default" | "icon" + +const BUTTON_BASE = "inline-flex shrink-0 items-center justify-center rounded-md border border-transparent text-xs font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50" + +const BUTTON_VARIANTS: Record = { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: "border-border hover:bg-input/50 hover:text-foreground", +} + +const BUTTON_SIZES: Record = { + default: "h-7 gap-1 px-2", + icon: "size-7", +} + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ComponentProps<"button"> & { variant?: ButtonVariant; size?: ButtonSize }) { + return ( + + ) +} + +export { Switch } diff --git a/web-panel/src/features/remote-panel/category.tsx b/web-panel/src/features/remote-panel/category.tsx new file mode 100644 index 0000000..4efcb01 --- /dev/null +++ b/web-panel/src/features/remote-panel/category.tsx @@ -0,0 +1,155 @@ +import { Icon, type IconName } from '@/components/ui/icon'; +import type { CheatSchema, TrainerMetaPayload, TrainerSummary } from './protocol'; + +const CATEGORY_LABELS: Record = { + challenge: 'Challenge', + character: 'Character', + cheats: 'Cheats', + crafting: 'Crafting', + enemies: 'Enemies', + game: 'Game', + inventory: 'Inventory', + items: 'Items', + physics: 'Physics', + pinned: 'Pinned', + player: 'Player', + resources: 'Resources', + stats: 'Stats', + teleport: 'Teleport', + vehicles: 'Vehicles', + weapons: 'Weapons', + world: 'World', +}; + +const CATEGORY_ICONS: Record = { + challenge: 'flame', + character: 'user', + cheats: 'sparkles', + crafting: 'hammer', + enemies: 'heart-broken', + game: 'gamepad', + inventory: 'backpack', + items: 'package', + physics: 'atom', + pinned: 'bolt', + player: 'user', + resources: 'box', + stats: 'chart', + teleport: 'map-pin', + vehicles: 'car', + weapons: 'swords', + world: 'world', +}; + +const CATEGORY_ACCENTS: Record = { + challenge: 'text-orange-300 bg-orange-500/12 ring-orange-300/30', + enemies: 'text-red-300 bg-red-500/12 ring-red-300/30', + inventory: 'text-amber-200 bg-amber-500/12 ring-amber-200/30', + physics: 'text-cyan-200 bg-cyan-500/12 ring-cyan-200/30', + player: 'text-emerald-200 bg-emerald-500/12 ring-emerald-200/30', + stats: 'text-fuchsia-200 bg-fuchsia-500/12 ring-fuchsia-200/30', + teleport: 'text-sky-200 bg-sky-500/12 ring-sky-200/30', + vehicles: 'text-lime-200 bg-lime-500/12 ring-lime-200/30', + weapons: 'text-rose-200 bg-rose-500/12 ring-rose-200/30', + world: 'text-teal-200 bg-teal-500/12 ring-teal-200/30', +}; + +export type CategoryGroup = { + id: string; + label: string; + cheats: CheatSchema[]; +}; + +export function formatCategoryName(category: string): string { + const key = category.toLowerCase(); + if (CATEGORY_LABELS[key]) { + return CATEGORY_LABELS[key]; + } + + return category + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +export function groupCheatsByCategory(trainerMeta: TrainerMetaPayload | null): CategoryGroup[] { + if (!trainerMeta) { + return []; + } + + const grouped = new Map(); + for (const cheat of trainerMeta.schema.cheats) { + const bucket = grouped.get(cheat.category) ?? []; + bucket.push(cheat); + grouped.set(cheat.category, bucket); + } + + return Array.from(grouped.entries()) + .map(([id, cheats]) => ({ id, label: formatCategoryName(id), cheats })) + .sort((left, right) => left.label.localeCompare(right.label)); +} + +export const PINNED_CATEGORY_ID = 'pinned'; + +export function buildPinnedGroup( + trainerMeta: TrainerMetaPayload | null, + pinnedTargets: Record, +): CategoryGroup | null { + if (!trainerMeta) { + return null; + } + + const pinnedCheats = trainerMeta.schema.cheats.filter((cheat) => pinnedTargets[cheat.target]); + if (pinnedCheats.length === 0) { + return null; + } + + return { + id: PINNED_CATEGORY_ID, + label: formatCategoryName(PINNED_CATEGORY_ID), + cheats: pinnedCheats, + }; +} + +export function filterGroups(groups: CategoryGroup[], query: string): CategoryGroup[] { + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return groups; + } + + const result: CategoryGroup[] = []; + for (const group of groups) { + const matchesGroup = group.label.toLowerCase().includes(normalized) || group.id.toLowerCase().includes(normalized); + const cheats = matchesGroup + ? group.cheats + : group.cheats.filter((cheat) => cheatMatchesQuery(cheat, normalized)); + + if (cheats.length > 0) { + result.push({ ...group, cheats }); + } + } + + return result; +} + +function cheatMatchesQuery(cheat: CheatSchema, query: string): boolean { + if (cheat.name?.toLowerCase().includes(query)) return true; + if (cheat.target?.toLowerCase().includes(query)) return true; + if (cheat.category?.toLowerCase().includes(query)) return true; + if (cheat.description?.toLowerCase().includes(query)) return true; + if (cheat.type?.toLowerCase().includes(query)) return true; + return false; +} + +export function getTrainerDisplayName(trainer: TrainerSummary): string { + return trainer.displayName?.trim() || trainer.gameId || trainer.titleId || trainer.trainerId; +} + +export function getCategoryAccent(category: string): string { + return CATEGORY_ACCENTS[category.toLowerCase()] ?? 'text-emerald-200 bg-emerald-500/12 ring-emerald-200/30'; +} + +export function CategoryIcon({ category, className }: { category: string; className?: string }) { + return ; +} diff --git a/web-panel/src/features/remote-panel/components/CategorySection.tsx b/web-panel/src/features/remote-panel/components/CategorySection.tsx new file mode 100644 index 0000000..2bc0881 --- /dev/null +++ b/web-panel/src/features/remote-panel/components/CategorySection.tsx @@ -0,0 +1,59 @@ +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { CategoryIcon, type CategoryGroup, getCategoryAccent } from '../category'; +import type { CheatSchema } from '../protocol'; +import { CheatTile } from './CheatTile'; + +type CategorySectionProps = { + group: CategoryGroup; + values: Record; + pendingTargets: Record; + pinnedTargets: Record; + disabled: boolean; + onCheatChange: (cheat: CheatSchema, nextValue: unknown) => void; + onTogglePin: (cheat: CheatSchema) => void; +}; + +export function CategorySection({ + group, + values, + pendingTargets, + pinnedTargets, + disabled, + onCheatChange, + onTogglePin, +}: CategorySectionProps) { + return ( +
+
+
+
+ +
+
+

{group.label}

+

{group.id}

+
+
+ + {group.cheats.length} nodes + +
+ +
+ {group.cheats.map((cheat) => ( + onCheatChange(cheat, nextValue)} + onTogglePin={() => onTogglePin(cheat)} + /> + ))} +
+
+ ); +} diff --git a/web-panel/src/features/remote-panel/components/CheatTile.tsx b/web-panel/src/features/remote-panel/components/CheatTile.tsx new file mode 100644 index 0000000..352e468 --- /dev/null +++ b/web-panel/src/features/remote-panel/components/CheatTile.tsx @@ -0,0 +1,53 @@ +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Icon } from '@/components/ui/icon'; +import { cn } from '@/lib/utils'; +import type { CheatSchema } from '../protocol'; +import { CheatControl } from '../controls/CheatControl'; + +type CheatTileProps = { + cheat: CheatSchema; + value: unknown; + pending: boolean; + disabled: boolean; + pinned: boolean; + onChange: (nextValue: unknown) => void; + onTogglePin: () => void; +}; + +export function CheatTile({ cheat, value, pending, disabled, pinned, onChange, onTogglePin }: CheatTileProps) { + return ( + + +
+
+ {cheat.name} + {cheat.description ?

{cheat.description}

: null} +
+
+ {pending ? : null} + {cheat.type} + +
+
+ {cheat.instructions ?

{cheat.instructions}

: null} +
+ + + +
+ ); +} diff --git a/web-panel/src/features/remote-panel/components/ConnectionPanel.tsx b/web-panel/src/features/remote-panel/components/ConnectionPanel.tsx new file mode 100644 index 0000000..b350f6d --- /dev/null +++ b/web-panel/src/features/remote-panel/components/ConnectionPanel.tsx @@ -0,0 +1,70 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Icon } from '@/components/ui/icon'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { DEFAULT_REMOTE_PORT } from '../constants'; +import type { ConnectionStatus } from '../state'; + +type ConnectionPanelProps = { + status: ConnectionStatus; + wsUrl: string; + lastError: string | null; + onConnect: () => void; + onDebugSession?: () => void; + onWsUrlChange: (value: string) => void; +}; + +export function ConnectionPanel({ status, wsUrl, lastError, onConnect, onDebugSession, onWsUrlChange }: ConnectionPanelProps) { + return ( + + +
+
+ Bridge uplink + Default relay port {DEFAULT_REMOTE_PORT} +
+ + {status} + +
+
+ +
+ + onWsUrlChange(event.currentTarget.value)} + /> +
+ +
+ + {import.meta.env.DEV && onDebugSession ? ( + + ) : null} +
+ + {lastError ? ( +
+ + {lastError} +
+ ) : null} +
+
+ ); +} diff --git a/web-panel/src/features/remote-panel/components/DeckHeader.tsx b/web-panel/src/features/remote-panel/components/DeckHeader.tsx new file mode 100644 index 0000000..c697c7c --- /dev/null +++ b/web-panel/src/features/remote-panel/components/DeckHeader.tsx @@ -0,0 +1,48 @@ +import { Badge } from '@/components/ui/badge'; +import { Icon } from '@/components/ui/icon'; +import { cn } from '@/lib/utils'; +import type { ConnectionStatus } from '../state'; + +const STATUS_LABELS: Record = { + idle: 'Standby', + connecting: 'Linking', + connected: 'Live', + error: 'Fault', +}; + +const STATUS_CLASSES: Record = { + idle: 'border-amber-300/30 bg-amber-500/10 text-amber-200', + connecting: 'border-sky-300/30 bg-sky-500/10 text-sky-200', + connected: 'border-emerald-300/30 bg-emerald-500/10 text-emerald-200', + error: 'border-red-300/30 bg-red-500/10 text-red-200', +}; + +export function DeckHeader({ connectionStatus, remoteUrl }: { connectionStatus: ConnectionStatus; remoteUrl: string }) { + return ( +
+
+
+ +
+
+
+

Wand Control Deck

+ + beta + +
+
+ local link + / + {remoteUrl.replace(/\/$/, '')} +
+
+
+ +
+ + {STATUS_LABELS[connectionStatus]} +
+
+ ); +} diff --git a/web-panel/src/features/remote-panel/components/EmptyDeck.tsx b/web-panel/src/features/remote-panel/components/EmptyDeck.tsx new file mode 100644 index 0000000..0a725cc --- /dev/null +++ b/web-panel/src/features/remote-panel/components/EmptyDeck.tsx @@ -0,0 +1,18 @@ +import { Card, CardContent } from '@/components/ui/card'; +import { Icon } from '@/components/ui/icon'; + +export function EmptyDeck() { + return ( + + +
+ +
+
+

No trainer signal

+

Connect the local bridge to stream trainer controls.

+
+
+
+ ); +} diff --git a/web-panel/src/features/remote-panel/components/TrainerOverview.tsx b/web-panel/src/features/remote-panel/components/TrainerOverview.tsx new file mode 100644 index 0000000..8e2d7f0 --- /dev/null +++ b/web-panel/src/features/remote-panel/components/TrainerOverview.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { Icon } from '@/components/ui/icon'; +import { getTrainerDisplayName } from '../category'; +import type { TrainerSummary } from '../protocol'; + +type TrainerOverviewProps = { + trainer: TrainerSummary; + cheatCount: number; + categoryCount: number; +}; + +export function TrainerOverview({ trainer, cheatCount, categoryCount }: TrainerOverviewProps) { + return ( +
+ + +
+ +
+
+

Active trainer

+

{getTrainerDisplayName(trainer)}

+
+ {trainer.gameVersion ?? 'unknown build'} + {trainer.language ?? 'n/a'} + #{trainer.trainerId} +
+
+
+
+ +
+ } label="Cheats" value={cheatCount} /> + } label="Loadouts" value={categoryCount} /> +
+ + {trainer.needsCompatibilityWarning ? ( + + + + Compatibility warning active + + + ) : null} +
+ ); +} + +function StatCard({ icon, label, value }: { icon: ReactNode; label: string; value: number }) { + return ( + + +
+

{label}

+

{value}

+
+
+ {icon} +
+
+
+ ); +} diff --git a/web-panel/src/features/remote-panel/constants.ts b/web-panel/src/features/remote-panel/constants.ts new file mode 100644 index 0000000..173d0d1 --- /dev/null +++ b/web-panel/src/features/remote-panel/constants.ts @@ -0,0 +1,37 @@ +export const DEFAULT_REMOTE_PORT = 3223; +export const REMOTE_BASE_PATH = '/remote/'; +export const REMOTE_WS_PATH = '/remote/ws'; +export const CLIENT_VERSION = '0.2.0'; +export const WS_QUERY_PARAM = 'ws'; + +const DEV_SERVER_PORTS = new Set(['4173', '5173']); + +function protocolForWebSocket(): 'ws' | 'wss' { + return window.location.protocol === 'https:' ? 'wss' : 'ws'; +} + +function isServedByRemoteBridge(): boolean { + return window.location.pathname.startsWith(REMOTE_BASE_PATH) && !DEV_SERVER_PORTS.has(window.location.port); +} + +export function readInitialRemoteUrl(): string { + if (isServedByRemoteBridge()) { + return `${window.location.protocol}//${window.location.host}${REMOTE_BASE_PATH}`; + } + + return `http://127.0.0.1:${DEFAULT_REMOTE_PORT}${REMOTE_BASE_PATH}`; +} + +export function readInitialWebSocketUrl(): string { + const params = new URLSearchParams(window.location.search); + const explicitUrl = params.get(WS_QUERY_PARAM)?.trim(); + if (explicitUrl) { + return explicitUrl; + } + + if (isServedByRemoteBridge()) { + return `${protocolForWebSocket()}://${window.location.host}${REMOTE_WS_PATH}`; + } + + return `ws://127.0.0.1:${DEFAULT_REMOTE_PORT}${REMOTE_WS_PATH}`; +} diff --git a/web-panel/src/features/remote-panel/controls/CheatControl.tsx b/web-panel/src/features/remote-panel/controls/CheatControl.tsx new file mode 100644 index 0000000..62186e7 --- /dev/null +++ b/web-panel/src/features/remote-panel/controls/CheatControl.tsx @@ -0,0 +1,182 @@ +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/ui/icon'; +import { Input } from '@/components/ui/input'; +import { Slider } from '@/components/ui/slider'; +import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; +import type { CheatSchema, CheatOption } from '../protocol'; +import { resolveOption } from '../protocol'; + +type CheatControlProps = { + cheat: CheatSchema; + value: unknown; + pending: boolean; + disabled: boolean; + onChange: (nextValue: unknown) => void; +}; + +function renderValue(value: unknown, postfix?: string): string { + if (typeof value === 'boolean') { + return value ? 'On' : 'Off'; + } + + if (value === null || value === undefined || value === '') { + return '--'; + } + + return `${String(value)}${postfix ?? ''}`; +} + +function numericValue(value: unknown, fallback: number): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value !== 'string') { + return fallback; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function optionKey(option: CheatOption): string { + return String(option.value); +} + +function findOption(options: CheatOption[], value: string): CheatOption | undefined { + return options.find((option) => String(option.value) === value); +} + +function isSameOption(left: unknown, right: unknown): boolean { + return String(left) === String(right); +} + +export function CheatControl({ cheat, value, pending, disabled, onChange }: CheatControlProps) { + const commonDisabled = disabled || pending; + const options = cheat.args.options?.map(resolveOption) ?? []; + + if (cheat.type === 'toggle') { + return ( +
+ {renderValue(value)} + +
+ ); + } + + if (cheat.type === 'slider') { + const min = cheat.args.min ?? 0; + const max = cheat.args.max ?? 100; + const currentValue = numericValue(value, min); + + return ( +
+
+ Range + + {renderValue(currentValue, cheat.args.postfix)} + +
+ +
+ ); + } + + if (cheat.type === 'number') { + return ( +
+ onChange(event.currentTarget.value)} + /> + + {renderValue(value, cheat.args.postfix)} + +
+ ); + } + + if (cheat.type === 'button') { + return ( + + ); + } + + if (cheat.type === 'selection') { + const selectedValue = String(value ?? options[0]?.value ?? ''); + + return ( + + ); + } + + if (cheat.type === 'scalar') { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); + } + + if (cheat.type === 'incremental') { + const currentIndex = options.findIndex((option) => isSameOption(option.value, value)); + const previous = currentIndex > 0 ? options[currentIndex - 1] : null; + const next = currentIndex >= 0 && currentIndex < options.length - 1 ? options[currentIndex + 1] : null; + + return ( +
+ + {renderValue(options[currentIndex]?.label ?? value)} + +
+ ); + } + + return ( +
+ Unsupported cheat type: {cheat.type} +
+ ); +} diff --git a/web-panel/src/features/remote-panel/debug-session.ts b/web-panel/src/features/remote-panel/debug-session.ts new file mode 100644 index 0000000..40fda9f --- /dev/null +++ b/web-panel/src/features/remote-panel/debug-session.ts @@ -0,0 +1,15 @@ +import { mockTrainerMeta, mockTrainerValues } from './mock-data'; +import type { PanelAction } from './state'; + +const MOCK_QUERY_PARAM = 'mock'; + +type PanelDispatch = (action: PanelAction) => void; + +export function isDebugSessionRequested(): boolean { + return new URLSearchParams(window.location.search).get(MOCK_QUERY_PARAM) === '1'; +} + +export function loadDebugSession(dispatch: PanelDispatch): void { + dispatch({ type: 'trainerMeta', payload: mockTrainerMeta }); + dispatch({ type: 'trainerValues', payload: mockTrainerValues.values }); +} \ No newline at end of file diff --git a/web-panel/src/features/remote-panel/message-handler.ts b/web-panel/src/features/remote-panel/message-handler.ts new file mode 100644 index 0000000..801aee2 --- /dev/null +++ b/web-panel/src/features/remote-panel/message-handler.ts @@ -0,0 +1,50 @@ +import type { IncomingMessage, TrainerMetaPayload } from './protocol'; +import { normalizeIncomingValue } from './protocol'; +import type { PanelAction } from './state'; + +type Dispatch = (action: PanelAction) => void; + +export function handleProtocolMessage(dispatch: Dispatch, message: IncomingMessage, trainerMeta: TrainerMetaPayload | null): void { + switch (message.type) { + case 'hello_ack': + handleHelloAck(dispatch, message.payload.accepted, message.payload.remoteUrl); + return; + case 'trainer_meta': + dispatch({ type: 'trainerMeta', payload: message.payload }); + return; + case 'trainer_values': + dispatch({ type: 'trainerValues', payload: message.payload.values }); + return; + case 'value_changed': + handleValueChanged(dispatch, message, trainerMeta); + return; + case 'trainer_changed': + dispatch({ type: 'trainerChanged' }); + return; + case 'set_value_result': + if (!message.payload.ok) { + dispatch({ type: 'error', message: message.payload.error?.message ?? 'The trainer rejected the requested value.' }); + } + return; + case 'error': + dispatch({ type: 'error', message: message.payload.message }); + return; + } +} + +function handleHelloAck(dispatch: Dispatch, accepted: boolean, remoteUrl?: string): void { + if (!accepted) { + dispatch({ type: 'error', message: 'The desktop bridge rejected the connection.' }); + return; + } + + if (remoteUrl) { + dispatch({ type: 'setRemoteUrl', remoteUrl }); + } +} + +function handleValueChanged(dispatch: Dispatch, message: Extract, trainerMeta: TrainerMetaPayload | null): void { + const cheat = trainerMeta?.schema.cheats.find((item) => item.target === message.payload.target || item.uuid === message.payload.cheatId); + const nextValue = cheat ? normalizeIncomingValue(cheat, message.payload.value) : message.payload.value; + dispatch({ type: 'valueChanged', target: message.payload.target, value: nextValue }); +} diff --git a/web-panel/src/features/remote-panel/mock-data.ts b/web-panel/src/features/remote-panel/mock-data.ts new file mode 100644 index 0000000..d659279 --- /dev/null +++ b/web-panel/src/features/remote-panel/mock-data.ts @@ -0,0 +1,5 @@ +import demoSession from '../../../fixtures/demo-session.json'; +import type { TrainerMetaPayload, TrainerValuesPayload } from './protocol'; + +export const mockTrainerMeta = demoSession.trainerMeta as TrainerMetaPayload; +export const mockTrainerValues = demoSession.trainerValues as TrainerValuesPayload; diff --git a/web-panel/src/features/remote-panel/pinned-storage.ts b/web-panel/src/features/remote-panel/pinned-storage.ts new file mode 100644 index 0000000..17cfa3b --- /dev/null +++ b/web-panel/src/features/remote-panel/pinned-storage.ts @@ -0,0 +1,59 @@ +import type { TrainerSummary } from './protocol'; + +const STORAGE_PREFIX = 'wand-remote.pinned-cheats.v1:'; + +export function getPinnedStorageKey(trainer: TrainerSummary | null | undefined): string | null { + if (!trainer) { + return null; + } + + const id = trainer.gameId?.trim() || trainer.titleId?.trim() || trainer.trainerId?.trim(); + return id ? `${STORAGE_PREFIX}${id}` : null; +} + +export function loadPinnedTargets(storageKey: string | null): Record { + if (!storageKey || typeof window === 'undefined') { + return {}; + } + + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return {}; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return {}; + } + + const next: Record = {}; + for (const target of parsed) { + if (typeof target === 'string' && target.length > 0) { + next[target] = true; + } + } + + return next; + } catch { + return {}; + } +} + +export function savePinnedTargets(storageKey: string | null, pinned: Record): void { + if (!storageKey || typeof window === 'undefined') { + return; + } + + try { + const targets = Object.keys(pinned); + if (targets.length === 0) { + window.localStorage.removeItem(storageKey); + return; + } + + window.localStorage.setItem(storageKey, JSON.stringify(targets)); + } catch { + // Ignore quota / serialization errors – pinning is a non-critical UX nicety. + } +} diff --git a/web-panel/src/features/remote-panel/protocol.ts b/web-panel/src/features/remote-panel/protocol.ts new file mode 100644 index 0000000..37c9390 --- /dev/null +++ b/web-panel/src/features/remote-panel/protocol.ts @@ -0,0 +1,208 @@ +export const PROTOCOL_VERSION = 1; + +export type CheatType = + | 'slider' + | 'number' + | 'toggle' + | 'button' + | 'selection' + | 'scalar' + | 'incremental'; + +export interface CheatOption { + label?: string; + value: string | number; +} + +export type CheatOptionLike = CheatOption | string | number; + +export interface CheatArgs { + min?: number; + max?: number; + step?: number; + options?: CheatOptionLike[]; + button?: string | boolean; + postfix?: string; + default?: string | number | boolean; +} + +export interface CheatSchema { + uuid: string; + target: string; + type: CheatType; + name: string; + description?: string | null; + instructions?: string | null; + category: string; + parent?: string | null; + flags?: number; + hotkeys?: string[][]; + args: CheatArgs; +} + +export interface TrainerSummary { + trainerId: string; + gameId: string; + displayName?: string | null; + titleId?: string | null; + gameVersion?: string | null; + trainerLoading: boolean; + gameInstalled: boolean; + needsCompatibilityWarning: boolean; + language?: string; + themeId?: string; + isTimeLimitExpired: boolean; + notesReadHash?: string | null; +} + +export interface TrainerMetaPayload { + session: { + instanceId: string; + }; + trainer: TrainerSummary; + schema: { + categories: string[]; + cheats: CheatSchema[]; + }; +} + +export type TrainerValuesPayload = { + trainerId: string; + values: Record; +}; + +export type ValueChangedPayload = { + trainerId: string; + target: string; + value: unknown; + oldValue?: unknown; + source?: string; + cheatId?: string; +}; + +export type TrainerChangedPayload = { + previousTrainerId?: string | null; + trainerId: string; +}; + +export type SetValuePayload = { + trainerId: string; + target: string; + value: unknown; + cheatId?: string; +}; + +export type SetValueResultPayload = { + ok: boolean; + trainerId: string; + target: string; + error?: { + code: string; + message: string; + }; +}; + +export type ErrorPayload = { + code: string; + message: string; + details?: Record; +}; + +export interface MessageEnvelope { + type: TType; + version: number; + requestId: string | null; + payload: TPayload; +} + +export type HelloMessage = MessageEnvelope< + 'hello', + { + client: 'mobile-web'; + clientVersion: string; + pairingToken?: string; + capabilities: { + supportsDeltaValues: boolean; + supportsTrainerSwitch: boolean; + }; + } +>; + +export type HelloAckMessage = MessageEnvelope< + 'hello_ack', + { + sessionId: string; + accepted: boolean; + serverVersion: string; + protocolVersion: number; + remoteUrl?: string; + advertisedUrls?: string[]; + } +>; + +export type TrainerMetaMessage = MessageEnvelope<'trainer_meta', TrainerMetaPayload>; +export type TrainerValuesMessage = MessageEnvelope<'trainer_values', TrainerValuesPayload>; +export type ValueChangedMessage = MessageEnvelope<'value_changed', ValueChangedPayload>; +export type TrainerChangedMessage = MessageEnvelope<'trainer_changed', TrainerChangedPayload>; +export type SetValueMessage = MessageEnvelope<'set_value', SetValuePayload>; +export type SetValueResultMessage = MessageEnvelope<'set_value_result', SetValueResultPayload>; +export type ErrorMessage = MessageEnvelope<'error', ErrorPayload>; + +export type IncomingMessage = + | HelloAckMessage + | TrainerMetaMessage + | TrainerValuesMessage + | ValueChangedMessage + | TrainerChangedMessage + | SetValueResultMessage + | ErrorMessage; + +export type OutgoingMessage = HelloMessage | SetValueMessage; + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function isIncomingMessage(value: unknown): value is IncomingMessage { + return isRecord(value) && typeof value.type === 'string' && typeof value.version === 'number' && 'payload' in value; +} + +export function resolveOption(option: CheatOptionLike): CheatOption { + if (typeof option === 'string' || typeof option === 'number') { + return { label: String(option), value: option }; + } + + return { + label: option.label ?? String(option.value), + value: option.value, + }; +} + +export function normalizeIncomingValue(cheat: CheatSchema, value: unknown): unknown { + if (cheat.type === 'toggle') { + return Boolean(value); + } + + return value; +} + +export function normalizeOutgoingValue(cheat: CheatSchema, value: unknown): unknown { + if (cheat.type === 'toggle') { + return Boolean(value); + } + + if (cheat.type !== 'slider' && cheat.type !== 'number') { + return value; + } + + if (typeof value !== 'string') { + return value; + } + + const trimmedValue = value.trim(); + if (!trimmedValue) { + return value; + } + + return Number(trimmedValue); +} diff --git a/web-panel/src/features/remote-panel/socket-client.ts b/web-panel/src/features/remote-panel/socket-client.ts new file mode 100644 index 0000000..e749f88 --- /dev/null +++ b/web-panel/src/features/remote-panel/socket-client.ts @@ -0,0 +1,119 @@ +import { CLIENT_VERSION } from './constants'; +import { + type HelloMessage, + type IncomingMessage, + type OutgoingMessage, + PROTOCOL_VERSION, + type SetValueMessage, + isIncomingMessage, +} from './protocol'; + +type SocketHandlers = { + onConnecting: () => void; + onOpen: () => void; + onMessage: (message: IncomingMessage) => void; + onClose: () => void; + onError: (message: string) => void; +}; + +export class PanelSocketClient { + private socket: WebSocket | null = null; + private intentionalDisconnect = false; + + constructor( + private readonly url: string, + private readonly handlers: SocketHandlers, + ) {} + + connect(pairingToken?: string): void { + this.disconnect(); + this.intentionalDisconnect = false; + this.handlers.onConnecting(); + + const socket = new WebSocket(this.url); + this.socket = socket; + + socket.addEventListener('open', () => { + this.handlers.onOpen(); + this.send(this.createHelloMessage(pairingToken)); + }); + + socket.addEventListener('message', (event) => this.handleMessage(event)); + + socket.addEventListener('close', () => { + if (this.socket === socket) { + this.socket = null; + } + + if (!this.intentionalDisconnect) { + this.handlers.onClose(); + } + }); + + socket.addEventListener('error', () => { + this.handlers.onError('WebSocket connection failed.'); + }); + } + + disconnect(): void { + this.intentionalDisconnect = true; + this.socket?.close(); + this.socket = null; + } + + send(message: OutgoingMessage): boolean { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + return false; + } + + this.socket.send(JSON.stringify(message)); + return true; + } + + setValue(trainerId: string, target: string, value: unknown, cheatId?: string): boolean { + const message: SetValueMessage = { + type: 'set_value', + version: PROTOCOL_VERSION, + requestId: `set_${target}_${Date.now()}`, + payload: { + trainerId, + target, + value, + cheatId, + }, + }; + + return this.send(message); + } + + private createHelloMessage(pairingToken?: string): HelloMessage { + return { + type: 'hello', + version: PROTOCOL_VERSION, + requestId: `hello_${Date.now()}`, + payload: { + client: 'mobile-web', + clientVersion: CLIENT_VERSION, + pairingToken, + capabilities: { + supportsDeltaValues: true, + supportsTrainerSwitch: true, + }, + }, + }; + } + + private handleMessage(event: MessageEvent): void { + try { + const parsed = JSON.parse(String(event.data)) as unknown; + if (!isIncomingMessage(parsed)) { + this.handlers.onError('Received an invalid protocol message.'); + return; + } + + this.handlers.onMessage(parsed); + } catch (error) { + this.handlers.onError(error instanceof Error ? error.message : 'Failed to parse websocket message.'); + } + } +} diff --git a/web-panel/src/features/remote-panel/state.ts b/web-panel/src/features/remote-panel/state.ts new file mode 100644 index 0000000..24eecca --- /dev/null +++ b/web-panel/src/features/remote-panel/state.ts @@ -0,0 +1,131 @@ +import { readInitialRemoteUrl, readInitialWebSocketUrl } from './constants'; +import type { TrainerMetaPayload } from './protocol'; + +export type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'error'; + +export type PanelState = { + connectionStatus: ConnectionStatus; + wsUrl: string; + remoteUrl: string; + trainerMeta: TrainerMetaPayload | null; + values: Record; + pendingTargets: Record; + pinnedTargets: Record; + lastError: string | null; +}; + +export type PanelAction = + | { type: 'setWsUrl'; wsUrl: string } + | { type: 'setRemoteUrl'; remoteUrl: string } + | { type: 'connecting' } + | { type: 'connected' } + | { type: 'trainerMeta'; payload: TrainerMetaPayload } + | { type: 'trainerValues'; payload: Record } + | { type: 'valueChanged'; target: string; value: unknown } + | { type: 'setPending'; target: string; pending: boolean } + | { type: 'trainerChanged' } + | { type: 'setPinnedTargets'; pinned: Record } + | { type: 'togglePinnedTarget'; target: string } + | { type: 'error'; message: string | null }; + +export function createInitialPanelState(): PanelState { + return { + connectionStatus: 'idle', + wsUrl: readInitialWebSocketUrl(), + remoteUrl: readInitialRemoteUrl(), + trainerMeta: null, + values: {}, + pendingTargets: {}, + pinnedTargets: {}, + lastError: null, + }; +} + +export function panelReducer(state: PanelState, action: PanelAction): PanelState { + switch (action.type) { + case 'setWsUrl': + return { + ...state, + wsUrl: action.wsUrl, + }; + case 'setRemoteUrl': + return { + ...state, + remoteUrl: action.remoteUrl, + }; + case 'connecting': + return { + ...state, + connectionStatus: 'connecting', + lastError: null, + }; + case 'connected': + return { + ...state, + connectionStatus: 'connected', + lastError: null, + }; + case 'trainerMeta': + return { + ...state, + trainerMeta: action.payload, + pendingTargets: {}, + }; + case 'trainerValues': + return { + ...state, + values: action.payload, + }; + case 'valueChanged': + return { + ...state, + values: { + ...state.values, + [action.target]: action.value, + }, + pendingTargets: { + ...state.pendingTargets, + [action.target]: false, + }, + }; + case 'setPending': + return { + ...state, + pendingTargets: { + ...state.pendingTargets, + [action.target]: action.pending, + }, + }; + case 'trainerChanged': + return { + ...state, + trainerMeta: null, + values: {}, + pendingTargets: {}, + pinnedTargets: {}, + }; + case 'setPinnedTargets': + return { + ...state, + pinnedTargets: action.pinned, + }; + case 'togglePinnedTarget': { + const next = { ...state.pinnedTargets }; + if (next[action.target]) { + delete next[action.target]; + } else { + next[action.target] = true; + } + return { + ...state, + pinnedTargets: next, + }; + } + case 'error': + return { + ...state, + connectionStatus: action.message ? 'error' : state.connectionStatus, + lastError: action.message, + }; + } +} diff --git a/web-panel/src/index.css b/web-panel/src/index.css new file mode 100644 index 0000000..97d5783 --- /dev/null +++ b/web-panel/src/index.css @@ -0,0 +1,63 @@ +@import "tailwindcss"; + +@theme inline { + --font-heading: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(0.122 0.022 255); + --foreground: oklch(0.968 0.016 96); + --card: oklch(0.18 0.028 252); + --card-foreground: oklch(0.968 0.016 96); + --popover: oklch(0.16 0.026 252); + --popover-foreground: oklch(0.968 0.016 96); + --primary: oklch(0.812 0.184 142); + --primary-foreground: oklch(0.13 0.032 145); + --secondary: oklch(0.251 0.046 252); + --secondary-foreground: oklch(0.92 0.025 94); + --muted: oklch(0.235 0.031 252); + --muted-foreground: oklch(0.72 0.025 248); + --accent: oklch(0.742 0.153 55); + --accent-foreground: oklch(0.16 0.028 50); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.32 0.042 252); + --input: oklch(0.255 0.038 252); + --ring: oklch(0.812 0.184 142); + --radius: 0.5rem; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply min-h-svh bg-background text-foreground antialiased; + } + html { + @apply font-sans; + } +} \ No newline at end of file diff --git a/web-panel/src/lib/utils.ts b/web-panel/src/lib/utils.ts new file mode 100644 index 0000000..74be409 --- /dev/null +++ b/web-panel/src/lib/utils.ts @@ -0,0 +1,32 @@ +type ClassValue = string | number | false | null | undefined | ClassValue[] | Record + +export function cn(...inputs: ClassValue[]) { + const classes: string[] = [] + + for (const input of inputs) { + if (!input) { + continue + } + + if (typeof input === "string" || typeof input === "number") { + classes.push(String(input)) + continue + } + + if (Array.isArray(input)) { + const value = cn(...input) + if (value) { + classes.push(value) + } + continue + } + + for (const [key, enabled] of Object.entries(input)) { + if (enabled) { + classes.push(key) + } + } + } + + return classes.join(" ") +} diff --git a/web-panel/src/main.tsx b/web-panel/src/main.tsx new file mode 100644 index 0000000..aedec34 --- /dev/null +++ b/web-panel/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './app'; +import './index.css'; + +const root = document.getElementById('root') ?? document.getElementById('app'); + +if (!root) { + throw new Error('App root not found.'); +} + +createRoot(root).render( + + + , +); diff --git a/web-panel/tsconfig.app.json b/web-panel/tsconfig.app.json new file mode 100644 index 0000000..2aa77aa --- /dev/null +++ b/web-panel/tsconfig.app.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "types": [ + "vite/client" + ], + "skipLibCheck": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/web-panel/tsconfig.json b/web-panel/tsconfig.json new file mode 100644 index 0000000..e16e825 --- /dev/null +++ b/web-panel/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ES2022" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": [ + "vite/client", + "node" + ], + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "src", + "vite.config.ts" + ] +} \ No newline at end of file diff --git a/web-panel/tsconfig.node.json b/web-panel/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/web-panel/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web-panel/vite.config.ts b/web-panel/vite.config.ts new file mode 100644 index 0000000..3021b2c --- /dev/null +++ b/web-panel/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import { fileURLToPath, URL } from 'node:url'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + base: './', + resolve: { + alias: [ + { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }, + { find: 'react-dom/client', replacement: 'preact/compat/client' }, + { find: 'react-dom', replacement: 'preact/compat' }, + { find: 'react/jsx-runtime', replacement: 'preact/jsx-runtime' }, + { find: 'react/jsx-dev-runtime', replacement: 'preact/jsx-dev-runtime' }, + { find: 'react', replacement: 'preact/compat' }, + ], + }, + server: { + host: '127.0.0.1', + port: 4173, + strictPort: true, + }, + preview: { + host: '127.0.0.1', + port: 4173, + strictPort: true, + }, + build: { + outDir: 'dist', + assetsDir: 'assets', + target: 'es2020', + sourcemap: false, + }, +}); \ No newline at end of file