DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Obfuscating an Avalonia app: compiled bindings, the ViewLocator convention, and what the XAML compiler already made rename-safe

A WPF obfuscation guide does not transfer to Avalonia, and the reason is the XAML compiler. WPF compiles XAML to BAML, a serialized object graph the runtime instantiates by name, and a binding path is a string the runtime walks with reflection. Avalonia compiles .axaml to IL in your own assembly. A compiled binding is a typed accessor; a DataTemplate with a DataType is a type reference; a control in XAML is a newobj. Most of what an obfuscator would break in WPF has already been turned into metadata tokens by the time Nebula sees an Avalonia assembly, and tokens are exactly the thing a renamer rewrites correctly.

Which means the failures that remain are the ones where Avalonia — or your own code — still goes through a string. There are three that matter, and they are worth ranking by how fast they bite: a reflection binding comes back empty with a log line and a blank control; the two compiler-generated types Avalonia locates by literal name make every avares:// include fail to load; and the ViewLocator in the default MVVM template breaks every navigation in the app at once, because it finds views by editing the view model's type name.

What the XAML compiler emits for a compiled binding

Start with the ordinary case and read the generated code, because the safety argument depends on exactly what is in it. A view with a typed data context and one binding:

<!-- MainWindow.axaml -->
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:MyApp.ViewModels"
        x:Class="MyApp.Views.MainWindow"
        x:DataType="vm:MainWindowViewModel">
  <TextBlock Text="{CompiledBinding Greeting}" />
</Window>
Enter fullscreen mode Exit fullscreen mode

With <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> in the project file, a plain {Binding Greeting} compiles the same way; x:DataType is what gives the compiler a type to resolve Greeting against. The compiler emits a populate method into your assembly. Decompiled and lightly cleaned up, the part that builds the binding looks like this:

// Generated into MyApp.dll by the Avalonia XAML compiler (cleaned up for reading).
// Namespace CompiledAvaloniaXaml, method !XamlIlPopulate for MainWindow.
var path = new CompiledBindingPathBuilder()
    .Property(
        new ClrPropertyInfo(
            "Greeting",                                          // literal name
            o => ((MainWindowViewModel)o).Greeting,              // getter: token ref to get_Greeting
            null,                                                // no setter: read-only binding
            typeof(string)),
        PropertyInfoAccessorFactory.CreateInpcPropertyAccessor)  // INotifyPropertyChanged accessor
    .Build();

textBlock.Bind(TextBlock.TextProperty, new CompiledBindingExtension(path));
Enter fullscreen mode Exit fullscreen mode

Two things reference Greeting, and they are different kinds of reference. The lambda o => ((MainWindowViewModel)o).Greeting compiles to callvirt instance string MainWindowViewModel::get_Greeting() — a metadata token. When Nebula renames get_Greeting to a, it rewrites every token that points at it, including this one; the lambda calls the renamed getter and returns the right value. The cast to MainWindowViewModel is a token too, rewritten the same way.

The second reference is the string "Greeting". It is not used to find the property — the accessor already has the getter. It is passed to the INPC accessor, which subscribes to PropertyChanged and re-reads the value when an event arrives whose PropertyName equals "Greeting". So the question is what the view model puts in that event:

public sealed partial class MainWindowViewModel : ObservableObject
{
    [ObservableProperty] private string _greeting = "Hello";
    // the MVVM Toolkit generator emits: OnPropertyChanged("Greeting") — a literal

    // or by hand:
    private string _title = "";
    public string Title
    {
        get => _title;
        set => SetProperty(ref _title, value);   // [CallerMemberName] → "Title", baked at compile time
    }
}
Enter fullscreen mode Exit fullscreen mode

nameof(Greeting), [CallerMemberName], and the source generators all resolve to a string literal in IL — ldstr "Greeting" — before the obfuscator ever runs. Renaming the property changes the metadata name and leaves the literal alone. The compiled binding compares its literal with the event's literal, and both still say Greeting. The binding works, the change notification works, and the property can be renamed to anything.

This is the same reason the nameof pitfall from the WPF world does not apply here in the same way: in Avalonia the compiled side also holds a literal, so there is no renamed metadata name on either side of the comparison. The one way to break it is a view model base class that raises PropertyChanged by enumerating GetType().GetProperties() and using prop.Name — a runtime name that does change. If you have one of those, it is a reflection dependency like any other, and it needs a preserve rule or a rewrite.

Reflection bindings: the string the runtime walks

