Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AffinityPluginLoader.sln
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AffinityPluginLoader", "Aff
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WineFix", "WineFix\WineFix.csproj", "{A9134DEC-BC99-468E-ADE6-8DAC9883F11E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoginFix", "LoginFix\LoginFix.csproj", "{BC45F218-FDC1-49F5-8B6E-E3151E1BE134}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Expand All @@ -27,6 +29,10 @@ Global
{A9134DEC-BC99-468E-ADE6-8DAC9883F11E}.Debug|x64.Build.0 = Debug|x64
{A9134DEC-BC99-468E-ADE6-8DAC9883F11E}.Release|x64.ActiveCfg = Release|x64
{A9134DEC-BC99-468E-ADE6-8DAC9883F11E}.Release|x64.Build.0 = Release|x64
{BC45F218-FDC1-49F5-8B6E-E3151E1BE134}.Debug|x64.ActiveCfg = Debug|x64
{BC45F218-FDC1-49F5-8B6E-E3151E1BE134}.Debug|x64.Build.0 = Debug|x64
{BC45F218-FDC1-49F5-8B6E-E3151E1BE134}.Release|x64.ActiveCfg = Release|x64
{BC45F218-FDC1-49F5-8B6E-E3151E1BE134}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
338 changes: 338 additions & 0 deletions LoginFix/LICENSE

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions LoginFix/LoginFix.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<LangVersion>latest</LangVersion>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<PropertyGroup>
<Product>LoginFix</Product>
<Description>Plugin to fix the Canva sign-in crash under Wine</Description>
<Version>0.1.0</Version>
<!-- Disable git hash in Release builds -->
<IncludeSourceRevisionInInformationalVersion Condition="'$(Configuration)' == 'Release'">false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Lib.Harmony" Version="2.4.2" />
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="WindowsBase" />
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AffinityPluginLoader\AffinityPluginLoader.csproj">
<Project>{0B4B2B01-D160-40EE-A9AC-9594470DDBC3}</Project>
<Name>AffinityPluginLoader</Name>
<Private>False</Private>
</ProjectReference>
</ItemGroup>

</Project>
25 changes: 25 additions & 0 deletions LoginFix/LoginFixPlugin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using HarmonyLib;
using AffinityPluginLoader;
using AffinityPluginLoader.Settings;

namespace LoginFix
{
/// <summary>
/// Works around a Wine WinRT gap that crashes the Canva sign-in callback.
/// </summary>
public class LoginFixPlugin : AffinityPlugin
{
public const string PluginId = "loginfix";

public override PluginSettingsDefinition DefineSettings()
{
return new PluginSettingsDefinition(PluginId);
}

public override void OnPatch(Harmony harmony, IPluginContext context)
{
context.Patch("ProcessCommandLineArguments fix",
h => Patches.ProcessCommandLineArgumentsPatch.ApplyPatches(h));
}
}
}
254 changes: 254 additions & 0 deletions LoginFix/Patches/ProcessCommandLineArgumentsPatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Pipes;
using System.Linq;
using System.Reflection;
using System.Threading;
using HarmonyLib;
using AffinityPluginLoader.Core;

namespace LoginFix.Patches
{
/// <summary>
/// Serif.Affinity.Application.ProcessCommandLineArguments references
/// Windows.ApplicationModel.DataTransfer.SharedStorageAccessManager (only reached for
/// "affinity-open-file:" arguments). Wine has no WinRT implementation for that type, and the
/// CLR resolves every type referenced in a method body when it JITs the method - so any call
/// into ProcessCommandLineArguments throws System.TypeLoadException, including the unrelated
/// "affinity://" OAuth callback path the sign-in flow depends on.
///
/// Harmony itself can't patch ProcessCommandLineArguments directly either: patching requires
/// decompiling the target method's IL (even for a plain prefix, to build the merged
/// dispatcher), which means resolving every operand in its body - including the poisoned
/// SharedStorageAccessManager call - and that resolution throws the same way the JIT does.
///
/// Both of ProcessCommandLineArguments' callers only reference it by signature (safe to
/// resolve), not by body, so patching *them* instead works: ProcessArguments() handles the
/// app's own startup command line, and SingleInstanceThread() receives arguments forwarded
/// over a named pipe by a second launched instance (this is the path the Canva sign-in
/// callback actually takes). Both patches fully replace their target with a safe
/// reimplementation that never touches the real ProcessCommandLineArguments.
/// </summary>
public static class ProcessCommandLineArgumentsPatch
{
static readonly HashSet<string> KnownFlags = new HashSet<string>
{
"--gpu-telemetry", "--no-dwm-warning", "--hw-ui", "--no-hw-ui", "--no-ocl",
"--click-through", "--no-click-through", "--input-logging", "--printmode1",
"--disable-cltest", "--disable-font-preview-cache",
"--disable-parallel-font-enumeration", "--font-cache-logging",
"--full-crash-dumps", "--disable-wintab", "--legacy-wintab",
};

static Type _applicationType;

public static void ApplyPatches(Harmony harmony)
{
Logger.Info("Applying command-line argument patches (Wine SharedStorageAccessManager fix)...");

var serifAssembly = AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => a.GetName().Name == "Serif.Affinity");
if (serifAssembly == null)
{
Logger.Error("Serif.Affinity assembly not found");
return;
}

_applicationType = serifAssembly.GetType("Serif.Affinity.Application");
if (_applicationType == null)
{
Logger.Error("Serif.Affinity.Application type not found");
return;
}

var processArguments = AccessTools.Method(_applicationType, "ProcessArguments", Type.EmptyTypes);
if (processArguments == null)
{
Logger.Error("ProcessArguments() not found");
}
else
{
harmony.Patch(processArguments,
prefix: new HarmonyMethod(AccessTools.Method(typeof(ProcessCommandLineArgumentsPatch), nameof(ProcessArgumentsPrefix))));
Logger.Info("Patched ProcessArguments (startup command line)");
}

var singleInstanceThread = AccessTools.Method(_applicationType, "SingleInstanceThread", Type.EmptyTypes);
if (singleInstanceThread == null)
{
Logger.Error("SingleInstanceThread() not found");
}
else
{
harmony.Patch(singleInstanceThread,
prefix: new HarmonyMethod(AccessTools.Method(typeof(ProcessCommandLineArgumentsPatch), nameof(SingleInstanceThreadPrefix))));
Logger.Info("Patched SingleInstanceThread (activation via named pipe - the sign-in callback path)");
}
}

// Replaces Application.ProcessArguments(). Mirrors the original's fallback chain
// (live command line, then per-user arguments.cfg, then per-machine arguments.cfg)
// but routes everything through SafeProcessCommandLineArguments instead of the
// real (poisoned) ProcessCommandLineArguments.
static bool ProcessArgumentsPrefix(object __instance)
{
try
{
var setCommandLineArguments = AccessTools.Method(_applicationType, "SetCommandLineArguments", new[] { typeof(string[]) });
setCommandLineArguments?.Invoke(__instance, new object[] { Environment.GetCommandLineArgs() });

SafeProcessCommandLineArguments(__instance, Environment.GetCommandLineArgs().Skip(1));

bool handledFromFile = false;
var appDataPathForCurrentUser = (string)AccessTools.Property(_applicationType.BaseType, "AppDataPathForCurrentUser")?.GetValue(__instance);
if (appDataPathForCurrentUser != null)
{
try
{
var lines = File.ReadAllText(Path.Combine(appDataPathForCurrentUser, "arguments.cfg"))
.Split(new[] { "\r\n", "\n", "\t", " " }, StringSplitOptions.RemoveEmptyEntries);
if (lines.Length != 0)
{
SafeProcessCommandLineArguments(__instance, lines);
handledFromFile = true;
}
}
catch { }
}

if (!handledFromFile)
{
var appDataPathForAllUsers = (string)AccessTools.Property(_applicationType.BaseType, "AppDataPathForAllUsers")?.GetValue(__instance);
if (appDataPathForAllUsers != null)
{
try
{
var lines = File.ReadAllText(Path.Combine(appDataPathForAllUsers, "arguments.cfg"))
.Split(new[] { "\r\n", "\n", "\t", " " }, StringSplitOptions.RemoveEmptyEntries);
if (lines.Length != 0)
{
SafeProcessCommandLineArguments(__instance, lines);
}
}
catch { }
}
}
}
catch (Exception ex)
{
Logger.Error("ProcessArguments replacement failed: " + ex);
}

return false;
}

// Replaces the static Application.SingleInstanceThread(). Mirrors the original's named
// pipe server loop exactly, except the received arguments are handed to
// SafeProcessCommandLineArguments on the dispatcher instead of the real
// ProcessCommandLineArguments.
static bool SingleInstanceThreadPrefix()
{
try
{
var application = System.Windows.Application.Current;
var isClosingProp = AccessTools.Property(_applicationType.BaseType, "IsClosing")
?? AccessTools.Property(_applicationType, "IsClosing");
var singleInstanceIdProp = AccessTools.Property(_applicationType, "SingleInstanceId");
var delayDocumentOpenField = AccessTools.Field(_applicationType, "m_delayDocumentOpen");

Func<bool> isClosing = () => (bool)isClosingProp.GetValue(application);
string singleInstanceId = (string)singleInstanceIdProp.GetValue(application);

while (!isClosing())
{
try
{
using (var server = new NamedPipeServerStream(singleInstanceId))
{
server.WaitForConnection();
while ((bool)delayDocumentOpenField.GetValue(application))
{
Thread.Sleep(500);
}
if (isClosing()) continue;

try
{
using (var reader = new BinaryReader(server))
{
string text = reader.ReadString();
var arguments = text.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
var argsToProcess = arguments.Skip(1).ToArray();
application.Dispatcher.BeginInvoke((Action)(() =>
{
SafeProcessCommandLineArguments(application, argsToProcess);
}));
}
}
catch { }
}
}
catch { }
}
}
catch (Exception ex)
{
Logger.Error("SingleInstanceThread replacement failed: " + ex);
}

return false;
}

// Safe stand-in for Application.ProcessCommandLineArguments(IEnumerable<string>).
// Drops "affinity-open-file:" support (needs SharedStorageAccessManager, unsupported
// under Wine either way); everything else matches the original's behavior.
static void SafeProcessCommandLineArguments(object instance, IEnumerable<string> arguments)
{
string callbackUrl = null;
var paths = new List<string>();

foreach (var argument in arguments)
{
var lower = argument.ToLowerInvariant();
if (KnownFlags.Contains(lower)) continue;
if (lower.StartsWith("affinity-open-file:"))
{
Logger.Warning("Ignoring affinity-open-file: argument (needs SharedStorageAccessManager, unsupported under Wine)");
continue;
}
if (lower.StartsWith("affinity://"))
{
callbackUrl = argument;
continue;
}
paths.Add(argument);
}

bool mainWindowLoaded = (bool)AccessTools.Field(_applicationType, "m_mainWindowLoaded").GetValue(instance);
var activateMainWindow = AccessTools.Method(_applicationType, "ActivateMainWindow");
var loadFiles = AccessTools.Method(_applicationType, "LoadFiles", new[] { typeof(List<string>) });

if (mainWindowLoaded)
{
activateMainWindow.Invoke(instance, null);
loadFiles.Invoke(instance, new object[] { paths });
}
else
{
var pathsToLoad = (List<string>)AccessTools.Field(_applicationType, "m_pathsToLoad").GetValue(instance);
foreach (var p in paths)
{
if (!pathsToLoad.Contains(p)) pathsToLoad.Add(p);
}
activateMainWindow.Invoke(instance, null);
}

if (!string.IsNullOrEmpty(callbackUrl))
{
var openUrl = AccessTools.Method(_applicationType, "OpenUrl", new[] { typeof(string) });
openUrl.Invoke(instance, new object[] { callbackUrl });
}
}
}
}
43 changes: 43 additions & 0 deletions LoginFix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# LoginFix

