diff --git a/src/Shared/SourceGeneratorAttributes.cs b/src/Shared/SourceGeneratorAttributes.cs
index bdd21d0426..6bc7b2bd36 100644
--- a/src/Shared/SourceGeneratorAttributes.cs
+++ b/src/Shared/SourceGeneratorAttributes.cs
@@ -51,4 +51,36 @@ namespace Avalonia.SourceGenerator
internal sealed class GenerateEnumValueListAttribute : Attribute
{
}
+
+ [AttributeUsage(AttributeTargets.Interface, Inherited = false, AllowMultiple = false)]
+ internal sealed class GenerateCrossThreadProxyAttribute : Attribute
+ {
+ public GenerateCrossThreadProxyAttribute(Type priorityType, string defaultPriorityExpression)
+ {
+ PriorityType = priorityType;
+ DefaultPriorityExpression = defaultPriorityExpression;
+ }
+
+ public Type PriorityType { get; }
+ public string DefaultPriorityExpression { get; }
+
+ ///
+ /// Optional. Name of the generated proxy class. Defaults to the
+ /// interface name with the leading 'I' stripped (if present) and
+ /// "Proxy" appended (e.g. IFoo → FooProxy).
+ ///
+ public string? GeneratedClassName { get; set; }
+ }
+
+ ///
+ /// When applied to a void-returning method on an interface marked with
+ /// , the generated proxy
+ /// returns instead of being
+ /// fire-and-forget. Has no effect on non-void methods (which are always
+ /// wrapped into ).
+ ///
+ [AttributeUsage(AttributeTargets.Method)]
+ internal sealed class GenerateCrossThreadProxyReturnTaskAttribute : Attribute
+ {
+ }
}
diff --git a/src/tools/DevGenerators/CrossThreadProxyGenerator.cs b/src/tools/DevGenerators/CrossThreadProxyGenerator.cs
new file mode 100644
index 0000000000..53c27121ae
--- /dev/null
+++ b/src/tools/DevGenerators/CrossThreadProxyGenerator.cs
@@ -0,0 +1,381 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Text;
+using Generator;
+using Microsoft.CodeAnalysis;
+
+namespace DevGenerators;
+
+///
+/// Generates cross-thread proxy classes for interfaces marked with
+/// [GenerateCrossThreadProxy]. The generated proxy mirrors the source
+/// interface but routes every call through a user-supplied marshaller
+/// (e.g. a dispatcher post). Void methods become fire-and-forget; non-void
+/// methods (and methods explicitly tagged with
+/// [GenerateCrossThreadProxyReturnTask]) are wrapped into
+/// / Task<T>.
+///
+[Generator(LanguageNames.CSharp)]
+public sealed class CrossThreadProxyGenerator : IIncrementalGenerator
+{
+ private const string ProxyAttributeName = "Avalonia.SourceGenerator.GenerateCrossThreadProxyAttribute";
+ private const string ReturnTaskAttributeName = "global::Avalonia.SourceGenerator.GenerateCrossThreadProxyReturnTaskAttribute";
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var proxies = context.SyntaxProvider
+ .ForAttributeWithMetadataName(
+ ProxyAttributeName,
+ static (node, _) => node is Microsoft.CodeAnalysis.CSharp.Syntax.InterfaceDeclarationSyntax,
+ static (ctx, _) => Extract(ctx))
+ .Where(static x => x is not null)
+ .Select(static (x, _) => x!);
+
+ context.RegisterSourceOutput(proxies, static (spc, model) =>
+ {
+ var source = Render(model);
+ spc.AddSource(model.HintName, source);
+ });
+ }
+
+ private static ProxyModel? Extract(GeneratorAttributeSyntaxContext ctx)
+ {
+ if (ctx.TargetSymbol is not INamedTypeSymbol iface || iface.TypeKind != TypeKind.Interface)
+ return null;
+
+ var attr = ctx.Attributes.FirstOrDefault();
+ if (attr is null || attr.ConstructorArguments.Length < 2)
+ return null;
+
+ var priorityTypeArg = attr.ConstructorArguments[0];
+ var defaultExprArg = attr.ConstructorArguments[1];
+ if (priorityTypeArg.Value is not INamedTypeSymbol priorityType
+ || defaultExprArg.Value is not string defaultExpr)
+ return null;
+
+ string? generatedClassName = null;
+ foreach (var named in attr.NamedArguments)
+ {
+ if (named.Key == "GeneratedClassName" && named.Value.Value is string s)
+ generatedClassName = s;
+ }
+
+ var className = generatedClassName ?? DefaultProxyName(iface.Name);
+ var ns = iface.ContainingNamespace.IsGlobalNamespace
+ ? null
+ : iface.ContainingNamespace.ToDisplayString();
+ var ifaceFqn = iface.GetFullyQualifiedName();
+ var priorityFqn = priorityType.GetFullyQualifiedName();
+
+ // Detect a single direct base interface that also carries the proxy attribute;
+ // we will inherit from its generated proxy class instead of re-emitting members.
+ string? baseProxyFqn = null;
+ var inheritedMethodSignatures = new HashSet();
+ foreach (var baseIface in iface.Interfaces)
+ {
+ var baseAttr = baseIface.GetAttributes().FirstOrDefault(a =>
+ a.AttributeClass?.ToDisplayString() == ProxyAttributeName);
+ if (baseAttr is null)
+ continue;
+ if (baseProxyFqn is not null)
+ return null; // multiple proxied bases unsupported
+
+ string? baseGenerated = null;
+ foreach (var na in baseAttr.NamedArguments)
+ {
+ if (na.Key == "GeneratedClassName" && na.Value.Value is string s)
+ baseGenerated = s;
+ }
+ var baseClassName = baseGenerated ?? DefaultProxyName(baseIface.Name);
+ var baseNs = baseIface.ContainingNamespace.IsGlobalNamespace
+ ? null
+ : baseIface.ContainingNamespace.ToDisplayString();
+ baseProxyFqn = "global::" + (baseNs is null ? baseClassName : baseNs + "." + baseClassName);
+
+ foreach (var bm in baseIface.AllInterfaces.Concat(new[] { baseIface })
+ .SelectMany(i => i.GetMembers().OfType()))
+ {
+ if (bm.MethodKind != MethodKind.Ordinary) continue;
+ inheritedMethodSignatures.Add(MethodSignature(bm));
+ }
+ }
+
+ var methodsBuilder = ImmutableArray.CreateBuilder();
+ foreach (var member in iface.GetMembers())
+ {
+ if (member is not IMethodSymbol m || m.MethodKind != MethodKind.Ordinary)
+ continue;
+ if (m.IsGenericMethod)
+ continue;
+ if (inheritedMethodSignatures.Contains(MethodSignature(m)))
+ continue;
+ var hasReturnTaskAttr = m.HasAttributeWithFullyQualifiedName(ReturnTaskAttributeName);
+ var returnsVoid = m.ReturnsVoid;
+ var wantTask = hasReturnTaskAttr || !returnsVoid;
+
+ var paramsBuilder = ImmutableArray.CreateBuilder();
+ var skip = false;
+ foreach (var p in m.Parameters)
+ {
+ if (p.RefKind != RefKind.None)
+ {
+ skip = true;
+ break;
+ }
+ paramsBuilder.Add(new ParamModel(p.Name, p.Type.GetFullyQualifiedName()));
+ }
+ if (skip)
+ continue;
+
+ methodsBuilder.Add(new MethodModel(
+ Name: m.Name,
+ ReturnTypeFqn: returnsVoid ? null : m.ReturnType.GetFullyQualifiedName(),
+ WrapInTask: wantTask,
+ Parameters: paramsBuilder.ToImmutable()));
+ }
+
+ var hintName = (ns is null ? className : ns + "." + className) + ".g.cs";
+
+ return new ProxyModel(
+ HintName: hintName,
+ Namespace: ns,
+ ClassName: className,
+ InterfaceFqn: ifaceFqn,
+ PriorityFqn: priorityFqn,
+ DefaultPriorityExpression: defaultExpr,
+ BaseProxyFqn: baseProxyFqn,
+ Methods: methodsBuilder.ToImmutable());
+ }
+
+ private static string MethodSignature(IMethodSymbol m)
+ {
+ var sb = new StringBuilder();
+ sb.Append(m.Name).Append('(');
+ for (var i = 0; i < m.Parameters.Length; i++)
+ {
+ if (i > 0) sb.Append(',');
+ sb.Append(m.Parameters[i].Type.GetFullyQualifiedName());
+ }
+ sb.Append(')');
+ return sb.ToString();
+ }
+
+ private static string DefaultProxyName(string ifaceName)
+ {
+ if (ifaceName.Length > 1 && ifaceName[0] == 'I' && char.IsUpper(ifaceName[1]))
+ return ifaceName.Substring(1) + "Proxy";
+ return ifaceName + "Proxy";
+ }
+
+ private static string Render(ProxyModel m)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine("using System;");
+ sb.AppendLine("using System.Threading.Tasks;");
+ if (m.Namespace is not null)
+ {
+ sb.Append("namespace ").Append(m.Namespace).AppendLine(";");
+ }
+
+ sb.Append("internal class ").Append(m.ClassName);
+ var canImplementInterface = m.Methods.All(mm => !mm.WrapInTask);
+ if (m.BaseProxyFqn is not null)
+ {
+ sb.Append(" : ").Append(m.BaseProxyFqn);
+ if (canImplementInterface)
+ sb.Append(", ").Append(m.InterfaceFqn);
+ }
+ else if (canImplementInterface)
+ {
+ sb.Append(" : ").Append(m.InterfaceFqn);
+ }
+ sb.AppendLine();
+ sb.AppendLine("{");
+ sb.Pad(1).Append("private readonly ").Append(m.InterfaceFqn).AppendLine(" _target;");
+ sb.Pad(1).Append("private readonly global::System.Action _marshaller;");
+ sb.AppendLine();
+ sb.Pad(1).Append("public ").Append(m.ClassName).Append("(")
+ .Append(m.InterfaceFqn).Append(" target, global::System.Action marshaller)");
+ if (m.BaseProxyFqn is not null)
+ sb.Append(" : base(target, marshaller)");
+ sb.AppendLine();
+ sb.Pad(1).AppendLine("{");
+ sb.Pad(2).AppendLine("_target = target;");
+ sb.Pad(2).AppendLine("_marshaller = marshaller;");
+ sb.Pad(1).AppendLine("}");
+
+ // Worker-side accessor for identity-passing scenarios (e.g. IWXdgTopLevel.SetParent
+ // takes another IWXdgTopLevel which on the worker side must be unwrapped to the real
+ // target so we can dereference its protocol object). Hide it from base classes via
+ // 'new' to avoid ambiguity in inheritance chains.
+ sb.AppendLine();
+ sb.Pad(1).Append("internal ");
+ if (m.BaseProxyFqn is not null)
+ sb.Append("new ");
+ sb.Append(m.InterfaceFqn).AppendLine(" ProxyTarget => _target;");
+
+ foreach (var method in m.Methods)
+ {
+ sb.AppendLine();
+ RenderMethod(sb, m, method);
+ }
+
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+
+ private static void RenderMethod(StringBuilder sb, ProxyModel m, MethodModel method)
+ {
+ var paramListNoPriority = string.Join(", ",
+ method.Parameters.Select(p => p.TypeFqn + " " + p.Name));
+ var paramListWithPriority = paramListNoPriority.Length > 0
+ ? paramListNoPriority + ", " + m.PriorityFqn + " priority"
+ : m.PriorityFqn + " priority";
+
+ var argList = string.Join(", ", method.Parameters.Select(p => p.Name));
+ var argListPlusDefaultPriority = argList.Length > 0
+ ? argList + ", " + m.DefaultPriorityExpression
+ : m.DefaultPriorityExpression;
+
+ string returnType;
+ if (!method.WrapInTask)
+ returnType = "void";
+ else if (method.ReturnTypeFqn is null)
+ returnType = "global::System.Threading.Tasks.Task";
+ else
+ returnType = "global::System.Threading.Tasks.Task<" + method.ReturnTypeFqn + ">";
+
+ // Convenience overload — uses the configured default priority.
+ sb.Pad(1).Append("public ").Append(returnType).Append(' ').Append(method.Name)
+ .Append('(').Append(paramListNoPriority).Append(") => ")
+ .Append(method.Name).Append('(').Append(argListPlusDefaultPriority).AppendLine(");");
+ sb.AppendLine();
+
+ sb.Pad(1).Append("public ").Append(returnType).Append(' ').Append(method.Name)
+ .Append('(').Append(paramListWithPriority).AppendLine(")");
+ sb.Pad(1).AppendLine("{");
+
+ if (!method.WrapInTask)
+ {
+ // Fire-and-forget void.
+ sb.Pad(2).Append("_marshaller(() => _target.").Append(method.Name)
+ .Append('(').Append(argList).AppendLine("), priority);");
+ }
+ else
+ {
+ // Task / Task wrapper.
+ string tcsType;
+ if (method.ReturnTypeFqn is null)
+ tcsType = "global::System.Threading.Tasks.TaskCompletionSource";
+ else
+ tcsType = "global::System.Threading.Tasks.TaskCompletionSource<" + method.ReturnTypeFqn + ">";
+
+ sb.Pad(2).Append("var tcs = new ").Append(tcsType)
+ .AppendLine("(global::System.Threading.Tasks.TaskCreationOptions.RunContinuationsAsynchronously);");
+ sb.Pad(2).AppendLine("_marshaller(() =>");
+ sb.Pad(2).AppendLine("{");
+ sb.Pad(3).AppendLine("try");
+ sb.Pad(3).AppendLine("{");
+ if (method.ReturnTypeFqn is null)
+ {
+ sb.Pad(4).Append("_target.").Append(method.Name).Append('(').Append(argList).AppendLine(");");
+ sb.Pad(4).AppendLine("tcs.TrySetResult(true);");
+ }
+ else
+ {
+ sb.Pad(4).Append("var __result = _target.").Append(method.Name)
+ .Append('(').Append(argList).AppendLine(");");
+ sb.Pad(4).AppendLine("tcs.TrySetResult(__result);");
+ }
+ sb.Pad(3).AppendLine("}");
+ sb.Pad(3).AppendLine("catch (global::System.Exception __ex)");
+ sb.Pad(3).AppendLine("{");
+ sb.Pad(4).AppendLine("tcs.TrySetException(__ex);");
+ sb.Pad(3).AppendLine("}");
+ sb.Pad(2).AppendLine("}, priority);");
+ sb.Pad(2).AppendLine("return tcs.Task;");
+ }
+
+ sb.Pad(1).AppendLine("}");
+ }
+
+ // ---- equatable models ----
+
+ private sealed record ProxyModel(
+ string HintName,
+ string? Namespace,
+ string ClassName,
+ string InterfaceFqn,
+ string PriorityFqn,
+ string DefaultPriorityExpression,
+ string? BaseProxyFqn,
+ ImmutableArray Methods)
+ {
+ public bool Equals(ProxyModel? other)
+ {
+ if (other is null) return false;
+ return HintName == other.HintName
+ && Namespace == other.Namespace
+ && ClassName == other.ClassName
+ && InterfaceFqn == other.InterfaceFqn
+ && PriorityFqn == other.PriorityFqn
+ && DefaultPriorityExpression == other.DefaultPriorityExpression
+ && BaseProxyFqn == other.BaseProxyFqn
+ && Methods.SequenceEqual(other.Methods);
+ }
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ var h = 17;
+ h = h * 31 + (HintName?.GetHashCode() ?? 0);
+ h = h * 31 + (Namespace?.GetHashCode() ?? 0);
+ h = h * 31 + (ClassName?.GetHashCode() ?? 0);
+ h = h * 31 + (InterfaceFqn?.GetHashCode() ?? 0);
+ h = h * 31 + (PriorityFqn?.GetHashCode() ?? 0);
+ h = h * 31 + (DefaultPriorityExpression?.GetHashCode() ?? 0);
+ h = h * 31 + (BaseProxyFqn?.GetHashCode() ?? 0);
+ foreach (var m in Methods) h = h * 31 + m.GetHashCode();
+ return h;
+ }
+ }
+ }
+
+ private sealed record MethodModel(
+ string Name,
+ string? ReturnTypeFqn,
+ bool WrapInTask,
+ ImmutableArray Parameters)
+ {
+ public bool Equals(MethodModel? other)
+ {
+ if (other is null) return false;
+ return Name == other.Name
+ && ReturnTypeFqn == other.ReturnTypeFqn
+ && WrapInTask == other.WrapInTask
+ && Parameters.SequenceEqual(other.Parameters);
+ }
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ var h = 17;
+ h = h * 31 + (Name?.GetHashCode() ?? 0);
+ h = h * 31 + (ReturnTypeFqn?.GetHashCode() ?? 0);
+ h = h * 31 + WrapInTask.GetHashCode();
+ foreach (var p in Parameters) h = h * 31 + p.GetHashCode();
+ return h;
+ }
+ }
+ }
+
+ private sealed record ParamModel(string Name, string TypeFqn);
+}
diff --git a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj
index 526f26806f..f818772efc 100644
--- a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj
+++ b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj
@@ -25,6 +25,10 @@
+
diff --git a/tests/Avalonia.Base.UnitTests/SourceGenerators/CrossThreadProxyGeneratorTests.cs b/tests/Avalonia.Base.UnitTests/SourceGenerators/CrossThreadProxyGeneratorTests.cs
new file mode 100644
index 0000000000..795e11275a
--- /dev/null
+++ b/tests/Avalonia.Base.UnitTests/SourceGenerators/CrossThreadProxyGeneratorTests.cs
@@ -0,0 +1,160 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Avalonia.SourceGenerator;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.SourceGenerators;
+
+public class CrossThreadProxyGeneratorTests
+{
+ public enum TestPriority { Low, Normal, High }
+
+ [GenerateCrossThreadProxy(typeof(TestPriority), "Avalonia.Base.UnitTests.SourceGenerators.CrossThreadProxyGeneratorTests.TestPriority.Normal")]
+ public interface IBaseProxied
+ {
+ void BaseFireAndForget(int x);
+ }
+
+ [GenerateCrossThreadProxy(typeof(TestPriority), "Avalonia.Base.UnitTests.SourceGenerators.CrossThreadProxyGeneratorTests.TestPriority.Normal")]
+ public interface IDerivedProxied : IBaseProxied
+ {
+ void Increment();
+ int GetValue();
+
+ [GenerateCrossThreadProxyReturnTask]
+ void AsyncFireAndForget(string s);
+ }
+
+ private sealed class Target : IDerivedProxied
+ {
+ public List BaseCalls { get; } = new();
+ public int Counter;
+ public List AsyncCalls { get; } = new();
+ public Func? GetValueImpl;
+
+ public void BaseFireAndForget(int x) => BaseCalls.Add(x);
+ public void Increment() => Counter++;
+ public int GetValue() => GetValueImpl?.Invoke() ?? Counter;
+ public void AsyncFireAndForget(string s) => AsyncCalls.Add(s);
+ }
+
+ private sealed class QueueMarshaller
+ {
+ public readonly Queue<(Action action, TestPriority priority)> Queue = new();
+ public void Post(Action a, TestPriority p) => Queue.Enqueue((a, p));
+ public void DrainAll()
+ {
+ while (Queue.Count > 0) Queue.Dequeue().action();
+ }
+ }
+
+ [Fact]
+ public void Fire_and_forget_void_routes_through_marshaller_with_default_priority()
+ {
+ var t = new Target();
+ var m = new QueueMarshaller();
+ var proxy = new DerivedProxiedProxy(t, m.Post);
+
+ proxy.Increment();
+
+ Assert.Single(m.Queue);
+ Assert.Equal(TestPriority.Normal, m.Queue.Peek().priority);
+ Assert.Equal(0, t.Counter);
+ m.DrainAll();
+ Assert.Equal(1, t.Counter);
+ }
+
+ [Fact]
+ public void Explicit_priority_overload_is_used()
+ {
+ var t = new Target();
+ var m = new QueueMarshaller();
+ var proxy = new DerivedProxiedProxy(t, m.Post);
+
+ proxy.Increment(TestPriority.High);
+
+ Assert.Equal(TestPriority.High, m.Queue.Peek().priority);
+ }
+
+ [Fact]
+ public async Task NonVoid_returns_Task_completed_after_marshaller_runs()
+ {
+ var t = new Target { Counter = 42 };
+ var m = new QueueMarshaller();
+ var proxy = new DerivedProxiedProxy(t, m.Post);
+
+ var task = proxy.GetValue();
+ Assert.False(task.IsCompleted);
+ m.DrainAll();
+ Assert.True(task.IsCompleted);
+ Assert.Equal(42, await task);
+ }
+
+ [Fact]
+ public void Exception_in_target_propagates_to_Task()
+ {
+ var t = new Target { GetValueImpl = () => throw new InvalidOperationException("boom") };
+ var m = new QueueMarshaller();
+ var proxy = new DerivedProxiedProxy(t, m.Post);
+
+ var task = proxy.GetValue();
+ m.DrainAll();
+ Assert.True(task.IsFaulted);
+ Assert.IsType(task.Exception!.InnerException);
+ }
+
+ [Fact]
+ public void Void_method_with_ReturnTask_attribute_returns_Task()
+ {
+ var t = new Target();
+ var m = new QueueMarshaller();
+ var proxy = new DerivedProxiedProxy(t, m.Post);
+
+ Task task = proxy.AsyncFireAndForget("hi");
+ Assert.False(task.IsCompleted);
+ m.DrainAll();
+ Assert.True(task.IsCompleted);
+ Assert.Equal(new[] { "hi" }, t.AsyncCalls);
+ }
+
+ [Fact]
+ public void Inherited_method_routes_through_base_proxy()
+ {
+ var t = new Target();
+ var m = new QueueMarshaller();
+ var proxy = new DerivedProxiedProxy(t, m.Post);
+
+ proxy.BaseFireAndForget(7);
+ Assert.Single(m.Queue);
+ m.DrainAll();
+ Assert.Equal(new[] { 7 }, t.BaseCalls);
+ }
+
+ [Fact]
+ public void Derived_proxy_is_assignable_to_base_proxy()
+ {
+ var t = new Target();
+ var m = new QueueMarshaller();
+ BaseProxiedProxy proxy = new DerivedProxiedProxy(t, m.Post);
+
+ proxy.BaseFireAndForget(3);
+ m.DrainAll();
+ Assert.Equal(new[] { 3 }, t.BaseCalls);
+ }
+
+ [Fact]
+ public void Marshaller_is_not_invoked_synchronously_during_proxy_call()
+ {
+ var t = new Target();
+ var m = new QueueMarshaller();
+ var proxy = new DerivedProxiedProxy(t, m.Post);
+
+ proxy.Increment();
+ proxy.Increment();
+ proxy.Increment();
+
+ Assert.Equal(0, t.Counter);
+ Assert.Equal(3, m.Queue.Count);
+ }
+}