mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-28 17:01:04 +00:00
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:
@@ -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"
|
||||
@@ -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
@@ -142,4 +142,7 @@ packages
|
||||
|
||||
# App settings (user preferences)
|
||||
appsettings.json
|
||||
*DotSettings.user
|
||||
*DotSettings.user
|
||||
.tmp
|
||||
.source
|
||||
web-panel/bridge/wand-remote-bridge.cjs
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||

|
||||
|
||||
---
|
||||
# WandEnhancer
|
||||
|
||||
[](https://gitlab.com/kitbyte/wand-enhancer)
|
||||
[](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
|
||||
|
||||
---
|
||||
|
||||
[](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date)
|
||||
[](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date)
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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")]
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=""$(ILRepackExe)" /allowMultiple /copyattrs /out:"$(OutputPath)$(AssemblyName).exe" "$(MainAssembly)" $(DllList)" />
|
||||
<Delete Files="@(AssemblyList)" ContinueOnError="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 177 KiB |
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)
|
||||
}
|
||||
+22
-21
@@ -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)
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
Reference in New Issue
Block a user