mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-29 15:01:16 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b776c52fc | |||
| 9c88caf49b | |||
| 537608c381 | |||
| b0279ee812 | |||
| c007e11cce | |||
| a7f0eae670 | |||
| 1c9a8fb780 | |||
| ec07dc63f0 | |||
| 88556ec70f | |||
| c02bad919d | |||
| b9faf80f86 | |||
| 6395ca3a27 | |||
| 4ce47dc6d2 | |||
| 8c6d87671c | |||
| a0b3968d33 | |||
| 6906a67a2e | |||
| 37ce6b3f4a | |||
| 8756e41fb9 | |||
| 544b9f0fb0 | |||
| a4f3a57f97 | |||
| 1f5ba9fc95 | |||
| 710d014e6d | |||
| a810e5b549 | |||
| 13759b1db6 | |||
| 3b2f373946 | |||
| f78f9609b5 | |||
| 633984ed3b |
@@ -0,0 +1,4 @@
|
||||
# The web panel is the bundled frontend shipped inside the C# patcher.
|
||||
# Mark it as vendored so GitHub Linguist keeps it out of the repository's
|
||||
# language statistics — the project is a C# app, not a TypeScript one.
|
||||
web-panel/** linguist-vendored
|
||||
@@ -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,8 @@
|
||||
patreon: kitbyte
|
||||
|
||||
custom: [
|
||||
"https://www.patreon.com/kitbyte/gift",
|
||||
"https://tronscan.org/#/address/TQdvau8pAy5Tg1Aa588tTcPCFgbcHtuoxc",
|
||||
"https://www.blockchain.com/explorer/addresses/btc/1EZKDcyU8REm9JW5xwXJqSpn5Xaq5yAWWX",
|
||||
"https://etherscan.io/address/0xd904d9d0557f88bbb1c4ab3582b4ca0d8a730e8d"
|
||||
]
|
||||
@@ -8,16 +8,16 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**🚨 STOP BEFORE YOU POST: THIS PROJECT HAS NO OFFICIAL YOUTUBE TUTORIALS. 🚨**
|
||||
If you downloaded an executable from a YouTube video link, you downloaded a virus/password stealer from a scammer. **Run an antivirus immediately and change your passwords.** Do not open issues about stolen accounts here — this project is not related to those videos.
|
||||
**STOP BEFORE YOU POST: THIS PROJECT DOES NOT PUBLISH OFFICIAL EXECUTABLE DOWNLOADS.**
|
||||
Build WandEnhancer yourself from your own fork or local source. If you downloaded an executable from YouTube, Discord, a mirror, or any other third-party website, treat it as untrusted. Do not open issues about third-party binaries or stolen accounts here - this project is not related to those downloads.
|
||||
|
||||
- type: checkboxes
|
||||
id: scam_check
|
||||
id: build_source_check
|
||||
attributes:
|
||||
label: ⚠️ Download Source Confirmation (REQUIRED)
|
||||
label: Build Source Confirmation (REQUIRED)
|
||||
description: You must check this box to proceed.
|
||||
options:
|
||||
- label: I confirm that I downloaded this tool DIRECTLY from this official GitHub repository, and NOT from a YouTube video, Discord, or any other third-party website.
|
||||
- label: I confirm that I built WandEnhancer myself from my own fork or local source, and did not download an executable from YouTube, Discord, mirrors, issue comments, or any other third-party website.
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
@@ -67,4 +67,4 @@ body:
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: Screenshots & Additional context
|
||||
description: Drag and drop screenshots here, or add any other context about the problem.
|
||||
description: Drag and drop screenshots here, or add any other context about the problem.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Build executable
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web-panel/pnpm-lock.yaml
|
||||
|
||||
- name: Build unsigned executable
|
||||
shell: pwsh
|
||||
run: ./build.ps1 -Configuration Release
|
||||
|
||||
- name: Upload unsigned executable
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: WandEnhancer-unsigned
|
||||
path: WandEnhancer/bin/Release/WandEnhancer.exe
|
||||
if-no-files-found: error
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
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: 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@v5
|
||||
|
||||
- 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
|
||||
@@ -6,25 +6,42 @@ This repository patches the Wand Electron app from a .NET Framework WPF desktop
|
||||
|
||||
## Remote Web Panel
|
||||
|
||||
- The default local remote port is `3223`. Keep C# and frontend constants aligned.
|
||||
- The default local remote port is `3223`. Keep bridge and frontend constants aligned; C# must not duplicate the presentation URL or port.
|
||||
- The embedded panel must stay small because the desktop patcher embeds it and then injects it into Wand's `app.asar`.
|
||||
- Remote tooltip links and every rendered `remote-qr-code` are redirected by `web-panel/bridge/scripts/default/remote-popup-cleanup.js`. It reuses Wand's loaded QR renderer through the webpack runtime, keeps the local URL visible as a fallback, and hides the Pro onboarding remote mobile app card. Do not reintroduce C# ASAR patches for the tooltip URL or QR component; a changed UI bundle must not make the whole remote-panel patch fail.
|
||||
- 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 TypeScript under `web-panel/bridge/src/`, but production runtime must be bundled/minified into `web-panel/dist/bridge.cjs` by `pnpm run build:bridge`. Do not copy bridge source into Wand or embed it 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.
|
||||
- UI uses Tailwind CSS and lightweight local primitives under `web-panel/src/shared/ui/`.
|
||||
- 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.
|
||||
- Pro activation is a C# asar patch (`EPatchType.ActivatePro`, independent of the remote panel / bridge). It rewrites three account-returning service methods to inject `subscription:{period:"yearly",state:"active"}` into the response before it reaches the store: `getUserAccount` and `setAccountWandBrandExperience` (Resolver-style, service field via `<service_name>` placeholder) and `setAccountLanguage` (`BuildSetAccountLanguagePatch` PatchFactory — captures the real param names + the original `post("/v3/account/language",{...})` expr and wraps `.then`). A fourth patch (`setAccountReducer`) rewrites the `ACTION_SET_ACCOUNT` store reducer so any account write (periodic `refreshAccount`, push/profile updates, etc.) keeps Pro even when it bypasses those API methods. Pro is `am(account) = !!account.subscription` (flags/512 are irrelevant). `setAccountLanguage` is the one the original two patches missed, which is why Pro dropped on language change. If a future Wand build changes these method bodies, re-derive the regexes against the live `app-*.bundle.js` (do NOT trust `.source/new` — it is a different version).
|
||||
|
||||
## 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/` MUST go through `web-panel/src/shared/storage.ts` (`loadJson` / `saveJson` / `loadStringSet` / `saveStringSet`). Do not reintroduce per-capability `try/catch` + `JSON.parse` duplication. Trainer/game storage IDs are derived through the shared `getTrainerStorageId(trainer)` helper; do not re-implement the `gameId → titleId → trainerId → 'global'` precedence inline.
|
||||
- Shared web protocol version, port, and HTTP/WS paths live in `web-panel/protocol/web-contract.json`. Bridge-only IPC channels, WS opcodes, and renderer injection delays live in `web-panel/bridge/src/constants.ts`. Do not redeclare these values inline.
|
||||
- UI string-union types follow the `E*` enum convention from `.claude/rules/frontend-conventions.md` (currently `ECheatType` in `protocol/messages.ts`, `EConnectionStatus` in `remote-session/remote-session.reducer.ts`); wire string values must remain on the right-hand side of enum members. Reducer action tags stay as discriminated-union string literals.
|
||||
- Cheat input controls live one-per-file under `web-panel/src/trainer/controls/`; shared `SliderTrack` / `StepButton` / `ControlInternalProps` are in `controls/shared.tsx` and number formatting helpers in `controls/format-number.ts`. `controls/CheatControl.tsx` remains a thin dispatcher keyed by `ECheatType`.
|
||||
- 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`.
|
||||
- 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`.
|
||||
- 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`.
|
||||
|
||||
+25
-25
@@ -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,41 +27,36 @@ 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();
|
||||
}
|
||||
|
||||
|
||||
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 +64,13 @@ 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);
|
||||
long fileSize = file.Stat is FileInfo fi ? fi.Length : 0;
|
||||
var placeholder = IntegrityHelper.CreatePlaceholder(fileSize);
|
||||
files.Add(new Disk.BasicFileInfo { Filename = filename, Unpack = shouldUnpack });
|
||||
filesystem.InsertFile(filename, shouldUnpack, file);
|
||||
filesystem.InsertFile(filename, shouldUnpack, file, placeholder);
|
||||
break;
|
||||
case FileType.Link:
|
||||
throw new NotImplementedException();
|
||||
@@ -81,14 +79,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,6 +1,8 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AsarSharp.Integrity;
|
||||
using AsarSharp.PickleTools;
|
||||
using AsarSharp.Utils;
|
||||
using Newtonsoft.Json;
|
||||
@@ -9,7 +11,9 @@ namespace AsarSharp.AsarFileSystem
|
||||
{
|
||||
public static class Disk
|
||||
{
|
||||
private static Dictionary<string, Filesystem> _filesystemCache = new Dictionary<string, Filesystem>();
|
||||
private const int StreamBufferSize = 1024 * 1024;
|
||||
private static readonly ConcurrentDictionary<string, Filesystem> _filesystemCache =
|
||||
new ConcurrentDictionary<string, Filesystem>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public class ArchiveHeader
|
||||
{
|
||||
@@ -29,36 +33,29 @@ namespace AsarSharp.AsarFileSystem
|
||||
public string Filename { get; set; }
|
||||
public bool Unpack { get; set; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
#region Reading
|
||||
|
||||
|
||||
public static ArchiveHeader ReadArchiveHeaderSync(string archivePath)
|
||||
{
|
||||
using (FileStream fs = File.OpenRead(archivePath))
|
||||
{
|
||||
// read the size of the header (8 bytes)
|
||||
using (var fs = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
65536, FileOptions.SequentialScan))
|
||||
{
|
||||
byte[] sizeBuf = new byte[8];
|
||||
if (fs.Read(sizeBuf, 0, 8) != 8)
|
||||
{
|
||||
throw new Exception("Unable to read header size");
|
||||
}
|
||||
|
||||
|
||||
var sizePickle = Pickle.CreateFromBuffer(sizeBuf);
|
||||
var size = sizePickle.CreateIterator().ReadUInt32();
|
||||
|
||||
// Read the header of the specified size
|
||||
|
||||
var headerBuf = new byte[size];
|
||||
if(fs.Read(headerBuf, 0, (int)size) != size)
|
||||
{
|
||||
if (fs.Read(headerBuf, 0, (int)size) != size)
|
||||
throw new Exception("Unable to read header");
|
||||
}
|
||||
|
||||
|
||||
var headerPickle = Pickle.CreateFromBuffer(headerBuf);
|
||||
var header = headerPickle.CreateIterator().ReadString();
|
||||
|
||||
var headerObj = JsonConvert.DeserializeObject<FilesystemEntry>(header);
|
||||
|
||||
|
||||
return new ArchiveHeader
|
||||
{
|
||||
Header = headerObj,
|
||||
@@ -67,137 +64,161 @@ namespace AsarSharp.AsarFileSystem
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static Filesystem ReadFilesystemSync(string archivePath)
|
||||
{
|
||||
if (!_filesystemCache.ContainsKey(archivePath) || _filesystemCache[archivePath] == null)
|
||||
return _filesystemCache.GetOrAdd(archivePath, key =>
|
||||
{
|
||||
ArchiveHeader header = ReadArchiveHeaderSync(archivePath);
|
||||
Filesystem filesystem = new Filesystem(archivePath);
|
||||
var header = ReadArchiveHeaderSync(key);
|
||||
var filesystem = new Filesystem(key);
|
||||
filesystem.SetHeader(header.Header, header.HeaderSize);
|
||||
_filesystemCache[archivePath] = filesystem;
|
||||
}
|
||||
|
||||
return _filesystemCache[archivePath];
|
||||
return filesystem;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public static byte[] ReadFileSync(Filesystem filesystem, string filename, FilesystemEntry info)
|
||||
{
|
||||
if (!info.IsFile || !info.Size.HasValue)
|
||||
{
|
||||
throw new ArgumentException("Entry is not a file", nameof(info));
|
||||
}
|
||||
|
||||
long size = info.Size.Value;
|
||||
byte[] buffer = new byte[size];
|
||||
|
||||
if (size <= 0)
|
||||
{
|
||||
return buffer;
|
||||
}
|
||||
|
||||
|
||||
if (size <= 0) return buffer;
|
||||
|
||||
if (info.Unpacked == true)
|
||||
{
|
||||
// It's an unpacked file, read it directly
|
||||
string filePath = Path.Combine($"{filesystem.GetRootPath()}.unpacked", filename);
|
||||
return File.ReadAllBytes(filePath);
|
||||
}
|
||||
|
||||
// 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, 65536, FileOptions.RandomAccess))
|
||||
{
|
||||
// Important: the offset must take into account the size of the Pickle header (8 bytes)
|
||||
// and the size of the header itself
|
||||
long offset = 8 + filesystem.GetHeaderSize() + long.Parse(info.Offset);
|
||||
fs.Position = offset;
|
||||
|
||||
// Read the whole file at once
|
||||
int bytesRead = fs.Read(buffer, 0, (int)size);
|
||||
if (bytesRead != size)
|
||||
{
|
||||
throw new Exception($"Failed to read entire file, got {bytesRead} bytes instead of {size}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
public static bool UncacheFilesystem(string archivePath)
|
||||
{
|
||||
if (_filesystemCache.ContainsKey(archivePath))
|
||||
{
|
||||
_filesystemCache.Remove(archivePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return _filesystemCache.TryRemove(archivePath, out _);
|
||||
}
|
||||
|
||||
public static void UncacheAll()
|
||||
{
|
||||
_filesystemCache.Clear();
|
||||
}
|
||||
|
||||
|
||||
public static void CopyFile(string dest, string rootPath, string filename)
|
||||
{
|
||||
if(dest == null || rootPath == null || filename == null)
|
||||
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.GetFullPath(Path.Combine(rootPath, filename));
|
||||
string destPath = Path.GetFullPath(Path.Combine(dest, filename));
|
||||
|
||||
if (string.Equals(sourcePath, destPath, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
}
|
||||
|
||||
string sourcePath = Path.Combine(rootPath, filename);
|
||||
string destPath = Path.Combine(dest, filename);
|
||||
|
||||
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 src = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan))
|
||||
using (var dst = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan))
|
||||
{
|
||||
sourceStream.CopyTo(destinationStream);
|
||||
src.CopyTo(dst, StreamBufferSize);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static void WriteFileSystem(string dest, Filesystem fileSystem,
|
||||
FilesystemFilesAndLinks lists,
|
||||
Dictionary<string, CrawledFileType> metadata)
|
||||
FilesystemFilesAndLinks lists, Dictionary<string, CrawledFileType> metadata)
|
||||
{
|
||||
var fsHeader = fileSystem.GetHeader();
|
||||
var headerPickle = Pickle.CreateEmpty();
|
||||
var serializerSettings = new JsonSerializerSettings()
|
||||
{ NullValueHandling = NullValueHandling.Ignore, DefaultValueHandling = DefaultValueHandling.Ignore } ;
|
||||
|
||||
var headerJson = JsonConvert.SerializeObject(fsHeader,serializerSettings);
|
||||
headerPickle.WriteString(headerJson);
|
||||
var headerBuf = headerPickle.ToBuffer();
|
||||
|
||||
var sizePickle = Pickle.CreateEmpty();
|
||||
sizePickle.WriteUInt32((uint)headerBuf.Length);
|
||||
var sizeBuf = sizePickle.ToBuffer();
|
||||
|
||||
using (FileStream fs = File.Create(dest))
|
||||
var serializerSettings = new JsonSerializerSettings
|
||||
{
|
||||
fs.Write(sizeBuf, 0, sizeBuf.Length);
|
||||
fs.Write(headerBuf, 0, headerBuf.Length);
|
||||
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
DefaultValueHandling = DefaultValueHandling.Ignore
|
||||
};
|
||||
|
||||
// --- Phase 1: write placeholder header ---
|
||||
string headerJson = JsonConvert.SerializeObject(fileSystem.GetHeader(), serializerSettings);
|
||||
var headerPickle = Pickle.CreateEmpty();
|
||||
headerPickle.WriteString(headerJson);
|
||||
|
||||
var sizePickle = Pickle.CreateEmpty();
|
||||
sizePickle.WriteUInt32((uint)headerPickle.GetTotalSize());
|
||||
int sizePickleSize = sizePickle.GetTotalSize();
|
||||
|
||||
var buf = new byte[StreamBufferSize];
|
||||
var blockBuf = new byte[4 * 1024 * 1024]; // shared across all files — avoids 4MB alloc per file
|
||||
|
||||
using (var fs = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan))
|
||||
{
|
||||
sizePickle.WriteTo(fs);
|
||||
headerPickle.WriteTo(fs);
|
||||
|
||||
// --- Phase 2: stream files, hash in one pass, patch nodes in-memory ---
|
||||
foreach (var file in lists.Files)
|
||||
{
|
||||
if (file.Unpack)
|
||||
{
|
||||
var filename = Extensions.GetRelativePath(fileSystem.GetRootPath(), file.Filename);
|
||||
CopyFile($"{dest}.unpacked", fileSystem.GetRootPath(), filename);
|
||||
var relName = Extensions.GetRelativePath(fileSystem.GetRootPath(), file.Filename);
|
||||
CopyFile($"{dest}.unpacked", fileSystem.GetRootPath(), relName);
|
||||
CopyAndHash(file.Filename, null, buf, blockBuf, fileSystem);
|
||||
continue;
|
||||
}
|
||||
using (var transformedFileStream = new FileStream(file.Filename, FileMode.Open, FileAccess.Read))
|
||||
{
|
||||
transformedFileStream.CopyTo(fs);
|
||||
}
|
||||
|
||||
CopyAndHash(file.Filename, fs, buf, blockBuf, fileSystem);
|
||||
}
|
||||
|
||||
// --- Phase 3: re-serialize header with real hashes, seek back, overwrite ---
|
||||
string patchedJson = JsonConvert.SerializeObject(fileSystem.GetHeader(), serializerSettings);
|
||||
var patchedPickle = Pickle.CreateEmpty();
|
||||
patchedPickle.WriteString(patchedJson);
|
||||
|
||||
var patchedSizePickle = Pickle.CreateEmpty();
|
||||
patchedSizePickle.WriteUInt32((uint)patchedPickle.GetTotalSize());
|
||||
|
||||
fs.Position = 0;
|
||||
patchedSizePickle.WriteTo(fs);
|
||||
patchedPickle.WriteTo(fs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyAndHash(string srcPath, Stream dest, byte[] buf, byte[] blockBuf, Filesystem fs)
|
||||
{
|
||||
string relPath = Extensions.GetRelativePath(fs.GetRootPath(), srcPath);
|
||||
var node = fs.GetNode(relPath, followLinks: false);
|
||||
|
||||
long fileSize = node?.Size ?? 0;
|
||||
int estimatedBlocks = fileSize > 0 ? (int)((fileSize + 4 * 1024 * 1024 - 1) / (4 * 1024 * 1024)) : 0;
|
||||
|
||||
using (var hasher = new IntegrityHelper.StreamingHasher(estimatedBlocks, blockBuf))
|
||||
using (var src = new FileStream(srcPath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan))
|
||||
{
|
||||
int read;
|
||||
while ((read = src.Read(buf, 0, buf.Length)) > 0)
|
||||
{
|
||||
hasher.Append(buf, 0, read);
|
||||
dest?.Write(buf, 0, read);
|
||||
}
|
||||
|
||||
if (node != null)
|
||||
node.Integrity = hasher.Finalise();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AsarSharp.Integrity;
|
||||
@@ -13,7 +13,7 @@ namespace AsarSharp.AsarFileSystem
|
||||
private int _headerSize;
|
||||
private long _offset;
|
||||
|
||||
private const uint UINT32_MAX = 0xFFFFFFFF; // 2^32 - 1
|
||||
private const uint UINT32_MAX = 0xFFFFFFFF;
|
||||
|
||||
public Filesystem(string src)
|
||||
{
|
||||
@@ -23,20 +23,9 @@ namespace AsarSharp.AsarFileSystem
|
||||
_offset = 0;
|
||||
}
|
||||
|
||||
public string GetRootPath()
|
||||
{
|
||||
return _src;
|
||||
}
|
||||
|
||||
public FilesystemEntry GetHeader()
|
||||
{
|
||||
return _header;
|
||||
}
|
||||
|
||||
public int GetHeaderSize()
|
||||
{
|
||||
return _headerSize;
|
||||
}
|
||||
public string GetRootPath() => _src;
|
||||
public FilesystemEntry GetHeader() => _header;
|
||||
public int GetHeaderSize() => _headerSize;
|
||||
|
||||
public void SetHeader(FilesystemEntry header, int headerSize)
|
||||
{
|
||||
@@ -47,82 +36,94 @@ namespace AsarSharp.AsarFileSystem
|
||||
public FilesystemEntry SearchNodeFromDirectory(string p)
|
||||
{
|
||||
FilesystemEntry json = _header;
|
||||
|
||||
// Normalize path delimiters to system delimiters
|
||||
p = p.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||
|
||||
string[] dirs = p.Split(Path.DirectorySeparatorChar);
|
||||
|
||||
foreach (string dir in dirs)
|
||||
|
||||
int len = p.Length;
|
||||
int start = 0;
|
||||
|
||||
// skip leading separators
|
||||
while (start < len && (p[start] == '/' || p[start] == '\\')) start++;
|
||||
|
||||
while (start < len)
|
||||
{
|
||||
if (dir == "." || string.IsNullOrEmpty(dir)) continue;
|
||||
|
||||
if (json.IsDirectory)
|
||||
// find next separator
|
||||
int end = start;
|
||||
while (end < len && p[end] != '/' && p[end] != '\\') end++;
|
||||
|
||||
int segLen = end - start;
|
||||
if (segLen == 0 || (segLen == 1 && p[start] == '.'))
|
||||
{
|
||||
if (!json.Files.ContainsKey(dir))
|
||||
{
|
||||
json.Files[dir] = new FilesystemEntry { Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal) };
|
||||
}
|
||||
json = json.Files[dir];
|
||||
start = end + 1;
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
string seg = p.Substring(start, segLen);
|
||||
|
||||
if (!json.IsDirectory)
|
||||
throw new Exception($"Unexpected directory state while traversing: {p}");
|
||||
|
||||
if (!json.Files.TryGetValue(seg, out var child))
|
||||
{
|
||||
child = new FilesystemEntry { Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal) };
|
||||
json.Files[seg] = child;
|
||||
}
|
||||
json = child;
|
||||
start = end + 1;
|
||||
}
|
||||
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
|
||||
public (FilesystemEntry parent, string name) SearchNodeFromPathWithParent(string p)
|
||||
{
|
||||
string rel = Extensions.GetRelativePath(_src, p);
|
||||
if (string.IsNullOrEmpty(rel))
|
||||
return (_header, string.Empty);
|
||||
|
||||
string name = Path.GetFileName(rel);
|
||||
string dir = Extensions.GetDirectoryName(rel);
|
||||
var parent = SearchNodeFromDirectory(dir);
|
||||
|
||||
if (parent.Files == null)
|
||||
parent.Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal);
|
||||
|
||||
if (!parent.Files.ContainsKey(name))
|
||||
parent.Files[name] = new FilesystemEntry();
|
||||
|
||||
return (parent, name);
|
||||
}
|
||||
|
||||
public List<string> ListFiles(bool isPack = false)
|
||||
{
|
||||
var files = new List<string>();
|
||||
|
||||
FillFilesFromMetadata("/", _header);
|
||||
return files;
|
||||
|
||||
void FillFilesFromMetadata(string basePath, FilesystemEntry metadata)
|
||||
{
|
||||
if (!metadata.IsDirectory)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!metadata.IsDirectory) return;
|
||||
foreach (var entry in metadata.Files)
|
||||
{
|
||||
string childPath = entry.Key;
|
||||
FilesystemEntry childMetadata = entry.Value;
|
||||
string fullPath = Path.Combine(basePath, childPath).Replace('\\', '/');
|
||||
|
||||
string packState =
|
||||
childMetadata.Unpacked == true ? "unpack" : "pack ";
|
||||
|
||||
string fullPath = Path.Combine(basePath, entry.Key).Replace('\\', '/');
|
||||
string packState = entry.Value.Unpacked == true ? "unpack" : "pack ";
|
||||
files.Add(isPack ? $"{packState} : {fullPath}" : fullPath);
|
||||
FillFilesFromMetadata(fullPath, childMetadata);
|
||||
FillFilesFromMetadata(fullPath, entry.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public FilesystemEntry GetNode(string p, bool followLinks = true)
|
||||
{
|
||||
// Normalize path delimiters
|
||||
p = p.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||
|
||||
FilesystemEntry node = SearchNodeFromDirectory(Extensions.GetDirectoryName(p));
|
||||
string name = Path.GetFileName(p);
|
||||
|
||||
// Process symbolic links
|
||||
|
||||
if (node.IsLink && followLinks)
|
||||
{
|
||||
return GetNode(Path.Combine(node.Link, name));
|
||||
}
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
if (node.IsDirectory && node.Files.TryGetValue(name, out var entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -132,104 +133,62 @@ namespace AsarSharp.AsarFileSystem
|
||||
public FilesystemEntry GetFile(string p, bool followLinks = true)
|
||||
{
|
||||
FilesystemEntry info = GetNode(p, followLinks);
|
||||
|
||||
if (info == null)
|
||||
{
|
||||
throw new Exception($"\"{p}\" was not found in this archive");
|
||||
}
|
||||
|
||||
// If followLinks=false, do not allow symbolic links (TODO)
|
||||
if (info.IsLink && followLinks)
|
||||
{
|
||||
return GetFile(info.Link, followLinks);
|
||||
}
|
||||
|
||||
if (info == null) throw new Exception($"\"{p}\" was not found in this archive");
|
||||
if (info.IsLink && followLinks) return GetFile(info.Link, followLinks);
|
||||
return info;
|
||||
}
|
||||
|
||||
public static string ReadLink(string path)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
return Path.GetFileName(path);
|
||||
// TODO , NOT IMPLEMENTED
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public static string ReadLink(string path) => throw new NotImplementedException();
|
||||
|
||||
#region Writing
|
||||
|
||||
|
||||
public FilesystemEntry SearchNodeFromPath(string p)
|
||||
{
|
||||
p = Extensions.GetRelativePath(_src, p);
|
||||
|
||||
if (string.IsNullOrEmpty(p))
|
||||
{
|
||||
return _header;
|
||||
}
|
||||
|
||||
var name = Path.GetFileName(p);
|
||||
var node = SearchNodeFromDirectory(Extensions.GetDirectoryName(p));
|
||||
|
||||
if (node.Files == null)
|
||||
{
|
||||
node.Files = new Dictionary<string, FilesystemEntry>();
|
||||
}
|
||||
|
||||
if (!node.Files.ContainsKey(name))
|
||||
{
|
||||
node.Files[name] = new FilesystemEntry();
|
||||
}
|
||||
|
||||
return node.Files[name];
|
||||
var (parent, name) = SearchNodeFromPathWithParent(p);
|
||||
if (string.IsNullOrEmpty(name)) return _header;
|
||||
return parent.Files[name];
|
||||
}
|
||||
|
||||
|
||||
public void InsertDirectory(string p, bool unpack)
|
||||
{
|
||||
FilesystemEntry node = SearchNodeFromPath(p);
|
||||
node.Files = node.Files ?? new Dictionary<string, FilesystemEntry>();
|
||||
node.Files = node.Files ?? new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal);
|
||||
node.Unpacked = unpack;
|
||||
}
|
||||
|
||||
public void InsertFile(string path, bool shouldUnpack, CrawledFileType file)
|
||||
|
||||
public void InsertFile(string path, bool shouldUnpack, CrawledFileType file,
|
||||
IntegrityHelper.FileIntegrity precomputedIntegrity = null)
|
||||
{
|
||||
var dirName = Path.GetDirectoryName(path);
|
||||
var dirNode = SearchNodeFromPath(dirName);
|
||||
var (dirNode, _) = SearchNodeFromPathWithParent(Path.GetDirectoryName(path) ?? path);
|
||||
var node = SearchNodeFromPath(path);
|
||||
|
||||
long size = 0;
|
||||
if (file.Stat is FileInfo fileInfo)
|
||||
{
|
||||
size = fileInfo.Length;
|
||||
}
|
||||
long size;
|
||||
if (file.Stat is FileInfo fi)
|
||||
size = fi.Length;
|
||||
else
|
||||
{
|
||||
throw new Exception($"{path}: stat is not a file");
|
||||
}
|
||||
|
||||
|
||||
if (shouldUnpack || dirNode.Unpacked == true)
|
||||
{
|
||||
node.Size = size;
|
||||
node.Unpacked = true;
|
||||
node.Integrity = IntegrityHelper.GetFileIntegrity(path);
|
||||
node.Integrity = precomputedIntegrity ?? IntegrityHelper.GetFileIntegrity(path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that the file size does not exceed UINT32_MAX
|
||||
if (size > UINT32_MAX)
|
||||
{
|
||||
throw new Exception($"{path}: file size cannot be larger than 4.2GB");
|
||||
}
|
||||
|
||||
node.Size = size;
|
||||
node.Offset = _offset.ToString();
|
||||
node.Integrity = IntegrityHelper.GetFileIntegrity(path);
|
||||
node.Integrity = precomputedIntegrity ?? IntegrityHelper.GetFileIntegrity(path);
|
||||
|
||||
if (!Extensions.IsWindowsPlatform() && (file.Stat.Attributes & FileAttributes.Hidden) != 0)
|
||||
{
|
||||
node.Executable = true;
|
||||
}
|
||||
|
||||
_offset += size;
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AsarSharp.Utils;
|
||||
|
||||
namespace AsarSharp.AsarFileSystem
|
||||
@@ -25,136 +24,108 @@ namespace AsarSharp.AsarFileSystem
|
||||
Directory,
|
||||
Link
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
{
|
||||
return new CrawledFileType { Type = FileType.Link, Stat = linkInfo };
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
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 = info };
|
||||
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>();
|
||||
var crawled = CrawlIterative(dir);
|
||||
var results = crawled.Select(filename => new { filename, type = DetermineFileType(filename) }).ToList();
|
||||
|
||||
var links = new List<string>();
|
||||
var filenames = new List<string>();
|
||||
var links = new List<string>();
|
||||
|
||||
foreach (var result in results.Where(result => result.type != null))
|
||||
foreach (var fullPath in CrawlIterative(dir))
|
||||
{
|
||||
metadata[result.filename] = result.type;
|
||||
if (result.type.Type == FileType.Link)
|
||||
{
|
||||
links.Add(result.filename);
|
||||
}
|
||||
filenames.Add(result.filename);
|
||||
var type = DetermineFileType(fullPath);
|
||||
if (type == null) continue;
|
||||
metadata[fullPath] = type;
|
||||
if (type.Type == FileType.Link) links.Add(fullPath);
|
||||
filenames.Add(fullPath);
|
||||
}
|
||||
|
||||
var filteredFilenames = new List<string>();
|
||||
if (links.Count == 0) return (filenames, metadata);
|
||||
|
||||
var filtered = new List<string>(filenames.Count);
|
||||
foreach (var filename in filenames)
|
||||
{
|
||||
var exactLinkIndex = links.FindIndex(link => filename == link);
|
||||
var isValid = true;
|
||||
bool isValid = true;
|
||||
string fileDir = Path.GetDirectoryName(filename) ?? string.Empty;
|
||||
|
||||
for (var i = 0; i < links.Count; i++)
|
||||
foreach (var link in links)
|
||||
{
|
||||
if (i == exactLinkIndex)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (string.Equals(filename, link, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
var link = links[i];
|
||||
var isFileWithinSymlinkDir = filename.StartsWith(link, StringComparison.OrdinalIgnoreCase);
|
||||
var relativePath = Extensions.GetRelativePath(link, Path.GetDirectoryName(filename) ?? string.Empty);
|
||||
|
||||
if (isFileWithinSymlinkDir && !relativePath.StartsWith("..", StringComparison.Ordinal))
|
||||
if (filename.StartsWith(link, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
isValid = false;
|
||||
break;
|
||||
string rel = Extensions.GetRelativePath(link, fileDir);
|
||||
if (!rel.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
isValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid)
|
||||
{
|
||||
filteredFilenames.Add(filename);
|
||||
}
|
||||
if (isValid) filtered.Add(filename);
|
||||
}
|
||||
|
||||
return (filteredFilenames, metadata);
|
||||
return (filtered, metadata);
|
||||
}
|
||||
|
||||
|
||||
// (File order is not important!!!)
|
||||
public static List<string> CrawlIterative(string dir)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var stack = new Stack<string>();
|
||||
var stack = new Stack<DirectoryInfo>();
|
||||
|
||||
|
||||
string basePath = Extensions.GetBasePath(dir);
|
||||
if (!Directory.Exists(basePath)) return result;
|
||||
|
||||
if (!Directory.Exists(basePath))
|
||||
return result;
|
||||
|
||||
// Add only the base directory to the stack, but not to the result
|
||||
stack.Push(basePath);
|
||||
stack.Push(new DirectoryInfo(basePath));
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
string currentDir = stack.Pop();
|
||||
|
||||
var current = stack.Pop();
|
||||
FileSystemInfo[] entries;
|
||||
try
|
||||
{
|
||||
// Add all files from the current directory
|
||||
result.AddRange(Directory.GetFiles(currentDir, "*", SearchOption.TopDirectoryOnly));
|
||||
|
||||
// Add subdirectories to the results and to the stack
|
||||
foreach (var directory in Directory.GetDirectories(currentDir, "*",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
// Add subdirectories to the result
|
||||
if (directory != basePath) // Do not add a base directory
|
||||
{
|
||||
result.Add(directory);
|
||||
}
|
||||
|
||||
// Add to the stack for processing
|
||||
stack.Push(directory);
|
||||
}
|
||||
entries = current.GetFileSystemInfos();
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Skip directories to which there is no access
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
result.Add(entry.FullName);
|
||||
if (entry is DirectoryInfo subDir)
|
||||
stack.Push(subDir);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
@@ -9,61 +9,148 @@ namespace AsarSharp.Integrity
|
||||
public static class IntegrityHelper
|
||||
{
|
||||
private const string ALGORITHM = "SHA256";
|
||||
// 4MB default block size
|
||||
private const int BLOCK_SIZE = 4 * 1024 * 1024;
|
||||
public const string PLACEHOLDER_HASH = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
private static readonly char[] HexDigits = "0123456789abcdef".ToCharArray();
|
||||
|
||||
public class FileIntegrity
|
||||
{
|
||||
[JsonProperty("algorithm")]
|
||||
public string Algorithm { get; set; }
|
||||
|
||||
|
||||
[JsonProperty("hash")]
|
||||
public string Hash { get; set; }
|
||||
|
||||
|
||||
[JsonProperty("blockSize")]
|
||||
public int BlockSize { get; set; }
|
||||
|
||||
|
||||
[JsonProperty("blocks")]
|
||||
public List<string> Blocks { get; set; }
|
||||
}
|
||||
|
||||
public static FileIntegrity GetFileIntegrity(string path)
|
||||
public static FileIntegrity CreatePlaceholder(long fileSize)
|
||||
{
|
||||
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
using(var fileHash = SHA256.Create())
|
||||
{
|
||||
int blockCount = fileSize > 0 ? (int)((fileSize + BLOCK_SIZE - 1) / BLOCK_SIZE) : 0;
|
||||
var blocks = new List<string>(blockCount);
|
||||
for (int i = 0; i < blockCount; i++)
|
||||
blocks.Add(PLACEHOLDER_HASH);
|
||||
|
||||
var blockHashes = new List<string>();
|
||||
var buffer = new byte[BLOCK_SIZE];
|
||||
return new FileIntegrity
|
||||
{
|
||||
Algorithm = ALGORITHM,
|
||||
Hash = PLACEHOLDER_HASH,
|
||||
BlockSize = BLOCK_SIZE,
|
||||
Blocks = blocks,
|
||||
};
|
||||
}
|
||||
|
||||
public static FileIntegrity GetFileIntegrity(string path, byte[] reusableBuffer = null)
|
||||
{
|
||||
bool ownBuffer = reusableBuffer == null;
|
||||
if (ownBuffer) reusableBuffer = new byte[BLOCK_SIZE];
|
||||
|
||||
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
65536, FileOptions.SequentialScan))
|
||||
using (var fileHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256))
|
||||
using (var blockHash = SHA256.Create())
|
||||
{
|
||||
int estimatedBlockCount = fileStream.Length > 0
|
||||
? (int)((fileStream.Length + BLOCK_SIZE - 1) / BLOCK_SIZE)
|
||||
: 0;
|
||||
var blockHashes = new List<string>(estimatedBlockCount);
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = fileStream.Read(buffer, 0, BLOCK_SIZE)) > 0)
|
||||
while ((bytesRead = fileStream.Read(reusableBuffer, 0, reusableBuffer.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(ToLowerHex(blockHash.ComputeHash(reusableBuffer, 0, bytesRead)));
|
||||
fileHash.AppendData(reusableBuffer, 0, bytesRead);
|
||||
}
|
||||
|
||||
fileHash.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
||||
|
||||
return new FileIntegrity
|
||||
{
|
||||
Algorithm = ALGORITHM,
|
||||
Hash = BitConverter.ToString(fileHash.Hash).Replace("-", "").ToLowerInvariant(),
|
||||
Hash = ToLowerHex(fileHash.GetHashAndReset()),
|
||||
BlockSize = BLOCK_SIZE,
|
||||
Blocks = blockHashes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string HashBlock(byte[] block)
|
||||
public sealed class StreamingHasher : IDisposable
|
||||
{
|
||||
using (var sha256 = SHA256.Create())
|
||||
private readonly IncrementalHash _fileHash;
|
||||
private readonly SHA256 _blockHash;
|
||||
private readonly byte[] _blockBuf;
|
||||
private int _blockFill;
|
||||
private readonly List<string> _blockHashes;
|
||||
|
||||
public StreamingHasher(int estimatedBlocks = 0, byte[] sharedBlockBuffer = null)
|
||||
{
|
||||
var hash = sha256.ComputeHash(block);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
_fileHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
_blockHash = SHA256.Create();
|
||||
_blockBuf = sharedBlockBuffer ?? new byte[BLOCK_SIZE];
|
||||
_blockFill = 0;
|
||||
_blockHashes = new List<string>(estimatedBlocks);
|
||||
}
|
||||
|
||||
public void Append(byte[] data, int offset, int count)
|
||||
{
|
||||
_fileHash.AppendData(data, offset, count);
|
||||
|
||||
int remaining = count;
|
||||
int src = offset;
|
||||
while (remaining > 0)
|
||||
{
|
||||
int space = BLOCK_SIZE - _blockFill;
|
||||
int copy = Math.Min(space, remaining);
|
||||
Buffer.BlockCopy(data, src, _blockBuf, _blockFill, copy);
|
||||
_blockFill += copy;
|
||||
src += copy;
|
||||
remaining -= copy;
|
||||
|
||||
if (_blockFill == BLOCK_SIZE)
|
||||
{
|
||||
_blockHashes.Add(ToLowerHex(_blockHash.ComputeHash(_blockBuf, 0, BLOCK_SIZE)));
|
||||
_blockFill = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public FileIntegrity Finalise()
|
||||
{
|
||||
if (_blockFill > 0)
|
||||
{
|
||||
_blockHashes.Add(ToLowerHex(_blockHash.ComputeHash(_blockBuf, 0, _blockFill)));
|
||||
_blockFill = 0;
|
||||
}
|
||||
|
||||
return new FileIntegrity
|
||||
{
|
||||
Algorithm = ALGORITHM,
|
||||
Hash = ToLowerHex(_fileHash.GetHashAndReset()),
|
||||
BlockSize = BLOCK_SIZE,
|
||||
Blocks = _blockHashes,
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fileHash.Dispose();
|
||||
_blockHash.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public static string ToLowerHex(byte[] bytes)
|
||||
{
|
||||
if (bytes == null || bytes.Length == 0) return string.Empty;
|
||||
var chars = new char[bytes.Length * 2];
|
||||
for (int i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
byte v = bytes[i];
|
||||
chars[i * 2] = HexDigits[v >> 4];
|
||||
chars[i * 2 + 1] = HexDigits[v & 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
# 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.9.3] - 2026-07-04
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed the Remote Web Panel no longer applying on newer Wand builds and reporting "unsupported version". The remote bridge patches now resolve Wand's minified internal names dynamically instead of relying on hardcoded ones that broke on Wand updates. #118 #123 #124 #126
|
||||
- Fixed Pro reverting to Free (with random sign-outs and the return of ads and the time limit) after linking a phone with Wand's mobile activation code. That native pairing triggers a server-side sign-out on a patched client, so the patcher now disables it; use the built-in Remote Web Panel to control Wand from another device instead. #120
|
||||
|
||||
## [1.0.9.2] - 2026-06-28
|
||||
|
||||
### Important
|
||||
|
||||
- Official releases no longer include downloadable `.exe` files. To update, sync your fork and rerun the `Build executable` workflow, or follow the instructions in [How to use](https://github.com/k1tbyte/Wand-Enhancer#-how-to-use).
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the built-in WandEnhancer updater. Official GitHub releases no longer ship executable assets.
|
||||
- Removed System.Net.Http
|
||||
- Removed self-signed certificate generation to prevent AV false positives.
|
||||
- Switched official releases to publish release notes only.
|
||||
|
||||
## [1.0.9.1] - 2026-06-24
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed Pro features disappearing after a day or two when Wand refreshed account data in the background; account store updates now preserve the patched active subscription by @Kava-4 in #110. Related issue #106
|
||||
- Fixed the new Pro account reducer guard so normal account updates do not fail while keeping Pro active.
|
||||
|
||||
## [1.0.9.0] - 2026-06-15
|
||||
|
||||
### Features
|
||||
|
||||
- The Remote Web Panel now shows mod names, descriptions, and instructions translated to your WeMod account language by @YifePlayte in #98. Related issue: #85
|
||||
- Added a language selector to the Remote Web Panel (English, Russian, German, French, Spanish, Simplified Chinese) with automatic detection from the browser language.
|
||||
|
||||
### Improvements
|
||||
|
||||
- Release builds are now code-signed, which reduces false-positive antivirus and VirusTotal detections.
|
||||
- Reworked the Remote Web Panel internals around feature capabilities for easier maintenance, with no change to existing behavior.
|
||||
|
||||
## [1.0.8.4] - 2026-06-10
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed QR code issues on the latest Wand version.
|
||||
- Fixed application hang that occurred after Wand updates with pending patches.
|
||||
|
||||
## [1.0.8.3] - 2026-06-06
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed the Remote Web Panel patches so they reliably apply on newer Wand builds by making the remote bridge patch anchors version-resilient.
|
||||
- Fixed Pro activation being lost after changing the app language; the account language endpoint now keeps the patched subscription.
|
||||
- Fixed "WeMod directory not found" when Wand/WeMod is installed outside the default location or only one brand folder exists. The patcher now also resolves the install directory from a running Wand/WeMod process. #82
|
||||
- Hid the Pro "Remote" onboarding card in the Explore Pro benefits dialog. #86
|
||||
|
||||
## [1.0.8.2] - 2026-05-15
|
||||
|
||||
### Fixes
|
||||
|
||||
- Rolled back an incorrect Disable Updates patch fix that introduced a `SyntaxError` preventing Wand from launching.
|
||||
|
||||
## [1.0.8.1] - 2026-05-15
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed a syntax error in the Disable Updates patch that prevented Wand from launching. #70
|
||||
- Fixed an issue where the Remote Web Panel WebSocket connection wouldn't automatically reconnect when turning returning to the app or turning on the screen.
|
||||
- Reduced battery consumption and device heating on mobile device by optimizing heavy UI blur effects and eliminating unnecessary React re-renders in the Remote Web Panel. #67
|
||||
|
||||
## [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 a notes-only release automatically. Official releases do not attach compiled binaries.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Use C# naming conventions:
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||

|
||||
|
||||
---
|
||||
# WandEnhancer
|
||||
|
||||
[](https://gitlab.com/kitbyte/wand-enhancer)
|
||||
|
||||
</div>
|
||||
|
||||
<h4>An open-source interoperability tool designed to extend local client-side configurations and improve the UX of the Wand application.</h4>
|
||||
|
||||
**🚨 IMPORTANT NOTICE: THIS PROJECT HAS NO OFFICIAL YOUTUBE TUTORIALS OR GUIDES. 🚨
|
||||
There are no official videos showing how to install or use this tool. Scammers are creating fake tutorials using this project's name and placing malware/password stealers in the video descriptions. If you downloaded an .exe or archive from a YouTube link, YOU HAVE DOWNLOADED MALWARE. The only official, safe, and original source for this project is this exact GitHub repository. We are not responsible for third-party downloads.**
|
||||
**🚨 IMPORTANT NOTICE: THIS PROJECT HAS NO OFFICIAL YOUTUBE TUTORIALS, GUIDES, OR PREBUILT EXECUTABLE DOWNLOADS. 🚨
|
||||
There are no official videos showing how to install or use this tool. Scammers are creating fake tutorials using this project's name and placing malware/password stealers in the video descriptions. Official GitHub releases contain release notes only, not `.exe` files. If you downloaded an `.exe` or archive from a YouTube link, a random website, or a third-party mirror, you did not get it from this project. We are not responsible for third-party downloads.**
|
||||
|
||||
## 👾 Is it safe to use?
|
||||
|
||||
@@ -32,23 +34,99 @@ WandEnhancer includes a built-in **Remote Web Panel** allowing you to control ap
|
||||
3. Scan the displayed **QR code** with your phone's camera.
|
||||
|
||||
### Troubleshooting & Remote Access:
|
||||
- **Page isn't loading?** First, ensure both your PC and phone are connected to the **exact same Wi-Fi network**. Next, make sure **Network Discovery** is turned on in your Windows network settings. If it still doesn't work, Windows Firewall might be blocking the connection—you may need to manually allow inbound traffic on TCP port `3223`.
|
||||
- **Page isn't loading?** First, ensure both your PC and phone are connected to the **same local network**. Some routers and guest Wi-Fi networks enable client isolation/AP isolation, which blocks devices on the same SSID from reaching each other. If it still does not load, check Windows Firewall and allow inbound traffic on TCP port `3223` for your local network. If Windows marked your connection as **Public**, switching it to **Private** can also help.
|
||||
- **Using mobile data or a different network?** If you want to use the panel over mobile data (LTE/5G) or from an entirely different network, you can use [Tailscale](https://tailscale.com/) or similar VPN tools.
|
||||
|
||||
## 👀 How to use?
|
||||
|
||||
1. Go to the [Releases](https://github.com/k1tbyte/Wand-Enhancer/releases) page.
|
||||
2. Download the latest source or binary.
|
||||
3. Run the enhancer to apply local client modifications.
|
||||
This repository does not publish official compiled binaries. Build your own executable from your own fork using GitHub Actions.
|
||||
|
||||
1. Sign in to GitHub and fork this repository.
|
||||
2. Open your fork, go to the **Actions** tab, and enable workflows if GitHub asks you to.
|
||||
3. Select the **Build executable** workflow.
|
||||
4. Click **Run workflow**, keep the default branch, and start the run.
|
||||
5. Wait for the workflow to finish, open the completed run, and download the artifact.
|
||||
6. Extract the artifact zip and run `WandEnhancer.exe` to apply local client modifications.
|
||||
|
||||
*Here how you do it:*
|
||||
|
||||
https://github.com/user-attachments/assets/7966cabe-0aa6-424d-8c2f-981ad91e0f91
|
||||
|
||||
|
||||
|
||||
## 🧩 Custom scripts
|
||||
|
||||
You can inject your own JavaScript into Wand at patch time to tweak or fix things in the client UI. This reuses the same renderer injection the Remote Web Panel uses, so it requires the **Remote Web Panel** patch to be enabled.
|
||||
|
||||
**How to add a script**
|
||||
|
||||
- In the patch dialog, add one or more `.js` files (only existing `.js` files are accepted), **or**
|
||||
- Drop `.js` files into a `renderer-scripts/` folder placed next to the patcher executable.
|
||||
|
||||
Then patch as usual — your scripts are bundled into the client and run inside Wand's window.
|
||||
|
||||
**How it runs**
|
||||
|
||||
- Each script runs inside Wand's renderer (full DOM access, plus Node `require`).
|
||||
- It is wrapped so a thrown error is logged and never crashes Wand.
|
||||
- It may run **more than once** per launch (on load and again shortly after), so guard one‑time work behind a global flag.
|
||||
- A small `WandEnhancer` helper is available: `WandEnhancer.log(...)`, `WandEnhancer.remoteUrl`, `WandEnhancer.apiVersion`.
|
||||
|
||||
**Minimal example** (`hello.js`)
|
||||
|
||||
```js
|
||||
// Injected scripts can run multiple times — guard one-time setup.
|
||||
if (!globalThis.__helloScriptInstalled) {
|
||||
globalThis.__helloScriptInstalled = true;
|
||||
|
||||
WandEnhancer.log("Hello from my custom script!", WandEnhancer.remoteUrl);
|
||||
|
||||
new MutationObserver(() => {
|
||||
const dialog = document.querySelector("ux-dialog:not([data-seen])");
|
||||
if (dialog) {
|
||||
dialog.setAttribute("data-seen", "1");
|
||||
WandEnhancer.log("A dialog opened.");
|
||||
}
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
```
|
||||
|
||||
> Scripts run with the same privileges as the Wand client. Only add scripts you trust and understand.
|
||||
|
||||
## 🛠️ How to build from source
|
||||
|
||||
Building from source on Windows requires a local development environment.
|
||||
|
||||
### Requirements
|
||||
|
||||
- `CMake`
|
||||
- `Node.js` and `pnpm`
|
||||
- `Visual Studio 2022` or `Build Tools for Visual Studio 2022` with `MSBuild`
|
||||
- Visual Studio `Desktop development with C++` workload
|
||||
- .NET Framework 4.8 desktop build tools / targeting pack
|
||||
|
||||
### Build steps
|
||||
|
||||
1. Clone this repository.
|
||||
2. Install the requirements above and make sure `cmake`, `pnpm`, and `MSBuild` are available.
|
||||
3. Run `build.cmd` from Command Prompt or PowerShell.
|
||||
|
||||
The build script installs the web panel dependencies, builds the frontend, compiles the native helper with CMake, restores NuGet packages, and builds the WPF solution.
|
||||
|
||||
---
|
||||
|
||||
## ❓ Q&A
|
||||
|
||||
- **I applied the configuration but get stuck on 'Loading...'**
|
||||
- Just close the application completely and restart it.
|
||||
- **Why is there no `.exe` in GitHub Releases?**
|
||||
- Official releases are notes-only on purpose. The project no longer distributes prebuilt executables because unsigned or self-built patching tools are repeatedly reuploaded, mislabeled, and flagged by third-party scanners. Build the executable from your own fork using GitHub Actions instead.
|
||||
- **Where do I download the executable?**
|
||||
- From your own fork's **Actions** artifact after running the **Build executable** workflow. Do not download `.exe` files from YouTube descriptions, random mirrors, Discord attachments, or issue comments.
|
||||
- **Why does Windows Defender or SmartScreen warn about my build?**
|
||||
- The GitHub Actions artifact is unsigned and uncommon, so Windows may warn even when the code was built directly from your fork. Review the source, verify the workflow logs, and only run binaries you built yourself.
|
||||
- **Can I use a binary built by someone else?**
|
||||
- You can, but you should treat it as untrusted. This repository cannot verify or support third-party builds.
|
||||
- **Does this send data anywhere?**
|
||||
- No. All operations are strictly offline and local to your machine.
|
||||
- The desktop patching work is local to your machine. The Remote Web Panel is served from your PC on your local network.
|
||||
|
||||
---
|
||||
## 🖼️ Screenshots
|
||||
@@ -65,7 +143,14 @@ This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) fi
|
||||
|
||||
---
|
||||
## ❤️ Support
|
||||
[](https://ko-fi.com/kitbyte)
|
||||
|
||||
If you find this project useful, you can support its development using any of the options below 🙌
|
||||
|
||||
[](https://www.patreon.com/kitbyte/gift)
|
||||
[](https://tronscan.org/#/address/TQdvau8pAy5Tg1Aa588tTcPCFgbcHtuoxc)
|
||||
[](https://www.blockchain.com/explorer/addresses/btc/1EZKDcyU8REm9JW5xwXJqSpn5Xaq5yAWWX)
|
||||
[](https://etherscan.io/address/0xd904d9d0557f88bbb1c4ab3582b4ca0d8a730e8d)
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
+109
-43
@@ -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,76 @@ 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.PatchFactory != null
|
||||
? patch.PatchFactory(match)
|
||||
: 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;
|
||||
if (patch.PatchFactory != null)
|
||||
{
|
||||
newJs = patch.SingleMatch
|
||||
? patch.Target.Replace(js, _ => patchSource, 1)
|
||||
: patch.Target.Replace(js, _ => patchSource);
|
||||
}
|
||||
else
|
||||
{
|
||||
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 +139,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 +163,11 @@ namespace WandEnhancer.Core
|
||||
remainingPatches.Remove(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileChanged)
|
||||
{
|
||||
File.WriteAllText(item, data);
|
||||
}
|
||||
}
|
||||
|
||||
if(remainingPatches.Count > 0)
|
||||
@@ -143,6 +177,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 +341,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 +399,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);
|
||||
@@ -437,4 +503,4 @@ namespace WandEnhancer.Core
|
||||
_logger("[ENHANCER] Done!", ELogType.Success);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using WandEnhancer.Models;
|
||||
@@ -7,9 +7,6 @@ namespace WandEnhancer.Core
|
||||
{
|
||||
public static class EnhancerConfig
|
||||
{
|
||||
private const int RemoteWebPanelDefaultPort = 3223;
|
||||
private static readonly string RemoteWebPanelFallbackUrl = $"http://localhost:{RemoteWebPanelDefaultPort}/remote/";
|
||||
|
||||
public class ResolveContext
|
||||
{
|
||||
public string Placeholder { get; set; }
|
||||
@@ -20,12 +17,86 @@ namespace WandEnhancer.Core
|
||||
{
|
||||
public Regex Target { get; set; }
|
||||
public string Patch { get; set; }
|
||||
public Func<Match, string> PatchFactory { get; set; }
|
||||
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; }
|
||||
}
|
||||
|
||||
private static string RequireGroup(Match match, string groupName, string patchName)
|
||||
{
|
||||
var group = match.Groups[groupName];
|
||||
if (!group.Success || string.IsNullOrEmpty(group.Value))
|
||||
{
|
||||
throw new Exception($"{patchName} failed to resolve {groupName}");
|
||||
}
|
||||
|
||||
return group.Value;
|
||||
}
|
||||
|
||||
private static string RequirePattern(string source, string pattern, string groupName, string patchName)
|
||||
{
|
||||
var match = Regex.Match(source, pattern, RegexOptions.Singleline);
|
||||
return RequireGroup(match, groupName, patchName);
|
||||
}
|
||||
|
||||
private static string BuildSetAccountLanguagePatch(Match match)
|
||||
{
|
||||
var parameters = RequireGroup(match, "params", "setAccountLanguage");
|
||||
var expr = RequireGroup(match, "expr", "setAccountLanguage");
|
||||
return $"setAccountLanguage({parameters}){{return ({expr}).then(response=>{{response&&\"object\"==typeof response&&(response.subscription={{period:\"yearly\",state:\"active\"}});return response;}})}}";
|
||||
}
|
||||
|
||||
private static string BuildSetAccountReducerPatch(Match match)
|
||||
{
|
||||
var decl = RequireGroup(match, "decl", "setAccountReducer");
|
||||
var fn = RequireGroup(match, "fn", "setAccountReducer");
|
||||
var parameters = RequireGroup(match, "params", "setAccountReducer");
|
||||
var state = RequireGroup(match, "state", "setAccountReducer");
|
||||
var account = RequireGroup(match, "account", "setAccountReducer");
|
||||
return
|
||||
$"const {decl}=\"ACTION_SET_ACCOUNT\";function {fn}({parameters}){{const a={account}&&\"object\"==typeof {account}?{{...{account},subscription:{{period:\"yearly\",state:\"active\"}}}}:{account};return{{...{state},account:a}}}}";
|
||||
}
|
||||
|
||||
private static string BuildRemoteBridgeResetPatch(Match match)
|
||||
{
|
||||
var source = match.Value;
|
||||
var method = RequireGroup(match, "method", "remoteBridgeReset");
|
||||
var disposableField = RequirePattern(source, @"this\.(?<disposable>#[\w$]+)\s*&&\s*\(\s*this\.\k<disposable>\.dispose\(\)", "disposable", "remoteBridgeReset");
|
||||
var instanceField = RequirePattern(source, @"this\.(?<instance>#[\w$]+)\s*=\s*Date\.now\(\)\.toString\(\)", "instance", "remoteBridgeReset");
|
||||
var trainerIdField = RequirePattern(source, @"Date\.now\(\)\.toString\(\)\s*\)?\s*,\s*\(?\s*this\.(?<trainerId>#[\w$]+)\s*=\s*null", "trainerId", "remoteBridgeReset");
|
||||
var supportedVersionsField = RequirePattern(source, @"this\.(?<versions>#[\w$]+)\s*=\s*\[\]", "versions", "remoteBridgeReset");
|
||||
var trainerField = RequirePattern(source, @"this\.(?<versions>#[\w$]+)\s*=\s*\[\]\s*\)?\s*,\s*\(?\s*this\.(?<trainer>#[\w$]+)\s*=\s*null", "trainer", "remoteBridgeReset");
|
||||
|
||||
return $"{method}(){{this.{disposableField}&&(this.{disposableField}.dispose(),this.{disposableField}=null),this.{instanceField}=Date.now().toString(),this.{trainerIdField}=null,this.{supportedVersionsField}=[],this.{trainerField}=null,this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null)}}";
|
||||
}
|
||||
|
||||
private static string BuildRemoteBridgeSyncSnapshotPatch(Match match)
|
||||
{
|
||||
var source = match.Value;
|
||||
var method = RequireGroup(match, "method", "remoteBridgeSyncSnapshot");
|
||||
var statusAlias = RequirePattern(source, @"this\.status\s*===\s*(?<value>[\w$]+)\.Connected", "value", "remoteBridgeSyncSnapshot");
|
||||
var trainerField = RequirePattern(source, @"this\.(?<trainer>#[\w$]+)\?\.\s*getMetadata\s*\(\s*(?<metadata>[\w$]+\.[\w$]+)\s*\)\?\.\s*gameVersion", "trainer", "remoteBridgeSyncSnapshot");
|
||||
var metadataExport = RequirePattern(source, @"this\.(?<trainer>#[\w$]+)\?\.\s*getMetadata\s*\(\s*(?<metadata>[\w$]+\.[\w$]+)\s*\)\?\.\s*gameVersion", "metadata", "remoteBridgeSyncSnapshot");
|
||||
var notesField = RequirePattern(source, @"this\.(?<notes>#[\w$]+)\s*\[\s*this\.(?<trainerId>#[\w$]+)\s*\?\?\s*""""\s*\]", "notes", "remoteBridgeSyncSnapshot");
|
||||
var trainerIdField = RequirePattern(source, @"this\.(?<notes>#[\w$]+)\s*\[\s*this\.(?<trainerId>#[\w$]+)\s*\?\?\s*""""\s*\]", "trainerId", "remoteBridgeSyncSnapshot");
|
||||
var gameField = RequirePattern(source, @"this\.(?<game>#[\w$]+)\s*&&.*?getPreferredInstallationInfo\s*\(\s*this\.\k<game>\s*\)", "game", "remoteBridgeSyncSnapshot");
|
||||
var installationField = RequirePattern(source, @"this\.(?<game>#[\w$]+)\s*&&.*?this\.(?<installation>#[\w$]+)\.getPreferredInstallationInfo\s*\(\s*this\.\k<game>\s*\)", "installation", "remoteBridgeSyncSnapshot");
|
||||
var supportedVersionsField = RequirePattern(source, @"!\s*this\.(?<versions>#[\w$]+)\.includes\s*\(\s*[\w$]+\.version\s*\)", "versions", "remoteBridgeSyncSnapshot");
|
||||
var remoteChannelField = RequirePattern(source, @"this\.(?<remote>#[\w$]+)\?\.\s*send\s*\(\s*""client-state""", "remote", "remoteBridgeSyncSnapshot");
|
||||
var valuesMethod = RequirePattern(source, @"values\s*:\s*this\.(?<values>#[\w$]+)\s*\(\s*\)", "values", "remoteBridgeSyncSnapshot");
|
||||
var instanceField = RequirePattern(source, @"instanceId\s*:\s*this\.(?<instance>#[\w$]+)", "instance", "remoteBridgeSyncSnapshot");
|
||||
var themeField = RequirePattern(source, @"themeId\s*:\s*this\.(?<theme>#[\w$]+)", "theme", "remoteBridgeSyncSnapshot");
|
||||
var settingsHelper = RequirePattern(source, @"settings\s*:\s*(?<settings>[\w$]+)\s*\(\s*this\.settings\s*\)", "settings", "remoteBridgeSyncSnapshot");
|
||||
var languageField = RequirePattern(source, @"language\s*:\s*this\.(?<language>#[\w$]+)", "language", "remoteBridgeSyncSnapshot");
|
||||
var timerField = RequirePattern(source, @"isTimeLimitExpired\s*:\s*""expired""\s*===\s*this\.(?<timer>#[\w$]+)\.timerState", "timer", "remoteBridgeSyncSnapshot");
|
||||
|
||||
return $"{method}(){{let e,t=!1,s=this.{trainerField}?.getMetadata({metadataExport})?.gameVersion??null,o=!1;const n=this.{notesField}[this.{trainerIdField}??\"\"]||null;this.{gameField}&&(e=this.{installationField}.getPreferredInstallationInfo(this.{gameField}),e.app&&(t=!0,s??=e.version??null,o=\"number\"==typeof e.version&&!this.{supportedVersionsField}.includes(e.version)));this.status==={statusAlias}.Connected&&this.{remoteChannelField}?.send(\"client-state\",{{instanceId:this.{instanceField},trainerId:this.{trainerIdField},trainerLoading:this.{trainerField}?.isLoading(),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,values:this.{valuesMethod}(),themeId:this.{themeField},settings:{settingsHelper}(this.settings),language:this.{languageField},accountUuid:this.account.uuid,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.{timerField}.timerState}});this.__wandRemoteBridge?.sync({{instanceId:this.{instanceField},trainerId:this.{trainerIdField},trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.{trainerField}?.getMetadata({metadataExport})??null,trainerLoading:this.{trainerField}?.isLoading()??false,gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,language:this.{languageField},themeId:this.{themeField},notesReadHash:n,isTimeLimitExpired:\"expired\"===this.{timerField}.timerState,values:this.{valuesMethod}()}})}}";
|
||||
}
|
||||
|
||||
public static Dictionary<EPatchType, PatchEntry[]> GetInstance()
|
||||
{
|
||||
return new Dictionary<EPatchType, PatchEntry[]>()
|
||||
@@ -36,6 +107,7 @@ namespace WandEnhancer.Core
|
||||
{
|
||||
new PatchEntry
|
||||
{
|
||||
SearchHints = new[] { "getUserAccount()", "/v3/account" },
|
||||
Resolver = new ResolveContext
|
||||
{
|
||||
Handler = (targetFunction) =>
|
||||
@@ -53,6 +125,7 @@ namespace WandEnhancer.Core
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
SearchHints = new[] { "setAccountWandBrandExperience()", "/v3/account/brand_experience_wand" },
|
||||
Resolver = new ResolveContext
|
||||
{
|
||||
Handler = (targetFunction) =>
|
||||
@@ -64,10 +137,50 @@ 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;})}"
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
// Account-returning endpoint the original patches missed: changing
|
||||
// language dispatches its (non-Pro) response into the store and
|
||||
// wiped Pro. Wrap the result the same way. Param names are captured
|
||||
// so the rewritten body keeps the real argument identifiers.
|
||||
Name = "setAccountLanguage",
|
||||
SearchHints = new[] { "setAccountLanguage(", "/v3/account/language" },
|
||||
Target = new Regex(
|
||||
@"setAccountLanguage\((?<params>[^)]*)\)\{\s*return\s+(?<expr>this\.#\w+\.post\(""/v3/account/language"",\{[^}]*\}\))\s*;?\s*\}",
|
||||
RegexOptions.Singleline),
|
||||
PatchFactory = BuildSetAccountLanguagePatch
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
// Last-resort guard: any code path that dispatches ACTION_SET_ACCOUNT
|
||||
// (periodic refreshAccount, push updates, profile edits, etc.) must keep
|
||||
// subscription on the store object even when it bypasses the account API
|
||||
// service methods patched above.
|
||||
Name = "setAccountReducer",
|
||||
SearchHints = new[] { "ACTION_SET_ACCOUNT" },
|
||||
Target = new Regex(
|
||||
@"const (?<decl>\w+)=""ACTION_SET_ACCOUNT"";function (?<fn>\w+)\((?<params>[^)]*)\)\{return\{\.\.\.(?<state>\w+),account:(?<account>\w+)\}\}",
|
||||
RegexOptions.Singleline),
|
||||
PatchFactory = BuildSetAccountReducerPatch
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
// Wand's native "connect phone" pairing (POST /v3/auth/remote_code)
|
||||
// triggers a server-side device handoff that deauthorizes this desktop
|
||||
// session - the reported "entered the mobile activation key and got
|
||||
// signed out" bug. Neutralize the code issuer so native pairing can
|
||||
// never start. The injected remote panel is independent of this flow
|
||||
// (IPC bridge, not Wand's Pusher pairing) and keeps working. The
|
||||
// rejection is swallowed by the caller's try/catch (renders no code).
|
||||
Name = "disableNativeRemotePairing",
|
||||
SearchHints = new[] { "requestRemoteAuthCode", "/v3/auth/remote_code" },
|
||||
Target = new Regex(@"requestRemoteAuthCode\(\)\{return this\.#[\w$]+\.post\(""/v3/auth/remote_code""\)\}"),
|
||||
Patch = "requestRemoteAuthCode(){return Promise.reject(new Error(\"wand-enhancer: native mobile pairing disabled\"))}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -75,8 +188,12 @@ namespace WandEnhancer.Core
|
||||
EPatchType.DisableUpdates,
|
||||
new[]
|
||||
{
|
||||
// Regex consumes 4 closing parens (`)))) `); the 5th (registerHandler's own close)
|
||||
// remains in the original file after replacement. Patch must end with 3 parens — NOT 4.
|
||||
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 +207,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,57 +228,55 @@ 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",
|
||||
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)}"
|
||||
SearchHints = new[] { "client-state" },
|
||||
Target = new Regex(@"(?<method>#[\w$]+)\(\)\s*\{\s*(?<body>(?:(?!__wandRemoteBridge|}\s*#[\w$]+\(\)).)*?Date\.now\(\)\.toString\(\)(?:(?!__wandRemoteBridge|}\s*#[\w$]+\(\)).)*?\[\](?:(?!__wandRemoteBridge|}\s*#[\w$]+\(\)).)*?)\s*\}\s*(?=#[\w$]+\(\)\s*\{\s*if\s*\(\s*this\.status\s*===\s*[\w$]+\.Connected\s*\).*?""client-state"")",
|
||||
RegexOptions.Singleline),
|
||||
PatchFactory = BuildRemoteBridgeResetPatch
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "remoteBridgeSyncSnapshot",
|
||||
Target = new Regex(@"#Be\(\)\{if\(this\.status===i\.Connected\)\{let e,t=!1,s=this\.#Ee\?\.getMetadata\(h\.vO\)\?\.gameVersion\?\?null,i=!1;const n=this\.#Ve\[this\.#ke\?\?""""\]\|\|null;this\.#Re&&\(e=this\.#Ae\.getPreferredInstallationInfo\(this\.#Re\),e\.app&&\(t=!0,s\?\?=e\.version\?\?null,i=""number""==typeof e\.version&&!this\.#_e\.includes\(e\.version\)\)\),this\.#Me\?\.send\(""client-state"",\{instanceId:this\.#Pe,trainerId:this\.#ke,trainerLoading:this\.#Ee\?\.isLoading\(\),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:i,values:this\.#Ke\(\),themeId:this\.#We,settings:R\(this\.settings\),language:this\.#Ne,accountUuid:this\.account\.uuid,notesReadHash:n,isTimeLimitExpired:""expired""===this\.#Fe\.timerState\}\)\}\}"),
|
||||
Patch = "#Be(){let e,t=!1,s=this.#Ee?.getMetadata(h.vO)?.gameVersion??null,o=!1;const n=this.#Ve[this.#ke??\"\"]||null;this.#Re&&(e=this.#Ae.getPreferredInstallationInfo(this.#Re),e.app&&(t=!0,s??=e.version??null,o=\"number\"==typeof e.version&&!this.#_e.includes(e.version)));this.status===i.Connected&&this.#Me?.send(\"client-state\",{instanceId:this.#Pe,trainerId:this.#ke,trainerLoading:this.#Ee?.isLoading(),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,values:this.#Ke(),themeId:this.#We,settings:R(this.settings),language:this.#Ne,accountUuid:this.account.uuid,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.#Fe.timerState});this.__wandRemoteBridge?.sync({instanceId:this.#Pe,trainerId:this.#ke,trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.#Ee?.getMetadata(h.vO)??null,trainerLoading:this.#Ee?.isLoading()??false,gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,language:this.#Ne,themeId:this.#We,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.#Fe.timerState,values:this.#Ke()})}"
|
||||
SearchHints = new[] { "client-state" },
|
||||
Target = new Regex(@"(?<method>#[\w$]+)\(\)\s*\{\s*if\s*\(\s*this\.status\s*===\s*[\w$]+\.Connected\s*\)\s*\{(?<body>.*?""client-state"".*?isTimeLimitExpired\s*:\s*""expired""\s*===\s*this\.\#[\w$]+\.timerState.*?\)\s*;?\s*\)?\s*;?)\s*\}\s*\}(?=\s*#[\w$]+\(\)\s*\{\s*if\s*\(\s*!this\.\#[\w$]+\?\.\s*isActive\(\)\s*\)\s*return\s*null)",
|
||||
RegexOptions.Singleline),
|
||||
PatchFactory = BuildRemoteBridgeSyncSnapshotPatch
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
// Inject the bridge init + setHandler right after the method's opening
|
||||
// brace; the rest of setCurrentTrainer is left untouched. Only `${trainer}`
|
||||
// (active-trainer field) and `${remoteSource}` (value-source enum, taken
|
||||
// via lookahead from the sole `e.source!==` site) vary between builds and
|
||||
// are resolved from the match — nothing is hardcoded.
|
||||
Name = "remoteBridgeBindHandler",
|
||||
Target = new Regex(@"setCurrentTrainer\(e,t=null\)\{const s=e\?\.trainerId\|\|null,i=\(s\?e\?\.gameId:null\)\|\|null,n=\(s\?e\?\.supportedVersions:null\)\|\|\[];if\(s===this\.#ke&&t===this\.#Ee\)return;"),
|
||||
Patch = "setCurrentTrainer(e,t=null){this.__wandRemoteBridge||(this.__wandRemoteBridge=(()=>{try{const r=globalThis.require||require;const{ipcRenderer:c}=r(\"electron\");try{c.invoke(\"wand-remote-url\").then((u=>{u&&(globalThis.__wandRemoteBridgeUrl=u)}))}catch(e){}const send=(ch,p)=>{try{return c.invoke(ch,p&&JSON.parse(JSON.stringify(p)))}catch(e){}};return{sync:(s)=>send(\"wand-remote-sync\",s),valueChanged:(s)=>send(\"wand-remote-value-changed\",s),setHandler:(h)=>{if(this.__wandRemoteBridgeBound)return;this.__wandRemoteBridgeBound=true;try{c.invoke(\"wand-remote-set-handler-bind\")}catch(e){}c.on(\"wand-remote-set-value\",(_e,req)=>{try{h(req)}catch(e){}})}}}catch(e){try{const r=globalThis.require||require,fs=r(\"node:fs\"),os=r(\"node:os\"),p=r(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [renderer-bind-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}return null}})());this.__wandRemoteBridge?.setHandler((e=>{if(!this.#Ee||!e?.target)return!1;return this.#Ee.isActive()?this.#Ee.setValue(e.target,e.value,g.kL.Remote,e.cheatId):!1}));this.__wandRemoteTrainerInfo=e??null;const s=e?.trainerId||null,i=(s?e?.gameId:null)||null,n=(s?e?.supportedVersions:null)||[];if(s===this.#ke&&t===this.#Ee)return;"
|
||||
SearchHints = new[] { "client-state" },
|
||||
Target = new Regex(@"(?<head>setCurrentTrainer\(e,t=null\)\{)(?=const s=e\?\.trainerId\|\|null,i=\(s\?e\?\.gameId:null\)\|\|null,n=\(s\?e\?\.supportedVersions:null\)\|\|\[\];if\(s===this\.#[\w$]+&&t===this\.(?<trainer>#[\w$]+)\)return;)(?=.*?e\.source!==(?<remoteSource>[\w$]+\.[\w$]+\.Remote))",
|
||||
RegexOptions.Singleline),
|
||||
Patch = "${head}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.${trainer}||!e?.target)return!1;return this.${trainer}.isActive()?this.${trainer}.setValue(e.target,e.value,${remoteSource},e.cheatId):!1}));this.__wandRemoteTrainerInfo=e??null;"
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
// Pure insertion: splice one `valueChanged` bridge call in after the
|
||||
// existing `client-value-changed` send, before the onValueSet callback
|
||||
// closes. Resolves no private names — `${head}`/`${tail}` carry the
|
||||
// original text verbatim. trainerId is omitted from the payload;
|
||||
// bridge-state falls back to the active snapshot trainer.
|
||||
Name = "remoteBridgeValueDelta",
|
||||
Target = new Regex(@"#ct\(e,t\)\{t\.push\(e\.onValueSet\(e=>\{this\.status===i\.Connected&&e\.source!==g\.kL\.Remote&&this\.#Me\?\.send\(""client-value-changed"",\{instanceId:this\.#Pe,name:e\.name,value:e\.value,cheatId:e\.cheatId\}\)\}\)\),this\.#Be\(\)\}"),
|
||||
Patch = "#ct(e,t){t.push(e.onValueSet(e=>{this.status===i.Connected&&e.source!==g.kL.Remote&&this.#Me?.send(\"client-value-changed\",{instanceId:this.#Pe,name:e.name,value:e.value,cheatId:e.cheatId}),this.__wandRemoteBridge?.valueChanged({trainerId:this.#ke,target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??\"desktop\"),cheatId:e.cheatId})})),this.#Be()}"
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "remoteTooltipPreviewUrl",
|
||||
Target = new Regex(@"remoteUrl=""wemodwebsite://remote"""),
|
||||
Patch = "remoteUrl=globalThis.__wandRemoteBridgeUrl||\"" + RemoteWebPanelFallbackUrl + "\""
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "remoteQrPreviewUrl",
|
||||
Resolver = new ResolveContext
|
||||
{
|
||||
Handler = (matchContent) =>
|
||||
{
|
||||
var match = Regex.Match(matchContent, @"this\.canvasElement&&(\w+)\.mo");
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
},
|
||||
Placeholder = "<qr_writer>"
|
||||
},
|
||||
Target = new Regex(@"this\.canvasElement&&\w+\.mo\(this\.canvasElement,`\$\{\w+\.A\.wemodWebsiteUrl\}/remote`,this\.options\)"),
|
||||
Patch = "this.canvasElement&&<qr_writer>.mo(this.canvasElement,globalThis.__wandRemoteBridgeUrl||\"" + RemoteWebPanelFallbackUrl + "\",this.options)"
|
||||
SearchHints = new[] { "client-value-changed" },
|
||||
Target = new Regex(@"(?<head>#[\w$]+\(e,t\)\{t\.push\(e\.onValueSet\(e=>\{this\.status===[\w$]+\.Connected&&e\.source!==[\w$]+\.[\w$]+\.Remote&&this\.#[\w$]+\?\.send\(""client-value-changed"",\{instanceId:this\.#[\w$]+,name:e\.name,value:e\.value,cheatId:e\.cheatId\}\))(?<tail>\}\)\),this\.#[\w$]+\(\)\})"),
|
||||
Patch = "${head},this.__wandRemoteBridge?.valueChanged({target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??\"desktop\"),cheatId:e.cheatId})${tail}"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Eine neue Version ist verfügbar</s:String>
|
||||
<s:String x:Key="mw_folder_path">Ordnerpfad</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Ordner nicht gefunden</s:String>
|
||||
<s:String x:Key="mw_patch">Anwenden</s:String>
|
||||
@@ -39,10 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Was werden wir verbessern?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Vor dem Update wird dringend empfohlen, Änderungen rückgängig zu machen, falls sie angewendet wurden</s:String>
|
||||
<s:String x:Key="up_update_now">Jetzt aktualisieren</s:String>
|
||||
<s:String x:Key="up_popup_title">Update verfügbar!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">A new version is available</s:String>
|
||||
<s:String x:Key="mw_folder_path">Folder path</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Folder not found</s:String>
|
||||
<s:String x:Key="mw_patch">Enhance</s:String>
|
||||
@@ -39,10 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">What are we gonna enhance?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Before updating, it is strongly recommended to roll back modifications if they have been applied</s:String>
|
||||
<s:String x:Key="up_update_now">Update now</s:String>
|
||||
<s:String x:Key="up_popup_title">Update available!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Una nueva versión está disponible</s:String>
|
||||
<s:String x:Key="mw_folder_path">Ruta de la carpeta</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Carpeta no encontrada</s:String>
|
||||
<s:String x:Key="mw_patch">Aplicar</s:String>
|
||||
@@ -39,10 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">¿Qué vamos a mejorar?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Antes de actualizar, se recomienda encarecidamente revertir las modificaciones si se han aplicado</s:String>
|
||||
<s:String x:Key="up_update_now">Actualizar ahora</s:String>
|
||||
<s:String x:Key="up_popup_title">¡Actualización disponible!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Une nouvelle version est disponible</s:String>
|
||||
<s:String x:Key="mw_folder_path">Chemin du dossier</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Dossier non trouvé</s:String>
|
||||
<s:String x:Key="mw_patch">Appliquer</s:String>
|
||||
@@ -39,10 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Qu'allons-nous modifier ?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Avant la mise à jour, il est fortement recommandé d'annuler les modifications si elles ont été appliquées</s:String>
|
||||
<s:String x:Key="up_update_now">Mettre à jour maintenant</s:String>
|
||||
<s:String x:Key="up_popup_title">Mise à jour disponible !</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">È disponibile una nuova versione</s:String>
|
||||
<s:String x:Key="mw_folder_path">Percorso cartella</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Cartella non trovata</s:String>
|
||||
<s:String x:Key="mw_patch">Applica</s:String>
|
||||
@@ -39,10 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Cosa modificheremo?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Prima dell'aggiornamento, si consiglia vivamente di annullare le modifiche se sono state applicate</s:String>
|
||||
<s:String x:Key="up_update_now">Aggiorna ora</s:String>
|
||||
<s:String x:Key="up_popup_title">Aggiornamento disponibile!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">新しいバージョンが利用可能です</s:String>
|
||||
<s:String x:Key="mw_folder_path">フォルダパス</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">フォルダが見つかりません</s:String>
|
||||
<s:String x:Key="mw_patch">適用</s:String>
|
||||
@@ -39,10 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">何を改善しますか?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">アップデート前に、変更が適用されている場合はロールバックすることを強くお勧めします</s:String>
|
||||
<s:String x:Key="up_update_now">今すぐ更新</s:String>
|
||||
<s:String x:Key="up_popup_title">アップデート利用可能!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Dostępna jest nowa wersja</s:String>
|
||||
<s:String x:Key="mw_folder_path">Ścieżka folderu</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Folder nie znaleziony</s:String>
|
||||
<s:String x:Key="mw_patch">Zastosuj</s:String>
|
||||
@@ -39,10 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Co będziemy ulepszać?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Przed aktualizacją zdecydowanie zaleca się cofnięcie zmian, jeśli zostały zastosowane</s:String>
|
||||
<s:String x:Key="up_update_now">Aktualizuj teraz</s:String>
|
||||
<s:String x:Key="up_popup_title">Dostępna aktualizacja!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Uma nova versão está disponível</s:String>
|
||||
<s:String x:Key="mw_folder_path">Caminho da pasta</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Pasta não encontrada</s:String>
|
||||
<s:String x:Key="mw_patch">Aplicar</s:String>
|
||||
@@ -39,10 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">O que vamos melhorar?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Antes de atualizar, é altamente recomendável reverter as modificações se elas foram aplicadas</s:String>
|
||||
<s:String x:Key="up_update_now">Atualizar agora</s:String>
|
||||
<s:String x:Key="up_popup_title">Atualização disponível!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Доступна новая версия</s:String>
|
||||
<s:String x:Key="mw_folder_path">Путь к папке</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Папка не найдена</s:String>
|
||||
<s:String x:Key="mw_patch">Применить</s:String>
|
||||
@@ -39,10 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Что будем улучшать?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Перед обновлением настоятельно рекомендуется откатить изменения, если они были применены</s:String>
|
||||
<s:String x:Key="up_update_now">Обновить сейчас</s:String>
|
||||
<s:String x:Key="up_popup_title">Доступно обновление!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Yeni bir sürüm mevcut</s:String>
|
||||
<s:String x:Key="mw_folder_path">Klasör yolu</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Klasör bulunamadı</s:String>
|
||||
<s:String x:Key="mw_patch">Uygula</s:String>
|
||||
@@ -39,10 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Neyi geliştireceğiz?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Güncellemeden önce, değişiklikler uygulandıysa geri almak şiddetle tavsiye edilir</s:String>
|
||||
<s:String x:Key="up_update_now">Şimdi güncelle</s:String>
|
||||
<s:String x:Key="up_popup_title">Güncelleme mevcut!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">Доступна нова версія</s:String>
|
||||
<s:String x:Key="mw_folder_path">Шлях до папки</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Папку не знайдено</s:String>
|
||||
<s:String x:Key="mw_patch">Застосувати</s:String>
|
||||
@@ -39,10 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">Що будемо покращувати?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">Перед оновленням наполегливо рекомендується відкотити зміни, якщо вони були застосовані</s:String>
|
||||
<s:String x:Key="up_update_now">Оновити зараз</s:String>
|
||||
<s:String x:Key="up_popup_title">Доступне оновлення!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_update_available">有新版本可用</s:String>
|
||||
<s:String x:Key="mw_folder_path">文件夹路径</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">未找到文件夹</s:String>
|
||||
<s:String x:Key="mw_patch">增强</s:String>
|
||||
@@ -39,10 +38,4 @@
|
||||
<s:String x:Key="pv_popup_title">我们要增强什么?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region UpdatePopup -->
|
||||
<s:String x:Key="up_warning">在更新之前,强烈建议回滚已应用的修改</s:String>
|
||||
<s:String x:Key="up_update_now">立即更新</s:String>
|
||||
<s:String x:Key="up_popup_title">有更新可用!</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -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.9.3")]
|
||||
[assembly: AssemblyFileVersion("1.0.9.3")]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -39,13 +40,73 @@ namespace WandEnhancer.Utils
|
||||
public static WeModConfig FindWeMod()
|
||||
{
|
||||
string localAppDataPath = Environment.GetEnvironmentVariable("LOCALAPPDATA");
|
||||
|
||||
foreach (var folder in Constants.WeModBrandNames)
|
||||
|
||||
if (!string.IsNullOrEmpty(localAppDataPath))
|
||||
{
|
||||
var weModDir = Path.Combine(localAppDataPath ?? "", folder);
|
||||
if(Directory.Exists(weModDir))
|
||||
foreach (var folder in Constants.WeModBrandNames)
|
||||
{
|
||||
return FindLatestWeMod(weModDir);
|
||||
var weModDir = Path.Combine(localAppDataPath, folder);
|
||||
if (!Directory.Exists(weModDir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep scanning the other brand folders if this one has no valid
|
||||
// install instead of giving up on the first folder that exists.
|
||||
var config = FindLatestWeMod(weModDir);
|
||||
if (config != null)
|
||||
{
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: a running Wand/WeMod process reveals the install directory
|
||||
// wherever it lives (non-default LOCALAPPDATA, moved install, other drive).
|
||||
return FindWeModFromRunningProcess();
|
||||
}
|
||||
|
||||
private static WeModConfig FindWeModFromRunningProcess()
|
||||
{
|
||||
foreach (var name in Constants.WeModBrandNames)
|
||||
{
|
||||
Process[] processes;
|
||||
try
|
||||
{
|
||||
processes = Process.GetProcessesByName(name);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var process in processes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exePath = process.MainModule?.FileName;
|
||||
if (string.IsNullOrEmpty(exePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process may be the versioned exe (dir is the install root) or
|
||||
// the launcher stub at the parent (dir holds `app-*` subfolders).
|
||||
var processDir = Path.GetDirectoryName(exePath);
|
||||
var config = CheckWeModPath(processDir) ?? FindLatestWeMod(processDir);
|
||||
if (config != null)
|
||||
{
|
||||
return config;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// MainModule throws on access-denied / bitness mismatch; skip.
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using System.Net.Http;
|
||||
using System.Windows;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace WandEnhancer.Utils
|
||||
{
|
||||
public class GitHubRelease
|
||||
{
|
||||
public class AssetsType
|
||||
{
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("browser_download_url")]
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
[JsonProperty("tag_name")]
|
||||
public string TagName { get; set; }
|
||||
|
||||
[JsonProperty("assets")]
|
||||
public AssetsType[] Assets { get; set; }
|
||||
|
||||
}
|
||||
|
||||
public class Updater
|
||||
{
|
||||
private GitHubRelease _release = null;
|
||||
private static readonly HttpClient _httpClient = new HttpClient()
|
||||
{
|
||||
DefaultRequestHeaders =
|
||||
{
|
||||
{ "User-Agent", "GitHub-Updater" }
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly string ApiUrl = $"https://api.github.com/repos/{Constants.Owner}/{Constants.RepoName}/releases/latest";
|
||||
public async Task<bool> CheckForUpdates()
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentVersion = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
var response = await _httpClient.GetAsync(ApiUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
_release = JsonConvert.DeserializeObject<GitHubRelease>(await response.Content.ReadAsStringAsync());
|
||||
|
||||
if (_release == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var latestVersion = new Version(_release.TagName);
|
||||
|
||||
return latestVersion > currentVersion;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Update()
|
||||
{
|
||||
if (_release == null)
|
||||
{
|
||||
throw new Exception("No release found");
|
||||
}
|
||||
|
||||
var asset = _release.Assets.FirstOrDefault(o => o.Name.EndsWith(".exe"));
|
||||
if(asset == null)
|
||||
{
|
||||
throw new Exception("No asset found");
|
||||
}
|
||||
|
||||
// download to temp
|
||||
var downloadPath = Path.Combine(Path.GetTempPath(), asset.Name);
|
||||
|
||||
using(var response = await _httpClient.GetAsync(asset.Url))
|
||||
using(var fileStream = File.Create(downloadPath))
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
await response.Content.CopyToAsync(fileStream);
|
||||
}
|
||||
|
||||
ApplyUpdate(downloadPath);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static void ApplyUpdate(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentExecutable = Assembly.GetExecutingAssembly().Location;
|
||||
|
||||
var psCommand = $"Start-Sleep -Seconds 2; " +
|
||||
$"Copy-Item -Path '{filePath}' -Destination '{currentExecutable}' -Force; " +
|
||||
$"Remove-Item -Path '{filePath}' -Force; " +
|
||||
$"Start-Sleep -Seconds 1; " +
|
||||
$"Start-Process -FilePath '{currentExecutable}';";
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-WindowStyle Hidden -ExecutionPolicy Bypass -Command \"{psCommand}\"",
|
||||
UseShellExecute = true,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
|
||||
Process.Start(startInfo);
|
||||
|
||||
Task.Delay(500).ContinueWith(_ =>
|
||||
{
|
||||
App.Shutdown();
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Update failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,6 @@
|
||||
v 1.0.0
|
||||
</TextBlock>
|
||||
|
||||
<Button Background="SpringGreen" Foreground="{DynamicResource Muted}"
|
||||
FontWeight="Medium" Padding="20 0" Margin="10 5 20 5"
|
||||
ToolTip="Click to update"
|
||||
Command="{Binding UpdateCommand}"
|
||||
Visibility="{Binding IsUpdateAvailable, Converter={StaticResource ToVisibilityConverter}}"
|
||||
Content="{DynamicResource mw_update_available}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
|
||||
@@ -227,4 +221,4 @@
|
||||
<controls:PopupHost x:Name="PopupHost"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
</Window>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
@@ -18,8 +17,6 @@ namespace WandEnhancer.View.MainWindow
|
||||
{
|
||||
private readonly MainWindow _view;
|
||||
public ObservableCollection<LogEntry> LogList { get; set; } = new ObservableCollection<LogEntry>();
|
||||
private static Updater _updater = new Updater();
|
||||
|
||||
private WeModConfig _weModConfig;
|
||||
|
||||
public WeModConfig WeModInfo
|
||||
@@ -61,18 +58,9 @@ namespace WandEnhancer.View.MainWindow
|
||||
set => SetProperty(ref _alreadyPatched, value);
|
||||
}
|
||||
|
||||
private bool _isUpdateAvailable;
|
||||
|
||||
public bool IsUpdateAvailable
|
||||
{
|
||||
get => _isUpdateAvailable;
|
||||
set => SetProperty(ref _isUpdateAvailable, value);
|
||||
}
|
||||
|
||||
public RelayCommand SetFolderPathCommand { get; }
|
||||
public RelayCommand ApplyPatchCommand { get; }
|
||||
public RelayCommand RestoreBackupCommand { get; }
|
||||
public RelayCommand UpdateCommand { get; }
|
||||
public RelayCommand OpenSettingsCommand { get; }
|
||||
public RelayCommand CopyLogsCommand { get; }
|
||||
public RelayCommand ExportLogsCommand { get; }
|
||||
@@ -185,27 +173,6 @@ namespace WandEnhancer.View.MainWindow
|
||||
});
|
||||
}
|
||||
|
||||
private void OnUpdate(object param)
|
||||
{
|
||||
MainWindow.Instance.OpenPopup(new UpdatePopup(() =>
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _updater.Update();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log($"Failed to update: {e.Message}", ELogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
Log("WandEnhancer updated successfully. Restarting...", ELogType.Success);
|
||||
});
|
||||
}), Application.Current.FindResource("up_popup_title") as string);
|
||||
}
|
||||
|
||||
private void OnOpenSettings(object param)
|
||||
{
|
||||
MainWindow.Instance.OpenPopup(new SettingsPopup(), Application.Current.FindResource("settings_title") as string);
|
||||
@@ -271,12 +238,10 @@ namespace WandEnhancer.View.MainWindow
|
||||
|
||||
public MainWindowVm(MainWindow view)
|
||||
{
|
||||
Task.Run(async () => IsUpdateAvailable = await _updater.CheckForUpdates());
|
||||
_view = view;
|
||||
SetFolderPathCommand = new RelayCommand(OnFolderPathSelection);
|
||||
ApplyPatchCommand = new RelayCommand(OnPatching);
|
||||
RestoreBackupCommand = new RelayCommand(OnBackupRestoring);
|
||||
UpdateCommand = new RelayCommand(OnUpdate);
|
||||
OpenSettingsCommand = new RelayCommand(OnOpenSettings);
|
||||
CopyLogsCommand = new RelayCommand(OnCopyLogs);
|
||||
ExportLogsCommand = new RelayCommand(OnExportLogs);
|
||||
@@ -288,4 +253,4 @@ namespace WandEnhancer.View.MainWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<UserControl x:Class="WandEnhancer.View.Popups.UpdatePopup"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="clr-namespace: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>
|
||||
</UserControl>
|
||||
@@ -1,22 +0,0 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace WandEnhancer.View.Popups
|
||||
{
|
||||
public partial class UpdatePopup : UserControl
|
||||
{
|
||||
private readonly Action _onUpdate;
|
||||
|
||||
public UpdatePopup(Action onUpdate)
|
||||
{
|
||||
_onUpdate = onUpdate;
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnUpdateClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_onUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
@@ -52,7 +55,6 @@
|
||||
<Reference Include="System.Data" />
|
||||
<Reference Include="System.Windows.Forms" />
|
||||
<Reference Include="System.Xml" />
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.Xaml">
|
||||
<RequiredTargetFramework>4.0</RequiredTargetFramework>
|
||||
</Reference>
|
||||
@@ -81,7 +83,6 @@
|
||||
<Compile Include="ReactiveUICore\RelayCommand.cs" />
|
||||
<Compile Include="Utils\Common.cs" />
|
||||
<Compile Include="Utils\Extensions.cs" />
|
||||
<Compile Include="Utils\Updater.cs" />
|
||||
<Compile Include="Utils\Win32\Shortcut.cs" />
|
||||
<Compile Include="View\Controls\InfoItem.xaml.cs">
|
||||
<DependentUpon>InfoItem.xaml</DependentUpon>
|
||||
@@ -100,9 +101,6 @@
|
||||
<DependentUpon>App.xaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Include="View\Popups\UpdatePopup.xaml.cs">
|
||||
<DependentUpon>UpdatePopup.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Page Include="Locale\lang.en-US.xaml" />
|
||||
<Page Include="Locale\lang.zh-CN.xaml" />
|
||||
<Page Include="Locale\lang.de-DE.xaml" />
|
||||
@@ -123,7 +121,6 @@
|
||||
<Page Include="View\MainWindow\MainWindow.xaml" />
|
||||
<Page Include="View\Popups\PatchVectorsPopup.xaml" />
|
||||
<Page Include="View\Popups\SettingsPopup.xaml" />
|
||||
<Page Include="View\Popups\UpdatePopup.xaml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Properties\AssemblyInfo.cs">
|
||||
@@ -151,10 +148,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 +161,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 +171,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 +186,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>
|
||||
</Project>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 177 KiB |
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -0,0 +1,115 @@
|
||||
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-NuGetPath {
|
||||
$nugetCommand = Get-Command 'nuget.exe' -ErrorAction SilentlyContinue
|
||||
if (-not $nugetCommand) {
|
||||
$nugetCommand = Get-Command 'nuget' -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($nugetCommand) {
|
||||
return $nugetCommand.Source
|
||||
}
|
||||
|
||||
$toolsDir = Join-Path $repoRoot '.tmp/tools'
|
||||
$nugetPath = Join-Path $toolsDir 'nuget.exe'
|
||||
if (-not (Test-Path $nugetPath)) {
|
||||
New-Item -ItemType Directory -Path $toolsDir -Force | Out-Null
|
||||
Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetPath
|
||||
}
|
||||
|
||||
return $nugetPath
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
# No version pin: pick whatever VS the host has (2022/2026/newer) so CI
|
||||
# keeps working when the runner image bumps its Visual Studio major.
|
||||
$installationPath = & $vswhere -latest -prerelease -products '*' -requires Microsoft.Component.MSBuild -property installationPath
|
||||
if ([string]::IsNullOrWhiteSpace($installationPath)) {
|
||||
throw 'Visual Studio 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'
|
||||
$nuget = Resolve-NuGetPath
|
||||
$pnpm = Resolve-CommandPath 'pnpm'
|
||||
$msbuild = Resolve-MSBuildPath
|
||||
|
||||
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' {
|
||||
# Let CMake choose its default Visual Studio generator (matches the host VS),
|
||||
# avoiding a hardcoded/derived name that breaks when the runner bumps VS.
|
||||
# Clearing CMAKE_GENERATOR ensures the default isn't overridden to a non-VS
|
||||
# generator that would reject the -A architecture flag.
|
||||
Remove-Item Env:CMAKE_GENERATOR -ErrorAction SilentlyContinue
|
||||
& $cmake -S $asarFusesSourceDir -B $asarFusesBuildDir -A x64
|
||||
}
|
||||
|
||||
Invoke-Step 'Build asar-fuses-bypass' {
|
||||
& $cmake --build $asarFusesBuildDir --config $Configuration
|
||||
}
|
||||
|
||||
Invoke-Step 'Restore NuGet packages' {
|
||||
& $nuget restore $solutionPath -NonInteractive
|
||||
}
|
||||
|
||||
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
|
||||
Vendored
+4
@@ -22,3 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Compiled lingui catalogs — generated from .po by `lingui compile`; the app
|
||||
# loads .po directly via @lingui/vite-plugin, so these are build artifacts.
|
||||
src/locales/**/*.js
|
||||
|
||||
Vendored
+254
@@ -0,0 +1,254 @@
|
||||
Use these rules as defaults, not as a reason to add ceremonial folders or wrapper layers.
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Organize code around product capabilities, not framework vocabulary.
|
||||
- Keep related UI, state, rules, and data access close until a real boundary justifies moving
|
||||
them apart.
|
||||
- Dependencies point from composition and UI toward stable rules and narrow capabilities.
|
||||
- Protect rendering code from business, state-management, and infrastructure complexity.
|
||||
- Keep one source of truth and derive everything else.
|
||||
- Apply KISS, YAGNI, and DRY together. Remove duplicated knowledge, not merely similar syntax.
|
||||
- Prefer explicit, readable flow over clever abstractions and hidden behavior.
|
||||
|
||||
## Screaming Architecture
|
||||
|
||||
The repository structure and public APIs should reveal what the product does.
|
||||
|
||||
Prefer:
|
||||
|
||||
```text
|
||||
features/
|
||||
checkout/
|
||||
search/
|
||||
account-security/
|
||||
```
|
||||
|
||||
Avoid making the application read primarily as:
|
||||
|
||||
```text
|
||||
components/
|
||||
hooks/
|
||||
services/
|
||||
stores/
|
||||
utils/
|
||||
```
|
||||
|
||||
Technical folders are useful inside a capability, where their owner is clear. Generic top-level
|
||||
folders easily become dependency magnets with unclear ownership.
|
||||
|
||||
Names should use product language. Prefer `useCheckoutSummary`, `reserveStock`, and
|
||||
`AccountSecurityPanel` over `useData`, `processItems`, and `GenericPanel`.
|
||||
|
||||
## Suggested Structure
|
||||
|
||||
Start with the smallest structure that makes ownership obvious:
|
||||
|
||||
```text
|
||||
src/
|
||||
app/ startup, providers, router, global composition
|
||||
pages/ route-level composition
|
||||
features/
|
||||
<capability>/
|
||||
index.ts optional public API
|
||||
ui/ optional rendering components
|
||||
model/ optional state, view models, decisions
|
||||
api/ optional external data access
|
||||
lib/ optional feature-local pure helpers
|
||||
domains/ optional shared product rules and types
|
||||
shared/
|
||||
ui/ domain-free visual primitives
|
||||
api/ generic transport/query infrastructure
|
||||
lib/ genuinely generic pure helpers
|
||||
```
|
||||
|
||||
Folders are created when they contain a real responsibility. A small feature may be one cohesive
|
||||
file. Do not create empty layers in anticipation of future complexity.
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
- `app` installs providers, constructs dependencies, and composes the application.
|
||||
- `pages` compose capabilities for a route. They do not own business rules or data protocols.
|
||||
- A feature owns one user-recognizable capability end to end.
|
||||
- Feature UI consumes its own model/view-model API, not raw infrastructure.
|
||||
- Shared domain code contains reusable product rules and stays independent of React and I/O.
|
||||
- `shared` contains only domain-free code. Product-specific code is not shared merely because
|
||||
two files use it.
|
||||
- Avoid feature-to-feature imports. Compose features in a page, promote truly shared rules to a
|
||||
domain module, or introduce a named workflow when coordination is the actual responsibility.
|
||||
- Cyclic imports are an architecture problem, not something to solve with a tooling workaround.
|
||||
|
||||
For a simple feature, direct `ui -> model -> api` dependencies are sufficient. Introduce ports,
|
||||
facades, dependency injection, or workflows only when they hide real complexity, enable
|
||||
important tests, or separate unstable infrastructure.
|
||||
|
||||
## Make Composition Read Like The Product
|
||||
|
||||
Pages and other composition boundaries should use capability-level APIs.
|
||||
|
||||
Prefer:
|
||||
|
||||
```tsx
|
||||
<CheckoutSummary />
|
||||
<PlaceOrderButton />
|
||||
```
|
||||
|
||||
Over:
|
||||
|
||||
```tsx
|
||||
<Card>
|
||||
<Select options={paymentOptions} onChange={handlePaymentChange} />
|
||||
<Button onClick={handleSubmit}>Submit</Button>
|
||||
</Card>
|
||||
```
|
||||
|
||||
The second version makes the page understand checkout behavior and low-level UI configuration.
|
||||
That knowledge belongs to the checkout capability.
|
||||
|
||||
This does not mean wrapping every native element or design-system primitive. Semantic HTML and
|
||||
visual primitives are correct inside feature UI. Create a capability component when it hides
|
||||
product behavior or gives composition code a clearer product-level API.
|
||||
|
||||
Avoid "raw components" whose consumers must know internal options, state transitions, query
|
||||
shapes, or protocol details. Avoid generic configuration-driven components that combine
|
||||
unrelated product modes behind dozens of props.
|
||||
|
||||
## UI Boundary
|
||||
|
||||
- Components render data and translate DOM events into named user intents.
|
||||
- Keep business decisions, data mapping, persistence, protocol handling, and multi-step async
|
||||
flows outside rendering components.
|
||||
- UI receives render-ready values. It should not reconstruct domain meaning from raw DTOs.
|
||||
- Prefer intent props and commands such as `onApprove`, `renameProject`, or `submitOrder` over
|
||||
generic `onChange`, `setState`, or `patch` APIs at capability boundaries.
|
||||
- Keep ephemeral visual state local: focus, hover, open/closed, and uncommitted input usually
|
||||
belong in the component.
|
||||
- Split components by responsibility and API clarity, not by arbitrary line limits.
|
||||
- Prefer slots and composition over components with many layout modes and boolean props.
|
||||
- Use semantic HTML and preserve accessibility behavior.
|
||||
|
||||
A view-model hook is useful when it protects UI from state shape, async coordination, or business
|
||||
decisions. Do not create a pass-through hook that only renames one value to satisfy a diagram.
|
||||
|
||||
## State Ownership
|
||||
|
||||
Choose the smallest correct owner:
|
||||
|
||||
| State | Preferred owner |
|
||||
| --- | --- |
|
||||
| Ephemeral visual state | local component state |
|
||||
| Uncommitted form state | the form or feature |
|
||||
| URL/shareable navigation state | the router/URL |
|
||||
| Remote server resource and cache | a query/cache layer |
|
||||
| Shared capability state | that feature's model/store |
|
||||
| Cross-capability process | a named workflow or app-level model |
|
||||
|
||||
- A store is not a bucket for every value used by several components.
|
||||
- Split state by capability and lifecycle, not by data type.
|
||||
- Expose narrow selectors, hooks, or commands. Do not expose a complete mutable store to all UI.
|
||||
- Store transitions should express user or domain intent, not generic object mutation.
|
||||
- Derive values instead of storing synchronized copies.
|
||||
- Do not use effects to keep two pieces of application state synchronized.
|
||||
- React Context is suitable for dependency injection or stable scoped state. Avoid one broad
|
||||
app context whose every update rerenders unrelated consumers.
|
||||
|
||||
State-library choice is an implementation detail. Architecture should survive replacing it
|
||||
without rewriting pages and rendering components.
|
||||
|
||||
## Effects And Async Work
|
||||
|
||||
- Use effects to synchronize with external systems, not to calculate render data or handle user
|
||||
events.
|
||||
- Start event-driven work from the event or model command that owns it.
|
||||
- Every subscription, timer, listener, or in-flight operation must have a clear owner and
|
||||
cleanup path.
|
||||
- The owning feature/model defines pending, success, empty, error, retry, and cancellation
|
||||
semantics.
|
||||
- Prevent stale async results and race conditions where users can trigger overlapping work.
|
||||
- Do not hide failures with broad `catch` blocks or silently convert errors into empty data.
|
||||
|
||||
## Data And Infrastructure
|
||||
|
||||
- Treat network responses, storage, URL input, files, and third-party SDK output as untrusted.
|
||||
- Validate and normalize data at the boundary where it enters the application.
|
||||
- Map transport DTOs and external errors into product-oriented values before they reach UI.
|
||||
- Keep raw `fetch`, storage APIs, SDK calls, and protocol details out of rendering components.
|
||||
- Keep a feature-specific API adapter inside the feature until it has a real shared consumer.
|
||||
- Introduce a client, repository, gateway, service, or facade only when its responsibility is
|
||||
distinct and useful.
|
||||
- Avoid wrapper chains that only forward calls. One clear adapter is better than
|
||||
`Client -> Service -> Facade` without separate responsibilities.
|
||||
- Inject infrastructure when tests, multiple implementations, lifecycle, or unstable external
|
||||
APIs justify it. Do not introduce dependency injection for every pure helper.
|
||||
|
||||
## Component And Hook APIs
|
||||
|
||||
- Component and hook APIs describe product intent, not internal implementation.
|
||||
- Avoid boolean prop combinations that create unclear or invalid modes. Prefer explicit variants
|
||||
or separate components.
|
||||
- Avoid passing raw query results, stores, SDK clients, or large configuration objects through
|
||||
component trees.
|
||||
- Keep public props small and cohesive. A component that needs unrelated groups of props likely
|
||||
owns too many responsibilities.
|
||||
- Custom hooks encapsulate React state, lifecycle, or reusable reactive behavior. Pure
|
||||
calculations remain plain functions.
|
||||
- Do not use `useEffect`, `useMemo`, `useCallback`, or `memo` by habit. Use them for correctness
|
||||
or measured performance needs.
|
||||
- Do not duplicate server or domain state into component state merely to make it editable.
|
||||
Create an explicit draft only when the UX requires commit/cancel semantics.
|
||||
|
||||
## Public Boundaries
|
||||
|
||||
- Export the smallest useful public surface of a feature.
|
||||
- Consumers should use a feature's public components, hooks, commands, and types, not deep
|
||||
internal paths.
|
||||
- Keep implementation-only state, DTOs, adapters, and helpers private.
|
||||
- Do not create barrel files everywhere. Use a public entry point only where a real boundary
|
||||
exists.
|
||||
- A reusable abstraction should have a clear owner and at least one current reason to exist.
|
||||
- Avoid generic `core`, `common`, `helpers`, `services`, or `utils` modules that collect
|
||||
unrelated responsibilities.
|
||||
|
||||
## Growing The Architecture
|
||||
|
||||
Start local and promote code only after pressure appears:
|
||||
|
||||
- A second consumer may justify shared domain code, but similar code is not automatically the
|
||||
same knowledge.
|
||||
- Repeated external integration logic may justify a shared adapter.
|
||||
- A process coordinating several capabilities may justify a named workflow.
|
||||
- A large feature may split into smaller capabilities when they have distinct responsibilities
|
||||
and lifecycles.
|
||||
- Separate packages are useful when an enforceable boundary, independent reuse, or independent
|
||||
lifecycle outweighs their maintenance cost.
|
||||
|
||||
Do not begin a small application with every possible layer, package, provider, repository,
|
||||
facade, and design pattern. Strong architecture makes growth cheaper; it does not predict every
|
||||
future requirement.
|
||||
|
||||
## Testing
|
||||
|
||||
- Test product behavior and public contracts, not implementation trivia.
|
||||
- Test pure rules with unit tests.
|
||||
- Test feature models and async transitions without rendering where practical.
|
||||
- Test components through accessible user behavior.
|
||||
- Test infrastructure mapping and validation at external boundaries.
|
||||
- Keep end-to-end tests for critical user journeys.
|
||||
- Mock external systems and unstable boundaries, not every internal function.
|
||||
- Add tests proportional to risk, especially for validation, permissions, races, retries,
|
||||
cancellation, and regressions.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
Before finishing a change, ask:
|
||||
|
||||
- Does the file location make its owner obvious?
|
||||
- Does composition code read in product language?
|
||||
- Is UI protected from raw state, DTOs, infrastructure, and business decisions?
|
||||
- Is there one source of truth?
|
||||
- Are effects only synchronizing external systems?
|
||||
- Is new shared code genuinely domain-free or genuinely shared?
|
||||
- Does every abstraction remove current complexity?
|
||||
- Can important behavior be tested without rendering the whole app?
|
||||
- Did the change preserve accessibility, error handling, and cleanup?
|
||||
- Is this the least code that clearly solves the current problem?
|
||||
Vendored
+8
-13
@@ -1,31 +1,26 @@
|
||||
# Wand Web Panel
|
||||
|
||||
Local mobile-friendly web panel scaffold for Wand.
|
||||
Local mobile-friendly web panel for Wand.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
pnpm install
|
||||
pnpm dev
|
||||
pnpm bridge:demo
|
||||
```
|
||||
|
||||
Hosted access on the local machine:
|
||||
|
||||
- `http://localhost:4173/?mock=1`
|
||||
- `http://localhost:4173/`
|
||||
|
||||
Hosted access on the LAN:
|
||||
|
||||
```bash
|
||||
npm run dev:host
|
||||
pnpm run dev:host
|
||||
```
|
||||
|
||||
Then open the machine IP on port `4173`.
|
||||
|
||||
## Modes
|
||||
|
||||
- `?mock=1`
|
||||
- dev server only; loads the demo trainer and values through a debug-only import
|
||||
- `?ws=ws://host:port/remote/ws`
|
||||
- connects to a real bridge once the desktop layer exists
|
||||
|
||||
Production builds exclude the debug route and demo JSON from the shipped bundle.
|
||||
Use `?ws=ws://host:port/remote/ws` to override the bridge URL. The fixture bridge is dev-only;
|
||||
production is bundled to `dist/bridge.cjs`.
|
||||
|
||||
Vendored
+63
@@ -0,0 +1,63 @@
|
||||
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, "src", "index.ts")
|
||||
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 EXCLUDED_RENDERER_SCRIPTS = new Set(["activate-pro.js"])
|
||||
|
||||
const rendererEntries = (
|
||||
await readdir(rendererScriptsRoot, { withFileTypes: true })
|
||||
)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
entry.name.endsWith(".js") &&
|
||||
!EXCLUDED_RENDERER_SCRIPTS.has(entry.name)
|
||||
)
|
||||
.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}`
|
||||
)
|
||||
+18
-10
@@ -4,17 +4,18 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import demoSession from '../fixtures/demo-session.json' with { type: 'json' };
|
||||
import webContract from '../protocol/web-contract.json' with { type: 'json' };
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const distDir = path.join(rootDir, 'dist');
|
||||
const DEFAULT_REMOTE_PORT = 3223;
|
||||
const DEFAULT_REMOTE_HOST = '0.0.0.0';
|
||||
const REMOTE_BASE_PATH = '/remote/';
|
||||
const REMOTE_WS_PATH = '/remote/ws';
|
||||
const REMOTE_HEALTH_PATH = '/remote/api/health';
|
||||
const REMOTE_ASSETS_PREFIX = '/remote/assets/';
|
||||
const DEFAULT_REMOTE_PORT = webContract.defaultRemotePort;
|
||||
const DEFAULT_REMOTE_HOST = webContract.defaultRemoteHost;
|
||||
const REMOTE_BASE_PATH = webContract.basePath;
|
||||
const REMOTE_WS_PATH = webContract.webSocketPath;
|
||||
const REMOTE_HEALTH_PATH = webContract.healthPath;
|
||||
const REMOTE_ASSETS_PREFIX = webContract.assetsPath;
|
||||
const host = process.env.HOST || DEFAULT_REMOTE_HOST;
|
||||
const port = Number(process.env.PORT || DEFAULT_REMOTE_PORT);
|
||||
|
||||
@@ -26,7 +27,7 @@ const wss = new WebSocketServer({ noServer: true });
|
||||
function jsonMessage(type, payload, requestId = null) {
|
||||
return JSON.stringify({
|
||||
type,
|
||||
version: 1,
|
||||
version: webContract.protocolVersion,
|
||||
requestId,
|
||||
payload,
|
||||
});
|
||||
@@ -145,13 +146,20 @@ wss.on('connection', (ws) => {
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const message = JSON.parse(String(raw));
|
||||
if (message?.version !== webContract.protocolVersion || typeof message?.type !== 'string' || !message?.payload) {
|
||||
ws.send(jsonMessage('error', {
|
||||
code: 'invalid_message',
|
||||
message: 'Expected a compatible protocol envelope.',
|
||||
}, message?.requestId ?? null));
|
||||
return;
|
||||
}
|
||||
if (message?.type === 'hello') {
|
||||
ws.send(
|
||||
jsonMessage('hello_ack', {
|
||||
sessionId: `sess_${Date.now()}`,
|
||||
accepted: true,
|
||||
serverVersion: '0.1.0-demo',
|
||||
protocolVersion: 1,
|
||||
protocolVersion: webContract.protocolVersion,
|
||||
}, message.requestId ?? null)
|
||||
);
|
||||
sendSnapshot(ws);
|
||||
@@ -160,7 +168,7 @@ wss.on('connection', (ws) => {
|
||||
|
||||
if (message?.type === 'set_value') {
|
||||
const target = message.payload?.target;
|
||||
if (typeof target !== 'string' || !(target in trainerValues.values)) {
|
||||
if (message.payload?.trainerId !== trainerMeta.trainer.trainerId || typeof target !== 'string' || !(target in trainerValues.values)) {
|
||||
ws.send(
|
||||
jsonMessage('set_value_result', {
|
||||
ok: false,
|
||||
@@ -209,4 +217,4 @@ wss.on('connection', (ws) => {
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`Wand web panel bridge listening on http://${host === DEFAULT_REMOTE_HOST ? 'localhost' : host}:${port}${REMOTE_BASE_PATH}`);
|
||||
});
|
||||
});
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// NOTE: Not wired into the build. Pro activation currently lives in the C# asar
|
||||
// patch (EPatchType.ActivatePro). This renderer-side variant is kept for future
|
||||
// use and is excluded from bridge/build.mjs (EXCLUDED_RENDERER_SCRIPTS), so it is
|
||||
// neither bundled nor injected. To re-enable, remove it from that exclusion list.
|
||||
import { installActivatePro } from "./activate-pro/index.js"
|
||||
|
||||
installActivatePro(globalThis.WandEnhancer)
|
||||
@@ -0,0 +1,140 @@
|
||||
// NOTE: Currently unused. Pro activation lives in the C# asar patch
|
||||
// (EPatchType.ActivatePro). This renderer-side variant patches the account service
|
||||
// prototype to inject the Pro subscription at the source. Kept for future use;
|
||||
// the entry `../activate-pro.js` is excluded from bridge/build.mjs.
|
||||
import { createLogger } from "../installed-apps-sync/logger.js"
|
||||
import {
|
||||
findExportedConstructor,
|
||||
getWebpackRequire,
|
||||
isRecord,
|
||||
} from "../installed-apps-sync/runtime.js"
|
||||
|
||||
const GLOBAL_FLAG = "__wandActivateProInstalled"
|
||||
const SERVICE_PATCH_KEY = "__wandEnhancerProAccountServicePatched"
|
||||
const ACCOUNT_SERVICE_METHODS = [
|
||||
"getUserAccount",
|
||||
"setAccountLanguage",
|
||||
"setAccountWandBrandExperience",
|
||||
]
|
||||
const RETRY_DELAY_MS = 400
|
||||
const MAX_ATTEMPTS = 90
|
||||
const DEFAULT_SUBSCRIPTION = Object.freeze({ period: "yearly", state: "active" })
|
||||
|
||||
export function installActivatePro(WandEnhancer) {
|
||||
if (globalThis[GLOBAL_FLAG]) {
|
||||
return
|
||||
}
|
||||
|
||||
globalThis[GLOBAL_FLAG] = true
|
||||
|
||||
const state = {
|
||||
attempts: 0,
|
||||
log: createLogger(WandEnhancer),
|
||||
}
|
||||
|
||||
state.log("info", "Activate Pro bootstrap starting.")
|
||||
retryBootstrap(state)
|
||||
}
|
||||
|
||||
function retryBootstrap(state) {
|
||||
if (patchAccountService(state)) {
|
||||
return
|
||||
}
|
||||
|
||||
state.attempts += 1
|
||||
if (state.attempts < MAX_ATTEMPTS) {
|
||||
setTimeout(() => retryBootstrap(state), RETRY_DELAY_MS)
|
||||
return
|
||||
}
|
||||
|
||||
state.log("error", "Activate Pro bootstrap exhausted; account service not found.")
|
||||
}
|
||||
|
||||
function patchAccountService(state) {
|
||||
const webpackRequire = getWebpackRequire()
|
||||
if (!webpackRequire) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ctor = findExportedConstructor(
|
||||
webpackRequire,
|
||||
(prototype) =>
|
||||
typeof prototype.getUserAccount === "function" &&
|
||||
typeof prototype.setAccountLanguage === "function" &&
|
||||
typeof prototype.setAccountWandBrandExperience === "function"
|
||||
)
|
||||
if (!ctor?.prototype) {
|
||||
return false
|
||||
}
|
||||
|
||||
const prototype = ctor.prototype
|
||||
if (prototype[SERVICE_PATCH_KEY]) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
for (const name of ACCOUNT_SERVICE_METHODS) {
|
||||
const original = prototype[name]
|
||||
if (typeof original !== "function") {
|
||||
continue
|
||||
}
|
||||
|
||||
prototype[name] = function patchedAccountMethod(...args) {
|
||||
return Promise.resolve(original.apply(this, args)).then((account) =>
|
||||
normalizeProAccount(account)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(prototype, SERVICE_PATCH_KEY, { value: true })
|
||||
state.log("info", "Pro account service patched.")
|
||||
return true
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Failed to patch account service.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProAccount(account) {
|
||||
if (!isRecord(account)) {
|
||||
return account
|
||||
}
|
||||
|
||||
const nextSubscription = normalizeProSubscription(account.subscription)
|
||||
if (nextSubscription === account.subscription) {
|
||||
return account
|
||||
}
|
||||
|
||||
return {
|
||||
...account,
|
||||
subscription: nextSubscription,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProSubscription(subscription) {
|
||||
if (!isRecord(subscription)) {
|
||||
return { ...DEFAULT_SUBSCRIPTION }
|
||||
}
|
||||
|
||||
const nextSubscription = { ...subscription }
|
||||
let changed = false
|
||||
|
||||
if (
|
||||
typeof nextSubscription.period !== "string" ||
|
||||
!nextSubscription.period.trim()
|
||||
) {
|
||||
nextSubscription.period = DEFAULT_SUBSCRIPTION.period
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (nextSubscription.state !== "active") {
|
||||
nextSubscription.state = DEFAULT_SUBSCRIPTION.state
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed ? nextSubscription : subscription
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
getWebpackRequire,
|
||||
isRecord,
|
||||
} from "./installed-apps-sync/runtime.js"
|
||||
|
||||
;(function installRemotePopupCleanup(WandEnhancer) {
|
||||
if (globalThis.__wandRemotePopupCleanupInstalled) {
|
||||
return
|
||||
}
|
||||
|
||||
globalThis.__wandRemotePopupCleanupInstalled = true
|
||||
|
||||
const style = document.createElement("style")
|
||||
style.id = "wand-remote-popup-cleanup-style"
|
||||
style.textContent = `
|
||||
article.pro-onboarding-card--remote {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
remote-tooltip .remote-tooltip .top-wrapper,
|
||||
remote-tooltip .remote-tooltip .remote-tooltip-section-divider,
|
||||
remote-tooltip .remote-tooltip .instructions .header,
|
||||
remote-tooltip .remote-tooltip .instructions .platforms {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
remote-tooltip .remote-tooltip .instructions-wrapper {
|
||||
margin: 0 !important;
|
||||
padding: 18px !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
remote-tooltip .remote-tooltip .instructions,
|
||||
remote-tooltip .remote-tooltip .instructions .content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 0 !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
remote-tooltip .remote-tooltip .instructions remote-qr-code {
|
||||
--wand-qr-size: clamp(180px, 70vw, 240px);
|
||||
width: var(--wand-qr-size) !important;
|
||||
height: var(--wand-qr-size) !important;
|
||||
min-width: var(--wand-qr-size) !important;
|
||||
min-height: var(--wand-qr-size) !important;
|
||||
max-width: var(--wand-qr-size) !important;
|
||||
max-height: var(--wand-qr-size) !important;
|
||||
flex: 0 0 var(--wand-qr-size) !important;
|
||||
aspect-ratio: 1 / 1 !important;
|
||||
display: block !important;
|
||||
border-radius: 12px !important;
|
||||
overflow: hidden !important;
|
||||
transform: none !important;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35) !important;
|
||||
}
|
||||
|
||||
remote-tooltip .remote-tooltip .instructions .content .text {
|
||||
display: block !important;
|
||||
max-width: 250px !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
|
||||
remote-tooltip .remote-tooltip .instructions remote-qr-code canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
aspect-ratio: 1 / 1 !important;
|
||||
display: block !important;
|
||||
object-fit: contain !important;
|
||||
image-rendering: pixelated !important;
|
||||
border-radius: 12px !important;
|
||||
transform: none !important;
|
||||
}
|
||||
`
|
||||
let qrRenderer = null
|
||||
let refreshScheduled = false
|
||||
|
||||
const installStyle = () => {
|
||||
if (!document.getElementById(style.id)) {
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
}
|
||||
|
||||
const getRemoteUrl = () =>
|
||||
globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl
|
||||
|
||||
const resolveQrRenderer = () => {
|
||||
if (qrRenderer) {
|
||||
return qrRenderer
|
||||
}
|
||||
|
||||
const webpackRequire = getWebpackRequire()
|
||||
for (const record of Object.values(webpackRequire?.c || {})) {
|
||||
const exports = record?.exports
|
||||
if (
|
||||
isRecord(exports) &&
|
||||
typeof exports.create === "function" &&
|
||||
typeof exports.mo === "function"
|
||||
) {
|
||||
qrRenderer = exports.mo
|
||||
return qrRenderer
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const updateLinks = (remoteUrl) => {
|
||||
if (!remoteUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const anchor of document.querySelectorAll("remote-tooltip a[href]")) {
|
||||
anchor.setAttribute("href", remoteUrl)
|
||||
anchor.textContent = remoteUrl.replace(/\/$/, "")
|
||||
}
|
||||
}
|
||||
|
||||
const updateQrCodes = async (remoteUrl) => {
|
||||
const renderQr = remoteUrl && resolveQrRenderer()
|
||||
if (!renderQr) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const canvas of document.querySelectorAll("remote-qr-code canvas")) {
|
||||
if (canvas.dataset.wandRemoteUrl === remoteUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await renderQr(canvas, remoteUrl)
|
||||
canvas.dataset.wandRemoteUrl = remoteUrl
|
||||
} catch (error) {
|
||||
WandEnhancer?.log("Failed to render local remote QR code", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
const remoteUrl = getRemoteUrl()
|
||||
installStyle()
|
||||
updateLinks(remoteUrl)
|
||||
void updateQrCodes(remoteUrl)
|
||||
}
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
if (refreshScheduled) {
|
||||
return
|
||||
}
|
||||
|
||||
refreshScheduled = true
|
||||
setTimeout(() => {
|
||||
refreshScheduled = false
|
||||
refresh()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
refresh()
|
||||
|
||||
const observer = new MutationObserver(scheduleRefresh)
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
})(globalThis.WandEnhancer)
|
||||
Vendored
+142
@@ -0,0 +1,142 @@
|
||||
const {
|
||||
buildInstalledAppsDebugPayload,
|
||||
gameStatusSignature,
|
||||
installedAppsSignature,
|
||||
normalizeGameStatusSnapshot,
|
||||
normalizeInstalledAppsSnapshot,
|
||||
normalizeSnapshot,
|
||||
normalizeTrainerValue,
|
||||
summarizeInstalledAppsSource,
|
||||
} = require('./normalizers');
|
||||
const { cloneValue, isRecord, safeString } = require('./utils');
|
||||
const { sendJson } = require('./websocket-codec');
|
||||
|
||||
function createBridgeState({ clients, log, getServerInfo }) {
|
||||
let currentSnapshot: any = null;
|
||||
let currentInstalledApps: any = null;
|
||||
let currentInstalledAppsSignature: string | null = null;
|
||||
let currentGameStatus: any = null;
|
||||
let currentGameStatusSignature: string | null = null;
|
||||
|
||||
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) {
|
||||
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;
|
||||
|
||||
const value = normalizeTrainerValue(currentSnapshot, target, change.value);
|
||||
currentSnapshot.trainerValues.values[target] = value;
|
||||
broadcast('value_changed', {
|
||||
trainerId: safeString(change.trainerId, currentSnapshot.trainerMeta.trainer.trainerId),
|
||||
target,
|
||||
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) 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 buildHealthPayload() {
|
||||
const installedAppsDebug = buildInstalledAppsDebugPayload(currentInstalledApps);
|
||||
const serverInfo = getServerInfo();
|
||||
return {
|
||||
ok: serverInfo.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: serverInfo.installedAppsApiPath,
|
||||
remoteUrl: serverInfo.remoteUrl,
|
||||
advertisedUrls: serverInfo.advertisedUrls,
|
||||
};
|
||||
}
|
||||
|
||||
function clear() {
|
||||
currentSnapshot = null;
|
||||
currentInstalledApps = null;
|
||||
currentInstalledAppsSignature = null;
|
||||
currentGameStatus = null;
|
||||
currentGameStatusSignature = null;
|
||||
}
|
||||
|
||||
return {
|
||||
get snapshot() { return currentSnapshot; },
|
||||
buildHealthPayload,
|
||||
buildInstalledAppsDebugPayload: () => buildInstalledAppsDebugPayload(currentInstalledApps),
|
||||
clear,
|
||||
sendSnapshot,
|
||||
sync,
|
||||
syncGameStatus,
|
||||
syncInstalledApps,
|
||||
valueChanged,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeState,
|
||||
};
|
||||
Vendored
+47
@@ -0,0 +1,47 @@
|
||||
const KNOWN_CHEAT_TYPES = new Set(['slider', 'number', 'toggle', 'button', 'selection', 'scalar', 'incremental']);
|
||||
const WEB_CONTRACT = require('../../protocol/web-contract.json');
|
||||
|
||||
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: WEB_CONTRACT.protocolVersion,
|
||||
BRIDGE_SERVER_VERSION: WEB_CONTRACT.serverVersion,
|
||||
DEFAULT_REMOTE_HOST: WEB_CONTRACT.defaultRemoteHost,
|
||||
DEFAULT_REMOTE_PORT: WEB_CONTRACT.defaultRemotePort,
|
||||
IPC_CHANNEL,
|
||||
KNOWN_CHEAT_TYPES,
|
||||
PORT_SCAN_RANGE: WEB_CONTRACT.portScanRange,
|
||||
REMOTE_ASSETS_PREFIX: WEB_CONTRACT.assetsPath,
|
||||
REMOTE_BASE_PATH: WEB_CONTRACT.basePath,
|
||||
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: WEB_CONTRACT.healthPath,
|
||||
REMOTE_INSTALLED_APPS_API_PATH: WEB_CONTRACT.installedAppsPath,
|
||||
REMOTE_INSTALLED_APPS_CHANNEL: IPC_CHANNEL.INSTALLED_APPS,
|
||||
REMOTE_WS_PATH: WEB_CONTRACT.webSocketPath,
|
||||
RENDERER_INJECTION_DELAYS_MS: Object.freeze([500, 2000]),
|
||||
RENDERER_SCRIPT_API_VERSION: 1,
|
||||
RENDERER_SCRIPTS_DIR: 'renderer-scripts',
|
||||
WS_OPCODE,
|
||||
};
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./runtime');
|
||||
const { installWandRuntime: installRuntime } = require('./wand/runtime');
|
||||
import type { BridgeOptions, ElectronPort } from './types';
|
||||
|
||||
function withDefaultPanelRoot(options: BridgeOptions = {}): BridgeOptions {
|
||||
if (options.panelRoot) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
panelRoot: __dirname,
|
||||
};
|
||||
}
|
||||
|
||||
function createBridgeRuntime(options: BridgeOptions = {}) {
|
||||
return createRuntime(withDefaultPanelRoot(options));
|
||||
}
|
||||
|
||||
function ensureBridge(options: BridgeOptions = {}) {
|
||||
return ensureRuntime(withDefaultPanelRoot(options));
|
||||
}
|
||||
|
||||
function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) {
|
||||
return installRuntime(electron, withDefaultPanelRoot(options));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeRuntime,
|
||||
ensureBridge,
|
||||
installWandRuntime,
|
||||
};
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { BRIDGE_LOG_FILE_NAME } = require('./constants');
|
||||
import type { BridgeOptions } from './types';
|
||||
|
||||
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: BridgeOptions = {}) {
|
||||
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,32 @@
|
||||
const { isRecord, safeString, toStringId } = require('../utils');
|
||||
|
||||
function normalizeRemoteCommandAction(value) {
|
||||
return value === 'launch' || value === 'stop' ? value : 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.'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeRemoteCommandAction,
|
||||
normalizeRemoteCommandResult,
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
const { isRecord, safeString, toStringId } = require('../utils');
|
||||
|
||||
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 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('|');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
gameStatusSignature,
|
||||
normalizeGameStatusSnapshot,
|
||||
};
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
const { KNOWN_CHEAT_TYPES } = require('../constants');
|
||||
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('../utils');
|
||||
const { normalizeRemoteCommandAction, normalizeRemoteCommandResult } = require('./command-results');
|
||||
const { gameStatusSignature, normalizeGameStatusSnapshot } = require('./game-status');
|
||||
const { normalizeTrainerValue } = require('./trainer');
|
||||
|
||||
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: Record<string, unknown> = {};
|
||||
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: Record<string, unknown> = {
|
||||
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 summarizeInstalledAppsSource(rawSnapshot) {
|
||||
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
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 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'),
|
||||
accessToken: safeString(rawSnapshot.accessToken),
|
||||
},
|
||||
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) : {},
|
||||
};
|
||||
for (const cheat of cheats) {
|
||||
if (cheat.target in trainerValues.values) {
|
||||
trainerValues.values[cheat.target] = normalizeTrainerValue({ trainerMeta }, cheat.target, trainerValues.values[cheat.target]);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
normalizeTrainerValue,
|
||||
summarizeInstalledAppsSource,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeTrainerValue } from './trainer';
|
||||
|
||||
describe('trainer normalization', () => {
|
||||
it('normalizes toggle values before they reach clients or Wand', () => {
|
||||
const snapshot = {
|
||||
trainerMeta: {
|
||||
schema: { cheats: [{ target: 'god', type: 'toggle' }] },
|
||||
},
|
||||
};
|
||||
|
||||
expect(normalizeTrainerValue(snapshot, 'god', 1)).toBe(true);
|
||||
expect(normalizeTrainerValue(snapshot, 'god', 0)).toBe(false);
|
||||
expect(normalizeTrainerValue(snapshot, 'speed', 2)).toBe(2);
|
||||
});
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export function normalizeTrainerValue(snapshot, target, value) {
|
||||
const cheat = snapshot?.trainerMeta?.schema?.cheats?.find((entry) => entry.target === target);
|
||||
return cheat?.type === 'toggle' ? Boolean(value) : cloneValue(value);
|
||||
}
|
||||
|
||||
function cloneValue(value) {
|
||||
if (Array.isArray(value)) return value.map(cloneValue);
|
||||
if (typeof value !== 'object' || value === null) return value;
|
||||
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)]));
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { validateClientMessage, validateSetValueTarget } from './protocol-router';
|
||||
|
||||
const snapshot = {
|
||||
trainerMeta: {
|
||||
trainer: { trainerId: 'active' },
|
||||
schema: { cheats: [{ target: 'god', type: 'toggle' }] },
|
||||
},
|
||||
trainerValues: { values: { god: false } },
|
||||
};
|
||||
|
||||
describe('bridge protocol router', () => {
|
||||
it('requires a compatible hello before commands', () => {
|
||||
const command = {
|
||||
type: 'set_value',
|
||||
version: 1,
|
||||
requestId: 'set',
|
||||
payload: { trainerId: 'active', target: 'god', value: true },
|
||||
};
|
||||
expect(validateClientMessage(command, false)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'handshake_required' },
|
||||
});
|
||||
expect(validateClientMessage({ ...command, version: 2 }, true)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'protocol_mismatch' },
|
||||
});
|
||||
});
|
||||
|
||||
it('validates trainer and target while normalizing toggle values', () => {
|
||||
expect(validateSetValueTarget({
|
||||
payload: { trainerId: 'other', target: 'god', value: 1 },
|
||||
}, snapshot)).toMatchObject({ ok: false, error: { code: 'trainer_mismatch' } });
|
||||
|
||||
expect(validateSetValueTarget({
|
||||
payload: { trainerId: 'active', target: 'god', value: 1 },
|
||||
}, snapshot)).toMatchObject({ ok: true, value: true });
|
||||
});
|
||||
});
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import webContract from '../../protocol/web-contract.json';
|
||||
|
||||
const BRIDGE_PROTOCOL_VERSION = webContract.protocolVersion;
|
||||
|
||||
export function validateClientMessage(message, handshaken) {
|
||||
if (!isRecord(message) || typeof message.type !== 'string' || !isRecord(message.payload)) {
|
||||
return invalid('invalid_message', 'Expected a protocol envelope with an object payload.');
|
||||
}
|
||||
|
||||
if (message.version !== BRIDGE_PROTOCOL_VERSION) {
|
||||
return invalid('protocol_mismatch', `Unsupported protocol version ${String(message.version)}.`);
|
||||
}
|
||||
|
||||
if (message.requestId !== null && typeof message.requestId !== 'string') {
|
||||
return invalid('invalid_request_id', 'requestId must be a string or null.');
|
||||
}
|
||||
|
||||
if (message.type === 'hello') {
|
||||
if (message.payload.client !== 'mobile-web' || typeof message.payload.clientVersion !== 'string' || !isRecord(message.payload.capabilities)) {
|
||||
return invalid('invalid_hello', 'The hello payload is incomplete.');
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (!handshaken) {
|
||||
return invalid('handshake_required', 'Send a compatible hello message before commands.');
|
||||
}
|
||||
|
||||
if (message.type === 'set_value') {
|
||||
if (!safeString(message.payload.trainerId) || !safeString(message.payload.target) || !('value' in message.payload)) {
|
||||
return invalid('invalid_set_value', 'trainerId, target and value are required.');
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (message.type === 'remote_command') {
|
||||
if (message.payload.action !== 'launch' && message.payload.action !== 'stop') {
|
||||
return invalid('invalid_command', 'Unknown remote command.');
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return invalid('unknown_message', 'Unknown protocol message type.');
|
||||
}
|
||||
|
||||
export function validateSetValueTarget(message, snapshot) {
|
||||
const target = safeString(message.payload?.target);
|
||||
const requestedTrainerId = safeString(message.payload?.trainerId);
|
||||
const activeTrainerId = snapshot?.trainerMeta?.trainer?.trainerId || '';
|
||||
if (!snapshot || requestedTrainerId !== activeTrainerId) {
|
||||
return invalid('trainer_mismatch', 'The requested trainer is not active.');
|
||||
}
|
||||
|
||||
const cheat = snapshot.trainerMeta.schema.cheats.find((entry) => entry.target === target);
|
||||
if (!target || !cheat || !(target in snapshot.trainerValues.values)) {
|
||||
return invalid('invalid_target', 'Unknown cheat target.');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
trainerId: activeTrainerId,
|
||||
target,
|
||||
cheat,
|
||||
value: cheat.type === 'toggle' ? Boolean(message.payload.value) : message.payload.value,
|
||||
};
|
||||
}
|
||||
|
||||
function invalid(code, message) {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function safeString(value) {
|
||||
return typeof value === 'string' && value.length > 0 ? value : '';
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findSteamAppId,
|
||||
getSteamClientIconUrl,
|
||||
normalizeImageUrl,
|
||||
} from '../scripts/default/installed-apps-sync/artwork.js';
|
||||
|
||||
describe('installed-apps renderer script models', () => {
|
||||
it('normalizes captured artwork shapes without a Wand runtime', () => {
|
||||
expect(normalizeImageUrl({ cover: { imageUrl: '//cdn.example/game.webp' } }))
|
||||
.toBe('https://cdn.example/game.webp');
|
||||
expect(normalizeImageUrl('file:///local/image.png')).toBeNull();
|
||||
});
|
||||
|
||||
it('finds nested Steam metadata and builds the Wand client icon URL', () => {
|
||||
const fixture = {
|
||||
game: {
|
||||
metadata: {
|
||||
steam: {
|
||||
appId: 1245620,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(findSteamAppId(fixture)).toBe('1245620');
|
||||
expect(getSteamClientIconUrl(findSteamAppId(fixture)))
|
||||
.toBe('https://api-cdn.wemod.com/steam_community/1245620/client_icon/96.webp');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WebSocket as NodeWebSocket } from 'ws';
|
||||
|
||||
describe('production bridge runtime', () => {
|
||||
it('preserves the public API and sends cached snapshots after hello', async () => {
|
||||
const bridge = require('../../dist/bridge.cjs');
|
||||
expect(Object.keys(bridge).sort()).toEqual(['createBridgeRuntime', 'ensureBridge', 'installWandRuntime']);
|
||||
|
||||
const port = await getFreePort();
|
||||
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
|
||||
runtime.sync(rawTrainerSnapshot());
|
||||
|
||||
try {
|
||||
await waitUntil(() => runtime.listening);
|
||||
const messages = await connectAndCollect(port, 3);
|
||||
expect(messages.map((message) => message.type)).toEqual(['hello_ack', 'trainer_meta', 'trainer_values']);
|
||||
expect(messages[2].payload.values.god).toBe(true);
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close((error) => error ? reject(error) : resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function connectAndCollect(port: number, count: number): Promise<any[]> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const messages: any[] = [];
|
||||
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`);
|
||||
socket.once('error', reject);
|
||||
socket.once('open', () => socket.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
version: 1,
|
||||
requestId: 'hello',
|
||||
payload: {
|
||||
client: 'mobile-web',
|
||||
clientVersion: 'test',
|
||||
capabilities: { supportsDeltaValues: true, supportsTrainerSwitch: true },
|
||||
},
|
||||
})));
|
||||
socket.on('message', (raw) => {
|
||||
messages.push(JSON.parse(String(raw)));
|
||||
if (messages.length === count) {
|
||||
socket.close();
|
||||
resolve(messages);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean): Promise<void> {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error('Bridge did not start listening.');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
function rawTrainerSnapshot() {
|
||||
return {
|
||||
instanceId: 'instance',
|
||||
trainerId: 'trainer',
|
||||
trainerInfo: { gameId: 'game', displayName: 'Game' },
|
||||
metadata: {
|
||||
info: {
|
||||
blueprint: {
|
||||
cheats: [{
|
||||
uuid: 'god',
|
||||
target: 'god',
|
||||
type: 'toggle',
|
||||
name: 'God mode',
|
||||
category: 'player',
|
||||
args: {},
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
values: { god: 1 },
|
||||
};
|
||||
}
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
const { createBridgeServer } = require('./server');
|
||||
import type { BridgeOptions } from './types';
|
||||
|
||||
function createBridgeRuntime(options: BridgeOptions = {}) {
|
||||
return createBridgeServer(options);
|
||||
}
|
||||
|
||||
function ensureBridge(options: BridgeOptions = {}) {
|
||||
if (!globalThis.__wandRemoteBridgeRuntime) {
|
||||
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
|
||||
}
|
||||
|
||||
return globalThis.__wandRemoteBridgeRuntime;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeRuntime,
|
||||
ensureBridge,
|
||||
};
|
||||
Vendored
+166
@@ -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');
|
||||
|
||||
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: any[] = [];
|
||||
const interfaces = os.networkInterfaces();
|
||||
let index = 0;
|
||||
|
||||
for (const [name, entries] of Object.entries(interfaces) as [string, any[] | undefined][]) {
|
||||
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) as number[];
|
||||
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): number[] | null {
|
||||
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,
|
||||
};
|
||||
Vendored
+411
@@ -0,0 +1,411 @@
|
||||
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');
|
||||
const { createBridgeLogger } = require('./logger');
|
||||
const {
|
||||
normalizeRemoteCommandAction,
|
||||
normalizeRemoteCommandResult,
|
||||
} = require('./normalizers');
|
||||
const { createBridgeState } = require('./bridge-state');
|
||||
const { validateClientMessage, validateSetValueTarget } = require('./protocol-router');
|
||||
const { getAdvertisedUrls, serveFile } = require('./server-files');
|
||||
const { cloneValue, isValidPort, safeString } = require('./utils');
|
||||
const { closeClient, createAcceptKey, makeFrame, parseFrame, sendJson } = require('./websocket-codec');
|
||||
import type { BridgeOptions } from './types';
|
||||
|
||||
function createBridgeServer(options: BridgeOptions = {}) {
|
||||
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<any>();
|
||||
const log = createBridgeLogger(options);
|
||||
let advertisedUrls: string[] = [];
|
||||
let setValueHandler: any = null;
|
||||
let commandHandler: any = null;
|
||||
let listening = false;
|
||||
const bridgeState = createBridgeState({
|
||||
clients,
|
||||
log,
|
||||
getServerInfo: () => ({
|
||||
advertisedUrls,
|
||||
installedAppsApiPath: REMOTE_INSTALLED_APPS_API_PATH,
|
||||
listening,
|
||||
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
function setAdvertisedPort(nextPort) {
|
||||
port = nextPort;
|
||||
advertisedUrls = getAdvertisedUrls(port);
|
||||
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry) => !entry.includes('localhost')) || advertisedUrls[0];
|
||||
}
|
||||
|
||||
function setHandler(handler) {
|
||||
setValueHandler = typeof handler === 'function' ? handler : null;
|
||||
}
|
||||
|
||||
function setCommandHandler(handler) {
|
||||
commandHandler = typeof handler === 'function' ? handler : null;
|
||||
}
|
||||
|
||||
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(bridgeState.buildHealthPayload()));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === REMOTE_INSTALLED_APPS_API_PATH) {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
response.end(JSON.stringify(bridgeState.buildInstalledAppsDebugPayload(), 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 currentSnapshot = bridgeState.snapshot;
|
||||
const validation = validateSetValueTarget(message, currentSnapshot);
|
||||
if (!validation.ok) {
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: false,
|
||||
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || '',
|
||||
target: safeString(message.payload?.target),
|
||||
error: validation.error,
|
||||
}, message.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
const { target } = validation;
|
||||
|
||||
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(validation.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) {
|
||||
const validation = validateClientMessage(message, client.handshaken);
|
||||
if (!validation.ok) {
|
||||
sendJson(client, 'error', validation.error, message?.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === 'hello') {
|
||||
client.handshaken = true;
|
||||
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);
|
||||
bridgeState.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,
|
||||
handshaken: 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();
|
||||
bridgeState.clear();
|
||||
listening = false;
|
||||
server.close();
|
||||
},
|
||||
setCommandHandler,
|
||||
setHandler,
|
||||
sync: bridgeState.sync,
|
||||
syncGameStatus: bridgeState.syncGameStatus,
|
||||
syncInstalledApps: bridgeState.syncInstalledApps,
|
||||
valueChanged: bridgeState.valueChanged,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeServer,
|
||||
};
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
export type BridgeOptions = {
|
||||
host?: string;
|
||||
logFile?: string;
|
||||
maxPort?: number | string;
|
||||
panelRoot?: string;
|
||||
port?: number | string;
|
||||
scriptsRoot?: string;
|
||||
};
|
||||
|
||||
export type WebContentsPort = {
|
||||
executeJavaScript(source: string, userGesture?: boolean): Promise<unknown>;
|
||||
isDestroyed(): boolean;
|
||||
on(event: string, listener: () => void): void;
|
||||
send(channel: string, payload: unknown): void;
|
||||
};
|
||||
|
||||
export type ElectronPort = {
|
||||
app: {
|
||||
on(event: 'web-contents-created', listener: (event: unknown, contents: WebContentsPort) => void): void;
|
||||
};
|
||||
ipcMain: {
|
||||
handle(channel: string, handler: (event: { sender?: WebContentsPort }, payload?: unknown) => unknown): void;
|
||||
};
|
||||
};
|
||||
Vendored
+64
@@ -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,
|
||||
};
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('../constants');
|
||||
const { writeInstallLog } = require('../logger');
|
||||
import type { BridgeOptions, ElectronPort } from '../types';
|
||||
|
||||
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: ElectronPort, runtime, options: BridgeOptions = {}) {
|
||||
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,
|
||||
};
|
||||
Vendored
+194
@@ -0,0 +1,194 @@
|
||||
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');
|
||||
const { writeInstallLog } = require('../logger');
|
||||
const { ensureBridge } = require('../runtime');
|
||||
const { installRendererScripts } = require('./renderer-scripts');
|
||||
const { safeString } = require('../utils');
|
||||
import type { BridgeOptions, ElectronPort, WebContentsPort } from '../types';
|
||||
|
||||
// Reads the signed-in WeMod access token from the renderer's localStorage so the
|
||||
// panel can request localized cheat metadata from the WeMod API.
|
||||
const WEMOD_ACCESS_TOKEN_SCRIPT =
|
||||
'JSON.parse(localStorage.getItem("infinity:globalStore") || "{}")?.token?.accessToken ?? null';
|
||||
|
||||
function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) {
|
||||
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: Set<WebContentsPort> = 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) => {
|
||||
void syncSnapshotWithAccessToken(runtime, event?.sender, 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);
|
||||
}
|
||||
|
||||
async function syncSnapshotWithAccessToken(runtime, sender, snapshot) {
|
||||
const accessToken = await readWemodAccessToken(sender);
|
||||
if (accessToken && snapshot && typeof snapshot === 'object') {
|
||||
snapshot.accessToken = accessToken;
|
||||
}
|
||||
|
||||
runtime.sync(snapshot);
|
||||
}
|
||||
|
||||
async function readWemodAccessToken(sender) {
|
||||
if (!sender || typeof sender.executeJavaScript !== 'function' || sender.isDestroyed?.()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await sender.executeJavaScript(WEMOD_ACCESS_TOKEN_SCRIPT);
|
||||
return typeof token === 'string' && token ? token : null;
|
||||
} catch (error) {
|
||||
writeInstallLog('warn', 'Failed to read WeMod access token from renderer.', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants');
|
||||
|
||||
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: number[] = [];
|
||||
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,
|
||||
};
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noImplicitAny": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
-896
@@ -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,
|
||||
};
|
||||
Vendored
+12
-1
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(['dist', 'src/locales']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
@@ -19,5 +19,16 @@ export default defineConfig([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['bridge/src/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-empty': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
Vendored
+30
-9
@@ -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,
|
||||
|
||||
Vendored
+2
-2
@@ -7,6 +7,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
<script type="module" src="./src/app/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from '@lingui/cli';
|
||||
import { formatter } from '@lingui/format-po';
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: 'en-US',
|
||||
locales: ['en-US', 'ru-RU', 'de-DE', 'fr-FR', 'es-ES', 'zh-CN'],
|
||||
catalogs: [
|
||||
{
|
||||
path: '<rootDir>/src/locales/{locale}/messages',
|
||||
include: ['src'],
|
||||
},
|
||||
],
|
||||
format: formatter({ lineNumbers: false }),
|
||||
});
|
||||
Vendored
+23
-4
@@ -6,30 +6,49 @@
|
||||
"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",
|
||||
"lint": "eslint src protocol bridge/src --max-warnings=0",
|
||||
"typecheck:web": "tsc --noEmit",
|
||||
"typecheck:bridge": "tsc -p bridge/tsconfig.json --noEmit",
|
||||
"typecheck": "pnpm typecheck:web && pnpm typecheck:bridge",
|
||||
"test": "pnpm build:bridge && vitest run",
|
||||
"i18n:extract": "lingui extract",
|
||||
"i18n:compile": "lingui compile",
|
||||
"preview": "vite preview",
|
||||
"preview:host": "vite preview --host 0.0.0.0",
|
||||
"bridge": "node ./bridge/server.mjs"
|
||||
"bridge:demo": "node ./bridge/dev-server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lingui/core": "^6.3.0",
|
||||
"@lingui/react": "^6.3.0",
|
||||
"preact": "^10.27.2",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@lingui/babel-plugin-lingui-macro": "^6.3.0",
|
||||
"@lingui/cli": "^6.3.0",
|
||||
"@lingui/format-po": "^6.3.0",
|
||||
"@lingui/vite-plugin": "^6.3.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@testing-library/preact": "^3.2.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@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",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.2"
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vite": "^7.3.2",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user