mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-29 22:01:18 +00:00
Compare commits
9 Commits
1.0.9.4
...
2.0.0.0-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b83750bf2 | |||
| 43e898c66c | |||
| f2e88e9247 | |||
| 5d1aa69a97 | |||
| e04e313fa3 | |||
| 572b61ac25 | |||
| 6716da5c80 | |||
| 20956c3228 | |||
| f798714f8d |
@@ -2,6 +2,9 @@ name: Build executable
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -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, '-') }}
|
||||
|
||||
@@ -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 `<service_name>` placeholder) and `setAccountLanguage` (`BuildSetAccountLanguagePatch` PatchFactory — captures the real param names + the original `post("/v3/account/language",{...})` expr and wraps `.then`). A fourth patch (`setAccountReducer`) rewrites the `ACTION_SET_ACCOUNT` store reducer so any account write (periodic `refreshAccount`, push/profile updates, etc.) keeps Pro even when it bypasses those API methods. Pro is `am(account) = !!account.subscription` (flags/512 are irrelevant). `setAccountLanguage` is the one the original two patches missed, which is why Pro dropped on language change. If a future Wand build changes these method bodies, re-derive the regexes against the live `app-*.bundle.js` (do NOT trust `.source/new` — it is a different version).
|
||||
- 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/<name>.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
|
||||
|
||||
|
||||
@@ -73,22 +73,26 @@ namespace AsarSharp
|
||||
filesystem.InsertFile(filename, shouldUnpack, file, placeholder);
|
||||
break;
|
||||
case FileType.Link:
|
||||
throw new NotImplementedException();
|
||||
throw new NotSupportedException($"Packing symlinks is not supported: '{filename}'");
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldUnpackPath(string relativePath)
|
||||
/// <summary>
|
||||
/// Matches the directory path (relative to the archive root) against the unpack regex.
|
||||
/// </summary>
|
||||
private bool ShouldUnpackPath(string relativeParentPath)
|
||||
{
|
||||
return _options?.Unpack?.IsMatch(relativePath) == true;
|
||||
return _options?.Unpack?.IsMatch(relativeParentPath) == true;
|
||||
}
|
||||
|
||||
private void InsertsDone(Filesystem filesystem, List<Disk.BasicFileInfo> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,13 +159,6 @@ namespace AsarSharp
|
||||
FilesystemEntry file, HashSet<string> dirCache)
|
||||
{
|
||||
var linkSrcPath = Extensions.GetDirectoryName(Path.Combine(dest, file.Link));
|
||||
var linkDestPath = Extensions.GetDirectoryName(destFilename);
|
||||
var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath);
|
||||
|
||||
try { File.Delete(destFilename); }
|
||||
catch { /* ignore — failing to remove an existing link is non-fatal */ }
|
||||
|
||||
var linkTo = Path.Combine(relativeLinkPath, Path.GetFileName(file.Link));
|
||||
|
||||
if (!Extensions.IsPathInside(dest, linkSrcPath))
|
||||
{
|
||||
@@ -173,6 +166,12 @@ namespace AsarSharp
|
||||
$"{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 @@ namespace AsarSharp
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ namespace AsarSharp.AsarFileSystem
|
||||
public static class Disk
|
||||
{
|
||||
private const int StreamBufferSize = 1024 * 1024;
|
||||
private static readonly ConcurrentDictionary<string, Filesystem> _filesystemCache =
|
||||
new ConcurrentDictionary<string, Filesystem>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public class ArchiveHeader
|
||||
{
|
||||
@@ -25,7 +23,6 @@ namespace AsarSharp.AsarFileSystem
|
||||
public class FilesystemFilesAndLinks
|
||||
{
|
||||
public List<BasicFileInfo> Files { get; set; } = new List<BasicFileInfo>();
|
||||
public List<BasicFileInfo> Links { get; set; } = new List<BasicFileInfo>();
|
||||
}
|
||||
|
||||
public class BasicFileInfo
|
||||
@@ -42,14 +39,14 @@ namespace AsarSharp.AsarFileSystem
|
||||
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 @@ namespace AsarSharp.AsarFileSystem
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
@@ -192,6 +155,18 @@ namespace AsarSharp.AsarFileSystem
|
||||
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);
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace AsarSharp.AsarFileSystem
|
||||
_headerSize = headerSize;
|
||||
}
|
||||
|
||||
public FilesystemEntry SearchNodeFromDirectory(string p)
|
||||
public FilesystemEntry SearchNodeFromDirectory(string p, bool create = true)
|
||||
{
|
||||
FilesystemEntry json = _header;
|
||||
|
||||
@@ -59,12 +59,31 @@ namespace AsarSharp.AsarFileSystem
|
||||
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<string, FilesystemEntry>(StringComparer.Ordinal);
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!json.Files.TryGetValue(seg, out var child))
|
||||
{
|
||||
child = new FilesystemEntry { Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal) };
|
||||
json.Files[seg] = child;
|
||||
if (create)
|
||||
{
|
||||
child = new FilesystemEntry { Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal) };
|
||||
json.Files[seg] = child;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
json = child;
|
||||
start = end + 1;
|
||||
@@ -81,7 +100,7 @@ namespace AsarSharp.AsarFileSystem
|
||||
|
||||
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<string, FilesystemEntry>(StringComparer.Ordinal);
|
||||
@@ -111,18 +130,23 @@ namespace AsarSharp.AsarFileSystem
|
||||
}
|
||||
}
|
||||
|
||||
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 @@ namespace AsarSharp.AsarFileSystem
|
||||
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 @@ namespace AsarSharp.AsarFileSystem
|
||||
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;
|
||||
|
||||
@@ -9,13 +9,6 @@ namespace AsarSharp.AsarFileSystem
|
||||
{
|
||||
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 @@ namespace AsarSharp.AsarFileSystem
|
||||
}
|
||||
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 @@ namespace AsarSharp.AsarFileSystem
|
||||
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 @@ namespace AsarSharp.AsarFileSystem
|
||||
{
|
||||
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 @@ namespace AsarSharp.AsarFileSystem
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using AsarSharp.Utils;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AsarSharp.Integrity
|
||||
@@ -60,7 +61,9 @@ namespace AsarSharp.Integrity
|
||||
var blockHashes = new List<string>(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);
|
||||
|
||||
@@ -28,16 +28,18 @@ namespace AsarSharp.PickleTools
|
||||
{
|
||||
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 @@ namespace AsarSharp.PickleTools
|
||||
}
|
||||
|
||||
|
||||
public bool WriteBool(bool value) => WriteInt(value ? 1 : 0);
|
||||
|
||||
|
||||
public bool WriteInt(int value)
|
||||
{
|
||||
@@ -121,74 +123,7 @@ namespace AsarSharp.PickleTools
|
||||
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 @@ namespace AsarSharp.PickleTools
|
||||
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 @@ namespace AsarSharp.PickleTools
|
||||
_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
|
||||
|
||||
@@ -18,10 +18,7 @@ namespace AsarSharp.PickleTools
|
||||
_endIndex = pickle.GetPayloadSize();
|
||||
}
|
||||
|
||||
public bool ReadBool()
|
||||
{
|
||||
return ReadInt() != 0;
|
||||
}
|
||||
|
||||
|
||||
public int ReadInt()
|
||||
{
|
||||
@@ -33,25 +30,7 @@ namespace AsarSharp.PickleTools
|
||||
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 @@ namespace AsarSharp.PickleTools
|
||||
|
||||
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}");
|
||||
|
||||
@@ -5,8 +5,30 @@ using System.Text;
|
||||
|
||||
namespace AsarSharp.Utils
|
||||
{
|
||||
internal static class Extensions
|
||||
public static class Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Fills <paramref name="count"/> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute path relative to <paramref name="relativeTo"/>.
|
||||
/// Fast common-case (path is inside relativeTo): plain prefix-strip.
|
||||
@@ -170,19 +192,7 @@ namespace AsarSharp.Utils
|
||||
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 +200,41 @@ namespace AsarSharp.Utils
|
||||
{
|
||||
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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
-4
@@ -3,16 +3,67 @@
|
||||
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
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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
|
||||

|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📜 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.
|
||||
|
||||
---
|
||||
|
||||
[](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date)
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
<FontFamily x:Key="Inter" >pack://application:,,,/Style/#Inter 18pt 18pt</FontFamily>
|
||||
|
||||
<converters:ToVisibilityConverter x:Key="ToVisibilityConverter"/>
|
||||
<converters:ToVisibilityInvertedConverter x:Key="ToVisibilityInvertedConverter"/>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using WandEnhancer.Models;
|
||||
|
||||
namespace WandEnhancer
|
||||
{
|
||||
@@ -8,36 +7,15 @@ namespace WandEnhancer
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,29 +18,7 @@ namespace WandEnhancer.Converters
|
||||
|
||||
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)
|
||||
|
||||
@@ -9,10 +9,4 @@ namespace WandEnhancer.Converters
|
||||
{ }
|
||||
}
|
||||
|
||||
internal sealed class ToVisibilityInvertedConverter : BaseBooleanConverter<Visibility>
|
||||
{
|
||||
public ToVisibilityInvertedConverter() :
|
||||
base(Visibility.Collapsed, Visibility.Visible)
|
||||
{ }
|
||||
}
|
||||
}
|
||||
+185
-168
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -18,6 +18,8 @@ namespace WandEnhancer.Core
|
||||
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 @@ namespace WandEnhancer.Core
|
||||
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 @@ namespace WandEnhancer.Core
|
||||
private readonly WeModConfig _weModConfig;
|
||||
private readonly Action<string, ELogType> _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;
|
||||
|
||||
/// <summary>For <see cref="Restore"/>, which needs the install paths but no patch selection.</summary>
|
||||
public Enhancer(WeModConfig weModConfig, Action<string, ELogType> logger)
|
||||
: this(weModConfig, logger, null)
|
||||
{
|
||||
}
|
||||
|
||||
public Enhancer(WeModConfig weModConfig, Action<string, ELogType> 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)
|
||||
|
||||
/// <summary>
|
||||
/// Both halves of the backup must exist. Accepting either one on its own reported a
|
||||
/// half-written backup as patched, which blocked patching while <see cref="Restore"/>
|
||||
/// refused to run - leaving the user with no way forward.
|
||||
/// </summary>
|
||||
public static bool IsPatched(string rootDirectory)
|
||||
{
|
||||
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;
|
||||
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 @@ namespace WandEnhancer.Core
|
||||
{
|
||||
throw new Exception("[ENHANCER] No app bundle found");
|
||||
}
|
||||
|
||||
|
||||
var remainingPatches = new HashSet<EPatchType>(_config.PatchTypes);
|
||||
var enhancerConfig = EnhancerConfig.GetInstance();
|
||||
|
||||
@@ -144,20 +99,22 @@ namespace WandEnhancer.Core
|
||||
{
|
||||
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,11 +126,27 @@ namespace WandEnhancer.Core
|
||||
File.WriteAllText(item, data);
|
||||
}
|
||||
}
|
||||
|
||||
if(remainingPatches.Count > 0)
|
||||
|
||||
ReportUnappliedPatches(remainingPatches, enhancerConfig);
|
||||
}
|
||||
|
||||
private void ReportUnappliedPatches(IEnumerable<EPatchType> remainingPatches, Dictionary<EPatchType, EnhancerConfig.PatchEntry[]> enhancerConfig)
|
||||
{
|
||||
var unapplied = remainingPatches
|
||||
.SelectMany(patchType => enhancerConfig[patchType]
|
||||
.Where(patch => !patch.Applied)
|
||||
.Select(patch => new { Label = JavaScriptPatchApplier.FormatLabel(patchType, patch), Patch = patch }))
|
||||
.ToList();
|
||||
|
||||
foreach (var skipped in unapplied.Where(entry => entry.Patch.IsResolved))
|
||||
{
|
||||
var failedPatches = string.Join(", ", remainingPatches.Select(p => p.ToString()));
|
||||
throw new Exception($"[ENHANCER] Failed to apply patches: {failedPatches}. The version may not be supported.");
|
||||
_logger($"[ENHANCER] [{skipped.Label}] Capability not present, skipping", ELogType.Info);
|
||||
}
|
||||
|
||||
var failed = unapplied.Where(entry => !entry.Patch.IsResolved).Select(entry => entry.Label).ToList();
|
||||
if (failed.Count > 0)
|
||||
{
|
||||
throw new Exception($"[ENHANCER] Failed to apply patches: {string.Join(", ", failed)}. The version may not be supported.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,44 +160,9 @@ namespace WandEnhancer.Core
|
||||
|
||||
private static bool CouldFileContainRemainingPatch(string filePath, IEnumerable<EPatchType> remainingPatches, Dictionary<EPatchType, EnhancerConfig.PatchEntry[]> enhancerConfig)
|
||||
{
|
||||
foreach (var patchType in remainingPatches)
|
||||
{
|
||||
foreach (var patchEntry in enhancerConfig[patchType])
|
||||
{
|
||||
if (patchEntry.Applied)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CanSearchPatchInFile(filePath, patchEntry))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CanSearchPatchInFile(string filePath, EnhancerConfig.PatchEntry patch)
|
||||
{
|
||||
if (patch.CandidateFileNames == null || patch.CandidateFileNames.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string fileName = Path.GetFileName(filePath);
|
||||
return patch.CandidateFileNames.Any(candidate => fileName.Equals(candidate, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool ContainsSearchHint(string source, string[] searchHints)
|
||||
{
|
||||
if (searchHints == null || searchHints.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return searchHints.Any(searchHint => source.IndexOf(searchHint, StringComparison.Ordinal) >= 0);
|
||||
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 @@ namespace WandEnhancer.Core
|
||||
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 @@ namespace WandEnhancer.Core
|
||||
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,7 +275,7 @@ namespace WandEnhancer.Core
|
||||
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)));
|
||||
copied++;
|
||||
@@ -372,11 +284,6 @@ namespace WandEnhancer.Core
|
||||
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 @@ namespace WandEnhancer.Core
|
||||
|
||||
if (CopyEmbeddedDirectory(EmbeddedRemotePanelDistPrefix, targetRoot) == 0)
|
||||
{
|
||||
CopyDirectory(FindWorkspacePath(WebPanelDirectoryName, WebPanelDistDirectoryName), targetRoot);
|
||||
AsarSharp.Utils.Extensions.CopyDirectory(FindWorkspacePath(WebPanelDirectoryName, WebPanelDistDirectoryName), targetRoot);
|
||||
}
|
||||
|
||||
if (!File.Exists(targetBridgePath))
|
||||
@@ -418,25 +325,79 @@ namespace WandEnhancer.Core
|
||||
_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))
|
||||
{
|
||||
dll.CopyTo(fileStream);
|
||||
return;
|
||||
}
|
||||
|
||||
if (File.Exists(stubPath) && !File.Exists(stubBackup))
|
||||
{
|
||||
File.Copy(stubPath, stubBackup);
|
||||
}
|
||||
|
||||
File.Copy(self, stubPath, true);
|
||||
_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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the patch selection saved next to the launcher, or null when absent or unreadable.</summary>
|
||||
public static PatchConfig LoadAutoPatchConfig(string launcherDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(launcherDirectory, Constants.AutoPatchConfigFileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Newtonsoft.Json.JsonConvert.DeserializeObject<PatchConfig>(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);
|
||||
@@ -451,7 +412,7 @@ namespace WandEnhancer.Core
|
||||
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,14 +422,14 @@ namespace WandEnhancer.Core
|
||||
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");
|
||||
}
|
||||
@@ -480,9 +441,9 @@ namespace WandEnhancer.Core
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -495,12 +456,68 @@ namespace WandEnhancer.Core
|
||||
}
|
||||
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();
|
||||
|
||||
|
||||
DeployLauncher();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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);
|
||||
File.Copy(_backupPath, _asarPath, true);
|
||||
|
||||
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))
|
||||
{
|
||||
File.Copy(stubBackup, stubPath, true);
|
||||
File.Delete(stubBackup);
|
||||
}
|
||||
|
||||
string autoPatchConfig = Path.Combine(squirrelRoot, Constants.AutoPatchConfigFileName);
|
||||
if (File.Exists(autoPatchConfig))
|
||||
{
|
||||
File.Delete(autoPatchConfig);
|
||||
}
|
||||
|
||||
File.Delete(_backupPath);
|
||||
Directory.Delete(_unpackedBackupPath, true);
|
||||
_logger("[ENHANCER] Backup restored successfully.", ELogType.Success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+274
-175
@@ -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
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class EnhancerConfig
|
||||
{
|
||||
public class ResolveContext
|
||||
{
|
||||
public string Placeholder { get; set; }
|
||||
public Func<string, string> Handler { get; set; }
|
||||
}
|
||||
/// <summary>Locates the edits a patch must make, or null when the anchor is absent from this file.</summary>
|
||||
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<Match, string> 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}");
|
||||
}
|
||||
/// <summary>Marks the patch optional: builds without these strings lack the feature entirely.</summary>
|
||||
public string[] CapabilityHints { get; set; }
|
||||
|
||||
return group.Value;
|
||||
}
|
||||
public bool Applied { get; set; }
|
||||
public bool CapabilityDetected { get; set; }
|
||||
|
||||
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);
|
||||
}
|
||||
public bool IsOptional => CapabilityHints != null && CapabilityHints.Length > 0;
|
||||
|
||||
private static string BuildSetAccountLanguagePatch(Match match)
|
||||
{
|
||||
var parameters = RequireGroup(match, "params", "setAccountLanguage");
|
||||
var expr = RequireGroup(match, "expr", "setAccountLanguage");
|
||||
return $"setAccountLanguage({parameters}){{return ({expr}).then(response=>{{response&&\"object\"==typeof response&&(response.subscription={{period:\"yearly\",state:\"active\"}});return response;}})}}";
|
||||
}
|
||||
|
||||
private static string BuildSetAccountReducerPatch(Match match)
|
||||
{
|
||||
var decl = RequireGroup(match, "decl", "setAccountReducer");
|
||||
var fn = RequireGroup(match, "fn", "setAccountReducer");
|
||||
var parameters = RequireGroup(match, "params", "setAccountReducer");
|
||||
var state = RequireGroup(match, "state", "setAccountReducer");
|
||||
var account = RequireGroup(match, "account", "setAccountReducer");
|
||||
return
|
||||
$"const {decl}=\"ACTION_SET_ACCOUNT\";function {fn}({parameters}){{const a={account}&&\"object\"==typeof {account}?{{...{account},subscription:{{period:\"yearly\",state:\"active\"}}}}:{account};return{{...{state},account:a}}}}";
|
||||
}
|
||||
|
||||
private static string BuildRemoteBridgeResetPatch(Match match)
|
||||
{
|
||||
var source = match.Value;
|
||||
var method = RequireGroup(match, "method", "remoteBridgeReset");
|
||||
var disposableField = RequirePattern(source, @"this\.(?<disposable>#[\w$]+)\s*&&\s*\(\s*this\.\k<disposable>\.dispose\(\)", "disposable", "remoteBridgeReset");
|
||||
var instanceField = RequirePattern(source, @"this\.(?<instance>#[\w$]+)\s*=\s*Date\.now\(\)\.toString\(\)", "instance", "remoteBridgeReset");
|
||||
var trainerIdField = RequirePattern(source, @"Date\.now\(\)\.toString\(\)\s*\)?\s*,\s*\(?\s*this\.(?<trainerId>#[\w$]+)\s*=\s*null", "trainerId", "remoteBridgeReset");
|
||||
var supportedVersionsField = RequirePattern(source, @"this\.(?<versions>#[\w$]+)\s*=\s*\[\]", "versions", "remoteBridgeReset");
|
||||
var trainerField = RequirePattern(source, @"this\.(?<versions>#[\w$]+)\s*=\s*\[\]\s*\)?\s*,\s*\(?\s*this\.(?<trainer>#[\w$]+)\s*=\s*null", "trainer", "remoteBridgeReset");
|
||||
|
||||
return $"{method}(){{this.{disposableField}&&(this.{disposableField}.dispose(),this.{disposableField}=null),this.{instanceField}=Date.now().toString(),this.{trainerIdField}=null,this.{supportedVersionsField}=[],this.{trainerField}=null,this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null)}}";
|
||||
}
|
||||
|
||||
private static string BuildRemoteBridgeSyncSnapshotPatch(Match match)
|
||||
{
|
||||
var source = match.Value;
|
||||
var method = RequireGroup(match, "method", "remoteBridgeSyncSnapshot");
|
||||
var statusAlias = RequirePattern(source, @"this\.status\s*===\s*(?<value>[\w$]+)\.Connected", "value", "remoteBridgeSyncSnapshot");
|
||||
var trainerField = RequirePattern(source, @"this\.(?<trainer>#[\w$]+)\?\.\s*getMetadata\s*\(\s*(?<metadata>[\w$]+\.[\w$]+)\s*\)\?\.\s*gameVersion", "trainer", "remoteBridgeSyncSnapshot");
|
||||
var metadataExport = RequirePattern(source, @"this\.(?<trainer>#[\w$]+)\?\.\s*getMetadata\s*\(\s*(?<metadata>[\w$]+\.[\w$]+)\s*\)\?\.\s*gameVersion", "metadata", "remoteBridgeSyncSnapshot");
|
||||
var notesField = RequirePattern(source, @"this\.(?<notes>#[\w$]+)\s*\[\s*this\.(?<trainerId>#[\w$]+)\s*\?\?\s*""""\s*\]", "notes", "remoteBridgeSyncSnapshot");
|
||||
var trainerIdField = RequirePattern(source, @"this\.(?<notes>#[\w$]+)\s*\[\s*this\.(?<trainerId>#[\w$]+)\s*\?\?\s*""""\s*\]", "trainerId", "remoteBridgeSyncSnapshot");
|
||||
var gameField = RequirePattern(source, @"this\.(?<game>#[\w$]+)\s*&&.*?getPreferredInstallationInfo\s*\(\s*this\.\k<game>\s*\)", "game", "remoteBridgeSyncSnapshot");
|
||||
var installationField = RequirePattern(source, @"this\.(?<game>#[\w$]+)\s*&&.*?this\.(?<installation>#[\w$]+)\.getPreferredInstallationInfo\s*\(\s*this\.\k<game>\s*\)", "installation", "remoteBridgeSyncSnapshot");
|
||||
var supportedVersionsField = RequirePattern(source, @"!\s*this\.(?<versions>#[\w$]+)\.includes\s*\(\s*[\w$]+\.version\s*\)", "versions", "remoteBridgeSyncSnapshot");
|
||||
var remoteChannelField = RequirePattern(source, @"this\.(?<remote>#[\w$]+)\?\.\s*send\s*\(\s*""client-state""", "remote", "remoteBridgeSyncSnapshot");
|
||||
var valuesMethod = RequirePattern(source, @"values\s*:\s*this\.(?<values>#[\w$]+)\s*\(\s*\)", "values", "remoteBridgeSyncSnapshot");
|
||||
var instanceField = RequirePattern(source, @"instanceId\s*:\s*this\.(?<instance>#[\w$]+)", "instance", "remoteBridgeSyncSnapshot");
|
||||
var themeField = RequirePattern(source, @"themeId\s*:\s*this\.(?<theme>#[\w$]+)", "theme", "remoteBridgeSyncSnapshot");
|
||||
var settingsHelper = RequirePattern(source, @"settings\s*:\s*(?<settings>[\w$]+)\s*\(\s*this\.settings\s*\)", "settings", "remoteBridgeSyncSnapshot");
|
||||
var languageField = RequirePattern(source, @"language\s*:\s*this\.(?<language>#[\w$]+)", "language", "remoteBridgeSyncSnapshot");
|
||||
var timerField = RequirePattern(source, @"isTimeLimitExpired\s*:\s*""expired""\s*===\s*this\.(?<timer>#[\w$]+)\.timerState", "timer", "remoteBridgeSyncSnapshot");
|
||||
|
||||
return $"{method}(){{let e,t=!1,s=this.{trainerField}?.getMetadata({metadataExport})?.gameVersion??null,o=!1;const n=this.{notesField}[this.{trainerIdField}??\"\"]||null;this.{gameField}&&(e=this.{installationField}.getPreferredInstallationInfo(this.{gameField}),e.app&&(t=!0,s??=e.version??null,o=\"number\"==typeof e.version&&!this.{supportedVersionsField}.includes(e.version)));this.status==={statusAlias}.Connected&&this.{remoteChannelField}?.send(\"client-state\",{{instanceId:this.{instanceField},trainerId:this.{trainerIdField},trainerLoading:this.{trainerField}?.isLoading(),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,values:this.{valuesMethod}(),themeId:this.{themeField},settings:{settingsHelper}(this.settings),language:this.{languageField},accountUuid:this.account.uuid,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.{timerField}.timerState}});this.__wandRemoteBridge?.sync({{instanceId:this.{instanceField},trainerId:this.{trainerIdField},trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.{trainerField}?.getMetadata({metadataExport})??null,trainerLoading:this.{trainerField}?.isLoading()??false,gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,language:this.{languageField},themeId:this.{themeField},notesReadHash:n,isTimeLimitExpired:\"expired\"===this.{timerField}.timerState,values:this.{valuesMethod}()}})}}";
|
||||
/// <summary>True once the patch is applied, or once a scan proved the feature is absent.</summary>
|
||||
public bool IsResolved => Applied || (IsOptional && !CapabilityDetected);
|
||||
}
|
||||
|
||||
public static Dictionary<EPatchType, PatchEntry[]> GetInstance()
|
||||
{
|
||||
return new Dictionary<EPatchType, PatchEntry[]>()
|
||||
return new Dictionary<EPatchType, PatchEntry[]>
|
||||
{
|
||||
{
|
||||
EPatchType.ActivatePro,
|
||||
@@ -107,80 +46,41 @@ namespace WandEnhancer.Core
|
||||
{
|
||||
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 = "<service_name>"
|
||||
},
|
||||
Name = "getUserAccount",
|
||||
Target = new Regex(@"getUserAccount\(\)\{.*?return\s+this\.#\w+\.fetch\(\{.*?\}\)\}",
|
||||
RegexOptions.Singleline),
|
||||
Patch =
|
||||
"getUserAccount(){return this.#<service_name>.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 = "<service_name>"
|
||||
},
|
||||
Name = "setAccountWandBrandExperience",
|
||||
Target = new Regex(
|
||||
@"setAccountWandBrandExperience\(\)\{.*?return\s+this\.#\w+\.post\(""/v3/account/brand_experience_wand""\)\}",
|
||||
RegexOptions.Singleline),
|
||||
Patch =
|
||||
"setAccountWandBrandExperience(){return this.#<service_name>.post(\"/v3/account/brand_experience_wand\").then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}"
|
||||
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\((?<params>[^)]*)\)\{\s*return\s+(?<expr>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 (?<decl>\w+)=""ACTION_SET_ACCOUNT"";function (?<fn>\w+)\((?<params>[^)]*)\)\{return\{\.\.\.(?<state>\w+),account:(?<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 @@ namespace WandEnhancer.Core
|
||||
EPatchType.DisableUpdates,
|
||||
new[]
|
||||
{
|
||||
// Regex consumes 4 closing parens (`)))) `); the 5th (registerHandler's own close)
|
||||
// remains in the original file after replacement. Patch must end with 3 parens — NOT 4.
|
||||
new PatchEntry
|
||||
{
|
||||
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 @@ namespace WandEnhancer.Core
|
||||
{
|
||||
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 `<app>.whenReady().then(`
|
||||
// call. This site is far more stable than the minified renderer
|
||||
// keydown listener that previously held the F12 -> ACTION_OPEN_DEV_TOOLS
|
||||
// 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(@"(?<app>\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 @@ namespace WandEnhancer.Core
|
||||
Name = "remoteBridgeMainBoot",
|
||||
CandidateFileNames = new[] { "index.js" },
|
||||
SearchHints = new[] { "whenReady().then(run)" },
|
||||
Target = new Regex(@"(?<app>\w+)\.whenReady\(\)\.then\(run\)"),
|
||||
Patch = "${app}.whenReady().then(()=>{try{const p=require(\"node:path\");require(p.join(__dirname,\"remote-panel\",\"bridge.cjs\")).installWandRuntime(require(\"electron\"));}catch(e){try{const fs=require(\"node:fs\"),os=require(\"node:os\"),p=require(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [boot-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}}return run()})"
|
||||
Locate = LocateBridgeBoot
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "remoteBridgeReset",
|
||||
SearchHints = new[] { "client-state" },
|
||||
Target = new Regex(@"(?<method>#[\w$]+)\(\)\s*\{\s*(?<body>(?:(?!__wandRemoteBridge|}\s*#[\w$]+\(\)).)*?Date\.now\(\)\.toString\(\)(?:(?!__wandRemoteBridge|}\s*#[\w$]+\(\)).)*?\[\](?:(?!__wandRemoteBridge|}\s*#[\w$]+\(\)).)*?)\s*\}\s*(?=#[\w$]+\(\)\s*\{\s*if\s*\(\s*this\.status\s*===\s*[\w$]+\.Connected\s*\).*?""client-state"")",
|
||||
RegexOptions.Singleline),
|
||||
PatchFactory = BuildRemoteBridgeResetPatch
|
||||
Locate = LocateBridgeReset
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "remoteBridgeSyncSnapshot",
|
||||
SearchHints = new[] { "client-state" },
|
||||
Target = new Regex(@"(?<method>#[\w$]+)\(\)\s*\{\s*if\s*\(\s*this\.status\s*===\s*[\w$]+\.Connected\s*\)\s*\{(?<body>.*?""client-state"".*?isTimeLimitExpired\s*:\s*""expired""\s*===\s*this\.\#[\w$]+\.timerState.*?\)\s*;?\s*\)?\s*;?)\s*\}\s*\}(?=\s*#[\w$]+\(\)\s*\{\s*if\s*\(\s*!this\.\#[\w$]+\?\.\s*isActive\(\)\s*\)\s*return\s*null)",
|
||||
RegexOptions.Singleline),
|
||||
PatchFactory = BuildRemoteBridgeSyncSnapshotPatch
|
||||
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(@"(?<head>setCurrentTrainer\(e,t=null\)\{)(?=const s=e\?\.trainerId\|\|null,i=\(s\?e\?\.gameId:null\)\|\|null,n=\(s\?e\?\.supportedVersions:null\)\|\|\[\];if\(s===this\.#[\w$]+&&t===this\.(?<trainer>#[\w$]+)\)return;)(?=.*?e\.source!==(?<remoteSource>[\w$]+\.[\w$]+\.Remote))",
|
||||
RegexOptions.Singleline),
|
||||
Patch = "${head}this.__wandRemoteBridge||(this.__wandRemoteBridge=(()=>{try{const r=globalThis.require||require;const{ipcRenderer:c}=r(\"electron\");try{c.invoke(\"wand-remote-url\").then((u=>{u&&(globalThis.__wandRemoteBridgeUrl=u)}))}catch(e){}const send=(ch,p)=>{try{return c.invoke(ch,p&&JSON.parse(JSON.stringify(p)))}catch(e){}};return{sync:(s)=>send(\"wand-remote-sync\",s),valueChanged:(s)=>send(\"wand-remote-value-changed\",s),setHandler:(h)=>{if(this.__wandRemoteBridgeBound)return;this.__wandRemoteBridgeBound=true;try{c.invoke(\"wand-remote-set-handler-bind\")}catch(e){}c.on(\"wand-remote-set-value\",(_e,req)=>{try{h(req)}catch(e){}})}}}catch(e){try{const r=globalThis.require||require,fs=r(\"node:fs\"),os=r(\"node:os\"),p=r(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [renderer-bind-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}return null}})());this.__wandRemoteBridge?.setHandler((e=>{if(!this.${trainer}||!e?.target)return!1;return this.${trainer}.isActive()?this.${trainer}.setValue(e.target,e.value,${remoteSource},e.cheatId):!1}));this.__wandRemoteTrainerInfo=e??null;"
|
||||
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(@"(?<head>#[\w$]+\(e,t\)\{t\.push\(e\.onValueSet\(e=>\{this\.status===[\w$]+\.Connected&&e\.source!==[\w$]+\.[\w$]+\.Remote&&this\.#[\w$]+\?\.send\(""client-value-changed"",\{instanceId:this\.#[\w$]+,name:e\.name,value:e\.value,cheatId:e\.cheatId\}\))(?<tail>\}\)\),this\.#[\w$]+\(\)\})"),
|
||||
Patch = "${head},this.__wandRemoteBridge?.valueChanged({target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??\"desktop\"),cheatId:e.cheatId})${tail}"
|
||||
Locate = LocateBridgeValueDelta
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Wraps the account-returning promise so the resolved account always reports an active subscription.</summary>
|
||||
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*(?<account>[\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));
|
||||
}
|
||||
|
||||
/// <summary>Clears the bridge alongside the session fields the reset method already nulls out.</summary>
|
||||
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")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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\.(?<trainer>#[\w$]+)\s*\?\.\s*getMetadata", "trainer"),
|
||||
"metadata", method.Resolve(@"getMetadata\(\s*(?<metadata>[\w$]+\.[\w$]+)\s*\)", "metadata"));
|
||||
|
||||
var edits = new List<JsEdit> { new JsEdit(js.MatchClose(sendOpen) + 1, payload) };
|
||||
edits.AddRange(HoistConnectedGuard(js, sendOpen));
|
||||
return edits.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Some builds wrap the whole snapshot method in <c>if (status === Connected)</c>. 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.
|
||||
/// </summary>
|
||||
private static IEnumerable<JsEdit> 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 == '?';
|
||||
}
|
||||
|
||||
/// <summary>Match that must be unambiguous: zero or several hits mean an unsupported build.</summary>
|
||||
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(@"(?<app>[\w$]+)\.whenReady\(\)\.then\(");
|
||||
private static readonly Regex WhenReadyThenRun = new Regex(@"(?<app>[\w$]+)\.whenReady\(\)\.then\(run\)");
|
||||
private static readonly Regex RemoteSetValue =
|
||||
new Regex(@"this\.(?<trainer>#[\w$]+)\.setValue\(\s*e\.name\s*,\s*e\.value\s*,\s*(?<source>[^,]+?)\s*,");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace WandEnhancer.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
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;
|
||||
|
||||
private static readonly byte[] Sentinel =
|
||||
Encoding.ASCII.GetBytes("dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX");
|
||||
|
||||
public static bool Launch(string exePath, string args, Action<string> log = null)
|
||||
{
|
||||
var si = new STARTUPINFO { cb = Marshal.SizeOf<STARTUPINFO>() };
|
||||
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()}).");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Debugged processes must survive after we detach and exit.
|
||||
DebugSetProcessKillOnExit(false);
|
||||
CloseHandle(pi.hThread);
|
||||
CloseHandle(pi.hProcess);
|
||||
|
||||
DrivePatchingDebugLoop(pi.dwProcessId, log);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void DrivePatchingDebugLoop(int mainPid, Action<string> log)
|
||||
{
|
||||
var pids = new List<int>();
|
||||
var brokeIn = new HashSet<int>();
|
||||
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;
|
||||
|
||||
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);
|
||||
if (!PatchFuse(hProc, baseImg))
|
||||
log?.Invoke($"Fuse not cleared in pid {pid}; renderers may fail with -36861.");
|
||||
// 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;
|
||||
break;
|
||||
|
||||
case EXIT_PROCESS_DEBUG_EVENT:
|
||||
pids.Remove(pid);
|
||||
if (pid == mainPid)
|
||||
{
|
||||
ContinueDebugEvent(pid, tid, status);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
ContinueDebugEvent(pid, tid, status);
|
||||
|
||||
now = clock.ElapsedMilliseconds;
|
||||
if (ShouldDetach(now, now - lastCreate))
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var pid in pids)
|
||||
DebugActiveProcessStop(pid);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
[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")]
|
||||
private static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -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<string, ELogType> _logger;
|
||||
|
||||
public JavaScriptPatchApplier(Action<string, ELogType> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace WandEnhancer.Core.Js
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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(@"(?<![\w$.])function\s*\*?\s*[\w$]*\s*\(");
|
||||
private static readonly HashSet<string> BlockKeywords =
|
||||
new HashSet<string>(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<string> RegexPrecedingKeywords =
|
||||
new HashSet<string>(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);
|
||||
}
|
||||
|
||||
/// <summary>Index of the delimiter closing the one at <paramref name="openIndex"/>, or -1.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Open delimiters enclosing <paramref name="index"/>, innermost first.</summary>
|
||||
public List<int> OpenerStack(int index)
|
||||
{
|
||||
var stack = new List<int>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Innermost enclosing delimiter of the given kind, or -1.</summary>
|
||||
public int EnclosingOpener(int index, char kind)
|
||||
{
|
||||
foreach (int opener in OpenerStack(index))
|
||||
{
|
||||
if (_text[opener] == kind)
|
||||
{
|
||||
return opener;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>Innermost named function or method whose body contains <paramref name="index"/>.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The named function whose body closes at <paramref name="closeIndex"/>, or null.</summary>
|
||||
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]);
|
||||
}
|
||||
|
||||
/// <summary>First function declared as <c>name(...)</c>, ignoring property and call sites.</summary>
|
||||
public JsFunction FindFunction(string name)
|
||||
{
|
||||
var pattern = new Regex($@"(?<![#\w$.]){Regex.Escape(name)}\s*\(");
|
||||
for (var match = pattern.Match(_text); match.Success; match = match.NextMatch())
|
||||
{
|
||||
int closeParen = MatchClose(match.Index + match.Length - 1);
|
||||
if (closeParen < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int bodyOpen = SkipWhitespaceForward(closeParen + 1);
|
||||
if (bodyOpen < _text.Length && _text[bodyOpen] == '{')
|
||||
{
|
||||
var function = ReadFunctionAt(bodyOpen);
|
||||
if (function != null && function.Name == name)
|
||||
{
|
||||
return function;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>First <c>function name(...) { }</c> declared at or after <paramref name="index"/>.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Index of the opening parenthesis of <c>callee(... "literal" ...)</c>, or -1. Wand reuses the
|
||||
/// same channel names for inbound listeners and outbound sends, so the callee disambiguates.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Trailing identifier directly before <paramref name="index"/>, e.g. <c>send</c> of <c>a?.send(</c>.</summary>
|
||||
public string NameBefore(int index)
|
||||
{
|
||||
int end = SkipWhitespaceBack(index - 1) + 1;
|
||||
var match = MatchNameEndingAt(end);
|
||||
return match.Success ? match.Value.TrimStart('#') : null;
|
||||
}
|
||||
|
||||
/// <summary>Identifier ending at <paramref name="end"/>, searched in a bounded window so
|
||||
/// multi-megabyte bundles are not copied on every lookup.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>The identifier ending at <paramref name="end"/> inclusive, or "" when there is none.</summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace WandEnhancer.Core.Js
|
||||
{
|
||||
/// <summary>A named function or class method located in a bundle, addressed by delimiter position.</summary>
|
||||
internal sealed class JsFunction
|
||||
{
|
||||
private static readonly Regex ReturnKeyword = new Regex(@"(?<![\w$])return(?![\w$])");
|
||||
|
||||
private readonly string _source;
|
||||
private JsCursor _body;
|
||||
|
||||
public JsFunction(string name, int start, int bodyOpen, int bodyClose, string source)
|
||||
{
|
||||
Name = name;
|
||||
Start = start;
|
||||
BodyOpen = bodyOpen;
|
||||
BodyClose = bodyClose;
|
||||
_source = source;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public int Start { get; }
|
||||
public int BodyOpen { get; }
|
||||
public int BodyClose { get; }
|
||||
|
||||
public string Body => _source.Substring(BodyOpen + 1, BodyClose - BodyOpen - 1);
|
||||
|
||||
private JsCursor BodyCursor => _body ?? (_body = new JsCursor(Body));
|
||||
|
||||
/// <summary>Captures a group from a pattern matched against this body only, not the whole bundle.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Rewrites the first match of a pattern scoped to this body; <c>${group}</c> back-references work.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites the last top-level <c>return X</c> as <c>return WRAPPER</c>, where the wrapper's
|
||||
/// <c>$0</c> placeholder receives the original expression.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A splice: replace <c>[Start, End)</c> of the bundle with <see cref="Text"/>.</summary>
|
||||
internal sealed class JsEdit
|
||||
{
|
||||
public JsEdit(int start, int end, string text)
|
||||
{
|
||||
Start = start;
|
||||
End = end;
|
||||
Text = text;
|
||||
}
|
||||
|
||||
/// <summary>An insertion at <paramref name="at"/>, replacing nothing.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads injected JavaScript from embedded <c>Patches/*.js</c> files so payloads stay
|
||||
/// lintable source rather than escaped C# string literals.
|
||||
/// </summary>
|
||||
internal static class PatchPayload
|
||||
{
|
||||
private const string ResourcePrefix = "patches/";
|
||||
|
||||
private static readonly ConcurrentDictionary<string, string> Cache =
|
||||
new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
private static readonly Regex Placeholder = new Regex(@"\$\{(?<name>\w+)\}");
|
||||
|
||||
/// <summary>
|
||||
/// Loads a payload, replacing each <c>${name}</c> 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.
|
||||
/// </summary>
|
||||
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<string, string>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,22 @@ namespace WandEnhancer.Core.Services
|
||||
|
||||
private static CultureInfo _currentLanguage;
|
||||
private static ResourceDictionary _englishBaseDictionary;
|
||||
private static ResourceDictionary _activeLocaleDictionary;
|
||||
|
||||
/// <summary>
|
||||
/// Localized string for <paramref name="key"/>, falling back to the key itself so a
|
||||
/// missing entry is visible rather than silently blank.
|
||||
/// </summary>
|
||||
public static string Get(string key)
|
||||
{
|
||||
return Application.Current?.TryFindResource(key) as string ?? key;
|
||||
}
|
||||
|
||||
/// <summary>Localized format string filled with <paramref name="args"/>.</summary>
|
||||
public static string Format(string key, params object[] args)
|
||||
{
|
||||
return string.Format(Get(key), args);
|
||||
}
|
||||
|
||||
public static CultureInfo CurrentLanguage
|
||||
{
|
||||
@@ -104,20 +120,19 @@ namespace WandEnhancer.Core.Services
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -27,8 +27,7 @@ namespace WandEnhancer.Core.Services
|
||||
}
|
||||
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 @@ namespace WandEnhancer.Core.Services
|
||||
}
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_folder_path">Ordnerpfad</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Ordner nicht gefunden</s:String>
|
||||
<s:String x:Key="mw_patch">Anwenden</s:String>
|
||||
@@ -35,7 +34,25 @@
|
||||
<s:String x:Key="pv_custom_scripts_hint">Ausgewählte .js-Dateien werden in Wand gepackt und im Renderer geladen.</s:String>
|
||||
<s:String x:Key="pv_no_custom_scripts">Keine Skripte ausgewählt</s:String>
|
||||
<s:String x:Key="pv_start">Starten</s:String>
|
||||
<s:String x:Key="pv_auto_apply">Nach Updates automatisch anwenden</s:String>
|
||||
<s:String x:Key="pv_popup_title">Was werden wir verbessern?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">WeMod-Verzeichnis unter {0} ({1}) gefunden</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod ist bereits gepatcht. Wenn Sie erneut patchen möchten, stellen Sie bitte zuerst das Backup wieder her.</s:String>
|
||||
<s:String x:Key="log_ready">Bereit zum Patchen.</s:String>
|
||||
<s:String x:Key="log_install_not_found">WeMod-Verzeichnis nicht gefunden.</s:String>
|
||||
<s:String x:Key="log_no_directory">Vorgang nicht möglich. Bitte geben Sie zuerst das Verzeichnis an.</s:String>
|
||||
<s:String x:Key="log_invalid_directory">Der ausgewählte Ordner {0} ist kein gültiges WeMod-Verzeichnis.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Fehler beim Wiederherstellen des Backups: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Fehler beim Patchen: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Protokolle in die Zwischenablage kopiert.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Fehler beim Kopieren der Protokolle: {0}</s:String>
|
||||
<s:String x:Key="log_exported">Protokolle nach {0} exportiert.</s:String>
|
||||
<s:String x:Key="log_export_failed">Fehler beim Exportieren der Protokolle: {0}</s:String>
|
||||
<s:String x:Key="log_open_link_failed">{0} konnte nicht in einem Browser geöffnet werden.</s:String>
|
||||
<s:String x:Key="dialog_pick_install">Wählen Sie das WeMod-Verzeichnis aus</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_folder_path">Folder path</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Folder not found</s:String>
|
||||
<s:String x:Key="mw_patch">Enhance</s:String>
|
||||
@@ -35,7 +34,25 @@
|
||||
<s:String x:Key="pv_custom_scripts_hint">Selected .js files are packed into Wand and loaded in the renderer.</s:String>
|
||||
<s:String x:Key="pv_no_custom_scripts">No scripts selected</s:String>
|
||||
<s:String x:Key="pv_start">Start</s:String>
|
||||
<s:String x:Key="pv_auto_apply">Auto-apply after updates</s:String>
|
||||
<s:String x:Key="pv_popup_title">What are we gonna enhance?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">WeMod directory found at {0} ({1})</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod already patched. If you want to patch again, please restore the backup first.</s:String>
|
||||
<s:String x:Key="log_ready">Ready for patching.</s:String>
|
||||
<s:String x:Key="log_install_not_found">WeMod directory not found.</s:String>
|
||||
<s:String x:Key="log_no_directory">Cant be done. Please specify the directory first.</s:String>
|
||||
<s:String x:Key="log_invalid_directory">The selected folder {0} is not a valid WeMod directory.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Failed to restore backup: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Failed to patch: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Logs copied to clipboard.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Failed to copy logs: {0}</s:String>
|
||||
<s:String x:Key="log_exported">Logs exported to {0}.</s:String>
|
||||
<s:String x:Key="log_export_failed">Failed to export logs: {0}</s:String>
|
||||
<s:String x:Key="log_open_link_failed">Could not open {0} in a browser.</s:String>
|
||||
<s:String x:Key="dialog_pick_install">Select the WeMod directory</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_folder_path">Ruta de la carpeta</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Carpeta no encontrada</s:String>
|
||||
<s:String x:Key="mw_patch">Aplicar</s:String>
|
||||
@@ -35,7 +34,25 @@
|
||||
<s:String x:Key="pv_custom_scripts_hint">Los archivos .js seleccionados se empaquetan en Wand y se cargan en el renderer.</s:String>
|
||||
<s:String x:Key="pv_no_custom_scripts">No hay scripts seleccionados</s:String>
|
||||
<s:String x:Key="pv_start">Iniciar</s:String>
|
||||
<s:String x:Key="pv_auto_apply">Aplicar automáticamente tras actualizar</s:String>
|
||||
<s:String x:Key="pv_popup_title">¿Qué vamos a mejorar?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Directorio de WeMod encontrado en {0} ({1})</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod ya está parcheado. Si quieres parchear de nuevo, restaura la copia de seguridad primero.</s:String>
|
||||
<s:String x:Key="log_ready">Listo para parchear.</s:String>
|
||||
<s:String x:Key="log_install_not_found">Directorio de WeMod no encontrado.</s:String>
|
||||
<s:String x:Key="log_no_directory">No se puede realizar. Por favor, especifica el directorio primero.</s:String>
|
||||
<s:String x:Key="log_invalid_directory">La carpeta seleccionada {0} no es un directorio de WeMod válido.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Error al restaurar la copia de seguridad: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Error al parchear: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Registros copiados al portapapeles.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Error al copiar los registros: {0}</s:String>
|
||||
<s:String x:Key="log_exported">Registros exportados a {0}.</s:String>
|
||||
<s:String x:Key="log_export_failed">Error al exportar los registros: {0}</s:String>
|
||||
<s:String x:Key="log_open_link_failed">No se pudo abrir {0} en un navegador.</s:String>
|
||||
<s:String x:Key="dialog_pick_install">Selecciona el directorio de WeMod</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_folder_path">Chemin du dossier</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Dossier non trouvé</s:String>
|
||||
<s:String x:Key="mw_patch">Appliquer</s:String>
|
||||
@@ -35,7 +34,25 @@
|
||||
<s:String x:Key="pv_custom_scripts_hint">Les fichiers .js sélectionnés sont intégrés dans Wand et chargés dans le renderer.</s:String>
|
||||
<s:String x:Key="pv_no_custom_scripts">Aucun script sélectionné</s:String>
|
||||
<s:String x:Key="pv_start">Démarrer</s:String>
|
||||
<s:String x:Key="pv_auto_apply">Appliquer automatiquement après les mises à jour</s:String>
|
||||
<s:String x:Key="pv_popup_title">Qu'allons-nous modifier ?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Répertoire WeMod trouvé à {0} ({1})</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod est déjà patché. Si vous souhaitez le patcher à nouveau, veuillez d'abord restaurer la sauvegarde.</s:String>
|
||||
<s:String x:Key="log_ready">Prêt pour le patch.</s:String>
|
||||
<s:String x:Key="log_install_not_found">Répertoire WeMod introuvable.</s:String>
|
||||
<s:String x:Key="log_no_directory">Impossible. Veuillez d'abord spécifier le répertoire.</s:String>
|
||||
<s:String x:Key="log_invalid_directory">Le dossier sélectionné {0} n'est pas un répertoire WeMod valide.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Échec de la restauration de la sauvegarde : {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Échec du patch : {0}</s:String>
|
||||
<s:String x:Key="log_copied">Journaux copiés dans le presse-papiers.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Échec de la copie des journaux : {0}</s:String>
|
||||
<s:String x:Key="log_exported">Journaux exportés vers {0}.</s:String>
|
||||
<s:String x:Key="log_export_failed">Échec de l'exportation des journaux : {0}</s:String>
|
||||
<s:String x:Key="log_open_link_failed">Impossible d'ouvrir {0} dans un navigateur.</s:String>
|
||||
<s:String x:Key="dialog_pick_install">Sélectionnez le répertoire WeMod</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_folder_path">Percorso cartella</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Cartella non trovata</s:String>
|
||||
<s:String x:Key="mw_patch">Applica</s:String>
|
||||
@@ -35,7 +34,25 @@
|
||||
<s:String x:Key="pv_custom_scripts_hint">I file .js selezionati vengono inseriti in Wand e caricati nel renderer.</s:String>
|
||||
<s:String x:Key="pv_no_custom_scripts">Nessuno script selezionato</s:String>
|
||||
<s:String x:Key="pv_start">Avvia</s:String>
|
||||
<s:String x:Key="pv_auto_apply">Applica automaticamente dopo gli aggiornamenti</s:String>
|
||||
<s:String x:Key="pv_popup_title">Cosa modificheremo?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Directory di WeMod trovata in {0} ({1})</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod è già stato patchato. Se vuoi patchare di nuovo, ripristina prima il backup.</s:String>
|
||||
<s:String x:Key="log_ready">Pronto per il patching.</s:String>
|
||||
<s:String x:Key="log_install_not_found">Directory di WeMod non trovata.</s:String>
|
||||
<s:String x:Key="log_no_directory">Impossibile procedere. Specifica prima la directory.</s:String>
|
||||
<s:String x:Key="log_invalid_directory">La cartella selezionata {0} non è una directory valida di WeMod.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Impossibile ripristinare il backup: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Impossibile eseguire il patch: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Log copiati negli appunti.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Impossibile copiare i log: {0}</s:String>
|
||||
<s:String x:Key="log_exported">Log esportati in {0}.</s:String>
|
||||
<s:String x:Key="log_export_failed">Impossibile esportare i log: {0}</s:String>
|
||||
<s:String x:Key="log_open_link_failed">Impossibile aprire {0} in un browser.</s:String>
|
||||
<s:String x:Key="dialog_pick_install">Seleziona la directory di WeMod</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_folder_path">フォルダパス</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">フォルダが見つかりません</s:String>
|
||||
<s:String x:Key="mw_patch">適用</s:String>
|
||||
@@ -35,7 +34,25 @@
|
||||
<s:String x:Key="pv_custom_scripts_hint">選択した .js ファイルは Wand に組み込まれ、レンダラーで読み込まれます。</s:String>
|
||||
<s:String x:Key="pv_no_custom_scripts">スクリプトが選択されていません</s:String>
|
||||
<s:String x:Key="pv_start">開始</s:String>
|
||||
<s:String x:Key="pv_auto_apply">更新後に自動適用</s:String>
|
||||
<s:String x:Key="pv_popup_title">何を改善しますか?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">WeModディレクトリが {0} ({1}) に見つかりました</s:String>
|
||||
<s:String x:Key="log_already_patched">WeModは既にパッチが適用されています。もう一度パッチを適用する場合は、まずバックアップを復元してください。</s:String>
|
||||
<s:String x:Key="log_ready">パッチ適用の準備ができました。</s:String>
|
||||
<s:String x:Key="log_install_not_found">WeModディレクトリが見つかりません。</s:String>
|
||||
<s:String x:Key="log_no_directory">実行できません。先にディレクトリを指定してください。</s:String>
|
||||
<s:String x:Key="log_invalid_directory">選択したフォルダ {0} は有効なWeModディレクトリではありません。</s:String>
|
||||
<s:String x:Key="log_restore_failed">バックアップの復元に失敗しました: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">パッチの適用に失敗しました: {0}</s:String>
|
||||
<s:String x:Key="log_copied">ログをクリップボードにコピーしました。</s:String>
|
||||
<s:String x:Key="log_copy_failed">ログのコピーに失敗しました: {0}</s:String>
|
||||
<s:String x:Key="log_exported">ログを {0} にエクスポートしました。</s:String>
|
||||
<s:String x:Key="log_export_failed">ログのエクスポートに失敗しました: {0}</s:String>
|
||||
<s:String x:Key="log_open_link_failed">{0} をブラウザで開くことができませんでした。</s:String>
|
||||
<s:String x:Key="dialog_pick_install">WeModディレクトリを選択してください</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_folder_path">Ścieżka folderu</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Folder nie znaleziony</s:String>
|
||||
<s:String x:Key="mw_patch">Zastosuj</s:String>
|
||||
@@ -35,7 +34,25 @@
|
||||
<s:String x:Key="pv_custom_scripts_hint">Wybrane pliki .js są pakowane do Wand i ładowane w rendererze.</s:String>
|
||||
<s:String x:Key="pv_no_custom_scripts">Nie wybrano skryptów</s:String>
|
||||
<s:String x:Key="pv_start">Rozpocznij</s:String>
|
||||
<s:String x:Key="pv_auto_apply">Zastosuj automatycznie po aktualizacji</s:String>
|
||||
<s:String x:Key="pv_popup_title">Co będziemy ulepszać?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Katalog WeMod znaleziony w {0} ({1})</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod został już zaktualizowany. Jeśli chcesz zaktualizować ponownie, najpierw przywróć kopię zapasową.</s:String>
|
||||
<s:String x:Key="log_ready">Gotowy do aktualizacji (patchowania).</s:String>
|
||||
<s:String x:Key="log_install_not_found">Nie znaleziono katalogu WeMod.</s:String>
|
||||
<s:String x:Key="log_no_directory">Nie można tego zrobić. Proszę najpierw określić katalog.</s:String>
|
||||
<s:String x:Key="log_invalid_directory">Wybrany folder {0} nie jest prawidłowym katalogiem WeMod.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Nie udało się przywrócić kopii zapasowej: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Nie udało się zaktualizować: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Logi skopiowane do schowka.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Nie udało się skopiować logów: {0}</s:String>
|
||||
<s:String x:Key="log_exported">Logi wyeksportowane do {0}.</s:String>
|
||||
<s:String x:Key="log_export_failed">Nie udało się wyeksportować logów: {0}</s:String>
|
||||
<s:String x:Key="log_open_link_failed">Nie można otworzyć {0} w przeglądarce.</s:String>
|
||||
<s:String x:Key="dialog_pick_install">Wybierz katalog WeMod</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_folder_path">Caminho da pasta</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Pasta não encontrada</s:String>
|
||||
<s:String x:Key="mw_patch">Aplicar</s:String>
|
||||
@@ -35,7 +34,25 @@
|
||||
<s:String x:Key="pv_custom_scripts_hint">Os arquivos .js selecionados são empacotados no Wand e carregados no renderer.</s:String>
|
||||
<s:String x:Key="pv_no_custom_scripts">Nenhum script selecionado</s:String>
|
||||
<s:String x:Key="pv_start">Iniciar</s:String>
|
||||
<s:String x:Key="pv_auto_apply">Aplicar automaticamente após atualizações</s:String>
|
||||
<s:String x:Key="pv_popup_title">O que vamos melhorar?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Diretório do WeMod encontrado em {0} ({1})</s:String>
|
||||
<s:String x:Key="log_already_patched">O WeMod já foi modificado. Se quiser modificar novamente, restaure o backup primeiro.</s:String>
|
||||
<s:String x:Key="log_ready">Pronto para modificar.</s:String>
|
||||
<s:String x:Key="log_install_not_found">Diretório do WeMod não encontrado.</s:String>
|
||||
<s:String x:Key="log_no_directory">Não é possível fazer isso. Por favor, especifique o diretório primeiro.</s:String>
|
||||
<s:String x:Key="log_invalid_directory">A pasta selecionada {0} não é um diretório válido do WeMod.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Falha ao restaurar o backup: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Falha ao modificar: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Logs copiados para a área de transferência.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Falha ao copiar logs: {0}</s:String>
|
||||
<s:String x:Key="log_exported">Logs exportados para {0}.</s:String>
|
||||
<s:String x:Key="log_export_failed">Falha ao exportar logs: {0}</s:String>
|
||||
<s:String x:Key="log_open_link_failed">Não foi possível abrir {0} no navegador.</s:String>
|
||||
<s:String x:Key="dialog_pick_install">Selecione o diretório do WeMod</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_folder_path">Путь к папке</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Папка не найдена</s:String>
|
||||
<s:String x:Key="mw_patch">Применить</s:String>
|
||||
@@ -35,7 +34,25 @@
|
||||
<s:String x:Key="pv_custom_scripts_hint">Выбранные .js попадут в Wand и загрузятся в renderer.</s:String>
|
||||
<s:String x:Key="pv_no_custom_scripts">Скрипты не выбраны</s:String>
|
||||
<s:String x:Key="pv_start">Начать</s:String>
|
||||
<s:String x:Key="pv_auto_apply">Авто-патч после обновлений</s:String>
|
||||
<s:String x:Key="pv_popup_title">Что будем улучшать?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Директория WeMod найдена в {0} ({1})</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod уже пропатчен. Если вы хотите пропатчить снова, сначала восстановите резервную копию.</s:String>
|
||||
<s:String x:Key="log_ready">Готово к патчингу.</s:String>
|
||||
<s:String x:Key="log_install_not_found">Директория WeMod не найдена.</s:String>
|
||||
<s:String x:Key="log_no_directory">Невозможно выполнить. Пожалуйста, сначала укажите директорию.</s:String>
|
||||
<s:String x:Key="log_invalid_directory">Выбранная папка {0} не является допустимой директорией WeMod.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Не удалось восстановить резервную копию: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Не удалось пропатчить: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Логи скопированы в буфер обмена.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Не удалось скопировать логи: {0}</s:String>
|
||||
<s:String x:Key="log_exported">Логи экспортированы в {0}.</s:String>
|
||||
<s:String x:Key="log_export_failed">Не удалось экспортировать логи: {0}</s:String>
|
||||
<s:String x:Key="log_open_link_failed">Не удалось открыть {0} в браузере.</s:String>
|
||||
<s:String x:Key="dialog_pick_install">Выберите директорию WeMod</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_folder_path">Klasör yolu</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Klasör bulunamadı</s:String>
|
||||
<s:String x:Key="mw_patch">Uygula</s:String>
|
||||
@@ -35,7 +34,25 @@
|
||||
<s:String x:Key="pv_custom_scripts_hint">Seçilen .js dosyaları Wand içine paketlenir ve renderer'da yüklenir.</s:String>
|
||||
<s:String x:Key="pv_no_custom_scripts">Betik seçilmedi</s:String>
|
||||
<s:String x:Key="pv_start">Başlat</s:String>
|
||||
<s:String x:Key="pv_auto_apply">Güncellemelerden sonra otomatik uygula</s:String>
|
||||
<s:String x:Key="pv_popup_title">Neyi geliştireceğiz?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">WeMod dizini {0} konumunda bulundu ({1})</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod zaten yamanmış. Tekrar yamamak istiyorsanız, lütfen önce yedeği geri yükleyin.</s:String>
|
||||
<s:String x:Key="log_ready">Yama işlemi için hazır.</s:String>
|
||||
<s:String x:Key="log_install_not_found">WeMod dizini bulunamadı.</s:String>
|
||||
<s:String x:Key="log_no_directory">İşlem yapılamıyor. Lütfen önce dizini belirtin.</s:String>
|
||||
<s:String x:Key="log_invalid_directory">Seçilen {0} klasörü geçerli bir WeMod dizini değil.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Yedek geri yüklenemedi: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Yama yapılamadı: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Günlükler panoya kopyalandı.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Günlükler kopyalanamadı: {0}</s:String>
|
||||
<s:String x:Key="log_exported">Günlükler {0} konumuna dışa aktarıldı.</s:String>
|
||||
<s:String x:Key="log_export_failed">Günlükler dışa aktarılamadı: {0}</s:String>
|
||||
<s:String x:Key="log_open_link_failed">{0} bir tarayıcıda açılamadı.</s:String>
|
||||
<s:String x:Key="dialog_pick_install">WeMod dizinini seçin</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_folder_path">Шлях до папки</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">Папку не знайдено</s:String>
|
||||
<s:String x:Key="mw_patch">Застосувати</s:String>
|
||||
@@ -35,7 +34,25 @@
|
||||
<s:String x:Key="pv_custom_scripts_hint">Вибрані файли .js пакуються у Wand і завантажуються в рендерері.</s:String>
|
||||
<s:String x:Key="pv_no_custom_scripts">Скрипти не вибрано</s:String>
|
||||
<s:String x:Key="pv_start">Почати</s:String>
|
||||
<s:String x:Key="pv_auto_apply">Автоматично застосовувати після оновлень</s:String>
|
||||
<s:String x:Key="pv_popup_title">Що будемо покращувати?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Директорію WeMod знайдено в {0} ({1})</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod вже пропатчено. Якщо ви хочете пропатчити знову, спершу відновіть резервну копію.</s:String>
|
||||
<s:String x:Key="log_ready">Готово до патчингу.</s:String>
|
||||
<s:String x:Key="log_install_not_found">Директорію WeMod не знайдено.</s:String>
|
||||
<s:String x:Key="log_no_directory">Не вдається виконати. Будь ласка, спочатку вкажіть директорію.</s:String>
|
||||
<s:String x:Key="log_invalid_directory">Вибрана папка {0} не є дійсною директорією WeMod.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Не вдалося відновити резервну копію: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Не вдалося пропатчити: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Логи скопійовано в буфер обміну.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Не вдалося скопіювати логи: {0}</s:String>
|
||||
<s:String x:Key="log_exported">Логи експортовано до {0}.</s:String>
|
||||
<s:String x:Key="log_export_failed">Не вдалося експортувати логи: {0}</s:String>
|
||||
<s:String x:Key="log_open_link_failed">Не вдалося відкрити {0} у браузері.</s:String>
|
||||
<s:String x:Key="dialog_pick_install">Виберіть директорію WeMod</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
||||
<s:String x:Key="mw_folder_path">文件夹路径</s:String>
|
||||
<s:String x:Key="mw_folder_not_found">未找到文件夹</s:String>
|
||||
<s:String x:Key="mw_patch">增强</s:String>
|
||||
@@ -35,7 +34,25 @@
|
||||
<s:String x:Key="pv_custom_scripts_hint">选中的 .js 文件会打包到 Wand 并在渲染器中加载。</s:String>
|
||||
<s:String x:Key="pv_no_custom_scripts">未选择脚本</s:String>
|
||||
<s:String x:Key="pv_start">开始</s:String>
|
||||
<s:String x:Key="pv_auto_apply">更新后自动应用</s:String>
|
||||
<s:String x:Key="pv_popup_title">我们要增强什么?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">在 {0} ({1}) 找到 WeMod 目录</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod 已经修补过。如果想再次修补,请先恢复备份。</s:String>
|
||||
<s:String x:Key="log_ready">准备修补。</s:String>
|
||||
<s:String x:Key="log_install_not_found">未找到 WeMod 目录。</s:String>
|
||||
<s:String x:Key="log_no_directory">无法执行。请先指定目录。</s:String>
|
||||
<s:String x:Key="log_invalid_directory">选择的文件夹 {0} 不是有效的 WeMod 目录。</s:String>
|
||||
<s:String x:Key="log_restore_failed">恢复备份失败: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">修补失败: {0}</s:String>
|
||||
<s:String x:Key="log_copied">日志已复制到剪贴板。</s:String>
|
||||
<s:String x:Key="log_copy_failed">复制日志失败: {0}</s:String>
|
||||
<s:String x:Key="log_exported">日志已导出至 {0}。</s:String>
|
||||
<s:String x:Key="log_export_failed">导出日志失败: {0}</s:String>
|
||||
<s:String x:Key="log_open_link_failed">无法在浏览器中打开 {0}。</s:String>
|
||||
<s:String x:Key="dialog_pick_install">选择 WeMod 目录</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -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<EPatchType> PatchTypes { get; set; }
|
||||
|
||||
public List<string> CustomScriptPaths { get; set; } = new List<string>();
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
/// <summary>When set, the patch selection is saved so the launcher re-applies it after a Wand update.</summary>
|
||||
public bool AutoApplyAfterUpdate { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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){}})),
|
||||
@@ -0,0 +1 @@
|
||||
return Promise.reject(new Error("wand-enhancer: native mobile pairing disabled"))
|
||||
@@ -0,0 +1 @@
|
||||
"ACTION_CHECK_FOR_UPDATE",(e=>expectUpdateFeedUrl(e,(e=>null)))
|
||||
@@ -0,0 +1 @@
|
||||
account:((account)=>account&&"object"==typeof account?{...account,subscription:{period:"yearly",state:"active"}}:account)(${account})
|
||||
@@ -0,0 +1 @@
|
||||
$0.then((response)=>{response&&"object"==typeof response&&(response.subscription={period:"yearly",state:"active"});return response})
|
||||
@@ -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()})
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
;this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null)
|
||||
@@ -0,0 +1 @@
|
||||
,this.__wandRemoteBridge?.sync({${snapshot},trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.${trainer}?.getMetadata(${metadata})??null})
|
||||
@@ -0,0 +1 @@
|
||||
,this.__wandRemoteBridge?.valueChanged({target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??"desktop"),cheatId:e.cheatId})
|
||||
+127
-13
@@ -1,45 +1,159 @@
|
||||
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
|
||||
{
|
||||
/// <summary>Log lines from a failed startup auto-patch, replayed by the UI when it opens.</summary>
|
||||
public static readonly List<KeyValuePair<string, ELogType>> StartupLog =
|
||||
new List<KeyValuePair<string, ELogType>>();
|
||||
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
if (TryLaunchMode(args))
|
||||
return;
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
|
||||
|
||||
List<LogEntry> logEntries = new List<LogEntry>();
|
||||
if (args.Length > 0)
|
||||
{
|
||||
// TODO: Command line arguments handling
|
||||
}
|
||||
|
||||
var application = new App();
|
||||
application.InitializeComponent();
|
||||
application.MainWindow = new MainWindow();
|
||||
foreach (var logEntry in logEntries)
|
||||
{
|
||||
MainWindow.Instance.ViewModel.LogList.Add(logEntry);
|
||||
}
|
||||
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);
|
||||
|
||||
if (args.Length > 0 &&
|
||||
args[0].StartsWith("--squirrel", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string updateExe = Path.Combine(myDir, "Update.exe");
|
||||
if (File.Exists(updateExe))
|
||||
Process.Start(updateExe, QuoteArguments(args));
|
||||
return true;
|
||||
}
|
||||
|
||||
var config = WeModInstalls.FindLatestWeMod(myDir);
|
||||
if (config == null)
|
||||
return false;
|
||||
|
||||
// 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 (!Enhancer.IsPatched(config.RootDirectory) && !TryAutoPatch(config, myDir))
|
||||
return false;
|
||||
|
||||
string forwardedArgs = args.Length > 0 ? QuoteArguments(args) : null;
|
||||
FuseLauncher.Launch(config.ExecutablePath, forwardedArgs,
|
||||
message => RecordStartupLog(message, ELogType.Warn));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static string QuoteArguments(IEnumerable<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordStartupLog(string message, ELogType type)
|
||||
{
|
||||
StartupLog.Add(new KeyValuePair<string, ELogType>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,5 +51,5 @@ using System.Windows;
|
||||
// You can specify all the values or you can default the Build and Revision Numbers
|
||||
// by using the '*' as shown below:
|
||||
// [assembly: AssemblyVersion("1.0.*")]
|
||||
[assembly: AssemblyVersion("1.0.9.4")]
|
||||
[assembly: AssemblyFileVersion("1.0.9.4")]
|
||||
[assembly: AssemblyVersion("2.0.0.0")]
|
||||
[assembly: AssemblyFileVersion("2.0.0.0")]
|
||||
|
||||
@@ -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<object, Task> _execute;
|
||||
private readonly Func<object, bool> _canExecute;
|
||||
|
||||
private long _isExecuting;
|
||||
|
||||
public AsyncRelayCommand(Func<object, Task> execute, Func<object, bool> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Process>(processes.Length);
|
||||
foreach (var process in processes)
|
||||
{
|
||||
if (process.Id == selfId)
|
||||
{
|
||||
process.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(process);
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,14 @@ using WandEnhancer.Models;
|
||||
|
||||
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 @@ namespace WandEnhancer.Utils
|
||||
}
|
||||
}
|
||||
}
|
||||
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 @@ namespace WandEnhancer.Utils
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string Base64Decode(string base64EncodedData)
|
||||
public static bool IsJavaScriptFile(string path)
|
||||
{
|
||||
var base64EncodedBytes = System.Convert.FromBase64String(base64EncodedData);
|
||||
return System.Text.Encoding.UTF8.GetString(base64EncodedBytes);
|
||||
}
|
||||
|
||||
public static string Base64Encode(string plainText)
|
||||
{
|
||||
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)
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<UserControl x:Class="WandEnhancer.View.Controls.InfoItem"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="clr-namespace:WandEnhancer.View.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignHeight="300" d:DesignWidth="300">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Viewbox Width="20" Height="20" VerticalAlignment="Top">
|
||||
<Path Fill="{Binding IconColor}" Data="{Binding IconData}"/>
|
||||
</Viewbox>
|
||||
<TextBlock Grid.Column="1" VerticalAlignment="Center" Margin="5 0 5 0" TextWrapping="Wrap"
|
||||
FontSize="12" Text="{Binding Text}"/>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
</Button>
|
||||
|
||||
<StackPanel Grid.Row="0" x:Name="TitleContainer" Orientation="Horizontal">
|
||||
<TextBlock x:Name="Title" Text="This is title" Foreground="{DynamicResource Foreground}"
|
||||
<TextBlock x:Name="Title" Foreground="{DynamicResource Foreground}"
|
||||
HorizontalAlignment="Left" FontWeight="Bold" FontSize="16"
|
||||
VerticalAlignment="Bottom"/>
|
||||
</StackPanel>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace WandEnhancer.View.MainWindow
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IShellView
|
||||
{
|
||||
void OpenPopup(FrameworkElement content, string title);
|
||||
void ClosePopup();
|
||||
void ScrollLogIntoView(LogEntry entry);
|
||||
}
|
||||
|
||||
/// <summary>Modal file/folder pickers, kept behind a seam so commands stay headless-testable.</summary>
|
||||
public interface IFileDialogs
|
||||
{
|
||||
/// <summary>Chosen folder, or null when cancelled.</summary>
|
||||
string PickFolder(string description, string initialPath);
|
||||
|
||||
/// <summary>Chosen file path, or null when cancelled.</summary>
|
||||
string PickSaveFile(string filter, string suggestedFileName);
|
||||
}
|
||||
}
|
||||
@@ -183,9 +183,10 @@
|
||||
Content="{DynamicResource mw_patch}"/>
|
||||
</Grid>
|
||||
<Button HorizontalAlignment="Right"
|
||||
Command="{Binding RestoreBackupCommand }"
|
||||
FontWeight="Bold" FontSize="16" Width="200"
|
||||
Command="{Binding RestoreBackupCommand}"
|
||||
FontWeight="Bold" FontSize="16" Width="200"
|
||||
Style="{StaticResource ColoredButton}"
|
||||
IsEnabled="{Binding IsIdle}"
|
||||
Visibility="{Binding AlreadyPatched, Converter={StaticResource ToVisibilityConverter}}"
|
||||
Content="{DynamicResource mw_restore}"/>
|
||||
</Grid>
|
||||
|
||||
@@ -8,21 +8,20 @@ namespace WandEnhancer.View.MainWindow
|
||||
/// <summary>
|
||||
/// Interaction logic for MainWindow.xaml
|
||||
/// </summary>
|
||||
public partial class MainWindow
|
||||
public partial class MainWindow : IShellView
|
||||
{
|
||||
public static MainWindow Instance;
|
||||
public readonly MainWindowVm ViewModel;
|
||||
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
this.ViewModel = new MainWindowVm(this);
|
||||
this.ViewModel = new MainWindowVm(this, new WindowsFileDialogs());
|
||||
this.DataContext = ViewModel;
|
||||
VersionLabel.Text = Constants.Version.ToString();
|
||||
Instance = this;
|
||||
|
||||
}
|
||||
|
||||
|
||||
public void OpenPopup(FrameworkElement content, string title = null)
|
||||
{
|
||||
this.PopupHost.PopupContent = content;
|
||||
@@ -30,6 +29,11 @@ namespace WandEnhancer.View.MainWindow
|
||||
PopupHost.IsOpen = true;
|
||||
}
|
||||
|
||||
public void ScrollLogIntoView(LogEntry entry)
|
||||
{
|
||||
this.LogList.ScrollIntoView(entry);
|
||||
}
|
||||
|
||||
private void OnDragMove(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
this.DragMove();
|
||||
@@ -47,7 +51,15 @@ namespace WandEnhancer.View.MainWindow
|
||||
|
||||
private void OpenSourceClicked(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
System.Diagnostics.Process.Start(Constants.RepositoryUrl);
|
||||
// No browser association, or the shell refuses the URL: not worth killing the app.
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(Constants.RepositoryUrl);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
ViewModel.ReportRepositoryLinkFailure(Constants.RepositoryUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using WandEnhancer.Core;
|
||||
using WandEnhancer.Core.Services;
|
||||
using WandEnhancer.Models;
|
||||
using WandEnhancer.ReactiveUICore;
|
||||
using WandEnhancer.Utils;
|
||||
@@ -15,33 +15,33 @@ namespace WandEnhancer.View.MainWindow
|
||||
{
|
||||
public class MainWindowVm : ObservableObject
|
||||
{
|
||||
private readonly MainWindow _view;
|
||||
public ObservableCollection<LogEntry> LogList { get; set; } = new ObservableCollection<LogEntry>();
|
||||
private const string LogExportFilter = "Text files (*.txt)|*.txt|All files (*.*)|*.*";
|
||||
|
||||
private readonly IShellView _shell;
|
||||
private readonly IFileDialogs _dialogs;
|
||||
public ObservableCollection<LogEntry> LogList { get; } = new ObservableCollection<LogEntry>();
|
||||
private WeModConfig _weModConfig;
|
||||
|
||||
public WeModConfig WeModInfo
|
||||
{
|
||||
get => _weModConfig;
|
||||
set
|
||||
set => SetProperty(ref _weModConfig, value);
|
||||
}
|
||||
|
||||
private void UseInstall(WeModConfig config)
|
||||
{
|
||||
WeModInfo = config;
|
||||
if (config == null)
|
||||
{
|
||||
SetProperty(ref _weModConfig, value);
|
||||
if (value == null) return;
|
||||
|
||||
Log($"WeMod directory found at '{_weModConfig}' ({_weModConfig.ExecutableName})", ELogType.Success);
|
||||
var resourcesPath = Path.Combine(_weModConfig.RootDirectory, "resources");
|
||||
if (File.Exists(Path.Combine(resourcesPath, "app.asar.backup")) ||
|
||||
Directory.Exists(Path.Combine(resourcesPath, "app.asar.unpacked.backup")))
|
||||
{
|
||||
Log("WeMod already patched. If you want to patch again, please restore the backup first.",
|
||||
ELogType.Warn);
|
||||
IsPatchEnabled = false;
|
||||
AlreadyPatched = true;
|
||||
return;
|
||||
}
|
||||
|
||||
Log("Ready for patching.", ELogType.Info);
|
||||
IsPatchEnabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
Log(LocalizationManager.Format("log_install_found", config, config.ExecutableName), ELogType.Success);
|
||||
AlreadyPatched = Enhancer.IsPatched(config.RootDirectory);
|
||||
IsPatchEnabled = !AlreadyPatched;
|
||||
|
||||
Log(LocalizationManager.Get(AlreadyPatched ? "log_already_patched" : "log_ready"),
|
||||
AlreadyPatched ? ELogType.Warn : ELogType.Info);
|
||||
}
|
||||
|
||||
private bool _isPatchEnabled;
|
||||
@@ -60,6 +60,24 @@ namespace WandEnhancer.View.MainWindow
|
||||
set => SetProperty(ref _alreadyPatched, value);
|
||||
}
|
||||
|
||||
private bool _isBusy;
|
||||
|
||||
/// <summary>True while a patch or restore runs; both are long file operations.</summary>
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isBusy, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(IsIdle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bound by buttons that must not be clickable a second time mid-run.</summary>
|
||||
public bool IsIdle => !_isBusy;
|
||||
|
||||
public RelayCommand SetFolderPathCommand { get; }
|
||||
public RelayCommand ApplyPatchCommand { get; }
|
||||
public RelayCommand RestoreBackupCommand { get; }
|
||||
@@ -69,87 +87,69 @@ namespace WandEnhancer.View.MainWindow
|
||||
|
||||
private void OnFolderPathSelection(object obj)
|
||||
{
|
||||
using (var dialog = new FolderBrowserDialog())
|
||||
string selectedPath = _dialogs.PickFolder(
|
||||
LocalizationManager.Get("dialog_pick_install"),
|
||||
Environment.GetEnvironmentVariable("LOCALAPPDATA"));
|
||||
if (selectedPath == null)
|
||||
{
|
||||
dialog.SelectedPath = Environment.GetEnvironmentVariable("LOCALAPPDATA");
|
||||
dialog.Description = "Select the WeMod directory";
|
||||
dialog.ShowNewFolderButton = false;
|
||||
|
||||
if (dialog.ShowDialog() != DialogResult.OK) return;
|
||||
string selectedPath = dialog.SelectedPath;
|
||||
string fileName = Path.GetFileName(selectedPath);
|
||||
|
||||
var info = Extensions.CheckWeModPath(selectedPath);
|
||||
|
||||
if (info != null)
|
||||
{
|
||||
WeModInfo = info;
|
||||
return;
|
||||
}
|
||||
|
||||
LogList.Add(new LogEntry
|
||||
{
|
||||
LogType = ELogType.Error,
|
||||
Message = $"The selected folder '{fileName}' is not a valid WeMod directory."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var info = WeModInstalls.CheckWeModPath(selectedPath);
|
||||
if (info == null)
|
||||
{
|
||||
Log(LocalizationManager.Format("log_invalid_directory", Path.GetFileName(selectedPath)), ELogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
UseInstall(info);
|
||||
}
|
||||
|
||||
private void OnBackupRestoring(object param)
|
||||
// Restore does the same heavy file IO as Patch, so it runs off the UI thread too.
|
||||
private async void OnBackupRestoring(object param)
|
||||
{
|
||||
var resourcesPath = Path.Combine(WeModInfo.RootDirectory, "resources");
|
||||
var backupPath = Path.Combine(resourcesPath, "app.asar.backup");
|
||||
var unpackedBackupPath = Path.Combine(resourcesPath, "app.asar.unpacked.backup");
|
||||
if (!File.Exists(backupPath) || !Directory.Exists(unpackedBackupPath))
|
||||
if (WeModInfo == null)
|
||||
{
|
||||
Log("Backup is incomplete. Restore the original Wand installation files or reinstall Wand.", ELogType.Error);
|
||||
Log(LocalizationManager.Get("log_no_directory"), ELogType.Warn);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
IsBusy = true;
|
||||
bool restored = await Task.Run(() =>
|
||||
{
|
||||
var asarPath = Path.Combine(resourcesPath, "app.asar");
|
||||
var unpackedPath = Path.Combine(resourcesPath, "app.asar.unpacked");
|
||||
File.Copy(backupPath, asarPath, true);
|
||||
|
||||
if (Directory.Exists(unpackedPath))
|
||||
try
|
||||
{
|
||||
Directory.Delete(unpackedPath, true);
|
||||
new Enhancer(WeModInfo, Log).Restore();
|
||||
return true;
|
||||
}
|
||||
Enhancer.CopyDirectory(unpackedBackupPath, unpackedPath);
|
||||
|
||||
var proxyDllPath = Path.Combine(WeModInfo.RootDirectory, "version.dll");
|
||||
if (File.Exists(proxyDllPath))
|
||||
catch (Exception e)
|
||||
{
|
||||
File.Delete(proxyDllPath);
|
||||
Log(LocalizationManager.Format("log_restore_failed", e.Message), ELogType.Error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
File.Delete(backupPath);
|
||||
Directory.Delete(unpackedBackupPath, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
IsBusy = false;
|
||||
if (restored)
|
||||
{
|
||||
Log($"Failed to restore backup: {e.Message}", ELogType.Error);
|
||||
return;
|
||||
AlreadyPatched = false;
|
||||
IsPatchEnabled = true;
|
||||
}
|
||||
|
||||
Log("Backup restored successfully.", ELogType.Success);
|
||||
AlreadyPatched = false;
|
||||
IsPatchEnabled = true;
|
||||
}
|
||||
|
||||
private void OnPatching(object param)
|
||||
{
|
||||
if (WeModInfo == null)
|
||||
{
|
||||
Log("Can't be done. Please specify the directory first.", ELogType.Warn);
|
||||
Log(LocalizationManager.Get("log_no_directory"), ELogType.Warn);
|
||||
return;
|
||||
}
|
||||
|
||||
MainWindow.Instance.OpenPopup(new PatchVectorsPopup(async config =>
|
||||
_shell.OpenPopup(new PatchVectorsPopup(async config =>
|
||||
{
|
||||
MainWindow.Instance.ClosePopup();
|
||||
_shell.ClosePopup();
|
||||
IsPatchEnabled = false;
|
||||
IsBusy = true;
|
||||
await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
@@ -159,32 +159,34 @@ namespace WandEnhancer.View.MainWindow
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log($"Failed to patch: {e.Message}", ELogType.Error);
|
||||
Log(LocalizationManager.Format("log_patch_failed", e.Message), ELogType.Error);
|
||||
IsPatchEnabled = true;
|
||||
}
|
||||
});
|
||||
}), Application.Current.FindResource("pv_popup_title") as string);
|
||||
IsBusy = false;
|
||||
}), LocalizationManager.Get("pv_popup_title"));
|
||||
}
|
||||
|
||||
private void Log(string message, ELogType logType)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
message = $"[{logType.ToString().ToUpper()}] {message}";
|
||||
|
||||
var entry = new LogEntry
|
||||
{
|
||||
LogType = logType,
|
||||
Message = message
|
||||
Message = $"[{logType.ToString().ToUpper()}] {message}"
|
||||
};
|
||||
LogList.Add(entry);
|
||||
_view.LogList.ScrollIntoView(entry);
|
||||
_shell.ScrollLogIntoView(entry);
|
||||
// The log commands are disabled while the list is empty, and appending a line
|
||||
// is not user input, so nothing else would re-evaluate CanExecute.
|
||||
System.Windows.Input.CommandManager.InvalidateRequerySuggested();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnOpenSettings(object param)
|
||||
{
|
||||
MainWindow.Instance.OpenPopup(new SettingsPopup(), Application.Current.FindResource("settings_title") as string);
|
||||
_shell.OpenPopup(new SettingsPopup(), LocalizationManager.Get("settings_title"));
|
||||
}
|
||||
|
||||
private string BuildLogReport()
|
||||
@@ -199,67 +201,68 @@ namespace WandEnhancer.View.MainWindow
|
||||
|
||||
private void OnCopyLogs(object param)
|
||||
{
|
||||
if (LogList.Count == 0)
|
||||
try
|
||||
{
|
||||
System.Windows.Clipboard.SetText(BuildLogReport());
|
||||
Log(LocalizationManager.Get("log_copied"), ELogType.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log(LocalizationManager.Format("log_copy_failed", e.Message), ELogType.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExportLogs(object param)
|
||||
{
|
||||
string path = _dialogs.PickSaveFile(
|
||||
LogExportFilter,
|
||||
$"wand-enhancer-log-{DateTime.Now:yyyyMMdd-HHmmss}.txt");
|
||||
if (path == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
System.Windows.Clipboard.SetText(BuildLogReport());
|
||||
Log("Logs copied to clipboard.", ELogType.Success);
|
||||
File.WriteAllText(path, BuildLogReport());
|
||||
Log(LocalizationManager.Format("log_exported", path), ELogType.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log($"Failed to copy logs: {e.Message}", ELogType.Error);
|
||||
Log(LocalizationManager.Format("log_export_failed", e.Message), ELogType.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExportLogs(object param)
|
||||
private bool HasLogs(object param) => LogList.Count > 0;
|
||||
|
||||
/// <summary>The shell could not hand the repository URL to a browser; show it instead.</summary>
|
||||
public void ReportRepositoryLinkFailure(string url)
|
||||
{
|
||||
if (LogList.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (var dialog = new SaveFileDialog
|
||||
{
|
||||
Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*",
|
||||
FileName = $"wand-enhancer-log-{DateTime.Now:yyyyMMdd-HHmmss}.txt"
|
||||
})
|
||||
{
|
||||
if (dialog.ShowDialog() != DialogResult.OK)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(dialog.FileName, BuildLogReport());
|
||||
Log($"Logs exported to '{dialog.FileName}'.", ELogType.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log($"Failed to export logs: {e.Message}", ELogType.Error);
|
||||
}
|
||||
}
|
||||
Log(LocalizationManager.Format("log_open_link_failed", url), ELogType.Warn);
|
||||
}
|
||||
|
||||
public MainWindowVm(MainWindow view)
|
||||
public MainWindowVm(IShellView shell, IFileDialogs dialogs)
|
||||
{
|
||||
_view = view;
|
||||
_shell = shell;
|
||||
_dialogs = dialogs;
|
||||
SetFolderPathCommand = new RelayCommand(OnFolderPathSelection);
|
||||
ApplyPatchCommand = new RelayCommand(OnPatching);
|
||||
RestoreBackupCommand = new RelayCommand(OnBackupRestoring);
|
||||
OpenSettingsCommand = new RelayCommand(OnOpenSettings);
|
||||
CopyLogsCommand = new RelayCommand(OnCopyLogs);
|
||||
ExportLogsCommand = new RelayCommand(OnExportLogs);
|
||||
CopyLogsCommand = new RelayCommand(OnCopyLogs, HasLogs);
|
||||
ExportLogsCommand = new RelayCommand(OnExportLogs, HasLogs);
|
||||
|
||||
WeModInfo = Extensions.FindWeMod();
|
||||
UseInstall(WeModInstalls.FindWeMod());
|
||||
if (WeModInfo == null)
|
||||
{
|
||||
Log("WeMod directory not found.", ELogType.Error);
|
||||
Log(LocalizationManager.Get("log_install_not_found"), ELogType.Error);
|
||||
}
|
||||
|
||||
foreach (var entry in Program.StartupLog)
|
||||
{
|
||||
Log(entry.Key, entry.Value);
|
||||
}
|
||||
Program.StartupLog.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace WandEnhancer.View.MainWindow
|
||||
{
|
||||
internal sealed class WindowsFileDialogs : IFileDialogs
|
||||
{
|
||||
public string PickFolder(string description, string initialPath)
|
||||
{
|
||||
using (var dialog = new FolderBrowserDialog
|
||||
{
|
||||
SelectedPath = initialPath,
|
||||
Description = description,
|
||||
ShowNewFolderButton = false,
|
||||
})
|
||||
{
|
||||
return dialog.ShowDialog() == DialogResult.OK ? dialog.SelectedPath : null;
|
||||
}
|
||||
}
|
||||
|
||||
public string PickSaveFile(string filter, string suggestedFileName)
|
||||
{
|
||||
using (var dialog = new SaveFileDialog { Filter = filter, FileName = suggestedFileName })
|
||||
{
|
||||
return dialog.ShowDialog() == DialogResult.OK ? dialog.FileName : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
<RowDefinition Height="27" />
|
||||
<RowDefinition Height="27" />
|
||||
<RowDefinition Height="27" />
|
||||
<RowDefinition Height="27" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
@@ -39,7 +40,11 @@
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Text="{DynamicResource pv_remote_web_panel_preview}" />
|
||||
<CheckBox Grid.Row="3" Grid.Column="1" x:Name="RemoteWebPanelPreviewBox" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
|
||||
<Border Grid.Row="4" Grid.ColumnSpan="2" Margin="0 14 0 0" Padding="10"
|
||||
<TextBlock Grid.Row="4" Grid.Column="0" VerticalAlignment="Center" Text="{DynamicResource pv_auto_apply}" />
|
||||
<CheckBox Grid.Row="4" Grid.Column="1" x:Name="AutoApplyBox" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
IsChecked="True" />
|
||||
|
||||
<Border Grid.Row="5" Grid.ColumnSpan="2" Margin="0 14 0 0" Padding="10"
|
||||
BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="4"
|
||||
Background="{DynamicResource Muted}">
|
||||
<StackPanel>
|
||||
@@ -91,7 +96,7 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Button Grid.Row="5" Grid.ColumnSpan="2" Padding="0 5 0 5" Margin="0 15 0 0" Content="{DynamicResource pv_start}"
|
||||
<Button Grid.Row="6" Grid.ColumnSpan="2" Padding="0 5 0 5" Margin="0 15 0 0" Content="{DynamicResource pv_start}"
|
||||
Click="OnPatchButtonClick" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -7,13 +7,13 @@ using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Microsoft.Win32;
|
||||
using WandEnhancer.Models;
|
||||
using WandEnhancer.Utils;
|
||||
|
||||
namespace WandEnhancer.View.Popups
|
||||
{
|
||||
public partial class PatchVectorsPopup : UserControl
|
||||
{
|
||||
private const string JavaScriptDialogFilter = "JavaScript files (*.js)|*.js";
|
||||
private const string JavaScriptFileExtension = ".js";
|
||||
|
||||
private readonly Action<PatchConfig> _onApply;
|
||||
private readonly ObservableCollection<SelectedScript> _selectedScripts = new ObservableCollection<SelectedScript>();
|
||||
@@ -40,7 +40,7 @@ namespace WandEnhancer.View.Popups
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var path in dialog.FileNames.Where(IsJavaScriptFile))
|
||||
foreach (var path in dialog.FileNames.Where(WeModInstalls.IsJavaScriptFile))
|
||||
{
|
||||
AddScript(path);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ namespace WandEnhancer.View.Popups
|
||||
{
|
||||
PatchTypes = result,
|
||||
CustomScriptPaths = _selectedScripts.Select(script => script.FullPath).ToList(),
|
||||
AutoApplyPatches = false
|
||||
AutoApplyAfterUpdate = AutoApplyBox.IsChecked == true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,11 +114,6 @@ namespace WandEnhancer.View.Popups
|
||||
_selectedScripts.Add(new SelectedScript(fullPath));
|
||||
}
|
||||
|
||||
private static bool IsJavaScriptFile(string path)
|
||||
{
|
||||
return File.Exists(path) && string.Equals(Path.GetExtension(path), JavaScriptFileExtension, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void UpdateScriptsEmptyState()
|
||||
{
|
||||
NoScriptsText.Visibility = _selectedScripts.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
@@ -56,7 +56,7 @@ namespace WandEnhancer.View.Popups
|
||||
LocalizationManager.CurrentLanguage = _selectedLanguage;
|
||||
}
|
||||
|
||||
MainWindow.MainWindow.Instance.ClosePopup();
|
||||
MainWindow.MainWindow.Instance?.ClosePopup();
|
||||
}
|
||||
|
||||
private class LanguageItem
|
||||
|
||||
@@ -39,12 +39,6 @@
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<StartupObject>WandEnhancer.Program</StartupObject>
|
||||
<CMakeSourceDir>..\tools\asar-fuses-bypass</CMakeSourceDir>
|
||||
<NativeBuildRoot>..\.tmp\cmake</NativeBuildRoot>
|
||||
<NativeBuildConfiguration Condition="'$(Configuration)' == 'Debug'">Debug</NativeBuildConfiguration>
|
||||
<NativeBuildConfiguration Condition="'$(NativeBuildConfiguration)' == ''">Release</NativeBuildConfiguration>
|
||||
<CMakeBuildDir>$(NativeBuildRoot)\asar-fuses-bypass</CMakeBuildDir>
|
||||
<ProxyDllPath>$(CMakeBuildDir)\$(NativeBuildConfiguration)\version.dll</ProxyDllPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
|
||||
@@ -72,24 +66,27 @@
|
||||
<Compile Include="Converters\ToVisibilityConverter.cs" />
|
||||
<Compile Include="Core\Enhancer.cs" />
|
||||
<Compile Include="Core\EnhancerConfig.cs" />
|
||||
<Compile Include="Core\FuseLauncher.cs" />
|
||||
<Compile Include="Core\JavaScriptPatchApplier.cs" />
|
||||
<Compile Include="Core\Js\JsCursor.cs" />
|
||||
<Compile Include="Core\Js\JsFunction.cs" />
|
||||
<Compile Include="Core\Js\PatchPayload.cs" />
|
||||
<Compile Include="Core\Services\LocalizationManager.cs" />
|
||||
<Compile Include="Core\Services\SettingsManager.cs" />
|
||||
<Compile Include="Models\WeModConfig.cs" />
|
||||
<Compile Include="Models\PatchConfig.cs" />
|
||||
<Compile Include="Models\Signature.cs" />
|
||||
<Compile Include="Program.cs" />
|
||||
<Compile Include="ReactiveUICore\AsyncRelayCommand.cs" />
|
||||
<Compile Include="ReactiveUICore\ObservableObject.cs" />
|
||||
<Compile Include="ReactiveUICore\RelayCommand.cs" />
|
||||
<Compile Include="Utils\Common.cs" />
|
||||
<Compile Include="Utils\Extensions.cs" />
|
||||
<Compile Include="Utils\Win32\Shortcut.cs" />
|
||||
<Compile Include="View\Controls\InfoItem.xaml.cs">
|
||||
<DependentUpon>InfoItem.xaml</DependentUpon>
|
||||
<Compile Include="Utils\ProcessTerminator.cs" />
|
||||
<Compile Include="Utils\WeModInstalls.cs" />
|
||||
<Compile Include="View\Controls\PopupHost.xaml.cs">
|
||||
<DependentUpon>PopupHost.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="View\Controls\PopupHost.xaml.cs" />
|
||||
<Compile Include="View\MainWindow\Logs.cs" />
|
||||
<Compile Include="View\MainWindow\MainWindow.xaml.cs" />
|
||||
<Compile Include="View\MainWindow\IShellView.cs" />
|
||||
<Compile Include="View\MainWindow\WindowsFileDialogs.cs" />
|
||||
<Compile Include="View\MainWindow\MainWindowVm.cs" />
|
||||
<Compile Include="View\Popups\PatchVectorsPopup.xaml.cs">
|
||||
<DependentUpon>PatchVectorsPopup.xaml</DependentUpon>
|
||||
@@ -116,7 +113,6 @@
|
||||
<Page Include="Style\ColorScheme.xaml" />
|
||||
<Page Include="Style\Icons.xaml" />
|
||||
<Page Include="Style\Styles.xaml" />
|
||||
<Page Include="View\Controls\InfoItem.xaml" />
|
||||
<Page Include="View\Controls\PopupHost.xaml" />
|
||||
<Page Include="View\MainWindow\MainWindow.xaml" />
|
||||
<Page Include="View\Popups\PatchVectorsPopup.xaml" />
|
||||
@@ -152,16 +148,16 @@
|
||||
<Name>AsarSharp</Name>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="$(ProxyDllPath)">
|
||||
<LogicalName>proxydll</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\web-panel\dist\**\*.*" Condition="Exists('..\web-panel\dist\index.html')">
|
||||
<LogicalName>remote-panel/dist/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Patches\*.js">
|
||||
<LogicalName>patches/%(Filename)%(Extension)</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
|
||||
@@ -171,14 +167,6 @@
|
||||
<Error Condition="!Exists('..\packages\ILRepack.2.0.41\build\ILRepack.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\ILRepack.2.0.41\build\ILRepack.props'))" />
|
||||
</Target>
|
||||
|
||||
<Target Name="ValidateNativeArtifacts" BeforeTargets="BeforeBuild">
|
||||
<Error Text="Proxy DLL not found: $(ProxyDllPath)"
|
||||
Condition="!Exists('$(ProxyDllPath)')" />
|
||||
|
||||
<Message Text="Embedding Proxy DLL as resource from $(ProxyDllPath)"
|
||||
Importance="high" />
|
||||
</Target>
|
||||
|
||||
<Target Name="ILRepack" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
|
||||
<PropertyGroup>
|
||||
<ILRepackExe>..\packages\ILRepack.2.0.41\tools\ILRepack.exe</ILRepackExe>
|
||||
|
||||
@@ -7,9 +7,6 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$webPanelDir = Join-Path $repoRoot 'web-panel'
|
||||
$nativeBuildRoot = Join-Path $repoRoot '.tmp/cmake'
|
||||
$asarFusesSourceDir = Join-Path $repoRoot 'tools/asar-fuses-bypass'
|
||||
$asarFusesBuildDir = Join-Path $nativeBuildRoot 'asar-fuses-bypass'
|
||||
$solutionPath = Join-Path $repoRoot 'Wand-Enhancer.sln'
|
||||
|
||||
function Resolve-CommandPath {
|
||||
@@ -48,23 +45,6 @@ function Resolve-MSBuildPath {
|
||||
return $msbuildPath
|
||||
}
|
||||
|
||||
function Resolve-DumpBinPath {
|
||||
param([string]$VisualStudioPath)
|
||||
|
||||
$versionFile = Join-Path $VisualStudioPath 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt'
|
||||
if (-not (Test-Path $versionFile)) {
|
||||
throw "MSVC tools version file not found: $versionFile"
|
||||
}
|
||||
|
||||
$toolsVersion = (Get-Content $versionFile -Raw).Trim()
|
||||
$dumpBinPath = Join-Path $VisualStudioPath "VC\Tools\MSVC\$toolsVersion\bin\Hostx64\x64\dumpbin.exe"
|
||||
if (-not (Test-Path $dumpBinPath)) {
|
||||
throw "dumpbin.exe not found: $dumpBinPath"
|
||||
}
|
||||
|
||||
return $dumpBinPath
|
||||
}
|
||||
|
||||
function Invoke-Step {
|
||||
param(
|
||||
[string]$Label,
|
||||
@@ -78,43 +58,47 @@ function Invoke-Step {
|
||||
}
|
||||
}
|
||||
|
||||
$cmake = Resolve-CommandPath 'cmake'
|
||||
function Resolve-TargetFrameworkRoot {
|
||||
# Some environments do not register the v4.8 targeting pack for MSBuild to find on its own.
|
||||
# Point at it explicitly when present; skip on CI where default resolution already works.
|
||||
$root = Join-Path ${env:ProgramFiles(x86)} 'Reference Assemblies\Microsoft\Framework'
|
||||
$frameworkList = Join-Path $root '.NETFramework\v4.8\RedistList\FrameworkList.xml'
|
||||
if (Test-Path $frameworkList) {
|
||||
return $root
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
$pnpm = Resolve-CommandPath 'pnpm'
|
||||
$visualStudio = Resolve-VisualStudioPath
|
||||
$msbuild = Resolve-MSBuildPath $visualStudio
|
||||
$dumpBin = Resolve-DumpBinPath $visualStudio
|
||||
$targetFrameworkRoot = Resolve-TargetFrameworkRoot
|
||||
|
||||
$buildArgs = @('/m', "/p:Configuration=$Configuration", '/p:Platform=Any CPU')
|
||||
if ($targetFrameworkRoot) {
|
||||
$buildArgs += "/p:TargetFrameworkRootPath=$targetFrameworkRoot"
|
||||
}
|
||||
|
||||
Invoke-Step 'Install web-panel dependencies' {
|
||||
& $pnpm --dir $webPanelDir install --frozen-lockfile
|
||||
}
|
||||
|
||||
Invoke-Step 'Lint web-panel' {
|
||||
& $pnpm --dir $webPanelDir run lint
|
||||
}
|
||||
|
||||
# Runs type-check (web + bridge), Vite, the bridge bundle, then the dist invariant check.
|
||||
Invoke-Step 'Build web-panel' {
|
||||
& $pnpm --dir $webPanelDir run build
|
||||
}
|
||||
|
||||
Invoke-Step 'Configure asar-fuses-bypass' {
|
||||
Remove-Item Env:CMAKE_GENERATOR -ErrorAction SilentlyContinue
|
||||
& $cmake -S $asarFusesSourceDir -B $asarFusesBuildDir -A x64
|
||||
}
|
||||
|
||||
Invoke-Step 'Build asar-fuses-bypass' {
|
||||
& $cmake --build $asarFusesBuildDir --config $Configuration
|
||||
}
|
||||
|
||||
Invoke-Step 'Verify native runtime dependencies' {
|
||||
$nativeDll = Join-Path $asarFusesBuildDir "$Configuration\version.dll"
|
||||
$dependencies = & $dumpBin /dependents $nativeDll
|
||||
if ($dependencies -match '(?im)^\s*(VCRUNTIME|MSVCP|api-ms-win-crt-)[^\s]*\.dll\s*$') {
|
||||
throw 'version.dll depends on the dynamic Visual C++ runtime.'
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Step 'Restore NuGet packages' {
|
||||
& $msbuild $solutionPath /m /t:Restore /p:RestorePackagesConfig=true
|
||||
}
|
||||
|
||||
Invoke-Step 'Build solution' {
|
||||
& $msbuild $solutionPath /m /p:Configuration=$Configuration '/p:Platform=Any CPU' /t:Build
|
||||
& $msbuild $solutionPath @buildArgs /t:Build
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
|
||||
@@ -18,7 +18,8 @@ function Normalize-Version {
|
||||
throw 'Version value cannot be empty.'
|
||||
}
|
||||
|
||||
return $Value.Trim().TrimStart('v', 'V')
|
||||
# A pre-release tag (1.1.0.0-rc.1) reads the notes of its base version.
|
||||
return ($Value.Trim().TrimStart('v', 'V') -replace '-.*$', '')
|
||||
}
|
||||
|
||||
function Get-ChangelogSection {
|
||||
|
||||
@@ -16,7 +16,9 @@ function Normalize-Version {
|
||||
throw 'Version value cannot be empty.'
|
||||
}
|
||||
|
||||
return $Value.Trim().TrimStart('v', 'V')
|
||||
# AssemblyVersion holds four numbers only, so a pre-release tag such as
|
||||
# 1.1.0.0-rc.1 must compare and look up its notes as 1.1.0.0.
|
||||
return ($Value.Trim().TrimStart('v', 'V') -replace '-.*$', '')
|
||||
}
|
||||
|
||||
function Get-ChangelogSection {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# Build directories
|
||||
/build/
|
||||
/build-debug/
|
||||
/build-release/
|
||||
/out/
|
||||
|
||||
# CMake generated files
|
||||
CMakeCache.txt
|
||||
CMakeFiles/
|
||||
cmake_install.cmake
|
||||
CTestTestfile.cmake
|
||||
Makefile
|
||||
install_manifest.txt
|
||||
|
||||
# Compiled binaries
|
||||
*.o
|
||||
*.obj
|
||||
*.lo
|
||||
*.la
|
||||
*.a
|
||||
*.so
|
||||
*.so.*
|
||||
*.dylib
|
||||
*.dll
|
||||
*.exe
|
||||
*.out
|
||||
*.app
|
||||
|
||||
# Debug files
|
||||
*.pch
|
||||
*.pdb
|
||||
*.mod
|
||||
*.map
|
||||
|
||||
# Generated configuration headers
|
||||
config.h
|
||||
config.hpp
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# IDE files
|
||||
# VS Code
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# CLion
|
||||
.idea/
|
||||
|
||||
# Visual Studio
|
||||
*.user
|
||||
*.suo
|
||||
*.vcxproj.user
|
||||
*.vcxproj.*
|
||||
*.sln
|
||||
|
||||
# Xcode
|
||||
*.pbxuser
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.perspectivev3
|
||||
*.xcworkspace/
|
||||
xcuserdata/
|
||||
|
||||
# OS junk
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Backup files
|
||||
*~
|
||||
*.swp
|
||||
*.tmp
|
||||
@@ -1,21 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
cmake_policy(SET CMP0091 NEW)
|
||||
project(asar_fuses_bypass C)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
|
||||
#[[
|
||||
add_executable(asar_fuses_bypass main.c)
|
||||
]]
|
||||
|
||||
set(CMAKE_SHARED_LIBRARY_PREFIX "")
|
||||
set(CMAKE_STATIC_LIBRARY_PREFIX "")
|
||||
|
||||
add_library(version SHARED library.c library.def fuses.c)
|
||||
|
||||
if(MSVC)
|
||||
set_property(TARGET version PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
|
||||
elseif(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_link_options(version PRIVATE -static -static-libgcc -static-libstdc++)
|
||||
endif()
|
||||
@@ -1,190 +0,0 @@
|
||||
//
|
||||
// Created by kitbyte on 30.11.2025.
|
||||
//
|
||||
|
||||
#include <Windows.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
#define ENABLE_LOGGING 0
|
||||
|
||||
#ifndef _DEBUG
|
||||
#undef ENABLE_LOGGING
|
||||
#define ENABLE_LOGGING 0
|
||||
#endif
|
||||
|
||||
#define FUSE_SENTINEL_LENGTH 32
|
||||
#define FUSE_VERSION_SUPPORTED 1
|
||||
#define FUSE_MIN_WIRE_LENGTH 5
|
||||
|
||||
#define ALIGN8(ptr, mod) ((((ULONG_PTR)(ptr) + 7) & ~7) + ((mod) * 8))
|
||||
|
||||
#if defined(_WIN64)
|
||||
#define SENTINEL_PART1 0x6E64474B70374C64ULL
|
||||
#define SENTINEL_PART2 0x6262503639377A4EULL
|
||||
#define SENTINEL_PART3 0x58486D4B4E57516AULL
|
||||
#define SENTINEL_PART4 0x5873743942615A42ULL
|
||||
#else
|
||||
static const DWORD SENTINEL_PARTS[8] = {
|
||||
0x70374C64, 0x6E64474B,
|
||||
0x39377A4E, 0x62625036,
|
||||
0x4E57516A, 0x58486D4B,
|
||||
0x42615A42, 0x58737439
|
||||
};
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
FUSE_RUN_AS_NODE = 0,
|
||||
FUSE_COOKIE_ENCRYPTION = 1,
|
||||
FUSE_NODE_OPTIONS = 2,
|
||||
FUSE_NODE_CLI_INSPECT = 3,
|
||||
FUSE_ASAR_INTEGRITY_VALIDATION = 4,
|
||||
FUSE_ONLY_LOAD_APP_FROM_ASAR = 5,
|
||||
FUSE_LOAD_BROWSER_V8_SNAPSHOT = 6,
|
||||
FUSE_GRANT_FILE_PROTOCOL = 7
|
||||
} ElectronFuseIndex;
|
||||
|
||||
typedef enum {
|
||||
FUSE_STATE_DISABLED = '0',
|
||||
FUSE_STATE_ENABLED = '1',
|
||||
FUSE_STATE_REMOVED = 'r'
|
||||
} FuseState;
|
||||
|
||||
typedef struct {
|
||||
char sentinel[FUSE_SENTINEL_LENGTH];
|
||||
unsigned char version;
|
||||
unsigned char wire_length;
|
||||
unsigned char fuses[];
|
||||
} FuseWire;
|
||||
|
||||
#if ENABLE_LOGGING
|
||||
|
||||
static FILE* g_logFile = NULL;
|
||||
|
||||
static void log_init(void) {
|
||||
char path[MAX_PATH];
|
||||
GetModuleFileNameA(NULL, path, MAX_PATH);
|
||||
char* dot = strrchr(path, '.');
|
||||
if (dot) strcpy(dot, ".log");
|
||||
else strcat(path, ". log");
|
||||
|
||||
g_logFile = fopen(path, "a");
|
||||
if (g_logFile) {
|
||||
time_t now = time(NULL);
|
||||
fprintf(g_logFile, "\n=== Session: %s", ctime(&now));
|
||||
fflush(g_logFile);
|
||||
}
|
||||
}
|
||||
|
||||
static void log_close(void) {
|
||||
if (g_logFile) {
|
||||
fclose(g_logFile);
|
||||
g_logFile = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static void log_msg(const char* fmt, .. .) {
|
||||
if (!g_logFile) return;
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
vfprintf(g_logFile, fmt, args);
|
||||
va_end(args);
|
||||
fflush(g_logFile);
|
||||
}
|
||||
|
||||
#else
|
||||
#define log_init() ((void)0)
|
||||
#define log_close() ((void)0)
|
||||
#define log_msg(...) ((void)0)
|
||||
#endif
|
||||
|
||||
static FuseWire* find_fuse_wire(int offset) {
|
||||
char* base = (char*)GetModuleHandleA(NULL);
|
||||
if (!base) return NULL;
|
||||
|
||||
IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base;
|
||||
if (dos->e_magic != IMAGE_DOS_SIGNATURE) return NULL;
|
||||
|
||||
IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(base + dos->e_lfanew);
|
||||
if (nt->Signature != IMAGE_NT_SIGNATURE) return NULL;
|
||||
|
||||
DWORD size = nt->OptionalHeader.SizeOfImage;
|
||||
char* start = (char*)ALIGN8(base, 1) + offset;
|
||||
char* end = (char*)ALIGN8(base + size - FUSE_SENTINEL_LENGTH, -1) - offset;
|
||||
|
||||
#if defined(_WIN64)
|
||||
for (DWORD64* p = (DWORD64*)start; p < (DWORD64*)end; p++) {
|
||||
if (p[0] == SENTINEL_PART1 && p[1] == SENTINEL_PART2 &&
|
||||
p[2] == SENTINEL_PART3 && p[3] == SENTINEL_PART4) {
|
||||
log_msg("[+] Sentinel at: %p\n", p);
|
||||
return (FuseWire*)p;
|
||||
}
|
||||
}
|
||||
#else
|
||||
for (DWORD* p = (DWORD*)start; p < (DWORD*)end; p += 2) {
|
||||
if (p[0] == SENTINEL_PARTS[0] && p[1] == SENTINEL_PARTS[1] &&
|
||||
p[2] == SENTINEL_PARTS[2] && p[3] == SENTINEL_PARTS[3] &&
|
||||
p[4] == SENTINEL_PARTS[4] && p[5] == SENTINEL_PARTS[5] &&
|
||||
p[6] == SENTINEL_PARTS[6] && p[7] == SENTINEL_PARTS[7]) {
|
||||
log_msg("[+] Sentinel at: %p\n", p);
|
||||
return (FuseWire*)p;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static BOOL patch_fuse(unsigned char* fuse) {
|
||||
DWORD prot;
|
||||
if (!VirtualProtect(fuse, 1, PAGE_READWRITE, &prot)) {
|
||||
log_msg("[-] VirtualProtect failed: %lu\n", GetLastError());
|
||||
return FALSE;
|
||||
}
|
||||
*fuse = FUSE_STATE_REMOVED;
|
||||
VirtualProtect(fuse, 1, prot, &prot);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
BOOL disable_asar_integrity(void) {
|
||||
log_init();
|
||||
|
||||
FuseWire* wire = find_fuse_wire(0);
|
||||
if (! wire) wire = find_fuse_wire(4);
|
||||
|
||||
if (! wire) {
|
||||
log_msg("[-] Fuse wire not found\n");
|
||||
log_close();
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
log_msg("[+] Wire at %p, ver=%d, len=%d\n", wire, wire->version, wire->wire_length);
|
||||
|
||||
if (wire->version != FUSE_VERSION_SUPPORTED) {
|
||||
log_msg("[-] Unsupported version: %d\n", wire->version);
|
||||
log_close();
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (wire->wire_length < FUSE_MIN_WIRE_LENGTH) {
|
||||
log_msg("[*] Wire too short, skip\n");
|
||||
log_close();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
unsigned char* target = &wire->fuses[FUSE_ASAR_INTEGRITY_VALIDATION];
|
||||
|
||||
if (*target == FUSE_STATE_REMOVED) {
|
||||
log_msg("[*] Already patched\n");
|
||||
log_close();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
log_msg("[*] Patching fuse[%d]: 0x%02X -> 0x%02X\n",
|
||||
FUSE_ASAR_INTEGRITY_VALIDATION, *target, FUSE_STATE_REMOVED);
|
||||
|
||||
BOOL result = patch_fuse(target);
|
||||
log_msg(result ? "[+] Success\n" : "[-] Failed\n");
|
||||
|
||||
log_close();
|
||||
return result;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
//
|
||||
// Created by kitbyte on 30.11.2025.
|
||||
//
|
||||
#include <Windows.h>
|
||||
#include <winver.h>
|
||||
|
||||
extern BOOL disable_asar_integrity(void);
|
||||
|
||||
static HMODULE g_originalVersionDll;
|
||||
|
||||
#define FOR_EACH_VERSION_FORWARDER(X) \
|
||||
X(GetFileVersionInfoA, BOOL, FALSE, \
|
||||
(LPCSTR filename, DWORD handle, DWORD length, LPVOID data), \
|
||||
(filename, handle, length, data)) \
|
||||
X(GetFileVersionInfoExA, BOOL, FALSE, \
|
||||
(DWORD flags, LPCSTR filename, DWORD handle, DWORD length, LPVOID data), \
|
||||
(flags, filename, handle, length, data)) \
|
||||
X(GetFileVersionInfoExW, BOOL, FALSE, \
|
||||
(DWORD flags, LPCWSTR filename, DWORD handle, DWORD length, LPVOID data), \
|
||||
(flags, filename, handle, length, data)) \
|
||||
X(GetFileVersionInfoSizeA, DWORD, 0, \
|
||||
(LPCSTR filename, LPDWORD handle), \
|
||||
(filename, handle)) \
|
||||
X(GetFileVersionInfoSizeExA, DWORD, 0, \
|
||||
(DWORD flags, LPCSTR filename, LPDWORD handle), \
|
||||
(flags, filename, handle)) \
|
||||
X(GetFileVersionInfoSizeExW, DWORD, 0, \
|
||||
(DWORD flags, LPCWSTR filename, LPDWORD handle), \
|
||||
(flags, filename, handle)) \
|
||||
X(GetFileVersionInfoSizeW, DWORD, 0, \
|
||||
(LPCWSTR filename, LPDWORD handle), \
|
||||
(filename, handle)) \
|
||||
X(GetFileVersionInfoW, BOOL, FALSE, \
|
||||
(LPCWSTR filename, DWORD handle, DWORD length, LPVOID data), \
|
||||
(filename, handle, length, data)) \
|
||||
X(VerFindFileA, DWORD, 0, \
|
||||
(DWORD flags, LPCSTR fileName, LPCSTR winDir, LPCSTR appDir, LPSTR curDir, PUINT curDirLen, LPSTR destDir, PUINT destDirLen), \
|
||||
(flags, fileName, winDir, appDir, curDir, curDirLen, destDir, destDirLen)) \
|
||||
X(VerFindFileW, DWORD, 0, \
|
||||
(DWORD flags, LPCWSTR fileName, LPCWSTR winDir, LPCWSTR appDir, LPWSTR curDir, PUINT curDirLen, LPWSTR destDir, PUINT destDirLen), \
|
||||
(flags, fileName, winDir, appDir, curDir, curDirLen, destDir, destDirLen)) \
|
||||
X(VerInstallFileA, DWORD, 0, \
|
||||
(DWORD flags, LPCSTR srcFileName, LPCSTR destFileName, LPCSTR srcDir, LPCSTR destDir, LPCSTR curDir, LPSTR tempFile, PUINT tempFileLen), \
|
||||
(flags, srcFileName, destFileName, srcDir, destDir, curDir, tempFile, tempFileLen)) \
|
||||
X(VerInstallFileW, DWORD, 0, \
|
||||
(DWORD flags, LPCWSTR srcFileName, LPCWSTR destFileName, LPCWSTR srcDir, LPCWSTR destDir, LPCWSTR curDir, LPWSTR tempFile, PUINT tempFileLen), \
|
||||
(flags, srcFileName, destFileName, srcDir, destDir, curDir, tempFile, tempFileLen)) \
|
||||
X(VerLanguageNameA, DWORD, 0, \
|
||||
(DWORD language, LPSTR buffer, DWORD bufferLength), \
|
||||
(language, buffer, bufferLength)) \
|
||||
X(VerLanguageNameW, DWORD, 0, \
|
||||
(DWORD language, LPWSTR buffer, DWORD bufferLength), \
|
||||
(language, buffer, bufferLength)) \
|
||||
X(VerQueryValueA, BOOL, FALSE, \
|
||||
(LPCVOID block, LPCSTR subBlock, LPVOID* buffer, PUINT bufferLength), \
|
||||
(block, subBlock, buffer, bufferLength)) \
|
||||
X(VerQueryValueW, BOOL, FALSE, \
|
||||
(LPCVOID block, LPCWSTR subBlock, LPVOID* buffer, PUINT bufferLength), \
|
||||
(block, subBlock, buffer, bufferLength))
|
||||
|
||||
#if defined(_MSC_VER) && !defined(_WIN64)
|
||||
|
||||
#define DECLARE_FORWARDER(name, return_type, default_value, params, args) \
|
||||
static FARPROC s_##name; \
|
||||
__declspec(naked) return_type WINAPI name params \
|
||||
{ \
|
||||
__asm \
|
||||
{ \
|
||||
jmp dword ptr [s_##name] \
|
||||
} \
|
||||
}
|
||||
|
||||
#define LOAD_FORWARDER(name, return_type, default_value, params, args) \
|
||||
s_##name = GetProcAddress(g_originalVersionDll, #name);
|
||||
|
||||
#else
|
||||
|
||||
#define DECLARE_FORWARDER(name, return_type, default_value, params, args) \
|
||||
typedef return_type (WINAPI *name##_fn) params; \
|
||||
static name##_fn s_##name; \
|
||||
return_type WINAPI name params \
|
||||
{ \
|
||||
if (s_##name == NULL) \
|
||||
{ \
|
||||
SetLastError(ERROR_PROC_NOT_FOUND); \
|
||||
return default_value; \
|
||||
} \
|
||||
return s_##name args; \
|
||||
}
|
||||
|
||||
#define LOAD_FORWARDER(name, return_type, default_value, params, args) \
|
||||
s_##name = (name##_fn)GetProcAddress(g_originalVersionDll, #name);
|
||||
|
||||
#endif
|
||||
|
||||
FOR_EACH_VERSION_FORWARDER(DECLARE_FORWARDER)
|
||||
|
||||
BOOL WINAPI GetFileVersionInfoByHandle(void)
|
||||
{
|
||||
SetLastError(ERROR_CALL_NOT_IMPLEMENTED);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static BOOL SourceInit(void)
|
||||
{
|
||||
WCHAR source[MAX_PATH];
|
||||
UINT sourceLength = GetSystemDirectoryW(source, MAX_PATH);
|
||||
|
||||
if (sourceLength == 0 || sourceLength >= MAX_PATH)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (wcscat_s(source, MAX_PATH, L"\\version.dll") != 0)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
g_originalVersionDll = LoadLibraryW(source);
|
||||
if (!g_originalVersionDll)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
FOR_EACH_VERSION_FORWARDER(LOAD_FORWARDER);
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
BOOL WINAPI DllMain(HMODULE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
|
||||
{
|
||||
(void)lpvReserved;
|
||||
|
||||
if (fdwReason == DLL_PROCESS_ATTACH)
|
||||
{
|
||||
DisableThreadLibraryCalls(hinstDLL);
|
||||
|
||||
if (!SourceInit())
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
disable_asar_integrity();
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
LIBRARY "VERSION"
|
||||
EXPORTS
|
||||
|
||||
GetFileVersionInfoA
|
||||
GetFileVersionInfoByHandle
|
||||
GetFileVersionInfoExA
|
||||
GetFileVersionInfoExW
|
||||
GetFileVersionInfoSizeA
|
||||
GetFileVersionInfoSizeExA
|
||||
GetFileVersionInfoSizeExW
|
||||
GetFileVersionInfoSizeW
|
||||
GetFileVersionInfoW
|
||||
VerFindFileA
|
||||
VerFindFileW
|
||||
VerInstallFileA
|
||||
VerInstallFileW
|
||||
VerLanguageNameA
|
||||
VerLanguageNameW
|
||||
VerQueryValueA
|
||||
VerQueryValueW
|
||||
@@ -12,9 +12,12 @@ export const RETRY_DELAY_MS = 1000
|
||||
export const MAX_BOOTSTRAP_ATTEMPTS = 60
|
||||
export const SYNC_INTERVAL_MS = 15000
|
||||
export const OPTIONAL_SERVICES_RETRY_INTERVAL_MS = 1000
|
||||
export const MAX_OPTIONAL_SERVICES_ATTEMPTS = 60
|
||||
export const FOLLOW_UP_SYNC_DELAY_MS = 2500
|
||||
export const UNAVAILABLE_TITLES_BATCH_SIZE = 250
|
||||
export const BOOTSTRAP_LOG_THROTTLE_ATTEMPTS = 5
|
||||
export const CONTAINER_LOG_THROTTLE_ATTEMPTS = 10
|
||||
export const CONTAINER_GRAPH_MAX_DEPTH = 4
|
||||
// Wand's webpack module exports the trainer-launch-request class under key `vO`.
|
||||
// Required so `trainerService.launch(req)` records `getMetadata(vO)` state in Wand. See AGENTS.md "Remote Play".
|
||||
export const TRAINER_LAUNCH_REQUEST_EXPORT_KEY = "vO"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TRAINER_ENDED_EVENT,
|
||||
TRAINER_SNAPSHOT_CHANNEL,
|
||||
} from "./constants.js"
|
||||
import { isRecord, safeString, toStringId } from "./runtime.js"
|
||||
import { invokeIpc, isRecord, safeString, toStringId } from "./runtime.js"
|
||||
|
||||
export function createIdleGameSession() {
|
||||
return {
|
||||
@@ -97,19 +97,7 @@ export function clearTrainerSnapshot(state, reason, clearSession = false) {
|
||||
}
|
||||
|
||||
void syncGameStatus(state, true)
|
||||
if (!state.ipcRenderer) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
void state.ipcRenderer.invoke(TRAINER_SNAPSHOT_CHANNEL, null)
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Trainer snapshot clear IPC failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
}
|
||||
void invokeIpc(state, TRAINER_SNAPSHOT_CHANNEL, null, "Trainer snapshot clear")
|
||||
}
|
||||
|
||||
export async function syncGameStatus(state, force = false) {
|
||||
@@ -125,22 +113,21 @@ export async function syncGameStatus(state, force = false) {
|
||||
|
||||
state.lastGameStatusSignature = signature
|
||||
|
||||
try {
|
||||
await state.ipcRenderer.invoke(GAME_STATUS_CHANNEL, snapshot)
|
||||
const sent = await invokeIpc(
|
||||
state,
|
||||
GAME_STATUS_CHANNEL,
|
||||
snapshot,
|
||||
"Game status snapshot",
|
||||
"error"
|
||||
)
|
||||
if (sent) {
|
||||
state.log(
|
||||
"info",
|
||||
"Game status snapshot sent.",
|
||||
`session=${snapshot.session.state}/${snapshot.session.event}, trainer=${snapshot.trainer.state}/${snapshot.trainer.event}`
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"error",
|
||||
"Game status snapshot IPC failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return false
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
function installLifecycleSubscriptions(state) {
|
||||
|
||||
+52
-23
@@ -2,9 +2,11 @@ import {
|
||||
BIND_CHANNEL,
|
||||
BOOTSTRAP_LOG_THROTTLE_ATTEMPTS,
|
||||
COMMAND_REQUEST_CHANNEL,
|
||||
CONTAINER_LOG_THROTTLE_ATTEMPTS,
|
||||
FOLLOW_UP_SYNC_DELAY_MS,
|
||||
GLOBAL_FLAG,
|
||||
MAX_BOOTSTRAP_ATTEMPTS,
|
||||
MAX_OPTIONAL_SERVICES_ATTEMPTS,
|
||||
OPTIONAL_SERVICES_RETRY_INTERVAL_MS,
|
||||
RETRY_DELAY_MS,
|
||||
SYNC_CHANNEL,
|
||||
@@ -24,11 +26,13 @@ import {
|
||||
import { createLogger } from "./logger.js"
|
||||
import { handleRemoteCommandRequest } from "./remote-commands.js"
|
||||
import {
|
||||
formatError,
|
||||
getAppRoot,
|
||||
getAureliaContainer,
|
||||
getRequire,
|
||||
getWebpackRequire,
|
||||
hasAppRoot,
|
||||
invokeIpc,
|
||||
isRecord,
|
||||
summarizeAureliaSubtree,
|
||||
} from "./runtime.js"
|
||||
@@ -36,6 +40,7 @@ import {
|
||||
getInstalledAppsService,
|
||||
getStoreRef,
|
||||
hasMissingOptionalServices,
|
||||
hasUnresolvedServices,
|
||||
resolveOptionalServices,
|
||||
} from "./services.js"
|
||||
|
||||
@@ -70,7 +75,9 @@ function createState(WandEnhancer) {
|
||||
pollTimer: null,
|
||||
optionalServicesTimer: null,
|
||||
bootstrapAttempts: 0,
|
||||
optionalServicesAttempts: 0,
|
||||
bridgeBound: false,
|
||||
bridgeBinding: false,
|
||||
refreshPatched: false,
|
||||
installedAppsService: null,
|
||||
gameLifecycleService: null,
|
||||
@@ -114,13 +121,11 @@ function setBootstrapReason(state, reason) {
|
||||
)
|
||||
}
|
||||
|
||||
function bindBridge(state) {
|
||||
if (state.bridgeBound || !state.ipcRenderer) {
|
||||
async function bindBridge(state) {
|
||||
if (state.bridgeBound || state.bridgeBinding || !state.ipcRenderer) {
|
||||
return
|
||||
}
|
||||
|
||||
state.bridgeBound = true
|
||||
|
||||
if (!state.commandListenerInstalled) {
|
||||
state.ipcRenderer.on(COMMAND_REQUEST_CHANNEL, (event, request) =>
|
||||
handleRemoteCommandRequest(state, event, request)
|
||||
@@ -129,11 +134,18 @@ function bindBridge(state) {
|
||||
state.log("info", "Bridge remote command handler installed.")
|
||||
}
|
||||
|
||||
// Await the bind: invoke rejects asynchronously, so marking the bridge bound up
|
||||
// front left set-value permanently dead whenever the main-process handler was not
|
||||
// registered yet - and the log still claimed success.
|
||||
state.bridgeBinding = true
|
||||
try {
|
||||
void state.ipcRenderer.invoke(BIND_CHANNEL)
|
||||
state.log("info", "Bridge set-value handler bind requested.")
|
||||
await state.ipcRenderer.invoke(BIND_CHANNEL)
|
||||
state.bridgeBound = true
|
||||
state.log("info", "Bridge set-value handler bound.")
|
||||
} catch (error) {
|
||||
state.log("warn", "Bridge bind failed.", error?.stack || String(error))
|
||||
state.log("warn", "Bridge bind failed; will retry on the next sync.", formatError(error))
|
||||
} finally {
|
||||
state.bridgeBinding = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,22 +182,21 @@ async function syncInstalledApps(state, force = false) {
|
||||
|
||||
state.lastSignature = signature
|
||||
|
||||
try {
|
||||
await state.ipcRenderer.invoke(SYNC_CHANNEL, snapshot)
|
||||
const sent = await invokeIpc(
|
||||
state,
|
||||
SYNC_CHANNEL,
|
||||
snapshot,
|
||||
"Installed apps snapshot",
|
||||
"error"
|
||||
)
|
||||
if (sent) {
|
||||
state.log(
|
||||
"info",
|
||||
"Installed apps snapshot sent.",
|
||||
`apps=${snapshot.apps.length}, catalogGames=${snapshot.diagnostics.catalogGames}, rawInstalledApps=${snapshot.diagnostics.rawInstalledApps}`
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"error",
|
||||
"Installed apps snapshot IPC failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return false
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
function queueSync(state, force = false) {
|
||||
@@ -269,11 +280,25 @@ function startOptionalServicesRetry(state) {
|
||||
}
|
||||
|
||||
state.optionalServicesTimer = setInterval(() => {
|
||||
state.optionalServicesAttempts += 1
|
||||
|
||||
const container = getAureliaContainer()
|
||||
const webpackRequire = getWebpackRequire()
|
||||
if (container && webpackRequire) {
|
||||
resolveRuntimeServices(state, container, webpackRequire)
|
||||
}
|
||||
|
||||
if (
|
||||
state.optionalServicesTimer &&
|
||||
state.optionalServicesAttempts >= MAX_OPTIONAL_SERVICES_ATTEMPTS
|
||||
) {
|
||||
stopOptionalServicesRetry(state)
|
||||
state.log(
|
||||
"warn",
|
||||
"Optional service retry exhausted.",
|
||||
`attempts=${state.optionalServicesAttempts}`
|
||||
)
|
||||
}
|
||||
}, OPTIONAL_SERVICES_RETRY_INTERVAL_MS)
|
||||
|
||||
state.log(
|
||||
@@ -341,7 +366,7 @@ function bootstrap(state) {
|
||||
}
|
||||
}
|
||||
|
||||
bindBridge(state)
|
||||
void bindBridge(state)
|
||||
patchRefreshApps(state)
|
||||
queueSync(state, true)
|
||||
queueFollowUpSync(state)
|
||||
@@ -369,11 +394,15 @@ function startPollTimer(state) {
|
||||
}
|
||||
|
||||
state.pollTimer = setInterval(() => {
|
||||
const container = getAureliaContainer()
|
||||
const webpackRequire = getWebpackRequire()
|
||||
if (container && webpackRequire) {
|
||||
resolveRuntimeServices(state, container, webpackRequire)
|
||||
if (hasUnresolvedServices(state)) {
|
||||
const container = getAureliaContainer()
|
||||
const webpackRequire = getWebpackRequire()
|
||||
if (container && webpackRequire) {
|
||||
resolveRuntimeServices(state, container, webpackRequire)
|
||||
}
|
||||
}
|
||||
|
||||
void bindBridge(state)
|
||||
void syncInstalledApps(state)
|
||||
}, SYNC_INTERVAL_MS)
|
||||
|
||||
@@ -385,7 +414,7 @@ function startPollTimer(state) {
|
||||
}
|
||||
|
||||
function logMissingContainer(state) {
|
||||
if (state.bootstrapAttempts % 10 !== 0) {
|
||||
if (state.bootstrapAttempts % CONTAINER_LOG_THROTTLE_ATTEMPTS !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "./artwork.js"
|
||||
import {
|
||||
getBasename,
|
||||
formatError,
|
||||
isRecord,
|
||||
normalizeStringList,
|
||||
safeString,
|
||||
@@ -140,7 +141,8 @@ export function buildSnapshot(state) {
|
||||
|
||||
const preferredApp = pickPreferredInstalledApp(
|
||||
rawInstalledApps,
|
||||
getCatalogGameCorrelationIds(game, versions)
|
||||
game,
|
||||
versions
|
||||
)
|
||||
if (!preferredApp) {
|
||||
continue
|
||||
@@ -192,7 +194,7 @@ export function buildSnapshot(state) {
|
||||
|
||||
const preferredApp = pickPreferredInstalledApp(
|
||||
rawInstalledApps,
|
||||
game.correlationIds
|
||||
game
|
||||
)
|
||||
if (!preferredApp) {
|
||||
continue
|
||||
@@ -221,7 +223,7 @@ export function buildSnapshot(state) {
|
||||
}
|
||||
}
|
||||
|
||||
const apps = Array.from(entriesByKey.values()).sort(compareSnapshotEntries)
|
||||
const apps = Array.from(entriesByKey.values()).sort(compareInstalledAppRecords)
|
||||
|
||||
return {
|
||||
instanceId: "wand-installed-apps",
|
||||
@@ -241,22 +243,13 @@ export function buildSnapshot(state) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural, not field-by-field. This used to enumerate fields and had already
|
||||
* drifted from the bridge's copy (it listed `location`, the bridge's did not), so a
|
||||
* game moving install directory never reached the panel.
|
||||
*/
|
||||
export function makeInstalledAppsSignature(snapshot) {
|
||||
return snapshot.apps
|
||||
.map((app) =>
|
||||
[
|
||||
app.platform,
|
||||
app.sku,
|
||||
app.displayName,
|
||||
app.gameId ?? "",
|
||||
app.titleId ?? "",
|
||||
app.location,
|
||||
app.imageUrl ?? "",
|
||||
app.platformLastPlayedTimestamp ?? "",
|
||||
app.platformTotalPlaytimeMinutes ?? "",
|
||||
].join("|")
|
||||
)
|
||||
.join("\n")
|
||||
return JSON.stringify(snapshot.apps)
|
||||
}
|
||||
|
||||
export function toInstalledAppRecord(correlationId, app) {
|
||||
@@ -407,7 +400,7 @@ async function fetchUnavailableTitles(state, correlationIds) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Unavailable titles refresh failed.",
|
||||
error?.stack || String(error)
|
||||
formatError(error)
|
||||
)
|
||||
} finally {
|
||||
state.unavailableTitlesFetchPromise = null
|
||||
@@ -469,44 +462,60 @@ function normalizeUnavailableTitleGame(game) {
|
||||
}
|
||||
}
|
||||
|
||||
function getCatalogGameCorrelationIds(game, versions) {
|
||||
const correlationIds = []
|
||||
function collectCorrelationIds(game, versions) {
|
||||
const entries = []
|
||||
|
||||
if (Array.isArray(game.correlationIds)) {
|
||||
if (Array.isArray(game?.correlationIds)) {
|
||||
for (const correlationId of game.correlationIds) {
|
||||
if (typeof correlationId === "string" && correlationId.trim()) {
|
||||
correlationIds.push(correlationId.trim())
|
||||
entries.push({ correlationId: correlationId.trim(), version: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const version of versions) {
|
||||
if (
|
||||
typeof version?.correlationId === "string" &&
|
||||
version.correlationId.trim()
|
||||
) {
|
||||
correlationIds.push(version.correlationId.trim())
|
||||
if (Array.isArray(versions)) {
|
||||
for (const version of versions) {
|
||||
if (
|
||||
typeof version?.correlationId === "string" &&
|
||||
version.correlationId.trim()
|
||||
) {
|
||||
entries.push({
|
||||
correlationId: version.correlationId.trim(),
|
||||
version: version.version ?? null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return correlationIds
|
||||
return entries
|
||||
}
|
||||
|
||||
function pickPreferredInstalledApp(rawInstalledApps, correlationIds) {
|
||||
const candidates = Array.from(new Set(correlationIds))
|
||||
.map((correlationId) =>
|
||||
toInstalledAppRecord(correlationId, rawInstalledApps[correlationId])
|
||||
)
|
||||
export function rankInstalledAppCandidates(rawInstalledApps, game, versions) {
|
||||
return Array.from(
|
||||
new Map(
|
||||
collectCorrelationIds(game, versions).map((entry) => [
|
||||
entry.correlationId,
|
||||
entry,
|
||||
])
|
||||
).values()
|
||||
)
|
||||
.map((entry) => {
|
||||
const app = rawInstalledApps?.[entry.correlationId]
|
||||
const record = toInstalledAppRecord(entry.correlationId, app)
|
||||
return record ? { app, version: entry.version ?? null, record } : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort(compareInstalledAppRecords)
|
||||
.sort((left, right) => compareInstalledAppRecords(left.record, right.record))
|
||||
}
|
||||
|
||||
return candidates[0] || null
|
||||
function pickPreferredInstalledApp(rawInstalledApps, game, versions) {
|
||||
return rankInstalledAppCandidates(rawInstalledApps, game, versions)[0]?.record || null
|
||||
}
|
||||
|
||||
function upsertSnapshotEntry(entriesByKey, entry) {
|
||||
const key = getSnapshotEntryKey(entry)
|
||||
const current = entriesByKey.get(key)
|
||||
if (!current || compareSnapshotEntries(entry, current) < 0) {
|
||||
if (!current || compareInstalledAppRecords(entry, current) < 0) {
|
||||
entriesByKey.set(key, entry)
|
||||
}
|
||||
}
|
||||
@@ -523,24 +532,6 @@ function getSnapshotEntryKey(entry) {
|
||||
return `${SNAPSHOT_ENTRY_KEY_PREFIX.APP}${entry.correlationId}`
|
||||
}
|
||||
|
||||
function compareSnapshotEntries(left, right) {
|
||||
const lastPlayedDiff =
|
||||
(right.platformLastPlayedTimestamp ?? 0) -
|
||||
(left.platformLastPlayedTimestamp ?? 0)
|
||||
if (lastPlayedDiff !== 0) {
|
||||
return lastPlayedDiff
|
||||
}
|
||||
|
||||
const playtimeDiff =
|
||||
(right.platformTotalPlaytimeMinutes ?? 0) -
|
||||
(left.platformTotalPlaytimeMinutes ?? 0)
|
||||
if (playtimeDiff !== 0) {
|
||||
return playtimeDiff
|
||||
}
|
||||
|
||||
return compareByIdentity(left, right)
|
||||
}
|
||||
|
||||
function compareByIdentity(left, right) {
|
||||
const displayNameDiff = left.displayName.localeCompare(right.displayName)
|
||||
if (displayNameDiff !== 0) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LOG_FILE_NAME, LOG_PREFIX } from "./constants.js"
|
||||
import { getRequire } from "./runtime.js"
|
||||
|
||||
// Logging runs inside Wand's own process; a failure here must never take the app down.
|
||||
export function createLogger(WandEnhancer) {
|
||||
let filePath = null
|
||||
|
||||
@@ -12,7 +13,7 @@ export function createLogger(WandEnhancer) {
|
||||
filePath = path.join(os.tmpdir(), LOG_FILE_NAME)
|
||||
globalThis.__wandInstalledAppsSyncLogFile = filePath
|
||||
}
|
||||
} catch (error) {}
|
||||
} catch {}
|
||||
|
||||
return function log(level, message, detail) {
|
||||
const method =
|
||||
@@ -21,13 +22,13 @@ export function createLogger(WandEnhancer) {
|
||||
|
||||
try {
|
||||
console[method](LOG_PREFIX, message, detail || "")
|
||||
} catch (error) {}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
if (WandEnhancer?.log) {
|
||||
WandEnhancer.log(`${LOG_PREFIX} ${message}`, detail || "")
|
||||
}
|
||||
} catch (error) {}
|
||||
} catch {}
|
||||
|
||||
writeFile(filePath, line)
|
||||
}
|
||||
@@ -42,5 +43,5 @@ function writeFile(filePath, line) {
|
||||
const require = getRequire()
|
||||
const fs = require?.("node:fs")
|
||||
fs?.appendFileSync(filePath, `${line}\n`)
|
||||
} catch (error) {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
} from "./constants.js"
|
||||
import { clearTrainerSnapshot, syncGameStatus } from "./game-status.js"
|
||||
import {
|
||||
compareInstalledAppRecords,
|
||||
getInstalledVersionsForGame,
|
||||
rankInstalledAppCandidates,
|
||||
resolveInstalledData,
|
||||
toInstalledAppRecord,
|
||||
} from "./installed-data.js"
|
||||
import {
|
||||
formatError,
|
||||
getPreferredLocale,
|
||||
invokeIpc,
|
||||
isRecord,
|
||||
safeString,
|
||||
toStringId,
|
||||
@@ -131,7 +132,7 @@ async function executeRemoteLaunchCommand(state, request) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Remote trainer launch failed.",
|
||||
error?.stack || String(error)
|
||||
formatError(error)
|
||||
)
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "launch_failed",
|
||||
@@ -176,7 +177,7 @@ async function executeRemoteStopCommand(state, request) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Remote trainer stop failed.",
|
||||
error?.stack || String(error)
|
||||
formatError(error)
|
||||
)
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "stop_failed",
|
||||
@@ -192,47 +193,14 @@ function getLaunchInfoForGame(gameId, data) {
|
||||
const game = isRecord(data?.catalog?.games?.[gameId])
|
||||
? data.catalog.games[gameId]
|
||||
: null
|
||||
const candidates = []
|
||||
|
||||
if (Array.isArray(game?.correlationIds)) {
|
||||
for (const correlationId of game.correlationIds) {
|
||||
if (typeof correlationId === "string" && correlationId.trim()) {
|
||||
candidates.push({ correlationId: correlationId.trim(), version: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
const top = rankInstalledAppCandidates(
|
||||
data?.rawInstalledApps ?? {},
|
||||
game,
|
||||
versions
|
||||
)[0]
|
||||
|
||||
for (const versionEntry of versions) {
|
||||
if (
|
||||
typeof versionEntry?.correlationId === "string" &&
|
||||
versionEntry.correlationId.trim()
|
||||
) {
|
||||
candidates.push({
|
||||
correlationId: versionEntry.correlationId.trim(),
|
||||
version: versionEntry.version ?? null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rankedCandidates = Array.from(
|
||||
new Map(
|
||||
candidates.map((candidate) => [candidate.correlationId, candidate])
|
||||
).values()
|
||||
)
|
||||
.map((candidate) => normalizeLaunchCandidate(candidate, data))
|
||||
.filter(Boolean)
|
||||
.sort((left, right) =>
|
||||
compareInstalledAppRecords(left.normalizedApp, right.normalizedApp)
|
||||
)
|
||||
|
||||
if (!rankedCandidates[0]) {
|
||||
return { app: null, version: null }
|
||||
}
|
||||
|
||||
return {
|
||||
app: rankedCandidates[0].app,
|
||||
version: rankedCandidates[0].version,
|
||||
}
|
||||
return top ? { app: top.app, version: top.version } : { app: null, version: null }
|
||||
}
|
||||
|
||||
async function resolveTrainerInfoForGame(state, gameId, data) {
|
||||
@@ -251,7 +219,7 @@ async function resolveTrainerInfoForGame(state, gameId, data) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Local trainer lookup failed.",
|
||||
error?.stack || String(error)
|
||||
formatError(error)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -268,26 +236,12 @@ async function resolveTrainerInfoForGame(state, gameId, data) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Compatible trainer lookup failed.",
|
||||
error?.stack || String(error)
|
||||
formatError(error)
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLaunchCandidate(candidate, data) {
|
||||
const app = data?.rawInstalledApps?.[candidate.correlationId]
|
||||
const normalizedApp = toInstalledAppRecord(candidate.correlationId, app)
|
||||
if (!normalizedApp || !isRecord(app)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
app,
|
||||
version: candidate.version,
|
||||
normalizedApp,
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapTrainerInfo(value) {
|
||||
if (isRecord(value?.trainer)) {
|
||||
return value.trainer
|
||||
@@ -297,17 +251,10 @@ function unwrapTrainerInfo(value) {
|
||||
}
|
||||
|
||||
async function sendRemoteCommandResponse(state, response) {
|
||||
if (!state.ipcRenderer) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await state.ipcRenderer.invoke(COMMAND_RESPONSE_CHANNEL, response)
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Remote command response IPC failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
}
|
||||
await invokeIpc(
|
||||
state,
|
||||
COMMAND_RESPONSE_CHANNEL,
|
||||
response,
|
||||
"Remote command response"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,50 @@
|
||||
import { CONTAINER_GRAPH_MAX_DEPTH } from "./constants.js"
|
||||
|
||||
export function isRecord(value) {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
export function formatError(error) {
|
||||
return error?.stack || String(error)
|
||||
}
|
||||
|
||||
export async function invokeIpc(state, channel, payload, label, level = "warn") {
|
||||
if (!state.ipcRenderer) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await state.ipcRenderer.invoke(channel, payload)
|
||||
return true
|
||||
} catch (error) {
|
||||
state.log(level, `${label} IPC failed.`, formatError(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isDiagnosticsDebugEnabled() {
|
||||
return globalThis.__wandInstalledAppsSyncDebug === true
|
||||
}
|
||||
|
||||
let lastPredicateErrorLogAt = 0
|
||||
|
||||
function logContainerGraphPredicateError(error) {
|
||||
if (!isDiagnosticsDebugEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastPredicateErrorLogAt < 1000) {
|
||||
return
|
||||
}
|
||||
|
||||
lastPredicateErrorLogAt = now
|
||||
console.debug(
|
||||
"[wand-installed-apps-sync] container graph predicate threw",
|
||||
formatError(error)
|
||||
)
|
||||
}
|
||||
|
||||
export function getRequire() {
|
||||
return (
|
||||
globalThis.require ||
|
||||
@@ -57,7 +100,26 @@ export function hasAppRoot() {
|
||||
return Boolean(getAppRoot())
|
||||
}
|
||||
|
||||
let cachedAureliaContainer = null
|
||||
|
||||
function isUsableContainer(container) {
|
||||
return isRecord(container) && typeof container.get === "function"
|
||||
}
|
||||
|
||||
export function getAureliaContainer() {
|
||||
if (isUsableContainer(cachedAureliaContainer)) {
|
||||
return cachedAureliaContainer
|
||||
}
|
||||
|
||||
const resolved = resolveAureliaContainer()
|
||||
if (resolved) {
|
||||
cachedAureliaContainer = resolved
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
function resolveAureliaContainer() {
|
||||
const root = getAppRoot()
|
||||
const rootContainer = getContainerFromSubtree(root)
|
||||
if (rootContainer) {
|
||||
@@ -77,6 +139,10 @@ export function getAureliaContainer() {
|
||||
}
|
||||
|
||||
export function summarizeAureliaSubtree(root) {
|
||||
if (!isDiagnosticsDebugEnabled()) {
|
||||
return "(debug-disabled)"
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
return "root=null"
|
||||
}
|
||||
@@ -149,7 +215,11 @@ export function findExportedConstructor(webpackRequire, predicate) {
|
||||
return null
|
||||
}
|
||||
|
||||
export function findInstanceInContainerGraph(root, predicate, maxDepth = 4) {
|
||||
export function findInstanceInContainerGraph(
|
||||
root,
|
||||
predicate,
|
||||
maxDepth = CONTAINER_GRAPH_MAX_DEPTH
|
||||
) {
|
||||
if (!root) {
|
||||
return null
|
||||
}
|
||||
@@ -171,7 +241,9 @@ export function findInstanceInContainerGraph(root, predicate, maxDepth = 4) {
|
||||
if (predicate(value)) {
|
||||
return value
|
||||
}
|
||||
} catch (error) {}
|
||||
} catch (error) {
|
||||
logContainerGraphPredicateError(error)
|
||||
}
|
||||
|
||||
if (depth >= maxDepth) {
|
||||
continue
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TRAINER_LAUNCH_REQUEST_EXPORT_KEY } from "./constants.js"
|
||||
import {
|
||||
findExportedConstructor,
|
||||
findInstanceInContainerGraph,
|
||||
formatError,
|
||||
isRecord,
|
||||
} from "./runtime.js"
|
||||
|
||||
@@ -13,6 +14,15 @@ export function hasMissingOptionalServices(state) {
|
||||
)
|
||||
}
|
||||
|
||||
export function hasUnresolvedServices(state) {
|
||||
return (
|
||||
hasMissingOptionalServices(state) ||
|
||||
!state.trainerApiService ||
|
||||
!state.trainerService ||
|
||||
!state.trainerLaunchRequestCtor
|
||||
)
|
||||
}
|
||||
|
||||
const OPTIONAL_SERVICE_SPECS = [
|
||||
{
|
||||
stateKey: "unavailableTitlesService",
|
||||
@@ -87,7 +97,7 @@ export function getInstalledAppsService(state, container, webpackRequire) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Failed to resolve installed apps service from Aurelia container.",
|
||||
error?.stack || String(error)
|
||||
formatError(error)
|
||||
)
|
||||
return null
|
||||
}
|
||||
@@ -129,7 +139,7 @@ export function getStoreRef(state, container, webpackRequire) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Failed to resolve Store from container.",
|
||||
error?.stack || String(error)
|
||||
formatError(error)
|
||||
)
|
||||
return null
|
||||
}
|
||||
@@ -216,7 +226,7 @@ function getContainerService(state, container, ctor, warningKey, label) {
|
||||
state.log(
|
||||
"warn",
|
||||
`Failed to resolve ${label.toLowerCase()} from Aurelia container.`,
|
||||
error?.stack || String(error)
|
||||
formatError(error)
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
Vendored
+49
-22
@@ -1,3 +1,6 @@
|
||||
import type { BridgeClient, LogFn, ServerInfo } from './types';
|
||||
import type { GameStatusPayload, InstalledAppsPayload, TrainerMetaPayload, TrainerValuesPayload, IncomingMessage } from '../../protocol/messages';
|
||||
|
||||
const {
|
||||
gameStatusSignature,
|
||||
installedAppsSignature,
|
||||
@@ -6,18 +9,40 @@ const {
|
||||
normalizeSnapshot,
|
||||
normalizeTrainerValue,
|
||||
summarizeInstalledAppsSource,
|
||||
} = require('./normalizers');
|
||||
const { cloneValue, isRecord, safeString } = require('./utils');
|
||||
const { sendJson } = require('./websocket-codec');
|
||||
} = require('./normalizers') as {
|
||||
gameStatusSignature: (snapshot: GameStatusPayload) => string;
|
||||
installedAppsSignature: (snapshot: InstalledAppsPayload) => string;
|
||||
normalizeGameStatusSnapshot: (snapshot: unknown) => GameStatusPayload | null;
|
||||
normalizeInstalledAppsSnapshot: (snapshot: unknown) => InstalledAppsPayload | null;
|
||||
normalizeSnapshot: (snapshot: unknown) => BridgeStateSnapshot | null;
|
||||
normalizeTrainerValue: (snapshot: BridgeStateSnapshot, target: string, value: unknown) => unknown;
|
||||
summarizeInstalledAppsSource: (snapshot: unknown) => string;
|
||||
};
|
||||
const { cloneValue, isRecord, safeString } = require('./utils') as {
|
||||
cloneValue: (value: unknown) => unknown;
|
||||
isRecord: (value: unknown) => value is Record<string, unknown>;
|
||||
safeString: (value: unknown, fallback?: string) => string;
|
||||
};
|
||||
const { sendJson } = require('./websocket-codec') as {
|
||||
sendJson: (client: BridgeClient, type: string, payload: unknown, requestId?: string | number | null) => void;
|
||||
};
|
||||
|
||||
function createBridgeState({ clients, log, getServerInfo }) {
|
||||
let currentSnapshot: any = null;
|
||||
let currentInstalledApps: any = null;
|
||||
type BridgeStateSnapshot = { trainerMeta: TrainerMetaPayload, trainerValues: TrainerValuesPayload };
|
||||
|
||||
type BridgeStateOptions = {
|
||||
clients: Iterable<BridgeClient>;
|
||||
log: LogFn;
|
||||
getServerInfo: () => ServerInfo & { listening: boolean; remoteUrl: string | null };
|
||||
};
|
||||
|
||||
function createBridgeState({ clients, log, getServerInfo }: BridgeStateOptions) {
|
||||
let currentSnapshot: BridgeStateSnapshot | null = null;
|
||||
let currentInstalledApps: InstalledAppsPayload | null = null;
|
||||
let currentInstalledAppsSignature: string | null = null;
|
||||
let currentGameStatus: any = null;
|
||||
let currentGameStatus: GameStatusPayload | null = null;
|
||||
let currentGameStatusSignature: string | null = null;
|
||||
|
||||
function broadcast(type, payload, requestId = null) {
|
||||
function broadcast(type: IncomingMessage['type'], payload: unknown, requestId: string | null = null) {
|
||||
for (const client of clients) {
|
||||
if (client.handshaken) {
|
||||
sendJson(client, type, payload, requestId);
|
||||
@@ -25,7 +50,7 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
}
|
||||
}
|
||||
|
||||
function sendSnapshot(client) {
|
||||
function sendSnapshot(client: BridgeClient) {
|
||||
if (!currentSnapshot) {
|
||||
sendJson(client, 'trainer_changed', { previousTrainerId: null, trainerId: '' });
|
||||
} else {
|
||||
@@ -36,7 +61,7 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
if (currentInstalledApps) sendJson(client, 'installed_apps', currentInstalledApps);
|
||||
}
|
||||
|
||||
function sync(rawSnapshot) {
|
||||
function sync(rawSnapshot: unknown) {
|
||||
const nextSnapshot = rawSnapshot ? normalizeSnapshot(rawSnapshot) : null;
|
||||
const previousTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
|
||||
const nextTrainerId = nextSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
|
||||
@@ -51,10 +76,9 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
}
|
||||
}
|
||||
|
||||
function syncTrainerMeta(rawSnapshot) {
|
||||
function syncTrainerMeta(rawSnapshot: unknown) {
|
||||
const localizedSnapshot = normalizeSnapshot(rawSnapshot);
|
||||
const activeTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId;
|
||||
if (!localizedSnapshot || localizedSnapshot.trainerMeta.trainer.trainerId !== activeTrainerId) {
|
||||
if (!currentSnapshot || !localizedSnapshot || localizedSnapshot.trainerMeta.trainer.trainerId !== currentSnapshot.trainerMeta.trainer.trainerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,15 +86,18 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
broadcast('trainer_meta', currentSnapshot.trainerMeta);
|
||||
}
|
||||
|
||||
function valueChanged(change) {
|
||||
if (!currentSnapshot || !isRecord(change)) return;
|
||||
function valueChanged(change: unknown) {
|
||||
const snapshot = currentSnapshot;
|
||||
if (!snapshot || !isRecord(change)) return;
|
||||
const target = safeString(change.target);
|
||||
if (!target) return;
|
||||
|
||||
if (safeString(change.trainerId) !== snapshot.trainerMeta.trainer.trainerId) return;
|
||||
|
||||
const value = normalizeTrainerValue(currentSnapshot, target, change.value);
|
||||
currentSnapshot.trainerValues.values[target] = value;
|
||||
const value = normalizeTrainerValue(snapshot, target, change.value);
|
||||
snapshot.trainerValues.values[target] = value;
|
||||
broadcast('value_changed', {
|
||||
trainerId: safeString(change.trainerId, currentSnapshot.trainerMeta.trainer.trainerId),
|
||||
trainerId: snapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
value,
|
||||
oldValue: cloneValue(change.oldValue),
|
||||
@@ -79,7 +106,7 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
});
|
||||
}
|
||||
|
||||
function syncInstalledApps(rawInstalledApps) {
|
||||
function syncInstalledApps(rawInstalledApps: unknown) {
|
||||
const sourceSummary = summarizeInstalledAppsSource(rawInstalledApps);
|
||||
const nextInstalledApps = normalizeInstalledAppsSnapshot(rawInstalledApps);
|
||||
if (!nextInstalledApps) {
|
||||
@@ -90,11 +117,11 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
if (nextSignature === currentInstalledAppsSignature) return;
|
||||
currentInstalledApps = nextInstalledApps;
|
||||
currentInstalledAppsSignature = nextSignature;
|
||||
log('info', `Installed apps snapshot accepted (${currentInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
|
||||
log('info', `Installed apps snapshot accepted (${nextInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
|
||||
broadcast('installed_apps', currentInstalledApps);
|
||||
}
|
||||
|
||||
function syncGameStatus(rawGameStatus) {
|
||||
function syncGameStatus(rawGameStatus: unknown) {
|
||||
const nextGameStatus = normalizeGameStatusSnapshot(rawGameStatus);
|
||||
if (!nextGameStatus) {
|
||||
log('warn', 'Ignored invalid game status snapshot.');
|
||||
@@ -104,7 +131,7 @@ function createBridgeState({ clients, log, getServerInfo }) {
|
||||
if (nextSignature === currentGameStatusSignature) return;
|
||||
currentGameStatus = nextGameStatus;
|
||||
currentGameStatusSignature = nextSignature;
|
||||
log('info', `Game status snapshot accepted (${currentGameStatus.session.state}/${currentGameStatus.session.event}).`);
|
||||
log('info', `Game status snapshot accepted (${nextGameStatus.session.state}/${nextGameStatus.session.event}).`);
|
||||
broadcast('game_status', currentGameStatus);
|
||||
}
|
||||
|
||||
|
||||
Vendored
+3
-5
@@ -1,6 +1,8 @@
|
||||
const KNOWN_CHEAT_TYPES = new Set(['slider', 'number', 'toggle', 'button', 'selection', 'scalar', 'incremental']);
|
||||
const { ECheatType } = require('../../protocol/messages');
|
||||
const WEB_CONTRACT = require('../../protocol/web-contract.json');
|
||||
|
||||
const KNOWN_CHEAT_TYPES = new Set(Object.values(ECheatType));
|
||||
|
||||
const WS_OPCODE = Object.freeze({
|
||||
TEXT: 1,
|
||||
BINARY: 2,
|
||||
@@ -34,12 +36,8 @@ module.exports = {
|
||||
PORT_SCAN_RANGE: WEB_CONTRACT.portScanRange,
|
||||
REMOTE_ASSETS_PREFIX: WEB_CONTRACT.assetsPath,
|
||||
REMOTE_BASE_PATH: WEB_CONTRACT.basePath,
|
||||
REMOTE_COMMAND_REQUEST_CHANNEL: IPC_CHANNEL.COMMAND_REQUEST,
|
||||
REMOTE_COMMAND_RESPONSE_CHANNEL: IPC_CHANNEL.COMMAND_RESPONSE,
|
||||
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS: 15000,
|
||||
REMOTE_GAME_STATUS_CHANNEL: IPC_CHANNEL.GAME_STATUS,
|
||||
REMOTE_HEALTH_PATH: WEB_CONTRACT.healthPath,
|
||||
REMOTE_INSTALLED_APPS_CHANNEL: IPC_CHANNEL.INSTALLED_APPS,
|
||||
REMOTE_WS_PATH: WEB_CONTRACT.webSocketPath,
|
||||
RENDERER_INJECTION_DELAYS_MS: Object.freeze([500, 2000]),
|
||||
RENDERER_SCRIPT_API_VERSION: 1,
|
||||
|
||||
Vendored
+2
-1
@@ -1,6 +1,7 @@
|
||||
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./runtime');
|
||||
const { installWandRuntime: installRuntime } = require('./wand/runtime');
|
||||
import type { BridgeOptions, ElectronPort } from './types';
|
||||
import type { BridgeOptions } from './types';
|
||||
import type { ElectronPort } from './types';
|
||||
|
||||
function withDefaultPanelRoot(options: BridgeOptions = {}): BridgeOptions {
|
||||
if (options.panelRoot) {
|
||||
|
||||
Vendored
+7
-6
@@ -3,30 +3,31 @@ const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { BRIDGE_LOG_FILE_NAME } = require('./constants');
|
||||
import type { BridgeOptions } from './types';
|
||||
import type { BridgeLogger, BridgeOptions, LogLevel } from './types';
|
||||
|
||||
function writeLogLine(logFile, level, message, error) {
|
||||
function writeLogLine(logFile: string, level: LogLevel, message: string, error?: unknown) {
|
||||
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
|
||||
const tag = `[wand-remote-bridge] ${message}`;
|
||||
|
||||
// Logging runs inside Wand's own process; a failure here must never take the app down.
|
||||
try {
|
||||
console[method](tag, error || '');
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const detail = error ? ` :: ${error && error.stack ? error.stack : String(error)}` : '';
|
||||
const detail = error ? ` :: ${error && typeof error === 'object' && 'stack' in error ? String(error.stack) : String(error)}` : '';
|
||||
fs.appendFileSync(logFile, `[${new Date().toISOString()}] [${level}] ${message}${detail}\n`);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function createBridgeLogger(options: BridgeOptions = {}) {
|
||||
function createBridgeLogger(options: BridgeOptions = {}): BridgeLogger {
|
||||
const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME);
|
||||
const log = (level, message, error) => writeLogLine(logFile, level, message, error);
|
||||
const log = ((level: LogLevel, message: string, error?: unknown) => writeLogLine(logFile, level, message, error)) as BridgeLogger;
|
||||
log.file = logFile;
|
||||
return log;
|
||||
}
|
||||
|
||||
function writeInstallLog(level, message, error) {
|
||||
function writeInstallLog(level: LogLevel, message: string, error?: unknown) {
|
||||
writeLogLine(path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME), level, message, error);
|
||||
}
|
||||
|
||||
|
||||
+15
-10
@@ -1,17 +1,22 @@
|
||||
import type { RemoteCommandAction, RemoteCommandResultPayload } from '../../../protocol/messages';
|
||||
import type { UnknownRecord } from '../types';
|
||||
const { isRecord, safeString, toStringId } = require('../utils');
|
||||
|
||||
function normalizeRemoteCommandAction(value) {
|
||||
return value === 'launch' || value === 'stop' ? value : null;
|
||||
function normalizeRemoteCommandAction(value: unknown): RemoteCommandAction | null {
|
||||
return value === 'launch' || value === 'stop' ? (value as RemoteCommandAction) : null;
|
||||
}
|
||||
|
||||
function normalizeRemoteCommandResult(rawResult, fallback) {
|
||||
const action = normalizeRemoteCommandAction(isRecord(rawResult) ? rawResult.action : null) || fallback.action;
|
||||
const gameId = isRecord(rawResult) ? toStringId(rawResult.gameId) || fallback.gameId || null : fallback.gameId || null;
|
||||
const titleId = isRecord(rawResult) ? toStringId(rawResult.titleId) || fallback.titleId || null : fallback.titleId || null;
|
||||
const ok = rawResult === true || Boolean(isRecord(rawResult) && rawResult.ok === true);
|
||||
function normalizeRemoteCommandResult(rawResult: unknown, fallback: { action: RemoteCommandAction, gameId?: string | null, titleId?: string | null }): RemoteCommandResultPayload {
|
||||
const raw = isRecord(rawResult) ? (rawResult as UnknownRecord) : null;
|
||||
const action = normalizeRemoteCommandAction(raw ? raw.action : null) || fallback.action;
|
||||
const gameId = raw ? toStringId(raw.gameId) || fallback.gameId || null : fallback.gameId || null;
|
||||
const titleId = raw ? toStringId(raw.titleId) || fallback.titleId || null : fallback.titleId || null;
|
||||
const ok = rawResult === true || Boolean(raw && raw.ok === true);
|
||||
const payload = { ok, action, gameId, titleId };
|
||||
if (ok) return payload;
|
||||
if (!isRecord(rawResult) || !isRecord(rawResult.error)) {
|
||||
|
||||
const errorRaw = raw && isRecord(raw.error) ? (raw.error as UnknownRecord) : null;
|
||||
if (!errorRaw) {
|
||||
return {
|
||||
...payload,
|
||||
error: { code: 'command_rejected', message: 'The renderer rejected the remote command.' },
|
||||
@@ -20,8 +25,8 @@ function normalizeRemoteCommandResult(rawResult, fallback) {
|
||||
return {
|
||||
...payload,
|
||||
error: {
|
||||
code: safeString(rawResult.error.code, 'command_rejected'),
|
||||
message: safeString(rawResult.error.message, 'The renderer rejected the remote command.'),
|
||||
code: safeString(errorRaw.code, 'command_rejected'),
|
||||
message: safeString(errorRaw.message, 'The renderer rejected the remote command.'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+9
-6
@@ -1,12 +1,15 @@
|
||||
import type { GameStatusPayload } from '../../../protocol/messages';
|
||||
import type { UnknownRecord } from '../types';
|
||||
const { isRecord, safeString, toStringId } = require('../utils');
|
||||
|
||||
function normalizeGameStatusSnapshot(rawSnapshot) {
|
||||
function normalizeGameStatusSnapshot(rawSnapshot: unknown): GameStatusPayload | null {
|
||||
if (!isRecord(rawSnapshot)) return null;
|
||||
const rawSession = isRecord(rawSnapshot.session) ? rawSnapshot.session : {};
|
||||
const rawTrainer = isRecord(rawSnapshot.trainer) ? rawSnapshot.trainer : {};
|
||||
const snap = rawSnapshot as UnknownRecord;
|
||||
const rawSession = isRecord(snap.session) ? (snap.session as UnknownRecord) : {};
|
||||
const rawTrainer = isRecord(snap.trainer) ? (snap.trainer as UnknownRecord) : {};
|
||||
return {
|
||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-game-status'),
|
||||
updatedAt: typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
|
||||
instanceId: safeString(snap.instanceId, 'wand-game-status'),
|
||||
updatedAt: typeof snap.updatedAt === 'string' ? snap.updatedAt : new Date().toISOString(),
|
||||
session: {
|
||||
state: rawSession.state === 'running' ? 'running' : 'idle',
|
||||
event: safeString(rawSession.event, 'snapshot'),
|
||||
@@ -29,7 +32,7 @@ function normalizeGameStatusSnapshot(rawSnapshot) {
|
||||
};
|
||||
}
|
||||
|
||||
function gameStatusSignature(snapshot) {
|
||||
function gameStatusSignature(snapshot: GameStatusPayload): string {
|
||||
return [
|
||||
snapshot.session.state,
|
||||
snapshot.session.event,
|
||||
|
||||
+110
-104
@@ -1,10 +1,12 @@
|
||||
import type { CheatArgs, CheatOption, CheatSchema, InstalledAppsPayload, InstalledAppSummary, TrainerMetaPayload, TrainerValuesPayload } from '../../../protocol/messages';
|
||||
import type { UnknownRecord } from '../types';
|
||||
const { KNOWN_CHEAT_TYPES } = require('../constants');
|
||||
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('../utils');
|
||||
const { normalizeRemoteCommandAction, normalizeRemoteCommandResult } = require('./command-results');
|
||||
const { gameStatusSignature, normalizeGameStatusSnapshot } = require('./game-status');
|
||||
const { normalizeTrainerValue } = require('./trainer');
|
||||
|
||||
function normalizeOption(option) {
|
||||
function normalizeOption(option: unknown): CheatOption | null {
|
||||
if (typeof option === 'string' || typeof option === 'number') {
|
||||
return {
|
||||
label: String(option),
|
||||
@@ -16,77 +18,80 @@ function normalizeOption(option) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = option.value;
|
||||
const opt = option as UnknownRecord;
|
||||
const value = opt.value;
|
||||
if (typeof value !== 'string' && typeof value !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: safeString(option.label, String(value)),
|
||||
label: safeString(opt.label, String(value)),
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeArgs(args) {
|
||||
function normalizeArgs(args: unknown): CheatArgs {
|
||||
if (!isRecord(args)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const next: Record<string, unknown> = {};
|
||||
if (typeof args.min === 'number') next.min = args.min;
|
||||
if (typeof args.max === 'number') next.max = args.max;
|
||||
if (typeof args.step === 'number') next.step = args.step;
|
||||
if (typeof args.postfix === 'string') next.postfix = args.postfix;
|
||||
if (typeof args.default === 'string' || typeof args.default === 'number' || typeof args.default === 'boolean') {
|
||||
next.default = args.default;
|
||||
const a = args as UnknownRecord;
|
||||
const next: CheatArgs = {};
|
||||
if (typeof a.min === 'number') next.min = a.min;
|
||||
if (typeof a.max === 'number') next.max = a.max;
|
||||
if (typeof a.step === 'number') next.step = a.step;
|
||||
if (typeof a.postfix === 'string') next.postfix = a.postfix;
|
||||
if (typeof a.default === 'string' || typeof a.default === 'number' || typeof a.default === 'boolean') {
|
||||
next.default = a.default;
|
||||
}
|
||||
|
||||
if (Array.isArray(args.options)) {
|
||||
next.options = args.options.map(normalizeOption).filter(Boolean);
|
||||
if (Array.isArray(a.options)) {
|
||||
next.options = a.options.map(normalizeOption).filter(Boolean) as CheatOption[];
|
||||
}
|
||||
|
||||
if (typeof args.button === 'string' || typeof args.button === 'boolean') {
|
||||
next.button = args.button;
|
||||
if (typeof a.button === 'string' || typeof a.button === 'boolean') {
|
||||
next.button = a.button;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeCheat(cheat, index) {
|
||||
function normalizeCheat(cheat: unknown, index: number): CheatSchema | null {
|
||||
if (!isRecord(cheat)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = safeString(cheat.target);
|
||||
const type = safeString(cheat.type);
|
||||
const c = cheat as UnknownRecord;
|
||||
const target = safeString(c.target);
|
||||
const type = safeString(c.type) as CheatSchema['type'];
|
||||
if (!target || !KNOWN_CHEAT_TYPES.has(type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized: Record<string, unknown> = {
|
||||
uuid: safeString(cheat.uuid, `${target}-${index}`),
|
||||
const normalized: CheatSchema = {
|
||||
uuid: safeString(c.uuid, `${target}-${index}`),
|
||||
target,
|
||||
type,
|
||||
name: safeString(cheat.name, target),
|
||||
description: typeof cheat.description === 'string' ? cheat.description : null,
|
||||
instructions: typeof cheat.instructions === 'string' ? cheat.instructions : null,
|
||||
category: safeString(cheat.category, 'general'),
|
||||
parent: typeof cheat.parent === 'string' ? cheat.parent : null,
|
||||
args: normalizeArgs(cheat.args),
|
||||
name: safeString(c.name, target),
|
||||
description: typeof c.description === 'string' ? c.description : null,
|
||||
instructions: typeof c.instructions === 'string' ? c.instructions : null,
|
||||
category: safeString(c.category, 'general'),
|
||||
parent: typeof c.parent === 'string' ? c.parent : null,
|
||||
args: normalizeArgs(c.args),
|
||||
};
|
||||
|
||||
if (typeof cheat.flags === 'number') {
|
||||
normalized.flags = cheat.flags;
|
||||
if (typeof c.flags === 'number') {
|
||||
normalized.flags = c.flags;
|
||||
}
|
||||
|
||||
if (Array.isArray(cheat.hotkeys)) {
|
||||
normalized.hotkeys = cheat.hotkeys.filter(Array.isArray).map((group) => group.map((item) => String(item)));
|
||||
if (Array.isArray(c.hotkeys)) {
|
||||
normalized.hotkeys = c.hotkeys.filter(Array.isArray).map((group: unknown[]) => group.map((item: unknown) => String(item)));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeImageUrl(...values) {
|
||||
function normalizeImageUrl(...values: unknown[]): string | null {
|
||||
const value = firstString(...values);
|
||||
if (!value) {
|
||||
return null;
|
||||
@@ -100,76 +105,78 @@ function normalizeImageUrl(...values) {
|
||||
}
|
||||
}
|
||||
|
||||
function getRawInstalledApps(rawSnapshot) {
|
||||
function getRawInstalledApps(rawSnapshot: unknown): unknown[] | null {
|
||||
if (Array.isArray(rawSnapshot)) {
|
||||
return rawSnapshot;
|
||||
}
|
||||
|
||||
if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.apps)) {
|
||||
return rawSnapshot.apps;
|
||||
}
|
||||
|
||||
if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.installedApps)) {
|
||||
return rawSnapshot.installedApps;
|
||||
if (isRecord(rawSnapshot)) {
|
||||
const snap = rawSnapshot as UnknownRecord;
|
||||
if (Array.isArray(snap.apps)) return snap.apps;
|
||||
if (Array.isArray(snap.installedApps)) return snap.installedApps;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeInstalledApp(app) {
|
||||
function normalizeInstalledApp(app: unknown): InstalledAppSummary | null {
|
||||
if (!isRecord(app)) {
|
||||
return null;
|
||||
}
|
||||
const a = app as UnknownRecord;
|
||||
|
||||
const platform = safeString(app.platform);
|
||||
const sku = safeString(app.sku);
|
||||
const platform = safeString(a.platform);
|
||||
const sku = safeString(a.sku);
|
||||
if (!platform || !sku) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const location = typeof app.location === 'string' ? app.location : '';
|
||||
const location = typeof a.location === 'string' ? a.location : '';
|
||||
return {
|
||||
platform,
|
||||
sku,
|
||||
correlationId: `${platform}:${sku}`,
|
||||
displayName: firstString(
|
||||
app.displayName,
|
||||
app.titleName,
|
||||
app.gameName,
|
||||
app.name,
|
||||
a.displayName,
|
||||
a.titleName,
|
||||
a.gameName,
|
||||
a.name,
|
||||
location.replaceAll('\\', '/').split('/').filter(Boolean).pop() || '',
|
||||
`${platform}:${sku}`
|
||||
),
|
||||
gameId: toStringId(app.gameId),
|
||||
titleId: toStringId(app.titleId),
|
||||
imageUrl: normalizeImageUrl(app.imageUrl, app.iconUrl, app.coverUrl, app.thumbnailUrl, app.logoUrl, app.headerImageUrl),
|
||||
platformLastPlayedTimestamp: typeof app.platformLastPlayedTimestamp === 'number' ? app.platformLastPlayedTimestamp : null,
|
||||
platformTotalPlaytimeMinutes: typeof app.platformTotalPlaytimeMinutes === 'number' ? app.platformTotalPlaytimeMinutes : null,
|
||||
gameId: toStringId(a.gameId),
|
||||
titleId: toStringId(a.titleId),
|
||||
imageUrl: normalizeImageUrl(a.imageUrl, a.iconUrl, a.coverUrl, a.thumbnailUrl, a.logoUrl, a.headerImageUrl),
|
||||
platformLastPlayedTimestamp: typeof a.platformLastPlayedTimestamp === 'number' ? a.platformLastPlayedTimestamp : null,
|
||||
platformTotalPlaytimeMinutes: typeof a.platformTotalPlaytimeMinutes === 'number' ? a.platformTotalPlaytimeMinutes : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInstalledAppsSnapshot(rawSnapshot) {
|
||||
function normalizeInstalledAppsSnapshot(rawSnapshot: unknown): InstalledAppsPayload | null {
|
||||
const rawApps = getRawInstalledApps(rawSnapshot);
|
||||
if (!rawApps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const apps = rawApps.map(normalizeInstalledApp).filter(Boolean).sort(compareInstalledApps);
|
||||
const apps = rawApps.map(normalizeInstalledApp).filter(Boolean) as InstalledAppSummary[];
|
||||
apps.sort(compareInstalledApps);
|
||||
const snap = isRecord(rawSnapshot) ? (rawSnapshot as UnknownRecord) : null;
|
||||
return {
|
||||
instanceId: isRecord(rawSnapshot) ? safeString(rawSnapshot.instanceId, 'wand-installed-apps') : 'wand-installed-apps',
|
||||
updatedAt: isRecord(rawSnapshot) && typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
|
||||
instanceId: snap ? safeString(snap.instanceId, 'wand-installed-apps') : 'wand-installed-apps',
|
||||
updatedAt: snap && typeof snap.updatedAt === 'string' ? snap.updatedAt : new Date().toISOString(),
|
||||
apps,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeInstalledAppsSource(rawSnapshot) {
|
||||
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) {
|
||||
return '';
|
||||
}
|
||||
function summarizeInstalledAppsSource(rawSnapshot: unknown): string {
|
||||
if (!isRecord(rawSnapshot)) return '';
|
||||
const snap = rawSnapshot as UnknownRecord;
|
||||
if (!isRecord(snap.diagnostics)) return '';
|
||||
const diag = snap.diagnostics as UnknownRecord;
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const key of ['rawInstalledApps', 'catalogGames', 'catalogTitles']) {
|
||||
const value = rawSnapshot.diagnostics[key];
|
||||
const value = diag[key];
|
||||
if (typeof value === 'number') {
|
||||
parts.push(`${key}=${value}`);
|
||||
}
|
||||
@@ -178,69 +185,68 @@ function summarizeInstalledAppsSource(rawSnapshot) {
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
function installedAppsSignature(snapshot) {
|
||||
return snapshot.apps
|
||||
.map((app) => [
|
||||
app.platform,
|
||||
app.sku,
|
||||
app.displayName,
|
||||
app.gameId || '',
|
||||
app.titleId || '',
|
||||
app.imageUrl || '',
|
||||
app.platformLastPlayedTimestamp || '',
|
||||
app.platformTotalPlaytimeMinutes || '',
|
||||
].join('|'))
|
||||
.join('\n');
|
||||
/**
|
||||
* Structural, not field-by-field: an explicit field list silently stops detecting
|
||||
* whatever it forgets. The apps are already normalized here, so key order is stable.
|
||||
*/
|
||||
function installedAppsSignature(snapshot: InstalledAppsPayload): string {
|
||||
return JSON.stringify(snapshot.apps);
|
||||
}
|
||||
|
||||
function normalizeSnapshot(rawSnapshot) {
|
||||
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.metadata) || !isRecord(rawSnapshot.metadata.info)) {
|
||||
return null;
|
||||
}
|
||||
function normalizeSnapshot(rawSnapshot: unknown): { trainerMeta: TrainerMetaPayload, trainerValues: TrainerValuesPayload } | null {
|
||||
if (!isRecord(rawSnapshot)) return null;
|
||||
const snap = rawSnapshot as UnknownRecord;
|
||||
if (!isRecord(snap.metadata)) return null;
|
||||
const meta = snap.metadata as UnknownRecord;
|
||||
if (!isRecord(meta.info)) return null;
|
||||
const info = meta.info as UnknownRecord;
|
||||
|
||||
const info = rawSnapshot.metadata.info;
|
||||
const blueprint = isRecord(info.blueprint) ? info.blueprint : {};
|
||||
const blueprint = isRecord(info.blueprint) ? (info.blueprint as UnknownRecord) : {};
|
||||
const rawCheats = Array.isArray(blueprint.cheats) ? blueprint.cheats : [];
|
||||
const cheats = rawCheats.map(normalizeCheat).filter(Boolean);
|
||||
const cheats = rawCheats.map(normalizeCheat).filter(Boolean) as CheatSchema[];
|
||||
const categories = Array.from(new Set(cheats.map((entry) => entry.category)));
|
||||
const trainerId = safeString(rawSnapshot.trainerId || rawSnapshot.trainerInfo?.trainerId);
|
||||
|
||||
const trainerInfo = isRecord(snap.trainerInfo) ? (snap.trainerInfo as UnknownRecord) : null;
|
||||
const infoGame = isRecord(info.game) ? (info.game as UnknownRecord) : null;
|
||||
|
||||
const trainerId = safeString(snap.trainerId || trainerInfo?.trainerId);
|
||||
const displayName = firstString(
|
||||
rawSnapshot.trainerInfo?.displayName,
|
||||
rawSnapshot.trainerInfo?.gameName,
|
||||
rawSnapshot.trainerInfo?.titleName,
|
||||
rawSnapshot.trainerInfo?.title,
|
||||
rawSnapshot.trainerInfo?.name,
|
||||
trainerInfo?.displayName,
|
||||
trainerInfo?.gameName,
|
||||
trainerInfo?.titleName,
|
||||
trainerInfo?.title,
|
||||
trainerInfo?.name,
|
||||
info.displayName,
|
||||
info.gameName,
|
||||
info.titleName,
|
||||
info.title,
|
||||
info.name,
|
||||
info.game?.displayName,
|
||||
info.game?.name,
|
||||
info.game?.title
|
||||
infoGame?.displayName,
|
||||
infoGame?.name,
|
||||
infoGame?.title
|
||||
);
|
||||
|
||||
if (!trainerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trainerMeta = {
|
||||
const trainerMeta: TrainerMetaPayload = {
|
||||
session: {
|
||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
|
||||
instanceId: safeString(snap.instanceId, 'wand-session'),
|
||||
},
|
||||
trainer: {
|
||||
trainerId,
|
||||
gameId: safeString(rawSnapshot.trainerInfo?.gameId || info.gameId),
|
||||
displayName: displayName || safeString(rawSnapshot.trainerInfo?.gameId || info.gameId, trainerId),
|
||||
gameId: safeString(trainerInfo?.gameId || info.gameId),
|
||||
displayName: displayName || safeString(trainerInfo?.gameId || info.gameId, trainerId),
|
||||
titleId: typeof info.titleId === 'string' ? info.titleId : null,
|
||||
gameVersion: typeof rawSnapshot.gameVersion === 'string' ? rawSnapshot.gameVersion : null,
|
||||
trainerLoading: rawSnapshot.trainerLoading === true,
|
||||
gameInstalled: rawSnapshot.gameInstalled !== false,
|
||||
needsCompatibilityWarning: rawSnapshot.needsCompatibilityWarning === true,
|
||||
language: safeString(rawSnapshot.language, 'en-US'),
|
||||
themeId: safeString(rawSnapshot.themeId, 'default'),
|
||||
isTimeLimitExpired: rawSnapshot.isTimeLimitExpired === true,
|
||||
notesReadHash: typeof rawSnapshot.notesReadHash === 'string' ? rawSnapshot.notesReadHash : null,
|
||||
gameVersion: typeof snap.gameVersion === 'string' ? snap.gameVersion : null,
|
||||
trainerLoading: snap.trainerLoading === true,
|
||||
gameInstalled: snap.gameInstalled !== false,
|
||||
needsCompatibilityWarning: snap.needsCompatibilityWarning === true,
|
||||
language: safeString(snap.language, 'en-US'),
|
||||
themeId: safeString(snap.themeId, 'default'),
|
||||
isTimeLimitExpired: snap.isTimeLimitExpired === true,
|
||||
notesReadHash: typeof snap.notesReadHash === 'string' ? snap.notesReadHash : null,
|
||||
},
|
||||
schema: {
|
||||
categories,
|
||||
@@ -248,9 +254,9 @@ function normalizeSnapshot(rawSnapshot) {
|
||||
},
|
||||
};
|
||||
|
||||
const trainerValues = {
|
||||
const trainerValues: TrainerValuesPayload = {
|
||||
trainerId,
|
||||
values: isRecord(rawSnapshot.values) ? cloneValue(rawSnapshot.values) : {},
|
||||
values: isRecord(snap.values) ? (cloneValue(snap.values) as Record<string, unknown>) : {},
|
||||
};
|
||||
for (const cheat of cheats) {
|
||||
if (cheat.target in trainerValues.values) {
|
||||
@@ -264,7 +270,7 @@ function normalizeSnapshot(rawSnapshot) {
|
||||
};
|
||||
}
|
||||
|
||||
function compareInstalledApps(left, right) {
|
||||
function compareInstalledApps(left: InstalledAppSummary, right: InstalledAppSummary): number {
|
||||
const displayNameDiff = left.displayName.localeCompare(right.displayName);
|
||||
if (displayNameDiff !== 0) {
|
||||
return displayNameDiff;
|
||||
|
||||
+15
-7
@@ -1,10 +1,18 @@
|
||||
export function normalizeTrainerValue(snapshot, target, value) {
|
||||
import { cloneValue } from '../utils';
|
||||
|
||||
type SnapshotShape = {
|
||||
trainerMeta?: {
|
||||
schema?: {
|
||||
cheats?: Array<{ target: string; type?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function normalizeTrainerValue(
|
||||
snapshot: SnapshotShape | null | undefined,
|
||||
target: string,
|
||||
value: unknown
|
||||
): unknown {
|
||||
const cheat = snapshot?.trainerMeta?.schema?.cheats?.find((entry) => entry.target === target);
|
||||
return cheat?.type === 'toggle' ? Boolean(value) : cloneValue(value);
|
||||
}
|
||||
|
||||
function cloneValue(value) {
|
||||
if (Array.isArray(value)) return value.map(cloneValue);
|
||||
if (typeof value !== 'object' || value === null) return value;
|
||||
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)]));
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,11 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ECheatType } from '../../protocol/messages';
|
||||
import { validateClientMessage, validateSetValueTarget } from './protocol-router';
|
||||
|
||||
const snapshot = {
|
||||
trainerMeta: {
|
||||
trainer: { trainerId: 'active' },
|
||||
schema: { cheats: [{ target: 'god', type: 'toggle' }] },
|
||||
schema: { cheats: [{ target: 'god', type: ECheatType.Toggle }] },
|
||||
},
|
||||
trainerValues: { values: { god: false } },
|
||||
};
|
||||
|
||||
+27
-30
@@ -1,8 +1,11 @@
|
||||
import webContract from '../../protocol/web-contract.json';
|
||||
import { isRecord, safeString } from './utils';
|
||||
import { isOutgoingMessage } from '../../protocol/validation';
|
||||
import type { CheatSchema, SetValueMessage, TrainerMetaPayload, TrainerValuesPayload } from '../../protocol/messages';
|
||||
|
||||
const BRIDGE_PROTOCOL_VERSION = webContract.protocolVersion;
|
||||
|
||||
export function validateClientMessage(message, handshaken) {
|
||||
export function validateClientMessage(message: unknown, handshaken: boolean) {
|
||||
if (!isRecord(message) || typeof message.type !== 'string' || !isRecord(message.payload)) {
|
||||
return invalid('invalid_message', 'Expected a protocol envelope with an object payload.');
|
||||
}
|
||||
@@ -15,35 +18,37 @@ export function validateClientMessage(message, handshaken) {
|
||||
return invalid('invalid_request_id', 'requestId must be a string or null.');
|
||||
}
|
||||
|
||||
if (message.type === 'hello') {
|
||||
if (message.payload.client !== 'mobile-web' || typeof message.payload.clientVersion !== 'string' || !isRecord(message.payload.capabilities)) {
|
||||
if (!isOutgoingMessage(message)) {
|
||||
const type = (message as Record<string, unknown>).type;
|
||||
if (type === 'hello') {
|
||||
return invalid('invalid_hello', 'The hello payload is incomplete.');
|
||||
}
|
||||
return { ok: true };
|
||||
if (type === 'set_value') {
|
||||
return invalid('invalid_set_value', 'trainerId, target and value are required.');
|
||||
}
|
||||
if (type === 'remote_command') {
|
||||
return invalid('invalid_command', 'Unknown remote command.');
|
||||
}
|
||||
return invalid('unknown_message', 'Unknown protocol message type.');
|
||||
}
|
||||
|
||||
if (!handshaken) {
|
||||
if (message.type !== 'hello' && !handshaken) {
|
||||
return invalid('handshake_required', 'Send a compatible hello message before commands.');
|
||||
}
|
||||
|
||||
if (message.type === 'set_value') {
|
||||
if (!safeString(message.payload.trainerId) || !safeString(message.payload.target) || !('value' in message.payload)) {
|
||||
return invalid('invalid_set_value', 'trainerId, target and value are required.');
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (message.type === 'remote_command') {
|
||||
if (message.payload.action !== 'launch' && message.payload.action !== 'stop') {
|
||||
return invalid('invalid_command', 'Unknown remote command.');
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return invalid('unknown_message', 'Unknown protocol message type.');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function validateSetValueTarget(message, snapshot) {
|
||||
/** Only the parts this validator actually reads, so callers need not build a full payload. */
|
||||
type ValidationSnapshot = {
|
||||
trainerMeta: {
|
||||
trainer: Pick<TrainerMetaPayload['trainer'], 'trainerId'>;
|
||||
schema: { cheats: Pick<CheatSchema, 'target' | 'type'>[] };
|
||||
};
|
||||
trainerValues: Pick<TrainerValuesPayload, 'values'>;
|
||||
};
|
||||
|
||||
export function validateSetValueTarget(message: Pick<SetValueMessage, 'payload'>, snapshot: ValidationSnapshot | null) {
|
||||
const target = safeString(message.payload?.target);
|
||||
const requestedTrainerId = safeString(message.payload?.trainerId);
|
||||
const activeTrainerId = snapshot?.trainerMeta?.trainer?.trainerId || '';
|
||||
@@ -65,14 +70,6 @@ export function validateSetValueTarget(message, snapshot) {
|
||||
};
|
||||
}
|
||||
|
||||
function invalid(code, message) {
|
||||
function invalid(code: string, message: string) {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function safeString(value) {
|
||||
return typeof value === 'string' && value.length > 0 ? value : '';
|
||||
}
|
||||
|
||||
Vendored
+6
-1
@@ -1,12 +1,17 @@
|
||||
const { createBridgeServer } = require('./server');
|
||||
import type { BridgeOptions } from './types';
|
||||
|
||||
declare global {
|
||||
var __wandRemoteBridgeRuntime: ReturnType<typeof createBridgeRuntime> | undefined;
|
||||
}
|
||||
|
||||
function createBridgeRuntime(options: BridgeOptions = {}) {
|
||||
return createBridgeServer(options);
|
||||
}
|
||||
|
||||
function ensureBridge(options: BridgeOptions = {}) {
|
||||
if (!globalThis.__wandRemoteBridgeRuntime) {
|
||||
// A closed instance must not be handed out again: its server is gone and its state cleared.
|
||||
if (!globalThis.__wandRemoteBridgeRuntime || globalThis.__wandRemoteBridgeRuntime.closed) {
|
||||
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
|
||||
}
|
||||
|
||||
|
||||
Vendored
+51
-13
@@ -18,7 +18,10 @@ const VIRTUAL_MAC_PREFIXES = new Set([
|
||||
'52:54:00',
|
||||
]);
|
||||
|
||||
function contentTypeFor(filePath) {
|
||||
import type { NetworkInterfaceInfo } from 'node:os';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
|
||||
function contentTypeFor(filePath: string) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
switch (extension) {
|
||||
case '.html':
|
||||
@@ -37,12 +40,12 @@ function contentTypeFor(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
function getAdvertisedUrls(port) {
|
||||
const candidates: any[] = [];
|
||||
function getAdvertisedUrls(port: number) {
|
||||
const candidates: { index: number, score: number, url: string }[] = [];
|
||||
const interfaces = os.networkInterfaces();
|
||||
let index = 0;
|
||||
|
||||
for (const [name, entries] of Object.entries(interfaces) as [string, any[] | undefined][]) {
|
||||
for (const [name, entries] of Object.entries(interfaces) as [string, NetworkInterfaceInfo[] | undefined][]) {
|
||||
if (!entries) {
|
||||
continue;
|
||||
}
|
||||
@@ -69,15 +72,15 @@ function getAdvertisedUrls(port) {
|
||||
return Array.from(new Set(urls));
|
||||
}
|
||||
|
||||
function isUsableIpv4Entry(entry) {
|
||||
function isUsableIpv4Entry(entry: NetworkInterfaceInfo) {
|
||||
return Boolean(entry && !entry.internal && isIpv4Family(entry.family) && parseIpv4(entry.address));
|
||||
}
|
||||
|
||||
function isIpv4Family(family) {
|
||||
function isIpv4Family(family: string | number) {
|
||||
return family === 'IPv4' || family === 4;
|
||||
}
|
||||
|
||||
function scoreIpv4Entry(name, entry) {
|
||||
function scoreIpv4Entry(name: string, entry: NetworkInterfaceInfo) {
|
||||
const octets = parseIpv4(entry.address) as number[];
|
||||
let score = 0;
|
||||
|
||||
@@ -116,7 +119,7 @@ function scoreIpv4Entry(name, entry) {
|
||||
return score;
|
||||
}
|
||||
|
||||
function parseIpv4(address): number[] | null {
|
||||
function parseIpv4(address: unknown): number[] | null {
|
||||
if (typeof address !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -130,15 +133,15 @@ function parseIpv4(address): number[] | null {
|
||||
return octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) ? octets : null;
|
||||
}
|
||||
|
||||
function isPrivateIpv4(octets) {
|
||||
function isPrivateIpv4(octets: number[]) {
|
||||
return octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168);
|
||||
}
|
||||
|
||||
function isLinkLocalIpv4(octets) {
|
||||
function isLinkLocalIpv4(octets: number[]) {
|
||||
return octets[0] === 169 && octets[1] === 254;
|
||||
}
|
||||
|
||||
function isVirtualMac(mac) {
|
||||
function isVirtualMac(mac: unknown) {
|
||||
if (typeof mac !== 'string') {
|
||||
return false;
|
||||
}
|
||||
@@ -146,9 +149,43 @@ function isVirtualMac(mac) {
|
||||
return VIRTUAL_MAC_PREFIXES.has(mac.toLowerCase().slice(0, 8));
|
||||
}
|
||||
|
||||
function serveFile(response, filePath) {
|
||||
// Async on purpose: this runs on the same event loop as every live WebSocket client,
|
||||
// so a blocking read would stall trainer updates for everyone.
|
||||
/**
|
||||
* Resolves a request path inside `root`, or null when it escapes.
|
||||
* Today's routing happens to be safe only because pathnames are never percent-decoded;
|
||||
* decoding without this check would turn `%2e%2e%2f` into a real traversal.
|
||||
*/
|
||||
function resolveInsideRoot(root: string, relativePath: string): string | null {
|
||||
const decoded = safeDecode(relativePath);
|
||||
if (decoded === null || decoded.indexOf('\0') >= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const candidate = path.resolve(resolvedRoot, `.${path.sep}${decoded}`);
|
||||
const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
||||
|
||||
return candidate === resolvedRoot || candidate.startsWith(prefix) ? candidate : null;
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath);
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function serveFile(response: ServerResponse, filePath: string | null) {
|
||||
if (filePath === null) {
|
||||
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath);
|
||||
response.writeHead(200, {
|
||||
'Content-Type': contentTypeFor(filePath),
|
||||
'Cache-Control': 'no-store',
|
||||
@@ -162,5 +199,6 @@ function serveFile(response, filePath) {
|
||||
|
||||
module.exports = {
|
||||
getAdvertisedUrls,
|
||||
resolveInsideRoot,
|
||||
serveFile,
|
||||
};
|
||||
|
||||
Vendored
+173
-141
@@ -8,7 +8,6 @@ const {
|
||||
DEFAULT_REMOTE_PORT,
|
||||
DEV_SERVER_PORTS,
|
||||
PORT_SCAN_RANGE,
|
||||
REMOTE_ASSETS_PREFIX,
|
||||
REMOTE_BASE_PATH,
|
||||
REMOTE_HEALTH_PATH,
|
||||
REMOTE_WS_PATH,
|
||||
@@ -21,17 +20,43 @@ const {
|
||||
} = require('./normalizers');
|
||||
const { createBridgeState } = require('./bridge-state');
|
||||
const { validateClientMessage, validateSetValueTarget } = require('./protocol-router');
|
||||
const { getAdvertisedUrls, serveFile } = require('./server-files');
|
||||
const { cloneValue, isValidPort, safeString } = require('./utils');
|
||||
const { getAdvertisedUrls, resolveInsideRoot, serveFile } = require('./server-files');
|
||||
const { cloneValue, isValidPort, safeString, toStringId } = require('./utils');
|
||||
const {
|
||||
closeClient,
|
||||
createAcceptKey,
|
||||
FRAME_TOO_LARGE_ERROR,
|
||||
WS_PROTOCOL_ERROR,
|
||||
makeFrame,
|
||||
parseFrame,
|
||||
sendJson,
|
||||
} = require('./websocket-codec');
|
||||
import type { BridgeOptions } from './types';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import type { BridgeOptions, BridgeClient } from './types';
|
||||
|
||||
const HTTP_BAD_REQUEST = 400;
|
||||
const HTTP_FORBIDDEN = 403;
|
||||
const HTTP_NOT_FOUND = 404;
|
||||
|
||||
const WS_CLOSE_NORMAL = 1000;
|
||||
const WS_CLOSE_PROTOCOL_ERROR = 1002;
|
||||
const WS_CLOSE_UNSUPPORTED = 1003;
|
||||
const WS_CLOSE_TOO_LARGE = 1009;
|
||||
|
||||
declare global {
|
||||
var __wandRemoteBridgeUrl: string | undefined;
|
||||
var __wandRemoteBridgeLogFile: string | undefined;
|
||||
}
|
||||
|
||||
type SetValueHandler = (args: { trainerId: string; target: string; value: unknown; cheatId?: string }) => boolean | Promise<boolean>;
|
||||
type CommandHandler = (args: { action: string; gameId: string; titleId: string }) => unknown | Promise<unknown>;
|
||||
|
||||
type ClientMessage = {
|
||||
type?: string;
|
||||
requestId?: string | number | null;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function createBridgeServer(options: BridgeOptions = {}) {
|
||||
const preferredPort = Number(options.port || process.env.WAND_REMOTE_PORT || DEFAULT_REMOTE_PORT);
|
||||
@@ -39,12 +64,13 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
const maxPort = Number(options.maxPort || process.env.WAND_REMOTE_MAX_PORT || port + PORT_SCAN_RANGE);
|
||||
const host = options.host || process.env.WAND_REMOTE_HOST || DEFAULT_REMOTE_HOST;
|
||||
const panelRoot = options.panelRoot || path.dirname(__dirname);
|
||||
const clients = new Set<any>();
|
||||
const clients = new Set<BridgeClient>();
|
||||
const log = createBridgeLogger(options);
|
||||
let advertisedUrls: string[] = [];
|
||||
let setValueHandler: any = null;
|
||||
let commandHandler: any = null;
|
||||
let setValueHandler: SetValueHandler | null = null;
|
||||
let commandHandler: CommandHandler | null = null;
|
||||
let listening = false;
|
||||
let closed = false;
|
||||
const bridgeState = createBridgeState({
|
||||
clients,
|
||||
log,
|
||||
@@ -55,24 +81,24 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
}),
|
||||
});
|
||||
|
||||
function setAdvertisedPort(nextPort) {
|
||||
function setAdvertisedPort(nextPort: number) {
|
||||
port = nextPort;
|
||||
advertisedUrls = getAdvertisedUrls(port);
|
||||
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry) => !entry.includes('localhost')) || advertisedUrls[0];
|
||||
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry: string) => !entry.includes('localhost')) || advertisedUrls[0];
|
||||
}
|
||||
|
||||
function setHandler(handler) {
|
||||
function setHandler(handler: SetValueHandler | null) {
|
||||
setValueHandler = typeof handler === 'function' ? handler : null;
|
||||
}
|
||||
|
||||
function setCommandHandler(handler) {
|
||||
function setCommandHandler(handler: CommandHandler | null) {
|
||||
commandHandler = typeof handler === 'function' ? handler : null;
|
||||
}
|
||||
|
||||
function handleRequest(request, response) {
|
||||
function handleRequest(request: IncomingMessage, response: ServerResponse) {
|
||||
const url = parseRequestUrl(request.url);
|
||||
if (!url) {
|
||||
response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.writeHead(HTTP_BAD_REQUEST, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.end('Bad Request');
|
||||
return;
|
||||
}
|
||||
@@ -100,23 +126,21 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith(REMOTE_ASSETS_PREFIX)) {
|
||||
serveFile(response, path.join(panelRoot, url.pathname.replace(REMOTE_BASE_PATH, '')));
|
||||
// Any file under the panel root, not just assets/: a Vite build also emits
|
||||
// icons and a manifest at the root, and /remote/index.html must resolve too.
|
||||
if (url.pathname.startsWith(REMOTE_BASE_PATH)) {
|
||||
serveFile(response, resolveInsideRoot(panelRoot, url.pathname.slice(REMOTE_BASE_PATH.length)));
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.writeHead(HTTP_NOT_FOUND, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.end('Not found');
|
||||
}
|
||||
|
||||
async function handleRemoteCommandMessage(client, message) {
|
||||
async function handleRemoteCommandMessage(client: BridgeClient, message: ClientMessage) {
|
||||
const action = normalizeRemoteCommandAction(message.payload?.action);
|
||||
const gameId = typeof message.payload?.gameId === 'string' || typeof message.payload?.gameId === 'number'
|
||||
? String(message.payload.gameId)
|
||||
: null;
|
||||
const titleId = typeof message.payload?.titleId === 'string' || typeof message.payload?.titleId === 'number'
|
||||
? String(message.payload.titleId)
|
||||
: null;
|
||||
const gameId = toStringId(message.payload?.gameId);
|
||||
const titleId = toStringId(message.payload?.titleId);
|
||||
|
||||
if (!action) {
|
||||
sendJson(client, 'error', {
|
||||
@@ -164,76 +188,52 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetValueMessage(client, message) {
|
||||
async function handleSetValueMessage(client: BridgeClient, message: ClientMessage) {
|
||||
const currentSnapshot = bridgeState.snapshot;
|
||||
const validation = validateSetValueTarget(message, currentSnapshot);
|
||||
const trainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId || '';
|
||||
const target = validation.ok ? validation.target : safeString(message.payload?.target);
|
||||
const reply = (error?: { code: string; message: string }) => sendJson(
|
||||
client,
|
||||
'set_value_result',
|
||||
{ ok: !error, trainerId, target, error },
|
||||
message.requestId ?? null,
|
||||
);
|
||||
|
||||
if (!validation.ok) {
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: false,
|
||||
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || '',
|
||||
target: safeString(message.payload?.target),
|
||||
error: validation.error,
|
||||
}, message.requestId ?? null);
|
||||
reply(validation.error);
|
||||
return;
|
||||
}
|
||||
const { target } = validation;
|
||||
|
||||
if (!setValueHandler) {
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: false,
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
error: {
|
||||
code: 'bridge_not_ready',
|
||||
message: 'The local bridge is not ready to write trainer values yet.',
|
||||
},
|
||||
}, message.requestId ?? null);
|
||||
reply({
|
||||
code: 'bridge_not_ready',
|
||||
message: 'The local bridge is not ready to write trainer values yet.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result = false;
|
||||
let accepted = false;
|
||||
try {
|
||||
result = await Promise.resolve(setValueHandler({
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
accepted = await Promise.resolve(setValueHandler({
|
||||
trainerId,
|
||||
target,
|
||||
value: cloneValue(validation.value),
|
||||
cheatId: typeof message.payload?.cheatId === 'string' ? message.payload.cheatId : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
log('warn', 'Set-value handler failed.', error);
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: false,
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
error: {
|
||||
code: 'set_failed',
|
||||
message: 'Failed to set trainer value.',
|
||||
},
|
||||
}, message.requestId ?? null);
|
||||
reply({ code: 'set_failed', message: 'Failed to set trainer value.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: false,
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
error: {
|
||||
code: 'set_rejected',
|
||||
message: 'The trainer rejected the requested value.',
|
||||
},
|
||||
}, message.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: true,
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
}, message.requestId ?? null);
|
||||
reply(accepted ? undefined : {
|
||||
code: 'set_rejected',
|
||||
message: 'The trainer rejected the requested value.',
|
||||
});
|
||||
}
|
||||
|
||||
async function handleClientMessage(client, message) {
|
||||
async function handleClientMessage(client: BridgeClient, message: ClientMessage) {
|
||||
const validation = validateClientMessage(message, client.handshaken);
|
||||
if (!validation.ok) {
|
||||
sendJson(client, 'error', validation.error, message?.requestId ?? null);
|
||||
@@ -264,91 +264,118 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function bindSocket(socket) {
|
||||
const client = {
|
||||
// Handling a message can await a renderer round-trip, so frames are drained by a single
|
||||
// loop per client. Appending on 'data' must never interleave with the loop mutating the
|
||||
// buffer, or frames are duplicated or lost.
|
||||
async function drainFrames(client: BridgeClient) {
|
||||
if (client.draining) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.draining = true;
|
||||
try {
|
||||
while (!client.closed && client.buffer.length > 0) {
|
||||
const frame = parseFrame(client.buffer);
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.buffer = client.buffer.subarray(frame.bytesConsumed);
|
||||
|
||||
if (!frame.fin) {
|
||||
closeClient(client, WS_CLOSE_UNSUPPORTED, 'Fragmented frames are not supported.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.opcode === WS_OPCODE.CLOSE) {
|
||||
let code = WS_CLOSE_NORMAL;
|
||||
if (frame.payload.length >= 2) {
|
||||
code = frame.payload.readUInt16BE(0);
|
||||
}
|
||||
closeClient(client, code, 'Closing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.opcode === WS_OPCODE.PING) {
|
||||
client.socket.write(makeFrame(WS_OPCODE.PONG, frame.payload));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame.opcode !== WS_OPCODE.TEXT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleClientMessage(client, JSON.parse(frame.payload.toString('utf8')));
|
||||
} catch (error) {
|
||||
// The frame is already consumed, so the ones behind it stay drainable.
|
||||
sendJson(client, 'error', {
|
||||
code: 'invalid_message',
|
||||
message: error instanceof Error ? error.message : 'Failed to process client message.',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error) {
|
||||
if (error.code === FRAME_TOO_LARGE_ERROR) {
|
||||
closeClient(client, WS_CLOSE_TOO_LARGE, error.message);
|
||||
return;
|
||||
}
|
||||
if (error.code === WS_PROTOCOL_ERROR) {
|
||||
closeClient(client, WS_CLOSE_PROTOCOL_ERROR, error.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log('warn', 'Dropping client after an unreadable frame.', error);
|
||||
closeClient(client, WS_CLOSE_PROTOCOL_ERROR, 'Protocol error.');
|
||||
} finally {
|
||||
client.draining = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bindSocket(socket: Socket) {
|
||||
const client: BridgeClient = {
|
||||
socket,
|
||||
buffer: Buffer.alloc(0),
|
||||
closed: false,
|
||||
draining: false,
|
||||
handshaken: false,
|
||||
};
|
||||
|
||||
clients.add(client);
|
||||
|
||||
socket.on('data', async (chunk) => {
|
||||
try {
|
||||
client.buffer = Buffer.concat([client.buffer, chunk]);
|
||||
|
||||
while (client.buffer.length > 0) {
|
||||
const frame = parseFrame(client.buffer);
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.buffer = client.buffer.subarray(frame.bytesConsumed);
|
||||
|
||||
if (!frame.fin) {
|
||||
closeClient(client, 1003, 'Fragmented frames are not supported.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.opcode === WS_OPCODE.CLOSE) {
|
||||
closeClient(client, 1000, 'Closing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.opcode === WS_OPCODE.PING) {
|
||||
client.socket.write(makeFrame(WS_OPCODE.PONG, frame.payload));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame.opcode !== WS_OPCODE.TEXT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await handleClientMessage(client, JSON.parse(frame.payload.toString('utf8')));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === FRAME_TOO_LARGE_ERROR) {
|
||||
closeClient(client, 1009, error.message);
|
||||
return;
|
||||
}
|
||||
sendJson(client, 'error', {
|
||||
code: 'invalid_message',
|
||||
message: error instanceof Error ? error.message : 'Failed to process client message.',
|
||||
});
|
||||
const dropClient = (error?: unknown) => {
|
||||
client.closed = true;
|
||||
clients.delete(client);
|
||||
if (error) {
|
||||
log('warn', 'WebSocket client error.', error);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
client.buffer = Buffer.concat([client.buffer, chunk]);
|
||||
void drainFrames(client);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
client.closed = true;
|
||||
clients.delete(client);
|
||||
});
|
||||
|
||||
socket.on('end', () => {
|
||||
client.closed = true;
|
||||
clients.delete(client);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
client.closed = true;
|
||||
clients.delete(client);
|
||||
log('warn', 'WebSocket client error.', error);
|
||||
});
|
||||
socket.on('close', () => dropClient());
|
||||
socket.on('end', () => dropClient());
|
||||
socket.on('error', dropClient);
|
||||
}
|
||||
|
||||
function handleUpgrade(request, socket) {
|
||||
function handleUpgrade(request: IncomingMessage, socket: Socket) {
|
||||
const url = parseRequestUrl(request.url);
|
||||
if (!url) {
|
||||
rejectUpgrade(socket, 400, 'Bad Request');
|
||||
rejectUpgrade(socket, HTTP_BAD_REQUEST, 'Bad Request');
|
||||
return;
|
||||
}
|
||||
if (url.pathname !== REMOTE_WS_PATH) {
|
||||
rejectUpgrade(socket, 404, 'Not Found');
|
||||
rejectUpgrade(socket, HTTP_NOT_FOUND, 'Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAllowedWebSocketOrigin(request.headers.origin, request.headers.host)) {
|
||||
rejectUpgrade(socket, 403, 'Forbidden');
|
||||
rejectUpgrade(socket, HTTP_FORBIDDEN, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -370,7 +397,7 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
bindSocket(socket);
|
||||
}
|
||||
|
||||
function listen(nextPort) {
|
||||
function listen(nextPort: number) {
|
||||
setAdvertisedPort(nextPort);
|
||||
server.listen(port, host);
|
||||
}
|
||||
@@ -381,7 +408,7 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
|
||||
const server = http.createServer(handleRequest);
|
||||
server.on('upgrade', handleUpgrade);
|
||||
server.on('error', (error) => {
|
||||
server.on('error', (error: Error & { code?: string }) => {
|
||||
if (!listening && error && error.code === 'EADDRINUSE' && port < maxPort) {
|
||||
const nextPort = port + 1;
|
||||
log('warn', `Port ${port} is busy, trying ${nextPort}.`);
|
||||
@@ -405,6 +432,9 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
get listening() {
|
||||
return listening;
|
||||
},
|
||||
get closed() {
|
||||
return closed;
|
||||
},
|
||||
get remoteUrl() {
|
||||
return globalThis.__wandRemoteBridgeUrl;
|
||||
},
|
||||
@@ -415,6 +445,8 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
clients.clear();
|
||||
bridgeState.clear();
|
||||
listening = false;
|
||||
closed = true;
|
||||
globalThis.__wandRemoteBridgeUrl = undefined;
|
||||
server.close();
|
||||
},
|
||||
setCommandHandler,
|
||||
@@ -427,7 +459,7 @@ function createBridgeServer(options: BridgeOptions = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseRequestUrl(requestUrl) {
|
||||
function parseRequestUrl(requestUrl: string | undefined) {
|
||||
try {
|
||||
return new URL(requestUrl || '/', 'http://localhost');
|
||||
} catch {
|
||||
@@ -435,7 +467,7 @@ function parseRequestUrl(requestUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedWebSocketOrigin(origin, host) {
|
||||
function isAllowedWebSocketOrigin(origin: string | undefined, host: string | undefined) {
|
||||
if (origin === undefined) {
|
||||
return true;
|
||||
}
|
||||
@@ -453,17 +485,17 @@ function isAllowedWebSocketOrigin(origin, host) {
|
||||
const sameHostname = parsed.hostname.toLowerCase() === requested.hostname.toLowerCase();
|
||||
const compatibleLoopback = isLoopback(parsed.hostname) && isLoopback(requested.hostname);
|
||||
return parsed.host.toLowerCase() === host.toLowerCase()
|
||||
|| DEV_SERVER_PORTS.includes(parsed.port) && (sameHostname || compatibleLoopback);
|
||||
|| (DEV_SERVER_PORTS.includes(parsed.port) && (sameHostname || compatibleLoopback));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopback(hostname) {
|
||||
function isLoopback(hostname: string) {
|
||||
return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(hostname.toLowerCase());
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket, statusCode, statusText) {
|
||||
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
|
||||
socket.end([
|
||||
`HTTP/1.1 ${statusCode} ${statusText}`,
|
||||
'Connection: close',
|
||||
|
||||
Vendored
+71
-16
@@ -1,24 +1,79 @@
|
||||
export type BridgeOptions = {
|
||||
host?: string;
|
||||
logFile?: string;
|
||||
maxPort?: number | string;
|
||||
panelRoot?: string;
|
||||
port?: number | string;
|
||||
scriptsRoot?: string;
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
/**
|
||||
* Shared vocabulary for the bridge runtime. Payloads crossing the Wand renderer
|
||||
* IPC boundary are genuinely unknown until a normalizer validates them, so they
|
||||
* are typed `unknown` and narrowed there - not `any`.
|
||||
*/
|
||||
|
||||
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
export type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
export type LogFn = (level: LogLevel, message: string, error?: unknown) => void;
|
||||
|
||||
/** One connected websocket peer. */
|
||||
export type BridgeClient = {
|
||||
socket: Socket;
|
||||
buffer: Buffer;
|
||||
closed: boolean;
|
||||
draining: boolean;
|
||||
handshaken: boolean;
|
||||
};
|
||||
|
||||
export type BridgeOptions = {
|
||||
logFile?: string;
|
||||
port?: number;
|
||||
maxPort?: number;
|
||||
host?: string;
|
||||
panelRoot?: string;
|
||||
};
|
||||
|
||||
export type ServerInfo = {
|
||||
port: number;
|
||||
advertisedUrls: string[];
|
||||
};
|
||||
|
||||
/** A decoded websocket frame. */
|
||||
export type WsFrame = {
|
||||
opcode: number;
|
||||
payload: Buffer;
|
||||
rest: Buffer;
|
||||
};
|
||||
|
||||
export interface BridgeLogger extends LogFn {
|
||||
file: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal structural views of the Electron objects the bridge touches. Declared here
|
||||
* rather than in each consumer: `@types/electron` is not a dependency, and the runtime
|
||||
* modules use `module.exports`, which esbuild disables in any file carrying an `export`.
|
||||
*/
|
||||
export type WebContentsPort = {
|
||||
executeJavaScript(source: string, userGesture?: boolean): Promise<unknown>;
|
||||
isDestroyed(): boolean;
|
||||
on(event: string, listener: () => void): void;
|
||||
send(channel: string, payload: unknown): void;
|
||||
send(channel: string, ...args: unknown[]): void;
|
||||
executeJavaScript(code: string, userGesture?: boolean): Promise<unknown>;
|
||||
on(event: 'dom-ready' | 'did-finish-load', listener: () => void): void;
|
||||
/** Optional in Electron's older typings; guarded at every call site. */
|
||||
once?(event: 'destroyed', listener: () => void): void;
|
||||
};
|
||||
|
||||
export type IpcMainEventPort = {
|
||||
sender?: WebContentsPort;
|
||||
};
|
||||
|
||||
export type IpcMainPort = {
|
||||
handle(channel: string, listener: (event: IpcMainEventPort, payload?: unknown) => unknown): void;
|
||||
};
|
||||
|
||||
export type AppPort = {
|
||||
on(event: 'web-contents-created', listener: (event: unknown, contents: WebContentsPort) => void): void;
|
||||
};
|
||||
|
||||
export type ElectronPort = {
|
||||
app: {
|
||||
on(event: 'web-contents-created', listener: (event: unknown, contents: WebContentsPort) => void): void;
|
||||
};
|
||||
ipcMain: {
|
||||
handle(channel: string, handler: (event: { sender?: WebContentsPort }, payload?: unknown) => unknown): void;
|
||||
};
|
||||
app: AppPort;
|
||||
ipcMain: IpcMainPort;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user