Drop the x:DataType, or leave compiled bindings off, and the same {Binding Greeting} compiles differently. The compiler cannot resolve Greeting against a type, so it emits a ReflectionBindingExtension carrying the path as a string. At runtime the binding engine parses "Greeting", takes the actual DataContext, and looks the property up by name — reflection against the renamed metadata. After obfuscation there is no property called Greeting; the lookup returns null, Avalonia logs a binding error at warning level, and the TextBlock is blank. No exception. Your smoke test, which checks that the window opens, passes.

You can preserve your way out of this — keep the public properties of every view model type — but it is the wrong fix, because it leaves the dependency in place and just stops it from being exercised. The right fix is to remove the dependency:

<!-- MyApp.csproj -->
<PropertyGroup>
  <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

With that on, every {Binding} is compiled, and every view needs an x:DataType (or a DataTemplate with a DataType) so the compiler has something to resolve against. The payoff is not only that the bindings become rename-safe — it is that any binding the compiler cannot resolve is now a build error. A binding that would have broken under obfuscation cannot be compiled in the first place. You have moved the field failure to the build server, which is where every obfuscation failure should live.

Where you genuinely need a late-bound path — a binding to a dynamic object, an untyped ItemsSource from a plugin — write {ReflectionBinding Path} explicitly so the intent is visible, and preserve exactly that type's public properties with a targeted rule rather than disabling compiled bindings project-wide.

The ViewLocator: the convention that breaks the whole app

Create an Avalonia MVVM project from the template and you get this class, and it is the most important thing in the project to read before obfuscating:

public class ViewLocator : IDataTemplate
{
    public Control? Build(object? param)
    {
        if (param is null) return null;

        var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
        var type = Type.GetType(name);

        if (type != null)
            return (Control)Activator.CreateInstance(type)!;

        return new TextBlock { Text = "Not Found: " + name };
    }

    public bool Match(object? data) => data is ViewModelBase;
}
Enter fullscreen mode Exit fullscreen mode

Every ContentControl whose content is a view model goes through Build, which means every navigation, every tab, every dialog bound to a view model. And Build works by editing the view model's type name as text: MyApp.ViewModels.MainWindowViewModel becomes MyApp.Views.MainWindowView, and Type.GetType finds it in the current assembly.

Rename the view model and one of two things happens. If Nebula renamed the namespace as well as the type, FullName is something like a.b. There is no "ViewModel" substring, Replace returns the input unchanged, and Type.GetType("a.b") — searching the calling assembly — finds the view model type itself. Activator.CreateInstance builds a view model, and the cast to Control throws InvalidCastException. If only the type name was renamed and the namespace kept, FullName is MyApp.ViewModels.a, the replace produces MyApp.Views.a, nothing has that name, and the user sees Not Found: MyApp.Views.a in place of every view.

Both are caught the first time you run the obfuscated build, which is good, but the fix people reach for — preserve every type named *ViewModel and *View — leaves the convention in place and keeps the most interesting type names in your app, the ones that describe what every screen does, in plain text. The right fix is to stop locating views by name. Avalonia already has a rename-safe mechanism for it:

<!-- App.axaml -->
<Application xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:vm="using:MyApp.ViewModels"
             xmlns:views="using:MyApp.Views"
             x:Class="MyApp.App">
  <Application.DataTemplates>
    <DataTemplate DataType="vm:MainWindowViewModel"><views:MainWindowView /></DataTemplate>
    <DataTemplate DataType="vm:SettingsViewModel"><views:SettingsView /></DataTemplate>
    <DataTemplate DataType="vm:ProjectViewModel"><views:ProjectView /></DataTemplate>
  </Application.DataTemplates>
</Application>
Enter fullscreen mode Exit fullscreen mode

Each DataType compiles to a typeof — a token — and each view element compiles to a newobj — also a token. There is no string anywhere in the mapping. Rename both sides to single letters and the templates still pair the right view with the right view model, because the pairing was never by name. As a side benefit, this is also the form that survives trimming and Native AOT, which choke on Type.GetType(string) for the same reason an obfuscator does: the name is not a reference the toolchain can see.

If you prefer code, a static dictionary keyed by Type gives the same property — typeof(MainWindowViewModel) is a token, and the renamer follows it. What you must not have, anywhere in the app, is a line that turns a type into a string and the string back into a type. Delete the template ViewLocator once the templates are in; it is not needed.

The two generated types Avalonia finds by name

There is one place where Avalonia itself resolves your assembly by string, and it is in code you did not write. The XAML compiler emits a CompiledAvaloniaXaml namespace into your assembly with two types, !XamlLoader and !AvaloniaResources, which hold the compiled populate and build methods for every .axaml file, keyed by resource path. When a StyleInclude or ResourceInclude has an avares://MyApp/Styles/Dark.axaml source, or when you call AvaloniaXamlLoader.Load(new Uri(...)), the runtime locates the compiled XAML for that path by calling Assembly.GetType with the literal type name and invoking the matching method. Rename the namespace or either type and that lookup returns null; the runtime reports that no precompiled XAML was found for the URI and the include fails to load.

