mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-28 23:01:13 +00:00
fix: bump version to 1.0.9.4, update changelog
This commit is contained in:
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10
|
||||
version: 10.17.0
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
|
||||
@@ -10,7 +10,10 @@ on:
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
if: github.repository == 'k1tbyte/Wand-Enhancer'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
jobs:
|
||||
validate-release-metadata:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -3,6 +3,26 @@
|
||||
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.4] - 2026-07-21
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed the Remote Web Panel QR code still opening the official Wand mobile client after Wand changed its bundled QR renderer export. The renderer bridge now resolves the current export without adding a fragile C# ASAR patch. [Discussion #140](https://github.com/k1tbyte/Wand-Enhancer/discussions/140)
|
||||
- Fixed Quick Presets reporting that a preset was saved when browser local storage rejected the write. Failed writes now leave the existing preset list unchanged and show an error, and the save dialog now stays above the bottom navigation dock.
|
||||
- Fixed the patcher giving up on process termination because it reused a stale process snapshot by @divya0795 in [#145](https://github.com/k1tbyte/Wand-Enhancer/pull/145). Related issue: [#136](https://github.com/k1tbyte/Wand-Enhancer/issues/136)
|
||||
- Fixed ASAR extraction path traversal and corrupt Pickle payload allocation by @divya0795 in [#143](https://github.com/k1tbyte/Wand-Enhancer/pull/143).
|
||||
- Fixed backup restore so `app.asar.unpacked` is restored together with `app.asar`, and the injected `version.dll` is removed after a successful restore.
|
||||
- Fixed `version.dll` requiring Visual C++ runtime DLLs on some systems by statically linking the runtime. Release builds now reject accidental dynamic VCRUNTIME, MSVCP, or UCRT dependencies. [#128](https://github.com/k1tbyte/Wand-Enhancer/issues/128)
|
||||
|
||||
### Security and Privacy
|
||||
|
||||
- Removed bearer credentials and local installation paths from the Remote Web Panel WebSocket protocol. Trainer localization now stays inside the Electron bridge.
|
||||
- Hardened the local bridge against malformed HTTP URLs, invalid Host headers, and oversized WebSocket frames, and removed the production installed-apps debug endpoint.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- GitLab mirror jobs are now skipped in forks instead of failing when the upstream mirror credentials are unavailable.
|
||||
|
||||
## [1.0.9.3] - 2026-07-04
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
**🚨 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?
|
||||
## 👾 What does it access?
|
||||
|
||||
Yes. This project is entirely open-source, allowing anyone to audit the code. It operates strictly locally, does not require internet access, and makes zero network requests. It simply adjusts local client settings to enhance your user experience.
|
||||
The .NET patcher modifies files in the selected local Wand installation and does not contact an update or telemetry service. The bundled `version.dll` proxy is loaded by Wand and changes Electron's ASAR-integrity fuse byte inside Wand's own process; it does not inject into another process. Wand itself remains an online application, build tools restore declared dependencies, and the optional Remote Web Panel deliberately starts a LAN HTTP/WebSocket server and uses Wand API/CDN data. Review the source and build the executable from your own fork; unsigned patching tools can trigger generic antivirus heuristics.
|
||||
|
||||
## 💫 What features are improved?
|
||||
|
||||
@@ -36,17 +36,20 @@ WandEnhancer includes a built-in **Remote Web Panel** allowing you to control ap
|
||||
### Troubleshooting & Remote Access:
|
||||
- **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.
|
||||
- The panel uses plain HTTP on port `3223` and has no pairing code. Anyone who can reach that port can view the panel and control the active trainer, so use it only on a trusted LAN/VPN and never expose the port directly to the internet.
|
||||
- The panel protocol does not include your Wand bearer token or installation-path fields.
|
||||
|
||||
## 👀 How to use?
|
||||
|
||||
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.
|
||||
2. Use **Sync fork** before each build so your fork contains the latest fixes.
|
||||
3. Open your fork, go to the **Actions** tab, and enable workflows if GitHub asks you to.
|
||||
4. Select the **Build executable** workflow.
|
||||
5. Click **Run workflow**, keep the default branch, and start the run.
|
||||
6. Wait for the workflow to finish, open the completed run, and download the artifact.
|
||||
7. Extract the artifact zip and run `WandEnhancer.exe` to apply local client modifications.
|
||||
|
||||
*Here how you do it:*
|
||||
|
||||
@@ -126,7 +129,9 @@ The build script installs the web panel dependencies, builds the frontend, compi
|
||||
- **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?**
|
||||
- The desktop patching work is local to your machine. The Remote Web Panel is served from your PC on your local network.
|
||||
- The .NET patching step is local. The optional Remote Web Panel listens on your LAN and may request trainer translations/artwork through Wand's existing API/CDN paths; it does not include an updater or project telemetry.
|
||||
- **How do I learn about a new version without an in-app update check?**
|
||||
- On GitHub choose **Watch → Custom → Releases**, then sync your fork and run **Build executable** when a release is published.
|
||||
|
||||
---
|
||||
## 🖼️ Screenshots
|
||||
|
||||
@@ -244,7 +244,7 @@ namespace WandEnhancer.Core
|
||||
throw new FileNotFoundException($"Required workspace artifact not found: {Path.Combine(segments)}");
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string sourceDir, string destinationDir)
|
||||
internal static void CopyDirectory(string sourceDir, string destinationDir)
|
||||
{
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
|
||||
@@ -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.9.3")]
|
||||
[assembly: AssemblyFileVersion("1.0.9.3")]
|
||||
[assembly: AssemblyVersion("1.0.9.4")]
|
||||
[assembly: AssemblyFileVersion("1.0.9.4")]
|
||||
|
||||
@@ -28,7 +28,9 @@ namespace WandEnhancer.View.MainWindow
|
||||
if (value == null) return;
|
||||
|
||||
Log($"WeMod directory found at '{_weModConfig}' ({_weModConfig.ExecutableName})", ELogType.Success);
|
||||
if (File.Exists(Path.Combine(_weModConfig.RootDirectory, "resources", "app.asar.backup")))
|
||||
var resourcesPath = Path.Combine(_weModConfig.RootDirectory, "resources");
|
||||
if (File.Exists(Path.Combine(resourcesPath, "app.asar.backup")) ||
|
||||
Directory.Exists(Path.Combine(resourcesPath, "app.asar.unpacked.backup")))
|
||||
{
|
||||
Log("WeMod already patched. If you want to patch again, please restore the backup first.",
|
||||
ELogType.Warn);
|
||||
@@ -95,35 +97,42 @@ namespace WandEnhancer.View.MainWindow
|
||||
|
||||
private void OnBackupRestoring(object param)
|
||||
{
|
||||
var backupPath = Path.Combine(WeModInfo.RootDirectory, "resources", "app.asar.backup");
|
||||
if (!File.Exists(backupPath))
|
||||
var resourcesPath = Path.Combine(WeModInfo.RootDirectory, "resources");
|
||||
var backupPath = Path.Combine(resourcesPath, "app.asar.backup");
|
||||
var unpackedBackupPath = Path.Combine(resourcesPath, "app.asar.unpacked.backup");
|
||||
if (!File.Exists(backupPath) || !Directory.Exists(unpackedBackupPath))
|
||||
{
|
||||
Log("Backup not found. Please dont delete it manually", ELogType.Error);
|
||||
Log("Backup is incomplete. Restore the original Wand installation files or reinstall Wand.", ELogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Try to lock the file to see if it's in use
|
||||
using (File.Open(backupPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
|
||||
var asarPath = Path.Combine(resourcesPath, "app.asar");
|
||||
var unpackedPath = Path.Combine(resourcesPath, "app.asar.unpacked");
|
||||
File.Copy(backupPath, asarPath, true);
|
||||
|
||||
if (Directory.Exists(unpackedPath))
|
||||
{
|
||||
Directory.Delete(unpackedPath, true);
|
||||
}
|
||||
Enhancer.CopyDirectory(unpackedBackupPath, unpackedPath);
|
||||
|
||||
var proxyDllPath = Path.Combine(WeModInfo.RootDirectory, "version.dll");
|
||||
|
||||
if (File.Exists(proxyDllPath))
|
||||
{
|
||||
File.Delete(proxyDllPath);
|
||||
}
|
||||
|
||||
File.Delete(backupPath);
|
||||
Directory.Delete(unpackedBackupPath, true);
|
||||
}
|
||||
catch
|
||||
catch (Exception e)
|
||||
{
|
||||
Log("Backup file is locked. Please close the WeMod and try again.", ELogType.Error);
|
||||
Log($"Failed to restore backup: {e.Message}", ELogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
File.Copy(backupPath, Path.Combine(WeModInfo.RootDirectory, "resources", "app.asar"), true);
|
||||
File.Delete(backupPath);
|
||||
Log("Backup restored successfully.", ELogType.Success);
|
||||
AlreadyPatched = false;
|
||||
IsPatchEnabled = true;
|
||||
|
||||
@@ -23,40 +23,24 @@ function Resolve-CommandPath {
|
||||
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 {
|
||||
function Resolve-VisualStudioPath {
|
||||
$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'
|
||||
return $installationPath
|
||||
}
|
||||
|
||||
function Resolve-MSBuildPath {
|
||||
param([string]$VisualStudioPath)
|
||||
|
||||
$msbuildPath = Join-Path $VisualStudioPath 'MSBuild\Current\Bin\MSBuild.exe'
|
||||
if (-not (Test-Path $msbuildPath)) {
|
||||
throw "MSBuild.exe not found: $msbuildPath"
|
||||
}
|
||||
@@ -64,6 +48,23 @@ function Resolve-MSBuildPath {
|
||||
return $msbuildPath
|
||||
}
|
||||
|
||||
function Resolve-DumpBinPath {
|
||||
param([string]$VisualStudioPath)
|
||||
|
||||
$versionFile = Join-Path $VisualStudioPath 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt'
|
||||
if (-not (Test-Path $versionFile)) {
|
||||
throw "MSVC tools version file not found: $versionFile"
|
||||
}
|
||||
|
||||
$toolsVersion = (Get-Content $versionFile -Raw).Trim()
|
||||
$dumpBinPath = Join-Path $VisualStudioPath "VC\Tools\MSVC\$toolsVersion\bin\Hostx64\x64\dumpbin.exe"
|
||||
if (-not (Test-Path $dumpBinPath)) {
|
||||
throw "dumpbin.exe not found: $dumpBinPath"
|
||||
}
|
||||
|
||||
return $dumpBinPath
|
||||
}
|
||||
|
||||
function Invoke-Step {
|
||||
param(
|
||||
[string]$Label,
|
||||
@@ -78,9 +79,10 @@ function Invoke-Step {
|
||||
}
|
||||
|
||||
$cmake = Resolve-CommandPath 'cmake'
|
||||
$nuget = Resolve-NuGetPath
|
||||
$pnpm = Resolve-CommandPath 'pnpm'
|
||||
$msbuild = Resolve-MSBuildPath
|
||||
$visualStudio = Resolve-VisualStudioPath
|
||||
$msbuild = Resolve-MSBuildPath $visualStudio
|
||||
$dumpBin = Resolve-DumpBinPath $visualStudio
|
||||
|
||||
Invoke-Step 'Install web-panel dependencies' {
|
||||
& $pnpm --dir $webPanelDir install --frozen-lockfile
|
||||
@@ -91,10 +93,6 @@ Invoke-Step 'Build web-panel' {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -103,8 +101,16 @@ Invoke-Step 'Build asar-fuses-bypass' {
|
||||
& $cmake --build $asarFusesBuildDir --config $Configuration
|
||||
}
|
||||
|
||||
Invoke-Step 'Verify native runtime dependencies' {
|
||||
$nativeDll = Join-Path $asarFusesBuildDir "$Configuration\version.dll"
|
||||
$dependencies = & $dumpBin /dependents $nativeDll
|
||||
if ($dependencies -match '(?im)^\s*(VCRUNTIME|MSVCP|api-ms-win-crt-)[^\s]*\.dll\s*$') {
|
||||
throw 'version.dll depends on the dynamic Visual C++ runtime.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Step 'Restore NuGet packages' {
|
||||
& $nuget restore $solutionPath -NonInteractive
|
||||
& $msbuild $solutionPath /m /t:Restore /p:RestorePackagesConfig=true
|
||||
}
|
||||
|
||||
Invoke-Step 'Build solution' {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
cmake_policy(SET CMP0091 NEW)
|
||||
project(asar_fuses_bypass C)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
@@ -10,8 +11,11 @@ add_executable(asar_fuses_bypass main.c)
|
||||
set(CMAKE_SHARED_LIBRARY_PREFIX "")
|
||||
set(CMAKE_STATIC_LIBRARY_PREFIX "")
|
||||
|
||||
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)
|
||||
|
||||
if(MSVC)
|
||||
set_property(TARGET version PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
elseif(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_link_options(version PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
|
||||
@@ -128,12 +128,14 @@ async function executeRemoteLaunchCommand(state, request) {
|
||||
void syncGameStatus(state, true)
|
||||
return buildCommandResponse(request, true)
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Remote trainer launch failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "launch_failed",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to launch the trainer.",
|
||||
message: "Failed to launch the trainer.",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -171,12 +173,14 @@ async function executeRemoteStopCommand(state, request) {
|
||||
clearTrainerSnapshot(state, REMOTE_STOP_EVENT, true)
|
||||
return buildCommandResponse(request, true)
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Remote trainer stop failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "stop_failed",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to stop the running trainer.",
|
||||
message: "Failed to stop the running trainer.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+2
-14
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
getWebpackRequire,
|
||||
isRecord,
|
||||
} from "./installed-apps-sync/runtime.js"
|
||||
import { resolveQrRenderer as findWandQrRenderer } from "./remote-popup-cleanup/qr-renderer.js"
|
||||
|
||||
;(function installRemotePopupCleanup(WandEnhancer) {
|
||||
if (globalThis.__wandRemotePopupCleanupInstalled) {
|
||||
@@ -91,21 +91,9 @@ import {
|
||||
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
|
||||
qrRenderer = findWandQrRenderer(getWebpackRequire())
|
||||
return qrRenderer
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const updateLinks = (remoteUrl) => {
|
||||
if (!remoteUrl) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isRecord } from "../installed-apps-sync/runtime.js"
|
||||
|
||||
const WAND_QR_RENDERER_EXPORT = "mo"
|
||||
|
||||
export function resolveQrRenderer(webpackRequire) {
|
||||
for (const record of Object.values(webpackRequire?.c || {})) {
|
||||
const exports = record?.exports
|
||||
if (
|
||||
isRecord(exports) &&
|
||||
typeof exports[WAND_QR_RENDERER_EXPORT] === "function"
|
||||
) {
|
||||
return exports[WAND_QR_RENDERER_EXPORT]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Vendored
+15
-9
@@ -1,5 +1,4 @@
|
||||
const {
|
||||
buildInstalledAppsDebugPayload,
|
||||
gameStatusSignature,
|
||||
installedAppsSignature,
|
||||
normalizeGameStatusSnapshot,
|
||||
@@ -20,9 +19,11 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
|
||||
function broadcast(type, payload, requestId = null) {
|
||||
for (const client of clients) {
|
||||
if (client.handshaken) {
|
||||
sendJson(client, type, payload, requestId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendSnapshot(client) {
|
||||
if (!currentSnapshot) {
|
||||
@@ -50,6 +51,17 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
}
|
||||
}
|
||||
|
||||
function syncTrainerMeta(rawSnapshot) {
|
||||
const localizedSnapshot = normalizeSnapshot(rawSnapshot);
|
||||
const activeTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId;
|
||||
if (!localizedSnapshot || localizedSnapshot.trainerMeta.trainer.trainerId !== activeTrainerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSnapshot.trainerMeta = localizedSnapshot.trainerMeta;
|
||||
broadcast('trainer_meta', currentSnapshot.trainerMeta);
|
||||
}
|
||||
|
||||
function valueChanged(change) {
|
||||
if (!currentSnapshot || !isRecord(change)) return;
|
||||
const target = safeString(change.target);
|
||||
@@ -97,7 +109,6 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
}
|
||||
|
||||
function buildHealthPayload() {
|
||||
const installedAppsDebug = buildInstalledAppsDebugPayload(currentInstalledApps);
|
||||
const serverInfo = getServerInfo();
|
||||
return {
|
||||
ok: serverInfo.listening,
|
||||
@@ -105,12 +116,7 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
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,
|
||||
installedAppsCount: currentInstalledApps?.apps?.length ?? 0,
|
||||
remoteUrl: serverInfo.remoteUrl,
|
||||
advertisedUrls: serverInfo.advertisedUrls,
|
||||
};
|
||||
@@ -127,10 +133,10 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
return {
|
||||
get snapshot() { return currentSnapshot; },
|
||||
buildHealthPayload,
|
||||
buildInstalledAppsDebugPayload: () => buildInstalledAppsDebugPayload(currentInstalledApps),
|
||||
clear,
|
||||
sendSnapshot,
|
||||
sync,
|
||||
syncTrainerMeta,
|
||||
syncGameStatus,
|
||||
syncInstalledApps,
|
||||
valueChanged,
|
||||
|
||||
Vendored
+2
-1
@@ -27,8 +27,10 @@ module.exports = {
|
||||
BRIDGE_SERVER_VERSION: WEB_CONTRACT.serverVersion,
|
||||
DEFAULT_REMOTE_HOST: WEB_CONTRACT.defaultRemoteHost,
|
||||
DEFAULT_REMOTE_PORT: WEB_CONTRACT.defaultRemotePort,
|
||||
DEV_SERVER_PORTS: Object.freeze(WEB_CONTRACT.devServerPorts.map(String)),
|
||||
IPC_CHANNEL,
|
||||
KNOWN_CHEAT_TYPES,
|
||||
MAX_WS_FRAME_BYTES: 1024 * 1024,
|
||||
PORT_SCAN_RANGE: WEB_CONTRACT.portScanRange,
|
||||
REMOTE_ASSETS_PREFIX: WEB_CONTRACT.assetsPath,
|
||||
REMOTE_BASE_PATH: WEB_CONTRACT.basePath,
|
||||
@@ -37,7 +39,6 @@ module.exports = {
|
||||
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]),
|
||||
|
||||
+10
-117
@@ -88,7 +88,16 @@ function normalizeCheat(cheat, index) {
|
||||
|
||||
function normalizeImageUrl(...values) {
|
||||
const value = firstString(...values);
|
||||
return value || null;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRawInstalledApps(rawSnapshot) {
|
||||
@@ -119,10 +128,6 @@ function normalizeInstalledApp(app) {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -137,8 +142,6 @@ function normalizeInstalledApp(app) {
|
||||
),
|
||||
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,
|
||||
@@ -152,19 +155,13 @@ function normalizeInstalledAppsSnapshot(rawSnapshot) {
|
||||
}
|
||||
|
||||
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 '';
|
||||
@@ -189,7 +186,6 @@ function installedAppsSignature(snapshot) {
|
||||
app.displayName,
|
||||
app.gameId || '',
|
||||
app.titleId || '',
|
||||
app.location,
|
||||
app.imageUrl || '',
|
||||
app.platformLastPlayedTimestamp || '',
|
||||
app.platformTotalPlaytimeMinutes || '',
|
||||
@@ -197,95 +193,6 @@ function installedAppsSignature(snapshot) {
|
||||
.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;
|
||||
@@ -320,7 +227,6 @@ function normalizeSnapshot(rawSnapshot) {
|
||||
const trainerMeta = {
|
||||
session: {
|
||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
|
||||
accessToken: safeString(rawSnapshot.accessToken),
|
||||
},
|
||||
trainer: {
|
||||
trainerId,
|
||||
@@ -372,20 +278,7 @@ function compareInstalledApps(left, right) {
|
||||
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,
|
||||
|
||||
+12
@@ -5,6 +5,7 @@ import {
|
||||
getSteamClientIconUrl,
|
||||
normalizeImageUrl,
|
||||
} from '../scripts/default/installed-apps-sync/artwork.js';
|
||||
import { resolveQrRenderer } from '../scripts/default/remote-popup-cleanup/qr-renderer.js';
|
||||
|
||||
describe('installed-apps renderer script models', () => {
|
||||
it('normalizes captured artwork shapes without a Wand runtime', () => {
|
||||
@@ -28,4 +29,15 @@ describe('installed-apps renderer script models', () => {
|
||||
expect(getSteamClientIconUrl(findSteamAppId(fixture)))
|
||||
.toBe('https://api-cdn.wemod.com/steam_community/1245620/client_icon/96.webp');
|
||||
});
|
||||
|
||||
it('resolves the tree-shaken Wand QR renderer without a create export', () => {
|
||||
const renderer = () => undefined;
|
||||
const webpackRequire = {
|
||||
c: {
|
||||
qrCode: { exports: { mo: renderer } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveQrRenderer(webpackRequire)).toBe(renderer);
|
||||
});
|
||||
});
|
||||
|
||||
+142
-3
@@ -1,4 +1,4 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { connect, createServer } from 'node:net';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WebSocket as NodeWebSocket } from 'ws';
|
||||
|
||||
@@ -10,12 +10,80 @@ describe('production bridge runtime', () => {
|
||||
const port = await getFreePort();
|
||||
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
|
||||
runtime.sync(rawTrainerSnapshot());
|
||||
runtime.syncInstalledApps({
|
||||
apps: [{
|
||||
platform: 'steam',
|
||||
sku: '123',
|
||||
displayName: 'Game',
|
||||
location: 'C:\\private\\Game',
|
||||
alternateLocations: ['D:\\also-private\\Game'],
|
||||
}],
|
||||
});
|
||||
|
||||
try {
|
||||
await waitUntil(() => runtime.listening);
|
||||
const messages = await connectAndCollect(port, 3);
|
||||
expect(messages.map((message) => message.type)).toEqual(['hello_ack', 'trainer_meta', 'trainer_values']);
|
||||
const messages = await connectAndCollect(port, 4);
|
||||
expect(messages.map((message) => message.type)).toEqual(['hello_ack', 'trainer_meta', 'trainer_values', 'installed_apps']);
|
||||
expect(messages[2].payload.values.god).toBe(true);
|
||||
expect(JSON.stringify(messages)).not.toContain('wand-secret');
|
||||
expect(JSON.stringify(messages)).not.toContain('private');
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a browser WebSocket from a different origin', async () => {
|
||||
const bridge = require('../../dist/bridge.cjs');
|
||||
const port = await getFreePort();
|
||||
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
|
||||
|
||||
try {
|
||||
await waitUntil(() => runtime.listening);
|
||||
await expectUpgradeStatus(port, 403);
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('allows the local Vite panel to connect to the loopback bridge', async () => {
|
||||
const bridge = require('../../dist/bridge.cjs');
|
||||
const port = await getFreePort();
|
||||
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
|
||||
|
||||
try {
|
||||
await waitUntil(() => runtime.listening);
|
||||
await expectUpgradeAccepted(port, 'http://127.0.0.1:4173');
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes an oversized WebSocket frame without buffering its payload', async () => {
|
||||
const bridge = require('../../dist/bridge.cjs');
|
||||
const port = await getFreePort();
|
||||
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
|
||||
|
||||
try {
|
||||
await waitUntil(() => runtime.listening);
|
||||
await expectSocketClose(port, Buffer.alloc(1024 * 1024 + 1), 1009);
|
||||
await expectDeclaredHugeFrameClose(port, 1009);
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not trust the HTTP Host header as a URL base', async () => {
|
||||
const bridge = require('../../dist/bridge.cjs');
|
||||
const port = await getFreePort();
|
||||
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
|
||||
|
||||
try {
|
||||
await waitUntil(() => runtime.listening);
|
||||
const malformedHost = await sendRawHttp(port, 'GET /remote/api/health HTTP/1.1\r\nHost: [\r\nConnection: close\r\n\r\n');
|
||||
expect(malformedHost).toContain('HTTP/1.1 200 OK');
|
||||
|
||||
const malformedTarget = await sendRawHttp(port, 'GET //[ HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
|
||||
expect(malformedTarget).toContain('HTTP/1.1 400 Bad Request');
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
@@ -59,6 +127,76 @@ async function connectAndCollect(port: number, count: number): Promise<any[]> {
|
||||
});
|
||||
}
|
||||
|
||||
async function expectUpgradeStatus(port: number, expectedStatus: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`, {
|
||||
headers: { Origin: 'https://example.com' },
|
||||
});
|
||||
socket.once('open', () => reject(new Error('Cross-origin WebSocket was accepted.')));
|
||||
socket.once('error', () => undefined);
|
||||
socket.once('unexpected-response', (_request, response) => {
|
||||
response.resume();
|
||||
if (response.statusCode === expectedStatus) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Expected HTTP ${expectedStatus}, got ${response.statusCode}.`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function expectUpgradeAccepted(port: number, origin: string): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`, {
|
||||
headers: { Origin: origin },
|
||||
});
|
||||
socket.once('open', () => {
|
||||
socket.close();
|
||||
resolve();
|
||||
});
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSocketClose(port: number, payload: Buffer, expectedCode: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`);
|
||||
socket.once('open', () => socket.send(payload));
|
||||
socket.once('close', (code) => code === expectedCode
|
||||
? resolve()
|
||||
: reject(new Error(`Expected close code ${expectedCode}, got ${code}.`)));
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectDeclaredHugeFrameClose(port: number, expectedCode: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`);
|
||||
socket.once('open', () => {
|
||||
const header = Buffer.alloc(10);
|
||||
header[0] = 0x81;
|
||||
header[1] = 0xff;
|
||||
header.writeUInt32BE(1, 2);
|
||||
(socket as any)._socket.write(header);
|
||||
});
|
||||
socket.once('close', (code) => code === expectedCode
|
||||
? resolve()
|
||||
: reject(new Error(`Expected close code ${expectedCode}, got ${code}.`)));
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function sendRawHttp(port: number, request: string): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const socket = connect(port, '127.0.0.1');
|
||||
socket.once('connect', () => socket.end(request));
|
||||
socket.on('data', (chunk) => chunks.push(chunk));
|
||||
socket.once('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean): Promise<void> {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!predicate()) {
|
||||
@@ -70,6 +208,7 @@ async function waitUntil(predicate: () => boolean): Promise<void> {
|
||||
function rawTrainerSnapshot() {
|
||||
return {
|
||||
instanceId: 'instance',
|
||||
accessToken: 'wand-secret',
|
||||
trainerId: 'trainer',
|
||||
trainerInfo: { gameId: 'game', displayName: 'Game' },
|
||||
metadata: {
|
||||
|
||||
Vendored
+84
-17
@@ -6,11 +6,11 @@ const {
|
||||
BRIDGE_SERVER_VERSION,
|
||||
DEFAULT_REMOTE_HOST,
|
||||
DEFAULT_REMOTE_PORT,
|
||||
DEV_SERVER_PORTS,
|
||||
PORT_SCAN_RANGE,
|
||||
REMOTE_ASSETS_PREFIX,
|
||||
REMOTE_BASE_PATH,
|
||||
REMOTE_HEALTH_PATH,
|
||||
REMOTE_INSTALLED_APPS_API_PATH,
|
||||
REMOTE_WS_PATH,
|
||||
WS_OPCODE,
|
||||
} = require('./constants');
|
||||
@@ -23,7 +23,14 @@ 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');
|
||||
const {
|
||||
closeClient,
|
||||
createAcceptKey,
|
||||
FRAME_TOO_LARGE_ERROR,
|
||||
makeFrame,
|
||||
parseFrame,
|
||||
sendJson,
|
||||
} = require('./websocket-codec');
|
||||
import type { BridgeOptions } from './types';
|
||||
|
||||
function createBridgeServer(options: BridgeOptions = {}) {
|
||||
@@ -43,7 +50,6 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
log,
|
||||
getServerInfo: () => ({
|
||||
advertisedUrls,
|
||||
installedAppsApiPath: REMOTE_INSTALLED_APPS_API_PATH,
|
||||
listening,
|
||||
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||
}),
|
||||
@@ -64,10 +70,15 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
}
|
||||
|
||||
function handleRequest(request, response) {
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||
const url = parseRequestUrl(request.url);
|
||||
if (!url) {
|
||||
response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.end('Bad Request');
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/' || url.pathname === '') {
|
||||
response.writeHead(302, { Location: '/remote/' });
|
||||
response.writeHead(302, { Location: REMOTE_BASE_PATH });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
@@ -89,12 +100,6 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
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;
|
||||
@@ -148,11 +153,12 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
const result = await Promise.resolve(commandHandler({ action, gameId, titleId }));
|
||||
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult(result, fallback), message.requestId ?? null);
|
||||
} catch (error) {
|
||||
log('warn', 'Remote command handler failed.', 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.',
|
||||
message: 'Failed to execute the remote command.',
|
||||
},
|
||||
}, fallback), message.requestId ?? null);
|
||||
}
|
||||
@@ -194,13 +200,14 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
cheatId: typeof message.payload?.cheatId === 'string' ? message.payload.cheatId : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
log('warn', 'Set-value handler failed.', 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: 'Failed to set trainer value.',
|
||||
},
|
||||
}, message.requestId ?? null);
|
||||
return;
|
||||
@@ -301,6 +308,10 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
await handleClientMessage(client, JSON.parse(frame.payload.toString('utf8')));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === FRAME_TOO_LARGE_ERROR) {
|
||||
closeClient(client, 1009, error.message);
|
||||
return;
|
||||
}
|
||||
sendJson(client, 'error', {
|
||||
code: 'invalid_message',
|
||||
message: error instanceof Error ? error.message : 'Failed to process client message.',
|
||||
@@ -326,15 +337,24 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
}
|
||||
|
||||
function handleUpgrade(request, socket) {
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||
const url = parseRequestUrl(request.url);
|
||||
if (!url) {
|
||||
rejectUpgrade(socket, 400, 'Bad Request');
|
||||
return;
|
||||
}
|
||||
if (url.pathname !== REMOTE_WS_PATH) {
|
||||
socket.destroy();
|
||||
rejectUpgrade(socket, 404, 'Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAllowedWebSocketOrigin(request.headers.origin, request.headers.host)) {
|
||||
rejectUpgrade(socket, 403, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const key = request.headers['sec-websocket-key'];
|
||||
if (typeof key !== 'string' || !key) {
|
||||
socket.destroy();
|
||||
rejectUpgrade(socket, 400, 'Bad Request');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -373,7 +393,7 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
});
|
||||
server.on('listening', () => {
|
||||
listening = true;
|
||||
log('info', `Listening on ${globalThis.__wandRemoteBridgeUrl}`);
|
||||
log('info', `Listening on ${host}:${port}.`);
|
||||
});
|
||||
|
||||
listen(port);
|
||||
@@ -400,12 +420,59 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
setCommandHandler,
|
||||
setHandler,
|
||||
sync: bridgeState.sync,
|
||||
syncTrainerMeta: bridgeState.syncTrainerMeta,
|
||||
syncGameStatus: bridgeState.syncGameStatus,
|
||||
syncInstalledApps: bridgeState.syncInstalledApps,
|
||||
valueChanged: bridgeState.valueChanged,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRequestUrl(requestUrl) {
|
||||
try {
|
||||
return new URL(requestUrl || '/', 'http://localhost');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedWebSocketOrigin(origin, host) {
|
||||
if (origin === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (typeof origin !== 'string' || typeof host !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
const requested = new URL(`http://${host}`);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sameHostname = parsed.hostname.toLowerCase() === requested.hostname.toLowerCase();
|
||||
const compatibleLoopback = isLoopback(parsed.hostname) && isLoopback(requested.hostname);
|
||||
return parsed.host.toLowerCase() === host.toLowerCase()
|
||||
|| DEV_SERVER_PORTS.includes(parsed.port) && (sameHostname || compatibleLoopback);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopback(hostname) {
|
||||
return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(hostname.toLowerCase());
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket, statusCode, statusText) {
|
||||
socket.end([
|
||||
`HTTP/1.1 ${statusCode} ${statusText}`,
|
||||
'Connection: close',
|
||||
'Content-Length: 0',
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeServer,
|
||||
};
|
||||
|
||||
Vendored
+13
-9
@@ -12,11 +12,10 @@ const {
|
||||
const { writeInstallLog } = require('../logger');
|
||||
const { ensureBridge } = require('../runtime');
|
||||
const { installRendererScripts } = require('./renderer-scripts');
|
||||
const { localizeTrainerSnapshot } = require('./trainer-localization');
|
||||
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';
|
||||
|
||||
@@ -83,8 +82,17 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
|
||||
}
|
||||
|
||||
globalThis.__wandRemoteBridgeIpcInstalled = true;
|
||||
let trainerSnapshotRevision = 0;
|
||||
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (event, snapshot) => {
|
||||
void syncSnapshotWithAccessToken(runtime, event?.sender, snapshot);
|
||||
const revision = ++trainerSnapshotRevision;
|
||||
runtime.sync(snapshot);
|
||||
void localizeSnapshot(event?.sender, snapshot).then((localizedSnapshot) => {
|
||||
if (localizedSnapshot !== snapshot && revision === trainerSnapshotRevision) {
|
||||
runtime.syncTrainerMeta(localizedSnapshot);
|
||||
}
|
||||
}).catch((error) => {
|
||||
writeInstallLog('warn', 'Failed to localize trainer metadata.', error);
|
||||
});
|
||||
return true;
|
||||
});
|
||||
electron.ipcMain.handle(REMOTE_INSTALLED_APPS_CHANNEL, (_event, snapshot) => {
|
||||
@@ -119,13 +127,9 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
|
||||
electron.ipcMain.handle(IPC_CHANNEL.REMOTE_URL, () => runtime.remoteUrl);
|
||||
}
|
||||
|
||||
async function syncSnapshotWithAccessToken(runtime, sender, snapshot) {
|
||||
async function localizeSnapshot(sender, snapshot) {
|
||||
const accessToken = await readWemodAccessToken(sender);
|
||||
if (accessToken && snapshot && typeof snapshot === 'object') {
|
||||
snapshot.accessToken = accessToken;
|
||||
}
|
||||
|
||||
runtime.sync(snapshot);
|
||||
return localizeTrainerSnapshot(snapshot, accessToken);
|
||||
}
|
||||
|
||||
async function readWemodAccessToken(sender) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { EventEmitter } from "node:events"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { localizeTrainerSnapshot } from "./trainer-localization"
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe("trainer localization", () => {
|
||||
it("uses the bearer token only inside the bridge and returns localized metadata", async () => {
|
||||
const snapshot = rawTrainerSnapshot()
|
||||
const loadStrings = vi.fn(async (request) => {
|
||||
expect(request).toMatchObject({
|
||||
accessToken: "wand-secret",
|
||||
gameId: "game",
|
||||
gameVersion: "1.0",
|
||||
language: "de-DE",
|
||||
})
|
||||
return { cheat_name: "Unverwundbar", cheat_description: "Kein Schaden" }
|
||||
})
|
||||
|
||||
const localized = await localizeTrainerSnapshot(
|
||||
snapshot,
|
||||
"wand-secret",
|
||||
loadStrings
|
||||
)
|
||||
|
||||
expect(localized).not.toBe(snapshot)
|
||||
expect(localized.metadata.info.blueprint.cheats[0]).toMatchObject({
|
||||
name: "Unverwundbar",
|
||||
description: "Kein Schaden",
|
||||
})
|
||||
expect(JSON.stringify(localized)).not.toContain("wand-secret")
|
||||
expect(snapshot.metadata.info.blueprint.cheats[0].name).toBe("cheat_name")
|
||||
})
|
||||
|
||||
it("deduplicates in-flight translation requests and caches successful strings", async () => {
|
||||
const https = require("node:https")
|
||||
const get = vi.spyOn(https, "get").mockImplementation((_url, _options, onResponse) => {
|
||||
const request = new EventEmitter() as any
|
||||
request.setTimeout = vi.fn()
|
||||
request.destroy = vi.fn()
|
||||
|
||||
queueMicrotask(() => {
|
||||
const response = new EventEmitter() as any
|
||||
response.statusCode = 200
|
||||
response.resume = vi.fn()
|
||||
response.setEncoding = vi.fn()
|
||||
const respond = onResponse as (response: any) => void
|
||||
respond(response)
|
||||
response.emit("data", JSON.stringify({
|
||||
i18n: { strings: { cheat_name: "Cached name" } },
|
||||
}))
|
||||
response.emit("end")
|
||||
})
|
||||
|
||||
return request
|
||||
})
|
||||
|
||||
const snapshot = {
|
||||
...rawTrainerSnapshot(),
|
||||
trainerInfo: { gameId: "cache-test-game" },
|
||||
}
|
||||
const pending = [
|
||||
localizeTrainerSnapshot(snapshot, "cache-test-token"),
|
||||
localizeTrainerSnapshot(snapshot, "cache-test-token"),
|
||||
]
|
||||
const localized = await Promise.all(pending)
|
||||
const cached = await localizeTrainerSnapshot(snapshot, "cache-test-token")
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(1)
|
||||
expect(localized[0].metadata.info.blueprint.cheats[0].name).toBe("Cached name")
|
||||
expect(cached.metadata.info.blueprint.cheats[0].name).toBe("Cached name")
|
||||
})
|
||||
})
|
||||
|
||||
function rawTrainerSnapshot() {
|
||||
return {
|
||||
instanceId: "instance",
|
||||
trainerId: "trainer",
|
||||
trainerInfo: { gameId: "game" },
|
||||
gameVersion: "1.0",
|
||||
language: "de-DE",
|
||||
metadata: {
|
||||
info: {
|
||||
blueprint: {
|
||||
cheats: [
|
||||
{
|
||||
target: "god",
|
||||
type: "toggle",
|
||||
name: "cheat_name",
|
||||
description: "cheat_description",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
const https = require("node:https")
|
||||
|
||||
const WEMOD_TRAINER_ENDPOINT = "https://api.wemod.com/v3/games"
|
||||
const RESPONSE_LIMIT_BYTES = 2 * 1024 * 1024
|
||||
const REQUEST_TIMEOUT_MS = 5000
|
||||
let cachedRequestKey = ""
|
||||
let cachedStrings: Record<string, string> | null = null
|
||||
let inFlightRequestKey = ""
|
||||
let inFlightRequest: Promise<Record<string, string> | null> | null = null
|
||||
|
||||
export async function localizeTrainerSnapshot(
|
||||
rawSnapshot,
|
||||
accessToken,
|
||||
loadStrings = fetchTrainerStrings
|
||||
) {
|
||||
const request = buildTrainerRequest(rawSnapshot, accessToken)
|
||||
if (!request) {
|
||||
return rawSnapshot
|
||||
}
|
||||
|
||||
let strings
|
||||
try {
|
||||
strings = await loadStrings(request)
|
||||
} catch {
|
||||
return rawSnapshot
|
||||
}
|
||||
if (!strings) {
|
||||
return rawSnapshot
|
||||
}
|
||||
|
||||
const info = rawSnapshot.metadata.info
|
||||
const blueprint = info.blueprint
|
||||
if (!Array.isArray(blueprint?.cheats)) {
|
||||
return rawSnapshot
|
||||
}
|
||||
|
||||
return {
|
||||
...rawSnapshot,
|
||||
metadata: {
|
||||
...rawSnapshot.metadata,
|
||||
info: {
|
||||
...info,
|
||||
blueprint: {
|
||||
...blueprint,
|
||||
cheats: blueprint.cheats.map((cheat) =>
|
||||
localizeCheat(cheat, strings)
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildTrainerRequest(rawSnapshot, accessToken) {
|
||||
if (!accessToken || !rawSnapshot || typeof rawSnapshot !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const gameId = stringValue(
|
||||
rawSnapshot.trainerInfo?.gameId || rawSnapshot.metadata?.info?.gameId
|
||||
)
|
||||
if (!gameId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
gameId,
|
||||
gameVersion: stringValue(rawSnapshot.gameVersion),
|
||||
language: stringValue(rawSnapshot.language),
|
||||
}
|
||||
}
|
||||
|
||||
function fetchTrainerStrings({ accessToken, gameId, gameVersion, language }) {
|
||||
const requestKey = [accessToken, gameId, gameVersion, language].join("\0")
|
||||
if (requestKey === cachedRequestKey) {
|
||||
return Promise.resolve(cachedStrings)
|
||||
}
|
||||
if (requestKey === inFlightRequestKey && inFlightRequest) {
|
||||
return inFlightRequest
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
`${WEMOD_TRAINER_ENDPOINT}/${encodeURIComponent(gameId)}/trainer`
|
||||
)
|
||||
if (gameVersion) url.searchParams.set("gameVersions", gameVersion)
|
||||
if (language) url.searchParams.set("locale", language)
|
||||
|
||||
const request = requestJson(url, accessToken)
|
||||
.then((payload) => normalizeStrings(payload?.i18n?.strings))
|
||||
.then((strings) => {
|
||||
if (strings) {
|
||||
cachedRequestKey = requestKey
|
||||
cachedStrings = strings
|
||||
}
|
||||
return strings
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightRequestKey === requestKey) {
|
||||
inFlightRequestKey = ""
|
||||
inFlightRequest = null
|
||||
}
|
||||
})
|
||||
|
||||
inFlightRequestKey = requestKey
|
||||
inFlightRequest = request
|
||||
return request
|
||||
}
|
||||
|
||||
function requestJson(url, accessToken): Promise<any> {
|
||||
return new Promise<any>((resolve) => {
|
||||
let settled = false
|
||||
const finish = (value) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const request = https.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
response.resume()
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
|
||||
let body = ""
|
||||
let receivedBytes = 0
|
||||
response.setEncoding("utf8")
|
||||
response.on("data", (chunk) => {
|
||||
receivedBytes += Buffer.byteLength(chunk)
|
||||
if (receivedBytes > RESPONSE_LIMIT_BYTES) {
|
||||
request.destroy()
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
body += chunk
|
||||
})
|
||||
response.on("end", () => {
|
||||
try {
|
||||
finish(JSON.parse(body))
|
||||
} catch {
|
||||
finish(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy())
|
||||
request.on("error", () => finish(null))
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeStrings(value): Record<string, string> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const strings = Object.fromEntries(
|
||||
Object.entries(value).filter((entry) => typeof entry[1] === "string")
|
||||
) as Record<string, string>
|
||||
return Object.keys(strings).length > 0 ? strings : null
|
||||
}
|
||||
|
||||
function localizeCheat(cheat, strings) {
|
||||
if (!cheat || typeof cheat !== "object") {
|
||||
return cheat
|
||||
}
|
||||
|
||||
return {
|
||||
...cheat,
|
||||
name: translate(cheat.name, strings),
|
||||
description: translate(cheat.description, strings),
|
||||
instructions: translate(cheat.instructions, strings),
|
||||
}
|
||||
}
|
||||
|
||||
function translate(value, strings) {
|
||||
return typeof value === "string" ? (strings[value] ?? value) : value
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return typeof value === "string" && value ? value : ""
|
||||
}
|
||||
+14
-2
@@ -1,8 +1,15 @@
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants');
|
||||
const { BRIDGE_PROTOCOL_VERSION, MAX_WS_FRAME_BYTES, WS_OPCODE } = require('./constants');
|
||||
|
||||
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
const FRAME_TOO_LARGE_ERROR = 'WS_FRAME_TOO_LARGE';
|
||||
|
||||
function frameTooLarge() {
|
||||
const error: any = new RangeError(`WebSocket frames are limited to ${MAX_WS_FRAME_BYTES} bytes.`);
|
||||
error.code = FRAME_TOO_LARGE_ERROR;
|
||||
return error;
|
||||
}
|
||||
|
||||
function jsonMessage(type, payload, requestId = null) {
|
||||
return JSON.stringify({
|
||||
@@ -88,13 +95,17 @@ function parseFrame(buffer) {
|
||||
const high = buffer.readUInt32BE(offset);
|
||||
const low = buffer.readUInt32BE(offset + 4);
|
||||
if (high !== 0) {
|
||||
throw new Error('Large websocket frames are not supported.');
|
||||
throw frameTooLarge();
|
||||
}
|
||||
|
||||
length = low;
|
||||
offset += 8;
|
||||
}
|
||||
|
||||
if (length > MAX_WS_FRAME_BYTES) {
|
||||
throw frameTooLarge();
|
||||
}
|
||||
|
||||
let mask = null;
|
||||
if (masked) {
|
||||
if (buffer.length < offset + 4) {
|
||||
@@ -131,6 +142,7 @@ function createAcceptKey(key) {
|
||||
module.exports = {
|
||||
closeClient,
|
||||
createAcceptKey,
|
||||
FRAME_TOO_LARGE_ERROR,
|
||||
jsonMessage,
|
||||
makeFrame,
|
||||
parseFrame,
|
||||
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
"name": "wand-web-panel",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"packageManager": "pnpm@10.17.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Vendored
-5
@@ -49,8 +49,6 @@ export interface InstalledAppSummary {
|
||||
displayName: string;
|
||||
gameId?: string | null;
|
||||
titleId?: string | null;
|
||||
location: string;
|
||||
alternateLocations: string[];
|
||||
imageUrl?: string | null;
|
||||
platformLastPlayedTimestamp?: number | null;
|
||||
platformTotalPlaytimeMinutes?: number | null;
|
||||
@@ -108,7 +106,6 @@ export interface TrainerSummary {
|
||||
export interface TrainerMetaPayload {
|
||||
session: {
|
||||
instanceId: string;
|
||||
accessToken?: string;
|
||||
};
|
||||
trainer: TrainerSummary;
|
||||
schema: {
|
||||
@@ -193,7 +190,6 @@ export type HelloMessage = MessageEnvelope<
|
||||
{
|
||||
client: 'mobile-web';
|
||||
clientVersion: string;
|
||||
pairingToken?: string;
|
||||
capabilities: {
|
||||
supportsDeltaValues: boolean;
|
||||
supportsTrainerSwitch: boolean;
|
||||
@@ -236,4 +232,3 @@ export type IncomingMessage =
|
||||
| ErrorMessage;
|
||||
|
||||
export type OutgoingMessage = HelloMessage | SetValueMessage | RemoteCommandMessage;
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -4,10 +4,10 @@
|
||||
"serverVersion": "0.2.0-wand",
|
||||
"defaultRemoteHost": "0.0.0.0",
|
||||
"defaultRemotePort": 3223,
|
||||
"devServerPorts": [4173, 5173],
|
||||
"portScanRange": 30,
|
||||
"basePath": "/remote/",
|
||||
"assetsPath": "/remote/assets/",
|
||||
"webSocketPath": "/remote/ws",
|
||||
"healthPath": "/remote/api/health",
|
||||
"installedAppsPath": "/remote/api/installed-apps"
|
||||
"healthPath": "/remote/api/health"
|
||||
}
|
||||
|
||||
-4
@@ -11,8 +11,6 @@ const apps: InstalledAppSummary[] = [
|
||||
correlationId: 'steam:one',
|
||||
displayName: 'Alpha Game',
|
||||
gameId: 'game-one',
|
||||
location: 'C:\\Games\\Alpha',
|
||||
alternateLocations: [],
|
||||
},
|
||||
{
|
||||
platform: 'epic',
|
||||
@@ -20,8 +18,6 @@ const apps: InstalledAppSummary[] = [
|
||||
correlationId: 'epic:two',
|
||||
displayName: 'Beta Game',
|
||||
gameId: 'game-two',
|
||||
location: 'C:\\Games\\Beta',
|
||||
alternateLocations: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Vendored
-16
@@ -6,7 +6,6 @@ export type LibraryGame = {
|
||||
title: string;
|
||||
platform: string;
|
||||
hours: number | null;
|
||||
path: string;
|
||||
imageUrl: string | null;
|
||||
app: InstalledAppSummary;
|
||||
gameId: string | null;
|
||||
@@ -37,7 +36,6 @@ export function buildLibraryGames(
|
||||
title: app.displayName,
|
||||
platform: formatHumanLabel(app.platform),
|
||||
hours: minutesToHours(app.platformTotalPlaytimeMinutes),
|
||||
path: app.location,
|
||||
imageUrl: app.imageUrl ?? null,
|
||||
app,
|
||||
gameId: app.gameId ?? null,
|
||||
@@ -71,7 +69,6 @@ export function filterLibraryGames(games: LibraryGame[], query: string): Library
|
||||
if (game.title.toLowerCase().includes(normalized)) return true;
|
||||
if (game.id.toLowerCase().includes(normalized)) return true;
|
||||
if (game.platform.toLowerCase().includes(normalized)) return true;
|
||||
if (game.path.toLowerCase().includes(normalized)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -88,19 +85,6 @@ export function formatHours(hours: number | null): string | null {
|
||||
return `${Math.round(hours)}h`;
|
||||
}
|
||||
|
||||
export function shortPath(path: string): string {
|
||||
if (!path.trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
if (parts.length <= 2) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
}
|
||||
|
||||
export function getGameCoverLabel(game: LibraryGame): string {
|
||||
return game.title.split(/[:\s]/).filter(Boolean)[0]?.slice(0, 8).toUpperCase() || 'GAME';
|
||||
}
|
||||
|
||||
+1
-2
@@ -8,7 +8,7 @@ import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import { SearchInput } from '@/shared/ui/SearchInput';
|
||||
|
||||
import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../model/games';
|
||||
import { filterLibraryGames, formatHours, getLibrarySections, type LibraryGame } from '../model/games';
|
||||
import { GameCover } from './GameCover';
|
||||
|
||||
type LibraryDrawerProps = {
|
||||
@@ -118,7 +118,6 @@ const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 font-mono text-[10.5px] tracking-[0.02em] text-(--deck-fg-3)">
|
||||
<span className="shrink-0">{game.platform.toUpperCase()}</span>
|
||||
{hours ? <span className="shrink-0">· {hours}</span> : null}
|
||||
{game.path ? <span className="min-w-0 truncate opacity-60">· {shortPath(game.path)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
|
||||
+3
-4
@@ -28,7 +28,7 @@ export class RemoteSessionClient {
|
||||
private readonly handlers: SocketHandlers,
|
||||
) {}
|
||||
|
||||
connect(pairingToken?: string): void {
|
||||
connect(): void {
|
||||
this.disconnect();
|
||||
this.intentionalDisconnect = false;
|
||||
this.handlers.onConnecting();
|
||||
@@ -38,7 +38,7 @@ export class RemoteSessionClient {
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.handlers.onTransportOpen();
|
||||
this.send(this.createHelloMessage(pairingToken));
|
||||
this.send(this.createHelloMessage());
|
||||
});
|
||||
socket.addEventListener('message', (event) => this.handleMessage(event));
|
||||
socket.addEventListener('close', () => {
|
||||
@@ -100,7 +100,7 @@ export class RemoteSessionClient {
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
private createHelloMessage(pairingToken?: string): HelloMessage {
|
||||
private createHelloMessage(): HelloMessage {
|
||||
return {
|
||||
type: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
@@ -108,7 +108,6 @@ export class RemoteSessionClient {
|
||||
payload: {
|
||||
client: 'mobile-web',
|
||||
clientVersion: WEB_CONTRACT.clientVersion,
|
||||
pairingToken,
|
||||
capabilities: { supportsDeltaValues: true, supportsTrainerSwitch: true },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { CheatSchema, TrainerMetaPayload } from '../../protocol/messages';
|
||||
|
||||
const WEMOD_TRAINER_ENDPOINT = 'https://api.wemod.com/v3/games';
|
||||
|
||||
type WemodTrainerResponse = {
|
||||
i18n?: { strings?: Record<string, string> };
|
||||
};
|
||||
|
||||
export async function localizeTrainerMeta(payload: TrainerMetaPayload): Promise<TrainerMetaPayload> {
|
||||
const strings = await fetchTrainerStrings(payload);
|
||||
if (!strings) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const cheats = payload.schema.cheats.map((cheat) => localizeCheat(cheat, strings));
|
||||
return { ...payload, schema: { ...payload.schema, cheats } };
|
||||
}
|
||||
|
||||
async function fetchTrainerStrings(payload: TrainerMetaPayload): Promise<Record<string, string> | null> {
|
||||
const { accessToken } = payload.session;
|
||||
const { gameId, gameVersion, language } = payload.trainer;
|
||||
if (!accessToken || !gameId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (gameVersion) params.set('gameVersions', gameVersion);
|
||||
if (language) params.set('locale', language);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WEMOD_TRAINER_ENDPOINT}/${gameId}/trainer?${params}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trainer = (await response.json()) as WemodTrainerResponse;
|
||||
return trainer.i18n?.strings ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function localizeCheat(cheat: CheatSchema, strings: Record<string, string>): CheatSchema {
|
||||
return {
|
||||
...cheat,
|
||||
name: strings[cheat.name] ?? cheat.name,
|
||||
description: translate(cheat.description, strings),
|
||||
instructions: translate(cheat.instructions, strings),
|
||||
};
|
||||
}
|
||||
|
||||
function translate(value: string | null | undefined, strings: Record<string, string>): string | null {
|
||||
if (!value) {
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
return strings[value] ?? value;
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { WEB_CONTRACT } from '../../protocol/contract';
|
||||
|
||||
export const WS_QUERY_PARAM = 'ws';
|
||||
|
||||
const DEV_SERVER_PORTS = new Set(['4173', '5173']);
|
||||
const DEV_SERVER_PORTS = new Set(WEB_CONTRACT.devServerPorts.map(String));
|
||||
|
||||
function protocolForWebSocket(): 'ws' | 'wss' {
|
||||
return window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { type CheatSchema, type InstalledAppSummary } from '../../protocol/messages';
|
||||
import { normalizeCheatValue } from '../trainer/model/values';
|
||||
import { RemoteSessionClient } from './remote-session.client';
|
||||
import { localizeTrainerMeta } from './remote-session.i18n';
|
||||
import {
|
||||
createInitialRemoteSessionState,
|
||||
EConnectionStatus,
|
||||
@@ -63,10 +62,6 @@ export function useRemoteSession() {
|
||||
onMessage: (message) => {
|
||||
const action = protocolAction(message);
|
||||
if (!action) return;
|
||||
if (action.type === 'trainerMeta') {
|
||||
void localizeTrainerMeta(action.payload).then((payload) => dispatch({ ...action, payload }));
|
||||
return;
|
||||
}
|
||||
dispatch(action);
|
||||
},
|
||||
onClose: () => {
|
||||
|
||||
Vendored
+21
-3
@@ -1,9 +1,10 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { loadStringSet, saveStringSet } from './storage';
|
||||
|
||||
describe('storage revival', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('revives only valid string ids', () => {
|
||||
localStorage.setItem('pins', JSON.stringify(['one', '', 2, 'two']));
|
||||
@@ -11,9 +12,26 @@ describe('storage revival', () => {
|
||||
});
|
||||
|
||||
it('removes empty sets', () => {
|
||||
saveStringSet('pins', { one: true });
|
||||
expect(saveStringSet('pins', { one: true })).toBe(true);
|
||||
expect(localStorage.getItem('pins')).toBe(JSON.stringify(['one']));
|
||||
saveStringSet('pins', {});
|
||||
expect(saveStringSet('pins', {})).toBe(true);
|
||||
expect(localStorage.getItem('pins')).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a failed browser storage write', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('Storage disabled', 'SecurityError');
|
||||
});
|
||||
|
||||
expect(saveStringSet('pins', { one: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('handles browsers that block access to local storage', () => {
|
||||
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
|
||||
throw new DOMException('Storage disabled', 'SecurityError');
|
||||
});
|
||||
|
||||
expect(saveStringSet('pins', { one: true })).toBe(false);
|
||||
expect(loadStringSet('pins')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+11
-6
@@ -3,7 +3,11 @@ import type { TrainerSummary } from '../../protocol/messages';
|
||||
type Reviver<T> = (raw: unknown) => T | null;
|
||||
|
||||
function getStore(): Storage | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTrainerStorageId(trainer: TrainerSummary | null | undefined): string | null {
|
||||
@@ -33,21 +37,22 @@ export function loadJson<T>(key: string | null, revive: Reviver<T>, fallback: T)
|
||||
}
|
||||
}
|
||||
|
||||
export function saveJson(key: string | null, value: unknown, isEmpty: (value: unknown) => boolean): void {
|
||||
export function saveJson(key: string | null, value: unknown, isEmpty: (value: unknown) => boolean): boolean {
|
||||
const store = getStore();
|
||||
if (!key || !store) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEmpty(value)) {
|
||||
store.removeItem(key);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
store.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch {
|
||||
// localStorage quota / serialization errors are non-critical for this UI.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +77,7 @@ export function loadStringSet(key: string | null): Record<string, true> {
|
||||
);
|
||||
}
|
||||
|
||||
export function saveStringSet(key: string | null, value: Record<string, true>): void {
|
||||
export function saveStringSet(key: string | null, value: Record<string, true>): boolean {
|
||||
const ids = Object.keys(value);
|
||||
saveJson(key, ids, () => ids.length === 0);
|
||||
return saveJson(key, ids, () => ids.length === 0);
|
||||
}
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ export function loadPresets(storageKey: string): RemotePreset[] {
|
||||
);
|
||||
}
|
||||
|
||||
export function savePresets(storageKey: string, presets: RemotePreset[]): void {
|
||||
saveJson(storageKey, presets, (value) => Array.isArray(value) && value.length === 0);
|
||||
export function savePresets(storageKey: string, presets: RemotePreset[]): boolean {
|
||||
return saveJson(storageKey, presets, (value) => Array.isArray(value) && value.length === 0);
|
||||
}
|
||||
|
||||
export function createPreset(name: string, values: Record<string, unknown>): RemotePreset {
|
||||
|
||||
+9
-3
@@ -31,8 +31,11 @@ export function usePresets({ presetStorageKey, trainerMeta, values, onError }: P
|
||||
}
|
||||
|
||||
const next = [...presets, createPreset(name, captured)];
|
||||
if (!savePresets(presetStorageKey, next)) {
|
||||
onError('Could not save the preset in this browser. Check site storage permissions and available space.');
|
||||
return false;
|
||||
}
|
||||
setPresets(next);
|
||||
savePresets(presetStorageKey, next);
|
||||
return true;
|
||||
},
|
||||
[onError, presets, presetStorageKey, trainerMeta, values],
|
||||
@@ -41,10 +44,13 @@ export function usePresets({ presetStorageKey, trainerMeta, values, onError }: P
|
||||
const deletePreset = useCallback(
|
||||
(presetId: string) => {
|
||||
const next = presets.filter((preset) => preset.id !== presetId);
|
||||
if (!savePresets(presetStorageKey, next)) {
|
||||
onError('Could not update presets in this browser. Check site storage permissions and available space.');
|
||||
return;
|
||||
}
|
||||
setPresets(next);
|
||||
savePresets(presetStorageKey, next);
|
||||
},
|
||||
[presets, presetStorageKey],
|
||||
[onError, presets, presetStorageKey],
|
||||
);
|
||||
|
||||
return { presets, addPreset, deletePreset };
|
||||
|
||||
+7
-3
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -47,14 +48,17 @@ export const QuickActions = ({ presets, onPanic, onAddPreset, onApplyPreset, onD
|
||||
))}
|
||||
<Chip icon="plus" label={_(msg`Add`)} variant="add" onClick={handleOpenModal} />
|
||||
</div>
|
||||
{modalOpen ? (
|
||||
{modalOpen
|
||||
? createPortal(
|
||||
<PresetModal
|
||||
draftName={draftName}
|
||||
onClose={handleCloseModal}
|
||||
onDraftNameChange={setDraftName}
|
||||
onSubmit={handleSubmitPreset}
|
||||
/>
|
||||
) : null}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user