Created
April 1, 2026 14:10
-
-
Save morvy/25f1b59410c8448b26d02793970e5b68 to your computer and use it in GitHub Desktop.
Fix affinity:// protocol handler for Affinity on Wine (Linux) + Patches Serif.Affinity.dll to bypass a WinRT SharedStorageAccessManager reference that crashes Wine when handling affinity:// callback URIs (used for login).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # affinity-uri-fix.sh — Fix affinity:// protocol handler for Affinity on Wine (Linux) | |
| # | |
| # Patches Serif.Affinity.dll to bypass a WinRT SharedStorageAccessManager reference | |
| # that crashes Wine when handling affinity:// callback URIs (used for login). | |
| # Also sets up the system URI handler so browsers can redirect back to Affinity. | |
| # | |
| # Requirements: dotnet SDK 8.0+ (for the IL patcher) | |
| # Usage: ./affinity-uri-fix.sh [/path/to/wine/prefix] | |
| # | |
| set -euo pipefail | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| CYAN='\033[0;36m' | |
| NC='\033[0m' | |
| info() { echo -e "${CYAN}[*]${NC} $*"; } | |
| ok() { echo -e "${GREEN}[+]${NC} $*"; } | |
| warn() { echo -e "${YELLOW}[!]${NC} $*"; } | |
| err() { echo -e "${RED}[-]${NC} $*" >&2; } | |
| # ── Step 1: Find the Affinity installation ──────────────────────────────────── | |
| find_affinity() { | |
| local prefix="$1" | |
| find "$prefix/drive_c" -path "*/Affinity/Affinity/Serif.Affinity.dll" 2>/dev/null | head -1 | |
| } | |
| find_wine_binary() { | |
| local prefix="$1" | |
| # Check for bundled Wine builds (ElementalWarrior, wine-tkg, etc.) | |
| for candidate in \ | |
| "$prefix"/ElementalWarrior*/bin/wine \ | |
| "$prefix"/wine-tkg*/bin/wine \ | |
| "$prefix"/wine*/bin/wine; do | |
| if [[ -x "$candidate" ]]; then | |
| echo "$candidate" | |
| return | |
| fi | |
| done | |
| # Fall back to system wine | |
| command -v wine 2>/dev/null || true | |
| } | |
| WINEPREFIX="" | |
| AFFINITY_DLL="" | |
| WINE_BIN="" | |
| if [[ ${1:-} ]]; then | |
| WINEPREFIX="$1" | |
| AFFINITY_DLL=$(find_affinity "$WINEPREFIX") | |
| if [[ -z "$AFFINITY_DLL" ]]; then | |
| err "Could not find Serif.Affinity.dll in prefix: $WINEPREFIX" | |
| exit 1 | |
| fi | |
| else | |
| info "Searching for Affinity installation..." | |
| for prefix in \ | |
| "$HOME/.AffinityLinux" \ | |
| "$HOME/.affinity" \ | |
| "$HOME/.wine" \ | |
| "$HOME/.local/share/wineprefixes"/*; do | |
| if [[ -d "$prefix" ]]; then | |
| AFFINITY_DLL=$(find_affinity "$prefix") | |
| if [[ -n "$AFFINITY_DLL" ]]; then | |
| WINEPREFIX="$prefix" | |
| break | |
| fi | |
| fi | |
| done | |
| if [[ -z "$AFFINITY_DLL" ]]; then | |
| info "Trying broader search..." | |
| AFFINITY_DLL=$(find "$HOME" -maxdepth 8 -path "*/Affinity/Affinity/Serif.Affinity.dll" 2>/dev/null | head -1) | |
| if [[ -n "$AFFINITY_DLL" ]]; then | |
| WINEPREFIX=$(echo "$AFFINITY_DLL" | sed 's|/drive_c/.*||') | |
| fi | |
| fi | |
| if [[ -z "$AFFINITY_DLL" ]]; then | |
| err "Could not find Affinity installation." | |
| err "Usage: $0 /path/to/wine/prefix" | |
| exit 1 | |
| fi | |
| fi | |
| AFFINITY_DIR=$(dirname "$AFFINITY_DLL") | |
| WINE_BIN=$(find_wine_binary "$WINEPREFIX") | |
| ok "Found Affinity: $AFFINITY_DLL" | |
| ok "Wine prefix: $WINEPREFIX" | |
| [[ -n "$WINE_BIN" ]] && ok "Wine binary: $WINE_BIN" | |
| # ── Step 2: Check if already patched ────────────────────────────────────────── | |
| if [[ -f "$AFFINITY_DLL.bak" ]]; then | |
| warn "Backup already exists: $AFFINITY_DLL.bak" | |
| warn "DLL may already be patched. Re-patching from backup..." | |
| cp "$AFFINITY_DLL.bak" "$AFFINITY_DLL" | |
| fi | |
| # ── Step 3: Check for dotnet SDK ────────────────────────────────────────────── | |
| if ! command -v dotnet &>/dev/null; then | |
| err "dotnet SDK not found. Install it first:" | |
| echo "" | |
| echo " Arch/Manjaro: sudo pacman -S dotnet-sdk" | |
| echo " Ubuntu/Debian: sudo apt install dotnet-sdk-8.0" | |
| echo " Fedora: sudo dnf install dotnet-sdk-8.0" | |
| echo " openSUSE: sudo zypper install dotnet-sdk-8.0" | |
| echo " Nix: nix-shell -p dotnet-sdk_8" | |
| echo " Other: https://dotnet.microsoft.com/download" | |
| echo "" | |
| exit 1 | |
| fi | |
| DOTNET_VERSION=$(dotnet --version 2>/dev/null | cut -d. -f1) | |
| if [[ "$DOTNET_VERSION" -lt 8 ]]; then | |
| err "dotnet SDK 8.0+ required (found: $(dotnet --version))" | |
| exit 1 | |
| fi | |
| # ── Step 4: Build and run the IL patcher ────────────────────────────────────── | |
| PATCHER_DIR=$(mktemp -d /tmp/affinity-patcher.XXXXXX) | |
| trap 'rm -rf "$PATCHER_DIR"' EXIT | |
| info "Building IL patcher..." | |
| TFM="net${DOTNET_VERSION}.0" | |
| cat > "$PATCHER_DIR/Patcher.csproj" <<EOF | |
| <Project Sdk="Microsoft.NET.Sdk"> | |
| <PropertyGroup> | |
| <OutputType>Exe</OutputType> | |
| <TargetFramework>${TFM}</TargetFramework> | |
| <ImplicitUsings>enable</ImplicitUsings> | |
| <Nullable>enable</Nullable> | |
| </PropertyGroup> | |
| <ItemGroup> | |
| <PackageReference Include="Mono.Cecil" Version="0.11.6" /> | |
| </ItemGroup> | |
| </Project> | |
| EOF | |
| cat > "$PATCHER_DIR/Program.cs" << 'CSHARP_EOF' | |
| using Mono.Cecil; | |
| using Mono.Cecil.Cil; | |
| using System; | |
| using System.IO; | |
| using System.Linq; | |
| // ─── Affinity URI Protocol Fix ─────────────────────────────────────────────── | |
| // | |
| // Uses Mono.Cecil to ANALYZE the IL and locate the exact patch site, then | |
| // applies a raw binary edit to the DLL file. This avoids Mono.Cecil's | |
| // assembly rewriter which corrupts embedded WPF/BAML resources. | |
| // | |
| // The patch bypasses the "affinity-open-file:" handler in | |
| // ProcessCommandLineArguments() which references the WinRT type | |
| // SharedStorageAccessManager — a type Wine cannot resolve. | |
| // ───────────────────────────────────────────────────────────────────────────── | |
| if (args.Length == 0) | |
| { | |
| Console.Error.WriteLine("Usage: patcher <path/to/Serif.Affinity.dll>"); | |
| return 1; | |
| } | |
| var dllPath = args[0]; | |
| if (!File.Exists(dllPath)) | |
| { | |
| Console.Error.WriteLine($"File not found: {dllPath}"); | |
| return 1; | |
| } | |
| // ── Phase 1: Analyze with Mono.Cecil ───────────────────────────────────────── | |
| var resolver = new DefaultAssemblyResolver(); | |
| resolver.AddSearchDirectory(Path.GetDirectoryName(dllPath)!); | |
| var dllBytes = File.ReadAllBytes(dllPath); | |
| using var assembly = AssemblyDefinition.ReadAssembly(new MemoryStream(dllBytes), | |
| new ReaderParameters { AssemblyResolver = resolver }); | |
| var appType = assembly.MainModule.Types | |
| .FirstOrDefault(t => t.FullName == "Serif.Affinity.Application"); | |
| if (appType == null) | |
| { | |
| Console.Error.WriteLine("ERROR: Could not find type Serif.Affinity.Application"); | |
| return 1; | |
| } | |
| var method = appType.Methods | |
| .FirstOrDefault(m => m.Name == "ProcessCommandLineArguments" | |
| && m.Parameters.Count == 1 | |
| && m.Parameters[0].ParameterType.FullName.Contains("IEnumerable")); | |
| if (method == null) | |
| { | |
| Console.Error.WriteLine("ERROR: Could not find ProcessCommandLineArguments(IEnumerable<string>)"); | |
| return 1; | |
| } | |
| Console.WriteLine($"Found: {method.FullName}"); | |
| // Find the "affinity-open-file:" string and trace to the StartsWith+brfalse pattern | |
| var instructions = method.Body.Instructions; | |
| int openFileVarIndex = -1; | |
| for (int i = 0; i < instructions.Count - 1; i++) | |
| { | |
| if (instructions[i].OpCode == OpCodes.Ldstr | |
| && (string)instructions[i].Operand == "affinity-open-file:") | |
| { | |
| var st = instructions[i + 1]; | |
| openFileVarIndex = st.OpCode.Code switch | |
| { | |
| Code.Stloc_0 => 0, Code.Stloc_1 => 1, Code.Stloc_2 => 2, Code.Stloc_3 => 3, | |
| Code.Stloc_S => ((VariableDefinition)st.Operand).Index, | |
| Code.Stloc => ((VariableDefinition)st.Operand).Index, | |
| _ => -1 | |
| }; | |
| break; | |
| } | |
| } | |
| if (openFileVarIndex == -1) | |
| { | |
| Console.Error.WriteLine("ERROR: 'affinity-open-file:' string not found in method."); | |
| Console.Error.WriteLine("This version may not need this patch."); | |
| return 1; | |
| } | |
| // Find the ldloc <var> + StartsWith + brfalse pattern | |
| int patchILOffset = -1; | |
| int patchILLength = -1; | |
| int brfalseBytePosInPatch = -1; | |
| for (int i = 1; i < instructions.Count - 2; i++) | |
| { | |
| var instr = instructions[i]; | |
| bool loadsOurVar = (openFileVarIndex <= 3) | |
| ? instr.OpCode.Code == (Code.Ldloc_0 + openFileVarIndex) | |
| : (instr.OpCode == OpCodes.Ldloc_S || instr.OpCode == OpCodes.Ldloc) | |
| && instr.Operand is VariableDefinition vd && vd.Index == openFileVarIndex; | |
| if (!loadsOurVar) continue; | |
| var next1 = instructions[i + 1]; | |
| var next2 = instructions[i + 2]; | |
| if ((next1.OpCode == OpCodes.Callvirt || next1.OpCode == OpCodes.Call) | |
| && next1.Operand?.ToString()?.Contains("StartsWith") == true | |
| && (next2.OpCode == OpCodes.Brfalse || next2.OpCode == OpCodes.Brfalse_S)) | |
| { | |
| var prev = instructions[i - 1]; | |
| patchILOffset = prev.Offset; | |
| // Calculate total byte length of the 4 instructions | |
| int endOffset = next2.Offset + (next2.OpCode == OpCodes.Brfalse ? 5 : 2); | |
| patchILLength = endOffset - patchILOffset; | |
| brfalseBytePosInPatch = next2.Offset - patchILOffset; | |
| Console.WriteLine($" Patch site: IL_{prev.Offset:X4} .. IL_{next2.Offset:X4} ({patchILLength} bytes)"); | |
| Console.WriteLine($" {prev}"); | |
| Console.WriteLine($" {instr}"); | |
| Console.WriteLine($" {next1}"); | |
| Console.WriteLine($" {next2}"); | |
| break; | |
| } | |
| } | |
| if (patchILOffset == -1) | |
| { | |
| Console.Error.WriteLine("ERROR: Could not locate IL pattern to patch."); | |
| return 1; | |
| } | |
| // ── Phase 2: Build the expected byte sequence from the IL ──────────────────── | |
| // Get the method's RVA and compute the file offset of IL_0000 | |
| uint methodRVA = (uint)method.RVA; | |
| // Parse PE sections to convert RVA -> file offset | |
| int peOffset = BitConverter.ToInt32(dllBytes, 0x3C); | |
| int numSections = BitConverter.ToInt16(dllBytes, peOffset + 6); | |
| int optHeaderSize = BitConverter.ToInt16(dllBytes, peOffset + 20); | |
| int sectionTable = peOffset + 24 + optHeaderSize; | |
| int RvaToFile(uint rva) | |
| { | |
| for (int s = 0; s < numSections; s++) | |
| { | |
| int off = sectionTable + s * 40; | |
| uint secVA = BitConverter.ToUInt32(dllBytes, off + 12); | |
| uint secSize = BitConverter.ToUInt32(dllBytes, off + 8); | |
| uint secRawPtr = BitConverter.ToUInt32(dllBytes, off + 20); | |
| if (rva >= secVA && rva < secVA + secSize) | |
| return (int)(secRawPtr + (rva - secVA)); | |
| } | |
| return -1; | |
| } | |
| int methodFileOffset = RvaToFile(methodRVA); | |
| if (methodFileOffset == -1) | |
| { | |
| Console.Error.WriteLine("ERROR: Could not map method RVA to file offset."); | |
| return 1; | |
| } | |
| // Parse the method header to find where IL_0000 starts | |
| byte headerByte = dllBytes[methodFileOffset]; | |
| int headerSize; | |
| if ((headerByte & 0x03) == 0x03) | |
| { | |
| // Fat header: size in dwords is bits 12-15 of the 2-byte flags | |
| int flagsWord = dllBytes[methodFileOffset] | (dllBytes[methodFileOffset + 1] << 8); | |
| headerSize = ((flagsWord >> 12) & 0xF) * 4; | |
| } | |
| else if ((headerByte & 0x03) == 0x02) | |
| { | |
| headerSize = 1; // Tiny header | |
| } | |
| else | |
| { | |
| Console.Error.WriteLine($"ERROR: Unknown method header format: 0x{headerByte:X2}"); | |
| return 1; | |
| } | |
| int ilStartFile = methodFileOffset + headerSize; | |
| int patchFileOffset = ilStartFile + patchILOffset; | |
| int brfalseFileOffset = ilStartFile + patchILOffset + brfalseBytePosInPatch; | |
| Console.WriteLine($" Method RVA: 0x{methodRVA:X}, file offset: 0x{methodFileOffset:X}"); | |
| Console.WriteLine($" Header size: {headerSize}, IL_0000 at: 0x{ilStartFile:X}"); | |
| Console.WriteLine($" Patch at file offset: 0x{patchFileOffset:X}"); | |
| // Verify the brfalse opcode is where we expect it | |
| byte brOpcode = dllBytes[brfalseFileOffset]; | |
| bool isLongBranch = (brOpcode == 0x39); // brfalse | |
| bool isShortBranch = (brOpcode == 0x2C); // brfalse.s | |
| if (!isLongBranch && !isShortBranch) | |
| { | |
| Console.Error.WriteLine($"ERROR: Expected brfalse (0x39) or brfalse.s (0x2C) at file offset 0x{brfalseFileOffset:X}, found 0x{brOpcode:02X}"); | |
| Console.Error.WriteLine("The DLL structure may have changed. Aborting."); | |
| return 1; | |
| } | |
| Console.WriteLine($" Verified: brfalse opcode 0x{brOpcode:02X} at file offset 0x{brfalseFileOffset:X}"); | |
| // ── Phase 3: Apply raw binary patch ────────────────────────────────────────── | |
| // Backup | |
| var backupPath = dllPath + ".bak"; | |
| if (!File.Exists(backupPath)) | |
| File.Copy(dllPath, backupPath); | |
| // NOP out everything from patchFileOffset to brfalseFileOffset | |
| for (int i = patchFileOffset; i < brfalseFileOffset; i++) | |
| dllBytes[i] = 0x00; // nop | |
| // Change brfalse (0x39) -> br (0x38), or brfalse.s (0x2C) -> br.s (0x2B) | |
| dllBytes[brfalseFileOffset] = isLongBranch ? (byte)0x38 : (byte)0x2B; | |
| // Branch offset bytes stay unchanged | |
| File.WriteAllBytes(dllPath, dllBytes); | |
| Console.WriteLine(" Patch applied successfully (raw binary edit, no DLL rewrite)."); | |
| return 0; | |
| CSHARP_EOF | |
| cd "$PATCHER_DIR" | |
| dotnet restore --verbosity quiet 2>&1 | grep -v "^$" || true | |
| info "Patching Serif.Affinity.dll..." | |
| dotnet run --verbosity quiet -- "$AFFINITY_DLL" | |
| ok "DLL patched. Backup at: $AFFINITY_DLL.bak" | |
| # Verify file sizes match (raw patch should not change size) | |
| ORIG_SIZE=$(stat -c%s "$AFFINITY_DLL.bak") | |
| NEW_SIZE=$(stat -c%s "$AFFINITY_DLL") | |
| if [[ "$ORIG_SIZE" != "$NEW_SIZE" ]]; then | |
| err "WARNING: Patched DLL size differs from original! Something went wrong." | |
| err "Restoring backup..." | |
| cp "$AFFINITY_DLL.bak" "$AFFINITY_DLL" | |
| exit 1 | |
| fi | |
| ok "File size verified: ${NEW_SIZE} bytes (unchanged)" | |
| # ── Step 5: Set up the affinity:// URI handler ─────────────────────────────── | |
| info "Setting up affinity:// URI handler..." | |
| HANDLER_SCRIPT="$HOME/.local/bin/affinity-uri-handler" | |
| DESKTOP_FILE="$HOME/.local/share/applications/wine-protocol-affinity.desktop" | |
| mkdir -p "$(dirname "$HANDLER_SCRIPT")" | |
| mkdir -p "$(dirname "$DESKTOP_FILE")" | |
| if [[ -n "$WINE_BIN" ]]; then | |
| HANDLER_WINE="$WINE_BIN" | |
| else | |
| HANDLER_WINE="wine" | |
| fi | |
| cat > "$HANDLER_SCRIPT" << HANDLER_EOF | |
| #!/bin/bash | |
| # Forward affinity:// URIs to Affinity via Wine's rundll32 URL handler | |
| export WINEPREFIX="$WINEPREFIX" | |
| exec "$HANDLER_WINE" rundll32.exe url.dll,FileProtocolHandler "\$1" | |
| HANDLER_EOF | |
| chmod +x "$HANDLER_SCRIPT" | |
| cat > "$DESKTOP_FILE" << DESKTOP_EOF | |
| [Desktop Entry] | |
| Type=Application | |
| Name=Affinity URI Handler | |
| Exec=$HANDLER_SCRIPT %u | |
| NoDisplay=true | |
| MimeType=x-scheme-handler/affinity; | |
| DESKTOP_EOF | |
| xdg-mime default wine-protocol-affinity.desktop x-scheme-handler/affinity 2>/dev/null || true | |
| update-desktop-database "$HOME/.local/share/applications/" 2>/dev/null || true | |
| ok "URI handler installed: $HANDLER_SCRIPT" | |
| # ── Done ────────────────────────────────────────────────────────────────────── | |
| echo "" | |
| ok "All done! Restart Affinity and try logging in." | |
| echo "" | |
| echo " To restore the original DLL:" | |
| echo " cp \"$AFFINITY_DLL.bak\" \"$AFFINITY_DLL\"" | |
| echo "" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment