Compare commits

..

2 Commits

Author SHA1 Message Date
kitbyte fa753b4a34 Release 1.0.7.0: Remote Web Panel & Stability Fixes 2026-05-01 22:39:15 +03:00
kitbyte 5273930eef refactor: rebrand project to WandEnhancer 2026-03-30 21:39:06 +03:00
121 changed files with 7265 additions and 627 deletions
-2
View File
@@ -1,2 +0,0 @@
ko_fi: kitbyte
custom: ["https://www.paypal.com/ncp/payment/ZP3NPDYP6A34W", "https://www.paypal.com/donate/?hosted_button_id=QGGKZTFPDKMHC"]
-30
View File
@@ -1,30 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: k1tbyte
---
**WeMod version**: X.X.X
**Patcher version**: X.X.X
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
+70
View File
@@ -0,0 +1,70 @@
name: Bug report
description: Create a report to help us improve
title: '[Bug]: '
labels: ["bug"]
assignees:
- k1tbyte
body:
- type: markdown
attributes:
value: |
**🚨 STOP BEFORE YOU POST: THIS PROJECT HAS NO OFFICIAL YOUTUBE TUTORIALS. 🚨**
If you downloaded an executable from a YouTube video link, you downloaded a virus/password stealer from a scammer. **Run an antivirus immediately and change your passwords.** Do not open issues about stolen accounts here — this project is not related to those videos.
- type: checkboxes
id: scam_check
attributes:
label: ⚠️ Download Source Confirmation (REQUIRED)
description: You must check this box to proceed.
options:
- label: I confirm that I downloaded this tool DIRECTLY from this official GitHub repository, and NOT from a YouTube video, Discord, or any other third-party website.
required: true
- type: input
id: client_version
attributes:
label: Client (Wand) version
description: What version of the client are you using? (e.g., 9.0.0)
validations:
required: true
- type: input
id: enhancer_version
attributes:
label: Enhancer version
description: What version of this tool are you using?
validations:
required: true
- type: textarea
id: bug_description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: To Reproduce
description: Steps to reproduce the behavior.
value: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
- type: textarea
id: additional_context
attributes:
label: Screenshots & Additional context
description: Drag and drop screenshots here, or add any other context about the problem.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+26
View File
@@ -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
+3 -2
View File
@@ -130,8 +130,8 @@ dist
.pnp.*
./WeModPatcher/obj/
./WeModPatcher/bin/
./WandEnhancer/obj/
./WandEnhancer/bin/
./AsarSharp/obj/
./AsarSharp/bin/
.idea
@@ -142,3 +142,4 @@ packages
# App settings (user preferences)
appsettings.json
*DotSettings.user
+30
View File
@@ -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 `<app>.whenReady().then(` site and attaches a `before-input-event` hook to every `BrowserWindow.webContents`. Do not patch the renderer keydown listener — the minified `ACTION_OPEN_DEV_TOOLS` dispatch site is not stable across Wand releases.
- Cheats can be pinned per game in the web panel via `pinned-storage.ts` (`localStorage` key `wand-remote.pinned-cheats.v1:<gameId>`). Pinned cheats render as a virtual `pinned` category at the top of the list; their normal category placement is preserved.
## 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`.
+35 -6
View File
@@ -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
// "<archive>.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");
+8 -8
View File
@@ -1,6 +1,6 @@
# Contributing to WeMod Patcher
# Contributing to WandEnhancer
Thank you for your interest in the WeMod Patcher project! This document provides guidelines for contributing to the project.
Thank you for your interest in the WandEnhancer project! This document provides guidelines for contributing to the project.
## Table of Contents
@@ -20,9 +20,9 @@ By participating in this project, you commit to maintaining respectful interacti
The project consists of the following main components:
- **WeModPatcher** - Main project containing the patcher logic and user interface
- **WandEnhancer** - Main project containing the enhancement logic and user interface
- **AsarSharp** - Library for working with ASAR archives (used for unpacking and modifying WeMod files)
- **Core** - Core of the patcher, including static and dynamic patching
- **Core** - Core of the enhancement flow, including static and dynamic modifications
- **Models** - Data models used in the project
- **View** - User interface components
@@ -30,10 +30,10 @@ The project consists of the following main components:
1. Clone the repository:
```
git clone https://github.com/k1tbyte/Wemod-Patcher.git
git clone https://github.com/k1tbyte/Wand-Enhancer.git
```
2. Open the solution `Wemod-Patcher.sln` in Visual Studio or JetBrains Rider.
2. Open the solution `Wand-Enhancer.sln` in Visual Studio or JetBrains Rider.
3. Restore NuGet packages.
@@ -43,7 +43,7 @@ The project consists of the following main components:
If you've found a bug, please create an Issue with a detailed description:
- WeMod Patcher version
- WandEnhancer version
- WeMod version where the problem occurred
- Detailed steps to reproduce the bug
- Expected and actual behavior
@@ -108,4 +108,4 @@ By contributing, you agree that your contributions will be licensed under the [A
---
Thank you for contributing to the WeMod Patcher project!
Thank you for contributing to the WandEnhancer project!
+39 -30
View File
@@ -3,57 +3,61 @@
![logo](./assets/icon.svg)
---
<h1>WeMod Patcher</h1>
# WandEnhancer
</div>
<h4>WeMod patcher allows you to get some WeMod Pro features absolutely free. This script patches WeMod, thereby removing the daily usage limit (2h), etc.</h4>
<h4>An open-source interoperability tool designed to extend local client-side configurations and improve the UX of the Wand application.</h4>
## 👾 No malware? Is it safe for me?
**🚨 IMPORTANT NOTICE: THIS PROJECT HAS NO OFFICIAL YOUTUBE TUTORIALS OR GUIDES. 🚨
There are no official videos showing how to install or use this tool. Scammers are creating fake tutorials using this project's name and placing malware/password stealers in the video descriptions. If you downloaded an .exe or archive from a YouTube link, YOU HAVE DOWNLOADED MALWARE. The only official, safe, and original source for this project is this exact GitHub repository. We are not responsible for third-party downloads.**
Yes, this is safe. This script is open-source, and you can check the code yourself. I have no intention of harming you or your computer. The script does absolutely nothing with your computer and does not require Internet access. It only affects WeMod files.
## 👾 Is it safe to use?
## 💻 Does this script only work with older versions of WeMod like other unlockers?
Yes. This project is entirely open-source, allowing anyone to audit the code. It operates strictly locally, does not require internet access, and makes zero network requests. It simply adjusts local client settings to enhance your user experience.
With this patch you will be able to use the latest version together with Pro.
## 💫 What features are improved?
## 💫 What features will be available?
✅ Local environment configuration management <br/>
✅ Automated compatibility adjustments for new client versions <br/>
✅ Advanced layout and theme customization (Client-side only) <br/>
✅ AI Features <br/>
✅ Remote web panel (Remote Connect on mobile) <br/>
✅ Unlimited usage time <br/>
✅ Disabling automatic updates (optional) <br/>
✅ Automatic patching of new WeMod versions <br/>
✅ AI Game guides <br/>
✅ Saving mods <br/>
✅ Exclusive to pro subscription customization for hacks <br/>
✅ Hotkeys (hotkey functionality is broken after static patching for unknown reason) <br/>
❌ Connect phone <br/>
## 🌐 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?
1. Go to [Releases](https://github.com/k1tbyte/Wemod-Patcher/releases) page.
2. Download latest version
3. Run and click the patch
1. Go to the [Releases](https://github.com/k1tbyte/Wand-Enhancer/releases) page.
2. Download the latest source or binary.
3. Run the enhancer to apply local client modifications.
---
## ❓ Q&A
- I applied the patch but when I inject I get stuck on 'Loading mods...'.
- Just close WeMod and try again
- During the game, some hacks are enabled without my input
- This is a bug after the static patch, you have to turn off hotkeys in WeMod settings
- VirusTotal claims that this program is a malware/trojan.
- Perhaps the patcher does have the same signatures as malware (virtual memory patching). But this is a false positive, you can look at the source code or even build the patcher yourself.
- Does this application transfer any data to the Internet from my computer?
- The short answer is NO. This application does not need access to the Internet. The most it does is download updates if you want it to.
- What makes this application better than other patchers?
- All actions related to patches are performed on your computer. No files of unknown origin will be downloaded.
- **I applied the configuration but get stuck on 'Loading...'**
- Just close the application completely and restart it.
- **Does this send data anywhere?**
- No. All operations are strictly offline and local to your machine.
---
## 🖼️ Screenshots
![1](./assets/screenshots/app1.png)
<div align='center'>
![2](./assets/screenshots/app2.png)
</div>
---
## 📜 License
@@ -65,4 +69,9 @@ This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) fi
---
[![Star History Chart](https://api.star-history.com/svg?repos=k1tbyte/Wemod-Patcher&type=Date)](https://www.star-history.com/#k1tbyte/Wemod-Patcher&Date)
> **Legal Disclaimer:**
> This project is a third-party enhancement tool intended solely for educational, research, and local interoperability purposes. It does not distribute any proprietary code or bypass server-side validations. All modifications are performed locally to customize the user's interface.
---
[![Star History Chart](https://api.star-history.com/svg?repos=k1tbyte/Wand-Enhancer&type=Date)](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date)
+1 -1
View File
@@ -1,6 +1,6 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WeModPatcher", "WeModPatcher\WeModPatcher.csproj", "{106D3E44-ECBB-4EF3-84B2-5FC6BCF77727}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WandEnhancer", "WandEnhancer\WandEnhancer.csproj", "{106D3E44-ECBB-4EF3-84B2-5FC6BCF77727}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AsarSharp", "AsarSharp\AsarSharp.csproj", "{BEAA604A-402A-4387-8903-A53FC913A26E}"
EndProject
@@ -1,8 +1,8 @@
<Application x:Class="WeModPatcher.App"
<Application x:Class="WandEnhancer.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WeModPatcher"
xmlns:converters="clr-namespace:WeModPatcher.Converters">
xmlns:local="clr-namespace:WandEnhancer"
xmlns:converters="clr-namespace:WandEnhancer.Converters">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
@@ -1,12 +1,12 @@
using System;
using System.Threading.Tasks;
using System.Windows;
using WeModPatcher.Core;
using WeModPatcher.Core.Services;
using WeModPatcher.View.MainWindow;
using WandEnhancer.Core;
using WandEnhancer.Core.Services;
using WandEnhancer.View.MainWindow;
using MessageBox = System.Windows.Forms.MessageBox;
namespace WeModPatcher
namespace WandEnhancer
{
/// <summary>
/// Interaction logic for App.xaml
@@ -1,12 +1,12 @@
using System;
using System.Reflection;
using WeModPatcher.Models;
using WandEnhancer.Models;
namespace WeModPatcher
namespace WandEnhancer
{
public static class Constants
{
public const string RepoName = "Wemod-Patcher";
public const string RepoName = "Wand-Enhancer";
public const string Owner = "k1tbyte";
/*public const string PatchRegistryName = "patchRegistry.json";*/
public static readonly string RepositoryUrl = $"https://github.com/{Owner}/{RepoName}";
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.Windows.Data;
namespace WeModPatcher.Converters
namespace WandEnhancer.Converters
{
public abstract class BaseBooleanConverter<T> : IValueConverter
{
@@ -1,6 +1,6 @@
using System.Windows;
namespace WeModPatcher.Converters
namespace WandEnhancer.Converters
{
internal sealed class ToVisibilityConverter : BaseBooleanConverter<Visibility>
{
+440
View File
@@ -0,0 +1,440 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using AsarSharp;
using WandEnhancer.Models;
using WandEnhancer.Utils;
using WandEnhancer.View.MainWindow;
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<string, ELogType> _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<string, ELogType> logger, PatchConfig config)
{
_weModConfig = weModConfig;
_logger = logger;
_config = config;
_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)
{
if (patch.Applied)
{
return js;
}
var matches = patch.Target.Matches(js);
if (matches.Count == 0)
{
return js;
}
var prefix = $"[ENHANCER] [{patchType} -> {patch.Name}]";
if(matches.Count > 1 && patch.SingleMatch)
{
throw new Exception(
$"{prefix} Patch failed. Multiple target functions found. Looks like the version is not supported");
}
if (patch.Resolver != null)
{
string resolvedField = patch.Resolver.Handler(matches[0].Value);
if (string.IsNullOrEmpty(resolvedField))
{
throw new Exception($"{prefix} Resolver failed to find field name");
}
patch.Patch = patch.Patch.Replace(patch.Resolver.Placeholder, resolvedField);
}
_logger($"{prefix} Found target function in: " + Path.GetFileName(fileName), ELogType.Info);
string newJs = patch.Target.Replace(js, patch.Patch);
File.WriteAllText(fileName, newJs);
_logger($"{prefix} Patch applied", ELogType.Success);
patch.Applied = true;
return newJs;
}
private void PatchAsar()
{
var items = Directory.EnumerateFiles(_unpackedPath)
.Where(file => !Directory.Exists(file) && Regex.IsMatch(Path.GetFileName(file), @"^app-\w+|index\.js"))
.ToList();
if (!items.Any())
{
throw new Exception("[ENHANCER] No app bundle found");
}
var remainingPatches = new HashSet<EPatchType>(_config.PatchTypes);
var enhancerConfig = EnhancerConfig.GetInstance();
foreach (var item in items)
{
if (remainingPatches.Count == 0)
{
break;
}
string data = File.ReadAllText(item);
foreach (var entry in remainingPatches.ToList())
{
var entries = enhancerConfig[entry];
foreach (var patchEntry in entries)
{
data = ApplyJsPatch(item, data, patchEntry, entry);
}
if (entries.All(x => x.Applied))
{
remainingPatches.Remove(entry);
}
}
}
if(remainingPatches.Count > 0)
{
var failedPatches = string.Join(", ", remainingPatches.Select(p => p.ToString()));
throw new Exception($"[ENHANCER] Failed to apply patches: {failedPatches}. The version may not be supported.");
}
}
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<string> 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();
var dll = assembly.GetManifestResourceStream(Constants.ProxyDllResouceName);
if (dll == null)
{
throw new Exception("[ENHANCER] Proxy DLL resource not found");
}
var destPath = Path.Combine(_weModConfig.RootDirectory, "version.dll");
using (var fileStream = File.Create(destPath))
{
dll.CopyTo(fileStream);
}
_logger("[ENHANCER] Proxy DLL attached", ELogType.Info);
}
public void Patch()
{
Common.TryKillProcess(_weModConfig.BrandName);
if (!File.Exists(_backupPath))
{
_logger("[ENHANCER] Creating backup...", ELogType.Info);
File.Copy(_asarPath, _backupPath);
}
else
{
_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))
{
throw new Exception("app.asar not found");
}
try
{
_logger("[ENHANCER] Extracting app.asar...", ELogType.Info);
AsarExtractor.ExtractAll(_asarPath, _unpackedPath);
}
catch (Exception e)
{
throw new Exception($"[ENHANCER] Failed to unpack app.asar: {e.Message}");
}
PatchAsar();
InjectRemotePanelFiles();
try
{
new AsarCreator(_unpackedPath, _asarPath, new CreateOptions
{
Unpack = new Regex(@"^static\\unpacked.*$")
}).CreatePackageWithOptions();
}
catch (Exception e)
{
throw new Exception($"[ENHANCER] Failed to pack app.asar: {e.Message}");
}
AttachProxyDll();
_logger("[ENHANCER] Done!", ELogType.Success);
}
}
}
+165
View File
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using WandEnhancer.Models;
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; }
public Func<string, string> Handler { get; set; }
}
public class PatchEntry
{
public Regex Target { get; set; }
public string Patch { get; set; }
public string Name { get; set; }
public bool Applied { get; set; }
public bool SingleMatch { get; set; } = true;
public ResolveContext Resolver { get; set; }
}
public static Dictionary<EPatchType, PatchEntry[]> GetInstance()
{
return new Dictionary<EPatchType, PatchEntry[]>()
{
{
EPatchType.ActivatePro,
new[]
{
new PatchEntry
{
Resolver = new ResolveContext
{
Handler = (targetFunction) =>
{
var fetchMatch = Regex.Match(targetFunction, @"return\s+this\.#(\w+)\.fetch");
return fetchMatch.Success ? fetchMatch.Groups[1].Value : null;
},
Placeholder = "<service_name>"
},
Name = "getUserAccount",
Target = new Regex(@"getUserAccount\(\)\{.*?return\s+this\.#\w+\.fetch\(\{.*?\}\)\}",
RegexOptions.Singleline),
Patch =
"getUserAccount(){return this.#<service_name>.fetch({endpoint:\"/v3/account\",method:\"GET\",name:\"/v3/account\",collectMetrics:0}).then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}"
},
new PatchEntry
{
Resolver = new ResolveContext
{
Handler = (targetFunction) =>
{
var match = Regex.Match(targetFunction, @"return\s+this\.#(\w+)\.post");
return match.Success ? match.Groups[1].Value : null;
},
Placeholder = "<service_name>"
},
Name = "setAccountWandBrandExperience",
Target = new Regex(
@"setAccountWandBrandExperience\(\){.*?return\s+this\.#\w+\.post\(""/v3/account/brand_experience_wand""\)}",
RegexOptions.Singleline),
Patch =
"setAccountWandBrandExperience(){return this.#<service_name>.post(\"/v3/account/brand_experience_wand\").then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}"
}
}
},
{
EPatchType.DisableUpdates,
new[]
{
new PatchEntry
{
Target = new Regex(@"registerHandler\(""ACTION_CHECK_FOR_UPDATE"".*?\)\)\)\)",
RegexOptions.Singleline),
Patch = "registerHandler(\"ACTION_CHECK_FOR_UPDATE\",(e=>expectUpdateFeedUrl(e,(e=>null)))"
}
}
},
{
EPatchType.DevToolsOnF12,
new[]
{
new PatchEntry
{
Name = "devToolsBeforeInputEvent",
// Anchor on the Electron main-process `<app>.whenReady().then(`
// call. This site is far more stable than the minified renderer
// keydown listener that previously held the F12 -> ACTION_OPEN_DEV_TOOLS
// dispatch (its identifiers and shape change on every Wand release).
// We attach a `before-input-event` hook to every BrowserWindow's
// webContents which toggles DevTools on F12 directly from the main
// process, bypassing the renderer dispatcher entirely.
Target = new Regex(@"(?<app>\w+)\.whenReady\(\)\.then\("),
Patch = "${app}.on(\"browser-window-created\",((_,w)=>{try{w.webContents.on(\"before-input-event\",((_,i)=>{if(\"F12\"===i.key&&\"keyDown\"===i.type){w.webContents.isDevToolsOpened()?w.webContents.closeDevTools():w.webContents.openDevTools({mode:\"detach\"})}}))}catch(e){}})),${app}.whenReady().then("
}
}
},
{
EPatchType.RemoteWebPanelPreview,
new[]
{
new PatchEntry
{
Name = "remoteBridgeMainBoot",
Target = new Regex(@"(?<app>\w+)\.whenReady\(\)\.then\(run\)"),
Patch = "${app}.whenReady().then(()=>{try{const p=require(\"node:path\");require(p.join(__dirname,\"remote-panel\",\"bridge.cjs\")).installWandRuntime(require(\"electron\"));}catch(e){try{const fs=require(\"node:fs\"),os=require(\"node:os\"),p=require(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [boot-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}}return run()})"
},
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\.canvasElement&&(\w+)\.mo");
return match.Success ? match.Groups[1].Value : null;
},
Placeholder = "<qr_writer>"
},
Target = new Regex(@"this\.canvasElement&&\w+\.mo\(this\.canvasElement,`\$\{\w+\.A\.wemodWebsiteUrl\}/remote`,this\.options\)"),
Patch = "this.canvasElement&&<qr_writer>.mo(this.canvasElement,globalThis.__wandRemoteBridgeUrl||\"" + RemoteWebPanelFallbackUrl + "\",this.options)"
}
}
}
};
}
}
}
@@ -6,7 +6,7 @@ using System.Linq;
using System.Threading;
using System.Windows;
namespace WeModPatcher.Core.Services
namespace WandEnhancer.Core.Services
{
public static class LocalizationManager
{
@@ -2,7 +2,7 @@ using System;
using System.IO;
using Newtonsoft.Json;
namespace WeModPatcher.Core.Services
namespace WandEnhancer.Core.Services
{
public class AppSettings
{
@@ -7,15 +7,17 @@
<!--#endregion -->
<!--#region MainWindow -->
<s:String x:Key="mw_title">WeMod Patcher</s:String>
<s:String x:Key="mw_title">WandEnhancer</s:String>
<s:String x:Key="mw_update_available">Eine neue Version ist verfügbar</s:String>
<s:String x:Key="mw_folder_path">Ordnerpfad</s:String>
<s:String x:Key="mw_folder_not_found">Ordner nicht gefunden</s:String>
<s:String x:Key="mw_patch">Patch</s:String>
<s:String x:Key="mw_patch">Anwenden</s:String>
<s:String x:Key="mw_restore">Wiederherstellen</s:String>
<s:String x:Key="mw_source_code">Quellcode</s:String>
<s:String x:Key="mw_made_by">Mit ❤️ von k1tbyte erstellt</s:String>
<s:String x:Key="mw_star_hint">Gib einen Stern, wenn dir das geholfen hat ;)</s:String>
<s:String x:Key="mw_copy_logs">Logs in die Zwischenablage kopieren</s:String>
<s:String x:Key="mw_export_logs">Logs in Datei exportieren</s:String>
<!--#endregion -->
<!--#region Settings -->
@@ -24,16 +26,21 @@
<s:String x:Key="settings_save">Speichern</s:String>
<!--#endregion -->
<!--#region PatchVectorsPopup -->
<!--#region EnhanceVectorsPopup -->
<s:String x:Key="pv_activate_pro">WeMod Pro aktivieren</s:String>
<s:String x:Key="pv_devtools">DevTools mit F12</s:String>
<s:String x:Key="pv_disable_updates">Updates deaktivieren</s:String>
<s:String x:Key="pv_remote_web_panel_preview">Remote-Zugriff aktivieren (Beta)</s:String>
<s:String x:Key="pv_custom_scripts">Benutzerdefinierte Skripte</s:String>
<s:String x:Key="pv_add_js_scripts">.js hinzufügen</s:String>
<s:String x:Key="pv_custom_scripts_hint">Ausgewählte .js-Dateien werden in Wand gepackt und im Renderer geladen.</s:String>
<s:String x:Key="pv_no_custom_scripts">Keine Skripte ausgewählt</s:String>
<s:String x:Key="pv_start">Starten</s:String>
<s:String x:Key="pv_popup_title">Was werden wir patchen?</s:String>
<s:String x:Key="pv_popup_title">Was werden wir verbessern?</s:String>
<!--#endregion -->
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Vor dem Update wird dringend empfohlen, Patches rückgängig zu machen, falls sie angewendet wurden</s:String>
<s:String x:Key="up_warning">Vor dem Update wird dringend empfohlen, Änderungen rückgängig zu machen, falls sie angewendet wurden</s:String>
<s:String x:Key="up_update_now">Jetzt aktualisieren</s:String>
<s:String x:Key="up_popup_title">Update verfügbar!</s:String>
<!--#endregion -->
@@ -7,15 +7,17 @@
<!--#endregion -->
<!--#region MainWindow -->
<s:String x:Key="mw_title">WeMod Patcher</s:String>
<s:String x:Key="mw_title">WandEnhancer</s:String>
<s:String x:Key="mw_update_available">A new version is available</s:String>
<s:String x:Key="mw_folder_path">Folder path</s:String>
<s:String x:Key="mw_folder_not_found">Folder not found</s:String>
<s:String x:Key="mw_patch">Patch</s:String>
<s:String x:Key="mw_patch">Enhance</s:String>
<s:String x:Key="mw_restore">Restore</s:String>
<s:String x:Key="mw_source_code">Source code</s:String>
<s:String x:Key="mw_made_by">Made with ❤️ by k1tbyte</s:String>
<s:String x:Key="mw_star_hint">Put a star if you found this helpful ;)</s:String>
<s:String x:Key="mw_copy_logs">Copy logs to clipboard</s:String>
<s:String x:Key="mw_export_logs">Export logs to file</s:String>
<!--#endregion -->
<!--#region Settings -->
@@ -24,16 +26,21 @@
<s:String x:Key="settings_save">Save</s:String>
<!--#endregion -->
<!--#region PatchVectorsPopup -->
<!--#region EnhanceVectorsPopup -->
<s:String x:Key="pv_activate_pro">Activate WeMod Pro</s:String>
<s:String x:Key="pv_devtools">DevTools on F12</s:String>
<s:String x:Key="pv_disable_updates">Disable updates</s:String>
<s:String x:Key="pv_remote_web_panel_preview">Enable remote access (beta)</s:String>
<s:String x:Key="pv_custom_scripts">Custom scripts</s:String>
<s:String x:Key="pv_add_js_scripts">Add .js</s:String>
<s:String x:Key="pv_custom_scripts_hint">Selected .js files are packed into Wand and loaded in the renderer.</s:String>
<s:String x:Key="pv_no_custom_scripts">No scripts selected</s:String>
<s:String x:Key="pv_start">Start</s:String>
<s:String x:Key="pv_popup_title">What are we gonna patch?</s:String>
<s:String x:Key="pv_popup_title">What are we gonna enhance?</s:String>
<!--#endregion -->
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Before updating, it is strongly recommended to roll back patches if they have been applied</s:String>
<s:String x:Key="up_warning">Before updating, it is strongly recommended to roll back modifications if they have been applied</s:String>
<s:String x:Key="up_update_now">Update now</s:String>
<s:String x:Key="up_popup_title">Update available!</s:String>
<!--#endregion -->
@@ -7,15 +7,17 @@
<!--#endregion -->
<!--#region MainWindow -->
<s:String x:Key="mw_title">WeMod Patcher</s:String>
<s:String x:Key="mw_title">WandEnhancer</s:String>
<s:String x:Key="mw_update_available">Una nueva versión está disponible</s:String>
<s:String x:Key="mw_folder_path">Ruta de la carpeta</s:String>
<s:String x:Key="mw_folder_not_found">Carpeta no encontrada</s:String>
<s:String x:Key="mw_patch">Parchear</s:String>
<s:String x:Key="mw_patch">Aplicar</s:String>
<s:String x:Key="mw_restore">Restaurar</s:String>
<s:String x:Key="mw_source_code">Código fuente</s:String>
<s:String x:Key="mw_made_by">Hecho con ❤️ por k1tbyte</s:String>
<s:String x:Key="mw_star_hint">Pon una estrella si te fue útil ;)</s:String>
<s:String x:Key="mw_copy_logs">Copiar registros al portapapeles</s:String>
<s:String x:Key="mw_export_logs">Exportar registros a un archivo</s:String>
<!--#endregion -->
<!--#region Settings -->
@@ -24,16 +26,21 @@
<s:String x:Key="settings_save">Guardar</s:String>
<!--#endregion -->
<!--#region PatchVectorsPopup -->
<!--#region EnhanceVectorsPopup -->
<s:String x:Key="pv_activate_pro">Activar WeMod Pro</s:String>
<s:String x:Key="pv_devtools">DevTools en F12</s:String>
<s:String x:Key="pv_disable_updates">Desactivar actualizaciones</s:String>
<s:String x:Key="pv_remote_web_panel_preview">Habilitar acceso remoto (beta)</s:String>
<s:String x:Key="pv_custom_scripts">Scripts personalizados</s:String>
<s:String x:Key="pv_add_js_scripts">Agregar .js</s:String>
<s:String x:Key="pv_custom_scripts_hint">Los archivos .js seleccionados se empaquetan en Wand y se cargan en el renderer.</s:String>
<s:String x:Key="pv_no_custom_scripts">No hay scripts seleccionados</s:String>
<s:String x:Key="pv_start">Iniciar</s:String>
<s:String x:Key="pv_popup_title">¿Qué vamos a parchear?</s:String>
<s:String x:Key="pv_popup_title">¿Qué vamos a mejorar?</s:String>
<!--#endregion -->
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Antes de actualizar, se recomienda encarecidamente revertir los parches si se han aplicado</s:String>
<s:String x:Key="up_warning">Antes de actualizar, se recomienda encarecidamente revertir las modificaciones si se han aplicado</s:String>
<s:String x:Key="up_update_now">Actualizar ahora</s:String>
<s:String x:Key="up_popup_title">¡Actualización disponible!</s:String>
<!--#endregion -->
@@ -7,15 +7,17 @@
<!--#endregion -->
<!--#region MainWindow -->
<s:String x:Key="mw_title">WeMod Patcher</s:String>
<s:String x:Key="mw_title">WandEnhancer</s:String>
<s:String x:Key="mw_update_available">Une nouvelle version est disponible</s:String>
<s:String x:Key="mw_folder_path">Chemin du dossier</s:String>
<s:String x:Key="mw_folder_not_found">Dossier non trouvé</s:String>
<s:String x:Key="mw_patch">Patcher</s:String>
<s:String x:Key="mw_patch">Appliquer</s:String>
<s:String x:Key="mw_restore">Restaurer</s:String>
<s:String x:Key="mw_source_code">Code source</s:String>
<s:String x:Key="mw_made_by">Fait avec ❤️ par k1tbyte</s:String>
<s:String x:Key="mw_star_hint">Mettez une étoile si cela vous a aidé ;)</s:String>
<s:String x:Key="mw_copy_logs">Copier les logs dans le presse-papiers</s:String>
<s:String x:Key="mw_export_logs">Exporter les logs dans un fichier</s:String>
<!--#endregion -->
<!--#region Settings -->
@@ -24,16 +26,21 @@
<s:String x:Key="settings_save">Enregistrer</s:String>
<!--#endregion -->
<!--#region PatchVectorsPopup -->
<!--#region EnhanceVectorsPopup -->
<s:String x:Key="pv_activate_pro">Activer WeMod Pro</s:String>
<s:String x:Key="pv_devtools">DevTools sur F12</s:String>
<s:String x:Key="pv_disable_updates">Désactiver les mises à jour</s:String>
<s:String x:Key="pv_remote_web_panel_preview">Activer l'accès à distance (bêta)</s:String>
<s:String x:Key="pv_custom_scripts">Scripts personnalisés</s:String>
<s:String x:Key="pv_add_js_scripts">Ajouter .js</s:String>
<s:String x:Key="pv_custom_scripts_hint">Les fichiers .js sélectionnés sont intégrés dans Wand et chargés dans le renderer.</s:String>
<s:String x:Key="pv_no_custom_scripts">Aucun script sélectionné</s:String>
<s:String x:Key="pv_start">Démarrer</s:String>
<s:String x:Key="pv_popup_title">Qu'allons-nous patcher ?</s:String>
<s:String x:Key="pv_popup_title">Qu'allons-nous modifier ?</s:String>
<!--#endregion -->
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Avant la mise à jour, il est fortement recommandé d'annuler les patchs s'ils ont été appliqués</s:String>
<s:String x:Key="up_warning">Avant la mise à jour, il est fortement recommandé d'annuler les modifications si elles ont été appliquées</s:String>
<s:String x:Key="up_update_now">Mettre à jour maintenant</s:String>
<s:String x:Key="up_popup_title">Mise à jour disponible !</s:String>
<!--#endregion -->
@@ -7,15 +7,17 @@
<!--#endregion -->
<!--#region MainWindow -->
<s:String x:Key="mw_title">WeMod Patcher</s:String>
<s:String x:Key="mw_title">WandEnhancer</s:String>
<s:String x:Key="mw_update_available">È disponibile una nuova versione</s:String>
<s:String x:Key="mw_folder_path">Percorso cartella</s:String>
<s:String x:Key="mw_folder_not_found">Cartella non trovata</s:String>
<s:String x:Key="mw_patch">Patch</s:String>
<s:String x:Key="mw_patch">Applica</s:String>
<s:String x:Key="mw_restore">Ripristina</s:String>
<s:String x:Key="mw_source_code">Codice sorgente</s:String>
<s:String x:Key="mw_made_by">Creato con ❤️ da k1tbyte</s:String>
<s:String x:Key="mw_star_hint">Metti una stella se ti è stato utile ;)</s:String>
<s:String x:Key="mw_copy_logs">Copia i log negli appunti</s:String>
<s:String x:Key="mw_export_logs">Esporta i log su file</s:String>
<!--#endregion -->
<!--#region Settings -->
@@ -24,16 +26,21 @@
<s:String x:Key="settings_save">Salva</s:String>
<!--#endregion -->
<!--#region PatchVectorsPopup -->
<!--#region EnhanceVectorsPopup -->
<s:String x:Key="pv_activate_pro">Attiva WeMod Pro</s:String>
<s:String x:Key="pv_devtools">DevTools su F12</s:String>
<s:String x:Key="pv_disable_updates">Disattiva aggiornamenti</s:String>
<s:String x:Key="pv_remote_web_panel_preview">Abilita accesso remoto (beta)</s:String>
<s:String x:Key="pv_custom_scripts">Script personalizzati</s:String>
<s:String x:Key="pv_add_js_scripts">Aggiungi .js</s:String>
<s:String x:Key="pv_custom_scripts_hint">I file .js selezionati vengono inseriti in Wand e caricati nel renderer.</s:String>
<s:String x:Key="pv_no_custom_scripts">Nessuno script selezionato</s:String>
<s:String x:Key="pv_start">Avvia</s:String>
<s:String x:Key="pv_popup_title">Cosa patcheremo?</s:String>
<s:String x:Key="pv_popup_title">Cosa modificheremo?</s:String>
<!--#endregion -->
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Prima dell'aggiornamento, si consiglia vivamente di annullare le patch se sono state applicate</s:String>
<s:String x:Key="up_warning">Prima dell'aggiornamento, si consiglia vivamente di annullare le modifiche se sono state applicate</s:String>
<s:String x:Key="up_update_now">Aggiorna ora</s:String>
<s:String x:Key="up_popup_title">Aggiornamento disponibile!</s:String>
<!--#endregion -->
@@ -7,15 +7,17 @@
<!--#endregion -->
<!--#region MainWindow -->
<s:String x:Key="mw_title">WeMod Patcher</s:String>
<s:String x:Key="mw_title">WandEnhancer</s:String>
<s:String x:Key="mw_update_available">新しいバージョンが利用可能です</s:String>
<s:String x:Key="mw_folder_path">フォルダパス</s:String>
<s:String x:Key="mw_folder_not_found">フォルダが見つかりません</s:String>
<s:String x:Key="mw_patch">パッチ</s:String>
<s:String x:Key="mw_patch">適用</s:String>
<s:String x:Key="mw_restore">復元</s:String>
<s:String x:Key="mw_source_code">ソースコード</s:String>
<s:String x:Key="mw_made_by">k1tbyte が ❤️ を込めて作成</s:String>
<s:String x:Key="mw_star_hint">役に立ったらスターをつけてください ;)</s:String>
<s:String x:Key="mw_copy_logs">ログをクリップボードにコピー</s:String>
<s:String x:Key="mw_export_logs">ログをファイルにエクスポート</s:String>
<!--#endregion -->
<!--#region Settings -->
@@ -24,16 +26,21 @@
<s:String x:Key="settings_save">保存</s:String>
<!--#endregion -->
<!--#region PatchVectorsPopup -->
<!--#region EnhanceVectorsPopup -->
<s:String x:Key="pv_activate_pro">WeMod Pro を有効化</s:String>
<s:String x:Key="pv_devtools">F12でDevTools</s:String>
<s:String x:Key="pv_disable_updates">アップデートを無効化</s:String>
<s:String x:Key="pv_remote_web_panel_preview">リモートアクセスを有効化(ベータ)</s:String>
<s:String x:Key="pv_custom_scripts">カスタムスクリプト</s:String>
<s:String x:Key="pv_add_js_scripts">.js を追加</s:String>
<s:String x:Key="pv_custom_scripts_hint">選択した .js ファイルは Wand に組み込まれ、レンダラーで読み込まれます。</s:String>
<s:String x:Key="pv_no_custom_scripts">スクリプトが選択されていません</s:String>
<s:String x:Key="pv_start">開始</s:String>
<s:String x:Key="pv_popup_title">何をパッチしますか?</s:String>
<s:String x:Key="pv_popup_title">何を改善しますか?</s:String>
<!--#endregion -->
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">アップデート前に、パッチが適用されている場合はロールバックすることを強くお勧めします</s:String>
<s:String x:Key="up_warning">アップデート前に、変更が適用されている場合はロールバックすることを強くお勧めします</s:String>
<s:String x:Key="up_update_now">今すぐ更新</s:String>
<s:String x:Key="up_popup_title">アップデート利用可能!</s:String>
<!--#endregion -->
@@ -7,15 +7,17 @@
<!--#endregion -->
<!--#region MainWindow -->
<s:String x:Key="mw_title">WeMod Patcher</s:String>
<s:String x:Key="mw_title">WandEnhancer</s:String>
<s:String x:Key="mw_update_available">Dostępna jest nowa wersja</s:String>
<s:String x:Key="mw_folder_path">Ścieżka folderu</s:String>
<s:String x:Key="mw_folder_not_found">Folder nie znaleziony</s:String>
<s:String x:Key="mw_patch">Patchuj</s:String>
<s:String x:Key="mw_patch">Zastosuj</s:String>
<s:String x:Key="mw_restore">Przywróć</s:String>
<s:String x:Key="mw_source_code">Kod źródłowy</s:String>
<s:String x:Key="mw_made_by">Wykonane z ❤️ przez k1tbyte</s:String>
<s:String x:Key="mw_star_hint">Daj gwiazdkę, jeśli ci pomogło ;)</s:String>
<s:String x:Key="mw_copy_logs">Skopiuj logi do schowka</s:String>
<s:String x:Key="mw_export_logs">Eksportuj logi do pliku</s:String>
<!--#endregion -->
<!--#region Settings -->
@@ -24,16 +26,21 @@
<s:String x:Key="settings_save">Zapisz</s:String>
<!--#endregion -->
<!--#region PatchVectorsPopup -->
<!--#region EnhanceVectorsPopup -->
<s:String x:Key="pv_activate_pro">Aktywuj WeMod Pro</s:String>
<s:String x:Key="pv_devtools">DevTools na F12</s:String>
<s:String x:Key="pv_disable_updates">Wyłącz aktualizacje</s:String>
<s:String x:Key="pv_remote_web_panel_preview">Włącz zdalny dostęp (beta)</s:String>
<s:String x:Key="pv_custom_scripts">Skrypty niestandardowe</s:String>
<s:String x:Key="pv_add_js_scripts">Dodaj .js</s:String>
<s:String x:Key="pv_custom_scripts_hint">Wybrane pliki .js są pakowane do Wand i ładowane w rendererze.</s:String>
<s:String x:Key="pv_no_custom_scripts">Nie wybrano skryptów</s:String>
<s:String x:Key="pv_start">Rozpocznij</s:String>
<s:String x:Key="pv_popup_title">Co będziemy patchować?</s:String>
<s:String x:Key="pv_popup_title">Co będziemy ulepszać?</s:String>
<!--#endregion -->
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Przed aktualizacją zdecydowanie zaleca się cofnięcie patchów, jeśli zostały zastosowane</s:String>
<s:String x:Key="up_warning">Przed aktualizacją zdecydowanie zaleca się cofnięcie zmian, jeśli zostały zastosowane</s:String>
<s:String x:Key="up_update_now">Aktualizuj teraz</s:String>
<s:String x:Key="up_popup_title">Dostępna aktualizacja!</s:String>
<!--#endregion -->
@@ -7,15 +7,17 @@
<!--#endregion -->
<!--#region MainWindow -->
<s:String x:Key="mw_title">WeMod Patcher</s:String>
<s:String x:Key="mw_title">WandEnhancer</s:String>
<s:String x:Key="mw_update_available">Uma nova versão está disponível</s:String>
<s:String x:Key="mw_folder_path">Caminho da pasta</s:String>
<s:String x:Key="mw_folder_not_found">Pasta não encontrada</s:String>
<s:String x:Key="mw_patch">Patch</s:String>
<s:String x:Key="mw_patch">Aplicar</s:String>
<s:String x:Key="mw_restore">Restaurar</s:String>
<s:String x:Key="mw_source_code">Código fonte</s:String>
<s:String x:Key="mw_made_by">Feito com ❤️ por k1tbyte</s:String>
<s:String x:Key="mw_star_hint">Dê uma estrela se isso te ajudou ;)</s:String>
<s:String x:Key="mw_copy_logs">Copiar logs para a área de transferência</s:String>
<s:String x:Key="mw_export_logs">Exportar logs para arquivo</s:String>
<!--#endregion -->
<!--#region Settings -->
@@ -24,16 +26,21 @@
<s:String x:Key="settings_save">Salvar</s:String>
<!--#endregion -->
<!--#region PatchVectorsPopup -->
<!--#region EnhanceVectorsPopup -->
<s:String x:Key="pv_activate_pro">Ativar WeMod Pro</s:String>
<s:String x:Key="pv_devtools">DevTools no F12</s:String>
<s:String x:Key="pv_disable_updates">Desativar atualizações</s:String>
<s:String x:Key="pv_remote_web_panel_preview">Ativar acesso remoto (beta)</s:String>
<s:String x:Key="pv_custom_scripts">Scripts personalizados</s:String>
<s:String x:Key="pv_add_js_scripts">Adicionar .js</s:String>
<s:String x:Key="pv_custom_scripts_hint">Os arquivos .js selecionados são empacotados no Wand e carregados no renderer.</s:String>
<s:String x:Key="pv_no_custom_scripts">Nenhum script selecionado</s:String>
<s:String x:Key="pv_start">Iniciar</s:String>
<s:String x:Key="pv_popup_title">O que vamos patchear?</s:String>
<s:String x:Key="pv_popup_title">O que vamos melhorar?</s:String>
<!--#endregion -->
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Antes de atualizar, é altamente recomendável reverter os patches se eles foram aplicados</s:String>
<s:String x:Key="up_warning">Antes de atualizar, é altamente recomendável reverter as modificações se elas foram aplicadas</s:String>
<s:String x:Key="up_update_now">Atualizar agora</s:String>
<s:String x:Key="up_popup_title">Atualização disponível!</s:String>
<!--#endregion -->
@@ -7,15 +7,17 @@
<!--#endregion -->
<!--#region MainWindow -->
<s:String x:Key="mw_title">WeMod Patcher</s:String>
<s:String x:Key="mw_title">WandEnhancer</s:String>
<s:String x:Key="mw_update_available">Доступна новая версия</s:String>
<s:String x:Key="mw_folder_path">Путь к папке</s:String>
<s:String x:Key="mw_folder_not_found">Папка не найдена</s:String>
<s:String x:Key="mw_patch">Патч</s:String>
<s:String x:Key="mw_patch">Применить</s:String>
<s:String x:Key="mw_restore">Восстановить</s:String>
<s:String x:Key="mw_source_code">Исходный код</s:String>
<s:String x:Key="mw_made_by">Сделано с ❤️ by k1tbyte</s:String>
<s:String x:Key="mw_star_hint">Поставьте звезду, если это было полезно ;)</s:String>
<s:String x:Key="mw_copy_logs">Скопировать логи в буфер обмена</s:String>
<s:String x:Key="mw_export_logs">Экспортировать логи в файл</s:String>
<!--#endregion -->
<!--#region Settings -->
@@ -24,16 +26,21 @@
<s:String x:Key="settings_save">Сохранить</s:String>
<!--#endregion -->
<!--#region PatchVectorsPopup -->
<!--#region EnhanceVectorsPopup -->
<s:String x:Key="pv_activate_pro">Активировать WeMod Pro</s:String>
<s:String x:Key="pv_devtools">DevTools на F12</s:String>
<s:String x:Key="pv_disable_updates">Отключить обновления</s:String>
<s:String x:Key="pv_remote_web_panel_preview">Remote-доступ (beta)</s:String>
<s:String x:Key="pv_custom_scripts">Свои скрипты</s:String>
<s:String x:Key="pv_add_js_scripts">Добавить .js</s:String>
<s:String x:Key="pv_custom_scripts_hint">Выбранные .js попадут в Wand и загрузятся в renderer.</s:String>
<s:String x:Key="pv_no_custom_scripts">Скрипты не выбраны</s:String>
<s:String x:Key="pv_start">Начать</s:String>
<s:String x:Key="pv_popup_title">Что будем патчить?</s:String>
<s:String x:Key="pv_popup_title">Что будем улучшать?</s:String>
<!--#endregion -->
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Перед обновлением настоятельно рекомендуется откатить патчи, если они были применены</s:String>
<s:String x:Key="up_warning">Перед обновлением настоятельно рекомендуется откатить изменения, если они были применены</s:String>
<s:String x:Key="up_update_now">Обновить сейчас</s:String>
<s:String x:Key="up_popup_title">Доступно обновление!</s:String>
<!--#endregion -->
@@ -7,15 +7,17 @@
<!--#endregion -->
<!--#region MainWindow -->
<s:String x:Key="mw_title">WeMod Patcher</s:String>
<s:String x:Key="mw_title">WandEnhancer</s:String>
<s:String x:Key="mw_update_available">Yeni bir sürüm mevcut</s:String>
<s:String x:Key="mw_folder_path">Klasör yolu</s:String>
<s:String x:Key="mw_folder_not_found">Klasör bulunamadı</s:String>
<s:String x:Key="mw_patch">Yama</s:String>
<s:String x:Key="mw_patch">Uygula</s:String>
<s:String x:Key="mw_restore">Geri Yükle</s:String>
<s:String x:Key="mw_source_code">Kaynak kodu</s:String>
<s:String x:Key="mw_made_by">k1tbyte tarafından ❤️ ile yapıldı</s:String>
<s:String x:Key="mw_star_hint">Yardımcı olduysa yıldız verin ;)</s:String>
<s:String x:Key="mw_copy_logs">Günlükleri panoya kopyala</s:String>
<s:String x:Key="mw_export_logs">Günlükleri dosyaya aktar</s:String>
<!--#endregion -->
<!--#region Settings -->
@@ -24,16 +26,21 @@
<s:String x:Key="settings_save">Kaydet</s:String>
<!--#endregion -->
<!--#region PatchVectorsPopup -->
<!--#region EnhanceVectorsPopup -->
<s:String x:Key="pv_activate_pro">WeMod Pro'yu Etkinleştir</s:String>
<s:String x:Key="pv_devtools">F12 ile DevTools</s:String>
<s:String x:Key="pv_disable_updates">Güncellemeleri devre dışı bırak</s:String>
<s:String x:Key="pv_remote_web_panel_preview">Uzaktan erişimi etkinleştir (beta)</s:String>
<s:String x:Key="pv_custom_scripts">Özel betikler</s:String>
<s:String x:Key="pv_add_js_scripts">.js ekle</s:String>
<s:String x:Key="pv_custom_scripts_hint">Seçilen .js dosyaları Wand içine paketlenir ve renderer'da yüklenir.</s:String>
<s:String x:Key="pv_no_custom_scripts">Betik seçilmedi</s:String>
<s:String x:Key="pv_start">Başlat</s:String>
<s:String x:Key="pv_popup_title">Ne yamalayacağız?</s:String>
<s:String x:Key="pv_popup_title">Neyi geliştireceğiz?</s:String>
<!--#endregion -->
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Güncellemeden önce, yamalar uygulandıysa geri almak şiddetle tavsiye edilir</s:String>
<s:String x:Key="up_warning">Güncellemeden önce, değişiklikler uygulandıysa geri almak şiddetle tavsiye edilir</s:String>
<s:String x:Key="up_update_now">Şimdi güncelle</s:String>
<s:String x:Key="up_popup_title">Güncelleme mevcut!</s:String>
<!--#endregion -->
@@ -7,15 +7,17 @@
<!--#endregion -->
<!--#region MainWindow -->
<s:String x:Key="mw_title">WeMod Patcher</s:String>
<s:String x:Key="mw_title">WandEnhancer</s:String>
<s:String x:Key="mw_update_available">Доступна нова версія</s:String>
<s:String x:Key="mw_folder_path">Шлях до папки</s:String>
<s:String x:Key="mw_folder_not_found">Папку не знайдено</s:String>
<s:String x:Key="mw_patch">Патч</s:String>
<s:String x:Key="mw_patch">Застосувати</s:String>
<s:String x:Key="mw_restore">Відновити</s:String>
<s:String x:Key="mw_source_code">Вихідний код</s:String>
<s:String x:Key="mw_made_by">Зроблено з ❤️ by k1tbyte</s:String>
<s:String x:Key="mw_star_hint">Поставте зірку, якщо це було корисно ;)</s:String>
<s:String x:Key="mw_copy_logs">Скопіювати логи до буфера обміну</s:String>
<s:String x:Key="mw_export_logs">Експортувати логи у файл</s:String>
<!--#endregion -->
<!--#region Settings -->
@@ -24,16 +26,21 @@
<s:String x:Key="settings_save">Зберегти</s:String>
<!--#endregion -->
<!--#region PatchVectorsPopup -->
<!--#region EnhanceVectorsPopup -->
<s:String x:Key="pv_activate_pro">Активувати WeMod Pro</s:String>
<s:String x:Key="pv_devtools">DevTools на F12</s:String>
<s:String x:Key="pv_disable_updates">Вимкнути оновлення</s:String>
<s:String x:Key="pv_remote_web_panel_preview">Увімкнути віддалений доступ (бета)</s:String>
<s:String x:Key="pv_custom_scripts">Користувацькі скрипти</s:String>
<s:String x:Key="pv_add_js_scripts">Додати .js</s:String>
<s:String x:Key="pv_custom_scripts_hint">Вибрані файли .js пакуються у Wand і завантажуються в рендерері.</s:String>
<s:String x:Key="pv_no_custom_scripts">Скрипти не вибрано</s:String>
<s:String x:Key="pv_start">Почати</s:String>
<s:String x:Key="pv_popup_title">Що будемо патчити?</s:String>
<s:String x:Key="pv_popup_title">Що будемо покращувати?</s:String>
<!--#endregion -->
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Перед оновленням наполегливо рекомендується відкотити патчі, якщо вони були застосовані</s:String>
<s:String x:Key="up_warning">Перед оновленням наполегливо рекомендується відкотити зміни, якщо вони були застосовані</s:String>
<s:String x:Key="up_update_now">Оновити зараз</s:String>
<s:String x:Key="up_popup_title">Доступне оновлення!</s:String>
<!--#endregion -->
@@ -7,15 +7,17 @@
<!--#endregion -->
<!--#region MainWindow -->
<s:String x:Key="mw_title">WeMod Patcher</s:String>
<s:String x:Key="mw_title">WandEnhancer</s:String>
<s:String x:Key="mw_update_available">有新版本可用</s:String>
<s:String x:Key="mw_folder_path">文件夹路径</s:String>
<s:String x:Key="mw_folder_not_found">未找到文件夹</s:String>
<s:String x:Key="mw_patch">补丁</s:String>
<s:String x:Key="mw_patch">增强</s:String>
<s:String x:Key="mw_restore">恢复</s:String>
<s:String x:Key="mw_source_code">源代码</s:String>
<s:String x:Key="mw_made_by">由 k1tbyte 用 ❤️ 制作</s:String>
<s:String x:Key="mw_star_hint">如果这对您有帮助,请给个星标 ;)</s:String>
<s:String x:Key="mw_copy_logs">复制日志到剪贴板</s:String>
<s:String x:Key="mw_export_logs">将日志导出到文件</s:String>
<!--#endregion -->
<!--#region Settings -->
@@ -24,16 +26,21 @@
<s:String x:Key="settings_save">保存</s:String>
<!--#endregion -->
<!--#region PatchVectorsPopup -->
<!--#region EnhanceVectorsPopup -->
<s:String x:Key="pv_activate_pro">激活 WeMod Pro</s:String>
<s:String x:Key="pv_devtools">按 F12 打开开发者工具</s:String>
<s:String x:Key="pv_disable_updates">禁用更新</s:String>
<s:String x:Key="pv_remote_web_panel_preview">启用远程访问(beta</s:String>
<s:String x:Key="pv_custom_scripts">自定义脚本</s:String>
<s:String x:Key="pv_add_js_scripts">添加 .js</s:String>
<s:String x:Key="pv_custom_scripts_hint">选中的 .js 文件会打包到 Wand 并在渲染器中加载。</s:String>
<s:String x:Key="pv_no_custom_scripts">未选择脚本</s:String>
<s:String x:Key="pv_start">开始</s:String>
<s:String x:Key="pv_popup_title">我们要打什么补丁</s:String>
<s:String x:Key="pv_popup_title">我们要增强什么</s:String>
<!--#endregion -->
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">在更新之前,强烈建议回滚已应用的补丁</s:String>
<s:String x:Key="up_warning">在更新之前,强烈建议回滚已应用的修改</s:String>
<s:String x:Key="up_update_now">立即更新</s:String>
<s:String x:Key="up_popup_title">有更新可用!</s:String>
<!--#endregion -->
@@ -1,10 +1,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using WeModPatcher.Utils;
using WandEnhancer.Utils;
namespace WeModPatcher.Models
namespace WandEnhancer.Models
{
public enum EPatchType
@@ -12,13 +11,16 @@ namespace WeModPatcher.Models
ActivatePro = 1,
DisableUpdates = 2,
DisableTelemetry = 4,
DevToolsOnF12 = 8
DevToolsOnF12 = 8,
RemoteWebPanelPreview = 16
}
public sealed class PatchConfig
{
private string _path;
public HashSet<EPatchType> PatchTypes { get; set; }
public List<string> CustomScriptPaths { get; set; } = new List<string>();
public bool AutoApplyPatches { get; set; }
@@ -1,6 +1,6 @@
using System;
namespace WeModPatcher.Models
namespace WandEnhancer.Models
{
public sealed class Signature
{
@@ -1,6 +1,6 @@
using Newtonsoft.Json;
namespace WeModPatcher.Models
namespace WandEnhancer.Models
{
public class WeModConfig
{
@@ -2,9 +2,9 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Windows.Forms;
using WeModPatcher.View.MainWindow;
using WandEnhancer.View.MainWindow;
namespace WeModPatcher
namespace WandEnhancer
{
public static class Program
{
@@ -7,11 +7,11 @@ using System.Windows;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("WeModPatcher")]
[assembly: AssemblyTitle("WandEnhancer")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("WeModPatcher")]
[assembly: AssemblyProduct("WandEnhancer")]
[assembly: AssemblyCopyright("Copyright © 2025")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
@@ -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")]
[assembly: AssemblyVersion("1.0.7.0")]
[assembly: AssemblyFileVersion("1.0.7.0")]
@@ -8,7 +8,7 @@
// </auto-generated>
//------------------------------------------------------------------------------
namespace WeModPatcher.Properties
namespace WandEnhancer.Properties
{
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
@@ -45,7 +45,7 @@ namespace WeModPatcher.Properties
if ((resourceMan == null))
{
global::System.Resources.ResourceManager temp =
new global::System.Resources.ResourceManager("WeModPatcher.Properties.Resources",
new global::System.Resources.ResourceManager("WandEnhancer.Properties.Resources",
typeof(Resources).Assembly);
resourceMan = temp;
}
@@ -3,7 +3,7 @@ using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
namespace WeModPatcher.ReactiveUICore
namespace WandEnhancer.ReactiveUICore
{
public sealed class AsyncRelayCommand : ICommand
{
@@ -1,7 +1,7 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace WeModPatcher.ReactiveUICore
namespace WandEnhancer.ReactiveUICore
{
public class ObservableObject : INotifyPropertyChanged
{
@@ -1,7 +1,7 @@
using System;
using System.Windows.Input;
namespace WeModPatcher.ReactiveUICore
namespace WandEnhancer.ReactiveUICore
{
public sealed class RelayCommand : ICommand
{
@@ -27,6 +27,14 @@
<Geometry x:Key="ArrowLeft">
M5.05 11.94l5-5v3.99H19l-.03 2.01H10.05v4Z
</Geometry>
<Geometry x:Key="CopyIcon">
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
</Geometry>
<Geometry x:Key="ExportIcon">
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
</Geometry>
<!--<Geometry x:Key="">
@@ -4,7 +4,7 @@ using System.IO;
using System.Reflection;
using System.Threading;
namespace WeModPatcher.Utils
namespace WandEnhancer.Utils
{
public static class Common
{
@@ -2,9 +2,9 @@
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using WeModPatcher.Models;
using WandEnhancer.Models;
namespace WeModPatcher.Utils
namespace WandEnhancer.Utils
{
public static class Extensions
{
@@ -8,7 +8,7 @@ using System.Net.Http;
using System.Windows;
using Newtonsoft.Json;
namespace WeModPatcher.Utils
namespace WandEnhancer.Utils
{
public class GitHubRelease
{
@@ -1,7 +1,7 @@
using System;
using System.Runtime.InteropServices;
namespace WeModPatcher.Utils.Win32
namespace WandEnhancer.Utils.Win32
{
public class Shortcut
{
@@ -1,9 +1,9 @@
<UserControl x:Class="WeModPatcher.View.Controls.InfoItem"
<UserControl x:Class="WandEnhancer.View.Controls.InfoItem"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WeModPatcher.View.Controls"
xmlns:local="clr-namespace:WandEnhancer.View.Controls"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="300">
<Grid>
@@ -2,7 +2,7 @@
using System.Windows.Controls;
using System.Windows.Media;
namespace WeModPatcher.View.Controls
namespace WandEnhancer.View.Controls
{
public partial class InfoItem : UserControl
{
@@ -1,9 +1,9 @@
<Grid x:Class="WeModPatcher.View.Controls.PopupHost"
<Grid x:Class="WandEnhancer.View.Controls.PopupHost"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WeModPatcher.View.Controls"
xmlns:local="clr-namespace:WandEnhancer.View.Controls"
mc:Ignorable="d"
Visibility="Collapsed">
<Border x:Name="Splash" Background="Black" CornerRadius="7"
@@ -5,7 +5,7 @@ using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media.Animation;
namespace WeModPatcher.View.Controls
namespace WandEnhancer.View.Controls
{
public partial class PopupHost : Grid
{
@@ -1,4 +1,4 @@
namespace WeModPatcher.View.MainWindow
namespace WandEnhancer.View.MainWindow
{
public enum ELogType
{
@@ -1,13 +1,13 @@
<Window x:Class="WeModPatcher.View.MainWindow.MainWindow"
<Window x:Class="WandEnhancer.View.MainWindow.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WeModPatcher.View.MainWindow"
xmlns:controls="clr-namespace:WeModPatcher.View.Controls"
xmlns:local="clr-namespace:WandEnhancer.View.MainWindow"
xmlns:controls="clr-namespace:WandEnhancer.View.Controls"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance local:MainWindowVm}"
Title="WeMod Patcher"
Title="WandEnhancer"
Height="510" MaxHeight="510"
Width="780" MaxWidth="780"
Opacity="0.97"
@@ -36,7 +36,7 @@
VerticalAlignment="Center"
FontSize="18" Margin="10 0 0 0">
<Bold>
WeMod Patcher
WandEnhancer
</Bold>
</TextBlock>
<TextBlock x:Name="VersionLabel" VerticalAlignment="Bottom"
@@ -109,7 +109,8 @@
<Border Grid.Row="1" BorderBrush="{DynamicResource Border}" BorderThickness="1"
Margin="10 0 10 10"
CornerRadius="5">
<ListBox ItemsSource="{Binding LogList}" SelectionMode="Single"
<Grid>
<ListBox ItemsSource="{Binding LogList}" SelectionMode="Single"
BorderBrush="Transparent" BorderThickness="0"
Background="Transparent"
x:Name="LogList"
@@ -152,6 +153,20 @@
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Top"
Margin="0 4 4 0">
<Button Width="22" Height="22" Padding="4"
Style="{StaticResource IconButton}"
Tag="{StaticResource CopyIcon}"
ToolTip="{DynamicResource mw_copy_logs}"
Command="{Binding CopyLogsCommand}"/>
<Button Width="22" Height="22" Padding="4" Margin="4 0 0 0"
Style="{StaticResource IconButton}"
Tag="{StaticResource ExportIcon}"
ToolTip="{DynamicResource mw_export_logs}"
Command="{Binding ExportLogsCommand}"/>
</StackPanel>
</Grid>
</Border>
@@ -3,7 +3,7 @@ using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
namespace WeModPatcher.View.MainWindow
namespace WandEnhancer.View.MainWindow
{
/// <summary>
/// Interaction logic for MainWindow.xaml
@@ -2,16 +2,17 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using WeModPatcher.Core;
using WeModPatcher.Models;
using WeModPatcher.ReactiveUICore;
using WeModPatcher.Utils;
using WeModPatcher.View.Popups;
using WandEnhancer.Core;
using WandEnhancer.Models;
using WandEnhancer.ReactiveUICore;
using WandEnhancer.Utils;
using WandEnhancer.View.Popups;
using Application = System.Windows.Application;
namespace WeModPatcher.View.MainWindow
namespace WandEnhancer.View.MainWindow
{
public class MainWindowVm : ObservableObject
{
@@ -73,6 +74,8 @@ namespace WeModPatcher.View.MainWindow
public RelayCommand RestoreBackupCommand { get; }
public RelayCommand UpdateCommand { get; }
public RelayCommand OpenSettingsCommand { get; }
public RelayCommand CopyLogsCommand { get; }
public RelayCommand ExportLogsCommand { get; }
private void OnFolderPathSelection(object obj)
{
@@ -154,7 +157,7 @@ namespace WeModPatcher.View.MainWindow
{
try
{
new Patcher(WeModInfo, Log, config).Patch();
new Enhancer(WeModInfo, Log, config).Patch();
AlreadyPatched = true;
}
catch (Exception e)
@@ -198,7 +201,7 @@ namespace WeModPatcher.View.MainWindow
return;
}
Log("WeModPatcher updated successfully. Restarting...", ELogType.Success);
Log("WandEnhancer updated successfully. Restarting...", ELogType.Success);
});
}), Application.Current.FindResource("up_popup_title") as string);
}
@@ -208,6 +211,64 @@ namespace WeModPatcher.View.MainWindow
MainWindow.Instance.OpenPopup(new SettingsPopup(), Application.Current.FindResource("settings_title") as string);
}
private string BuildLogReport()
{
var builder = new StringBuilder();
foreach (var entry in LogList)
{
builder.AppendLine(entry.Message);
}
return builder.ToString();
}
private void OnCopyLogs(object param)
{
if (LogList.Count == 0)
{
return;
}
try
{
System.Windows.Clipboard.SetText(BuildLogReport());
Log("Logs copied to clipboard.", ELogType.Success);
}
catch (Exception e)
{
Log($"Failed to copy logs: {e.Message}", ELogType.Error);
}
}
private void OnExportLogs(object param)
{
if (LogList.Count == 0)
{
return;
}
using (var dialog = new SaveFileDialog
{
Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*",
FileName = $"wand-enhancer-log-{DateTime.Now:yyyyMMdd-HHmmss}.txt"
})
{
if (dialog.ShowDialog() != DialogResult.OK)
{
return;
}
try
{
File.WriteAllText(dialog.FileName, BuildLogReport());
Log($"Logs exported to '{dialog.FileName}'.", ELogType.Success);
}
catch (Exception e)
{
Log($"Failed to export logs: {e.Message}", ELogType.Error);
}
}
}
public MainWindowVm(MainWindow view)
{
Task.Run(async () => IsUpdateAvailable = await _updater.CheckForUpdates());
@@ -217,6 +278,8 @@ namespace WeModPatcher.View.MainWindow
RestoreBackupCommand = new RelayCommand(OnBackupRestoring);
UpdateCommand = new RelayCommand(OnUpdate);
OpenSettingsCommand = new RelayCommand(OnOpenSettings);
CopyLogsCommand = new RelayCommand(OnCopyLogs);
ExportLogsCommand = new RelayCommand(OnExportLogs);
WeModInfo = Extensions.FindWeMod();
if (WeModInfo == null)
@@ -0,0 +1,99 @@
<UserControl x:Class="WandEnhancer.View.Popups.PatchVectorsPopup"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WandEnhancer.View.Popups"
xmlns:controls="clr-namespace:WandEnhancer.View.Controls"
mc:Ignorable="d"
d:DesignHeight="Auto" d:DesignWidth="Auto"
Background="{DynamicResource Background}"
Foreground="{DynamicResource MutedForeground}"
FontWeight="Medium"
FontSize="13">
<Grid MinWidth="430">
<Grid Visibility="Visible" Margin="0 0 5 0">
<Grid.RowDefinitions>
<RowDefinition Height="27" />
<RowDefinition Height="27" />
<RowDefinition Height="27" />
<RowDefinition Height="27" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Text="{DynamicResource pv_activate_pro}" />
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="ActivateProBox" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="True" />
<TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Text="{DynamicResource pv_devtools}" />
<CheckBox Grid.Row="1" Grid.Column="1" x:Name="DevToolsHotkeyBox" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Text="{DynamicResource pv_disable_updates}" />
<CheckBox Grid.Row="2" Grid.Column="1" x:Name="DisableUpdateBox" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Text="{DynamicResource pv_remote_web_panel_preview}" />
<CheckBox Grid.Row="3" Grid.Column="1" x:Name="RemoteWebPanelPreviewBox" HorizontalAlignment="Right" VerticalAlignment="Center" />
<Border Grid.Row="4" Grid.ColumnSpan="2" Margin="0 14 0 0" Padding="10"
BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="4"
Background="{DynamicResource Muted}">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" VerticalAlignment="Center"
Foreground="{DynamicResource Foreground}"
Text="{DynamicResource pv_custom_scripts}" />
<Button Grid.Column="1" Padding="10 4" Content="{DynamicResource pv_add_js_scripts}"
Click="OnAddScriptClick" />
</Grid>
<TextBlock Margin="0 7 0 0" FontSize="11" TextWrapping="Wrap"
Opacity="0.8"
Text="{DynamicResource pv_custom_scripts_hint}" />
<ItemsControl x:Name="ScriptList" Margin="0 2 0 0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="0 6 6 0" Padding="8 3"
Background="{DynamicResource Card}"
BorderBrush="{DynamicResource Border}"
BorderThickness="1"
CornerRadius="3">
<DockPanel LastChildFill="True">
<Button DockPanel.Dock="Right" Tag="{Binding}" Content="x"
BorderThickness="0" Padding="6 0" Margin="6 0 0 0"
Click="OnRemoveScriptClick" />
<TextBlock VerticalAlignment="Center"
Foreground="{DynamicResource Foreground}"
Text="{Binding FileName}" />
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock x:Name="NoScriptsText" Margin="0 7 0 0" FontSize="11"
Opacity="0.7"
Text="{DynamicResource pv_no_custom_scripts}" />
</StackPanel>
</Border>
<Button Grid.Row="5" Grid.ColumnSpan="2" Padding="0 5 0 5" Margin="0 15 0 0" Content="{DynamicResource pv_start}"
Click="OnPatchButtonClick" />
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,140 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using Microsoft.Win32;
using WandEnhancer.Models;
namespace WandEnhancer.View.Popups
{
public partial class PatchVectorsPopup : UserControl
{
private const string JavaScriptDialogFilter = "JavaScript files (*.js)|*.js";
private const string JavaScriptFileExtension = ".js";
private readonly Action<PatchConfig> _onApply;
private readonly ObservableCollection<SelectedScript> _selectedScripts = new ObservableCollection<SelectedScript>();
public PatchVectorsPopup(Action<PatchConfig> onApply)
{
_onApply = onApply;
InitializeComponent();
ScriptList.ItemsSource = _selectedScripts;
UpdateScriptsEmptyState();
}
private void OnAddScriptClick(object sender, RoutedEventArgs e)
{
var dialog = new OpenFileDialog
{
Filter = JavaScriptDialogFilter,
Multiselect = true,
CheckFileExists = true
};
if (dialog.ShowDialog() != true)
{
return;
}
foreach (var path in dialog.FileNames.Where(IsJavaScriptFile))
{
AddScript(path);
}
if (_selectedScripts.Count > 0)
{
RemoteWebPanelPreviewBox.IsChecked = true;
}
UpdateScriptsEmptyState();
}
private void OnRemoveScriptClick(object sender, RoutedEventArgs e)
{
var button = sender as Button;
var script = button?.Tag as SelectedScript;
if (script == null)
{
return;
}
_selectedScripts.Remove(script);
UpdateScriptsEmptyState();
}
private void OnPatchButtonClick(object sender, RoutedEventArgs e)
{
if (ActivateProBox.IsChecked != true && DisableUpdateBox.IsChecked != true &&
DevToolsHotkeyBox.IsChecked != true && RemoteWebPanelPreviewBox.IsChecked != true)
{
return;
}
var result = new HashSet<EPatchType>();
if (ActivateProBox.IsChecked == true)
{
result.Add(EPatchType.ActivatePro);
}
if (DisableUpdateBox.IsChecked == true)
{
result.Add(EPatchType.DisableUpdates);
}
if (DevToolsHotkeyBox.IsChecked == true)
{
result.Add(EPatchType.DevToolsOnF12);
}
if (RemoteWebPanelPreviewBox.IsChecked == true)
{
result.Add(EPatchType.RemoteWebPanelPreview);
}
_onApply(new PatchConfig
{
PatchTypes = result,
CustomScriptPaths = _selectedScripts.Select(script => script.FullPath).ToList(),
AutoApplyPatches = false
});
}
private void AddScript(string path)
{
var fullPath = Path.GetFullPath(path);
if (_selectedScripts.Any(script => string.Equals(script.FullPath, fullPath, StringComparison.OrdinalIgnoreCase)))
{
return;
}
_selectedScripts.Add(new SelectedScript(fullPath));
}
private static bool IsJavaScriptFile(string path)
{
return File.Exists(path) && string.Equals(Path.GetExtension(path), JavaScriptFileExtension, StringComparison.OrdinalIgnoreCase);
}
private void UpdateScriptsEmptyState()
{
NoScriptsText.Visibility = _selectedScripts.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
}
private sealed class SelectedScript
{
public SelectedScript(string fullPath)
{
FullPath = fullPath;
FileName = Path.GetFileName(fullPath);
}
public string FullPath { get; }
public string FileName { get; }
}
}
}
@@ -1,4 +1,4 @@
<UserControl x:Class="WeModPatcher.View.Popups.SettingsPopup"
<UserControl x:Class="WandEnhancer.View.Popups.SettingsPopup"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -2,11 +2,11 @@ using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using WeModPatcher.Core;
using WeModPatcher.Core.Services;
using WeModPatcher.View.MainWindow;
using WandEnhancer.Core;
using WandEnhancer.Core.Services;
using WandEnhancer.View.MainWindow;
namespace WeModPatcher.View.Popups
namespace WandEnhancer.View.Popups
{
public partial class SettingsPopup : UserControl
{
@@ -1,9 +1,9 @@
<UserControl x:Class="WeModPatcher.View.Popups.UpdatePopup"
<UserControl x:Class="WandEnhancer.View.Popups.UpdatePopup"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WeModPatcher.View.Popups"
xmlns:local="clr-namespace:WandEnhancer.View.Popups"
mc:Ignorable="d"
d:DesignHeight="Auto" d:DesignWidth="Auto"
Background="{DynamicResource Background}"
@@ -2,7 +2,7 @@
using System.Windows;
using System.Windows.Controls;
namespace WeModPatcher.View.Popups
namespace WandEnhancer.View.Popups
{
public partial class UpdatePopup : UserControl
{
@@ -7,8 +7,8 @@
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{106D3E44-ECBB-4EF3-84B2-5FC6BCF77727}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>WeModPatcher</RootNamespace>
<AssemblyName>WeModPatcher</AssemblyName>
<RootNamespace>WandEnhancer</RootNamespace>
<AssemblyName>WandEnhancer</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
@@ -38,7 +38,7 @@
<Prefer32bit>false</Prefer32bit>
</PropertyGroup>
<PropertyGroup>
<StartupObject>WeModPatcher.Program</StartupObject>
<StartupObject>WandEnhancer.Program</StartupObject>
<CMakeSourceDir>..\tools\asar-fuses-bypass</CMakeSourceDir>
<CMakeBuildDir>$(CMakeSourceDir)\cmake-build-release</CMakeBuildDir>
<ProxyDllPath>$(CMakeBuildDir)\version.dll</ProxyDllPath>
@@ -68,8 +68,8 @@
<Compile Include="Constants.cs" />
<Compile Include="Converters\BaseBooleanConverter.cs" />
<Compile Include="Converters\ToVisibilityConverter.cs" />
<Compile Include="Core\Patcher.cs" />
<Compile Include="Core\PatcherConfig.cs" />
<Compile Include="Core\Enhancer.cs" />
<Compile Include="Core\EnhancerConfig.cs" />
<Compile Include="Core\Services\LocalizationManager.cs" />
<Compile Include="Core\Services\SettingsManager.cs" />
<Compile Include="Models\WeModConfig.cs" />
@@ -160,6 +160,17 @@
<LogicalName>proxydll</LogicalName>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\web-panel\dist\**\*.*" Condition="Exists('..\web-panel\dist\index.html')">
<LogicalName>remote-panel/dist/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="..\web-panel\bridge\wand-remote-bridge.cjs" Condition="Exists('..\web-panel\bridge\wand-remote-bridge.cjs')">
<LogicalName>remote-panel/bridge.cjs</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="..\web-panel\scripts\default\*.js" Condition="Exists('..\web-panel\scripts\default')">
<LogicalName>remote-panel/renderer-scripts/%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
-194
View File
@@ -1,194 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using AsarSharp;
using Newtonsoft.Json;
using WeModPatcher.Models;
using WeModPatcher.Utils;
using WeModPatcher.View.MainWindow;
using Application = System.Windows.Application;
namespace WeModPatcher.Core
{
public class Patcher
{
private readonly WeModConfig _weModConfig;
private readonly Action<string, ELogType> _logger;
private readonly PatchConfig _config;
private readonly string _asarPath;
private readonly string _backupPath;
private readonly string _unpackedPath;
public Patcher(WeModConfig weModConfig, Action<string, ELogType> logger, PatchConfig config)
{
_weModConfig = weModConfig;
_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");
}
private string ApplyJsPatch(string fileName, string js, PatcherConfig.PatchEntry patch, EPatchType patchType)
{
if (patch.Applied)
{
return js;
}
var matches = patch.Target.Matches(js);
if (matches.Count == 0)
{
return js;
}
var prefix = $"[PATCHER] [{patchType} -> {patch.Name}]";
if(matches.Count > 1 && patch.SingleMatch)
{
throw new Exception(
$"{prefix} Patch failed. Multiple target functions found. Looks like the version is not supported");
}
if (patch.Resolver != null)
{
string resolvedField = patch.Resolver.Handler(matches[0].Value);
if (string.IsNullOrEmpty(resolvedField))
{
throw new Exception($"{prefix} Resolver failed to find field name");
}
patch.Patch = patch.Patch.Replace(patch.Resolver.Placeholder, resolvedField);
}
_logger($"{prefix} Found target function in: " + Path.GetFileName(fileName), ELogType.Info);
string newJs = patch.Target.Replace(js, patch.Patch);
File.WriteAllText(fileName, newJs);
_logger($"{prefix} Patch applied", ELogType.Success);
patch.Applied = true;
return newJs;
}
private void PatchAsar()
{
var items = Directory.EnumerateFiles(_unpackedPath)
.Where(file => !Directory.Exists(file) && Regex.IsMatch(Path.GetFileName(file), @"^app-\w+|index\.js"))
.ToList();
if (!items.Any())
{
throw new Exception("[PATCHER] No app bundle found");
}
// Track patches that still need to be completed
var remainingPatches = new HashSet<EPatchType>(_config.PatchTypes);
var patcherConfig = PatcherConfig.GetInstance();
foreach (var item in items)
{
if (remainingPatches.Count == 0)
{
break;
}
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 = patcherConfig[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);
}
}
}
if(remainingPatches.Count > 0)
{
var failedPatches = string.Join(", ", remainingPatches.Select(p => p.ToString()));
throw new Exception($"[PATCHER] Failed to apply patches: {failedPatches}. The version may not be supported.");
}
}
private void AttachProxyDll()
{
var assembly = Assembly.GetExecutingAssembly();
var dll = assembly.GetManifestResourceStream(Constants.ProxyDllResouceName);
if (dll == null)
{
throw new Exception("[PATCHER] Proxy DLL resource not found");
}
var destPath = Path.Combine(_weModConfig.RootDirectory, "version.dll");
using (var fileStream = File.Create(destPath))
{
dll.CopyTo(fileStream);
}
_logger("[PATCHER] Proxy DLL attached", ELogType.Info);
}
public void Patch()
{
Common.TryKillProcess(_weModConfig.BrandName);
if (!File.Exists(_backupPath))
{
_logger("[PATCHER] Creating backup...", ELogType.Info);
File.Copy(_asarPath, _backupPath);
}
else
{
_logger("[PATCHER] Backup already exists", ELogType.Warn);
}
if(!File.Exists(_asarPath))
{
throw new Exception("app.asar not found");
}
try
{
_logger("[PATCHER] Extracting app.asar...", ELogType.Info);
AsarExtractor.ExtractAll(_asarPath, _unpackedPath);
}
catch (Exception e)
{
throw new Exception($"[PATCHER] Failed to unpack app.asar: {e.Message}");
}
PatchAsar();
try
{
new AsarCreator(_unpackedPath, _asarPath, new CreateOptions
{
Unpack = new Regex(@"^static\\unpacked.*$")
}).CreatePackageWithOptions();
}
catch (Exception e)
{
throw new Exception($"[PATCHER] Failed to pack app.asar: {e.Message}");
}
AttachProxyDll();
_logger("[PATCHER] Done!", ELogType.Success);
}
}
}
-105
View File
@@ -1,105 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using WeModPatcher.Models;
namespace WeModPatcher.Core
{
public static class PatcherConfig
{
public class ResolveContext
{
public string Placeholder { get; set; }
public Func<string, string> Handler { get; set; }
}
public class PatchEntry
{
public Regex Target { get; set; }
public string Patch { get; set; }
public string Name { get; set; }
public bool Applied { get; set; }
public bool SingleMatch { get; set; } = true;
public ResolveContext Resolver { get; set; }
}
public static Dictionary<EPatchType, PatchEntry[]> GetInstance()
{
return new Dictionary<EPatchType, PatchEntry[]>()
{
{
EPatchType.ActivatePro,
new[]
{
new PatchEntry
{
Resolver = new ResolveContext
{
Handler = (targetFunction) =>
{
var fetchMatch = Regex.Match(targetFunction, @"return\s+this\.#(\w+)\.fetch");
return fetchMatch.Success ? fetchMatch.Groups[1].Value : null;
},
Placeholder = "<service_name>"
},
Name = "getUserAccount",
Target = new Regex(@"getUserAccount\(\)\{.*?return\s+this\.#\w+\.fetch\(\{.*?\}\)\}",
RegexOptions.Singleline),
Patch =
"getUserAccount(){return this.#<service_name>.fetch({endpoint:\"/v3/account\",method:\"GET\",name:\"/v3/account\",collectMetrics:0}).then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}"
},
new PatchEntry
{
Resolver = new ResolveContext
{
Handler = (targetFunction) =>
{
var match = Regex.Match(targetFunction, @"return\s+this\.#(\w+)\.post");
return match.Success ? match.Groups[1].Value : null;
},
Placeholder = "<service_name>"
},
Name = "setAccountWandBrandExperience",
Target = new Regex(
@"setAccountWandBrandExperience\(\){.*?return\s+this\.#\w+\.post\(""/v3/account/brand_experience_wand""\)}",
RegexOptions.Singleline),
Patch =
"setAccountWandBrandExperience(){return this.#<service_name>.post(\"/v3/account/brand_experience_wand\").then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}"
}
}
},
{
EPatchType.DisableUpdates,
new[]
{
new PatchEntry
{
Target = new Regex(@"registerHandler\(""ACTION_CHECK_FOR_UPDATE"".*?\)\)\)\)",
RegexOptions.Singleline),
Patch = "registerHandler(\"ACTION_CHECK_FOR_UPDATE\",(e=>expectUpdateFeedUrl(e,(e=>null)))"
}
}
},
{
EPatchType.DevToolsOnF12,
new[]
{
new PatchEntry
{
Resolver = new ResolveContext
{
Handler = (matchContent) => {
var match = Regex.Match(matchContent, @"this\.#(\w+)\(""ACTION_OPEN_DEV_TOOLS""\)");
return match.Success ? match.Groups[1].Value : null;
},
Placeholder = "<dispatch_method>"
},
Target = new Regex(@"document\.addEventListener\(""keydown"",\s*\((?<arg>\w+)\s*=>\s*\{[^}]*?""ACTION_OPEN_DEV_TOOLS""[^}]*?\}\)\)", RegexOptions.Singleline),
Patch = "document.addEventListener(\"keydown\",(${arg}=>{\"F12\"!==${arg}.key||this.#<dispatch_method>(\"ACTION_OPEN_DEV_TOOLS\")}))"
}
}
}
};
}
}
}
@@ -1,47 +0,0 @@
<UserControl x:Class="WeModPatcher.View.Popups.PatchVectorsPopup"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WeModPatcher.View.Popups"
xmlns:controls="clr-namespace:WeModPatcher.View.Controls"
mc:Ignorable="d"
d:DesignHeight="Auto" d:DesignWidth="Auto"
Background="{DynamicResource Background}"
Foreground="{DynamicResource MutedForeground}"
FontWeight="Medium"
FontSize="13">
<Grid>
<Grid Visibility="Visible" Margin="0 0 5 0">
<Grid.RowDefinitions>
<RowDefinition Height="27" />
<RowDefinition Height="27" />
<RowDefinition Height="27" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" VerticalAlignment="Center" Text="{DynamicResource pv_activate_pro}" />
<CheckBox Grid.Row="0" x:Name="ActivateProBox" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="True" />
<TextBlock Grid.Row="1" VerticalAlignment="Center" Text="{DynamicResource pv_devtools}" />
<CheckBox Grid.Row="1" x:Name="DevToolsHotkeyBox" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Row="2" VerticalAlignment="Center" Text="{DynamicResource pv_disable_updates}" />
<CheckBox Grid.Row="2" x:Name="DisableUpdateBox" HorizontalAlignment="Right" VerticalAlignment="Center" />
<!--<TextBlock
ToolTip="Disable if you want to use older versions separately and manage versions manually via different shortcuts"
ToolTipService.InitialShowDelay="300"
Grid.Row="3" VerticalAlignment="Center">
Apply the patch to new versions <LineBreak /> automatically (hover to see more)
</TextBlock>
<CheckBox Grid.Row="3" x:Name="AutoUpdates" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="True" />-->
<Button Grid.Row="3" Padding="0 5 0 5" Margin="0 15 0 0" Content="{DynamicResource pv_start}"
Click="OnPatchButtonClick" />
</Grid>
</Grid>
</UserControl>
@@ -1,51 +0,0 @@
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using WeModPatcher.Models;
using WeModPatcher.View.Controls;
namespace WeModPatcher.View.Popups
{
public partial class PatchVectorsPopup : UserControl
{
private readonly Action<PatchConfig> _onApply;
public PatchVectorsPopup(Action<PatchConfig> onApply)
{
_onApply = onApply;
InitializeComponent();
}
private void OnPatchButtonClick(object sender, RoutedEventArgs e)
{
if (ActivateProBox.IsChecked != true && DisableUpdateBox.IsChecked != true &&
DevToolsHotkeyBox.IsChecked != true)
{
return;
}
var result = new HashSet<EPatchType>();
if (ActivateProBox.IsChecked == true)
{
result.Add(EPatchType.ActivatePro);
}
if (DisableUpdateBox.IsChecked == true)
{
result.Add(EPatchType.DisableUpdates);
}
if (DevToolsHotkeyBox.IsChecked == true)
{
result.Add(EPatchType.DevToolsOnF12);
}
_onApply(new PatchConfig
{
PatchTypes = result,
AutoApplyPatches =/* AutoUpdates.IsChecked == true*/ false
});
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+7
View File
@@ -0,0 +1,7 @@
node_modules/
coverage/
.pnpm-store/
pnpm-lock.yaml
package-lock.json
pnpm-lock.yaml
yarn.lock
+11
View File
@@ -0,0 +1,11 @@
{
"endOfLine": "lf",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 80,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "src/index.css",
"tailwindFunctions": ["cn", "cva"]
}
+31
View File
@@ -0,0 +1,31 @@
# Wand Web Panel
Local mobile-friendly web panel scaffold for Wand.
## Commands
```bash
npm install
npm run dev
```
Hosted access on the local machine:
- `http://localhost:4173/?mock=1`
Hosted access on the LAN:
```bash
npm run dev:host
```
Then open the machine IP on port `4173`.
## Modes
- `?mock=1`
- dev server only; loads the demo trainer and values through a debug-only import
- `?ws=ws://host:port/remote/ws`
- connects to a real bridge once the desktop layer exists
Production builds exclude the debug route and demo JSON from the shipped bundle.
+212
View File
@@ -0,0 +1,212 @@
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { WebSocketServer } from 'ws';
import demoSession from '../fixtures/demo-session.json' with { type: 'json' };
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const distDir = path.join(rootDir, 'dist');
const DEFAULT_REMOTE_PORT = 3223;
const DEFAULT_REMOTE_HOST = '0.0.0.0';
const REMOTE_BASE_PATH = '/remote/';
const REMOTE_WS_PATH = '/remote/ws';
const REMOTE_HEALTH_PATH = '/remote/api/health';
const REMOTE_ASSETS_PREFIX = '/remote/assets/';
const host = process.env.HOST || DEFAULT_REMOTE_HOST;
const port = Number(process.env.PORT || DEFAULT_REMOTE_PORT);
const trainerMeta = structuredClone(demoSession.trainerMeta);
const trainerValues = structuredClone(demoSession.trainerValues);
const wss = new WebSocketServer({ noServer: true });
function jsonMessage(type, payload, requestId = null) {
return JSON.stringify({
type,
version: 1,
requestId,
payload,
});
}
function sendSnapshot(ws) {
ws.send(
jsonMessage('trainer_meta', trainerMeta)
);
ws.send(
jsonMessage('trainer_values', trainerValues)
);
}
function broadcast(type, payload, requestId = null) {
const serialized = jsonMessage(type, payload, requestId);
for (const client of wss.clients) {
if (client.readyState === 1) {
client.send(serialized);
}
}
}
function normalizeValue(target, value) {
const cheat = trainerMeta.schema.cheats.find((entry) => entry.target === target);
if (!cheat) {
return value;
}
if (cheat.type === 'toggle') {
return Boolean(value);
}
if (cheat.type === 'slider' || cheat.type === 'number') {
const numeric = typeof value === 'string' ? Number(value) : value;
return Number.isFinite(numeric) ? numeric : trainerValues.values[target];
}
return value;
}
function contentTypeFor(filePath) {
if (filePath.endsWith('.html')) return 'text/html; charset=utf-8';
if (filePath.endsWith('.js')) return 'application/javascript; charset=utf-8';
if (filePath.endsWith('.css')) return 'text/css; charset=utf-8';
if (filePath.endsWith('.json')) return 'application/json; charset=utf-8';
if (filePath.endsWith('.svg')) return 'image/svg+xml';
return 'application/octet-stream';
}
async function serveFile(res, filePath) {
try {
const content = await readFile(filePath);
res.writeHead(200, {
'Content-Type': contentTypeFor(filePath),
'Cache-Control': 'no-store',
});
res.end(content);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
}
}
const server = createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
if (url.pathname === '/' || url.pathname === '') {
res.writeHead(302, { Location: REMOTE_BASE_PATH });
res.end();
return;
}
if (url.pathname === REMOTE_BASE_PATH.slice(0, -1)) {
res.writeHead(302, { Location: REMOTE_BASE_PATH });
res.end();
return;
}
if (url.pathname === REMOTE_BASE_PATH) {
await serveFile(res, path.join(distDir, 'index.html'));
return;
}
if (url.pathname === REMOTE_HEALTH_PATH) {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: true, trainerId: trainerMeta.trainer.trainerId }));
return;
}
if (url.pathname.startsWith(REMOTE_ASSETS_PREFIX)) {
const relativePath = url.pathname.replace(REMOTE_BASE_PATH, '');
await serveFile(res, path.join(distDir, relativePath));
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
});
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
if (url.pathname !== REMOTE_WS_PATH) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
wss.on('connection', (ws) => {
ws.on('error', console.error);
ws.on('message', (raw) => {
try {
const message = JSON.parse(String(raw));
if (message?.type === 'hello') {
ws.send(
jsonMessage('hello_ack', {
sessionId: `sess_${Date.now()}`,
accepted: true,
serverVersion: '0.1.0-demo',
protocolVersion: 1,
}, message.requestId ?? null)
);
sendSnapshot(ws);
return;
}
if (message?.type === 'set_value') {
const target = message.payload?.target;
if (typeof target !== 'string' || !(target in trainerValues.values)) {
ws.send(
jsonMessage('set_value_result', {
ok: false,
trainerId: trainerMeta.trainer.trainerId,
target: typeof target === 'string' ? target : '',
error: {
code: 'invalid_target',
message: 'Unknown cheat target.',
},
}, message.requestId ?? null)
);
return;
}
const previousValue = trainerValues.values[target];
const nextValue = normalizeValue(target, message.payload?.value);
trainerValues.values[target] = nextValue;
ws.send(
jsonMessage('set_value_result', {
ok: true,
trainerId: trainerMeta.trainer.trainerId,
target,
}, message.requestId ?? null)
);
broadcast('value_changed', {
trainerId: trainerMeta.trainer.trainerId,
target,
value: nextValue,
oldValue: previousValue,
source: 'remote',
cheatId: message.payload?.cheatId,
});
}
} catch (error) {
ws.send(
jsonMessage('error', {
code: 'invalid_message',
message: error instanceof Error ? error.message : 'Failed to parse client message.',
})
);
}
});
});
server.listen(port, host, () => {
console.log(`Wand web panel bridge listening on http://${host === DEFAULT_REMOTE_HOST ? 'localhost' : host}:${port}${REMOTE_BASE_PATH}`);
});
+896
View File
@@ -0,0 +1,896 @@
const crypto = require('node:crypto');
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const KNOWN_CHEAT_TYPES = new Set(['slider', 'number', 'toggle', 'button', 'selection', 'scalar', 'incremental']);
const DEFAULT_REMOTE_PORT = 3223;
const PORT_SCAN_RANGE = 30;
const DEFAULT_REMOTE_HOST = '0.0.0.0';
const REMOTE_BASE_PATH = '/remote/';
const REMOTE_WS_PATH = '/remote/ws';
const REMOTE_HEALTH_PATH = '/remote/api/health';
const REMOTE_ASSETS_PREFIX = '/remote/assets/';
const BRIDGE_LOG_FILE_NAME = 'wand-remote-bridge.log';
const RENDERER_SCRIPTS_DIR = 'renderer-scripts';
const RENDERER_SCRIPT_API_VERSION = 1;
function isRecord(value) {
return typeof value === 'object' && value !== null;
}
function safeString(value, fallback = '') {
return typeof value === 'string' && value.length ? value : fallback;
}
function firstString(...values) {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return '';
}
function cloneValue(value) {
if (Array.isArray(value)) {
return value.map(cloneValue);
}
if (isRecord(value)) {
const result = {};
for (const [key, entry] of Object.entries(value)) {
result[key] = cloneValue(entry);
}
return result;
}
return value;
}
function isValidPort(value) {
return Number.isFinite(value) && value > 0 && value < 65536;
}
function normalizeOption(option) {
if (typeof option === 'string' || typeof option === 'number') {
return {
label: String(option),
value: option,
};
}
if (isRecord(option)) {
const value = option.value;
if (typeof value === 'string' || typeof value === 'number') {
return {
label: safeString(option.label, String(value)),
value,
};
}
}
return null;
}
function normalizeArgs(args) {
if (!isRecord(args)) {
return {};
}
const next = {};
if (typeof args.min === 'number') next.min = args.min;
if (typeof args.max === 'number') next.max = args.max;
if (typeof args.step === 'number') next.step = args.step;
if (typeof args.postfix === 'string') next.postfix = args.postfix;
if (typeof args.default === 'string' || typeof args.default === 'number' || typeof args.default === 'boolean') {
next.default = args.default;
}
if (Array.isArray(args.options)) {
next.options = args.options.map(normalizeOption).filter(Boolean);
}
if (typeof args.button === 'string' || typeof args.button === 'boolean') {
next.button = args.button;
}
return next;
}
function normalizeCheat(cheat, index) {
if (!isRecord(cheat)) {
return null;
}
const target = safeString(cheat.target);
const type = safeString(cheat.type);
if (!target || !KNOWN_CHEAT_TYPES.has(type)) {
return null;
}
const normalized = {
uuid: safeString(cheat.uuid, `${target}-${index}`),
target,
type,
name: safeString(cheat.name, target),
description: typeof cheat.description === 'string' ? cheat.description : null,
instructions: typeof cheat.instructions === 'string' ? cheat.instructions : null,
category: safeString(cheat.category, 'general'),
parent: typeof cheat.parent === 'string' ? cheat.parent : null,
args: normalizeArgs(cheat.args),
};
if (typeof cheat.flags === 'number') {
normalized.flags = cheat.flags;
}
if (Array.isArray(cheat.hotkeys)) {
normalized.hotkeys = cheat.hotkeys.filter(Array.isArray).map((group) => group.map((item) => String(item)));
}
return normalized;
}
function normalizeSnapshot(rawSnapshot) {
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.metadata) || !isRecord(rawSnapshot.metadata.info)) {
return null;
}
const info = rawSnapshot.metadata.info;
const blueprint = isRecord(info.blueprint) ? info.blueprint : {};
const rawCheats = Array.isArray(blueprint.cheats) ? blueprint.cheats : [];
const cheats = rawCheats.map(normalizeCheat).filter(Boolean);
const categories = Array.from(new Set(cheats.map((entry) => entry.category)));
const trainerId = safeString(rawSnapshot.trainerId || rawSnapshot.trainerInfo?.trainerId);
const displayName = firstString(
rawSnapshot.trainerInfo?.displayName,
rawSnapshot.trainerInfo?.gameName,
rawSnapshot.trainerInfo?.titleName,
rawSnapshot.trainerInfo?.title,
rawSnapshot.trainerInfo?.name,
info.displayName,
info.gameName,
info.titleName,
info.title,
info.name,
info.game?.displayName,
info.game?.name,
info.game?.title
);
if (!trainerId) {
return null;
}
const trainerMeta = {
session: {
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
},
trainer: {
trainerId,
gameId: safeString(rawSnapshot.trainerInfo?.gameId || info.gameId),
displayName: displayName || safeString(rawSnapshot.trainerInfo?.gameId || info.gameId, trainerId),
titleId: typeof info.titleId === 'string' ? info.titleId : null,
gameVersion: typeof rawSnapshot.gameVersion === 'string' ? rawSnapshot.gameVersion : null,
trainerLoading: rawSnapshot.trainerLoading === true,
gameInstalled: rawSnapshot.gameInstalled !== false,
needsCompatibilityWarning: rawSnapshot.needsCompatibilityWarning === true,
language: safeString(rawSnapshot.language, 'en-US'),
themeId: safeString(rawSnapshot.themeId, 'default'),
isTimeLimitExpired: rawSnapshot.isTimeLimitExpired === true,
notesReadHash: typeof rawSnapshot.notesReadHash === 'string' ? rawSnapshot.notesReadHash : null,
},
schema: {
categories,
cheats,
},
};
const trainerValues = {
trainerId,
values: isRecord(rawSnapshot.values) ? cloneValue(rawSnapshot.values) : {},
};
return {
trainerMeta,
trainerValues,
};
}
function jsonMessage(type, payload, requestId = null) {
return JSON.stringify({
type,
version: 1,
requestId,
payload,
});
}
function makeFrame(opcode, payload) {
const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
const header = [];
header.push(0x80 | (opcode & 0x0f));
if (source.length < 126) {
header.push(source.length);
return Buffer.concat([Buffer.from(header), source]);
}
if (source.length < 65536) {
const prefix = Buffer.from([header[0], 126, (source.length >> 8) & 0xff, source.length & 0xff]);
return Buffer.concat([prefix, source]);
}
const prefix = Buffer.alloc(10);
prefix[0] = header[0];
prefix[1] = 127;
prefix.writeUInt32BE(0, 2);
prefix.writeUInt32BE(source.length, 6);
return Buffer.concat([prefix, source]);
}
function sendText(client, text) {
if (!client.closed) {
client.socket.write(makeFrame(1, Buffer.from(text, 'utf8')));
}
}
function sendJson(client, type, payload, requestId = null) {
sendText(client, jsonMessage(type, payload, requestId));
}
function closeClient(client, code = 1000, reason = 'Closing') {
if (client.closed) {
return;
}
client.closed = true;
const reasonBuffer = Buffer.from(reason, 'utf8');
const payload = Buffer.alloc(2 + reasonBuffer.length);
payload.writeUInt16BE(code, 0);
reasonBuffer.copy(payload, 2);
client.socket.write(makeFrame(8, payload));
client.socket.end();
}
function parseFrame(buffer) {
if (buffer.length < 2) {
return null;
}
const first = buffer[0];
const second = buffer[1];
const fin = (first & 0x80) !== 0;
const opcode = first & 0x0f;
const masked = (second & 0x80) !== 0;
let length = second & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.length < offset + 2) {
return null;
}
length = buffer.readUInt16BE(offset);
offset += 2;
} else if (length === 127) {
if (buffer.length < offset + 8) {
return null;
}
const high = buffer.readUInt32BE(offset);
const low = buffer.readUInt32BE(offset + 4);
if (high !== 0) {
throw new Error('Large websocket frames are not supported.');
}
length = low;
offset += 8;
}
let mask = null;
if (masked) {
if (buffer.length < offset + 4) {
return null;
}
mask = buffer.subarray(offset, offset + 4);
offset += 4;
}
if (buffer.length < offset + length) {
return null;
}
const payload = Buffer.from(buffer.subarray(offset, offset + length));
if (masked && mask) {
for (let index = 0; index < payload.length; index += 1) {
payload[index] ^= mask[index % 4];
}
}
return {
bytesConsumed: offset + length,
fin,
opcode,
payload,
};
}
function contentTypeFor(filePath) {
const extension = path.extname(filePath).toLowerCase();
switch (extension) {
case '.html':
return 'text/html; charset=utf-8';
case '.js':
case '.cjs':
return 'application/javascript; charset=utf-8';
case '.css':
return 'text/css; charset=utf-8';
case '.json':
return 'application/json; charset=utf-8';
case '.svg':
return 'image/svg+xml';
default:
return 'application/octet-stream';
}
}
function getAdvertisedUrls(port) {
const urls = [];
const interfaces = os.networkInterfaces();
for (const entries of Object.values(interfaces)) {
if (!entries) {
continue;
}
for (const entry of entries) {
if (!entry || entry.internal || entry.family !== 'IPv4') {
continue;
}
urls.push(`http://${entry.address}:${port}${REMOTE_BASE_PATH}`);
}
}
urls.unshift(`http://localhost:${port}${REMOTE_BASE_PATH}`);
return Array.from(new Set(urls));
}
function createBridgeRuntime(options = {}) {
const preferredPort = Number(options.port || process.env.WAND_REMOTE_PORT || DEFAULT_REMOTE_PORT);
let port = isValidPort(preferredPort) ? preferredPort : DEFAULT_REMOTE_PORT;
const maxPort = Number(options.maxPort || process.env.WAND_REMOTE_MAX_PORT || port + PORT_SCAN_RANGE);
const host = options.host || process.env.WAND_REMOTE_HOST || DEFAULT_REMOTE_HOST;
const panelRoot = options.panelRoot || __dirname;
const clients = new Set();
let advertisedUrls = [];
let currentSnapshot = null;
let setValueHandler = null;
let listening = false;
function setAdvertisedPort(nextPort) {
port = nextPort;
advertisedUrls = getAdvertisedUrls(port);
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry) => !entry.includes('localhost')) || advertisedUrls[0];
}
setAdvertisedPort(port);
const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME);
function log(level, message, error) {
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
const tag = `[wand-remote-bridge] ${message}`;
try { console[method](tag, error || ''); } catch { /* renderer may close console */ }
try {
const detail = error ? ` :: ${error && error.stack ? error.stack : String(error)}` : '';
fs.appendFileSync(logFile, `[${new Date().toISOString()}] [${level}] ${message}${detail}\n`);
} catch { /* best-effort */ }
}
log('info', `Bridge starting (pid=${process.pid}, panelRoot=${panelRoot}, preferredPort=${port}, host=${host})`);
globalThis.__wandRemoteBridgeLogFile = logFile;
function broadcast(type, payload, requestId = null) {
for (const client of clients) {
sendJson(client, type, payload, requestId);
}
}
function sendSnapshot(client) {
if (!currentSnapshot) {
sendJson(client, 'trainer_changed', {
previousTrainerId: null,
trainerId: '',
});
return;
}
sendJson(client, 'trainer_meta', currentSnapshot.trainerMeta);
sendJson(client, 'trainer_values', currentSnapshot.trainerValues);
}
function sync(rawSnapshot) {
const nextSnapshot = rawSnapshot ? normalizeSnapshot(rawSnapshot) : null;
const previousTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
const nextTrainerId = nextSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
currentSnapshot = nextSnapshot;
if (previousTrainerId !== nextTrainerId) {
broadcast('trainer_changed', {
previousTrainerId,
trainerId: nextTrainerId || '',
});
}
if (currentSnapshot) {
broadcast('trainer_meta', currentSnapshot.trainerMeta);
broadcast('trainer_values', currentSnapshot.trainerValues);
}
}
function valueChanged(change) {
if (!currentSnapshot || !isRecord(change)) {
return;
}
const target = safeString(change.target);
if (!target) {
return;
}
currentSnapshot.trainerValues.values[target] = cloneValue(change.value);
broadcast('value_changed', {
trainerId: safeString(change.trainerId, currentSnapshot.trainerMeta.trainer.trainerId),
target,
value: cloneValue(change.value),
oldValue: cloneValue(change.oldValue),
source: safeString(change.source, 'desktop'),
cheatId: typeof change.cheatId === 'string' ? change.cheatId : undefined,
});
}
function setHandler(handler) {
setValueHandler = typeof handler === 'function' ? handler : null;
}
function serveFile(response, filePath) {
try {
const content = fs.readFileSync(filePath);
response.writeHead(200, {
'Content-Type': contentTypeFor(filePath),
'Cache-Control': 'no-store',
});
response.end(content);
} catch {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found');
}
}
const server = http.createServer((request, response) => {
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
if (url.pathname === '/' || url.pathname === '') {
response.writeHead(302, { Location: '/remote/' });
response.end();
return;
}
if (url.pathname === REMOTE_BASE_PATH.slice(0, -1)) {
response.writeHead(302, { Location: REMOTE_BASE_PATH });
response.end();
return;
}
if (url.pathname === REMOTE_BASE_PATH) {
serveFile(response, path.join(panelRoot, 'index.html'));
return;
}
if (url.pathname === REMOTE_HEALTH_PATH) {
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
response.end(JSON.stringify({
ok: listening,
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || null,
remoteUrl: globalThis.__wandRemoteBridgeUrl,
advertisedUrls,
}));
return;
}
if (url.pathname.startsWith(REMOTE_ASSETS_PREFIX)) {
serveFile(response, path.join(panelRoot, url.pathname.replace(REMOTE_BASE_PATH, '')));
return;
}
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found');
});
server.on('upgrade', (request, socket) => {
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
if (url.pathname !== REMOTE_WS_PATH) {
socket.destroy();
return;
}
const key = request.headers['sec-websocket-key'];
if (typeof key !== 'string' || !key) {
socket.destroy();
return;
}
const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
socket.write([
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${accept}`,
'',
'',
].join('\r\n'));
const client = {
socket,
buffer: Buffer.alloc(0),
closed: false,
};
clients.add(client);
socket.on('data', async (chunk) => {
try {
client.buffer = Buffer.concat([client.buffer, chunk]);
while (client.buffer.length > 0) {
const frame = parseFrame(client.buffer);
if (!frame) {
return;
}
client.buffer = client.buffer.subarray(frame.bytesConsumed);
if (!frame.fin) {
closeClient(client, 1003, 'Fragmented frames are not supported.');
return;
}
if (frame.opcode === 8) {
closeClient(client, 1000, 'Closing');
return;
}
if (frame.opcode === 9) {
client.socket.write(makeFrame(10, frame.payload));
continue;
}
if (frame.opcode !== 1) {
continue;
}
const message = JSON.parse(frame.payload.toString('utf8'));
if (message?.type === 'hello') {
sendJson(client, 'hello_ack', {
sessionId: `sess_${Date.now()}`,
accepted: true,
serverVersion: '0.2.0-wand',
protocolVersion: 1,
remoteUrl: globalThis.__wandRemoteBridgeUrl,
advertisedUrls,
}, message.requestId ?? null);
sendSnapshot(client);
continue;
}
if (message?.type === 'set_value') {
const target = safeString(message.payload?.target);
if (!currentSnapshot || !target || !(target in currentSnapshot.trainerValues.values)) {
sendJson(client, 'set_value_result', {
ok: false,
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || '',
target,
error: {
code: 'invalid_target',
message: 'Unknown cheat target.',
},
}, message.requestId ?? null);
continue;
}
if (!setValueHandler) {
sendJson(client, 'set_value_result', {
ok: false,
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
target,
error: {
code: 'bridge_not_ready',
message: 'The local bridge is not ready to write trainer values yet.',
},
}, message.requestId ?? null);
continue;
}
let result = false;
try {
result = await Promise.resolve(setValueHandler({
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
target,
value: cloneValue(message.payload?.value),
cheatId: typeof message.payload?.cheatId === 'string' ? message.payload.cheatId : undefined,
}));
} catch (error) {
sendJson(client, 'set_value_result', {
ok: false,
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
target,
error: {
code: 'set_failed',
message: error instanceof Error ? error.message : 'Failed to set trainer value.',
},
}, message.requestId ?? null);
continue;
}
if (!result) {
sendJson(client, 'set_value_result', {
ok: false,
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
target,
error: {
code: 'set_rejected',
message: 'The trainer rejected the requested value.',
},
}, message.requestId ?? null);
continue;
}
sendJson(client, 'set_value_result', {
ok: true,
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
target,
}, message.requestId ?? null);
}
}
} catch (error) {
sendJson(client, 'error', {
code: 'invalid_message',
message: error instanceof Error ? error.message : 'Failed to process client message.',
});
}
});
socket.on('close', () => {
client.closed = true;
clients.delete(client);
});
socket.on('end', () => {
client.closed = true;
clients.delete(client);
});
socket.on('error', (error) => {
client.closed = true;
clients.delete(client);
log('warn', 'WebSocket client error.', error);
});
});
server.on('error', (error) => {
if (!listening && error && error.code === 'EADDRINUSE' && port < maxPort) {
const nextPort = port + 1;
log('warn', `Port ${port} is busy, trying ${nextPort}.`);
listen(nextPort);
return;
}
log('warn', `Bridge server error on ${host}:${port}.`, error);
});
server.on('listening', () => {
listening = true;
log('info', `Listening on ${globalThis.__wandRemoteBridgeUrl}`);
});
function listen(nextPort) {
setAdvertisedPort(nextPort);
server.listen(port, host);
}
listen(port);
return {
get listening() {
return listening;
},
get remoteUrl() {
return globalThis.__wandRemoteBridgeUrl;
},
get advertisedUrls() {
return advertisedUrls.slice();
},
sync,
valueChanged,
setHandler,
close() {
for (const client of clients) {
closeClient(client);
}
clients.clear();
currentSnapshot = null;
listening = false;
server.close();
},
};
}
function ensureBridge(options = {}) {
if (!globalThis.__wandRemoteBridgeRuntime) {
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
}
return globalThis.__wandRemoteBridgeRuntime;
}
function writeInstallLog(level, message, error) {
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
const tag = `[wand-remote-bridge] ${message}`;
try { console[method](tag, error || ''); } catch { /* best-effort */ }
try {
const detail = error ? ` :: ${error && error.stack ? error.stack : String(error)}` : '';
fs.appendFileSync(path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME), `[${new Date().toISOString()}] [${level}] ${message}${detail}\n`);
} catch { /* best-effort */ }
}
function loadRendererScripts(panelRoot, scriptsRoot) {
const root = scriptsRoot || path.join(panelRoot, RENDERER_SCRIPTS_DIR);
if (!fs.existsSync(root)) {
return [];
}
return fs.readdirSync(root)
.filter((name) => name.endsWith('.js'))
.sort((left, right) => left.localeCompare(right))
.map((name) => ({
name,
source: fs.readFileSync(path.join(root, name), 'utf8'),
}));
}
function buildRendererBootstrap(remoteUrl, scripts) {
// Inline each script source directly instead of wrapping it in `new Function(...)`.
// Wand's renderer ships with a strict CSP (no `unsafe-eval`), so any attempt to eval
// a string at runtime — including the `Function` constructor — silently throws
// "EvalError: Refused to evaluate a string as JavaScript". `executeJavaScript`
// itself runs in the page's V8 context and is not affected by CSP, so concatenating
// sources into a single payload makes scripts behave the same as a manual paste in
// DevTools (which is the only path the user reported as working).
const header = `
globalThis.__wandRemoteBridgeUrl = ${JSON.stringify(remoteUrl)};
if (!globalThis.WandEnhancer) {
globalThis.WandEnhancer = Object.freeze({
apiVersion: ${RENDERER_SCRIPT_API_VERSION},
remoteUrl: ${JSON.stringify(remoteUrl)},
log: function () { try { console.info.apply(console, ["[wand-enhancer-script]"].concat(Array.from(arguments))); } catch (_) {} },
});
} else {
try { globalThis.__wandRemoteBridgeUrl = ${JSON.stringify(remoteUrl)}; } catch (_) {}
}
console.info("[wand-remote-bridge] Renderer bootstrap (" + ${scripts.length} + " script(s)).");
`;
const body = scripts.map((script) => {
const tag = JSON.stringify(`wand-enhancer-script-${script.name}`);
return `
;(function (WandEnhancer) {
try {
${script.source}
} catch (error) {
try { console.warn("[wand-remote-bridge] Renderer script failed", ${JSON.stringify(script.name)}, error); } catch (_) {}
}
})(globalThis.WandEnhancer);
//# sourceURL=${tag.slice(1, -1)}
`;
}).join('\n');
return `(() => {\n${header}\n${body}\n})();`;
}
function installRendererScripts(electron, runtime, options = {}) {
if (globalThis.__wandRemoteBridgeRendererScriptsInstalled) {
return;
}
globalThis.__wandRemoteBridgeRendererScriptsInstalled = true;
const scripts = loadRendererScripts(options.panelRoot || __dirname, options.scriptsRoot);
if (scripts.length === 0) {
writeInstallLog('info', 'No renderer scripts found.');
return;
}
electron.app.on('web-contents-created', (_event, contents) => {
const inject = () => {
if (!contents || contents.isDestroyed()) {
return;
}
contents.executeJavaScript(buildRendererBootstrap(runtime.remoteUrl, scripts), true)
.catch((error) => writeInstallLog('warn', 'Failed to inject renderer scripts.', error));
};
contents.on('dom-ready', inject);
contents.on('did-finish-load', inject);
setTimeout(inject, 500);
setTimeout(inject, 2000);
});
writeInstallLog('info', `Renderer script injection installed (${scripts.map((script) => script.name).join(', ')}).`);
}
function installWandRuntime(electron, options = {}) {
const runtime = ensureBridge(options);
if (!electron || !electron.ipcMain || !electron.app) {
throw new Error('Electron main-process API is required to install Wand runtime hooks.');
}
const boundRenderers = globalThis.__wandRemoteBridgeBoundRenderers || new Set();
globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers;
runtime.setHandler((request) => {
let delivered = false;
for (const sender of Array.from(boundRenderers)) {
try {
if (!sender || sender.isDestroyed()) {
boundRenderers.delete(sender);
continue;
}
sender.send('wand-remote-set-value', request);
delivered = true;
} catch (error) {
boundRenderers.delete(sender);
writeInstallLog('warn', 'Failed to forward set_value to renderer.', error);
}
}
return delivered;
});
if (!globalThis.__wandRemoteBridgeIpcInstalled) {
globalThis.__wandRemoteBridgeIpcInstalled = true;
electron.ipcMain.handle('wand-remote-sync', (_event, snapshot) => {
runtime.sync(snapshot);
return true;
});
electron.ipcMain.handle('wand-remote-value-changed', (_event, change) => {
runtime.valueChanged(change);
return true;
});
electron.ipcMain.handle('wand-remote-set-handler-bind', (event) => {
if (event && event.sender) {
boundRenderers.add(event.sender);
}
return true;
});
electron.ipcMain.handle('wand-remote-url', () => runtime.remoteUrl);
}
installRendererScripts(electron, runtime, options);
writeInstallLog('info', 'Wand runtime hooks installed.');
return runtime;
}
module.exports = {
createBridgeRuntime,
ensureBridge,
installWandRuntime,
};
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-mira",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "mist",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "tabler",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+140
View File
@@ -0,0 +1,140 @@
{
"trainerMeta": {
"session": {
"instanceId": "mock-instance"
},
"trainer": {
"trainerId": "mock-trainer-1",
"gameId": "mock-game-1",
"displayName": "Mock Adventure",
"titleId": "mock-title-1",
"gameVersion": "1.0.0",
"trainerLoading": false,
"gameInstalled": true,
"needsCompatibilityWarning": false,
"language": "en-US",
"themeId": "default",
"isTimeLimitExpired": false,
"notesReadHash": null
},
"schema": {
"categories": ["player", "inventory", "world"],
"cheats": [
{
"uuid": "toggle-god-mode",
"target": "god_mode",
"type": "toggle",
"name": "God Mode",
"description": "Ignore incoming damage.",
"instructions": null,
"category": "player",
"parent": null,
"args": {}
},
{
"uuid": "slider-health",
"target": "player_health",
"type": "slider",
"name": "Health",
"description": "Tune player health in real time.",
"instructions": null,
"category": "player",
"parent": null,
"args": {
"min": 0,
"max": 100,
"step": 1
}
},
{
"uuid": "number-money",
"target": "player_money",
"type": "number",
"name": "Money",
"description": "Set the current money amount.",
"instructions": null,
"category": "inventory",
"parent": null,
"args": {
"min": 0,
"max": 999999,
"step": 100
}
},
{
"uuid": "button-restock",
"target": "restock_ammo",
"type": "button",
"name": "Restock Ammo",
"description": "Apply a one-shot action.",
"instructions": "Click once to refill ammo.",
"category": "inventory",
"parent": null,
"args": {}
},
{
"uuid": "selection-difficulty",
"target": "difficulty",
"type": "selection",
"name": "Difficulty",
"description": "Pick one predefined option.",
"instructions": null,
"category": "world",
"parent": null,
"args": {
"options": [
{ "label": "Easy", "value": "easy" },
{ "label": "Normal", "value": "normal" },
{ "label": "Hard", "value": "hard" }
]
}
},
{
"uuid": "scalar-speed",
"target": "game_speed",
"type": "scalar",
"name": "Game Speed",
"description": "Scalar-style preset selector.",
"instructions": null,
"category": "world",
"parent": null,
"args": {
"postfix": "x",
"default": 1,
"options": [0.5, 1, 1.5, 2]
}
},
{
"uuid": "incremental-time",
"target": "time_of_day",
"type": "incremental",
"name": "Time of Day",
"description": "Step through a small sequence of values.",
"instructions": null,
"category": "world",
"parent": null,
"args": {
"options": [
{ "label": "Dawn", "value": "dawn" },
{ "label": "Day", "value": "day" },
{ "label": "Dusk", "value": "dusk" },
{ "label": "Night", "value": "night" }
]
}
}
]
}
},
"trainerValues": {
"trainerId": "mock-trainer-1",
"values": {
"god_mode": false,
"player_health": 83,
"player_money": 15000,
"restock_ammo": 0,
"difficulty": "normal",
"game_speed": 1,
"time_of_day": "day"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wand</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "wand-web-panel",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:host": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"preview:host": "vite preview --host 0.0.0.0",
"bridge": "node ./bridge/server.mjs"
},
"dependencies": {
"preact": "^10.27.2",
"ws": "^8.18.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^7.3.2"
}
}
+2251
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
# Custom Renderer Scripts
Place user scripts here as plain `.js` files before running the Wand patch.
During patching, Wand Enhancer copies default scripts from `web-panel/scripts/default` and user scripts from this folder into `remote-panel/renderer-scripts` inside `app.asar`. Scripts are loaded in filename order on every Wand renderer startup.
Each script runs in the Wand renderer and receives a small global API:
```js
(function (WandEnhancer) {
WandEnhancer.log('custom script loaded', WandEnhancer.remoteUrl);
})(globalThis.WandEnhancer);
```
Use unique global guards for repeat-safe scripts because the renderer can be reinjected after navigation.
@@ -0,0 +1,8 @@
(function installUserHudMarker(WandEnhancer) {
if (globalThis.__wandEnhancerUserHudMarkerInstalled) {
return;
}
globalThis.__wandEnhancerUserHudMarkerInstalled = true;
WandEnhancer.log('user script loaded', WandEnhancer.remoteUrl);
})(globalThis.WandEnhancer);
@@ -0,0 +1,8 @@
(() => {
if (document.documentElement.dataset.wandEnhancerCustomScript === 'loaded') {
return;
}
document.documentElement.dataset.wandEnhancerCustomScript = 'loaded';
console.info('[Wand Enhancer] Custom renderer script loaded');
})();
@@ -0,0 +1,94 @@
(function installRemotePopupCleanup(WandEnhancer) {
if (globalThis.__wandRemotePopupCleanupInstalled) {
return;
}
globalThis.__wandRemotePopupCleanupInstalled = true;
const style = document.createElement('style');
style.id = 'wand-remote-popup-cleanup-style';
style.textContent = `
remote-tooltip .remote-tooltip .top-wrapper,
remote-tooltip .remote-tooltip .remote-tooltip-section-divider,
remote-tooltip .remote-tooltip .instructions .header,
remote-tooltip .remote-tooltip .instructions .content .text,
remote-tooltip .remote-tooltip .instructions .platforms {
display: none !important;
}
remote-tooltip .remote-tooltip .instructions-wrapper {
margin: 0 !important;
padding: 18px !important;
text-align: center !important;
}
remote-tooltip .remote-tooltip .instructions,
remote-tooltip .remote-tooltip .instructions .content {
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
gap: 0 !important;
}
remote-tooltip .remote-tooltip .instructions remote-qr-code {
all: unset !important;
--wand-qr-size: clamp(220px, 100vw, 300px);
width: var(--wand-qr-size) !important;
height: var(--wand-qr-size) !important;
min-width: var(--wand-qr-size) !important;
min-height: var(--wand-qr-size) !important;
max-width: var(--wand-qr-size) !important;
max-height: var(--wand-qr-size) !important;
flex: 0 0 var(--wand-qr-size) !important;
aspect-ratio: 1 / 1 !important;
display: block !important;
border-radius: 12px !important;
overflow: hidden !important;
transform: none !important;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35) !important;
}
remote-tooltip .remote-tooltip .instructions remote-qr-code canvas {
width: 100% !important;
height: 100% !important;
aspect-ratio: 1 / 1 !important;
display: block !important;
object-fit: contain !important;
image-rendering: pixelated !important;
border-radius: 12px !important;
transform: none !important;
}
`;
const installStyle = () => {
if (!document.getElementById(style.id)) {
document.head.appendChild(style);
}
};
const updateLinks = () => {
const remoteUrl = globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl;
if (!remoteUrl) {
return;
}
for (const anchor of document.querySelectorAll('remote-tooltip a[href]')) {
anchor.setAttribute('href', remoteUrl);
anchor.textContent = remoteUrl.replace(/\/$/, '');
}
};
installStyle();
updateLinks();
const observer = new MutationObserver(() => {
installStyle();
updateLinks();
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
})(globalThis.WandEnhancer);
+221
View File
@@ -0,0 +1,221 @@
import { useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { CategorySection } from '@/features/remote-panel/components/CategorySection';
import { ConnectionPanel } from '@/features/remote-panel/components/ConnectionPanel';
import { DeckHeader } from '@/features/remote-panel/components/DeckHeader';
import { EmptyDeck } from '@/features/remote-panel/components/EmptyDeck';
import { TrainerOverview } from '@/features/remote-panel/components/TrainerOverview';
import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from '@/features/remote-panel/category';
import { handleProtocolMessage } from '@/features/remote-panel/message-handler';
import { normalizeOutgoingValue, type CheatSchema, type TrainerMetaPayload } from '@/features/remote-panel/protocol';
import {
getPinnedStorageKey,
loadPinnedTargets,
savePinnedTargets,
} from '@/features/remote-panel/pinned-storage';
import { PanelSocketClient } from '@/features/remote-panel/socket-client';
import { createInitialPanelState, panelReducer } from '@/features/remote-panel/state';
import { Icon } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
export function App() {
const [state, dispatch] = useReducer(panelReducer, createInitialPanelState());
const [searchQuery, setSearchQuery] = useState('');
const clientRef = useRef<PanelSocketClient | null>(null);
const trainerMetaRef = useRef<TrainerMetaPayload | null>(state.trainerMeta);
useEffect(() => {
return () => {
clientRef.current?.disconnect();
clientRef.current = null;
};
}, []);
useEffect(() => {
trainerMetaRef.current = state.trainerMeta;
}, [state.trainerMeta]);
const groups = useMemo(() => groupCheatsByCategory(state.trainerMeta), [state.trainerMeta]);
const pinnedGroup = useMemo(
() => buildPinnedGroup(state.trainerMeta, state.pinnedTargets),
[state.trainerMeta, state.pinnedTargets],
);
const filteredGroups = useMemo(() => filterGroups(groups, searchQuery), [groups, searchQuery]);
const filteredPinnedGroup = useMemo(
() => (pinnedGroup ? filterGroups([pinnedGroup], searchQuery)[0] ?? null : null),
[pinnedGroup, searchQuery],
);
const pinnedStorageKey = useMemo(
() => getPinnedStorageKey(state.trainerMeta?.trainer ?? null),
[state.trainerMeta?.trainer],
);
const activeTrainer = state.trainerMeta?.trainer ?? null;
const cheatCount = state.trainerMeta?.schema.cheats.length ?? 0;
const controlsDisabled = Boolean(activeTrainer?.trainerLoading || activeTrainer?.isTimeLimitExpired);
useEffect(() => {
dispatch({ type: 'setPinnedTargets', pinned: loadPinnedTargets(pinnedStorageKey) });
}, [pinnedStorageKey]);
function connect(): void {
clientRef.current?.disconnect();
const wsUrl = state.wsUrl.trim();
if (!wsUrl) {
dispatch({ type: 'error', message: 'Enter a WebSocket URL first.' });
return;
}
const nextClient = new PanelSocketClient(wsUrl, {
onConnecting: () => dispatch({ type: 'connecting' }),
onOpen: () => dispatch({ type: 'connected' }),
onMessage: (message) => handleProtocolMessage(dispatch, message, trainerMetaRef.current),
onClose: () => dispatch({ type: 'error', message: 'The WebSocket connection closed.' }),
onError: (message) => dispatch({ type: 'error', message }),
});
clientRef.current = nextClient;
nextClient.connect();
}
function handleCheatChange(cheat: CheatSchema, nextValue: unknown): void {
const normalizedValue = normalizeOutgoingValue(cheat, nextValue);
dispatch({ type: 'setPending', target: cheat.target, pending: true });
dispatch({ type: 'valueChanged', target: cheat.target, value: normalizedValue });
if (state.connectionStatus !== 'connected' || !state.trainerMeta || !clientRef.current) {
dispatch({ type: 'setPending', target: cheat.target, pending: false });
return;
}
const sent = clientRef.current.setValue(state.trainerMeta.trainer.trainerId, cheat.target, normalizedValue, cheat.uuid);
if (!sent) {
dispatch({ type: 'setPending', target: cheat.target, pending: false });
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
}
}
function handleTogglePin(cheat: CheatSchema): void {
const next = { ...state.pinnedTargets };
if (next[cheat.target]) {
delete next[cheat.target];
} else {
next[cheat.target] = true;
}
dispatch({ type: 'togglePinnedTarget', target: cheat.target });
savePinnedTargets(pinnedStorageKey, next);
}
async function loadDebugSession(): Promise<void> {
if (!import.meta.env.DEV) {
return;
}
clientRef.current?.disconnect();
const debugSession = await import('@/features/remote-panel/debug-session');
debugSession.loadDebugSession(dispatch);
}
useEffect(() => {
if (import.meta.env.DEV) {
void import('@/features/remote-panel/debug-session').then((debugSession) => {
if (debugSession.isDebugSessionRequested()) {
debugSession.loadDebugSession(dispatch);
return;
}
if (state.wsUrl.trim()) {
connect();
}
});
return;
}
if (!state.wsUrl.trim()) {
return;
}
connect();
}, []);
return (
<main className="min-h-svh overflow-hidden bg-background px-2 py-2 text-foreground sm:px-5 sm:py-3 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-3 sm:gap-4">
<DeckHeader connectionStatus={state.connectionStatus} remoteUrl={state.remoteUrl} />
<div className="grid gap-3 sm:gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<aside className="space-y-3 sm:space-y-4">
<ConnectionPanel
status={state.connectionStatus}
wsUrl={state.wsUrl}
lastError={state.lastError}
onConnect={connect}
onDebugSession={import.meta.env.DEV ? loadDebugSession : undefined}
onWsUrlChange={(wsUrl) => dispatch({ type: 'setWsUrl', wsUrl })}
/>
</aside>
<section className="space-y-4 sm:space-y-5">
{activeTrainer ? (
<>
<TrainerOverview trainer={activeTrainer} cheatCount={cheatCount} categoryCount={groups.length} />
<div className="relative">
<Icon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" name="search" />
<Input
type="text"
value={searchQuery}
onInput={(event) => setSearchQuery((event.target as HTMLInputElement).value)}
placeholder="Search cheats, categories, targets..."
className="h-9 pl-8 pr-8 text-sm"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery('')}
aria-label="Clear search"
className="absolute right-2 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:text-white"
>
<Icon className="size-3.5" name="x" />
</button>
) : null}
</div>
<div className="space-y-4 sm:space-y-7">
{filteredPinnedGroup ? (
<CategorySection
key="__pinned__"
group={filteredPinnedGroup}
values={state.values}
pendingTargets={state.pendingTargets}
pinnedTargets={state.pinnedTargets}
disabled={controlsDisabled}
onCheatChange={handleCheatChange}
onTogglePin={handleTogglePin}
/>
) : null}
{filteredGroups.map((group) => (
<CategorySection
key={group.id}
group={group}
values={state.values}
pendingTargets={state.pendingTargets}
pinnedTargets={state.pinnedTargets}
disabled={controlsDisabled}
onCheatChange={handleCheatChange}
onTogglePin={handleTogglePin}
/>
))}
{searchQuery && filteredGroups.length === 0 && !filteredPinnedGroup ? (
<p className="rounded-[8px] border border-white/10 bg-white/4.5 px-3 py-4 text-center text-sm text-muted-foreground">
No cheats match "{searchQuery}".
</p>
) : null}
</div>
</>
) : (
<EmptyDeck />
)}
</section>
</div>
</div>
</main>
);
}
+29
View File
@@ -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<BadgeVariant, string> = {
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 (
<span
data-slot="badge"
data-variant={variant}
className={cn(BADGE_BASE, BADGE_VARIANTS[variant], className)}
{...props}
/>
)
}
export { Badge }
+36
View File
@@ -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<ButtonVariant, string> = {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: "border-border hover:bg-input/50 hover:text-foreground",
}
const BUTTON_SIZES: Record<ButtonSize, string> = {
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 (
<button
type="button"
data-slot="button"
className={cn(BUTTON_BASE, BUTTON_VARIANTS[variant], BUTTON_SIZES[size], className)}
{...props}
/>
)
}
export { Button }
+70
View File
@@ -0,0 +1,70 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-xs text-card-foreground ring-1 ring-foreground/10",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"grid auto-rows-min items-start gap-1 rounded-t-lg px-4",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading text-sm font-medium", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-xs/relaxed text-muted-foreground", className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
}
+99
View File
@@ -0,0 +1,99 @@
import type { ReactNode, SVGProps } from 'react';
export type IconName =
| 'activity'
| 'alert'
| 'atom'
| 'backpack'
| 'bolt'
| 'box'
| 'boxes'
| 'car'
| 'category'
| 'chart'
| 'chevron-left'
| 'chevron-right'
| 'flame'
| 'flask'
| 'gamepad'
| 'hammer'
| 'heart-broken'
| 'loader'
| 'map-pin'
| 'package'
| 'pin'
| 'pin-off'
| 'plug'
| 'play'
| 'radar'
| 'refresh'
| 'shield-bolt'
| 'sparkles'
| 'search'
| 'swords'
| 'trophy'
| 'user'
| 'wifi'
| 'world'
| 'x';
const ICON_PATHS: Record<IconName, ReactNode> = {
activity: <path d="M3 12h4l2-6 4 12 2-6h6" />,
alert: <><path d="M12 3 2.8 20h18.4z" /><path d="M12 9v4" /><path d="M12 17h.01" /></>,
atom: <><circle cx="12" cy="12" r="1.5" /><path d="M4 12c2-4 14-4 16 0" /><path d="M4 12c2 4 14 4 16 0" /><path d="M12 4c4 2 4 14 0 16" /></>,
backpack: <><path d="M8 8V7a4 4 0 0 1 8 0v1" /><path d="M6 9h12v11H6z" /><path d="M9 14h6" /></>,
bolt: <path d="m13 2-8 12h6l-1 8 8-12h-6z" />,
box: <><path d="m12 3 8 4.5v9L12 21l-8-4.5v-9z" /><path d="m4 7.5 8 4.5 8-4.5" /><path d="M12 12v9" /></>,
boxes: <><path d="M4 7h7v7H4z" /><path d="M13 10h7v7h-7z" /><path d="M7 16h7v5H7z" /></>,
car: <><path d="M5 13 7 7h10l2 6" /><path d="M5 13h14v5H5z" /><path d="M8 18v2" /><path d="M16 18v2" /></>,
category: <><path d="M4 4h7v7H4z" /><path d="M13 4h7v7h-7z" /><path d="M4 13h7v7H4z" /><path d="M13 13h7v7h-7z" /></>,
chart: <><path d="M4 19V5" /><path d="M4 19h16" /><path d="M8 15v-4" /><path d="M12 15V8" /><path d="M16 15v-6" /></>,
'chevron-left': <path d="m15 6-6 6 6 6" />,
'chevron-right': <path d="m9 6 6 6-6 6" />,
flame: <path d="M12 22c4 0 7-3 7-7 0-3-2-5-5-8 0 3-2 4-4 5 0-3-1-5-3-7 0 5-3 7-3 10 0 4 3 7 8 7z" />,
flask: <><path d="M9 3h6" /><path d="M10 3v5l-5 9a3 3 0 0 0 2.6 4h8.8a3 3 0 0 0 2.6-4l-5-9V3" /><path d="M8 15h8" /></>,
gamepad: <><path d="M6 11h12a4 4 0 0 1 4 4v1a3 3 0 0 1-5.2 2L15 16H9l-1.8 2A3 3 0 0 1 2 16v-1a4 4 0 0 1 4-4z" /><path d="M7 15h4" /><path d="M9 13v4" /><path d="M16.5 14.5h.01" /><path d="M18.5 16.5h.01" /></>,
hammer: <><path d="M14 5 5 14" /><path d="m4 15 5 5" /><path d="M12 3h5l4 4-3 3-4-4" /></>,
'heart-broken': <path d="M20 8.5c0 6-8 11.5-8 11.5S4 14.5 4 8.5A4.5 4.5 0 0 1 12 6a4.5 4.5 0 0 1 8 2.5zM12 6l-2 4 4 2-2 4" />,
loader: <><path d="M12 3a9 9 0 1 0 9 9" /><path d="M21 12a9 9 0 0 0-9-9" /></>,
'map-pin': <><path d="M12 21s7-5.2 7-11a7 7 0 1 0-14 0c0 5.8 7 11 7 11z" /><circle cx="12" cy="10" r="2" /></>,
package: <><path d="M5 8h14v11H5z" /><path d="m8 8 2-4h4l2 4" /><path d="M12 8v11" /></>,
pin: <path fill="currentColor" stroke="none" d="M16,12V4H17V2H7V4H8V12L6,14V16H11.2V22H12.8V16H18V14L16,12Z" />,
'pin-off': <path fill="currentColor" stroke="none" d="M2,5.27L3.28,4L20,20.72L18.73,22L12.8,16.07V22H11.2V16H6V14L8,12V11.27L2,5.27M16,12L18,14V16H17.82L8,6.18V4H7V2H17V4H16V12Z" />,
plug: <><path d="M8 2v6" /><path d="M16 2v6" /><path d="M7 8h10v4a5 5 0 0 1-10 0z" /><path d="M12 17v5" /></>,
play: <path d="m8 5 11 7-11 7z" />,
radar: <><circle cx="12" cy="12" r="2" /><path d="M12 4a8 8 0 0 1 8 8" /><path d="M4 12a8 8 0 0 1 8-8" /><path d="M12 20a8 8 0 0 1-8-8" /><path d="M12 12l6-6" /></>,
refresh: <><path d="M20 6v5h-5" /><path d="M4 18v-5h5" /><path d="M18 11a6 6 0 0 0-10-4L4 11" /><path d="M6 13a6 6 0 0 0 10 4l4-4" /></>,
'shield-bolt': <><path d="M12 3 20 6v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" /><path d="m13 7-4 6h3l-1 4 4-6h-3z" /></>,
sparkles: <><path d="m12 3 1.6 5.4L19 10l-5.4 1.6L12 17l-1.6-5.4L5 10l5.4-1.6z" /><path d="m5 16 .8 2.2L8 19l-2.2.8L5 22l-.8-2.2L2 19l2.2-.8z" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>,
swords: <><path d="M14 6 20 0" /><path d="m14 6 4 4" /><path d="M4 20 14 10" /><path d="M10 6 4 0" /><path d="m10 6-4 4" /><path d="M20 20 10 10" /></>,
trophy: <><path d="M8 4h8v4a4 4 0 0 1-8 0z" /><path d="M8 6H4a4 4 0 0 0 4 4" /><path d="M16 6h4a4 4 0 0 1-4 4" /><path d="M12 12v5" /><path d="M8 21h8" /></>,
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></>,
wifi: <><path d="M4 9a12 12 0 0 1 16 0" /><path d="M7 12a7 7 0 0 1 10 0" /><path d="M10 15a3 3 0 0 1 4 0" /><path d="M12 19h.01" /></>,
world: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18" /><path d="M12 3a15 15 0 0 1 0 18" /><path d="M12 3a15 15 0 0 0 0 18" /></>,
x: <><path d="M6 6l12 12" /><path d="M18 6 6 18" /></>,
};
type IconProps = Omit<SVGProps<SVGSVGElement>, 'stroke'> & {
name: IconName;
stroke?: number | string;
};
export function Icon({ name, className, stroke = 1.8, ...props }: IconProps) {
return (
<svg
aria-hidden="true"
className={className}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={stroke}
viewBox="0 0 24 24"
{...props}
>
{ICON_PATHS[name]}
</svg>
);
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-7 w-full min-w-0 rounded-md border border-input bg-input/20 px-2 py-0.5 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-xs",
className
)}
{...props}
/>
)
}
export { Input }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-xs/relaxed leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+40
View File
@@ -0,0 +1,40 @@
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
disabled,
onValueChange,
value,
min = 0,
max = 100,
step = 1,
...props
}: Omit<ComponentProps<"input">, "defaultValue" | "onChange" | "type" | "value"> & {
defaultValue?: number
onValueChange?: (value: number) => void
value?: number
}) {
const currentValue = Number(value ?? defaultValue ?? min)
return (
<input
type="range"
data-slot="slider"
defaultValue={defaultValue}
disabled={disabled}
className={cn("h-2 w-full cursor-pointer accent-primary disabled:cursor-not-allowed disabled:opacity-50", className)}
value={value}
min={min}
max={max}
step={step}
onChange={(event) => onValueChange?.(Number(event.currentTarget.value))}
{...props}
aria-valuenow={currentValue}
/>
)
}
export { Slider }
+46
View File
@@ -0,0 +1,46 @@
import type { ComponentProps } from "react"
import { cn } from "@/lib/utils"
function Switch({
checked = false,
className,
disabled,
onCheckedChange,
size = "default",
...props
}: Omit<ComponentProps<"button">, "onChange"> & {
checked?: boolean
onCheckedChange?: (checked: boolean) => void
size?: "sm" | "default"
}) {
return (
<button
type="button"
aria-checked={checked}
data-slot="switch"
data-size={size}
disabled={disabled}
role="switch"
className={cn(
"relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50",
size === "sm" ? "h-3.5 w-6" : "h-4 w-7",
checked ? "bg-primary" : "bg-input",
className
)}
onClick={() => onCheckedChange?.(!checked)}
{...props}
>
<span
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform",
size === "sm" ? "size-3" : "size-3.5",
checked ? "translate-x-[calc(100%-2px)]" : "translate-x-0"
)}
/>
</button>
)
}
export { Switch }
@@ -0,0 +1,155 @@
import { Icon, type IconName } from '@/components/ui/icon';
import type { CheatSchema, TrainerMetaPayload, TrainerSummary } from './protocol';
const CATEGORY_LABELS: Record<string, string> = {
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<string, IconName> = {
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<string, string> = {
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<string, CheatSchema[]>();
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<string, true>,
): 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 <Icon className={className} name={CATEGORY_ICONS[category.toLowerCase()] ?? 'gamepad'} stroke={1.8} />;
}
@@ -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<string, unknown>;
pendingTargets: Record<string, boolean>;
pinnedTargets: Record<string, true>;
disabled: boolean;
onCheatChange: (cheat: CheatSchema, nextValue: unknown) => void;
onTogglePin: (cheat: CheatSchema) => void;
};
export function CategorySection({
group,
values,
pendingTargets,
pinnedTargets,
disabled,
onCheatChange,
onTogglePin,
}: CategorySectionProps) {
return (
<section className="space-y-2 sm:space-y-3">
<header className="flex items-center justify-between gap-2 sm:gap-3">
<div className="flex items-center gap-2 sm:gap-3">
<div className={cn('flex size-8 items-center justify-center rounded-[8px] ring-1 sm:size-10', getCategoryAccent(group.id))}>
<CategoryIcon category={group.id} className="size-4 sm:size-5" />
</div>
<div>
<h3 className="text-base font-bold text-white sm:text-xl">{group.label}</h3>
<p className="text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground sm:text-xs">{group.id}</p>
</div>
</div>
<Badge className="border-white/10 bg-white/5 text-white" variant="outline">
{group.cheats.length} nodes
</Badge>
</header>
<div className="grid gap-2 sm:gap-3 lg:grid-cols-2 xl:grid-cols-3">
{group.cheats.map((cheat) => (
<CheatTile
key={cheat.uuid}
cheat={cheat}
value={values[cheat.target]}
pending={Boolean(pendingTargets[cheat.target])}
pinned={Boolean(pinnedTargets[cheat.target])}
disabled={disabled}
onChange={(nextValue) => onCheatChange(cheat, nextValue)}
onTogglePin={() => onTogglePin(cheat)}
/>
))}
</div>
</section>
);
}

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