diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 3cc084bb..033e657b 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -69,6 +69,8 @@ public static class LibationScaffolding // // outdated. kept here as an example of what belongs in this area // // Migrations.migrate_to_v5_2_0__pre_config(); + InstallFolderUnblock.TryUnblockProcessDirectoryIfWindows(); + Configuration.SetLibationVersion(BuildVersion); //***********************************************// diff --git a/Source/LibationAvalonia/App.axaml.cs b/Source/LibationAvalonia/App.axaml.cs index bb3f5e13..9e473331 100644 --- a/Source/LibationAvalonia/App.axaml.cs +++ b/Source/LibationAvalonia/App.axaml.cs @@ -151,13 +151,14 @@ public class App : Application List library = await LibraryTask; await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library)); } - catch (Exception ex) when (StartupAssemblyBootstrap.IsMissingDependencyAssembly(ex)) + catch (Exception ex) when (StartupAssemblyBootstrap.IsInstallFolderAssemblyLoadFailure(ex)) { Serilog.Log.Logger.Error(ex, "Failed to load library at startup"); + StartupAssemblyBootstrap.TryGetStartupFailureMessage(ex, out var title, out var body); await MessageBox.Show( MainWindow, - StartupAssemblyBootstrap.GetLibraryLoadFailureMessage(), - "Library load failed", + body, + title, MessageBoxButtons.OK, MessageBoxIcon.Error); await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync([])); diff --git a/Source/LibationAvalonia/Program.cs b/Source/LibationAvalonia/Program.cs index db9bda49..02e001cf 100644 --- a/Source/LibationAvalonia/Program.cs +++ b/Source/LibationAvalonia/Program.cs @@ -128,13 +128,24 @@ static class Program { var dispatcher = new DispatcherFrame(); - var mbAlert = new MessageBoxAlertAdminDialog(""" - Libation encountered a fatal error and must close. + string caption; + string message; + if (StartupAssemblyBootstrap.TryGetStartupFailureMessage(exception, out var title, out var body)) + { + caption = title; + message = body; + } + else + { + caption = "Libation Crash"; + message = """ + Libation encountered a fatal error and must close. - Please consider reporting this issue on GitHub, including the contents of the LibationCrash.log file created in your user folder. - """, - "Libation Crash", - exception); + Please consider reporting this issue on GitHub, including the contents of the LibationCrash.log file created in your user folder. + """; + } + + var mbAlert = new MessageBoxAlertAdminDialog(message, caption, exception); mbAlert.Closed += (_, _) => dispatcher.Continue = false; mbAlert.Show(); Dispatcher.UIThread.PushFrame(dispatcher); diff --git a/Source/LibationFileManager/InstallFolderUnblock.cs b/Source/LibationFileManager/InstallFolderUnblock.cs new file mode 100644 index 00000000..2bdd4d97 --- /dev/null +++ b/Source/LibationFileManager/InstallFolderUnblock.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace LibationFileManager; + +/// +/// Removes Mark-of-the-Web (Zone.Identifier) from install-folder binaries on Windows. +/// Helps after manual downloads and in-app upgrades before Application Control evaluates files. +/// +public static class InstallFolderUnblock +{ + private static readonly string[] UnblockExtensions = [".dll", ".exe"]; + + public static void TryUnblockProcessDirectoryIfWindows() + { + if (!OperatingSystem.IsWindows()) + return; + + try + { + _ = UnblockDirectory(Configuration.ProcessDirectory); + } + catch + { + // Best-effort before logging may be available. + } + } + + public static int UnblockDirectory(string directory) + { + if (!OperatingSystem.IsWindows() || string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + return 0; + + var unblocked = 0; + if (TryUnblockFile(directory)) + unblocked++; + + foreach (var file in Directory.EnumerateFiles(directory)) + { + if (!ShouldUnblock(file)) + continue; + + if (TryUnblockFile(file)) + unblocked++; + } + + return unblocked; + } + + private static bool ShouldUnblock(string path) + { + var extension = Path.GetExtension(path); + foreach (var candidate in UnblockExtensions) + { + if (extension.Equals(candidate, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static bool TryUnblockFile(string path) + { + var zoneIdentifier = path + ":Zone.Identifier"; + if (!DeleteFile(zoneIdentifier)) + { + var error = Marshal.GetLastWin32Error(); + // File or stream does not exist. + if (error is 2 or 3) + return false; + + throw new IOException($"Could not remove Zone.Identifier from '{path}'. Win32 error {error}."); + } + + return true; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeleteFile(string lpFileName); +} diff --git a/Source/LibationFileManager/StartupAssemblyBootstrap.cs b/Source/LibationFileManager/StartupAssemblyBootstrap.cs index c3430e6e..74dd1fe1 100644 --- a/Source/LibationFileManager/StartupAssemblyBootstrap.cs +++ b/Source/LibationFileManager/StartupAssemblyBootstrap.cs @@ -9,6 +9,8 @@ namespace LibationFileManager; public static class StartupAssemblyBootstrap { public const string EntityFrameworkCoreSqliteAssemblyFileName = "Microsoft.EntityFrameworkCore.Sqlite.dll"; + public const int ApplicationControlBlockedHResult = unchecked((int)0x800711C7); + private const string TroubleshootApplicationControlUrl = "https://getlibation.com/docs/advanced/troubleshoot#windows-smart-app-control-and-in-app-upgrades"; /// /// Registers assembly resolution and verifies required install-folder assemblies exist. @@ -42,6 +44,9 @@ public static class StartupAssemblyBootstrap This often happens after an incomplete in-app upgrade. Quit Libation completely, then install a fresh copy of the latest release to a new folder (do not overlay files on top of the old install). + If the error mentions an Application Control policy or Smart App Control, see: + {TroubleshootApplicationControlUrl} + Install folder: {Configuration.ProcessDirectory} @@ -49,6 +54,78 @@ public static class StartupAssemblyBootstrap {Path.Combine(Configuration.ProcessDirectory, EntityFrameworkCoreSqliteAssemblyFileName)} """; + public static string GetApplicationControlBlockedMessage(Exception? ex = null) + { + var blockedFile = TryGetBlockedAssemblyPath(ex) ?? "(unknown)"; + + return $""" + Windows blocked Libation from loading a required file in its install folder. This often happens after an in-app upgrade when Smart App Control or another Application Control policy is enabled. + + Blocked file: + {blockedFile} + + Install folder: + {Configuration.ProcessDirectory} + + Your library database, accounts, and settings are stored separately and should be unaffected. + + To recover: + 1. Quit Libation completely. + 2. Download the latest release zip from GitHub and extract it to a new folder (do not copy files on top of the old install). + 3. Run Libation from the new folder. + + If Windows still blocks the app, review Smart App Control under Windows Security -> App & browser control, or run this in PowerShell (replace the path if needed): + Unblock-File -Path '{Configuration.ProcessDirectory}\*' -Recurse + + More help: + {TroubleshootApplicationControlUrl} + """; + } + + public static bool IsApplicationControlBlockedAssembly(Exception ex) + { + for (var current = ex; current is not null; current = current.InnerException) + { + if (current is FileLoadException fileLoadException) + { + if (fileLoadException.HResult == ApplicationControlBlockedHResult) + return true; + + if (fileLoadException.Message.Contains("Application Control policy", StringComparison.OrdinalIgnoreCase)) + return true; + } + + if (current.Message.Contains("Application Control policy", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + public static bool IsInstallFolderAssemblyLoadFailure(Exception ex) => + IsApplicationControlBlockedAssembly(ex) || IsMissingDependencyAssembly(ex); + + public static bool TryGetStartupFailureMessage(Exception ex, out string title, out string body) + { + if (IsApplicationControlBlockedAssembly(ex)) + { + title = "Libation blocked by Windows security"; + body = GetApplicationControlBlockedMessage(ex); + return true; + } + + if (IsMissingDependencyAssembly(ex)) + { + title = "Library load failed"; + body = GetLibraryLoadFailureMessage(); + return true; + } + + title = string.Empty; + body = string.Empty; + return false; + } + public static bool IsMissingDependencyAssembly(Exception ex) { for (var current = ex; current is not null; current = current.InnerException) @@ -65,6 +142,20 @@ public static class StartupAssemblyBootstrap return false; } + private static string? TryGetBlockedAssemblyPath(Exception? ex) + { + for (var current = ex; current is not null; current = current.InnerException) + { + if (current is FileLoadException { FileName: { Length: > 0 } blockedPath }) + return blockedPath; + + if (current is FileNotFoundException { FileName: { Length: > 0 } missingPath }) + return missingPath; + } + + return null; + } + private static void ValidateEntityFrameworkCoreSqlitePresent() { var path = Path.Combine(Configuration.ProcessDirectory, EntityFrameworkCoreSqliteAssemblyFileName); diff --git a/Source/LibationWinForms/Program.cs b/Source/LibationWinForms/Program.cs index 799f8b30..f6663f16 100644 --- a/Source/LibationWinForms/Program.cs +++ b/Source/LibationWinForms/Program.cs @@ -79,11 +79,13 @@ static class Program if (Configuration.Instance.SerilogInitialized) Log.Error(ex, "Fatal error during startup"); - var (title, body) = StartupAssemblyBootstrap.IsMissingDependencyAssembly(ex) - ? ("Library load failed", StartupAssemblyBootstrap.GetLibraryLoadFailureMessage()) - : ( - "Fatal error, pre-logging", - "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file."); + string title; + string body; + if (!StartupAssemblyBootstrap.TryGetStartupFailureMessage(ex, out title, out body)) + { + title = "Fatal error, pre-logging"; + body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file."; + } try { @@ -210,14 +212,11 @@ static class Program { await form.InitLibraryAsync(await libraryLoadTask); } - catch (Exception ex) when (StartupAssemblyBootstrap.IsMissingDependencyAssembly(ex)) + catch (Exception ex) when (StartupAssemblyBootstrap.IsInstallFolderAssemblyLoadFailure(ex)) { Log.Error(ex, "Failed to load library at startup"); - MessageBoxLib.ShowAdminAlert( - form, - StartupAssemblyBootstrap.GetLibraryLoadFailureMessage(), - "Library load failed", - ex); + StartupAssemblyBootstrap.TryGetStartupFailureMessage(ex, out var title, out var body); + MessageBoxLib.ShowAdminAlert(form, body, title, ex); await form.InitLibraryAsync([]); } } diff --git a/Source/LoadByOS/WindowsConfigApp/WinInterop.cs b/Source/LoadByOS/WindowsConfigApp/WinInterop.cs index 3a0d1b80..6173a659 100644 --- a/Source/LoadByOS/WindowsConfigApp/WinInterop.cs +++ b/Source/LoadByOS/WindowsConfigApp/WinInterop.cs @@ -57,6 +57,16 @@ internal class WinInterop : IInteropFunctions if (proc.ExitCode != 0) throw new InvalidOperationException($"ZipExtractor exited with code {proc.ExitCode}."); + try + { + var unblockedCount = InstallFolderUnblock.UnblockDirectory(thisDir); + Serilog.Log.Logger.Information("Unblocked {UnblockedCount} install files after upgrade in {InstallDir}", unblockedCount, thisDir); + } + catch (Exception ex) + { + Serilog.Log.Logger.Warning(ex, "Could not unblock install files after upgrade in {InstallDir}", thisDir); + } + TrySyncInstallMetadata(); } diff --git a/docs/advanced/troubleshoot.md b/docs/advanced/troubleshoot.md index d39ff69d..29c3b11a 100644 --- a/docs/advanced/troubleshoot.md +++ b/docs/advanced/troubleshoot.md @@ -32,6 +32,40 @@ Platform-specific steps: [Windows](#hangover-windows) ยท [macOS](#hangover-macos ## Windows +### Smart App Control and in-app upgrades {#windows-smart-app-control-and-in-app-upgrades} + +After accepting an in-app update, Libation may fail to restart with an error like: + +`An Application Control policy has blocked this file. (0x800711C7)` + +Windows **Smart App Control** (and similar Application Control policies on recent Windows 11 builds) can block DLLs that were just written when the in-app upgrader overlays a new release onto your existing install folder. The blocked path is usually under your **Libation install folder** (where `Libation.exe` lives), not your user data folder (`%UserProfile%\Libation`). + +**Symptoms** + +- Fatal crash immediately after an in-app upgrade (Chardonnay / Avalonia). +- Classic may start but library import or database access fails with the same `0x800711C7` message on a `.dll` in the install folder. +- Windows Security may also warn about an unsigned library. + +**Fix (recommended)** + +1. Quit Libation completely. +2. Download the latest [release zip](https://github.com/rmcrackan/Libation/releases/latest) from GitHub. +3. Extract to a **new folder** (for example `C:\Apps\Libation`). Do **not** copy new files on top of the old install folder. +4. Run `Libation.exe` from the new folder. Your library database, accounts, and settings in `%UserProfile%\Libation` (or the path in `appsettings.json` -> `LibationFiles`) are separate and should still work. + +**If Windows still blocks the new install** + +1. Open **Windows Security** -> **App & browser control** and review **Smart App Control** (Evaluation or On modes are the usual trigger). +2. In PowerShell, unblock the install folder (adjust the path): + + ```powershell + Unblock-File -Path 'C:\Apps\Libation\*' -Recurse + ``` + +3. Avoid running Libation from cloud-sync folders (OneDrive, etc.) if you can; use a normal local path for the install folder. + +Related reports: [#1876](https://github.com/rmcrackan/Libation/issues/1876), [#1873](https://github.com/rmcrackan/Libation/issues/1873). + ### Hangover (Windows) Hangover.exe is located in the folder containing Libation.exe. Double-click it to run it.