Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f798714
chore(build): enforce lint, type-check and dist invariants in build a…
k1tbyte Aug 29, 2026
20956c3
fix(asar): correct archive tree lookups and fail loudly on unreadable…
k1tbyte Aug 29, 2026
6716da5
feat(patch-engine): locate patches structurally instead of by signature
k1tbyte Aug 29, 2026
572b61a
feat(launcher): clear the ASAR fuse from a debugger instead of a prox…
k1tbyte Aug 29, 2026
e04e313
refactor(wpf): decouple the view model from the window and localize i…
k1tbyte Aug 29, 2026
5d1aa69
refactor(bridge): type the bridge and harden the websocket and file s…
k1tbyte Aug 29, 2026
f2e88e9
fix(renderer-scripts): await the bridge bind and retry it on poll
k1tbyte Aug 29, 2026
43e898c
feat(panel): add IconButton primitive and fix input, reconnect and a1…
k1tbyte Aug 29, 2026
8b83750
chore(release): prepare 2.0.0.0 and support pre-release tags
k1tbyte Aug 29, 2026
9bcb4bb
fix(patch): roll back the installation when patching fails
k1tbyte Aug 29, 2026
ff0c861
docs(changelog): note the failed-patch rollback
k1tbyte Aug 29, 2026
2cac405
feat(launcher): write startup diagnostics to launcher.log
k1tbyte Aug 29, 2026
df9aedc
fix(patch): stop the deployed launcher inheriting a read-only flag
k1tbyte Aug 29, 2026
a236456
feat(patch): report what is blocking a denied copy
k1tbyte Aug 29, 2026
56e1fc2
refactor(web-panel): migrate to biome, drop baseUrl, normalize eol
k1tbyte Aug 29, 2026
318a12b
fix(launcher): clear the fuse in every process Wand spawns
k1tbyte Aug 30, 2026
6c0e4dc
feat(launcher): show the window when Wand fails to start
k1tbyte Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1,4 +1,44 @@
# Auto-detect text files and normalize line endings to LF
* text=auto eol=lf

# Web panel & source files
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
*.json text eol=lf
*.css text eol=lf
*.html text eol=lf
*.md text eol=lf
*.svg text eol=lf
*.po text eol=lf

# Windows scripts / C# solutions
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=lf
*.cs text eol=crlf
*.sln text eol=crlf
*.csproj text eol=crlf

# Binary files
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.dll binary
*.exe binary
*.zip binary

