diff --git a/.gitattributes b/.gitattributes index 3c722334..03757c2a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,44 @@ +# Auto-detect text files and normalize line endings to LF +* text=auto eol=lf + +# Web panel & source files +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.mjs text eol=lf +*.cjs text eol=lf +*.json text eol=lf +*.css text eol=lf +*.html text eol=lf +*.md text eol=lf +*.svg text eol=lf +*.po text eol=lf + +# Windows scripts / C# solutions +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=lf +*.cs text eol=crlf +*.sln text eol=crlf +*.csproj text eol=crlf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.dll binary +*.exe binary +*.zip binary + +# GitHub Linguist # 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. +# language statistics - the project is a C# app, not a TypeScript one. web-panel/** linguist-vendored diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 796047a5..e6cb57f1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,9 @@ name: Build executable on: workflow_dispatch: + pull_request: + push: + branches: [master] jobs: build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8108b492..d625899b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,3 +50,7 @@ jobs: body_path: release-notes.md files: CHANGELOG.md fail_on_unmatched_files: true + # A tag carrying a suffix (1.1.0.0-rc.1) publishes as a pre-release and + # does not become the "Latest release" on the repository page. + prerelease: ${{ contains(github.ref_name, '-') }} + make_latest: ${{ !contains(github.ref_name, '-') }} diff --git a/.gitignore b/.gitignore index f782a44b..75af64a6 100644 --- a/.gitignore +++ b/.gitignore @@ -145,4 +145,6 @@ appsettings.json *DotSettings.user .tmp .source -web-panel/bridge/wand-remote-bridge.cjs \ No newline at end of file +web-panel/bridge/wand-remote-bridge.cjs +.vscode +docs \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index bd3fab1d..167fcb09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,18 @@ 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 `` placeholder) and `setAccountLanguage` (`BuildSetAccountLanguagePatch` PatchFactory — captures the real param names + the original `post("/v3/account/language",{...})` expr and wraps `.then`). A fourth patch (`setAccountReducer`) rewrites the `ACTION_SET_ACCOUNT` store reducer so any account write (periodic `refreshAccount`, push/profile updates, etc.) keeps Pro even when it bypasses those API methods. Pro is `am(account) = !!account.subscription` (flags/512 are irrelevant). `setAccountLanguage` is the one the original two patches missed, which is why Pro dropped on language change. If a future Wand build changes these method bodies, re-derive the regexes against the live `app-*.bundle.js` (do NOT trust `.source/new` — it is a different version). +- Pro activation is a C# asar patch (`EPatchType.ActivatePro`, independent of the remote panel / bridge). It wraps the returned promise of three account-returning service methods so `subscription:{period:"yearly",state:"active"}` is injected before the response reaches the store: `getUserAccount`, `setAccountWandBrandExperience` and `setAccountLanguage`. A fourth patch (`setAccountReducer`) rewrites the `ACTION_SET_ACCOUNT` store reducer so any account write (periodic `refreshAccount`, push/profile updates, etc.) keeps Pro even when it bypasses those API methods. Pro is `am(account) = !!account.subscription` (flags/512 are irrelevant). `setAccountLanguage` is the one the original two patches missed, which is why Pro dropped on language change. `setAccountWandBrandExperience` does not exist on every build, so it is declared optional through `CapabilityHints`. + +## Patch Engine + +- Patches are located structurally, not by shape. A patch anchors on something Wand does not rename between builds — an API endpoint, an IPC channel name, a public method name — and then walks the delimiter structure (`Core/Js/JsCursor.cs`) to the edit site. Identifiers that do change (`#Xe`, `l.vO`, the numeric Remote source) are read out of the located region, never baked into a pattern. A rebuild that only reminifies therefore needs no change here. +- Never write a regex that spans a whole method body or matches across a bundle. Scope patterns to a located `JsFunction` via `Resolve`, where they run against a few hundred characters instead of megabytes. +- A patch is one `PatchEntry` in `Core/EnhancerConfig.cs` with a `Locate` delegate returning the edits to splice. Return `null` when the anchor is absent from this file — that means "not my file", not "failure". Throw only when the anchor IS present but the surrounding structure is unrecognisable; that is a genuinely unsupported build and must fail loudly. +- Injected JavaScript lives in `WandEnhancer/Patches/*.js` and is embedded as `patches/.js`. Load it with `PatchPayload.Load(name, "key", value, ...)`, which fills `${key}` placeholders in one pass. Do not put payload JS back into C# string literals. +- Multiple edits from one patch are applied highest-offset-first, so their positions stay valid. Keep them non-overlapping. +- A patch that only exists on some builds sets `CapabilityHints`: absent capability logs a skip, a detected-but-unpatchable capability still fails the run. +- When a build really does restructure something, add a fallback branch inside that patch's `Locate` rather than a version table — old shapes keep working because the old branch is still there. +- Verify against real bundles, minified and prettified, before shipping: locating must succeed on both and the patched files must pass `node --check`. ## ASAR Patch Pipeline diff --git a/AsarSharp/AsarCreator.cs b/AsarSharp/AsarCreator.cs index 9b1276f7..804f69fc 100644 --- a/AsarSharp/AsarCreator.cs +++ b/AsarSharp/AsarCreator.cs @@ -73,22 +73,26 @@ private void HandleFile(Filesystem filesystem, string filename, List + /// Matches the directory path (relative to the archive root) against the unpack regex. + /// + private bool ShouldUnpackPath(string relativeParentPath) { - return _options?.Unpack?.IsMatch(relativePath) == true; + return _options?.Unpack?.IsMatch(relativeParentPath) == true; } private void InsertsDone(Filesystem filesystem, List files) { - Directory.CreateDirectory( - Path.GetDirectoryName(_destPath) - ?? throw new InvalidOperationException()); + string dir = Path.GetDirectoryName(_destPath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + Disk.WriteFileSystem(_destPath, filesystem, - new Disk.FilesystemFilesAndLinks { Files = files, Links = null }, _metadata); + new Disk.FilesystemFilesAndLinks { Files = files }, _metadata); } } } diff --git a/AsarSharp/AsarExtractor.cs b/AsarSharp/AsarExtractor.cs index 96aca5dc..29ba9d9a 100644 --- a/AsarSharp/AsarExtractor.cs +++ b/AsarSharp/AsarExtractor.cs @@ -159,13 +159,6 @@ private static void ExtractLink(string dest, string fullPath, string destFilenam FilesystemEntry file, HashSet dirCache) { var linkSrcPath = Extensions.GetDirectoryName(Path.Combine(dest, file.Link)); - var linkDestPath = Extensions.GetDirectoryName(destFilename); - var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath); - - try { File.Delete(destFilename); } - catch { /* ignore — failing to remove an existing link is non-fatal */ } - - var linkTo = Path.Combine(relativeLinkPath, Path.GetFileName(file.Link)); if (!Extensions.IsPathInside(dest, linkSrcPath)) { @@ -173,6 +166,12 @@ private static void ExtractLink(string dest, string fullPath, string destFilenam $"{fullPath}: file \"{file.Link}\" links out of the package to \"{linkSrcPath}\""); } + try { File.Delete(destFilename); } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + { + // Nothing to replace, or the old entry is locked; the copy below reports the real failure. + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var targetPath = Path.Combine(linkSrcPath, Path.GetFileName(file.Link)); @@ -189,8 +188,10 @@ private static void ExtractLink(string dest, string fullPath, string destFilenam } else { + var linkDestPath = Extensions.GetDirectoryName(destFilename); + var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath); EnsureParentDir(destFilename, dirCache); - Extensions.CreateSymbolicLink(linkTo, destFilename); + Extensions.CreateSymbolicLink(Path.Combine(relativeLinkPath, Path.GetFileName(file.Link)), destFilename); } } } diff --git a/AsarSharp/AsarFileSystem/Disk.cs b/AsarSharp/AsarFileSystem/Disk.cs index d83e11a9..e088b3a1 100644 --- a/AsarSharp/AsarFileSystem/Disk.cs +++ b/AsarSharp/AsarFileSystem/Disk.cs @@ -12,8 +12,6 @@ namespace AsarSharp.AsarFileSystem public static class Disk { private const int StreamBufferSize = 1024 * 1024; - private static readonly ConcurrentDictionary _filesystemCache = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); public class ArchiveHeader { @@ -25,7 +23,6 @@ public class ArchiveHeader public class FilesystemFilesAndLinks { public List Files { get; set; } = new List(); - public List Links { get; set; } = new List(); } public class BasicFileInfo @@ -42,14 +39,14 @@ public static ArchiveHeader ReadArchiveHeaderSync(string archivePath) 65536, FileOptions.SequentialScan)) { byte[] sizeBuf = new byte[8]; - if (fs.Read(sizeBuf, 0, 8) != 8) + if (fs.ReadFull(sizeBuf, 0, 8) != 8) throw new Exception("Unable to read header size"); var sizePickle = Pickle.CreateFromBuffer(sizeBuf); var size = sizePickle.CreateIterator().ReadUInt32(); var headerBuf = new byte[size]; - if (fs.Read(headerBuf, 0, (int)size) != size) + if (fs.ReadFull(headerBuf, 0, (int)size) != size) throw new Exception("Unable to read header"); var headerPickle = Pickle.CreateFromBuffer(headerBuf); @@ -65,62 +62,28 @@ public static ArchiveHeader ReadArchiveHeaderSync(string archivePath) } } + /// + /// Reads the header fresh every time: an archive is repacked in place during a patch run, + /// so a cached header would hand out stale offsets on the next read of the same path. + /// public static Filesystem ReadFilesystemSync(string archivePath) { - return _filesystemCache.GetOrAdd(archivePath, key => - { - var header = ReadArchiveHeaderSync(key); - var filesystem = new Filesystem(key); - filesystem.SetHeader(header.Header, header.HeaderSize); - return filesystem; - }); - } - - public static byte[] ReadFileSync(Filesystem filesystem, string filename, FilesystemEntry info) - { - if (!info.IsFile || !info.Size.HasValue) - throw new ArgumentException("Entry is not a file", nameof(info)); - - long size = info.Size.Value; - byte[] buffer = new byte[size]; - - if (size <= 0) return buffer; - - if (info.Unpacked == true) - { - string filePath = Path.Combine($"{filesystem.GetRootPath()}.unpacked", filename); - return File.ReadAllBytes(filePath); - } - - using (var fs = new FileStream(filesystem.GetRootPath(), FileMode.Open, FileAccess.Read, - FileShare.Read, 65536, FileOptions.RandomAccess)) - { - long offset = 8 + filesystem.GetHeaderSize() + long.Parse(info.Offset); - fs.Position = offset; - int bytesRead = fs.Read(buffer, 0, (int)size); - if (bytesRead != size) - throw new Exception($"Failed to read entire file, got {bytesRead} bytes instead of {size}"); - } - - return buffer; + var header = ReadArchiveHeaderSync(archivePath); + var filesystem = new Filesystem(archivePath); + filesystem.SetHeader(header.Header, header.HeaderSize); + return filesystem; } #endregion - public static bool UncacheFilesystem(string archivePath) - { - return _filesystemCache.TryRemove(archivePath, out _); - } - - public static void UncacheAll() - { - _filesystemCache.Clear(); - } - public static void CopyFile(string dest, string rootPath, string filename) { - if (dest == null || rootPath == null || filename == null) - throw new ArgumentNullException(); + if (dest == null) + throw new ArgumentNullException(nameof(dest)); + if (rootPath == null) + throw new ArgumentNullException(nameof(rootPath)); + if (filename == null) + throw new ArgumentNullException(nameof(filename)); string normalizedDestRoot = Path.GetFullPath(dest) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); @@ -165,7 +128,56 @@ public static void WriteFileSystem(string dest, Filesystem fileSystem, var buf = new byte[StreamBufferSize]; var blockBuf = new byte[4 * 1024 * 1024]; // shared across all files — avoids 4MB alloc per file - using (var fs = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan)) + // Build beside the target and swap at the end. Writing straight into dest truncates + // it on open, so any failure mid-write left the caller with a destroyed archive. + string tempPath = dest + ".building"; + try + { + WriteArchive(tempPath, dest, fileSystem, lists, serializerSettings, + headerPickle, sizePickle, sizePickleSize, buf, blockBuf); + ReplaceFile(tempPath, dest); + } + catch + { + TryDelete(tempPath); + throw; + } + } + + private static void ReplaceFile(string tempPath, string dest) + { + if (!File.Exists(dest)) + { + File.Move(tempPath, dest); + return; + } + + // A read-only or hidden archive would fail the swap the same way an overwrite does. + Extensions.ClearAttributes(dest); + // File.Replace swaps in one step, so dest is never observed missing or half-written. + File.Replace(tempPath, dest, null, true); + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + { + // Leftover build file only wastes space; the real failure is already propagating. + } + } + + private static void WriteArchive(string archivePath, string dest, Filesystem fileSystem, + FilesystemFilesAndLinks lists, JsonSerializerSettings serializerSettings, + Pickle headerPickle, Pickle sizePickle, int sizePickleSize, byte[] buf, byte[] blockBuf) + { + using (var fs = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan)) { sizePickle.WriteTo(fs); headerPickle.WriteTo(fs); @@ -192,6 +204,18 @@ public static void WriteFileSystem(string dest, Filesystem fileSystem, var patchedSizePickle = Pickle.CreateEmpty(); patchedSizePickle.WriteUInt32((uint)patchedPickle.GetTotalSize()); + // The rewrite lands on top of the placeholder header, so it must be exactly as + // long. Placeholder hashes are the same width as real ones, so this holds unless + // a file changed size between crawl and write - which would silently shred the + // payload that follows. + if (patchedPickle.GetTotalSize() != headerPickle.GetTotalSize() || + patchedSizePickle.GetTotalSize() != sizePickleSize) + { + throw new InvalidOperationException( + "ASAR header changed size while packing (a source file was modified mid-build). " + + "Aborting rather than writing a corrupt archive."); + } + fs.Position = 0; patchedSizePickle.WriteTo(fs); patchedPickle.WriteTo(fs); diff --git a/AsarSharp/AsarFileSystem/FileSystem.cs b/AsarSharp/AsarFileSystem/FileSystem.cs index e577c4ed..0410245e 100644 --- a/AsarSharp/AsarFileSystem/FileSystem.cs +++ b/AsarSharp/AsarFileSystem/FileSystem.cs @@ -33,7 +33,7 @@ public void SetHeader(FilesystemEntry header, int headerSize) _headerSize = headerSize; } - public FilesystemEntry SearchNodeFromDirectory(string p) + public FilesystemEntry SearchNodeFromDirectory(string p, bool create = true) { FilesystemEntry json = _header; @@ -59,12 +59,31 @@ public FilesystemEntry SearchNodeFromDirectory(string p) string seg = p.Substring(start, segLen); if (!json.IsDirectory) - throw new Exception($"Unexpected directory state while traversing: {p}"); + { + if (create) + throw new Exception($"Unexpected directory state while traversing: {p}"); + return null; + } + + if (json.Files == null) + { + if (create) + json.Files = new Dictionary(StringComparer.Ordinal); + else + return null; + } if (!json.Files.TryGetValue(seg, out var child)) { - child = new FilesystemEntry { Files = new Dictionary(StringComparer.Ordinal) }; - json.Files[seg] = child; + if (create) + { + child = new FilesystemEntry { Files = new Dictionary(StringComparer.Ordinal) }; + json.Files[seg] = child; + } + else + { + return null; + } } json = child; start = end + 1; @@ -81,7 +100,7 @@ public FilesystemEntry SearchNodeFromDirectory(string p) string name = Path.GetFileName(rel); string dir = Extensions.GetDirectoryName(rel); - var parent = SearchNodeFromDirectory(dir); + var parent = SearchNodeFromDirectory(dir, true); if (parent.Files == null) parent.Files = new Dictionary(StringComparer.Ordinal); @@ -111,18 +130,23 @@ void FillFilesFromMetadata(string basePath, FilesystemEntry metadata) } } - public FilesystemEntry GetNode(string p, bool followLinks = true) + public FilesystemEntry GetNode(string p, bool followLinks = true, int linkDepth = 0) { + if (linkDepth > 40) + throw new Exception($"Symlink loop detected at {p}"); + p = p.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); - FilesystemEntry node = SearchNodeFromDirectory(Extensions.GetDirectoryName(p)); + FilesystemEntry node = SearchNodeFromDirectory(Extensions.GetDirectoryName(p), false); + if (node == null) + return null; string name = Path.GetFileName(p); if (node.IsLink && followLinks) - return GetNode(Path.Combine(node.Link, name)); + return GetNode(Path.Combine(node.Link, name), followLinks, linkDepth + 1); if (!string.IsNullOrEmpty(name)) { - if (node.IsDirectory && node.Files.TryGetValue(name, out var entry)) + if (node.IsDirectory && node.Files != null && node.Files.TryGetValue(name, out var entry)) return entry; return null; } @@ -130,16 +154,17 @@ public FilesystemEntry GetNode(string p, bool followLinks = true) return node; } - public FilesystemEntry GetFile(string p, bool followLinks = true) + public FilesystemEntry GetFile(string p, bool followLinks = true, int linkDepth = 0) { - FilesystemEntry info = GetNode(p, followLinks); + if (linkDepth > 40) + throw new Exception($"Symlink loop detected at {p}"); + + FilesystemEntry info = GetNode(p, followLinks, linkDepth); if (info == null) throw new Exception($"\"{p}\" was not found in this archive"); - if (info.IsLink && followLinks) return GetFile(info.Link, followLinks); + if (info.IsLink && followLinks) return GetFile(info.Link, followLinks, linkDepth + 1); return info; } - public static string ReadLink(string path) => throw new NotImplementedException(); - #region Writing public FilesystemEntry SearchNodeFromPath(string p) @@ -159,7 +184,7 @@ public void InsertDirectory(string p, bool unpack) public void InsertFile(string path, bool shouldUnpack, CrawledFileType file, IntegrityHelper.FileIntegrity precomputedIntegrity = null) { - var (dirNode, _) = SearchNodeFromPathWithParent(Path.GetDirectoryName(path) ?? path); + var (dirNode, _) = SearchNodeFromPathWithParent(path); var node = SearchNodeFromPath(path); long size; diff --git a/AsarSharp/AsarFileSystem/FileSystemCrawler.cs b/AsarSharp/AsarFileSystem/FileSystemCrawler.cs index a4ee35ef..6bf5ea9b 100644 --- a/AsarSharp/AsarFileSystem/FileSystemCrawler.cs +++ b/AsarSharp/AsarFileSystem/FileSystemCrawler.cs @@ -9,13 +9,6 @@ public class CrawledFileType { public FileType Type { get; set; } public FileSystemInfo Stat { get; set; } - public TransformedFile Transformed { get; set; } - } - - public class TransformedFile - { - public string Path { get; set; } - public FileSystemInfo Stat { get; set; } } public enum FileType @@ -36,7 +29,7 @@ public static CrawledFileType DetermineFileType(string filename) } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { - return null; + throw new IOException($"Failed to read attributes for '{filename}'", ex); } bool isDirectory = (attributes & FileAttributes.Directory) == FileAttributes.Directory; @@ -59,7 +52,6 @@ public static (List filenames, Dictionary metad foreach (var fullPath in CrawlIterative(dir)) { var type = DetermineFileType(fullPath); - if (type == null) continue; metadata[fullPath] = type; if (type.Type == FileType.Link) links.Add(fullPath); filenames.Add(fullPath); @@ -77,7 +69,8 @@ public static (List filenames, Dictionary metad { if (string.Equals(filename, link, StringComparison.OrdinalIgnoreCase)) continue; - if (filename.StartsWith(link, StringComparison.OrdinalIgnoreCase)) + // Require a separator after the prefix so "…/foobar" does not match link "…/foo". + if (filename.StartsWith(link + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { string rel = Extensions.GetRelativePath(link, fileDir); if (!rel.StartsWith("..", StringComparison.Ordinal)) @@ -120,7 +113,7 @@ public static List CrawlIterative(string dir) foreach (var entry in entries) { result.Add(entry.FullName); - if (entry is DirectoryInfo subDir) + if (entry is DirectoryInfo subDir && (subDir.Attributes & FileAttributes.ReparsePoint) == 0) stack.Push(subDir); } } diff --git a/AsarSharp/Integrity/IntegrityHelper.cs b/AsarSharp/Integrity/IntegrityHelper.cs index f6c5302d..fb765824 100644 --- a/AsarSharp/Integrity/IntegrityHelper.cs +++ b/AsarSharp/Integrity/IntegrityHelper.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Security.Cryptography; +using AsarSharp.Utils; using Newtonsoft.Json; namespace AsarSharp.Integrity @@ -60,7 +61,9 @@ public static FileIntegrity GetFileIntegrity(string path, byte[] reusableBuffer var blockHashes = new List(estimatedBlockCount); int bytesRead; - while ((bytesRead = fileStream.Read(reusableBuffer, 0, reusableBuffer.Length)) > 0) + // ReadFull, not Read: a short read would hash a partial block and produce + // integrity blocks Electron rejects. + while ((bytesRead = fileStream.ReadFull(reusableBuffer, 0, reusableBuffer.Length)) > 0) { blockHashes.Add(ToLowerHex(blockHash.ComputeHash(reusableBuffer, 0, bytesRead))); fileHash.AppendData(reusableBuffer, 0, bytesRead); diff --git a/AsarSharp/PickleTools/Pickle.cs b/AsarSharp/PickleTools/Pickle.cs index c192a8fc..b02e72a9 100644 --- a/AsarSharp/PickleTools/Pickle.cs +++ b/AsarSharp/PickleTools/Pickle.cs @@ -28,16 +28,18 @@ private Pickle(byte[] buffer = null) { if (buffer != null) { + if (buffer.Length < SIZE_UINT32) + throw new ArgumentException("Buffer is too small.", nameof(buffer)); + _header = buffer; - _headerSize = buffer.Length - GetPayloadSize(); + int payloadSize = GetPayloadSize(); + if (payloadSize > buffer.Length) + throw new ArgumentException("Payload size exceeds buffer length.", nameof(buffer)); + + _headerSize = buffer.Length - payloadSize; _capacityAfterHeader = CAPACITY_READ_ONLY; _writeOffset = 0; - if (_headerSize > buffer.Length) - { - _headerSize = 0; - } - if (_headerSize != AlignInt(_headerSize, SIZE_UINT32)) { _headerSize = 0; @@ -86,7 +88,7 @@ public void WriteTo(Stream stream) } - public bool WriteBool(bool value) => WriteInt(value ? 1 : 0); + public bool WriteInt(int value) { @@ -121,74 +123,7 @@ public bool WriteUInt32(uint value) return true; } - public bool WriteInt64(long value) - { - const int dataLength = SIZE_INT64; - int newSize = _writeOffset + dataLength; - - if (newSize > _capacityAfterHeader) - { - Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); - } - - WriteInt64LE(value, _headerSize + _writeOffset); - SetPayloadSize(newSize); - _writeOffset = newSize; - return true; - } - - - public bool WriteUInt64(ulong value) - { - const int dataLength = SIZE_UINT64; - int newSize = _writeOffset + dataLength; - - if (newSize > _capacityAfterHeader) - { - Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); - } - - WriteUInt64LE(value, _headerSize + _writeOffset); - SetPayloadSize(newSize); - _writeOffset = newSize; - return true; - } - - public bool WriteFloat(float value) - { - const int dataLength = SIZE_FLOAT; - int newSize = _writeOffset + dataLength; - - if (newSize > _capacityAfterHeader) - { - Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); - } - - int bits = BitConverter.ToInt32(BitConverter.GetBytes(value), 0); - WriteInt32LE(bits, _headerSize + _writeOffset); - - SetPayloadSize(newSize); - _writeOffset = newSize; - return true; - } - - public bool WriteDouble(double value) - { - const int dataLength = SIZE_DOUBLE; - int newSize = _writeOffset + dataLength; - if (newSize > _capacityAfterHeader) - { - Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); - } - - long bits = BitConverter.DoubleToInt64Bits(value); - WriteInt64LE(bits, _headerSize + _writeOffset); - - SetPayloadSize(newSize); - _writeOffset = newSize; - return true; - } public bool WriteString(string value) { @@ -226,7 +161,13 @@ public void SetPayloadSize(int payloadSize) WriteUInt32LE((uint)payloadSize, 0); } - public int GetPayloadSize() => (int)ReadUInt32LE(0); + public int GetPayloadSize() + { + uint size = ReadUInt32LE(0); + if (size > int.MaxValue) + throw new InvalidOperationException("Payload size exceeds maximum allowed (2GB)."); + return (int)size; + } private void Resize(int newCapacity) { @@ -275,29 +216,7 @@ private void WriteUInt32LE(uint value, int offset) _header[offset + 3] = (byte)(value >> 24); } - private void WriteInt64LE(long value, int offset) - { - _header[offset] = (byte)value; - _header[offset + 1] = (byte)(value >> 8); - _header[offset + 2] = (byte)(value >> 16); - _header[offset + 3] = (byte)(value >> 24); - _header[offset + 4] = (byte)(value >> 32); - _header[offset + 5] = (byte)(value >> 40); - _header[offset + 6] = (byte)(value >> 48); - _header[offset + 7] = (byte)(value >> 56); - } - private void WriteUInt64LE(ulong value, int offset) - { - _header[offset] = (byte)value; - _header[offset + 1] = (byte)(value >> 8); - _header[offset + 2] = (byte)(value >> 16); - _header[offset + 3] = (byte)(value >> 24); - _header[offset + 4] = (byte)(value >> 32); - _header[offset + 5] = (byte)(value >> 40); - _header[offset + 6] = (byte)(value >> 48); - _header[offset + 7] = (byte)(value >> 56); - } #endregion diff --git a/AsarSharp/PickleTools/PickleIterator.cs b/AsarSharp/PickleTools/PickleIterator.cs index 11d069a5..95647e78 100644 --- a/AsarSharp/PickleTools/PickleIterator.cs +++ b/AsarSharp/PickleTools/PickleIterator.cs @@ -18,10 +18,7 @@ public PickleIterator(Pickle pickle) _endIndex = pickle.GetPayloadSize(); } - public bool ReadBool() - { - return ReadInt() != 0; - } + public int ReadInt() { @@ -33,25 +30,7 @@ public uint ReadUInt32() return ReadBytes(Pickle.SIZE_UINT32, BitConverter.ToUInt32); } - public long ReadInt64() - { - return ReadBytes(Pickle.SIZE_INT64, BitConverter.ToInt64); - } - public ulong ReadUInt64() - { - return ReadBytes(Pickle.SIZE_UINT64, BitConverter.ToUInt64); - } - - public float ReadFloat() - { - return ReadBytes(Pickle.SIZE_FLOAT, BitConverter.ToSingle); - } - - public double ReadDouble() - { - return ReadBytes(Pickle.SIZE_DOUBLE, BitConverter.ToDouble); - } public string ReadString() { @@ -75,7 +54,7 @@ private byte[] ReadBytes(int length) private int GetReadPayloadOffsetAndAdvance(int length) { - if (length > _endIndex - _readIndex) + if (length < 0 || length > _endIndex - _readIndex) { _readIndex = _endIndex; throw new InvalidOperationException($"Failed to read data with length of {length}"); diff --git a/AsarSharp/Utils/Extensions.cs b/AsarSharp/Utils/Extensions.cs index 3abaf5f3..7b18bb8a 100644 --- a/AsarSharp/Utils/Extensions.cs +++ b/AsarSharp/Utils/Extensions.cs @@ -1,12 +1,36 @@ using System; using System.IO; using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; using System.Text; namespace AsarSharp.Utils { - internal static class Extensions + public static class Extensions { + /// + /// Fills bytes. Stream.Read may legally return fewer than + /// asked for; treating a short read as EOF corrupts header parsing and block hashes. + /// Returns the bytes actually read, which is less than count only at end of stream. + /// + public static int ReadFull(this Stream stream, byte[] buffer, int offset, int count) + { + int total = 0; + while (total < count) + { + int read = stream.Read(buffer, offset + total, count - total); + if (read <= 0) + { + break; + } + + total += read; + } + + return total; + } + /// /// Compute path relative to . /// Fast common-case (path is inside relativeTo): plain prefix-strip. @@ -131,6 +155,83 @@ public static string GetDirectoryName(string path) return result; } + /// + /// Overwrites , clearing attributes on both ends. CopyFile + /// carries the source's ReadOnly flag onto the copy and then refuses to overwrite what it + /// produced, failing with "Access to the path is denied" - so one read-only source (an exe + /// run straight out of a .zip, say) poisons the destination for every later run. + /// + public static void CopyOver(string source, string destination) + { + ClearAttributes(destination); + + try + { + File.Copy(source, destination, true); + } + catch (UnauthorizedAccessException e) + { + throw new UnauthorizedAccessException($"{e.Message} {DescribeDenial(destination)}", e); + } + + ClearAttributes(destination); + } + + /// + /// "Access to the path is denied" names none of the half-dozen things that cause it, and + /// the state is gone by the time anyone reads the report. Attributes were already cleared + /// above, which rules the most common cause out before the message is even written. + /// + private static string DescribeDenial(string destination) + { + if (Directory.Exists(destination)) + { + return "The destination is a directory, not a file."; + } + + if (!File.Exists(destination)) + { + return "The destination does not exist, so the containing folder is refusing new files."; + } + + return $"Attributes {File.GetAttributes(destination)}, owner {DescribeOwner(destination)}, " + + $"running as {Environment.UserName}. A read-only flag, antivirus, folder " + + "permissions or a delete still pending on the file are the usual causes."; + } + + private static string DescribeOwner(string path) + { + try + { + return File.GetAccessControl(path).GetOwner(typeof(NTAccount)).Value; + } + catch (Exception e) when (e is IdentityNotMappedException || e is UnauthorizedAccessException || + e is InvalidOperationException || e is PrivilegeNotHeldException || + e is PlatformNotSupportedException) + { + return "unreadable"; + } + } + + /// + /// Resets a file to Normal: ReadOnly, Hidden and System all block an overwrite. Best + /// effort - a file that denies even this reports it properly through the write that follows. + /// + public static void ClearAttributes(string path) + { + try + { + if (File.Exists(path)) + { + File.SetAttributes(path, FileAttributes.Normal); + } + } + catch (Exception e) when (e is UnauthorizedAccessException || e is IOException) + { + // Swallowed so the caller's own failure is the one that surfaces. + } + } + public static void CopyDirectory(string sourceDir, string destinationDir) { Directory.CreateDirectory(destinationDir); @@ -138,7 +239,7 @@ public static void CopyDirectory(string sourceDir, string destinationDir) foreach (var file in Directory.GetFiles(sourceDir)) { var destFile = Path.Combine(destinationDir, Path.GetFileName(file)); - File.Copy(file, destFile, true); + CopyOver(file, destFile); } foreach (var dir in Directory.GetDirectories(sourceDir)) @@ -170,19 +271,7 @@ public static void SetUnixFilePermission(string filePath, string permission) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; - var process = new System.Diagnostics.Process - { - StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "chmod", - Arguments = $"{permission} \"{filePath}\"", - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true - } - }; - process.Start(); - process.WaitForExit(); + RunTool("chmod", $"{permission} \"{filePath}\""); } @@ -190,32 +279,41 @@ public static void CreateSymbolicLink(string linkTarget, string linkPath) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - NativeMethods.CreateSymbolicLink(linkPath, linkTarget, + bool success = NativeMethods.CreateSymbolicLink(linkPath, linkTarget, Directory.Exists(linkTarget) ? NativeMethods.SymLinkFlag.Directory : NativeMethods.SymLinkFlag.File); + if (!success) + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); return; } - var process = new System.Diagnostics.Process - { - StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "ln", - Arguments = $"-s \"{linkTarget}\" \"{linkPath}\"", - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true - } - }; - process.Start(); - process.WaitForExit(); + RunTool("ln", $"-s \"{linkTarget}\" \"{linkPath}\""); } - public static bool IsWindowsPlatform() { - return Environment.OSVersion.Platform == PlatformID.Win32NT; + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + } + + private static void RunTool(string fileName, string arguments) + { + using (var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + UseShellExecute = false, + CreateNoWindow = true + } + }) + { + process.Start(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"Tool {fileName} failed with exit code {process.ExitCode}."); + } } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index d184a376..2149ae6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,16 +3,70 @@ This file is the source of truth for release notes. The newest entry must match the version in `WandEnhancer/Properties/AssemblyInfo.cs`. +## [2.0.0.0] - 2026-08-29 + +### Important + +- The bundled `version.dll` proxy is gone. The launcher starts Wand as a child process, apply patches in every process Electron spawns, and detaches once startup settles. This is what fixes Wand refusing to launch after enhancing on related issues: #207 #210 #211 #213 #214 #217 +- The native helper and its CMake build step were removed. Building from source no longer needs `CMake` or the Visual Studio C++ workload. +- WandEnhancer now installs itself as the Wand launcher entry point, so starting Wand goes through the patcher. Restoring a backup puts the original launcher back. + +### Features + +- **Auto-patch after Wand updates.** Enable *Auto-apply after updates* in the patch dialog and your selection is saved next to the launcher. When Wand updates and drops the patches, the next launch re-applies them. On failure the UI opens and shows which patch broke instead of silently starting an unpatched client. +- **Rewritten patch engine with legacy version support.** Patches are located structurally instead of by regex signature: each anchors on something Wand does not rename between builds. A client rebuild that only re-minifies no longer breaks patching, and older clients keep working. #178 #186 +- A patch whose feature is missing from your client is now reported as skipped instead of failing the whole run, and failures name the patch that broke. + +### Fixes + +- Fixed the "Buy Pro" banner still showing after a successful patch, and Pro not activating on newer clients. +- Fixed the Enhancer closing itself when any button was pressed. #184 +- A failed patch now puts your original Wand files back instead of leaving a half-patched install behind. Packing also builds the archive beside the old one and swaps it in at the end, so a failure can no longer destroy `app.asar`. #221 +- Fixed patching and *Restore* both failing with "Access to the path is denied" after the first successful patch. Copying carried the read-only flag from the patcher onto the launcher it installs, and then refused to overwrite what it had written - so running WandEnhancer straight out of the downloaded `.zip`, which Windows marks read-only, broke every later run. #214 +- Fixed a half-written backup reporting the installation as patched, which blocked patching and restore at the same time. +- Fixed invalid ASAR integrity metadata produced from short reads, which could yield an archive the client rejects. #170 +- Fixed the packer silently dropping files it could not read, for example while Wand was still running. +- Fixed archive tree lookups resolving the wrong parent and creating phantom directories in the header. +- Fixed hangs on symlink cycles and directory junctions while reading or packing an archive. +- Fixed the language switcher leaking a resource dictionary on every switch. #164 +- Fixed *Restore* freezing the window while it ran. +- Fixed Squirrel install and update arguments breaking when the Windows user profile path contains spaces. +- Fixed a latent crash path from a patch type that had no configuration entry. #172 +- Remote panel: fixed a blank page when the interface translations failed to load. +- Remote panel: fixed number inputs eating the decimal point while typing, and steppers drifting on fractional steps. +- Remote panel: fixed the increment control refusing to step from a value outside its option list. +- Remote panel: fixed endless two-second reconnect attempts, and reconnecting again after you disconnected on purpose. +- Remote panel: fixed installed-game updates not arriving when only the install location changed. +- Remote panel: fixed value writes silently doing nothing when the client bound to the bridge before it was ready. + +### Improvements + +- The launcher now writes a `launcher.log` next to itself, recording every process Electron starts and whether its fuse was cleared, exit and crash codes, and how long it stayed attached. Starting Wand happens without a window, so until now a client that refused to open left nothing to go on. +- Log messages in the desktop app are now translated into all 12 supported languages. +- The remote panel is now usable with a keyboard and a screen reader: dialogs trap focus and close on Escape, and controls have accessible names. Pinning a mod previously required a swipe and had no keyboard path at all, so mod rows now have a pin button. + +### Security and Privacy + +- The panel's static file server now resolves every request inside the panel directory. +- The local bridge enforces the WebSocket framing rules required of a server (RFC 6455). +- Late trainer events naming a different trainer no longer overwrite the active trainer's values. + +### Maintenance + +- The Electron bridge is now fully type-checked; roughly 200 latent typing gaps were fixed. +- `build.ps1` and CI now run lint, type-check, and a dist verification step that syntax-checks the bundles and fails when dev-only payloads leak into a production build. CI runs on pull requests and pushes to `master`. +- Removed dead code: the `version.dll` project, an unused control and converter, and unused Pickle helpers. + ## [1.0.9.4] - 2026-07-21 ### Fixes -- Fixed the Remote Web Panel QR code still opening the official Wand mobile client after Wand changed its bundled QR renderer export. The renderer bridge now resolves the current export without adding a fragile C# ASAR patch. [Discussion #140](https://github.com/k1tbyte/Wand-Enhancer/discussions/140) +- Fixed the Remote Web Panel QR code still opening the official Wand mobile client after Wand changed its bundled QR renderer export. The renderer bridge now resolves the current export without adding a fragile C# ASAR patch. #140 - Fixed Quick Presets reporting that a preset was saved when browser local storage rejected the write. Failed writes now leave the existing preset list unchanged and show an error, and the save dialog now stays above the bottom navigation dock. -- Fixed the patcher giving up on process termination because it reused a stale process snapshot by @divya0795 in [#145](https://github.com/k1tbyte/Wand-Enhancer/pull/145). Related issue: [#136](https://github.com/k1tbyte/Wand-Enhancer/issues/136) -- Fixed ASAR extraction path traversal and corrupt Pickle payload allocation by @divya0795 in [#143](https://github.com/k1tbyte/Wand-Enhancer/pull/143). +- Fixed the patcher giving up on process termination because it reused a stale process snapshot by @divya0795 in #145. Related issue: #136 +- Fixed ASAR extraction path traversal and corrupt Pickle payload allocation by @divya0795 in #143. - Fixed backup restore so `app.asar.unpacked` is restored together with `app.asar`, and the injected `version.dll` is removed after a successful restore. -- Fixed `version.dll` requiring Visual C++ runtime DLLs on some systems by statically linking the runtime. Release builds now reject accidental dynamic VCRUNTIME, MSVCP, or UCRT dependencies. [#128](https://github.com/k1tbyte/Wand-Enhancer/issues/128) +- Fixed `version.dll` requiring Visual C++ runtime DLLs on some systems by statically linking the runtime. Release builds now reject accidental dynamic VCRUNTIME, MSVCP, or UCRT dependencies. #128 ### Security and Privacy diff --git a/README.md b/README.md index 8c7bb789..cd1db709 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ There are no official videos showing how to install or use this tool. Scammers a ## 👾 What does it access? -The .NET patcher modifies files in the selected local Wand installation and does not contact an update or telemetry service. The bundled `version.dll` proxy is loaded by Wand and changes Electron's ASAR-integrity fuse byte inside Wand's own process; it does not inject into another process. Wand itself remains an online application, build tools restore declared dependencies, and the optional Remote Web Panel deliberately starts a LAN HTTP/WebSocket server and uses Wand API/CDN data. Review the source and build the executable from your own fork; unsigned patching tools can trigger generic antivirus heuristics. +The .NET patcher modifies files in the selected local Wand installation and does not contact an update or telemetry service. Wand itself remains an online application, build tools restore declared dependencies, and the optional Remote Web Panel deliberately starts a LAN HTTP/WebSocket server and uses Wand API/CDN data. Review the source and build the executable from your own fork; unsigned patching tools can trigger generic antivirus heuristics. ## 💫 What features are improved? @@ -102,19 +102,17 @@ Building from source on Windows requires a local development environment. ### Requirements -- `CMake` - `Node.js` and `pnpm` - `Visual Studio 2022` or `Build Tools for Visual Studio 2022` with `MSBuild` -- Visual Studio `Desktop development with C++` workload - .NET Framework 4.8 desktop build tools / targeting pack ### Build steps 1. Clone this repository. -2. Install the requirements above and make sure `cmake`, `pnpm`, and `MSBuild` are available. +2. Install the requirements above and make sure `pnpm` and `MSBuild` are available. 3. Run `build.cmd` from Command Prompt or PowerShell. -The build script installs the web panel dependencies, builds the frontend, compiles the native helper with CMake, restores NuGet packages, and builds the WPF solution. +The build script installs the web panel dependencies, type-checks and lints the panel, builds the frontend and bridge, restores NuGet packages, and builds the WPF solution. --- @@ -141,12 +139,11 @@ The build script installs the web panel dependencies, builds the frontend, compi ![2](./assets/screenshots/app2.png) ---- ## 📜 License This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) file for details. ---- + ## ❤️ Support If you find this project useful, you can support its development using any of the options below 🙌 @@ -163,5 +160,3 @@ If you find this project useful, you can support its development using any of th > This project is a third-party enhancement tool intended solely for educational, research, and local interoperability purposes. It does not distribute any proprietary code or bypass server-side validations. All modifications are performed locally to customize the user's interface. --- - -[![Star History Chart](https://api.star-history.com/svg?repos=k1tbyte/Wand-Enhancer&type=Date)](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date) diff --git a/WandEnhancer/App.xaml b/WandEnhancer/App.xaml index dafdfba3..273e31e6 100644 --- a/WandEnhancer/App.xaml +++ b/WandEnhancer/App.xaml @@ -15,7 +15,6 @@ pack://application:,,,/Style/#Inter 18pt 18pt - \ No newline at end of file diff --git a/WandEnhancer/Constants.cs b/WandEnhancer/Constants.cs index cb5bbcbe..1ea79d9d 100644 --- a/WandEnhancer/Constants.cs +++ b/WandEnhancer/Constants.cs @@ -1,6 +1,5 @@ -using System; +using System; using System.Reflection; -using WandEnhancer.Models; namespace WandEnhancer { @@ -8,36 +7,15 @@ public static class Constants { public const string RepoName = "Wand-Enhancer"; public const string Owner = "k1tbyte"; - /*public const string PatchRegistryName = "patchRegistry.json";*/ public static readonly string RepositoryUrl = $"https://github.com/{Owner}/{RepoName}"; public static readonly Version Version; public static readonly string[] WeModBrandNames = { "Wand", "WeMod" }; public const string AppSettingsFileName = "appsettings.json"; - - public const string ProxyDllResouceName = "proxydll"; + public const string AutoPatchConfigFileName = "enhancer.json"; - // cmp dword ptr [rdx], 0 - // jnz loc_XXXXXXXX - // mov rsi, rdx - /*public static Signature ExePatchSignature = new Signature( - "83 3A 00 0F ?? ?? 01 00 00 48 89 D6 48 B8", - 4, - new byte[]{ 0x84, 0x17 }, - new byte[]{ 0x85, 0x22 } - );*/ - - /*// ... - // test eax, eax (0x85 for r/m16/32/64) - // jnz short loc_1403A4DD2 (Integrity check failed) - // call near ptr funk_1445527E0 - // ... - private const string PatchSignature = "E8 ?? ?? ?? ?? ?? C0 75 ?? F6 C3 01 74 ?? 48 89 F9 E8 ?? ?? ?? ??"; - private static readonly byte[] PatchBytes = { 0x31 }; - private const int PatchOffset = 0x5;*/ - - static Constants() + static Constants() { Version = Assembly.GetExecutingAssembly().GetName().Version; } } -} \ No newline at end of file +} diff --git a/WandEnhancer/Converters/BaseBooleanConverter.cs b/WandEnhancer/Converters/BaseBooleanConverter.cs index 501878bd..69265806 100644 --- a/WandEnhancer/Converters/BaseBooleanConverter.cs +++ b/WandEnhancer/Converters/BaseBooleanConverter.cs @@ -18,29 +18,7 @@ protected BaseBooleanConverter(T trueValue, T falseValue) public virtual object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - switch (value) - { - case null: - return False; - case bool booleanValue: - return booleanValue ? True : False; - } - - if (!(value is int intValue)) - { - return True; - } - - switch (parameter) - { - case null: - return intValue == 0 ? False : True; - case int param: - return intValue > param ? True : False; - default: - //Because object not null - return True; - } + return value is bool booleanValue && booleanValue ? True : False; } public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) diff --git a/WandEnhancer/Converters/ToVisibilityConverter.cs b/WandEnhancer/Converters/ToVisibilityConverter.cs index 66c72801..282762bf 100644 --- a/WandEnhancer/Converters/ToVisibilityConverter.cs +++ b/WandEnhancer/Converters/ToVisibilityConverter.cs @@ -9,10 +9,4 @@ public ToVisibilityConverter() : { } } - internal sealed class ToVisibilityInvertedConverter : BaseBooleanConverter - { - public ToVisibilityInvertedConverter() : - base(Visibility.Collapsed, Visibility.Visible) - { } - } } \ No newline at end of file diff --git a/WandEnhancer/Core/Enhancer.cs b/WandEnhancer/Core/Enhancer.cs index de59311a..4171d045 100644 --- a/WandEnhancer/Core/Enhancer.cs +++ b/WandEnhancer/Core/Enhancer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -18,6 +18,8 @@ public class Enhancer private const string AppAsarUnpackedDirectoryName = "app.asar.unpacked"; private const string AppAsarBackupFileName = "app.asar.backup"; private const string AppAsarUnpackedBackupDirectoryName = "app.asar.unpacked.backup"; + private const string ProxyDllFileName = "version.dll"; + private const string StubBackupSuffix = ".stub"; private const string WebPanelDirectoryName = "web-panel"; private const string WebPanelDistDirectoryName = "dist"; private const string LocalCustomScriptsDirectoryName = "renderer-scripts"; @@ -28,7 +30,6 @@ public class Enhancer private const string AppBundleFilePrefix = "app-"; private const string AppBundleFileSuffix = ".bundle.js"; private const string IndexBundleFileName = "index.js"; - private const string JavaScriptFileExtension = ".js"; private const string JavaScriptFileSearchPattern = "*.js"; private const string DuplicateScriptSuffix = ".custom"; private const int FirstDuplicateScriptIndex = 1; @@ -36,92 +37,46 @@ public class Enhancer private readonly WeModConfig _weModConfig; private readonly Action _logger; private readonly PatchConfig _config; + private readonly JavaScriptPatchApplier _jsPatchApplier; private readonly string _asarPath; private readonly string _backupPath; private readonly string _unpackedPath; private readonly string _unpackedBackupPath; + /// For , which needs the install paths but no patch selection. + public Enhancer(WeModConfig weModConfig, Action logger) + : this(weModConfig, logger, null) + { + } + public Enhancer(WeModConfig weModConfig, Action logger, PatchConfig config) { _weModConfig = weModConfig; _logger = logger; _config = config; + _jsPatchApplier = new JavaScriptPatchApplier(logger); _asarPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarFileName); _unpackedPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedDirectoryName); _backupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarBackupFileName); _unpackedBackupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedBackupDirectoryName); } - - private string ApplyJsPatch(string fileName, string js, EnhancerConfig.PatchEntry patch, EPatchType patchType, out bool patchApplied) - { - patchApplied = false; - - if (patch.Applied) - { - return js; - } - if (!CanSearchPatchInFile(fileName, patch) || !ContainsSearchHint(js, patch.SearchHints)) - { - return js; - } - - var match = patch.Target.Match(js); - if (!match.Success) - { - return js; - } - - var prefix = $"[ENHANCER] [{patchType} -> {patch.Name}]"; - - if(patch.SingleMatch && match.NextMatch().Success) - { - throw new Exception( - $"{prefix} Patch failed. Multiple target functions found. Looks like the version is not supported"); - } - - string patchSource = patch.PatchFactory != null - ? patch.PatchFactory(match) - : patch.Patch; - - if (patch.Resolver != null) - { - string resolvedField = patch.Resolver.Handler(match.Value); - if (string.IsNullOrEmpty(resolvedField)) - { - throw new Exception($"{prefix} Resolver failed to find field name"); - } - - patchSource = patchSource.Replace(patch.Resolver.Placeholder, resolvedField); - } - - _logger($"{prefix} Found target function in: " + Path.GetFileName(fileName), ELogType.Info); - - string newJs; - if (patch.PatchFactory != null) - { - newJs = patch.SingleMatch - ? patch.Target.Replace(js, _ => patchSource, 1) - : patch.Target.Replace(js, _ => patchSource); - } - else - { - newJs = patch.SingleMatch - ? patch.Target.Replace(js, patchSource, 1) - : patch.Target.Replace(js, patchSource); - } - - _logger($"{prefix} Patch applied", ELogType.Success); - patch.Applied = true; - patchApplied = true; - - return newJs; + /// + /// Both halves of the backup must exist. Accepting either one on its own reported a + /// half-written backup as patched, which blocked patching while + /// refused to run - leaving the user with no way forward. + /// + public static bool IsPatched(string rootDirectory) + { + var resources = Path.Combine(rootDirectory, ResourcesDirectoryName); + return File.Exists(Path.Combine(resources, AppAsarBackupFileName)) + && Directory.Exists(Path.Combine(resources, AppAsarUnpackedBackupDirectoryName)); } private void PatchAsar() { - var items = Directory.EnumerateFiles(_unpackedPath, $"*{JavaScriptFileExtension}", SearchOption.TopDirectoryOnly) + var items = Directory.EnumerateFiles(_unpackedPath, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly) .Where(IsCandidateBundleFile) .ToList(); @@ -129,7 +84,7 @@ private void PatchAsar() { throw new Exception("[ENHANCER] No app bundle found"); } - + var remainingPatches = new HashSet(_config.PatchTypes); var enhancerConfig = EnhancerConfig.GetInstance(); @@ -144,20 +99,22 @@ private void PatchAsar() { continue; } - + string data = File.ReadAllText(item); bool fileChanged = false; - + foreach (var entry in remainingPatches.ToList()) { var entries = enhancerConfig[entry]; foreach (var patchEntry in entries) { bool patchApplied; - data = ApplyJsPatch(item, data, patchEntry, entry, out patchApplied); + data = _jsPatchApplier.Apply(item, data, patchEntry, entry, out patchApplied); fileChanged = fileChanged || patchApplied; } + // Optional patches stay in the scan until every file has been checked, because + // their capability may still show up in a bundle we have not read yet. if (entries.All(x => x.Applied)) { remainingPatches.Remove(entry); @@ -169,62 +126,43 @@ private void PatchAsar() File.WriteAllText(item, data); } } - - if(remainingPatches.Count > 0) - { - var failedPatches = string.Join(", ", remainingPatches.Select(p => p.ToString())); - throw new Exception($"[ENHANCER] Failed to apply patches: {failedPatches}. The version may not be supported."); - } - } - private static bool IsCandidateBundleFile(string filePath) - { - string fileName = Path.GetFileName(filePath); - return fileName.Equals(IndexBundleFileName, StringComparison.OrdinalIgnoreCase) - || (fileName.StartsWith(AppBundleFilePrefix, StringComparison.OrdinalIgnoreCase) - && fileName.EndsWith(AppBundleFileSuffix, StringComparison.OrdinalIgnoreCase)); + ReportUnappliedPatches(remainingPatches, enhancerConfig); } - private static bool CouldFileContainRemainingPatch(string filePath, IEnumerable remainingPatches, Dictionary enhancerConfig) + private void ReportUnappliedPatches(IEnumerable remainingPatches, Dictionary enhancerConfig) { - foreach (var patchType in remainingPatches) - { - foreach (var patchEntry in enhancerConfig[patchType]) - { - if (patchEntry.Applied) - { - continue; - } + var unapplied = remainingPatches + .SelectMany(patchType => enhancerConfig[patchType] + .Where(patch => !patch.Applied) + .Select(patch => new { Label = JavaScriptPatchApplier.FormatLabel(patchType, patch), Patch = patch })) + .ToList(); - if (CanSearchPatchInFile(filePath, patchEntry)) - { - return true; - } - } + foreach (var skipped in unapplied.Where(entry => entry.Patch.IsResolved)) + { + _logger($"[ENHANCER] [{skipped.Label}] Capability not present, skipping", ELogType.Info); } - return false; - } - - private static bool CanSearchPatchInFile(string filePath, EnhancerConfig.PatchEntry patch) - { - if (patch.CandidateFileNames == null || patch.CandidateFileNames.Length == 0) + var failed = unapplied.Where(entry => !entry.Patch.IsResolved).Select(entry => entry.Label).ToList(); + if (failed.Count > 0) { - return true; + throw new Exception($"[ENHANCER] Failed to apply patches: {string.Join(", ", failed)}. The version may not be supported."); } + } + private static bool IsCandidateBundleFile(string filePath) + { string fileName = Path.GetFileName(filePath); - return patch.CandidateFileNames.Any(candidate => fileName.Equals(candidate, StringComparison.OrdinalIgnoreCase)); + return fileName.Equals(IndexBundleFileName, StringComparison.OrdinalIgnoreCase) + || (fileName.StartsWith(AppBundleFilePrefix, StringComparison.OrdinalIgnoreCase) + && fileName.EndsWith(AppBundleFileSuffix, StringComparison.OrdinalIgnoreCase)); } - private static bool ContainsSearchHint(string source, string[] searchHints) + private static bool CouldFileContainRemainingPatch(string filePath, IEnumerable remainingPatches, Dictionary enhancerConfig) { - if (searchHints == null || searchHints.Length == 0) - { - return true; - } - - return searchHints.Any(searchHint => source.IndexOf(searchHint, StringComparison.Ordinal) >= 0); + return remainingPatches + .SelectMany(patchType => enhancerConfig[patchType]) + .Any(patchEntry => !patchEntry.Applied && JavaScriptPatchApplier.CanSearchFile(filePath, patchEntry)); } private static string FindWorkspacePath(params string[] segments) @@ -244,25 +182,6 @@ private static string FindWorkspacePath(params string[] segments) throw new FileNotFoundException($"Required workspace artifact not found: {Path.Combine(segments)}"); } - internal static void CopyDirectory(string sourceDir, string destinationDir) - { - Directory.CreateDirectory(destinationDir); - - foreach (var directory in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories)) - { - var relativePath = directory.Substring(sourceDir.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - Directory.CreateDirectory(Path.Combine(destinationDir, relativePath)); - } - - foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories)) - { - var relativePath = file.Substring(sourceDir.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var destinationPath = Path.Combine(destinationDir, relativePath); - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath) ?? destinationDir); - File.Copy(file, destinationPath, true); - } - } - private static int CopyJavaScriptFiles(string sourceDir, string destinationDir) { if (string.IsNullOrEmpty(sourceDir) || !Directory.Exists(sourceDir)) @@ -270,16 +189,9 @@ private static int CopyJavaScriptFiles(string sourceDir, string destinationDir) return 0; } - Directory.CreateDirectory(destinationDir); - - int copied = 0; - foreach (var file in Directory.GetFiles(sourceDir, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly)) - { - File.Copy(file, GetAvailableScriptPath(destinationDir, Path.GetFileName(file))); - copied++; - } - - return copied; + return CopySelectedJavaScriptFiles( + Directory.GetFiles(sourceDir, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly), + destinationDir); } private static string GetAvailableScriptPath(string destinationDir, string fileName) @@ -363,20 +275,15 @@ private static int CopySelectedJavaScriptFiles(IEnumerable files, string Directory.CreateDirectory(destinationDir); int copied = 0; - foreach (var file in files.Where(IsJavaScriptFile).Distinct(StringComparer.OrdinalIgnoreCase)) + foreach (var file in files.Where(WeModInstalls.IsJavaScriptFile).Distinct(StringComparer.OrdinalIgnoreCase)) { - File.Copy(file, GetAvailableScriptPath(destinationDir, Path.GetFileName(file))); + AsarSharp.Utils.Extensions.CopyOver(file, GetAvailableScriptPath(destinationDir, Path.GetFileName(file))); copied++; } return copied; } - private static bool IsJavaScriptFile(string file) - { - return File.Exists(file) && string.Equals(Path.GetExtension(file), JavaScriptFileExtension, StringComparison.OrdinalIgnoreCase); - } - private void InjectRemotePanelFiles() { if (!_config.PatchTypes.Contains(EPatchType.RemoteWebPanelPreview)) @@ -396,7 +303,7 @@ private void InjectRemotePanelFiles() if (CopyEmbeddedDirectory(EmbeddedRemotePanelDistPrefix, targetRoot) == 0) { - CopyDirectory(FindWorkspacePath(WebPanelDirectoryName, WebPanelDistDirectoryName), targetRoot); + AsarSharp.Utils.Extensions.CopyDirectory(FindWorkspacePath(WebPanelDirectoryName, WebPanelDistDirectoryName), targetRoot); } if (!File.Exists(targetBridgePath)) @@ -418,40 +325,94 @@ private void InjectRemotePanelFiles() _logger($"[ENHANCER] Injected remote panel assets and renderer scripts into app.asar (default: {defaultScriptCount}, selected: {selectedScriptCount}, local: {localScriptCount})", ELogType.Info); } - private void AttachProxyDll() + private string SquirrelRoot { - var assembly = Assembly.GetExecutingAssembly(); - var dll = assembly.GetManifestResourceStream(Constants.ProxyDllResouceName); - if (dll == null) + get { - throw new Exception("[ENHANCER] Proxy DLL resource not found"); + string root = Directory.GetParent(_weModConfig.RootDirectory)?.FullName; + if (string.IsNullOrEmpty(root)) + { + throw new Exception("[ENHANCER] Cannot determine Squirrel root directory"); + } + + return root; } - var destPath = Path.Combine(_weModConfig.RootDirectory, "version.dll"); - using (var fileStream = File.Create(destPath)) + } + + private void DeployLauncher() + { + string stubPath = Path.Combine(SquirrelRoot, _weModConfig.ExecutableName); + string stubBackup = stubPath + StubBackupSuffix; + string self = Assembly.GetExecutingAssembly().Location; + + // Auto-patch runs from inside the deployed launcher: it cannot overwrite its own + // running image, and does not need to - it is already in place. + if (string.Equals(self, stubPath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (File.Exists(stubPath) && !File.Exists(stubBackup)) + { + AsarSharp.Utils.Extensions.CopyOver(stubPath, stubBackup); + } + + AsarSharp.Utils.Extensions.CopyOver(self, stubPath); + _logger("[ENHANCER] Launcher deployed to root directory", ELogType.Info); + } + + private void SaveAutoPatchConfig() + { + string path = Path.Combine(SquirrelRoot, Constants.AutoPatchConfigFileName); + File.WriteAllText(path, Newtonsoft.Json.JsonConvert.SerializeObject(_config, Newtonsoft.Json.Formatting.Indented)); + } + + private void DeleteAutoPatchConfig() + { + string path = Path.Combine(SquirrelRoot, Constants.AutoPatchConfigFileName); + if (File.Exists(path)) + { + File.Delete(path); + } + } + + /// Reads the patch selection saved next to the launcher, or null when absent or unreadable. + public static PatchConfig LoadAutoPatchConfig(string launcherDirectory) + { + try { - dll.CopyTo(fileStream); + string path = Path.Combine(launcherDirectory, Constants.AutoPatchConfigFileName); + if (!File.Exists(path)) + { + return null; + } + + return Newtonsoft.Json.JsonConvert.DeserializeObject(File.ReadAllText(path)); + } + catch (Exception e) when (e is IOException || e is Newtonsoft.Json.JsonException || e is UnauthorizedAccessException) + { + return null; } - _logger("[ENHANCER] Proxy DLL attached", ELogType.Info); } public void Patch() { - Common.TryKillProcess(_weModConfig.BrandName); + ProcessTerminator.TryKillProcess(_weModConfig.BrandName); if (!File.Exists(_backupPath)) { _logger("[ENHANCER] Creating backup...", ELogType.Info); - File.Copy(_asarPath, _backupPath); + AsarSharp.Utils.Extensions.CopyOver(_asarPath, _backupPath); } else { _logger("[ENHANCER] Backup found, restoring pristine app.asar before patching...", ELogType.Info); - File.Copy(_backupPath, _asarPath, true); + AsarSharp.Utils.Extensions.CopyOver(_backupPath, _asarPath); } if (!Directory.Exists(_unpackedBackupPath) && Directory.Exists(_unpackedPath)) { _logger("[ENHANCER] Creating backup of app.asar.unpacked...", ELogType.Info); - CopyDirectory(_unpackedPath, _unpackedBackupPath); + AsarSharp.Utils.Extensions.CopyDirectory(_unpackedPath, _unpackedBackupPath); } else if (Directory.Exists(_unpackedBackupPath)) { @@ -461,18 +422,51 @@ public void Patch() Directory.Delete(_unpackedPath, true); } - CopyDirectory(_unpackedBackupPath, _unpackedPath); + AsarSharp.Utils.Extensions.CopyDirectory(_unpackedBackupPath, _unpackedPath); } else if (!Directory.Exists(_unpackedPath)) { throw new Exception("[ENHANCER] app.asar.unpacked is missing and no backup exists. Restore the original Wand installation files or reinstall Wand, then patch again."); } - if(!File.Exists(_asarPath)) + if (!File.Exists(_asarPath)) { throw new Exception("app.asar not found"); } + // Everything past this point mutates the installation. A half-applied patch does + // not boot - the fuse is only cleared by the deployed launcher, so a patched + // app.asar without it dies with -36861 - so failure has to put the files back. + try + { + ExtractSources(); + PatchAsar(); + InjectRemotePanelFiles(); + PackSources(); + DeployLauncher(); + } + catch + { + RollbackQuietly(); + throw; + } + + // enhancer.json only exists to drive auto-patch. Without it the launcher still + // runs Wand (fuse patch only), so drop it when the user opts out. + if (_config.AutoApplyAfterUpdate) + { + SaveAutoPatchConfig(); + } + else + { + DeleteAutoPatchConfig(); + } + + _logger("[ENHANCER] Done!", ELogType.Success); + } + + private void ExtractSources() + { try { _logger("[ENHANCER] Extracting app.asar...", ELogType.Info); @@ -480,12 +474,12 @@ public void Patch() } catch (Exception e) { - throw new Exception($"[ENHANCER] Failed to unpack app.asar: {e.Message}"); + throw new Exception($"[ENHANCER] Failed to unpack app.asar: {e.Message}", e); } - - PatchAsar(); - InjectRemotePanelFiles(); + } + private void PackSources() + { try { new AsarCreator(_unpackedPath, _asarPath, new CreateOptions @@ -495,12 +489,88 @@ public void Patch() } catch (Exception e) { - throw new Exception($"[ENHANCER] Failed to pack app.asar: {e.Message}"); + throw new Exception($"[ENHANCER] Failed to pack app.asar: {e.Message}", e); } - - AttachProxyDll(); - - _logger("[ENHANCER] Done!", ELogType.Success); + } + + /// + /// Best-effort restore after a failed patch. Never throws: the caller is already + /// propagating the real failure and it must not be replaced by a cleanup error. + /// + private void RollbackQuietly() + { + try + { + if (File.Exists(_backupPath)) + { + AsarSharp.Utils.Extensions.CopyOver(_backupPath, _asarPath); + } + + if (Directory.Exists(_unpackedBackupPath)) + { + if (Directory.Exists(_unpackedPath)) + { + Directory.Delete(_unpackedPath, true); + } + + AsarSharp.Utils.Extensions.CopyDirectory(_unpackedBackupPath, _unpackedPath); + } + + _logger("[ENHANCER] Patch failed - the original Wand files were restored.", ELogType.Warn); + } + catch (Exception e) + { + _logger($"[ENHANCER] Patch failed and the rollback did not finish: {e.Message}. " + + "Use Restore before launching Wand.", ELogType.Error); + } + } + + public void Restore() + { + if (!File.Exists(_backupPath) || !Directory.Exists(_unpackedBackupPath)) + { + throw new Exception("[ENHANCER] Backup is incomplete. Restore the original Wand installation files or reinstall Wand."); + } + + ProcessTerminator.TryKillProcess(_weModConfig.BrandName); + AsarSharp.Utils.Extensions.CopyOver(_backupPath, _asarPath); + + if (Directory.Exists(_unpackedPath)) + { + Directory.Delete(_unpackedPath, true); + } + + AsarSharp.Utils.Extensions.CopyDirectory(_unpackedBackupPath, _unpackedPath); + + // Clean up legacy proxy DLL + var proxyDllPath = Path.Combine(_weModConfig.RootDirectory, ProxyDllFileName); + if (File.Exists(proxyDllPath)) + { + File.Delete(proxyDllPath); + } + + // Restore original Squirrel stub and drop the auto-patch config + string squirrelRoot = SquirrelRoot; + string stubPath = Path.Combine(squirrelRoot, _weModConfig.ExecutableName); + string stubBackup = stubPath + StubBackupSuffix; + if (File.Exists(stubBackup)) + { + AsarSharp.Utils.Extensions.CopyOver(stubBackup, stubPath); + File.Delete(stubBackup); + } + + foreach (var leftover in new[] { Constants.AutoPatchConfigFileName, LauncherLog.FileName }) + { + string path = Path.Combine(squirrelRoot, leftover); + if (File.Exists(path)) + { + File.Delete(path); + } + } + + File.Delete(_backupPath); + Directory.Delete(_unpackedBackupPath, true); + _logger("[ENHANCER] Backup restored successfully.", ELogType.Success); } } } diff --git a/WandEnhancer/Core/EnhancerConfig.cs b/WandEnhancer/Core/EnhancerConfig.cs index ab23c495..594283aa 100644 --- a/WandEnhancer/Core/EnhancerConfig.cs +++ b/WandEnhancer/Core/EnhancerConfig.cs @@ -1,105 +1,44 @@ using System; using System.Collections.Generic; using System.Text.RegularExpressions; +using WandEnhancer.Core.Js; using WandEnhancer.Models; namespace WandEnhancer.Core { - public static class EnhancerConfig + /// + /// Patch definitions. Each entry anchors on something Wand does not rename between builds - + /// an API endpoint, an IPC channel name or a public method name - and then navigates the + /// delimiter structure to the edit site. Minified identifiers are read out of the located + /// region rather than baked into a pattern, so a rebuild does not invalidate a patch. + /// + internal static class EnhancerConfig { - public class ResolveContext - { - public string Placeholder { get; set; } - public Func Handler { get; set; } - } + /// Locates the edits a patch must make, or null when the anchor is absent from this file. + public delegate JsEdit[] PatchLocator(JsCursor js); - public class PatchEntry + public sealed class PatchEntry { - public Regex Target { get; set; } - public string Patch { get; set; } - public Func PatchFactory { get; set; } public string Name { get; set; } - public bool Applied { get; set; } - public bool SingleMatch { get; set; } = true; + public PatchLocator Locate { get; set; } public string[] CandidateFileNames { get; set; } public string[] SearchHints { get; set; } - public ResolveContext Resolver { get; set; } - } - - private static string RequireGroup(Match match, string groupName, string patchName) - { - var group = match.Groups[groupName]; - if (!group.Success || string.IsNullOrEmpty(group.Value)) - { - throw new Exception($"{patchName} failed to resolve {groupName}"); - } - return group.Value; - } - - private static string RequirePattern(string source, string pattern, string groupName, string patchName) - { - var match = Regex.Match(source, pattern, RegexOptions.Singleline); - return RequireGroup(match, groupName, patchName); - } - - private static string BuildSetAccountLanguagePatch(Match match) - { - var parameters = RequireGroup(match, "params", "setAccountLanguage"); - var expr = RequireGroup(match, "expr", "setAccountLanguage"); - return $"setAccountLanguage({parameters}){{return ({expr}).then(response=>{{response&&\"object\"==typeof response&&(response.subscription={{period:\"yearly\",state:\"active\"}});return response;}})}}"; - } + /// Marks the patch optional: builds without these strings lack the feature entirely. + public string[] CapabilityHints { get; set; } - private static string BuildSetAccountReducerPatch(Match match) - { - var decl = RequireGroup(match, "decl", "setAccountReducer"); - var fn = RequireGroup(match, "fn", "setAccountReducer"); - var parameters = RequireGroup(match, "params", "setAccountReducer"); - var state = RequireGroup(match, "state", "setAccountReducer"); - var account = RequireGroup(match, "account", "setAccountReducer"); - return - $"const {decl}=\"ACTION_SET_ACCOUNT\";function {fn}({parameters}){{const a={account}&&\"object\"==typeof {account}?{{...{account},subscription:{{period:\"yearly\",state:\"active\"}}}}:{account};return{{...{state},account:a}}}}"; - } + public bool Applied { get; set; } + public bool CapabilityDetected { get; set; } - private static string BuildRemoteBridgeResetPatch(Match match) - { - var source = match.Value; - var method = RequireGroup(match, "method", "remoteBridgeReset"); - var disposableField = RequirePattern(source, @"this\.(?#[\w$]+)\s*&&\s*\(\s*this\.\k\.dispose\(\)", "disposable", "remoteBridgeReset"); - var instanceField = RequirePattern(source, @"this\.(?#[\w$]+)\s*=\s*Date\.now\(\)\.toString\(\)", "instance", "remoteBridgeReset"); - var trainerIdField = RequirePattern(source, @"Date\.now\(\)\.toString\(\)\s*\)?\s*,\s*\(?\s*this\.(?#[\w$]+)\s*=\s*null", "trainerId", "remoteBridgeReset"); - var supportedVersionsField = RequirePattern(source, @"this\.(?#[\w$]+)\s*=\s*\[\]", "versions", "remoteBridgeReset"); - var trainerField = RequirePattern(source, @"this\.(?#[\w$]+)\s*=\s*\[\]\s*\)?\s*,\s*\(?\s*this\.(?#[\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)}}"; - } + public bool IsOptional => CapabilityHints != null && CapabilityHints.Length > 0; - private static string BuildRemoteBridgeSyncSnapshotPatch(Match match) - { - var source = match.Value; - var method = RequireGroup(match, "method", "remoteBridgeSyncSnapshot"); - var statusAlias = RequirePattern(source, @"this\.status\s*===\s*(?[\w$]+)\.Connected", "value", "remoteBridgeSyncSnapshot"); - var trainerField = RequirePattern(source, @"this\.(?#[\w$]+)\?\.\s*getMetadata\s*\(\s*(?[\w$]+\.[\w$]+)\s*\)\?\.\s*gameVersion", "trainer", "remoteBridgeSyncSnapshot"); - var metadataExport = RequirePattern(source, @"this\.(?#[\w$]+)\?\.\s*getMetadata\s*\(\s*(?[\w$]+\.[\w$]+)\s*\)\?\.\s*gameVersion", "metadata", "remoteBridgeSyncSnapshot"); - var notesField = RequirePattern(source, @"this\.(?#[\w$]+)\s*\[\s*this\.(?#[\w$]+)\s*\?\?\s*""""\s*\]", "notes", "remoteBridgeSyncSnapshot"); - var trainerIdField = RequirePattern(source, @"this\.(?#[\w$]+)\s*\[\s*this\.(?#[\w$]+)\s*\?\?\s*""""\s*\]", "trainerId", "remoteBridgeSyncSnapshot"); - var gameField = RequirePattern(source, @"this\.(?#[\w$]+)\s*&&.*?getPreferredInstallationInfo\s*\(\s*this\.\k\s*\)", "game", "remoteBridgeSyncSnapshot"); - var installationField = RequirePattern(source, @"this\.(?#[\w$]+)\s*&&.*?this\.(?#[\w$]+)\.getPreferredInstallationInfo\s*\(\s*this\.\k\s*\)", "installation", "remoteBridgeSyncSnapshot"); - var supportedVersionsField = RequirePattern(source, @"!\s*this\.(?#[\w$]+)\.includes\s*\(\s*[\w$]+\.version\s*\)", "versions", "remoteBridgeSyncSnapshot"); - var remoteChannelField = RequirePattern(source, @"this\.(?#[\w$]+)\?\.\s*send\s*\(\s*""client-state""", "remote", "remoteBridgeSyncSnapshot"); - var valuesMethod = RequirePattern(source, @"values\s*:\s*this\.(?#[\w$]+)\s*\(\s*\)", "values", "remoteBridgeSyncSnapshot"); - var instanceField = RequirePattern(source, @"instanceId\s*:\s*this\.(?#[\w$]+)", "instance", "remoteBridgeSyncSnapshot"); - var themeField = RequirePattern(source, @"themeId\s*:\s*this\.(?#[\w$]+)", "theme", "remoteBridgeSyncSnapshot"); - var settingsHelper = RequirePattern(source, @"settings\s*:\s*(?[\w$]+)\s*\(\s*this\.settings\s*\)", "settings", "remoteBridgeSyncSnapshot"); - var languageField = RequirePattern(source, @"language\s*:\s*this\.(?#[\w$]+)", "language", "remoteBridgeSyncSnapshot"); - var timerField = RequirePattern(source, @"isTimeLimitExpired\s*:\s*""expired""\s*===\s*this\.(?#[\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}()}})}}"; + /// True once the patch is applied, or once a scan proved the feature is absent. + public bool IsResolved => Applied || (IsOptional && !CapabilityDetected); } public static Dictionary GetInstance() { - return new Dictionary() + return new Dictionary { { EPatchType.ActivatePro, @@ -107,80 +46,41 @@ public static Dictionary GetInstance() { new PatchEntry { - SearchHints = new[] { "getUserAccount()", "/v3/account" }, - Resolver = new ResolveContext - { - Handler = (targetFunction) => - { - var fetchMatch = Regex.Match(targetFunction, @"return\s+this\.#(\w+)\.fetch"); - return fetchMatch.Success ? fetchMatch.Groups[1].Value : null; - }, - Placeholder = "" - }, Name = "getUserAccount", - Target = new Regex(@"getUserAccount\(\)\{.*?return\s+this\.#\w+\.fetch\(\{.*?\}\)\}", - RegexOptions.Singleline), - Patch = - "getUserAccount(){return this.#.fetch({endpoint:\"/v3/account\",method:\"GET\",name:\"/v3/account\",collectMetrics:0}).then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}" + SearchHints = new[] { "getUserAccount(" }, + Locate = js => ForceProSubscription(js, "getUserAccount") }, new PatchEntry { - SearchHints = new[] { "setAccountWandBrandExperience()", "/v3/account/brand_experience_wand" }, - Resolver = new ResolveContext - { - Handler = (targetFunction) => - { - var match = Regex.Match(targetFunction, @"return\s+this\.#(\w+)\.post"); - return match.Success ? match.Groups[1].Value : null; - }, - Placeholder = "" - }, Name = "setAccountWandBrandExperience", - Target = new Regex( - @"setAccountWandBrandExperience\(\)\{.*?return\s+this\.#\w+\.post\(""/v3/account/brand_experience_wand""\)\}", - RegexOptions.Singleline), - Patch = - "setAccountWandBrandExperience(){return this.#.post(\"/v3/account/brand_experience_wand\").then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}" + SearchHints = new[] { "setAccountWandBrandExperience(" }, + CapabilityHints = new[] { "/v3/account/brand_experience_wand" }, + Locate = js => ForceProSubscription(js, "setAccountWandBrandExperience") }, 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. + // Changing language returns a fresh account object that would otherwise + // overwrite the patched subscription in the store. Name = "setAccountLanguage", - SearchHints = new[] { "setAccountLanguage(", "/v3/account/language" }, - Target = new Regex( - @"setAccountLanguage\((?[^)]*)\)\{\s*return\s+(?this\.#\w+\.post\(""/v3/account/language"",\{[^}]*\}\))\s*;?\s*\}", - RegexOptions.Singleline), - PatchFactory = BuildSetAccountLanguagePatch + SearchHints = new[] { "setAccountLanguage(" }, + Locate = js => ForceProSubscription(js, "setAccountLanguage") }, new PatchEntry { - // Last-resort guard: any code path that dispatches ACTION_SET_ACCOUNT - // (periodic refreshAccount, push updates, profile edits, etc.) must keep - // subscription on the store object even when it bypasses the account API - // service methods patched above. + // Catches every path that dispatches ACTION_SET_ACCOUNT without going + // through the account API methods above (refresh, push, profile edits). Name = "setAccountReducer", SearchHints = new[] { "ACTION_SET_ACCOUNT" }, - Target = new Regex( - @"const (?\w+)=""ACTION_SET_ACCOUNT"";function (?\w+)\((?[^)]*)\)\{return\{\.\.\.(?\w+),account:(?\w+)\}\}", - RegexOptions.Singleline), - PatchFactory = BuildSetAccountReducerPatch + Locate = LocateAccountReducer }, new PatchEntry { - // Wand's native "connect phone" pairing (POST /v3/auth/remote_code) - // triggers a server-side device handoff that deauthorizes this desktop - // session - the reported "entered the mobile activation key and got - // signed out" bug. Neutralize the code issuer so native pairing can - // never start. The injected remote panel is independent of this flow - // (IPC bridge, not Wand's Pusher pairing) and keeps working. The - // rejection is swallowed by the caller's try/catch (renders no code). + // Wand's own phone pairing performs a server-side device handoff that + // signs this desktop session out. The injected panel does not use it. Name = "disableNativeRemotePairing", - SearchHints = new[] { "requestRemoteAuthCode", "/v3/auth/remote_code" }, - Target = new Regex(@"requestRemoteAuthCode\(\)\{return this\.#[\w$]+\.post\(""/v3/auth/remote_code""\)\}"), - Patch = "requestRemoteAuthCode(){return Promise.reject(new Error(\"wand-enhancer: native mobile pairing disabled\"))}" + SearchHints = new[] { "requestRemoteAuthCode" }, + Locate = js => Edits(js.FindFunction("requestRemoteAuthCode")? + .ReplaceBody(PatchPayload.Load("disable-native-pairing"))) } } }, @@ -188,15 +88,12 @@ public static Dictionary GetInstance() 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 { + Name = "disableUpdateCheck", 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)))" + Locate = LocateUpdateHandler } } }, @@ -206,18 +103,12 @@ public static Dictionary GetInstance() { new PatchEntry { + // Hooked in the main process: the renderer's keydown dispatcher is + // reshaped on every Wand release, the Electron app API is not. Name = "devToolsBeforeInputEvent", CandidateFileNames = new[] { "index.js" }, SearchHints = new[] { "whenReady().then(" }, - // Anchor on the Electron main-process `.whenReady().then(` - // call. This site is far more stable than the minified renderer - // keydown listener that previously held the F12 -> ACTION_OPEN_DEV_TOOLS - // dispatch (its identifiers and shape change on every Wand release). - // We attach a `before-input-event` hook to every BrowserWindow's - // webContents which toggles DevTools on F12 directly from the main - // process, bypassing the renderer dispatcher entirely. - Target = new Regex(@"(?\w+)\.whenReady\(\)\.then\("), - Patch = "${app}.on(\"browser-window-created\",((_,w)=>{try{w.webContents.on(\"before-input-event\",((_,i)=>{if(\"F12\"===i.key&&\"keyDown\"===i.type){w.webContents.isDevToolsOpened()?w.webContents.closeDevTools():w.webContents.openDevTools({mode:\"detach\"})}}))}catch(e){}})),${app}.whenReady().then(" + Locate = LocateDevToolsHook } } }, @@ -230,53 +121,261 @@ public static Dictionary GetInstance() Name = "remoteBridgeMainBoot", CandidateFileNames = new[] { "index.js" }, SearchHints = new[] { "whenReady().then(run)" }, - Target = new Regex(@"(?\w+)\.whenReady\(\)\.then\(run\)"), - Patch = "${app}.whenReady().then(()=>{try{const p=require(\"node:path\");require(p.join(__dirname,\"remote-panel\",\"bridge.cjs\")).installWandRuntime(require(\"electron\"));}catch(e){try{const fs=require(\"node:fs\"),os=require(\"node:os\"),p=require(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [boot-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}}return run()})" + Locate = LocateBridgeBoot }, new PatchEntry { Name = "remoteBridgeReset", SearchHints = new[] { "client-state" }, - Target = new Regex(@"(?#[\w$]+)\(\)\s*\{\s*(?(?:(?!__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 + Locate = LocateBridgeReset }, new PatchEntry { Name = "remoteBridgeSyncSnapshot", SearchHints = new[] { "client-state" }, - Target = new Regex(@"(?#[\w$]+)\(\)\s*\{\s*if\s*\(\s*this\.status\s*===\s*[\w$]+\.Connected\s*\)\s*\{(?.*?""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 + Locate = LocateBridgeSync }, new PatchEntry { - // Inject the bridge init + setHandler right after the method's opening - // brace; the rest of setCurrentTrainer is left untouched. Only `${trainer}` - // (active-trainer field) and `${remoteSource}` (value-source enum, taken - // via lookahead from the sole `e.source!==` site) vary between builds and - // are resolved from the match — nothing is hardcoded. Name = "remoteBridgeBindHandler", - SearchHints = new[] { "client-state" }, - Target = new Regex(@"(?setCurrentTrainer\(e,t=null\)\{)(?=const s=e\?\.trainerId\|\|null,i=\(s\?e\?\.gameId:null\)\|\|null,n=\(s\?e\?\.supportedVersions:null\)\|\|\[\];if\(s===this\.#[\w$]+&&t===this\.(?#[\w$]+)\)return;)(?=.*?e\.source!==(?[\w$]+\.[\w$]+\.Remote))", - RegexOptions.Singleline), - Patch = "${head}this.__wandRemoteBridge||(this.__wandRemoteBridge=(()=>{try{const r=globalThis.require||require;const{ipcRenderer:c}=r(\"electron\");try{c.invoke(\"wand-remote-url\").then((u=>{u&&(globalThis.__wandRemoteBridgeUrl=u)}))}catch(e){}const send=(ch,p)=>{try{return c.invoke(ch,p&&JSON.parse(JSON.stringify(p)))}catch(e){}};return{sync:(s)=>send(\"wand-remote-sync\",s),valueChanged:(s)=>send(\"wand-remote-value-changed\",s),setHandler:(h)=>{if(this.__wandRemoteBridgeBound)return;this.__wandRemoteBridgeBound=true;try{c.invoke(\"wand-remote-set-handler-bind\")}catch(e){}c.on(\"wand-remote-set-value\",(_e,req)=>{try{h(req)}catch(e){}})}}}catch(e){try{const r=globalThis.require||require,fs=r(\"node:fs\"),os=r(\"node:os\"),p=r(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [renderer-bind-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}return null}})());this.__wandRemoteBridge?.setHandler((e=>{if(!this.${trainer}||!e?.target)return!1;return this.${trainer}.isActive()?this.${trainer}.setValue(e.target,e.value,${remoteSource},e.cheatId):!1}));this.__wandRemoteTrainerInfo=e??null;" + SearchHints = new[] { "setCurrentTrainer(" }, + Locate = LocateBridgeBindHandler }, new PatchEntry { - // Pure insertion: splice one `valueChanged` bridge call in after the - // existing `client-value-changed` send, before the onValueSet callback - // closes. Resolves no private names — `${head}`/`${tail}` carry the - // original text verbatim. trainerId is omitted from the payload; - // bridge-state falls back to the active snapshot trainer. Name = "remoteBridgeValueDelta", SearchHints = new[] { "client-value-changed" }, - Target = new Regex(@"(?#[\w$]+\(e,t\)\{t\.push\(e\.onValueSet\(e=>\{this\.status===[\w$]+\.Connected&&e\.source!==[\w$]+\.[\w$]+\.Remote&&this\.#[\w$]+\?\.send\(""client-value-changed"",\{instanceId:this\.#[\w$]+,name:e\.name,value:e\.value,cheatId:e\.cheatId\}\))(?\}\)\),this\.#[\w$]+\(\)\})"), - Patch = "${head},this.__wandRemoteBridge?.valueChanged({target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??\"desktop\"),cheatId:e.cheatId})${tail}" + Locate = LocateBridgeValueDelta } } } }; } + + /// Wraps the account-returning promise so the resolved account always reports an active subscription. + private static JsEdit[] ForceProSubscription(JsCursor js, string methodName) + { + return Edits(js.FindFunction(methodName)?.WrapReturn(PatchPayload.Load("pro-subscription"))); + } + + private static JsEdit[] LocateAccountReducer(JsCursor js) + { + int anchor = js.IndexOf("\"ACTION_SET_ACCOUNT\""); + var reducer = anchor < 0 ? null : js.FindFunctionAfter(anchor); + if (reducer == null) + { + return null; + } + + // The payload's ${account} survives PatchPayload untouched and is resolved by the + // regex replacement below, which is what carries the original identifier through. + return Edits(reducer.ReplaceInBody( + @"account:\s*(?[\w$]+)", + PatchPayload.Load("pro-account-reducer"))); + } + + private static JsEdit[] LocateUpdateHandler(JsCursor js) + { + int callOpen = js.FindCall("registerHandler", "\"ACTION_CHECK_FOR_UPDATE\""); + if (callOpen < 0) + { + return null; + } + + return Edits(new JsEdit(callOpen + 1, js.MatchClose(callOpen), PatchPayload.Load("disable-updates"))); + } + + private static JsEdit[] LocateDevToolsHook(JsCursor js) + { + var match = WhenReady.Match(js.Text); + if (!match.Success) + { + return null; + } + + var payload = PatchPayload.Load("devtools-f12", "app", match.Groups["app"].Value); + return Edits(new JsEdit(match.Index, match.Index, payload)); + } + + private static JsEdit[] LocateBridgeBoot(JsCursor js) + { + var match = WhenReadyThenRun.Match(js.Text); + if (!match.Success) + { + return null; + } + + var payload = PatchPayload.Load("remote-bridge-boot", "app", match.Groups["app"].Value); + return Edits(new JsEdit(match.Index, match.Index + match.Length, payload)); + } + + /// Clears the bridge alongside the session fields the reset method already nulls out. + private static JsEdit[] LocateBridgeReset(JsCursor js) + { + var sync = FindClientStateMethod(js); + var reset = sync == null ? null : js.FunctionEndingAt(js.SkipWhitespaceBack(sync.Start - 1)); + if (reset == null || reset.Body.IndexOf("Date.now()", StringComparison.Ordinal) < 0) + { + return null; + } + + return Edits(reset.InsertAtEnd(PatchPayload.Load("remote-bridge-reset"))); + } + + /// + /// Mirrors Wand's own client-state payload to the bridge by copying the object literal + /// verbatim, so fields Wand adds or drops between builds carry over untouched. + /// + private static JsEdit[] LocateBridgeSync(JsCursor js) + { + int sendOpen = js.FindCall("send", "\"client-state\""); + if (sendOpen < 0) + { + return null; + } + + var method = js.EnclosingFunction(sendOpen); + int snapshotOpen = js.IndexOf("{", sendOpen); + int snapshotClose = js.MatchClose(snapshotOpen); + if (method == null || snapshotOpen < 0 || snapshotClose < 0) + { + throw new Exception("client-state payload object could not be located"); + } + + // Prettified builds leave a trailing comma inside the literal; appending after it + // would produce an illegal hole. + string snapshot = js.Text.Substring(snapshotOpen + 1, snapshotClose - snapshotOpen - 1) + .Trim() + .TrimEnd(','); + + var payload = PatchPayload.Load( + "remote-bridge-sync", + "snapshot", snapshot, + "trainer", method.Resolve(@"this\.(?#[\w$]+)\s*\?\.\s*getMetadata", "trainer"), + "metadata", method.Resolve(@"getMetadata\(\s*(?[\w$]+\.[\w$]+)\s*\)", "metadata")); + + var edits = new List { new JsEdit(js.MatchClose(sendOpen) + 1, payload) }; + edits.AddRange(HoistConnectedGuard(js, sendOpen)); + return edits.ToArray(); + } + + /// + /// Some builds wrap the whole snapshot method in if (status === Connected). The bridge + /// must publish regardless of Wand's own remote status, so the guard is moved onto the send + /// itself, leaving the block - and the locals the payload reads - intact. + /// + private static IEnumerable HoistConnectedGuard(JsCursor js, int sendOpen) + { + int blockOpen = js.EnclosingOpener(sendOpen, '{'); + int closeParen = blockOpen < 0 ? -1 : js.SkipWhitespaceBack(blockOpen - 1); + if (closeParen < 0 || js.Text[closeParen] != ')') + { + yield break; + } + + var stack = js.OpenerStack(closeParen); + if (stack.Count == 0 || js.NameBefore(stack[0]) != "if") + { + yield break; + } + + int openParen = stack[0]; + string test = js.Text.Substring(openParen + 1, closeParen - openParen - 1); + + // Only the connection guard may be hoisted. A nested unrelated `if` would otherwise + // have its condition moved onto the send, and an `else` branch would be orphaned by + // turning the block into a bare one. + if (test.IndexOf("this.status", StringComparison.Ordinal) < 0 || HasElseBranch(js, blockOpen)) + { + yield break; + } + + int guardStart = js.SkipWhitespaceBack(openParen - 1) - 1; + + int calleeStart = sendOpen; + while (calleeStart > 0 && IsCalleeChar(js.Text[calleeStart - 1])) + { + calleeStart--; + } + + yield return new JsEdit(calleeStart, calleeStart, $"({test})&&"); + yield return new JsEdit(guardStart, blockOpen, string.Empty); + } + + private static bool HasElseBranch(JsCursor js, int blockOpen) + { + int afterBlock = js.SkipWhitespaceForward(js.MatchClose(blockOpen) + 1); + return string.CompareOrdinal(js.Text, afterBlock, "else", 0, 4) == 0; + } + + private static JsEdit[] LocateBridgeBindHandler(JsCursor js) + { + var method = js.FindFunction("setCurrentTrainer"); + if (method == null) + { + return null; + } + + // The same call reveals both the active-trainer field and the numeric or enum value + // Wand uses for a remote-originated write. Wand has sibling call sites for other + // sources (Overlay), so an ambiguous match would silently bind the wrong one. + var setValue = MatchExactlyOnce(RemoteSetValue, js.Text, "Remote setValue call"); + + return Edits(method.InsertAtStart(PatchPayload.Load( + "remote-bridge-renderer", + "trainer", setValue.Groups["trainer"].Value, + "remoteSource", setValue.Groups["source"].Value))); + } + + private static JsEdit[] LocateBridgeValueDelta(JsCursor js) + { + int sendOpen = js.FindCall("send", "\"client-value-changed\""); + if (sendOpen < 0) + { + return null; + } + + int sendClose = js.MatchClose(sendOpen); + return Edits(new JsEdit(sendClose + 1, PatchPayload.Load("remote-bridge-value-delta"))); + } + + private static JsFunction FindClientStateMethod(JsCursor js) + { + int sendOpen = js.FindCall("send", "\"client-state\""); + return sendOpen < 0 ? null : js.EnclosingFunction(sendOpen); + } + + private static JsEdit[] Edits(JsEdit edit) + { + return edit == null ? null : new[] { edit }; + } + + private static bool IsCalleeChar(char value) + { + return char.IsLetterOrDigit(value) || value == '_' || value == '$' || value == '#' + || value == '.' || value == '?'; + } + + /// Match that must be unambiguous: zero or several hits mean an unsupported build. + private static Match MatchExactlyOnce(Regex pattern, string text, string what) + { + var match = pattern.Match(text); + if (!match.Success) + { + throw new Exception($"{what} could not be located"); + } + + if (match.NextMatch().Success) + { + throw new Exception($"{what} matched more than once; cannot tell which call site is the right one"); + } + + return match; + } + + private static readonly Regex WhenReady = new Regex(@"(?[\w$]+)\.whenReady\(\)\.then\("); + private static readonly Regex WhenReadyThenRun = new Regex(@"(?[\w$]+)\.whenReady\(\)\.then\(run\)"); + private static readonly Regex RemoteSetValue = + new Regex(@"this\.(?#[\w$]+)\.setValue\(\s*e\.name\s*,\s*e\.value\s*,\s*(?[^,]+?)\s*,"); } } diff --git a/WandEnhancer/Core/FuseLauncher.cs b/WandEnhancer/Core/FuseLauncher.cs new file mode 100644 index 00000000..822c719e --- /dev/null +++ b/WandEnhancer/Core/FuseLauncher.cs @@ -0,0 +1,394 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using WandEnhancer.View.MainWindow; + +namespace WandEnhancer.Core +{ + /// + /// Launches Electron under a startup-only debugger and clears the ASAR integrity + /// fuse in every process it spawns (main, renderer, gpu, utility). Electron respawns + /// children from its own on-disk exe where the fuse is still enabled, so patching only + /// the main process leaves renderers crashing with -36861. The debugger stops each child + /// at creation, so there is no race, and memory patching is immune to Chromium's sandbox + /// DLL-signature mitigations. We detach once the window is up - long before any game + /// launch - so game anti-debug/DRM is never exposed to a debugger. + /// + internal static class FuseLauncher + { + private const int FuseAsarIntegrity = 4; + private const byte FuseStateRemoved = (byte)'r'; + private const int SentinelLength = 32; + private const int ScanChunkSize = 0x100000; + + // Electron's fuse wire follows the sentinel: [version][fuseCount][state per fuse]. + private const int FuseWireVersionOffset = 0; + private const int FuseWireCountOffset = 1; + private const int FuseWireStatesOffset = 2; + private const byte FuseWireSupportedVersion = 1; + private const int FuseWireMinCount = 5; + // Longest tail read past a sentinel hit: version + count + the fuse we edit. + private const int FuseWireTailBytes = FuseWireStatesOffset + FuseAsarIntegrity + 1; + + // x64 DEBUG_EVENT: dwDebugEventCode, dwProcessId, dwThreadId, 4 bytes padding, + // then the union. CREATE_PROCESS_DEBUG_INFO starts with hFile, hProcess, hThread, + // lpBaseOfImage; EXCEPTION_DEBUG_INFO starts with the exception code. + private const int DebugEventSize = 192; + private const int OffsetDebugEventCode = 0; + private const int OffsetProcessId = 4; + private const int OffsetThreadId = 8; + private const int OffsetUnion = 16; + private const int OffsetExceptionCode = OffsetUnion; + // EXCEPTION_DEBUG_INFO is EXCEPTION_RECORD (152 bytes on x64) followed by dwFirstChance. + private const int OffsetExceptionFirstChance = OffsetUnion + 152; + private const int OffsetExitCode = OffsetUnion; + private const int OffsetCreateProcessFile = OffsetUnion; + private const int OffsetCreateProcessHandle = OffsetUnion + 8; + private const int OffsetCreateProcessImageBase = OffsetUnion + 24; + + // Detach after the startup process burst settles (all children spawned and patched), + // capped hard so we never linger into gameplay. + private const long MinDebugMs = 3000; + private const long QuietMs = 1500; + private const long MaxDebugMs = 9000; + + // Electron dies a second or two after a renderer fails, which is past the detach. + // Watching that window is the only way the exit code reaches the log. + private const int PostDetachWatchMs = 5000; + + private const int AsarIntegrityExitCode = -36861; + + private static readonly byte[] Sentinel = + Encoding.ASCII.GetBytes("dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX"); + + public static bool Launch(string exePath, string args, Action log = null) + { + var si = new STARTUPINFO { cb = Marshal.SizeOf() }; + var cmdLine = new StringBuilder( + string.IsNullOrEmpty(args) ? $"\"{exePath}\"" : $"\"{exePath}\" {args}"); + + if (!CreateProcessW(null, cmdLine, IntPtr.Zero, IntPtr.Zero, + false, DEBUG_PROCESS, IntPtr.Zero, + Path.GetDirectoryName(exePath), ref si, out var pi)) + { + log?.Invoke($"Could not start Wand under the fuse patcher (win32 error {Marshal.GetLastWin32Error()}).", + ELogType.Error); + return false; + } + + // Debugged processes must survive after we detach and exit. + DebugSetProcessKillOnExit(false); + CloseHandle(pi.hThread); + log?.Invoke($"Started {exePath} as pid {pi.dwProcessId}.", ELogType.Info); + + try + { + // The process handle outlives the debug loop on purpose: once detached it is + // the only remaining way to read why Wand died. + if (!DrivePatchingDebugLoop(pi.dwProcessId, log)) + { + WatchAfterDetach(pi.hProcess, log); + } + } + finally + { + CloseHandle(pi.hProcess); + } + + return true; + } + + /// True when the main process exited while the debugger was still attached. + private static bool DrivePatchingDebugLoop(int mainPid, Action log) + { + var pids = new List(); + var brokeIn = new HashSet(); + var evt = new byte[DebugEventSize]; + // Stopwatch, not TickCount: TickCount is a 32-bit millisecond counter that wraps + // every ~25 days, and a negative elapsed would keep the debugger attached forever. + var clock = Stopwatch.StartNew(); + long lastCreate = 0; + int created = 0; + int patched = 0; + + while (true) + { + long now = clock.ElapsedMilliseconds; + + if (!WaitForDebugEvent(evt, 200)) + { + if (ShouldDetach(now, now - lastCreate)) break; + continue; + } + + int code = BitConverter.ToInt32(evt, OffsetDebugEventCode); + int pid = BitConverter.ToInt32(evt, OffsetProcessId); + int tid = BitConverter.ToInt32(evt, OffsetThreadId); + uint status = DBG_CONTINUE; + + switch (code) + { + case CREATE_PROCESS_DEBUG_EVENT: + var hFile = (IntPtr)BitConverter.ToInt64(evt, OffsetCreateProcessFile); + var hProc = (IntPtr)BitConverter.ToInt64(evt, OffsetCreateProcessHandle); + var baseImg = (IntPtr)BitConverter.ToInt64(evt, OffsetCreateProcessImageBase); + if (!pids.Contains(pid)) pids.Add(pid); + created++; + bool cleared = PatchFuse(hProc, baseImg); + if (cleared) patched++; + log?.Invoke( + $"pid {pid} started at {now} ms - fuse " + + (cleared ? "cleared." : $"NOT cleared, it may exit with {AsarIntegrityExitCode}."), + cleared ? ELogType.Info : ELogType.Warn); + // The debugger owns the image handle the kernel hands over with this event. + if (hFile != IntPtr.Zero) CloseHandle(hFile); + lastCreate = now; + break; + + case EXCEPTION_DEBUG_EVENT: + int exCode = BitConverter.ToInt32(evt, OffsetExceptionCode); + // Pass the one-shot startup breakpoint, let the app own the rest. + status = (exCode == EXCEPTION_BREAKPOINT && brokeIn.Add(pid)) + ? DBG_CONTINUE + : DBG_EXCEPTION_NOT_HANDLED; + // Chromium raises first-chance exceptions constantly and handles them. + // A second chance means nothing handled it and the process is dying. + if (BitConverter.ToInt32(evt, OffsetExceptionFirstChance) == 0) + log?.Invoke($"pid {pid} hit an unhandled exception at {now} ms: {DescribeCode(exCode)}.", + ELogType.Error); + break; + + case EXIT_PROCESS_DEBUG_EVENT: + int exitCode = BitConverter.ToInt32(evt, OffsetExitCode); + pids.Remove(pid); + log?.Invoke( + $"{(pid == mainPid ? "Main process" : $"pid {pid}")} exited at {now} ms " + + $"with code {DescribeCode(exitCode)}.", + exitCode == 0 ? ELogType.Info : ELogType.Error); + + if (pid == mainPid) + { + log?.Invoke($"Wand exited during startup: {created} processes started, {patched} fuse-patched.", + ELogType.Error); + ContinueDebugEvent(pid, tid, status); + return true; + } + break; + } + + ContinueDebugEvent(pid, tid, status); + + now = clock.ElapsedMilliseconds; + if (ShouldDetach(now, now - lastCreate)) + break; + } + + long detachedAt = clock.ElapsedMilliseconds; + // The detach reason matters: hitting the cap means Electron was still spawning + // processes we never patched, which looks exactly like "Wand does not open". + string reason = detachedAt > MaxDebugMs + ? $"{MaxDebugMs} ms cap reached" + : $"no new process for {QuietMs} ms"; + log?.Invoke( + $"Detached after {detachedAt} ms ({reason}): {created} processes started, " + + $"{patched} fuse-patched, {pids.Count} still attached.", + patched == 0 ? ELogType.Error : ELogType.Info); + + foreach (var pid in pids) + DebugActiveProcessStop(pid); + + return false; + } + + /// + /// Electron usually dies a second or two after a renderer fails, which lands after the + /// detach. Without this the log ends on a healthy-looking "detached" line. + /// + private static void WatchAfterDetach(IntPtr hProcess, Action log) + { + if (WaitForSingleObject(hProcess, PostDetachWatchMs) != WAIT_OBJECT_0) + { + log?.Invoke($"Wand still running {PostDetachWatchMs} ms after detach.", ELogType.Success); + return; + } + + if (!GetExitCodeProcess(hProcess, out int exitCode)) + { + log?.Invoke($"Wand exited after detach, exit code unreadable (win32 error {Marshal.GetLastWin32Error()}).", + ELogType.Error); + return; + } + + log?.Invoke($"Wand exited right after detach with code {DescribeCode(exitCode)}.", ELogType.Error); + } + + + private static string DescribeCode(int code) + { + switch (code) + { + case 0: return "0"; + case AsarIntegrityExitCode: + return $"{code} (ASAR integrity check failed - the fuse was not cleared in that process)"; + case unchecked((int)0xC0000005): return $"0x{code:X8} (access violation)"; + case unchecked((int)0xC0000135): return $"0x{code:X8} (a required DLL is missing)"; + case unchecked((int)0xC0000142): return $"0x{code:X8} (a DLL failed to initialise)"; + case unchecked((int)0xC0000409): return $"0x{code:X8} (stack buffer overrun)"; + default: return $"{code} (0x{code:X8})"; + } + } + + private static bool ShouldDetach(long elapsed, long sinceLastCreate) + { + if (elapsed > MaxDebugMs) return true; + return elapsed > MinDebugMs && sinceLastCreate > QuietMs; + } + + private static bool PatchFuse(IntPtr hProcess, IntPtr imageBase) + { + if (imageBase == IntPtr.Zero) return false; + + int sizeOfImage = ReadSizeOfImage(hProcess, imageBase); + if (sizeOfImage == 0) return false; + + const int overlap = 64; + var buffer = new byte[ScanChunkSize + overlap]; + + for (long offset = 0; offset < sizeOfImage; offset += ScanChunkSize) + { + int toRead = (int)Math.Min(ScanChunkSize + overlap, sizeOfImage - offset); + if (toRead < SentinelLength + FuseWireTailBytes) break; + + var addr = new IntPtr(imageBase.ToInt64() + offset); + if (!ReadProcessMemory(hProcess, addr, buffer, toRead, out int bytesRead)) + continue; + if (bytesRead < SentinelLength + FuseWireTailBytes) continue; + + int limit = bytesRead - SentinelLength - FuseWireTailBytes; + // Byte-by-byte: the linker is free to place the sentinel at any alignment, + // and a miss means every renderer dies with -36861. + for (int i = 0; i <= limit; i++) + { + if (buffer[i] != Sentinel[0] || !MatchesSentinel(buffer, i)) continue; + + int wireOffset = i + SentinelLength; + if (buffer[wireOffset + FuseWireVersionOffset] != FuseWireSupportedVersion || + buffer[wireOffset + FuseWireCountOffset] < FuseWireMinCount) continue; + + int fusePos = wireOffset + FuseWireStatesOffset + FuseAsarIntegrity; + if (buffer[fusePos] == FuseStateRemoved) return true; + + var target = new IntPtr(imageBase.ToInt64() + offset + fusePos); + VirtualProtectEx(hProcess, target, (UIntPtr)1, PAGE_READWRITE, out uint oldProt); + bool ok = WriteProcessMemory(hProcess, target, new[] { FuseStateRemoved }, 1, out _); + VirtualProtectEx(hProcess, target, (UIntPtr)1, oldProt, out _); + return ok; + } + } + + return false; + } + + private static bool MatchesSentinel(byte[] buffer, int offset) + { + for (int j = 1; j < SentinelLength; j++) + if (buffer[offset + j] != Sentinel[j]) return false; + return true; + } + + private static int ReadSizeOfImage(IntPtr hProcess, IntPtr imageBase) + { + var dosHeader = new byte[64]; + if (!ReadProcessMemory(hProcess, imageBase, dosHeader, 64, out _)) + return 0; + + int peOffset = BitConverter.ToInt32(dosHeader, 0x3C); + var buf = new byte[4]; + // SizeOfImage sits at optional-header offset 56 (PE signature + COFF header = 24). + var addr = new IntPtr(imageBase.ToInt64() + peOffset + 80); + if (!ReadProcessMemory(hProcess, addr, buf, 4, out _)) + return 0; + + return BitConverter.ToInt32(buf, 0); + } + + #region P/Invoke + + private const uint DEBUG_PROCESS = 0x1; + private const uint PAGE_READWRITE = 0x04; + private const uint DBG_CONTINUE = 0x00010002; + private const uint DBG_EXCEPTION_NOT_HANDLED = 0x80010001; + private const int EXCEPTION_DEBUG_EVENT = 1; + private const int CREATE_PROCESS_DEBUG_EVENT = 3; + private const int EXIT_PROCESS_DEBUG_EVENT = 5; + private const int EXCEPTION_BREAKPOINT = unchecked((int)0x80000003); + private const uint WAIT_OBJECT_0 = 0; + + [StructLayout(LayoutKind.Sequential)] + private struct STARTUPINFO + { + public int cb; + public IntPtr lpReserved, lpDesktop, lpTitle; + public int dwX, dwY, dwXSize, dwYSize; + public int dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags; + public short wShowWindow, cbReserved2; + public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public IntPtr hProcess, hThread; + public int dwProcessId, dwThreadId; + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CreateProcessW( + string lpApplicationName, StringBuilder lpCommandLine, + IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, + bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, + string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, + out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadProcessMemory( + IntPtr hProcess, IntPtr lpBaseAddress, + byte[] lpBuffer, int dwSize, out int lpNumberOfBytesRead); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool WriteProcessMemory( + IntPtr hProcess, IntPtr lpBaseAddress, + byte[] lpBuffer, int dwSize, out int lpNumberOfBytesWritten); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool VirtualProtectEx( + IntPtr hProcess, IntPtr lpAddress, UIntPtr dwSize, + uint flNewProtect, out uint lpflOldProtect); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool WaitForDebugEvent(byte[] lpDebugEvent, int dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ContinueDebugEvent(int dwProcessId, int dwThreadId, uint dwContinueStatus); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DebugActiveProcessStop(int dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DebugSetProcessKillOnExit(bool KillOnExit); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr hHandle, int dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr hProcess, out int lpExitCode); + + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr hObject); + + #endregion + } +} diff --git a/WandEnhancer/Core/JavaScriptPatchApplier.cs b/WandEnhancer/Core/JavaScriptPatchApplier.cs new file mode 100644 index 00000000..2e78286e --- /dev/null +++ b/WandEnhancer/Core/JavaScriptPatchApplier.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Linq; +using WandEnhancer.Core.Js; +using WandEnhancer.Models; +using WandEnhancer.View.MainWindow; + +namespace WandEnhancer.Core +{ + internal sealed class JavaScriptPatchApplier + { + private readonly Action _logger; + + public JavaScriptPatchApplier(Action logger) + { + _logger = logger; + } + + public string Apply(string fileName, string source, EnhancerConfig.PatchEntry patch, EPatchType patchType, out bool patchApplied) + { + patchApplied = false; + if (patch.Applied || !CanSearchFile(fileName, patch)) + { + return source; + } + + patch.CapabilityDetected |= ContainsAny(source, patch.CapabilityHints); + if (!ContainsAny(source, patch.SearchHints)) + { + return source; + } + + string label = FormatLabel(patchType, patch); + JsEdit[] edits; + try + { + edits = patch.Locate(new JsCursor(source)); + } + catch (Exception e) + { + throw new Exception($"[ENHANCER] [{label}] {e.Message}. The version may not be supported.", e); + } + + if (edits == null || edits.Length == 0) + { + return source; + } + + _logger($"[ENHANCER] [{label}] Found target in: {Path.GetFileName(fileName)}", ELogType.Info); + foreach (var edit in edits.OrderByDescending(edit => edit.Start)) + { + source = edit.ApplyTo(source); + } + + _logger($"[ENHANCER] [{label}] Patch applied", ELogType.Success); + patch.Applied = true; + patchApplied = true; + return source; + } + + public static string FormatLabel(EPatchType patchType, EnhancerConfig.PatchEntry patch) + { + return string.IsNullOrEmpty(patch.Name) ? patchType.ToString() : $"{patchType} -> {patch.Name}"; + } + + public static bool CanSearchFile(string filePath, EnhancerConfig.PatchEntry patch) + { + if (patch.CandidateFileNames == null || patch.CandidateFileNames.Length == 0) + { + return true; + } + + string fileName = Path.GetFileName(filePath); + return patch.CandidateFileNames.Any(candidate => fileName.Equals(candidate, StringComparison.OrdinalIgnoreCase)); + } + + private static bool ContainsAny(string source, string[] hints) + { + return hints != null && hints.Any(hint => source.IndexOf(hint, StringComparison.Ordinal) >= 0); + } + } +} diff --git a/WandEnhancer/Core/Js/JsCursor.cs b/WandEnhancer/Core/Js/JsCursor.cs new file mode 100644 index 00000000..e4e5aa3e --- /dev/null +++ b/WandEnhancer/Core/Js/JsCursor.cs @@ -0,0 +1,409 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace WandEnhancer.Core.Js +{ + /// + /// Navigates minified JavaScript by matching delimiters rather than by matching shape. + /// Wand renames identifiers on every build but never renames its API endpoints, IPC + /// channel names or public method names, so anchoring on those and walking the + /// delimiter structure keeps a patch valid across builds. + /// + internal sealed class JsCursor + { + private const string RegexPrecedingChars = "(,=:[!&|?{};+-*%~^<>"; + private const int NameLookbackChars = 128; + private static readonly Regex NameBeforeParen = new Regex(@"[#\w$]+$"); + private static readonly Regex FunctionKeyword = new Regex(@"(? BlockKeywords = + new HashSet(StringComparer.Ordinal) { "if", "for", "while", "switch", "catch", "with", "do", "else" }; + + // A slash after one of these is a regex literal, not division. Minifiers emit + // `return/re/.test(x)` with no space, so missing these desyncs the whole scan. + private static readonly HashSet RegexPrecedingKeywords = + new HashSet(StringComparer.Ordinal) + { + "return", "typeof", "instanceof", "in", "of", "new", "delete", "void", + "throw", "case", "do", "else", "yield", "await" + }; + + private readonly string _text; + + public JsCursor(string text) + { + _text = text; + } + + public string Text => _text; + + public int IndexOf(string value, int from = 0) + { + return from >= _text.Length ? -1 : _text.IndexOf(value, from, StringComparison.Ordinal); + } + + /// Index of the delimiter closing the one at , or -1. + public int MatchClose(int openIndex) + { + char open = _text[openIndex]; + char close = CloserOf(open); + int depth = 0; + + for (int index = openIndex; index < _text.Length;) + { + char current = _text[index]; + if (current == open) + { + depth++; + index++; + } + else if (current == close) + { + if (--depth == 0) + { + return index; + } + + index++; + } + else + { + index = SkipToken(index); + } + } + + return -1; + } + + /// Open delimiters enclosing , innermost first. + public List OpenerStack(int index) + { + var stack = new List(); + for (int cursor = 0; cursor < index && cursor < _text.Length;) + { + char current = _text[cursor]; + if (current == '{' || current == '(' || current == '[') + { + stack.Add(cursor); + cursor++; + } + else if (current == '}' || current == ')' || current == ']') + { + if (stack.Count > 0) + { + stack.RemoveAt(stack.Count - 1); + } + + cursor++; + } + else + { + cursor = SkipToken(cursor); + } + } + + stack.Reverse(); + return stack; + } + + /// Innermost enclosing delimiter of the given kind, or -1. + public int EnclosingOpener(int index, char kind) + { + foreach (int opener in OpenerStack(index)) + { + if (_text[opener] == kind) + { + return opener; + } + } + + return -1; + } + + /// Innermost named function or method whose body contains . + public JsFunction EnclosingFunction(int index) + { + foreach (int opener in OpenerStack(index)) + { + if (_text[opener] != '{') + { + continue; + } + + var function = ReadFunctionAt(opener); + if (function != null) + { + return function; + } + } + + return null; + } + + /// The named function whose body closes at , or null. + public JsFunction FunctionEndingAt(int closeIndex) + { + if (closeIndex < 0 || closeIndex >= _text.Length || _text[closeIndex] != '}') + { + return null; + } + + var stack = OpenerStack(closeIndex); + return stack.Count == 0 ? null : ReadFunctionAt(stack[0]); + } + + /// First function declared as name(...), ignoring property and call sites. + public JsFunction FindFunction(string name) + { + var pattern = new Regex($@"(?First function name(...) { } declared at or after . + public JsFunction FindFunctionAfter(int index) + { + var match = FunctionKeyword.Match(_text, index); + if (!match.Success) + { + return null; + } + + int closeParen = MatchClose(match.Index + match.Length - 1); + if (closeParen < 0) + { + return null; + } + + int bodyOpen = SkipWhitespaceForward(closeParen + 1); + return bodyOpen < _text.Length && _text[bodyOpen] == '{' ? ReadFunctionAt(bodyOpen) : null; + } + + /// + /// Index of the opening parenthesis of callee(... "literal" ...), or -1. Wand reuses the + /// same channel names for inbound listeners and outbound sends, so the callee disambiguates. + /// + public int FindCall(string callee, string literal) + { + for (int anchor = IndexOf(literal); anchor >= 0; anchor = IndexOf(literal, anchor + 1)) + { + int open = EnclosingOpener(anchor, '('); + if (open >= 0 && NameBefore(open) == callee) + { + return open; + } + } + + return -1; + } + + /// Trailing identifier directly before , e.g. send of a?.send(. + public string NameBefore(int index) + { + int end = SkipWhitespaceBack(index - 1) + 1; + var match = MatchNameEndingAt(end); + return match.Success ? match.Value.TrimStart('#') : null; + } + + /// Identifier ending at , searched in a bounded window so + /// multi-megabyte bundles are not copied on every lookup. + private Match MatchNameEndingAt(int end) + { + int windowStart = Math.Max(0, end - NameLookbackChars); + return NameBeforeParen.Match(_text.Substring(windowStart, end - windowStart)); + } + + public int SkipWhitespaceBack(int index) + { + while (index >= 0 && char.IsWhiteSpace(_text[index])) + { + index--; + } + + return index; + } + + public int SkipWhitespaceForward(int index) + { + while (index < _text.Length && char.IsWhiteSpace(_text[index])) + { + index++; + } + + return index; + } + + private JsFunction ReadFunctionAt(int bodyOpen) + { + int closeParen = SkipWhitespaceBack(bodyOpen - 1); + if (closeParen < 0 || _text[closeParen] != ')') + { + return null; + } + + var stack = OpenerStack(closeParen); + if (stack.Count == 0 || _text[stack[0]] != '(') + { + return null; + } + + int nameEnd = SkipWhitespaceBack(stack[0] - 1) + 1; + var nameMatch = MatchNameEndingAt(nameEnd); + if (!nameMatch.Success || BlockKeywords.Contains(nameMatch.Value)) + { + return null; + } + + int bodyClose = MatchClose(bodyOpen); + return bodyClose < 0 + ? null + : new JsFunction(nameMatch.Value, nameEnd - nameMatch.Length, bodyOpen, bodyClose, _text); + } + + private int SkipToken(int index) + { + char current = _text[index]; + if (current == '"' || current == '\'' || current == '`') + { + return SkipString(index, current); + } + + if (current != '/' || index + 1 >= _text.Length) + { + return index + 1; + } + + char next = _text[index + 1]; + if (next == '/') + { + int lineEnd = _text.IndexOf('\n', index); + return lineEnd < 0 ? _text.Length : lineEnd + 1; + } + + if (next == '*') + { + int commentEnd = _text.IndexOf("*/", index + 2, StringComparison.Ordinal); + return commentEnd < 0 ? _text.Length : commentEnd + 2; + } + + return StartsRegexLiteral(index) ? SkipRegexLiteral(index) : index + 1; + } + + private int SkipString(int index, char quote) + { + for (int cursor = index + 1; cursor < _text.Length; cursor++) + { + char current = _text[cursor]; + if (current == '\\') + { + cursor++; + } + else if (current == quote) + { + return cursor + 1; + } + else if (quote == '`' && current == '$' && cursor + 1 < _text.Length && _text[cursor + 1] == '{') + { + int interpolationEnd = MatchClose(cursor + 1); + cursor = interpolationEnd < 0 ? _text.Length : interpolationEnd; + } + } + + return _text.Length; + } + + private int SkipRegexLiteral(int index) + { + bool inCharacterClass = false; + for (int cursor = index + 1; cursor < _text.Length; cursor++) + { + char current = _text[cursor]; + if (current == '\\') + { + cursor++; + } + else if (current == '[') + { + inCharacterClass = true; + } + else if (current == ']') + { + inCharacterClass = false; + } + else if (current == '\n') + { + return index + 1; + } + else if (current == '/' && !inCharacterClass) + { + return cursor + 1; + } + } + + return _text.Length; + } + + private bool StartsRegexLiteral(int index) + { + int previous = SkipWhitespaceBack(index - 1); + if (previous < 0 || RegexPrecedingChars.IndexOf(_text[previous]) >= 0) + { + return true; + } + + return IsIdentifierChar(_text[previous]) && RegexPrecedingKeywords.Contains(WordEndingAt(previous)); + } + + /// The identifier ending at inclusive, or "" when there is none. + private string WordEndingAt(int end) + { + int start = end; + while (start >= 0 && IsIdentifierChar(_text[start])) + { + start--; + } + + // A preceding '.' makes it a member name (`x.in`), never a keyword. + if (start >= 0 && _text[start] == '.') + { + return string.Empty; + } + + return _text.Substring(start + 1, end - start); + } + + private static bool IsIdentifierChar(char value) + { + return char.IsLetterOrDigit(value) || value == '_' || value == '$'; + } + + private static char CloserOf(char open) + { + switch (open) + { + case '{': return '}'; + case '(': return ')'; + case '[': return ']'; + default: throw new ArgumentException($"Not an opening delimiter: {open}", nameof(open)); + } + } + } +} diff --git a/WandEnhancer/Core/Js/JsFunction.cs b/WandEnhancer/Core/Js/JsFunction.cs new file mode 100644 index 00000000..d5335c47 --- /dev/null +++ b/WandEnhancer/Core/Js/JsFunction.cs @@ -0,0 +1,129 @@ +using System; +using System.Text.RegularExpressions; + +namespace WandEnhancer.Core.Js +{ + /// A named function or class method located in a bundle, addressed by delimiter position. + internal sealed class JsFunction + { + private static readonly Regex ReturnKeyword = new Regex(@"(? _source.Substring(BodyOpen + 1, BodyClose - BodyOpen - 1); + + private JsCursor BodyCursor => _body ?? (_body = new JsCursor(Body)); + + /// Captures a group from a pattern matched against this body only, not the whole bundle. + public string Resolve(string pattern, string group) + { + var match = Regex.Match(Body, pattern, RegexOptions.Singleline); + if (!match.Success || string.IsNullOrEmpty(match.Groups[group].Value)) + { + throw new Exception($"Could not resolve '{group}' inside {Name}()"); + } + + return match.Groups[group].Value; + } + + /// Rewrites the first match of a pattern scoped to this body; ${group} back-references work. + public JsEdit ReplaceInBody(string pattern, string replacement) + { + var match = Regex.Match(Body, pattern, RegexOptions.Singleline); + if (!match.Success) + { + throw new Exception($"Pattern '{pattern}' not found inside {Name}()"); + } + + int start = BodyOpen + 1 + match.Index; + return new JsEdit(start, start + match.Length, match.Result(replacement)); + } + + public JsEdit InsertAtStart(string code) => new JsEdit(BodyOpen + 1, BodyOpen + 1, code); + + public JsEdit InsertAtEnd(string code) => new JsEdit(BodyClose, BodyClose, code); + + public JsEdit ReplaceBody(string code) => new JsEdit(BodyOpen + 1, BodyClose, code); + + /// + /// Rewrites the last top-level return X as return WRAPPER, where the wrapper's + /// $0 placeholder receives the original expression. + /// + public JsEdit WrapReturn(string wrapper) + { + var body = BodyCursor; + int keywordEnd = -1; + for (var match = ReturnKeyword.Match(body.Text); match.Success; match = match.NextMatch()) + { + if (body.OpenerStack(match.Index).Count == 0) + { + keywordEnd = match.Index + match.Length; + } + } + + if (keywordEnd < 0) + { + throw new Exception($"No top-level return statement in {Name}()"); + } + + int expressionStart = body.SkipWhitespaceForward(keywordEnd); + int expressionEnd = FindStatementEnd(body, expressionStart); + string expression = body.Text.Substring(expressionStart, expressionEnd - expressionStart); + + return new JsEdit( + BodyOpen + 1 + expressionStart, + BodyOpen + 1 + expressionEnd, + wrapper.Replace("$0", $"({expression})")); + } + + private static int FindStatementEnd(JsCursor body, int start) + { + for (int cursor = start; cursor < body.Text.Length; cursor++) + { + if (body.Text[cursor] == ';' && body.OpenerStack(cursor).Count == 0) + { + return cursor; + } + } + + return body.Text.Length; + } + } + + /// A splice: replace [Start, End) of the bundle with . + internal sealed class JsEdit + { + public JsEdit(int start, int end, string text) + { + Start = start; + End = end; + Text = text; + } + + /// An insertion at , replacing nothing. + public JsEdit(int at, string text) : this(at, at, text) + { + } + + public int Start { get; } + public int End { get; } + public string Text { get; } + + public string ApplyTo(string source) => source.Substring(0, Start) + Text + source.Substring(End); + } +} diff --git a/WandEnhancer/Core/Js/PatchPayload.cs b/WandEnhancer/Core/Js/PatchPayload.cs new file mode 100644 index 00000000..742c7e8f --- /dev/null +++ b/WandEnhancer/Core/Js/PatchPayload.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace WandEnhancer.Core.Js +{ + /// + /// Loads injected JavaScript from embedded Patches/*.js files so payloads stay + /// lintable source rather than escaped C# string literals. + /// + internal static class PatchPayload + { + private const string ResourcePrefix = "patches/"; + + private static readonly ConcurrentDictionary Cache = + new ConcurrentDictionary(StringComparer.Ordinal); + + private static readonly Regex Placeholder = new Regex(@"\$\{(?\w+)\}"); + + /// + /// Loads a payload, replacing each ${name} placeholder from alternating name/value pairs. + /// Substitution is a single pass, so injected bundle text is never rescanned for placeholders. + /// Unknown placeholders are left intact for the caller's own regex replacement to resolve. + /// + public static string Load(string name, params string[] placeholders) + { + if (placeholders.Length % 2 != 0) + { + throw new ArgumentException("Placeholders must be name/value pairs", nameof(placeholders)); + } + + var values = new Dictionary(StringComparer.Ordinal); + for (int index = 0; index < placeholders.Length; index += 2) + { + values[placeholders[index]] = placeholders[index + 1]; + } + + return Placeholder.Replace( + Cache.GetOrAdd(name, ReadResource), + match => values.TryGetValue(match.Groups["name"].Value, out var value) ? value : match.Value); + } + + private static string ReadResource(string name) + { + string resourceName = $"{ResourcePrefix}{name}.js"; + using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName)) + { + if (stream == null) + { + throw new FileNotFoundException($"Embedded patch payload not found: {resourceName}"); + } + + using (var reader = new StreamReader(stream)) + { + return reader.ReadToEnd().Trim(); + } + } + } + } +} diff --git a/WandEnhancer/Core/LauncherLog.cs b/WandEnhancer/Core/LauncherLog.cs new file mode 100644 index 00000000..ae3945fd --- /dev/null +++ b/WandEnhancer/Core/LauncherLog.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using WandEnhancer.View.MainWindow; + +namespace WandEnhancer.Core +{ + /// + /// Append-only log written next to the deployed launcher. Launch mode has no window and + /// exits as soon as Wand is up, so without this file a "Wand does not start" report + /// carries no evidence at all. Every operation swallows its own failure: diagnostics must + /// never be the reason Wand fails to launch. + /// + internal static class LauncherLog + { + public const string FileName = "launcher.log"; + private const long MaxBytes = 512 * 1024; + + private static string _path; + + public static void Open(string launcherDirectory, string header) + { + try + { + var file = new FileInfo(Path.Combine(launcherDirectory, FileName)); + // Dropped whole rather than trimmed: the session being diagnosed is the last + // one, and keeping half a rotated file is not worth the code. + if (file.Exists && file.Length > MaxBytes) + { + file.Delete(); + } + + _path = file.FullName; + Write($"=== {DateTime.Now:yyyy-MM-dd} {header}", ELogType.Info); + } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || + e is ArgumentException || e is NotSupportedException) + { + _path = null; + } + } + + public static void Write(string message, ELogType type) + { + if (_path == null) + { + return; + } + + try + { + File.AppendAllText(_path, + $"{DateTime.Now:HH:mm:ss.fff} [{type.ToString().ToUpperInvariant()}] {message}{Environment.NewLine}"); + } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + { + // A log line lost to a locked or full disk must not abort the launch. + } + } + } +} diff --git a/WandEnhancer/Core/Services/LocalizationManager.cs b/WandEnhancer/Core/Services/LocalizationManager.cs index 254f1258..c4a9b252 100644 --- a/WandEnhancer/Core/Services/LocalizationManager.cs +++ b/WandEnhancer/Core/Services/LocalizationManager.cs @@ -28,6 +28,22 @@ public static class LocalizationManager private static CultureInfo _currentLanguage; private static ResourceDictionary _englishBaseDictionary; + private static ResourceDictionary _activeLocaleDictionary; + + /// + /// Localized string for , falling back to the key itself so a + /// missing entry is visible rather than silently blank. + /// + public static string Get(string key) + { + return Application.Current?.TryFindResource(key) as string ?? key; + } + + /// Localized format string filled with . + public static string Format(string key, params object[] args) + { + return string.Format(Get(key), args); + } public static CultureInfo CurrentLanguage { @@ -104,20 +120,19 @@ private static void SetLanguage(CultureInfo culture, bool saveSettings = true) localeDict[entry.Key] = targetDict[entry.Key]; } - // Find and replace the old locale dictionary - var oldDict = Application.Current.Resources.MergedDictionaries - .FirstOrDefault(d => d.Source != null && d.Source.OriginalString.StartsWith("Locale/lang.")); - - if (oldDict != null) + // Track the dictionary we injected: it is built by merging entries, so its Source is + // null and a Source-based lookup never finds it - every switch used to append another. + var merged = Application.Current.Resources.MergedDictionaries; + if (_activeLocaleDictionary != null && merged.Contains(_activeLocaleDictionary)) { - var index = Application.Current.Resources.MergedDictionaries.IndexOf(oldDict); - Application.Current.Resources.MergedDictionaries.Remove(oldDict); - Application.Current.Resources.MergedDictionaries.Insert(index, localeDict); + merged[merged.IndexOf(_activeLocaleDictionary)] = localeDict; } else { - Application.Current.Resources.MergedDictionaries.Add(localeDict); + merged.Add(localeDict); } + + _activeLocaleDictionary = localeDict; if (saveSettings) { diff --git a/WandEnhancer/Core/Services/SettingsManager.cs b/WandEnhancer/Core/Services/SettingsManager.cs index 33a9e73d..03d70245 100644 --- a/WandEnhancer/Core/Services/SettingsManager.cs +++ b/WandEnhancer/Core/Services/SettingsManager.cs @@ -27,8 +27,7 @@ public static AppSettings LoadSettings() } catch (Exception) { - // Settings loading is non-critical - silently fall back to defaults - // This can fail due to file permissions, corrupted JSON, etc. + // Unreadable or corrupt settings must not block startup; defaults apply. } return null; } @@ -42,8 +41,7 @@ public static void SaveSettings(AppSettings settings) } catch (Exception) { - // Settings saving is non-critical - silently ignore errors - // This can fail due to file permissions or read-only directories + // A read-only install directory must not break the app; the choice is lost, not fatal. } } } diff --git a/WandEnhancer/Locale/lang.de-DE.xaml b/WandEnhancer/Locale/lang.de-DE.xaml index 6c2a7371..b975833f 100644 --- a/WandEnhancer/Locale/lang.de-DE.xaml +++ b/WandEnhancer/Locale/lang.de-DE.xaml @@ -7,7 +7,6 @@ - WandEnhancer Ordnerpfad Ordner nicht gefunden Anwenden @@ -35,7 +34,25 @@ Ausgewählte .js-Dateien werden in Wand gepackt und im Renderer geladen. Keine Skripte ausgewählt Starten + Nach Updates automatisch anwenden Was werden wir verbessern? + + WeMod-Verzeichnis unter {0} ({1}) gefunden + WeMod ist bereits gepatcht. Wenn Sie erneut patchen möchten, stellen Sie bitte zuerst das Backup wieder her. + Bereit zum Patchen. + WeMod-Verzeichnis nicht gefunden. + Vorgang nicht möglich. Bitte geben Sie zuerst das Verzeichnis an. + Der ausgewählte Ordner {0} ist kein gültiges WeMod-Verzeichnis. + Fehler beim Wiederherstellen des Backups: {0} + Fehler beim Patchen: {0} + Protokolle in die Zwischenablage kopiert. + Fehler beim Kopieren der Protokolle: {0} + Protokolle nach {0} exportiert. + Fehler beim Exportieren der Protokolle: {0} + {0} konnte nicht in einem Browser geöffnet werden. + Wählen Sie das WeMod-Verzeichnis aus + + diff --git a/WandEnhancer/Locale/lang.en-US.xaml b/WandEnhancer/Locale/lang.en-US.xaml index 9db07b47..216e2b30 100644 --- a/WandEnhancer/Locale/lang.en-US.xaml +++ b/WandEnhancer/Locale/lang.en-US.xaml @@ -7,7 +7,6 @@ - WandEnhancer Folder path Folder not found Enhance @@ -35,7 +34,25 @@ Selected .js files are packed into Wand and loaded in the renderer. No scripts selected Start + Auto-apply after updates What are we gonna enhance? + + WeMod directory found at {0} ({1}) + WeMod already patched. If you want to patch again, please restore the backup first. + Ready for patching. + WeMod directory not found. + Cant be done. Please specify the directory first. + The selected folder {0} is not a valid WeMod directory. + Failed to restore backup: {0} + Failed to patch: {0} + Logs copied to clipboard. + Failed to copy logs: {0} + Logs exported to {0}. + Failed to export logs: {0} + Could not open {0} in a browser. + Select the WeMod directory + + diff --git a/WandEnhancer/Locale/lang.es-ES.xaml b/WandEnhancer/Locale/lang.es-ES.xaml index 53907126..8a8893e4 100644 --- a/WandEnhancer/Locale/lang.es-ES.xaml +++ b/WandEnhancer/Locale/lang.es-ES.xaml @@ -7,7 +7,6 @@ - WandEnhancer Ruta de la carpeta Carpeta no encontrada Aplicar @@ -35,7 +34,25 @@ Los archivos .js seleccionados se empaquetan en Wand y se cargan en el renderer. No hay scripts seleccionados Iniciar + Aplicar automáticamente tras actualizar ¿Qué vamos a mejorar? + + Directorio de WeMod encontrado en {0} ({1}) + WeMod ya está parcheado. Si quieres parchear de nuevo, restaura la copia de seguridad primero. + Listo para parchear. + Directorio de WeMod no encontrado. + No se puede realizar. Por favor, especifica el directorio primero. + La carpeta seleccionada {0} no es un directorio de WeMod válido. + Error al restaurar la copia de seguridad: {0} + Error al parchear: {0} + Registros copiados al portapapeles. + Error al copiar los registros: {0} + Registros exportados a {0}. + Error al exportar los registros: {0} + No se pudo abrir {0} en un navegador. + Selecciona el directorio de WeMod + + diff --git a/WandEnhancer/Locale/lang.fr-FR.xaml b/WandEnhancer/Locale/lang.fr-FR.xaml index 9f6f00f6..0246c11b 100644 --- a/WandEnhancer/Locale/lang.fr-FR.xaml +++ b/WandEnhancer/Locale/lang.fr-FR.xaml @@ -7,7 +7,6 @@ - WandEnhancer Chemin du dossier Dossier non trouvé Appliquer @@ -35,7 +34,25 @@ Les fichiers .js sélectionnés sont intégrés dans Wand et chargés dans le renderer. Aucun script sélectionné Démarrer + Appliquer automatiquement après les mises à jour Qu'allons-nous modifier ? + + Répertoire WeMod trouvé à {0} ({1}) + WeMod est déjà patché. Si vous souhaitez le patcher à nouveau, veuillez d'abord restaurer la sauvegarde. + Prêt pour le patch. + Répertoire WeMod introuvable. + Impossible. Veuillez d'abord spécifier le répertoire. + Le dossier sélectionné {0} n'est pas un répertoire WeMod valide. + Échec de la restauration de la sauvegarde : {0} + Échec du patch : {0} + Journaux copiés dans le presse-papiers. + Échec de la copie des journaux : {0} + Journaux exportés vers {0}. + Échec de l'exportation des journaux : {0} + Impossible d'ouvrir {0} dans un navigateur. + Sélectionnez le répertoire WeMod + + diff --git a/WandEnhancer/Locale/lang.it-IT.xaml b/WandEnhancer/Locale/lang.it-IT.xaml index 2ca0fb40..1a4914d0 100644 --- a/WandEnhancer/Locale/lang.it-IT.xaml +++ b/WandEnhancer/Locale/lang.it-IT.xaml @@ -7,7 +7,6 @@ - WandEnhancer Percorso cartella Cartella non trovata Applica @@ -35,7 +34,25 @@ I file .js selezionati vengono inseriti in Wand e caricati nel renderer. Nessuno script selezionato Avvia + Applica automaticamente dopo gli aggiornamenti Cosa modificheremo? + + Directory di WeMod trovata in {0} ({1}) + WeMod è già stato patchato. Se vuoi patchare di nuovo, ripristina prima il backup. + Pronto per il patching. + Directory di WeMod non trovata. + Impossibile procedere. Specifica prima la directory. + La cartella selezionata {0} non è una directory valida di WeMod. + Impossibile ripristinare il backup: {0} + Impossibile eseguire il patch: {0} + Log copiati negli appunti. + Impossibile copiare i log: {0} + Log esportati in {0}. + Impossibile esportare i log: {0} + Impossibile aprire {0} in un browser. + Seleziona la directory di WeMod + + diff --git a/WandEnhancer/Locale/lang.ja-JP.xaml b/WandEnhancer/Locale/lang.ja-JP.xaml index 44e9f49e..90884980 100644 --- a/WandEnhancer/Locale/lang.ja-JP.xaml +++ b/WandEnhancer/Locale/lang.ja-JP.xaml @@ -7,7 +7,6 @@ - WandEnhancer フォルダパス フォルダが見つかりません 適用 @@ -35,7 +34,25 @@ 選択した .js ファイルは Wand に組み込まれ、レンダラーで読み込まれます。 スクリプトが選択されていません 開始 + 更新後に自動適用 何を改善しますか? + + WeModディレクトリが {0} ({1}) に見つかりました + WeModは既にパッチが適用されています。もう一度パッチを適用する場合は、まずバックアップを復元してください。 + パッチ適用の準備ができました。 + WeModディレクトリが見つかりません。 + 実行できません。先にディレクトリを指定してください。 + 選択したフォルダ {0} は有効なWeModディレクトリではありません。 + バックアップの復元に失敗しました: {0} + パッチの適用に失敗しました: {0} + ログをクリップボードにコピーしました。 + ログのコピーに失敗しました: {0} + ログを {0} にエクスポートしました。 + ログのエクスポートに失敗しました: {0} + {0} をブラウザで開くことができませんでした。 + WeModディレクトリを選択してください + + diff --git a/WandEnhancer/Locale/lang.pl-PL.xaml b/WandEnhancer/Locale/lang.pl-PL.xaml index 37b1ade5..bb77eda3 100644 --- a/WandEnhancer/Locale/lang.pl-PL.xaml +++ b/WandEnhancer/Locale/lang.pl-PL.xaml @@ -7,7 +7,6 @@ - WandEnhancer Ścieżka folderu Folder nie znaleziony Zastosuj @@ -35,7 +34,25 @@ Wybrane pliki .js są pakowane do Wand i ładowane w rendererze. Nie wybrano skryptów Rozpocznij + Zastosuj automatycznie po aktualizacji Co będziemy ulepszać? + + Katalog WeMod znaleziony w {0} ({1}) + WeMod został już zaktualizowany. Jeśli chcesz zaktualizować ponownie, najpierw przywróć kopię zapasową. + Gotowy do aktualizacji (patchowania). + Nie znaleziono katalogu WeMod. + Nie można tego zrobić. Proszę najpierw określić katalog. + Wybrany folder {0} nie jest prawidłowym katalogiem WeMod. + Nie udało się przywrócić kopii zapasowej: {0} + Nie udało się zaktualizować: {0} + Logi skopiowane do schowka. + Nie udało się skopiować logów: {0} + Logi wyeksportowane do {0}. + Nie udało się wyeksportować logów: {0} + Nie można otworzyć {0} w przeglądarce. + Wybierz katalog WeMod + + diff --git a/WandEnhancer/Locale/lang.pt-BR.xaml b/WandEnhancer/Locale/lang.pt-BR.xaml index 6f26576c..985142a5 100644 --- a/WandEnhancer/Locale/lang.pt-BR.xaml +++ b/WandEnhancer/Locale/lang.pt-BR.xaml @@ -7,7 +7,6 @@ - WandEnhancer Caminho da pasta Pasta não encontrada Aplicar @@ -35,7 +34,25 @@ Os arquivos .js selecionados são empacotados no Wand e carregados no renderer. Nenhum script selecionado Iniciar + Aplicar automaticamente após atualizações O que vamos melhorar? + + Diretório do WeMod encontrado em {0} ({1}) + O WeMod já foi modificado. Se quiser modificar novamente, restaure o backup primeiro. + Pronto para modificar. + Diretório do WeMod não encontrado. + Não é possível fazer isso. Por favor, especifique o diretório primeiro. + A pasta selecionada {0} não é um diretório válido do WeMod. + Falha ao restaurar o backup: {0} + Falha ao modificar: {0} + Logs copiados para a área de transferência. + Falha ao copiar logs: {0} + Logs exportados para {0}. + Falha ao exportar logs: {0} + Não foi possível abrir {0} no navegador. + Selecione o diretório do WeMod + + diff --git a/WandEnhancer/Locale/lang.ru-RU.xaml b/WandEnhancer/Locale/lang.ru-RU.xaml index 1c24c509..fa470b5d 100644 --- a/WandEnhancer/Locale/lang.ru-RU.xaml +++ b/WandEnhancer/Locale/lang.ru-RU.xaml @@ -7,7 +7,6 @@ - WandEnhancer Путь к папке Папка не найдена Применить @@ -35,7 +34,25 @@ Выбранные .js попадут в Wand и загрузятся в renderer. Скрипты не выбраны Начать + Авто-патч после обновлений Что будем улучшать? + + Директория WeMod найдена в {0} ({1}) + WeMod уже пропатчен. Если вы хотите пропатчить снова, сначала восстановите резервную копию. + Готово к патчингу. + Директория WeMod не найдена. + Невозможно выполнить. Пожалуйста, сначала укажите директорию. + Выбранная папка {0} не является допустимой директорией WeMod. + Не удалось восстановить резервную копию: {0} + Не удалось пропатчить: {0} + Логи скопированы в буфер обмена. + Не удалось скопировать логи: {0} + Логи экспортированы в {0}. + Не удалось экспортировать логи: {0} + Не удалось открыть {0} в браузере. + Выберите директорию WeMod + + diff --git a/WandEnhancer/Locale/lang.tr-TR.xaml b/WandEnhancer/Locale/lang.tr-TR.xaml index 30a4af6f..6ebc108c 100644 --- a/WandEnhancer/Locale/lang.tr-TR.xaml +++ b/WandEnhancer/Locale/lang.tr-TR.xaml @@ -7,7 +7,6 @@ - WandEnhancer Klasör yolu Klasör bulunamadı Uygula @@ -35,7 +34,25 @@ Seçilen .js dosyaları Wand içine paketlenir ve renderer'da yüklenir. Betik seçilmedi Başlat + Güncellemelerden sonra otomatik uygula Neyi geliştireceğiz? + + WeMod dizini {0} konumunda bulundu ({1}) + WeMod zaten yamanmış. Tekrar yamamak istiyorsanız, lütfen önce yedeği geri yükleyin. + Yama işlemi için hazır. + WeMod dizini bulunamadı. + İşlem yapılamıyor. Lütfen önce dizini belirtin. + Seçilen {0} klasörü geçerli bir WeMod dizini değil. + Yedek geri yüklenemedi: {0} + Yama yapılamadı: {0} + Günlükler panoya kopyalandı. + Günlükler kopyalanamadı: {0} + Günlükler {0} konumuna dışa aktarıldı. + Günlükler dışa aktarılamadı: {0} + {0} bir tarayıcıda açılamadı. + WeMod dizinini seçin + + diff --git a/WandEnhancer/Locale/lang.uk-UA.xaml b/WandEnhancer/Locale/lang.uk-UA.xaml index 47a77b19..6093129b 100644 --- a/WandEnhancer/Locale/lang.uk-UA.xaml +++ b/WandEnhancer/Locale/lang.uk-UA.xaml @@ -7,7 +7,6 @@ - WandEnhancer Шлях до папки Папку не знайдено Застосувати @@ -35,7 +34,25 @@ Вибрані файли .js пакуються у Wand і завантажуються в рендерері. Скрипти не вибрано Почати + Автоматично застосовувати після оновлень Що будемо покращувати? + + Директорію WeMod знайдено в {0} ({1}) + WeMod вже пропатчено. Якщо ви хочете пропатчити знову, спершу відновіть резервну копію. + Готово до патчингу. + Директорію WeMod не знайдено. + Не вдається виконати. Будь ласка, спочатку вкажіть директорію. + Вибрана папка {0} не є дійсною директорією WeMod. + Не вдалося відновити резервну копію: {0} + Не вдалося пропатчити: {0} + Логи скопійовано в буфер обміну. + Не вдалося скопіювати логи: {0} + Логи експортовано до {0}. + Не вдалося експортувати логи: {0} + Не вдалося відкрити {0} у браузері. + Виберіть директорію WeMod + + diff --git a/WandEnhancer/Locale/lang.zh-CN.xaml b/WandEnhancer/Locale/lang.zh-CN.xaml index f0624f38..c4130b11 100644 --- a/WandEnhancer/Locale/lang.zh-CN.xaml +++ b/WandEnhancer/Locale/lang.zh-CN.xaml @@ -7,7 +7,6 @@ - WandEnhancer 文件夹路径 未找到文件夹 增强 @@ -35,7 +34,25 @@ 选中的 .js 文件会打包到 Wand 并在渲染器中加载。 未选择脚本 开始 + 更新后自动应用 我们要增强什么? + + 在 {0} ({1}) 找到 WeMod 目录 + WeMod 已经修补过。如果想再次修补,请先恢复备份。 + 准备修补。 + 未找到 WeMod 目录。 + 无法执行。请先指定目录。 + 选择的文件夹 {0} 不是有效的 WeMod 目录。 + 恢复备份失败: {0} + 修补失败: {0} + 日志已复制到剪贴板。 + 复制日志失败: {0} + 日志已导出至 {0}。 + 导出日志失败: {0} + 无法在浏览器中打开 {0}。 + 选择 WeMod 目录 + + diff --git a/WandEnhancer/Models/PatchConfig.cs b/WandEnhancer/Models/PatchConfig.cs index c050324c..2d9811c1 100644 --- a/WandEnhancer/Models/PatchConfig.cs +++ b/WandEnhancer/Models/PatchConfig.cs @@ -1,41 +1,22 @@ -using System; using System.Collections.Generic; -using Newtonsoft.Json; -using WandEnhancer.Utils; namespace WandEnhancer.Models { - public enum EPatchType { ActivatePro = 1, DisableUpdates = 2, - DisableTelemetry = 4, DevToolsOnF12 = 8, RemoteWebPanelPreview = 16 } - + public sealed class PatchConfig { - private string _path; public HashSet PatchTypes { get; set; } public List CustomScriptPaths { get; set; } = new List(); - - public bool AutoApplyPatches { get; set; } - - [JsonIgnore] - public WeModConfig AppProps { get; private set; } - public string Path - { - get => _path; - set - { - _path = value; - AppProps = Extensions.CheckWeModPath(_path) ?? throw new Exception("Invalid WeMod path"); - } - } + /// When set, the patch selection is saved so the launcher re-applies it after a Wand update. + public bool AutoApplyAfterUpdate { get; set; } } - -} \ No newline at end of file +} diff --git a/WandEnhancer/Models/Signature.cs b/WandEnhancer/Models/Signature.cs deleted file mode 100644 index 789d3a43..00000000 --- a/WandEnhancer/Models/Signature.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; - -namespace WandEnhancer.Models -{ - public sealed class Signature - { - public readonly byte[] OriginalBytes; - public readonly byte[] PatchBytes; - public readonly byte[] Sequence; - public readonly byte[] Mask; - public readonly int Offset; - - public int Length => Sequence.Length; - - public static implicit operator byte[](Signature signature) => signature.Sequence; - - public Signature(string signature, int offset, byte[] patchBytes, byte[] originalBytes) - { - Parse(signature, out Sequence, out Mask); - PatchBytes = patchBytes; - OriginalBytes = originalBytes; - Offset = offset; - } - - private static void Parse(string signatureStr, out byte[] pattern, out byte[] mask) - { - var parts = signatureStr.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); - var length = parts.Length; - - pattern = new byte[length]; - mask = new byte[length]; - - for (var i = 0; i < length; i++) - { - if (parts[i] == "??" || parts[i] == "?") - { - pattern[i] = 0; - // wildcard byte - mask[i] = 0; - continue; - } - - pattern[i] = Convert.ToByte(parts[i], 16); - mask[i] = 1; - } - } - } -} diff --git a/WandEnhancer/Patches/devtools-f12.js b/WandEnhancer/Patches/devtools-f12.js new file mode 100644 index 00000000..037fff45 --- /dev/null +++ b/WandEnhancer/Patches/devtools-f12.js @@ -0,0 +1 @@ +${app}.on("browser-window-created",((_,w)=>{try{w.webContents.on("before-input-event",((_,i)=>{if("F12"===i.key&&"keyDown"===i.type){w.webContents.isDevToolsOpened()?w.webContents.closeDevTools():w.webContents.openDevTools({mode:"detach"})}}))}catch(e){}})), diff --git a/WandEnhancer/Patches/disable-native-pairing.js b/WandEnhancer/Patches/disable-native-pairing.js new file mode 100644 index 00000000..36aec00e --- /dev/null +++ b/WandEnhancer/Patches/disable-native-pairing.js @@ -0,0 +1 @@ +return Promise.reject(new Error("wand-enhancer: native mobile pairing disabled")) diff --git a/WandEnhancer/Patches/disable-updates.js b/WandEnhancer/Patches/disable-updates.js new file mode 100644 index 00000000..ffbc3ae0 --- /dev/null +++ b/WandEnhancer/Patches/disable-updates.js @@ -0,0 +1 @@ +"ACTION_CHECK_FOR_UPDATE",(e=>expectUpdateFeedUrl(e,(e=>null))) diff --git a/WandEnhancer/Patches/pro-account-reducer.js b/WandEnhancer/Patches/pro-account-reducer.js new file mode 100644 index 00000000..674add69 --- /dev/null +++ b/WandEnhancer/Patches/pro-account-reducer.js @@ -0,0 +1 @@ +account:((account)=>account&&"object"==typeof account?{...account,subscription:{period:"yearly",state:"active"}}:account)(${account}) diff --git a/WandEnhancer/Patches/pro-subscription.js b/WandEnhancer/Patches/pro-subscription.js new file mode 100644 index 00000000..36397ef9 --- /dev/null +++ b/WandEnhancer/Patches/pro-subscription.js @@ -0,0 +1 @@ +$0.then((response)=>{response&&"object"==typeof response&&(response.subscription={period:"yearly",state:"active"});return response}) diff --git a/WandEnhancer/Patches/remote-bridge-boot.js b/WandEnhancer/Patches/remote-bridge-boot.js new file mode 100644 index 00000000..61f08b74 --- /dev/null +++ b/WandEnhancer/Patches/remote-bridge-boot.js @@ -0,0 +1 @@ +${app}.whenReady().then(()=>{try{const p=require("node:path");require(p.join(__dirname,"remote-panel","bridge.cjs")).installWandRuntime(require("electron"))}catch(e){try{const fs=require("node:fs"),os=require("node:os"),p=require("node:path");fs.appendFileSync(p.join(os.tmpdir(),"wand-remote-bridge.log"),"["+new Date().toISOString()+"] [boot-error] "+(e&&e.stack||e)+"\n")}catch(_){}}return run()}) diff --git a/WandEnhancer/Patches/remote-bridge-renderer.js b/WandEnhancer/Patches/remote-bridge-renderer.js new file mode 100644 index 00000000..3f99f3dc --- /dev/null +++ b/WandEnhancer/Patches/remote-bridge-renderer.js @@ -0,0 +1 @@ +this.__wandRemoteBridge||(this.__wandRemoteBridge=(()=>{try{const r=globalThis.require||require;const{ipcRenderer:c}=r("electron");try{c.invoke("wand-remote-url").then((u=>{u&&(globalThis.__wandRemoteBridgeUrl=u)}))}catch(e){}const send=(ch,p)=>{try{return c.invoke(ch,p&&JSON.parse(JSON.stringify(p)))}catch(e){}};return{sync:(s)=>send("wand-remote-sync",s),valueChanged:(s)=>send("wand-remote-value-changed",s),setHandler:(h)=>{if(this.__wandRemoteBridgeBound)return;this.__wandRemoteBridgeBound=true;try{c.invoke("wand-remote-set-handler-bind")}catch(e){}c.on("wand-remote-set-value",(_e,req)=>{try{h(req)}catch(e){}})}}}catch(e){try{const r=globalThis.require||require,fs=r("node:fs"),os=r("node:os"),p=r("node:path");fs.appendFileSync(p.join(os.tmpdir(),"wand-remote-bridge.log"),"["+new Date().toISOString()+"] [renderer-bind-error] "+(e&&e.stack||e)+"\n")}catch(_){}return null}})());this.__wandRemoteBridge?.setHandler((e=>{if(!this.${trainer}||!e?.target)return!1;return this.${trainer}.isActive()?this.${trainer}.setValue(e.target,e.value,${remoteSource},e.cheatId):!1}));this.__wandRemoteTrainerInfo=e??null; diff --git a/WandEnhancer/Patches/remote-bridge-reset.js b/WandEnhancer/Patches/remote-bridge-reset.js new file mode 100644 index 00000000..373f7091 --- /dev/null +++ b/WandEnhancer/Patches/remote-bridge-reset.js @@ -0,0 +1 @@ +;this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null) diff --git a/WandEnhancer/Patches/remote-bridge-sync.js b/WandEnhancer/Patches/remote-bridge-sync.js new file mode 100644 index 00000000..aa8cecbe --- /dev/null +++ b/WandEnhancer/Patches/remote-bridge-sync.js @@ -0,0 +1 @@ +,this.__wandRemoteBridge?.sync({${snapshot},trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.${trainer}?.getMetadata(${metadata})??null}) diff --git a/WandEnhancer/Patches/remote-bridge-value-delta.js b/WandEnhancer/Patches/remote-bridge-value-delta.js new file mode 100644 index 00000000..a47a0f64 --- /dev/null +++ b/WandEnhancer/Patches/remote-bridge-value-delta.js @@ -0,0 +1 @@ +,this.__wandRemoteBridge?.valueChanged({target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??"desktop"),cheatId:e.cheatId}) diff --git a/WandEnhancer/Program.cs b/WandEnhancer/Program.cs index c27be549..cf75b3f4 100644 --- a/WandEnhancer/Program.cs +++ b/WandEnhancer/Program.cs @@ -1,45 +1,179 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; using System.Threading.Tasks; using System.Windows.Forms; +using WandEnhancer.Core; +using WandEnhancer.Models; +using WandEnhancer.Utils; using WandEnhancer.View.MainWindow; namespace WandEnhancer { public static class Program { + /// Log lines from a failed startup auto-patch, replayed by the UI when it opens. + public static readonly List> StartupLog = + new List>(); + [STAThread] public static void Main(string[] args) { + if (TryLaunchMode(args)) + return; + AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; - - List logEntries = new List(); - if (args.Length > 0) - { - // TODO: Command line arguments handling - } var application = new App(); application.InitializeComponent(); application.MainWindow = new MainWindow(); - foreach (var logEntry in logEntries) + application.Run(); + } + + private static bool TryLaunchMode(string[] args) + { + string myExe = Assembly.GetExecutingAssembly().Location; + string myName = Path.GetFileNameWithoutExtension(myExe); + + if (!Constants.WeModBrandNames.Any( + n => n.Equals(myName, StringComparison.OrdinalIgnoreCase))) + return false; + + string myDir = Path.GetDirectoryName(myExe); + string forwardedArgs = args.Length > 0 ? QuoteArguments(args) : null; + + LauncherLog.Open(myDir, $"WandEnhancer {Constants.Version} | {myExe}" + + (forwardedArgs == null ? "" : $" | args {forwardedArgs}")); + + if (args.Length > 0 && + args[0].StartsWith("--squirrel", StringComparison.OrdinalIgnoreCase)) { - MainWindow.Instance.ViewModel.LogList.Add(logEntry); + string updateExe = Path.Combine(myDir, "Update.exe"); + if (File.Exists(updateExe)) + { + LauncherLog.Write($"Squirrel hook {args[0]} forwarded to Update.exe.", ELogType.Info); + Process.Start(updateExe, QuoteArguments(args)); + } + else + { + LauncherLog.Write($"Squirrel hook {args[0]} ignored: Update.exe is missing.", ELogType.Warn); + } + + return true; } - application.Run(); + + var config = WeModInstalls.FindLatestWeMod(myDir); + if (config == null) + { + LauncherLog.Write($"No Wand install found under {myDir}; opening the UI instead.", ELogType.Error); + return false; + } + + bool isPatched = Enhancer.IsPatched(config.RootDirectory); + LauncherLog.Write($"Install {config.ExecutablePath} is {(isPatched ? "patched" : "not patched")}.", + ELogType.Info); + + // A fresh Wand version drops our patches; re-apply the saved selection automatically. + // On failure fall through to the UI so the user sees which patch broke. + if (!isPatched && !TryAutoPatch(config, myDir)) + return false; + + FuseLauncher.Launch(config.ExecutablePath, forwardedArgs, LauncherLog.Write); + return true; + } + + /// + /// Re-quotes argv for a command line. Squirrel hands us paths with spaces + /// (`--squirrel-install "C:\Users\Some Name\..."`); re-joining on spaces splits them. + /// + private static string QuoteArguments(IEnumerable args) + { + return string.Join(" ", args.Select(QuoteArgument)); + } + + private static string QuoteArgument(string value) + { + if (!string.IsNullOrEmpty(value) && value.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) + { + return value; + } + + // Backslashes are literal unless they run into the closing quote, where they double. + var quoted = new System.Text.StringBuilder("\""); + int backslashes = 0; + foreach (char current in value ?? string.Empty) + { + if (current == '\\') + { + backslashes++; + continue; + } + + if (current == '"') + { + quoted.Append('\\', backslashes * 2 + 1).Append('"'); + } + else + { + quoted.Append('\\', backslashes).Append(current); + } + + backslashes = 0; + } + + return quoted.Append('\\', backslashes * 2).Append('"').ToString(); + } + + private static bool TryAutoPatch(WeModConfig config, string launcherDir) + { + var patchConfig = Enhancer.LoadAutoPatchConfig(launcherDir); + if (patchConfig == null) + return true; // nothing saved to replay; launch as-is + + try + { + new Enhancer(config, RecordStartupLog, patchConfig).Patch(); + return true; + } + catch (Exception e) + { + // Localization resources are not loaded yet in launcher mode (no Application), + // so these two replay into the UI log in English by design. + RecordStartupLog($"Auto-patch failed: {e.Message}", ELogType.Error); + RecordStartupLog("The new Wand version may need updated patches. Restore the backup and patch again.", ELogType.Warn); + return false; + } + } + + /// Buffers for the UI and mirrors to disk: auto-patch runs headless, so the + /// file is the only copy if the user never opens the window afterwards. + private static void RecordStartupLog(string message, ELogType type) + { + StartupLog.Add(new KeyValuePair(message, type)); + LauncherLog.Write(message, type); } + // Fires on the finalizer thread for a task nobody awaited. Non-fatal since .NET 4.5: + // record it and mark it observed rather than killing a patch mid-run. private static void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e) { - MessageBox.Show(e.Exception.ToString()); - Environment.Exit(1); + e.SetObserved(); + RecordStartupLog($"Background task failed: {e.Exception.GetBaseException().Message}", ELogType.Error); } private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) { - MessageBox.Show(e.ExceptionObject.ToString()); + var error = e.ExceptionObject as Exception; + MessageBox.Show( + error?.Message ?? e.ExceptionObject?.ToString() ?? "Unknown error", + Constants.RepoName, + MessageBoxButtons.OK, + MessageBoxIcon.Error); Environment.Exit(1); } } diff --git a/WandEnhancer/Properties/AssemblyInfo.cs b/WandEnhancer/Properties/AssemblyInfo.cs index e452cd64..e38c8215 100644 --- a/WandEnhancer/Properties/AssemblyInfo.cs +++ b/WandEnhancer/Properties/AssemblyInfo.cs @@ -51,5 +51,5 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.9.4")] -[assembly: AssemblyFileVersion("1.0.9.4")] +[assembly: AssemblyVersion("2.0.0.0")] +[assembly: AssemblyFileVersion("2.0.0.0")] diff --git a/WandEnhancer/ReactiveUICore/AsyncRelayCommand.cs b/WandEnhancer/ReactiveUICore/AsyncRelayCommand.cs deleted file mode 100644 index ebe47878..00000000 --- a/WandEnhancer/ReactiveUICore/AsyncRelayCommand.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Input; - -namespace WandEnhancer.ReactiveUICore -{ - public sealed class AsyncRelayCommand : ICommand - { - private readonly Func _execute; - private readonly Func _canExecute; - - private long _isExecuting; - - public AsyncRelayCommand(Func execute, Func canExecute = null) - { - this._execute = execute; - this._canExecute = canExecute ?? (o => true); - } - - public event EventHandler CanExecuteChanged - { - add => CommandManager.RequerySuggested += value; - remove => CommandManager.RequerySuggested -= value; - } - - private static void RaiseCanExecuteChanged() => CommandManager.InvalidateRequerySuggested(); - - public bool CanExecute(object parameter) => Interlocked.Read(ref _isExecuting) == 0 && _canExecute(parameter); - - public async void Execute(object parameter) - { - Interlocked.Exchange(ref _isExecuting, 1); - RaiseCanExecuteChanged(); - - try - { - await _execute(parameter); - } - finally - { - Interlocked.Exchange(ref _isExecuting, 0); - RaiseCanExecuteChanged(); - } - } - } -} \ No newline at end of file diff --git a/WandEnhancer/Utils/Common.cs b/WandEnhancer/Utils/Common.cs deleted file mode 100644 index ca9e49e5..00000000 --- a/WandEnhancer/Utils/Common.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Reflection; -using System.Threading; - -namespace WandEnhancer.Utils -{ - public static class Common - { - public static void TryKillProcess(string processName) - { - Process[] processes = Process.GetProcessesByName(processName); - // Retry while any target process is still alive, capped at 5 attempts. - // The previous condition (processes.Length > i || i < 5) compared the - // process count to the loop index and, because of the "|| i < 5", always - // ran at least 5 iterations — sleeping ~1.25s even when the process was - // never running. - for (int i = 0; processes.Length > 0 && i < 5; i++) - { - foreach (var process in processes) - { - try - { - process.Kill(); - } - catch - { - // ignored - } - } - - processes = Process.GetProcessesByName(processName); - Thread.Sleep(250); - } - - if (processes.Length > 0) - { - throw new Exception("Failed to kill WeMod"); - } - } - - public static string GetCurrentDir() - { - var assemblyLocation = Assembly.GetExecutingAssembly().Location; - return Path.GetDirectoryName(assemblyLocation) ?? throw new InvalidOperationException(); - } - - public static string ComputeSha256Hash(string input) - { - using (var sha256 = System.Security.Cryptography.SHA256.Create()) - { - var bytes = System.Text.Encoding.UTF8.GetBytes(input); - var hashBytes = sha256.ComputeHash(bytes); - return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); - } - } - } -} \ No newline at end of file diff --git a/WandEnhancer/Utils/ProcessTerminator.cs b/WandEnhancer/Utils/ProcessTerminator.cs new file mode 100644 index 00000000..47904158 --- /dev/null +++ b/WandEnhancer/Utils/ProcessTerminator.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; + +namespace WandEnhancer.Utils +{ + public static class ProcessTerminator + { + private const int KillAttempts = 5; + private const int KillRetryDelayMs = 250; + + public static void TryKillProcess(string processName) + { + // The launcher itself runs as Wand.exe; never target our own process. + int selfId = Process.GetCurrentProcess().Id; + + for (int attempt = 0; attempt < KillAttempts; attempt++) + { + var processes = Others(Process.GetProcessesByName(processName), selfId); + try + { + if (processes.Length == 0) + { + return; + } + + foreach (var process in processes) + { + try + { + process.Kill(); + } + catch (Exception e) when (e is InvalidOperationException || e is System.ComponentModel.Win32Exception) + { + // Already exited, or protected: the post-loop check decides the outcome. + } + } + } + finally + { + foreach (var process in processes) + { + process.Dispose(); + } + } + + Thread.Sleep(KillRetryDelayMs); + } + + var survivors = Others(Process.GetProcessesByName(processName), selfId); + try + { + if (survivors.Length > 0) + { + throw new InvalidOperationException($"Failed to close {processName}. Close it manually and try again."); + } + } + finally + { + foreach (var process in survivors) + { + process.Dispose(); + } + } + } + + private static Process[] Others(Process[] processes, int selfId) + { + var result = new List(processes.Length); + foreach (var process in processes) + { + if (process.Id == selfId) + { + process.Dispose(); + continue; + } + + result.Add(process); + } + + return result.ToArray(); + } + } +} diff --git a/WandEnhancer/Utils/Extensions.cs b/WandEnhancer/Utils/WeModInstalls.cs similarity index 88% rename from WandEnhancer/Utils/Extensions.cs rename to WandEnhancer/Utils/WeModInstalls.cs index f2b3936c..c491a236 100644 --- a/WandEnhancer/Utils/Extensions.cs +++ b/WandEnhancer/Utils/WeModInstalls.cs @@ -7,13 +7,14 @@ namespace WandEnhancer.Utils { - public static class Extensions + public static class WeModInstalls { + public const string JavaScriptFileExtension = ".js"; + public static WeModConfig CheckWeModPath(string versionRoot) { try { - foreach (var name in Constants.WeModBrandNames) { var exeName = $"{name}.exe"; @@ -29,9 +30,9 @@ public static WeModConfig CheckWeModPath(string versionRoot) } } } - catch + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || e is ArgumentException) { - // ignored + // An unreadable or malformed candidate directory is not this install. } return null; @@ -113,16 +114,10 @@ private static WeModConfig FindWeModFromRunningProcess() return null; } - public static string Base64Decode(string base64EncodedData) - { - var base64EncodedBytes = System.Convert.FromBase64String(base64EncodedData); - return System.Text.Encoding.UTF8.GetString(base64EncodedBytes); - } - - public static string Base64Encode(string plainText) + public static bool IsJavaScriptFile(string path) { - var plainTextBytes = System.Text.Encoding.UTF8.GetBytes(plainText); - return System.Convert.ToBase64String(plainTextBytes); + return File.Exists(path) + && string.Equals(Path.GetExtension(path), JavaScriptFileExtension, StringComparison.OrdinalIgnoreCase); } public static WeModConfig FindLatestWeMod(string root) diff --git a/WandEnhancer/Utils/Win32/Shortcut.cs b/WandEnhancer/Utils/Win32/Shortcut.cs deleted file mode 100644 index a179f720..00000000 --- a/WandEnhancer/Utils/Win32/Shortcut.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Runtime.InteropServices; - -namespace WandEnhancer.Utils.Win32 -{ - public class Shortcut - { - public class ShortcutParams - { - public string FileName { get; set; } - public string TargetPath { get; set; } - public string Arguments { get; set; } - public string WorkingDirectory { get; set; } - public string Description { get; set; } - public string Hotkey { get; set; } - public string IconPath { get; set; } - }; - - private static readonly Type m_type = Type.GetTypeFromProgID("WScript.Shell"); - private static readonly object m_shell = Activator.CreateInstance(m_type); - - [ComImport, TypeLibType(0x1040), Guid("F935DC23-1CF0-11D0-ADB9-00C04FD58A0B")] - private interface IWshShortcut - { - [DispId(0)] - string FullName { [return: MarshalAs(UnmanagedType.BStr)][DispId(0)] get; } - [DispId(0x3e8)] - string Arguments { [return: MarshalAs(UnmanagedType.BStr)][DispId(0x3e8)] get; [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3e8)] set; } - [DispId(0x3e9)] - string Description { [return: MarshalAs(UnmanagedType.BStr)][DispId(0x3e9)] get; [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3e9)] set; } - [DispId(0x3ea)] - string Hotkey { [return: MarshalAs(UnmanagedType.BStr)][DispId(0x3ea)] get; [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3ea)] set; } - [DispId(0x3eb)] - string IconLocation { [return: MarshalAs(UnmanagedType.BStr)][DispId(0x3eb)] get; [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3eb)] set; } - [DispId(0x3ec)] - string RelativePath { [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3ec)] set; } - [DispId(0x3ed)] - string TargetPath { [return: MarshalAs(UnmanagedType.BStr)][DispId(0x3ed)] get; [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3ed)] set; } - [DispId(0x3ee)] - int WindowStyle { [DispId(0x3ee)] get; [param: In][DispId(0x3ee)] set; } - [DispId(0x3ef)] - string WorkingDirectory { [return: MarshalAs(UnmanagedType.BStr)][DispId(0x3ef)] get; [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3ef)] set; } - [TypeLibFunc((short)0x40), DispId(0x7d0)] - void Load([In, MarshalAs(UnmanagedType.BStr)] string PathLink); - [DispId(0x7d1)] - void Save(); - } - - public static void CreateShortcut(string fileName, string targetPath, string arguments, string workingDirectory, string description, string iconPath) - { - IWshShortcut shortcut = (IWshShortcut)m_type.InvokeMember("CreateShortcut", System.Reflection.BindingFlags.InvokeMethod, null, m_shell, new object[] { fileName }); - shortcut.Description = description; - shortcut.TargetPath = targetPath; - shortcut.WorkingDirectory = workingDirectory; - shortcut.Arguments = arguments; - if (!string.IsNullOrEmpty(iconPath)) - shortcut.IconLocation = iconPath; - shortcut.Save(); - } - } -} \ No newline at end of file diff --git a/WandEnhancer/View/Controls/InfoItem.xaml b/WandEnhancer/View/Controls/InfoItem.xaml deleted file mode 100644 index f67623cf..00000000 --- a/WandEnhancer/View/Controls/InfoItem.xaml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - diff --git a/WandEnhancer/View/Controls/InfoItem.xaml.cs b/WandEnhancer/View/Controls/InfoItem.xaml.cs deleted file mode 100644 index 4fba9cd7..00000000 --- a/WandEnhancer/View/Controls/InfoItem.xaml.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; - -namespace WandEnhancer.View.Controls -{ - public partial class InfoItem : UserControl - { - public static readonly DependencyProperty IconDataProperty = - DependencyProperty.Register(nameof(IconData), typeof(Geometry), typeof(InfoItem)); - - public static readonly DependencyProperty IconColorProperty = - DependencyProperty.Register(nameof(IconColor), typeof(Brush), typeof(InfoItem)); - - public static readonly DependencyProperty TextProperty = - DependencyProperty.Register(nameof(Text), typeof(string), typeof(InfoItem)); - - public Geometry IconData - { - get => (Geometry)GetValue(IconDataProperty); - set => SetValue(IconDataProperty, value); - } - - public Brush IconColor - { - get => (Brush)GetValue(IconColorProperty); - set => SetValue(IconColorProperty, value); - } - - public string Text - { - get => (string)GetValue(TextProperty); - set => SetValue(TextProperty, value); - } - - public InfoItem() - { - InitializeComponent(); - this.DataContext = this; - } - } -} \ No newline at end of file diff --git a/WandEnhancer/View/Controls/PopupHost.xaml b/WandEnhancer/View/Controls/PopupHost.xaml index b6f3723e..f63ede7c 100644 --- a/WandEnhancer/View/Controls/PopupHost.xaml +++ b/WandEnhancer/View/Controls/PopupHost.xaml @@ -38,7 +38,7 @@ - diff --git a/WandEnhancer/View/MainWindow/IShellView.cs b/WandEnhancer/View/MainWindow/IShellView.cs new file mode 100644 index 00000000..4dbb7b02 --- /dev/null +++ b/WandEnhancer/View/MainWindow/IShellView.cs @@ -0,0 +1,26 @@ +using System.Windows; + +namespace WandEnhancer.View.MainWindow +{ + /// + /// What the view model needs from the shell window. Exists so the view model does not + /// hold the concrete window or reach through a static Instance, which made every command + /// untestable and crashed whenever the singleton was not set yet. + /// + public interface IShellView + { + void OpenPopup(FrameworkElement content, string title); + void ClosePopup(); + void ScrollLogIntoView(LogEntry entry); + } + + /// Modal file/folder pickers, kept behind a seam so commands stay headless-testable. + public interface IFileDialogs + { + /// Chosen folder, or null when cancelled. + string PickFolder(string description, string initialPath); + + /// Chosen file path, or null when cancelled. + string PickSaveFile(string filter, string suggestedFileName); + } +} diff --git a/WandEnhancer/View/MainWindow/MainWindow.xaml b/WandEnhancer/View/MainWindow/MainWindow.xaml index 5bc0fa9b..e8b8c59c 100644 --- a/WandEnhancer/View/MainWindow/MainWindow.xaml +++ b/WandEnhancer/View/MainWindow/MainWindow.xaml @@ -183,9 +183,10 @@ Content="{DynamicResource mw_patch}"/> - ); + return ( + + ); }; diff --git a/web-panel/src/app/ui/PlaceholderState.tsx b/web-panel/src/app/ui/PlaceholderState.tsx index 0b8f99b4..3c96d3cb 100644 --- a/web-panel/src/app/ui/PlaceholderState.tsx +++ b/web-panel/src/app/ui/PlaceholderState.tsx @@ -1,25 +1,29 @@ import { Icon, type IconName } from '@/shared/ui/Icon'; type PlaceholderStateProps = { - icon: IconName; - title: string; - sub: string; - action: string; - onAction: () => void; + icon: IconName; + title: string; + sub: string; + action: string; + onAction: () => void; }; export const PlaceholderState = ({ icon, title, sub, action, onAction }: PlaceholderStateProps) => { - return ( -
-
- -
-

