Creating a Plugin
This guide walks through creating an APL plugin from scratch.
Unstable API
APL is experimental. While APL is still v0, expect breaking changes to the plugin API between updates.
Prerequisites
- .NET Framework 4.8 SDK (or the Docker build environment — see Building)
- A reference to
AffinityPluginLoader.dlland0Harmony.dllfrom an APL release
Project Setup
Create a .NET Framework 4.8 class library. Add references to AffinityPluginLoader.dll and 0Harmony.dll.
Minimal Plugin
Plugins extend the AffinityPlugin base class. At minimum, override OnPatch to apply Harmony patches when Affinity's assemblies are loaded:
using HarmonyLib;
using AffinityPluginLoader;
public class MyPlugin : AffinityPlugin
{
public override void OnPatch(Harmony harmony, IPluginContext context)
{
context.Patch("My patch", h =>
{
// Find and patch target methods here
});
}
}
Place the compiled DLL in apl/plugins/ and launch Affinity through AffinityHook.exe.
Plugin Metadata
APL reads standard .NET assembly attributes to display plugin info in the preferences dialog:
<PropertyGroup>
<AssemblyTitle>My Plugin</AssemblyTitle>
<Version>1.0.0</Version>
<Company>Your Name</Company>
<Description>A short description of what this plugin does.</Description>
</PropertyGroup>
The plugin ID is derived from the AssemblyProduct (or AssemblyTitle) attribute, lowercased with spaces replaced by hyphens.
Plugin Lifecycle
APL loads plugins through a staged pipeline that mirrors Affinity's own startup sequence. Each stage corresponds to a virtual method on AffinityPlugin that you can override:
| Stage | Method | What's Available |
|---|---|---|
| 0 – Load | OnLoad |
Plugin discovered, settings initialized. No Affinity types yet. |
| 1 – Patch | OnPatch |
Serif assemblies loaded. Apply Harmony patches here. |
| 2 – ServicesReady | OnServicesReady |
Affinity's DI container and services initialized. |
| 3 – Ready | OnReady |
Full runtime including native engine, tools, effects. |
| 4 – UiReady | OnUiReady |
Main window loaded. Full UI tree available. |
| 5 – StartupComplete | OnStartupComplete |
Splash hidden, app idle. Safe to show dialogs. |
You only need to override the stages you use. Most plugins only need OnPatch.
Stage Details
Stage 0 – Load: Called immediately after plugin discovery. Settings are already loaded from TOML. Use this for early setup that doesn't require any Affinity types.
Stage 1 – Patch: The main patching stage. Serif assemblies (Serif.Affinity, Serif.Interop.Persona, etc.) are loaded. Apply Harmony patches here. Use context.Patch() for automatic deferral if a dependency isn't loaded yet (see Patching with Harmony).
Stages 2–5: Triggered by Harmony postfixes on Affinity's internal lifecycle methods (InitialiseServices, OnServicesInitialised, OnMainWindowLoaded, PostLoad). Use these for work that depends on Affinity's runtime being progressively more initialized.
IPluginContext
Every stage method receives an IPluginContext with:
Harmony— shared Harmony instance for patchingSettings— your plugin'sSettingsStore(null if you didn't define settings)CurrentStage— the currentLoadStageenum valuePatch(description, action)— apply a patch with automatic deferral (see below)Log()/LogWarning()/LogError()— logging helpers that tag output with your plugin ID
Patching with Harmony
Finding Patch Targets
Use reflection to find types and methods in Affinity's assemblies at runtime:
public override void OnPatch(Harmony harmony, IPluginContext context)
{
context.Patch("Fix something", h =>
{
var assembly = AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => a.GetName().Name == "Serif.Affinity");
var targetType = assembly?.GetType("Some.Namespace.TargetClass");
var targetMethod = targetType?.GetMethod("TargetMethod",
BindingFlags.Public | BindingFlags.Instance);
h.Patch(targetMethod,
prefix: new HarmonyMethod(typeof(MyPlugin), nameof(MyPrefix)));
});
}
static bool MyPrefix() => false; // Skip original method
Automatic Patch Deferral
Use context.Patch() instead of calling harmony.Patch() directly. If your patch throws a TypeLoadException (because a transitive dependency isn't loaded yet), APL automatically defers it and retries when new assemblies are loaded:
context.Patch("My deferred patch", h =>
{
// This is safe even if the target type's dependencies
// aren't loaded yet — APL will retry automatically
h.Patch(targetMethod, prefix: new HarmonyMethod(...));
});
Patch Types
Harmony supports several patch types. The most common ones are:
- Prefix — runs before the original method. Can skip the original by returning
false. - Postfix — runs after the original method. Can modify the return value.
- Transpiler — rewrites the IL instructions of the original method.
See the Harmony patching documentation for the full list of patch types and detailed usage.
Settings API
Override DefineSettings() to declare configuration options for your plugin. APL auto-generates a preferences page in Affinity's preferences dialog and persists values to a TOML file.
public override PluginSettingsDefinition DefineSettings()
{
return new PluginSettingsDefinition("myplugin")
.AddSection("General")
.AddBool("my_toggle", "Enable feature",
defaultValue: true,
description: "Description shown below the toggle.")
.AddEnum("my_choice", "Pick one", new List<EnumOption>
{
new EnumOption("a", "Option A"),
new EnumOption("b", "Option B"),
})
.AddSlider("my_slider", "Amount", 0, 100, defaultValue: 50);
}
Setting Types
| Method | Control | Value Type |
|---|---|---|
AddBool |
Toggle switch | bool |
AddString |
Text input | string |
AddEnum |
Dropdown | string (one of the option values) |
AddSlider |
Slider | double |
AddDropdownSlider |
Dropdown with slider | double |
Setting Options
All setting types accept these common parameters:
| Parameter | Description |
|---|---|
key |
Unique key used in TOML and environment variables |
displayName |
Label shown in the preferences UI |
defaultValue |
Value used when no config file or override exists |
description |
Help text shown below the control. Supports basic markdown. |
restartRequired |
When true, shows a restart notice when the value changes |
infoMessage |
Tooltip text shown on an (i) icon next to the setting name |
Slider types additionally accept minimum, maximum, and precision (decimal places).
Layout Elements
You can add non-setting elements to organize the preferences page:
| Method | Description |
|---|---|
AddSection(title) |
Section header. Also groups settings under a TOML table. |
AddInlineText(text) |
Plain text paragraph |
AddInlineMutedText(text) |
Dimmed/secondary text |
AddInlineXaml(xaml, dataContext) |
Custom XAML content |
Reading Settings
Use the SettingsStore on context.Settings to read values:
// Get the effective value (respects environment variable overrides)
bool enabled = context.Settings.GetEffectiveValue<bool>("my_toggle");
// Get the stored value only (ignores env overrides)
string choice = context.Settings.GetValue<string>("my_choice");
// Check if a setting is overridden by an environment variable
bool isOverridden = context.Settings.IsOverriddenByEnv("my_toggle");
Use GetEffectiveValue<T>() in most cases — it checks for environment variable overrides first, then falls back to the TOML/GUI value.
Environment Variable Overrides
Any plugin setting can be overridden via environment variables:
For example, a plugin with ID myplugin and setting key my_toggle:
Custom Preferences XAML
For advanced cases, override GetCustomPreferencesXaml() to provide custom XAML for your plugin's preferences tab instead of the auto-generated UI.
Building
Linux
Use the provided Docker build script for a reproducible build environment:
To build and deploy directly to your Affinity install directory for testing:
Windows
Example
See the WineFix source code for a complete working plugin with multiple patches, settings with sections, conditional patching based on settings, and deferred patch application.