From 47824fe88fd1df02d80646bd93a718a784e01618 Mon Sep 17 00:00:00 2001 From: jfacemyer <1776340+jfacemyer@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:38:17 -0400 Subject: [PATCH 1/6] WineFix: detect Wayland from the XDG session type as well The color picker magnifier fix defaults to "auto", which applies it when WAYLAND_DISPLAY is set. Wine 11.16 and later leave WAYLAND_DISPLAY out of the Windows environment (ntdll's is_ignored_env_var), so under current Wine the fix was never applied and the zoom preview stayed black on Wayland. The XDG session variables still come through, prefixed WINE_HOST_; also accept WINE_HOST_XDG_SESSION_TYPE or XDG_SESSION_TYPE set to "wayland". --- WineFix/WineFixPlugin.cs | 21 ++++++++++++++++++++- docs/winefix/configuration.md | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/WineFix/WineFixPlugin.cs b/WineFix/WineFixPlugin.cs index d05ea2c..63e7c63 100644 --- a/WineFix/WineFixPlugin.cs +++ b/WineFix/WineFixPlugin.cs @@ -119,7 +119,7 @@ public override void OnPatch(Harmony harmony, IPluginContext context) var magnifierFix = context.Settings.GetEffectiveValue(ColorPickerMagnifierFixKey); bool applyMagnifierFix = magnifierFix == "enabled" || - (magnifierFix == "auto" && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"))); + (magnifierFix == "auto" && IsWaylandSession()); if (applyMagnifierFix) { @@ -141,5 +141,24 @@ public override void OnPatch(Harmony harmony, IPluginContext context) Logger.Info("Skipping Canva sign-in helper (disabled in settings)"); } } + + /// + /// Whether the desktop session is Wayland; Wine itself may be running on XWayland. + /// Wine 11.16 and later leave WAYLAND_DISPLAY out of the Windows environment, but + /// pass the XDG session variables through with a WINE_HOST_ prefix. + /// + private static bool IsWaylandSession() + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"))) + return true; + + foreach (var name in new[] { "WINE_HOST_XDG_SESSION_TYPE", "XDG_SESSION_TYPE" }) + { + if (string.Equals(Environment.GetEnvironmentVariable(name), "wayland", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } } } diff --git a/docs/winefix/configuration.md b/docs/winefix/configuration.md index de2a45a..747412c 100644 --- a/docs/winefix/configuration.md +++ b/docs/winefix/configuration.md @@ -26,7 +26,7 @@ WineFix uses plugin ID `winefix`. Settings are stored in `apl/config/winefix.tom | Value | Behavior | |---|---| -| `auto` | Apply the fix only if Wayland or XWayland is detected (checks `WAYLAND_DISPLAY`). | +| `auto` | Apply the fix only if Wayland or XWayland is detected (checks `WAYLAND_DISPLAY`, then `XDG_SESSION_TYPE`; Wine 11.16 and later pass only the latter, as `WINE_HOST_XDG_SESSION_TYPE`). | | `enabled` | Always apply the fix. | | `disabled` | Never apply the fix. | From 3287e323f4da4714d535f5d8d318243f1d1392aa Mon Sep 17 00:00:00 2001 From: jfacemyer <1776340+jfacemyer@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:38:46 -0400 Subject: [PATCH 2/6] WineFix: take the color picker capture again once the overlay is hidden ColourPickerMagnifier.StartDragging hides the tool overlay (selection handles, outlines), and the first timer tick captures the canvas for the zoom preview; that capture is kept for the whole drag. Under Wine the canvas has usually not been redrawn without the overlay by then, so the preview showed the handles while the picked color came from the document under them. Drop the capture 50, 150, 300 and 600 ms into the drag, so the next tick takes it again from the redrawn canvas. --- WineFix/Patches/ColorPickerWaylandPatch.cs | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/WineFix/Patches/ColorPickerWaylandPatch.cs b/WineFix/Patches/ColorPickerWaylandPatch.cs index 7757838..ddeb8d0 100644 --- a/WineFix/Patches/ColorPickerWaylandPatch.cs +++ b/WineFix/Patches/ColorPickerWaylandPatch.cs @@ -31,12 +31,18 @@ public static class ColorPickerWaylandPatch private static PropertyInfo _currentViewProperty; private static PropertyInfo _renderControlProperty; // lazily cached (needs runtime type) private static ConstructorInfo _colourRGBConstructor; + private static FieldInfo _screenBmpField; // State private static IntPtr _hwnd = IntPtr.Zero; private static bool _pickerActive = false; private static bool _useExactPixelColor = false; private static double _monitorScale = 1.0; + private static int _dragStartTicks; + private static int _recaptureIndex; + + // When to take the canvas capture again, in ms after StartDragging; see UpdateWindowLocation_Prefix. + private static readonly int[] RecaptureAfterMs = { 50, 150, 300, 600 }; private const int SRCCOPY = 0x00CC0020; @@ -135,6 +141,14 @@ public static void ApplyPatches(Harmony harmony) harmony.Patch(finishDragging, prefix: new HarmonyMethod(typeof(ColorPickerWaylandPatch), nameof(FinishDragging_Prefix))); Logger.Info("Patched ColourPickerMagnifier StartDragging/FinishDragging"); + + var updateWindowLocation = magnifierType.GetMethod("UpdateWindowLocation", BindingFlags.NonPublic | BindingFlags.Instance); + _screenBmpField = magnifierType.GetField("_screenBmp", BindingFlags.NonPublic | BindingFlags.Instance); + if (updateWindowLocation != null && _screenBmpField != null) + { + harmony.Patch(updateWindowLocation, prefix: new HarmonyMethod(typeof(ColorPickerWaylandPatch), nameof(UpdateWindowLocation_Prefix))); + Logger.Info("Patched ColourPickerMagnifier UpdateWindowLocation (recapture after the overlay is hidden)"); + } } Logger.Info("ColorPickerWayland patch applied successfully"); @@ -146,6 +160,8 @@ public static void StartDragging_Prefix() { _pickerActive = true; _hwnd = IntPtr.Zero; + _dragStartTicks = Environment.TickCount; + _recaptureIndex = 0; try { @@ -194,6 +210,31 @@ public static void FinishDragging_Prefix() _pickerActive = false; } + /// + /// StartDragging hides the tool overlay (selection handles, outlines) and the first tick + /// captures the canvas, which is then kept for the whole drag. Under Wine the canvas is + /// usually not yet redrawn without the overlay at that point, so the zoom preview showed + /// the handles while the picked color came from the document under them. Drop the capture + /// a few times early in the drag, so the next tick takes it again from the redrawn canvas. + /// + public static void UpdateWindowLocation_Prefix(object __instance) + { + if (!_pickerActive || _recaptureIndex >= RecaptureAfterMs.Length) + return; + + int elapsed = Environment.TickCount - _dragStartTicks; + if (elapsed < RecaptureAfterMs[_recaptureIndex]) + return; + while (_recaptureIndex < RecaptureAfterMs.Length && elapsed >= RecaptureAfterMs[_recaptureIndex]) + _recaptureIndex++; + + if (_screenBmpField.GetValue(__instance) is Bitmap old) + { + _screenBmpField.SetValue(__instance, null); + old.Dispose(); + } + } + // ── Exact mode: override picked colour with the zoom preview center pixel ── /// From 8e25c534318991fd3e65ec9ddf533574571d92d3 Mon Sep 17 00:00:00 2001 From: jfacemyer <1776340+jfacemyer@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:30:22 -0400 Subject: [PATCH 3/6] WineFix: end the color picker pick when Affinity loses focus The color picker's magnifier is a topmost window. Under Wine, switching to another application does not end the pick, so the magnifier stayed above that application's windows. End it the way the tool does, by calling UserInterfaceService.ShowColourPicker(false) as a canceled pick, when the application is deactivated, and ignore requests to show it until the application is active again: the tool still sees the mouse over the canvas while Affinity is in the background. --- WineFix/Patches/ColorPickerWaylandPatch.cs | 59 ++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/WineFix/Patches/ColorPickerWaylandPatch.cs b/WineFix/Patches/ColorPickerWaylandPatch.cs index ddeb8d0..c272dd9 100644 --- a/WineFix/Patches/ColorPickerWaylandPatch.cs +++ b/WineFix/Patches/ColorPickerWaylandPatch.cs @@ -32,6 +32,7 @@ public static class ColorPickerWaylandPatch private static PropertyInfo _renderControlProperty; // lazily cached (needs runtime type) private static ConstructorInfo _colourRGBConstructor; private static FieldInfo _screenBmpField; + private static MethodInfo _showColourPickerMethod; // State private static IntPtr _hwnd = IntPtr.Zero; @@ -40,6 +41,8 @@ public static class ColorPickerWaylandPatch private static double _monitorScale = 1.0; private static int _dragStartTicks; private static int _recaptureIndex; + private static object _pickerService; // the UserInterfaceService showing the picker, if any + private static bool _appInactive; // When to take the canvas capture again, in ms after StartDragging; see UpdateWindowLocation_Prefix. private static readonly int[] RecaptureAfterMs = { 50, 150, 300, 600 }; @@ -151,6 +154,19 @@ public static void ApplyPatches(Harmony harmony) } } + // End the pick when Affinity loses focus + var uiServiceType = serifAssembly.GetType("Serif.Affinity.Services.UserInterfaceService"); + _showColourPickerMethod = uiServiceType?.GetMethod("ShowColourPicker", BindingFlags.Public | BindingFlags.Instance); + var application = System.Windows.Application.Current; + if (_showColourPickerMethod != null && application != null) + { + harmony.Patch(_showColourPickerMethod, + prefix: new HarmonyMethod(typeof(ColorPickerWaylandPatch), nameof(ShowColourPicker_Prefix))); + application.Deactivated += OnApplicationDeactivated; + application.Activated += OnApplicationActivated; + Logger.Info("Patched UserInterfaceService.ShowColourPicker (end the pick when Affinity loses focus)"); + } + Logger.Info("ColorPickerWayland patch applied successfully"); } @@ -210,6 +226,49 @@ public static void FinishDragging_Prefix() _pickerActive = false; } + // ── Ending the pick when Affinity loses focus ── + + /// + /// The magnifier is a topmost window, and under Wine switching to another application + /// does not end the pick, so it stayed above that application's windows. The tool ends + /// a pick by calling ShowColourPicker(false); do the same, as a canceled pick, when + /// Affinity is deactivated. While Affinity is inactive the tool still sees the mouse over + /// the canvas and would show the magnifier again, so requests to show it are ignored + /// until Affinity is active again; the tool shows a new one when it is next used. + /// + public static bool ShowColourPicker_Prefix(object __instance, bool isShowing, ref bool __result) + { + if (isShowing && _appInactive) + { + __result = true; + return false; + } + _pickerService = isShowing ? __instance : null; + return true; + } + + private static void OnApplicationDeactivated(object sender, EventArgs e) + { + _appInactive = true; + var service = _pickerService; + if (service == null) + return; + try + { + _showColourPickerMethod.Invoke(service, new object[] { false, false, false, 0, true, false }); + Logger.Info("Color picker ended: Affinity lost focus"); + } + catch (Exception ex) + { + Logger.Debug($"Failed to end the color picker: {ex.Message}"); + } + } + + private static void OnApplicationActivated(object sender, EventArgs e) + { + _appInactive = false; + } + /// /// StartDragging hides the tool overlay (selection handles, outlines) and the first tick /// captures the canvas, which is then kept for the whole drag. Under Wine the canvas is From 9d4c95fdb758c5a3b545e6411a38608ec43a0ca9 Mon Sep 17 00:00:00 2001 From: jfacemyer <1776340+jfacemyer@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:38:36 -0400 Subject: [PATCH 4/6] WineFix: also end a picker-button drag when Affinity loses focus A drag from a picker button, such as the one in the Colors panel, ends on mouse up or on lost mouse capture. After switching to another application the mouse comes up there, and under Wine the button keeps the capture, so the pick was still running on return. End it through the magnifier's OnFinishDragging(true), which makes the button release its capture as a canceled pick. --- WineFix/Patches/ColorPickerWaylandPatch.cs | 31 +++++++++++++++------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/WineFix/Patches/ColorPickerWaylandPatch.cs b/WineFix/Patches/ColorPickerWaylandPatch.cs index c272dd9..72abf2b 100644 --- a/WineFix/Patches/ColorPickerWaylandPatch.cs +++ b/WineFix/Patches/ColorPickerWaylandPatch.cs @@ -33,6 +33,7 @@ public static class ColorPickerWaylandPatch private static ConstructorInfo _colourRGBConstructor; private static FieldInfo _screenBmpField; private static MethodInfo _showColourPickerMethod; + private static MethodInfo _onFinishDraggingMethod; // State private static IntPtr _hwnd = IntPtr.Zero; @@ -42,6 +43,7 @@ public static class ColorPickerWaylandPatch private static int _dragStartTicks; private static int _recaptureIndex; private static object _pickerService; // the UserInterfaceService showing the picker, if any + private static object _activeMagnifier; // the ColourPickerMagnifier between StartDragging and FinishDragging private static bool _appInactive; // When to take the canvas capture again, in ms after StartDragging; see UpdateWindowLocation_Prefix. @@ -139,6 +141,8 @@ public static void ApplyPatches(Harmony harmony) if (startDragging != null) harmony.Patch(startDragging, prefix: new HarmonyMethod(typeof(ColorPickerWaylandPatch), nameof(StartDragging_Prefix))); + _onFinishDraggingMethod = magnifierType.GetMethod("OnFinishDragging", BindingFlags.Public | BindingFlags.Instance); + var finishDragging = magnifierType.GetMethod("FinishDragging", BindingFlags.Public | BindingFlags.Instance); if (finishDragging != null) harmony.Patch(finishDragging, prefix: new HarmonyMethod(typeof(ColorPickerWaylandPatch), nameof(FinishDragging_Prefix))); @@ -172,9 +176,10 @@ public static void ApplyPatches(Harmony harmony) // ── Picker lifecycle ── - public static void StartDragging_Prefix() + public static void StartDragging_Prefix(object __instance) { _pickerActive = true; + _activeMagnifier = __instance; _hwnd = IntPtr.Zero; _dragStartTicks = Environment.TickCount; _recaptureIndex = 0; @@ -224,17 +229,21 @@ public static void StartDragging_Prefix() public static void FinishDragging_Prefix() { _pickerActive = false; + _activeMagnifier = null; } // ── Ending the pick when Affinity loses focus ── /// /// The magnifier is a topmost window, and under Wine switching to another application - /// does not end the pick, so it stayed above that application's windows. The tool ends - /// a pick by calling ShowColourPicker(false); do the same, as a canceled pick, when - /// Affinity is deactivated. While Affinity is inactive the tool still sees the mouse over - /// the canvas and would show the magnifier again, so requests to show it are ignored - /// until Affinity is active again; the tool shows a new one when it is next used. + /// does not end the pick, so it stayed above that application's windows. End it as a + /// canceled pick when Affinity is deactivated. The tool ends a pick by calling + /// ShowColourPicker(false); do the same. While Affinity is inactive the tool still sees the + /// mouse over the canvas and would show the magnifier again, so requests to show it are + /// ignored until Affinity is active again; the tool shows a new one when it is next used. + /// A drag from a picker button (the Colors panel) ends on mouse up or lost capture, and + /// both happen elsewhere; end it through the magnifier's OnFinishDragging, which tells the + /// button to release its capture. /// public static bool ShowColourPicker_Prefix(object __instance, bool isShowing, ref bool __result) { @@ -251,11 +260,15 @@ private static void OnApplicationDeactivated(object sender, EventArgs e) { _appInactive = true; var service = _pickerService; - if (service == null) - return; + var magnifier = _activeMagnifier; try { - _showColourPickerMethod.Invoke(service, new object[] { false, false, false, 0, true, false }); + if (service != null) + _showColourPickerMethod.Invoke(service, new object[] { false, false, false, 0, true, false }); + else if (magnifier != null && _onFinishDraggingMethod != null) + _onFinishDraggingMethod.Invoke(magnifier, new object[] { true }); + else + return; Logger.Info("Color picker ended: Affinity lost focus"); } catch (Exception ex) From e6a1d529ccc14d1b31210d5f89c3d22309aad268 Mon Sep 17 00:00:00 2001 From: jfacemyer <1776340+jfacemyer@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:45:00 -0400 Subject: [PATCH 5/6] WineFix: end the color picker pick from the dispatcher, not inside WM_ACTIVATEAPP Application.Deactivated comes from WM_ACTIVATEAPP, a sent message, which can arrive inside ColourPickerMagnifier's timer tick while its SetWindowPos runs. Ending the pick there destroyed the magnifier window, and the rest of timer_Tick then dereferenced it: a NullReferenceException that took Affinity down. Defer the end with Dispatcher.BeginInvoke, as WPF's Popup does for the same message, and check that the application is still inactive and the same pick still running. --- WineFix/Patches/ColorPickerWaylandPatch.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/WineFix/Patches/ColorPickerWaylandPatch.cs b/WineFix/Patches/ColorPickerWaylandPatch.cs index 72abf2b..f230da8 100644 --- a/WineFix/Patches/ColorPickerWaylandPatch.cs +++ b/WineFix/Patches/ColorPickerWaylandPatch.cs @@ -244,6 +244,10 @@ public static void FinishDragging_Prefix() /// A drag from a picker button (the Colors panel) ends on mouse up or lost capture, and /// both happen elsewhere; end it through the magnifier's OnFinishDragging, which tells the /// button to release its capture. + /// + /// The end is deferred: WM_ACTIVATEAPP is a sent message and can arrive inside the + /// magnifier's own timer tick (its SetWindowPos call), and ending the pick there destroys + /// the window the rest of that tick still uses. /// public static bool ShowColourPicker_Prefix(object __instance, bool isShowing, ref bool __result) { @@ -261,11 +265,21 @@ private static void OnApplicationDeactivated(object sender, EventArgs e) _appInactive = true; var service = _pickerService; var magnifier = _activeMagnifier; + if (service == null && magnifier == null) + return; + System.Windows.Application.Current?.Dispatcher.BeginInvoke( + System.Windows.Threading.DispatcherPriority.Normal, new Action(() => EndPick(service, magnifier))); + } + + private static void EndPick(object service, object magnifier) + { + if (!_appInactive) + return; // Affinity is active again try { - if (service != null) + if (service != null && service == _pickerService) _showColourPickerMethod.Invoke(service, new object[] { false, false, false, 0, true, false }); - else if (magnifier != null && _onFinishDraggingMethod != null) + else if (magnifier != null && magnifier == _activeMagnifier && _onFinishDraggingMethod != null) _onFinishDraggingMethod.Invoke(magnifier, new object[] { true }); else return; From b1d876562ba4f084481deec32bb68ace36cb5c04 Mon Sep 17 00:00:00 2001 From: jfacemyer <1776340+jfacemyer@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:12:11 -0400 Subject: [PATCH 6/6] WineFix: stop a color picker timer that outlives its window ColourPickerMagnifier.timer_Tick moves the magnifier window and then reads it. A tick delivered after the pick has ended and the window is gone throws a NullReferenceException, which takes Affinity down; this happened after switching applications mid-pick. Stop such a timer and skip the tick. --- WineFix/Patches/ColorPickerWaylandPatch.cs | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/WineFix/Patches/ColorPickerWaylandPatch.cs b/WineFix/Patches/ColorPickerWaylandPatch.cs index f230da8..d6cee6f 100644 --- a/WineFix/Patches/ColorPickerWaylandPatch.cs +++ b/WineFix/Patches/ColorPickerWaylandPatch.cs @@ -32,6 +32,7 @@ public static class ColorPickerWaylandPatch private static PropertyInfo _renderControlProperty; // lazily cached (needs runtime type) private static ConstructorInfo _colourRGBConstructor; private static FieldInfo _screenBmpField; + private static FieldInfo _dragdropWindowField; private static MethodInfo _showColourPickerMethod; private static MethodInfo _onFinishDraggingMethod; @@ -149,6 +150,15 @@ public static void ApplyPatches(Harmony harmony) Logger.Info("Patched ColourPickerMagnifier StartDragging/FinishDragging"); + // Stop a timer that outlives its window instead of letting it throw + var timerTick = magnifierType.GetMethod("timer_Tick", BindingFlags.NonPublic | BindingFlags.Instance); + _dragdropWindowField = magnifierType.GetField("_dragdropWindow", BindingFlags.NonPublic | BindingFlags.Instance); + if (timerTick != null && _dragdropWindowField != null) + { + harmony.Patch(timerTick, prefix: new HarmonyMethod(typeof(ColorPickerWaylandPatch), nameof(TimerTick_Prefix))); + Logger.Info("Patched ColourPickerMagnifier timer_Tick (stop a timer whose window is gone)"); + } + var updateWindowLocation = magnifierType.GetMethod("UpdateWindowLocation", BindingFlags.NonPublic | BindingFlags.Instance); _screenBmpField = magnifierType.GetField("_screenBmp", BindingFlags.NonPublic | BindingFlags.Instance); if (updateWindowLocation != null && _screenBmpField != null) @@ -296,6 +306,20 @@ private static void OnApplicationActivated(object sender, EventArgs e) _appInactive = false; } + /// + /// timer_Tick moves the magnifier window and then reads it; once the pick has ended and the + /// window is gone, a tick still delivered throws a NullReferenceException that takes + /// Affinity down. Stop such a timer and skip the tick. + /// + public static bool TimerTick_Prefix(object __instance, object sender) + { + if (_dragdropWindowField.GetValue(__instance) != null) + return true; + (sender as System.Windows.Threading.DispatcherTimer)?.Stop(); + Logger.Debug("Stopped a color picker timer whose window is gone"); + return false; + } + /// /// StartDragging hides the tool overlay (selection handles, outlines) and the first tick /// captures the canvas, which is then kept for the whole drag. Under Wine the canvas is