InitializeComponent is usually not affected, because the compiler rewrites the AvaloniaXamlLoader.Load(this) call inside it into a direct call to the generated populate method — a token, not a lookup. But any load by URI goes through the reflective path, and almost every app has at least one, in App.axaml's styles. So the namespace stays.

A few other Avalonia mechanisms look string-shaped but are safe, and it is worth knowing why so you do not over-preserve. x:Name="Submit" with this.FindControl<Button>("Submit") is literal against literal — the name scope registers the literal from XAML and the lookup passes the literal from C#; the generated field can be renamed. AvaloniaProperty.Register<MyControl, string>(nameof(Text)) registers a literal; bindings that target an AvaloniaProperty resolve through the property registry by that literal, not through the CLR wrapper's name. Style selectors (Button.primary, :pointerover) are strings on both sides. avares:// URIs contain the assembly name, which Nebula does not rename. None of these need a rule.

The Nebula configuration

With compiled bindings on and the ViewLocator replaced by templates, the configuration is short, because almost nothing in the app is resolved by name any more:

<Nebula>
  <Assembly file="MyApp.Desktop.dll">
    <Rename enabled="true">
      <!-- Avalonia locates these compiler-generated types with Assembly.GetType by literal
           name when it loads XAML by avares:// URI. Keep the namespace exactly as emitted. -->
      <Preserve names="true" members="true" namespace="CompiledAvaloniaXaml" />
    </Rename>
    <ControlFlow enabled="true" />
    <StringEncryption enabled="true" />
  </Assembly>
</Nebula>
Enter fullscreen mode Exit fullscreen mode

String encryption is safe to leave on for the whole assembly. The "Greeting" literal in the generated accessor and the "Greeting" literal in the view model's OnPropertyChanged are both decrypted at the point of use, and the INPC comparison happens on the decrypted values; the binding sees the same two strings it always did.

If you are not yet in a position to remove the reflection dependencies — a large app with a ViewLocator and years of untyped bindings — this is the interim rule, scoped to exactly the surface the string lookups touch and nothing more:

<Rename enabled="true">
  <Preserve names="true" members="true" namespace="CompiledAvaloniaXaml" />
  <!-- INTERIM ONLY: a convention-based ViewLocator needs the view model and view TYPE
       names; reflection bindings need view model PUBLIC PROPERTY names. Nothing else. -->
  <Preserve names="true" members="public" baseType="MyApp.ViewModels.ViewModelBase" />
  <Preserve names="true" members="false" baseType="Avalonia.Controls.UserControl" />
  <Preserve names="true" members="false" baseType="Avalonia.Controls.Window" />
</Rename>
Enter fullscreen mode Exit fullscreen mode

Note that members="false" on the view types keeps only the type name the locator needs; the view's fields, handlers and generated x:Name fields are still renamed. And the view model rule keeps the public property names the reflection bindings walk while renaming private fields, commands' backing members and everything the binding engine never sees. Treat this block as a to-do list — each line is a dependency to delete, and when the last one goes, so does the block.

The test that proves it

The failure modes here are quiet: a blank TextBlock, a Not Found label, a style that did not apply. The test that catches all of them is the one that builds every view through the real template pipeline against the obfuscated assembly, using Avalonia's headless platform so it runs in CI:

[AvaloniaFact]                       // Avalonia.Headless.XUnit
public void Every_view_model_resolves_to_a_real_view()
{
    var app = Application.Current!;                       // the OBFUSCATED MyApp.Desktop.dll
    var viewModels = new object[]
    {
        new MainWindowViewModel(), new SettingsViewModel(), new ProjectViewModel()
    };

    foreach (var vm in viewModels)
    {
        var template = app.DataTemplates.FirstOrDefault(t => t.Match(vm));
        Assert.NotNull(template);                          // a view model with no template

        var view = template!.Build(vm);
        Assert.IsNotType<TextBlock>(view);                 // the "Not Found" fallback
        Assert.NotNull(view);
    }
}
Enter fullscreen mode Exit fullscreen mode

Add one assertion per compiled binding you care about — set a view model property, pump the dispatcher, read the control — and the test fails on a renamed accessor long before a customer does. With the compiler enforcing compiled bindings and this test enforcing the view mapping, there is no name left in the app for the obfuscator to break, which is exactly the state you want before you flatten it.

Top comments (0)