LoginFix is an APL plugin which fixes the Canva sign-in flow crashing under Wine.

WineFix's own "Canva sign-in dialog patched out" fix is a temporary workaround, pending a protocol handler fix. LoginFix is that fix: it lets sign-in complete instead of disabling it.

## Install

1. [Install APL](https://apl.ncuroe.dev/guide/installation/) first.
2. Download `loginfix-vX.X.X.zip` from the [latest release](https://github.com/noahc3/AffinityPluginLoader/releases) and extract into your Affinity install directory.

## What It Fixes

`Serif.Affinity.Application.ProcessCommandLineArguments` has one branch, only reached for an unrelated `affinity-open-file:` argument, that calls the WinRT type `Windows.ApplicationModel.DataTransfer.SharedStorageAccessManager`. Wine has no implementation of that type.

The .NET CLR resolves every type referenced anywhere in a method's body when it JITs that method, not just the branch actually taken. So any call into `ProcessCommandLineArguments` throws `System.TypeLoadException`, regardless of which arguments were passed.

This matters because the Canva sign-in callback goes through this exact method. The second `Affinity.exe` instance the OS launches for the callback URL forwards its arguments to the already-running instance over a named pipe, and that named-pipe handler lands in `ProcessCommandLineArguments`. So login always crashed.

Harmony itself cannot patch `ProcessCommandLineArguments` directly either. Patching a method, even with a plain prefix, requires Harmony to decompile its IL, which means resolving every operand in the method body, including the poisoned `SharedStorageAccessManager` call. A patch on that method throws the same `TypeLoadException`.

LoginFix instead patches `ProcessCommandLineArguments`'s two callers:

- `ProcessArguments()`, used for the app's own startup command line
- `SingleInstanceThread()`, the named-pipe listener that receives the sign-in callback from the second launched instance

Neither caller references the poisoned type directly, only by method signature, which Harmony can resolve without a problem. Both are fully replaced with a safe reimplementation that never calls the real `ProcessCommandLineArguments`. The only thing dropped is the unrelated `affinity-open-file:` handling, which needs `SharedStorageAccessManager` and cannot work under Wine regardless.

## Compatibility with WineFix

LoginFix and WineFix patch different methods and can run together. If you install both, WineFix's sign-in dialog suppression and LoginFix's working sign-in flow may conflict, since one hides the dialog and the other completes it. If you install LoginFix, you likely want to disable WineFix's sign-in dialog patch, if that becomes a separate toggle in a future WineFix release.

## Licensing

LoginFix is licensed under **GPLv2**. See the [LICENSE](LICENSE) file.

### License Exemption

[Canva](https://github.com/canva) and its subsidiaries are exempt from GPLv2 licensing and may (at its option) instead license any source code authored for the LoginFix project under the Zero-Clause BSD license.

# Credits

Built and verified on a manual Wine install and on Lutris, tested on stock distro Wine 11.15. See [AffinityOnLinux](https://github.com/seapear/AffinityOnLinux) for the project this fix was developed alongside.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,16 @@ Plugins extend `AffinityPlugin` and use [Harmony](https://github.com/pardeike/Ha

WineFix is an APL plugin that fixes Wine-specific Affinity bugs. See [WineFix/](WineFix/) for an overview, or the [WineFix docs](https://apl.ncuroe.dev/winefix/) for full details.

## LoginFix

LoginFix is an APL plugin that fixes the Canva sign-in flow crashing under Wine. See [LoginFix/](LoginFix/) for the root cause and fix details.

## Licensing

APL (AffinityHook, AffinityBootstrap, AffinityPluginLoader) is licensed under the **MIT License**. See the LICENSE file under each project directory.

> [!WARNING]
> WineFix is offered under a different license. See [WineFix#Licensing](WineFix#licensing) for information.
> WineFix and LoginFix are offered under a different license. See [WineFix#Licensing](WineFix#licensing) and [LoginFix#Licensing](LoginFix#licensing) for information.

### License Exemption

Expand Down
Loading