# GitHub Linguist
# The web panel is the bundled frontend shipped inside the C# patcher.
# Mark it as vendored so GitHub Linguist keeps it out of the repository's
# language statistics the project is a C# app, not a TypeScript one.
# language statistics - the project is a C# app, not a TypeScript one.
web-panel/** linguist-vendored
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ name: Build executable

on:
workflow_dispatch:
pull_request:
push:
branches: [master]

jobs:
build:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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, '-') }}
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,6 @@ appsettings.json
*DotSettings.user
.tmp
.source
web-panel/bridge/wand-remote-bridge.cjs
web-panel/bridge/wand-remote-bridge.cjs
.vscode
docs
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 11 additions & 7 deletions AsarSharp/AsarCreator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,26 @@ private void HandleFile(Filesystem filesystem, string filename, List<Disk.BasicF
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);
}
}
}
17 changes: 9 additions & 8 deletions AsarSharp/AsarExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,20 +159,19 @@ private static void ExtractLink(string dest, string fullPath, string destFilenam
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))
{
throw new InvalidOperationException(
$"{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));
Expand All @@ -189,8 +188,10 @@ private static void ExtractLink(string dest, string fullPath, string destFilenam
}
else
{
var linkDestPath = Extensions.GetDirectoryName(destFilename);
var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath);
EnsureParentDir(destFilename, dirCache);
Extensions.CreateSymbolicLink(linkTo, destFilename);
Extensions.CreateSymbolicLink(Path.Combine(relativeLinkPath, Path.GetFileName(file.Link)), destFilename);
}
}
}
Expand Down
132 changes: 78 additions & 54 deletions AsarSharp/AsarFileSystem/Disk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -25,7 +23,6 @@ public class ArchiveHeader
public class FilesystemFilesAndLinks
{
public List<BasicFileInfo> Files { get; set; } = new List<BasicFileInfo>();
public List<BasicFileInfo> Links { get; set; } = new List<BasicFileInfo>();
}

public class BasicFileInfo
Expand All @@ -42,14 +39,14 @@ public static ArchiveHeader ReadArchiveHeaderSync(string archivePath)
65536, FileOptions.SequentialScan))
{
byte[] sizeBuf = new byte[8];
if (fs.Read(sizeBuf, 0, 8) != 8)
if (fs.ReadFull(sizeBuf, 0, 8) != 8)
throw new Exception("Unable to read header size");

var sizePickle = Pickle.CreateFromBuffer(sizeBuf);
var size = sizePickle.CreateIterator().ReadUInt32();

var headerBuf = new byte[size];
if (fs.Read(headerBuf, 0, (int)size) != size)
if (fs.ReadFull(headerBuf, 0, (int)size) != size)
throw new Exception("Unable to read header");

var headerPickle = Pickle.CreateFromBuffer(headerBuf);
Expand All @@ -65,62 +62,28 @@ public static ArchiveHeader ReadArchiveHeaderSync(string archivePath)
}
}

/// <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);
Expand Down Expand Up @@ -165,7 +128,56 @@ public static void WriteFileSystem(string dest, Filesystem fileSystem,
var buf = new byte[StreamBufferSize];
var blockBuf = new byte[4 * 1024 * 1024]; // shared across all files — avoids 4MB alloc per file

using (var fs = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan))
// Build beside the target and swap at the end. Writing straight into dest truncates
// it on open, so any failure mid-write left the caller with a destroyed archive.
string tempPath = dest + ".building";
try
{
WriteArchive(tempPath, dest, fileSystem, lists, serializerSettings,
headerPickle, sizePickle, sizePickleSize, buf, blockBuf);
ReplaceFile(tempPath, dest);
}
catch
{
TryDelete(tempPath);
throw;
}
}

private static void ReplaceFile(string tempPath, string dest)
{
if (!File.Exists(dest))
{
File.Move(tempPath, dest);
return;
}

// A read-only or hidden archive would fail the swap the same way an overwrite does.
Extensions.ClearAttributes(dest);
// File.Replace swaps in one step, so dest is never observed missing or half-written.
File.Replace(tempPath, dest, null, true);
}

private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (Exception e) when (e is IOException || e is UnauthorizedAccessException)
{
// Leftover build file only wastes space; the real failure is already propagating.
}
}

private static void WriteArchive(string archivePath, string dest, Filesystem fileSystem,
FilesystemFilesAndLinks lists, JsonSerializerSettings serializerSettings,
Pickle headerPickle, Pickle sizePickle, int sizePickleSize, byte[] buf, byte[] blockBuf)
{
using (var fs = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan))
{
sizePickle.WriteTo(fs);
headerPickle.WriteTo(fs);
Expand All @@ -192,6 +204,18 @@ public static void WriteFileSystem(string dest, Filesystem fileSystem,
var patchedSizePickle = Pickle.CreateEmpty();
patchedSizePickle.WriteUInt32((uint)patchedPickle.GetTotalSize());

// The rewrite lands on top of the placeholder header, so it must be exactly as
// long. Placeholder hashes are the same width as real ones, so this holds unless
// a file changed size between crawl and write - which would silently shred the
// payload that follows.
if (patchedPickle.GetTotalSize() != headerPickle.GetTotalSize() ||
patchedSizePickle.GetTotalSize() != sizePickleSize)
{
throw new InvalidOperationException(
"ASAR header changed size while packing (a source file was modified mid-build). " +
"Aborting rather than writing a corrupt archive.");
}

fs.Position = 0;
patchedSizePickle.WriteTo(fs);
patchedPickle.WriteTo(fs);
Expand Down
Loading