feat: ship 1.0.8.0 release automation and runtime overhaul

- reduce ASAR IO overhead with streamed archive reads, buffered copies, faster relative-path handling and placeholder integrity records
- fix in-place app.asar.unpacked packing/extraction self-copy cases that caused locked-file failures
- tighten JS patch discovery with candidate bundle filters and search hints
- require prebuilt remote-panel dist artifacts and clean up embedded bridge/script packaging
- add unified build entrypoints for PowerShell, cmd and bash and move native CMake output under .tmp
- add release metadata validation, changelog section extraction, pre-commit hook and GitHub Actions validation/release pipelines
- make CHANGELOG the source of truth for release notes and document the tag-driven release flow
- add updater release notes UI with latest/full changelog loading and localize the new update strings
- modularize bridge renderer scripts, add installed apps and game status sync, and support remote launch/stop commands
- centralize bridge protocol, IPC and WebSocket constants and improve LAN IP selection for QR pairing
- refactor remote panel controls/state enums, persist accent color, polish library/session UI and refresh assets
This commit is contained in:
kitbyte
2026-05-04 22:59:33 +03:00
parent 3b2f373946
commit 13759b1db6
125 changed files with 8934 additions and 2839 deletions
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo_root=$(dirname "$script_dir")
script_path="$repo_root/scripts/validate-release-metadata.ps1"
if command -v cygpath >/dev/null 2>&1; then
script_path=$(cygpath -w "$script_path")
fi
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$script_path"
+54
View File
@@ -0,0 +1,54 @@
name: Release
on:
push:
tags:
- '*'
jobs:
publish-release:
if: github.repository == 'k1tbyte/Wand-Enhancer'
runs-on: windows-latest
permissions:
contents: write
env:
RELEASE_VERSION: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: web-panel/pnpm-lock.yaml
- name: Validate release metadata
shell: pwsh
run: ./scripts/validate-release-metadata.ps1 -ExpectedVersion $env:RELEASE_VERSION
- name: Extract release notes from changelog
shell: pwsh
run: ./scripts/get-changelog-section.ps1 -Version $env:RELEASE_VERSION -OutputPath release-notes.md
- name: Build release
shell: pwsh
run: ./build.ps1 -Configuration Release
- name: Publish GitHub release
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
body_path: release-notes.md
files: |
WandEnhancer/bin/Release/WandEnhancer.exe
CHANGELOG.md
fail_on_unmatched_files: true
@@ -0,0 +1,18 @@
name: Validate Release Metadata
on:
push:
branches: [ "master", "main" ]
pull_request:
branches: [ "master", "main" ]
jobs:
validate-release-metadata:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Validate version and changelog sync
shell: pwsh
run: ./scripts/validate-release-metadata.ps1
+4 -1
View File
@@ -142,4 +142,7 @@ packages
# App settings (user preferences)
appsettings.json
*DotSettings.user
*DotSettings.user
.tmp
.source
web-panel/bridge/wand-remote-bridge.cjs
+20 -5
View File
@@ -9,22 +9,37 @@ This repository patches the Wand Electron app from a .NET Framework WPF desktop
- 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.
- The Electron bridge is authored as modular CommonJS source under `web-panel/bridge/source.cjs` and `web-panel/bridge/bridge-modules/`, but production runtime must be bundled/minified into `web-panel/dist/bridge.cjs` by `pnpm run build:bridge`. Do not copy `bridge-modules` into Wand or embed them as ASAR resources.
- 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.
- Default renderer script sources live in `web-panel/bridge/scripts/default/` and are bundled/minified into `web-panel/dist/renderer-scripts/` by `pnpm run build:bridge`. 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.
- `web-panel/bridge/scripts/default/installed-apps-sync.js` resolves Wand's renderer services/store and publishes `My Games` snapshots through the `wand-remote-installed-apps` IPC channel. The synced list must mirror Wand's `my_games` source criteria: catalog games come from `installedGameVersions`, and extra installed unsupported titles come from `correlatedUnavailableTitles` whose `games[].correlationIds` match `installedApps`.
- If the injected renderer cannot read a populated `correlatedUnavailableTitles` slice from the live store, `installed-apps-sync.js` must fall back to Wand's `/v3/unavailable_titles` correlation lookup through the renderer API client instead of degrading to raw install entries or an empty `My Games` list.
- Installed app snapshots should include game artwork in `imageUrl` when possible. `installed-apps-sync.js` must prefer Wand's own client icon CDN shape `https://api-cdn.wemod.com/steam_community/<steamAppId>/client_icon/96.webp` whenever the matched title/game/version metadata contains a Steam AppID, regardless of install platform. Do not assume `steamAppId` is a flat property; search nested `steam*` metadata before falling back to installed Steam `sku`. If metadata still does not expose the icon, fall back to the rendered Wand sidebar DOM (`.sidebar-game-row-image` background-image) keyed by `titleId` parsed from `data-tooltip-trigger-for`. The web panel `GameCover` must tolerate broken artwork URLs and fall back to its text cover.
- The same renderer sync script also forwards lifecycle state through `wand-remote-game-status`: `game-launched` / `game-ended` come from Wand's launch monitor service, and trainer runtime comes from the running-trainer visibility service. The web panel consumes this as the `game_status` websocket message.
- When Wand does not emit a `game-launched` event but a trainer is already active, `wand-remote-game-status` must synthesize a running session from the running-trainer visibility payload so the remote panel does not show an idle game session next to a running trainer.
- The websocket `hello` snapshot must still send cached `installed_apps` and `game_status` even when no trainer snapshot is active yet; do not reintroduce a handshake path that returns early after `trainer_changed`.
- Remote Play/Stop uses the websocket `remote_command` message. The bridge forwards it over `wand-remote-command` / `wand-remote-command-response`, and `installed-apps-sync.js` resolves Wand's trainer API + trainer service to launch a trainer for a `gameId` or end the current trainer.
- Remote Play must construct Wand's real trainer launch request class (`69482.vO`) before calling `trainerService.launch(...)`. Passing a plain object launches the game process but breaks Wand's `getMetadata(vO)`-based trainer state, causing missing status, disappearing play/close buttons, and stuck loading behavior.
## 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.
- Inject `web-panel/dist` as `remote-panel/`; it must already contain `bridge.cjs` and generated default renderer scripts under `renderer-scripts/`. Selected/local custom renderer scripts are then copied under `remote-panel/renderer-scripts`.
- Do not commit extracted `.source/` or `.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.
- Custom quick presets are per trainer/game and stored by `preset-storage.ts` under `localStorage` key `wand-remote.presets.v1:<gameId-or-trainerId>`. Presets capture persistent cheat values only; do not include `button` one-shot cheats in saved presets.
- All `localStorage` access in `web-panel/src/features/remote-panel/` MUST go through the shared helpers in `storage.ts` (`loadJson` / `saveJson` / `loadStringSet` / `saveStringSet`). Do not reintroduce per-module `try/catch` + `JSON.parse` duplication in `pinned-storage`, `preset-storage`, or `game-pin-storage`. Trainer/game storage IDs are derived through the shared `getTrainerStorageId(trainer)` helper in `storage.ts`; do not re-implement the `gameId → titleId → trainerId → 'global'` precedence inline.
- All shared bridge port/path/IPC channel/WS-opcode/protocol-version constants live in `web-panel/bridge/bridge-modules/constants.cjs` (exports `IPC_CHANNEL`, `WS_OPCODE`, `BRIDGE_PROTOCOL_VERSION`, `BRIDGE_SERVER_VERSION`, `RENDERER_INJECTION_DELAYS_MS`). Do not redeclare `3223`, `/remote/*`, IPC channel strings, raw WS opcode numbers (1/8/9/10), or the 500/2000 ms injection delays inline. The renderer-script-side equivalents (e.g. `vO`/`TRAINER_LAUNCH_REQUEST_EXPORT_KEY`, snapshot key prefixes, bootstrap log throttle) live in `web-panel/bridge/scripts/default/installed-apps-sync/constants.js`.
- UI string-union types follow the `E*` enum convention from `.claude/rules/frontend-conventions.md` (currently `ECheatType` in `protocol.ts`, `EConnectionStatus` in `state.ts`); the wire string values must remain on the right-hand side of the enum members. Reducer `PanelAction` `type` tags stay as discriminated-union string literals (the union itself provides the discrimination — converting it to an enum loses pattern matching).
- Cheat input controls live one-per-file under `web-panel/src/features/remote-panel/controls/` (`ToggleControl`, `SliderControl`, `ScalarControl`, `NumberControl`, `ActionButton`, `SelectionControl`, `IncrementalControl`); shared `SliderTrack` / `StepButton` / `ControlInternalProps` are in `controls/shared.tsx` and number formatting helpers in `controls/format-number.ts`. `controls/CheatControl.tsx` is a thin dispatcher map keyed by `ECheatType` — do not inline new control bodies into it.
- Mobile drawer performance is sensitive to `backdrop-filter`. Keep drawer panels and nested glass controls blur-free under coarse pointers, and do not add per-row `backdrop-blur-*` inside drawer lists.
## 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`.
- Web panel build: `cd web-panel && pnpm run build` (runs type-check, Vite build, then `build:bridge` into `dist`).
- Bridge/script syntax checks after build: `node --check web-panel/dist/bridge.cjs` and `node --check web-panel/dist/renderer-scripts/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`.
+31 -24
View File
@@ -1,14 +1,13 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using AsarSharp.AsarFileSystem;
using AsarSharp.Integrity;
using AsarSharp.Utils;
namespace AsarSharp
{
public class CreateOptions
{
public Regex Unpack { get; set; }
@@ -28,10 +27,10 @@ namespace AsarSharp
_destPath = destPath ?? throw new ArgumentNullException(nameof(destPath));
_options = options;
}
public void CreatePackageWithOptions()
{
var result = FileSystemCrawler.CrawlFileSystem(_folderPath);
var result = FileSystemCrawler.CrawlFileSystem(_folderPath);
_filenames = result.filenames;
_metadata = result.metadata;
CreatePackageFromFiles();
@@ -41,28 +40,25 @@ namespace AsarSharp
public void CreatePackageFromFiles()
{
var filesystem = new Filesystem(_folderPath);
var files = new List<Disk.BasicFileInfo>();
var filenamesSorted = _filenames.ToList();
foreach (var filename in filenamesSorted)
var files = new List<Disk.BasicFileInfo>(_filenames.Count);
foreach (var filename in _filenames)
{
HandleFile(filesystem, filename, files);
}
InsertsDone(filesystem, files);
}
private void HandleFile(Filesystem filesystem, string filename, List<Disk.BasicFileInfo> files)
{
if (!_metadata.ContainsKey(filename))
if (!_metadata.TryGetValue(filename, out var file))
{
var fileType = FileSystemCrawler.DetermineFileType(filename);
_metadata[filename] = fileType ?? throw new Exception("Unknown file type for file: " + filename);
file = FileSystemCrawler.DetermineFileType(filename)
?? throw new Exception("Unknown file type for file: " + filename);
_metadata[filename] = file;
}
var file = _metadata[filename];
switch (file.Type)
{
@@ -70,9 +66,18 @@ namespace AsarSharp
filesystem.InsertDirectory(filename, false);
break;
case FileType.File:
var shouldUnpack = ShouldUnpackPath(Extensions.GetRelativePath(_folderPath, Path.GetDirectoryName(filename)));
string parentDir = Path.GetDirectoryName(filename) ?? string.Empty;
string relParent = Extensions.GetRelativePath(_folderPath, parentDir);
bool shouldUnpack = ShouldUnpackPath(relParent);
files.Add(new Disk.BasicFileInfo { Filename = filename, Unpack = shouldUnpack });
filesystem.InsertFile(filename, shouldUnpack, file);
// Build a placeholder integrity record up front. Real
// SHA-256 hashes are filled in by Disk.WriteFileSystem
// during the streamed write — eliminates the second pass
// over each file (open → hash → close → open → copy → close).
long size = (file.Stat is FileInfo fi) ? fi.Length : 0;
var placeholder = IntegrityHelper.CreatePlaceholder(size);
filesystem.InsertFile(filename, shouldUnpack, file, placeholder);
break;
case FileType.Link:
throw new NotImplementedException();
@@ -81,14 +86,16 @@ namespace AsarSharp
private bool ShouldUnpackPath(string relativePath)
{
return _options.Unpack?.IsMatch(relativePath) == true;
return _options?.Unpack?.IsMatch(relativePath) == true;
}
private void InsertsDone(Filesystem filesystem, List<Disk.BasicFileInfo> files)
{
Directory.CreateDirectory(Path.GetDirectoryName(_destPath) ?? throw new InvalidOperationException());
Disk.WriteFileSystem(_destPath, filesystem, new Disk.FilesystemFilesAndLinks { Files = files, Links = null }, _metadata);
Directory.CreateDirectory(
Path.GetDirectoryName(_destPath)
?? throw new InvalidOperationException());
Disk.WriteFileSystem(_destPath, filesystem,
new Disk.FilesystemFilesAndLinks { Files = files, Links = null }, _metadata);
}
}
}
}
+136 -102
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -10,126 +10,62 @@ namespace AsarSharp
{
public class AsarExtractor
{
private const int IO_BUFFER_SIZE = 1024 * 1024;
private const int FS_INTERNAL_BUFFER = 4096;
public static void ExtractAll(string archivePath, string dest)
{
var filesystem = Disk.ReadFilesystemSync(archivePath);
var filenames = filesystem.ListFiles();
// under windows just extract links as regular files
// On Windows, links are extracted as plain files.
bool followLinks = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
// create destination directory
Directory.CreateDirectory(dest);
byte[] ioBuffer = new byte[IO_BUFFER_SIZE];
var dirCache = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Path.GetFullPath(dest) };
var extractionErrors = new List<Exception>();
foreach (var fullPath in filenames)
string rootPath = filesystem.GetRootPath();
long dataOffset = 8 + filesystem.GetHeaderSize();
// One archive handle for all reads — old code opened it per file.
using (var archive = new FileStream(rootPath, FileMode.Open, FileAccess.Read, FileShare.Read,
FS_INTERNAL_BUFFER, FileOptions.RandomAccess))
{
try
foreach (var fullPath in filenames)
{
// Remove leading slash
var filename = fullPath.Substring(1);
var destFilename = Path.Combine(dest, filename);
var file = filesystem.GetFile(filename, followLinks);
// Check that the file is not written outside the specified destination folder
string relativePath = Extensions.GetRelativePath(dest, destFilename);
if (relativePath.StartsWith(".."))
try
{
throw new InvalidOperationException($"{fullPath}: file \"{destFilename}\" writes out of the package");
}
var filename = fullPath.Substring(1);
var destFilename = Path.Combine(dest, filename);
var file = filesystem.GetFile(filename, followLinks);
if (file.IsDirectory)
{
// it's a directory, create it and continue with the next entry
Directory.CreateDirectory(destFilename);
}
// TODO (LINK NOT SUPPORTED)
else if (file.IsLink)
{
// it's a symlink, create a symlink
var linkSrcPath = Extensions.GetDirectoryName(Path.Combine(dest, file.Link));
var linkDestPath = Extensions.GetDirectoryName(destFilename);
var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath);
// try to delete output file, because we can't overwrite a link
try
{
File.Delete(destFilename);
}
catch {
// Ignore errors during file link deletion
}
var linkTo = Path.Combine(relativeLinkPath, Path.GetFileName(file.Link));
if (Extensions.GetRelativePath(dest, linkSrcPath).StartsWith(".."))
// Path-traversal guard.
string relativePath = Extensions.GetRelativePath(dest, destFilename);
if (relativePath.StartsWith(".."))
{
throw new InvalidOperationException(
$"{fullPath}: file \"{file.Link}\" links out of the package to \"{linkSrcPath}\"");
$"{fullPath}: file \"{destFilename}\" writes out of the package");
}
// On Windows, creating symlinks requires additional permissions or enabling Developer Mode,
// so just copy the contents of the file
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
if (file.IsDirectory)
{
var targetPath = Path.Combine(linkSrcPath, Path.GetFileName(file.Link));
if (Directory.Exists(targetPath))
{
Directory.CreateDirectory(destFilename);
Extensions.CopyDirectory(targetPath, destFilename);
}
else if (File.Exists(targetPath))
{
Directory.CreateDirectory(Extensions.GetDirectoryName(destFilename));
File.Copy(targetPath, destFilename, true);
}
EnsureDirectory(destFilename, dirCache);
continue;
}
else
if (file.IsLink)
{
// On Unix systems we use symlinks
Directory.CreateDirectory(Extensions.GetDirectoryName(destFilename));
Extensions.CreateSymbolicLink(linkTo, destFilename);
ExtractLink(dest, fullPath, destFilename, file, dirCache);
continue;
}
}
else if (file.IsFile)
{
// it's a file, try to extract it
if (!file.IsFile) continue;
try
{
// 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);
}
ExtractFile(archive, dataOffset, rootPath, filename, destFilename, file, ioBuffer, dirCache);
if (file.Executable == true && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
@@ -141,10 +77,10 @@ namespace AsarSharp
extractionErrors.Add(e);
}
}
}
catch (Exception ex)
{
extractionErrors.Add(ex);
catch (Exception ex)
{
extractionErrors.Add(ex);
}
}
}
@@ -156,5 +92,103 @@ namespace AsarSharp
extractionErrors);
}
}
private static void EnsureDirectory(string path, HashSet<string> cache)
{
string full = Path.GetFullPath(path);
if (cache.Contains(full)) return;
Directory.CreateDirectory(full);
// Mark every ancestor too so siblings skip the syscall.
string p = full;
while (!string.IsNullOrEmpty(p) && cache.Add(p))
{
p = Path.GetDirectoryName(p);
}
}
private static void EnsureParentDir(string filePath, HashSet<string> cache)
{
string parent = Path.GetDirectoryName(filePath);
if (string.IsNullOrEmpty(parent)) return;
EnsureDirectory(parent, cache);
}
private static void ExtractFile(FileStream archive, long dataOffset, string rootPath,
string filename, string destFilename, FilesystemEntry file, byte[] buffer,
HashSet<string> dirCache)
{
EnsureParentDir(destFilename, dirCache);
if (file.Unpacked == true)
{
string unpackedSourcePath = Path.GetFullPath(Path.Combine($"{rootPath}.unpacked", filename));
string unpackedDestPath = Path.GetFullPath(destFilename);
if (string.Equals(unpackedSourcePath, unpackedDestPath, StringComparison.OrdinalIgnoreCase))
return; // self-copy
if (!File.Exists(unpackedSourcePath))
return; // header references a missing unpacked file — skip rather than abort
File.Copy(unpackedSourcePath, destFilename, true);
return;
}
long size = file.Size ?? 0;
using (var dst = new FileStream(destFilename, FileMode.Create, FileAccess.Write, FileShare.None,
FS_INTERNAL_BUFFER, FileOptions.SequentialScan))
{
if (size <= 0) return;
archive.Position = dataOffset + long.Parse(file.Offset);
long remaining = size;
while (remaining > 0)
{
int toRead = remaining > buffer.Length ? buffer.Length : (int)remaining;
int got = archive.Read(buffer, 0, toRead);
if (got <= 0) throw new EndOfStreamException("Archive truncated");
dst.Write(buffer, 0, got);
remaining -= got;
}
}
}
private static void ExtractLink(string dest, string fullPath, string destFilename,
FilesystemEntry file, HashSet<string> dirCache)
{
var linkSrcPath = Extensions.GetDirectoryName(Path.Combine(dest, file.Link));
var linkDestPath = Extensions.GetDirectoryName(destFilename);
var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath);
try { File.Delete(destFilename); }
catch { /* ignore — failing to remove an existing link is non-fatal */ }
var linkTo = Path.Combine(relativeLinkPath, Path.GetFileName(file.Link));
if (Extensions.GetRelativePath(dest, linkSrcPath).StartsWith(".."))
{
throw new InvalidOperationException(
$"{fullPath}: file \"{file.Link}\" links out of the package to \"{linkSrcPath}\"");
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var targetPath = Path.Combine(linkSrcPath, Path.GetFileName(file.Link));
if (Directory.Exists(targetPath))
{
EnsureDirectory(destFilename, dirCache);
Extensions.CopyDirectory(targetPath, destFilename);
}
else if (File.Exists(targetPath))
{
EnsureParentDir(destFilename, dirCache);
File.Copy(targetPath, destFilename, true);
}
}
else
{
EnsureParentDir(destFilename, dirCache);
Extensions.CreateSymbolicLink(linkTo, destFilename);
}
}
}
}
}
+23 -12
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using AsarSharp.PickleTools;
@@ -9,6 +9,7 @@ namespace AsarSharp.AsarFileSystem
{
public static class Disk
{
private const int StreamBufferSize = 1024 * 1024;
private static Dictionary<string, Filesystem> _filesystemCache = new Dictionary<string, Filesystem>();
public class ArchiveHeader
@@ -35,7 +36,7 @@ namespace AsarSharp.AsarFileSystem
public static ArchiveHeader ReadArchiveHeaderSync(string archivePath)
{
using (FileStream fs = File.OpenRead(archivePath))
using (var fs = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan))
{
// read the size of the header (8 bytes)
byte[] sizeBuf = new byte[8];
@@ -103,7 +104,7 @@ namespace AsarSharp.AsarFileSystem
}
// Read from the ASAR archive
using (FileStream fs = File.OpenRead(filesystem.GetRootPath()))
using (var fs = new FileStream(filesystem.GetRootPath(), FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan))
{
// Important: the offset must take into account the size of the Pickle header (8 bytes)
// and the size of the header itself
@@ -145,19 +146,29 @@ namespace AsarSharp.AsarFileSystem
if(dest == null || rootPath == null || filename == null)
throw new ArgumentNullException();
if (dest == rootPath)
string normalizedDestRoot = Path.GetFullPath(dest)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
string normalizedRootPath = Path.GetFullPath(rootPath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (string.Equals(normalizedDestRoot, normalizedRootPath, StringComparison.OrdinalIgnoreCase))
{
return;
}
string sourcePath = Path.Combine(rootPath, filename);
string destPath = Path.Combine(dest, filename);
string sourcePath = Path.GetFullPath(Path.Combine(rootPath, filename));
string destPath = Path.GetFullPath(Path.Combine(dest, filename));
if (string.Equals(sourcePath, destPath, StringComparison.OrdinalIgnoreCase))
{
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(destPath) ?? throw new InvalidOperationException());
using (var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read))
using (var destinationStream = new FileStream(destPath, FileMode.Create, FileAccess.Write))
using (var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan))
using (var destinationStream = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan))
{
sourceStream.CopyTo(destinationStream);
sourceStream.CopyTo(destinationStream, StreamBufferSize);
}
}
@@ -179,7 +190,7 @@ namespace AsarSharp.AsarFileSystem
sizePickle.WriteUInt32((uint)headerBuf.Length);
var sizeBuf = sizePickle.ToBuffer();
using (FileStream fs = File.Create(dest))
using (var fs = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan))
{
fs.Write(sizeBuf, 0, sizeBuf.Length);
fs.Write(headerBuf, 0, headerBuf.Length);
@@ -192,9 +203,9 @@ namespace AsarSharp.AsarFileSystem
CopyFile($"{dest}.unpacked", fileSystem.GetRootPath(), filename);
continue;
}
using (var transformedFileStream = new FileStream(file.Filename, FileMode.Open, FileAccess.Read))
using (var transformedFileStream = new FileStream(file.Filename, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan))
{
transformedFileStream.CopyTo(fs);
transformedFileStream.CopyTo(fs, StreamBufferSize);
}
}
}
+1 -2
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using AsarSharp.Integrity;
@@ -150,7 +150,6 @@ namespace AsarSharp.AsarFileSystem
public static string ReadLink(string path)
{
throw new NotImplementedException();
return Path.GetFileName(path);
// TODO , NOT IMPLEMENTED
}
+29 -20
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -28,32 +28,37 @@ namespace AsarSharp.AsarFileSystem
public static class FileSystemCrawler
{
public static CrawledFileType DetermineFileType(string filename)
{
var fileInfo = new FileInfo(filename);
if (fileInfo.Exists)
FileAttributes attributes;
try
{
return new CrawledFileType { Type = FileType.File, Stat = fileInfo };
attributes = File.GetAttributes(filename);
}
var directoryInfo = new DirectoryInfo(filename);
if (directoryInfo.Exists)
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
return new CrawledFileType { Type = FileType.Directory, Stat = directoryInfo };
return null;
}
var linkInfo = new FileInfo(filename);
if (linkInfo.Exists && (linkInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
bool isDirectory = (attributes & FileAttributes.Directory) == FileAttributes.Directory;
bool isLink = (attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
FileSystemInfo info = isDirectory
? (FileSystemInfo)new DirectoryInfo(filename)
: new FileInfo(filename);
if (isLink)
{
return new CrawledFileType { Type = FileType.Link, Stat = linkInfo };
return new CrawledFileType { Type = FileType.Link, Stat = info };
}
return null;
if (isDirectory)
{
return new CrawledFileType { Type = FileType.Directory, Stat = info };
}
return new CrawledFileType { Type = FileType.File, Stat = info };
}
public static (List<string> filenames, Dictionary<string, CrawledFileType> metadata) CrawlFileSystem(string dir)
{
var metadata = new Dictionary<string, CrawledFileType>();
@@ -73,7 +78,12 @@ namespace AsarSharp.AsarFileSystem
filenames.Add(result.filename);
}
var filteredFilenames = new List<string>();
if (links.Count == 0)
{
return (filenames, metadata);
}
var filteredFilenames = new List<string>(filenames.Count);
foreach (var filename in filenames)
{
@@ -107,7 +117,6 @@ namespace AsarSharp.AsarFileSystem
return (filteredFilenames, metadata);
}
// (File order is not important!!!)
public static List<string> CrawlIterative(string dir)
{
+30 -14
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
@@ -11,6 +11,7 @@ namespace AsarSharp.Integrity
private const string ALGORITHM = "SHA256";
// 4MB default block size
private const int BLOCK_SIZE = 4 * 1024 * 1024;
private static readonly char[] HexDigits = "0123456789abcdef".ToCharArray();
public class FileIntegrity
{
@@ -29,20 +30,21 @@ namespace AsarSharp.Integrity
public static FileIntegrity GetFileIntegrity(string path)
{
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, BLOCK_SIZE, FileOptions.SequentialScan))
using(var fileHash = SHA256.Create())
using (var blockHash = SHA256.Create())
{
var blockHashes = new List<string>();
int estimatedBlockCount = fileStream.Length > 0
? (int)((fileStream.Length + BLOCK_SIZE - 1) / BLOCK_SIZE)
: 0;
var blockHashes = new List<string>(estimatedBlockCount);
var buffer = new byte[BLOCK_SIZE];
int bytesRead;
while ((bytesRead = fileStream.Read(buffer, 0, BLOCK_SIZE)) > 0)
while ((bytesRead = fileStream.Read(buffer, 0, buffer.Length)) > 0)
{
var block = new byte[bytesRead];
Array.Copy(buffer, block, bytesRead);
blockHashes.Add(HashBlock(block));
fileHash.TransformBlock(block, 0, block.Length, null, 0);
blockHashes.Add(HashBlock(blockHash, buffer, bytesRead));
fileHash.TransformBlock(buffer, 0, bytesRead, null, 0);
}
fileHash.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
@@ -50,20 +52,34 @@ namespace AsarSharp.Integrity
return new FileIntegrity
{
Algorithm = ALGORITHM,
Hash = BitConverter.ToString(fileHash.Hash).Replace("-", "").ToLowerInvariant(),
Hash = ToLowerHex(fileHash.Hash),
BlockSize = BLOCK_SIZE,
Blocks = blockHashes,
};
}
}
private static string HashBlock(byte[] block)
private static string HashBlock(HashAlgorithm hashAlgorithm, byte[] buffer, int bytesRead)
{
using (var sha256 = SHA256.Create())
return ToLowerHex(hashAlgorithm.ComputeHash(buffer, 0, bytesRead));
}
private static string ToLowerHex(byte[] bytes)
{
if (bytes == null || bytes.Length == 0)
{
var hash = sha256.ComputeHash(block);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
return string.Empty;
}
var chars = new char[bytes.Length * 2];
for (int index = 0; index < bytes.Length; index++)
{
byte value = bytes[index];
chars[index * 2] = HexDigits[value >> 4];
chars[index * 2 + 1] = HexDigits[value & 0x0F];
}
return new string(chars);
}
}
}
+93 -219
View File
@@ -1,4 +1,5 @@
using System;
using System;
using System.IO;
using System.Text;
namespace AsarSharp.PickleTools
@@ -12,10 +13,10 @@ namespace AsarSharp.PickleTools
public const int SIZE_FLOAT = 4;
public const int SIZE_DOUBLE = 8;
// Size of memory allocation unit for payload
public const int PAYLOAD_UNIT = 64;
// Initial payload allocation. Bumped from 64 — large headers used to
// realloc many times when growing geometrically from 64.
public const int PAYLOAD_UNIT = 4096;
// Maximum value for read-only
public const long CAPACITY_READ_ONLY = 9007199254740992;
private byte[] _header;
@@ -57,55 +58,40 @@ namespace AsarSharp.PickleTools
SetPayloadSize(0);
}
}
public static Pickle CreateEmpty()
{
return new Pickle();
}
public static Pickle CreateFromBuffer(byte[] buffer)
{
return new Pickle(buffer);
}
public byte[] GetHeader()
{
return _header;
}
public static Pickle CreateEmpty() => new Pickle();
public static Pickle CreateFromBuffer(byte[] buffer) => new Pickle(buffer);
public int GetHeaderSize()
{
return _headerSize;
}
public PickleIterator CreateIterator()
{
return new PickleIterator(this);
}
public byte[] GetHeader() => _header;
public int GetHeaderSize() => _headerSize;
/// <summary>
/// Converts Pickle to a byte array
/// </summary>
public PickleIterator CreateIterator() => new PickleIterator(this);
/// <summary>Total byte length of the serialised pickle (header + payload).</summary>
public int GetTotalSize() => _headerSize + GetPayloadSize();
/// <summary>Materialise the pickle into a fresh byte array (allocates).</summary>
public byte[] ToBuffer()
{
int resultSize = _headerSize + GetPayloadSize();
int resultSize = GetTotalSize();
byte[] result = new byte[resultSize];
Array.Copy(_header, 0, result, 0, resultSize);
Buffer.BlockCopy(_header, 0, result, 0, resultSize);
return result;
}
public bool WriteBool(bool value)
/// <summary>Write the serialised pickle straight to <paramref name="stream"/> — no extra copy.</summary>
public void WriteTo(Stream stream)
{
return WriteInt(value ? 1 : 0);
stream.Write(_header, 0, GetTotalSize());
}
public bool WriteBool(bool value) => WriteInt(value ? 1 : 0);
public bool WriteInt(int value)
{
EnsureCapacity(SIZE_INT32);
var dataLength = AlignInt(SIZE_INT32, SIZE_UINT32);
var newSize = _writeOffset + dataLength;
const int dataLength = SIZE_INT32; // already 4-byte aligned
int newSize = _writeOffset + dataLength;
if (newSize > _capacityAfterHeader)
{
@@ -113,13 +99,6 @@ namespace AsarSharp.PickleTools
}
WriteInt32LE(value, _headerSize + _writeOffset);
var endOffset = _headerSize + _writeOffset + SIZE_INT32;
for (int i = endOffset; i < endOffset + dataLength - SIZE_INT32; i++)
{
_header[i] = 0;
}
SetPayloadSize(newSize);
_writeOffset = newSize;
return true;
@@ -128,10 +107,8 @@ namespace AsarSharp.PickleTools
public bool WriteUInt32(uint value)
{
EnsureCapacity(SIZE_UINT32);
var dataLength = AlignInt(SIZE_UINT32, SIZE_UINT32);
var newSize = _writeOffset + dataLength;
const int dataLength = SIZE_UINT32;
int newSize = _writeOffset + dataLength;
if (newSize > _capacityAfterHeader)
{
@@ -139,24 +116,15 @@ namespace AsarSharp.PickleTools
}
WriteUInt32LE(value, _headerSize + _writeOffset);
var endOffset = _headerSize + _writeOffset + SIZE_UINT32;
for (int i = endOffset; i < endOffset + dataLength - SIZE_UINT32; i++)
{
_header[i] = 0;
}
SetPayloadSize(newSize);
_writeOffset = newSize;
return true;
}
public bool WriteInt64(long value)
{
EnsureCapacity(SIZE_INT64);
var dataLength = AlignInt(SIZE_INT64, SIZE_UINT32);
var newSize = _writeOffset + dataLength;
const int dataLength = SIZE_INT64;
int newSize = _writeOffset + dataLength;
if (newSize > _capacityAfterHeader)
{
@@ -164,13 +132,6 @@ namespace AsarSharp.PickleTools
}
WriteInt64LE(value, _headerSize + _writeOffset);
var endOffset = _headerSize + _writeOffset + SIZE_INT64;
for (int i = endOffset; i < endOffset + dataLength - SIZE_INT64; i++)
{
_header[i] = 0;
}
SetPayloadSize(newSize);
_writeOffset = newSize;
return true;
@@ -179,10 +140,8 @@ namespace AsarSharp.PickleTools
public bool WriteUInt64(ulong value)
{
EnsureCapacity(SIZE_UINT64);
var dataLength = AlignInt(SIZE_UINT64, SIZE_UINT32);
var newSize = _writeOffset + dataLength;
const int dataLength = SIZE_UINT64;
int newSize = _writeOffset + dataLength;
if (newSize > _capacityAfterHeader)
{
@@ -190,102 +149,69 @@ namespace AsarSharp.PickleTools
}
WriteUInt64LE(value, _headerSize + _writeOffset);
var endOffset = _headerSize + _writeOffset + SIZE_UINT64;
for (int i = endOffset; i < endOffset + dataLength - SIZE_UINT64; i++)
{
_header[i] = 0;
}
SetPayloadSize(newSize);
_writeOffset = newSize;
return true;
}
public bool WriteFloat(float value)
{
EnsureCapacity(SIZE_FLOAT);
var dataLength = AlignInt(SIZE_FLOAT, SIZE_UINT32);
var newSize = _writeOffset + dataLength;
const int dataLength = SIZE_FLOAT;
int newSize = _writeOffset + dataLength;
if (newSize > _capacityAfterHeader)
{
Resize(Math.Max((int)_capacityAfterHeader * 2, newSize));
}
byte[] bytes = BitConverter.GetBytes(value);
if (!BitConverter.IsLittleEndian)
{
Array.Reverse(bytes);
}
Array.Copy(bytes, 0, _header, _headerSize + _writeOffset, SIZE_FLOAT);
var endOffset = _headerSize + _writeOffset + SIZE_FLOAT;
for (int i = endOffset; i < endOffset + dataLength - SIZE_FLOAT; i++)
{
_header[i] = 0;
}
int bits = BitConverter.ToInt32(BitConverter.GetBytes(value), 0);
WriteInt32LE(bits, _headerSize + _writeOffset);
SetPayloadSize(newSize);
_writeOffset = newSize;
return true;
}
public bool WriteDouble(double value)
{
EnsureCapacity(SIZE_DOUBLE);
var dataLength = AlignInt(SIZE_DOUBLE, SIZE_UINT32);
var newSize = _writeOffset + dataLength;
const int dataLength = SIZE_DOUBLE;
int newSize = _writeOffset + dataLength;
if (newSize > _capacityAfterHeader)
{
Resize(Math.Max((int)_capacityAfterHeader * 2, newSize));
}
byte[] bytes = BitConverter.GetBytes(value);
if (!BitConverter.IsLittleEndian)
{
Array.Reverse(bytes);
}
Array.Copy(bytes, 0, _header, _headerSize + _writeOffset, SIZE_DOUBLE);
var endOffset = _headerSize + _writeOffset + SIZE_DOUBLE;
for (int i = endOffset; i < endOffset + dataLength - SIZE_DOUBLE; i++)
{
_header[i] = 0;
}
long bits = BitConverter.DoubleToInt64Bits(value);
WriteInt64LE(bits, _headerSize + _writeOffset);
SetPayloadSize(newSize);
_writeOffset = newSize;
return true;
}
public bool WriteString(string value)
{
byte[] strBytes = Encoding.UTF8.GetBytes(value);
int length = strBytes.Length;
int byteLen = Encoding.UTF8.GetByteCount(value);
if (!WriteInt(length))
if (!WriteInt(byteLen))
{
return false;
}
var dataLength = AlignInt(length, SIZE_UINT32);
var newSize = _writeOffset + dataLength;
int aligned = AlignInt(byteLen, SIZE_UINT32);
int newSize = _writeOffset + aligned;
if (newSize > _capacityAfterHeader)
{
Resize(Math.Max((int)_capacityAfterHeader * 2, newSize));
}
Array.Copy(strBytes, 0, _header, _headerSize + _writeOffset, length);
int writeStart = _headerSize + _writeOffset;
Encoding.UTF8.GetBytes(value, 0, value.Length, _header, writeStart);
var endOffset = _headerSize + _writeOffset + length;
for (int i = endOffset; i < endOffset + dataLength - length; i++)
// zero alignment padding
for (int i = writeStart + byteLen; i < writeStart + aligned; i++)
{
_header[i] = 0;
}
@@ -294,132 +220,80 @@ namespace AsarSharp.PickleTools
_writeOffset = newSize;
return true;
}
public void SetPayloadSize(int payloadSize)
{
WriteUInt32LE((uint)payloadSize, 0);
}
public int GetPayloadSize()
{
return (int)ReadUInt32LE(0);
}
public int GetPayloadSize() => (int)ReadUInt32LE(0);
private void Resize(int newCapacity)
{
newCapacity = AlignInt(newCapacity, PAYLOAD_UNIT);
byte[] newHeader = new byte[_header.Length + newCapacity];
Array.Copy(_header, 0, newHeader, 0, _header.Length);
Buffer.BlockCopy(_header, 0, newHeader, 0, _header.Length);
_header = newHeader;
_capacityAfterHeader = newCapacity;
}
public static int AlignInt(int i, int alignment)
{
return i + ((alignment - (i % alignment)) % alignment);
}
private void EnsureCapacity(int additionalSize)
{
var dataLength = AlignInt(additionalSize, SIZE_UINT32);
var newSize = _writeOffset + dataLength;
if (newSize > _capacityAfterHeader)
{
Resize(Math.Max((int)_capacityAfterHeader * 2, newSize));
}
}
#region Auxiliary methods for reading/writing values in Little Endian
private uint ReadUInt32LE(int offset)
{
if (BitConverter.IsLittleEndian)
{
return BitConverter.ToUInt32(_header, offset);
}
else
{
return (uint)(_header[offset] |
(_header[offset + 1] << 8) |
(_header[offset + 2] << 16) |
(_header[offset + 3] << 24));
}
// _header is allocated by us so always little-endian-friendly when on LE host.
return (uint)(_header[offset] |
(_header[offset + 1] << 8) |
(_header[offset + 2] << 16) |
(_header[offset + 3] << 24));
}
private void WriteInt32LE(int value, int offset)
{
if (BitConverter.IsLittleEndian)
{
byte[] bytes = BitConverter.GetBytes(value);
Array.Copy(bytes, 0, _header, offset, 4);
}
else
{
_header[offset] = (byte)value;
_header[offset + 1] = (byte)(value >> 8);
_header[offset + 2] = (byte)(value >> 16);
_header[offset + 3] = (byte)(value >> 24);
}
_header[offset] = (byte)value;
_header[offset + 1] = (byte)(value >> 8);
_header[offset + 2] = (byte)(value >> 16);
_header[offset + 3] = (byte)(value >> 24);
}
private void WriteUInt32LE(uint value, int offset)
{
if (BitConverter.IsLittleEndian)
{
byte[] bytes = BitConverter.GetBytes(value);
Array.Copy(bytes, 0, _header, offset, 4);
}
else
{
_header[offset] = (byte)value;
_header[offset + 1] = (byte)(value >> 8);
_header[offset + 2] = (byte)(value >> 16);
_header[offset + 3] = (byte)(value >> 24);
}
_header[offset] = (byte)value;
_header[offset + 1] = (byte)(value >> 8);
_header[offset + 2] = (byte)(value >> 16);
_header[offset + 3] = (byte)(value >> 24);
}
private void WriteInt64LE(long value, int offset)
{
if (BitConverter.IsLittleEndian)
{
byte[] bytes = BitConverter.GetBytes(value);
Array.Copy(bytes, 0, _header, offset, 8);
}
else
{
_header[offset] = (byte)value;
_header[offset + 1] = (byte)(value >> 8);
_header[offset + 2] = (byte)(value >> 16);
_header[offset + 3] = (byte)(value >> 24);
_header[offset + 4] = (byte)(value >> 32);
_header[offset + 5] = (byte)(value >> 40);
_header[offset + 6] = (byte)(value >> 48);
_header[offset + 7] = (byte)(value >> 56);
}
}
private void WriteUInt64LE(ulong value, int offset)
{
if (BitConverter.IsLittleEndian)
{
byte[] bytes = BitConverter.GetBytes(value);
Array.Copy(bytes, 0, _header, offset, 8);
}
else
{
_header[offset] = (byte)value;
_header[offset + 1] = (byte)(value >> 8);
_header[offset + 2] = (byte)(value >> 16);
_header[offset + 3] = (byte)(value >> 24);
_header[offset + 4] = (byte)(value >> 32);
_header[offset + 5] = (byte)(value >> 40);
_header[offset + 6] = (byte)(value >> 48);
_header[offset + 7] = (byte)(value >> 56);
}
_header[offset] = (byte)value;
_header[offset + 1] = (byte)(value >> 8);
_header[offset + 2] = (byte)(value >> 16);
_header[offset + 3] = (byte)(value >> 24);
_header[offset + 4] = (byte)(value >> 32);
_header[offset + 5] = (byte)(value >> 40);
_header[offset + 6] = (byte)(value >> 48);
_header[offset + 7] = (byte)(value >> 56);
}
private void WriteUInt64LE(ulong value, int offset)
{
_header[offset] = (byte)value;
_header[offset + 1] = (byte)(value >> 8);
_header[offset + 2] = (byte)(value >> 16);
_header[offset + 3] = (byte)(value >> 24);
_header[offset + 4] = (byte)(value >> 32);
_header[offset + 5] = (byte)(value >> 40);
_header[offset + 6] = (byte)(value >> 48);
_header[offset + 7] = (byte)(value >> 56);
}
#endregion
}
}
}
+93 -38
View File
@@ -1,11 +1,19 @@
using System;
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
namespace AsarSharp.Utils
{
internal static class Extensions
{
/// <summary>
/// Compute path relative to <paramref name="relativeTo"/>.
/// Fast common-case (path is inside relativeTo): plain prefix-strip.
/// Falls back to <see cref="Path.GetFullPath"/> + manual relativisation
/// when paths must be normalised or '..' segments are required.
/// Replaces previous URI-based implementation which was a large hot-path cost.
/// </summary>
public static string GetRelativePath(string relativeTo, string path)
{
if (string.IsNullOrEmpty(relativeTo))
@@ -13,84 +21,134 @@ namespace AsarSharp.Utils
if (string.IsNullOrEmpty(path))
throw new ArgumentNullException(nameof(path));
var fullRelativeTo = Path.GetFullPath(relativeTo);
var fullPath = Path.GetFullPath(path);
// Fast path: literal prefix match (no normalisation). Covers ~all
// intra-archive callers where both inputs already come from the
// same crawl pass.
string baseFast = TrimTrailingSeparators(relativeTo);
string pathFast = TrimTrailingSeparators(path);
if (string.Equals(fullRelativeTo, fullPath, StringComparison.OrdinalIgnoreCase))
return "";
if (string.Equals(baseFast, pathFast, StringComparison.OrdinalIgnoreCase))
return string.Empty;
var relativeToUri = new Uri(fullRelativeTo.EndsWith(Path.DirectorySeparatorChar.ToString())
? fullRelativeTo
: fullRelativeTo + Path.DirectorySeparatorChar);
var pathUri = new Uri(fullPath.EndsWith(Path.DirectorySeparatorChar.ToString()) && !File.Exists(fullPath)
? fullPath
: fullPath + (Directory.Exists(fullPath) ? Path.DirectorySeparatorChar.ToString() : ""));
if (pathFast.Length > baseFast.Length &&
pathFast.StartsWith(baseFast, StringComparison.OrdinalIgnoreCase) &&
IsSeparator(pathFast[baseFast.Length]))
{
return pathFast.Substring(baseFast.Length + 1);
}
var relativeUri = relativeToUri.MakeRelativeUri(pathUri);
var relativePath = Uri.UnescapeDataString(relativeUri.ToString())
.Replace('/', Path.DirectorySeparatorChar);
return relativePath.TrimEnd(Path.DirectorySeparatorChar);
// Slow path: normalise both sides and compute relative — used for
// security checks (out-of-tree symlink/destination guards) and the
// rare "go up" case.
return GetRelativePathNormalised(relativeTo, path);
}
private static string GetRelativePathNormalised(string relativeTo, string path)
{
string fullBase = Path.GetFullPath(relativeTo);
string fullPath = Path.GetFullPath(path);
fullBase = TrimTrailingSeparators(fullBase);
fullPath = TrimTrailingSeparators(fullPath);
if (string.Equals(fullBase, fullPath, StringComparison.OrdinalIgnoreCase))
return string.Empty;
if (fullPath.Length > fullBase.Length &&
fullPath.StartsWith(fullBase, StringComparison.OrdinalIgnoreCase) &&
IsSeparator(fullPath[fullBase.Length]))
{
return fullPath.Substring(fullBase.Length + 1);
}
// Need to walk up the common ancestor.
char sep = Path.DirectorySeparatorChar;
string[] baseParts = fullBase.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
string[] pathParts = fullPath.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
int common = 0;
int max = Math.Min(baseParts.Length, pathParts.Length);
while (common < max &&
string.Equals(baseParts[common], pathParts[common], StringComparison.OrdinalIgnoreCase))
{
common++;
}
var sb = new StringBuilder();
for (int i = common; i < baseParts.Length; i++)
{
if (sb.Length > 0) sb.Append(sep);
sb.Append("..");
}
for (int i = common; i < pathParts.Length; i++)
{
if (sb.Length > 0) sb.Append(sep);
sb.Append(pathParts[i]);
}
return sb.ToString();
}
private static string TrimTrailingSeparators(string s)
{
int end = s.Length;
while (end > 0 && IsSeparator(s[end - 1])) end--;
return end == s.Length ? s : s.Substring(0, end);
}
private static bool IsSeparator(char c) => c == '/' || c == '\\';
public static string GetDirectoryName(string path)
{
if (string.IsNullOrEmpty(path))
return ".";
string result = Path.GetDirectoryName(path);
// If the result is an empty string, return “.” as in Node.js
if (string.IsNullOrEmpty(result))
return ".";
return result;
}
public static void CopyDirectory(string sourceDir, string destinationDir)
{
// Create the destination directory
Directory.CreateDirectory(destinationDir);
// Get all files in the source directory
foreach (var file in Directory.GetFiles(sourceDir))
{
var destFile = Path.Combine(destinationDir, Path.GetFileName(file));
File.Copy(file, destFile, true);
}
// Recursively copy all subdirectories
foreach (var dir in Directory.GetDirectories(sourceDir))
{
var destDir = Path.Combine(destinationDir, Path.GetFileName(dir));
CopyDirectory(dir, destDir);
}
}
public static string GetBasePath(string dir)
{
// Look for the last path delimiter before any pattern
int wildcardIndex = dir.IndexOfAny(new[] { '*', '?' });
if (wildcardIndex == -1)
{
return dir;
}
int lastSeparatorIndex = dir.LastIndexOf(Path.DirectorySeparatorChar, wildcardIndex);
if (lastSeparatorIndex == -1)
{
return ".";
}
return dir.Substring(0, lastSeparatorIndex);
}
public static void SetUnixFilePermission(string filePath, string permission)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return;
// Use chmod
var process = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
@@ -105,14 +163,12 @@ namespace AsarSharp.Utils
process.Start();
process.WaitForExit();
}
public static void CreateSymbolicLink(string linkTarget, string linkPath)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// On Windows, creating symlinks requires special privileges,
// so on many systems it simply won't work without administrator privileges
NativeMethods.CreateSymbolicLink(linkPath, linkTarget,
Directory.Exists(linkTarget)
? NativeMethods.SymLinkFlag.Directory
@@ -120,7 +176,6 @@ namespace AsarSharp.Utils
return;
}
// In Unix systems we use the corresponding system call
var process = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
@@ -135,11 +190,11 @@ namespace AsarSharp.Utils
process.Start();
process.WaitForExit();
}
public static bool IsWindowsPlatform()
{
return Environment.OSVersion.Platform == PlatformID.Win32NT;
}
}
}
}
+115
View File
@@ -0,0 +1,115 @@
# Changelog
This file is the source of truth for release notes.
The newest entry must match the version in `WandEnhancer/Properties/AssemblyInfo.cs`.
## [1.0.8.0] - 2026-05-06
### Features
- Added the My Games list to the Remote Panel with remote start and stop actions.
- Improved the Remote Panel with new UI and overall UX.
- Added an update dialog with release notes and access to full patch notes.
### Improvements
- Optimized and sped up patching and ASAR unpack/pack operations.
### Fixes
- Fixed in-place handling of unpacked `app.asar.unpacked` assets during packing to avoid locked-file failures.
- Fixed local network IP detection for QR-based Remote Panel pairing, so the app no longer picks Cloudflare, VMware, and similar non-LAN adapters by mistake.
## [1.0.7.0] - 2026-05-01
### Features
- New Remote Web Panel: control local app features from a phone or another PC over the local network via QR code connection. #37
- Custom Script Loader: inject and execute custom user `.js` scripts directly into the Wand renderer process via the patch modal.
- Added the ability to export and copy application logs from the UI.
- Stabilized the DevTools on `F12` patch.
- Added a repository mirror on GitLab. #47
### Fixes
- Fixed ASAR unpacking failures on locked files or missing entries. #63 #57
## [1.0.6.0] - 2025-12-14
### Fixes
- Fixed issues related to Wand `12.5.1`. #35
- Fixed a bug where the patch could not be reapplied after restoring without restarting the patcher.
- Removed the redundant telemetry removal option from patch settings.
### Features
- Added localization support.
- Added the patch option to open Wand DevTools with `F12`.
## [1.0.5.0] - 2025-11-30
### Fixes
- Fixed the issue where games detected the debugger. #33 #23 #19 #13
### Breaking Changes
- Removed patch methods.
- Removed shortcut launch.
- Removed automatic patching for new versions because it is not compatible with the current patch method.
### Notes
- This version is incompatible with previous versions of the patcher. Before updating, previous patches must be rolled back.
- With the current method, the patcher only needs to be run once to apply the patch.
- Thanks to issue #12 for sharing the patching method used here.
## [1.0.4.0] - 2025-11-05
### Features
- Added backward compatibility for older WeMod versions so both legacy WeMod and the newer Wand builds can be patched.
- Added manual version management for patches, including separate patches and shortcuts for individual WeMod or Wand versions.
## [1.0.3.0] - 2025-11-03
### Fixes
- Fixed issues related to the WeMod to Wand rebrand. #24
## [1.0.2.0] - 2025-04-09
### Fixes
- Fixed a performance issue when a process with an applied patch was scanned again.
- Fixed exception propagation into the WeMod process. #11
## [1.0.1.0] - 2025-04-01
### Fixes
- Fixed WeMod overlay breakage when using the runtime patch.
## [1.0.0.0] - 2025-03-24
### Changes
- Replaced Electron with WPF.
- Reduced the `.exe` size by more than 70x.
- Updated the UI.
- Added two types of patching.
- Fixed hotkeys breaking after patching.
- Added patch recovery.
- Removed external dependencies such as Electron and ASAR tooling from runtime.
- Added a patch option to disable WeMod updates.
### Notes
- VirusTotal detection increased with the new patching method.
## [0.0.1] - 2025-01-04
### Changes
- Basic ElectronJS wrapper over the original script.
+17
View File
@@ -8,6 +8,7 @@ Thank you for your interest in the WandEnhancer project! This document provides
- [Bug Reports](#bug-reports)
- [Feature Suggestions](#feature-suggestions)
- [Creating a Pull Request](#creating-a-pull-request)
- [Release Process](#release-process)
- [Code Style](#code-style)
- [Testing](#testing)
- [License](#license)
@@ -82,6 +83,22 @@ Suggestions for new features or improvements are welcome! Create an Issue descri
7. In the Pull Request description, explain the changes made and why they're necessary.
## Release Process
1. Update `WandEnhancer/Properties/AssemblyInfo.cs`.
2. Add a new top section with the same version to `CHANGELOG.md`.
3. Configure local hooks once:
```
git config core.hooksPath .githooks
```
4. Commit and push the version/changelog changes.
5. Create and push a tag matching the same version exactly, for example:
```
git tag 1.0.8.0
git push origin 1.0.8.0
```
6. GitHub Actions will validate the version, build the project, extract the matching changelog section, and publish the release automatically.
## Code Style
- Use C# naming conventions:
+5 -2
View File
@@ -2,8 +2,11 @@
![logo](./assets/icon.svg)
---
# WandEnhancer
[![GitLab Mirror](https://img.shields.io/badge/GitLab-mirror-fc6d26?logo=gitlab)](https://gitlab.com/kitbyte/wand-enhancer)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-0/72-brightgreen?logo=virustotal)](https://www.virustotal.com/gui/file/f6897cf583e9f8ea11e0ee4c3fb99b86c50336b28de706e3e0b9181b4e3cf223)
</div>
<h4>An open-source interoperability tool designed to extend local client-side configurations and improve the UX of the Wand application.</h4>
@@ -74,4 +77,4 @@ This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) fi
---
[![Star History Chart](https://api.star-history.com/svg?repos=k1tbyte/Wand-Enhancer&type=Date)](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=k1tbyte/Wand-Enhancer&type=Date)](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date)
+94 -41
View File
@@ -20,17 +20,14 @@ namespace WandEnhancer.Core
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 AppBundleFilePrefix = "app-";
private const string AppBundleFileSuffix = ".bundle.js";
private const string IndexBundleFileName = "index.js";
private const string JavaScriptFileExtension = ".js";
private const string JavaScriptFileSearchPattern = "*.js";
private const string DuplicateScriptSuffix = ".custom";
@@ -56,52 +53,63 @@ namespace WandEnhancer.Core
_unpackedBackupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedBackupDirectoryName);
}
private string ApplyJsPatch(string fileName, string js, EnhancerConfig.PatchEntry patch, EPatchType patchType)
private string ApplyJsPatch(string fileName, string js, EnhancerConfig.PatchEntry patch, EPatchType patchType, out bool patchApplied)
{
patchApplied = false;
if (patch.Applied)
{
return js;
}
if (!CanSearchPatchInFile(fileName, patch) || !ContainsSearchHint(js, patch.SearchHints))
{
return js;
}
var matches = patch.Target.Matches(js);
if (matches.Count == 0)
var match = patch.Target.Match(js);
if (!match.Success)
{
return js;
}
var prefix = $"[ENHANCER] [{patchType} -> {patch.Name}]";
if(matches.Count > 1 && patch.SingleMatch)
if(patch.SingleMatch && match.NextMatch().Success)
{
throw new Exception(
$"{prefix} Patch failed. Multiple target functions found. Looks like the version is not supported");
}
string patchSource = patch.Patch;
if (patch.Resolver != null)
{
string resolvedField = patch.Resolver.Handler(matches[0].Value);
string resolvedField = patch.Resolver.Handler(match.Value);
if (string.IsNullOrEmpty(resolvedField))
{
throw new Exception($"{prefix} Resolver failed to find field name");
}
patch.Patch = patch.Patch.Replace(patch.Resolver.Placeholder, resolvedField);
patchSource = patchSource.Replace(patch.Resolver.Placeholder, resolvedField);
}
_logger($"{prefix} Found target function in: " + Path.GetFileName(fileName), ELogType.Info);
string newJs = patch.Target.Replace(js, patch.Patch);
File.WriteAllText(fileName, newJs);
string newJs = patch.SingleMatch
? patch.Target.Replace(js, patchSource, 1)
: patch.Target.Replace(js, patchSource);
_logger($"{prefix} Patch applied", ELogType.Success);
patch.Applied = true;
patchApplied = true;
return newJs;
}
private void PatchAsar()
{
var items = Directory.EnumerateFiles(_unpackedPath)
.Where(file => !Directory.Exists(file) && Regex.IsMatch(Path.GetFileName(file), @"^app-\w+|index\.js"))
var items = Directory.EnumerateFiles(_unpackedPath, $"*{JavaScriptFileExtension}", SearchOption.TopDirectoryOnly)
.Where(IsCandidateBundleFile)
.ToList();
if (!items.Any())
@@ -118,15 +126,23 @@ namespace WandEnhancer.Core
{
break;
}
if (!CouldFileContainRemainingPatch(item, remainingPatches, enhancerConfig))
{
continue;
}
string data = File.ReadAllText(item);
bool fileChanged = false;
foreach (var entry in remainingPatches.ToList())
{
var entries = enhancerConfig[entry];
foreach (var patchEntry in entries)
{
data = ApplyJsPatch(item, data, patchEntry, entry);
bool patchApplied;
data = ApplyJsPatch(item, data, patchEntry, entry, out patchApplied);
fileChanged = fileChanged || patchApplied;
}
if (entries.All(x => x.Applied))
@@ -134,6 +150,11 @@ namespace WandEnhancer.Core
remainingPatches.Remove(entry);
}
}
if (fileChanged)
{
File.WriteAllText(item, data);
}
}
if(remainingPatches.Count > 0)
@@ -143,6 +164,56 @@ namespace WandEnhancer.Core
}
}
private static bool IsCandidateBundleFile(string filePath)
{
string fileName = Path.GetFileName(filePath);
return fileName.Equals(IndexBundleFileName, StringComparison.OrdinalIgnoreCase)
|| (fileName.StartsWith(AppBundleFilePrefix, StringComparison.OrdinalIgnoreCase)
&& fileName.EndsWith(AppBundleFileSuffix, StringComparison.OrdinalIgnoreCase));
}
private static bool CouldFileContainRemainingPatch(string filePath, IEnumerable<EPatchType> remainingPatches, Dictionary<EPatchType, EnhancerConfig.PatchEntry[]> enhancerConfig)
{
foreach (var patchType in remainingPatches)
{
foreach (var patchEntry in enhancerConfig[patchType])
{
if (patchEntry.Applied)
{
continue;
}
if (CanSearchPatchInFile(filePath, patchEntry))
{
return true;
}
}
}
return false;
}
private static bool CanSearchPatchInFile(string filePath, EnhancerConfig.PatchEntry patch)
{
if (patch.CandidateFileNames == null || patch.CandidateFileNames.Length == 0)
{
return true;
}
string fileName = Path.GetFileName(filePath);
return patch.CandidateFileNames.Any(candidate => fileName.Equals(candidate, StringComparison.OrdinalIgnoreCase));
}
private static bool ContainsSearchHint(string source, string[] searchHints)
{
if (searchHints == null || searchHints.Length == 0)
{
return true;
}
return searchHints.Any(searchHint => source.IndexOf(searchHint, StringComparison.Ordinal) >= 0);
}
private static string FindWorkspacePath(params string[] segments)
{
string current = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
@@ -257,26 +328,6 @@ namespace WandEnhancer.Core
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);
@@ -335,15 +386,17 @@ namespace WandEnhancer.Core
CopyDirectory(FindWorkspacePath(WebPanelDirectoryName, WebPanelDistDirectoryName), targetRoot);
}
if (!CopyEmbeddedFile(EmbeddedRemotePanelBridgeResourceName, targetBridgePath))
if (!File.Exists(targetBridgePath))
{
File.Copy(FindWorkspacePath(WebPanelDirectoryName, WebPanelBridgeDirectoryName, RemoteBridgeSourceFileName), targetBridgePath, true);
throw new FileNotFoundException("[ENHANCER] Remote bridge artifact is missing. Run `cd web-panel && pnpm run build` before patching.", targetBridgePath);
}
int defaultScriptCount = CopyEmbeddedDirectory(EmbeddedRemotePanelDefaultScriptsPrefix, targetScriptsRoot);
int defaultScriptCount = Directory.Exists(targetScriptsRoot)
? Directory.GetFiles(targetScriptsRoot, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly).Length
: 0;
if (defaultScriptCount == 0)
{
defaultScriptCount = CopyJavaScriptFiles(FindWorkspacePath(WebPanelDirectoryName, WebPanelScriptsDirectoryName, DefaultScriptsDirectoryName), targetScriptsRoot);
throw new FileNotFoundException("[ENHANCER] Remote renderer script artifacts are missing. Run `cd web-panel && pnpm run build` before patching.", targetScriptsRoot);
}
int selectedScriptCount = CopySelectedJavaScriptFiles(_config.CustomScriptPaths, targetScriptsRoot);
+19 -3
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using WandEnhancer.Models;
@@ -23,6 +23,8 @@ namespace WandEnhancer.Core
public string Name { get; set; }
public bool Applied { get; set; }
public bool SingleMatch { get; set; } = true;
public string[] CandidateFileNames { get; set; }
public string[] SearchHints { get; set; }
public ResolveContext Resolver { get; set; }
}
@@ -36,6 +38,7 @@ namespace WandEnhancer.Core
{
new PatchEntry
{
SearchHints = new[] { "getUserAccount()", "/v3/account" },
Resolver = new ResolveContext
{
Handler = (targetFunction) =>
@@ -53,6 +56,7 @@ namespace WandEnhancer.Core
},
new PatchEntry
{
SearchHints = new[] { "setAccountWandBrandExperience()", "/v3/account/brand_experience_wand" },
Resolver = new ResolveContext
{
Handler = (targetFunction) =>
@@ -64,7 +68,7 @@ namespace WandEnhancer.Core
},
Name = "setAccountWandBrandExperience",
Target = new Regex(
@"setAccountWandBrandExperience\(\){.*?return\s+this\.#\w+\.post\(""/v3/account/brand_experience_wand""\)}",
@"setAccountWandBrandExperience\(\)\{.*?return\s+this\.#\w+\.post\(""/v3/account/brand_experience_wand""\)\}",
RegexOptions.Singleline),
Patch =
"setAccountWandBrandExperience(){return this.#<service_name>.post(\"/v3/account/brand_experience_wand\").then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}"
@@ -77,6 +81,8 @@ namespace WandEnhancer.Core
{
new PatchEntry
{
CandidateFileNames = new[] { "index.js" },
SearchHints = new[] { "ACTION_CHECK_FOR_UPDATE" },
Target = new Regex(@"registerHandler\(""ACTION_CHECK_FOR_UPDATE"".*?\)\)\)\)",
RegexOptions.Singleline),
Patch = "registerHandler(\"ACTION_CHECK_FOR_UPDATE\",(e=>expectUpdateFeedUrl(e,(e=>null)))"
@@ -90,6 +96,8 @@ namespace WandEnhancer.Core
new PatchEntry
{
Name = "devToolsBeforeInputEvent",
CandidateFileNames = new[] { "index.js" },
SearchHints = new[] { "whenReady().then(" },
// Anchor on the Electron main-process `<app>.whenReady().then(`
// call. This site is far more stable than the minified renderer
// keydown listener that previously held the F12 -> ACTION_OPEN_DEV_TOOLS
@@ -109,42 +117,50 @@ namespace WandEnhancer.Core
new PatchEntry
{
Name = "remoteBridgeMainBoot",
CandidateFileNames = new[] { "index.js" },
SearchHints = new[] { "whenReady().then(run)" },
Target = new Regex(@"(?<app>\w+)\.whenReady\(\)\.then\(run\)"),
Patch = "${app}.whenReady().then(()=>{try{const p=require(\"node:path\");require(p.join(__dirname,\"remote-panel\",\"bridge.cjs\")).installWandRuntime(require(\"electron\"));}catch(e){try{const fs=require(\"node:fs\"),os=require(\"node:os\"),p=require(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [boot-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}}return run()})"
},
new PatchEntry
{
Name = "remoteBridgeReset",
SearchHints = new[] { "client-state" },
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",
SearchHints = new[] { "client-state" },
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",
SearchHints = new[] { "client-state" },
Target = new Regex(@"setCurrentTrainer\(e,t=null\)\{const s=e\?\.trainerId\|\|null,i=\(s\?e\?\.gameId:null\)\|\|null,n=\(s\?e\?\.supportedVersions:null\)\|\|\[];if\(s===this\.#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",
SearchHints = new[] { "client-value-changed" },
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",
SearchHints = new[] { "remote_tooltip.scan_the_qr_code_or_visit_the_site", "remote_tooltip.connect_to_wand_remote" },
Target = new Regex(@"remoteUrl=""wemodwebsite://remote"""),
Patch = "remoteUrl=globalThis.__wandRemoteBridgeUrl||\"" + RemoteWebPanelFallbackUrl + "\""
},
new PatchEntry
{
Name = "remoteQrPreviewUrl",
SearchHints = new[] { "resources/elements/remote-qr-code" },
Resolver = new ResolveContext
{
Handler = (matchContent) =>
@@ -162,4 +178,4 @@ namespace WandEnhancer.Core
};
}
}
}
}
+8
View File
@@ -41,6 +41,14 @@
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Vor dem Update wird dringend empfohlen, Änderungen rückgängig zu machen, falls sie angewendet wurden</s:String>
<s:String x:Key="up_current_version">Aktuelle Version</s:String>
<s:String x:Key="up_latest_version">Neueste Version</s:String>
<s:String x:Key="up_release_notes">Versionshinweise</s:String>
<s:String x:Key="up_release_notes_unavailable">Für diese Version sind keine Versionshinweise verfügbar.</s:String>
<s:String x:Key="up_show_more">Gesamtes Changelog anzeigen</s:String>
<s:String x:Key="up_show_less">Nur aktuelle Hinweise anzeigen</s:String>
<s:String x:Key="up_loading_changelog">Changelog wird geladen...</s:String>
<s:String x:Key="up_changelog_failed">Das vollständige Changelog konnte nicht geladen werden. Stattdessen werden die aktuellen Hinweise angezeigt.</s:String>
<s:String x:Key="up_update_now">Jetzt aktualisieren</s:String>
<s:String x:Key="up_popup_title">Update verfügbar!</s:String>
<!--#endregion -->
+8
View File
@@ -41,6 +41,14 @@
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Before updating, it is strongly recommended to roll back modifications if they have been applied</s:String>
<s:String x:Key="up_current_version">Current version</s:String>
<s:String x:Key="up_latest_version">Latest version</s:String>
<s:String x:Key="up_release_notes">Release notes</s:String>
<s:String x:Key="up_release_notes_unavailable">Release notes are unavailable for this release.</s:String>
<s:String x:Key="up_show_more">Show full changelog</s:String>
<s:String x:Key="up_show_less">Show latest notes</s:String>
<s:String x:Key="up_loading_changelog">Loading changelog...</s:String>
<s:String x:Key="up_changelog_failed">Failed to load the full changelog. The latest notes are shown instead.</s:String>
<s:String x:Key="up_update_now">Update now</s:String>
<s:String x:Key="up_popup_title">Update available!</s:String>
<!--#endregion -->
+8
View File
@@ -41,6 +41,14 @@
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Antes de actualizar, se recomienda encarecidamente revertir las modificaciones si se han aplicado</s:String>
<s:String x:Key="up_current_version">Versión actual</s:String>
<s:String x:Key="up_latest_version">Última versión</s:String>
<s:String x:Key="up_release_notes">Notas de la versión</s:String>
<s:String x:Key="up_release_notes_unavailable">Las notas de la versión no están disponibles para esta versión.</s:String>
<s:String x:Key="up_show_more">Mostrar changelog completo</s:String>
<s:String x:Key="up_show_less">Mostrar solo las notas actuales</s:String>
<s:String x:Key="up_loading_changelog">Cargando changelog...</s:String>
<s:String x:Key="up_changelog_failed">No se pudo cargar el changelog completo. Se muestran las notas actuales.</s:String>
<s:String x:Key="up_update_now">Actualizar ahora</s:String>
<s:String x:Key="up_popup_title">¡Actualización disponible!</s:String>
<!--#endregion -->
+8
View File
@@ -41,6 +41,14 @@
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Avant la mise à jour, il est fortement recommandé d'annuler les modifications si elles ont été appliquées</s:String>
<s:String x:Key="up_current_version">Version actuelle</s:String>
<s:String x:Key="up_latest_version">Dernière version</s:String>
<s:String x:Key="up_release_notes">Notes de version</s:String>
<s:String x:Key="up_release_notes_unavailable">Les notes de version ne sont pas disponibles pour cette version.</s:String>
<s:String x:Key="up_show_more">Afficher le changelog complet</s:String>
<s:String x:Key="up_show_less">Afficher uniquement les notes actuelles</s:String>
<s:String x:Key="up_loading_changelog">Chargement du changelog...</s:String>
<s:String x:Key="up_changelog_failed">Impossible de charger le changelog complet. Les notes actuelles sont affichées à la place.</s:String>
<s:String x:Key="up_update_now">Mettre à jour maintenant</s:String>
<s:String x:Key="up_popup_title">Mise à jour disponible !</s:String>
<!--#endregion -->
+8
View File
@@ -41,6 +41,14 @@
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Prima dell'aggiornamento, si consiglia vivamente di annullare le modifiche se sono state applicate</s:String>
<s:String x:Key="up_current_version">Versione corrente</s:String>
<s:String x:Key="up_latest_version">Ultima versione</s:String>
<s:String x:Key="up_release_notes">Note di rilascio</s:String>
<s:String x:Key="up_release_notes_unavailable">Le note di rilascio non sono disponibili per questa versione.</s:String>
<s:String x:Key="up_show_more">Mostra il changelog completo</s:String>
<s:String x:Key="up_show_less">Mostra solo le note correnti</s:String>
<s:String x:Key="up_loading_changelog">Caricamento del changelog...</s:String>
<s:String x:Key="up_changelog_failed">Impossibile caricare il changelog completo. Vengono mostrate solo le note correnti.</s:String>
<s:String x:Key="up_update_now">Aggiorna ora</s:String>
<s:String x:Key="up_popup_title">Aggiornamento disponibile!</s:String>
<!--#endregion -->
+8
View File
@@ -41,6 +41,14 @@
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">アップデート前に、変更が適用されている場合はロールバックすることを強くお勧めします</s:String>
<s:String x:Key="up_current_version">現在のバージョン</s:String>
<s:String x:Key="up_latest_version">最新バージョン</s:String>
<s:String x:Key="up_release_notes">リリースノート</s:String>
<s:String x:Key="up_release_notes_unavailable">このリリースのリリースノートは利用できません。</s:String>
<s:String x:Key="up_show_more">完全な変更履歴を表示</s:String>
<s:String x:Key="up_show_less">最新のリリースノートのみ表示</s:String>
<s:String x:Key="up_loading_changelog">変更履歴を読み込み中...</s:String>
<s:String x:Key="up_changelog_failed">完全な変更履歴を読み込めませんでした。代わりに最新のリリースノートを表示しています。</s:String>
<s:String x:Key="up_update_now">今すぐ更新</s:String>
<s:String x:Key="up_popup_title">アップデート利用可能!</s:String>
<!--#endregion -->
+8
View File
@@ -41,6 +41,14 @@
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Przed aktualizacją zdecydowanie zaleca się cofnięcie zmian, jeśli zostały zastosowane</s:String>
<s:String x:Key="up_current_version">Aktualna wersja</s:String>
<s:String x:Key="up_latest_version">Najnowsza wersja</s:String>
<s:String x:Key="up_release_notes">Informacje o wydaniu</s:String>
<s:String x:Key="up_release_notes_unavailable">Informacje o wydaniu są niedostępne dla tej wersji.</s:String>
<s:String x:Key="up_show_more">Pokaż cały changelog</s:String>
<s:String x:Key="up_show_less">Pokaż tylko bieżące zmiany</s:String>
<s:String x:Key="up_loading_changelog">Ładowanie changeloga...</s:String>
<s:String x:Key="up_changelog_failed">Nie udało się załadować pełnego changeloga. Zamiast tego wyświetlono bieżące zmiany.</s:String>
<s:String x:Key="up_update_now">Aktualizuj teraz</s:String>
<s:String x:Key="up_popup_title">Dostępna aktualizacja!</s:String>
<!--#endregion -->
+8
View File
@@ -41,6 +41,14 @@
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Antes de atualizar, é altamente recomendável reverter as modificações se elas foram aplicadas</s:String>
<s:String x:Key="up_current_version">Versão atual</s:String>
<s:String x:Key="up_latest_version">Versão mais recente</s:String>
<s:String x:Key="up_release_notes">Notas da versão</s:String>
<s:String x:Key="up_release_notes_unavailable">As notas da versão não estão disponíveis para esta versão.</s:String>
<s:String x:Key="up_show_more">Mostrar changelog completo</s:String>
<s:String x:Key="up_show_less">Mostrar apenas as notas atuais</s:String>
<s:String x:Key="up_loading_changelog">Carregando changelog...</s:String>
<s:String x:Key="up_changelog_failed">Falha ao carregar o changelog completo. As notas atuais estão sendo exibidas.</s:String>
<s:String x:Key="up_update_now">Atualizar agora</s:String>
<s:String x:Key="up_popup_title">Atualização disponível!</s:String>
<!--#endregion -->
+8
View File
@@ -41,6 +41,14 @@
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Перед обновлением настоятельно рекомендуется откатить изменения, если они были применены</s:String>
<s:String x:Key="up_current_version">Текущая версия</s:String>
<s:String x:Key="up_latest_version">Новая версия</s:String>
<s:String x:Key="up_release_notes">Что нового</s:String>
<s:String x:Key="up_release_notes_unavailable">Для этого релиза патчноуты недоступны.</s:String>
<s:String x:Key="up_show_more">Показать весь changelog</s:String>
<s:String x:Key="up_show_less">Показать только актуальные изменения</s:String>
<s:String x:Key="up_loading_changelog">Загрузка changelog...</s:String>
<s:String x:Key="up_changelog_failed">Не удалось загрузить полный changelog. Показаны только актуальные изменения.</s:String>
<s:String x:Key="up_update_now">Обновить сейчас</s:String>
<s:String x:Key="up_popup_title">Доступно обновление!</s:String>
<!--#endregion -->
+8
View File
@@ -41,6 +41,14 @@
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Güncellemeden önce, değişiklikler uygulandıysa geri almak şiddetle tavsiye edilir</s:String>
<s:String x:Key="up_current_version">Geçerli sürüm</s:String>
<s:String x:Key="up_latest_version">En son sürüm</s:String>
<s:String x:Key="up_release_notes">Sürüm notları</s:String>
<s:String x:Key="up_release_notes_unavailable">Bu sürüm için sürüm notları kullanılamıyor.</s:String>
<s:String x:Key="up_show_more">Tüm changelog'u göster</s:String>
<s:String x:Key="up_show_less">Yalnızca güncel notları göster</s:String>
<s:String x:Key="up_loading_changelog">Changelog yükleniyor...</s:String>
<s:String x:Key="up_changelog_failed">Tam changelog yüklenemedi. Bunun yerine güncel notlar gösteriliyor.</s:String>
<s:String x:Key="up_update_now">Şimdi güncelle</s:String>
<s:String x:Key="up_popup_title">Güncelleme mevcut!</s:String>
<!--#endregion -->
+8
View File
@@ -41,6 +41,14 @@
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">Перед оновленням наполегливо рекомендується відкотити зміни, якщо вони були застосовані</s:String>
<s:String x:Key="up_current_version">Поточна версія</s:String>
<s:String x:Key="up_latest_version">Остання версія</s:String>
<s:String x:Key="up_release_notes">Нотатки до релізу</s:String>
<s:String x:Key="up_release_notes_unavailable">Нотатки до цього релізу недоступні.</s:String>
<s:String x:Key="up_show_more">Показати весь список змін</s:String>
<s:String x:Key="up_show_less">Показати лише актуальні зміни</s:String>
<s:String x:Key="up_loading_changelog">Завантаження списку змін...</s:String>
<s:String x:Key="up_changelog_failed">Не вдалося завантажити повний список змін. Натомість показано лише актуальні зміни.</s:String>
<s:String x:Key="up_update_now">Оновити зараз</s:String>
<s:String x:Key="up_popup_title">Доступне оновлення!</s:String>
<!--#endregion -->
+8
View File
@@ -41,6 +41,14 @@
<!--#region UpdatePopup -->
<s:String x:Key="up_warning">在更新之前,强烈建议回滚已应用的修改</s:String>
<s:String x:Key="up_current_version">当前版本</s:String>
<s:String x:Key="up_latest_version">最新版本</s:String>
<s:String x:Key="up_release_notes">更新说明</s:String>
<s:String x:Key="up_release_notes_unavailable">此版本的更新说明不可用。</s:String>
<s:String x:Key="up_show_more">显示完整更新日志</s:String>
<s:String x:Key="up_show_less">仅显示当前说明</s:String>
<s:String x:Key="up_loading_changelog">正在加载更新日志...</s:String>
<s:String x:Key="up_changelog_failed">无法加载完整更新日志。当前仅显示本次说明。</s:String>
<s:String x:Key="up_update_now">立即更新</s:String>
<s:String x:Key="up_popup_title">有更新可用!</s:String>
<!--#endregion -->
+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.7.0")]
[assembly: AssemblyFileVersion("1.0.7.0")]
[assembly: AssemblyVersion("1.0.8.0")]
[assembly: AssemblyFileVersion("1.0.8.0")]
+155 -4
View File
@@ -1,15 +1,23 @@
using System;
using System.Globalization;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using System.Net.Http;
using System.Windows;
using Newtonsoft.Json;
namespace WandEnhancer.Utils
{
public class UpdateReleaseInfo
{
public string Version { get; set; }
public string LatestNotes { get; set; }
}
public class GitHubRelease
{
public class AssetsType
@@ -26,11 +34,19 @@ namespace WandEnhancer.Utils
[JsonProperty("assets")]
public AssetsType[] Assets { get; set; }
[JsonProperty("body")]
public string Body { get; set; }
[JsonProperty("published_at")]
public DateTimeOffset PublishedAt { get; set; }
}
public class Updater
{
private GitHubRelease _release = null;
private UpdateReleaseInfo _updateInfo = null;
private string _fullChangelog = null;
private static readonly HttpClient _httpClient = new HttpClient()
{
DefaultRequestHeaders =
@@ -40,6 +56,7 @@ namespace WandEnhancer.Utils
};
private static readonly string ApiUrl = $"https://api.github.com/repos/{Constants.Owner}/{Constants.RepoName}/releases/latest";
private static readonly string ReleasesApiUrl = $"https://api.github.com/repos/{Constants.Owner}/{Constants.RepoName}/releases?per_page=20";
public async Task<bool> CheckForUpdates()
{
try
@@ -48,22 +65,59 @@ namespace WandEnhancer.Utils
var response = await _httpClient.GetAsync(ApiUrl);
response.EnsureSuccessStatusCode();
_release = JsonConvert.DeserializeObject<GitHubRelease>(await response.Content.ReadAsStringAsync());
_updateInfo = null;
_fullChangelog = null;
if (_release == null)
{
return false;
}
var latestVersion = new Version(_release.TagName);
var latestVersion = ParseVersion(_release.TagName);
if (latestVersion <= currentVersion)
{
return false;
}
_updateInfo = new UpdateReleaseInfo
{
Version = NormalizeVersion(_release.TagName),
LatestNotes = NormalizeText(_release.Body)
};
return latestVersion > currentVersion;
return true;
}
catch (Exception e)
catch (Exception)
{
return false;
}
}
public async Task<UpdateReleaseInfo> GetUpdateInfoAsync()
{
if (_updateInfo != null)
{
return _updateInfo;
}
return await CheckForUpdates()
? _updateInfo
: null;
}
public async Task<string> GetFullChangelogAsync()
{
if (!string.IsNullOrWhiteSpace(_fullChangelog))
{
return NormalizeText(_fullChangelog);
}
_fullChangelog = await TryLoadFullChangelogAsync();
return NormalizeText(_fullChangelog);
}
public async Task Update()
{
if (_release == null)
@@ -125,6 +179,103 @@ namespace WandEnhancer.Utils
throw new Exception($"Update failed: {ex.Message}");
}
}
private static Version ParseVersion(string versionTag)
{
return new Version(NormalizeVersion(versionTag));
}
private static string NormalizeVersion(string versionTag)
{
if (string.IsNullOrWhiteSpace(versionTag))
{
throw new ArgumentException("Version tag cannot be empty.", nameof(versionTag));
}
return versionTag.Trim().TrimStart('v', 'V');
}
private static string NormalizeText(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return NormalizeLineEndings(text).Trim();
}
private static string NormalizeLineEndings(string text)
{
return text
.Replace("\r\n", "\n")
.Replace('\r', '\n');
}
private static async Task<string> TryLoadFullChangelogAsync()
{
return await TryBuildReleaseHistoryAsync();
}
private static async Task<string> TryBuildReleaseHistoryAsync()
{
try
{
var response = await _httpClient.GetAsync(ReleasesApiUrl);
if (!response.IsSuccessStatusCode)
{
return null;
}
var releases = JsonConvert.DeserializeObject<GitHubRelease[]>(await response.Content.ReadAsStringAsync());
if (releases == null || releases.Length == 0)
{
return null;
}
return BuildReleaseHistory(releases);
}
catch
{
return null;
}
}
private static string BuildReleaseHistory(GitHubRelease[] releases)
{
var builder = new StringBuilder();
foreach (var release in releases.Where(item => !string.IsNullOrWhiteSpace(item?.TagName)))
{
if (builder.Length > 0)
{
builder.AppendLine();
builder.AppendLine();
}
builder.Append("## [")
.Append(NormalizeVersion(release.TagName))
.Append("]");
if (release.PublishedAt != default(DateTimeOffset))
{
builder.Append(" - ")
.Append(release.PublishedAt.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
}
var notes = NormalizeText(release.Body);
if (string.IsNullOrWhiteSpace(notes))
{
continue;
}
builder.AppendLine();
builder.AppendLine();
builder.Append(notes);
}
return NormalizeText(builder.ToString());
}
}
}
+17 -4
View File
@@ -185,10 +185,19 @@ namespace WandEnhancer.View.MainWindow
});
}
private void OnUpdate(object param)
private async void OnUpdate(object param)
{
MainWindow.Instance.OpenPopup(new UpdatePopup(() =>
var updateInfo = await _updater.GetUpdateInfoAsync();
if (updateInfo == null)
{
Log("No update details are available right now.", ELogType.Warn);
return;
}
MainWindow.Instance.OpenPopup(new UpdatePopup(Constants.Version.ToString(), updateInfo.Version,
updateInfo.LatestNotes, () =>
{
MainWindow.Instance.ClosePopup();
Task.Run(async () =>
{
try
@@ -203,7 +212,7 @@ namespace WandEnhancer.View.MainWindow
Log("WandEnhancer updated successfully. Restarting...", ELogType.Success);
});
}), Application.Current.FindResource("up_popup_title") as string);
}, () => _updater.GetFullChangelogAsync()), Application.Current.FindResource("up_popup_title") as string);
}
private void OnOpenSettings(object param)
@@ -271,7 +280,11 @@ namespace WandEnhancer.View.MainWindow
public MainWindowVm(MainWindow view)
{
Task.Run(async () => IsUpdateAvailable = await _updater.CheckForUpdates());
Task.Run(async () =>
{
var isUpdateAvailable = await _updater.CheckForUpdates();
Application.Current.Dispatcher.Invoke(() => IsUpdateAvailable = isUpdateAvailable);
});
_view = view;
SetFolderPathCommand = new RelayCommand(OnFolderPathSelection);
ApplyPatchCommand = new RelayCommand(OnPatching);
+92 -8
View File
@@ -3,18 +3,102 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WandEnhancer.View.Popups"
mc:Ignorable="d"
d:DesignHeight="Auto" d:DesignWidth="Auto"
Background="{DynamicResource Background}"
Foreground="{DynamicResource MutedForeground}"
FontWeight="Medium"
FontSize="13">
<StackPanel>
<TextBlock Foreground="Red" MaxWidth="320" TextAlignment="Center" Text="{DynamicResource up_warning}" TextWrapping="Wrap" />
<Button Padding="0 5 0 5" Margin="0 15 0 0" Content="{DynamicResource up_update_now}"
Click="OnUpdateClick" />
</StackPanel>
<Grid MinWidth="500" MaxWidth="620">
<Grid.Resources>
<Style x:Key="UpdateActionButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="{DynamicResource Primary}" />
<Setter Property="BorderBrush" Value="{DynamicResource Primary}" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryForeground}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource Primary}" />
<Setter Property="BorderBrush" Value="{DynamicResource Primary}" />
</Trigger>
</Style.Triggers>
</Style>
</Grid.Resources>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border Grid.Row="0" HorizontalAlignment="Left" MaxWidth="560"
Padding="9 4" CornerRadius="4"
Background="{DynamicResource Muted}">
<DockPanel LastChildFill="True">
<Ellipse Width="7" Height="7" Fill="{DynamicResource Destructive}"
Margin="0 0 8 0" VerticalAlignment="Center" />
<TextBlock Foreground="{DynamicResource Foreground}" FontSize="11.5"
Text="{DynamicResource up_warning}" TextWrapping="Wrap" />
</DockPanel>
</Border>
<WrapPanel Grid.Row="1" Margin="0 10 0 0" Orientation="Horizontal">
<Border Padding="8 4" Margin="0 0 8 0" CornerRadius="4"
Background="{DynamicResource Muted}" BorderBrush="{DynamicResource Border}" BorderThickness="1">
<StackPanel Orientation="Horizontal">
<TextBlock Margin="0 0 7 0" VerticalAlignment="Center"
Foreground="{DynamicResource MutedForeground}" FontSize="10.5"
Text="{DynamicResource up_current_version}" />
<TextBlock x:Name="CurrentVersionValue" VerticalAlignment="Center"
Foreground="{DynamicResource Foreground}" FontSize="12.5" FontWeight="Bold" />
</StackPanel>
</Border>
<Border Padding="8 4" CornerRadius="4"
Background="{DynamicResource Primary}" BorderThickness="1">
<StackPanel Orientation="Horizontal">
<TextBlock Margin="0 0 7 0" VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryForeground}" FontSize="10.5"
Text="{DynamicResource up_latest_version}" />
<TextBlock x:Name="LatestVersionValue" VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryForeground}" FontSize="12.5" FontWeight="Bold" />
</StackPanel>
</Border>
</WrapPanel>
<TextBlock Grid.Row="2" Margin="0 12 0 6" Foreground="{DynamicResource Foreground}"
FontSize="14" FontWeight="Bold" Text="{DynamicResource up_release_notes}" />
<Border Grid.Row="3"
Background="{DynamicResource Muted}" BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="4">
<ScrollViewer x:Name="NotesScrollViewer"
VerticalScrollBarVisibility="Hidden"
HorizontalScrollBarVisibility="Disabled"
CanContentScroll="False">
<TextBox x:Name="NotesTextBlock" Background="Transparent"
BorderBrush="Transparent"
Padding="12"
BorderThickness="0" IsReadOnly="True"
Foreground="{DynamicResource Foreground}"
TextWrapping="Wrap" />
</ScrollViewer>
</Border>
<Grid Grid.Row="4" Margin="0 12 0 0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button x:Name="ShowMoreButton" Grid.Column="1" Margin="0 0 10 0"
Padding="12 5" Content="{DynamicResource up_show_more}"
Click="OnShowMoreClick" />
<Button Grid.Column="2" Padding="18 5" Style="{StaticResource UpdateActionButton}"
Content="{DynamicResource up_update_now}"
Click="OnUpdateClick" />
</Grid>
</Grid>
</UserControl>
+74 -1
View File
@@ -1,4 +1,5 @@
using System;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
@@ -7,16 +8,88 @@ namespace WandEnhancer.View.Popups
public partial class UpdatePopup : UserControl
{
private readonly Action _onUpdate;
private readonly Func<Task<string>> _loadFullChangelog;
private readonly string _latestNotes;
private string _fullChangelog;
private bool _showingFullChangelog;
public UpdatePopup(Action onUpdate)
public UpdatePopup(string currentVersion, string latestVersion, string latestNotes, Action onUpdate,
Func<Task<string>> loadFullChangelog)
{
_onUpdate = onUpdate;
_loadFullChangelog = loadFullChangelog;
InitializeComponent();
CurrentVersionValue.Text = currentVersion;
LatestVersionValue.Text = latestVersion;
_latestNotes = string.IsNullOrWhiteSpace(latestNotes)
? GetResourceText("up_release_notes_unavailable")
: latestNotes;
SetNotesText(_latestNotes);
ShowMoreButton.Visibility = loadFullChangelog == null ? Visibility.Collapsed : Visibility.Visible;
}
private void OnUpdateClick(object sender, RoutedEventArgs e)
{
_onUpdate();
}
private async void OnShowMoreClick(object sender, RoutedEventArgs e)
{
if (_loadFullChangelog == null)
{
return;
}
if (_showingFullChangelog)
{
SetNotesText(_latestNotes);
ShowMoreButton.Content = GetResourceText("up_show_more");
_showingFullChangelog = false;
return;
}
if (string.IsNullOrWhiteSpace(_fullChangelog))
{
ShowMoreButton.IsEnabled = false;
ShowMoreButton.Content = GetResourceText("up_loading_changelog");
try
{
_fullChangelog = await _loadFullChangelog();
}
finally
{
ShowMoreButton.IsEnabled = true;
}
}
if (string.IsNullOrWhiteSpace(_fullChangelog))
{
ShowMoreButton.Content = GetResourceText("up_show_more");
SetNotesText(string.Concat(
_latestNotes,
Environment.NewLine,
Environment.NewLine,
GetResourceText("up_changelog_failed")));
return;
}
SetNotesText(_fullChangelog);
ShowMoreButton.Content = GetResourceText("up_show_less");
_showingFullChangelog = true;
}
private void SetNotesText(string text)
{
NotesTextBlock.Text = text ?? string.Empty;
NotesScrollViewer.ScrollToTop();
}
private static string GetResourceText(string key)
{
return Application.Current.TryFindResource(key) as string ?? string.Empty;
}
}
}
+11 -12
View File
@@ -40,8 +40,11 @@
<PropertyGroup>
<StartupObject>WandEnhancer.Program</StartupObject>
<CMakeSourceDir>..\tools\asar-fuses-bypass</CMakeSourceDir>
<CMakeBuildDir>$(CMakeSourceDir)\cmake-build-release</CMakeBuildDir>
<ProxyDllPath>$(CMakeBuildDir)\version.dll</ProxyDllPath>
<NativeBuildRoot>..\.tmp\cmake</NativeBuildRoot>
<NativeBuildConfiguration Condition="'$(Configuration)' == 'Debug'">Debug</NativeBuildConfiguration>
<NativeBuildConfiguration Condition="'$(NativeBuildConfiguration)' == ''">Release</NativeBuildConfiguration>
<CMakeBuildDir>$(NativeBuildRoot)\asar-fuses-bypass</CMakeBuildDir>
<ProxyDllPath>$(CMakeBuildDir)\$(NativeBuildConfiguration)\version.dll</ProxyDllPath>
</PropertyGroup>
<ItemGroup>
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
@@ -151,10 +154,10 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AsarSharp\AsarSharp.csproj">
<Project>{beaa604a-402a-4387-8903-a53fc913a26e}</Project>
<Project>{BEAA604A-402A-4387-8903-A53FC913A26E}</Project>
<Name>AsarSharp</Name>
</ProjectReference>
</ItemGroup>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="$(ProxyDllPath)">
<LogicalName>proxydll</LogicalName>
@@ -164,12 +167,6 @@
<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" />
@@ -180,7 +177,7 @@
<Error Condition="!Exists('..\packages\ILRepack.2.0.41\build\ILRepack.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\ILRepack.2.0.41\build\ILRepack.props'))" />
</Target>
<Target Name="EmbedProxyDll" BeforeTargets="BeforeBuild">
<Target Name="ValidateNativeArtifacts" BeforeTargets="BeforeBuild">
<Error Text="Proxy DLL not found: $(ProxyDllPath)"
Condition="!Exists('$(ProxyDllPath)')" />
@@ -195,13 +192,15 @@
</PropertyGroup>
<ItemGroup>
<AssemblyList Include="$(OutputPath)*.dll" />
<AssemblyList Include="$(OutputPath)*.dll" />
</ItemGroup>
<PropertyGroup>
<DllList>@(AssemblyList->'%(FullPath)', ' ')</DllList>
</PropertyGroup>
<Delete Files="$(OutputPath)$(AssemblyName).pdb" ContinueOnError="true" />
<Exec Command="&quot;$(ILRepackExe)&quot; /allowMultiple /copyattrs /out:&quot;$(OutputPath)$(AssemblyName).exe&quot; &quot;$(MainAssembly)&quot; $(DllList)" />
<Delete Files="@(AssemblyList)" ContinueOnError="true" />
</Target>
</Project>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 177 KiB

