Skip to content

Instantly share code, notes, and snippets.

@morvy
Created April 1, 2026 14:10
Show Gist options
  • Select an option

  • Save morvy/25f1b59410c8448b26d02793970e5b68 to your computer and use it in GitHub Desktop.

Select an option

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).
#!/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