mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-29 15:01:16 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c02bad919d | |||
| b9faf80f86 | |||
| 6395ca3a27 | |||
| 4ce47dc6d2 | |||
| 8c6d87671c | |||
| a0b3968d33 | |||
| 6906a67a2e | |||
| 37ce6b3f4a | |||
| 8756e41fb9 | |||
| 544b9f0fb0 | |||
| a4f3a57f97 |
@@ -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
|
||||
@@ -22,6 +22,7 @@ This repository patches the Wand Electron app from a .NET Framework WPF desktop
|
||||
- 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`). 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
|
||||
|
||||
|
||||
@@ -3,6 +3,40 @@
|
||||
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.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
|
||||
|
||||
@@ -46,6 +46,45 @@ WandEnhancer includes a built-in **Remote Web Panel** allowing you to control ap
|
||||
|
||||
> Source archives are intended for developers who want to build the project locally. They are not prebuilt binaries.
|
||||
|
||||
## 🧩 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.
|
||||
|
||||
@@ -81,7 +81,9 @@ namespace WandEnhancer.Core
|
||||
$"{prefix} Patch failed. Multiple target functions found. Looks like the version is not supported");
|
||||
}
|
||||
|
||||
string patchSource = patch.Patch;
|
||||
string patchSource = patch.PatchFactory != null
|
||||
? patch.PatchFactory(match)
|
||||
: patch.Patch;
|
||||
|
||||
if (patch.Resolver != null)
|
||||
{
|
||||
@@ -95,10 +97,21 @@ namespace WandEnhancer.Core
|
||||
}
|
||||
|
||||
_logger($"{prefix} Found target function in: " + Path.GetFileName(fileName), ELogType.Info);
|
||||
|
||||
string newJs = patch.SingleMatch
|
||||
? patch.Target.Replace(js, patchSource, 1)
|
||||
: patch.Target.Replace(js, patchSource);
|
||||
|
||||
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;
|
||||
@@ -490,4 +503,4 @@ namespace WandEnhancer.Core
|
||||
_logger("[ENHANCER] Done!", ELogType.Success);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +17,7 @@ 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;
|
||||
@@ -28,6 +26,66 @@ namespace WandEnhancer.Core
|
||||
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 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[]>()
|
||||
@@ -72,6 +130,19 @@ namespace WandEnhancer.Core
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -79,13 +150,15 @@ 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))))"
|
||||
Patch = "registerHandler(\"ACTION_CHECK_FOR_UPDATE\",(e=>expectUpdateFeedUrl(e,(e=>null)))"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -126,15 +199,17 @@ namespace WandEnhancer.Core
|
||||
{
|
||||
Name = "remoteBridgeReset",
|
||||
SearchHints = new[] { "client-state" },
|
||||
Target = new Regex(@"#Je\(\)\{this\.#Oe&&\(this\.#Oe\.dispose\(\),this\.#Oe=null\),this\.#Pe=Date\.now\(\)\.toString\(\),this\.#ke=null,this\.#_e=\[],this\.#Ee=null\}"),
|
||||
Patch = "#Je(){this.#Oe&&(this.#Oe.dispose(),this.#Oe=null),this.#Pe=Date.now().toString(),this.#ke=null,this.#_e=[],this.#Ee=null,this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null)}"
|
||||
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",
|
||||
SearchHints = new[] { "client-state" },
|
||||
Target = new Regex(@"#Be\(\)\{if\(this\.status===i\.Connected\)\{let e,t=!1,s=this\.#Ee\?\.getMetadata\(h\.vO\)\?\.gameVersion\?\?null,i=!1;const n=this\.#Ve\[this\.#ke\?\?""""\]\|\|null;this\.#Re&&\(e=this\.#Ae\.getPreferredInstallationInfo\(this\.#Re\),e\.app&&\(t=!0,s\?\?=e\.version\?\?null,i=""number""==typeof e\.version&&!this\.#_e\.includes\(e\.version\)\)\),this\.#Me\?\.send\(""client-state"",\{instanceId:this\.#Pe,trainerId:this\.#ke,trainerLoading:this\.#Ee\?\.isLoading\(\),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:i,values:this\.#Ke\(\),themeId:this\.#We,settings:R\(this\.settings\),language:this\.#Ne,accountUuid:this\.account\.uuid,notesReadHash:n,isTimeLimitExpired:""expired""===this\.#Fe\.timerState\}\)\}\}"),
|
||||
Patch = "#Be(){let e,t=!1,s=this.#Ee?.getMetadata(h.vO)?.gameVersion??null,o=!1;const n=this.#Ve[this.#ke??\"\"]||null;this.#Re&&(e=this.#Ae.getPreferredInstallationInfo(this.#Re),e.app&&(t=!0,s??=e.version??null,o=\"number\"==typeof e.version&&!this.#_e.includes(e.version)));this.status===i.Connected&&this.#Me?.send(\"client-state\",{instanceId:this.#Pe,trainerId:this.#ke,trainerLoading:this.#Ee?.isLoading(),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,values:this.#Ke(),themeId:this.#We,settings:R(this.settings),language:this.#Ne,accountUuid:this.account.uuid,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.#Fe.timerState});this.__wandRemoteBridge?.sync({instanceId:this.#Pe,trainerId:this.#ke,trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.#Ee?.getMetadata(h.vO)??null,trainerLoading:this.#Ee?.isLoading()??false,gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,language:this.#Ne,themeId:this.#We,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.#Fe.timerState,values:this.#Ke()})}"
|
||||
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
|
||||
{
|
||||
@@ -149,29 +224,6 @@ namespace WandEnhancer.Core
|
||||
SearchHints = new[] { "client-value-changed" },
|
||||
Target = new Regex(@"#ct\(e,t\)\{t\.push\(e\.onValueSet\(e=>\{this\.status===i\.Connected&&e\.source!==g\.kL\.Remote&&this\.#Me\?\.send\(""client-value-changed"",\{instanceId:this\.#Pe,name:e\.name,value:e\.value,cheatId:e\.cheatId\}\)\}\)\),this\.#Be\(\)\}"),
|
||||
Patch = "#ct(e,t){t.push(e.onValueSet(e=>{this.status===i.Connected&&e.source!==g.kL.Remote&&this.#Me?.send(\"client-value-changed\",{instanceId:this.#Pe,name:e.name,value:e.value,cheatId:e.cheatId}),this.__wandRemoteBridge?.valueChanged({trainerId:this.#ke,target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??\"desktop\"),cheatId:e.cheatId})})),this.#Be()}"
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "remoteTooltipPreviewUrl",
|
||||
SearchHints = new[] { "remote_tooltip.scan_the_qr_code_or_visit_the_site", "remote_tooltip.connect_to_wand_remote" },
|
||||
Target = new Regex(@"remoteUrl=""wemodwebsite://remote"""),
|
||||
Patch = "remoteUrl=globalThis.__wandRemoteBridgeUrl||\"" + RemoteWebPanelFallbackUrl + "\""
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "remoteQrPreviewUrl",
|
||||
SearchHints = new[] { "resources/elements/remote-qr-code" },
|
||||
Resolver = new ResolveContext
|
||||
{
|
||||
Handler = (matchContent) =>
|
||||
{
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.8.1")]
|
||||
[assembly: AssemblyFileVersion("1.0.8.1")]
|
||||
[assembly: AssemblyVersion("1.0.9.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.9.0")]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,11 @@ function Resolve-MSBuildPath {
|
||||
throw "vswhere.exe not found: $vswhere"
|
||||
}
|
||||
|
||||
$installationPath = & $vswhere -latest -version '[17.0,18.0)' -requires Microsoft.Component.MSBuild -property installationPath
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($installationPath)) {
|
||||
throw 'Visual Studio 2022 with MSBuild was not found.'
|
||||
# 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'
|
||||
@@ -79,7 +81,6 @@ $cmake = Resolve-CommandPath 'cmake'
|
||||
$nuget = Resolve-NuGetPath
|
||||
$pnpm = Resolve-CommandPath 'pnpm'
|
||||
$msbuild = Resolve-MSBuildPath
|
||||
$generator = 'Visual Studio 17 2022'
|
||||
|
||||
Invoke-Step 'Install web-panel dependencies' {
|
||||
& $pnpm --dir $webPanelDir install --frozen-lockfile
|
||||
@@ -90,7 +91,12 @@ Invoke-Step 'Build web-panel' {
|
||||
}
|
||||
|
||||
Invoke-Step 'Configure asar-fuses-bypass' {
|
||||
& $cmake -S $asarFusesSourceDir -B $asarFusesBuildDir -G $generator -A x64
|
||||
# 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' {
|
||||
@@ -105,5 +111,45 @@ Invoke-Step 'Build solution' {
|
||||
& $msbuild $solutionPath /m /p:Configuration=$Configuration '/p:Platform=Any CPU' /t:Build
|
||||
}
|
||||
|
||||
# Code-sign the release executable. A self-signed signature notably lowers
|
||||
# false-positive AV/VirusTotal detections. The cert is reused across builds and
|
||||
# generated on first use, so no secrets or env configuration are required.
|
||||
if ($Configuration -eq 'Release') {
|
||||
Write-Host '==> Sign WandEnhancer.exe' -ForegroundColor Cyan
|
||||
|
||||
$exePath = Join-Path $repoRoot "WandEnhancer/bin/$Configuration/WandEnhancer.exe"
|
||||
if (-not (Test-Path $exePath)) {
|
||||
throw "Executable not found for signing: $exePath"
|
||||
}
|
||||
|
||||
$signingSubject = 'CN=Wand-Enhancer'
|
||||
$cert = Get-ChildItem Cert:\CurrentUser\My |
|
||||
Where-Object { $_.Subject -eq $signingSubject -and $_.HasPrivateKey } |
|
||||
Select-Object -First 1
|
||||
if (-not $cert) {
|
||||
$cert = New-SelfSignedCertificate `
|
||||
-Subject $signingSubject `
|
||||
-Type CodeSigningCert `
|
||||
-CertStoreLocation Cert:\CurrentUser\My `
|
||||
-KeyExportPolicy Exportable `
|
||||
-KeyUsage DigitalSignature `
|
||||
-KeyAlgorithm RSA `
|
||||
-KeyLength 2048 `
|
||||
-HashAlgorithm SHA256 `
|
||||
-NotAfter (Get-Date).AddYears(5)
|
||||
Write-Host "Generated self-signed code-signing certificate: $($cert.Subject) [$($cert.Thumbprint)]"
|
||||
}
|
||||
|
||||
$signature = Set-AuthenticodeSignature -FilePath $exePath -Certificate $cert -HashAlgorithm SHA256
|
||||
# A self-signed root is intentionally untrusted, so the status is
|
||||
# 'UnknownError' (untrusted root) even though the signature is embedded.
|
||||
# Only a missing SignerCertificate means signing actually failed.
|
||||
if (-not $signature.SignerCertificate) {
|
||||
throw "Signing failed: $($signature.Status) - $($signature.StatusMessage)"
|
||||
}
|
||||
|
||||
Write-Host "Signed $exePath [$($cert.Thumbprint)] (status: $($signature.Status))"
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Build completed successfully ($Configuration)." -ForegroundColor Green
|
||||
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
+6
-11
@@ -1,17 +1,18 @@
|
||||
# Wand Web Panel
|
||||
|
||||
Local mobile-friendly web panel scaffold for Wand.
|
||||
Local mobile-friendly web panel for Wand.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
pnpm dev
|
||||
pnpm bridge:demo
|
||||
```
|
||||
|
||||
Hosted access on the local machine:
|
||||
|
||||
- `http://localhost:4173/?mock=1`
|
||||
- `http://localhost:4173/`
|
||||
|
||||
Hosted access on the LAN:
|
||||
|
||||
@@ -21,11 +22,5 @@ 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
+36
-29
@@ -6,51 +6,58 @@ import { fileURLToPath } from "node:url"
|
||||
const bridgeRoot = dirname(fileURLToPath(import.meta.url))
|
||||
const webPanelRoot = resolve(bridgeRoot, "..")
|
||||
const distRoot = resolve(webPanelRoot, "dist")
|
||||
const bridgeEntryPoint = resolve(bridgeRoot, "source.cjs")
|
||||
const 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",
|
||||
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 })
|
||||
await readdir(rendererScriptsRoot, { withFileTypes: true })
|
||||
)
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".js"))
|
||||
.map((entry) => resolve(rendererScriptsRoot, entry.name))
|
||||
.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}`)
|
||||
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",
|
||||
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}`
|
||||
`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
|
||||
}
|
||||
+85
-12
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
getWebpackRequire,
|
||||
isRecord,
|
||||
} from "./installed-apps-sync/runtime.js"
|
||||
|
||||
;(function installRemotePopupCleanup(WandEnhancer) {
|
||||
if (globalThis.__wandRemotePopupCleanupInstalled) {
|
||||
return
|
||||
@@ -8,10 +13,13 @@
|
||||
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 .content .text,
|
||||
remote-tooltip .remote-tooltip .instructions .platforms {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -25,15 +33,15 @@
|
||||
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: 0 !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
remote-tooltip .remote-tooltip .instructions remote-qr-code {
|
||||
all: unset !important;
|
||||
--wand-qr-size: clamp(220px, 100vw, 300px);
|
||||
--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;
|
||||
@@ -49,6 +57,12 @@
|
||||
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;
|
||||
@@ -60,6 +74,8 @@
|
||||
transform: none !important;
|
||||
}
|
||||
`
|
||||
let qrRenderer = null
|
||||
let refreshScheduled = false
|
||||
|
||||
const installStyle = () => {
|
||||
if (!document.getElementById(style.id)) {
|
||||
@@ -67,9 +83,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
const updateLinks = () => {
|
||||
const remoteUrl =
|
||||
globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl
|
||||
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
|
||||
}
|
||||
@@ -80,13 +118,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
installStyle()
|
||||
updateLinks()
|
||||
const updateQrCodes = async (remoteUrl) => {
|
||||
const renderQr = remoteUrl && resolveQrRenderer()
|
||||
if (!renderQr) {
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
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()
|
||||
})
|
||||
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,
|
||||
|
||||
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,
|
||||
};
|
||||
+11
-10
@@ -1,4 +1,5 @@
|
||||
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,
|
||||
@@ -22,23 +23,23 @@ const IPC_CHANNEL = Object.freeze({
|
||||
|
||||
module.exports = {
|
||||
BRIDGE_LOG_FILE_NAME: 'wand-remote-bridge.log',
|
||||
BRIDGE_PROTOCOL_VERSION: 1,
|
||||
BRIDGE_SERVER_VERSION: '0.2.0-wand',
|
||||
DEFAULT_REMOTE_HOST: '0.0.0.0',
|
||||
DEFAULT_REMOTE_PORT: 3223,
|
||||
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: 30,
|
||||
REMOTE_ASSETS_PREFIX: '/remote/assets/',
|
||||
REMOTE_BASE_PATH: '/remote/',
|
||||
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: '/remote/api/health',
|
||||
REMOTE_INSTALLED_APPS_API_PATH: '/remote/api/installed-apps',
|
||||
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: '/remote/ws',
|
||||
REMOTE_WS_PATH: WEB_CONTRACT.webSocketPath,
|
||||
RENDERER_INJECTION_DELAYS_MS: Object.freeze([500, 2000]),
|
||||
RENDERER_SCRIPT_API_VERSION: 1,
|
||||
RENDERER_SCRIPTS_DIR: 'renderer-scripts',
|
||||
@@ -1,7 +1,8 @@
|
||||
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./bridge-modules/runtime.cjs');
|
||||
const { installWandRuntime: installRuntime } = require('./bridge-modules/wand-runtime.cjs');
|
||||
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./runtime');
|
||||
const { installWandRuntime: installRuntime } = require('./wand/runtime');
|
||||
import type { BridgeOptions, ElectronPort } from './types';
|
||||
|
||||
function withDefaultPanelRoot(options = {}) {
|
||||
function withDefaultPanelRoot(options: BridgeOptions = {}): BridgeOptions {
|
||||
if (options.panelRoot) {
|
||||
return options;
|
||||
}
|
||||
@@ -12,15 +13,15 @@ function withDefaultPanelRoot(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function createBridgeRuntime(options = {}) {
|
||||
function createBridgeRuntime(options: BridgeOptions = {}) {
|
||||
return createRuntime(withDefaultPanelRoot(options));
|
||||
}
|
||||
|
||||
function ensureBridge(options = {}) {
|
||||
function ensureBridge(options: BridgeOptions = {}) {
|
||||
return ensureRuntime(withDefaultPanelRoot(options));
|
||||
}
|
||||
|
||||
function installWandRuntime(electron, options = {}) {
|
||||
function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) {
|
||||
return installRuntime(electron, withDefaultPanelRoot(options));
|
||||
}
|
||||
|
||||
+3
-2
@@ -2,7 +2,8 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { BRIDGE_LOG_FILE_NAME } = require('./constants.cjs');
|
||||
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';
|
||||
@@ -18,7 +19,7 @@ function writeLogLine(logFile, level, message, error) {
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function createBridgeLogger(options = {}) {
|
||||
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;
|
||||
@@ -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,
|
||||
};
|
||||
Vendored
+15
-100
@@ -1,5 +1,8 @@
|
||||
const { KNOWN_CHEAT_TYPES } = require('./constants.cjs');
|
||||
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('./utils.cjs');
|
||||
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') {
|
||||
@@ -29,7 +32,7 @@ function normalizeArgs(args) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const next = {};
|
||||
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;
|
||||
@@ -60,7 +63,7 @@ function normalizeCheat(cheat, index) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
const normalized: Record<string, unknown> = {
|
||||
uuid: safeString(cheat.uuid, `${target}-${index}`),
|
||||
target,
|
||||
type,
|
||||
@@ -161,88 +164,13 @@ function normalizeInstalledAppsSnapshot(rawSnapshot) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGameStatusSnapshot(rawSnapshot) {
|
||||
if (!isRecord(rawSnapshot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSession = isRecord(rawSnapshot.session) ? rawSnapshot.session : {};
|
||||
const rawTrainer = isRecord(rawSnapshot.trainer) ? rawSnapshot.trainer : {};
|
||||
|
||||
return {
|
||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-game-status'),
|
||||
updatedAt: typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
|
||||
session: {
|
||||
state: rawSession.state === 'running' ? 'running' : 'idle',
|
||||
event: safeString(rawSession.event, 'snapshot'),
|
||||
processId: typeof rawSession.processId === 'number' ? rawSession.processId : null,
|
||||
gameId: toStringId(rawSession.gameId),
|
||||
titleId: toStringId(rawSession.titleId),
|
||||
titleName: typeof rawSession.titleName === 'string' ? rawSession.titleName : null,
|
||||
sessionDurationSeconds: typeof rawSession.sessionDurationSeconds === 'number' ? rawSession.sessionDurationSeconds : null,
|
||||
startedAt: typeof rawSession.startedAt === 'string' ? rawSession.startedAt : null,
|
||||
endedAt: typeof rawSession.endedAt === 'string' ? rawSession.endedAt : null,
|
||||
},
|
||||
trainer: {
|
||||
state: rawTrainer.state === 'running' ? 'running' : 'idle',
|
||||
event: safeString(rawTrainer.event, 'snapshot'),
|
||||
trainerId: toStringId(rawTrainer.trainerId),
|
||||
displayName: typeof rawTrainer.displayName === 'string' ? rawTrainer.displayName : null,
|
||||
gameId: toStringId(rawTrainer.gameId),
|
||||
titleId: toStringId(rawTrainer.titleId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRemoteCommandAction(value) {
|
||||
if (value === 'launch' || value === 'stop') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeRemoteCommandResult(rawResult, fallback) {
|
||||
const action = normalizeRemoteCommandAction(isRecord(rawResult) ? rawResult.action : null) || fallback.action;
|
||||
const gameId = isRecord(rawResult) ? toStringId(rawResult.gameId) || fallback.gameId || null : fallback.gameId || null;
|
||||
const titleId = isRecord(rawResult) ? toStringId(rawResult.titleId) || fallback.titleId || null : fallback.titleId || null;
|
||||
const ok = rawResult === true || Boolean(isRecord(rawResult) && rawResult.ok === true);
|
||||
const payload = {
|
||||
ok,
|
||||
action,
|
||||
gameId,
|
||||
titleId,
|
||||
};
|
||||
|
||||
if (ok) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (!isRecord(rawResult) || !isRecord(rawResult.error)) {
|
||||
return {
|
||||
...payload,
|
||||
error: {
|
||||
code: 'command_rejected',
|
||||
message: 'The renderer rejected the remote command.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
error: {
|
||||
code: safeString(rawResult.error.code, 'command_rejected'),
|
||||
message: safeString(rawResult.error.message, 'The renderer rejected the remote command.'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeInstalledAppsSource(rawSnapshot) {
|
||||
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
const parts: string[] = [];
|
||||
for (const key of ['rawInstalledApps', 'catalogGames', 'catalogTitles']) {
|
||||
const value = rawSnapshot.diagnostics[key];
|
||||
if (typeof value === 'number') {
|
||||
@@ -269,26 +197,6 @@ function installedAppsSignature(snapshot) {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function gameStatusSignature(snapshot) {
|
||||
return [
|
||||
snapshot.session.state,
|
||||
snapshot.session.event,
|
||||
snapshot.session.processId || '',
|
||||
snapshot.session.gameId || '',
|
||||
snapshot.session.titleId || '',
|
||||
snapshot.session.titleName || '',
|
||||
snapshot.session.sessionDurationSeconds || '',
|
||||
snapshot.session.startedAt || '',
|
||||
snapshot.session.endedAt || '',
|
||||
snapshot.trainer.state,
|
||||
snapshot.trainer.event,
|
||||
snapshot.trainer.trainerId || '',
|
||||
snapshot.trainer.displayName || '',
|
||||
snapshot.trainer.gameId || '',
|
||||
snapshot.trainer.titleId || '',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function buildInstalledAppsDebugPayload(snapshot) {
|
||||
if (!snapshot) {
|
||||
return {
|
||||
@@ -412,6 +320,7 @@ function normalizeSnapshot(rawSnapshot) {
|
||||
const trainerMeta = {
|
||||
session: {
|
||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
|
||||
accessToken: safeString(rawSnapshot.accessToken),
|
||||
},
|
||||
trainer: {
|
||||
trainerId,
|
||||
@@ -437,6 +346,11 @@ function normalizeSnapshot(rawSnapshot) {
|
||||
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,
|
||||
@@ -479,5 +393,6 @@ module.exports = {
|
||||
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,
|
||||
};
|
||||
+5
-5
@@ -2,7 +2,7 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { REMOTE_BASE_PATH } = require('./constants.cjs');
|
||||
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;
|
||||
@@ -38,11 +38,11 @@ function contentTypeFor(filePath) {
|
||||
}
|
||||
|
||||
function getAdvertisedUrls(port) {
|
||||
const candidates = [];
|
||||
const candidates: any[] = [];
|
||||
const interfaces = os.networkInterfaces();
|
||||
let index = 0;
|
||||
|
||||
for (const [name, entries] of Object.entries(interfaces)) {
|
||||
for (const [name, entries] of Object.entries(interfaces) as [string, any[] | undefined][]) {
|
||||
if (!entries) {
|
||||
continue;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ function isIpv4Family(family) {
|
||||
}
|
||||
|
||||
function scoreIpv4Entry(name, entry) {
|
||||
const octets = parseIpv4(entry.address);
|
||||
const octets = parseIpv4(entry.address) as number[];
|
||||
let score = 0;
|
||||
|
||||
if (isPrivateIpv4(octets)) {
|
||||
@@ -116,7 +116,7 @@ function scoreIpv4Entry(name, entry) {
|
||||
return score;
|
||||
}
|
||||
|
||||
function parseIpv4(address) {
|
||||
function parseIpv4(address): number[] | null {
|
||||
if (typeof address !== 'string') {
|
||||
return null;
|
||||
}
|
||||
+48
-178
@@ -13,40 +13,41 @@ const {
|
||||
REMOTE_INSTALLED_APPS_API_PATH,
|
||||
REMOTE_WS_PATH,
|
||||
WS_OPCODE,
|
||||
} = require('./constants.cjs');
|
||||
const { createBridgeLogger } = require('./logger.cjs');
|
||||
} = require('./constants');
|
||||
const { createBridgeLogger } = require('./logger');
|
||||
const {
|
||||
buildInstalledAppsDebugPayload,
|
||||
gameStatusSignature,
|
||||
installedAppsSignature,
|
||||
normalizeGameStatusSnapshot,
|
||||
normalizeInstalledAppsSnapshot,
|
||||
normalizeRemoteCommandAction,
|
||||
normalizeRemoteCommandResult,
|
||||
normalizeSnapshot,
|
||||
summarizeInstalledAppsSource,
|
||||
} = require('./normalizers.cjs');
|
||||
const { getAdvertisedUrls, serveFile } = require('./static-server.cjs');
|
||||
const { cloneValue, isRecord, isValidPort, safeString } = require('./utils.cjs');
|
||||
const { closeClient, createAcceptKey, makeFrame, parseFrame, sendJson } = require('./websocket.cjs');
|
||||
} = 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 createBridgeRuntime(options = {}) {
|
||||
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();
|
||||
const clients = new Set<any>();
|
||||
const log = createBridgeLogger(options);
|
||||
let advertisedUrls = [];
|
||||
let currentSnapshot = null;
|
||||
let currentInstalledApps = null;
|
||||
let currentInstalledAppsSignature = null;
|
||||
let currentGameStatus = null;
|
||||
let currentGameStatusSignature = null;
|
||||
let setValueHandler = null;
|
||||
let commandHandler = null;
|
||||
let 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;
|
||||
@@ -54,112 +55,6 @@ function createBridgeRuntime(options = {}) {
|
||||
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry) => !entry.includes('localhost')) || advertisedUrls[0];
|
||||
}
|
||||
|
||||
function broadcast(type, payload, requestId = null) {
|
||||
for (const client of clients) {
|
||||
sendJson(client, type, payload, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
function sendSnapshot(client) {
|
||||
if (!currentSnapshot) {
|
||||
sendJson(client, 'trainer_changed', {
|
||||
previousTrainerId: null,
|
||||
trainerId: '',
|
||||
});
|
||||
} else {
|
||||
sendJson(client, 'trainer_meta', currentSnapshot.trainerMeta);
|
||||
sendJson(client, 'trainer_values', currentSnapshot.trainerValues);
|
||||
}
|
||||
|
||||
if (currentGameStatus) {
|
||||
sendJson(client, 'game_status', currentGameStatus);
|
||||
}
|
||||
|
||||
if (currentInstalledApps) {
|
||||
sendJson(client, 'installed_apps', currentInstalledApps);
|
||||
}
|
||||
}
|
||||
|
||||
function sync(rawSnapshot) {
|
||||
const nextSnapshot = rawSnapshot ? normalizeSnapshot(rawSnapshot) : null;
|
||||
const previousTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
|
||||
const nextTrainerId = nextSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
|
||||
currentSnapshot = nextSnapshot;
|
||||
|
||||
if (previousTrainerId !== nextTrainerId) {
|
||||
broadcast('trainer_changed', {
|
||||
previousTrainerId,
|
||||
trainerId: nextTrainerId || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (!currentSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcast('trainer_meta', currentSnapshot.trainerMeta);
|
||||
broadcast('trainer_values', currentSnapshot.trainerValues);
|
||||
}
|
||||
|
||||
function valueChanged(change) {
|
||||
if (!currentSnapshot || !isRecord(change)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = safeString(change.target);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSnapshot.trainerValues.values[target] = cloneValue(change.value);
|
||||
broadcast('value_changed', {
|
||||
trainerId: safeString(change.trainerId, currentSnapshot.trainerMeta.trainer.trainerId),
|
||||
target,
|
||||
value: cloneValue(change.value),
|
||||
oldValue: cloneValue(change.oldValue),
|
||||
source: safeString(change.source, 'desktop'),
|
||||
cheatId: typeof change.cheatId === 'string' ? change.cheatId : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function syncInstalledApps(rawInstalledApps) {
|
||||
const sourceSummary = summarizeInstalledAppsSource(rawInstalledApps);
|
||||
const nextInstalledApps = normalizeInstalledAppsSnapshot(rawInstalledApps);
|
||||
if (!nextInstalledApps) {
|
||||
log('warn', `Ignored invalid installed apps snapshot.${sourceSummary ? ` ${sourceSummary}` : ''}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSignature = installedAppsSignature(nextInstalledApps);
|
||||
if (nextSignature === currentInstalledAppsSignature) {
|
||||
log('info', `Installed apps snapshot unchanged (${nextInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
|
||||
return;
|
||||
}
|
||||
|
||||
currentInstalledApps = nextInstalledApps;
|
||||
currentInstalledAppsSignature = nextSignature;
|
||||
log('info', `Installed apps snapshot accepted (${currentInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
|
||||
broadcast('installed_apps', currentInstalledApps);
|
||||
}
|
||||
|
||||
function syncGameStatus(rawGameStatus) {
|
||||
const nextGameStatus = normalizeGameStatusSnapshot(rawGameStatus);
|
||||
if (!nextGameStatus) {
|
||||
log('warn', 'Ignored invalid game status snapshot.');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSignature = gameStatusSignature(nextGameStatus);
|
||||
if (nextSignature === currentGameStatusSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentGameStatus = nextGameStatus;
|
||||
currentGameStatusSignature = nextSignature;
|
||||
log('info', `Game status snapshot accepted (${currentGameStatus.session.state}/${currentGameStatus.session.event}).`);
|
||||
broadcast('game_status', currentGameStatus);
|
||||
}
|
||||
|
||||
function setHandler(handler) {
|
||||
setValueHandler = typeof handler === 'function' ? handler : null;
|
||||
}
|
||||
@@ -168,25 +63,6 @@ function createBridgeRuntime(options = {}) {
|
||||
commandHandler = typeof handler === 'function' ? handler : null;
|
||||
}
|
||||
|
||||
function buildHealthPayload() {
|
||||
const installedAppsDebug = buildInstalledAppsDebugPayload(currentInstalledApps);
|
||||
return {
|
||||
ok: listening,
|
||||
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || null,
|
||||
gameSessionState: currentGameStatus?.session?.state || 'idle',
|
||||
gameSessionEvent: currentGameStatus?.session?.event || 'snapshot',
|
||||
runningTrainerId: currentGameStatus?.trainer?.trainerId || null,
|
||||
installedAppsCount: installedAppsDebug.counts.myGamesEntries,
|
||||
installedRawAppsCount: installedAppsDebug.counts.rawInstallEntries,
|
||||
installedTitlesCount: installedAppsDebug.counts.groupedTitles,
|
||||
installedUniqueTitleIdsCount: installedAppsDebug.counts.uniqueTitleIds,
|
||||
installedUniqueGameIdsCount: installedAppsDebug.counts.uniqueGameIds,
|
||||
installedAppsApiPath: REMOTE_INSTALLED_APPS_API_PATH,
|
||||
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||
advertisedUrls,
|
||||
};
|
||||
}
|
||||
|
||||
function handleRequest(request, response) {
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||
|
||||
@@ -209,13 +85,13 @@ function createBridgeRuntime(options = {}) {
|
||||
|
||||
if (url.pathname === REMOTE_HEALTH_PATH) {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
response.end(JSON.stringify(buildHealthPayload()));
|
||||
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(buildInstalledAppsDebugPayload(currentInstalledApps), null, 2));
|
||||
response.end(JSON.stringify(bridgeState.buildInstalledAppsDebugPayload(), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -283,19 +159,18 @@ function createBridgeRuntime(options = {}) {
|
||||
}
|
||||
|
||||
async function handleSetValueMessage(client, message) {
|
||||
const target = safeString(message.payload?.target);
|
||||
if (!currentSnapshot || !target || !(target in currentSnapshot.trainerValues.values)) {
|
||||
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,
|
||||
error: {
|
||||
code: 'invalid_target',
|
||||
message: 'Unknown cheat target.',
|
||||
},
|
||||
target: safeString(message.payload?.target),
|
||||
error: validation.error,
|
||||
}, message.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
const { target } = validation;
|
||||
|
||||
if (!setValueHandler) {
|
||||
sendJson(client, 'set_value_result', {
|
||||
@@ -315,7 +190,7 @@ function createBridgeRuntime(options = {}) {
|
||||
result = await Promise.resolve(setValueHandler({
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
value: cloneValue(message.payload?.value),
|
||||
value: cloneValue(validation.value),
|
||||
cheatId: typeof message.payload?.cheatId === 'string' ? message.payload.cheatId : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -352,7 +227,14 @@ function createBridgeRuntime(options = {}) {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -361,7 +243,7 @@ function createBridgeRuntime(options = {}) {
|
||||
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||
advertisedUrls,
|
||||
}, message.requestId ?? null);
|
||||
sendSnapshot(client);
|
||||
bridgeState.sendSnapshot(client);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -380,6 +262,7 @@ function createBridgeRuntime(options = {}) {
|
||||
socket,
|
||||
buffer: Buffer.alloc(0),
|
||||
closed: false,
|
||||
handshaken: false,
|
||||
};
|
||||
|
||||
clients.add(client);
|
||||
@@ -510,32 +393,19 @@ function createBridgeRuntime(options = {}) {
|
||||
closeClient(client);
|
||||
}
|
||||
clients.clear();
|
||||
currentSnapshot = null;
|
||||
currentInstalledApps = null;
|
||||
currentInstalledAppsSignature = null;
|
||||
currentGameStatus = null;
|
||||
currentGameStatusSignature = null;
|
||||
bridgeState.clear();
|
||||
listening = false;
|
||||
server.close();
|
||||
},
|
||||
setCommandHandler,
|
||||
setHandler,
|
||||
sync,
|
||||
syncGameStatus,
|
||||
syncInstalledApps,
|
||||
valueChanged,
|
||||
sync: bridgeState.sync,
|
||||
syncGameStatus: bridgeState.syncGameStatus,
|
||||
syncInstalledApps: bridgeState.syncInstalledApps,
|
||||
valueChanged: bridgeState.valueChanged,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureBridge(options = {}) {
|
||||
if (!globalThis.__wandRemoteBridgeRuntime) {
|
||||
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
|
||||
}
|
||||
|
||||
return globalThis.__wandRemoteBridgeRuntime;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeRuntime,
|
||||
ensureBridge,
|
||||
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;
|
||||
};
|
||||
};
|
||||
web-panel/bridge/bridge-modules/renderer-scripts.cjs → web-panel/bridge/src/wand/renderer-scripts.ts
Vendored
+4
-3
@@ -1,8 +1,9 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('./constants.cjs');
|
||||
const { writeInstallLog } = require('./logger.cjs');
|
||||
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);
|
||||
@@ -51,7 +52,7 @@ function buildRendererBootstrap(remoteUrl, scripts) {
|
||||
return `(() => {\n${header}\n${body}\n})();`;
|
||||
}
|
||||
|
||||
function installRendererScripts(electron, runtime, options = {}) {
|
||||
function installRendererScripts(electron: ElectronPort, runtime, options: BridgeOptions = {}) {
|
||||
if (globalThis.__wandRemoteBridgeRendererScriptsInstalled) {
|
||||
return;
|
||||
}
|
||||
+38
-9
@@ -8,19 +8,25 @@ const {
|
||||
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS,
|
||||
REMOTE_GAME_STATUS_CHANNEL,
|
||||
REMOTE_INSTALLED_APPS_CHANNEL,
|
||||
} = require('./constants.cjs');
|
||||
const { writeInstallLog } = require('./logger.cjs');
|
||||
const { ensureBridge } = require('./runtime.cjs');
|
||||
const { installRendererScripts } = require('./renderer-scripts.cjs');
|
||||
const { safeString } = require('./utils.cjs');
|
||||
} = 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';
|
||||
|
||||
function installWandRuntime(electron, options = {}) {
|
||||
// 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 = globalThis.__wandRemoteBridgeBoundRenderers || new Set();
|
||||
const boundRenderers: Set<WebContentsPort> = globalThis.__wandRemoteBridgeBoundRenderers || new Set();
|
||||
const pendingCommandResponses = globalThis.__wandRemoteBridgePendingCommandResponses || new Map();
|
||||
globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers;
|
||||
globalThis.__wandRemoteBridgePendingCommandResponses = pendingCommandResponses;
|
||||
@@ -77,8 +83,8 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
|
||||
}
|
||||
|
||||
globalThis.__wandRemoteBridgeIpcInstalled = true;
|
||||
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (_event, snapshot) => {
|
||||
runtime.sync(snapshot);
|
||||
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) => {
|
||||
@@ -113,6 +119,29 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
|
||||
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)}`;
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants.cjs');
|
||||
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants');
|
||||
|
||||
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
@@ -15,7 +15,7 @@ function jsonMessage(type, payload, requestId = null) {
|
||||
|
||||
function makeFrame(opcode, payload) {
|
||||
const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
||||
const header = [];
|
||||
const header: number[] = [];
|
||||
header.push(0x80 | (opcode & 0x0f));
|
||||
|
||||
if (source.length < 126) {
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noImplicitAny": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
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
+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
+19
-2
@@ -8,17 +8,31 @@
|
||||
"dev:host": "vite --host 0.0.0.0",
|
||||
"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",
|
||||
@@ -28,10 +42,13 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+2261
File diff suppressed because it is too large
Load Diff
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
import contract from './web-contract.json';
|
||||
|
||||
export const WEB_CONTRACT = contract;
|
||||
export const PROTOCOL_VERSION = contract.protocolVersion;
|
||||
+2
-49
@@ -1,5 +1,4 @@
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
const NUMBER_GROUP_SEPARATOR_PATTERN = /[,\s]/g;
|
||||
export { PROTOCOL_VERSION } from './contract';
|
||||
|
||||
// String values mirror the wire protocol; do not rename the right-hand side.
|
||||
export enum ECheatType {
|
||||
@@ -109,6 +108,7 @@ export interface TrainerSummary {
|
||||
export interface TrainerMetaPayload {
|
||||
session: {
|
||||
instanceId: string;
|
||||
accessToken?: string;
|
||||
};
|
||||
trainer: TrainerSummary;
|
||||
schema: {
|
||||
@@ -237,50 +237,3 @@ export type IncomingMessage =
|
||||
|
||||
export type OutgoingMessage = HelloMessage | SetValueMessage | RemoteCommandMessage;
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
export function isIncomingMessage(value: unknown): value is IncomingMessage {
|
||||
return isRecord(value) && typeof value.type === 'string' && typeof value.version === 'number' && 'payload' in value;
|
||||
}
|
||||
|
||||
export function resolveOption(option: CheatOptionLike): CheatOption {
|
||||
if (typeof option === 'string' || typeof option === 'number') {
|
||||
return { label: String(option), value: option };
|
||||
}
|
||||
|
||||
return {
|
||||
label: option.label ?? String(option.value),
|
||||
value: option.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeIncomingValue(cheat: CheatSchema, value: unknown): unknown {
|
||||
if (cheat.type === ECheatType.Toggle) {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeOutgoingValue(cheat: CheatSchema, value: unknown): unknown {
|
||||
if (cheat.type === ECheatType.Toggle) {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
if (cheat.type !== ECheatType.Slider && cheat.type !== ECheatType.Number) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Number(trimmedValue.replace(NUMBER_GROUP_SEPARATOR_PATTERN, ''));
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PROTOCOL_VERSION } from './contract';
|
||||
import { isIncomingMessage, isOutgoingMessage } from './validation';
|
||||
|
||||
describe('web protocol validation', () => {
|
||||
it('rejects messages with another envelope version', () => {
|
||||
expect(isIncomingMessage({
|
||||
type: 'error',
|
||||
version: PROTOCOL_VERSION + 1,
|
||||
requestId: null,
|
||||
payload: { code: 'bad', message: 'bad' },
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects incomplete required payloads', () => {
|
||||
expect(isIncomingMessage({
|
||||
type: 'hello_ack',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: null,
|
||||
payload: { accepted: true },
|
||||
})).toBe(false);
|
||||
expect(isOutgoingMessage({
|
||||
type: 'set_value',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: 'set',
|
||||
payload: { target: 'speed', value: 1 },
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
Vendored
+97
@@ -0,0 +1,97 @@
|
||||
import { PROTOCOL_VERSION } from './contract';
|
||||
import type { IncomingMessage, OutgoingMessage } from './messages';
|
||||
|
||||
const INCOMING_TYPES = new Set<IncomingMessage['type']>([
|
||||
'hello_ack',
|
||||
'trainer_meta',
|
||||
'trainer_values',
|
||||
'game_status',
|
||||
'installed_apps',
|
||||
'value_changed',
|
||||
'trainer_changed',
|
||||
'set_value_result',
|
||||
'remote_command_result',
|
||||
'error',
|
||||
]);
|
||||
|
||||
const OUTGOING_TYPES = new Set<OutgoingMessage['type']>(['hello', 'set_value', 'remote_command']);
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
export function isIncomingMessage(value: unknown): value is IncomingMessage {
|
||||
if (!isEnvelope(value) || !INCOMING_TYPES.has(value.type as IncomingMessage['type'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = value.payload;
|
||||
switch (value.type) {
|
||||
case 'hello_ack':
|
||||
return hasString(payload, 'sessionId') && hasBoolean(payload, 'accepted') && hasString(payload, 'serverVersion')
|
||||
&& hasNumber(payload, 'protocolVersion');
|
||||
case 'trainer_meta':
|
||||
return isRecord(payload.session) && hasString(payload.session, 'instanceId')
|
||||
&& isRecord(payload.trainer) && hasString(payload.trainer, 'trainerId')
|
||||
&& isRecord(payload.schema) && Array.isArray(payload.schema.categories) && Array.isArray(payload.schema.cheats);
|
||||
case 'trainer_values':
|
||||
return hasString(payload, 'trainerId') && isRecord(payload.values);
|
||||
case 'installed_apps':
|
||||
return hasString(payload, 'instanceId') && hasString(payload, 'updatedAt') && Array.isArray(payload.apps);
|
||||
case 'game_status':
|
||||
return hasString(payload, 'instanceId') && hasString(payload, 'updatedAt')
|
||||
&& isRecord(payload.session) && isRecord(payload.trainer);
|
||||
case 'value_changed':
|
||||
return hasString(payload, 'trainerId') && hasString(payload, 'target') && 'value' in payload;
|
||||
case 'trainer_changed':
|
||||
return hasString(payload, 'trainerId');
|
||||
case 'set_value_result':
|
||||
return hasBoolean(payload, 'ok') && hasString(payload, 'trainerId') && hasString(payload, 'target');
|
||||
case 'remote_command_result':
|
||||
return hasBoolean(payload, 'ok') && (payload.action === 'launch' || payload.action === 'stop');
|
||||
case 'error':
|
||||
return hasString(payload, 'code') && hasString(payload, 'message');
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isOutgoingMessage(value: unknown): value is OutgoingMessage {
|
||||
if (!isEnvelope(value) || !OUTGOING_TYPES.has(value.type as OutgoingMessage['type'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = value.payload;
|
||||
if (value.type === 'hello') {
|
||||
return payload.client === 'mobile-web' && hasString(payload, 'clientVersion') && isRecord(payload.capabilities);
|
||||
}
|
||||
if (value.type === 'set_value') {
|
||||
return hasString(payload, 'trainerId') && hasString(payload, 'target') && 'value' in payload;
|
||||
}
|
||||
return (payload.action === 'launch' || payload.action === 'stop');
|
||||
}
|
||||
|
||||
function isEnvelope(value: unknown): value is Record<string, unknown> & {
|
||||
type: string;
|
||||
version: number;
|
||||
requestId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
} {
|
||||
return isRecord(value)
|
||||
&& typeof value.type === 'string'
|
||||
&& value.version === PROTOCOL_VERSION
|
||||
&& (value.requestId === null || typeof value.requestId === 'string')
|
||||
&& isRecord(value.payload);
|
||||
}
|
||||
|
||||
function hasString(value: Record<string, unknown>, key: string): boolean {
|
||||
return typeof value[key] === 'string';
|
||||
}
|
||||
|
||||
function hasNumber(value: Record<string, unknown>, key: string): boolean {
|
||||
return typeof value[key] === 'number';
|
||||
}
|
||||
|
||||
function hasBoolean(value: Record<string, unknown>, key: string): boolean {
|
||||
return typeof value[key] === 'boolean';
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"clientVersion": "0.2.0",
|
||||
"serverVersion": "0.2.0-wand",
|
||||
"defaultRemoteHost": "0.0.0.0",
|
||||
"defaultRemotePort": 3223,
|
||||
"portScanRange": 30,
|
||||
"basePath": "/remote/",
|
||||
"assetsPath": "/remote/assets/",
|
||||
"webSocketPath": "/remote/ws",
|
||||
"healthPath": "/remote/api/health",
|
||||
"installedAppsPath": "/remote/api/installed-apps"
|
||||
}
|
||||
Vendored
-378
@@ -1,378 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type UIEvent } from 'react';
|
||||
|
||||
import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from '@/features/remote-panel/category';
|
||||
import { CategorySection } from '@/features/remote-panel/components/CategorySection';
|
||||
import { Drawer } from '@/features/remote-panel/components/Drawer';
|
||||
import { FloatingDock } from '@/features/remote-panel/components/FloatingDock';
|
||||
import { LibraryDrawer } from '@/features/remote-panel/components/LibraryDrawer';
|
||||
import { PlaceholderState } from '@/features/remote-panel/components/PlaceholderState';
|
||||
import { QuickActions } from '@/features/remote-panel/components/QuickActions';
|
||||
import { SearchInput } from '@/features/remote-panel/components/SearchInput';
|
||||
import { SettingsDrawer } from '@/features/remote-panel/components/SettingsDrawer';
|
||||
import { TopBar } from '@/features/remote-panel/components/TopBar';
|
||||
import { TrainerHeader } from '@/features/remote-panel/components/TrainerHeader';
|
||||
import { buildLibraryGames, getCurrentGame, type LibraryGame } from '@/features/remote-panel/game-library';
|
||||
import { loadPinnedGameIds, savePinnedGameIds, togglePinnedGame } from '@/features/remote-panel/game-pin-storage';
|
||||
import { handleProtocolMessage } from '@/features/remote-panel/message-handler';
|
||||
import { getPinnedStorageKey, loadPinnedTargets, savePinnedTargets } from '@/features/remote-panel/pinned-storage';
|
||||
import { capturePresetValues, createPreset, getPresetStorageKey, loadPresets, savePresets, type RemotePreset } from '@/features/remote-panel/preset-storage';
|
||||
import { normalizeOutgoingValue, type CheatSchema, type InstalledAppSummary } from '@/features/remote-panel/protocol';
|
||||
import { ECheatType } from '@/features/remote-panel/protocol';
|
||||
import { PanelSocketClient } from '@/features/remote-panel/socket-client';
|
||||
import { createInitialPanelState, EConnectionStatus, panelReducer } from '@/features/remote-panel/state';
|
||||
|
||||
const SCROLL_HIDE_THRESHOLD_PX = 60;
|
||||
const SCROLL_REVEAL_DEAD_ZONE_PX = 4;
|
||||
|
||||
export const App = () => {
|
||||
const [state, dispatch] = useReducer(panelReducer, createInitialPanelState());
|
||||
const [cheatQuery, setCheatQuery] = useState('');
|
||||
const [gameQuery, setGameQuery] = useState('');
|
||||
const [leftOpen, setLeftOpen] = useState(false);
|
||||
const [rightOpen, setRightOpen] = useState(false);
|
||||
const [hideDock, setHideDock] = useState(false);
|
||||
const [pinnedGameIds, setPinnedGameIds] = useState<Record<string, true>>({});
|
||||
const [presets, setPresets] = useState<RemotePreset[]>([]);
|
||||
const lastScrollRef = useRef(0);
|
||||
const clientRef = useRef<PanelSocketClient | null>(null);
|
||||
const stateRef = useRef(state);
|
||||
const handleConnectRef = useRef<() => void>(() => {});
|
||||
const pinnedStorageKeyRef = useRef<string | null>('');
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedGameIds(loadPinnedGameIds());
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
handleConnectRef.current = handleConnect;
|
||||
pinnedStorageKeyRef.current = pinnedStorageKey;
|
||||
});
|
||||
|
||||
const activeTrainer = state.trainerMeta?.trainer ?? null;
|
||||
const libraryGames = useMemo(
|
||||
() => buildLibraryGames(state.installedApps, state.gameStatus, activeTrainer, pinnedGameIds),
|
||||
[activeTrainer, pinnedGameIds, state.gameStatus, state.installedApps],
|
||||
);
|
||||
const currentGame = useMemo(() => getCurrentGame(libraryGames), [libraryGames]);
|
||||
const groups = useMemo(() => groupCheatsByCategory(state.trainerMeta), [state.trainerMeta]);
|
||||
const pinnedGroup = useMemo(() => buildPinnedGroup(state.trainerMeta, state.pinnedTargets), [state.trainerMeta, state.pinnedTargets]);
|
||||
const filteredGroups = useMemo(() => filterGroups(groups, cheatQuery), [cheatQuery, groups]);
|
||||
const filteredPinnedGroup = useMemo(
|
||||
() => (pinnedGroup ? filterGroups([pinnedGroup], cheatQuery)[0] ?? null : null),
|
||||
[cheatQuery, pinnedGroup],
|
||||
);
|
||||
const pinnedStorageKey = useMemo(() => getPinnedStorageKey(activeTrainer), [activeTrainer]);
|
||||
const presetStorageKey = useMemo(() => getPresetStorageKey(activeTrainer), [activeTrainer]);
|
||||
const socketReady = clientRef.current?.isOpen() ?? false;
|
||||
const connected = state.connectionStatus === EConnectionStatus.Connected;
|
||||
const controlsDisabled = Boolean(activeTrainer?.trainerLoading || activeTrainer?.isTimeLimitExpired);
|
||||
const totalVisibleCheats = filteredGroups.reduce((count, group) => count + group.cheats.length, filteredPinnedGroup?.cheats.length ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'setPinnedTargets', pinned: loadPinnedTargets(pinnedStorageKey) });
|
||||
}, [pinnedStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setPresets(loadPresets(presetStorageKey));
|
||||
}, [presetStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.wsUrl.trim()) {
|
||||
handleConnect();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && !clientRef.current?.isOpen()) {
|
||||
handleConnectRef.current();
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}, []);
|
||||
|
||||
function handleConnect(): void {
|
||||
clientRef.current?.disconnect();
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const wsUrl = state.wsUrl.trim();
|
||||
if (!wsUrl) {
|
||||
dispatch({ type: 'error', message: 'Enter a WebSocket URL first.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextClient = new PanelSocketClient(wsUrl, {
|
||||
onConnecting: () => dispatch({ type: 'connecting' }),
|
||||
onOpen: () => dispatch({ type: 'connected' }),
|
||||
onMessage: (message) => handleProtocolMessage(dispatch, message, stateRef.current.trainerMeta),
|
||||
onClose: () => {
|
||||
dispatch({ type: 'error', message: 'The WebSocket connection closed.' });
|
||||
if (document.visibilityState === 'visible') {
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
if (document.visibilityState === 'visible' && stateRef.current.wsUrl.trim()) {
|
||||
handleConnectRef.current();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
onError: (message) => dispatch({ type: 'error', message }),
|
||||
});
|
||||
|
||||
clientRef.current = nextClient;
|
||||
nextClient.connect();
|
||||
}
|
||||
|
||||
function handleDisconnect(): void {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
dispatch({ type: 'disconnected' });
|
||||
}
|
||||
|
||||
const handleCheatChange = useCallback((cheat: CheatSchema, nextValue: unknown): void => {
|
||||
const { connectionStatus, trainerMeta } = stateRef.current;
|
||||
const normalizedValue = normalizeOutgoingValue(cheat, nextValue);
|
||||
dispatch({ type: 'setPending', target: cheat.target, pending: true });
|
||||
dispatch({ type: 'valueChanged', target: cheat.target, value: normalizedValue });
|
||||
|
||||
if (connectionStatus !== EConnectionStatus.Connected || !trainerMeta || !clientRef.current) {
|
||||
dispatch({ type: 'setPending', target: cheat.target, pending: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const sent = clientRef.current.setValue(trainerMeta.trainer.trainerId, cheat.target, normalizedValue, cheat.uuid);
|
||||
if (!sent) {
|
||||
dispatch({ type: 'setPending', target: cheat.target, pending: false });
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleCheatPin = useCallback((cheat: CheatSchema): void => {
|
||||
const { pinnedTargets } = stateRef.current;
|
||||
const next = { ...pinnedTargets };
|
||||
if (next[cheat.target]) {
|
||||
delete next[cheat.target];
|
||||
} else {
|
||||
next[cheat.target] = true;
|
||||
}
|
||||
|
||||
dispatch({ type: 'togglePinnedTarget', target: cheat.target });
|
||||
savePinnedTargets(pinnedStorageKeyRef.current, next);
|
||||
}, []);
|
||||
|
||||
function handleToggleGamePin(game: LibraryGame): void {
|
||||
const next = togglePinnedGame(game, pinnedGameIds);
|
||||
setPinnedGameIds(next);
|
||||
savePinnedGameIds(next);
|
||||
}
|
||||
|
||||
function handleLaunchGame(app: InstalledAppSummary): void {
|
||||
const client = clientRef.current;
|
||||
if (!app.gameId) {
|
||||
dispatch({ type: 'error', message: 'This My Games entry does not expose a Wand game id.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client?.isOpen()) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client.launchGame(app.gameId, app.titleId ?? undefined)) {
|
||||
dispatch({ type: 'error', message: 'Failed to send the launch command to the bridge.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setRightOpen(false);
|
||||
}
|
||||
|
||||
function handlePlayGame(game: LibraryGame): void {
|
||||
handleLaunchGame(game.app);
|
||||
}
|
||||
|
||||
function handleStopPlaying(): void {
|
||||
const client = clientRef.current;
|
||||
if (!client?.isOpen()) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activeGameId = state.gameStatus?.session.gameId ?? state.gameStatus?.trainer.gameId ?? undefined;
|
||||
const activeTitleId = state.gameStatus?.session.titleId ?? state.gameStatus?.trainer.titleId ?? undefined;
|
||||
if (!client.stopPlaying(activeGameId ?? undefined, activeTitleId ?? undefined)) {
|
||||
dispatch({ type: 'error', message: 'Failed to send the stop command to the bridge.' });
|
||||
}
|
||||
}
|
||||
|
||||
function handlePanic(): void {
|
||||
if (!state.trainerMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cheat of state.trainerMeta.schema.cheats) {
|
||||
if (cheat.type === ECheatType.Toggle && Boolean(state.values[cheat.target])) {
|
||||
handleCheatChange(cheat, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddPreset(name: string): boolean {
|
||||
if (!state.trainerMeta) {
|
||||
dispatch({ type: 'error', message: 'No active trainer to save as a preset.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const values = capturePresetValues(state.trainerMeta.schema.cheats, state.values);
|
||||
if (Object.keys(values).length === 0) {
|
||||
dispatch({ type: 'error', message: 'There are no mod values to save yet.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextPresets = [...presets, createPreset(name, values)];
|
||||
setPresets(nextPresets);
|
||||
savePresets(presetStorageKey, nextPresets);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleApplyPreset(preset: RemotePreset): void {
|
||||
if (!state.trainerMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cheat of state.trainerMeta.schema.cheats) {
|
||||
if (!(cheat.target in preset.values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
handleCheatChange(cheat, preset.values[cheat.target]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeletePreset(presetId: string): void {
|
||||
const nextPresets = presets.filter((preset) => preset.id !== presetId);
|
||||
setPresets(nextPresets);
|
||||
savePresets(presetStorageKey, nextPresets);
|
||||
}
|
||||
|
||||
function handleScroll(event: UIEvent<HTMLDivElement>): void {
|
||||
const y = event.currentTarget.scrollTop;
|
||||
if (y > lastScrollRef.current && y > SCROLL_HIDE_THRESHOLD_PX) {
|
||||
setHideDock(true);
|
||||
} else if (y < lastScrollRef.current - SCROLL_REVEAL_DEAD_ZONE_PX) {
|
||||
setHideDock(false);
|
||||
}
|
||||
|
||||
lastScrollRef.current = y;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-svh bg-[#050608] text-(--deck-fg)">
|
||||
<div className="flex min-h-svh w-full p-0">
|
||||
<section className="relative h-svh w-full overflow-hidden bg-(--deck-bg) shadow-[0_40px_100px_-20px_rgba(0,0,0,.7),0_0_0_1px_rgba(255,255,255,.06)]">
|
||||
<div className="pointer-events-none absolute -inset-12 z-0 bg-[radial-gradient(circle_at_30%_15%,color-mix(in_oklab,var(--deck-accent)_22%,transparent),transparent_45%),radial-gradient(circle_at_80%_85%,color-mix(in_oklab,var(--deck-accent)_16%,transparent),transparent_45%),radial-gradient(circle_at_20%_80%,color-mix(in_oklab,var(--deck-accent)_8%,transparent),transparent_50%)]" />
|
||||
<div className="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(ellipse_100%_60%_at_50%_0%,rgba(255,255,255,0.025),transparent)]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<TopBar status={state.connectionStatus} currentGame={currentGame} runningTrainer={activeTrainer} onOpenSettings={() => setLeftOpen(true)} />
|
||||
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 pb-27.5" onScroll={handleScroll}>
|
||||
{!connected ? (
|
||||
<PlaceholderState icon="plug" title="Bridge offline" sub="Open Settings to point Wand at your trainer bridge over WebSocket." action="Open Settings" onAction={() => setLeftOpen(true)} />
|
||||
) : !activeTrainer ? (
|
||||
<PlaceholderState icon="gamepad-variant-outline" title="Select a game" sub="No game is running yet. Open the library and launch one to start tweaking." action="Browse library" onAction={() => setRightOpen(true)} />
|
||||
) : (
|
||||
<>
|
||||
<TrainerHeader trainer={activeTrainer} game={currentGame} isPinned={Boolean(currentGame && pinnedGameIds[currentGame.id])} onPin={() => currentGame && handleToggleGamePin(currentGame)} />
|
||||
<QuickActions presets={presets} onAddPreset={handleAddPreset} onApplyPreset={handleApplyPreset} onDeletePreset={handleDeletePreset} onPanic={handlePanic} />
|
||||
<div className="sticky top-0 z-10 -mx-3.5 mb-2.5 px-3.5 py-0.5">
|
||||
<SearchInput value={cheatQuery} placeholder="Search mods" onChange={setCheatQuery} />
|
||||
</div>
|
||||
{filteredPinnedGroup ? (
|
||||
<CategorySection
|
||||
forceOpen={Boolean(cheatQuery)}
|
||||
group={filteredPinnedGroup}
|
||||
values={state.values}
|
||||
pendingTargets={state.pendingTargets}
|
||||
pinnedTargets={state.pinnedTargets}
|
||||
disabled={controlsDisabled}
|
||||
onCheatChange={handleCheatChange}
|
||||
onTogglePin={handleToggleCheatPin}
|
||||
/>
|
||||
) : null}
|
||||
{filteredGroups.map((group, index) => (
|
||||
<CategorySection
|
||||
key={group.id}
|
||||
forceOpen={Boolean(cheatQuery)}
|
||||
group={group}
|
||||
openByDefault={index < 2}
|
||||
values={state.values}
|
||||
pendingTargets={state.pendingTargets}
|
||||
pinnedTargets={state.pinnedTargets}
|
||||
disabled={controlsDisabled}
|
||||
onCheatChange={handleCheatChange}
|
||||
onTogglePin={handleToggleCheatPin}
|
||||
/>
|
||||
))}
|
||||
{cheatQuery && totalVisibleCheats === 0 ? <p className="px-8 py-8 text-center text-[13px] text-(--deck-fg-4)">No mods match "{cheatQuery}"</p> : null}
|
||||
<div className="mt-4 text-center font-mono text-[10px] uppercase tracking-[0.08em] text-(--deck-fg-4)">
|
||||
{cheatQuery ? `${totalVisibleCheats} matches` : `END · ${state.trainerMeta?.schema.cheats.length ?? 0} MODS`}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingDock
|
||||
status={state.connectionStatus}
|
||||
runningGameTitle={currentGame?.title ?? null}
|
||||
hidden={hideDock}
|
||||
leftHasBadge={!connected}
|
||||
rightHasBadge={connected && !currentGame}
|
||||
onOpenSettings={() => setLeftOpen(true)}
|
||||
onOpenLibrary={() => setRightOpen(true)}
|
||||
/>
|
||||
|
||||
<Drawer open={leftOpen} side="left" onClose={() => setLeftOpen(false)}>
|
||||
<SettingsDrawer
|
||||
status={state.connectionStatus}
|
||||
wsUrl={state.wsUrl}
|
||||
currentGame={currentGame}
|
||||
currentTrainer={activeTrainer}
|
||||
lastError={state.lastError}
|
||||
onClose={() => setLeftOpen(false)}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onWsUrlChange={(wsUrl) => dispatch({ type: 'setWsUrl', wsUrl })}
|
||||
/>
|
||||
</Drawer>
|
||||
<Drawer open={rightOpen} side="right" onClose={() => setRightOpen(false)}>
|
||||
<LibraryDrawer
|
||||
games={libraryGames}
|
||||
query={gameQuery}
|
||||
canLaunch={socketReady}
|
||||
onClose={() => setRightOpen(false)}
|
||||
onPin={handleToggleGamePin}
|
||||
onPlay={handlePlayGame}
|
||||
onStop={handleStopPlaying}
|
||||
onQueryChange={setGameQuery}
|
||||
/>
|
||||
</Drawer>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
Vendored
+122
@@ -0,0 +1,122 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { FloatingDock } from '@/app/ui/FloatingDock';
|
||||
import { SessionPlaceholder } from '@/app/ui/SessionPlaceholder';
|
||||
import { SettingsDrawer } from '@/app/ui/SettingsDrawer';
|
||||
import { TopBar } from '@/app/ui/TopBar';
|
||||
import { LibraryDrawer } from '@/library/ui/LibraryDrawer';
|
||||
import { Drawer } from '@/shared/ui/Drawer';
|
||||
import { SearchInput } from '@/shared/ui/SearchInput';
|
||||
import { CategorySection } from '@/trainer/ui/CategorySection';
|
||||
import { QuickActions } from '@/trainer/ui/QuickActions';
|
||||
import { TrainerHeader } from '@/trainer/ui/TrainerHeader';
|
||||
|
||||
import { useRemotePanel } from './use-remote-panel';
|
||||
|
||||
export const App = () => {
|
||||
const { _ } = useLingui();
|
||||
const panel = useRemotePanel();
|
||||
const { session, trainer, library, shell } = panel;
|
||||
|
||||
return (
|
||||
<main className="min-h-svh bg-[#050608] text-(--deck-fg)">
|
||||
<div className="flex min-h-svh w-full p-0">
|
||||
<section className="relative h-svh w-full overflow-hidden bg-(--deck-bg) shadow-[0_40px_100px_-20px_rgba(0,0,0,.7),0_0_0_1px_rgba(255,255,255,.06)]">
|
||||
<div className="pointer-events-none absolute -inset-12 z-0 bg-[radial-gradient(circle_at_30%_15%,color-mix(in_oklab,var(--deck-accent)_22%,transparent),transparent_45%),radial-gradient(circle_at_80%_85%,color-mix(in_oklab,var(--deck-accent)_16%,transparent),transparent_45%),radial-gradient(circle_at_20%_80%,color-mix(in_oklab,var(--deck-accent)_8%,transparent),transparent_50%)]" />
|
||||
<div className="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(ellipse_100%_60%_at_50%_0%,rgba(255,255,255,0.025),transparent)]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<TopBar status={session.status} currentGame={library.currentGame} runningTrainer={trainer.activeTrainer} onOpenSettings={shell.openSettings} />
|
||||
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 pb-27.5" onScroll={shell.onScroll}>
|
||||
{!session.connected || !trainer.activeTrainer ? (
|
||||
<SessionPlaceholder
|
||||
connected={session.connected}
|
||||
activeTrainer={trainer.activeTrainer}
|
||||
onOpenLibrary={shell.openLibrary}
|
||||
onOpenSettings={shell.openSettings}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TrainerHeader trainer={trainer.activeTrainer} game={library.currentGame} isPinned={Boolean(library.currentGame && library.pinnedGameIds[library.currentGame.id])} onPin={() => library.currentGame && library.togglePin(library.currentGame)} />
|
||||
<QuickActions presets={trainer.presets} onAddPreset={trainer.addPreset} onApplyPreset={trainer.applyPreset} onDeletePreset={trainer.deletePreset} onPanic={trainer.panic} />
|
||||
<div className="sticky top-0 z-10 -mx-3.5 mb-2.5 px-3.5 py-0.5">
|
||||
<SearchInput value={trainer.query} placeholder={_(msg`Search mods`)} onChange={trainer.setQuery} />
|
||||
</div>
|
||||
{trainer.filteredPinnedGroup ? (
|
||||
<CategorySection
|
||||
forceOpen={Boolean(trainer.query)}
|
||||
group={trainer.filteredPinnedGroup}
|
||||
values={session.values}
|
||||
pendingTargets={session.pendingTargets}
|
||||
pinnedTargets={trainer.pinnedTargets}
|
||||
disabled={trainer.controlsDisabled}
|
||||
onCheatChange={trainer.changeCheat}
|
||||
onTogglePin={trainer.togglePin}
|
||||
/>
|
||||
) : null}
|
||||
{trainer.filteredGroups.map((group, index) => (
|
||||
<CategorySection
|
||||
key={group.id}
|
||||
forceOpen={Boolean(trainer.query)}
|
||||
group={group}
|
||||
openByDefault={index < 2}
|
||||
values={session.values}
|
||||
pendingTargets={session.pendingTargets}
|
||||
pinnedTargets={trainer.pinnedTargets}
|
||||
disabled={trainer.controlsDisabled}
|
||||
onCheatChange={trainer.changeCheat}
|
||||
onTogglePin={trainer.togglePin}
|
||||
/>
|
||||
))}
|
||||
{trainer.query && trainer.totalVisibleCheats === 0 ? <p className="px-8 py-8 text-center text-[13px] text-(--deck-fg-4)"><Trans>No mods match "{trainer.query}"</Trans></p> : null}
|
||||
<div className="mt-4 text-center font-mono text-[10px] uppercase tracking-[0.08em] text-(--deck-fg-4)">
|
||||
{trainer.query
|
||||
? _(msg`${trainer.totalVisibleCheats} matches`)
|
||||
: _(msg`END · ${trainer.totalCheats} MODS`)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingDock
|
||||
status={session.status}
|
||||
runningGameTitle={library.currentGame?.title ?? null}
|
||||
hidden={shell.dockHidden}
|
||||
leftHasBadge={!session.connected}
|
||||
rightHasBadge={session.connected && !library.currentGame}
|
||||
onOpenSettings={shell.openSettings}
|
||||
onOpenLibrary={shell.openLibrary}
|
||||
/>
|
||||
|
||||
<Drawer open={shell.leftOpen} side="left" onClose={shell.closeSettings}>
|
||||
<SettingsDrawer
|
||||
status={session.status}
|
||||
wsUrl={session.wsUrl}
|
||||
currentGame={library.currentGame}
|
||||
currentTrainer={trainer.activeTrainer}
|
||||
lastError={session.lastError}
|
||||
onClose={shell.closeSettings}
|
||||
onConnect={session.connect}
|
||||
onDisconnect={session.disconnect}
|
||||
onWsUrlChange={session.setWsUrl}
|
||||
/>
|
||||
</Drawer>
|
||||
<Drawer open={shell.rightOpen} side="right" onClose={shell.closeLibrary}>
|
||||
<LibraryDrawer
|
||||
games={library.games}
|
||||
query={library.query}
|
||||
canLaunch={session.socketReady}
|
||||
onClose={shell.closeLibrary}
|
||||
onPin={library.togglePin}
|
||||
onPlay={library.playGame}
|
||||
onStop={library.stopPlaying}
|
||||
onQueryChange={library.setQuery}
|
||||
/>
|
||||
</Drawer>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
Vendored
+70
@@ -0,0 +1,70 @@
|
||||
import { i18n, type Messages } from '@lingui/core';
|
||||
|
||||
export const DEFAULT_LOCALE = 'en-US';
|
||||
|
||||
export const SUPPORTED_LOCALES = [
|
||||
{ code: 'en-US', label: 'English' },
|
||||
{ code: 'ru-RU', label: 'Русский' },
|
||||
{ code: 'de-DE', label: 'Deutsch' },
|
||||
{ code: 'fr-FR', label: 'Français' },
|
||||
{ code: 'es-ES', label: 'Español' },
|
||||
{ code: 'zh-CN', label: '简体中文' },
|
||||
] as const;
|
||||
|
||||
export type LocaleCode = (typeof SUPPORTED_LOCALES)[number]['code'];
|
||||
|
||||
const LOCALE_STORAGE_KEY = 'wand:locale';
|
||||
|
||||
type CatalogModule = { messages: Messages };
|
||||
|
||||
const catalogs = import.meta.glob<CatalogModule>('../locales/*/messages.po');
|
||||
|
||||
export async function activateLocale(locale: LocaleCode): Promise<void> {
|
||||
const loadCatalog = catalogs[`../locales/${locale}/messages.po`];
|
||||
if (!loadCatalog) {
|
||||
throw new Error(`Locale catalog not found: ${locale}`);
|
||||
}
|
||||
|
||||
const { messages } = await loadCatalog();
|
||||
i18n.load(locale, messages);
|
||||
i18n.activate(locale);
|
||||
persistLocale(locale);
|
||||
}
|
||||
|
||||
export function detectInitialLocale(): LocaleCode {
|
||||
return readStoredLocale() ?? matchBrowserLocale() ?? DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
function readStoredLocale(): LocaleCode | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
return isSupportedLocale(stored) ? stored : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function matchBrowserLocale(): LocaleCode | null {
|
||||
const candidates = typeof navigator === 'undefined' ? [] : (navigator.languages ?? [navigator.language]);
|
||||
for (const candidate of candidates) {
|
||||
const base = candidate.split('-')[0];
|
||||
const match = SUPPORTED_LOCALES.find(({ code }) => code === candidate || code.split('-')[0] === base);
|
||||
if (match) {
|
||||
return match.code;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function persistLocale(locale: LocaleCode): void {
|
||||
try {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// Ignore storage failures (private mode, blocked cookies, etc.).
|
||||
}
|
||||
}
|
||||
|
||||
function isSupportedLocale(value: string | null): value is LocaleCode {
|
||||
return value !== null && SUPPORTED_LOCALES.some(({ code }) => code === value);
|
||||
}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
|
||||
import { applySavedAccentColor } from '@/appearance/appearance-storage';
|
||||
|
||||
import { App } from './app';
|
||||
import { activateLocale, detectInitialLocale } from './i18n';
|
||||
import '../index.css';
|
||||
|
||||
const root = document.getElementById('root') ?? document.getElementById('app');
|
||||
|
||||
if (!root) {
|
||||
throw new Error('App root not found.');
|
||||
}
|
||||
|
||||
applySavedAccentColor();
|
||||
|
||||
activateLocale(detectInitialLocale()).then(() => {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
+10
-6
@@ -1,7 +1,10 @@
|
||||
import { Icon, type IconName } from '@/components/ui/icon';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EConnectionStatus } from '../state';
|
||||
import { Icon, type IconName } from '@/shared/ui/Icon';
|
||||
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
import { EConnectionStatus } from '@/remote-session/remote-session.reducer';
|
||||
|
||||
type FloatingDockProps = {
|
||||
status: EConnectionStatus;
|
||||
@@ -22,18 +25,19 @@ export const FloatingDock = ({
|
||||
onOpenSettings,
|
||||
onOpenLibrary,
|
||||
}: FloatingDockProps) => {
|
||||
const { _ } = useLingui();
|
||||
const live = status === EConnectionStatus.Connected;
|
||||
|
||||
return (
|
||||
<div className={cn('absolute bottom-4.5 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-white/10 bg-[#0e1016]/80 p-1.5 shadow-[0_12px_40px_-10px_rgba(0,0,0,.65),inset_0_1px_0_rgba(255,255,255,.05)] backdrop-blur-2xl transition duration-300', hidden ? 'translate-y-20 opacity-0' : 'translate-y-0 opacity-100')}>
|
||||
<DockButton badge={leftHasBadge} icon="settings" label="Settings" onClick={onOpenSettings} />
|
||||
<DockButton badge={leftHasBadge} icon="settings" label={_(msg`Settings`)} onClick={onOpenSettings} />
|
||||
<div className="flex h-9.5 items-center gap-2 border-x border-white/10 px-3">
|
||||
<span className={cn('size-1.5 rounded-full', live ? 'bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)] motion-safe:animate-[breathe_2s_ease-in-out_infinite]' : 'bg-(--deck-fg-4)')} />
|
||||
<span className="max-w-30 truncate text-[11px] font-semibold text-(--deck-fg-2)">
|
||||
{runningGameTitle || 'No session'}
|
||||
{runningGameTitle || _(msg`No session`)}
|
||||
</span>
|
||||
</div>
|
||||
<DockButton badge={rightHasBadge} icon="list" label="Library" onClick={onOpenLibrary} />
|
||||
<DockButton badge={rightHasBadge} icon="list" label={_(msg`Library`)} onClick={onOpenLibrary} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { Icon, type IconName } from '@/components/ui/icon';
|
||||
import { Icon, type IconName } from '@/shared/ui/Icon';
|
||||
|
||||
type PlaceholderStateProps = {
|
||||
icon: IconName;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import type { TrainerSummary } from '../../../protocol/messages';
|
||||
import { PlaceholderState } from './PlaceholderState';
|
||||
|
||||
type SessionPlaceholderProps = {
|
||||
connected: boolean;
|
||||
activeTrainer: TrainerSummary | null;
|
||||
onOpenLibrary: () => void;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
export const SessionPlaceholder = ({
|
||||
connected,
|
||||
activeTrainer,
|
||||
onOpenLibrary,
|
||||
onOpenSettings,
|
||||
}: SessionPlaceholderProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<PlaceholderState
|
||||
icon="plug"
|
||||
title={_(msg`Bridge offline`)}
|
||||
sub={_(msg`Open Settings to point Wand at your trainer bridge over WebSocket.`)}
|
||||
action={_(msg`Open Settings`)}
|
||||
onAction={onOpenSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeTrainer) {
|
||||
return (
|
||||
<PlaceholderState
|
||||
icon="gamepad-variant-outline"
|
||||
title={_(msg`Select a game`)}
|
||||
sub={_(msg`No game is running yet. Open the library and launch one to start tweaking.`)}
|
||||
action={_(msg`Browse library`)}
|
||||
onAction={onOpenLibrary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+81
-40
@@ -1,23 +1,28 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
import { activateLocale, type LocaleCode, SUPPORTED_LOCALES } from '@/app/i18n';
|
||||
import { DEFAULT_ACCENT_COLOR, loadAccentColor, setAccentColor } from '@/appearance/appearance-storage';
|
||||
import type { LibraryGame } from '@/library/model/games';
|
||||
import { EConnectionStatus } from '@/remote-session/remote-session.reducer';
|
||||
import { WEB_CONTRACT } from '../../../protocol/contract';
|
||||
import type { TrainerSummary } from '../../../protocol/messages';
|
||||
|
||||
import { DEFAULT_ACCENT_COLOR, loadAccentColor, setAccentColor } from '../accent-storage';
|
||||
import { DEFAULT_REMOTE_PORT } from '../constants';
|
||||
import type { LibraryGame } from '../game-library';
|
||||
import type { TrainerSummary } from '../protocol';
|
||||
import { EConnectionStatus } from '../state';
|
||||
import { StatusPill } from './StatusPill';
|
||||
|
||||
const ACCENT_OPTIONS = [
|
||||
{ value: '#3B82F6', label: 'Cobalt', swatchClass: 'bg-[#3B82F6]' },
|
||||
{ value: DEFAULT_ACCENT_COLOR, label: 'Cyan', swatchClass: 'bg-[#00FFD5]' },
|
||||
{ value: '#FF2E63', label: 'Crimson', swatchClass: 'bg-[#FF2E63]' },
|
||||
{ value: '#A78BFA', label: 'Violet', swatchClass: 'bg-[#A78BFA]' },
|
||||
{ value: '#7CFF5B', label: 'Lime', swatchClass: 'bg-[#7CFF5B]' },
|
||||
{ value: '#FFB12E', label: 'Amber', swatchClass: 'bg-[#FFB12E]' },
|
||||
{ value: '#ee00ff', label: 'Magenta', swatchClass: 'bg-[#ee00ff]' },
|
||||
const ACCENT_OPTIONS: { value: string; label: MessageDescriptor; swatchClass: string }[] = [
|
||||
{ value: '#3B82F6', label: msg`Cobalt`, swatchClass: 'bg-[#3B82F6]' },
|
||||
{ value: DEFAULT_ACCENT_COLOR, label: msg`Cyan`, swatchClass: 'bg-[#00FFD5]' },
|
||||
{ value: '#FF2E63', label: msg`Crimson`, swatchClass: 'bg-[#FF2E63]' },
|
||||
{ value: '#A78BFA', label: msg`Violet`, swatchClass: 'bg-[#A78BFA]' },
|
||||
{ value: '#7CFF5B', label: msg`Lime`, swatchClass: 'bg-[#7CFF5B]' },
|
||||
{ value: '#FFB12E', label: msg`Amber`, swatchClass: 'bg-[#FFB12E]' },
|
||||
{ value: '#ee00ff', label: msg`Magenta`, swatchClass: 'bg-[#ee00ff]' },
|
||||
];
|
||||
|
||||
type SettingsDrawerProps = {
|
||||
@@ -43,14 +48,20 @@ export const SettingsDrawer = ({
|
||||
onDisconnect,
|
||||
onWsUrlChange,
|
||||
}: SettingsDrawerProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="remote-glass-header flex items-center justify-between border-b px-3.5 py-3.5">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-(--deck-fg)">Settings</h2>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">wand remote · port {DEFAULT_REMOTE_PORT}</p>
|
||||
<h2 className="text-lg font-bold text-(--deck-fg)">
|
||||
<Trans>Settings</Trans>
|
||||
</h2>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">
|
||||
<Trans>wand remote · port {WEB_CONTRACT.defaultRemotePort}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" aria-label="Close settings" className="remote-glass-control flex size-8 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
|
||||
<button type="button" aria-label={_(msg`Close settings`)} className="remote-glass-control flex size-8 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
|
||||
<Icon className="size-4" name="x" />
|
||||
</button>
|
||||
</header>
|
||||
@@ -58,10 +69,13 @@ export const SettingsDrawer = ({
|
||||
<BridgeControl status={status} wsUrl={wsUrl} onConnect={onConnect} onDisconnect={onDisconnect} onWsUrlChange={onWsUrlChange} />
|
||||
{lastError ? <ErrorPanel message={lastError} /> : null}
|
||||
|
||||
<SectionHeader title="Session" />
|
||||
<SectionHeader title={_(msg`Session`)} />
|
||||
<SessionPanel currentGame={currentGame} currentTrainer={currentTrainer} />
|
||||
|
||||
<SectionHeader title="Accent Color" />
|
||||
<SectionHeader title={_(msg`Language`)} />
|
||||
<LanguagePicker />
|
||||
|
||||
<SectionHeader title={_(msg`Accent Color`)} />
|
||||
<AccentPicker />
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,20 +91,24 @@ type BridgeControlProps = {
|
||||
};
|
||||
|
||||
const BridgeControl = ({ status, wsUrl, onConnect, onDisconnect, onWsUrlChange }: BridgeControlProps) => {
|
||||
const { _ } = useLingui();
|
||||
const live = status === EConnectionStatus.Connected;
|
||||
const connecting = status === EConnectionStatus.Connecting;
|
||||
const connecting = status === EConnectionStatus.Connecting || status === EConnectionStatus.Reconnecting;
|
||||
const handleInput = (event: FormEvent<HTMLInputElement>) => onWsUrlChange(event.currentTarget.value);
|
||||
const buttonLabel = connecting ? '...' : _(live ? msg`STOP` : msg`GO`);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-mono text-[10px] font-bold uppercase tracking-[0.18em] text-(--deck-fg-4)">Bridge</h3>
|
||||
<h3 className="font-mono text-[10px] font-bold uppercase tracking-[0.18em] text-(--deck-fg-4)">
|
||||
<Trans>Bridge</Trans>
|
||||
</h3>
|
||||
<StatusPill status={status} />
|
||||
</div>
|
||||
<div className="remote-glass-control flex h-10 items-stretch overflow-hidden rounded-[10px] border">
|
||||
<input
|
||||
value={wsUrl}
|
||||
placeholder={`ws://127.0.0.1:${DEFAULT_REMOTE_PORT}/remote/ws`}
|
||||
placeholder={`ws://127.0.0.1:${WEB_CONTRACT.defaultRemotePort}${WEB_CONTRACT.webSocketPath}`}
|
||||
spellCheck={false}
|
||||
className="min-w-0 flex-1 bg-transparent px-3 font-mono text-[12.5px] text-(--deck-fg) outline-none placeholder:text-(--deck-fg-4)"
|
||||
onInput={handleInput}
|
||||
@@ -101,7 +119,7 @@ const BridgeControl = ({ status, wsUrl, onConnect, onDisconnect, onWsUrlChange }
|
||||
className={cn('px-4 text-[11px] font-bold tracking-[0.08em] disabled:cursor-wait disabled:opacity-70', live ? 'bg-red-500/15 text-red-300' : 'bg-(--deck-accent) text-black')}
|
||||
onClick={live ? onDisconnect : onConnect}
|
||||
>
|
||||
{getBridgeButtonLabel(status)}
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -119,7 +137,11 @@ const ErrorPanel = ({ message }: { message: string }) => {
|
||||
|
||||
const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGame | null; currentTrainer: TrainerSummary | null }) => {
|
||||
if (!currentGame) {
|
||||
return <div className="remote-glass-control rounded-[10px] border p-3 text-[12px] text-(--deck-fg-3)">No active game session.</div>;
|
||||
return (
|
||||
<div className="remote-glass-control rounded-[10px] border p-3 text-[12px] text-(--deck-fg-3)">
|
||||
<Trans>No active game session.</Trans>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const subtitleBase = currentTrainer?.displayName ?? currentGame.platform;
|
||||
@@ -130,7 +152,9 @@ const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGam
|
||||
<div className="remote-glass-control rounded-[10px] border p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="size-1.5 rounded-full bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)]" />
|
||||
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.12em] text-(--deck-accent)">Active Session</span>
|
||||
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.12em] text-(--deck-accent)">
|
||||
<Trans>Active Session</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="truncate text-sm font-semibold text-(--deck-fg)">{currentGame.title}</h3>
|
||||
<p className="mt-0.5 truncate font-mono text-[11px] text-(--deck-fg-3)">
|
||||
@@ -140,7 +164,34 @@ const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGam
|
||||
);
|
||||
};
|
||||
|
||||
const LanguagePicker = () => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const handleSelect = (locale: LocaleCode) => {
|
||||
void activateLocale(locale);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{SUPPORTED_LOCALES.map(({ code, label }) => {
|
||||
const active = i18n.locale === code;
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
className={cn('remote-glass-control flex items-center justify-center rounded-[9px] border px-2 py-2 text-[12px] font-medium', active ? 'border-(--deck-accent) text-(--deck-fg)' : 'text-(--deck-fg-3)')}
|
||||
onClick={() => handleSelect(code)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccentPicker = () => {
|
||||
const { _ } = useLingui();
|
||||
const [current, setCurrent] = useState(loadAccentColor);
|
||||
|
||||
const applyAccent = (value: string) => {
|
||||
@@ -155,13 +206,15 @@ const AccentPicker = () => {
|
||||
return (
|
||||
<button key={option.value} type="button" className={cn('remote-glass-control flex items-center gap-1.5 rounded-[9px] border px-2 py-2 text-[12px] font-medium', active ? 'border-(--deck-accent) text-(--deck-fg)' : 'text-(--deck-fg-3)')} onClick={() => applyAccent(option.value)}>
|
||||
<span className={cn('size-3.5 shrink-0 rounded-lg border border-white/10', option.swatchClass)} />
|
||||
{option.label}
|
||||
{_(option.label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="remote-glass-control flex h-9.5 items-center gap-2 rounded-[9px] border px-2.5">
|
||||
<span className="flex-1 font-mono text-[11px] font-semibold uppercase tracking-[0.08em] text-(--deck-fg-3)">Custom</span>
|
||||
<span className="flex-1 font-mono text-[11px] font-semibold uppercase tracking-[0.08em] text-(--deck-fg-3)">
|
||||
<Trans>Custom</Trans>
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-(--deck-fg-4)">{current}</span>
|
||||
<input type="color" value={current} className="size-5 rounded border-0 bg-transparent p-0" onChange={(event) => applyAccent(event.currentTarget.value)} />
|
||||
</label>
|
||||
@@ -177,15 +230,3 @@ const SectionHeader = ({ title }: { title: string }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getBridgeButtonLabel(status: EConnectionStatus): string {
|
||||
if (status === EConnectionStatus.Connected) {
|
||||
return 'STOP';
|
||||
}
|
||||
|
||||
if (status === EConnectionStatus.Connecting) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
return 'GO';
|
||||
}
|
||||
Vendored
+19
-10
@@ -1,28 +1,37 @@
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EConnectionStatus } from '../state';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
const STATUS_LABELS: Record<EConnectionStatus, string> = {
|
||||
[EConnectionStatus.Connected]: 'LIVE',
|
||||
[EConnectionStatus.Connecting]: 'LINKING',
|
||||
[EConnectionStatus.Error]: 'OFFLINE',
|
||||
[EConnectionStatus.Idle]: 'OFFLINE',
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
import { EConnectionStatus } from '@/remote-session/remote-session.reducer';
|
||||
|
||||
const STATUS_LABELS: Record<EConnectionStatus, MessageDescriptor> = {
|
||||
[EConnectionStatus.Connected]: msg`LIVE`,
|
||||
[EConnectionStatus.Connecting]: msg`LINKING`,
|
||||
[EConnectionStatus.Reconnecting]: msg`LINKING`,
|
||||
[EConnectionStatus.Error]: msg`OFFLINE`,
|
||||
[EConnectionStatus.Idle]: msg`OFFLINE`,
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<EConnectionStatus, string> = {
|
||||
[EConnectionStatus.Connected]: 'border-[color-mix(in_oklab,var(--deck-accent)_30%,transparent)] text-(--deck-accent)',
|
||||
[EConnectionStatus.Connecting]: 'border-amber-300/30 text-amber-300',
|
||||
[EConnectionStatus.Reconnecting]: 'border-amber-300/30 text-amber-300',
|
||||
[EConnectionStatus.Error]: 'border-white/10 text-(--deck-fg-4)',
|
||||
[EConnectionStatus.Idle]: 'border-white/10 text-(--deck-fg-4)',
|
||||
};
|
||||
|
||||
export const StatusPill = ({ status }: { status: EConnectionStatus }) => {
|
||||
const live = status === EConnectionStatus.Connected || status === EConnectionStatus.Connecting;
|
||||
const { _ } = useLingui();
|
||||
const live = status === EConnectionStatus.Connected
|
||||
|| status === EConnectionStatus.Connecting
|
||||
|| status === EConnectionStatus.Reconnecting;
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-1.5 rounded-full border bg-white/[0.04] px-2.5 py-1 font-mono text-[9.5px] font-bold tracking-[0.12em] backdrop-blur-md', STATUS_CLASSES[status])}>
|
||||
{live ? <span className="size-1.5 rounded-full bg-current shadow-[0_0_6px_currentColor] motion-safe:animate-[breathe_1.6s_ease-in-out_infinite]" /> : null}
|
||||
{STATUS_LABELS[status]}
|
||||
{_(STATUS_LABELS[status])}
|
||||
{status === EConnectionStatus.Error ? <Icon className="size-3" name="alert" /> : null}
|
||||
</div>
|
||||
);
|
||||
Vendored
+12
-6
@@ -1,8 +1,12 @@
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import type { LibraryGame } from '../game-library';
|
||||
import type { TrainerSummary } from '../protocol';
|
||||
import type { EConnectionStatus } from '../state';
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
|
||||
import type { LibraryGame } from '@/library/model/games';
|
||||
import type { TrainerSummary } from '../../../protocol/messages';
|
||||
import type { EConnectionStatus } from '@/remote-session/remote-session.reducer';
|
||||
import { StatusPill } from './StatusPill';
|
||||
|
||||
type TopBarProps = {
|
||||
@@ -13,17 +17,19 @@ type TopBarProps = {
|
||||
};
|
||||
|
||||
export const TopBar = ({ status, currentGame, runningTrainer, onOpenSettings }: TopBarProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<header className="remote-glass-header sticky top-0 z-20 border-b px-3.5 pb-2.5 pt-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button type="button" aria-label="Settings" className="remote-glass-control flex size-[34px] shrink-0 items-center justify-center rounded-[9px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onOpenSettings}>
|
||||
<button type="button" aria-label={_(msg`Settings`)} className="remote-glass-control flex size-[34px] shrink-0 items-center justify-center rounded-[9px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onOpenSettings}>
|
||||
<Icon className="size-[18px]" name="menu" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-mono text-[9.5px] font-bold tracking-[0.16em] text-(--deck-fg-4)">WAND · REMOTE DECK</div>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-(--deck-fg)">
|
||||
{currentGame ? currentGame.title : 'Idle · no game'}
|
||||
{currentGame ? currentGame.title : <Trans>Idle · no game</Trans>}
|
||||
</span>
|
||||
{currentGame && runningTrainer?.gameVersion ? (
|
||||
<span className="shrink-0 rounded-[4px] bg-[color-mix(in_oklab,var(--deck-accent)_12%,transparent)] px-1.5 py-0.5 font-mono text-[9.5px] font-bold tracking-[0.06em] text-(--deck-accent)">
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
|
||||
import type { TrainerSummary } from '../../../protocol/messages';
|
||||
import { TrainerHeader } from '../../trainer/ui/TrainerHeader';
|
||||
import { SessionPlaceholder } from './SessionPlaceholder';
|
||||
|
||||
i18n.load('en', {});
|
||||
i18n.activate('en');
|
||||
|
||||
const renderWithI18n = (ui: ReactNode) => render(<I18nProvider i18n={i18n}>{ui}</I18nProvider>);
|
||||
|
||||
const trainer: TrainerSummary = {
|
||||
trainerId: 'trainer',
|
||||
gameId: 'game',
|
||||
displayName: 'Test Trainer',
|
||||
trainerLoading: false,
|
||||
gameInstalled: true,
|
||||
needsCompatibilityWarning: false,
|
||||
isTimeLimitExpired: false,
|
||||
};
|
||||
|
||||
describe('session state components', () => {
|
||||
it('renders the offline intent', () => {
|
||||
const openSettings = vi.fn();
|
||||
renderWithI18n(<SessionPlaceholder connected={false} activeTrainer={null} onOpenLibrary={() => undefined} onOpenSettings={openSettings} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Settings' }));
|
||||
expect(screen.getByText('Bridge offline')).toBeTruthy();
|
||||
expect(openSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders the no-trainer intent', () => {
|
||||
renderWithI18n(<SessionPlaceholder connected activeTrainer={null} onOpenLibrary={() => undefined} onOpenSettings={() => undefined} />);
|
||||
expect(screen.getByText('Select a game')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders an active trainer header', () => {
|
||||
renderWithI18n(<TrainerHeader trainer={trainer} game={null} isPinned={false} onPin={() => undefined} />);
|
||||
expect(screen.getByText('Test Trainer')).toBeTruthy();
|
||||
expect(screen.getByText('Trainer Active')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { useCallback, useRef, useState, type UIEvent } from 'react';
|
||||
|
||||
const SCROLL_HIDE_THRESHOLD_PX = 60;
|
||||
const SCROLL_REVEAL_DEAD_ZONE_PX = 4;
|
||||
|
||||
export function useDockAutoHide() {
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const lastScrollRef = useRef(0);
|
||||
|
||||
const onScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||
const y = event.currentTarget.scrollTop;
|
||||
if (y > lastScrollRef.current && y > SCROLL_HIDE_THRESHOLD_PX) {
|
||||
setHidden(true);
|
||||
} else if (y < lastScrollRef.current - SCROLL_REVEAL_DEAD_ZONE_PX) {
|
||||
setHidden(false);
|
||||
}
|
||||
|
||||
lastScrollRef.current = y;
|
||||
}, []);
|
||||
|
||||
return { hidden, onScroll };
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { ECheatType } from '../../protocol/messages';
|
||||
import { buildLibraryGames, getCurrentGame, type LibraryGame } from '../library/model/games';
|
||||
import { useGamePins } from '../library/pinned-games/use-game-pins';
|
||||
import { useRemoteSession } from '../remote-session/use-remote-session';
|
||||
import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from '../trainer/model/categories';
|
||||
import { getPinnedStorageKey } from '../trainer/pinned-cheats/pinned-cheat-storage';
|
||||
import { usePinnedCheats } from '../trainer/pinned-cheats/use-pinned-cheats';
|
||||
import { getPresetStorageKey, type RemotePreset } from '../trainer/presets/preset-storage';
|
||||
import { usePresets } from '../trainer/presets/use-presets';
|
||||
import { useDockAutoHide } from './use-dock-auto-hide';
|
||||
|
||||
export function useRemotePanel() {
|
||||
const session = useRemoteSession();
|
||||
const [cheatQuery, setCheatQuery] = useState('');
|
||||
const [gameQuery, setGameQuery] = useState('');
|
||||
const [leftOpen, setLeftOpen] = useState(false);
|
||||
const [rightOpen, setRightOpen] = useState(false);
|
||||
const dock = useDockAutoHide();
|
||||
|
||||
const activeTrainer = session.state.trainerMeta?.trainer ?? null;
|
||||
const { pinnedGameIds, togglePin: toggleGamePin } = useGamePins();
|
||||
const libraryGames = useMemo(
|
||||
() => buildLibraryGames(session.state.installedApps, session.state.gameStatus, activeTrainer, pinnedGameIds),
|
||||
[activeTrainer, pinnedGameIds, session.state.gameStatus, session.state.installedApps],
|
||||
);
|
||||
const currentGame = useMemo(() => getCurrentGame(libraryGames), [libraryGames]);
|
||||
|
||||
const pinnedStorageKey = useMemo(() => getPinnedStorageKey(activeTrainer), [activeTrainer]);
|
||||
const { pinnedTargets, toggle: togglePinnedCheat } = usePinnedCheats({ pinnedStorageKey });
|
||||
const groups = useMemo(() => groupCheatsByCategory(session.state.trainerMeta), [session.state.trainerMeta]);
|
||||
const pinnedGroup = useMemo(
|
||||
() => buildPinnedGroup(session.state.trainerMeta, pinnedTargets),
|
||||
[pinnedTargets, session.state.trainerMeta],
|
||||
);
|
||||
const filteredGroups = useMemo(() => filterGroups(groups, cheatQuery), [cheatQuery, groups]);
|
||||
const filteredPinnedGroup = useMemo(
|
||||
() => (pinnedGroup ? filterGroups([pinnedGroup], cheatQuery)[0] ?? null : null),
|
||||
[cheatQuery, pinnedGroup],
|
||||
);
|
||||
|
||||
const presetStorageKey = useMemo(() => getPresetStorageKey(activeTrainer), [activeTrainer]);
|
||||
const presets = usePresets({
|
||||
presetStorageKey,
|
||||
trainerMeta: session.state.trainerMeta,
|
||||
values: session.state.values,
|
||||
onError: session.reportError,
|
||||
});
|
||||
|
||||
const panic = useCallback(() => {
|
||||
const trainerMeta = session.state.trainerMeta;
|
||||
if (!trainerMeta) return;
|
||||
for (const cheat of trainerMeta.schema.cheats) {
|
||||
if (cheat.type === ECheatType.Toggle && Boolean(session.state.values[cheat.target])) {
|
||||
session.changeCheat(cheat, false);
|
||||
}
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const applyPreset = useCallback((preset: RemotePreset) => {
|
||||
const trainerMeta = session.state.trainerMeta;
|
||||
if (!trainerMeta) return;
|
||||
for (const cheat of trainerMeta.schema.cheats) {
|
||||
if (cheat.target in preset.values) {
|
||||
session.changeCheat(cheat, preset.values[cheat.target]);
|
||||
}
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const playGame = useCallback((game: LibraryGame) => {
|
||||
if (session.launchGame(game.app)) {
|
||||
setRightOpen(false);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const totalVisibleCheats = filteredGroups.reduce(
|
||||
(count, group) => count + group.cheats.length,
|
||||
filteredPinnedGroup?.cheats.length ?? 0,
|
||||
);
|
||||
|
||||
return {
|
||||
session: {
|
||||
status: session.state.connectionStatus,
|
||||
wsUrl: session.state.wsUrl,
|
||||
lastError: session.state.lastError,
|
||||
values: session.state.values,
|
||||
pendingTargets: session.pendingTargets,
|
||||
connected: session.connected,
|
||||
socketReady: session.socketReady,
|
||||
connect: session.connect,
|
||||
disconnect: session.disconnect,
|
||||
setWsUrl: session.setWsUrl,
|
||||
},
|
||||
trainer: {
|
||||
activeTrainer,
|
||||
query: cheatQuery,
|
||||
setQuery: setCheatQuery,
|
||||
filteredGroups,
|
||||
filteredPinnedGroup,
|
||||
pinnedTargets,
|
||||
controlsDisabled: Boolean(activeTrainer?.trainerLoading || activeTrainer?.isTimeLimitExpired),
|
||||
totalVisibleCheats,
|
||||
totalCheats: session.state.trainerMeta?.schema.cheats.length ?? 0,
|
||||
changeCheat: session.changeCheat,
|
||||
togglePin: togglePinnedCheat,
|
||||
panic,
|
||||
presets: presets.presets,
|
||||
addPreset: presets.addPreset,
|
||||
applyPreset,
|
||||
deletePreset: presets.deletePreset,
|
||||
},
|
||||
library: {
|
||||
games: libraryGames,
|
||||
currentGame,
|
||||
pinnedGameIds,
|
||||
query: gameQuery,
|
||||
setQuery: setGameQuery,
|
||||
togglePin: toggleGamePin,
|
||||
playGame,
|
||||
stopPlaying: session.stopPlaying,
|
||||
},
|
||||
shell: {
|
||||
leftOpen,
|
||||
rightOpen,
|
||||
openSettings: () => setLeftOpen(true),
|
||||
closeSettings: () => setLeftOpen(false),
|
||||
openLibrary: () => setRightOpen(true),
|
||||
closeLibrary: () => setRightOpen(false),
|
||||
dockHidden: dock.hidden,
|
||||
onScroll: dock.onScroll,
|
||||
},
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { loadJson, saveJson } from './storage';
|
||||
import { loadJson, saveJson } from '../shared/storage';
|
||||
|
||||
export const DEFAULT_ACCENT_COLOR = '#00ffd5';
|
||||
|
||||
@@ -39,4 +39,4 @@ function normalizeAccentColor(value: unknown): string | null {
|
||||
|
||||
const normalizedValue = value.trim().toLowerCase();
|
||||
return HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : null;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export const DEFAULT_REMOTE_PORT = 3223;
|
||||
export const REMOTE_BASE_PATH = '/remote/';
|
||||
export const REMOTE_WS_PATH = '/remote/ws';
|
||||
export const CLIENT_VERSION = '0.2.0';
|
||||
export const WS_QUERY_PARAM = 'ws';
|
||||
|
||||
const DEV_SERVER_PORTS = new Set(['4173', '5173']);
|
||||
|
||||
function protocolForWebSocket(): 'ws' | 'wss' {
|
||||
return window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
}
|
||||
|
||||
function isServedByRemoteBridge(): boolean {
|
||||
return window.location.pathname.startsWith(REMOTE_BASE_PATH) && !DEV_SERVER_PORTS.has(window.location.port);
|
||||
}
|
||||
|
||||
export function readInitialRemoteUrl(): string {
|
||||
if (isServedByRemoteBridge()) {
|
||||
return `${window.location.protocol}//${window.location.host}${REMOTE_BASE_PATH}`;
|
||||
}
|
||||
|
||||
return `http://127.0.0.1:${DEFAULT_REMOTE_PORT}${REMOTE_BASE_PATH}`;
|
||||
}
|
||||
|
||||
export function readInitialWebSocketUrl(): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const explicitUrl = params.get(WS_QUERY_PARAM)?.trim();
|
||||
if (explicitUrl) {
|
||||
return explicitUrl;
|
||||
}
|
||||
|
||||
if (isServedByRemoteBridge()) {
|
||||
return `${protocolForWebSocket()}://${window.location.host}${REMOTE_WS_PATH}`;
|
||||
}
|
||||
|
||||
return `ws://127.0.0.1:${DEFAULT_REMOTE_PORT}${REMOTE_WS_PATH}`;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { IncomingMessage, TrainerMetaPayload } from './protocol';
|
||||
import { normalizeIncomingValue } from './protocol';
|
||||
import type { PanelAction } from './state';
|
||||
|
||||
type Dispatch = (action: PanelAction) => void;
|
||||
|
||||
export function handleProtocolMessage(dispatch: Dispatch, message: IncomingMessage, trainerMeta: TrainerMetaPayload | null): void {
|
||||
switch (message.type) {
|
||||
case 'hello_ack':
|
||||
handleHelloAck(dispatch, message.payload.accepted, message.payload.remoteUrl);
|
||||
return;
|
||||
case 'trainer_meta':
|
||||
dispatch({ type: 'trainerMeta', payload: message.payload });
|
||||
return;
|
||||
case 'game_status':
|
||||
dispatch({ type: 'gameStatus', payload: message.payload });
|
||||
return;
|
||||
case 'installed_apps':
|
||||
dispatch({ type: 'installedApps', payload: message.payload });
|
||||
return;
|
||||
case 'trainer_values':
|
||||
dispatch({ type: 'trainerValues', payload: message.payload.values });
|
||||
return;
|
||||
case 'value_changed':
|
||||
handleValueChanged(dispatch, message, trainerMeta);
|
||||
return;
|
||||
case 'trainer_changed':
|
||||
dispatch({ type: 'trainerChanged' });
|
||||
return;
|
||||
case 'set_value_result':
|
||||
if (!message.payload.ok) {
|
||||
dispatch({ type: 'error', message: message.payload.error?.message ?? 'The trainer rejected the requested value.' });
|
||||
}
|
||||
return;
|
||||
case 'remote_command_result':
|
||||
if (!message.payload.ok) {
|
||||
dispatch({ type: 'error', message: message.payload.error?.message ?? 'The remote game command was rejected.' });
|
||||
}
|
||||
return;
|
||||
case 'error':
|
||||
dispatch({ type: 'error', message: message.payload.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function handleHelloAck(dispatch: Dispatch, accepted: boolean, remoteUrl?: string): void {
|
||||
if (!accepted) {
|
||||
dispatch({ type: 'error', message: 'The desktop bridge rejected the connection.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (remoteUrl) {
|
||||
dispatch({ type: 'setRemoteUrl', remoteUrl });
|
||||
}
|
||||
}
|
||||
|
||||
function handleValueChanged(dispatch: Dispatch, message: Extract<IncomingMessage, { type: 'value_changed' }>, trainerMeta: TrainerMetaPayload | null): void {
|
||||
const cheat = trainerMeta?.schema.cheats.find((item) => item.target === message.payload.target || item.uuid === message.payload.cheatId);
|
||||
const nextValue = cheat ? normalizeIncomingValue(cheat, message.payload.value) : message.payload.value;
|
||||
dispatch({ type: 'valueChanged', target: message.payload.target, value: nextValue });
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { CLIENT_VERSION } from './constants';
|
||||
import {
|
||||
type HelloMessage,
|
||||
type IncomingMessage,
|
||||
type OutgoingMessage,
|
||||
PROTOCOL_VERSION,
|
||||
type RemoteCommandMessage,
|
||||
type SetValueMessage,
|
||||
isIncomingMessage,
|
||||
} from './protocol';
|
||||
|
||||
type SocketHandlers = {
|
||||
onConnecting: () => void;
|
||||
onOpen: () => void;
|
||||
onMessage: (message: IncomingMessage) => void;
|
||||
onClose: () => void;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
export class PanelSocketClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private intentionalDisconnect = false;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly handlers: SocketHandlers,
|
||||
) { }
|
||||
|
||||
connect(pairingToken?: string): void {
|
||||
this.disconnect();
|
||||
this.intentionalDisconnect = false;
|
||||
this.handlers.onConnecting();
|
||||
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.handlers.onOpen();
|
||||
this.send(this.createHelloMessage(pairingToken));
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => this.handleMessage(event));
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.socket === socket) {
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
if (!this.intentionalDisconnect) {
|
||||
this.handlers.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
this.handlers.onError('WebSocket connection failed.');
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.intentionalDisconnect = true;
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return Boolean(this.socket && this.socket.readyState === WebSocket.OPEN);
|
||||
}
|
||||
|
||||
send(message: OutgoingMessage): boolean {
|
||||
const socket = this.socket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
setValue(trainerId: string, target: string, value: unknown, cheatId?: string): boolean {
|
||||
const message: SetValueMessage = {
|
||||
type: 'set_value',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: `set_${target}_${Date.now()}`,
|
||||
payload: {
|
||||
trainerId,
|
||||
target,
|
||||
value,
|
||||
cheatId,
|
||||
},
|
||||
};
|
||||
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
launchGame(gameId: string, titleId?: string): boolean {
|
||||
const message: RemoteCommandMessage = {
|
||||
type: 'remote_command',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: `command_launch_${Date.now()}`,
|
||||
payload: {
|
||||
action: 'launch',
|
||||
gameId,
|
||||
titleId,
|
||||
},
|
||||
};
|
||||
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
stopPlaying(gameId?: string, titleId?: string): boolean {
|
||||
const message: RemoteCommandMessage = {
|
||||
type: 'remote_command',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: `command_stop_${Date.now()}`,
|
||||
payload: {
|
||||
action: 'stop',
|
||||
gameId,
|
||||
titleId,
|
||||
},
|
||||
};
|
||||
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
private createHelloMessage(pairingToken?: string): HelloMessage {
|
||||
return {
|
||||
type: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: `hello_${Date.now()}`,
|
||||
payload: {
|
||||
client: 'mobile-web',
|
||||
clientVersion: CLIENT_VERSION,
|
||||
pairingToken,
|
||||
capabilities: {
|
||||
supportsDeltaValues: true,
|
||||
supportsTrainerSwitch: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const parsed = JSON.parse(String(event.data)) as unknown;
|
||||
if (!isIncomingMessage(parsed)) {
|
||||
this.handlers.onError('Received an invalid protocol message.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlers.onMessage(parsed);
|
||||
} catch (error) {
|
||||
this.handlers.onError(error instanceof Error ? error.message : 'Failed to parse websocket message.');
|
||||
}
|
||||
}
|
||||
}
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
import { readInitialRemoteUrl, readInitialWebSocketUrl } from './constants';
|
||||
import type { GameStatusPayload, InstalledAppSummary, InstalledAppsPayload, TrainerMetaPayload } from './protocol';
|
||||
|
||||
export enum EConnectionStatus {
|
||||
Idle = 'idle',
|
||||
Connecting = 'connecting',
|
||||
Connected = 'connected',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export type PanelState = {
|
||||
connectionStatus: EConnectionStatus;
|
||||
wsUrl: string;
|
||||
remoteUrl: string;
|
||||
trainerMeta: TrainerMetaPayload | null;
|
||||
gameStatus: GameStatusPayload | null;
|
||||
installedApps: InstalledAppSummary[];
|
||||
installedAppsUpdatedAt: string | null;
|
||||
values: Record<string, unknown>;
|
||||
pendingTargets: Record<string, boolean>;
|
||||
pinnedTargets: Record<string, true>;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export type PanelAction =
|
||||
| { type: 'setWsUrl'; wsUrl: string }
|
||||
| { type: 'setRemoteUrl'; remoteUrl: string }
|
||||
| { type: 'connecting' }
|
||||
| { type: 'connected' }
|
||||
| { type: 'disconnected' }
|
||||
| { type: 'trainerMeta'; payload: TrainerMetaPayload }
|
||||
| { type: 'gameStatus'; payload: GameStatusPayload }
|
||||
| { type: 'installedApps'; payload: InstalledAppsPayload }
|
||||
| { type: 'trainerValues'; payload: Record<string, unknown> }
|
||||
| { type: 'valueChanged'; target: string; value: unknown }
|
||||
| { type: 'setPending'; target: string; pending: boolean }
|
||||
| { type: 'trainerChanged' }
|
||||
| { type: 'setPinnedTargets'; pinned: Record<string, true> }
|
||||
| { type: 'togglePinnedTarget'; target: string }
|
||||
| { type: 'error'; message: string | null };
|
||||
|
||||
export function createInitialPanelState(): PanelState {
|
||||
return {
|
||||
connectionStatus: EConnectionStatus.Idle,
|
||||
wsUrl: readInitialWebSocketUrl(),
|
||||
remoteUrl: readInitialRemoteUrl(),
|
||||
trainerMeta: null,
|
||||
gameStatus: null,
|
||||
installedApps: [],
|
||||
installedAppsUpdatedAt: null,
|
||||
values: {},
|
||||
pendingTargets: {},
|
||||
pinnedTargets: {},
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function panelReducer(state: PanelState, action: PanelAction): PanelState {
|
||||
switch (action.type) {
|
||||
case 'setWsUrl':
|
||||
return {
|
||||
...state,
|
||||
wsUrl: action.wsUrl,
|
||||
};
|
||||
case 'setRemoteUrl':
|
||||
return {
|
||||
...state,
|
||||
remoteUrl: action.remoteUrl,
|
||||
};
|
||||
case 'connecting':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: EConnectionStatus.Connecting,
|
||||
lastError: null,
|
||||
};
|
||||
case 'connected':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: EConnectionStatus.Connected,
|
||||
lastError: null,
|
||||
};
|
||||
case 'disconnected':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: EConnectionStatus.Idle,
|
||||
trainerMeta: null,
|
||||
gameStatus: null,
|
||||
values: {},
|
||||
pendingTargets: {},
|
||||
lastError: null,
|
||||
};
|
||||
case 'trainerMeta':
|
||||
return {
|
||||
...state,
|
||||
trainerMeta: action.payload,
|
||||
pendingTargets: {},
|
||||
};
|
||||
case 'gameStatus':
|
||||
return {
|
||||
...state,
|
||||
gameStatus: action.payload,
|
||||
};
|
||||
case 'installedApps':
|
||||
return {
|
||||
...state,
|
||||
installedApps: action.payload.apps,
|
||||
installedAppsUpdatedAt: action.payload.updatedAt,
|
||||
};
|
||||
case 'trainerValues':
|
||||
return {
|
||||
...state,
|
||||
values: action.payload,
|
||||
};
|
||||
case 'valueChanged':
|
||||
return {
|
||||
...state,
|
||||
values: {
|
||||
...state.values,
|
||||
[action.target]: action.value,
|
||||
},
|
||||
pendingTargets: {
|
||||
...state.pendingTargets,
|
||||
[action.target]: false,
|
||||
},
|
||||
};
|
||||
case 'setPending':
|
||||
return {
|
||||
...state,
|
||||
pendingTargets: {
|
||||
...state.pendingTargets,
|
||||
[action.target]: action.pending,
|
||||
},
|
||||
};
|
||||
case 'trainerChanged':
|
||||
return {
|
||||
...state,
|
||||
trainerMeta: null,
|
||||
values: {},
|
||||
pendingTargets: {},
|
||||
pinnedTargets: {},
|
||||
};
|
||||
case 'setPinnedTargets':
|
||||
return {
|
||||
...state,
|
||||
pinnedTargets: action.pinned,
|
||||
};
|
||||
case 'togglePinnedTarget': {
|
||||
const next = { ...state.pinnedTargets };
|
||||
if (next[action.target]) {
|
||||
delete next[action.target];
|
||||
} else {
|
||||
next[action.target] = true;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
pinnedTargets: next,
|
||||
};
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: action.message && state.connectionStatus !== EConnectionStatus.Connected ? EConnectionStatus.Error : state.connectionStatus,
|
||||
lastError: action.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { InstalledAppSummary } from '../../../protocol/messages';
|
||||
import { buildLibraryGames, filterLibraryGames, getCurrentGame } from './games';
|
||||
import { togglePinnedGame } from '../pinned-games/game-pin-storage';
|
||||
|
||||
const apps: InstalledAppSummary[] = [
|
||||
{
|
||||
platform: 'steam',
|
||||
sku: 'one',
|
||||
correlationId: 'steam:one',
|
||||
displayName: 'Alpha Game',
|
||||
gameId: 'game-one',
|
||||
location: 'C:\\Games\\Alpha',
|
||||
alternateLocations: [],
|
||||
},
|
||||
{
|
||||
platform: 'epic',
|
||||
sku: 'two',
|
||||
correlationId: 'epic:two',
|
||||
displayName: 'Beta Game',
|
||||
gameId: 'game-two',
|
||||
location: 'C:\\Games\\Beta',
|
||||
alternateLocations: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe('library models', () => {
|
||||
it('projects running and pinned games and filters them', () => {
|
||||
const games = buildLibraryGames(apps, {
|
||||
instanceId: 'status',
|
||||
updatedAt: 'now',
|
||||
session: { state: 'running', event: 'snapshot', gameId: 'game-two' },
|
||||
trainer: { state: 'idle', event: 'snapshot' },
|
||||
}, null, { 'game-one': true });
|
||||
|
||||
expect(getCurrentGame(games)?.id).toBe('game-two');
|
||||
expect(games.find((game) => game.id === 'game-one')?.pinned).toBe(true);
|
||||
expect(filterLibraryGames(games, 'alpha').map((game) => game.id)).toEqual(['game-one']);
|
||||
});
|
||||
|
||||
it('toggles pins without mutating the current set', () => {
|
||||
const game = buildLibraryGames([apps[0]], null, null, {})[0];
|
||||
const current = {};
|
||||
const next = togglePinnedGame(game, current);
|
||||
expect(next).toEqual({ 'game-one': true });
|
||||
expect(current).toEqual({});
|
||||
expect(togglePinnedGame(game, next)).toEqual({});
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { formatHumanLabel } from '@/lib/utils';
|
||||
import type { GameStatusPayload, InstalledAppSummary, TrainerSummary } from './protocol';
|
||||
import { formatHumanLabel } from '@/shared/lib/ui';
|
||||
import type { GameStatusPayload, InstalledAppSummary, TrainerSummary } from '../../../protocol/messages';
|
||||
|
||||
export type LibraryGame = {
|
||||
id: string;
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { loadStringSet, saveStringSet } from './storage';
|
||||
import type { LibraryGame } from './game-library';
|
||||
import { loadStringSet, saveStringSet } from '../../shared/storage';
|
||||
import type { LibraryGame } from '../model/games';
|
||||
|
||||
const STORAGE_KEY = 'wand-remote.pinned-games.v1';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { LibraryGame } from '../model/games';
|
||||
import { loadPinnedGameIds, savePinnedGameIds, togglePinnedGame } from './game-pin-storage';
|
||||
|
||||
export function useGamePins() {
|
||||
const [pinnedGameIds, setPinnedGameIds] = useState<Record<string, true>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedGameIds(loadPinnedGameIds());
|
||||
}, []);
|
||||
|
||||
const togglePin = useCallback((game: LibraryGame) => {
|
||||
setPinnedGameIds((current) => {
|
||||
const next = togglePinnedGame(game, current);
|
||||
savePinnedGameIds(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { pinnedGameIds, togglePin };
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { getGameCoverLabel, type LibraryGame } from '../game-library';
|
||||
import { getGameCoverLabel, type LibraryGame } from '../model/games';
|
||||
|
||||
type GameCoverProps = {
|
||||
game: LibraryGame;
|
||||
+27
-15
@@ -1,11 +1,15 @@
|
||||
import { memo, useMemo, type ReactNode } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Icon, type IconName } from '@/components/ui/icon';
|
||||
import { Icon, type IconName } from '@/shared/ui/Icon';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../game-library';
|
||||
import { SearchInput } from '@/shared/ui/SearchInput';
|
||||
|
||||
import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../model/games';
|
||||
import { GameCover } from './GameCover';
|
||||
import { SearchInput } from './SearchInput';
|
||||
|
||||
type LibraryDrawerProps = {
|
||||
games: LibraryGame[];
|
||||
@@ -19,6 +23,7 @@ type LibraryDrawerProps = {
|
||||
};
|
||||
|
||||
const LibraryDrawerBase = ({ games, query, canLaunch, onClose, onPin, onPlay, onStop, onQueryChange }: LibraryDrawerProps) => {
|
||||
const { _ } = useLingui();
|
||||
const filteredGames = useMemo(() => filterLibraryGames(games, query), [games, query]);
|
||||
const sections = useMemo(() => getLibrarySections(filteredGames), [filteredGames]);
|
||||
|
||||
@@ -26,34 +31,40 @@ const LibraryDrawerBase = ({ games, query, canLaunch, onClose, onPin, onPlay, on
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="remote-glass-header flex items-center gap-2.5 border-b px-3.5 py-3.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-bold text-(--deck-fg)">Library</h2>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">{games.length} games detected</p>
|
||||
<h2 className="text-lg font-bold text-(--deck-fg)">
|
||||
<Trans>Library</Trans>
|
||||
</h2>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">
|
||||
<Plural value={games.length} one="# game detected" other="# games detected" />
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" aria-label="Close library" className="remote-glass-control flex size-8 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
|
||||
<button type="button" aria-label={_(msg`Close library`)} className="remote-glass-control flex size-8 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
|
||||
<Icon className="size-4" name="x" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="border-b border-white/6 px-3.5 py-2.5">
|
||||
<SearchInput value={query} placeholder="Search games" onChange={onQueryChange} />
|
||||
<SearchInput value={query} placeholder={_(msg`Search games`)} onChange={onQueryChange} />
|
||||
</div>
|
||||
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain pb-6">
|
||||
{sections.running ? (
|
||||
<GameSection accent count={1} icon="dot" title="Now Playing">
|
||||
<GameSection accent count={1} icon="dot" title={_(msg`Now Playing`)}>
|
||||
<GameRow game={sections.running} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />
|
||||
</GameSection>
|
||||
) : null}
|
||||
{sections.pinned.length > 0 ? (
|
||||
<GameSection count={sections.pinned.length} icon="star-filled" title="Favorites">
|
||||
<GameSection count={sections.pinned.length} icon="star-filled" title={_(msg`Favorites`)}>
|
||||
{sections.pinned.map((game) => <GameRow key={game.id} game={game} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />)}
|
||||
</GameSection>
|
||||
) : null}
|
||||
{sections.rest.length > 0 ? (
|
||||
<GameSection count={sections.rest.length} title="All Games">
|
||||
<GameSection count={sections.rest.length} title={_(msg`All Games`)}>
|
||||
{sections.rest.map((game) => <GameRow key={game.id} game={game} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />)}
|
||||
</GameSection>
|
||||
) : null}
|
||||
{filteredGames.length === 0 ? (
|
||||
<p className="px-8 py-10 text-center text-[13px] text-(--deck-fg-4)">No games match "{query}"</p>
|
||||
<p className="px-8 py-10 text-center text-[13px] text-(--deck-fg-4)">
|
||||
<Trans>No games match "{query}"</Trans>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,6 +105,7 @@ type GameRowProps = {
|
||||
};
|
||||
|
||||
const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps) => {
|
||||
const { _ } = useLingui();
|
||||
const hours = formatHours(game.hours);
|
||||
const handlePin = () => onPin(game);
|
||||
const handlePlay = () => onPlay(game);
|
||||
@@ -110,11 +122,11 @@ const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<IconButton active={game.pinned} label={game.pinned ? 'Remove favorite' : 'Favorite game'} icon={game.pinned ? 'star-filled' : 'star'} onClick={handlePin} />
|
||||
<IconButton active={game.pinned} label={game.pinned ? _(msg`Remove favorite`) : _(msg`Favorite game`)} icon={game.pinned ? 'star-filled' : 'star'} onClick={handlePin} />
|
||||
{game.running ? (
|
||||
<IconButton danger label="Stop playing" icon="stop" onClick={onStop} />
|
||||
<IconButton danger label={_(msg`Stop playing`)} icon="stop" onClick={onStop} />
|
||||
) : (
|
||||
<IconButton disabled={!canLaunch || !game.gameId} play label="Play" icon="play" onClick={handlePlay} />
|
||||
<IconButton disabled={!canLaunch || !game.gameId} play label={_(msg`Play`)} icon="play" onClick={handlePlay} />
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-06-15 00:17+0300\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: de-DE\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. placeholder {0}: games.length
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "{0, plural, one {# game detected} other {# games detected}}"
|
||||
msgstr "{0, plural, one {# Spiel erkannt} other {# Spiele erkannt}}"
|
||||
|
||||
#. placeholder {0}: trainer.totalVisibleCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "{0} matches"
|
||||
msgstr "{0} Treffer"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods"
|
||||
msgstr "{cheatCount} Mods"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods · {enabledCount}/{toggleCount} on"
|
||||
msgstr "{cheatCount} Mods · {enabledCount}/{toggleCount} an"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Accent Color"
|
||||
msgstr "Akzentfarbe"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Active Session"
|
||||
msgstr "Aktive Sitzung"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add"
|
||||
msgstr "Hinzufügen"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add Preset"
|
||||
msgstr "Preset hinzufügen"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "All Games"
|
||||
msgstr "Alle Spiele"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Amber"
|
||||
msgstr "Bernstein"
|
||||
|
||||
#: src/trainer/controls/ActionButton.tsx
|
||||
msgid "Apply"
|
||||
msgstr "Anwenden"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Bridge"
|
||||
msgstr "Bridge"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Bridge offline"
|
||||
msgstr "Bridge offline"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Browse library"
|
||||
msgstr "Bibliothek öffnen"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "Abbrechen"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Challenge"
|
||||
msgstr "Herausforderung"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Character"
|
||||
msgstr "Charakter"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Cheats"
|
||||
msgstr "Cheats"
|
||||
|
||||
#: src/shared/ui/SearchInput.tsx
|
||||
msgid "Clear search"
|
||||
msgstr "Suche leeren"
|
||||
|
||||
#: src/shared/ui/Drawer.tsx
|
||||
msgid "Close drawer"
|
||||
msgstr "Leiste schließen"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Close library"
|
||||
msgstr "Bibliothek schließen"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Close preset modal"
|
||||
msgstr "Preset-Fenster schließen"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Close settings"
|
||||
msgstr "Einstellungen schließen"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cobalt"
|
||||
msgstr "Kobalt"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Crafting"
|
||||
msgstr "Handwerk"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Crimson"
|
||||
msgstr "Karminrot"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Custom"
|
||||
msgstr "Eigene"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cyan"
|
||||
msgstr "Cyan"
|
||||
|
||||
#. placeholder {0}: preset.name
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Delete preset {0}"
|
||||
msgstr "Preset {0} löschen"
|
||||
|
||||
#. placeholder {0}: trainer.totalCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "END · {0} MODS"
|
||||
msgstr "ENDE · {0} MODS"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Enemies"
|
||||
msgstr "Gegner"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Favorite game"
|
||||
msgstr "Spiel favorisieren"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Favorites"
|
||||
msgstr "Favoriten"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Game"
|
||||
msgstr "Spiel"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "GO"
|
||||
msgstr "START"
|
||||
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Idle · no game"
|
||||
msgstr "Inaktiv · kein Spiel"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Inventory"
|
||||
msgstr "Inventar"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Items"
|
||||
msgstr "Gegenstände"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Library"
|
||||
msgstr "Bibliothek"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Lime"
|
||||
msgstr "Limette"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LINKING"
|
||||
msgstr "VERBINDET"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LIVE"
|
||||
msgstr "LIVE"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Magenta"
|
||||
msgstr "Magenta"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "New preset"
|
||||
msgstr "Neues Preset"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "No active game session."
|
||||
msgstr "Keine aktive Spielsitzung."
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "No game is running yet. Open the library and launch one to start tweaking."
|
||||
msgstr "Es läuft noch kein Spiel. Öffnen Sie die Bibliothek und starten Sie eines, um loszulegen."
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "No games match \"{query}\""
|
||||
msgstr "Keine Spiele für „{query}“"
|
||||
|
||||
#. placeholder {0}: trainer.query
|
||||
#: src/app/app.tsx
|
||||
msgid "No mods match \"{0}\""
|
||||
msgstr "Keine Mods für „{0}“"
|
||||
|
||||
#: src/trainer/controls/SelectionControl.tsx
|
||||
msgid "No options"
|
||||
msgstr "Keine Optionen"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
msgid "No session"
|
||||
msgstr "Keine Sitzung"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Now Playing"
|
||||
msgstr "Wird gespielt"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "OFFLINE"
|
||||
msgstr "OFFLINE"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings"
|
||||
msgstr "Einstellungen öffnen"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings to point Wand at your trainer bridge over WebSocket."
|
||||
msgstr "Öffnen Sie die Einstellungen, um Wand über WebSocket mit Ihrer Trainer-Bridge zu verbinden."
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Panic Off"
|
||||
msgstr "Alles aus"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Physics"
|
||||
msgstr "Physik"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Pinned"
|
||||
msgstr "Angeheftet"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Play"
|
||||
msgstr "Spielen"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Player"
|
||||
msgstr "Spieler"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Preset name"
|
||||
msgstr "Preset-Name"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Remove favorite"
|
||||
msgstr "Favorit entfernen"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Resources"
|
||||
msgstr "Ressourcen"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Save"
|
||||
msgstr "Speichern"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Search games"
|
||||
msgstr "Spiele suchen"
|
||||
|
||||
#: src/app/app.tsx
|
||||
msgid "Search mods"
|
||||
msgstr "Mods suchen"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Select a game"
|
||||
msgstr "Spiel auswählen"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Session"
|
||||
msgstr "Sitzung"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Settings"
|
||||
msgstr "Einstellungen"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Stats"
|
||||
msgstr "Werte"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "STOP"
|
||||
msgstr "STOPP"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Stop playing"
|
||||
msgstr "Beenden"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Teleport"
|
||||
msgstr "Teleport"
|
||||
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Trainer Active"
|
||||
msgstr "Trainer aktiv"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Vehicles"
|
||||
msgstr "Fahrzeuge"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Violet"
|
||||
msgstr "Violett"
|
||||
|
||||
#. placeholder {0}: WEB_CONTRACT.defaultRemotePort
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "wand remote · port {0}"
|
||||
msgstr "wand remote · Port {0}"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Weapons"
|
||||
msgstr "Waffen"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "World"
|
||||
msgstr "Welt"
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-06-14 23:42+0300\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: en\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. placeholder {0}: games.length
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "{0, plural, one {# game detected} other {# games detected}}"
|
||||
msgstr "{0, plural, one {# game detected} other {# games detected}}"
|
||||
|
||||
#. placeholder {0}: trainer.totalVisibleCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "{0} matches"
|
||||
msgstr "{0} matches"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods"
|
||||
msgstr "{cheatCount} mods"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods · {enabledCount}/{toggleCount} on"
|
||||
msgstr "{cheatCount} mods · {enabledCount}/{toggleCount} on"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Accent Color"
|
||||
msgstr "Accent Color"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Active Session"
|
||||
msgstr "Active Session"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add"
|
||||
msgstr "Add"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add Preset"
|
||||
msgstr "Add Preset"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "All Games"
|
||||
msgstr "All Games"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Amber"
|
||||
msgstr "Amber"
|
||||
|
||||
#: src/trainer/controls/ActionButton.tsx
|
||||
msgid "Apply"
|
||||
msgstr "Apply"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Bridge"
|
||||
msgstr "Bridge"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Bridge offline"
|
||||
msgstr "Bridge offline"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Browse library"
|
||||
msgstr "Browse library"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "Cancel"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Challenge"
|
||||
msgstr "Challenge"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Character"
|
||||
msgstr "Character"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Cheats"
|
||||
msgstr "Cheats"
|
||||
|
||||
#: src/shared/ui/SearchInput.tsx
|
||||
msgid "Clear search"
|
||||
msgstr "Clear search"
|
||||
|
||||
#: src/shared/ui/Drawer.tsx
|
||||
msgid "Close drawer"
|
||||
msgstr "Close drawer"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Close library"
|
||||
msgstr "Close library"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Close preset modal"
|
||||
msgstr "Close preset modal"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Close settings"
|
||||
msgstr "Close settings"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cobalt"
|
||||
msgstr "Cobalt"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Crafting"
|
||||
msgstr "Crafting"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Crimson"
|
||||
msgstr "Crimson"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Custom"
|
||||
msgstr "Custom"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cyan"
|
||||
msgstr "Cyan"
|
||||
|
||||
#. placeholder {0}: preset.name
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Delete preset {0}"
|
||||
msgstr "Delete preset {0}"
|
||||
|
||||
#. placeholder {0}: trainer.totalCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "END · {0} MODS"
|
||||
msgstr "END · {0} MODS"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Enemies"
|
||||
msgstr "Enemies"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Favorite game"
|
||||
msgstr "Favorite game"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Favorites"
|
||||
msgstr "Favorites"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Game"
|
||||
msgstr "Game"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "GO"
|
||||
msgstr "GO"
|
||||
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Idle · no game"
|
||||
msgstr "Idle · no game"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Inventory"
|
||||
msgstr "Inventory"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Items"
|
||||
msgstr "Items"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Language"
|
||||
msgstr "Language"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Library"
|
||||
msgstr "Library"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Lime"
|
||||
msgstr "Lime"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LINKING"
|
||||
msgstr "LINKING"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LIVE"
|
||||
msgstr "LIVE"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Magenta"
|
||||
msgstr "Magenta"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "New preset"
|
||||
msgstr "New preset"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "No active game session."
|
||||
msgstr "No active game session."
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "No game is running yet. Open the library and launch one to start tweaking."
|
||||
msgstr "No game is running yet. Open the library and launch one to start tweaking."
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "No games match \"{query}\""
|
||||
msgstr "No games match \"{query}\""
|
||||
|
||||
#. placeholder {0}: trainer.query
|
||||
#: src/app/app.tsx
|
||||
msgid "No mods match \"{0}\""
|
||||
msgstr "No mods match \"{0}\""
|
||||
|
||||
#: src/trainer/controls/SelectionControl.tsx
|
||||
msgid "No options"
|
||||
msgstr "No options"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
msgid "No session"
|
||||
msgstr "No session"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Now Playing"
|
||||
msgstr "Now Playing"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "OFFLINE"
|
||||
msgstr "OFFLINE"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings"
|
||||
msgstr "Open Settings"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings to point Wand at your trainer bridge over WebSocket."
|
||||
msgstr "Open Settings to point Wand at your trainer bridge over WebSocket."
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Panic Off"
|
||||
msgstr "Panic Off"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Physics"
|
||||
msgstr "Physics"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Pinned"
|
||||
msgstr "Pinned"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Play"
|
||||
msgstr "Play"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Player"
|
||||
msgstr "Player"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Preset name"
|
||||
msgstr "Preset name"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Remove favorite"
|
||||
msgstr "Remove favorite"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Resources"
|
||||
msgstr "Resources"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Save"
|
||||
msgstr "Save"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Search games"
|
||||
msgstr "Search games"
|
||||
|
||||
#: src/app/app.tsx
|
||||
msgid "Search mods"
|
||||
msgstr "Search mods"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Select a game"
|
||||
msgstr "Select a game"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Session"
|
||||
msgstr "Session"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Settings"
|
||||
msgstr "Settings"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Stats"
|
||||
msgstr "Stats"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "STOP"
|
||||
msgstr "STOP"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Stop playing"
|
||||
msgstr "Stop playing"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Teleport"
|
||||
msgstr "Teleport"
|
||||
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Trainer Active"
|
||||
msgstr "Trainer Active"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Vehicles"
|
||||
msgstr "Vehicles"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Violet"
|
||||
msgstr "Violet"
|
||||
|
||||
#. placeholder {0}: WEB_CONTRACT.defaultRemotePort
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "wand remote · port {0}"
|
||||
msgstr "wand remote · port {0}"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Weapons"
|
||||
msgstr "Weapons"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "World"
|
||||
msgstr "World"
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-06-15 00:17+0300\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: es-ES\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. placeholder {0}: games.length
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "{0, plural, one {# game detected} other {# games detected}}"
|
||||
msgstr "{0, plural, one {# juego detectado} other {# juegos detectados}}"
|
||||
|
||||
#. placeholder {0}: trainer.totalVisibleCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "{0} matches"
|
||||
msgstr "{0} coincidencias"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods"
|
||||
msgstr "{cheatCount} mods"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods · {enabledCount}/{toggleCount} on"
|
||||
msgstr "{cheatCount} mods · {enabledCount}/{toggleCount} activos"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Accent Color"
|
||||
msgstr "Color de acento"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Active Session"
|
||||
msgstr "Sesión activa"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add"
|
||||
msgstr "Añadir"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add Preset"
|
||||
msgstr "Añadir preajuste"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "All Games"
|
||||
msgstr "Todos los juegos"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Amber"
|
||||
msgstr "Ámbar"
|
||||
|
||||
#: src/trainer/controls/ActionButton.tsx
|
||||
msgid "Apply"
|
||||
msgstr "Aplicar"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Bridge"
|
||||
msgstr "Puente"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Bridge offline"
|
||||
msgstr "Puente desconectado"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Browse library"
|
||||
msgstr "Explorar biblioteca"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "Cancelar"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Challenge"
|
||||
msgstr "Desafío"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Character"
|
||||
msgstr "Personaje"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Cheats"
|
||||
msgstr "Trucos"
|
||||
|
||||
#: src/shared/ui/SearchInput.tsx
|
||||
msgid "Clear search"
|
||||
msgstr "Borrar búsqueda"
|
||||
|
||||
#: src/shared/ui/Drawer.tsx
|
||||
msgid "Close drawer"
|
||||
msgstr "Cerrar panel"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Close library"
|
||||
msgstr "Cerrar biblioteca"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Close preset modal"
|
||||
msgstr "Cerrar ventana de preajuste"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Close settings"
|
||||
msgstr "Cerrar ajustes"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cobalt"
|
||||
msgstr "Cobalto"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Crafting"
|
||||
msgstr "Fabricación"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Crimson"
|
||||
msgstr "Carmesí"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Custom"
|
||||
msgstr "Personalizado"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cyan"
|
||||
msgstr "Cian"
|
||||
|
||||
#. placeholder {0}: preset.name
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Delete preset {0}"
|
||||
msgstr "Eliminar preajuste {0}"
|
||||
|
||||
#. placeholder {0}: trainer.totalCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "END · {0} MODS"
|
||||
msgstr "FIN · {0} MODS"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Enemies"
|
||||
msgstr "Enemigos"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Favorite game"
|
||||
msgstr "Marcar favorito"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Favorites"
|
||||
msgstr "Favoritos"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Game"
|
||||
msgstr "Juego"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "GO"
|
||||
msgstr "IR"
|
||||
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Idle · no game"
|
||||
msgstr "Inactivo · sin juego"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Inventory"
|
||||
msgstr "Inventario"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Items"
|
||||
msgstr "Objetos"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Language"
|
||||
msgstr "Idioma"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Library"
|
||||
msgstr "Biblioteca"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Lime"
|
||||
msgstr "Lima"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LINKING"
|
||||
msgstr "CONECTANDO"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LIVE"
|
||||
msgstr "EN VIVO"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Magenta"
|
||||
msgstr "Magenta"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Name"
|
||||
msgstr "Nombre"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "New preset"
|
||||
msgstr "Nuevo preajuste"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "No active game session."
|
||||
msgstr "No hay sesión de juego activa."
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "No game is running yet. Open the library and launch one to start tweaking."
|
||||
msgstr "Aún no hay ningún juego en marcha. Abre la biblioteca e inicia uno para empezar a ajustar."
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "No games match \"{query}\""
|
||||
msgstr "Ningún juego coincide con «{query}»"
|
||||
|
||||
#. placeholder {0}: trainer.query
|
||||
#: src/app/app.tsx
|
||||
msgid "No mods match \"{0}\""
|
||||
msgstr "Ningún mod coincide con «{0}»"
|
||||
|
||||
#: src/trainer/controls/SelectionControl.tsx
|
||||
msgid "No options"
|
||||
msgstr "Sin opciones"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
msgid "No session"
|
||||
msgstr "Sin sesión"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Now Playing"
|
||||
msgstr "Jugando ahora"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "OFFLINE"
|
||||
msgstr "SIN CONEXIÓN"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings"
|
||||
msgstr "Abrir ajustes"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings to point Wand at your trainer bridge over WebSocket."
|
||||
msgstr "Abre los ajustes para conectar Wand con tu puente de trainer por WebSocket."
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Panic Off"
|
||||
msgstr "Apagar todo"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Physics"
|
||||
msgstr "Física"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Pinned"
|
||||
msgstr "Fijados"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Play"
|
||||
msgstr "Jugar"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Player"
|
||||
msgstr "Jugador"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Preset name"
|
||||
msgstr "Nombre del preajuste"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Remove favorite"
|
||||
msgstr "Quitar favorito"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Resources"
|
||||
msgstr "Recursos"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Save"
|
||||
msgstr "Guardar"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Search games"
|
||||
msgstr "Buscar juegos"
|
||||
|
||||
#: src/app/app.tsx
|
||||
msgid "Search mods"
|
||||
msgstr "Buscar mods"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Select a game"
|
||||
msgstr "Elige un juego"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Session"
|
||||
msgstr "Sesión"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Settings"
|
||||
msgstr "Ajustes"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Stats"
|
||||
msgstr "Estadísticas"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "STOP"
|
||||
msgstr "PARAR"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Stop playing"
|
||||
msgstr "Detener"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Teleport"
|
||||
msgstr "Teletransporte"
|
||||
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Trainer Active"
|
||||
msgstr "Trainer activo"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Vehicles"
|
||||
msgstr "Vehículos"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Violet"
|
||||
msgstr "Violeta"
|
||||
|
||||
#. placeholder {0}: WEB_CONTRACT.defaultRemotePort
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "wand remote · port {0}"
|
||||
msgstr "wand remote · puerto {0}"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Weapons"
|
||||
msgstr "Armas"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "World"
|
||||
msgstr "Mundo"
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-06-15 00:17+0300\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: fr-FR\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. placeholder {0}: games.length
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "{0, plural, one {# game detected} other {# games detected}}"
|
||||
msgstr "{0, plural, one {# jeu détecté} other {# jeux détectés}}"
|
||||
|
||||
#. placeholder {0}: trainer.totalVisibleCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "{0} matches"
|
||||
msgstr "{0} résultats"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods"
|
||||
msgstr "{cheatCount} mods"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods · {enabledCount}/{toggleCount} on"
|
||||
msgstr "{cheatCount} mods · {enabledCount}/{toggleCount} actifs"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Accent Color"
|
||||
msgstr "Couleur d'accent"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Active Session"
|
||||
msgstr "Session active"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add"
|
||||
msgstr "Ajouter"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add Preset"
|
||||
msgstr "Ajouter un préréglage"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "All Games"
|
||||
msgstr "Tous les jeux"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Amber"
|
||||
msgstr "Ambre"
|
||||
|
||||
#: src/trainer/controls/ActionButton.tsx
|
||||
msgid "Apply"
|
||||
msgstr "Appliquer"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Bridge"
|
||||
msgstr "Pont"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Bridge offline"
|
||||
msgstr "Pont hors ligne"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Browse library"
|
||||
msgstr "Parcourir la bibliothèque"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Challenge"
|
||||
msgstr "Défi"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Character"
|
||||
msgstr "Personnage"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Cheats"
|
||||
msgstr "Triches"
|
||||
|
||||
#: src/shared/ui/SearchInput.tsx
|
||||
msgid "Clear search"
|
||||
msgstr "Effacer la recherche"
|
||||
|
||||
#: src/shared/ui/Drawer.tsx
|
||||
msgid "Close drawer"
|
||||
msgstr "Fermer le panneau"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Close library"
|
||||
msgstr "Fermer la bibliothèque"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Close preset modal"
|
||||
msgstr "Fermer la fenêtre de préréglage"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Close settings"
|
||||
msgstr "Fermer les paramètres"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cobalt"
|
||||
msgstr "Cobalt"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Crafting"
|
||||
msgstr "Artisanat"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Crimson"
|
||||
msgstr "Cramoisi"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Custom"
|
||||
msgstr "Personnalisé"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cyan"
|
||||
msgstr "Cyan"
|
||||
|
||||
#. placeholder {0}: preset.name
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Delete preset {0}"
|
||||
msgstr "Supprimer le préréglage {0}"
|
||||
|
||||
#. placeholder {0}: trainer.totalCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "END · {0} MODS"
|
||||
msgstr "FIN · {0} MODS"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Enemies"
|
||||
msgstr "Ennemis"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Favorite game"
|
||||
msgstr "Mettre en favori"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Favorites"
|
||||
msgstr "Favoris"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Game"
|
||||
msgstr "Jeu"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "GO"
|
||||
msgstr "GO"
|
||||
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Idle · no game"
|
||||
msgstr "Inactif · aucun jeu"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Inventory"
|
||||
msgstr "Inventaire"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Items"
|
||||
msgstr "Objets"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Language"
|
||||
msgstr "Langue"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Library"
|
||||
msgstr "Bibliothèque"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Lime"
|
||||
msgstr "Citron vert"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LINKING"
|
||||
msgstr "CONNEXION"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LIVE"
|
||||
msgstr "EN DIRECT"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Magenta"
|
||||
msgstr "Magenta"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Name"
|
||||
msgstr "Nom"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "New preset"
|
||||
msgstr "Nouveau préréglage"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "No active game session."
|
||||
msgstr "Aucune session de jeu active."
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "No game is running yet. Open the library and launch one to start tweaking."
|
||||
msgstr "Aucun jeu n'est lancé. Ouvrez la bibliothèque et lancez-en un pour commencer."
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "No games match \"{query}\""
|
||||
msgstr "Aucun jeu pour « {query} »"
|
||||
|
||||
#. placeholder {0}: trainer.query
|
||||
#: src/app/app.tsx
|
||||
msgid "No mods match \"{0}\""
|
||||
msgstr "Aucun mod pour « {0} »"
|
||||
|
||||
#: src/trainer/controls/SelectionControl.tsx
|
||||
msgid "No options"
|
||||
msgstr "Aucune option"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
msgid "No session"
|
||||
msgstr "Aucune session"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Now Playing"
|
||||
msgstr "En cours"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "OFFLINE"
|
||||
msgstr "HORS LIGNE"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings"
|
||||
msgstr "Ouvrir les paramètres"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings to point Wand at your trainer bridge over WebSocket."
|
||||
msgstr "Ouvrez les paramètres pour connecter Wand à votre pont de trainer via WebSocket."
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Panic Off"
|
||||
msgstr "Tout désactiver"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Physics"
|
||||
msgstr "Physique"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Pinned"
|
||||
msgstr "Épinglés"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Play"
|
||||
msgstr "Jouer"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Player"
|
||||
msgstr "Joueur"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Preset name"
|
||||
msgstr "Nom du préréglage"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Remove favorite"
|
||||
msgstr "Retirer des favoris"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Resources"
|
||||
msgstr "Ressources"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Save"
|
||||
msgstr "Enregistrer"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Search games"
|
||||
msgstr "Rechercher des jeux"
|
||||
|
||||
#: src/app/app.tsx
|
||||
msgid "Search mods"
|
||||
msgstr "Rechercher des mods"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Select a game"
|
||||
msgstr "Choisir un jeu"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Session"
|
||||
msgstr "Session"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Settings"
|
||||
msgstr "Paramètres"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Stats"
|
||||
msgstr "Stats"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "STOP"
|
||||
msgstr "STOP"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Stop playing"
|
||||
msgstr "Arrêter"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Teleport"
|
||||
msgstr "Téléportation"
|
||||
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Trainer Active"
|
||||
msgstr "Trainer actif"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Vehicles"
|
||||
msgstr "Véhicules"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Violet"
|
||||
msgstr "Violet"
|
||||
|
||||
#. placeholder {0}: WEB_CONTRACT.defaultRemotePort
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "wand remote · port {0}"
|
||||
msgstr "wand remote · port {0}"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Weapons"
|
||||
msgstr "Armes"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "World"
|
||||
msgstr "Monde"
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-06-15 00:17+0300\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: ru-RU\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. placeholder {0}: games.length
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "{0, plural, one {# game detected} other {# games detected}}"
|
||||
msgstr "{0, plural, one {# игра найдена} few {# игры найдено} many {# игр найдено} other {# игр найдено}}"
|
||||
|
||||
#. placeholder {0}: trainer.totalVisibleCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "{0} matches"
|
||||
msgstr "{0} совпадений"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods"
|
||||
msgstr "{cheatCount} модов"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods · {enabledCount}/{toggleCount} on"
|
||||
msgstr "{cheatCount} модов · {enabledCount}/{toggleCount} вкл"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Accent Color"
|
||||
msgstr "Акцентный цвет"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Active Session"
|
||||
msgstr "Активная сессия"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add"
|
||||
msgstr "Добавить"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add Preset"
|
||||
msgstr "Добавить пресет"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "All Games"
|
||||
msgstr "Все игры"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Amber"
|
||||
msgstr "Янтарный"
|
||||
|
||||
#: src/trainer/controls/ActionButton.tsx
|
||||
msgid "Apply"
|
||||
msgstr "Применить"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Bridge"
|
||||
msgstr "Мост"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Bridge offline"
|
||||
msgstr "Мост не в сети"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Browse library"
|
||||
msgstr "Открыть библиотеку"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "Отмена"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Challenge"
|
||||
msgstr "Испытание"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Character"
|
||||
msgstr "Персонаж"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Cheats"
|
||||
msgstr "Читы"
|
||||
|
||||
#: src/shared/ui/SearchInput.tsx
|
||||
msgid "Clear search"
|
||||
msgstr "Очистить поиск"
|
||||
|
||||
#: src/shared/ui/Drawer.tsx
|
||||
msgid "Close drawer"
|
||||
msgstr "Закрыть панель"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Close library"
|
||||
msgstr "Закрыть библиотеку"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Close preset modal"
|
||||
msgstr "Закрыть окно пресета"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Close settings"
|
||||
msgstr "Закрыть настройки"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cobalt"
|
||||
msgstr "Кобальт"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Crafting"
|
||||
msgstr "Крафт"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Crimson"
|
||||
msgstr "Багровый"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Custom"
|
||||
msgstr "Свой"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cyan"
|
||||
msgstr "Циан"
|
||||
|
||||
#. placeholder {0}: preset.name
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Delete preset {0}"
|
||||
msgstr "Удалить пресет {0}"
|
||||
|
||||
#. placeholder {0}: trainer.totalCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "END · {0} MODS"
|
||||
msgstr "КОНЕЦ · {0} МОДОВ"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Enemies"
|
||||
msgstr "Враги"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Favorite game"
|
||||
msgstr "В избранное"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Favorites"
|
||||
msgstr "Избранное"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Game"
|
||||
msgstr "Игра"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "GO"
|
||||
msgstr "ПУСК"
|
||||
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Idle · no game"
|
||||
msgstr "Простой · нет игры"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Inventory"
|
||||
msgstr "Инвентарь"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Items"
|
||||
msgstr "Предметы"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Language"
|
||||
msgstr "Язык"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Library"
|
||||
msgstr "Библиотека"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Lime"
|
||||
msgstr "Лайм"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LINKING"
|
||||
msgstr "ПОДКЛЮЧЕНИЕ"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LIVE"
|
||||
msgstr "В СЕТИ"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Magenta"
|
||||
msgstr "Пурпурный"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Name"
|
||||
msgstr "Название"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "New preset"
|
||||
msgstr "Новый пресет"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "No active game session."
|
||||
msgstr "Нет активной игровой сессии."
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "No game is running yet. Open the library and launch one to start tweaking."
|
||||
msgstr "Игра ещё не запущена. Откройте библиотеку и запустите игру, чтобы начать настройку."
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "No games match \"{query}\""
|
||||
msgstr "Нет игр по запросу «{query}»"
|
||||
|
||||
#. placeholder {0}: trainer.query
|
||||
#: src/app/app.tsx
|
||||
msgid "No mods match \"{0}\""
|
||||
msgstr "Нет модов по запросу «{0}»"
|
||||
|
||||
#: src/trainer/controls/SelectionControl.tsx
|
||||
msgid "No options"
|
||||
msgstr "Нет вариантов"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
msgid "No session"
|
||||
msgstr "Нет сессии"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Now Playing"
|
||||
msgstr "Сейчас в игре"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "OFFLINE"
|
||||
msgstr "НЕ В СЕТИ"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings"
|
||||
msgstr "Открыть настройки"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings to point Wand at your trainer bridge over WebSocket."
|
||||
msgstr "Откройте настройки, чтобы указать Wand адрес моста трейнера по WebSocket."
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Panic Off"
|
||||
msgstr "Выключить всё"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Physics"
|
||||
msgstr "Физика"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Pinned"
|
||||
msgstr "Закреплённые"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Play"
|
||||
msgstr "Играть"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Player"
|
||||
msgstr "Игрок"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Preset name"
|
||||
msgstr "Название пресета"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Remove favorite"
|
||||
msgstr "Убрать из избранного"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Resources"
|
||||
msgstr "Ресурсы"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Save"
|
||||
msgstr "Сохранить"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Search games"
|
||||
msgstr "Поиск игр"
|
||||
|
||||
#: src/app/app.tsx
|
||||
msgid "Search mods"
|
||||
msgstr "Поиск модов"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Select a game"
|
||||
msgstr "Выберите игру"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Session"
|
||||
msgstr "Сессия"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Settings"
|
||||
msgstr "Настройки"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Stats"
|
||||
msgstr "Статы"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "STOP"
|
||||
msgstr "СТОП"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Stop playing"
|
||||
msgstr "Остановить"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Teleport"
|
||||
msgstr "Телепорт"
|
||||
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Trainer Active"
|
||||
msgstr "Трейнер активен"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Vehicles"
|
||||
msgstr "Транспорт"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Violet"
|
||||
msgstr "Фиолетовый"
|
||||
|
||||
#. placeholder {0}: WEB_CONTRACT.defaultRemotePort
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "wand remote · port {0}"
|
||||
msgstr "wand remote · порт {0}"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Weapons"
|
||||
msgstr "Оружие"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "World"
|
||||
msgstr "Мир"
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-06-15 00:17+0300\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: zh-CN\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. placeholder {0}: games.length
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "{0, plural, one {# game detected} other {# games detected}}"
|
||||
msgstr "{0, plural, other {检测到 # 个游戏}}"
|
||||
|
||||
#. placeholder {0}: trainer.totalVisibleCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "{0} matches"
|
||||
msgstr "{0} 个匹配"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods"
|
||||
msgstr "{cheatCount} 个模组"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods · {enabledCount}/{toggleCount} on"
|
||||
msgstr "{cheatCount} 个模组 · {enabledCount}/{toggleCount} 已开"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Accent Color"
|
||||
msgstr "强调色"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Active Session"
|
||||
msgstr "当前会话"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add"
|
||||
msgstr "添加"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add Preset"
|
||||
msgstr "添加预设"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "All Games"
|
||||
msgstr "所有游戏"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Amber"
|
||||
msgstr "琥珀"
|
||||
|
||||
#: src/trainer/controls/ActionButton.tsx
|
||||
msgid "Apply"
|
||||
msgstr "应用"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Bridge"
|
||||
msgstr "桥接"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Bridge offline"
|
||||
msgstr "桥接已离线"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Browse library"
|
||||
msgstr "浏览游戏库"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "取消"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Challenge"
|
||||
msgstr "挑战"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Character"
|
||||
msgstr "角色"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Cheats"
|
||||
msgstr "作弊"
|
||||
|
||||
#: src/shared/ui/SearchInput.tsx
|
||||
msgid "Clear search"
|
||||
msgstr "清除搜索"
|
||||
|
||||
#: src/shared/ui/Drawer.tsx
|
||||
msgid "Close drawer"
|
||||
msgstr "关闭抽屉"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Close library"
|
||||
msgstr "关闭游戏库"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Close preset modal"
|
||||
msgstr "关闭预设窗口"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Close settings"
|
||||
msgstr "关闭设置"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cobalt"
|
||||
msgstr "钴蓝"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Crafting"
|
||||
msgstr "制作"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Crimson"
|
||||
msgstr "深红"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Custom"
|
||||
msgstr "自定义"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cyan"
|
||||
msgstr "青色"
|
||||
|
||||
#. placeholder {0}: preset.name
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Delete preset {0}"
|
||||
msgstr "删除预设 {0}"
|
||||
|
||||
#. placeholder {0}: trainer.totalCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "END · {0} MODS"
|
||||
msgstr "结束 · {0} 个模组"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Enemies"
|
||||
msgstr "敌人"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Favorite game"
|
||||
msgstr "收藏游戏"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Favorites"
|
||||
msgstr "收藏"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Game"
|
||||
msgstr "游戏"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "GO"
|
||||
msgstr "连接"
|
||||
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Idle · no game"
|
||||
msgstr "空闲 · 无游戏"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Inventory"
|
||||
msgstr "物品栏"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Items"
|
||||
msgstr "物品"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Language"
|
||||
msgstr "语言"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Library"
|
||||
msgstr "游戏库"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Lime"
|
||||
msgstr "青柠"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LINKING"
|
||||
msgstr "连接中"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LIVE"
|
||||
msgstr "在线"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Magenta"
|
||||
msgstr "品红"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Name"
|
||||
msgstr "名称"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "New preset"
|
||||
msgstr "新预设"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "No active game session."
|
||||
msgstr "没有活动的游戏会话。"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "No game is running yet. Open the library and launch one to start tweaking."
|
||||
msgstr "尚未运行游戏。打开游戏库并启动一个即可开始调整。"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "No games match \"{query}\""
|
||||
msgstr "没有匹配「{query}」的游戏"
|
||||
|
||||
#. placeholder {0}: trainer.query
|
||||
#: src/app/app.tsx
|
||||
msgid "No mods match \"{0}\""
|
||||
msgstr "没有匹配「{0}」的模组"
|
||||
|
||||
#: src/trainer/controls/SelectionControl.tsx
|
||||
msgid "No options"
|
||||
msgstr "无选项"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
msgid "No session"
|
||||
msgstr "无会话"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Now Playing"
|
||||
msgstr "正在游玩"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "OFFLINE"
|
||||
msgstr "离线"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings"
|
||||
msgstr "打开设置"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings to point Wand at your trainer bridge over WebSocket."
|
||||
msgstr "打开设置,通过 WebSocket 将 Wand 指向你的训练器桥接。"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Panic Off"
|
||||
msgstr "全部关闭"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Physics"
|
||||
msgstr "物理"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Pinned"
|
||||
msgstr "已固定"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Play"
|
||||
msgstr "开始游戏"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Player"
|
||||
msgstr "玩家"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Preset name"
|
||||
msgstr "预设名称"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Remove favorite"
|
||||
msgstr "取消收藏"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Resources"
|
||||
msgstr "资源"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Save"
|
||||
msgstr "保存"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Search games"
|
||||
msgstr "搜索游戏"
|
||||
|
||||
#: src/app/app.tsx
|
||||
msgid "Search mods"
|
||||
msgstr "搜索模组"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Select a game"
|
||||
msgstr "选择游戏"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Session"
|
||||
msgstr "会话"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Settings"
|
||||
msgstr "设置"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Stats"
|
||||
msgstr "属性"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "STOP"
|
||||
msgstr "停止"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Stop playing"
|
||||
msgstr "停止游戏"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Teleport"
|
||||
msgstr "传送"
|
||||
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Trainer Active"
|
||||
msgstr "训练器已激活"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Vehicles"
|
||||
msgstr "载具"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Violet"
|
||||
msgstr "紫色"
|
||||
|
||||
#. placeholder {0}: WEB_CONTRACT.defaultRemotePort
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "wand remote · port {0}"
|
||||
msgstr "wand remote · 端口 {0}"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Weapons"
|
||||
msgstr "武器"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "World"
|
||||
msgstr "世界"
|
||||
Vendored
-21
@@ -1,21 +0,0 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { applySavedAccentColor } from '@/features/remote-panel/accent-storage';
|
||||
|
||||
import { App } from './app';
|
||||
import './index.css';
|
||||
|
||||
const root = document.getElementById('root') ?? document.getElementById('app');
|
||||
|
||||
if (!root) {
|
||||
throw new Error('App root not found.');
|
||||
}
|
||||
|
||||
applySavedAccentColor();
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.po' {
|
||||
import type { Messages } from '@lingui/core';
|
||||
export const messages: Messages;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { WEB_CONTRACT } from '../../protocol/contract';
|
||||
import {
|
||||
type HelloMessage,
|
||||
type IncomingMessage,
|
||||
type OutgoingMessage,
|
||||
PROTOCOL_VERSION,
|
||||
type RemoteCommandMessage,
|
||||
type SetValueMessage,
|
||||
} from '../../protocol/messages';
|
||||
import { isIncomingMessage } from '../../protocol/validation';
|
||||
|
||||
type SocketHandlers = {
|
||||
onConnecting: () => void;
|
||||
onTransportOpen: () => void;
|
||||
onMessage: (message: IncomingMessage) => void;
|
||||
onClose: () => void;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
let requestSequence = 0;
|
||||
|
||||
export class RemoteSessionClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private intentionalDisconnect = false;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly handlers: SocketHandlers,
|
||||
) {}
|
||||
|
||||
connect(pairingToken?: string): void {
|
||||
this.disconnect();
|
||||
this.intentionalDisconnect = false;
|
||||
this.handlers.onConnecting();
|
||||
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.handlers.onTransportOpen();
|
||||
this.send(this.createHelloMessage(pairingToken));
|
||||
});
|
||||
socket.addEventListener('message', (event) => this.handleMessage(event));
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.socket === socket) {
|
||||
this.socket = null;
|
||||
}
|
||||
if (!this.intentionalDisconnect) {
|
||||
this.handlers.onClose();
|
||||
}
|
||||
});
|
||||
socket.addEventListener('error', () => this.handlers.onError('WebSocket connection failed.'));
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.intentionalDisconnect = true;
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return Boolean(this.socket && this.socket.readyState === WebSocket.OPEN);
|
||||
}
|
||||
|
||||
setValue(trainerId: string, target: string, value: unknown, cheatId?: string): string | null {
|
||||
const requestId = createRequestId(`set_${target}`);
|
||||
const message: SetValueMessage = {
|
||||
type: 'set_value',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId,
|
||||
payload: { trainerId, target, value, cheatId },
|
||||
};
|
||||
return this.send(message) ? requestId : null;
|
||||
}
|
||||
|
||||
launchGame(gameId: string, titleId?: string): boolean {
|
||||
return this.sendCommand('launch', gameId, titleId);
|
||||
}
|
||||
|
||||
stopPlaying(gameId?: string, titleId?: string): boolean {
|
||||
return this.sendCommand('stop', gameId, titleId);
|
||||
}
|
||||
|
||||
private send(message: OutgoingMessage): boolean {
|
||||
const socket = this.socket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
socket.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
private sendCommand(action: 'launch' | 'stop', gameId?: string, titleId?: string): boolean {
|
||||
const message: RemoteCommandMessage = {
|
||||
type: 'remote_command',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: createRequestId(`command_${action}`),
|
||||
payload: { action, gameId, titleId },
|
||||
};
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
private createHelloMessage(pairingToken?: string): HelloMessage {
|
||||
return {
|
||||
type: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: createRequestId('hello'),
|
||||
payload: {
|
||||
client: 'mobile-web',
|
||||
clientVersion: WEB_CONTRACT.clientVersion,
|
||||
pairingToken,
|
||||
capabilities: { supportsDeltaValues: true, supportsTrainerSwitch: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const parsed = JSON.parse(String(event.data)) as unknown;
|
||||
if (!isIncomingMessage(parsed)) {
|
||||
this.handlers.onError('Received an invalid protocol message.');
|
||||
return;
|
||||
}
|
||||
this.handlers.onMessage(parsed);
|
||||
} catch (error) {
|
||||
this.handlers.onError(error instanceof Error ? error.message : 'Failed to parse websocket message.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestId(prefix: string): string {
|
||||
requestSequence += 1;
|
||||
return `${prefix}_${Date.now()}_${requestSequence}`;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { PROTOCOL_VERSION, type IncomingMessage } from '../../protocol/messages';
|
||||
import type { RemoteSessionAction } from './remote-session.reducer';
|
||||
|
||||
export function protocolAction(message: IncomingMessage): RemoteSessionAction | null {
|
||||
switch (message.type) {
|
||||
case 'hello_ack':
|
||||
if (!message.payload.accepted) {
|
||||
return { type: 'error', message: 'The desktop bridge rejected the connection.' };
|
||||
}
|
||||
if (message.payload.protocolVersion !== PROTOCOL_VERSION) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: `Protocol mismatch: bridge=${message.payload.protocolVersion}, panel=${PROTOCOL_VERSION}.`,
|
||||
};
|
||||
}
|
||||
return { type: 'connected' };
|
||||
case 'trainer_meta':
|
||||
return { type: 'trainerMeta', payload: message.payload };
|
||||
case 'game_status':
|
||||
return { type: 'gameStatus', payload: message.payload };
|
||||
case 'installed_apps':
|
||||
return { type: 'installedApps', payload: message.payload };
|
||||
case 'trainer_values':
|
||||
return { type: 'trainerValues', payload: message.payload.values };
|
||||
case 'value_changed':
|
||||
return { type: 'valueChanged', target: message.payload.target, value: message.payload.value };
|
||||
case 'trainer_changed':
|
||||
return { type: 'trainerChanged' };
|
||||
case 'set_value_result':
|
||||
return {
|
||||
type: 'writeResult',
|
||||
target: message.payload.target,
|
||||
requestId: message.requestId,
|
||||
ok: message.payload.ok,
|
||||
message: message.payload.error?.message,
|
||||
};
|
||||
case 'remote_command_result':
|
||||
return message.payload.ok
|
||||
? null
|
||||
: { type: 'error', message: message.payload.error?.message ?? 'The remote game command was rejected.' };
|
||||
case 'error':
|
||||
return { type: 'error', message: message.payload.message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PROTOCOL_VERSION, type HelloAckMessage } from '../../protocol/messages';
|
||||
import { protocolAction } from './remote-session.protocol';
|
||||
import {
|
||||
createInitialRemoteSessionState,
|
||||
EConnectionStatus,
|
||||
remoteSessionReducer,
|
||||
type RemoteSessionState,
|
||||
} from './remote-session.reducer';
|
||||
|
||||
describe('remote session protocol', () => {
|
||||
it('connects only after an accepted compatible hello acknowledgement', () => {
|
||||
const action = protocolAction(helloAck(PROTOCOL_VERSION));
|
||||
expect(action).toEqual({ type: 'connected' });
|
||||
});
|
||||
|
||||
it('rejects a protocol version mismatch', () => {
|
||||
const action = protocolAction(helloAck(PROTOCOL_VERSION + 1));
|
||||
expect(action).toEqual({
|
||||
type: 'error',
|
||||
message: `Protocol mismatch: bridge=${PROTOCOL_VERSION + 1}, panel=${PROTOCOL_VERSION}.`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote session reducer', () => {
|
||||
it('enters reconnecting state after an unexpected close', () => {
|
||||
const state = { ...initialState(), connectionStatus: EConnectionStatus.Connected };
|
||||
const next = remoteSessionReducer(state, { type: 'connectionClosed', message: 'closed' });
|
||||
|
||||
expect(next.connectionStatus).toBe(EConnectionStatus.Reconnecting);
|
||||
expect(next.lastError).toBe('closed');
|
||||
});
|
||||
|
||||
it('applies snapshots and clears trainer data on trainer switch', () => {
|
||||
let state = remoteSessionReducer(initialState(), { type: 'trainerValues', payload: { speed: 2 } });
|
||||
state = remoteSessionReducer(state, { type: 'valueChanged', target: 'speed', value: 3 });
|
||||
expect(state.values.speed).toBe(3);
|
||||
|
||||
state = remoteSessionReducer(state, { type: 'trainerChanged' });
|
||||
expect(state.values).toEqual({});
|
||||
expect(state.pendingWrites).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps a write pending until result and commits a successful result', () => {
|
||||
let state = withConfirmedValue(1);
|
||||
state = remoteSessionReducer(state, { type: 'writeStarted', target: 'speed', value: 2, requestId: 'new' });
|
||||
expect(state.values.speed).toBe(2);
|
||||
expect(state.pendingWrites.speed?.requestId).toBe('new');
|
||||
|
||||
state = remoteSessionReducer(state, { type: 'writeResult', target: 'speed', requestId: 'new', ok: true });
|
||||
expect(state.confirmedValues.speed).toBe(2);
|
||||
expect(state.pendingWrites.speed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears a write after a matching value delta', () => {
|
||||
let state = withConfirmedValue(false);
|
||||
state = remoteSessionReducer(state, { type: 'writeStarted', target: 'speed', value: true, requestId: 'new' });
|
||||
state = remoteSessionReducer(state, { type: 'valueChanged', target: 'speed', value: true });
|
||||
|
||||
expect(state.pendingWrites.speed).toBeUndefined();
|
||||
expect(state.confirmedValues.speed).toBe(true);
|
||||
});
|
||||
|
||||
it('rolls back only the current rejected request', () => {
|
||||
let state = withConfirmedValue(1);
|
||||
state = remoteSessionReducer(state, { type: 'writeStarted', target: 'speed', value: 2, requestId: 'old' });
|
||||
state = remoteSessionReducer(state, { type: 'writeStarted', target: 'speed', value: 3, requestId: 'new' });
|
||||
state = remoteSessionReducer(state, { type: 'writeResult', target: 'speed', requestId: 'old', ok: false });
|
||||
expect(state.values.speed).toBe(3);
|
||||
expect(state.pendingWrites.speed?.requestId).toBe('new');
|
||||
|
||||
state = remoteSessionReducer(state, { type: 'writeResult', target: 'speed', requestId: 'new', ok: false });
|
||||
expect(state.values.speed).toBe(1);
|
||||
expect(state.pendingWrites.speed).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function helloAck(protocolVersion: number): HelloAckMessage {
|
||||
return {
|
||||
type: 'hello_ack',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: 'hello',
|
||||
payload: {
|
||||
sessionId: 'session',
|
||||
accepted: true,
|
||||
serverVersion: 'test',
|
||||
protocolVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function initialState(): RemoteSessionState {
|
||||
return { ...createInitialRemoteSessionState(), wsUrl: 'ws://test' };
|
||||
}
|
||||
|
||||
function withConfirmedValue(value: unknown): RemoteSessionState {
|
||||
return {
|
||||
...initialState(),
|
||||
values: { speed: value },
|
||||
confirmedValues: { speed: value },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
GameStatusPayload,
|
||||
InstalledAppSummary,
|
||||
InstalledAppsPayload,
|
||||
TrainerMetaPayload,
|
||||
} from '../../protocol/messages';
|
||||
import { readInitialWebSocketUrl } from './remote-session.urls';
|
||||
|
||||
export enum EConnectionStatus {
|
||||
Idle = 'idle',
|
||||
Connecting = 'connecting',
|
||||
Reconnecting = 'reconnecting',
|
||||
Connected = 'connected',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export type PendingWrite = {
|
||||
requestId: string;
|
||||
value: unknown;
|
||||
previousConfirmedValue: unknown;
|
||||
};
|
||||
|
||||
export type RemoteSessionState = {
|
||||
connectionStatus: EConnectionStatus;
|
||||
wsUrl: string;
|
||||
trainerMeta: TrainerMetaPayload | null;
|
||||
gameStatus: GameStatusPayload | null;
|
||||
installedApps: InstalledAppSummary[];
|
||||
values: Record<string, unknown>;
|
||||
confirmedValues: Record<string, unknown>;
|
||||
pendingWrites: Record<string, PendingWrite>;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export type RemoteSessionAction =
|
||||
| { type: 'setWsUrl'; wsUrl: string }
|
||||
| { type: 'connecting'; reconnecting?: boolean }
|
||||
| { type: 'connected' }
|
||||
| { type: 'connectionClosed'; message: string }
|
||||
| { type: 'disconnected' }
|
||||
| { type: 'trainerMeta'; payload: TrainerMetaPayload }
|
||||
| { type: 'gameStatus'; payload: GameStatusPayload }
|
||||
| { type: 'installedApps'; payload: InstalledAppsPayload }
|
||||
| { type: 'trainerValues'; payload: Record<string, unknown> }
|
||||
| { type: 'valueChanged'; target: string; value: unknown }
|
||||
| { type: 'writeStarted'; target: string; value: unknown; requestId: string }
|
||||
| { type: 'writeResult'; target: string; requestId: string | null; ok: boolean; message?: string }
|
||||
| { type: 'trainerChanged' }
|
||||
| { type: 'error'; message: string | null };
|
||||
|
||||
export function createInitialRemoteSessionState(): RemoteSessionState {
|
||||
return {
|
||||
connectionStatus: EConnectionStatus.Idle,
|
||||
wsUrl: readInitialWebSocketUrl(),
|
||||
trainerMeta: null,
|
||||
gameStatus: null,
|
||||
installedApps: [],
|
||||
values: {},
|
||||
confirmedValues: {},
|
||||
pendingWrites: {},
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function remoteSessionReducer(
|
||||
state: RemoteSessionState,
|
||||
action: RemoteSessionAction,
|
||||
): RemoteSessionState {
|
||||
switch (action.type) {
|
||||
case 'setWsUrl':
|
||||
return { ...state, wsUrl: action.wsUrl };
|
||||
case 'connecting':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: action.reconnecting ? EConnectionStatus.Reconnecting : EConnectionStatus.Connecting,
|
||||
lastError: null,
|
||||
};
|
||||
case 'connected':
|
||||
return { ...state, connectionStatus: EConnectionStatus.Connected, lastError: null };
|
||||
case 'connectionClosed':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: EConnectionStatus.Reconnecting,
|
||||
pendingWrites: {},
|
||||
lastError: action.message,
|
||||
};
|
||||
case 'disconnected':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: EConnectionStatus.Idle,
|
||||
trainerMeta: null,
|
||||
gameStatus: null,
|
||||
values: {},
|
||||
confirmedValues: {},
|
||||
pendingWrites: {},
|
||||
lastError: null,
|
||||
};
|
||||
case 'trainerMeta':
|
||||
return { ...state, trainerMeta: action.payload, pendingWrites: {} };
|
||||
case 'gameStatus':
|
||||
return { ...state, gameStatus: action.payload };
|
||||
case 'installedApps':
|
||||
return { ...state, installedApps: action.payload.apps };
|
||||
case 'trainerValues':
|
||||
return {
|
||||
...state,
|
||||
values: action.payload,
|
||||
confirmedValues: action.payload,
|
||||
pendingWrites: {},
|
||||
};
|
||||
case 'valueChanged':
|
||||
return applyConfirmedValue(state, action.target, action.value);
|
||||
case 'writeStarted':
|
||||
return {
|
||||
...state,
|
||||
values: { ...state.values, [action.target]: action.value },
|
||||
pendingWrites: {
|
||||
...state.pendingWrites,
|
||||
[action.target]: {
|
||||
requestId: action.requestId,
|
||||
value: action.value,
|
||||
previousConfirmedValue: state.confirmedValues[action.target],
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'writeResult':
|
||||
return applyWriteResult(state, action);
|
||||
case 'trainerChanged':
|
||||
return {
|
||||
...state,
|
||||
trainerMeta: null,
|
||||
values: {},
|
||||
confirmedValues: {},
|
||||
pendingWrites: {},
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: action.message && state.connectionStatus !== EConnectionStatus.Connected
|
||||
? EConnectionStatus.Error
|
||||
: state.connectionStatus,
|
||||
lastError: action.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function applyConfirmedValue(
|
||||
state: RemoteSessionState,
|
||||
target: string,
|
||||
value: unknown,
|
||||
): RemoteSessionState {
|
||||
const pending = state.pendingWrites[target];
|
||||
const pendingWrites = { ...state.pendingWrites };
|
||||
if (pending && Object.is(pending.value, value)) {
|
||||
delete pendingWrites[target];
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
values: { ...state.values, [target]: value },
|
||||
confirmedValues: { ...state.confirmedValues, [target]: value },
|
||||
pendingWrites,
|
||||
};
|
||||
}
|
||||
|
||||
function applyWriteResult(
|
||||
state: RemoteSessionState,
|
||||
action: Extract<RemoteSessionAction, { type: 'writeResult' }>,
|
||||
): RemoteSessionState {
|
||||
const pending = state.pendingWrites[action.target];
|
||||
if (!pending || !action.requestId || pending.requestId !== action.requestId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const pendingWrites = { ...state.pendingWrites };
|
||||
delete pendingWrites[action.target];
|
||||
|
||||
if (action.ok) {
|
||||
return {
|
||||
...state,
|
||||
confirmedValues: { ...state.confirmedValues, [action.target]: pending.value },
|
||||
pendingWrites,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
values: { ...state.values, [action.target]: pending.previousConfirmedValue },
|
||||
pendingWrites,
|
||||
lastError: action.message ?? 'The trainer rejected the requested value.',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { WEB_CONTRACT } from '../../protocol/contract';
|
||||
|
||||
export const WS_QUERY_PARAM = 'ws';
|
||||
|
||||
const DEV_SERVER_PORTS = new Set(['4173', '5173']);
|
||||
|
||||
function protocolForWebSocket(): 'ws' | 'wss' {
|
||||
return window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
}
|
||||
|
||||
function isServedByRemoteBridge(): boolean {
|
||||
return window.location.pathname.startsWith(WEB_CONTRACT.basePath) && !DEV_SERVER_PORTS.has(window.location.port);
|
||||
}
|
||||
|
||||
export function readInitialWebSocketUrl(): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const explicitUrl = params.get(WS_QUERY_PARAM)?.trim();
|
||||
if (explicitUrl) {
|
||||
return explicitUrl;
|
||||
}
|
||||
|
||||
if (isServedByRemoteBridge()) {
|
||||
return `${protocolForWebSocket()}://${window.location.host}${WEB_CONTRACT.webSocketPath}`;
|
||||
}
|
||||
|
||||
return `ws://127.0.0.1:${WEB_CONTRACT.defaultRemotePort}${WEB_CONTRACT.webSocketPath}`;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { EConnectionStatus, type RemoteSessionState } from './remote-session.reducer';
|
||||
|
||||
export function selectIsConnected(state: RemoteSessionState): boolean {
|
||||
return state.connectionStatus === EConnectionStatus.Connected;
|
||||
}
|
||||
|
||||
export function selectPendingTargets(state: RemoteSessionState): Record<string, boolean> {
|
||||
return Object.fromEntries(Object.keys(state.pendingWrites).map((target) => [target, true]));
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
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,
|
||||
remoteSessionReducer,
|
||||
type RemoteSessionState,
|
||||
} from './remote-session.reducer';
|
||||
import { protocolAction } from './remote-session.protocol';
|
||||
import { selectIsConnected, selectPendingTargets } from './selectors';
|
||||
|
||||
const RECONNECT_DELAY_MS = 2000;
|
||||
|
||||
export function useRemoteSession() {
|
||||
const [state, dispatch] = useReducer(remoteSessionReducer, undefined, createInitialRemoteSessionState);
|
||||
const stateRef = useRef(state);
|
||||
const clientRef = useRef<RemoteSessionClient | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
const connectRef = useRef<() => void>(() => {});
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
const clearReconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current !== null) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleReconnect = useCallback(() => {
|
||||
clearReconnect();
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
if (document.visibilityState === 'visible' && stateRef.current.wsUrl.trim()) {
|
||||
connectRef.current();
|
||||
}
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}, [clearReconnect]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
clientRef.current?.disconnect();
|
||||
clearReconnect();
|
||||
|
||||
const wsUrl = stateRef.current.wsUrl.trim();
|
||||
if (!wsUrl) {
|
||||
dispatch({ type: 'error', message: 'Enter a WebSocket URL first.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new RemoteSessionClient(wsUrl, {
|
||||
onConnecting: () => dispatch({
|
||||
type: 'connecting',
|
||||
reconnecting: stateRef.current.connectionStatus === EConnectionStatus.Reconnecting,
|
||||
}),
|
||||
onTransportOpen: () => undefined,
|
||||
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: () => {
|
||||
dispatch({ type: 'connectionClosed', message: 'The WebSocket connection closed. Reconnecting...' });
|
||||
scheduleReconnect();
|
||||
},
|
||||
onError: (message) => dispatch({ type: 'error', message }),
|
||||
});
|
||||
|
||||
clientRef.current = client;
|
||||
client.connect();
|
||||
}, [clearReconnect, scheduleReconnect]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
clearReconnect();
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
dispatch({ type: 'disconnected' });
|
||||
}, [clearReconnect]);
|
||||
|
||||
const setWsUrl = useCallback((wsUrl: string) => dispatch({ type: 'setWsUrl', wsUrl }), []);
|
||||
const reportError = useCallback((message: string | null) => dispatch({ type: 'error', message }), []);
|
||||
|
||||
const changeCheat = useCallback((cheat: CheatSchema, nextValue: unknown) => {
|
||||
const current = stateRef.current;
|
||||
if (current.connectionStatus !== EConnectionStatus.Connected || !current.trainerMeta) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not connected.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const value = normalizeCheatValue(cheat, nextValue);
|
||||
const requestId = clientRef.current?.setValue(
|
||||
current.trainerMeta.trainer.trainerId,
|
||||
cheat.target,
|
||||
value,
|
||||
cheat.uuid,
|
||||
) ?? null;
|
||||
if (!requestId) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
dispatch({ type: 'writeStarted', target: cheat.target, value, requestId });
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const launchGame = useCallback((app: InstalledAppSummary): boolean => {
|
||||
if (!app.gameId) {
|
||||
dispatch({ type: 'error', message: 'This My Games entry does not expose a Wand game id.' });
|
||||
return false;
|
||||
}
|
||||
if (!isReadyToSend(stateRef.current, clientRef.current)) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not connected.' });
|
||||
return false;
|
||||
}
|
||||
if (!clientRef.current?.launchGame(app.gameId, app.titleId ?? undefined)) {
|
||||
dispatch({ type: 'error', message: 'Failed to send the launch command to the bridge.' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const stopPlaying = useCallback(() => {
|
||||
const current = stateRef.current;
|
||||
if (!isReadyToSend(current, clientRef.current)) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not connected.' });
|
||||
return;
|
||||
}
|
||||
const gameId = current.gameStatus?.session.gameId ?? current.gameStatus?.trainer.gameId ?? undefined;
|
||||
const titleId = current.gameStatus?.session.titleId ?? current.gameStatus?.trainer.titleId ?? undefined;
|
||||
if (!clientRef.current?.stopPlaying(gameId ?? undefined, titleId ?? undefined)) {
|
||||
dispatch({ type: 'error', message: 'Failed to send the stop command to the bridge.' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
connectRef.current = connect;
|
||||
}, [connect]);
|
||||
|
||||
useEffect(() => {
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && !clientRef.current?.isOpen()) {
|
||||
connectRef.current();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (stateRef.current.wsUrl.trim()) {
|
||||
connectRef.current();
|
||||
}
|
||||
return () => {
|
||||
clearReconnect();
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, [clearReconnect]);
|
||||
|
||||
const connected = selectIsConnected(state);
|
||||
const pendingTargets = useMemo(() => selectPendingTargets(state), [state]);
|
||||
|
||||
return {
|
||||
state,
|
||||
connected,
|
||||
pendingTargets,
|
||||
socketReady: connected,
|
||||
connect,
|
||||
disconnect,
|
||||
setWsUrl,
|
||||
reportError,
|
||||
changeCheat,
|
||||
launchGame,
|
||||
stopPlaying,
|
||||
};
|
||||
}
|
||||
|
||||
function isReadyToSend(state: RemoteSessionState, client: RemoteSessionClient | null): boolean {
|
||||
return state.connectionStatus === EConnectionStatus.Connected && Boolean(client?.isOpen());
|
||||
}
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadStringSet, saveStringSet } from './storage';
|
||||
|
||||
describe('storage revival', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('revives only valid string ids', () => {
|
||||
localStorage.setItem('pins', JSON.stringify(['one', '', 2, 'two']));
|
||||
expect(loadStringSet('pins')).toEqual({ one: true, two: true });
|
||||
});
|
||||
|
||||
it('removes empty sets', () => {
|
||||
saveStringSet('pins', { one: true });
|
||||
expect(localStorage.getItem('pins')).toBe(JSON.stringify(['one']));
|
||||
saveStringSet('pins', {});
|
||||
expect(localStorage.getItem('pins')).toBeNull();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { TrainerSummary } from './protocol';
|
||||
import type { TrainerSummary } from '../../protocol/messages';
|
||||
|
||||
type Reviver<T> = (raw: unknown) => T | null;
|
||||
|
||||
Vendored
+5
-2
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
const DRAWER_SIDE_CLASSES = {
|
||||
left: 'left-0 border-r',
|
||||
@@ -23,6 +25,7 @@ type DrawerProps = {
|
||||
};
|
||||
|
||||
export const Drawer = ({ open, side, children, onClose }: DrawerProps) => {
|
||||
const { _ } = useLingui();
|
||||
const sideClassName = DRAWER_SIDE_CLASSES[side];
|
||||
const closedClassName = DRAWER_CLOSED_CLASSES[side];
|
||||
|
||||
@@ -30,7 +33,7 @@ export const Drawer = ({ open, side, children, onClose }: DrawerProps) => {
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close drawer"
|
||||
aria-label={_(msg`Close drawer`)}
|
||||
className={cn(DRAWER_OVERLAY_CLASS, open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
+6
-3
@@ -1,8 +1,10 @@
|
||||
import type { FormEvent } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
type SearchInputProps = {
|
||||
value: string;
|
||||
@@ -12,6 +14,7 @@ type SearchInputProps = {
|
||||
};
|
||||
|
||||
export const SearchInput = ({ value, placeholder, className, onChange }: SearchInputProps) => {
|
||||
const { _ } = useLingui();
|
||||
const handleInput = (event: FormEvent<HTMLInputElement>) => onChange(event.currentTarget.value);
|
||||
const handleClear = () => onChange('');
|
||||
|
||||
@@ -26,7 +29,7 @@ export const SearchInput = ({ value, placeholder, className, onChange }: SearchI
|
||||
onInput={handleInput}
|
||||
/>
|
||||
{value ? (
|
||||
<button type="button" aria-label="Clear search" className="flex size-6 items-center justify-center rounded-[7px] text-(--deck-fg-3) hover:bg-white/6 hover:text-(--deck-fg)" onClick={handleClear}>
|
||||
<button type="button" aria-label={_(msg`Clear search`)} className="flex size-6 items-center justify-center rounded-[7px] text-(--deck-fg-3) hover:bg-white/6 hover:text-(--deck-fg)" onClick={handleClear}>
|
||||
<Icon className="size-3.5" name="x" />
|
||||
</button>
|
||||
) : null}
|
||||
+6
-2
@@ -1,10 +1,14 @@
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
|
||||
import type { ControlInternalProps } from './shared';
|
||||
|
||||
export const ActionButton = ({ cheat, disabled, onChange }: ControlInternalProps) => {
|
||||
const { _ } = useLingui();
|
||||
const handleClick = () => onChange(1);
|
||||
const label = typeof cheat.args.button === 'string' ? cheat.args.button : 'Apply';
|
||||
const label = typeof cheat.args.button === 'string' ? cheat.args.button : _(msg`Apply`);
|
||||
|
||||
return (
|
||||
<button
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
import type { CheatSchema } from '../protocol';
|
||||
import { ECheatType } from '../protocol';
|
||||
import type { CheatSchema } from '../../../protocol/messages';
|
||||
import { ECheatType } from '../../../protocol/messages';
|
||||
import { ActionButton } from './ActionButton';
|
||||
import { IncrementalControl } from './IncrementalControl';
|
||||
import { NumberControl } from './NumberControl';
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import { resolveOption } from '../protocol';
|
||||
import { resolveOption } from '../model/values';
|
||||
import { ActionButton } from './ActionButton';
|
||||
import { StepButton, type ControlInternalProps } from './shared';
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { type FormEvent } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import { formatInputNumber, numericValue, stripNumberGrouping } from './format-number';
|
||||
import { StepButton, type ControlInternalProps } from './shared';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user