diff --git a/CHANGELOG.md b/CHANGELOG.md index 6854b84..9499690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ The newest entry must match the version in `WandEnhancer/Properties/AssemblyInfo ### Improvements -- The launcher now writes a `launcher.log` next to itself, recording every process Electron starts and whether its fuse was cleared, exit and crash codes, and how long it stayed attached. Starting Wand happens without a window, so until now a client that refused to open left nothing to go on. +- When Wand fails to start, the Enhancer window now opens by itself with the reason already in the log, instead of leaving you to find a log file. The same lines are still written to a `launcher.log` next to the launcher: every process Electron starts and whether the patch reached it, plus exit and crash codes. - 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. diff --git a/WandEnhancer/Core/FuseLauncher.cs b/WandEnhancer/Core/FuseLauncher.cs index f6af3e0..7063c14 100644 --- a/WandEnhancer/Core/FuseLauncher.cs +++ b/WandEnhancer/Core/FuseLauncher.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Runtime.InteropServices; using System.Text; using WandEnhancer.View.MainWindow; @@ -20,7 +21,8 @@ namespace WandEnhancer.Core { private const int AsarIntegrityExitCode = -36861; - public static void Launch(string exePath, string args, Action log = null) + /// False when the session ended badly enough to be worth showing the user. + public static bool Launch(string exePath, string args, Action log = null) { long stateRva = ElectronFuse.FindStateRva(exePath); @@ -34,12 +36,12 @@ namespace WandEnhancer.Core IntPtr.Zero, System.IO.Path.GetDirectoryName(exePath), ref startupInfo, out var info)) { log?.Invoke($"Could not start Wand (win32 error {Marshal.GetLastWin32Error()}).", ELogType.Error); - return; + return false; } IntPtr job = IntPtr.Zero; IntPtr port = IntPtr.Zero; - bool mainCleared = false; + bool resumed = false; try { @@ -49,10 +51,10 @@ namespace WandEnhancer.Core { log?.Invoke($"No Electron fuse block in {exePath}. A patched Wand will exit " + $"with {AsarIntegrityExitCode}; an unpatched one is unaffected.", ELogType.Error); - return; + return false; } - mainCleared = ElectronFuse.ClearIn(info.hProcess, stateRva); + bool mainCleared = ElectronFuse.ClearIn(info.hProcess, stateRva); log?.Invoke(mainCleared ? $"pid {info.dwProcessId} started - fuse cleared." : $"Fuse not cleared in pid {info.dwProcessId}; it may exit with {AsarIntegrityExitCode}.", @@ -62,28 +64,42 @@ namespace WandEnhancer.Core { log?.Invoke($"Could not watch Wand for new processes (win32 error {Marshal.GetLastWin32Error()}). " + "Wand will run, but the in-game overlay will not.", ELogType.Error); - return; + return false; } - } - finally - { - ResumeThread(info.hThread); - CloseHandle(info.hThread); - } - try - { + ResumeThread(info.hThread); + resumed = true; + ClearFuseInNewProcesses(port, exePath, stateRva, info.dwProcessId, mainCleared, log); - log?.Invoke(GetExitCodeProcess(info.hProcess, out int exitCode) - ? $"Wand exited with code {DescribeCode(exitCode)}." - : "Wand exited, code unreadable.", - ELogType.Info); + + if (!GetExitCodeProcess(info.hProcess, out int exitCode)) + { + log?.Invoke("Wand exited, and its exit code could not be read.", ELogType.Error); + return false; + } + + log?.Invoke($"Wand exited with code {DescribeCode(exitCode)}.", + exitCode == 0 ? ELogType.Info : ELogType.Error); + return exitCode == 0; } finally { - CloseHandle(port); - CloseHandle(job); + if (!resumed) + { + ResumeThread(info.hThread); + } + + CloseHandle(info.hThread); CloseHandle(info.hProcess); + if (port != IntPtr.Zero) + { + CloseHandle(port); + } + + if (job != IntPtr.Zero) + { + CloseHandle(job); + } } } @@ -116,35 +132,60 @@ namespace WandEnhancer.Core private static void ClearFuseInNewProcesses(IntPtr port, string exePath, long stateRva, int mainProcessId, bool mainCleared, Action log) { + var tracked = new Dictionary(); int cleared = mainCleared ? 1 : 0; int missed = mainCleared ? 0 : 1; - while (GetQueuedCompletionStatus(port, out uint message, out _, out IntPtr value, INFINITE)) + try { - if (message == JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO) + while (GetQueuedCompletionStatus(port, out uint message, out _, out IntPtr value, INFINITE)) { - break; - } + if (message == JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO) + { + break; + } - int processId = value.ToInt32(); - // The main process is announced here too, having been patched while it was still - // suspended, and a game started from Wand joins the job like any other child. - if (message != JOB_OBJECT_MSG_NEW_PROCESS || processId == mainProcessId || - !IsImage(processId, exePath)) - { - continue; - } + int processId = value.ToInt32(); + if (message == JOB_OBJECT_MSG_EXIT_PROCESS || message == JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS) + { + ReportExit(tracked, processId, log); + continue; + } - if (Clear(processId, stateRva)) - { - cleared++; - log?.Invoke($"pid {processId} started - fuse cleared.", ELogType.Info); + // The main process is announced here too, having been patched while it was + // still suspended, and a game started from Wand joins the job like any child. + if (message != JOB_OBJECT_MSG_NEW_PROCESS || processId == mainProcessId || + !IsImage(processId, exePath)) + { + continue; + } + + // The handle is kept open: it is what makes the exit code readable later, and + // it also stops Windows handing the pid to someone else in the meantime. + IntPtr process = OpenProcess(ProcessAccess, false, processId); + if (process != IntPtr.Zero) + { + tracked[processId] = process; + } + + if (process != IntPtr.Zero && ElectronFuse.ClearIn(process, stateRva)) + { + cleared++; + log?.Invoke($"pid {processId} started - fuse cleared.", ELogType.Info); + } + else + { + missed++; + log?.Invoke($"Fuse not cleared in pid {processId}; it may exit with {AsarIntegrityExitCode}.", + ELogType.Warn); + } } - else + } + finally + { + foreach (var handle in tracked.Values) { - missed++; - log?.Invoke($"Fuse not cleared in pid {processId}; it may exit with {AsarIntegrityExitCode}.", - ELogType.Warn); + CloseHandle(handle); } } @@ -152,6 +193,26 @@ namespace WandEnhancer.Core missed == 0 ? ELogType.Info : ELogType.Warn); } + /// + /// Only anomalies are reported: on a normal shutdown every process exits with 0, and a + /// line each would bury the one death that matters. + /// + private static void ReportExit(Dictionary tracked, int processId, Action log) + { + if (!tracked.TryGetValue(processId, out IntPtr process)) + { + return; + } + + tracked.Remove(processId); + if (GetExitCodeProcess(process, out int exitCode) && exitCode != 0) + { + log?.Invoke($"pid {processId} exited with code {DescribeCode(exitCode)}.", ELogType.Error); + } + + CloseHandle(process); + } + /// /// Identity check before anything heavier: a game started from Wand is in the job as well, /// and is opened for nothing beyond the right the task manager uses to read a path. @@ -177,24 +238,6 @@ namespace WandEnhancer.Core } } - private static bool Clear(int processId, long stateRva) - { - IntPtr process = OpenProcess(ProcessAccess, false, processId); - if (process == IntPtr.Zero) - { - return false; - } - - try - { - return ElectronFuse.ClearIn(process, stateRva); - } - finally - { - CloseHandle(process); - } - } - private static string DescribeCode(int code) { switch (code) @@ -202,6 +245,8 @@ namespace WandEnhancer.Core case 0: return "0"; case AsarIntegrityExitCode: return $"{code} (ASAR integrity check failed - the fuse was not cleared in time)"; + // Chromium breaks into a debugger that is not there when it hits a fatal error. + case unchecked((int)0x80000003): return $"0x{code:X8} (Wand aborted itself during startup)"; case unchecked((int)0xC0000005): return $"0x{code:X8} (access violation)"; case unchecked((int)0xC0000135): return $"0x{code:X8} (a required DLL is missing)"; case unchecked((int)0xC0000142): return $"0x{code:X8} (a DLL failed to initialise)"; @@ -219,6 +264,8 @@ namespace WandEnhancer.Core private const int JobObjectAssociateCompletionPortInformation = 7; private const uint JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO = 4; private const uint JOB_OBJECT_MSG_NEW_PROCESS = 6; + private const uint JOB_OBJECT_MSG_EXIT_PROCESS = 7; + private const uint JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS = 8; private const uint INFINITE = 0xFFFFFFFF; private static readonly IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1); diff --git a/WandEnhancer/Program.cs b/WandEnhancer/Program.cs index cf75b3f..6ed5e68 100644 --- a/WandEnhancer/Program.cs +++ b/WandEnhancer/Program.cs @@ -28,9 +28,18 @@ namespace WandEnhancer AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + bool startupFailed = StartupLog.Exists(entry => entry.Value == ELogType.Error); + var application = new App(); application.InitializeComponent(); - application.MainWindow = new MainWindow(); + var window = new MainWindow(); + + // Launch mode has no window at all, so a user whose Wand never opened would have to + // be told where launcher.log lives. Put the same lines in front of them instead. + if (startupFailed) + window.Loaded += (sender, e) => BringToFront(window); + + application.MainWindow = window; application.Run(); } @@ -82,14 +91,21 @@ namespace WandEnhancer if (!isPatched && !TryAutoPatch(config, myDir)) return false; - FuseLauncher.Launch(config.ExecutablePath, forwardedArgs, LauncherLog.Write); - return true; + // RecordStartupLog, not LauncherLog.Write: whatever the launcher says has to survive + // into the window on the failure path below. + return FuseLauncher.Launch(config.ExecutablePath, forwardedArgs, RecordStartupLog); } - /// - /// Re-quotes argv for a command line. Squirrel hands us paths with spaces - /// (`--squirrel-install "C:\Users\Some Name\..."`); re-joining on spaces splits them. - /// + + private static void BringToFront(System.Windows.Window window) + { + window.WindowState = System.Windows.WindowState.Normal; + window.Topmost = true; + window.Activate(); + window.Topmost = false; + } + + private static string QuoteArguments(IEnumerable args) { return string.Join(" ", args.Select(QuoteArgument));