+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
exit /b %ERRORLEVEL%
+84
View File
@@ -0,0 +1,84 @@
param(
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Release'
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$webPanelDir = Join-Path $repoRoot 'web-panel'
$nativeBuildRoot = Join-Path $repoRoot '.tmp/cmake'
$asarFusesSourceDir = Join-Path $repoRoot 'tools/asar-fuses-bypass'
$asarFusesBuildDir = Join-Path $nativeBuildRoot 'asar-fuses-bypass'
$solutionPath = Join-Path $repoRoot 'Wand-Enhancer.sln'
function Resolve-CommandPath {
param([string]$Name)
$command = Get-Command $Name -ErrorAction SilentlyContinue
if (-not $command) {
throw "Required command not found in PATH: $Name"
}
return $command.Source
}
function Resolve-MSBuildPath {
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
if (-not (Test-Path $vswhere)) {
throw "vswhere.exe not found: $vswhere"
}
$installationPath = & $vswhere -latest -version '[17.0,18.0)' -requires Microsoft.Component.MSBuild -property installationPath
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($installationPath)) {
throw 'Visual Studio 2022 with MSBuild was not found.'
}
$msbuildPath = Join-Path $installationPath 'MSBuild\Current\Bin\MSBuild.exe'
if (-not (Test-Path $msbuildPath)) {
throw "MSBuild.exe not found: $msbuildPath"
}
return $msbuildPath
}
function Invoke-Step {
param(
[string]$Label,
[scriptblock]$Action
)
Write-Host "==> $Label" -ForegroundColor Cyan
& $Action
if ($LASTEXITCODE -ne 0) {
throw "Step failed: $Label"
}
}
$cmake = Resolve-CommandPath 'cmake'
$pnpm = Resolve-CommandPath 'pnpm'
$msbuild = Resolve-MSBuildPath
$generator = 'Visual Studio 17 2022'
Invoke-Step 'Install web-panel dependencies' {
& $pnpm --dir $webPanelDir install --frozen-lockfile
}
Invoke-Step 'Build web-panel' {
& $pnpm --dir $webPanelDir run build
}
Invoke-Step 'Configure asar-fuses-bypass' {
& $cmake -S $asarFusesSourceDir -B $asarFusesBuildDir -G $generator -A x64
}
Invoke-Step 'Build asar-fuses-bypass' {
& $cmake --build $asarFusesBuildDir --config $Configuration
}
Invoke-Step 'Build solution' {
& $msbuild $solutionPath /m /p:Configuration=$Configuration '/p:Platform=Any CPU' /t:Build
}
Write-Host ''
Write-Host "Build completed successfully ($Configuration)." -ForegroundColor Green
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
script_path="$script_dir/build.ps1"
if command -v cygpath >/dev/null 2>&1; then
script_path="$(cygpath -w "$script_path")"
fi
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$script_path" "$@"
+81
View File
@@ -0,0 +1,81 @@
param(
[Parameter(Mandatory = $true)]
[string]$Version,
[string]$OutputPath
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$repoRoot = Split-Path -Parent $PSScriptRoot
$changelogPath = Join-Path $repoRoot 'CHANGELOG.md'
function Normalize-Version {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
throw 'Version value cannot be empty.'
}
return $Value.Trim().TrimStart('v', 'V')
}
function Get-ChangelogSection {
param(
[string]$Content,
[string]$TargetVersion
)
$normalizedTargetVersion = Normalize-Version $TargetVersion
$normalizedContent = $Content -replace "`r`n", "`n" -replace "`r", "`n"
$lines = $normalizedContent -split "`n"
$builder = New-Object System.Text.StringBuilder
$isInsideSection = $false
foreach ($line in $lines) {
$match = [regex]::Match($line, '^##\s+\[(?<version>[^\]]+)\]')
if ($match.Success) {
if ($isInsideSection) {
break
}
$isInsideSection = (Normalize-Version $match.Groups['version'].Value) -eq $normalizedTargetVersion
continue
}
if (-not $isInsideSection) {
continue
}
[void]$builder.AppendLine($line)
}
return $builder.ToString().Trim()
}
if (-not (Test-Path $changelogPath)) {
throw "CHANGELOG.md not found: $changelogPath"
}
$changelogContent = Get-Content -Path $changelogPath -Raw
$section = Get-ChangelogSection -Content $changelogContent -TargetVersion $Version
if ([string]::IsNullOrWhiteSpace($section)) {
throw "Changelog section for version '$(Normalize-Version $Version)' was not found in CHANGELOG.md."
}
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
Write-Output $section
exit 0
}
$resolvedOutputPath = if ([System.IO.Path]::IsPathRooted($OutputPath)) {
$OutputPath
}
else {
Join-Path $repoRoot $OutputPath
}
Set-Content -Path $resolvedOutputPath -Value $section -Encoding utf8
Write-Host "Wrote changelog section to $resolvedOutputPath"
+111
View File
@@ -0,0 +1,111 @@
param(
[string]$ExpectedVersion
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$repoRoot = Split-Path -Parent $PSScriptRoot
$assemblyInfoPath = Join-Path $repoRoot 'WandEnhancer\Properties\AssemblyInfo.cs'
$changelogPath = Join-Path $repoRoot 'CHANGELOG.md'
function Normalize-Version {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
throw 'Version value cannot be empty.'
}
return $Value.Trim().TrimStart('v', 'V')
}
function Get-ChangelogSection {
param(
[string]$Content,
[string]$TargetVersion
)
$normalizedTargetVersion = Normalize-Version $TargetVersion
$normalizedContent = $Content -replace "`r`n", "`n" -replace "`r", "`n"
$lines = $normalizedContent -split "`n"
$builder = New-Object System.Text.StringBuilder
$isInsideSection = $false
foreach ($line in $lines) {
$match = [regex]::Match($line, '^##\s+\[(?<version>[^\]]+)\]')
if ($match.Success) {
if ($isInsideSection) {
break
}
$isInsideSection = (Normalize-Version $match.Groups['version'].Value) -eq $normalizedTargetVersion
continue
}
if (-not $isInsideSection) {
continue
}
[void]$builder.AppendLine($line)
}
return $builder.ToString().Trim()
}
if (-not (Test-Path $assemblyInfoPath)) {
throw "AssemblyInfo.cs not found: $assemblyInfoPath"
}
if (-not (Test-Path $changelogPath)) {
throw "CHANGELOG.md not found: $changelogPath"
}
$assemblyInfoContent = Get-Content -Path $assemblyInfoPath -Raw
$changelogContent = Get-Content -Path $changelogPath -Raw
$assemblyVersionMatch = [regex]::Match($assemblyInfoContent, '(?m)^\s*\[assembly:\s*AssemblyVersion\("(?<version>[^"]+)"\)\]')
$fileVersionMatch = [regex]::Match($assemblyInfoContent, '(?m)^\s*\[assembly:\s*AssemblyFileVersion\("(?<version>[^"]+)"\)\]')
if (-not $assemblyVersionMatch.Success) {
throw 'AssemblyVersion was not found in AssemblyInfo.cs.'
}
if (-not $fileVersionMatch.Success) {
throw 'AssemblyFileVersion was not found in AssemblyInfo.cs.'
}
$assemblyVersion = Normalize-Version $assemblyVersionMatch.Groups['version'].Value
$fileVersion = Normalize-Version $fileVersionMatch.Groups['version'].Value
if ($assemblyVersion -ne $fileVersion) {
throw "AssemblyVersion ($assemblyVersion) and AssemblyFileVersion ($fileVersion) must match."
}
$changelogVersionMatches = [regex]::Matches($changelogContent, '(?m)^##\s+\[(?<version>[^\]]+)\]')
if ($changelogVersionMatches.Count -eq 0) {
throw 'CHANGELOG.md must contain at least one version section.'
}
$latestChangelogVersion = Normalize-Version $changelogVersionMatches[0].Groups['version'].Value
if ($latestChangelogVersion -ne $assemblyVersion) {
throw "The first CHANGELOG.md section ($latestChangelogVersion) must match AssemblyInfo.cs version ($assemblyVersion)."
}
$latestSection = Get-ChangelogSection -Content $changelogContent -TargetVersion $assemblyVersion
if ([string]::IsNullOrWhiteSpace($latestSection)) {
throw "CHANGELOG.md section '$assemblyVersion' is empty."
}
if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
$normalizedExpectedVersion = Normalize-Version $ExpectedVersion
if ($normalizedExpectedVersion -ne $assemblyVersion) {
throw "Release tag version ($normalizedExpectedVersion) must match AssemblyInfo.cs version ($assemblyVersion)."
}
$expectedSection = Get-ChangelogSection -Content $changelogContent -TargetVersion $normalizedExpectedVersion
if ([string]::IsNullOrWhiteSpace($expectedSection)) {
throw "CHANGELOG.md section '$normalizedExpectedVersion' is missing or empty."
}
}
Write-Host "Validated release metadata for version $assemblyVersion"
+3 -1
View File
@@ -10,6 +10,8 @@ add_executable(asar_fuses_bypass main.c)
set(CMAKE_SHARED_LIBRARY_PREFIX "")
set(CMAKE_STATIC_LIBRARY_PREFIX "")
add_link_options(-static -static-libgcc -static-libstdc++)
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
add_link_options(-static -static-libgcc -static-libstdc++)
endif()
add_library(version SHARED library.c library.def fuses.c)
+122 -63
View File
@@ -2,87 +2,146 @@
// Created by kitbyte on 30.11.2025.
//
#include <Windows.h>
#include <winver.h>
extern BOOL disable_asar_integrity(void);
#ifdef _WIN64
#define WRAPPER_GENFUNC(name) \
FARPROC orig_##name; \
void _##name(); \
__asm__( \
".global _" #name "\n" \
"_" #name ":\n" \
" movq orig_" #name "(%rip), %rax\n" \
" jmp *%rax\n" \
);
#else
#define WRAPPER_GENFUNC(name) \
FARPROC orig_##name; \
__declspec(naked) void _##name() \
static HMODULE g_originalVersionDll;
#define FOR_EACH_VERSION_FORWARDER(X) \
X(GetFileVersionInfoA, BOOL, FALSE, \
(LPCSTR filename, DWORD handle, DWORD length, LPVOID data), \
(filename, handle, length, data)) \
X(GetFileVersionInfoExA, BOOL, FALSE, \
(DWORD flags, LPCSTR filename, DWORD handle, DWORD length, LPVOID data), \
(flags, filename, handle, length, data)) \
X(GetFileVersionInfoExW, BOOL, FALSE, \
(DWORD flags, LPCWSTR filename, DWORD handle, DWORD length, LPVOID data), \
(flags, filename, handle, length, data)) \
X(GetFileVersionInfoSizeA, DWORD, 0, \
(LPCSTR filename, LPDWORD handle), \
(filename, handle)) \
X(GetFileVersionInfoSizeExA, DWORD, 0, \
(DWORD flags, LPCSTR filename, LPDWORD handle), \
(flags, filename, handle)) \
X(GetFileVersionInfoSizeExW, DWORD, 0, \
(DWORD flags, LPCWSTR filename, LPDWORD handle), \
(flags, filename, handle)) \
X(GetFileVersionInfoSizeW, DWORD, 0, \
(LPCWSTR filename, LPDWORD handle), \
(filename, handle)) \
X(GetFileVersionInfoW, BOOL, FALSE, \
(LPCWSTR filename, DWORD handle, DWORD length, LPVOID data), \
(filename, handle, length, data)) \
X(VerFindFileA, DWORD, 0, \
(DWORD flags, LPCSTR fileName, LPCSTR winDir, LPCSTR appDir, LPSTR curDir, PUINT curDirLen, LPSTR destDir, PUINT destDirLen), \
(flags, fileName, winDir, appDir, curDir, curDirLen, destDir, destDirLen)) \
X(VerFindFileW, DWORD, 0, \
(DWORD flags, LPCWSTR fileName, LPCWSTR winDir, LPCWSTR appDir, LPWSTR curDir, PUINT curDirLen, LPWSTR destDir, PUINT destDirLen), \
(flags, fileName, winDir, appDir, curDir, curDirLen, destDir, destDirLen)) \
X(VerInstallFileA, DWORD, 0, \
(DWORD flags, LPCSTR srcFileName, LPCSTR destFileName, LPCSTR srcDir, LPCSTR destDir, LPCSTR curDir, LPSTR tempFile, PUINT tempFileLen), \
(flags, srcFileName, destFileName, srcDir, destDir, curDir, tempFile, tempFileLen)) \
X(VerInstallFileW, DWORD, 0, \
(DWORD flags, LPCWSTR srcFileName, LPCWSTR destFileName, LPCWSTR srcDir, LPCWSTR destDir, LPCWSTR curDir, LPWSTR tempFile, PUINT tempFileLen), \
(flags, srcFileName, destFileName, srcDir, destDir, curDir, tempFile, tempFileLen)) \
X(VerLanguageNameA, DWORD, 0, \
(DWORD language, LPSTR buffer, DWORD bufferLength), \
(language, buffer, bufferLength)) \
X(VerLanguageNameW, DWORD, 0, \
(DWORD language, LPWSTR buffer, DWORD bufferLength), \
(language, buffer, bufferLength)) \
X(VerQueryValueA, BOOL, FALSE, \
(LPCVOID block, LPCSTR subBlock, LPVOID* buffer, PUINT bufferLength), \
(block, subBlock, buffer, bufferLength)) \
X(VerQueryValueW, BOOL, FALSE, \
(LPCVOID block, LPCWSTR subBlock, LPVOID* buffer, PUINT bufferLength), \
(block, subBlock, buffer, bufferLength))
#if defined(_MSC_VER) && !defined(_WIN64)
#define DECLARE_FORWARDER(name, return_type, default_value, params, args) \
static FARPROC s_##name; \
__declspec(naked) return_type WINAPI name params \
{ \
__asm \
{ \
asm("jmp *_orig_"#name); \
}
jmp dword ptr [s_##name] \
} \
}
#define LOAD_FORWARDER(name, return_type, default_value, params, args) \
s_##name = GetProcAddress(g_originalVersionDll, #name);
#else
#define DECLARE_FORWARDER(name, return_type, default_value, params, args) \
typedef return_type (WINAPI *name##_fn) params; \
static name##_fn s_##name; \
return_type WINAPI name params \
{ \
if (s_##name == NULL) \
{ \
SetLastError(ERROR_PROC_NOT_FOUND); \
return default_value; \
} \
return s_##name args; \
}
#define LOAD_FORWARDER(name, return_type, default_value, params, args) \
s_##name = (name##_fn)GetProcAddress(g_originalVersionDll, #name);
#endif
WRAPPER_GENFUNC(GetFileVersionInfoA)
WRAPPER_GENFUNC(GetFileVersionInfoByHandle)
WRAPPER_GENFUNC(GetFileVersionInfoExW)
WRAPPER_GENFUNC(GetFileVersionInfoExA)
WRAPPER_GENFUNC(GetFileVersionInfoSizeA)
WRAPPER_GENFUNC(GetFileVersionInfoSizeExA)
WRAPPER_GENFUNC(GetFileVersionInfoSizeExW)
WRAPPER_GENFUNC(GetFileVersionInfoSizeW)
WRAPPER_GENFUNC(GetFileVersionInfoW)
WRAPPER_GENFUNC(VerFindFileA)
WRAPPER_GENFUNC(VerFindFileW)
WRAPPER_GENFUNC(VerInstallFileA)
WRAPPER_GENFUNC(VerInstallFileW)
WRAPPER_GENFUNC(VerLanguageNameA)
WRAPPER_GENFUNC(VerLanguageNameW)
WRAPPER_GENFUNC(VerQueryValueA)
WRAPPER_GENFUNC(VerQueryValueW)
FOR_EACH_VERSION_FORWARDER(DECLARE_FORWARDER)
#define WRAPPER_FUNC(name) orig_##name = GetProcAddress(hOriginalDll, #name);
void SourceInit()
BOOL WINAPI GetFileVersionInfoByHandle(void)
{
TCHAR source[MAX_PATH];
GetSystemDirectory(source, MAX_PATH);
strcat_s(source, sizeof source, "\\version.dll");
HMODULE hOriginalDll = LoadLibrary(source);
WRAPPER_FUNC(GetFileVersionInfoA);
WRAPPER_FUNC(GetFileVersionInfoByHandle);
WRAPPER_FUNC(GetFileVersionInfoExW);
WRAPPER_FUNC(GetFileVersionInfoExA);
WRAPPER_FUNC(GetFileVersionInfoSizeA);
WRAPPER_FUNC(GetFileVersionInfoSizeExW);
WRAPPER_FUNC(GetFileVersionInfoSizeExA);
WRAPPER_FUNC(GetFileVersionInfoSizeW);
WRAPPER_FUNC(GetFileVersionInfoW);
WRAPPER_FUNC(VerFindFileA);
WRAPPER_FUNC(VerFindFileW);
WRAPPER_FUNC(VerInstallFileA);
WRAPPER_FUNC(VerInstallFileW);
WRAPPER_FUNC(VerLanguageNameA);
WRAPPER_FUNC(VerLanguageNameW);
WRAPPER_FUNC(VerQueryValueA);
WRAPPER_FUNC(VerQueryValueW);
SetLastError(ERROR_CALL_NOT_IMPLEMENTED);
return FALSE;
}
void Payload()
static BOOL SourceInit(void)
{
disable_asar_integrity();
WCHAR source[MAX_PATH];
UINT sourceLength = GetSystemDirectoryW(source, MAX_PATH);
if (sourceLength == 0 || sourceLength >= MAX_PATH)
{
return FALSE;
}
if (wcscat_s(source, MAX_PATH, L"\\version.dll") != 0)
{
return FALSE;
}
g_originalVersionDll = LoadLibraryW(source);
if (!g_originalVersionDll)
{
return FALSE;
}
FOR_EACH_VERSION_FORWARDER(LOAD_FORWARDER);
return TRUE;
}
BOOL WINAPI DllMain(HMODULE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
(void)lpvReserved;
if (fdwReason == DLL_PROCESS_ATTACH)
{
DisableThreadLibraryCalls(hinstDLL);
SourceInit();
Payload();
if (!SourceInit())
{
return FALSE;
}
disable_asar_integrity();
}
return TRUE;
}
+17 -17
View File
@@ -1,20 +1,20 @@
LIBRARY "VERSION"
EXPORTS
GetFileVersionInfoA = _GetFileVersionInfoA
GetFileVersionInfoByHandle = _GetFileVersionInfoByHandle
GetFileVersionInfoExA = _GetFileVersionInfoExA
GetFileVersionInfoExW = _GetFileVersionInfoExW
GetFileVersionInfoSizeA = _GetFileVersionInfoSizeA
GetFileVersionInfoSizeExA = _GetFileVersionInfoSizeExA
GetFileVersionInfoSizeExW = _GetFileVersionInfoSizeExW
GetFileVersionInfoSizeW = _GetFileVersionInfoSizeW
GetFileVersionInfoW = _GetFileVersionInfoW
VerFindFileA = _VerFindFileA
VerFindFileW = _VerFindFileW
VerInstallFileA = _VerInstallFileA
VerInstallFileW = _VerInstallFileW
VerLanguageNameA = _VerLanguageNameA
VerLanguageNameW = _VerLanguageNameW
VerQueryValueA = _VerQueryValueA
VerQueryValueW = _VerQueryValueW
GetFileVersionInfoA
GetFileVersionInfoByHandle
GetFileVersionInfoExA
GetFileVersionInfoExW
GetFileVersionInfoSizeA
GetFileVersionInfoSizeExA
GetFileVersionInfoSizeExW
GetFileVersionInfoSizeW
GetFileVersionInfoW
VerFindFileA
VerFindFileW
VerInstallFileA
VerInstallFileW
VerLanguageNameA
VerLanguageNameW
VerQueryValueA
VerQueryValueW
+3 -3
View File
@@ -5,8 +5,8 @@ Local mobile-friendly web panel scaffold for Wand.
## Commands
```bash
npm install
npm run dev
pnpm install
pnpm run dev
```
Hosted access on the local machine:
@@ -16,7 +16,7 @@ Hosted access on the local machine:
Hosted access on the LAN:
```bash
npm run dev:host
pnpm run dev:host
```
Then open the machine IP on port `4173`.
@@ -0,0 +1,46 @@
const KNOWN_CHEAT_TYPES = new Set(['slider', 'number', 'toggle', 'button', 'selection', 'scalar', 'incremental']);
const WS_OPCODE = Object.freeze({
TEXT: 1,
BINARY: 2,
CLOSE: 8,
PING: 9,
PONG: 10,
});
const IPC_CHANNEL = Object.freeze({
BIND_HANDLER: 'wand-remote-set-handler-bind',
COMMAND_REQUEST: 'wand-remote-command',
COMMAND_RESPONSE: 'wand-remote-command-response',
GAME_STATUS: 'wand-remote-game-status',
INSTALLED_APPS: 'wand-remote-installed-apps',
REMOTE_URL: 'wand-remote-url',
SET_VALUE: 'wand-remote-set-value',
TRAINER_SNAPSHOT: 'wand-remote-sync',
VALUE_CHANGED: 'wand-remote-value-changed',
});
module.exports = {
BRIDGE_LOG_FILE_NAME: 'wand-remote-bridge.log',
BRIDGE_PROTOCOL_VERSION: 1,
BRIDGE_SERVER_VERSION: '0.2.0-wand',
DEFAULT_REMOTE_HOST: '0.0.0.0',
DEFAULT_REMOTE_PORT: 3223,
IPC_CHANNEL,
KNOWN_CHEAT_TYPES,
PORT_SCAN_RANGE: 30,
REMOTE_ASSETS_PREFIX: '/remote/assets/',
REMOTE_BASE_PATH: '/remote/',
REMOTE_COMMAND_REQUEST_CHANNEL: IPC_CHANNEL.COMMAND_REQUEST,
REMOTE_COMMAND_RESPONSE_CHANNEL: IPC_CHANNEL.COMMAND_RESPONSE,
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS: 15000,
REMOTE_GAME_STATUS_CHANNEL: IPC_CHANNEL.GAME_STATUS,
REMOTE_HEALTH_PATH: '/remote/api/health',
REMOTE_INSTALLED_APPS_API_PATH: '/remote/api/installed-apps',
REMOTE_INSTALLED_APPS_CHANNEL: IPC_CHANNEL.INSTALLED_APPS,
REMOTE_WS_PATH: '/remote/ws',
RENDERER_INJECTION_DELAYS_MS: Object.freeze([500, 2000]),
RENDERER_SCRIPT_API_VERSION: 1,
RENDERER_SCRIPTS_DIR: 'renderer-scripts',
WS_OPCODE,
};
@@ -0,0 +1,35 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { BRIDGE_LOG_FILE_NAME } = require('./constants.cjs');
function writeLogLine(logFile, level, message, error) {
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
const tag = `[wand-remote-bridge] ${message}`;
try {
console[method](tag, error || '');
} catch { }
try {
const detail = error ? ` :: ${error && error.stack ? error.stack : String(error)}` : '';
fs.appendFileSync(logFile, `[${new Date().toISOString()}] [${level}] ${message}${detail}\n`);
} catch { }
}
function createBridgeLogger(options = {}) {
const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME);
const log = (level, message, error) => writeLogLine(logFile, level, message, error);
log.file = logFile;
return log;
}
function writeInstallLog(level, message, error) {
writeLogLine(path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME), level, message, error);
}
module.exports = {
createBridgeLogger,
writeInstallLog,
};
@@ -0,0 +1,483 @@
const { KNOWN_CHEAT_TYPES } = require('./constants.cjs');
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('./utils.cjs');
function normalizeOption(option) {
if (typeof option === 'string' || typeof option === 'number') {
return {
label: String(option),
value: option,
};
}
if (!isRecord(option)) {
return null;
}
const value = option.value;
if (typeof value !== 'string' && typeof value !== 'number') {
return null;
}
return {
label: safeString(option.label, String(value)),
value,
};
}
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 normalizeImageUrl(...values) {
const value = firstString(...values);
return value || null;
}
function getRawInstalledApps(rawSnapshot) {
if (Array.isArray(rawSnapshot)) {
return rawSnapshot;
}
if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.apps)) {
return rawSnapshot.apps;
}
if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.installedApps)) {
return rawSnapshot.installedApps;
}
return null;
}
function normalizeInstalledApp(app) {
if (!isRecord(app)) {
return null;
}
const platform = safeString(app.platform);
const sku = safeString(app.sku);
if (!platform || !sku) {
return null;
}
const location = typeof app.location === 'string' ? app.location : '';
const alternateLocations = Array.isArray(app.alternateLocations)
? app.alternateLocations.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim())
: [];
return {
platform,
sku,
correlationId: `${platform}:${sku}`,
displayName: firstString(
app.displayName,
app.titleName,
app.gameName,
app.name,
location.replaceAll('\\', '/').split('/').filter(Boolean).pop() || '',
`${platform}:${sku}`
),
gameId: toStringId(app.gameId),
titleId: toStringId(app.titleId),
location,
alternateLocations,
imageUrl: normalizeImageUrl(app.imageUrl, app.iconUrl, app.coverUrl, app.thumbnailUrl, app.logoUrl, app.headerImageUrl),
platformLastPlayedTimestamp: typeof app.platformLastPlayedTimestamp === 'number' ? app.platformLastPlayedTimestamp : null,
platformTotalPlaytimeMinutes: typeof app.platformTotalPlaytimeMinutes === 'number' ? app.platformTotalPlaytimeMinutes : null,
};
}
function normalizeInstalledAppsSnapshot(rawSnapshot) {
const rawApps = getRawInstalledApps(rawSnapshot);
if (!rawApps) {
return null;
}
const apps = rawApps.map(normalizeInstalledApp).filter(Boolean).sort(compareInstalledApps);
const diagnostics = isRecord(rawSnapshot) && isRecord(rawSnapshot.diagnostics)
? cloneValue(rawSnapshot.diagnostics)
: null;
return {
instanceId: isRecord(rawSnapshot) ? safeString(rawSnapshot.instanceId, 'wand-installed-apps') : 'wand-installed-apps',
updatedAt: isRecord(rawSnapshot) && typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
apps,
diagnostics,
};
}
function normalizeGameStatusSnapshot(rawSnapshot) {
if (!isRecord(rawSnapshot)) {
return null;
}
const rawSession = isRecord(rawSnapshot.session) ? rawSnapshot.session : {};
const rawTrainer = isRecord(rawSnapshot.trainer) ? rawSnapshot.trainer : {};
return {
instanceId: safeString(rawSnapshot.instanceId, 'wand-game-status'),
updatedAt: typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
session: {
state: rawSession.state === 'running' ? 'running' : 'idle',
event: safeString(rawSession.event, 'snapshot'),
processId: typeof rawSession.processId === 'number' ? rawSession.processId : null,
gameId: toStringId(rawSession.gameId),
titleId: toStringId(rawSession.titleId),
titleName: typeof rawSession.titleName === 'string' ? rawSession.titleName : null,
sessionDurationSeconds: typeof rawSession.sessionDurationSeconds === 'number' ? rawSession.sessionDurationSeconds : null,
startedAt: typeof rawSession.startedAt === 'string' ? rawSession.startedAt : null,
endedAt: typeof rawSession.endedAt === 'string' ? rawSession.endedAt : null,
},
trainer: {
state: rawTrainer.state === 'running' ? 'running' : 'idle',
event: safeString(rawTrainer.event, 'snapshot'),
trainerId: toStringId(rawTrainer.trainerId),
displayName: typeof rawTrainer.displayName === 'string' ? rawTrainer.displayName : null,
gameId: toStringId(rawTrainer.gameId),
titleId: toStringId(rawTrainer.titleId),
},
};
}
function normalizeRemoteCommandAction(value) {
if (value === 'launch' || value === 'stop') {
return value;
}
return null;
}
function normalizeRemoteCommandResult(rawResult, fallback) {
const action = normalizeRemoteCommandAction(isRecord(rawResult) ? rawResult.action : null) || fallback.action;
const gameId = isRecord(rawResult) ? toStringId(rawResult.gameId) || fallback.gameId || null : fallback.gameId || null;
const titleId = isRecord(rawResult) ? toStringId(rawResult.titleId) || fallback.titleId || null : fallback.titleId || null;
const ok = rawResult === true || Boolean(isRecord(rawResult) && rawResult.ok === true);
const payload = {
ok,
action,
gameId,
titleId,
};
if (ok) {
return payload;
}
if (!isRecord(rawResult) || !isRecord(rawResult.error)) {
return {
...payload,
error: {
code: 'command_rejected',
message: 'The renderer rejected the remote command.',
},
};
}
return {
...payload,
error: {
code: safeString(rawResult.error.code, 'command_rejected'),
message: safeString(rawResult.error.message, 'The renderer rejected the remote command.'),
},
};
}
function summarizeInstalledAppsSource(rawSnapshot) {
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) {
return '';
}
const parts = [];
for (const key of ['rawInstalledApps', 'catalogGames', 'catalogTitles']) {
const value = rawSnapshot.diagnostics[key];
if (typeof value === 'number') {
parts.push(`${key}=${value}`);
}
}
return parts.join(', ');
}
function installedAppsSignature(snapshot) {
return snapshot.apps
.map((app) => [
app.platform,
app.sku,
app.displayName,
app.gameId || '',
app.titleId || '',
app.location,
app.imageUrl || '',
app.platformLastPlayedTimestamp || '',
app.platformTotalPlaytimeMinutes || '',
].join('|'))
.join('\n');
}
function gameStatusSignature(snapshot) {
return [
snapshot.session.state,
snapshot.session.event,
snapshot.session.processId || '',
snapshot.session.gameId || '',
snapshot.session.titleId || '',
snapshot.session.titleName || '',
snapshot.session.sessionDurationSeconds || '',
snapshot.session.startedAt || '',
snapshot.session.endedAt || '',
snapshot.trainer.state,
snapshot.trainer.event,
snapshot.trainer.trainerId || '',
snapshot.trainer.displayName || '',
snapshot.trainer.gameId || '',
snapshot.trainer.titleId || '',
].join('|');
}
function buildInstalledAppsDebugPayload(snapshot) {
if (!snapshot) {
return {
ok: false,
instanceId: null,
updatedAt: null,
counts: {
myGamesEntries: 0,
rawInstallEntries: 0,
groupedTitles: 0,
uniqueTitleIds: 0,
uniqueGameIds: 0,
},
diagnostics: null,
byPlatform: {},
titles: [],
apps: [],
};
}
const diagnostics = isRecord(snapshot.diagnostics) ? snapshot.diagnostics : null;
const byPlatform = {};
const uniqueTitleIds = new Set();
const uniqueGameIds = new Set();
const titleGroups = new Map();
for (const app of snapshot.apps) {
byPlatform[app.platform] = (byPlatform[app.platform] || 0) + 1;
if (app.titleId) {
uniqueTitleIds.add(app.titleId);
}
if (app.gameId) {
uniqueGameIds.add(app.gameId);
}
const groupKey = resolveInstalledAppGroupKey(app);
let group = titleGroups.get(groupKey);
if (!group) {
group = {
key: groupKey,
titleId: app.titleId,
displayName: app.displayName,
gameIds: new Set(),
platforms: new Set(),
apps: [],
};
titleGroups.set(groupKey, group);
}
if (app.gameId) {
group.gameIds.add(app.gameId);
}
group.platforms.add(app.platform);
group.apps.push(app);
}
const titles = Array.from(titleGroups.values())
.map((group) => ({
key: group.key,
titleId: group.titleId,
displayName: group.displayName,
gameIds: Array.from(group.gameIds).sort(),
platforms: Array.from(group.platforms).sort(),
appEntries: group.apps.length,
apps: group.apps,
}))
.sort((left, right) => left.displayName.localeCompare(right.displayName));
return {
ok: true,
instanceId: snapshot.instanceId,
updatedAt: snapshot.updatedAt,
counts: {
myGamesEntries: snapshot.apps.length,
rawInstallEntries: typeof diagnostics?.rawInstalledApps === 'number' ? diagnostics.rawInstalledApps : snapshot.apps.length,
groupedTitles: titles.length,
uniqueTitleIds: uniqueTitleIds.size,
uniqueGameIds: uniqueGameIds.size,
},
diagnostics,
byPlatform,
titles,
apps: snapshot.apps,
};
}
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 compareInstalledApps(left, right) {
const displayNameDiff = left.displayName.localeCompare(right.displayName);
if (displayNameDiff !== 0) {
return displayNameDiff;
}
const platformDiff = left.platform.localeCompare(right.platform);
if (platformDiff !== 0) {
return platformDiff;
}
return left.sku.localeCompare(right.sku);
}
function resolveInstalledAppGroupKey(app) {
if (app.titleId) {
return `title:${app.titleId}`;
}
if (app.gameId) {
return `game:${app.gameId}`;
}
return `app:${app.correlationId}`;
}
module.exports = {
buildInstalledAppsDebugPayload,
gameStatusSignature,
installedAppsSignature,
normalizeGameStatusSnapshot,
normalizeInstalledAppsSnapshot,
normalizeRemoteCommandAction,
normalizeRemoteCommandResult,
normalizeSnapshot,
summarizeInstalledAppsSource,
};
@@ -0,0 +1,90 @@
const fs = require('node:fs');
const path = require('node:path');
const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('./constants.cjs');
const { writeInstallLog } = require('./logger.cjs');
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) {
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 || path.dirname(__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);
for (const delayMs of RENDERER_INJECTION_DELAYS_MS) {
setTimeout(inject, delayMs);
}
});
writeInstallLog('info', `Renderer script injection installed (${scripts.map((script) => script.name).join(', ')}).`);
}
module.exports = {
buildRendererBootstrap,
installRendererScripts,
loadRendererScripts,
};
+541
View File
@@ -0,0 +1,541 @@
const http = require('node:http');
const path = require('node:path');
const {
BRIDGE_PROTOCOL_VERSION,
BRIDGE_SERVER_VERSION,
DEFAULT_REMOTE_HOST,
DEFAULT_REMOTE_PORT,
PORT_SCAN_RANGE,
REMOTE_ASSETS_PREFIX,
REMOTE_BASE_PATH,
REMOTE_HEALTH_PATH,
REMOTE_INSTALLED_APPS_API_PATH,
REMOTE_WS_PATH,
WS_OPCODE,
} = require('./constants.cjs');
const { createBridgeLogger } = require('./logger.cjs');
const {
buildInstalledAppsDebugPayload,
gameStatusSignature,
installedAppsSignature,
normalizeGameStatusSnapshot,
normalizeInstalledAppsSnapshot,
normalizeRemoteCommandAction,
normalizeRemoteCommandResult,
normalizeSnapshot,
summarizeInstalledAppsSource,
} = require('./normalizers.cjs');
const { getAdvertisedUrls, serveFile } = require('./static-server.cjs');
const { cloneValue, isRecord, isValidPort, safeString } = require('./utils.cjs');
const { closeClient, createAcceptKey, makeFrame, parseFrame, sendJson } = require('./websocket.cjs');
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 || path.dirname(__dirname);
const clients = new Set();
const log = createBridgeLogger(options);
let advertisedUrls = [];
let currentSnapshot = null;
let currentInstalledApps = null;
let currentInstalledAppsSignature = null;
let currentGameStatus = null;
let currentGameStatusSignature = null;
let setValueHandler = null;
let commandHandler = null;
let listening = false;
function setAdvertisedPort(nextPort) {
port = nextPort;
advertisedUrls = getAdvertisedUrls(port);
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry) => !entry.includes('localhost')) || advertisedUrls[0];
}
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: '',
});
} else {
sendJson(client, 'trainer_meta', currentSnapshot.trainerMeta);
sendJson(client, 'trainer_values', currentSnapshot.trainerValues);
}
if (currentGameStatus) {
sendJson(client, 'game_status', currentGameStatus);
}
if (currentInstalledApps) {
sendJson(client, 'installed_apps', currentInstalledApps);
}
}
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) {
return;
}
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 syncInstalledApps(rawInstalledApps) {
const sourceSummary = summarizeInstalledAppsSource(rawInstalledApps);
const nextInstalledApps = normalizeInstalledAppsSnapshot(rawInstalledApps);
if (!nextInstalledApps) {
log('warn', `Ignored invalid installed apps snapshot.${sourceSummary ? ` ${sourceSummary}` : ''}`);
return;
}
const nextSignature = installedAppsSignature(nextInstalledApps);
if (nextSignature === currentInstalledAppsSignature) {
log('info', `Installed apps snapshot unchanged (${nextInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
return;
}
currentInstalledApps = nextInstalledApps;
currentInstalledAppsSignature = nextSignature;
log('info', `Installed apps snapshot accepted (${currentInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
broadcast('installed_apps', currentInstalledApps);
}
function syncGameStatus(rawGameStatus) {
const nextGameStatus = normalizeGameStatusSnapshot(rawGameStatus);
if (!nextGameStatus) {
log('warn', 'Ignored invalid game status snapshot.');
return;
}
const nextSignature = gameStatusSignature(nextGameStatus);
if (nextSignature === currentGameStatusSignature) {
return;
}
currentGameStatus = nextGameStatus;
currentGameStatusSignature = nextSignature;
log('info', `Game status snapshot accepted (${currentGameStatus.session.state}/${currentGameStatus.session.event}).`);
broadcast('game_status', currentGameStatus);
}
function setHandler(handler) {
setValueHandler = typeof handler === 'function' ? handler : null;
}
function setCommandHandler(handler) {
commandHandler = typeof handler === 'function' ? handler : null;
}
function buildHealthPayload() {
const installedAppsDebug = buildInstalledAppsDebugPayload(currentInstalledApps);
return {
ok: listening,
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || null,
gameSessionState: currentGameStatus?.session?.state || 'idle',
gameSessionEvent: currentGameStatus?.session?.event || 'snapshot',
runningTrainerId: currentGameStatus?.trainer?.trainerId || null,
installedAppsCount: installedAppsDebug.counts.myGamesEntries,
installedRawAppsCount: installedAppsDebug.counts.rawInstallEntries,
installedTitlesCount: installedAppsDebug.counts.groupedTitles,
installedUniqueTitleIdsCount: installedAppsDebug.counts.uniqueTitleIds,
installedUniqueGameIdsCount: installedAppsDebug.counts.uniqueGameIds,
installedAppsApiPath: REMOTE_INSTALLED_APPS_API_PATH,
remoteUrl: globalThis.__wandRemoteBridgeUrl,
advertisedUrls,
};
}
function handleRequest(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(buildHealthPayload()));
return;
}
if (url.pathname === REMOTE_INSTALLED_APPS_API_PATH) {
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
response.end(JSON.stringify(buildInstalledAppsDebugPayload(currentInstalledApps), null, 2));
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');
}
async function handleRemoteCommandMessage(client, message) {
const action = normalizeRemoteCommandAction(message.payload?.action);
const gameId = typeof message.payload?.gameId === 'string' || typeof message.payload?.gameId === 'number'
? String(message.payload.gameId)
: null;
const titleId = typeof message.payload?.titleId === 'string' || typeof message.payload?.titleId === 'number'
? String(message.payload.titleId)
: null;
if (!action) {
sendJson(client, 'error', {
code: 'invalid_command',
message: 'Unknown remote command.',
}, message.requestId ?? null);
return;
}
const fallback = { action, gameId, titleId };
if (action === 'launch' && !gameId) {
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult({
ok: false,
error: {
code: 'invalid_game',
message: 'A game id is required to launch a trainer.',
},
}, fallback), message.requestId ?? null);
return;
}
if (!commandHandler) {
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult({
ok: false,
error: {
code: 'bridge_not_ready',
message: 'The local bridge is not ready to execute remote game commands yet.',
},
}, fallback), message.requestId ?? null);
return;
}
try {
const result = await Promise.resolve(commandHandler({ action, gameId, titleId }));
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult(result, fallback), message.requestId ?? null);
} catch (error) {
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult({
ok: false,
error: {
code: 'command_failed',
message: error instanceof Error ? error.message : 'Failed to execute the remote command.',
},
}, fallback), message.requestId ?? null);
}
}
async function handleSetValueMessage(client, message) {
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);
return;
}
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);
return;
}
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);
return;
}
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);
return;
}
sendJson(client, 'set_value_result', {
ok: true,
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
target,
}, message.requestId ?? null);
}
async function handleClientMessage(client, message) {
if (message?.type === 'hello') {
sendJson(client, 'hello_ack', {
sessionId: `sess_${Date.now()}`,
accepted: true,
serverVersion: BRIDGE_SERVER_VERSION,
protocolVersion: BRIDGE_PROTOCOL_VERSION,
remoteUrl: globalThis.__wandRemoteBridgeUrl,
advertisedUrls,
}, message.requestId ?? null);
sendSnapshot(client);
return;
}
if (message?.type === 'remote_command') {
await handleRemoteCommandMessage(client, message);
return;
}
if (message?.type === 'set_value') {
await handleSetValueMessage(client, message);
}
}
function bindSocket(socket) {
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 === WS_OPCODE.CLOSE) {
closeClient(client, 1000, 'Closing');
return;
}
if (frame.opcode === WS_OPCODE.PING) {
client.socket.write(makeFrame(WS_OPCODE.PONG, frame.payload));
continue;
}
if (frame.opcode !== WS_OPCODE.TEXT) {
continue;
}
await handleClientMessage(client, JSON.parse(frame.payload.toString('utf8')));
}
} 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);
});
}
function handleUpgrade(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;
}
socket.write([
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${createAcceptKey(key)}`,
'',
'',
].join('\r\n'));
bindSocket(socket);
}
function listen(nextPort) {
setAdvertisedPort(nextPort);
server.listen(port, host);
}
setAdvertisedPort(port);
log('info', `Bridge starting (pid=${process.pid}, panelRoot=${panelRoot}, preferredPort=${port}, host=${host})`);
globalThis.__wandRemoteBridgeLogFile = log.file;
const server = http.createServer(handleRequest);
server.on('upgrade', handleUpgrade);
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}`);
});
listen(port);
return {
get advertisedUrls() {
return advertisedUrls.slice();
},
get listening() {
return listening;
},
get remoteUrl() {
return globalThis.__wandRemoteBridgeUrl;
},
close() {
for (const client of clients) {
closeClient(client);
}
clients.clear();
currentSnapshot = null;
currentInstalledApps = null;
currentInstalledAppsSignature = null;
currentGameStatus = null;
currentGameStatusSignature = null;
listening = false;
server.close();
},
setCommandHandler,
setHandler,
sync,
syncGameStatus,
syncInstalledApps,
valueChanged,
};
}
function ensureBridge(options = {}) {
if (!globalThis.__wandRemoteBridgeRuntime) {
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
}
return globalThis.__wandRemoteBridgeRuntime;
}
module.exports = {
createBridgeRuntime,
ensureBridge,
};
@@ -0,0 +1,166 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { REMOTE_BASE_PATH } = require('./constants.cjs');
const IPV4_OCTET_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const PHYSICAL_INTERFACE_NAME_PATTERN = /(?:ethernet|wi-?fi|wireless|wlan|lan|local area)/i;
const VIRTUAL_INTERFACE_NAME_PATTERN = /(?:bluetooth|container|docker|hamachi|hyper-v|loopback|npcap|pseudo|tap|tailscale|teredo|tunnel|tun|virtual|vmware|vbox|virtualbox|vpn|wireguard|wsl|zerotier)/i;
const VIRTUAL_MAC_PREFIXES = new Set([
'00:05:69',
'00:0c:29',
'00:15:5d',
'00:16:3e',
'00:1c:14',
'00:50:56',
'08:00:27',
'52:54:00',
]);
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 candidates = [];
const interfaces = os.networkInterfaces();
let index = 0;
for (const [name, entries] of Object.entries(interfaces)) {
if (!entries) {
continue;
}
for (const entry of entries) {
if (!isUsableIpv4Entry(entry)) {
continue;
}
candidates.push({
index,
score: scoreIpv4Entry(name, entry),
url: `http://${entry.address}:${port}${REMOTE_BASE_PATH}`,
});
index += 1;
}
}
const urls = candidates
.sort((left, right) => right.score - left.score || left.index - right.index)
.map((candidate) => candidate.url);
urls.unshift(`http://localhost:${port}${REMOTE_BASE_PATH}`);
return Array.from(new Set(urls));
}
function isUsableIpv4Entry(entry) {
return Boolean(entry && !entry.internal && isIpv4Family(entry.family) && parseIpv4(entry.address));
}
function isIpv4Family(family) {
return family === 'IPv4' || family === 4;
}
function scoreIpv4Entry(name, entry) {
const octets = parseIpv4(entry.address);
let score = 0;
if (isPrivateIpv4(octets)) {
score += 1000;
}
if (octets[0] === 192 && octets[1] === 168) {
score += 40;
} else if (octets[0] === 10) {
score += 30;
} else if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) {
score += 20;
}
if (PHYSICAL_INTERFACE_NAME_PATTERN.test(name)) {
score += 120;
}
if (VIRTUAL_INTERFACE_NAME_PATTERN.test(name)) {
score -= 700;
}
if (isVirtualMac(entry.mac)) {
score -= 450;
}
if (isLinkLocalIpv4(octets)) {
score -= 1200;
}
if (octets[3] === 1 || octets[3] === 254) {
score -= 25;
}
return score;
}
function parseIpv4(address) {
if (typeof address !== 'string') {
return null;
}
const match = address.match(IPV4_OCTET_PATTERN);
if (!match) {
return null;
}
const octets = match.slice(1).map((part) => Number(part));
return octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) ? octets : null;
}
function isPrivateIpv4(octets) {
return octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168);
}
function isLinkLocalIpv4(octets) {
return octets[0] === 169 && octets[1] === 254;
}
function isVirtualMac(mac) {
if (typeof mac !== 'string') {
return false;
}
return VIRTUAL_MAC_PREFIXES.has(mac.toLowerCase().slice(0, 8));
}
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');
}
}
module.exports = {
getAdvertisedUrls,
serveFile,
};
+64
View File
@@ -0,0 +1,64 @@
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') {
continue;
}
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return '';
}
function cloneValue(value) {
if (Array.isArray(value)) {
return value.map(cloneValue);
}
if (!isRecord(value)) {
return value;
}
const result = {};
for (const [key, entry] of Object.entries(value)) {
result[key] = cloneValue(entry);
}
return result;
}
function isValidPort(value) {
return Number.isFinite(value) && value > 0 && value < 65536;
}
function toStringId(value) {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
return null;
}
module.exports = {
cloneValue,
firstString,
isRecord,
isValidPort,
safeString,
toStringId,
};
@@ -0,0 +1,165 @@
const crypto = require('node:crypto');
const path = require('node:path');
const {
IPC_CHANNEL,
REMOTE_COMMAND_REQUEST_CHANNEL,
REMOTE_COMMAND_RESPONSE_CHANNEL,
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS,
REMOTE_GAME_STATUS_CHANNEL,
REMOTE_INSTALLED_APPS_CHANNEL,
} = require('./constants.cjs');
const { writeInstallLog } = require('./logger.cjs');
const { ensureBridge } = require('./runtime.cjs');
const { installRendererScripts } = require('./renderer-scripts.cjs');
const { safeString } = require('./utils.cjs');
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();
const pendingCommandResponses = globalThis.__wandRemoteBridgePendingCommandResponses || new Map();
globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers;
globalThis.__wandRemoteBridgePendingCommandResponses = pendingCommandResponses;
runtime.setHandler((request) => {
let delivered = false;
for (const sender of Array.from(boundRenderers)) {
try {
if (!sender || sender.isDestroyed()) {
boundRenderers.delete(sender);
continue;
}
sender.send(IPC_CHANNEL.SET_VALUE, request);
delivered = true;
} catch (error) {
boundRenderers.delete(sender);
writeInstallLog('warn', 'Failed to forward set_value to renderer.', error);
}
}
return delivered;
});
runtime.setCommandHandler(async (request) => {
for (const sender of Array.from(boundRenderers)) {
try {
if (!sender || sender.isDestroyed()) {
boundRenderers.delete(sender);
continue;
}
return await dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses);
} catch (error) {
writeInstallLog('warn', 'Failed to execute remote command in renderer.', error);
}
}
return buildRendererBridgeMissingResponse(request);
});
installIpcHandlers(electron, runtime, boundRenderers, pendingCommandResponses);
installRendererScripts(electron, runtime, {
...options,
panelRoot: options.panelRoot || path.dirname(__dirname),
});
writeInstallLog('info', 'Wand runtime hooks installed.');
return runtime;
}
function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandResponses) {
if (globalThis.__wandRemoteBridgeIpcInstalled) {
return;
}
globalThis.__wandRemoteBridgeIpcInstalled = true;
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (_event, snapshot) => {
runtime.sync(snapshot);
return true;
});
electron.ipcMain.handle(REMOTE_INSTALLED_APPS_CHANNEL, (_event, snapshot) => {
runtime.syncInstalledApps(snapshot);
return true;
});
electron.ipcMain.handle(REMOTE_GAME_STATUS_CHANNEL, (_event, snapshot) => {
runtime.syncGameStatus(snapshot);
return true;
});
electron.ipcMain.handle(REMOTE_COMMAND_RESPONSE_CHANNEL, (_event, response) => {
const requestId = safeString(response?.requestId);
const pending = requestId ? pendingCommandResponses.get(requestId) : null;
if (!pending) {
return false;
}
pending.resolve(response);
return true;
});
electron.ipcMain.handle(IPC_CHANNEL.VALUE_CHANGED, (_event, change) => {
runtime.valueChanged(change);
return true;
});
electron.ipcMain.handle(IPC_CHANNEL.BIND_HANDLER, (event) => {
if (event && event.sender) {
boundRenderers.add(event.sender);
}
return true;
});
electron.ipcMain.handle(IPC_CHANNEL.REMOTE_URL, () => runtime.remoteUrl);
}
function dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses) {
return new Promise((resolve, reject) => {
const requestId = `remote_command_${typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : Date.now().toString(36)}`;
const timer = setTimeout(() => {
pendingCommandResponses.delete(requestId);
reject(new Error('Renderer remote command timed out.'));
}, REMOTE_COMMAND_RESPONSE_TIMEOUT_MS);
pendingCommandResponses.set(requestId, {
resolve: (response) => {
clearTimeout(timer);
pendingCommandResponses.delete(requestId);
resolve(response);
},
reject: (error) => {
clearTimeout(timer);
pendingCommandResponses.delete(requestId);
reject(error instanceof Error ? error : new Error(String(error)));
},
});
try {
sender.send(REMOTE_COMMAND_REQUEST_CHANNEL, {
...request,
requestId,
});
} catch (error) {
clearTimeout(timer);
pendingCommandResponses.delete(requestId);
reject(error);
}
});
}
function buildRendererBridgeMissingResponse(request) {
return {
ok: false,
action: request?.action === 'stop' ? 'stop' : 'launch',
gameId: typeof request?.gameId === 'string' ? request.gameId : null,
titleId: typeof request?.titleId === 'string' ? request.titleId : null,
error: {
code: 'bridge_not_ready',
message: 'The renderer command bridge is not ready yet.',
},
};
}
module.exports = {
installWandRuntime,
};
@@ -0,0 +1,139 @@
const crypto = require('node:crypto');
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants.cjs');
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function jsonMessage(type, payload, requestId = null) {
return JSON.stringify({
type,
version: BRIDGE_PROTOCOL_VERSION,
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(WS_OPCODE.TEXT, 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(WS_OPCODE.CLOSE, 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 createAcceptKey(key) {
return crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
}
module.exports = {
closeClient,
createAcceptKey,
jsonMessage,
makeFrame,
parseFrame,
sendJson,
sendText,
};
+56
View File
@@ -0,0 +1,56 @@
import { build } from "esbuild"
import { readdir } from "node:fs/promises"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
const bridgeRoot = dirname(fileURLToPath(import.meta.url))
const webPanelRoot = resolve(bridgeRoot, "..")
const distRoot = resolve(webPanelRoot, "dist")
const bridgeEntryPoint = resolve(bridgeRoot, "source.cjs")
const bridgeOutfile = resolve(distRoot, "bridge.cjs")
const rendererScriptsRoot = resolve(bridgeRoot, "scripts", "default")
const rendererScriptsOutdir = resolve(distRoot, "renderer-scripts")
await build({
banner: {
js: "// Generated by bridge/build.mjs. Do not edit this bundle by hand.",
},
bundle: true,
entryPoints: [bridgeEntryPoint],
format: "cjs",
legalComments: "none",
minify: true,
outfile: bridgeOutfile,
platform: "node",
target: "node16",
})
const rendererEntries = (
await readdir(rendererScriptsRoot, { withFileTypes: true })
)
.filter((entry) => entry.isFile() && entry.name.endsWith(".js"))
.map((entry) => resolve(rendererScriptsRoot, entry.name))
if (rendererEntries.length === 0) {
throw new Error(`No renderer script entries found in ${rendererScriptsRoot}`)
}
await build({
banner: {
js: "// Generated by bridge/build.mjs. Do not edit this bundle by hand.",
},
bundle: true,
entryNames: "[name]",
entryPoints: rendererEntries,
format: "iife",
legalComments: "none",
minify: true,
outdir: rendererScriptsOutdir,
platform: "browser",
target: "es2020",
})
console.log(`Built ${bridgeOutfile}`)
console.log(
`Built ${rendererEntries.length} renderer script(s) in ${rendererScriptsOutdir}`
)
@@ -0,0 +1,3 @@
import { installInstalledAppsSync } from "./installed-apps-sync/index.js"
installInstalledAppsSync(globalThis.WandEnhancer)
@@ -0,0 +1,255 @@
import {
DATA_IMAGE_URL_PREFIX,
IMAGE_FIELD_NAMES,
MAX_IMAGE_URL_SEARCH_DEPTH,
PROTOCOL_RELATIVE_IMAGE_URL_PATTERN,
REMOTE_IMAGE_URL_PATTERN,
SIDEBAR_GAME_ROW_CONTAINER_SELECTOR,
SIDEBAR_GAME_ROW_IMAGE_SELECTOR,
SIDEBAR_GAME_ROW_MORE_SELECTOR,
SIDEBAR_GAME_ROW_TITLE_SELECTOR,
SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN,
STEAM_APP_ID_FIELD_NAMES,
STEAM_APP_ID_PATTERN,
STEAM_CONTAINER_FIELD_PATTERN,
STEAM_CONTAINER_ID_FIELD_NAMES,
STEAM_PLATFORM,
WEMOD_STEAM_COMMUNITY_CDN_BASE_URL,
} from "./constants.js"
import { isRecord, safeString, toStringId } from "./runtime.js"
export function pickImageUrl(...values) {
for (const value of values) {
const imageUrl = normalizeImageUrl(value)
if (imageUrl) {
return imageUrl
}
}
return null
}
export function normalizeImageUrl(value, depth = 0) {
if (typeof value === "string") {
const trimmed = value.trim()
if (
REMOTE_IMAGE_URL_PATTERN.test(trimmed) ||
trimmed.startsWith(DATA_IMAGE_URL_PREFIX)
) {
return trimmed
}
if (PROTOCOL_RELATIVE_IMAGE_URL_PATTERN.test(trimmed)) {
return `https:${trimmed}`
}
return null
}
if (depth > MAX_IMAGE_URL_SEARCH_DEPTH || !value) {
return null
}
if (Array.isArray(value)) {
for (const entry of value) {
const imageUrl = normalizeImageUrl(entry, depth + 1)
if (imageUrl) {
return imageUrl
}
}
return null
}
if (!isRecord(value)) {
return null
}
for (const key of IMAGE_FIELD_NAMES) {
const imageUrl = normalizeImageUrl(value[key], depth + 1)
if (imageUrl) {
return imageUrl
}
}
return null
}
export function getSteamClientIconUrl(...values) {
for (const value of values) {
const steamAppId = toStringId(value)
if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) {
return `${WEMOD_STEAM_COMMUNITY_CDN_BASE_URL}/${steamAppId}/client_icon/96.webp`
}
}
return null
}
export function getSidebarGameRowClientIcons() {
const byTitleId = new Map()
const byTitleName = new Map()
if (typeof document === "undefined") {
return { byTitleId, byTitleName }
}
const containers = document.querySelectorAll(
SIDEBAR_GAME_ROW_CONTAINER_SELECTOR
)
for (const container of containers) {
const imageElement = container.querySelector(
SIDEBAR_GAME_ROW_IMAGE_SELECTOR
)
const titleElement = container.querySelector(
SIDEBAR_GAME_ROW_TITLE_SELECTOR
)
const moreButton = container.querySelector(SIDEBAR_GAME_ROW_MORE_SELECTOR)
if (!imageElement || !titleElement) {
continue
}
const backgroundImageUrl = getCssBackgroundImageUrl(
safeString(imageElement.style?.backgroundImage) ||
getComputedStyle(imageElement).backgroundImage
)
if (!backgroundImageUrl) {
continue
}
const tooltipId = safeString(
moreButton?.getAttribute?.("data-tooltip-trigger-for")
)
const titleId =
tooltipId.match(SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN)?.[1] ?? null
const titleName = normalizeTitleMatchKey(titleElement.textContent)
if (titleId) {
byTitleId.set(titleId, backgroundImageUrl)
}
if (titleName) {
byTitleName.set(titleName, backgroundImageUrl)
}
}
return { byTitleId, byTitleName }
}
export function getSidebarGameRowClientIconUrl(
sidebarIcons,
titleId,
...names
) {
const normalizedTitleId = toStringId(titleId)
if (normalizedTitleId && sidebarIcons.byTitleId.has(normalizedTitleId)) {
return sidebarIcons.byTitleId.get(normalizedTitleId) ?? null
}
for (const name of names) {
const normalizedName = normalizeTitleMatchKey(name)
if (normalizedName && sidebarIcons.byTitleName.has(normalizedName)) {
return sidebarIcons.byTitleName.get(normalizedName) ?? null
}
}
return null
}
export function findSteamAppId(...roots) {
const seen = new Set()
const queue = roots.map((value) => ({ value, depth: 0, steamContext: false }))
while (queue.length > 0) {
const current = queue.shift()
const value = current?.value
const depth = current?.depth ?? 0
const steamContext = current?.steamContext ?? false
if (!value || depth > MAX_IMAGE_URL_SEARCH_DEPTH || seen.has(value)) {
continue
}
if (typeof value === "string" || typeof value === "number") {
const steamAppId = steamContext ? toStringId(value) : null
if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) {
return steamAppId
}
continue
}
seen.add(value)
if (Array.isArray(value)) {
for (const entry of value) {
queue.push({ value: entry, depth: depth + 1, steamContext })
}
continue
}
if (!isRecord(value)) {
continue
}
for (const [key, entry] of Object.entries(value)) {
const steamAppId = getSteamAppIdFromEntry(key, entry, steamContext)
if (steamAppId) {
return steamAppId
}
if (isRecord(entry) || Array.isArray(entry)) {
queue.push({
value: entry,
depth: depth + 1,
steamContext: steamContext || STEAM_CONTAINER_FIELD_PATTERN.test(key),
})
}
}
}
return null
}
export function getInstalledAppSteamAppId(platform, sku) {
if (safeString(platform).toLowerCase() !== STEAM_PLATFORM) {
return null
}
return sku
}
function normalizeTitleMatchKey(value) {
const normalized = safeString(value).trim().toLowerCase().replace(/\s+/g, " ")
return normalized || null
}
function getCssBackgroundImageUrl(value) {
const backgroundImage = safeString(value)
if (!backgroundImage || backgroundImage === "none") {
return null
}
const match = backgroundImage.match(/url\((['"]?)(.*?)\1\)/i)
if (!match?.[2]) {
return null
}
return normalizeImageUrl(match[2])
}
function getSteamAppIdFromEntry(key, entry, steamContext) {
if (STEAM_APP_ID_FIELD_NAMES.has(key)) {
const steamAppId = toStringId(entry)
if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) {
return steamAppId
}
}
if (steamContext && STEAM_CONTAINER_ID_FIELD_NAMES.has(key)) {
const steamAppId = toStringId(entry)
if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) {
return steamAppId
}
}
return null
}
@@ -0,0 +1,95 @@
export const GLOBAL_FLAG = "__wandInstalledAppsSyncInstalled"
export const BIND_CHANNEL = "wand-remote-set-handler-bind"
export const SYNC_CHANNEL = "wand-remote-installed-apps"
export const TRAINER_SNAPSHOT_CHANNEL = "wand-remote-sync"
export const GAME_STATUS_CHANNEL = "wand-remote-game-status"
export const COMMAND_REQUEST_CHANNEL = "wand-remote-command"
export const COMMAND_RESPONSE_CHANNEL = "wand-remote-command-response"
export const REMOTE_COMMAND_LAUNCH = "launch"
export const REMOTE_COMMAND_STOP = "stop"
export const REMOTE_COMMAND_TRIGGER = "remote"
export const RETRY_DELAY_MS = 1000
export const MAX_BOOTSTRAP_ATTEMPTS = 60
export const SYNC_INTERVAL_MS = 15000
export const OPTIONAL_SERVICES_RETRY_INTERVAL_MS = 1000
export const FOLLOW_UP_SYNC_DELAY_MS = 2500
export const UNAVAILABLE_TITLES_BATCH_SIZE = 250
export const BOOTSTRAP_LOG_THROTTLE_ATTEMPTS = 5
// Wand's webpack module exports the trainer-launch-request class under key `vO`.
// Required so `trainerService.launch(req)` records `getMetadata(vO)` state in Wand. See AGENTS.md "Remote Play".
export const TRAINER_LAUNCH_REQUEST_EXPORT_KEY = "vO"
export const SNAPSHOT_ENTRY_KEY_PREFIX = Object.freeze({
TITLE: "title:",
GAME: "game:",
APP: "app:",
})
export const GAME_LAUNCHED_EVENT = "game-launched"
export const GAME_ENDED_EVENT = "game-ended"
export const SYNTHETIC_SESSION_RUNNING_EVENT = "trainer-running"
export const SYNTHETIC_SESSION_IDLE_EVENT = "trainer-idle"
export const TRAINER_ENDED_EVENT = "trainer-ended"
export const REMOTE_STOP_EVENT = "remote-stop"
export const STEAM_PLATFORM = "steam"
export const STEAM_APP_ID_PATTERN = /^\d+$/
export const WEMOD_STEAM_COMMUNITY_CDN_BASE_URL =
"https://api-cdn.wemod.com/steam_community"
export const REMOTE_IMAGE_URL_PATTERN = /^https?:\/\//i
export const PROTOCOL_RELATIVE_IMAGE_URL_PATTERN = /^\/\//
export const DATA_IMAGE_URL_PREFIX = "data:image/"
export const MAX_IMAGE_URL_SEARCH_DEPTH = 4
export const SIDEBAR_GAME_ROW_CONTAINER_SELECTOR = ".sidebar-game-row-container"
export const SIDEBAR_GAME_ROW_IMAGE_SELECTOR = ".sidebar-game-row-image"
export const SIDEBAR_GAME_ROW_TITLE_SELECTOR = ".sidebar-game-row-title"
export const SIDEBAR_GAME_ROW_MORE_SELECTOR = ".sidebar-game-row-more"
export const SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN =
/sidebar-game-row-(.+?)-more-button-tooltip/
export const STEAM_APP_ID_FIELD_NAMES = new Set(["steamAppId", "steamAppID"])
export const STEAM_CONTAINER_FIELD_PATTERN = /steam/i
export const STEAM_CONTAINER_ID_FIELD_NAMES = new Set(["appId", "appID", "id"])
export const LOG_PREFIX = "[wand-installed-apps-sync]"
export const LOG_FILE_NAME = "wand-remote-installed-apps-sync.log"
export const EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS = new Set(["standalone"])
export const IMAGE_FIELD_NAMES = [
"imageUrl",
"imageURL",
"iconUrl",
"iconURL",
"coverUrl",
"coverURL",
"thumbnailUrl",
"thumbnailURL",
"logoUrl",
"logoURL",
"headerImageUrl",
"headerImageURL",
"boxArtUrl",
"boxartUrl",
"posterUrl",
"tileUrl",
"capsuleUrl",
"heroUrl",
"backgroundUrl",
"image",
"icon",
"cover",
"thumbnail",
"logo",
"headerImage",
"header",
"boxArt",
"boxart",
"poster",
"tile",
"capsule",
"hero",
"background",
"large",
"medium",
"small",
"original",
"source",
"href",
"uri",
"url",
"src",
]
@@ -0,0 +1,338 @@
import {
GAME_ENDED_EVENT,
GAME_LAUNCHED_EVENT,
GAME_STATUS_CHANNEL,
SYNTHETIC_SESSION_IDLE_EVENT,
SYNTHETIC_SESSION_RUNNING_EVENT,
TRAINER_ENDED_EVENT,
TRAINER_SNAPSHOT_CHANNEL,
} from "./constants.js"
import { isRecord, safeString, toStringId } from "./runtime.js"
export function createIdleGameSession() {
return {
state: "idle",
event: "snapshot",
processId: null,
gameId: null,
titleId: null,
titleName: null,
sessionDurationSeconds: null,
startedAt: null,
endedAt: null,
}
}
export function createIdleTrainerStatus() {
return {
state: "idle",
event: "snapshot",
trainerId: null,
displayName: null,
gameId: null,
titleId: null,
}
}
export function installGameStatusSubscriptions(state) {
let installed = false
if (
state.gameLifecycleService &&
!state.gameLifecycleSubscriptionsInstalled
) {
installed = installLifecycleSubscriptions(state) || installed
}
if (
state.trainerVisibilityService &&
!state.trainerVisibilitySubscriptionInstalled
) {
state.currentRunningTrainer = normalizeRunningTrainerStatus(
state.trainerVisibilityService.runningTrainer,
"snapshot"
)
syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer)
installed = installTrainerVisibilitySubscription(state) || installed
state.trainerVisibilitySubscriptionInstalled = true
}
if (
state.trainerService &&
!state.trainerEndedSubscriptionInstalled &&
typeof state.trainerService.onTrainerEnded === "function"
) {
state.trainerService.onTrainerEnded(() => {
clearTrainerSnapshot(state, TRAINER_ENDED_EVENT, true)
})
state.trainerEndedSubscriptionInstalled = true
installed = true
}
if (!installed) {
return
}
state.log(
"info",
"Game status hooks installed.",
`lifecycle=${state.gameLifecycleSubscriptionsInstalled ? "yes" : "no"}, trainer=${state.trainerVisibilitySubscriptionInstalled ? "yes" : "no"}, trainerEnded=${state.trainerEndedSubscriptionInstalled ? "yes" : "no"}`
)
void syncGameStatus(state, true)
}
export function clearTrainerSnapshot(state, reason, clearSession = false) {
state.currentRunningTrainer = {
...createIdleTrainerStatus(),
event: reason,
}
if (clearSession) {
clearGameSession(state, reason)
} else if (
state.currentGameSession.state === "running" &&
isSyntheticGameSessionEvent(state.currentGameSession.event)
) {
syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer)
}
void syncGameStatus(state, true)
if (!state.ipcRenderer) {
return
}
try {
void state.ipcRenderer.invoke(TRAINER_SNAPSHOT_CHANNEL, null)
} catch (error) {
state.log(
"warn",
"Trainer snapshot clear IPC failed.",
error?.stack || String(error)
)
}
}
export async function syncGameStatus(state, force = false) {
if (!state.ipcRenderer) {
return false
}
const snapshot = buildGameStatusSnapshot(state)
const signature = makeGameStatusSignature(snapshot)
if (!force && signature === state.lastGameStatusSignature) {
return false
}
state.lastGameStatusSignature = signature
try {
await state.ipcRenderer.invoke(GAME_STATUS_CHANNEL, snapshot)
state.log(
"info",
"Game status snapshot sent.",
`session=${snapshot.session.state}/${snapshot.session.event}, trainer=${snapshot.trainer.state}/${snapshot.trainer.event}`
)
return true
} catch (error) {
state.log(
"error",
"Game status snapshot IPC failed.",
error?.stack || String(error)
)
return false
}
}
function installLifecycleSubscriptions(state) {
let installed = false
if (typeof state.gameLifecycleService.onGameLaunched === "function") {
state.gameLifecycleService.onGameLaunched((event) => {
state.currentGameSession = {
state: "running",
event: GAME_LAUNCHED_EVENT,
processId:
typeof event?.processId === "number" ? event.processId : null,
gameId: toStringId(event?.gameId),
titleId: toStringId(event?.titleId),
titleName: safeString(event?.titleName),
sessionDurationSeconds: null,
startedAt: new Date().toISOString(),
endedAt: null,
}
void syncGameStatus(state, true)
})
installed = true
}
if (typeof state.gameLifecycleService.onGameEnded === "function") {
state.gameLifecycleService.onGameEnded((event) => {
clearGameSession(
state,
GAME_ENDED_EVENT,
typeof event?.sessionDurationSeconds === "number"
? event.sessionDurationSeconds
: state.currentGameSession.sessionDurationSeconds
)
void syncGameStatus(state, true)
})
installed = true
}
if (installed) {
state.gameLifecycleSubscriptionsInstalled = true
}
return installed
}
function installTrainerVisibilitySubscription(state) {
if (
typeof state.trainerVisibilityService.onRunningTrainerChanged !== "function"
) {
return false
}
state.trainerVisibilityService.onRunningTrainerChanged((runningTrainer) => {
state.currentRunningTrainer = normalizeRunningTrainerStatus(
runningTrainer,
runningTrainer ? "trainer-running" : "trainer-idle"
)
syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer)
void syncGameStatus(state, true)
})
return true
}
function normalizeRunningTrainerStatus(runningTrainer, event = "snapshot") {
const info = isRecord(runningTrainer?.info)
? runningTrainer.info
: isRecord(runningTrainer)
? runningTrainer
: null
if (!info) {
return {
...createIdleTrainerStatus(),
event,
}
}
return {
state: "running",
event,
trainerId: toStringId(info.trainerId) || toStringId(info.id),
displayName: safeString(
info.displayName,
info.gameName,
info.titleName,
info.title,
info.name
),
gameId: toStringId(info.gameId),
titleId: toStringId(info.titleId),
}
}
function syncGameSessionFromTrainerStatus(state, trainerStatus) {
if (trainerStatus?.state === "running") {
if (
state.currentGameSession.state === "running" &&
!isSyntheticGameSessionEvent(state.currentGameSession.event)
) {
return false
}
state.currentGameSession = {
state: "running",
event: SYNTHETIC_SESSION_RUNNING_EVENT,
processId: state.currentGameSession.processId,
gameId: trainerStatus.gameId ?? state.currentGameSession.gameId,
titleId: trainerStatus.titleId ?? state.currentGameSession.titleId,
titleName:
trainerStatus.displayName ?? state.currentGameSession.titleName,
sessionDurationSeconds: null,
startedAt:
state.currentGameSession.state === "running" &&
isSyntheticGameSessionEvent(state.currentGameSession.event)
? state.currentGameSession.startedAt
: new Date().toISOString(),
endedAt: null,
}
return true
}
if (
state.currentGameSession.state !== "running" ||
!isSyntheticGameSessionEvent(state.currentGameSession.event)
) {
return false
}
const startedAt = state.currentGameSession.startedAt
const sessionDurationSeconds = startedAt
? Math.max(
0,
Math.round((Date.now() - new Date(startedAt).getTime()) / 1000)
)
: null
clearGameSession(state, SYNTHETIC_SESSION_IDLE_EVENT, sessionDurationSeconds)
return true
}
function clearGameSession(
state,
event,
sessionDurationSeconds = state.currentGameSession.sessionDurationSeconds
) {
state.currentGameSession = {
state: "idle",
event,
processId: null,
gameId: null,
titleId: null,
titleName: null,
sessionDurationSeconds,
startedAt: state.currentGameSession.startedAt,
endedAt: new Date().toISOString(),
}
}
function isSyntheticGameSessionEvent(event) {
return (
event === SYNTHETIC_SESSION_RUNNING_EVENT ||
event === SYNTHETIC_SESSION_IDLE_EVENT
)
}
function buildGameStatusSnapshot(state) {
return {
instanceId: "wand-game-status",
updatedAt: new Date().toISOString(),
session: { ...state.currentGameSession },
trainer: { ...state.currentRunningTrainer },
}
}
function makeGameStatusSignature(snapshot) {
return [
snapshot.session.state,
snapshot.session.event,
snapshot.session.processId ?? "",
snapshot.session.gameId ?? "",
snapshot.session.titleId ?? "",
snapshot.session.titleName ?? "",
snapshot.session.sessionDurationSeconds ?? "",
snapshot.session.startedAt ?? "",
snapshot.session.endedAt ?? "",
snapshot.trainer.state,
snapshot.trainer.event,
snapshot.trainer.trainerId ?? "",
snapshot.trainer.displayName ?? "",
snapshot.trainer.gameId ?? "",
snapshot.trainer.titleId ?? "",
].join("|")
}
@@ -0,0 +1,421 @@
import {
BIND_CHANNEL,
BOOTSTRAP_LOG_THROTTLE_ATTEMPTS,
COMMAND_REQUEST_CHANNEL,
FOLLOW_UP_SYNC_DELAY_MS,
GLOBAL_FLAG,
MAX_BOOTSTRAP_ATTEMPTS,
OPTIONAL_SERVICES_RETRY_INTERVAL_MS,
RETRY_DELAY_MS,
SYNC_CHANNEL,
SYNC_INTERVAL_MS,
} from "./constants.js"
import {
createIdleGameSession,
createIdleTrainerStatus,
installGameStatusSubscriptions,
} from "./game-status.js"
import {
buildSnapshot,
makeInstalledAppsSignature,
refreshUnavailableTitles,
resolveInstalledData,
} from "./installed-data.js"
import { createLogger } from "./logger.js"
import { handleRemoteCommandRequest } from "./remote-commands.js"
import {
getAppRoot,
getAureliaContainer,
getRequire,
getWebpackRequire,
hasAppRoot,
isRecord,
summarizeAureliaSubtree,
} from "./runtime.js"
import {
getInstalledAppsService,
getStoreRef,
hasMissingOptionalServices,
resolveOptionalServices,
} from "./services.js"
export function installInstalledAppsSync(WandEnhancer) {
if (globalThis[GLOBAL_FLAG]) {
return
}
globalThis[GLOBAL_FLAG] = true
const state = createState(WandEnhancer)
state.resolveRemoteCommandServices = () => resolveRemoteCommandServices(state)
state.queueSync = (force = false) => queueSync(state, force)
state.queueFollowUpSync = () => queueFollowUpSync(state)
state.log(
"info",
"Script loaded.",
`logFile=${globalThis.__wandInstalledAppsSyncLogFile || "console-only"}`
)
retryBootstrap(state)
}
function createState(WandEnhancer) {
return {
WandEnhancer,
log: createLogger(WandEnhancer),
lastSignature: null,
lastGameStatusSignature: null,
refreshTimer: null,
followUpSyncTimer: null,
pollTimer: null,
optionalServicesTimer: null,
bootstrapAttempts: 0,
bridgeBound: false,
refreshPatched: false,
installedAppsService: null,
gameLifecycleService: null,
trainerVisibilityService: null,
unavailableTitlesService: null,
storeRef: null,
ipcRenderer: null,
lastBootstrapReason: null,
gameLifecycleSubscriptionsInstalled: false,
trainerVisibilitySubscriptionInstalled: false,
trainerEndedSubscriptionInstalled: false,
unavailableTitlesFetchKey: null,
unavailableTitlesFetchPromise: null,
unavailableTitlesById: {},
trainerApiService: null,
trainerService: null,
trainerLaunchRequestCtor: null,
commandListenerInstalled: false,
missingOptionalServiceWarnings: new Set(),
currentGameSession: createIdleGameSession(),
currentRunningTrainer: createIdleTrainerStatus(),
resolveRemoteCommandServices: null,
queueSync: null,
queueFollowUpSync: null,
}
}
function setBootstrapReason(state, reason) {
if (
reason === state.lastBootstrapReason &&
state.bootstrapAttempts % BOOTSTRAP_LOG_THROTTLE_ATTEMPTS !== 0
) {
return
}
state.lastBootstrapReason = reason
state.log(
"info",
`Bootstrap waiting: ${reason}.`,
`attempt=${state.bootstrapAttempts + 1}/${MAX_BOOTSTRAP_ATTEMPTS}`
)
}
function bindBridge(state) {
if (state.bridgeBound || !state.ipcRenderer) {
return
}
state.bridgeBound = true
if (!state.commandListenerInstalled) {
state.ipcRenderer.on(COMMAND_REQUEST_CHANNEL, (event, request) =>
handleRemoteCommandRequest(state, event, request)
)
state.commandListenerInstalled = true
state.log("info", "Bridge remote command handler installed.")
}
try {
void state.ipcRenderer.invoke(BIND_CHANNEL)
state.log("info", "Bridge set-value handler bind requested.")
} catch (error) {
state.log("warn", "Bridge bind failed.", error?.stack || String(error))
}
}
async function syncInstalledApps(state, force = false) {
if (!state.ipcRenderer || (!state.installedAppsService && !state.storeRef)) {
return false
}
const data = resolveInstalledData(state)
if (!data) {
if (state.installedAppsService) {
state.log(
"warn",
"Service resolved but installedApps is empty/undefined. Store fallback also unavailable."
)
}
return false
}
if (Object.keys(data.rawInstalledApps).length > 0) {
await refreshUnavailableTitles(state, data.rawInstalledApps, force)
}
const snapshot = buildSnapshot(state)
if (!snapshot) {
state.log("warn", "Snapshot build returned nothing.")
return false
}
const signature = makeInstalledAppsSignature(snapshot)
if (!force && signature === state.lastSignature) {
return false
}
state.lastSignature = signature
try {
await state.ipcRenderer.invoke(SYNC_CHANNEL, snapshot)
state.log(
"info",
"Installed apps snapshot sent.",
`apps=${snapshot.apps.length}, catalogGames=${snapshot.diagnostics.catalogGames}, rawInstalledApps=${snapshot.diagnostics.rawInstalledApps}`
)
return true
} catch (error) {
state.log(
"error",
"Installed apps snapshot IPC failed.",
error?.stack || String(error)
)
return false
}
}
function queueSync(state, force = false) {
if (state.refreshTimer) {
clearTimeout(state.refreshTimer)
}
state.refreshTimer = setTimeout(() => {
state.refreshTimer = null
void syncInstalledApps(state, force)
}, 0)
}
function queueFollowUpSync(state) {
if (state.followUpSyncTimer) {
clearTimeout(state.followUpSyncTimer)
}
state.followUpSyncTimer = setTimeout(() => {
state.followUpSyncTimer = null
void syncInstalledApps(state, true)
}, FOLLOW_UP_SYNC_DELAY_MS)
}
function patchRefreshApps(state) {
if (
!state.installedAppsService ||
state.refreshPatched ||
typeof state.installedAppsService.refreshApps !== "function"
) {
return
}
const originalRefreshApps = state.installedAppsService.refreshApps.bind(
state.installedAppsService
)
state.refreshPatched = true
state.log("info", "refreshApps hook installed.")
state.installedAppsService.refreshApps = async (...args) => {
const result = await originalRefreshApps(...args)
state.log("info", "refreshApps completed; queueing installed apps sync.")
queueSync(state, true)
queueFollowUpSync(state)
return result
}
}
function resolveRemoteCommandServices(state) {
const container = getAureliaContainer()
const webpackRequire = getWebpackRequire()
if (!container || !webpackRequire) {
return false
}
resolveRuntimeServices(state, container, webpackRequire)
return true
}
function resolveRuntimeServices(state, container, webpackRequire) {
resolveOptionalServices(state, container, webpackRequire)
installGameStatusSubscriptions(state)
if (!hasMissingOptionalServices(state)) {
stopOptionalServicesRetry(state)
}
}
function stopOptionalServicesRetry(state) {
if (!state.optionalServicesTimer) {
return
}
clearInterval(state.optionalServicesTimer)
state.optionalServicesTimer = null
}
function startOptionalServicesRetry(state) {
if (state.optionalServicesTimer || !hasMissingOptionalServices(state)) {
return
}
state.optionalServicesTimer = setInterval(() => {
const container = getAureliaContainer()
const webpackRequire = getWebpackRequire()
if (container && webpackRequire) {
resolveRuntimeServices(state, container, webpackRequire)
}
}, OPTIONAL_SERVICES_RETRY_INTERVAL_MS)
state.log(
"info",
"Optional service retry timer started.",
`${OPTIONAL_SERVICES_RETRY_INTERVAL_MS}ms`
)
}
function bootstrap(state) {
if (!hasAppRoot()) {
setBootstrapReason(state, "app root not ready")
return false
}
if (!state.ipcRenderer && !resolveIpcRenderer(state)) {
return false
}
const webpackRequire = getWebpackRequire()
if (!webpackRequire) {
setBootstrapReason(state, "webpack runtime not ready")
return false
}
const container = getAureliaContainer()
if (!container) {
logMissingContainer(state)
setBootstrapReason(state, "Aurelia container not ready")
return false
}
state.log("info", "Aurelia container resolved.")
if (!state.storeRef) {
state.storeRef = getStoreRef(state, container, webpackRequire)
if (!state.storeRef) {
state.log(
"warn",
"Store reference unavailable; unsupported installed titles will be missing."
)
}
}
state.installedAppsService = getInstalledAppsService(
state,
container,
webpackRequire
)
if (!state.installedAppsService) {
setBootstrapReason(state, "installed apps service not ready")
return false
}
resolveRuntimeServices(state, container, webpackRequire)
startOptionalServicesRetry(state)
if (!isRecord(state.installedAppsService.installedApps)) {
state.log(
"info",
"Service instance has no installedApps data yet; reading from store until refreshApps populates it."
)
if (!state.storeRef) {
state.log("warn", "Store fallback also unavailable; will retry on poll.")
}
}
bindBridge(state)
patchRefreshApps(state)
queueSync(state, true)
queueFollowUpSync(state)
startPollTimer(state)
state.log("info", "Installed apps sync ready.")
return true
}
function resolveIpcRenderer(state) {
const electron = getRequire()?.("electron")
if (!electron?.ipcRenderer) {
setBootstrapReason(state, "electron ipcRenderer not ready")
return false
}
state.ipcRenderer = electron.ipcRenderer
state.log("info", "Electron ipcRenderer resolved.")
return true
}
function startPollTimer(state) {
if (state.pollTimer) {
return
}
state.pollTimer = setInterval(() => {
const container = getAureliaContainer()
const webpackRequire = getWebpackRequire()
if (container && webpackRequire) {
resolveRuntimeServices(state, container, webpackRequire)
}
void syncInstalledApps(state)
}, SYNC_INTERVAL_MS)
state.log(
"info",
"Installed apps poll timer started.",
`${SYNC_INTERVAL_MS}ms`
)
}
function logMissingContainer(state) {
if (state.bootstrapAttempts % 10 !== 0) {
return
}
const root = getAppRoot()
const aureliaKeys = root
? Object.getOwnPropertyNames(root)
.filter((key) => key.startsWith("__") || key === "au")
.join(", ")
: "root=null"
state.log(
"warn",
"Aurelia container not found.",
`rootProps=${aureliaKeys || "(none)"}, subtree=${summarizeAureliaSubtree(root)}`
)
}
function retryBootstrap(state) {
if (bootstrap(state)) {
return
}
state.bootstrapAttempts += 1
if (state.bootstrapAttempts < MAX_BOOTSTRAP_ATTEMPTS) {
setTimeout(() => retryBootstrap(state), RETRY_DELAY_MS)
return
}
state.log(
"error",
"Installed apps sync bootstrap exhausted.",
state.lastBootstrapReason || "unknown reason"
)
}
@@ -0,0 +1,587 @@
import {
EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS,
SNAPSHOT_ENTRY_KEY_PREFIX,
UNAVAILABLE_TITLES_BATCH_SIZE,
} from "./constants.js"
import {
findSteamAppId,
getInstalledAppSteamAppId,
getSidebarGameRowClientIconUrl,
getSidebarGameRowClientIcons,
getSteamClientIconUrl,
pickImageUrl,
} from "./artwork.js"
import {
getBasename,
isRecord,
normalizeStringList,
safeString,
toStringId,
} from "./runtime.js"
export function resolveInstalledData(state) {
const storeState = getStoreState(state.storeRef)
if (isRecord(state.installedAppsService?.installedApps)) {
return {
rawInstalledApps: state.installedAppsService.installedApps,
catalog: isRecord(state.installedAppsService.catalog)
? state.installedAppsService.catalog
: isRecord(storeState?.catalog)
? storeState.catalog
: {},
installedGameVersions: isRecord(
state.installedAppsService.installedVersions
)
? state.installedAppsService.installedVersions
: isRecord(storeState?.installedGameVersions)
? storeState.installedGameVersions
: {},
correlatedUnavailableTitles: getResolvedUnavailableTitles(
state,
storeState?.correlatedUnavailableTitles
),
source: "service",
}
}
if (isRecord(storeState?.installedApps)) {
return {
rawInstalledApps: storeState.installedApps,
catalog: isRecord(storeState.catalog) ? storeState.catalog : {},
installedGameVersions: isRecord(storeState.installedGameVersions)
? storeState.installedGameVersions
: {},
correlatedUnavailableTitles: getResolvedUnavailableTitles(
state,
storeState.correlatedUnavailableTitles
),
source: "store",
}
}
return null
}
export async function refreshUnavailableTitles(
state,
rawInstalledApps,
force = false
) {
if (!state.unavailableTitlesService) {
return state.unavailableTitlesById
}
const correlationIds = getCorrelationIdsForUnavailableTitles(rawInstalledApps)
const fetchKey = correlationIds.join("\n")
if (!fetchKey) {
state.unavailableTitlesFetchKey = ""
state.unavailableTitlesById = {}
return state.unavailableTitlesById
}
if (!force && fetchKey === state.unavailableTitlesFetchKey) {
if (state.unavailableTitlesFetchPromise) {
await state.unavailableTitlesFetchPromise
}
return state.unavailableTitlesById
}
state.unavailableTitlesFetchKey = fetchKey
state.unavailableTitlesFetchPromise = fetchUnavailableTitles(
state,
correlationIds
)
await state.unavailableTitlesFetchPromise
return state.unavailableTitlesById
}
export function buildSnapshot(state) {
const data = resolveInstalledData(state)
if (!data) {
if (state.installedAppsService) {
state.log(
"warn",
"Service resolved but installedApps is empty/undefined. Store fallback also unavailable."
)
}
return null
}
const {
rawInstalledApps,
catalog,
installedGameVersions,
correlatedUnavailableTitles,
source,
} = data
const catalogGames = isRecord(catalog.games) ? catalog.games : {}
const catalogTitles = isRecord(catalog.titles) ? catalog.titles : {}
const sidebarGameRowClientIcons = getSidebarGameRowClientIcons()
const entriesByKey = new Map()
let matchedCatalogGames = 0
let matchedUnavailableGames = 0
state.log(
"info",
`Building snapshot from ${source}.`,
`rawInstalledApps=${Object.keys(rawInstalledApps).length}, catalogGames=${Object.keys(catalogGames).length}, installedGameVersions=${Object.keys(installedGameVersions).length}, unavailableTitles=${Object.keys(correlatedUnavailableTitles).length}`
)
for (const [gameId, versions] of Object.entries(installedGameVersions)) {
if (!Array.isArray(versions)) {
continue
}
const game = catalogGames[gameId]
if (!isRecord(game)) {
continue
}
const preferredApp = pickPreferredInstalledApp(
rawInstalledApps,
getCatalogGameCorrelationIds(game, versions)
)
if (!preferredApp) {
continue
}
const titleId = toStringId(game.titleId)
const title = titleId
? catalogTitles[titleId] || catalogTitles[game.titleId] || null
: null
const sidebarClientIconUrl = getSidebarGameRowClientIconUrl(
sidebarGameRowClientIcons,
titleId,
title?.name,
title?.displayName,
game.displayName,
game.title,
game.name,
preferredApp.displayName
)
upsertSnapshotEntry(entriesByKey, {
...preferredApp,
displayName: safeString(
title?.name,
title?.displayName,
game.displayName,
game.title,
game.name,
preferredApp.displayName,
gameId
),
imageUrl: pickImageUrlForTitle(title, game, preferredApp, sidebarClientIconUrl, versions),
gameId: String(gameId),
titleId,
})
matchedCatalogGames += 1
}
for (const unavailableTitle of Object.values(correlatedUnavailableTitles)) {
if (!isRecord(unavailableTitle) || !Array.isArray(unavailableTitle.games)) {
continue
}
const titleId = toStringId(unavailableTitle.id)
for (const game of unavailableTitle.games) {
if (!isRecord(game) || !Array.isArray(game.correlationIds)) {
continue
}
const preferredApp = pickPreferredInstalledApp(
rawInstalledApps,
game.correlationIds
)
if (!preferredApp) {
continue
}
const sidebarClientIconUrl = getSidebarGameRowClientIconUrl(
sidebarGameRowClientIcons,
titleId,
unavailableTitle.name,
game.name,
preferredApp.displayName
)
upsertSnapshotEntry(entriesByKey, {
...preferredApp,
displayName: safeString(
unavailableTitle.name,
game.name,
preferredApp.displayName,
preferredApp.correlationId
),
imageUrl: pickImageUrlForTitle(unavailableTitle, game, preferredApp, sidebarClientIconUrl),
gameId: toStringId(game.id),
titleId,
})
matchedUnavailableGames += 1
}
}
const apps = Array.from(entriesByKey.values()).sort(compareSnapshotEntries)
return {
instanceId: "wand-installed-apps",
updatedAt: new Date().toISOString(),
apps,
diagnostics: {
catalogGames: Object.keys(catalogGames).length,
catalogTitles: Object.keys(catalogTitles).length,
installedGameVersions: Object.keys(installedGameVersions).length,
correlatedUnavailableTitles: Object.keys(correlatedUnavailableTitles)
.length,
matchedCatalogGames,
matchedUnavailableGames,
myGames: apps.length,
rawInstalledApps: Object.keys(rawInstalledApps).length,
},
}
}
export function makeInstalledAppsSignature(snapshot) {
return snapshot.apps
.map((app) =>
[
app.platform,
app.sku,
app.displayName,
app.gameId ?? "",
app.titleId ?? "",
app.location,
app.imageUrl ?? "",
app.platformLastPlayedTimestamp ?? "",
app.platformTotalPlaytimeMinutes ?? "",
].join("|")
)
.join("\n")
}
export function toInstalledAppRecord(correlationId, app) {
if (
!isRecord(app) ||
typeof correlationId !== "string" ||
!correlationId.trim()
) {
return null
}
const [fallbackPlatform, fallbackSku] = correlationId.split(":")
const platform = safeString(app.platform, fallbackPlatform)
const sku = safeString(app.sku, fallbackSku)
if (!platform || !sku) {
return null
}
const location = typeof app.location === "string" ? app.location : ""
const alternateLocations = normalizeStringList(app.alternateLocations)
return {
platform,
sku,
correlationId,
displayName: safeString(
app.displayName,
app.titleName,
app.gameName,
app.name,
getBasename(location),
correlationId
),
location,
alternateLocations,
imageUrl: pickImageUrl(
app.imageUrl,
app.iconUrl,
app.coverUrl,
app.thumbnailUrl,
app.logoUrl,
app.headerImageUrl,
app.icon,
app.images,
app.assets,
getSteamClientIconUrl(
findSteamAppId(app),
getInstalledAppSteamAppId(platform, sku)
)
),
platformLastPlayedTimestamp:
typeof app.platformLastPlayedTimestamp === "number"
? app.platformLastPlayedTimestamp
: null,
platformTotalPlaytimeMinutes:
typeof app.platformTotalPlaytimeMinutes === "number"
? app.platformTotalPlaytimeMinutes
: null,
}
}
export function compareInstalledAppRecords(left, right) {
const lastPlayedDiff =
(right.platformLastPlayedTimestamp ?? 0) -
(left.platformLastPlayedTimestamp ?? 0)
if (lastPlayedDiff !== 0) {
return lastPlayedDiff
}
const playtimeDiff =
(right.platformTotalPlaytimeMinutes ?? 0) -
(left.platformTotalPlaytimeMinutes ?? 0)
if (playtimeDiff !== 0) {
return playtimeDiff
}
return compareByIdentity(left, right)
}
export function getInstalledVersionsForGame(gameId, data) {
const versions = Array.isArray(data?.installedGameVersions?.[gameId])
? data.installedGameVersions[gameId]
: []
return Array.from(
new Set(
versions
.map((entry) => entry?.version)
.filter(
(entry) => typeof entry === "string" || typeof entry === "number"
)
)
)
}
function getStoreState(storeRef) {
const state =
typeof storeRef?.state?.getValue === "function"
? storeRef.state.getValue()
: null
return isRecord(state) ? state : null
}
function getResolvedUnavailableTitles(state, liveTitles) {
const liveCount = isRecord(liveTitles) ? Object.keys(liveTitles).length : 0
return liveCount > 0 ? liveTitles : state.unavailableTitlesById
}
function getCorrelationIdsForUnavailableTitles(rawInstalledApps) {
return Object.entries(rawInstalledApps)
.filter(
([, app]) =>
isRecord(app) &&
!EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS.has(safeString(app.platform))
)
.map(([correlationId]) => correlationId)
.sort()
}
async function fetchUnavailableTitles(state, correlationIds) {
const nextTitlesById = {}
try {
for (
let index = 0;
index < correlationIds.length;
index += UNAVAILABLE_TITLES_BATCH_SIZE
) {
const batch = correlationIds.slice(
index,
index + UNAVAILABLE_TITLES_BATCH_SIZE
)
const response =
await state.unavailableTitlesService.getUnavailableTitlesByCorrelationIds(
batch
)
for (const title of normalizeUnavailableTitlesResponse(response)) {
nextTitlesById[title.id] = title
}
}
state.unavailableTitlesById = nextTitlesById
state.log(
"info",
"Unavailable titles refreshed.",
`correlationIds=${correlationIds.length}, titles=${Object.keys(nextTitlesById).length}`
)
} catch (error) {
state.log(
"warn",
"Unavailable titles refresh failed.",
error?.stack || String(error)
)
} finally {
state.unavailableTitlesFetchPromise = null
}
}
function normalizeUnavailableTitlesResponse(value) {
const titles = Array.isArray(value)
? value
: Array.isArray(value?.data)
? value.data
: []
return titles.map(normalizeUnavailableTitle).filter(Boolean)
}
function normalizeUnavailableTitle(title) {
if (!isRecord(title)) {
return null
}
const titleId = toStringId(title.id ?? title.titleId)
if (!titleId) {
return null
}
const games = Array.isArray(title.games)
? title.games.map(normalizeUnavailableTitleGame).filter(Boolean)
: []
if (games.length === 0) {
return null
}
return {
...title,
id: titleId,
name: safeString(title.name, title.titleName, titleId),
games,
}
}
function normalizeUnavailableTitleGame(game) {
if (!isRecord(game)) {
return null
}
const gameId = toStringId(game.id ?? game.gameId)
const correlationIds = normalizeStringList(game.correlationIds)
if (!gameId || correlationIds.length === 0) {
return null
}
return {
...game,
id: gameId,
platformId: safeString(game.platformId, "unknown"),
correlationIds,
flags: typeof game.flags === "number" ? game.flags : 0,
name: safeString(game.name, game.titleName, game.title, gameId),
}
}
function getCatalogGameCorrelationIds(game, versions) {
const correlationIds = []
if (Array.isArray(game.correlationIds)) {
for (const correlationId of game.correlationIds) {
if (typeof correlationId === "string" && correlationId.trim()) {
correlationIds.push(correlationId.trim())
}
}
}
for (const version of versions) {
if (
typeof version?.correlationId === "string" &&
version.correlationId.trim()
) {
correlationIds.push(version.correlationId.trim())
}
}
return correlationIds
}
function pickPreferredInstalledApp(rawInstalledApps, correlationIds) {
const candidates = Array.from(new Set(correlationIds))
.map((correlationId) =>
toInstalledAppRecord(correlationId, rawInstalledApps[correlationId])
)
.filter(Boolean)
.sort(compareInstalledAppRecords)
return candidates[0] || null
}
function upsertSnapshotEntry(entriesByKey, entry) {
const key = getSnapshotEntryKey(entry)
const current = entriesByKey.get(key)
if (!current || compareSnapshotEntries(entry, current) < 0) {
entriesByKey.set(key, entry)
}
}
function getSnapshotEntryKey(entry) {
if (entry.titleId) {
return `${SNAPSHOT_ENTRY_KEY_PREFIX.TITLE}${entry.titleId}`
}
if (entry.gameId) {
return `${SNAPSHOT_ENTRY_KEY_PREFIX.GAME}${entry.gameId}`
}
return `${SNAPSHOT_ENTRY_KEY_PREFIX.APP}${entry.correlationId}`
}
function compareSnapshotEntries(left, right) {
const lastPlayedDiff =
(right.platformLastPlayedTimestamp ?? 0) -
(left.platformLastPlayedTimestamp ?? 0)
if (lastPlayedDiff !== 0) {
return lastPlayedDiff
}
const playtimeDiff =
(right.platformTotalPlaytimeMinutes ?? 0) -
(left.platformTotalPlaytimeMinutes ?? 0)
if (playtimeDiff !== 0) {
return playtimeDiff
}
return compareByIdentity(left, right)
}
function compareByIdentity(left, right) {
const displayNameDiff = left.displayName.localeCompare(right.displayName)
if (displayNameDiff !== 0) {
return displayNameDiff
}
const platformDiff = left.platform.localeCompare(right.platform)
if (platformDiff !== 0) {
return platformDiff
}
return left.sku.localeCompare(right.sku)
}
function pickImageUrlForTitle(title, game, preferredApp, sidebarClientIconUrl, versions) {
const steamRoots = versions !== undefined
? [title, game, versions, preferredApp]
: [title, game, preferredApp]
return pickImageUrl(
title?.imageUrl,
title?.iconUrl,
title?.coverUrl,
title?.thumbnailUrl,
title?.logoUrl,
title?.headerImageUrl,
getSteamClientIconUrl(
findSteamAppId(...steamRoots),
getInstalledAppSteamAppId(preferredApp.platform, preferredApp.sku)
),
sidebarClientIconUrl,
title?.images,
title?.assets,
game.imageUrl,
game.iconUrl,
game.coverUrl,
game.thumbnailUrl,
game.logoUrl,
game.headerImageUrl,
game.images,
game.assets,
preferredApp.imageUrl
)
}
@@ -0,0 +1,46 @@
import { LOG_FILE_NAME, LOG_PREFIX } from "./constants.js"
import { getRequire } from "./runtime.js"
export function createLogger(WandEnhancer) {
let filePath = null
try {
const require = getRequire()
const os = require?.("node:os")
const path = require?.("node:path")
if (os && path) {
filePath = path.join(os.tmpdir(), LOG_FILE_NAME)
globalThis.__wandInstalledAppsSyncLogFile = filePath
}
} catch (error) {}
return function log(level, message, detail) {
const method =
level === "error" ? "error" : level === "warn" ? "warn" : "info"
const line = `[${new Date().toISOString()}] [${level}] ${message}${detail ? ` :: ${detail}` : ""}`
try {
console[method](LOG_PREFIX, message, detail || "")
} catch (error) {}
try {
if (WandEnhancer?.log) {
WandEnhancer.log(`${LOG_PREFIX} ${message}`, detail || "")
}
} catch (error) {}
writeFile(filePath, line)
}
}
function writeFile(filePath, line) {
if (!filePath) {
return
}
try {
const require = getRequire()
const fs = require?.("node:fs")
fs?.appendFileSync(filePath, `${line}\n`)
} catch (error) {}
}
@@ -0,0 +1,309 @@
import {
COMMAND_RESPONSE_CHANNEL,
REMOTE_COMMAND_LAUNCH,
REMOTE_COMMAND_STOP,
REMOTE_COMMAND_TRIGGER,
REMOTE_STOP_EVENT,
} from "./constants.js"
import { clearTrainerSnapshot, syncGameStatus } from "./game-status.js"
import {
compareInstalledAppRecords,
getInstalledVersionsForGame,
resolveInstalledData,
toInstalledAppRecord,
} from "./installed-data.js"
import {
getPreferredLocale,
isRecord,
safeString,
toStringId,
} from "./runtime.js"
export function handleRemoteCommandRequest(state, _event, request) {
void (async () => {
let response
if (request?.action === REMOTE_COMMAND_LAUNCH) {
response = await executeRemoteLaunchCommand(state, request)
} else if (request?.action === REMOTE_COMMAND_STOP) {
response = await executeRemoteStopCommand(state, request)
} else {
response = buildCommandResponse(request, false, {
code: "invalid_command",
message: "Unknown remote command.",
})
}
await sendRemoteCommandResponse(state, response)
})()
}
function buildCommandResponse(request, ok, error = null) {
const response = {
requestId: safeString(request?.requestId),
ok,
action:
request?.action === REMOTE_COMMAND_STOP
? REMOTE_COMMAND_STOP
: REMOTE_COMMAND_LAUNCH,
gameId: toStringId(request?.gameId),
titleId: toStringId(request?.titleId),
}
if (!error) {
return response
}
return {
...response,
error,
}
}
async function executeRemoteLaunchCommand(state, request) {
const gameId = toStringId(request?.gameId)
if (!gameId) {
return buildCommandResponse(request, false, {
code: "invalid_game",
message: "A game id is required to launch a trainer.",
})
}
if (!state.resolveRemoteCommandServices()) {
return buildCommandResponse(request, false, {
code: "bridge_not_ready",
message: "The Wand renderer container is not ready yet.",
})
}
if (!state.trainerService) {
return buildCommandResponse(request, false, {
code: "trainer_service_missing",
message: "The Wand trainer service is not available yet.",
})
}
if (!state.trainerLaunchRequestCtor) {
return buildCommandResponse(request, false, {
code: "trainer_launch_missing",
message:
"The Wand trainer launch request constructor is not available yet.",
})
}
const data = resolveInstalledData(state)
if (!data) {
return buildCommandResponse(request, false, {
code: "installations_missing",
message: "Installed game data is not available yet.",
})
}
const launchInfo = getLaunchInfoForGame(gameId, data)
if (!isRecord(launchInfo.app)) {
return buildCommandResponse(request, false, {
code: "game_not_installed",
message: "Wand could not resolve a preferred installation for this game.",
})
}
const trainerInfo = await resolveTrainerInfoForGame(state, gameId, data)
if (!trainerInfo) {
return buildCommandResponse(request, false, {
code: "trainer_not_found",
message: "Wand could not find a compatible trainer for this game.",
})
}
try {
const launchRequest = new state.trainerLaunchRequestCtor(
trainerInfo,
launchInfo.app,
launchInfo.version,
REMOTE_COMMAND_TRIGGER
)
await state.trainerService.launch(launchRequest)
state.queueSync(true)
state.queueFollowUpSync()
void syncGameStatus(state, true)
return buildCommandResponse(request, true)
} catch (error) {
return buildCommandResponse(request, false, {
code: "launch_failed",
message:
error instanceof Error
? error.message
: "Failed to launch the trainer.",
})
}
}
async function executeRemoteStopCommand(state, request) {
if (!state.resolveRemoteCommandServices()) {
return buildCommandResponse(request, false, {
code: "bridge_not_ready",
message: "The Wand renderer container is not ready yet.",
})
}
if (
!state.trainerService ||
typeof state.trainerService.endTrainer !== "function"
) {
return buildCommandResponse(request, false, {
code: "trainer_service_missing",
message: "The Wand trainer service is not available yet.",
})
}
if (
!state.trainerService.trainer &&
state.currentRunningTrainer.state !== "running"
) {
return buildCommandResponse(request, false, {
code: "no_active_trainer",
message: "No trainer is running right now.",
})
}
try {
await state.trainerService.endTrainer()
clearTrainerSnapshot(state, REMOTE_STOP_EVENT, true)
return buildCommandResponse(request, true)
} catch (error) {
return buildCommandResponse(request, false, {
code: "stop_failed",
message:
error instanceof Error
? error.message
: "Failed to stop the running trainer.",
})
}
}
function getLaunchInfoForGame(gameId, data) {
const versions = Array.isArray(data?.installedGameVersions?.[gameId])
? data.installedGameVersions[gameId]
: []
const game = isRecord(data?.catalog?.games?.[gameId])
? data.catalog.games[gameId]
: null
const candidates = []
if (Array.isArray(game?.correlationIds)) {
for (const correlationId of game.correlationIds) {
if (typeof correlationId === "string" && correlationId.trim()) {
candidates.push({ correlationId: correlationId.trim(), version: null })
}
}
}
for (const versionEntry of versions) {
if (
typeof versionEntry?.correlationId === "string" &&
versionEntry.correlationId.trim()
) {
candidates.push({
correlationId: versionEntry.correlationId.trim(),
version: versionEntry.version ?? null,
})
}
}
const rankedCandidates = Array.from(
new Map(
candidates.map((candidate) => [candidate.correlationId, candidate])
).values()
)
.map((candidate) => normalizeLaunchCandidate(candidate, data))
.filter(Boolean)
.sort((left, right) =>
compareInstalledAppRecords(left.normalizedApp, right.normalizedApp)
)
if (!rankedCandidates[0]) {
return { app: null, version: null }
}
return {
app: rankedCandidates[0].app,
version: rankedCandidates[0].version,
}
}
async function resolveTrainerInfoForGame(state, gameId, data) {
if (!state.trainerApiService) {
return null
}
try {
const localTrainer = unwrapTrainerInfo(
await state.trainerApiService.getLatestLocalTrainerForGame(gameId)
)
if (localTrainer) {
return localTrainer
}
} catch (error) {
state.log(
"warn",
"Local trainer lookup failed.",
error?.stack || String(error)
)
}
try {
return unwrapTrainerInfo(
await state.trainerApiService.getMostCompatibleTrainerForGame(
gameId,
getPreferredLocale(),
getInstalledVersionsForGame(gameId, data),
false
)
)
} catch (error) {
state.log(
"warn",
"Compatible trainer lookup failed.",
error?.stack || String(error)
)
return null
}
}
function normalizeLaunchCandidate(candidate, data) {
const app = data?.rawInstalledApps?.[candidate.correlationId]
const normalizedApp = toInstalledAppRecord(candidate.correlationId, app)
if (!normalizedApp || !isRecord(app)) {
return null
}
return {
app,
version: candidate.version,
normalizedApp,
}
}
function unwrapTrainerInfo(value) {
if (isRecord(value?.trainer)) {
return value.trainer
}
return isRecord(value) ? value : null
}
async function sendRemoteCommandResponse(state, response) {
if (!state.ipcRenderer) {
return
}
try {
await state.ipcRenderer.invoke(COMMAND_RESPONSE_CHANNEL, response)
} catch (error) {
state.log(
"warn",
"Remote command response IPC failed.",
error?.stack || String(error)
)
}
}
@@ -0,0 +1,324 @@
export function isRecord(value) {
return typeof value === "object" && value !== null
}
export function getRequire() {
return (
globalThis.require ||
(typeof window !== "undefined" ? window.require : null)
)
}
export function safeString(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value.trim()
}
}
return ""
}
export function getWebpackRequire() {
const chunk = globalThis.webpackChunkWeMod
if (!Array.isArray(chunk)) {
return null
}
if (typeof chunk.__wandWebpackRequire === "function") {
return chunk.__wandWebpackRequire
}
let resolvedRequire = null
chunk.push([
[`wand-enhancer-${Date.now()}`],
{},
(webpackRequire) => {
resolvedRequire = webpackRequire
},
])
if (typeof resolvedRequire === "function") {
chunk.__wandWebpackRequire = resolvedRequire
}
return resolvedRequire
}
export function getAppRoot() {
return (
document.getElementById("root") ||
document.querySelector("[aurelia-app]") ||
document.querySelector("root")
)
}
export function hasAppRoot() {
return Boolean(getAppRoot())
}
export function getAureliaContainer() {
const root = getAppRoot()
const rootContainer = getContainerFromSubtree(root)
if (rootContainer) {
return rootContainer
}
const bodyContainer = getContainerFromElement(document.body)
if (bodyContainer) {
return bodyContainer
}
if (isRecord(globalThis.aurelia) && globalThis.aurelia.container) {
return globalThis.aurelia.container
}
return null
}
export function summarizeAureliaSubtree(root) {
if (!root) {
return "root=null"
}
let elementsWithAu = 0
let controllerEntries = 0
let namedAuEntries = 0
function inspectElement(element) {
if (!isRecord(element?.au)) {
return
}
elementsWithAu += 1
if (element.au.controller) {
controllerEntries += 1
}
for (const [key, value] of Object.entries(element.au)) {
if (key !== "controller" && isRecord(value)) {
namedAuEntries += 1
}
}
}
inspectElement(root)
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
let element = walker.nextNode()
while (element) {
inspectElement(element)
element = walker.nextNode()
}
return `elementsWithAu=${elementsWithAu}, controllerEntries=${controllerEntries}, namedAuEntries=${namedAuEntries}`
}
export function findExportedConstructor(webpackRequire, predicate) {
const cache = webpackRequire?.c
if (!cache || typeof cache !== "object") {
return null
}
for (const record of Object.values(cache)) {
const exports = record?.exports
const candidates = []
if (typeof exports === "function") {
candidates.push(exports)
} else if (isRecord(exports)) {
if (typeof exports.default === "function") {
candidates.push(exports.default)
}
for (const value of Object.values(exports)) {
if (typeof value === "function") {
candidates.push(value)
}
}
}
for (const candidate of candidates) {
if (candidate?.prototype && predicate(candidate.prototype)) {
return candidate
}
}
}
return null
}
export function findInstanceInContainerGraph(root, predicate, maxDepth = 4) {
if (!root) {
return null
}
const seen = new Set()
const queue = [{ value: root, depth: 0 }]
while (queue.length > 0) {
const current = queue.shift()
const value = current?.value
const depth = current?.depth ?? 0
if (!value || seen.has(value)) {
continue
}
seen.add(value)
try {
if (predicate(value)) {
return value
}
} catch (error) {}
if (depth >= maxDepth) {
continue
}
enqueueNestedValues(queue, seen, value, depth + 1)
}
return null
}
export function getBasename(location) {
if (typeof location !== "string" || !location.trim()) {
return ""
}
const normalized = location.replace(/\\/g, "/").replace(/\/+$/, "")
const leaf = normalized.split("/").filter(Boolean).pop()
return leaf ? leaf.trim() : ""
}
export function toStringId(value) {
if (typeof value === "string" && value.trim()) {
return value.trim()
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value)
}
return null
}
export function normalizeStringList(value) {
if (!Array.isArray(value)) {
return []
}
return value
.filter((entry) => typeof entry === "string" && entry.trim())
.map((entry) => entry.trim())
}
export function getPreferredLocale() {
return safeString(
document.documentElement?.lang,
Array.isArray(globalThis.navigator?.languages)
? globalThis.navigator.languages.find(
(entry) => typeof entry === "string" && entry.trim()
)
: "",
globalThis.navigator?.language,
"en-US"
)
}
function getContainerFromAu(au) {
if (!isRecord(au)) {
return null
}
if (au.container) {
return au.container
}
const directControllerContainer =
au.controller?.container || au.controller?.viewModel?.container
if (directControllerContainer) {
return directControllerContainer
}
for (const value of Object.values(au)) {
if (!isRecord(value)) {
continue
}
const container =
value.container ||
value.controller?.container ||
value.viewModel?.container ||
value.controller?.viewModel?.container
if (container) {
return container
}
}
return null
}
function getContainerFromElement(element) {
if (!element) {
return null
}
if (element.__aurelia__?.container) {
return element.__aurelia__.container
}
return getContainerFromAu(element.au)
}
function getContainerFromSubtree(root) {
if (!root) {
return null
}
const rootContainer = getContainerFromElement(root)
if (rootContainer) {
return rootContainer
}
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
let element = walker.nextNode()
while (element) {
const container = getContainerFromElement(element)
if (container) {
return container
}
element = walker.nextNode()
}
return null
}
function enqueueNestedValues(queue, seen, value, depth) {
if (value instanceof Map) {
enqueueIterable(queue, seen, value.values(), depth)
return
}
if (value instanceof Set || Array.isArray(value)) {
enqueueIterable(queue, seen, value.values(), depth)
return
}
if (!isRecord(value) && typeof value !== "function") {
return
}
enqueueIterable(queue, seen, Object.values(value), depth)
}
function enqueueIterable(queue, seen, values, depth) {
for (const entry of values) {
if ((isRecord(entry) || typeof entry === "function") && !seen.has(entry)) {
queue.push({ value: entry, depth })
}
}
}
@@ -0,0 +1,254 @@
import { TRAINER_LAUNCH_REQUEST_EXPORT_KEY } from "./constants.js"
import {
findExportedConstructor,
findInstanceInContainerGraph,
isRecord,
} from "./runtime.js"
export function hasMissingOptionalServices(state) {
return (
!state.gameLifecycleService ||
!state.trainerVisibilityService ||
!state.unavailableTitlesService
)
}
const OPTIONAL_SERVICE_SPECS = [
{
stateKey: "unavailableTitlesService",
methods: ["getUnavailableTitle", "searchUnavailableTitles", "getUnavailableTitlesByCorrelationIds"],
label: "Unavailable titles service",
},
{
stateKey: "gameLifecycleService",
methods: ["onGameLaunched", "onGameEnded", "launch"],
label: "Game lifecycle service",
},
{
stateKey: "trainerVisibilityService",
methods: ["onDisplayTrainerChanged", "onVisibleTrainerChanged", "onRunningTrainerChanged"],
label: "Trainer visibility service",
},
{
stateKey: "trainerApiService",
methods: ["getLatestLocalTrainerForGame", "getMostCompatibleTrainerForGame", "getTrainerById"],
label: "Trainer API service",
},
{
stateKey: "trainerService",
methods: ["launch", "endTrainer", "onNewTrainer", "onTrainerEnded"],
label: "Trainer service",
},
]
export function resolveOptionalServices(state, container, webpackRequire) {
for (const spec of OPTIONAL_SERVICE_SPECS) {
if (!state[spec.stateKey]) {
state[spec.stateKey] = resolveOptionalService(state, container, webpackRequire, spec)
}
}
if (!state.trainerLaunchRequestCtor) {
state.trainerLaunchRequestCtor = getTrainerLaunchRequestCtor(state, webpackRequire)
}
}
export function getInstalledAppsService(state, container, webpackRequire) {
const ctor = findExportedConstructor(webpackRequire, (prototype) => {
return (
typeof prototype.refreshApps === "function" &&
typeof prototype.watchGame === "function"
)
})
if (!ctor) {
state.log(
"warn",
"Installed apps service constructor not found in webpack cache."
)
return null
}
try {
const service = container.get(ctor)
const appsCount = isRecord(service.installedApps)
? Object.keys(service.installedApps).length
: -1
const catalogGamesCount = isRecord(service.catalog?.games)
? Object.keys(service.catalog.games).length
: -1
state.log(
"info",
"Installed apps service resolved.",
`ctor=${ctor.name || "<anon>"}, installedApps=${appsCount}, catalogGames=${catalogGamesCount}`
)
return service
} catch (error) {
state.log(
"warn",
"Failed to resolve installed apps service from Aurelia container.",
error?.stack || String(error)
)
return null
}
}
export function getStoreRef(state, container, webpackRequire) {
const storeCtor = findExportedConstructor(webpackRequire, (prototype) => {
return (
typeof prototype.dispatch === "function" &&
typeof prototype.registerAction === "function" &&
typeof prototype.unregisterAction === "function"
)
})
if (!storeCtor) {
state.log("warn", "Store constructor not found in webpack cache.")
return null
}
try {
const store = container.get(storeCtor)
const storeState =
typeof store.state?.getValue === "function"
? store.state.getValue()
: null
const installedAppsCount = isRecord(storeState?.installedApps)
? Object.keys(storeState.installedApps).length
: -1
const catalogGamesCount = isRecord(storeState?.catalog?.games)
? Object.keys(storeState.catalog.games).length
: -1
state.log(
"info",
"Store resolved via fallback.",
`ctor=${storeCtor.name || "<anon>"}, installedApps=${installedAppsCount}, catalogGames=${catalogGamesCount}`
)
return store
} catch (error) {
state.log(
"warn",
"Failed to resolve Store from container.",
error?.stack || String(error)
)
return null
}
}
function resolveOptionalService(state, container, webpackRequire, spec) {
const matchesMethods = (target) => hasAllMethods(target, spec.methods)
const ctor = findExportedConstructor(webpackRequire, matchesMethods)
if (ctor) {
return getContainerService(state, container, ctor, spec.stateKey, spec.label)
}
return findFallbackService(
state,
container,
spec.stateKey,
`${spec.label} constructor not found in webpack cache.`,
matchesMethods
)
}
function hasAllMethods(target, methods) {
if (!target) {
return false
}
for (const method of methods) {
if (typeof target[method] !== "function") {
return false
}
}
return true
}
function getTrainerLaunchRequestCtor(state, webpackRequire) {
const cache = webpackRequire?.c
if (!cache || typeof cache !== "object") {
warnMissingOptionalService(
state,
"trainerLaunchRequestCtor",
"Trainer launch request constructor cache is unavailable."
)
return null
}
for (const record of Object.values(cache)) {
const exports = record?.exports
if (!isRecord(exports)) {
continue
}
const candidate = exports[TRAINER_LAUNCH_REQUEST_EXPORT_KEY]
if (
typeof candidate === "function" &&
typeof exports.ZS === "function" &&
typeof exports.jR === "function" &&
typeof exports.UY === "function"
) {
clearMissingOptionalServiceWarning(state, "trainerLaunchRequestCtor")
return candidate
}
}
warnMissingOptionalService(
state,
"trainerLaunchRequestCtor",
"Trainer launch request constructor not found in webpack cache."
)
return null
}
function getContainerService(state, container, ctor, warningKey, label) {
try {
const service = container.get(ctor)
clearMissingOptionalServiceWarning(state, warningKey)
state.log(
"info",
`${label} resolved.`,
`ctor=${ctor.name || "<anon>"}${service?.runningTrainer ? ", running=yes" : ""}`
)
return service
} catch (error) {
state.log(
"warn",
`Failed to resolve ${label.toLowerCase()} from Aurelia container.`,
error?.stack || String(error)
)
return null
}
}
function findFallbackService(
state,
container,
warningKey,
missingMessage,
predicate
) {
const fallbackService = findInstanceInContainerGraph(container, predicate)
if (fallbackService) {
clearMissingOptionalServiceWarning(state, warningKey)
state.log("info", `${warningKey} resolved from container graph.`)
return fallbackService
}
warnMissingOptionalService(state, warningKey, missingMessage)
return null
}
function warnMissingOptionalService(state, key, message) {
if (state.missingOptionalServiceWarnings.has(key)) {
return
}
state.missingOptionalServiceWarnings.add(key)
state.log("warn", message)
}
function clearMissingOptionalServiceWarning(state, key) {
state.missingOptionalServiceWarnings.delete(key)
}
@@ -1,12 +1,12 @@
(function installRemotePopupCleanup(WandEnhancer) {
;(function installRemotePopupCleanup(WandEnhancer) {
if (globalThis.__wandRemotePopupCleanupInstalled) {
return;
return
}
globalThis.__wandRemotePopupCleanupInstalled = true;
globalThis.__wandRemotePopupCleanupInstalled = true
const style = document.createElement('style');
style.id = 'wand-remote-popup-cleanup-style';
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,
@@ -59,36 +59,37 @@
border-radius: 12px !important;
transform: none !important;
}
`;
`
const installStyle = () => {
if (!document.getElementById(style.id)) {
document.head.appendChild(style);
document.head.appendChild(style)
}
};
}
const updateLinks = () => {
const remoteUrl = globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl;
const remoteUrl =
globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl
if (!remoteUrl) {
return;
return
}
for (const anchor of document.querySelectorAll('remote-tooltip a[href]')) {
anchor.setAttribute('href', remoteUrl);
anchor.textContent = remoteUrl.replace(/\/$/, '');
for (const anchor of document.querySelectorAll("remote-tooltip a[href]")) {
anchor.setAttribute("href", remoteUrl)
anchor.textContent = remoteUrl.replace(/\/$/, "")
}
};
}
installStyle();
updateLinks();
installStyle()
updateLinks()
const observer = new MutationObserver(() => {
installStyle();
updateLinks();
});
installStyle()
updateLinks()
})
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
})(globalThis.WandEnhancer);
})
})(globalThis.WandEnhancer)
+31
View File
@@ -0,0 +1,31 @@
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./bridge-modules/runtime.cjs');
const { installWandRuntime: installRuntime } = require('./bridge-modules/wand-runtime.cjs');
function withDefaultPanelRoot(options = {}) {
if (options.panelRoot) {
return options;
}
return {
...options,
panelRoot: __dirname,
};
}
function createBridgeRuntime(options = {}) {
return createRuntime(withDefaultPanelRoot(options));
}
function ensureBridge(options = {}) {
return ensureRuntime(withDefaultPanelRoot(options));
}
function installWandRuntime(electron, options = {}) {
return installRuntime(electron, withDefaultPanelRoot(options));
}
module.exports = {
createBridgeRuntime,
ensureBridge,
installWandRuntime,
};
-896
View File
@@ -1,896 +0,0 @@
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,
};
+30 -9
View File
@@ -50,15 +50,33 @@
"uuid": "number-money",
"target": "player_money",
"type": "number",
"name": "Money",
"description": "Set the current money amount.",
"name": "Runes",
"description": "Set the current rune amount.",
"instructions": null,
"category": "inventory",
"parent": null,
"args": {
"min": 0,
"max": 999999,
"step": 100
"max": 9999999,
"step": 1000
}
},
{
"uuid": "selection-spawn-item",
"target": "spawn_item",
"type": "selection",
"name": "Spawn Item",
"description": "Choose the item to spawn.",
"instructions": null,
"category": "inventory",
"parent": null,
"args": {
"options": [
{ "label": "Erdtree Greatshield", "value": "erdtree_greatshield" },
{ "label": "Rivers of Blood", "value": "rivers_of_blood" },
{ "label": "Moonveil Katana", "value": "moonveil_katana" },
{ "label": "Blasphemous Blade", "value": "blasphemous_blade" }
]
}
},
{
@@ -93,15 +111,17 @@
"uuid": "scalar-speed",
"target": "game_speed",
"type": "scalar",
"name": "Game Speed",
"description": "Scalar-style preset selector.",
"name": "Time Scale",
"description": "Tune simulation speed in real time.",
"instructions": null,
"category": "world",
"parent": null,
"args": {
"min": 0,
"max": 5,
"step": 0.01,
"postfix": "x",
"default": 1,
"options": [0.5, 1, 1.5, 2]
"default": 1
}
},
{
@@ -130,7 +150,8 @@
"values": {
"god_mode": false,
"player_health": 83,
"player_money": 15000,
"player_money": 2400000,
"spawn_item": "erdtree_greatshield",
"restock_ammo": 0,
"difficulty": "normal",
"game_speed": 1,
+4 -2
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"dev:host": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit && vite build && pnpm run build:bridge",
"build:bridge": "node ./bridge/build.mjs",
"preview": "vite preview",
"preview:host": "vite preview --host 0.0.0.0",
"bridge": "node ./bridge/server.mjs"
@@ -22,6 +23,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"esbuild": "0.27.7",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
@@ -32,4 +34,4 @@
"typescript": "^5.9.3",
"vite": "^7.3.2"
}
}
}
+3
View File
@@ -33,6 +33,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^5.2.0
version: 5.2.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))
esbuild:
specifier: 0.27.7
version: 0.27.7
eslint:
specifier: ^9.39.4
version: 9.39.4(jiti@2.6.1)
+4 -4
View File
@@ -2,14 +2,14 @@
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.
During patching, Wand Enhancer copies generated default scripts from `web-panel/dist/renderer-scripts` and user scripts from this folder into `remote-panel/renderer-scripts` inside `app.asar`. Default script sources live under `web-panel/bridge/scripts/default` and are generated by `pnpm run build:bridge`. 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);
;(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.
+239 -119
View File
@@ -1,29 +1,44 @@
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';
import { useEffect, useMemo, useReducer, useRef, useState, type UIEvent } from 'react';
export function App() {
import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from '@/features/remote-panel/category';
import { CategorySection } from '@/features/remote-panel/components/CategorySection';
import { Drawer } from '@/features/remote-panel/components/Drawer';
import { FloatingDock } from '@/features/remote-panel/components/FloatingDock';
import { LibraryDrawer } from '@/features/remote-panel/components/LibraryDrawer';
import { PlaceholderState } from '@/features/remote-panel/components/PlaceholderState';
import { QuickActions } from '@/features/remote-panel/components/QuickActions';
import { SearchInput } from '@/features/remote-panel/components/SearchInput';
import { SettingsDrawer } from '@/features/remote-panel/components/SettingsDrawer';
import { TopBar } from '@/features/remote-panel/components/TopBar';
import { TrainerHeader } from '@/features/remote-panel/components/TrainerHeader';
import { buildLibraryGames, getCurrentGame, type LibraryGame } from '@/features/remote-panel/game-library';
import { loadPinnedGameIds, savePinnedGameIds, togglePinnedGame } from '@/features/remote-panel/game-pin-storage';
import { handleProtocolMessage } from '@/features/remote-panel/message-handler';
import { getPinnedStorageKey, loadPinnedTargets, savePinnedTargets } from '@/features/remote-panel/pinned-storage';
import { capturePresetValues, createPreset, getPresetStorageKey, loadPresets, savePresets, type RemotePreset } from '@/features/remote-panel/preset-storage';
import { normalizeOutgoingValue, type CheatSchema, type InstalledAppSummary, type TrainerMetaPayload } from '@/features/remote-panel/protocol';
import { ECheatType } from '@/features/remote-panel/protocol';
import { PanelSocketClient } from '@/features/remote-panel/socket-client';
import { createInitialPanelState, EConnectionStatus, panelReducer } from '@/features/remote-panel/state';
const SCROLL_HIDE_THRESHOLD_PX = 60;
const SCROLL_REVEAL_DEAD_ZONE_PX = 4;
export const App = () => {
const [state, dispatch] = useReducer(panelReducer, createInitialPanelState());
const [searchQuery, setSearchQuery] = useState('');
const [cheatQuery, setCheatQuery] = useState('');
const [gameQuery, setGameQuery] = useState('');
const [leftOpen, setLeftOpen] = useState(false);
const [rightOpen, setRightOpen] = useState(false);
const [hideDock, setHideDock] = useState(false);
const [pinnedGameIds, setPinnedGameIds] = useState<Record<string, true>>({});
const [presets, setPresets] = useState<RemotePreset[]>([]);
const lastScrollRef = useRef(0);
const clientRef = useRef<PanelSocketClient | null>(null);
const trainerMetaRef = useRef<TrainerMetaPayload | null>(state.trainerMeta);
useEffect(() => {
setPinnedGameIds(loadPinnedGameIds());
return () => {
clientRef.current?.disconnect();
clientRef.current = null;
@@ -34,29 +49,41 @@ export function App() {
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 libraryGames = useMemo(
() => buildLibraryGames(state.installedApps, state.gameStatus, activeTrainer, pinnedGameIds),
[activeTrainer, pinnedGameIds, state.gameStatus, state.installedApps],
);
const currentGame = useMemo(() => getCurrentGame(libraryGames), [libraryGames]);
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, cheatQuery), [cheatQuery, groups]);
const filteredPinnedGroup = useMemo(
() => (pinnedGroup ? filterGroups([pinnedGroup], cheatQuery)[0] ?? null : null),
[cheatQuery, pinnedGroup],
);
const pinnedStorageKey = useMemo(() => getPinnedStorageKey(activeTrainer), [activeTrainer]);
const presetStorageKey = useMemo(() => getPresetStorageKey(activeTrainer), [activeTrainer]);
const socketReady = clientRef.current?.isOpen() ?? false;
const connected = state.connectionStatus === EConnectionStatus.Connected;
const controlsDisabled = Boolean(activeTrainer?.trainerLoading || activeTrainer?.isTimeLimitExpired);
const totalVisibleCheats = filteredGroups.reduce((count, group) => count + group.cheats.length, filteredPinnedGroup?.cheats.length ?? 0);
useEffect(() => {
dispatch({ type: 'setPinnedTargets', pinned: loadPinnedTargets(pinnedStorageKey) });
}, [pinnedStorageKey]);
function connect(): void {
useEffect(() => {
setPresets(loadPresets(presetStorageKey));
}, [presetStorageKey]);
useEffect(() => {
if (state.wsUrl.trim()) {
handleConnect();
}
}, []);
function handleConnect(): void {
clientRef.current?.disconnect();
const wsUrl = state.wsUrl.trim();
@@ -77,12 +104,18 @@ export function App() {
nextClient.connect();
}
function handleDisconnect(): void {
clientRef.current?.disconnect();
clientRef.current = null;
dispatch({ type: 'disconnected' });
}
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) {
if (state.connectionStatus !== EConnectionStatus.Connected || !state.trainerMeta || !clientRef.current) {
dispatch({ type: 'setPending', target: cheat.target, pending: false });
return;
}
@@ -94,128 +127,215 @@ export function App() {
}
}
function handleTogglePin(cheat: CheatSchema): void {
function handleToggleCheatPin(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);
function handleToggleGamePin(game: LibraryGame): void {
const next = togglePinnedGame(game, pinnedGameIds);
setPinnedGameIds(next);
savePinnedGameIds(next);
}
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();
}
});
function handleLaunchGame(app: InstalledAppSummary): void {
const client = clientRef.current;
if (!app.gameId) {
dispatch({ type: 'error', message: 'This My Games entry does not expose a Wand game id.' });
return;
}
if (!state.wsUrl.trim()) {
if (!client?.isOpen()) {
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
return;
}
connect();
}, []);
if (!client.launchGame(app.gameId, app.titleId ?? undefined)) {
dispatch({ type: 'error', message: 'Failed to send the launch command to the bridge.' });
return;
}
setRightOpen(false);
}
function handlePlayGame(game: LibraryGame): void {
handleLaunchGame(game.app);
}
function handleStopPlaying(): void {
const client = clientRef.current;
if (!client?.isOpen()) {
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
return;
}
const activeGameId = state.gameStatus?.session.gameId ?? state.gameStatus?.trainer.gameId ?? undefined;
const activeTitleId = state.gameStatus?.session.titleId ?? state.gameStatus?.trainer.titleId ?? undefined;
if (!client.stopPlaying(activeGameId ?? undefined, activeTitleId ?? undefined)) {
dispatch({ type: 'error', message: 'Failed to send the stop command to the bridge.' });
}
}
function handlePanic(): void {
if (!state.trainerMeta) {
return;
}
for (const cheat of state.trainerMeta.schema.cheats) {
if (cheat.type === ECheatType.Toggle && Boolean(state.values[cheat.target])) {
handleCheatChange(cheat, false);
}
}
}
function handleAddPreset(name: string): boolean {
if (!state.trainerMeta) {
dispatch({ type: 'error', message: 'No active trainer to save as a preset.' });
return false;
}
const values = capturePresetValues(state.trainerMeta.schema.cheats, state.values);
if (Object.keys(values).length === 0) {
dispatch({ type: 'error', message: 'There are no mod values to save yet.' });
return false;
}
const nextPresets = [...presets, createPreset(name, values)];
setPresets(nextPresets);
savePresets(presetStorageKey, nextPresets);
return true;
}
function handleApplyPreset(preset: RemotePreset): void {
if (!state.trainerMeta) {
return;
}
for (const cheat of state.trainerMeta.schema.cheats) {
if (!(cheat.target in preset.values)) {
continue;
}
handleCheatChange(cheat, preset.values[cheat.target]);
}
}
function handleDeletePreset(presetId: string): void {
const nextPresets = presets.filter((preset) => preset.id !== presetId);
setPresets(nextPresets);
savePresets(presetStorageKey, nextPresets);
}
function handleScroll(event: UIEvent<HTMLDivElement>): void {
const y = event.currentTarget.scrollTop;
if (y > lastScrollRef.current && y > SCROLL_HIDE_THRESHOLD_PX) {
setHideDock(true);
} else if (y < lastScrollRef.current - SCROLL_REVEAL_DEAD_ZONE_PX) {
setHideDock(false);
}
lastScrollRef.current = y;
}
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">
<main className="min-h-svh bg-[#050608] text-(--deck-fg)">
<div className="flex min-h-svh w-full p-0">
<section className="relative h-svh w-full overflow-hidden bg-(--deck-bg) shadow-[0_40px_100px_-20px_rgba(0,0,0,.7),0_0_0_1px_rgba(255,255,255,.06)]">
<div className="pointer-events-none absolute -inset-12 z-0 bg-[radial-gradient(circle_at_30%_15%,color-mix(in_oklab,var(--deck-accent)_22%,transparent),transparent_45%),radial-gradient(circle_at_80%_85%,color-mix(in_oklab,var(--deck-accent)_16%,transparent),transparent_45%),radial-gradient(circle_at_20%_80%,color-mix(in_oklab,var(--deck-accent)_8%,transparent),transparent_50%)] blur-[50px]" />
<div className="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(ellipse_100%_60%_at_50%_0%,rgba(255,255,255,0.025),transparent)]" />
<div className="relative z-10 flex h-full flex-col">
<TopBar status={state.connectionStatus} currentGame={currentGame} runningTrainer={activeTrainer} onOpenSettings={() => setLeftOpen(true)} />
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 pb-[110px]" onScroll={handleScroll}>
{!connected ? (
<PlaceholderState icon="plug" title="Bridge offline" sub="Open Settings to point Wand at your trainer bridge over WebSocket." action="Open Settings" onAction={() => setLeftOpen(true)} />
) : !activeTrainer ? (
<PlaceholderState icon="gamepad-variant-outline" title="Select a game" sub="No game is running yet. Open the library and launch one to start tweaking." action="Browse library" onAction={() => setRightOpen(true)} />
) : (
<>
<TrainerHeader trainer={activeTrainer} game={currentGame} isPinned={Boolean(currentGame && pinnedGameIds[currentGame.id])} onPin={() => currentGame && handleToggleGamePin(currentGame)} />
<QuickActions presets={presets} onAddPreset={handleAddPreset} onApplyPreset={handleApplyPreset} onDeletePreset={handleDeletePreset} onPanic={handlePanic} />
<div className="sticky top-0 z-10 -mx-3.5 mb-2.5 px-3.5 py-0.5">
<SearchInput value={cheatQuery} placeholder="Search mods" onChange={setCheatQuery} />
</div>
{filteredPinnedGroup ? (
<CategorySection
key="__pinned__"
forceOpen={Boolean(cheatQuery)}
group={filteredPinnedGroup}
values={state.values}
pendingTargets={state.pendingTargets}
pinnedTargets={state.pinnedTargets}
disabled={controlsDisabled}
onCheatChange={handleCheatChange}
onTogglePin={handleTogglePin}
onTogglePin={handleToggleCheatPin}
/>
) : null}
{filteredGroups.map((group) => (
{filteredGroups.map((group, index) => (
<CategorySection
key={group.id}
forceOpen={Boolean(cheatQuery)}
group={group}
openByDefault={index < 2}
values={state.values}
pendingTargets={state.pendingTargets}
pinnedTargets={state.pinnedTargets}
disabled={controlsDisabled}
onCheatChange={handleCheatChange}
onTogglePin={handleTogglePin}
onTogglePin={handleToggleCheatPin}
/>
))}
{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>
{cheatQuery && totalVisibleCheats === 0 ? <p className="px-8 py-8 text-center text-[13px] text-(--deck-fg-4)">No mods match "{cheatQuery}"</p> : null}
<div className="mt-4 text-center font-mono text-[10px] uppercase tracking-[0.08em] text-(--deck-fg-4)">
{cheatQuery ? `${totalVisibleCheats} matches` : `END · ${state.trainerMeta?.schema.cheats.length ?? 0} MODS`}
</div>
</>
)}
</div>
</div>
<FloatingDock
status={state.connectionStatus}
runningGameTitle={currentGame?.title ?? null}
hidden={hideDock}
leftHasBadge={!connected}
rightHasBadge={connected && !currentGame}
onOpenSettings={() => setLeftOpen(true)}
onOpenLibrary={() => setRightOpen(true)}
/>
<Drawer open={leftOpen} side="left" onClose={() => setLeftOpen(false)}>
<SettingsDrawer
status={state.connectionStatus}
wsUrl={state.wsUrl}
currentGame={currentGame}
currentTrainer={activeTrainer}
lastError={state.lastError}
onClose={() => setLeftOpen(false)}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onWsUrlChange={(wsUrl) => dispatch({ type: 'setWsUrl', wsUrl })}
/>
</Drawer>
<Drawer open={rightOpen} side="right" onClose={() => setRightOpen(false)}>
<LibraryDrawer
games={libraryGames}
query={gameQuery}
canLaunch={socketReady}
onClose={() => setRightOpen(false)}
onPin={handleToggleGamePin}
onPlay={handlePlayGame}
onStop={handleStopPlaying}
onQueryChange={setGameQuery}
/>
</Drawer>
</section>
</div>
</main>
);
}
};
-29
View File
@@ -1,29 +0,0 @@
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
@@ -1,36 +0,0 @@
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
@@ -1,70 +0,0 @@
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,
}
+27 -21
View File
@@ -1,76 +1,82 @@
import type { ReactNode, SVGProps } from 'react';
export type IconName =
| 'activity'
| 'alert'
| 'arrow-right'
| 'atom'
| 'backpack'
| 'bolt'
| 'box'
| 'boxes'
| 'car'
| 'category'
| 'chart'
| 'chevron-down'
| 'chevron-left'
| 'chevron-right'
| 'dot'
| 'flame'
| 'flask'
| 'gamepad'
| 'gamepad-variant-outline'
| 'hammer'
| 'heart-broken'
| 'list'
| 'loader'
| 'map-pin'
| 'menu'
| 'minus'
| 'package'
| 'pin'
| 'pin-off'
| 'plug'
| 'play'
| 'radar'
| 'refresh'
| 'shield-bolt'
| 'sparkles'
| 'plus'
| 'search'
| 'settings'
| 'sparkles'
| 'star'
| 'star-filled'
| 'stop'
| '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" /></>,
'arrow-right': <><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></>,
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-down': <path d="m6 9 6 6 6-6" />,
'chevron-left': <path d="m15 6-6 6 6 6" />,
'chevron-right': <path d="m9 6 6 6-6 6" />,
dot: <circle cx="12" cy="12" r="4" fill="currentColor" stroke="none" />,
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" /></>,
'gamepad-variant-outline': <path fill="currentColor" stroke="none" d="M6,9H8V11H10V13H8V15H6V13H4V11H6V9M18.5,9A1.5,1.5 0 0,1 20,10.5A1.5,1.5 0 0,1 18.5,12A1.5,1.5 0 0,1 17,10.5A1.5,1.5 0 0,1 18.5,9M15.5,12A1.5,1.5 0 0,1 17,13.5A1.5,1.5 0 0,1 15.5,15A1.5,1.5 0 0,1 14,13.5A1.5,1.5 0 0,1 15.5,12M17,5A7,7 0 0,1 24,12A7,7 0 0,1 17,19C15.04,19 13.27,18.2 12,16.9C10.73,18.2 8.96,19 7,19A7,7 0 0,1 0,12A7,7 0 0,1 7,5H17M7,7A5,5 0 0,0 2,12A5,5 0 0,0 7,17C8.64,17 10.09,16.21 11,15H13C13.91,16.21 15.36,17 17,17A5,5 0 0,0 22,12A5,5 0 0,0 17,7H7Z" />,
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" />,
list: <><path d="M4 6h16" /><path d="M4 12h16" /><path d="M4 18h10" /><circle cx="3" cy="6" r=".6" fill="currentColor" /><circle cx="3" cy="12" r=".6" fill="currentColor" /><circle cx="3" cy="18" r=".6" fill="currentColor" /></>,
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" /></>,
menu: <><path d="M4 7h12" /><path d="M4 12h16" /><path d="M4 17h8" /></>,
minus: <path d="M5 12h14" />,
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" /></>,
plus: <><path d="M12 5v14" /><path d="M5 12h14" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" /></>,
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" /></>,
star: <path d="m12 3 2.7 5.5 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" />,
'star-filled': <path fill="currentColor" stroke="none" d="m12 3 2.7 5.5 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" />,
stop: <rect x="6" y="6" width="12" height="12" rx="1.5" fill="currentColor" stroke="none" />,
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" /></>,
};
@@ -96,4 +102,4 @@ export function Icon({ name, className, stroke = 1.8, ...props }: IconProps) {
{ICON_PATHS[name]}
</svg>
);
}
}
-19
View File
@@ -1,19 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,40 +0,0 @@
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
@@ -1,46 +0,0 @@
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,42 @@
import { loadJson, saveJson } from './storage';
export const DEFAULT_ACCENT_COLOR = '#00ffd5';
const ACCENT_COLOR_STORAGE_KEY = 'wand-remote.accent-color.v1';
const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
export function applySavedAccentColor(): string {
return applyAccentColor(loadAccentColor());
}
export function loadAccentColor(): string {
return loadJson<string>(ACCENT_COLOR_STORAGE_KEY, reviveAccentColor, DEFAULT_ACCENT_COLOR);
}
export function setAccentColor(value: string): string {
const nextColor = normalizeAccentColor(value) ?? DEFAULT_ACCENT_COLOR;
applyAccentColor(nextColor);
saveJson(ACCENT_COLOR_STORAGE_KEY, nextColor, (storedValue) => storedValue === DEFAULT_ACCENT_COLOR);
return nextColor;
}
function applyAccentColor(value: string): string {
if (typeof document !== 'undefined') {
document.documentElement.style.setProperty('--deck-accent', value);
}
return value;
}
function reviveAccentColor(raw: unknown): string | null {
return normalizeAccentColor(raw);
}
function normalizeAccentColor(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const normalizedValue = value.trim().toLowerCase();
return HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : null;
}
@@ -1,4 +1,5 @@
import { Icon, type IconName } from '@/components/ui/icon';
import { formatHumanLabel } from '@/lib/utils';
import type { CheatSchema, TrainerMetaPayload, TrainerSummary } from './protocol';
const CATEGORY_LABELS: Record<string, string> = {
@@ -41,19 +42,6 @@ const CATEGORY_ICONS: Record<string, IconName> = {
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;
@@ -61,16 +49,7 @@ export type CategoryGroup = {
};
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());
return CATEGORY_LABELS[category.toLowerCase()] ?? formatHumanLabel(category);
}
export function groupCheatsByCategory(trainerMeta: TrainerMetaPayload | null): CategoryGroup[] {
@@ -146,10 +125,6 @@ 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} />;
}
@@ -1,7 +1,11 @@
import { Badge } from '@/components/ui/badge';
import { useEffect, useMemo, useState } from 'react';
import { Icon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { CategoryIcon, type CategoryGroup, getCategoryAccent } from '../category';
import { CategoryIcon, type CategoryGroup } from '../category';
import type { CheatSchema } from '../protocol';
import { ECheatType } from '../protocol';
import { CheatTile } from './CheatTile';
type CategorySectionProps = {
@@ -10,38 +14,49 @@ type CategorySectionProps = {
pendingTargets: Record<string, boolean>;
pinnedTargets: Record<string, true>;
disabled: boolean;
openByDefault?: boolean;
forceOpen?: boolean;
onCheatChange: (cheat: CheatSchema, nextValue: unknown) => void;
onTogglePin: (cheat: CheatSchema) => void;
};
export function CategorySection({
export const CategorySection = ({
group,
values,
pendingTargets,
pinnedTargets,
disabled,
openByDefault = true,
forceOpen = false,
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>
}: CategorySectionProps) => {
const [open, setOpen] = useState(openByDefault);
const enabledCount = useMemo(() => getEnabledToggleCount(group.cheats, values), [group.cheats, values]);
const toggleCount = useMemo(() => getToggleCount(group.cheats), [group.cheats]);
const handleToggle = () => setOpen((current) => !current);
<div className="grid gap-2 sm:gap-3 lg:grid-cols-2 xl:grid-cols-3">
{group.cheats.map((cheat) => (
useEffect(() => {
if (forceOpen) {
setOpen(true);
}
}, [forceOpen]);
return (
<section className="mb-2.5 overflow-hidden rounded-[14px] border border-white/10 bg-white/[0.035] shadow-[inset_0_1px_0_rgba(255,255,255,.05)] backdrop-blur-2xl">
<button type="button" className="flex w-full items-center gap-2.5 px-3.5 py-3 text-left text-(--deck-fg)" onClick={handleToggle}>
<span className="flex size-[30px] shrink-0 items-center justify-center rounded-[8px] border border-[color-mix(in_oklab,var(--deck-accent)_22%,transparent)] bg-white/[0.04] text-(--deck-accent)">
<CategoryIcon category={group.id} className="size-[15px]" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">{group.label}</span>
<span className="mt-0.5 block font-mono text-[10.5px] text-(--deck-fg-4)">{formatSummary(group.cheats.length, enabledCount, toggleCount)}</span>
</span>
{enabledCount > 0 ? <span className="inline-flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-full bg-(--deck-accent) px-1.5 text-center font-mono text-[10px] font-bold leading-none tabular-nums text-black">{enabledCount}</span> : null}
<Icon className={cn('size-4 text-(--deck-fg-3) transition-transform', open ? 'rotate-0' : '-rotate-90')} name="chevron-down" />
</button>
<div className={cn('overflow-hidden transition-[max-height] duration-300', open ? 'max-h-[4000px]' : 'max-h-0')}>
{group.cheats.map((cheat, index) => (
<CheatTile
key={cheat.uuid}
cheat={cheat}
@@ -49,6 +64,7 @@ export function CategorySection({
pending={Boolean(pendingTargets[cheat.target])}
pinned={Boolean(pinnedTargets[cheat.target])}
disabled={disabled}
first={index === 0}
onChange={(nextValue) => onCheatChange(cheat, nextValue)}
onTogglePin={() => onTogglePin(cheat)}
/>
@@ -56,4 +72,20 @@ export function CategorySection({
</div>
</section>
);
};
function getEnabledToggleCount(cheats: CheatSchema[], values: Record<string, unknown>): number {
return cheats.filter((cheat) => cheat.type === ECheatType.Toggle && Boolean(values[cheat.target])).length;
}
function getToggleCount(cheats: CheatSchema[]): number {
return cheats.filter((cheat) => cheat.type === ECheatType.Toggle).length;
}
function formatSummary(cheatCount: number, enabledCount: number, toggleCount: number): string {
if (toggleCount <= 0) {
return `${cheatCount} mods`;
}
return `${cheatCount} mods · ${enabledCount}/${toggleCount} on`;
}
@@ -1,8 +1,10 @@
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import { Icon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import type { CheatSchema } from '../protocol';
import { ECheatType } from '../protocol';
import { CheatControl } from '../controls/CheatControl';
type CheatTileProps = {
@@ -11,43 +13,154 @@ type CheatTileProps = {
pending: boolean;
disabled: boolean;
pinned: boolean;
first: boolean;
onChange: (nextValue: unknown) => void;
onTogglePin: () => void;
};
export function CheatTile({ cheat, value, pending, disabled, pinned, onChange, onTogglePin }: CheatTileProps) {
const SWIPE_REVEAL = 80;
const SWIPE_TRIGGER = 56;
const SWIPE_DEAD_ZONE = 8;
const SWIPE_ANIMATION_MS = 220;
export const CheatTile = ({ cheat, value, pending, disabled, pinned, first, onChange, onTogglePin }: CheatTileProps) => {
const [offset, setOffset] = useState(0);
const [animating, setAnimating] = useState(false);
const [armed, setArmed] = useState(false);
const dragRef = useRef<{ id: number; startX: number; startY: number; locked: boolean | null } | null>(null);
useEffect(() => {
setOffset(0);
setArmed(false);
}, [pinned]);
const settle = (target: number) => {
setAnimating(true);
setOffset(target);
window.setTimeout(() => setAnimating(false), SWIPE_ANIMATION_MS);
};
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse' && event.button !== 0) return;
if (isInteractiveTarget(event.target)) return;
dragRef.current = { id: event.pointerId, startX: event.clientX, startY: event.clientY, locked: null };
setAnimating(false);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.id !== event.pointerId) return;
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (drag.locked === null) {
if (Math.abs(dx) < SWIPE_DEAD_ZONE && Math.abs(dy) < SWIPE_DEAD_ZONE) return;
drag.locked = Math.abs(dx) > Math.abs(dy) && dx < 0;
if (!drag.locked) {
dragRef.current = null;
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
}
const next = clamp(dx, -SWIPE_REVEAL * 1.2, 0);
setOffset(next);
setArmed(-next >= SWIPE_TRIGGER);
};
const handlePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.id !== event.pointerId) return;
dragRef.current = null;
if (drag.locked !== true) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const triggered = -offset >= SWIPE_TRIGGER;
settle(0);
setArmed(false);
if (triggered) {
onTogglePin();
}
};
const stacked = isStackedControl(cheat);
const showReveal = offset < 0;
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 className={cn('relative overflow-hidden', first ? '' : 'border-t border-white/[0.06]')}>
{showReveal ? <PinReveal pinned={pinned} armed={armed} /> : null}
<div
className={cn('relative', animating ? 'transition-transform duration-200 ease-out' : '')}
style={{ transform: `translate3d(${offset}px, 0, 0)`, touchAction: 'pan-y' }}
onPointerCancel={handlePointerEnd}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
>
<div className="flex flex-col gap-2 px-3.5 py-3">
<div className={cn('flex gap-3', stacked ? 'flex-col items-stretch' : 'items-start justify-between')}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="truncate text-[13.5px] font-medium leading-tight text-(--deck-fg)">{cheat.name}</h4>
{pending ? <Icon className="size-3.5 shrink-0 animate-spin text-(--deck-accent)" name="loader" /> : null}
</div>
{cheat.description ? <p className="mt-1 line-clamp-2 text-[11.5px] leading-snug text-(--deck-fg-3)">{cheat.description}</p> : null}
</div>
<CheatControl cheat={cheat} disabled={disabled} pending={pending} value={value} onChange={onChange} />
</div>
{cheat.instructions ? (
<div className="flex gap-2 rounded-[8px] border border-amber-300/25 bg-amber-400/10 px-2.5 py-2 text-[11.5px] leading-5 text-amber-200">
<Icon className="mt-0.5 size-3.5 shrink-0" name="alert" />
<span>{cheat.instructions}</span>
</div>
) : null}
</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>
</div>
</div>
);
};
const PinReveal = ({ pinned, armed }: { pinned: boolean; armed: boolean }) => {
return (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center justify-center px-4">
<span className={cn('flex size-9 items-center justify-center rounded-full border transition-all duration-150', armed ? 'scale-110 border-(--deck-accent) bg-[color-mix(in_oklab,var(--deck-accent)_28%,transparent)] text-(--deck-accent) shadow-[0_0_0_4px_color-mix(in_oklab,var(--deck-accent)_18%,transparent)]' : 'border-white/10 bg-white/[0.06] text-(--deck-fg-3)')}>
<Icon className="size-4" name={pinned ? 'pin-off' : 'pin'} />
</span>
</div>
);
};
function isStackedControl(cheat: CheatSchema): boolean {
if (
cheat.type === ECheatType.Slider ||
cheat.type === ECheatType.Scalar ||
cheat.type === ECheatType.Number ||
cheat.type === ECheatType.Incremental ||
cheat.type === ECheatType.Button
) {
return true;
}
if (cheat.type === ECheatType.Selection) {
const optionCount = cheat.args.options?.length ?? 0;
return optionCount > 0;
}
return false;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function isInteractiveTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return Boolean(target.closest('input, button, select, textarea, a, [role="slider"], [role="button"]'));
}
@@ -1,70 +0,0 @@
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>
);
}
@@ -1,48 +0,0 @@
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,42 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
const DRAWER_SIDE_CLASSES = {
left: 'left-0 border-r',
right: 'right-0 border-l',
} as const;
const DRAWER_CLOSED_CLASSES = {
left: '-translate-x-full',
right: 'translate-x-full',
} as const;
const DRAWER_OVERLAY_CLASS = 'remote-drawer-overlay absolute inset-0 z-30 transition-opacity duration-200 ease-out motion-reduce:transition-none';
const DRAWER_PANEL_CLASS = 'remote-drawer-panel absolute bottom-0 top-0 z-40 flex w-[88%] max-w-90 flex-col border-white/10 transition-transform duration-300 ease-out motion-reduce:transition-none';
type DrawerProps = {
open: boolean;
side: 'left' | 'right';
children: ReactNode;
onClose: () => void;
};
export const Drawer = ({ open, side, children, onClose }: DrawerProps) => {
const sideClassName = DRAWER_SIDE_CLASSES[side];
const closedClassName = DRAWER_CLOSED_CLASSES[side];
return (
<>
<button
type="button"
aria-label="Close drawer"
className={cn(DRAWER_OVERLAY_CLASS, open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
onClick={onClose}
/>
<aside aria-hidden={!open} className={cn(DRAWER_PANEL_CLASS, sideClassName, open ? 'translate-x-0' : closedClassName)} data-open={open ? 'true' : 'false'}>
{children}
</aside>
</>
);
};
@@ -1,18 +0,0 @@
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,55 @@
import { Icon, type IconName } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { EConnectionStatus } from '../state';
type FloatingDockProps = {
status: EConnectionStatus;
runningGameTitle: string | null;
hidden: boolean;
leftHasBadge: boolean;
rightHasBadge: boolean;
onOpenSettings: () => void;
onOpenLibrary: () => void;
};
export const FloatingDock = ({
status,
runningGameTitle,
hidden,
leftHasBadge,
rightHasBadge,
onOpenSettings,
onOpenLibrary,
}: FloatingDockProps) => {
const live = status === EConnectionStatus.Connected;
return (
<div className={cn('absolute bottom-4.5 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-white/10 bg-[#0e1016]/80 p-1.5 shadow-[0_12px_40px_-10px_rgba(0,0,0,.65),inset_0_1px_0_rgba(255,255,255,.05)] backdrop-blur-2xl transition duration-300', hidden ? 'translate-y-20 opacity-0' : 'translate-y-0 opacity-100')}>
<DockButton badge={leftHasBadge} icon="settings" label="Settings" onClick={onOpenSettings} />
<div className="flex h-9.5 items-center gap-2 border-x border-white/10 px-3">
<span className={cn('size-1.5 rounded-full', live ? 'bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)] motion-safe:animate-[breathe_2s_ease-in-out_infinite]' : 'bg-(--deck-fg-4)')} />
<span className="max-w-30 truncate text-[11px] font-semibold text-(--deck-fg-2)">
{runningGameTitle || 'No session'}
</span>
</div>
<DockButton badge={rightHasBadge} icon="list" label="Library" onClick={onOpenLibrary} />
</div>
);
};
type DockButtonProps = {
badge: boolean;
icon: IconName;
label: string;
onClick: () => void;
};
const DockButton = ({ badge, icon, label, onClick }: DockButtonProps) => {
return (
<button type="button" aria-label={label} className="relative flex h-9.5 w-11 items-center justify-center rounded-full text-(--deck-fg) hover:bg-white/6" onClick={onClick}>
<Icon className="size-4.5" name={icon} stroke={1.7} />
{badge ? <span className="absolute right-2 top-1.5 size-1.5 rounded-full bg-(--deck-accent) shadow-[0_0_4px_var(--deck-accent)]" /> : null}
</button>
);
};
@@ -0,0 +1,32 @@
import { useState } from 'react';
import { getGameCoverLabel, type LibraryGame } from '../game-library';
type GameCoverProps = {
game: LibraryGame;
size?: 'sm' | 'lg';
};
const SIZE_CLASSES: Record<NonNullable<GameCoverProps['size']>, string> = {
lg: 'size-16 rounded-[10px] text-[8px]',
sm: 'size-11 rounded-[9px] text-[7px]',
};
export const GameCover = ({ game, size = 'sm' }: GameCoverProps) => {
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const sizeClass = SIZE_CLASSES[size];
const imageUrl = game.imageUrl && game.imageUrl !== failedUrl ? game.imageUrl : null;
const handleImageError = () => setFailedUrl(game.imageUrl ?? null);
return (
<div className={`relative shrink-0 overflow-hidden border border-white/6 bg-[linear-gradient(135deg,rgba(238,0,255,0.28),rgba(0,0,0,0.48))] shadow-[inset_0_0_0_1px_rgba(255,255,255,.04),0_2px_6px_rgba(0,0,0,.35)] ${sizeClass}`}>
{imageUrl ? <img alt="" className="absolute inset-0 size-full object-cover" src={imageUrl} loading="lazy" onError={handleImageError} /> : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,.14),transparent_35%,rgba(0,0,0,.42))]" />
{game.running ? <span className="absolute right-1 top-1 size-1.5 rounded-full bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)]" /> : null}
<div className="absolute inset-x-1 bottom-1 truncate font-mono font-bold uppercase tracking-wider text-white/85 drop-shadow">
{getGameCoverLabel(game)}
</div>
</div>
);
};
@@ -0,0 +1,166 @@
import type { ReactNode } from 'react';
import { Icon, type IconName } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../game-library';
import { GameCover } from './GameCover';
import { SearchInput } from './SearchInput';
type LibraryDrawerProps = {
games: LibraryGame[];
query: string;
canLaunch: boolean;
onClose: () => void;
onPin: (game: LibraryGame) => void;
onPlay: (game: LibraryGame) => void;
onStop: () => void;
onQueryChange: (query: string) => void;
};
export const LibraryDrawer = ({ games, query, canLaunch, onClose, onPin, onPlay, onStop, onQueryChange }: LibraryDrawerProps) => {
const filteredGames = filterLibraryGames(games, query);
const sections = getLibrarySections(filteredGames);
return (
<div className="flex h-full flex-col">
<header className="remote-glass-header flex items-center gap-2.5 border-b px-3.5 py-3.5">
<div className="min-w-0 flex-1">
<h2 className="text-lg font-bold text-(--deck-fg)">Library</h2>
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">{games.length} games detected</p>
</div>
<button type="button" aria-label="Close library" className="remote-glass-control flex size-8 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
<Icon className="size-4" name="x" />
</button>
</header>
<div className="border-b border-white/6 px-3.5 py-2.5">
<SearchInput value={query} placeholder="Search games" onChange={onQueryChange} />
</div>
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain pb-6">
{sections.running ? (
<GameSection accent count={1} icon="dot" title="Now Playing">
<GameRow game={sections.running} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />
</GameSection>
) : null}
{sections.pinned.length > 0 ? (
<GameSection count={sections.pinned.length} icon="star-filled" title="Favorites">
{sections.pinned.map((game) => <GameRow key={game.id} game={game} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />)}
</GameSection>
) : null}
{sections.rest.length > 0 ? (
<GameSection count={sections.rest.length} title="All Games">
{sections.rest.map((game) => <GameRow key={game.id} game={game} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />)}
</GameSection>
) : null}
{filteredGames.length === 0 ? (
<p className="px-8 py-10 text-center text-[13px] text-(--deck-fg-4)">No games match "{query}"</p>
) : null}
</div>
</div>
);
};
type GameSectionProps = {
title: string;
count?: number;
icon?: IconName;
accent?: boolean;
children: ReactNode;
};
const GameSection = ({ title, count, icon, accent = false, children }: GameSectionProps) => {
return (
<section className="mt-2">
<div className="flex items-center gap-2 px-3.5 pb-1.5 pt-3.5">
{icon ? <Icon className={cn('size-3', accent ? 'text-(--deck-accent)' : 'text-(--deck-fg-4)')} name={icon} stroke={2} /> : null}
<h3 className={cn('font-mono text-[10px] font-bold uppercase tracking-[0.18em]', accent ? 'text-(--deck-accent)' : 'text-(--deck-fg-4)')}>{title}</h3>
<div className="h-px flex-1 bg-white/6" />
{typeof count === 'number' ? <span className="font-mono text-[10px] text-(--deck-fg-4)">{count}</span> : null}
</div>
{children}
</section>
);
};
type GameRowProps = {
game: LibraryGame;
canLaunch: boolean;
query: string;
onPin: (game: LibraryGame) => void;
onPlay: (game: LibraryGame) => void;
onStop: () => void;
};
const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps) => {
const hours = formatHours(game.hours);
const handlePin = () => onPin(game);
const handlePlay = () => onPlay(game);
return (
<article className={cn('mx-2 mb-1 flex items-center gap-2.5 rounded-[10px] border px-2.5 py-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]', game.running ? 'border-[color-mix(in_oklab,var(--deck-accent)_24%,transparent)] bg-[color-mix(in_oklab,var(--deck-accent)_7%,transparent)]' : 'border-white/[0.07] bg-white/2.5')}>
<GameCover game={game} />
<div className="min-w-0 flex-1">
<h4 className="truncate text-[13.5px] font-semibold text-(--deck-fg)">{highlightTitle(game.title, query)}</h4>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 font-mono text-[10.5px] tracking-[0.02em] text-(--deck-fg-3)">
<span className="shrink-0">{game.platform.toUpperCase()}</span>
{hours ? <span className="shrink-0">· {hours}</span> : null}
{game.path ? <span className="min-w-0 truncate opacity-60">· {shortPath(game.path)}</span> : null}
</div>
</div>
<div className="flex shrink-0 gap-1">
<IconButton active={game.pinned} label={game.pinned ? 'Remove favorite' : 'Favorite game'} icon={game.pinned ? 'star-filled' : 'star'} onClick={handlePin} />
{game.running ? (
<IconButton danger label="Stop playing" icon="stop" onClick={onStop} />
) : (
<IconButton disabled={!canLaunch || !game.gameId} play label="Play" icon="play" onClick={handlePlay} />
)}
</div>
</article>
);
};
type IconButtonProps = {
icon: IconName;
label: string;
active?: boolean;
danger?: boolean;
disabled?: boolean;
play?: boolean;
onClick: () => void;
};
const IconButton = ({ icon, label, active = false, danger = false, disabled = false, play = false, onClick }: IconButtonProps) => {
return (
<button
type="button"
aria-label={label}
disabled={disabled}
className={cn(
'remote-glass-control flex size-7.5 items-center justify-center rounded-[7px] border text-(--deck-fg-2) disabled:cursor-not-allowed disabled:opacity-40',
active ? 'border-[color-mix(in_oklab,var(--deck-accent)_38%,transparent)] bg-[color-mix(in_oklab,var(--deck-accent)_16%,transparent)] text-(--deck-accent) shadow-[0_0_14px_-6px_var(--deck-accent)]' : '',
play ? 'bg-[color-mix(in_oklab,var(--deck-accent)_15%,transparent)] text-(--deck-accent) ring-1 ring-[color-mix(in_oklab,var(--deck-accent)_35%,transparent)]' : '',
danger ? 'bg-red-500/15 text-red-300 ring-1 ring-red-400/30' : '',
)}
onClick={onClick}
>
<Icon className={cn('size-3.5', active ? 'drop-shadow-[0_0_5px_var(--deck-accent)]' : '')} name={icon} />
</button>
);
};
function highlightTitle(title: string, query: string): ReactNode {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return title;
}
const index = title.toLowerCase().indexOf(normalized);
if (index < 0) {
return title;
}
const before = title.slice(0, index);
const match = title.slice(index, index + query.length);
const after = title.slice(index + query.length);
return <>{before}<span className="rounded-[3px] bg-[color-mix(in_oklab,var(--deck-accent)_25%,transparent)] px-0.5 text-(--deck-fg)">{match}</span>{after}</>;
}
@@ -0,0 +1,25 @@
import { Icon, type IconName } from '@/components/ui/icon';
type PlaceholderStateProps = {
icon: IconName;
title: string;
sub: string;
action: string;
onAction: () => void;
};
export const PlaceholderState = ({ icon, title, sub, action, onAction }: PlaceholderStateProps) => {
return (
<div className="flex min-h-145 flex-col items-center justify-center px-10 py-12 text-center">
<div className="mb-4 flex size-18 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.05] text-(--deck-accent) shadow-[0_0_0_8px_color-mix(in_oklab,var(--deck-accent)_6%,transparent),0_0_40px_-8px_color-mix(in_oklab,var(--deck-accent)_30%,transparent)] backdrop-blur-xl">
<Icon className="size-7" name={icon} stroke={1.6} />
</div>
<h2 className="text-lg font-bold text-(--deck-fg)">{title}</h2>
<p className="mt-1.5 max-w-65 text-[13px] leading-6 text-(--deck-fg-3)">{sub}</p>
<button type="button" className="mt-5 inline-flex items-center gap-2 rounded-[10px] bg-(--deck-accent) px-4 py-2.5 text-[13px] font-bold text-black shadow-[0_8px_24px_-8px_var(--deck-accent)]" onClick={onAction}>
{action}
<Icon className="size-3.5" name="arrow-right" stroke={2.2} />
</button>
</div>
);
};
@@ -0,0 +1,161 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { Icon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import type { RemotePreset } from '../preset-storage';
type QuickActionsProps = {
presets: RemotePreset[];
onPanic: () => void;
onAddPreset: (name: string) => boolean;
onApplyPreset: (preset: RemotePreset) => void;
onDeletePreset: (presetId: string) => void;
};
const DEFAULT_PRESET_NAME = 'New preset';
export const QuickActions = ({ presets, onPanic, onAddPreset, onApplyPreset, onDeletePreset }: QuickActionsProps) => {
const [modalOpen, setModalOpen] = useState(false);
const [draftName, setDraftName] = useState('');
const handleOpenModal = () => {
setDraftName('');
setModalOpen(true);
};
const handleCloseModal = () => setModalOpen(false);
const handleSubmitPreset = (name: string): boolean => {
const saved = onAddPreset(name);
if (!saved) {
return false;
}
setModalOpen(false);
return true;
};
return (
<>
<div className="remote-scrollbar-hidden mb-3 flex gap-1.5 overflow-x-auto pb-0.5">
<Chip icon="bolt" label="Panic Off" variant="danger" onClick={onPanic} />
{presets.map((preset) => (
<PresetChip key={preset.id} preset={preset} onApply={onApplyPreset} onDelete={onDeletePreset} />
))}
<Chip icon="plus" label="Add" variant="add" onClick={handleOpenModal} />
</div>
{modalOpen ? (
<PresetModal
draftName={draftName}
onClose={handleCloseModal}
onDraftNameChange={setDraftName}
onSubmit={handleSubmitPreset}
/>
) : null}
</>
);
};
type ChipProps = {
icon: 'bolt' | 'plus';
label: string;
variant: 'add' | 'danger';
onClick: () => void;
};
const Chip = ({ icon, label, variant, onClick }: ChipProps) => {
return (
<button type="button" className={cn('inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[11.5px] font-semibold backdrop-blur-xl', getChipVariantClass(variant))} onClick={onClick}>
<Icon className="size-3" name={icon} stroke={2} />
{label}
</button>
);
};
type PresetChipProps = {
preset: RemotePreset;
onApply: (preset: RemotePreset) => void;
onDelete: (presetId: string) => void;
};
const PresetChip = ({ preset, onApply, onDelete }: PresetChipProps) => {
const handleApply = () => onApply(preset);
const handleDelete = () => onDelete(preset.id);
return (
<span className="inline-flex shrink-0 overflow-hidden rounded-full border border-white/10 bg-white/[0.055] text-[11.5px] font-semibold text-(--deck-fg-2) shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] backdrop-blur-xl">
<button type="button" className="inline-flex h-[30px] max-w-[140px] items-center gap-1.5 px-3" onClick={handleApply}>
<Icon className="size-3 shrink-0 text-(--deck-accent)" name="sparkles" stroke={2} />
<span className="truncate">{preset.name}</span>
</button>
<button type="button" aria-label={`Delete preset ${preset.name}`} className="flex h-[30px] w-7 items-center justify-center border-l border-white/10 text-(--deck-fg-4) hover:text-(--deck-fg)" onClick={handleDelete}>
<Icon className="size-3" name="x" stroke={2.1} />
</button>
</span>
);
};
type PresetModalProps = {
draftName: string;
onClose: () => void;
onDraftNameChange: (name: string) => void;
onSubmit: (name: string) => boolean;
};
const PresetModal = ({ draftName, onClose, onDraftNameChange, onSubmit }: PresetModalProps) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const trimmedName = draftName.trim();
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleInput = (event: FormEvent<HTMLInputElement>) => onDraftNameChange(event.currentTarget.value);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const nextName = trimmedName || DEFAULT_PRESET_NAME;
onSubmit(nextName);
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/45 p-3 backdrop-blur-[6px] sm:items-center">
<button type="button" aria-label="Close preset modal" className="absolute inset-0" onClick={onClose} />
<form className="remote-glass-drawer relative w-full max-w-[362px] rounded-[18px] border border-white/10 p-4" onSubmit={handleSubmit}>
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-[15px] font-bold text-(--deck-fg)">Add Preset</h3>
</div>
<button type="button" aria-label="Close preset modal" className="remote-glass-control flex size-8 shrink-0 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
<Icon className="size-4" name="x" />
</button>
</div>
<label className="mb-3 block">
<span className="mb-1.5 block font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-(--deck-fg-4)">Name</span>
<input
ref={inputRef}
value={draftName}
placeholder="Preset name"
maxLength={32}
spellCheck={false}
className="remote-glass-control h-11 w-full rounded-[10px] border px-3 text-[13px] font-semibold text-(--deck-fg) outline-none placeholder:text-(--deck-fg-4)"
onInput={handleInput}
/>
</label>
<div className="grid grid-cols-2 gap-2">
<button type="button" className="remote-glass-control h-10 rounded-[10px] border text-[12px] font-semibold text-(--deck-fg-2)" onClick={onClose}>Cancel</button>
<button type="submit" className="h-10 rounded-[10px] bg-(--deck-accent) text-[12px] font-bold text-black shadow-[0_8px_24px_-8px_var(--deck-accent)]">Save</button>
</div>
</form>
</div>
);
};
function getChipVariantClass(variant: ChipProps['variant']): string {
if (variant === 'danger') {
return 'border-red-400/30 bg-red-500/10 text-red-300 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]';
}
return 'border-white/10 bg-white/[0.055] text-(--deck-fg-2) shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]';
}
@@ -0,0 +1,35 @@
import type { FormEvent } from 'react';
import { Icon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
type SearchInputProps = {
value: string;
placeholder: string;
className?: string;
onChange: (value: string) => void;
};
export const SearchInput = ({ value, placeholder, className, onChange }: SearchInputProps) => {
const handleInput = (event: FormEvent<HTMLInputElement>) => onChange(event.currentTarget.value);
const handleClear = () => onChange('');
return (
<div className={cn('remote-glass-control flex h-9.5 items-center gap-2 rounded-[10px] border px-2.5', className)}>
<Icon className="size-3.5 shrink-0 text-(--deck-fg-3)" name="search" />
<input
value={value}
placeholder={placeholder}
spellCheck={false}
className="min-w-0 flex-1 bg-transparent text-[13px] text-(--deck-fg) outline-none placeholder:text-(--deck-fg-4)"
onInput={handleInput}
/>
{value ? (
<button type="button" aria-label="Clear search" className="flex size-6 items-center justify-center rounded-[7px] text-(--deck-fg-3) hover:bg-white/6 hover:text-(--deck-fg)" onClick={handleClear}>
<Icon className="size-3.5" name="x" />
</button>
) : null}
</div>
);
};
@@ -0,0 +1,191 @@
import { useState, type FormEvent } from 'react';
import { Icon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { DEFAULT_ACCENT_COLOR, loadAccentColor, setAccentColor } from '../accent-storage';
import { DEFAULT_REMOTE_PORT } from '../constants';
import type { LibraryGame } from '../game-library';
import type { TrainerSummary } from '../protocol';
import { EConnectionStatus } from '../state';
import { StatusPill } from './StatusPill';
const ACCENT_OPTIONS = [
{ value: '#3B82F6', label: 'Cobalt', swatchClass: 'bg-[#3B82F6]' },
{ value: DEFAULT_ACCENT_COLOR, label: 'Cyan', swatchClass: 'bg-[#00FFD5]' },
{ value: '#FF2E63', label: 'Crimson', swatchClass: 'bg-[#FF2E63]' },
{ value: '#A78BFA', label: 'Violet', swatchClass: 'bg-[#A78BFA]' },
{ value: '#7CFF5B', label: 'Lime', swatchClass: 'bg-[#7CFF5B]' },
{ value: '#FFB12E', label: 'Amber', swatchClass: 'bg-[#FFB12E]' },
{ value: '#ee00ff', label: 'Magenta', swatchClass: 'bg-[#ee00ff]' },
];
type SettingsDrawerProps = {
status: EConnectionStatus;
wsUrl: string;
currentGame: LibraryGame | null;
currentTrainer: TrainerSummary | null;
lastError: string | null;
onClose: () => void;
onConnect: () => void;
onDisconnect: () => void;
onWsUrlChange: (value: string) => void;
};
export const SettingsDrawer = ({
status,
wsUrl,
currentGame,
currentTrainer,
lastError,
onClose,
onConnect,
onDisconnect,
onWsUrlChange,
}: SettingsDrawerProps) => {
return (
<div className="flex h-full flex-col">
<header className="remote-glass-header flex items-center justify-between border-b px-3.5 py-3.5">
<div>
<h2 className="text-lg font-bold text-(--deck-fg)">Settings</h2>
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">wand remote · port {DEFAULT_REMOTE_PORT}</p>
</div>
<button type="button" aria-label="Close settings" className="remote-glass-control flex size-8 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
<Icon className="size-4" name="x" />
</button>
</header>
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain p-3.5">
<BridgeControl status={status} wsUrl={wsUrl} onConnect={onConnect} onDisconnect={onDisconnect} onWsUrlChange={onWsUrlChange} />
{lastError ? <ErrorPanel message={lastError} /> : null}
<SectionHeader title="Session" />
<SessionPanel currentGame={currentGame} currentTrainer={currentTrainer} />
<SectionHeader title="Accent Color" />
<AccentPicker />
</div>
</div>
);
};
type BridgeControlProps = {
status: EConnectionStatus;
wsUrl: string;
onConnect: () => void;
onDisconnect: () => void;
onWsUrlChange: (value: string) => void;
};
const BridgeControl = ({ status, wsUrl, onConnect, onDisconnect, onWsUrlChange }: BridgeControlProps) => {
const live = status === EConnectionStatus.Connected;
const connecting = status === EConnectionStatus.Connecting;
const handleInput = (event: FormEvent<HTMLInputElement>) => onWsUrlChange(event.currentTarget.value);
return (
<section>
<div className="mb-2 flex items-center justify-between">
<h3 className="font-mono text-[10px] font-bold uppercase tracking-[0.18em] text-(--deck-fg-4)">Bridge</h3>
<StatusPill status={status} />
</div>
<div className="remote-glass-control flex h-10 items-stretch overflow-hidden rounded-[10px] border">
<input
value={wsUrl}
placeholder={`ws://127.0.0.1:${DEFAULT_REMOTE_PORT}/remote/ws`}
spellCheck={false}
className="min-w-0 flex-1 bg-transparent px-3 font-mono text-[12.5px] text-(--deck-fg) outline-none placeholder:text-(--deck-fg-4)"
onInput={handleInput}
/>
<button
type="button"
disabled={connecting}
className={cn('px-4 text-[11px] font-bold tracking-[0.08em] disabled:cursor-wait disabled:opacity-70', live ? 'bg-red-500/15 text-red-300' : 'bg-(--deck-accent) text-black')}
onClick={live ? onDisconnect : onConnect}
>
{getBridgeButtonLabel(status)}
</button>
</div>
</section>
);
};
const ErrorPanel = ({ message }: { message: string }) => {
return (
<div className="mt-3 flex items-start gap-2 rounded-[10px] border border-red-400/25 bg-red-500/10 p-3 text-[12px] leading-5 text-red-100">
<Icon className="mt-0.5 size-3.5 shrink-0" name="alert" />
<span>{message}</span>
</div>
);
};
const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGame | null; currentTrainer: TrainerSummary | null }) => {
if (!currentGame) {
return <div className="remote-glass-control rounded-[10px] border p-3 text-[12px] text-(--deck-fg-3)">No active game session.</div>;
}
const subtitleBase = currentTrainer?.displayName ?? currentGame.platform;
const subtitleVersion = currentTrainer?.gameVersion ? ` · v${currentTrainer.gameVersion}` : '';
const sessionSubtitle = `${subtitleBase}${subtitleVersion}`;
return (
<div className="remote-glass-control rounded-[10px] border p-3">
<div className="mb-2 flex items-center gap-2">
<span className="size-1.5 rounded-full bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)]" />
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.12em] text-(--deck-accent)">Active Session</span>
</div>
<h3 className="truncate text-sm font-semibold text-(--deck-fg)">{currentGame.title}</h3>
<p className="mt-0.5 truncate font-mono text-[11px] text-(--deck-fg-3)">
{sessionSubtitle}
</p>
</div>
);
};
const AccentPicker = () => {
const [current, setCurrent] = useState(loadAccentColor);
const applyAccent = (value: string) => {
setCurrent(setAccentColor(value));
};
return (
<div className="space-y-1.5">
<div className="grid grid-cols-3 gap-1.5">
{ACCENT_OPTIONS.map((option) => {
const active = current.toLowerCase() === option.value.toLowerCase();
return (
<button key={option.value} type="button" className={cn('remote-glass-control flex items-center gap-1.5 rounded-[9px] border px-2 py-2 text-[12px] font-medium', active ? 'border-(--deck-accent) text-(--deck-fg)' : 'text-(--deck-fg-3)')} onClick={() => applyAccent(option.value)}>
<span className={cn('size-3.5 shrink-0 rounded-lg border border-white/10', option.swatchClass)} />
{option.label}
</button>
);
})}
</div>
<label className="remote-glass-control flex h-9.5 items-center gap-2 rounded-[9px] border px-2.5">
<span className="flex-1 font-mono text-[11px] font-semibold uppercase tracking-[0.08em] text-(--deck-fg-3)">Custom</span>
<span className="font-mono text-[11px] text-(--deck-fg-4)">{current}</span>
<input type="color" value={current} className="size-5 rounded border-0 bg-transparent p-0" onChange={(event) => applyAccent(event.currentTarget.value)} />
</label>
</div>
);
};
const SectionHeader = ({ title }: { title: string }) => {
return (
<div className="flex items-center gap-2 pb-1.5 pt-4">
<h3 className="font-mono text-[10px] font-bold uppercase tracking-[0.18em] text-(--deck-fg-4)">{title}</h3>
<div className="h-px flex-1 bg-white/6" />
</div>
);
};
function getBridgeButtonLabel(status: EConnectionStatus): string {
if (status === EConnectionStatus.Connected) {
return 'STOP';
}
if (status === EConnectionStatus.Connecting) {
return '...';
}
return 'GO';
}
@@ -0,0 +1,29 @@
import { Icon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { EConnectionStatus } from '../state';
const STATUS_LABELS: Record<EConnectionStatus, string> = {
[EConnectionStatus.Connected]: 'LIVE',
[EConnectionStatus.Connecting]: 'LINKING',
[EConnectionStatus.Error]: 'OFFLINE',
[EConnectionStatus.Idle]: 'OFFLINE',
};
const STATUS_CLASSES: Record<EConnectionStatus, string> = {
[EConnectionStatus.Connected]: 'border-[color-mix(in_oklab,var(--deck-accent)_30%,transparent)] text-(--deck-accent)',
[EConnectionStatus.Connecting]: 'border-amber-300/30 text-amber-300',
[EConnectionStatus.Error]: 'border-white/10 text-(--deck-fg-4)',
[EConnectionStatus.Idle]: 'border-white/10 text-(--deck-fg-4)',
};
export const StatusPill = ({ status }: { status: EConnectionStatus }) => {
const live = status === EConnectionStatus.Connected || status === EConnectionStatus.Connecting;
return (
<div className={cn('inline-flex items-center gap-1.5 rounded-full border bg-white/[0.04] px-2.5 py-1 font-mono text-[9.5px] font-bold tracking-[0.12em] backdrop-blur-md', STATUS_CLASSES[status])}>
{live ? <span className="size-1.5 rounded-full bg-current shadow-[0_0_6px_currentColor] motion-safe:animate-[breathe_1.6s_ease-in-out_infinite]" /> : null}
{STATUS_LABELS[status]}
{status === EConnectionStatus.Error ? <Icon className="size-3" name="alert" /> : null}
</div>
);
};
@@ -0,0 +1,39 @@
import { Icon } from '@/components/ui/icon';
import type { LibraryGame } from '../game-library';
import type { TrainerSummary } from '../protocol';
import type { EConnectionStatus } from '../state';
import { StatusPill } from './StatusPill';
type TopBarProps = {
status: EConnectionStatus;
currentGame: LibraryGame | null;
runningTrainer: TrainerSummary | null;
onOpenSettings: () => void;
};
export const TopBar = ({ status, currentGame, runningTrainer, onOpenSettings }: TopBarProps) => {
return (
<header className="remote-glass-header sticky top-0 z-20 border-b px-3.5 pb-2.5 pt-3">
<div className="flex items-center gap-2.5">
<button type="button" aria-label="Settings" className="remote-glass-control flex size-[34px] shrink-0 items-center justify-center rounded-[9px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onOpenSettings}>
<Icon className="size-[18px]" name="menu" />
</button>
<div className="min-w-0 flex-1">
<div className="font-mono text-[9.5px] font-bold tracking-[0.16em] text-(--deck-fg-4)">WAND · REMOTE DECK</div>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate text-sm font-semibold text-(--deck-fg)">
{currentGame ? currentGame.title : 'Idle · no game'}
</span>
{currentGame && runningTrainer?.gameVersion ? (
<span className="shrink-0 rounded-[4px] bg-[color-mix(in_oklab,var(--deck-accent)_12%,transparent)] px-1.5 py-0.5 font-mono text-[9.5px] font-bold tracking-[0.06em] text-(--deck-accent)">
v{runningTrainer.gameVersion}
</span>
) : null}
</div>
</div>
<StatusPill status={status} />
</div>
</header>
);
};
@@ -0,0 +1,49 @@
import { Icon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { getTrainerDisplayName } from '../category';
import type { LibraryGame } from '../game-library';
import type { TrainerSummary } from '../protocol';
import { GameCover } from './GameCover';
type TrainerHeaderProps = {
trainer: TrainerSummary;
game: LibraryGame | null;
isPinned: boolean;
onPin: () => void;
};
export const TrainerHeader = ({ trainer, game, isPinned, onPin }: TrainerHeaderProps) => {
return (
<section className="relative mb-3 mt-3.5 overflow-hidden rounded-[14px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.015))] p-3.5 shadow-[0_8px_32px_-12px_rgba(0,0,0,.5),inset_0_1px_0_rgba(255,255,255,.06)]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_90%_-20%,color-mix(in_oklab,var(--deck-accent)_28%,transparent),transparent_55%)]" />
<div className="relative flex items-center gap-3">
{game ? <GameCover game={game} size="lg" /> : <FallbackCover />}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)] motion-safe:animate-[breathe_2s_ease-in-out_infinite]" />
<span className="font-mono text-[9.5px] font-bold uppercase tracking-[0.16em] text-(--deck-accent)">Trainer Active</span>
</div>
<h2 className="mt-1 truncate text-base font-bold leading-tight text-(--deck-fg)">{getTrainerDisplayName(trainer)}</h2>
<div className="mt-1 flex min-w-0 items-center gap-2.5 font-mono text-[11px] text-(--deck-fg-3)">
<span className="truncate uppercase">{game?.platform ?? 'Wand'}</span>
{trainer.gameVersion ? <span className="shrink-0">· v{trainer.gameVersion}</span> : null}
<span className="min-w-0 truncate">· #{trainer.trainerId}</span>
</div>
</div>
<button type="button" aria-label={isPinned ? 'Remove favorite' : 'Favorite game'} disabled={!game} className="flex size-8.5 shrink-0 items-center justify-center rounded-[9px] border border-white/10 bg-white/5 text-(--deck-fg-3) backdrop-blur-xl hover:text-(--deck-fg) disabled:cursor-not-allowed disabled:opacity-35 data-[active=true]:border-[color-mix(in_oklab,var(--deck-accent)_35%,transparent)] data-[active=true]:bg-[color-mix(in_oklab,var(--deck-accent)_18%,transparent)] data-[active=true]:text-(--deck-accent) data-[active=true]:shadow-[0_0_18px_-8px_var(--deck-accent)]" data-active={isPinned} onClick={onPin}>
<Icon className={cn('size-4', isPinned ? 'drop-shadow-[0_0_5px_var(--deck-accent)]' : '')} name={isPinned ? 'star-filled' : 'star'} />
</button>
</div>
</section>
);
};
const FallbackCover = () => {
return (
<div className="relative size-16 shrink-0 overflow-hidden rounded-[10px] border border-white/6 bg-[linear-gradient(135deg,rgba(238,0,255,0.28),rgba(0,0,0,0.48))] shadow-[inset_0_0_0_1px_rgba(255,255,255,.04),0_2px_6px_rgba(0,0,0,.35)]">
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,.14),transparent_35%,rgba(0,0,0,.42))]" />
<div className="absolute inset-x-1 bottom-1 truncate font-mono text-[8px] font-bold uppercase tracking-wider text-white/85 drop-shadow">WAND</div>
</div>
);
};

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