From 9bcb4bb99188c15c1057babb1a9d167c92cbe86e Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sat, 29 Aug 2026 18:44:24 +0300 Subject: [PATCH] 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. --- AsarSharp/AsarFileSystem/Disk.cs | 49 ++++++++++++++++- WandEnhancer/Core/Enhancer.cs | 92 ++++++++++++++++++++++++-------- 2 files changed, 119 insertions(+), 22 deletions(-) diff --git a/AsarSharp/AsarFileSystem/Disk.cs b/AsarSharp/AsarFileSystem/Disk.cs index 509fc5e..42a2809 100644 --- a/AsarSharp/AsarFileSystem/Disk.cs +++ b/AsarSharp/AsarFileSystem/Disk.cs @@ -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); diff --git a/WandEnhancer/Core/Enhancer.cs b/WandEnhancer/Core/Enhancer.cs index 28411c0..72e1b95 100644 --- a/WandEnhancer/Core/Enhancer.cs +++ b/WandEnhancer/Core/Enhancer.cs @@ -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); + } + } + + /// + /// Best-effort restore after a failed patch. Never throws: the caller is already + /// propagating the real failure and it must not be replaced by a cleanup error. + /// + private void RollbackQuietly() + { + try + { + if (File.Exists(_backupPath)) + { + 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))