{title}

-

{sub}

- -
- ); + return ( +
+
+ +
+

{title}

+

{sub}

+ +
+ ); }; diff --git a/web-panel/src/app/ui/SessionPlaceholder.tsx b/web-panel/src/app/ui/SessionPlaceholder.tsx index e96af9e8..c75f4341 100644 --- a/web-panel/src/app/ui/SessionPlaceholder.tsx +++ b/web-panel/src/app/ui/SessionPlaceholder.tsx @@ -5,43 +5,45 @@ import type { TrainerSummary } from '../../../protocol/messages'; import { PlaceholderState } from './PlaceholderState'; type SessionPlaceholderProps = { - connected: boolean; - activeTrainer: TrainerSummary | null; - onOpenLibrary: () => void; - onOpenSettings: () => void; + connected: boolean; + activeTrainer: TrainerSummary | null; + onOpenLibrary: () => void; + onOpenSettings: () => void; }; export const SessionPlaceholder = ({ - connected, - activeTrainer, - onOpenLibrary, - onOpenSettings, + connected, + activeTrainer, + onOpenLibrary, + onOpenSettings, }: SessionPlaceholderProps) => { - const { _ } = useLingui(); + const { _ } = useLingui(); - if (!connected) { - return ( - - ); - } + if (!connected) { + return ( + + ); + } - if (!activeTrainer) { - return ( - - ); - } + if (!activeTrainer) { + return ( + + ); + } - return null; + return null; }; diff --git a/web-panel/src/app/ui/SettingsDrawer.tsx b/web-panel/src/app/ui/SettingsDrawer.tsx index a08749fb..3f058b61 100644 --- a/web-panel/src/app/ui/SettingsDrawer.tsx +++ b/web-panel/src/app/ui/SettingsDrawer.tsx @@ -1,232 +1,284 @@ -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 { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; - -import { Icon } from '@/shared/ui/Icon'; -import { cn } from '@/shared/lib/ui'; +import { Trans } from '@lingui/react/macro'; +import { type FormEvent, useState } from 'react'; import { activateLocale, type LocaleCode, SUPPORTED_LOCALES } from '@/app/i18n'; -import { DEFAULT_ACCENT_COLOR, loadAccentColor, setAccentColor } from '@/appearance/appearance-storage'; +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 { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; +import { IconButton } from '@/shared/ui/IconButton'; import { WEB_CONTRACT } from '../../../protocol/contract'; import type { TrainerSummary } from '../../../protocol/messages'; import { StatusPill } from './StatusPill'; 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]' }, + { 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 = { - status: EConnectionStatus; - wsUrl: string; - currentGame: LibraryGame | null; - currentTrainer: TrainerSummary | null; - lastError: string | null; - onClose: () => void; - onConnect: () => void; - onDisconnect: () => void; - onWsUrlChange: (value: string) => void; + status: EConnectionStatus; + wsUrl: string; + currentGame: LibraryGame | null; + currentTrainer: TrainerSummary | null; + lastError: string | null; + onClose: () => void; + onConnect: () => void; + onDisconnect: () => void; + onWsUrlChange: (value: string) => void; }; export const SettingsDrawer = ({ - status, - wsUrl, - currentGame, - currentTrainer, - lastError, - onClose, - onConnect, - onDisconnect, - onWsUrlChange, + status, + wsUrl, + currentGame, + currentTrainer, + lastError, + onClose, + onConnect, + onDisconnect, + onWsUrlChange, }: SettingsDrawerProps) => { - const { _ } = useLingui(); - - return ( -
-
-
-

- Settings -

-

- wand remote · port {WEB_CONTRACT.defaultRemotePort} -

+ const { _ } = useLingui(); + + return ( +
+
+
+

+ Settings +

+

+ wand remote · port {WEB_CONTRACT.defaultRemotePort} +

+
+ +
+
+ + {lastError ? : null} + + + + + + + + + +
- -
-
- - {lastError ? : null} - - - - - - - - - -
-
- ); + ); }; type BridgeControlProps = { - status: EConnectionStatus; - wsUrl: string; - onConnect: () => void; - onDisconnect: () => void; - onWsUrlChange: (value: string) => void; + status: EConnectionStatus; + wsUrl: string; + onConnect: () => void; + onDisconnect: () => void; + onWsUrlChange: (value: string) => void; }; -const BridgeControl = ({ status, wsUrl, onConnect, onDisconnect, onWsUrlChange }: BridgeControlProps) => { - const { _ } = useLingui(); - const live = status === EConnectionStatus.Connected; - const connecting = status === EConnectionStatus.Connecting || status === EConnectionStatus.Reconnecting; - const handleInput = (event: FormEvent) => onWsUrlChange(event.currentTarget.value); - const buttonLabel = connecting ? '...' : _(live ? msg`STOP` : msg`GO`); - - return ( -
-
-

- Bridge -

- -
-
- - -
-
- ); +const BridgeControl = ({ + status, + wsUrl, + onConnect, + onDisconnect, + onWsUrlChange, +}: BridgeControlProps) => { + const { _ } = useLingui(); + const live = status === EConnectionStatus.Connected; + const connecting = + status === EConnectionStatus.Connecting || status === EConnectionStatus.Reconnecting; + const handleInput = (event: FormEvent) => + onWsUrlChange(event.currentTarget.value); + const buttonLabel = connecting ? '...' : _(live ? msg`STOP` : msg`GO`); + + return ( +
+
+

+ Bridge +

+ +
+
+ + +
+
+ ); }; const ErrorPanel = ({ message }: { message: string }) => { - return ( -
- - {message} -
- ); + return ( +
+ + {message} +
+ ); }; -const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGame | null; currentTrainer: TrainerSummary | null }) => { - if (!currentGame) { +const SessionPanel = ({ + currentGame, + currentTrainer, +}: { + currentGame: LibraryGame | null; + currentTrainer: TrainerSummary | null; +}) => { + if (!currentGame) { + return ( +
+ No active game session. +
+ ); + } + + const subtitleBase = currentTrainer?.displayName ?? currentGame.platform; + const subtitleVersion = currentTrainer?.gameVersion ? ` · v${currentTrainer.gameVersion}` : ''; + const sessionSubtitle = `${subtitleBase}${subtitleVersion}`; + return ( -
- No active game session. -
+
+
+ + + Active Session + +
+

{currentGame.title}

+

+ {sessionSubtitle} +

+
); - } - - const subtitleBase = currentTrainer?.displayName ?? currentGame.platform; - const subtitleVersion = currentTrainer?.gameVersion ? ` · v${currentTrainer.gameVersion}` : ''; - const sessionSubtitle = `${subtitleBase}${subtitleVersion}`; - - return ( -
-
- - - Active Session - -
-

{currentGame.title}

-

- {sessionSubtitle} -

-
- ); }; const LanguagePicker = () => { - const { i18n } = useLingui(); + const { i18n } = useLingui(); - const handleSelect = (locale: LocaleCode) => { - void activateLocale(locale); - }; + const handleSelect = (locale: LocaleCode) => { + void activateLocale(locale); + }; - return ( -
- {SUPPORTED_LOCALES.map(({ code, label }) => { - const active = i18n.locale === code; - return ( - - ); - })} -
- ); + return ( +
+ {SUPPORTED_LOCALES.map(({ code, label }) => { + const active = i18n.locale === code; + return ( + + ); + })} +
+ ); }; const AccentPicker = () => { - const { _ } = useLingui(); - const [current, setCurrent] = useState(loadAccentColor); - - const applyAccent = (value: string) => { - setCurrent(setAccentColor(value)); - }; - - return ( -
-
- {ACCENT_OPTIONS.map((option) => { - const active = current.toLowerCase() === option.value.toLowerCase(); - return ( - - ); - })} -
- -
- ); + const { _ } = useLingui(); + const [current, setCurrent] = useState(loadAccentColor); + + const applyAccent = (value: string) => { + setCurrent(setAccentColor(value)); + }; + + return ( +
+
+ {ACCENT_OPTIONS.map((option) => { + const active = current.toLowerCase() === option.value.toLowerCase(); + return ( + + ); + })} +
+ +
+ ); }; const SectionHeader = ({ title }: { title: string }) => { - return ( -
-

{title}

-
-
- ); + return ( +
+

+ {title} +

+
+
+ ); }; diff --git a/web-panel/src/app/ui/StatusPill.tsx b/web-panel/src/app/ui/StatusPill.tsx index e6265700..ba1eca5b 100644 --- a/web-panel/src/app/ui/StatusPill.tsx +++ b/web-panel/src/app/ui/StatusPill.tsx @@ -1,38 +1,46 @@ -import { msg } from '@lingui/core/macro'; import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; - -import { Icon } from '@/shared/ui/Icon'; -import { cn } from '@/shared/lib/ui'; import { EConnectionStatus } from '@/remote-session/remote-session.reducer'; +import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; const STATUS_LABELS: Record = { - [EConnectionStatus.Connected]: msg`LIVE`, - [EConnectionStatus.Connecting]: msg`LINKING`, - [EConnectionStatus.Reconnecting]: msg`LINKING`, - [EConnectionStatus.Error]: msg`OFFLINE`, - [EConnectionStatus.Idle]: msg`OFFLINE`, + [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.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)', + [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 { _ } = useLingui(); - const live = status === EConnectionStatus.Connected - || status === EConnectionStatus.Connecting - || status === EConnectionStatus.Reconnecting; + const { _ } = useLingui(); + const live = + status === EConnectionStatus.Connected || + status === EConnectionStatus.Connecting || + status === EConnectionStatus.Reconnecting; - return ( -
- {live ? : null} - {_(STATUS_LABELS[status])} - {status === EConnectionStatus.Error ? : null} -
- ); + return ( +
+ {live ? ( + + ) : null} + {_(STATUS_LABELS[status])} + {status === EConnectionStatus.Error ? : null} +
+ ); }; diff --git a/web-panel/src/app/ui/TopBar.tsx b/web-panel/src/app/ui/TopBar.tsx index 105fbbbf..59533635 100644 --- a/web-panel/src/app/ui/TopBar.tsx +++ b/web-panel/src/app/ui/TopBar.tsx @@ -1,45 +1,49 @@ import { msg } from '@lingui/core/macro'; -import { Trans } from '@lingui/react/macro'; import { useLingui } from '@lingui/react'; - -import { Icon } from '@/shared/ui/Icon'; - +import { Trans } from '@lingui/react/macro'; import type { LibraryGame } from '@/library/model/games'; -import type { TrainerSummary } from '../../../protocol/messages'; import type { EConnectionStatus } from '@/remote-session/remote-session.reducer'; +import { IconButton } from '@/shared/ui/IconButton'; +import type { TrainerSummary } from '../../../protocol/messages'; import { StatusPill } from './StatusPill'; type TopBarProps = { - status: EConnectionStatus; - currentGame: LibraryGame | null; - runningTrainer: TrainerSummary | null; - onOpenSettings: () => void; + status: EConnectionStatus; + currentGame: LibraryGame | null; + runningTrainer: TrainerSummary | null; + onOpenSettings: () => void; }; export const TopBar = ({ status, currentGame, runningTrainer, onOpenSettings }: TopBarProps) => { - const { _ } = useLingui(); + const { _ } = useLingui(); - return ( -
-
- -
-
WAND · REMOTE DECK
-
- - {currentGame ? currentGame.title : Idle · no game} - - {currentGame && runningTrainer?.gameVersion ? ( - - v{runningTrainer.gameVersion} - - ) : null} -
-
- -
-
- ); + return ( +
+
+ +
+
+ WAND · REMOTE DECK +
+
+ + {currentGame ? currentGame.title : Idle · no game} + + {currentGame && runningTrainer?.gameVersion ? ( + + v{runningTrainer.gameVersion} + + ) : null} +
+
+ +
+
+ ); }; diff --git a/web-panel/src/app/ui/session-states.test.tsx b/web-panel/src/app/ui/session-states.test.tsx index e09350fe..d5ed7ce4 100644 --- a/web-panel/src/app/ui/session-states.test.tsx +++ b/web-panel/src/app/ui/session-states.test.tsx @@ -1,8 +1,8 @@ -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 { fireEvent, render, screen } from '@testing-library/preact'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; import type { TrainerSummary } from '../../../protocol/messages'; import { TrainerHeader } from '../../trainer/ui/TrainerHeader'; @@ -14,32 +14,53 @@ i18n.activate('en'); const renderWithI18n = (ui: ReactNode) => render({ui}); const trainer: TrainerSummary = { - trainerId: 'trainer', - gameId: 'game', - displayName: 'Test Trainer', - trainerLoading: false, - gameInstalled: true, - needsCompatibilityWarning: false, - isTimeLimitExpired: false, + 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( undefined} onOpenSettings={openSettings} />); - fireEvent.click(screen.getByRole('button', { name: 'Open Settings' })); - expect(screen.getByText('Bridge offline')).toBeTruthy(); - expect(openSettings).toHaveBeenCalledOnce(); - }); + it('renders the offline intent', () => { + const openSettings = vi.fn(); + renderWithI18n( + 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( undefined} onOpenSettings={() => undefined} />); - expect(screen.getByText('Select a game')).toBeTruthy(); - }); + it('renders the no-trainer intent', () => { + renderWithI18n( + undefined} + onOpenSettings={() => undefined} + />, + ); + expect(screen.getByText('Select a game')).toBeTruthy(); + }); - it('renders an active trainer header', () => { - renderWithI18n( undefined} />); - expect(screen.getByText('Test Trainer')).toBeTruthy(); - expect(screen.getByText('Trainer Active')).toBeTruthy(); - }); + it('renders an active trainer header', () => { + renderWithI18n( + undefined} + />, + ); + expect(screen.getByText('Test Trainer')).toBeTruthy(); + expect(screen.getByText('Trainer Active')).toBeTruthy(); + }); }); diff --git a/web-panel/src/app/use-dock-auto-hide.ts b/web-panel/src/app/use-dock-auto-hide.ts index 66730375..efba9d00 100644 --- a/web-panel/src/app/use-dock-auto-hide.ts +++ b/web-panel/src/app/use-dock-auto-hide.ts @@ -1,22 +1,22 @@ -import { useCallback, useRef, useState, type UIEvent } from 'react'; +import { type UIEvent, useCallback, useRef, useState } 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 [hidden, setHidden] = useState(false); + const lastScrollRef = useRef(0); - const onScroll = useCallback((event: UIEvent) => { - 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); - } + const onScroll = useCallback((event: UIEvent) => { + 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; - }, []); + lastScrollRef.current = y; + }, []); - return { hidden, onScroll }; + return { hidden, onScroll }; } diff --git a/web-panel/src/app/use-remote-panel.ts b/web-panel/src/app/use-remote-panel.ts index f1df4645..18d69959 100644 --- a/web-panel/src/app/use-remote-panel.ts +++ b/web-panel/src/app/use-remote-panel.ts @@ -12,123 +12,140 @@ 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 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 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 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 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 { changeCheat, launchGame } = session; + const { trainerMeta, values } = session.state; - 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 panic = useCallback(() => { + if (!trainerMeta) return; + for (const cheat of trainerMeta.schema.cheats) { + if (cheat.type === ECheatType.Toggle && values[cheat.target]) { + changeCheat(cheat, false); + } + } + }, [trainerMeta, values, changeCheat]); - const playGame = useCallback((game: LibraryGame) => { - if (session.launchGame(game.app)) { - setRightOpen(false); - } - }, [session]); + const applyPreset = useCallback( + (preset: RemotePreset) => { + if (!trainerMeta) return; + for (const cheat of trainerMeta.schema.cheats) { + if (cheat.target in preset.values) { + changeCheat(cheat, preset.values[cheat.target]); + } + } + }, + [trainerMeta, changeCheat], + ); - const totalVisibleCheats = filteredGroups.reduce( - (count, group) => count + group.cheats.length, - filteredPinnedGroup?.cheats.length ?? 0, - ); + const playGame = useCallback( + (game: LibraryGame) => { + if (launchGame(game.app)) { + setRightOpen(false); + } + }, + [launchGame], + ); - 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, - }, - }; + 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, + 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, + }, + }; } diff --git a/web-panel/src/appearance/appearance-storage.ts b/web-panel/src/appearance/appearance-storage.ts index 1c7f49f6..6aefcba9 100644 --- a/web-panel/src/appearance/appearance-storage.ts +++ b/web-panel/src/appearance/appearance-storage.ts @@ -6,37 +6,41 @@ const ACCENT_COLOR_STORAGE_KEY = 'wand-remote.accent-color.v1'; const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; export function applySavedAccentColor(): string { - return applyAccentColor(loadAccentColor()); + return applyAccentColor(loadAccentColor()); } export function loadAccentColor(): string { - return loadJson(ACCENT_COLOR_STORAGE_KEY, reviveAccentColor, DEFAULT_ACCENT_COLOR); + return loadJson(ACCENT_COLOR_STORAGE_KEY, reviveAccentColor, DEFAULT_ACCENT_COLOR); } export function setAccentColor(value: string): string { - const nextColor = normalizeAccentColor(value) ?? DEFAULT_ACCENT_COLOR; - applyAccentColor(nextColor); - saveJson(ACCENT_COLOR_STORAGE_KEY, nextColor, (storedValue) => storedValue === DEFAULT_ACCENT_COLOR); - return nextColor; + const nextColor = normalizeAccentColor(value) ?? DEFAULT_ACCENT_COLOR; + applyAccentColor(nextColor); + saveJson( + ACCENT_COLOR_STORAGE_KEY, + nextColor, + (storedValue) => storedValue === DEFAULT_ACCENT_COLOR, + ); + return nextColor; } function applyAccentColor(value: string): string { - if (typeof document !== 'undefined') { - document.documentElement.style.setProperty('--deck-accent', value); - } + if (typeof document !== 'undefined') { + document.documentElement.style.setProperty('--deck-accent', value); + } - return value; + return value; } function reviveAccentColor(raw: unknown): string | null { - return normalizeAccentColor(raw); + return normalizeAccentColor(raw); } function normalizeAccentColor(value: unknown): string | null { - if (typeof value !== 'string') { - return null; - } + if (typeof value !== 'string') { + return null; + } - const normalizedValue = value.trim().toLowerCase(); - return HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : null; + const normalizedValue = value.trim().toLowerCase(); + return HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : null; } diff --git a/web-panel/src/index.css b/web-panel/src/index.css index ec7729f3..056de1c8 100644 --- a/web-panel/src/index.css +++ b/web-panel/src/index.css @@ -29,15 +29,15 @@ } :root { - --deck-bg: #07080B; + --deck-bg: #07080b; --deck-surface-0: rgba(20, 23, 31, 0.88); --deck-surface-1: rgba(28, 32, 42, 0.85); --deck-line: rgba(255, 255, 255, 0.06); - --deck-line-2: rgba(255, 255, 255, 0.10); - --deck-fg: #F2F4F8; - --deck-fg-2: #B6BCCB; - --deck-fg-3: #7F8699; - --deck-fg-4: #535A6B; + --deck-line-2: rgba(255, 255, 255, 0.1); + --deck-fg: #f2f4f8; + --deck-fg-2: #b6bccb; + --deck-fg-3: #7f8699; + --deck-fg-4: #535a6b; --deck-accent: #00ffd5; --background: oklch(0.122 0.022 255); --foreground: oklch(0.968 0.016 96); @@ -114,13 +114,17 @@ .remote-glass-header { background: linear-gradient(180deg, rgba(18, 20, 27, 0.72), rgba(10, 12, 17, 0.48)); border-color: rgba(255, 255, 255, 0.08); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 14px 34px rgba(0, 0, 0, 0.22); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 14px 34px rgba(0, 0, 0, 0.22); backdrop-filter: blur(24px) saturate(160%); } .remote-glass-drawer { - background: linear-gradient(180deg, rgba(17, 19, 26, 0.70), rgba(10, 12, 17, 0.52)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 30px 80px rgba(0, 0, 0, 0.46); + background: linear-gradient(180deg, rgba(17, 19, 26, 0.7), rgba(10, 12, 17, 0.52)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 30px 80px rgba(0, 0, 0, 0.46); backdrop-filter: blur(38px) saturate(165%); } @@ -131,8 +135,10 @@ } .remote-drawer-panel { - background: linear-gradient(180deg, rgba(17, 19, 26, 0.90), rgba(10, 12, 17, 0.82)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 30px 80px rgba(0, 0, 0, 0.46); + background: linear-gradient(180deg, rgba(17, 19, 26, 0.9), rgba(10, 12, 17, 0.82)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 30px 80px rgba(0, 0, 0, 0.46); -webkit-backdrop-filter: blur(22px) saturate(155%); backdrop-filter: blur(22px) saturate(155%); transform: translateZ(0); @@ -156,7 +162,9 @@ .remote-drawer-panel { background: linear-gradient(180deg, rgba(17, 19, 26, 0.97), rgba(10, 12, 17, 0.95)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055), 0 18px 48px rgba(0, 0, 0, 0.40); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.055), + 0 18px 48px rgba(0, 0, 0, 0.4); -webkit-backdrop-filter: none; backdrop-filter: none; } @@ -179,15 +187,16 @@ } .remote-glass-control { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.070), rgba(255, 255, 255, 0.036)); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.036)); border-color: rgba(255, 255, 255, 0.105); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055), 0 8px 24px rgba(0, 0, 0, 0.12); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.055), + 0 8px 24px rgba(0, 0, 0, 0.12); backdrop-filter: blur(18px) saturate(150%); } } @keyframes breathe { - 0%, 100% { opacity: 1; @@ -196,4 +205,4 @@ 50% { opacity: 0.4; } -} \ No newline at end of file +} diff --git a/web-panel/src/library/model/games.test.ts b/web-panel/src/library/model/games.test.ts index fbb7624f..e0701684 100644 --- a/web-panel/src/library/model/games.test.ts +++ b/web-panel/src/library/model/games.test.ts @@ -1,46 +1,51 @@ 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'; +import { buildLibraryGames, filterLibraryGames, getCurrentGame } from './games'; const apps: InstalledAppSummary[] = [ - { - platform: 'steam', - sku: 'one', - correlationId: 'steam:one', - displayName: 'Alpha Game', - gameId: 'game-one', - }, - { - platform: 'epic', - sku: 'two', - correlationId: 'epic:two', - displayName: 'Beta Game', - gameId: 'game-two', - }, + { + platform: 'steam', + sku: 'one', + correlationId: 'steam:one', + displayName: 'Alpha Game', + gameId: 'game-one', + }, + { + platform: 'epic', + sku: 'two', + correlationId: 'epic:two', + displayName: 'Beta Game', + gameId: 'game-two', + }, ]; 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 }); + 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']); - }); + 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({}); - }); + 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({}); + }); }); diff --git a/web-panel/src/library/model/games.ts b/web-panel/src/library/model/games.ts index 813e6fe5..fed59af5 100644 --- a/web-panel/src/library/model/games.ts +++ b/web-panel/src/library/model/games.ts @@ -1,5 +1,9 @@ import { formatHumanLabel } from '@/shared/lib/ui'; -import type { GameStatusPayload, InstalledAppSummary, TrainerSummary } from '../../../protocol/messages'; +import type { + GameStatusPayload, + InstalledAppSummary, + TrainerSummary, +} from '../../../protocol/messages'; export type LibraryGame = { id: string; @@ -26,24 +30,28 @@ export function buildLibraryGames( trainer: TrainerSummary | null, pinnedGameIds: Record, ): LibraryGame[] { - const activeGameId = status?.session.gameId ?? status?.trainer.gameId ?? trainer?.gameId ?? null; - const activeTitleId = status?.session.titleId ?? status?.trainer.titleId ?? trainer?.titleId ?? null; - - return apps.map((app) => { - const id = getInstalledAppId(app); - return { - id, - title: app.displayName, - platform: formatHumanLabel(app.platform), - hours: minutesToHours(app.platformTotalPlaytimeMinutes), - imageUrl: app.imageUrl ?? null, - app, - gameId: app.gameId ?? null, - titleId: app.titleId ?? null, - pinned: Boolean(pinnedGameIds[id]), - running: isActiveInstalledApp(app, activeGameId, activeTitleId), - }; - }).sort(compareLibraryGames); + const activeGameId = + status?.session.gameId ?? status?.trainer.gameId ?? trainer?.gameId ?? null; + const activeTitleId = + status?.session.titleId ?? status?.trainer.titleId ?? trainer?.titleId ?? null; + + return apps + .map((app) => { + const id = getInstalledAppId(app); + return { + id, + title: app.displayName, + platform: formatHumanLabel(app.platform), + hours: minutesToHours(app.platformTotalPlaytimeMinutes), + imageUrl: app.imageUrl ?? null, + app, + gameId: app.gameId ?? null, + titleId: app.titleId ?? null, + pinned: Boolean(pinnedGameIds[id]), + running: isActiveInstalledApp(app, activeGameId, activeTitleId), + }; + }) + .sort(compareLibraryGames); } export function getCurrentGame(games: LibraryGame[]): LibraryGame | null { @@ -115,7 +123,11 @@ function compareLibraryGames(left: LibraryGame, right: LibraryGame): number { return left.title.localeCompare(right.title); } -function isActiveInstalledApp(app: InstalledAppSummary, activeGameId: string | null, activeTitleId: string | null): boolean { +function isActiveInstalledApp( + app: InstalledAppSummary, + activeGameId: string | null, + activeTitleId: string | null, +): boolean { if (activeGameId && app.gameId === activeGameId) { return true; } diff --git a/web-panel/src/library/pinned-games/game-pin-storage.ts b/web-panel/src/library/pinned-games/game-pin-storage.ts index 6875412b..be5291ae 100644 --- a/web-panel/src/library/pinned-games/game-pin-storage.ts +++ b/web-panel/src/library/pinned-games/game-pin-storage.ts @@ -4,20 +4,23 @@ import type { LibraryGame } from '../model/games'; const STORAGE_KEY = 'wand-remote.pinned-games.v1'; export function loadPinnedGameIds(): Record { - return loadStringSet(STORAGE_KEY); + return loadStringSet(STORAGE_KEY); } export function savePinnedGameIds(pinnedGameIds: Record): void { - saveStringSet(STORAGE_KEY, pinnedGameIds); + saveStringSet(STORAGE_KEY, pinnedGameIds); } -export function togglePinnedGame(game: LibraryGame, pinnedGameIds: Record): Record { - const next = { ...pinnedGameIds }; - if (next[game.id]) { - delete next[game.id]; - return next; - } +export function togglePinnedGame( + game: LibraryGame, + pinnedGameIds: Record, +): Record { + const next = { ...pinnedGameIds }; + if (next[game.id]) { + delete next[game.id]; + return next; + } - next[game.id] = true; - return next; + next[game.id] = true; + return next; } diff --git a/web-panel/src/library/pinned-games/use-game-pins.ts b/web-panel/src/library/pinned-games/use-game-pins.ts index a881528b..6574f887 100644 --- a/web-panel/src/library/pinned-games/use-game-pins.ts +++ b/web-panel/src/library/pinned-games/use-game-pins.ts @@ -4,19 +4,19 @@ import type { LibraryGame } from '../model/games'; import { loadPinnedGameIds, savePinnedGameIds, togglePinnedGame } from './game-pin-storage'; export function useGamePins() { - const [pinnedGameIds, setPinnedGameIds] = useState>({}); + const [pinnedGameIds, setPinnedGameIds] = useState>({}); - useEffect(() => { - setPinnedGameIds(loadPinnedGameIds()); - }, []); + useEffect(() => { + setPinnedGameIds(loadPinnedGameIds()); + }, []); - const togglePin = useCallback((game: LibraryGame) => { - setPinnedGameIds((current) => { - const next = togglePinnedGame(game, current); - savePinnedGameIds(next); - return next; - }); - }, []); + const togglePin = useCallback((game: LibraryGame) => { + setPinnedGameIds((current) => { + const next = togglePinnedGame(game, current); + savePinnedGameIds(next); + return next; + }); + }, []); - return { pinnedGameIds, togglePin }; + return { pinnedGameIds, togglePin }; } diff --git a/web-panel/src/library/ui/GameCover.tsx b/web-panel/src/library/ui/GameCover.tsx index 0e315dca..967a7800 100644 --- a/web-panel/src/library/ui/GameCover.tsx +++ b/web-panel/src/library/ui/GameCover.tsx @@ -3,30 +3,42 @@ import { useState } from 'react'; import { getGameCoverLabel, type LibraryGame } from '../model/games'; type GameCoverProps = { - game: LibraryGame; - size?: 'sm' | 'lg'; + game: LibraryGame; + size?: 'sm' | 'lg'; }; const SIZE_CLASSES: Record, string> = { - lg: 'size-16 rounded-[10px] text-[8px]', - sm: 'size-11 rounded-[9px] text-[7px]', + lg: 'size-16 rounded-[10px] text-[8px]', + sm: 'size-11 rounded-[9px] text-[7px]', }; export const GameCover = ({ game, size = 'sm' }: GameCoverProps) => { - const [failedUrl, setFailedUrl] = useState(null); - const sizeClass = SIZE_CLASSES[size]; - const imageUrl = game.imageUrl && game.imageUrl !== failedUrl ? game.imageUrl : null; + const [failedUrl, setFailedUrl] = useState(null); + const sizeClass = SIZE_CLASSES[size]; + const imageUrl = game.imageUrl && game.imageUrl !== failedUrl ? game.imageUrl : null; - const handleImageError = () => setFailedUrl(game.imageUrl ?? null); + const handleImageError = () => setFailedUrl(game.imageUrl ?? null); - return ( -
- {imageUrl ? : null} -
- {game.running ? : null} -
- {getGameCoverLabel(game)} -
-
- ); + return ( +
+ {imageUrl ? ( + + ) : null} +
+ {game.running ? ( + + ) : null} +
+ {getGameCoverLabel(game)} +
+
+ ); }; diff --git a/web-panel/src/library/ui/LibraryDrawer.tsx b/web-panel/src/library/ui/LibraryDrawer.tsx index c6cc2d1a..87e06bc7 100644 --- a/web-panel/src/library/ui/LibraryDrawer.tsx +++ b/web-panel/src/library/ui/LibraryDrawer.tsx @@ -1,179 +1,255 @@ -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 '@/shared/ui/Icon'; +import { Plural, Trans } from '@lingui/react/macro'; +import { memo, type ReactNode, useMemo } from 'react'; import { cn } from '@/shared/lib/ui'; +import { Icon, type IconName } from '@/shared/ui/Icon'; +import { IconButton } from '@/shared/ui/IconButton'; import { SearchInput } from '@/shared/ui/SearchInput'; -import { filterLibraryGames, formatHours, getLibrarySections, type LibraryGame } from '../model/games'; +import { + filterLibraryGames, + formatHours, + getLibrarySections, + type LibraryGame, +} from '../model/games'; import { GameCover } from './GameCover'; type LibraryDrawerProps = { - games: LibraryGame[]; - query: string; - canLaunch: boolean; - onClose: () => void; - onPin: (game: LibraryGame) => void; - onPlay: (game: LibraryGame) => void; - onStop: () => void; - onQueryChange: (query: string) => void; + games: LibraryGame[]; + query: string; + canLaunch: boolean; + onClose: () => void; + onPin: (game: LibraryGame) => void; + onPlay: (game: LibraryGame) => void; + onStop: () => void; + onQueryChange: (query: string) => void; }; -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]); - - return ( -
-
-
-

- Library -

-

- -

+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]); + + return ( +
+
+
+

+ Library +

+

+ +

+
+ +
+
+ +
+
+ {sections.running ? ( + + + + ) : null} + {sections.pinned.length > 0 ? ( + + {sections.pinned.map((game) => ( + + ))} + + ) : null} + {sections.rest.length > 0 ? ( + + {sections.rest.map((game) => ( + + ))} + + ) : null} + {filteredGames.length === 0 ? ( +

+ No games match "{query}" +

+ ) : null} +
- -
-
- -
-
- {sections.running ? ( - - - - ) : null} - {sections.pinned.length > 0 ? ( - - {sections.pinned.map((game) => )} - - ) : null} - {sections.rest.length > 0 ? ( - - {sections.rest.map((game) => )} - - ) : null} - {filteredGames.length === 0 ? ( -

- No games match "{query}" -

- ) : null} -
-
- ); + ); }; export const LibraryDrawer = memo(LibraryDrawerBase); type GameSectionProps = { - title: string; - count?: number; - icon?: IconName; - accent?: boolean; - children: ReactNode; + title: string; + count?: number; + icon?: IconName; + accent?: boolean; + children: ReactNode; }; const GameSection = ({ title, count, icon, accent = false, children }: GameSectionProps) => { - return ( -
-
- {icon ? : null} -

{title}

-
- {typeof count === 'number' ? {count} : null} -
- {children} -
- ); + return ( +
+
+ {icon ? ( + + ) : null} +

+ {title} +

+
+ {typeof count === 'number' ? ( + {count} + ) : null} +
+ {children} +
+ ); }; type GameRowProps = { - game: LibraryGame; - canLaunch: boolean; - query: string; - onPin: (game: LibraryGame) => void; - onPlay: (game: LibraryGame) => void; - onStop: () => void; + game: LibraryGame; + canLaunch: boolean; + query: string; + onPin: (game: LibraryGame) => void; + onPlay: (game: LibraryGame) => void; + onStop: () => void; }; const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps) => { - const { _ } = useLingui(); - const hours = formatHours(game.hours); - const handlePin = () => onPin(game); - const handlePlay = () => onPlay(game); - - return ( -
- -
-

{highlightTitle(game.title, query)}

-
- {game.platform.toUpperCase()} - {hours ? · {hours} : null} -
-
-
- - {game.running ? ( - - ) : ( - - )} -
-
- ); -}; - -type IconButtonProps = { - icon: IconName; - label: string; - active?: boolean; - danger?: boolean; - disabled?: boolean; - play?: boolean; - onClick: () => void; -}; - -const IconButton = ({ icon, label, active = false, danger = false, disabled = false, play = false, onClick }: IconButtonProps) => { - return ( - - ); + const { _ } = useLingui(); + const hours = formatHours(game.hours); + const handlePin = () => onPin(game); + const handlePlay = () => onPlay(game); + + return ( +
+ +
+

+ {highlightTitle(game.title, query)} +

+
+ {game.platform.toUpperCase()} + {hours ? · {hours} : null} +
+
+
+ + {game.running ? ( + + ) : ( + + )} +
+
+ ); }; function highlightTitle(title: string, query: string): ReactNode { - const normalized = query.trim().toLowerCase(); - if (!normalized) { - return title; - } - - const index = title.toLowerCase().indexOf(normalized); - if (index < 0) { - return title; - } - - const before = title.slice(0, index); - const match = title.slice(index, index + query.length); - const after = title.slice(index + query.length); - return <>{before}{match}{after}; + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return title; + } + + const index = title.toLowerCase().indexOf(normalized); + if (index < 0) { + return title; + } + + const before = title.slice(0, index); + const match = title.slice(index, index + query.length); + const after = title.slice(index + query.length); + return ( + <> + {before} + + {match} + + {after} + + ); } diff --git a/web-panel/src/po.d.ts b/web-panel/src/po.d.ts index c08a49d5..e253829d 100644 --- a/web-panel/src/po.d.ts +++ b/web-panel/src/po.d.ts @@ -1,4 +1,4 @@ declare module '*.po' { - import type { Messages } from '@lingui/core'; - export const messages: Messages; + import type { Messages } from '@lingui/core'; + export const messages: Messages; } diff --git a/web-panel/src/remote-session/remote-session.client.ts b/web-panel/src/remote-session/remote-session.client.ts index 2426e966..bc263663 100644 --- a/web-panel/src/remote-session/remote-session.client.ts +++ b/web-panel/src/remote-session/remote-session.client.ts @@ -1,133 +1,137 @@ import { WEB_CONTRACT } from '../../protocol/contract'; import { - type HelloMessage, - type IncomingMessage, - type OutgoingMessage, - PROTOCOL_VERSION, - type RemoteCommandMessage, - type SetValueMessage, + 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; + 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(): 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()); - }); - socket.addEventListener('message', (event) => this.handleMessage(event)); - socket.addEventListener('close', () => { - if (this.socket === socket) { + private socket: WebSocket | null = null; + private intentionalDisconnect = false; + + constructor( + private readonly url: string, + private readonly handlers: SocketHandlers, + ) {} + + connect(): 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()); + }); + 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; - } - 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(): HelloMessage { - return { - type: 'hello', - version: PROTOCOL_VERSION, - requestId: createRequestId('hello'), - payload: { - client: 'mobile-web', - clientVersion: WEB_CONTRACT.clientVersion, - 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.'); + + 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(): HelloMessage { + return { + type: 'hello', + version: PROTOCOL_VERSION, + requestId: createRequestId('hello'), + payload: { + client: 'mobile-web', + clientVersion: WEB_CONTRACT.clientVersion, + 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}`; + requestSequence += 1; + return `${prefix}_${Date.now()}_${requestSequence}`; } diff --git a/web-panel/src/remote-session/remote-session.protocol.ts b/web-panel/src/remote-session/remote-session.protocol.ts index be3ba73b..cb3000ff 100644 --- a/web-panel/src/remote-session/remote-session.protocol.ts +++ b/web-panel/src/remote-session/remote-session.protocol.ts @@ -1,44 +1,52 @@ -import { PROTOCOL_VERSION, type IncomingMessage } from '../../protocol/messages'; +import { type IncomingMessage, PROTOCOL_VERSION } 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 }; - } + 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 }; + } } diff --git a/web-panel/src/remote-session/remote-session.reducer.test.ts b/web-panel/src/remote-session/remote-session.reducer.test.ts index c5468920..6e50a483 100644 --- a/web-panel/src/remote-session/remote-session.reducer.test.ts +++ b/web-panel/src/remote-session/remote-session.reducer.test.ts @@ -1,104 +1,142 @@ import { describe, expect, it } from 'vitest'; -import { PROTOCOL_VERSION, type HelloAckMessage } from '../../protocol/messages'; +import { type HelloAckMessage, PROTOCOL_VERSION } from '../../protocol/messages'; import { protocolAction } from './remote-session.protocol'; import { - createInitialRemoteSessionState, - EConnectionStatus, - remoteSessionReducer, - type RemoteSessionState, + createInitialRemoteSessionState, + EConnectionStatus, + type RemoteSessionState, + remoteSessionReducer, } 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}.`, + 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(); - }); + 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, - }, - }; + 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' }; + return { ...createInitialRemoteSessionState(), wsUrl: 'ws://test' }; } function withConfirmedValue(value: unknown): RemoteSessionState { - return { - ...initialState(), - values: { speed: value }, - confirmedValues: { speed: value }, - }; + return { + ...initialState(), + values: { speed: value }, + confirmedValues: { speed: value }, + }; } diff --git a/web-panel/src/remote-session/remote-session.reducer.ts b/web-panel/src/remote-session/remote-session.reducer.ts index eecd6692..96e265a9 100644 --- a/web-panel/src/remote-session/remote-session.reducer.ts +++ b/web-panel/src/remote-session/remote-session.reducer.ts @@ -1,192 +1,201 @@ import type { - GameStatusPayload, - InstalledAppSummary, - InstalledAppsPayload, - TrainerMetaPayload, + 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', + Idle = 'idle', + Connecting = 'connecting', + Reconnecting = 'reconnecting', + Connected = 'connected', + Error = 'error', } export type PendingWrite = { - requestId: string; - value: unknown; - previousConfirmedValue: unknown; + requestId: string; + value: unknown; + previousConfirmedValue: unknown; }; export type RemoteSessionState = { - connectionStatus: EConnectionStatus; - wsUrl: string; - trainerMeta: TrainerMetaPayload | null; - gameStatus: GameStatusPayload | null; - installedApps: InstalledAppSummary[]; - values: Record; - confirmedValues: Record; - pendingWrites: Record; - lastError: string | null; + connectionStatus: EConnectionStatus; + wsUrl: string; + trainerMeta: TrainerMetaPayload | null; + gameStatus: GameStatusPayload | null; + installedApps: InstalledAppSummary[]; + values: Record; + confirmedValues: Record; + pendingWrites: Record; + 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 } - | { 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 }; + | { 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 } + | { 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, + return { connectionStatus: EConnectionStatus.Idle, + wsUrl: readInitialWebSocketUrl(), trainerMeta: null, gameStatus: null, + installedApps: [], 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, - }; - } + }; +} + +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, + 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]; - } + 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, - }; + return { + ...state, + values: { ...state.values, [target]: value }, + confirmedValues: { ...state.confirmedValues, [target]: value }, + pendingWrites, + }; } function applyWriteResult( - state: RemoteSessionState, - action: Extract, + state: RemoteSessionState, + action: Extract, ): RemoteSessionState { - const pending = state.pendingWrites[action.target]; - if (!pending || !action.requestId || pending.requestId !== action.requestId) { - return state; - } + const pending = state.pendingWrites[action.target]; + if (!pending || !action.requestId || pending.requestId !== action.requestId) { + return state; + } - const pendingWrites = { ...state.pendingWrites }; - delete pendingWrites[action.target]; + const pendingWrites = { ...state.pendingWrites }; + delete pendingWrites[action.target]; + + if (action.ok) { + return { + ...state, + confirmedValues: { ...state.confirmedValues, [action.target]: pending.value }, + pendingWrites, + }; + } - if (action.ok) { return { - ...state, - confirmedValues: { ...state.confirmedValues, [action.target]: pending.value }, - pendingWrites, + ...state, + values: { ...state.values, [action.target]: pending.previousConfirmedValue }, + pendingWrites, + lastError: action.message ?? 'The trainer rejected the requested value.', }; - } - - return { - ...state, - values: { ...state.values, [action.target]: pending.previousConfirmedValue }, - pendingWrites, - lastError: action.message ?? 'The trainer rejected the requested value.', - }; } diff --git a/web-panel/src/remote-session/remote-session.urls.ts b/web-panel/src/remote-session/remote-session.urls.ts index a1378e1c..4ef93b1e 100644 --- a/web-panel/src/remote-session/remote-session.urls.ts +++ b/web-panel/src/remote-session/remote-session.urls.ts @@ -5,23 +5,26 @@ export const WS_QUERY_PARAM = 'ws'; const DEV_SERVER_PORTS = new Set(WEB_CONTRACT.devServerPorts.map(String)); function protocolForWebSocket(): 'ws' | 'wss' { - return window.location.protocol === 'https:' ? 'wss' : 'ws'; + 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); + 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; - } + 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}`; - } + if (isServedByRemoteBridge()) { + return `${protocolForWebSocket()}://${window.location.host}${WEB_CONTRACT.webSocketPath}`; + } - return `ws://127.0.0.1:${WEB_CONTRACT.defaultRemotePort}${WEB_CONTRACT.webSocketPath}`; + return `ws://127.0.0.1:${WEB_CONTRACT.defaultRemotePort}${WEB_CONTRACT.webSocketPath}`; } diff --git a/web-panel/src/remote-session/selectors.ts b/web-panel/src/remote-session/selectors.ts index 798ad7d6..8557921c 100644 --- a/web-panel/src/remote-session/selectors.ts +++ b/web-panel/src/remote-session/selectors.ts @@ -1,9 +1,9 @@ import { EConnectionStatus, type RemoteSessionState } from './remote-session.reducer'; export function selectIsConnected(state: RemoteSessionState): boolean { - return state.connectionStatus === EConnectionStatus.Connected; + return state.connectionStatus === EConnectionStatus.Connected; } export function selectPendingTargets(state: RemoteSessionState): Record { - return Object.fromEntries(Object.keys(state.pendingWrites).map((target) => [target, true])); + return Object.fromEntries(Object.keys(state.pendingWrites).map((target) => [target, true])); } diff --git a/web-panel/src/remote-session/use-remote-session.ts b/web-panel/src/remote-session/use-remote-session.ts index f06be947..68d320c9 100644 --- a/web-panel/src/remote-session/use-remote-session.ts +++ b/web-panel/src/remote-session/use-remote-session.ts @@ -1,185 +1,226 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; -import { type CheatSchema, type InstalledAppSummary } from '../../protocol/messages'; +import type { CheatSchema, InstalledAppSummary } from '../../protocol/messages'; import { normalizeCheatValue } from '../trainer/model/values'; import { RemoteSessionClient } from './remote-session.client'; +import { protocolAction } from './remote-session.protocol'; import { - createInitialRemoteSessionState, - EConnectionStatus, - remoteSessionReducer, - type RemoteSessionState, + createInitialRemoteSessionState, + EConnectionStatus, + type RemoteSessionState, + remoteSessionReducer, } from './remote-session.reducer'; -import { protocolAction } from './remote-session.protocol'; import { selectIsConnected, selectPendingTargets } from './selectors'; -const RECONNECT_DELAY_MS = 2000; +const RECONNECT_BASE_DELAY_MS = 2000; +const RECONNECT_MAX_DELAY_MS = 30000; export function useRemoteSession() { - const [state, dispatch] = useReducer(remoteSessionReducer, undefined, createInitialRemoteSessionState); - const stateRef = useRef(state); - const clientRef = useRef(null); - const reconnectTimeoutRef = useRef(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; - 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; + const [state, dispatch] = useReducer( + remoteSessionReducer, + undefined, + createInitialRemoteSessionState, + ); + const stateRef = useRef(state); + const clientRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const reconnectAttemptRef = useRef(0); + // Set when the user disconnects on purpose, so refocus does not silently reconnect. + const userDisconnectedRef = useRef(false); + 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' || userDisconnectedRef.current) { + return; + } + + // Back off: the bridge is usually down because Wand is closed, and a phone + // retrying every 2s until the tab is hidden drains the battery for nothing. + const delay = Math.min( + RECONNECT_BASE_DELAY_MS * 2 ** reconnectAttemptRef.current, + RECONNECT_MAX_DELAY_MS, + ); + reconnectAttemptRef.current += 1; + + reconnectTimeoutRef.current = window.setTimeout(() => { + if (document.visibilityState === 'visible' && stateRef.current.wsUrl.trim()) { + connectRef.current(); + } + }, delay); + }, [clearReconnect]); + + const connect = useCallback(() => { + clientRef.current?.disconnect(); + clearReconnect(); + userDisconnectedRef.current = false; + + 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: () => { + reconnectAttemptRef.current = 0; + }, + onMessage: (message) => { + const action = protocolAction(message); + if (!action) 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(() => { + userDisconnectedRef.current = true; + reconnectAttemptRef.current = 0; + 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, titleId)) { + 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' || userDisconnectedRef.current) { + return; + } + if (!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, + connect, + disconnect, + setWsUrl, + reportError, + changeCheat, + launchGame, + stopPlaying, }; - }, [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()); + return state.connectionStatus === EConnectionStatus.Connected && Boolean(client?.isOpen()); } diff --git a/web-panel/src/shared/lib/ui.ts b/web-panel/src/shared/lib/ui.ts index 202749f7..5e08e081 100644 --- a/web-panel/src/shared/lib/ui.ts +++ b/web-panel/src/shared/lib/ui.ts @@ -1,40 +1,47 @@ -type ClassValue = string | number | false | null | undefined | ClassValue[] | Record +type ClassValue = + | string + | number + | false + | null + | undefined + | ClassValue[] + | Record; export function formatHumanLabel(value: string): string { - return value - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .replace(/\b\w/g, (letter) => letter.toUpperCase()); + return value + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (letter) => letter.toUpperCase()); } export function cn(...inputs: ClassValue[]) { - const classes: string[] = [] + const classes: string[] = []; - for (const input of inputs) { - if (!input) { - continue - } + for (const input of inputs) { + if (!input) { + continue; + } - if (typeof input === "string" || typeof input === "number") { - classes.push(String(input)) - continue - } + if (typeof input === 'string' || typeof input === 'number') { + classes.push(String(input)); + continue; + } - if (Array.isArray(input)) { - const value = cn(...input) - if (value) { - classes.push(value) - } - continue - } + if (Array.isArray(input)) { + const value = cn(...input); + if (value) { + classes.push(value); + } + continue; + } - for (const [key, enabled] of Object.entries(input)) { - if (enabled) { - classes.push(key) - } + for (const [key, enabled] of Object.entries(input)) { + if (enabled) { + classes.push(key); + } + } } - } - return classes.join(" ") + return classes.join(' '); } diff --git a/web-panel/src/shared/storage.test.ts b/web-panel/src/shared/storage.test.ts index ecb4823d..6ab1b088 100644 --- a/web-panel/src/shared/storage.test.ts +++ b/web-panel/src/shared/storage.test.ts @@ -3,35 +3,35 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { loadStringSet, saveStringSet } from './storage'; describe('storage revival', () => { - beforeEach(() => localStorage.clear()); - afterEach(() => vi.restoreAllMocks()); - - it('revives only valid string ids', () => { - localStorage.setItem('pins', JSON.stringify(['one', '', 2, 'two'])); - expect(loadStringSet('pins')).toEqual({ one: true, two: true }); - }); - - it('removes empty sets', () => { - expect(saveStringSet('pins', { one: true })).toBe(true); - expect(localStorage.getItem('pins')).toBe(JSON.stringify(['one'])); - expect(saveStringSet('pins', {})).toBe(true); - expect(localStorage.getItem('pins')).toBeNull(); - }); - - it('reports a failed browser storage write', () => { - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new DOMException('Storage disabled', 'SecurityError'); + beforeEach(() => localStorage.clear()); + afterEach(() => vi.restoreAllMocks()); + + 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', () => { + expect(saveStringSet('pins', { one: true })).toBe(true); + expect(localStorage.getItem('pins')).toBe(JSON.stringify(['one'])); + expect(saveStringSet('pins', {})).toBe(true); + expect(localStorage.getItem('pins')).toBeNull(); }); - expect(saveStringSet('pins', { one: true })).toBe(false); - }); + it('reports a failed browser storage write', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Storage disabled', 'SecurityError'); + }); - it('handles browsers that block access to local storage', () => { - vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { - throw new DOMException('Storage disabled', 'SecurityError'); + expect(saveStringSet('pins', { one: true })).toBe(false); }); - expect(saveStringSet('pins', { one: true })).toBe(false); - expect(loadStringSet('pins')).toEqual({}); - }); + it('handles browsers that block access to local storage', () => { + vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { + throw new DOMException('Storage disabled', 'SecurityError'); + }); + + expect(saveStringSet('pins', { one: true })).toBe(false); + expect(loadStringSet('pins')).toEqual({}); + }); }); diff --git a/web-panel/src/shared/storage.ts b/web-panel/src/shared/storage.ts index 124b7d95..92377242 100644 --- a/web-panel/src/shared/storage.ts +++ b/web-panel/src/shared/storage.ts @@ -3,81 +3,112 @@ import type { TrainerSummary } from '../../protocol/messages'; type Reviver = (raw: unknown) => T | null; function getStore(): Storage | null { - try { - return typeof window === 'undefined' ? null : window.localStorage; - } catch { - return null; - } + try { + return typeof window === 'undefined' ? null : window.localStorage; + } catch { + return null; + } } export function getTrainerStorageId(trainer: TrainerSummary | null | undefined): string | null { - if (!trainer) { - return null; - } + if (!trainer) { + return null; + } - const id = trainer.gameId?.trim() || trainer.titleId?.trim() || trainer.trainerId?.trim(); - return id || null; + const id = trainer.gameId?.trim() || trainer.titleId?.trim() || trainer.trainerId?.trim(); + return id || null; } -export function loadJson(key: string | null, revive: Reviver, fallback: T): T { - const store = getStore(); - if (!key || !store) { - return fallback; - } - - try { - const raw = store.getItem(key); - if (!raw) { - return fallback; +export function loadString(key: string | null): string | null { + const store = getStore(); + if (!key || !store) { + return null; } - return revive(JSON.parse(raw) as unknown) ?? fallback; - } catch { - return fallback; - } + try { + return store.getItem(key); + } catch { + return null; + } } -export function saveJson(key: string | null, value: unknown, isEmpty: (value: unknown) => boolean): boolean { - const store = getStore(); - if (!key || !store) { - return false; - } +export function saveString(key: string | null, value: string): boolean { + const store = getStore(); + if (!key || !store) { + return false; + } + + try { + store.setItem(key, value); + return true; + } catch { + return false; + } +} - try { - if (isEmpty(value)) { - store.removeItem(key); - return true; +export function loadJson(key: string | null, revive: Reviver, fallback: T): T { + const store = getStore(); + if (!key || !store) { + return fallback; } - store.setItem(key, JSON.stringify(value)); - return true; - } catch { - return false; - } + try { + const raw = store.getItem(key); + if (!raw) { + return fallback; + } + + return revive(JSON.parse(raw) as unknown) ?? fallback; + } catch { + return fallback; + } } -export function loadStringSet(key: string | null): Record { - return loadJson>( - key, - (raw) => { - if (!Array.isArray(raw)) { - return null; - } +export function saveJson( + key: string | null, + value: unknown, + isEmpty: (value: unknown) => boolean, +): boolean { + const store = getStore(); + if (!key || !store) { + return false; + } - const result: Record = {}; - for (const value of raw) { - if (typeof value === 'string' && value.length > 0) { - result[value] = true; + try { + if (isEmpty(value)) { + store.removeItem(key); + return true; } - } - return result; - }, - {}, - ); + store.setItem(key, JSON.stringify(value)); + return true; + } catch { + return false; + } +} + +export function loadStringSet(key: string | null): Record { + return loadJson>( + key, + (raw) => { + if (!Array.isArray(raw)) { + return null; + } + + const result: Record = {}; + for (const value of raw) { + if (typeof value === 'string' && value.length > 0) { + result[value] = true; + } + } + + return result; + }, + {}, + ); } export function saveStringSet(key: string | null, value: Record): boolean { - const ids = Object.keys(value); - return saveJson(key, ids, () => ids.length === 0); + const ids = Object.keys(value); + return saveJson(key, ids, () => ids.length === 0); } diff --git a/web-panel/src/shared/ui/Drawer.tsx b/web-panel/src/shared/ui/Drawer.tsx index 105e36df..ea72ab0a 100644 --- a/web-panel/src/shared/ui/Drawer.tsx +++ b/web-panel/src/shared/ui/Drawer.tsx @@ -1,45 +1,125 @@ -import type { ReactNode } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { type ReactNode, useEffect, useRef } from 'react'; import { cn } from '@/shared/lib/ui'; const DRAWER_SIDE_CLASSES = { - left: 'left-0 border-r', - right: 'right-0 border-l', + left: 'left-0 border-r', + right: 'right-0 border-l', } as const; const DRAWER_CLOSED_CLASSES = { - left: '-translate-x-full', - right: 'translate-x-full', + left: '-translate-x-full', + right: 'translate-x-full', } as const; -const DRAWER_OVERLAY_CLASS = 'remote-drawer-overlay absolute inset-0 z-30 transition-opacity duration-200 ease-out motion-reduce:transition-none'; -const DRAWER_PANEL_CLASS = 'remote-drawer-panel absolute bottom-0 top-0 z-40 flex w-[88%] max-w-90 flex-col border-white/10 transition-transform duration-300 ease-out motion-reduce:transition-none'; +const DRAWER_OVERLAY_CLASS = + 'remote-drawer-overlay absolute inset-0 z-30 transition-opacity duration-200 ease-out motion-reduce:transition-none'; +const DRAWER_PANEL_CLASS = + 'remote-drawer-panel absolute bottom-0 top-0 z-40 flex w-[88%] max-w-90 flex-col border-white/10 transition-transform duration-300 ease-out motion-reduce:transition-none'; + +const FOCUSABLE_SELECTOR = + 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; type DrawerProps = { - open: boolean; - side: 'left' | 'right'; - children: ReactNode; - onClose: () => void; + open: boolean; + side: 'left' | 'right'; + /** Accessible name for the dialog; an icon-only drawer has no other one. */ + label: string; + children: ReactNode; + onClose: () => void; }; -export const Drawer = ({ open, side, children, onClose }: DrawerProps) => { - const { _ } = useLingui(); - const sideClassName = DRAWER_SIDE_CLASSES[side]; - const closedClassName = DRAWER_CLOSED_CLASSES[side]; - - return ( - <> - + ); +}; diff --git a/web-panel/src/shared/ui/SearchInput.tsx b/web-panel/src/shared/ui/SearchInput.tsx index 0ecda01a..1d5d74a0 100644 --- a/web-panel/src/shared/ui/SearchInput.tsx +++ b/web-panel/src/shared/ui/SearchInput.tsx @@ -1,38 +1,47 @@ -import type { FormEvent } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; - -import { Icon } from '@/shared/ui/Icon'; - +import type { FormEvent } from 'react'; import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; type SearchInputProps = { - value: string; - placeholder: string; - className?: string; - onChange: (value: string) => void; + value: string; + placeholder: string; + className?: string; + onChange: (value: string) => void; }; export const SearchInput = ({ value, placeholder, className, onChange }: SearchInputProps) => { - const { _ } = useLingui(); - const handleInput = (event: FormEvent) => onChange(event.currentTarget.value); - const handleClear = () => onChange(''); + const { _ } = useLingui(); + const handleInput = (event: FormEvent) => onChange(event.currentTarget.value); + const handleClear = () => onChange(''); - return ( -
- - - {value ? ( - - ) : null} -
- ); + return ( +
+ + + {value ? ( + + ) : null} +
+ ); }; diff --git a/web-panel/src/trainer/controls/ActionButton.tsx b/web-panel/src/trainer/controls/ActionButton.tsx index 57904982..7770f788 100644 --- a/web-panel/src/trainer/controls/ActionButton.tsx +++ b/web-panel/src/trainer/controls/ActionButton.tsx @@ -6,19 +6,19 @@ 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 : _(msg`Apply`); + const { _ } = useLingui(); + const handleClick = () => onChange(1); + const label = typeof cheat.args.button === 'string' ? cheat.args.button : _(msg`Apply`); - return ( - - ); + return ( + + ); }; diff --git a/web-panel/src/trainer/controls/CheatControl.tsx b/web-panel/src/trainer/controls/CheatControl.tsx index 1b8c0b3d..ad4d4fa4 100644 --- a/web-panel/src/trainer/controls/CheatControl.tsx +++ b/web-panel/src/trainer/controls/CheatControl.tsx @@ -1,4 +1,4 @@ -import { type ReactElement } from 'react'; +import type { ReactElement } from 'react'; import type { CheatSchema } from '../../../protocol/messages'; import { ECheatType } from '../../../protocol/messages'; @@ -8,32 +8,34 @@ import { NumberControl } from './NumberControl'; import { ScalarControl } from './ScalarControl'; import { SelectionControl } from './SelectionControl'; import { SliderControl } from './SliderControl'; -import { ToggleControl } from './ToggleControl'; import type { ControlInternalProps } from './shared'; +import { ToggleControl } from './ToggleControl'; type CheatControlProps = { - cheat: CheatSchema; - value: unknown; - pending: boolean; - disabled: boolean; - onChange: (nextValue: unknown) => void; + cheat: CheatSchema; + value: unknown; + pending: boolean; + disabled: boolean; + onChange: (nextValue: unknown) => void; }; const CONTROL_BY_TYPE: Record ReactElement> = { - [ECheatType.Toggle]: (props) => , - [ECheatType.Slider]: (props) => , - [ECheatType.Number]: (props) => , - [ECheatType.Button]: (props) => , - [ECheatType.Selection]: (props) => , - [ECheatType.Scalar]: (props) => , - [ECheatType.Incremental]: (props) => , + [ECheatType.Toggle]: (props) => , + [ECheatType.Slider]: (props) => , + [ECheatType.Number]: (props) => , + [ECheatType.Button]: (props) => , + [ECheatType.Selection]: (props) => , + [ECheatType.Scalar]: (props) => , + [ECheatType.Incremental]: (props) => , }; export const CheatControl = ({ cheat, value, pending, disabled, onChange }: CheatControlProps) => { - const Renderer = CONTROL_BY_TYPE[cheat.type]; - if (!Renderer) { - return Unsupported: {cheat.type}; - } + const Renderer = CONTROL_BY_TYPE[cheat.type]; + if (!Renderer) { + return Unsupported: {cheat.type}; + } - return ; + return ( + + ); }; diff --git a/web-panel/src/trainer/controls/IncrementalControl.tsx b/web-panel/src/trainer/controls/IncrementalControl.tsx index 7834319c..fb8bf0e2 100644 --- a/web-panel/src/trainer/controls/IncrementalControl.tsx +++ b/web-panel/src/trainer/controls/IncrementalControl.tsx @@ -1,33 +1,50 @@ +import { useMemo } from 'react'; + import { cn } from '@/shared/lib/ui'; -import { resolveOption } from '../model/values'; +import { isSameOption, resolveOption } from '../model/values'; import { ActionButton } from './ActionButton'; -import { StepButton, type ControlInternalProps } from './shared'; +import { type ControlInternalProps, STEPPER_SHELL_CLASS, StepButton } from './shared'; const INCREMENTAL_STEP_GRID = 'grid-cols-[46px_minmax(0,1fr)_46px]'; export const IncrementalControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => { - const options = (cheat.args.options ?? []).map(resolveOption); - if (options.length === 0) { - return ; - } + const options = useMemo( + () => (cheat.args.options ?? []).map(resolveOption), + [cheat.args.options], + ); + if (options.length === 0) { + return ; + } - const currentIndex = options.findIndex((option) => isSameOption(option.value, value)); - const previous = currentIndex > 0 ? options[currentIndex - 1] : null; - const next = currentIndex >= 0 && currentIndex < options.length - 1 ? options[currentIndex + 1] : null; - const currentLabel = options[currentIndex]?.label ?? String(value ?? '--'); + const matchedIndex = options.findIndex((option) => isSameOption(option.value, value)); + // An unrecognised value must not strand the user: treat it as "before the first + // option" so stepping forward still walks the list. + const currentIndex = matchedIndex >= 0 ? matchedIndex : -1; + const previous = currentIndex > 0 ? options[currentIndex - 1] : null; + const next = currentIndex < options.length - 1 ? options[currentIndex + 1] : null; + const currentLabel = options[currentIndex]?.label ?? String(value ?? '--'); - return ( -
- previous && onChange(previous.value)} /> - - {currentLabel}{cheat.args.postfix ?? ''} - - next && onChange(next.value)} /> -
- ); + return ( +
+ previous && onChange(previous.value)} + /> + + {currentLabel} + {cheat.args.postfix ?? ''} + + next && onChange(next.value)} + /> +
+ ); }; - -function isSameOption(left: unknown, right: unknown): boolean { - return String(left) === String(right); -} diff --git a/web-panel/src/trainer/controls/NumberControl.tsx b/web-panel/src/trainer/controls/NumberControl.tsx index 6a5df5aa..3cfb93a6 100644 --- a/web-panel/src/trainer/controls/NumberControl.tsx +++ b/web-panel/src/trainer/controls/NumberControl.tsx @@ -1,30 +1,72 @@ -import { type FormEvent } from 'react'; +import { type FormEvent, useState } from 'react'; import { cn } from '@/shared/lib/ui'; import { formatInputNumber, numericValue, stripNumberGrouping } from './format-number'; -import { StepButton, type ControlInternalProps } from './shared'; +import { type ControlInternalProps, STEPPER_SHELL_CLASS, StepButton } from './shared'; +import { snapToStep } from './step'; const NUMBER_STEP_GRID = 'grid-cols-[48px_minmax(0,1fr)_48px]'; export const NumberControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => { - const step = cheat.args.step ?? 1; - const currentValue = numericValue(value, 0); - const handleInput = (event: FormEvent) => onChange(stripNumberGrouping(event.currentTarget.value)); - const decrement = () => onChange(Math.max(cheat.args.min ?? Number.NEGATIVE_INFINITY, currentValue - step)); - const increment = () => onChange(Math.min(cheat.args.max ?? Number.POSITIVE_INFINITY, currentValue + step)); - - return ( -
- - - -
- ); + const step = cheat.args.step ?? 1; + const currentValue = numericValue(value, 0); + // While focused the raw text is authoritative, otherwise formatting would eat the + // decimal point in "0." and re-group digits under the caret as the user types. + const [draft, setDraft] = useState(null); + + const handleInput = (event: FormEvent) => { + const raw = event.currentTarget.value; + setDraft(raw); + onChange(stripNumberGrouping(raw)); + }; + + const commit = (next: number) => { + setDraft(null); + onChange(next); + }; + + const decrement = () => + commit( + snapToStep( + Math.max(cheat.args.min ?? Number.NEGATIVE_INFINITY, currentValue - step), + step, + ), + ); + const increment = () => + commit( + snapToStep( + Math.min(cheat.args.max ?? Number.POSITIVE_INFINITY, currentValue + step), + step, + ), + ); + + return ( +
+ + setDraft(null)} + /> + +
+ ); }; diff --git a/web-panel/src/trainer/controls/ScalarControl.tsx b/web-panel/src/trainer/controls/ScalarControl.tsx index 60e6dc84..6a6738c8 100644 --- a/web-panel/src/trainer/controls/ScalarControl.tsx +++ b/web-panel/src/trainer/controls/ScalarControl.tsx @@ -1,49 +1,65 @@ -import { type FormEvent } from 'react'; +import { type FormEvent, useMemo } from 'react'; import type { CheatSchema } from '../../../protocol/messages'; import { resolveOption } from '../model/values'; -import { formatNumber, numericValue } from './format-number'; -import { SliderTrack, type ControlInternalProps } from './shared'; +import { numericValue } from './format-number'; +import { type ControlInternalProps, SliderReadout } from './shared'; export const ScalarControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => { - const numericOptions = getNumericOptions(cheat.args.options ?? []); - const min = cheat.args.min ?? numericOptions[0] ?? 0; - const max = cheat.args.max ?? numericOptions[numericOptions.length - 1] ?? 100; - const step = cheat.args.step ?? inferStep(numericOptions) ?? 1; - const currentValue = numericValue(value, min); - const handleInput = (event: FormEvent) => onChange(Number(event.currentTarget.value)); + const numericOptions = useMemo( + () => getNumericOptions(cheat.args.options ?? []), + [cheat.args.options], + ); + const min = cheat.args.min ?? numericOptions[0] ?? 0; + const max = cheat.args.max ?? numericOptions[numericOptions.length - 1] ?? 100; + const step = cheat.args.step ?? inferStep(numericOptions) ?? 1; + const currentValue = numericValue(value, min); + const handleInput = (event: FormEvent) => + onChange(Number(event.currentTarget.value)); - return ( -
-
- {formatNumber(currentValue, step)}{cheat.args.postfix ?? ''} -
- -
- {min}{cheat.args.postfix ?? ''} - {max}{cheat.args.postfix ?? ''} -
-
- ); + return ( +
+ +
+ + {min} + {cheat.args.postfix ?? ''} + + + {max} + {cheat.args.postfix ?? ''} + +
+
+ ); }; function getNumericOptions(options: NonNullable): number[] { - return options - .map(resolveOption) - .map((option) => numericValue(option.value, Number.NaN)) - .filter((option) => Number.isFinite(option)) - .sort((left, right) => left - right); + return options + .map(resolveOption) + .map((option) => numericValue(option.value, Number.NaN)) + .filter((option) => Number.isFinite(option)) + .sort((left, right) => left - right); } function inferStep(options: number[]): number | null { - if (options.length < 2) { - return null; - } + if (options.length < 2) { + return null; + } - const steps = options - .slice(1) - .map((option, index) => Math.abs(option - options[index])) - .filter((option) => option > 0); + const steps = options + .slice(1) + .map((option, index) => Math.abs(option - options[index])) + .filter((option) => option > 0); - return steps.length > 0 ? Math.min(...steps) : null; + return steps.length > 0 ? Math.min(...steps) : null; } diff --git a/web-panel/src/trainer/controls/SelectionControl.tsx b/web-panel/src/trainer/controls/SelectionControl.tsx index 6b849cc4..073eeb02 100644 --- a/web-panel/src/trainer/controls/SelectionControl.tsx +++ b/web-panel/src/trainer/controls/SelectionControl.tsx @@ -1,75 +1,122 @@ -import { useState } from 'react'; import { Trans } from '@lingui/react/macro'; - -import { Icon } from '@/shared/ui/Icon'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; import type { CheatOption } from '../../../protocol/messages'; -import { resolveOption } from '../model/values'; +import { isSameOption, resolveOption } from '../model/values'; import type { ControlInternalProps } from './shared'; export const SelectionControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => { - const options = (cheat.args.options ?? []).map(resolveOption); - const [open, setOpen] = useState(false); - if (options.length === 0) { - return No options; - } + const options = useMemo( + () => (cheat.args.options ?? []).map(resolveOption), + [cheat.args.options], + ); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + // Without these the list can only be dismissed by picking something. + useEffect(() => { + if (!open) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); - const selectedOption = findOption(options, String(value ?? options[0].value)) ?? options[0]; - const handleToggle = () => { - if (disabled) { - return; + if (options.length === 0) { + return ( + + No options + + ); } - setOpen((current) => !current); - }; - const handleSelect = (option: CheatOption) => { - onChange(option.value); - setOpen(false); - }; + const selectedOption = + options.find((option) => isSameOption(option.value, value ?? options[0].value)) ?? + options[0]; + const handleToggle = () => { + if (disabled) { + return; + } - return ( -
- - {open ? ( -
- {options.map((option) => { - const active = isSameOption(selectedOption.value, option.value); - return ( - - ); - })} + aria-expanded={open} + aria-haspopup="listbox" + disabled={disabled} + className="flex h-[38px] w-full items-center justify-between gap-3 rounded-[10px] border border-white/10 bg-white/5.5 px-3 text-left text-[13px] font-semibold text-(--deck-fg) shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] outline-none backdrop-blur-xl disabled:cursor-not-allowed disabled:opacity-50" + onClick={handleToggle} + > + {selectedOption.label} + + + {open ? ( +
+ {options.map((option) => { + const active = isSameOption(selectedOption.value, option.value); + return ( + + ); + })} +
+ ) : null}
- ) : null} -
- ); + ); }; function optionKey(option: CheatOption): string { - return String(option.value); -} - -function findOption(options: CheatOption[], value: string): CheatOption | undefined { - return options.find((option) => String(option.value) === value); -} - -function isSameOption(left: unknown, right: unknown): boolean { - return String(left) === String(right); + return String(option.value); } diff --git a/web-panel/src/trainer/controls/SliderControl.tsx b/web-panel/src/trainer/controls/SliderControl.tsx index 3230ab21..3fb0d978 100644 --- a/web-panel/src/trainer/controls/SliderControl.tsx +++ b/web-panel/src/trainer/controls/SliderControl.tsx @@ -1,21 +1,28 @@ -import { type FormEvent } from 'react'; +import type { FormEvent } from 'react'; -import { formatNumber, numericValue } from './format-number'; -import { SliderTrack, type ControlInternalProps } from './shared'; +import { numericValue } from './format-number'; +import { type ControlInternalProps, SliderReadout } from './shared'; export const SliderControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => { - const min = cheat.args.min ?? 0; - const max = cheat.args.max ?? 100; - const step = cheat.args.step ?? 1; - const currentValue = numericValue(value, min); - const handleInput = (event: FormEvent) => onChange(Number(event.currentTarget.value)); + const min = cheat.args.min ?? 0; + const max = cheat.args.max ?? 100; + const step = cheat.args.step ?? 1; + const currentValue = numericValue(value, min); + const handleInput = (event: FormEvent) => + onChange(Number(event.currentTarget.value)); - return ( -
-
- {formatNumber(currentValue, step)}{cheat.args.postfix ?? ''} -
- -
- ); + return ( +
+ +
+ ); }; diff --git a/web-panel/src/trainer/controls/ToggleControl.tsx b/web-panel/src/trainer/controls/ToggleControl.tsx index ba7bcd80..4f824041 100644 --- a/web-panel/src/trainer/controls/ToggleControl.tsx +++ b/web-panel/src/trainer/controls/ToggleControl.tsx @@ -3,18 +3,30 @@ import { cn } from '@/shared/lib/ui'; import type { ControlInternalProps } from './shared'; export const ToggleControl = ({ value, disabled, onChange }: ControlInternalProps) => { - const checked = Boolean(value); - const handleClick = () => onChange(!checked); + const checked = Boolean(value); + const handleClick = () => onChange(!checked); - return ( - - ); + return ( + + ); }; diff --git a/web-panel/src/trainer/controls/format-number.ts b/web-panel/src/trainer/controls/format-number.ts index 4ebcfec2..ecb528c2 100644 --- a/web-panel/src/trainer/controls/format-number.ts +++ b/web-panel/src/trainer/controls/format-number.ts @@ -2,39 +2,41 @@ const NUMBER_FORMAT_LOCALE = 'en-US'; const NUMBER_MAX_FRACTION_DIGITS = 6; const NUMBER_GROUP_SEPARATOR_PATTERN = /[,\s]/g; -export const groupedNumberFormat = new Intl.NumberFormat(NUMBER_FORMAT_LOCALE, { maximumFractionDigits: NUMBER_MAX_FRACTION_DIGITS }); +export const groupedNumberFormat = new Intl.NumberFormat(NUMBER_FORMAT_LOCALE, { + maximumFractionDigits: NUMBER_MAX_FRACTION_DIGITS, +}); export function formatNumber(value: number, step: number): string { - if (step >= 1) { - return String(Math.round(value)); - } + if (step >= 1) { + return String(Math.round(value)); + } - const decimals = step.toString().split('.')[1]?.length ?? 1; - return value.toFixed(decimals); + const decimals = step.toString().split('.')[1]?.length ?? 1; + return value.toFixed(decimals); } export function formatInputNumber(value: unknown): string { - if (value === null || value === undefined || value === '') { - return ''; - } + if (value === null || value === undefined || value === '') { + return ''; + } - const numeric = numericValue(value, Number.NaN); - return Number.isFinite(numeric) ? groupedNumberFormat.format(numeric) : String(value); + const numeric = numericValue(value, Number.NaN); + return Number.isFinite(numeric) ? groupedNumberFormat.format(numeric) : String(value); } export function numericValue(value: unknown, fallback: number): number { - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } - if (typeof value !== 'string') { - return fallback; - } + if (typeof value !== 'string') { + return fallback; + } - const parsed = Number(stripNumberGrouping(value)); - return Number.isFinite(parsed) ? parsed : fallback; + const parsed = Number(stripNumberGrouping(value)); + return Number.isFinite(parsed) ? parsed : fallback; } export function stripNumberGrouping(value: string): string { - return value.replace(NUMBER_GROUP_SEPARATOR_PATTERN, ''); + return value.replace(NUMBER_GROUP_SEPARATOR_PATTERN, ''); } diff --git a/web-panel/src/trainer/controls/shared.tsx b/web-panel/src/trainer/controls/shared.tsx index cb8d6dc9..b7842ee1 100644 --- a/web-panel/src/trainer/controls/shared.tsx +++ b/web-panel/src/trainer/controls/shared.tsx @@ -1,64 +1,126 @@ -import { type FormEvent } from 'react'; - -import { Icon } from '@/shared/ui/Icon'; +import type { FormEvent } from 'react'; import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; import type { CheatSchema } from '../../../protocol/messages'; +import { formatNumber } from './format-number'; export type ControlInternalProps = { - cheat: CheatSchema; - value: unknown; - disabled: boolean; - onChange: (nextValue: unknown) => void; + cheat: CheatSchema; + value: unknown; + disabled: boolean; + onChange: (nextValue: unknown) => void; }; +export const STEPPER_SHELL_CLASS = + 'grid h-[38px] w-full items-stretch overflow-hidden rounded-[10px] border border-white/10 bg-white/5.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] backdrop-blur-xl'; + type SliderTrackProps = { - min: number; - max: number; - step: number; - value: number; - disabled: boolean; - onInput: (event: FormEvent) => void; + min: number; + max: number; + step: number; + value: number; + label: string; + disabled: boolean; + onInput: (event: FormEvent) => void; }; -export const SliderTrack = ({ min, max, step, value, disabled, onInput }: SliderTrackProps) => { - const pct = max === min ? 0 : Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); +export const SliderTrack = ({ + min, + max, + step, + value, + label, + disabled, + onInput, +}: SliderTrackProps) => { + const pct = max === min ? 0 : Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); - return ( -
-
-
-
- -
- ); + return ( +
+
+
+
+ +
+ ); }; type StepButtonProps = { - icon: 'minus' | 'plus' | 'chevron-left' | 'chevron-right'; - border: 'left' | 'right'; - disabled: boolean; - onClick: () => void; + icon: 'minus' | 'plus' | 'chevron-left' | 'chevron-right'; + border: 'left' | 'right'; + /** Required: the button renders an icon only, so it has no other accessible name. */ + label: string; + disabled: boolean; + onClick: () => void; +}; + +export const StepButton = ({ icon, border, label, disabled, onClick }: StepButtonProps) => { + return ( + + ); +}; + +type SliderReadoutProps = { + value: number; + min: number; + max: number; + step: number; + postfix: string; + label: string; + disabled: boolean; + onInput: (event: FormEvent) => void; }; -export const StepButton = ({ icon, border, disabled, onClick }: StepButtonProps) => { - return ( - - ); +export const SliderReadout = ({ + value, + min, + max, + step, + postfix, + label, + disabled, + onInput, +}: SliderReadoutProps) => { + return ( + <> +
+ {formatNumber(value, step)} + {postfix} +
+ + + ); }; diff --git a/web-panel/src/trainer/controls/step.ts b/web-panel/src/trainer/controls/step.ts new file mode 100644 index 00000000..a90a18ab --- /dev/null +++ b/web-panel/src/trainer/controls/step.ts @@ -0,0 +1,20 @@ +/** + * Rounds to the step's decimal precision. Repeated `value + step` on a fractional + * step drifts (0.1 + 0.2 -> 0.30000000000000004) and that drift is sent on the wire. + */ +export function snapToStep(value: number, step: number): number { + if (!Number.isFinite(value)) { + return 0; + } + + const decimals = decimalPlaces(step); + return decimals === 0 ? Math.round(value) : Number(value.toFixed(decimals)); +} + +export function decimalPlaces(step: number): number { + if (!Number.isFinite(step) || Number.isInteger(step)) { + return 0; + } + + return step.toString().split('.')[1]?.length ?? 0; +} diff --git a/web-panel/src/trainer/model/categories.test.ts b/web-panel/src/trainer/model/categories.test.ts index ecf47a8a..6df8eeb0 100644 --- a/web-panel/src/trainer/model/categories.test.ts +++ b/web-panel/src/trainer/model/categories.test.ts @@ -4,29 +4,47 @@ import { ECheatType, type TrainerMetaPayload } from '../../../protocol/messages' import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from './categories'; const trainerMeta: TrainerMetaPayload = { - session: { instanceId: 'session' }, - trainer: { - trainerId: 'trainer', - gameId: 'game', - trainerLoading: false, - gameInstalled: true, - needsCompatibilityWarning: false, - isTimeLimitExpired: false, - }, - schema: { - categories: ['player', 'world'], - cheats: [ - { uuid: '1', target: 'health', type: ECheatType.Toggle, name: 'Infinite Health', category: 'player', args: {} }, - { uuid: '2', target: 'time', type: ECheatType.Slider, name: 'World Time', category: 'world', args: {} }, - ], - }, + session: { instanceId: 'session' }, + trainer: { + trainerId: 'trainer', + gameId: 'game', + trainerLoading: false, + gameInstalled: true, + needsCompatibilityWarning: false, + isTimeLimitExpired: false, + }, + schema: { + categories: ['player', 'world'], + cheats: [ + { + uuid: '1', + target: 'health', + type: ECheatType.Toggle, + name: 'Infinite Health', + category: 'player', + args: {}, + }, + { + uuid: '2', + target: 'time', + type: ECheatType.Slider, + name: 'World Time', + category: 'world', + args: {}, + }, + ], + }, }; describe('trainer categories', () => { - it('groups, filters and projects pinned cheats', () => { - const groups = groupCheatsByCategory(trainerMeta); - expect(groups.map((group) => group.id)).toEqual(['player', 'world']); - expect(filterGroups(groups, 'health')[0]?.cheats.map((cheat) => cheat.target)).toEqual(['health']); - expect(buildPinnedGroup(trainerMeta, { time: true })?.cheats.map((cheat) => cheat.target)).toEqual(['time']); - }); + it('groups, filters and projects pinned cheats', () => { + const groups = groupCheatsByCategory(trainerMeta); + expect(groups.map((group) => group.id)).toEqual(['player', 'world']); + expect(filterGroups(groups, 'health')[0]?.cheats.map((cheat) => cheat.target)).toEqual([ + 'health', + ]); + expect( + buildPinnedGroup(trainerMeta, { time: true })?.cheats.map((cheat) => cheat.target), + ).toEqual(['time']); + }); }); diff --git a/web-panel/src/trainer/model/categories.ts b/web-panel/src/trainer/model/categories.ts index e7995178..7d04ace5 100644 --- a/web-panel/src/trainer/model/categories.ts +++ b/web-panel/src/trainer/model/categories.ts @@ -2,81 +2,80 @@ import { formatHumanLabel } from '@/shared/lib/ui'; import type { CheatSchema, TrainerMetaPayload, TrainerSummary } from '../../../protocol/messages'; export type CategoryGroup = { - id: string; - label: string; - cheats: CheatSchema[]; + id: string; + cheats: CheatSchema[]; }; export function groupCheatsByCategory(trainerMeta: TrainerMetaPayload | null): CategoryGroup[] { - if (!trainerMeta) { - return []; - } + if (!trainerMeta) { + return []; + } - const grouped = new Map(); - for (const cheat of trainerMeta.schema.cheats) { - const bucket = grouped.get(cheat.category) ?? []; - bucket.push(cheat); - grouped.set(cheat.category, bucket); - } + const grouped = new Map(); + for (const cheat of trainerMeta.schema.cheats) { + const bucket = grouped.get(cheat.category) ?? []; + bucket.push(cheat); + grouped.set(cheat.category, bucket); + } - return Array.from(grouped.entries()) - .map(([id, cheats]) => ({ id, label: formatHumanLabel(id), cheats })) - .sort((left, right) => left.label.localeCompare(right.label)); + return Array.from(grouped.entries()) + .map(([id, cheats]) => ({ id, cheats })) + .sort((left, right) => formatHumanLabel(left.id).localeCompare(formatHumanLabel(right.id))); } export const PINNED_CATEGORY_ID = 'pinned'; export function buildPinnedGroup( - trainerMeta: TrainerMetaPayload | null, - pinnedTargets: Record, + trainerMeta: TrainerMetaPayload | null, + pinnedTargets: Record, ): CategoryGroup | null { - if (!trainerMeta) { - return null; - } + if (!trainerMeta) { + return null; + } - const pinnedCheats = trainerMeta.schema.cheats.filter((cheat) => pinnedTargets[cheat.target]); - if (pinnedCheats.length === 0) { - return null; - } + const pinnedCheats = trainerMeta.schema.cheats.filter((cheat) => pinnedTargets[cheat.target]); + if (pinnedCheats.length === 0) { + return null; + } - return { - id: PINNED_CATEGORY_ID, - label: formatHumanLabel(PINNED_CATEGORY_ID), - cheats: pinnedCheats, - }; + return { + id: PINNED_CATEGORY_ID, + cheats: pinnedCheats, + }; } export function filterGroups(groups: CategoryGroup[], query: string): CategoryGroup[] { - const normalized = query.trim().toLowerCase(); - if (!normalized) { - return groups; - } + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return groups; + } - const result: CategoryGroup[] = []; - for (const group of groups) { - const matchesGroup = group.label.toLowerCase().includes(normalized) || group.id.toLowerCase().includes(normalized); - const cheats = matchesGroup - ? group.cheats - : group.cheats.filter((cheat) => cheatMatchesQuery(cheat, normalized)); + const result: CategoryGroup[] = []; + for (const group of groups) { + const matchesGroup = + formatHumanLabel(group.id).toLowerCase().includes(normalized) || + group.id.toLowerCase().includes(normalized); + const cheats = matchesGroup + ? group.cheats + : group.cheats.filter((cheat) => cheatMatchesQuery(cheat, normalized)); - if (cheats.length > 0) { - result.push({ ...group, cheats }); + if (cheats.length > 0) { + result.push({ ...group, cheats }); + } } - } - return result; + return result; } function cheatMatchesQuery(cheat: CheatSchema, query: string): boolean { - if (cheat.name?.toLowerCase().includes(query)) return true; - if (cheat.target?.toLowerCase().includes(query)) return true; - if (cheat.category?.toLowerCase().includes(query)) return true; - if (cheat.description?.toLowerCase().includes(query)) return true; - if (cheat.type?.toLowerCase().includes(query)) return true; - return false; + if (cheat.name?.toLowerCase().includes(query)) return true; + if (cheat.target?.toLowerCase().includes(query)) return true; + if (cheat.category?.toLowerCase().includes(query)) return true; + if (cheat.description?.toLowerCase().includes(query)) return true; + if (cheat.type?.toLowerCase().includes(query)) return true; + return false; } export function getTrainerDisplayName(trainer: TrainerSummary): string { - return trainer.displayName?.trim() || trainer.gameId || trainer.titleId || trainer.trainerId; + return trainer.displayName?.trim() || trainer.gameId || trainer.titleId || trainer.trainerId; } - diff --git a/web-panel/src/trainer/model/values.ts b/web-panel/src/trainer/model/values.ts index 2f1ea01c..0ba6a05f 100644 --- a/web-panel/src/trainer/model/values.ts +++ b/web-panel/src/trainer/model/values.ts @@ -1,30 +1,40 @@ -import { ECheatType, type CheatOption, type CheatOptionLike, type CheatSchema } from '../../../protocol/messages'; +import { + type CheatOption, + type CheatOptionLike, + type CheatSchema, + ECheatType, +} from '../../../protocol/messages'; +import { stripNumberGrouping } from '../controls/format-number'; -const NUMBER_GROUP_SEPARATOR_PATTERN = /[,\s]/g; +// Wand sends option values as either strings or numbers for the same cheat, so identity +// is compared by string form. +export function isSameOption(left: unknown, right: unknown): boolean { + return String(left) === String(right); +} export function resolveOption(option: CheatOptionLike): CheatOption { - if (typeof option === 'string' || typeof option === 'number') { - return { label: String(option), value: option }; - } + if (typeof option === 'string' || typeof option === 'number') { + return { label: String(option), value: option }; + } - return { - label: option.label ?? String(option.value), - value: option.value, - }; + return { + label: option.label ?? String(option.value), + value: option.value, + }; } export function normalizeCheatValue(cheat: CheatSchema, value: unknown): unknown { - if (cheat.type === ECheatType.Toggle) { - return Boolean(value); - } + if (cheat.type === ECheatType.Toggle) { + return Boolean(value); + } - if (cheat.type !== ECheatType.Slider && cheat.type !== ECheatType.Number) { - return value; - } + if (cheat.type !== ECheatType.Slider && cheat.type !== ECheatType.Number) { + return value; + } - if (typeof value !== 'string' || !value.trim()) { - return value; - } + if (typeof value !== 'string' || !value.trim()) { + return value; + } - return Number(value.trim().replace(NUMBER_GROUP_SEPARATOR_PATTERN, '')); + return Number(stripNumberGrouping(value.trim())); } diff --git a/web-panel/src/trainer/pinned-cheats/pinned-cheat-storage.ts b/web-panel/src/trainer/pinned-cheats/pinned-cheat-storage.ts index e2fac087..60849eac 100644 --- a/web-panel/src/trainer/pinned-cheats/pinned-cheat-storage.ts +++ b/web-panel/src/trainer/pinned-cheats/pinned-cheat-storage.ts @@ -1,5 +1,5 @@ import type { TrainerSummary } from '../../../protocol/messages'; -import { getTrainerStorageId, loadStringSet, saveStringSet } from '../../shared/storage'; +import { getTrainerStorageId } from '../../shared/storage'; const STORAGE_PREFIX = 'wand-remote.pinned-cheats.v1:'; @@ -7,11 +7,3 @@ export function getPinnedStorageKey(trainer: TrainerSummary | null | undefined): const id = getTrainerStorageId(trainer); return id ? `${STORAGE_PREFIX}${id}` : null; } - -export function loadPinnedTargets(storageKey: string | null): Record { - return loadStringSet(storageKey); -} - -export function savePinnedTargets(storageKey: string | null, pinned: Record): void { - saveStringSet(storageKey, pinned); -} diff --git a/web-panel/src/trainer/pinned-cheats/use-pinned-cheats.ts b/web-panel/src/trainer/pinned-cheats/use-pinned-cheats.ts index 2c418e86..aec48acb 100644 --- a/web-panel/src/trainer/pinned-cheats/use-pinned-cheats.ts +++ b/web-panel/src/trainer/pinned-cheats/use-pinned-cheats.ts @@ -1,31 +1,31 @@ import { useCallback, useEffect, useState } from 'react'; import type { CheatSchema } from '../../../protocol/messages'; -import { loadPinnedTargets, savePinnedTargets } from './pinned-cheat-storage'; +import { loadStringSet, saveStringSet } from '../../shared/storage'; type PinnedTargetsParams = { - pinnedStorageKey: string | null; + pinnedStorageKey: string | null; }; export function usePinnedCheats({ pinnedStorageKey }: PinnedTargetsParams) { - const [pinnedTargets, setPinnedTargets] = useState>({}); + const [pinnedTargets, setPinnedTargets] = useState>({}); - useEffect(() => { - setPinnedTargets(loadPinnedTargets(pinnedStorageKey)); - }, [pinnedStorageKey]); + useEffect(() => { + setPinnedTargets(loadStringSet(pinnedStorageKey)); + }, [pinnedStorageKey]); - const toggle = useCallback( - (cheat: CheatSchema) => { - setPinnedTargets((current) => { - const next = { ...current }; - if (next[cheat.target]) delete next[cheat.target]; - else next[cheat.target] = true; - savePinnedTargets(pinnedStorageKey, next); - return next; - }); - }, - [pinnedStorageKey], - ); + const toggle = useCallback( + (cheat: CheatSchema) => { + setPinnedTargets((current) => { + const next = { ...current }; + if (next[cheat.target]) delete next[cheat.target]; + else next[cheat.target] = true; + saveStringSet(pinnedStorageKey, next); + return next; + }); + }, + [pinnedStorageKey], + ); - return { pinnedTargets, toggle }; + return { pinnedTargets, toggle }; } diff --git a/web-panel/src/trainer/presets/preset-storage.test.ts b/web-panel/src/trainer/presets/preset-storage.test.ts index bf5b6961..c981c3ea 100644 --- a/web-panel/src/trainer/presets/preset-storage.test.ts +++ b/web-panel/src/trainer/presets/preset-storage.test.ts @@ -1,31 +1,48 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { ECheatType, type CheatSchema } from '../../../protocol/messages'; +import { type CheatSchema, ECheatType } from '../../../protocol/messages'; import { capturePresetValues, loadPresets, savePresets } from './preset-storage'; const cheats: CheatSchema[] = [ - { uuid: 'toggle', target: 'god', type: ECheatType.Toggle, name: 'God', category: 'player', args: {} }, - { uuid: 'button', target: 'apply', type: ECheatType.Button, name: 'Apply', category: 'player', args: {} }, + { + uuid: 'toggle', + target: 'god', + type: ECheatType.Toggle, + name: 'God', + category: 'player', + args: {}, + }, + { + uuid: 'button', + target: 'apply', + type: ECheatType.Button, + name: 'Apply', + category: 'player', + args: {}, + }, ]; describe('preset storage', () => { - beforeEach(() => localStorage.clear()); + beforeEach(() => localStorage.clear()); - it('captures persistent values and excludes one-shot actions', () => { - expect(capturePresetValues(cheats, { god: true, apply: 1 })).toEqual({ god: true }); - }); + it('captures persistent values and excludes one-shot actions', () => { + expect(capturePresetValues(cheats, { god: true, apply: 1 })).toEqual({ god: true }); + }); - it('revives valid presets and ignores malformed entries', () => { - localStorage.setItem('presets', JSON.stringify([ - { id: 'valid', name: 'Valid', createdAt: 'now', values: { god: true } }, - { id: 'invalid', values: {} }, - ])); + it('revives valid presets and ignores malformed entries', () => { + localStorage.setItem( + 'presets', + JSON.stringify([ + { id: 'valid', name: 'Valid', createdAt: 'now', values: { god: true } }, + { id: 'invalid', values: {} }, + ]), + ); - expect(loadPresets('presets')).toEqual([ - { id: 'valid', name: 'Valid', createdAt: 'now', values: { god: true } }, - ]); + expect(loadPresets('presets')).toEqual([ + { id: 'valid', name: 'Valid', createdAt: 'now', values: { god: true } }, + ]); - savePresets('presets', []); - expect(localStorage.getItem('presets')).toBeNull(); - }); + savePresets('presets', []); + expect(localStorage.getItem('presets')).toBeNull(); + }); }); diff --git a/web-panel/src/trainer/presets/preset-storage.ts b/web-panel/src/trainer/presets/preset-storage.ts index 583a4481..a0930ee2 100644 --- a/web-panel/src/trainer/presets/preset-storage.ts +++ b/web-panel/src/trainer/presets/preset-storage.ts @@ -1,92 +1,100 @@ -import { ECheatType, type CheatSchema, type TrainerSummary } from '../../../protocol/messages'; +import { type CheatSchema, ECheatType, type TrainerSummary } from '../../../protocol/messages'; import { getTrainerStorageId, loadJson, saveJson } from '../../shared/storage'; export type RemotePreset = { - id: string; - name: string; - values: Record; - createdAt: string; + id: string; + name: string; + values: Record; + createdAt: string; }; const STORAGE_KEY_PREFIX = 'wand-remote.presets.v1:'; const PRESET_COMPATIBLE_TYPES = new Set([ - ECheatType.Toggle, - ECheatType.Slider, - ECheatType.Number, - ECheatType.Selection, - ECheatType.Scalar, - ECheatType.Incremental, + ECheatType.Toggle, + ECheatType.Slider, + ECheatType.Number, + ECheatType.Selection, + ECheatType.Scalar, + ECheatType.Incremental, ]); export function getPresetStorageKey(trainer: TrainerSummary | null): string { - return `${STORAGE_KEY_PREFIX}${getTrainerStorageId(trainer) ?? 'global'}`; + return `${STORAGE_KEY_PREFIX}${getTrainerStorageId(trainer) ?? 'global'}`; } export function loadPresets(storageKey: string): RemotePreset[] { - return loadJson( - storageKey, - (raw) => (Array.isArray(raw) ? raw.map(normalizePreset).filter((preset): preset is RemotePreset => Boolean(preset)) : null), - [], - ); + return loadJson( + storageKey, + (raw) => + Array.isArray(raw) + ? raw + .map(normalizePreset) + .filter((preset): preset is RemotePreset => Boolean(preset)) + : null, + [], + ); } export function savePresets(storageKey: string, presets: RemotePreset[]): boolean { - return saveJson(storageKey, presets, (value) => Array.isArray(value) && value.length === 0); + return saveJson(storageKey, presets, (value) => Array.isArray(value) && value.length === 0); } export function createPreset(name: string, values: Record): RemotePreset { - return { - id: createPresetId(), - name: name.trim(), - values, - createdAt: new Date().toISOString(), - }; + return { + id: createPresetId(), + name: name.trim(), + values, + createdAt: new Date().toISOString(), + }; } -export function capturePresetValues(cheats: CheatSchema[], currentValues: Record): Record { - const values: Record = {}; - for (const cheat of cheats) { - if (!PRESET_COMPATIBLE_TYPES.has(cheat.type)) { - continue; +export function capturePresetValues( + cheats: CheatSchema[], + currentValues: Record, +): Record { + const values: Record = {}; + for (const cheat of cheats) { + if (!PRESET_COMPATIBLE_TYPES.has(cheat.type)) { + continue; + } + + if (!(cheat.target in currentValues)) { + continue; + } + + values[cheat.target] = currentValues[cheat.target]; } - if (!(cheat.target in currentValues)) { - continue; - } - - values[cheat.target] = currentValues[cheat.target]; - } - - return values; + return values; } function normalizePreset(value: unknown): RemotePreset | null { - if (!isRecord(value) || !isRecord(value.values)) { - return null; - } - - const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : createPresetId(); - const name = typeof value.name === 'string' && value.name.trim() ? value.name.trim() : null; - if (!name) { - return null; - } - - return { - id, - name, - values: value.values, - createdAt: typeof value.createdAt === 'string' ? value.createdAt : new Date().toISOString(), - }; + if (!isRecord(value) || !isRecord(value.values)) { + return null; + } + + const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : createPresetId(); + const name = typeof value.name === 'string' && value.name.trim() ? value.name.trim() : null; + if (!name) { + return null; + } + + return { + id, + name, + values: value.values, + createdAt: typeof value.createdAt === 'string' ? value.createdAt : new Date().toISOString(), + }; } function createPresetId(): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID(); - } + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } - return `preset_${Date.now().toString(36)}`; + return `preset_${Date.now().toString(36)}`; } function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + return typeof value === 'object' && value !== null; } diff --git a/web-panel/src/trainer/presets/use-presets.ts b/web-panel/src/trainer/presets/use-presets.ts index 39d3877d..2b1f2602 100644 --- a/web-panel/src/trainer/presets/use-presets.ts +++ b/web-panel/src/trainer/presets/use-presets.ts @@ -1,57 +1,67 @@ import { useCallback, useEffect, useState } from 'react'; import type { TrainerMetaPayload } from '../../../protocol/messages'; -import { capturePresetValues, createPreset, loadPresets, savePresets, type RemotePreset } from './preset-storage'; +import { + capturePresetValues, + createPreset, + loadPresets, + type RemotePreset, + savePresets, +} from './preset-storage'; type PresetsParams = { - presetStorageKey: string; - trainerMeta: TrainerMetaPayload | null; - values: Record; - onError: (message: string) => void; + presetStorageKey: string; + trainerMeta: TrainerMetaPayload | null; + values: Record; + onError: (message: string) => void; }; export function usePresets({ presetStorageKey, trainerMeta, values, onError }: PresetsParams) { - const [presets, setPresets] = useState([]); - - useEffect(() => { - setPresets(loadPresets(presetStorageKey)); - }, [presetStorageKey]); - - const addPreset = useCallback( - (name: string): boolean => { - if (!trainerMeta) { - onError('No active trainer to save as a preset.'); - return false; - } - - const captured = capturePresetValues(trainerMeta.schema.cheats, values); - if (Object.keys(captured).length === 0) { - onError('There are no mod values to save yet.'); - return false; - } - - const next = [...presets, createPreset(name, captured)]; - if (!savePresets(presetStorageKey, next)) { - onError('Could not save the preset in this browser. Check site storage permissions and available space.'); - return false; - } - setPresets(next); - return true; - }, - [onError, presets, presetStorageKey, trainerMeta, values], - ); - - const deletePreset = useCallback( - (presetId: string) => { - const next = presets.filter((preset) => preset.id !== presetId); - if (!savePresets(presetStorageKey, next)) { - onError('Could not update presets in this browser. Check site storage permissions and available space.'); - return; - } - setPresets(next); - }, - [onError, presets, presetStorageKey], - ); - - return { presets, addPreset, deletePreset }; + const [presets, setPresets] = useState([]); + + useEffect(() => { + setPresets(loadPresets(presetStorageKey)); + }, [presetStorageKey]); + + const addPreset = useCallback( + (name: string): boolean => { + if (!trainerMeta) { + onError('No active trainer to save as a preset.'); + return false; + } + + const captured = capturePresetValues(trainerMeta.schema.cheats, values); + if (Object.keys(captured).length === 0) { + onError('There are no mod values to save yet.'); + return false; + } + + const next = [...presets, createPreset(name, captured)]; + if (!savePresets(presetStorageKey, next)) { + onError( + 'Could not save the preset in this browser. Check site storage permissions and available space.', + ); + return false; + } + setPresets(next); + return true; + }, + [onError, presets, presetStorageKey, trainerMeta, values], + ); + + const deletePreset = useCallback( + (presetId: string) => { + const next = presets.filter((preset) => preset.id !== presetId); + if (!savePresets(presetStorageKey, next)) { + onError( + 'Could not update presets in this browser. Check site storage permissions and available space.', + ); + return; + } + setPresets(next); + }, + [onError, presets, presetStorageKey], + ); + + return { presets, addPreset, deletePreset }; } diff --git a/web-panel/src/trainer/ui/CategoryIcon.tsx b/web-panel/src/trainer/ui/CategoryIcon.tsx index 482d1a20..78ceac80 100644 --- a/web-panel/src/trainer/ui/CategoryIcon.tsx +++ b/web-panel/src/trainer/ui/CategoryIcon.tsx @@ -1,25 +1,31 @@ import { Icon, type IconName } from '@/shared/ui/Icon'; const CATEGORY_ICONS: Record = { - challenge: 'flame', - character: 'user', - cheats: 'sparkles', - crafting: 'hammer', - enemies: 'heart-broken', - game: 'gamepad', - inventory: 'backpack', - items: 'package', - physics: 'atom', - pinned: 'bolt', - player: 'user', - resources: 'box', - stats: 'chart', - teleport: 'map-pin', - vehicles: 'car', - weapons: 'swords', - world: 'world', + challenge: 'flame', + character: 'user', + cheats: 'sparkles', + crafting: 'hammer', + enemies: 'heart-broken', + game: 'gamepad', + inventory: 'backpack', + items: 'package', + physics: 'atom', + pinned: 'bolt', + player: 'user', + resources: 'box', + stats: 'chart', + teleport: 'map-pin', + vehicles: 'car', + weapons: 'swords', + world: 'world', }; export function CategoryIcon({ category, className }: { category: string; className?: string }) { - return ; + return ( + + ); } diff --git a/web-panel/src/trainer/ui/CategorySection.tsx b/web-panel/src/trainer/ui/CategorySection.tsx index 86a5d911..100f0366 100644 --- a/web-panel/src/trainer/ui/CategorySection.tsx +++ b/web-panel/src/trainer/ui/CategorySection.tsx @@ -1,115 +1,154 @@ -import { memo, useEffect, useMemo, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; - +import { memo, useEffect, useMemo, useState } from 'react'; +import { cn, formatHumanLabel } from '@/shared/lib/ui'; import { Icon } from '@/shared/ui/Icon'; -import { cn } from '@/shared/lib/ui'; - -import type { CategoryGroup } from '../model/categories'; -import { CategoryIcon } from './CategoryIcon'; -import { CATEGORY_LABELS } from './category-labels'; import type { CheatSchema } from '../../../protocol/messages'; import { ECheatType } from '../../../protocol/messages'; +import type { CategoryGroup } from '../model/categories'; +import { CategoryIcon } from './CategoryIcon'; import { CheatTile } from './CheatTile'; +import { CATEGORY_LABELS } from './category-labels'; type CategorySectionProps = { - group: CategoryGroup; - values: Record; - pendingTargets: Record; - pinnedTargets: Record; - disabled: boolean; - openByDefault?: boolean; - forceOpen?: boolean; - onCheatChange: (cheat: CheatSchema, nextValue: unknown) => void; - onTogglePin: (cheat: CheatSchema) => void; + group: CategoryGroup; + values: Record; + pendingTargets: Record; + pinnedTargets: Record; + disabled: boolean; + openByDefault?: boolean; + forceOpen?: boolean; + onCheatChange: (cheat: CheatSchema, nextValue: unknown) => void; + onTogglePin: (cheat: CheatSchema) => void; }; const CategorySectionBase = ({ - group, - values, - pendingTargets, - pinnedTargets, - disabled, - openByDefault = true, - forceOpen = false, - onCheatChange, - onTogglePin, + group, + values, + pendingTargets, + pinnedTargets, + disabled, + openByDefault = true, + forceOpen = false, + onCheatChange, + onTogglePin, }: CategorySectionProps) => { - const { _ } = useLingui(); - const [open, setOpen] = useState(openByDefault); - const enabledCount = useMemo(() => getEnabledToggleCount(group.cheats, values), [group.cheats, values]); - const toggleCount = useMemo(() => getToggleCount(group.cheats), [group.cheats]); - const handleToggle = () => setOpen((current) => !current); + const { _ } = useLingui(); + const [open, setOpen] = useState(openByDefault); + const panelId = `category-panel-${group.id}`; + const { toggleCount, enabledCount } = useMemo( + () => countToggles(group.cheats, values), + [group.cheats, values], + ); + const handleToggle = () => setOpen((current) => !current); - const cheatCount = group.cheats.length; - const descriptor = CATEGORY_LABELS[group.id.toLowerCase()]; - const label = descriptor ? _(descriptor) : group.label; - const summary = - toggleCount > 0 ? _(msg`${cheatCount} mods · ${enabledCount}/${toggleCount} on`) : _(msg`${cheatCount} mods`); + const cheatCount = group.cheats.length; + const descriptor = CATEGORY_LABELS[group.id.toLowerCase()]; + const label = descriptor ? _(descriptor) : formatHumanLabel(group.id); + const summary = + toggleCount > 0 + ? _(msg`${cheatCount} mods · ${enabledCount}/${toggleCount} on`) + : _(msg`${cheatCount} mods`); - const cheatHandlers = useMemo( - () => - group.cheats.map((cheat) => ({ - onChange: (nextValue: unknown) => onCheatChange(cheat, nextValue), - onTogglePin: () => onTogglePin(cheat), - })), - [group.cheats, onCheatChange, onTogglePin], - ); + const cheatHandlers = useMemo( + () => + group.cheats.map((cheat) => ({ + onChange: (nextValue: unknown) => onCheatChange(cheat, nextValue), + onTogglePin: () => onTogglePin(cheat), + })), + [group.cheats, onCheatChange, onTogglePin], + ); - useEffect(() => { - if (forceOpen) { - setOpen(true); - } - }, [forceOpen]); + useEffect(() => { + if (forceOpen) { + setOpen(true); + } + }, [forceOpen]); - return ( -
- -
- {group.cheats.map((cheat, index) => ( - - ))} -
-
- ); + return ( +
+ +
+
+ {group.cheats.map((cheat, index) => ( + + ))} +
+
+
+ ); }; export const CategorySection = memo(CategorySectionBase, (prev, next) => { - if (prev.group !== next.group) return false; - if (prev.disabled !== next.disabled) return false; - if (prev.forceOpen !== next.forceOpen) return false; - for (const cheat of next.group.cheats) { - if (prev.values[cheat.target] !== next.values[cheat.target]) return false; - if (prev.pendingTargets[cheat.target] !== next.pendingTargets[cheat.target]) return false; - if (prev.pinnedTargets[cheat.target] !== next.pinnedTargets[cheat.target]) return false; - } - return true; + if (prev.group !== next.group) return false; + if (prev.disabled !== next.disabled) return false; + if (prev.forceOpen !== next.forceOpen) return false; + // Skipping these would keep stale handlers alive whenever the parent stops + // memoising them - a correctness guarantee should not rest on that assumption. + if (prev.onCheatChange !== next.onCheatChange) return false; + if (prev.onTogglePin !== next.onTogglePin) return false; + for (const cheat of next.group.cheats) { + if (prev.values[cheat.target] !== next.values[cheat.target]) return false; + if (prev.pendingTargets[cheat.target] !== next.pendingTargets[cheat.target]) return false; + if (prev.pinnedTargets[cheat.target] !== next.pinnedTargets[cheat.target]) return false; + } + return true; }); -function getEnabledToggleCount(cheats: CheatSchema[], values: Record): number { - return cheats.filter((cheat) => cheat.type === ECheatType.Toggle && Boolean(values[cheat.target])).length; -} - -function getToggleCount(cheats: CheatSchema[]): number { - return cheats.filter((cheat) => cheat.type === ECheatType.Toggle).length; +function countToggles( + cheats: CheatSchema[], + values: Record, +): { toggleCount: number; enabledCount: number } { + let toggleCount = 0; + let enabledCount = 0; + for (const cheat of cheats) { + if (cheat.type !== ECheatType.Toggle) continue; + toggleCount += 1; + if (values[cheat.target]) enabledCount += 1; + } + return { toggleCount, enabledCount }; } diff --git a/web-panel/src/trainer/ui/CheatTile.tsx b/web-panel/src/trainer/ui/CheatTile.tsx index 41864005..7cc40fae 100644 --- a/web-panel/src/trainer/ui/CheatTile.tsx +++ b/web-panel/src/trainer/ui/CheatTile.tsx @@ -1,168 +1,256 @@ -import { memo, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; - -import { Icon } from '@/shared/ui/Icon'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { memo, type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react'; import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; +import { IconButton } from '@/shared/ui/IconButton'; import type { CheatSchema } from '../../../protocol/messages'; import { ECheatType } from '../../../protocol/messages'; import { CheatControl } from '../controls/CheatControl'; type CheatTileProps = { - cheat: CheatSchema; - value: unknown; - pending: boolean; - disabled: boolean; - pinned: boolean; - first: boolean; - onChange: (nextValue: unknown) => void; - onTogglePin: () => void; + cheat: CheatSchema; + value: unknown; + pending: boolean; + disabled: boolean; + pinned: boolean; + first: boolean; + onChange: (nextValue: unknown) => void; + onTogglePin: () => void; }; const SWIPE_REVEAL = 80; const SWIPE_TRIGGER = 56; const SWIPE_DEAD_ZONE = 8; -const SWIPE_ANIMATION_MS = 220; - -const CheatTileBase = ({ cheat, value, pending, disabled, pinned, first, onChange, onTogglePin }: CheatTileProps) => { - const [offset, setOffset] = useState(0); - const [animating, setAnimating] = useState(false); - const [armed, setArmed] = useState(false); - const dragRef = useRef<{ id: number; startX: number; startY: number; locked: boolean | null } | null>(null); - - useEffect(() => { - setOffset(0); - setArmed(false); - }, [pinned]); - - const settle = (target: number) => { - setAnimating(true); - setOffset(target); - window.setTimeout(() => setAnimating(false), SWIPE_ANIMATION_MS); - }; - - const handlePointerDown = (event: ReactPointerEvent) => { - if (event.pointerType === 'mouse' && event.button !== 0) return; - if (isInteractiveTarget(event.target)) return; - dragRef.current = { id: event.pointerId, startX: event.clientX, startY: event.clientY, locked: null }; - setAnimating(false); - }; - - const handlePointerMove = (event: ReactPointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.id !== event.pointerId) return; - - const dx = event.clientX - drag.startX; - const dy = event.clientY - drag.startY; - - if (drag.locked === null) { - if (Math.abs(dx) < SWIPE_DEAD_ZONE && Math.abs(dy) < SWIPE_DEAD_ZONE) return; - drag.locked = Math.abs(dx) > Math.abs(dy) && dx < 0; - if (!drag.locked) { +const SWIPE_ANIMATION_MS = 200; + +const CheatTileBase = ({ + cheat, + value, + pending, + disabled, + pinned, + first, + onChange, + onTogglePin, +}: CheatTileProps) => { + const { _ } = useLingui(); + const [offset, setOffset] = useState(0); + const [animating, setAnimating] = useState(false); + const [armed, setArmed] = useState(false); + const dragRef = useRef<{ + id: number; + startX: number; + startY: number; + locked: boolean | null; + } | null>(null); + const settleTimerRef = useRef(null); + + useEffect(() => { + setOffset(0); + setArmed(false); + }, [pinned]); + + // Cleared on unmount: the tile unmounts on category change while the timer is pending. + useEffect( + () => () => { + if (settleTimerRef.current !== null) { + window.clearTimeout(settleTimerRef.current); + } + }, + [], + ); + + const settle = (target: number) => { + setAnimating(true); + setOffset(target); + if (settleTimerRef.current !== null) { + window.clearTimeout(settleTimerRef.current); + } + settleTimerRef.current = window.setTimeout(() => { + settleTimerRef.current = null; + setAnimating(false); + }, SWIPE_ANIMATION_MS); + }; + + const handlePointerDown = (event: ReactPointerEvent) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + if (isInteractiveTarget(event.target)) return; + dragRef.current = { + id: event.pointerId, + startX: event.clientX, + startY: event.clientY, + locked: null, + }; + setAnimating(false); + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.id !== event.pointerId) return; + + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + + if (drag.locked === null) { + if (Math.abs(dx) < SWIPE_DEAD_ZONE && Math.abs(dy) < SWIPE_DEAD_ZONE) return; + drag.locked = Math.abs(dx) > Math.abs(dy) && dx < 0; + if (!drag.locked) { + dragRef.current = null; + return; + } + event.currentTarget.setPointerCapture(event.pointerId); + } + + const next = clamp(dx, -SWIPE_REVEAL * 1.2, 0); + setOffset(next); + setArmed(-next >= SWIPE_TRIGGER); + }; + + const handlePointerEnd = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.id !== event.pointerId) return; dragRef.current = null; - return; - } - event.currentTarget.setPointerCapture(event.pointerId); - } - const next = clamp(dx, -SWIPE_REVEAL * 1.2, 0); - setOffset(next); - setArmed(-next >= SWIPE_TRIGGER); - }; + if (drag.locked !== true) { + return; + } - const handlePointerEnd = (event: ReactPointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.id !== event.pointerId) return; - dragRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } - if (drag.locked !== true) { - return; - } + const triggered = -offset >= SWIPE_TRIGGER; + settle(0); + setArmed(false); + if (triggered) { + onTogglePin(); + } + }; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } + const stacked = isStackedControl(cheat); + const showReveal = offset < 0; - const triggered = -offset >= SWIPE_TRIGGER; - settle(0); - setArmed(false); - if (triggered) { - onTogglePin(); - } - }; - - const stacked = isStackedControl(cheat); - const showReveal = offset < 0; - - return ( -
- {showReveal ? : null} -
-
-
-
-
-

{cheat.name}

- {pending ? : null} -
- {cheat.description ?

{cheat.description}

: null} -
- -
- {cheat.instructions ? ( -
- - {cheat.instructions} + return ( +
+ {showReveal ? : null} +
+
+
+
+
+

+ {cheat.name} +

+ {pending ? ( + + ) : null} + {/* The swipe gesture is pointer-only; this is the keyboard and screen-reader path. */} + +
+ {cheat.description ? ( +

+ {cheat.description} +

+ ) : null} +
+ +
+ {cheat.instructions ? ( +
+ + {cheat.instructions} +
+ ) : null} +
- ) : null}
-
-
- ); + ); }; export const CheatTile = memo(CheatTileBase); const PinReveal = ({ pinned, armed }: { pinned: boolean; armed: boolean }) => { - return ( -
- - - -
- ); + return ( +
+ + + +
+ ); }; function isStackedControl(cheat: CheatSchema): boolean { - if ( - cheat.type === ECheatType.Slider || - cheat.type === ECheatType.Scalar || - cheat.type === ECheatType.Number || - cheat.type === ECheatType.Incremental || - cheat.type === ECheatType.Button - ) { - return true; - } - - if (cheat.type === ECheatType.Selection) { - const optionCount = cheat.args.options?.length ?? 0; - return optionCount > 0; - } - - return false; + if ( + cheat.type === ECheatType.Slider || + cheat.type === ECheatType.Scalar || + cheat.type === ECheatType.Number || + cheat.type === ECheatType.Incremental || + cheat.type === ECheatType.Button + ) { + return true; + } + + if (cheat.type === ECheatType.Selection) { + const optionCount = cheat.args.options?.length ?? 0; + return optionCount > 0; + } + + return false; } function clamp(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max); + return Math.min(Math.max(value, min), max); } function isInteractiveTarget(target: EventTarget | null): boolean { - if (!(target instanceof Element)) return false; - return Boolean(target.closest('input, button, select, textarea, a, [role="slider"], [role="button"]')); + if (!(target instanceof Element)) return false; + return Boolean( + target.closest('input, button, select, textarea, a, [role="slider"], [role="button"]'), + ); } diff --git a/web-panel/src/trainer/ui/QuickActions.tsx b/web-panel/src/trainer/ui/QuickActions.tsx index 96b6efa3..54c50874 100644 --- a/web-panel/src/trainer/ui/QuickActions.tsx +++ b/web-panel/src/trainer/ui/QuickActions.tsx @@ -1,177 +1,223 @@ -import { useEffect, useRef, useState, type FormEvent } from 'react'; -import { createPortal } from 'react-dom'; import { msg } from '@lingui/core/macro'; -import { Trans } from '@lingui/react/macro'; import { useLingui } from '@lingui/react'; - -import { Icon } from '@/shared/ui/Icon'; +import { Trans } from '@lingui/react/macro'; +import { type FormEvent, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; +import { IconButton } from '@/shared/ui/IconButton'; import type { RemotePreset } from '../presets/preset-storage'; type QuickActionsProps = { - presets: RemotePreset[]; - onPanic: () => void; - onAddPreset: (name: string) => boolean; - onApplyPreset: (preset: RemotePreset) => void; - onDeletePreset: (presetId: string) => void; + presets: RemotePreset[]; + onPanic: () => void; + onAddPreset: (name: string) => boolean; + onApplyPreset: (preset: RemotePreset) => void; + onDeletePreset: (presetId: string) => void; }; -export const QuickActions = ({ presets, onPanic, onAddPreset, onApplyPreset, onDeletePreset }: QuickActionsProps) => { - const { _ } = useLingui(); - const [modalOpen, setModalOpen] = useState(false); - const [draftName, setDraftName] = useState(''); - - const handleOpenModal = () => { - setDraftName(''); - setModalOpen(true); - }; - - const handleCloseModal = () => setModalOpen(false); - - const handleSubmitPreset = (name: string): boolean => { - const saved = onAddPreset(name); - if (!saved) { - return false; - } - - setModalOpen(false); - return true; - }; - - return ( - <> -
- - {presets.map((preset) => ( - - ))} - -
- {modalOpen - ? createPortal( - , - document.body, - ) - : null} - - ); +export const QuickActions = ({ + presets, + onPanic, + onAddPreset, + onApplyPreset, + onDeletePreset, +}: QuickActionsProps) => { + const { _ } = useLingui(); + const [modalOpen, setModalOpen] = useState(false); + const [draftName, setDraftName] = useState(''); + + const handleOpenModal = () => { + setDraftName(''); + setModalOpen(true); + }; + + const handleCloseModal = () => setModalOpen(false); + + const handleSubmitPreset = (name: string): boolean => { + const saved = onAddPreset(name); + if (!saved) { + return false; + } + + setModalOpen(false); + return true; + }; + + return ( + <> +
+ + {presets.map((preset) => ( + + ))} + +
+ {modalOpen + ? createPortal( + , + document.body, + ) + : null} + + ); }; type ChipProps = { - icon: 'bolt' | 'plus'; - label: string; - variant: 'add' | 'danger'; - onClick: () => void; + icon: 'bolt' | 'plus'; + label: string; + variant: 'add' | 'danger'; + onClick: () => void; }; const Chip = ({ icon, label, variant, onClick }: ChipProps) => { - return ( - - ); + return ( + + ); }; type PresetChipProps = { - preset: RemotePreset; - onApply: (preset: RemotePreset) => void; - onDelete: (presetId: string) => void; + preset: RemotePreset; + onApply: (preset: RemotePreset) => void; + onDelete: (presetId: string) => void; }; const PresetChip = ({ preset, onApply, onDelete }: PresetChipProps) => { - const { _ } = useLingui(); - const handleApply = () => onApply(preset); - const handleDelete = () => onDelete(preset.id); - - return ( - - - - - ); + const { _ } = useLingui(); + const handleApply = () => onApply(preset); + const handleDelete = () => onDelete(preset.id); + + return ( + + + + + ); }; type PresetModalProps = { - draftName: string; - onClose: () => void; - onDraftNameChange: (name: string) => void; - onSubmit: (name: string) => boolean; + draftName: string; + onClose: () => void; + onDraftNameChange: (name: string) => void; + onSubmit: (name: string) => boolean; }; const PresetModal = ({ draftName, onClose, onDraftNameChange, onSubmit }: PresetModalProps) => { - const { _ } = useLingui(); - const inputRef = useRef(null); - const trimmedName = draftName.trim(); - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - const handleInput = (event: FormEvent) => onDraftNameChange(event.currentTarget.value); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - const nextName = trimmedName || _(msg`New preset`); - onSubmit(nextName); - }; - - return ( -
- -
- -
- - + const { _ } = useLingui(); + const inputRef = useRef(null); + const trimmedName = draftName.trim(); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleInput = (event: FormEvent) => + onDraftNameChange(event.currentTarget.value); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const nextName = trimmedName || _(msg`New preset`); + onSubmit(nextName); + }; + + return ( +
+ + +
+
- -
- ); + ); }; function getChipVariantClass(variant: ChipProps['variant']): string { - if (variant === 'danger') { - return 'border-red-400/30 bg-red-500/10 text-red-300 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]'; - } + if (variant === 'danger') { + return 'border-red-400/30 bg-red-500/10 text-red-300 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]'; + } - return 'border-white/10 bg-white/[0.055] text-(--deck-fg-2) shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]'; + return 'border-white/10 bg-white/5.5 text-(--deck-fg-2) shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]'; } diff --git a/web-panel/src/trainer/ui/TrainerHeader.tsx b/web-panel/src/trainer/ui/TrainerHeader.tsx index a2cfb501..d7ef4ce3 100644 --- a/web-panel/src/trainer/ui/TrainerHeader.tsx +++ b/web-panel/src/trainer/ui/TrainerHeader.tsx @@ -1,56 +1,74 @@ import { msg } from '@lingui/core/macro'; -import { Trans } from '@lingui/react/macro'; import { useLingui } from '@lingui/react'; - -import { Icon } from '@/shared/ui/Icon'; -import { cn } from '@/shared/lib/ui'; - +import { Trans } from '@lingui/react/macro'; import type { LibraryGame } from '@/library/model/games'; import { GameCover } from '@/library/ui/GameCover'; - -import { getTrainerDisplayName } from '../model/categories'; +import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; import type { TrainerSummary } from '../../../protocol/messages'; +import { getTrainerDisplayName } from '../model/categories'; type TrainerHeaderProps = { - trainer: TrainerSummary; - game: LibraryGame | null; - isPinned: boolean; - onPin: () => void; + trainer: TrainerSummary; + game: LibraryGame | null; + isPinned: boolean; + onPin: () => void; }; export const TrainerHeader = ({ trainer, game, isPinned, onPin }: TrainerHeaderProps) => { - const { _ } = useLingui(); + const { _ } = useLingui(); - return ( -
-
-
- {game ? : } -
-
- - Trainer Active -
-

{getTrainerDisplayName(trainer)}

-
- {game?.platform ?? 'Wand'} - {trainer.gameVersion ? · v{trainer.gameVersion} : null} - · #{trainer.trainerId} -
-
- -
-
- ); + return ( +
+
+
+ {game ? : } +
+
+ + + Trainer Active + +
+

+ {getTrainerDisplayName(trainer)} +

+
+ {game?.platform ?? 'Wand'} + {trainer.gameVersion ? ( + · v{trainer.gameVersion} + ) : null} + · #{trainer.trainerId} +
+
+ +
+
+ ); }; const FallbackCover = () => { - return ( -
-
-
WAND
-
- ); + return ( +
+
+
+ WAND +
+
+ ); }; diff --git a/web-panel/src/trainer/ui/category-labels.ts b/web-panel/src/trainer/ui/category-labels.ts index 6e71752d..900e92c3 100644 --- a/web-panel/src/trainer/ui/category-labels.ts +++ b/web-panel/src/trainer/ui/category-labels.ts @@ -2,21 +2,21 @@ import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; export const CATEGORY_LABELS: Record = { - challenge: msg`Challenge`, - character: msg`Character`, - cheats: msg`Cheats`, - crafting: msg`Crafting`, - enemies: msg`Enemies`, - game: msg`Game`, - inventory: msg`Inventory`, - items: msg`Items`, - physics: msg`Physics`, - pinned: msg`Pinned`, - player: msg`Player`, - resources: msg`Resources`, - stats: msg`Stats`, - teleport: msg`Teleport`, - vehicles: msg`Vehicles`, - weapons: msg`Weapons`, - world: msg`World`, + challenge: msg`Challenge`, + character: msg`Character`, + cheats: msg`Cheats`, + crafting: msg`Crafting`, + enemies: msg`Enemies`, + game: msg`Game`, + inventory: msg`Inventory`, + items: msg`Items`, + physics: msg`Physics`, + pinned: msg`Pinned`, + player: msg`Player`, + resources: msg`Resources`, + stats: msg`Stats`, + teleport: msg`Teleport`, + vehicles: msg`Vehicles`, + weapons: msg`Weapons`, + world: msg`World`, }; diff --git a/web-panel/tsconfig.app.json b/web-panel/tsconfig.app.json index 2aa77aa4..490d9d1f 100644 --- a/web-panel/tsconfig.app.json +++ b/web-panel/tsconfig.app.json @@ -3,15 +3,9 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2022", "useDefineForClassFields": true, - "lib": [ - "ES2022", - "DOM", - "DOM.Iterable" - ], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": [ - "vite/client" - ], + "types": ["vite/client"], "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", @@ -28,14 +22,9 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, - "baseUrl": ".", "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, - "include": [ - "src" - ] -} \ No newline at end of file + "include": ["src"] +} diff --git a/web-panel/tsconfig.json b/web-panel/tsconfig.json index efe44b14..7f43c52b 100644 --- a/web-panel/tsconfig.json +++ b/web-panel/tsconfig.json @@ -2,11 +2,7 @@ "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, - "lib": [ - "DOM", - "DOM.Iterable", - "ES2022" - ], + "lib": ["DOM", "DOM.Iterable", "ES2022"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, @@ -19,21 +15,10 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "types": [ - "vite/client", - "node" - ], - "baseUrl": ".", + "types": ["vite/client", "node"], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, - "include": [ - "src", - "protocol", - "vitest.config.ts", - "vite.config.ts" - ] + "include": ["src", "protocol", "vitest.config.ts", "vite.config.ts"] } diff --git a/web-panel/tsconfig.node.json b/web-panel/tsconfig.node.json index 8a67f62f..0cc4c356 100644 --- a/web-panel/tsconfig.node.json +++ b/web-panel/tsconfig.node.json @@ -1,26 +1,26 @@ { - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2023", - "lib": ["ES2023"], - "module": "ESNext", - "types": ["node"], - "skipLibCheck": true, + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] } diff --git a/web-panel/vite.config.ts b/web-panel/vite.config.ts index 21dae8c2..0a6cdeba 100644 --- a/web-panel/vite.config.ts +++ b/web-panel/vite.config.ts @@ -1,40 +1,40 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import tailwindcss from '@tailwindcss/vite'; -import { lingui } from '@lingui/vite-plugin'; import { fileURLToPath, URL } from 'node:url'; +import { lingui } from '@lingui/vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [ - react({ babel: { plugins: ['@lingui/babel-plugin-lingui-macro'] } }), - lingui(), - tailwindcss(), - ], - base: './', - resolve: { - alias: [ - { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }, - { find: 'react-dom/client', replacement: 'preact/compat/client' }, - { find: 'react-dom', replacement: 'preact/compat' }, - { find: 'react/jsx-runtime', replacement: 'preact/jsx-runtime' }, - { find: 'react/jsx-dev-runtime', replacement: 'preact/jsx-dev-runtime' }, - { find: 'react', replacement: 'preact/compat' }, + plugins: [ + react({ babel: { plugins: ['@lingui/babel-plugin-lingui-macro'] } }), + lingui(), + tailwindcss(), ], - }, - server: { - host: '127.0.0.1', - port: 4173, - strictPort: true, - }, - preview: { - host: '127.0.0.1', - port: 4173, - strictPort: true, - }, - build: { - outDir: 'dist', - assetsDir: 'assets', - target: 'es2020', - sourcemap: false, - }, -}); \ No newline at end of file + base: './', + resolve: { + alias: [ + { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }, + { find: 'react-dom/client', replacement: 'preact/compat/client' }, + { find: 'react-dom', replacement: 'preact/compat' }, + { find: 'react/jsx-runtime', replacement: 'preact/jsx-runtime' }, + { find: 'react/jsx-dev-runtime', replacement: 'preact/jsx-dev-runtime' }, + { find: 'react', replacement: 'preact/compat' }, + ], + }, + server: { + host: '127.0.0.1', + port: 4173, + strictPort: true, + }, + preview: { + host: '127.0.0.1', + port: 4173, + strictPort: true, + }, + build: { + outDir: 'dist', + assetsDir: 'assets', + target: 'es2020', + sourcemap: false, + }, +}); diff --git a/web-panel/vitest.config.ts b/web-panel/vitest.config.ts index a123f288..96cd7293 100644 --- a/web-panel/vitest.config.ts +++ b/web-panel/vitest.config.ts @@ -3,17 +3,17 @@ import { defineConfig, mergeConfig } from 'vitest/config'; import viteConfig from './vite.config'; export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'jsdom', - restoreMocks: true, - alias: { - 'react-dom/test-utils': 'preact/test-utils', - }, - // Inline @lingui/react so its bare `react` import resolves to preact/compat - // via the alias above instead of pulling in a second (real) React copy. - server: { deps: { inline: [/@lingui\/react/] } }, - }, - }), + viteConfig, + defineConfig({ + test: { + environment: 'jsdom', + restoreMocks: true, + alias: { + 'react-dom/test-utils': 'preact/test-utils', + }, + // Inline @lingui/react so its bare `react` import resolves to preact/compat + // via the alias above instead of pulling in a second (real) React copy. + server: { deps: { inline: [/@lingui\/react/] } }, + }, + }), );