Release 1.0.7.0: Remote Web Panel & Stability Fixes

This commit is contained in:
kitbyte
2026-05-01 22:23:27 +03:00
parent 5273930eef
commit fa753b4a34
76 changed files with 6676 additions and 50 deletions
+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
+1
View File
@@ -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");
+18 -2
View File
@@ -20,8 +20,20 @@ Yes. This project is entirely open-source, allowing anyone to audit the code. It
✅ Local environment configuration management <br/>
✅ Automated compatibility adjustments for new client versions <br/>
✅ Advanced layout and theme customization (Client-side only) <br/>
✅ AI Features
Remote/Mobile connectivity features <br/>
✅ AI Features <br/>
Remote web panel (Remote Connect on mobile) <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?
@@ -41,7 +53,11 @@ Yes. This project is entirely open-source, allowing anyone to audit the code. It
---
## 🖼️ Screenshots
![1](./assets/screenshots/app1.png)
<div align='center'>
![2](./assets/screenshots/app2.png)
</div>
---
## 📜 License
+259 -13
View File
@@ -4,27 +4,45 @@ using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using AsarSharp;
using Newtonsoft.Json;
using WandEnhancer.Models;
using WandEnhancer.Utils;
using WandEnhancer.View.MainWindow;
using Application = System.Windows.Application;
namespace WandEnhancer.Core
{
public class Enhancer
{
private const string ResourcesDirectoryName = "resources";
private const string AppAsarFileName = "app.asar";
private const string AppAsarUnpackedDirectoryName = "app.asar.unpacked";
private const string AppAsarBackupFileName = "app.asar.backup";
private const string AppAsarUnpackedBackupDirectoryName = "app.asar.unpacked.backup";
private const string WebPanelDirectoryName = "web-panel";
private const string WebPanelDistDirectoryName = "dist";
private const string WebPanelBridgeDirectoryName = "bridge";
private const string WebPanelScriptsDirectoryName = "scripts";
private const string DefaultScriptsDirectoryName = "default";
private const string LocalCustomScriptsDirectoryName = "renderer-scripts";
private const string RemotePanelDirectoryName = "remote-panel";
private const string RemoteBridgeSourceFileName = "wand-remote-bridge.cjs";
private const string RemoteBridgeTargetFileName = "bridge.cjs";
private const string RemoteRendererScriptsDirectoryName = "renderer-scripts";
private const string EmbeddedRemotePanelDistPrefix = "remote-panel/dist/";
private const string EmbeddedRemotePanelBridgeResourceName = "remote-panel/bridge.cjs";
private const string EmbeddedRemotePanelDefaultScriptsPrefix = "remote-panel/renderer-scripts/";
private const string JavaScriptFileExtension = ".js";
private const string JavaScriptFileSearchPattern = "*.js";
private const string DuplicateScriptSuffix = ".custom";
private const int FirstDuplicateScriptIndex = 1;
private readonly WeModConfig _weModConfig;
private readonly Action<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)
{
@@ -32,9 +50,10 @@ namespace WandEnhancer.Core
_logger = logger;
_config = config;
_asarPath = Path.Combine(weModConfig.RootDirectory, "resources", "app.asar");
_unpackedPath = Path.Combine(weModConfig.RootDirectory, "resources", "app.asar.unpacked");
_backupPath = Path.Combine(weModConfig.RootDirectory, "resources", "app.asar.backup");
_asarPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarFileName);
_unpackedPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedDirectoryName);
_backupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarBackupFileName);
_unpackedBackupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedBackupDirectoryName);
}
private string ApplyJsPatch(string fileName, string js, EnhancerConfig.PatchEntry patch, EPatchType patchType)
@@ -90,7 +109,6 @@ namespace WandEnhancer.Core
throw new Exception("[ENHANCER] No app bundle found");
}
// Track patches that still need to be completed
var remainingPatches = new HashSet<EPatchType>(_config.PatchTypes);
var enhancerConfig = EnhancerConfig.GetInstance();
@@ -103,17 +121,14 @@ namespace WandEnhancer.Core
string data = File.ReadAllText(item);
// Iterate over a copy of the list so we can modify the HashSet
foreach (var entry in remainingPatches.ToList())
{
var entries = enhancerConfig[entry];
foreach (var patchEntry in entries)
{
// Update data in memory so subsequent patches in the same file work on latest content
data = ApplyJsPatch(item, data, patchEntry, entry);
}
// Check if all entries for this patch type are applied
if (entries.All(x => x.Applied))
{
remainingPatches.Remove(entry);
@@ -128,6 +143,215 @@ namespace WandEnhancer.Core
}
}
private static string FindWorkspacePath(params string[] segments)
{
string current = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
while (!string.IsNullOrEmpty(current))
{
string candidate = Path.Combine(new[] { current }.Concat(segments).ToArray());
if (Directory.Exists(candidate) || File.Exists(candidate))
{
return candidate;
}
current = Directory.GetParent(current)?.FullName;
}
throw new FileNotFoundException($"Required workspace artifact not found: {Path.Combine(segments)}");
}
private static void CopyDirectory(string sourceDir, string destinationDir)
{
Directory.CreateDirectory(destinationDir);
foreach (var directory in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
{
var relativePath = directory.Substring(sourceDir.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
}
foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
{
var relativePath = file.Substring(sourceDir.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var destinationPath = Path.Combine(destinationDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath) ?? destinationDir);
File.Copy(file, destinationPath, true);
}
}
private static int CopyJavaScriptFiles(string sourceDir, string destinationDir)
{
if (string.IsNullOrEmpty(sourceDir) || !Directory.Exists(sourceDir))
{
return 0;
}
Directory.CreateDirectory(destinationDir);
int copied = 0;
foreach (var file in Directory.GetFiles(sourceDir, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly))
{
File.Copy(file, GetAvailableScriptPath(destinationDir, Path.GetFileName(file)));
copied++;
}
return copied;
}
private static string GetAvailableScriptPath(string destinationDir, string fileName)
{
string destinationPath = Path.Combine(destinationDir, fileName);
if (!File.Exists(destinationPath))
{
return destinationPath;
}
string name = Path.GetFileNameWithoutExtension(fileName);
string extension = Path.GetExtension(fileName);
for (int index = FirstDuplicateScriptIndex; ; index++)
{
destinationPath = Path.Combine(destinationDir, $"{name}{DuplicateScriptSuffix}{index}{extension}");
if (!File.Exists(destinationPath))
{
return destinationPath;
}
}
}
private static int CopyEmbeddedDirectory(string resourcePrefix, string destinationDir)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceNames = assembly.GetManifestResourceNames()
.Where(name => name.StartsWith(resourcePrefix, StringComparison.Ordinal))
.ToList();
if (resourceNames.Count == 0)
{
return 0;
}
Directory.CreateDirectory(destinationDir);
foreach (var resourceName in resourceNames)
{
var relativePath = resourceName.Substring(resourcePrefix.Length)
.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar);
var destinationPath = Path.Combine(destinationDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath) ?? destinationDir);
using (var resource = assembly.GetManifestResourceStream(resourceName))
{
if (resource == null)
{
throw new FileNotFoundException($"Embedded resource not found: {resourceName}");
}
using (var output = File.Create(destinationPath))
{
resource.CopyTo(output);
}
}
}
return resourceNames.Count;
}
private static bool CopyEmbeddedFile(string resourceName, string destinationPath)
{
var assembly = Assembly.GetExecutingAssembly();
using (var resource = assembly.GetManifestResourceStream(resourceName))
{
if (resource == null)
{
return false;
}
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath) ?? ".");
using (var output = File.Create(destinationPath))
{
resource.CopyTo(output);
}
}
return true;
}
private static string FindLocalCustomScriptsPath()
{
string executableDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
if (string.IsNullOrEmpty(executableDirectory))
{
return null;
}
string localScripts = Path.Combine(executableDirectory, LocalCustomScriptsDirectoryName);
return Directory.Exists(localScripts) ? localScripts : null;
}
private static int CopySelectedJavaScriptFiles(IEnumerable<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();
@@ -154,7 +378,28 @@ namespace WandEnhancer.Core
}
else
{
_logger("[ENHANCER] Backup already exists", ELogType.Warn);
_logger("[ENHANCER] Backup found, restoring pristine app.asar before patching...", ELogType.Info);
File.Copy(_backupPath, _asarPath, true);
}
if (!Directory.Exists(_unpackedBackupPath) && Directory.Exists(_unpackedPath))
{
_logger("[ENHANCER] Creating backup of app.asar.unpacked...", ELogType.Info);
CopyDirectory(_unpackedPath, _unpackedBackupPath);
}
else if (Directory.Exists(_unpackedBackupPath))
{
_logger("[ENHANCER] Restoring pristine app.asar.unpacked before patching...", ELogType.Info);
if (Directory.Exists(_unpackedPath))
{
Directory.Delete(_unpackedPath, true);
}
CopyDirectory(_unpackedBackupPath, _unpackedPath);
}
else if (!Directory.Exists(_unpackedPath))
{
throw new Exception("[ENHANCER] app.asar.unpacked is missing and no backup exists. Restore the original Wand installation files or reinstall Wand, then patch again.");
}
if(!File.Exists(_asarPath))
@@ -173,6 +418,7 @@ namespace WandEnhancer.Core
}
PatchAsar();
InjectRemotePanelFiles();
try
{
+65 -5
View File
@@ -7,6 +7,9 @@ namespace WandEnhancer.Core
{
public static class EnhancerConfig
{
private const int RemoteWebPanelDefaultPort = 3223;
private static readonly string RemoteWebPanelFallbackUrl = $"http://localhost:{RemoteWebPanelDefaultPort}/remote/";
public class ResolveContext
{
public string Placeholder { get; set; }
@@ -86,16 +89,73 @@ namespace WandEnhancer.Core
{
new PatchEntry
{
Name = "devToolsBeforeInputEvent",
// Anchor on the Electron main-process `<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\.#(\w+)\(""ACTION_OPEN_DEV_TOOLS""\)");
Handler = (matchContent) =>
{
var match = Regex.Match(matchContent, @"this\.canvasElement&&(\w+)\.mo");
return match.Success ? match.Groups[1].Value : null;
},
Placeholder = "<dispatch_method>"
Placeholder = "<qr_writer>"
},
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\")}))"
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)"
}
}
}
+7
View File
@@ -16,6 +16,8 @@
<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 -->
@@ -28,6 +30,11 @@
<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 verbessern?</s:String>
<!--#endregion -->
+7
View File
@@ -16,6 +16,8 @@
<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 -->
@@ -28,6 +30,11 @@
<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 enhance?</s:String>
<!--#endregion -->
+7
View File
@@ -16,6 +16,8 @@
<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 -->
@@ -28,6 +30,11 @@
<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 mejorar?</s:String>
<!--#endregion -->
+7
View File
@@ -16,6 +16,8 @@
<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 -->
@@ -28,6 +30,11 @@
<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 modifier ?</s:String>
<!--#endregion -->
+7
View File
@@ -16,6 +16,8 @@
<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 -->
@@ -28,6 +30,11 @@
<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 modificheremo?</s:String>
<!--#endregion -->
+7
View File
@@ -16,6 +16,8 @@
<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 -->
@@ -28,6 +30,11 @@
<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>
<!--#endregion -->
+7
View File
@@ -16,6 +16,8 @@
<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 -->
@@ -28,6 +30,11 @@
<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 ulepszać?</s:String>
<!--#endregion -->
+7
View File
@@ -16,6 +16,8 @@
<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 -->
@@ -28,6 +30,11 @@
<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 melhorar?</s:String>
<!--#endregion -->
+7
View File
@@ -16,6 +16,8 @@
<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 -->
@@ -28,6 +30,11 @@
<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>
<!--#endregion -->
+7
View File
@@ -16,6 +16,8 @@
<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 -->
@@ -28,6 +30,11 @@
<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">Neyi geliştireceğiz?</s:String>
<!--#endregion -->
+7
View File
@@ -16,6 +16,8 @@
<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 -->
@@ -28,6 +30,11 @@
<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>
<!--#endregion -->
+7
View File
@@ -16,6 +16,8 @@
<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 -->
@@ -28,6 +30,11 @@
<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>
<!--#endregion -->
+4 -2
View File
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using WandEnhancer.Utils;
@@ -12,13 +11,16 @@ namespace WandEnhancer.Models
ActivatePro = 1,
DisableUpdates = 2,
DisableTelemetry = 4,
DevToolsOnF12 = 8
DevToolsOnF12 = 8,
RemoteWebPanelPreview = 16
}
public sealed class PatchConfig
{
private string _path;
public HashSet<EPatchType> PatchTypes { get; set; }
public List<string> CustomScriptPaths { get; set; } = new List<string>();
public bool AutoApplyPatches { get; set; }
+2 -2
View File
@@ -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
View File
@@ -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="">
+16 -1
View File
@@ -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>
@@ -2,6 +2,7 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using WandEnhancer.Core;
@@ -73,6 +74,8 @@ namespace WandEnhancer.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)
{
@@ -208,6 +211,64 @@ namespace WandEnhancer.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 WandEnhancer.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)
+68 -16
View File
@@ -11,35 +11,87 @@
Foreground="{DynamicResource MutedForeground}"
FontWeight="Medium"
FontSize="13">
<Grid>
<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" VerticalAlignment="Center" Text="{DynamicResource pv_activate_pro}" />
<CheckBox Grid.Row="0" x:Name="ActivateProBox" HorizontalAlignment="Right" VerticalAlignment="Center"
<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" VerticalAlignment="Center" Text="{DynamicResource pv_devtools}" />
<CheckBox Grid.Row="1" x:Name="DevToolsHotkeyBox" HorizontalAlignment="Right" VerticalAlignment="Center" />
<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" VerticalAlignment="Center" Text="{DynamicResource pv_disable_updates}" />
<CheckBox Grid.Row="2" x:Name="DisableUpdateBox" 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
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" />-->
<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" />
<Button Grid.Row="3" Padding="0 5 0 5" Margin="0 15 0 0" Content="{DynamicResource pv_start}"
<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>
@@ -1,26 +1,75 @@
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;
using WandEnhancer.View.Controls;
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)
DevToolsHotkeyBox.IsChecked != true && RemoteWebPanelPreviewBox.IsChecked != true)
{
return;
}
@@ -41,11 +90,51 @@ namespace WandEnhancer.View.Popups
result.Add(EPatchType.DevToolsOnF12);
}
if (RemoteWebPanelPreviewBox.IsChecked == true)
{
result.Add(EPatchType.RemoteWebPanelPreview);
}
_onApply(new PatchConfig
{
PatchTypes = result,
AutoApplyPatches =/* AutoUpdates.IsChecked == true*/ false
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; }
}
}
}
+11
View File
@@ -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">
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>
);
}
@@ -0,0 +1,53 @@
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Icon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import type { CheatSchema } from '../protocol';
import { CheatControl } from '../controls/CheatControl';
type CheatTileProps = {
cheat: CheatSchema;
value: unknown;
pending: boolean;
disabled: boolean;
pinned: boolean;
onChange: (nextValue: unknown) => void;
onTogglePin: () => void;
};
export function CheatTile({ cheat, value, pending, disabled, pinned, onChange, onTogglePin }: CheatTileProps) {
return (
<Card className="rounded-[8px] border-white/10 bg-white/4.5 shadow-xl shadow-black/20 transition-colors hover:border-emerald-300/25">
<CardHeader className="gap-1.5 p-3 sm:gap-2 sm:p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate text-[0.95rem] font-bold text-white sm:text-base">{cheat.name}</CardTitle>
{cheat.description ? <p className="mt-0.5 line-clamp-2 text-[0.78rem] text-muted-foreground sm:text-sm">{cheat.description}</p> : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{pending ? <Icon className="size-4 animate-spin text-emerald-200" name="loader" /> : null}
<Badge className="hidden border-white/10 bg-black/20 text-white sm:inline-flex" variant="outline">{cheat.type}</Badge>
<button
type="button"
onClick={onTogglePin}
aria-label={pinned ? 'Unpin cheat' : 'Pin cheat'}
title={pinned ? 'Unpin' : 'Pin to top'}
className={cn(
'flex size-7 items-center justify-center rounded-md border border-white/10 transition-colors',
pinned
? 'bg-amber-300/20 text-amber-200 hover:bg-amber-300/30'
: 'bg-white/5 text-muted-foreground hover:text-white',
)}
>
<Icon className="size-4" name={pinned ? 'pin-off' : 'pin'} />
</button>
</div>
</div>
{cheat.instructions ? <p className="rounded-[8px] border border-amber-300/20 bg-amber-300/10 p-2 text-[0.72rem] text-amber-100 sm:text-xs">{cheat.instructions}</p> : null}
</CardHeader>
<CardContent className="p-3 pt-0 sm:p-4 sm:pt-0">
<CheatControl cheat={cheat} value={value} pending={pending} disabled={disabled} onChange={onChange} />
</CardContent>
</Card>
);
}
@@ -0,0 +1,70 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Icon } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { DEFAULT_REMOTE_PORT } from '../constants';
import type { ConnectionStatus } from '../state';
type ConnectionPanelProps = {
status: ConnectionStatus;
wsUrl: string;
lastError: string | null;
onConnect: () => void;
onDebugSession?: () => void;
onWsUrlChange: (value: string) => void;
};
export function ConnectionPanel({ status, wsUrl, lastError, onConnect, onDebugSession, onWsUrlChange }: ConnectionPanelProps) {
return (
<Card className="rounded-[8px] border-white/10 bg-white/4.5 shadow-xl shadow-black/25">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base font-bold text-white">Bridge uplink</CardTitle>
<CardDescription className="mt-1 text-muted-foreground">Default relay port {DEFAULT_REMOTE_PORT}</CardDescription>
</div>
<Badge className="border border-white/10 bg-white/5 text-white" variant="outline">
{status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="ws-url" className="text-[0.7rem] uppercase tracking-[0.12em] text-muted-foreground">
WebSocket endpoint
</Label>
<Input
id="ws-url"
type="url"
value={wsUrl}
placeholder={`ws://127.0.0.1:${DEFAULT_REMOTE_PORT}/remote/ws`}
className="h-9 rounded-[8px] border-white/10 bg-black/25 font-mono text-[0.8rem] text-white placeholder:text-white/30"
onChange={(event) => onWsUrlChange(event.currentTarget.value)}
/>
</div>
<div className="flex flex-wrap gap-2">
<Button className="h-9 rounded-[8px] bg-emerald-300 text-black hover:bg-emerald-200" onClick={onConnect}>
<Icon className="size-4" name="plug" />
Connect
</Button>
{import.meta.env.DEV && onDebugSession ? (
<Button className="h-9 rounded-[8px] border-white/10 bg-white/5 text-white hover:bg-white/10" variant="outline" onClick={onDebugSession}>
<Icon className="size-4" name="flask" />
Debug session
</Button>
) : null}
</div>
{lastError ? (
<div className="flex items-start gap-2 rounded-[8px] border border-red-300/25 bg-red-500/10 p-3 text-sm text-red-100">
<Icon className="mt-0.5 size-4 shrink-0" name="alert" />
<span>{lastError}</span>
</div>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,48 @@
import { Badge } from '@/components/ui/badge';
import { Icon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import type { ConnectionStatus } from '../state';
const STATUS_LABELS: Record<ConnectionStatus, string> = {
idle: 'Standby',
connecting: 'Linking',
connected: 'Live',
error: 'Fault',
};
const STATUS_CLASSES: Record<ConnectionStatus, string> = {
idle: 'border-amber-300/30 bg-amber-500/10 text-amber-200',
connecting: 'border-sky-300/30 bg-sky-500/10 text-sky-200',
connected: 'border-emerald-300/30 bg-emerald-500/10 text-emerald-200',
error: 'border-red-300/30 bg-red-500/10 text-red-200',
};
export function DeckHeader({ connectionStatus, remoteUrl }: { connectionStatus: ConnectionStatus; remoteUrl: string }) {
return (
<header className="flex flex-col gap-4 rounded-[8px] border border-white/10 bg-white/[0.035] p-4 shadow-2xl shadow-black/30 backdrop-blur md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-[8px] border border-emerald-300/30 bg-emerald-300/10 text-emerald-200 shadow-lg shadow-emerald-500/10">
<Icon className="size-6" name="shield-bolt" stroke={1.7} />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold tracking-normal text-white">Wand Control Deck</h1>
<Badge className="border border-lime-300/30 bg-lime-300/10 text-lime-200" variant="outline">
beta
</Badge>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs font-medium text-muted-foreground">
<span className="inline-flex items-center gap-1"><Icon className="size-3.5" name="wifi" /> local link</span>
<span className="text-white/20">/</span>
<span className="inline-flex min-w-0 items-center gap-1"><Icon className="size-3.5" name="plug" /> <span className="truncate">{remoteUrl.replace(/\/$/, '')}</span></span>
</div>
</div>
</div>
<div className={cn('flex w-fit items-center gap-2 rounded-[8px] border px-3 py-2 text-xs font-semibold uppercase tracking-[0.08em]', STATUS_CLASSES[connectionStatus])}>
<Icon className="size-4" name="activity" />
{STATUS_LABELS[connectionStatus]}
</div>
</header>
);
}
@@ -0,0 +1,18 @@
import { Card, CardContent } from '@/components/ui/card';
import { Icon } from '@/components/ui/icon';
export function EmptyDeck() {
return (
<Card className="rounded-[8px] border-dashed border-white/15 bg-white/[0.035]">
<CardContent className="flex min-h-64 flex-col items-center justify-center gap-3 py-10 text-center">
<div className="flex size-14 items-center justify-center rounded-[8px] border border-cyan-300/25 bg-cyan-500/10 text-cyan-200">
<Icon className="size-8" name="radar" />
</div>
<div>
<h2 className="text-xl font-bold text-white">No trainer signal</h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground">Connect the local bridge to stream trainer controls.</p>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,65 @@
import type { ReactNode } from 'react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Icon } from '@/components/ui/icon';
import { getTrainerDisplayName } from '../category';
import type { TrainerSummary } from '../protocol';
type TrainerOverviewProps = {
trainer: TrainerSummary;
cheatCount: number;
categoryCount: number;
};
export function TrainerOverview({ trainer, cheatCount, categoryCount }: TrainerOverviewProps) {
return (
<section className="grid gap-2 sm:gap-3 lg:grid-cols-[minmax(0,1.6fr)_repeat(2,minmax(160px,0.7fr))]">
<Card className="rounded-[8px] border-emerald-300/20 bg-emerald-300/8 shadow-xl shadow-emerald-950/20">
<CardContent className="flex items-center gap-2.5 px-3 py-3 sm:gap-3 sm:px-4 sm:py-4">
<div className="flex size-10 items-center justify-center rounded-[8px] border border-emerald-300/25 bg-black/25 text-emerald-200 sm:size-12">
<Icon className="size-5 sm:size-7" name="gamepad" />
</div>
<div className="min-w-0">
<p className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-emerald-100/70 sm:text-[0.68rem]">Active trainer</p>
<h2 className="truncate text-lg font-bold text-white sm:text-2xl">{getTrainerDisplayName(trainer)}</h2>
<div className="mt-1 flex flex-wrap gap-1 sm:gap-1.5">
<Badge className="border-white/10 bg-white/5 text-white" variant="outline">{trainer.gameVersion ?? 'unknown build'}</Badge>
<Badge className="border-white/10 bg-white/5 text-white" variant="outline">{trainer.language ?? 'n/a'}</Badge>
<Badge className="border-white/10 bg-white/5 text-white" variant="outline">#{trainer.trainerId}</Badge>
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-2 sm:gap-3 lg:contents">
<StatCard icon={<Icon className="size-5" name="boxes" />} label="Cheats" value={cheatCount} />
<StatCard icon={<Icon className="size-5" name="category" />} label="Loadouts" value={categoryCount} />
</div>
{trainer.needsCompatibilityWarning ? (
<Card className="rounded-[8px] border-orange-300/25 bg-orange-500/10 lg:col-span-3">
<CardContent className="flex items-center gap-2 px-3 py-2.5 text-orange-100 sm:px-4 sm:py-3">
<Icon className="size-4" name="trophy" />
Compatibility warning active
</CardContent>
</Card>
) : null}
</section>
);
}
function StatCard({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
return (
<Card className="rounded-[8px] border-white/10 bg-white/4.5">
<CardContent className="flex items-center justify-between gap-2 px-3 py-3 sm:gap-3 sm:px-4 sm:py-4">
<div>
<p className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground sm:text-[0.68rem]">{label}</p>
<p className="text-2xl font-bold text-white sm:text-3xl">{value}</p>
</div>
<div className="flex size-9 items-center justify-center rounded-[8px] border border-amber-300/20 bg-amber-300/10 text-amber-200 sm:size-10">
{icon}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,37 @@
export const DEFAULT_REMOTE_PORT = 3223;
export const REMOTE_BASE_PATH = '/remote/';
export const REMOTE_WS_PATH = '/remote/ws';
export const CLIENT_VERSION = '0.2.0';
export const WS_QUERY_PARAM = 'ws';
const DEV_SERVER_PORTS = new Set(['4173', '5173']);
function protocolForWebSocket(): 'ws' | 'wss' {
return window.location.protocol === 'https:' ? 'wss' : 'ws';
}
function isServedByRemoteBridge(): boolean {
return window.location.pathname.startsWith(REMOTE_BASE_PATH) && !DEV_SERVER_PORTS.has(window.location.port);
}
export function readInitialRemoteUrl(): string {
if (isServedByRemoteBridge()) {
return `${window.location.protocol}//${window.location.host}${REMOTE_BASE_PATH}`;
}
return `http://127.0.0.1:${DEFAULT_REMOTE_PORT}${REMOTE_BASE_PATH}`;
}
export function readInitialWebSocketUrl(): string {
const params = new URLSearchParams(window.location.search);
const explicitUrl = params.get(WS_QUERY_PARAM)?.trim();
if (explicitUrl) {
return explicitUrl;
}
if (isServedByRemoteBridge()) {
return `${protocolForWebSocket()}://${window.location.host}${REMOTE_WS_PATH}`;
}
return `ws://127.0.0.1:${DEFAULT_REMOTE_PORT}${REMOTE_WS_PATH}`;
}
@@ -0,0 +1,182 @@
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import type { CheatSchema, CheatOption } from '../protocol';
import { resolveOption } from '../protocol';
type CheatControlProps = {
cheat: CheatSchema;
value: unknown;
pending: boolean;
disabled: boolean;
onChange: (nextValue: unknown) => void;
};
function renderValue(value: unknown, postfix?: string): string {
if (typeof value === 'boolean') {
return value ? 'On' : 'Off';
}
if (value === null || value === undefined || value === '') {
return '--';
}
return `${String(value)}${postfix ?? ''}`;
}
function numericValue(value: unknown, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value !== 'string') {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function optionKey(option: CheatOption): string {
return String(option.value);
}
function findOption(options: CheatOption[], value: string): CheatOption | undefined {
return options.find((option) => String(option.value) === value);
}
function isSameOption(left: unknown, right: unknown): boolean {
return String(left) === String(right);
}
export function CheatControl({ cheat, value, pending, disabled, onChange }: CheatControlProps) {
const commonDisabled = disabled || pending;
const options = cheat.args.options?.map(resolveOption) ?? [];
if (cheat.type === 'toggle') {
return (
<div className="flex items-center justify-between gap-3 rounded-[8px] border border-white/10 bg-black/20 p-3">
<span className="text-sm font-semibold text-white">{renderValue(value)}</span>
<Switch checked={Boolean(value)} disabled={commonDisabled} onCheckedChange={onChange} />
</div>
);
}
if (cheat.type === 'slider') {
const min = cheat.args.min ?? 0;
const max = cheat.args.max ?? 100;
const currentValue = numericValue(value, min);
return (
<div className="space-y-3 rounded-[8px] border border-white/10 bg-black/20 p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">Range</span>
<span className="rounded-[6px] border border-emerald-300/25 bg-emerald-300/10 px-2 py-1 font-mono text-sm text-emerald-100">
{renderValue(currentValue, cheat.args.postfix)}
</span>
</div>
<Slider
min={min}
max={max}
step={cheat.args.step ?? 1}
value={currentValue}
disabled={commonDisabled}
onValueChange={onChange}
/>
</div>
);
}
if (cheat.type === 'number') {
return (
<div className="grid grid-cols-[1fr_auto] gap-2 rounded-[8px] border border-white/10 bg-black/20 p-3">
<Input
type="number"
min={cheat.args.min}
max={cheat.args.max}
step={cheat.args.step ?? 1}
value={String(value ?? '')}
disabled={commonDisabled}
className="h-9 rounded-[8px] border-white/10 bg-white/5 text-white"
onChange={(event) => onChange(event.currentTarget.value)}
/>
<span className="flex min-w-16 items-center justify-center rounded-[8px] border border-amber-300/25 bg-amber-300/10 px-2 font-mono text-sm text-amber-100">
{renderValue(value, cheat.args.postfix)}
</span>
</div>
);
}
if (cheat.type === 'button') {
return (
<Button className="h-10 w-full rounded-[8px] bg-amber-300 text-black hover:bg-amber-200" disabled={commonDisabled} onClick={() => onChange(1)}>
<Icon className="size-4" name="play" />
{typeof cheat.args.button === 'string' ? cheat.args.button : 'Apply'}
</Button>
);
}
if (cheat.type === 'selection') {
const selectedValue = String(value ?? options[0]?.value ?? '');
return (
<select
value={selectedValue}
disabled={commonDisabled}
className="h-10 w-full rounded-[8px] border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50"
onChange={(event) => onChange(findOption(options, event.currentTarget.value)?.value ?? event.currentTarget.value)}
>
{options.map((option) => (
<option key={`${cheat.uuid}-${optionKey(option)}`} value={optionKey(option)}>
{option.label}
</option>
))}
</select>
);
}
if (cheat.type === 'scalar') {
return (
<div className="grid grid-cols-2 gap-2 rounded-[8px] border border-white/10 bg-black/20 p-2 sm:grid-cols-4">
{options.map((option) => (
<Button
key={`${cheat.uuid}-${optionKey(option)}`}
className={cn('h-9 rounded-[8px] border-white/10', isSameOption(value, option.value) ? 'bg-emerald-300 text-black hover:bg-emerald-200' : 'bg-white/5 text-white hover:bg-white/10')}
disabled={commonDisabled}
variant={isSameOption(value, option.value) ? 'default' : 'outline'}
onClick={() => onChange(option.value)}
>
{option.label}
</Button>
))}
</div>
);
}
if (cheat.type === 'incremental') {
const currentIndex = options.findIndex((option) => isSameOption(option.value, value));
const previous = currentIndex > 0 ? options[currentIndex - 1] : null;
const next = currentIndex >= 0 && currentIndex < options.length - 1 ? options[currentIndex + 1] : null;
return (
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-2 rounded-[8px] border border-white/10 bg-black/20 p-2">
<Button size="icon" variant="outline" className="rounded-[8px] border-white/10 bg-white/5 text-white" disabled={commonDisabled || !previous} onClick={() => previous && onChange(previous.value)}>
<Icon className="size-4" name="chevron-left" />
</Button>
<span className="truncate text-center text-sm font-semibold text-white">{renderValue(options[currentIndex]?.label ?? value)}</span>
<Button size="icon" variant="outline" className="rounded-[8px] border-white/10 bg-white/5 text-white" disabled={commonDisabled || !next} onClick={() => next && onChange(next.value)}>
<Icon className="size-4" name="chevron-right" />
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2 rounded-[8px] border border-red-300/25 bg-red-500/10 p-3 text-sm text-red-100">
<Icon className="size-4" name="refresh" /> Unsupported cheat type: {cheat.type}
</div>
);
}
@@ -0,0 +1,15 @@
import { mockTrainerMeta, mockTrainerValues } from './mock-data';
import type { PanelAction } from './state';
const MOCK_QUERY_PARAM = 'mock';
type PanelDispatch = (action: PanelAction) => void;
export function isDebugSessionRequested(): boolean {
return new URLSearchParams(window.location.search).get(MOCK_QUERY_PARAM) === '1';
}
export function loadDebugSession(dispatch: PanelDispatch): void {
dispatch({ type: 'trainerMeta', payload: mockTrainerMeta });
dispatch({ type: 'trainerValues', payload: mockTrainerValues.values });
}
@@ -0,0 +1,50 @@
import type { IncomingMessage, TrainerMetaPayload } from './protocol';
import { normalizeIncomingValue } from './protocol';
import type { PanelAction } from './state';
type Dispatch = (action: PanelAction) => void;
export function handleProtocolMessage(dispatch: Dispatch, message: IncomingMessage, trainerMeta: TrainerMetaPayload | null): void {
switch (message.type) {
case 'hello_ack':
handleHelloAck(dispatch, message.payload.accepted, message.payload.remoteUrl);
return;
case 'trainer_meta':
dispatch({ type: 'trainerMeta', payload: message.payload });
return;
case 'trainer_values':
dispatch({ type: 'trainerValues', payload: message.payload.values });
return;
case 'value_changed':
handleValueChanged(dispatch, message, trainerMeta);
return;
case 'trainer_changed':
dispatch({ type: 'trainerChanged' });
return;
case 'set_value_result':
if (!message.payload.ok) {
dispatch({ type: 'error', message: message.payload.error?.message ?? 'The trainer rejected the requested value.' });
}
return;
case 'error':
dispatch({ type: 'error', message: message.payload.message });
return;
}
}
function handleHelloAck(dispatch: Dispatch, accepted: boolean, remoteUrl?: string): void {
if (!accepted) {
dispatch({ type: 'error', message: 'The desktop bridge rejected the connection.' });
return;
}
if (remoteUrl) {
dispatch({ type: 'setRemoteUrl', remoteUrl });
}
}
function handleValueChanged(dispatch: Dispatch, message: Extract<IncomingMessage, { type: 'value_changed' }>, trainerMeta: TrainerMetaPayload | null): void {
const cheat = trainerMeta?.schema.cheats.find((item) => item.target === message.payload.target || item.uuid === message.payload.cheatId);
const nextValue = cheat ? normalizeIncomingValue(cheat, message.payload.value) : message.payload.value;
dispatch({ type: 'valueChanged', target: message.payload.target, value: nextValue });
}
@@ -0,0 +1,5 @@
import demoSession from '../../../fixtures/demo-session.json';
import type { TrainerMetaPayload, TrainerValuesPayload } from './protocol';
export const mockTrainerMeta = demoSession.trainerMeta as TrainerMetaPayload;
export const mockTrainerValues = demoSession.trainerValues as TrainerValuesPayload;
@@ -0,0 +1,59 @@
import type { TrainerSummary } from './protocol';
const STORAGE_PREFIX = 'wand-remote.pinned-cheats.v1:';
export function getPinnedStorageKey(trainer: TrainerSummary | null | undefined): string | null {
if (!trainer) {
return null;
}
const id = trainer.gameId?.trim() || trainer.titleId?.trim() || trainer.trainerId?.trim();
return id ? `${STORAGE_PREFIX}${id}` : null;
}
export function loadPinnedTargets(storageKey: string | null): Record<string, true> {
if (!storageKey || typeof window === 'undefined') {
return {};
}
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return {};
}
const next: Record<string, true> = {};
for (const target of parsed) {
if (typeof target === 'string' && target.length > 0) {
next[target] = true;
}
}
return next;
} catch {
return {};
}
}
export function savePinnedTargets(storageKey: string | null, pinned: Record<string, true>): void {
if (!storageKey || typeof window === 'undefined') {
return;
}
try {
const targets = Object.keys(pinned);
if (targets.length === 0) {
window.localStorage.removeItem(storageKey);
return;
}
window.localStorage.setItem(storageKey, JSON.stringify(targets));
} catch {
// Ignore quota / serialization errors pinning is a non-critical UX nicety.
}
}
@@ -0,0 +1,208 @@
export const PROTOCOL_VERSION = 1;
export type CheatType =
| 'slider'
| 'number'
| 'toggle'
| 'button'
| 'selection'
| 'scalar'
| 'incremental';
export interface CheatOption {
label?: string;
value: string | number;
}
export type CheatOptionLike = CheatOption | string | number;
export interface CheatArgs {
min?: number;
max?: number;
step?: number;
options?: CheatOptionLike[];
button?: string | boolean;
postfix?: string;
default?: string | number | boolean;
}
export interface CheatSchema {
uuid: string;
target: string;
type: CheatType;
name: string;
description?: string | null;
instructions?: string | null;
category: string;
parent?: string | null;
flags?: number;
hotkeys?: string[][];
args: CheatArgs;
}
export interface TrainerSummary {
trainerId: string;
gameId: string;
displayName?: string | null;
titleId?: string | null;
gameVersion?: string | null;
trainerLoading: boolean;
gameInstalled: boolean;
needsCompatibilityWarning: boolean;
language?: string;
themeId?: string;
isTimeLimitExpired: boolean;
notesReadHash?: string | null;
}
export interface TrainerMetaPayload {
session: {
instanceId: string;
};
trainer: TrainerSummary;
schema: {
categories: string[];
cheats: CheatSchema[];
};
}
export type TrainerValuesPayload = {
trainerId: string;
values: Record<string, unknown>;
};
export type ValueChangedPayload = {
trainerId: string;
target: string;
value: unknown;
oldValue?: unknown;
source?: string;
cheatId?: string;
};
export type TrainerChangedPayload = {
previousTrainerId?: string | null;
trainerId: string;
};
export type SetValuePayload = {
trainerId: string;
target: string;
value: unknown;
cheatId?: string;
};
export type SetValueResultPayload = {
ok: boolean;
trainerId: string;
target: string;
error?: {
code: string;
message: string;
};
};
export type ErrorPayload = {
code: string;
message: string;
details?: Record<string, unknown>;
};
export interface MessageEnvelope<TType extends string, TPayload> {
type: TType;
version: number;
requestId: string | null;
payload: TPayload;
}
export type HelloMessage = MessageEnvelope<
'hello',
{
client: 'mobile-web';
clientVersion: string;
pairingToken?: string;
capabilities: {
supportsDeltaValues: boolean;
supportsTrainerSwitch: boolean;
};
}
>;
export type HelloAckMessage = MessageEnvelope<
'hello_ack',
{
sessionId: string;
accepted: boolean;
serverVersion: string;
protocolVersion: number;
remoteUrl?: string;
advertisedUrls?: string[];
}
>;
export type TrainerMetaMessage = MessageEnvelope<'trainer_meta', TrainerMetaPayload>;
export type TrainerValuesMessage = MessageEnvelope<'trainer_values', TrainerValuesPayload>;
export type ValueChangedMessage = MessageEnvelope<'value_changed', ValueChangedPayload>;
export type TrainerChangedMessage = MessageEnvelope<'trainer_changed', TrainerChangedPayload>;
export type SetValueMessage = MessageEnvelope<'set_value', SetValuePayload>;
export type SetValueResultMessage = MessageEnvelope<'set_value_result', SetValueResultPayload>;
export type ErrorMessage = MessageEnvelope<'error', ErrorPayload>;
export type IncomingMessage =
| HelloAckMessage
| TrainerMetaMessage
| TrainerValuesMessage
| ValueChangedMessage
| TrainerChangedMessage
| SetValueResultMessage
| ErrorMessage;
export type OutgoingMessage = HelloMessage | SetValueMessage;
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function isIncomingMessage(value: unknown): value is IncomingMessage {
return isRecord(value) && typeof value.type === 'string' && typeof value.version === 'number' && 'payload' in value;
}
export function resolveOption(option: CheatOptionLike): CheatOption {
if (typeof option === 'string' || typeof option === 'number') {
return { label: String(option), value: option };
}
return {
label: option.label ?? String(option.value),
value: option.value,
};
}
export function normalizeIncomingValue(cheat: CheatSchema, value: unknown): unknown {
if (cheat.type === 'toggle') {
return Boolean(value);
}
return value;
}
export function normalizeOutgoingValue(cheat: CheatSchema, value: unknown): unknown {
if (cheat.type === 'toggle') {
return Boolean(value);
}
if (cheat.type !== 'slider' && cheat.type !== 'number') {
return value;
}
if (typeof value !== 'string') {
return value;
}
const trimmedValue = value.trim();
if (!trimmedValue) {
return value;
}
return Number(trimmedValue);
}
@@ -0,0 +1,119 @@
import { CLIENT_VERSION } from './constants';
import {
type HelloMessage,
type IncomingMessage,
type OutgoingMessage,
PROTOCOL_VERSION,
type SetValueMessage,
isIncomingMessage,
} from './protocol';
type SocketHandlers = {
onConnecting: () => void;
onOpen: () => void;
onMessage: (message: IncomingMessage) => void;
onClose: () => void;
onError: (message: string) => void;
};
export class PanelSocketClient {
private socket: WebSocket | null = null;
private intentionalDisconnect = false;
constructor(
private readonly url: string,
private readonly handlers: SocketHandlers,
) {}
connect(pairingToken?: string): void {
this.disconnect();
this.intentionalDisconnect = false;
this.handlers.onConnecting();
const socket = new WebSocket(this.url);
this.socket = socket;
socket.addEventListener('open', () => {
this.handlers.onOpen();
this.send(this.createHelloMessage(pairingToken));
});
socket.addEventListener('message', (event) => this.handleMessage(event));
socket.addEventListener('close', () => {
if (this.socket === socket) {
this.socket = null;
}
if (!this.intentionalDisconnect) {
this.handlers.onClose();
}
});
socket.addEventListener('error', () => {
this.handlers.onError('WebSocket connection failed.');
});
}
disconnect(): void {
this.intentionalDisconnect = true;
this.socket?.close();
this.socket = null;
}
send(message: OutgoingMessage): boolean {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return false;
}
this.socket.send(JSON.stringify(message));
return true;
}
setValue(trainerId: string, target: string, value: unknown, cheatId?: string): boolean {
const message: SetValueMessage = {
type: 'set_value',
version: PROTOCOL_VERSION,
requestId: `set_${target}_${Date.now()}`,
payload: {
trainerId,
target,
value,
cheatId,
},
};
return this.send(message);
}
private createHelloMessage(pairingToken?: string): HelloMessage {
return {
type: 'hello',
version: PROTOCOL_VERSION,
requestId: `hello_${Date.now()}`,
payload: {
client: 'mobile-web',
clientVersion: CLIENT_VERSION,
pairingToken,
capabilities: {
supportsDeltaValues: true,
supportsTrainerSwitch: true,
},
},
};
}
private handleMessage(event: MessageEvent): void {
try {
const parsed = JSON.parse(String(event.data)) as unknown;
if (!isIncomingMessage(parsed)) {
this.handlers.onError('Received an invalid protocol message.');
return;
}
this.handlers.onMessage(parsed);
} catch (error) {
this.handlers.onError(error instanceof Error ? error.message : 'Failed to parse websocket message.');
}
}
}
@@ -0,0 +1,131 @@
import { readInitialRemoteUrl, readInitialWebSocketUrl } from './constants';
import type { TrainerMetaPayload } from './protocol';
export type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'error';
export type PanelState = {
connectionStatus: ConnectionStatus;
wsUrl: string;
remoteUrl: string;
trainerMeta: TrainerMetaPayload | null;
values: Record<string, unknown>;
pendingTargets: Record<string, boolean>;
pinnedTargets: Record<string, true>;
lastError: string | null;
};
export type PanelAction =
| { type: 'setWsUrl'; wsUrl: string }
| { type: 'setRemoteUrl'; remoteUrl: string }
| { type: 'connecting' }
| { type: 'connected' }
| { type: 'trainerMeta'; payload: TrainerMetaPayload }
| { type: 'trainerValues'; payload: Record<string, unknown> }
| { type: 'valueChanged'; target: string; value: unknown }
| { type: 'setPending'; target: string; pending: boolean }
| { type: 'trainerChanged' }
| { type: 'setPinnedTargets'; pinned: Record<string, true> }
| { type: 'togglePinnedTarget'; target: string }
| { type: 'error'; message: string | null };
export function createInitialPanelState(): PanelState {
return {
connectionStatus: 'idle',
wsUrl: readInitialWebSocketUrl(),
remoteUrl: readInitialRemoteUrl(),
trainerMeta: null,
values: {},
pendingTargets: {},
pinnedTargets: {},
lastError: null,
};
}
export function panelReducer(state: PanelState, action: PanelAction): PanelState {
switch (action.type) {
case 'setWsUrl':
return {
...state,
wsUrl: action.wsUrl,
};
case 'setRemoteUrl':
return {
...state,
remoteUrl: action.remoteUrl,
};
case 'connecting':
return {
...state,
connectionStatus: 'connecting',
lastError: null,
};
case 'connected':
return {
...state,
connectionStatus: 'connected',
lastError: null,
};
case 'trainerMeta':
return {
...state,
trainerMeta: action.payload,
pendingTargets: {},
};
case 'trainerValues':
return {
...state,
values: action.payload,
};
case 'valueChanged':
return {
...state,
values: {
...state.values,
[action.target]: action.value,
},
pendingTargets: {
...state.pendingTargets,
[action.target]: false,
},
};
case 'setPending':
return {
...state,
pendingTargets: {
...state.pendingTargets,
[action.target]: action.pending,
},
};
case 'trainerChanged':
return {
...state,
trainerMeta: null,
values: {},
pendingTargets: {},
pinnedTargets: {},
};
case 'setPinnedTargets':
return {
...state,
pinnedTargets: action.pinned,
};
case 'togglePinnedTarget': {
const next = { ...state.pinnedTargets };
if (next[action.target]) {
delete next[action.target];
} else {
next[action.target] = true;
}
return {
...state,
pinnedTargets: next,
};
}
case 'error':
return {
...state,
connectionStatus: action.message ? 'error' : state.connectionStatus,
lastError: action.message,
};
}
}
+63
View File
@@ -0,0 +1,63 @@
@import "tailwindcss";
@theme inline {
--font-heading: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(0.122 0.022 255);
--foreground: oklch(0.968 0.016 96);
--card: oklch(0.18 0.028 252);
--card-foreground: oklch(0.968 0.016 96);
--popover: oklch(0.16 0.026 252);
--popover-foreground: oklch(0.968 0.016 96);
--primary: oklch(0.812 0.184 142);
--primary-foreground: oklch(0.13 0.032 145);
--secondary: oklch(0.251 0.046 252);
--secondary-foreground: oklch(0.92 0.025 94);
--muted: oklch(0.235 0.031 252);
--muted-foreground: oklch(0.72 0.025 248);
--accent: oklch(0.742 0.153 55);
--accent-foreground: oklch(0.16 0.028 50);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.32 0.042 252);
--input: oklch(0.255 0.038 252);
--ring: oklch(0.812 0.184 142);
--radius: 0.5rem;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply min-h-svh bg-background text-foreground antialiased;
}
html {
@apply font-sans;
}
}
+32
View File
@@ -0,0 +1,32 @@
type ClassValue = string | number | false | null | undefined | ClassValue[] | Record<string, boolean | null | undefined>
export function cn(...inputs: ClassValue[]) {
const classes: string[] = []
for (const input of inputs) {
if (!input) {
continue
}
if (typeof input === "string" || typeof input === "number") {
classes.push(String(input))
continue
}
if (Array.isArray(input)) {
const value = cn(...input)
if (value) {
classes.push(value)
}
continue
}
for (const [key, enabled] of Object.entries(input)) {
if (enabled) {
classes.push(key)
}
}
}
return classes.join(" ")
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app';
import './index.css';
const root = document.getElementById('root') ?? document.getElementById('app');
if (!root) {
throw new Error('App root not found.');
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"types": [
"vite/client"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src"
]
}
+37
View File
@@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": [
"vite/client",
"node"
],
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src",
"vite.config.ts"
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [react(), tailwindcss()],
base: './',
resolve: {
alias: [
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
{ find: 'react-dom/client', replacement: 'preact/compat/client' },
{ find: 'react-dom', replacement: 'preact/compat' },
{ find: 'react/jsx-runtime', replacement: 'preact/jsx-runtime' },
{ find: 'react/jsx-dev-runtime', replacement: 'preact/jsx-dev-runtime' },
{ find: 'react', replacement: 'preact/compat' },
],
},
server: {
host: '127.0.0.1',
port: 4173,
strictPort: true,
},
preview: {
host: '127.0.0.1',
port: 4173,
strictPort: true,
},
build: {
outDir: 'dist',
assetsDir: 'assets',
target: 'es2020',
sourcemap: false,
},
});