diff --git a/WineFix/Patches/PluginHostPin.cs b/WineFix/Patches/PluginHostPin.cs
new file mode 100644
index 0000000..cfebc58
--- /dev/null
+++ b/WineFix/Patches/PluginHostPin.cs
@@ -0,0 +1,86 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using AffinityPluginLoader.Core;
+
+namespace WineFix.Patches
+{
+ ///
+ /// Keeps libplugins.dll loaded for the lifetime of the process.
+ ///
+ /// Affinity loads and frees its plugin host more than once per session, and
+ /// something keeps calling into it after the last reference is dropped. A
+ /// crash dump taken on a JPEG import showed the main thread taking an
+ /// access violation trying to *execute* 0x6fffc524d6c0 -- an address inside
+ /// libplugins.dll, which by then was in the dump's unloaded-module list with
+ /// the memory marked FREE/NOACCESS. The call arrived from libscripting.dll
+ /// via Serif.Interop.Persona.dll, so the scripting engine was still holding
+ /// an entry point into a module the loader had already released. The module
+ /// appeared in that list twice at the same base address, so this is a
+ /// load/free cycle rather than a one-off teardown.
+ ///
+ /// Taking one reference that is never released makes the refcount unable to
+ /// reach zero, so the pages stay mapped and the stale pointer stays valid.
+ /// It does not fix whatever drops the last reference -- it removes the
+ /// consequence. Presumably something on Windows holds this module up in a
+ /// way Wine's loader does not, since the same build does not crash there.
+ ///
+ /// This is not a Harmony patch: the caller is native code with no managed
+ /// frame to intercept.
+ ///
+ public static class PluginHostPin
+ {
+ private const string ModuleName = "libplugins.dll";
+
+ // Held for the lifetime of the process. Never freed -- that is the point.
+ private static IntPtr _handle = IntPtr.Zero;
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern IntPtr LoadLibraryW(string lpLibFileName);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern IntPtr GetModuleHandleW(string lpModuleName);
+
+ public static void Apply()
+ {
+ if (_handle != IntPtr.Zero)
+ {
+ Logger.Info("Plugin host already held, nothing to do");
+ return;
+ }
+
+ // Full path rather than the bare name: the bare name would be resolved
+ // against the search path, and the point is to reference the copy
+ // sitting beside Affinity.exe -- the one that gets unloaded.
+ string path = ModuleName;
+ try
+ {
+ var dir = AppDomain.CurrentDomain.BaseDirectory;
+ if (!string.IsNullOrEmpty(dir))
+ {
+ var candidate = Path.Combine(dir, ModuleName);
+ if (File.Exists(candidate)) path = candidate;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning("Could not resolve the application directory, falling back to the module name: " + ex.Message);
+ }
+
+ bool alreadyLoaded = GetModuleHandleW(ModuleName) != IntPtr.Zero;
+
+ _handle = LoadLibraryW(path);
+ if (_handle == IntPtr.Zero)
+ {
+ Logger.Error("Failed to hold " + ModuleName + " (error " + Marshal.GetLastWin32Error() + ")");
+ return;
+ }
+
+ // When it was already loaded this only raises the refcount and no
+ // DllMain runs; when it was not, we have just loaded it earlier than
+ // Affinity would have.
+ Logger.Info("Holding a reference to " + ModuleName + " (" +
+ (alreadyLoaded ? "was already loaded" : "loaded early") + ")");
+ }
+ }
+}
diff --git a/WineFix/README.md b/WineFix/README.md
index a1fb431..db31e18 100644
--- a/WineFix/README.md
+++ b/WineFix/README.md
@@ -23,6 +23,7 @@ For detailed instructions, see the [WineFix Installation Guide](https://apl.ncur
- **Font enumeration fix** — Intermittent startup crash from parallel font enumeration. Fixed by forcing synchronous font loading.
- **Canva sign-in helper** — Canva sign-in helper to allow copy/paste of the authorization URL to complete sign-in, no protocol handler required.
- **Command-line file opening fix** — Opening files from the desktop or command line crashes or silently fails due to a missing WinRT type. Fixed by bypassing `ProcessCommandLineArguments` and opening files directly via `IDocumentViewService`.
+- **Plugin host pin** — Affinity frees its plugin host (`libplugins.dll`) during a session and later calls back into it, crashing with an access violation. Fixed by holding a reference to the module so the released code stays mapped.
## Configuration
diff --git a/WineFix/WineFixPlugin.cs b/WineFix/WineFixPlugin.cs
index d05ea2c..299da3f 100644
--- a/WineFix/WineFixPlugin.cs
+++ b/WineFix/WineFixPlugin.cs
@@ -22,6 +22,7 @@ public class WineFixPlugin : AffinityPlugin
public const string SettingWidenStubFix = "widen_stub_fix";
public const string SettingBezierSplitGuard = "bezier_split_guard";
public const string SettingCommandLineFileOpen = "command_line_file_open";
+ public const string SettingPinPluginHost = "pin_plugin_host";
public override PluginSettingsDefinition DefineSettings()
{
@@ -73,7 +74,23 @@ public override PluginSettingsDefinition DefineSettings()
.AddBool(SettingForceSyncFontEnum, "Force synchronous font enumeration",
defaultValue: true,
restartRequired: true,
- description: "Disable parallel font enumeration to significantly reduce frequency of startup crashes. May increase application startup time on systems with lots of fonts.");
+ description: "Disable parallel font enumeration to significantly reduce frequency of startup crashes. May increase application startup time on systems with lots of fonts.")
+ .AddBool(SettingPinPluginHost, "Keep the plugin host loaded",
+ defaultValue: true,
+ restartRequired: true,
+ description: "Hold a reference to libplugins.dll so it is never unloaded. Affinity frees its plugin host during a session and then calls back into it, which crashes the application with an access violation. Holding the module keeps the released code mapped so the stale call still lands on real instructions.");
+ }
+
+ public override void OnLoad(IPluginContext context)
+ {
+ if (context.Settings.GetEffectiveValue(SettingPinPluginHost))
+ {
+ Patches.PluginHostPin.Apply();
+ }
+ else
+ {
+ Logger.Info("Skipping plugin host hold (setting: disabled)");
+ }
}
public override void OnPatch(Harmony harmony, IPluginContext context)