fix(patch): roll back the installation when patching fails

Patching mutated the install in place with no recovery, so a failure left a
partly patched Wand behind: rewritten bundles in app.asar.unpacked, or a patched
app.asar with no launcher deployed, which cannot clear the fuse and fails with
-36861. Restore both backups on any failure and rethrow the original error.

Pack the archive into a sibling file and swap it in at the end. Writing straight
into app.asar truncated it when the stream opened, so a failed pack destroyed
the archive it was replacing.
This commit is contained in:
kitbyte
2026-08-29 18:44:24 +03:00
parent 8b83750bf2
commit 9bcb4bb991
2 changed files with 119 additions and 22 deletions
+48 -1
View File
@@ -128,7 +128,54 @@ namespace AsarSharp.AsarFileSystem
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;
}
// 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);
+71 -21
View File
@@ -434,33 +434,23 @@ namespace WandEnhancer.Core
throw new Exception("app.asar not found");
}
// Everything past this point mutates the installation. A half-applied patch does
// not boot - the fuse is only cleared by the deployed launcher, so a patched
// app.asar without it dies with -36861 - so failure has to put the files back.
try
{
_logger("[ENHANCER] Extracting app.asar...", ELogType.Info);
AsarExtractor.ExtractAll(_asarPath, _unpackedPath);
ExtractSources();
PatchAsar();
InjectRemotePanelFiles();
PackSources();
DeployLauncher();
}
catch (Exception e)
catch
{
throw new Exception($"[ENHANCER] Failed to unpack app.asar: {e.Message}", e);
RollbackQuietly();
throw;
}
PatchAsar();
InjectRemotePanelFiles();
try
{
new AsarCreator(_unpackedPath, _asarPath, new CreateOptions
{
Unpack = new Regex(@"^static\\unpacked.*$")
}).CreatePackageWithOptions();
}
catch (Exception e)
{
throw new Exception($"[ENHANCER] Failed to pack app.asar: {e.Message}", e);
}
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)
@@ -475,6 +465,66 @@ namespace WandEnhancer.Core
_logger("[ENHANCER] Done!", ELogType.Success);
}
private void ExtractSources()
{
try
{
_logger("[ENHANCER] Extracting app.asar...", ELogType.Info);
AsarExtractor.ExtractAll(_asarPath, _unpackedPath);
}
catch (Exception e)
{
throw new Exception($"[ENHANCER] Failed to unpack app.asar: {e.Message}", e);
}
}
private void PackSources()
{
try
{
new AsarCreator(_unpackedPath, _asarPath, new CreateOptions
{
Unpack = new Regex(@"^static\\unpacked.*$")
}).CreatePackageWithOptions();
}
catch (Exception e)
{
throw new Exception($"[ENHANCER] Failed to pack app.asar: {e.Message}", e);
}
}
/// <summary>
/// Best-effort restore after a failed patch. Never throws: the caller is already
/// propagating the real failure and it must not be replaced by a cleanup error.
/// </summary>
private void RollbackQuietly()
{
try
{
if (File.Exists(_backupPath))
{
File.Copy(_backupPath, _asarPath, true);
}
if (Directory.Exists(_unpackedBackupPath))
{
if (Directory.Exists(_unpackedPath))
{
Directory.Delete(_unpackedPath, true);
}
AsarSharp.Utils.Extensions.CopyDirectory(_unpackedBackupPath, _unpackedPath);
}
_logger("[ENHANCER] Patch failed - the original Wand files were restored.", ELogType.Warn);
}
catch (Exception e)
{
_logger($"[ENHANCER] Patch failed and the rollback did not finish: {e.Message}. " +
"Use Restore before launching Wand.", ELogType.Error);
}
}
public void Restore()
{
if (!File.Exists(_backupPath) || !Directory.Exists(_unpackedBackupPath))