Static Skia, HarfBuzz and ANGLE archives for Avalonia, so a Native AOT publish produces one executable instead of one beside three native libraries.
Forked from greepar/StaticLink.Avalonia (MIT), which did the hard part. What this fork adds is Vulkan, three package tiers that say out loud which backends they contain, and checks - at pack time, at build time and at run time - that a package cannot quietly contain less than its name claims.
Upstream builds Skia with skia_use_vulkan = false. SkiaSharp's C API does not compile absent
backends out, it stubs them:
gr_direct_context_t* gr_direct_context_make_vulkan(...) {
return SK_ONLY_VULKAN(ToGrDirectContext(...), nullptr);
}The symbol still exists, still links, and returns nullptr. An Avalonia application whose renderer
list starts with Vulkan therefore loads vulkan-1.dll, creates an instance, a device and a queue,
commits to the Vulkan platform graphics, hands it all to Skia, and receives a null context back. The
window opens, stays responsive, logs nothing and paints nothing. No crash, no exception, no fallback
to the next renderer in the list.
That cost us an afternoon on a real application. NOTICE.md has the full account and the list of what changed.
Three packages. Each is a strict superset of the one below it, so no tier can leave a hole in an Avalonia fallback chain.
| Package | Backends | Choose it when |
|---|---|---|
Optris.StaticGraphics.Avalonia.Vulkan |
Vulkan, OpenGL/ANGLE, Software | the default. Anything Vulkan-first, and anything that might become Vulkan-first later |
Optris.StaticGraphics.Avalonia.OpenGL |
OpenGL/ANGLE, Software | the renderer list never mentions Vulkan, and the roughly 2.5 MB Vulkan costs in skia.lib matters |
Optris.StaticGraphics.Avalonia.Software |
Software | no GPU is in play at all: headless, RDP-only, container workloads |
ANGLE is built for Windows only. Nothing else ever referenced it - the macOS targets never mention
it and the Linux DirectPInvoke list omits av_libglesv2 - so the other platforms were carrying
about 23 MB of archive and six ninja builds a release for nothing.
Enabling Vulkan needs no new dependency and no new link input. Skia vendors the Vulkan headers, the
memory allocator is already synced by git-sync-deps, and Skia never links a Vulkan library: it
resolves entry points through a proc-address getter that Avalonia supplies. Nothing here adds a hard
dependency on vulkan-1, so a machine with no ICD still falls back the way Avalonia intends.
macOS is OpenGL or Software. Avalonia has no Vulkan backend there.
The Metal backend is compiled into the macOS payload and cannot currently be used. The Vulkan
tier on macOS is meant to be delivered by Metal - Vulkan only reaches Apple hardware through
MoltenVK, which Skia does not vendor - so build-macos-static-graphics.sh sets skia_use_metal and
asserts GrMtlGpu. That assertion passes. Metal still cannot render.
Avalonia's Metal support does not go through SkiaSharp's managed bindings, because SkiaSharp exposes
no managed Metal GRContext API. Avalonia.Skia.Metal.SkiaMetalApi instead loads a dynamic
libSkiaSharp at runtime and resolves gr_direct_context_make_metal_with_options and
gr_backendrendertarget_new_metal out of it by name. A statically linked build has no
libSkiaSharp.dylib - this package deletes it on publish, and CI asserts it is gone - so the
constructor throws DllNotFoundException before Metal is ever reached.
Measured on an M4 Pro against Optris.StaticGraphics.Avalonia.Vulkan 3.119.4.1:
libskia.a |
152 GrMtlGpu symbols, both Metal entry points present |
| published binary | exports zero of them, so no self-dlopen fallback could reach them either |
OPTRIS_SMOKE_BACKEND=Metal |
DllNotFoundException: Unable to load shared library 'libSkiaSharp', exit 134 |
=OpenGL, =Software |
both render and pass on the same binary |
So the macOS Vulkan tier proves OpenGL and Software, and its top backend is dropped from what CI
smoke asserts. verify-tier-payload's GrMtlGpu check is therefore necessary but not
sufficient - it proves Metal was compiled in, not that it can run. That is an uncomfortable state
for a repository founded on refusing exactly that gap, which is why it is written down here instead
of left to be rediscovered.
What would close it: export those two entry points from the AOT binary and register a
DllImportResolver mapping libSkiaSharp to the main program handle, so Avalonia's lookup resolves
against the statically linked Skia. Plausible - the package already uses DirectPInvoke for
libSkiaSharp - but unproven.
The smoke app already understands OPTRIS_SMOKE_BACKEND=Metal and refuses it on non-macOS, so the
failure above reproduces in one command. Note that GitHub's macOS runners cannot host this test at
all: they give Avalonia no usable GPU context, and even the Software backend fails there in a
background session. Real hardware, in a GUI session, is required.
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.1" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<PackageReference Include="Optris.StaticGraphics.Avalonia.Vulkan" Version="3.119.4.12" />
</ItemGroup>The first three parts of the package version are the SkiaSharp version it was built against; the
fourth is the build revision. It is a release version, not a prerelease - upstream's 3.119.4-7922.1
makes every consumer opt into prereleases forever, and every consumer of theirs after that.
The first three parts ARE the SkiaSharp version, and it is not a choice: it must be the SkiaSharp that YOUR Avalonia depends on. Avalonia.Skia declares that dependency, and the managed bindings your app calls come from it. A package built from a different SkiaSharp major ships native code those bindings were not compiled against.
| Avalonia | SkiaSharp it depends on | package version to use |
|---|---|---|
| 12.1.x | 3.119.4 | 3.119.4.* |
| 11.3.x | 2.88.9 | not published - see below |
To check for any release: read id="SkiaSharp" out of
https://api.nuget.org/v3-flatcontainer/avalonia.skia/<version>/avalonia.skia.nuspec.
These packages track Avalonia's latest stable major only. Avalonia maintains several majors at once, and each needs its own SkiaSharp and therefore its own build - an older line is not a configuration flag, it is a separate release. CI resolves the newest stable major, reads the SkiaSharp version off it and compiles that; an explicit SkiaSharp that contradicts the Avalonia being targeted is refused rather than reconciled.
That refusal exists because the mismatch is otherwise invisible. This repository shipped it once: the smoke ran Avalonia 11.3.14 while the packages carried SkiaSharp 3.119.4, and it rendered anyway only because the package's own SkiaSharp reference is higher, so NuGet silently upgraded Avalonia's Skia underneath it. Highest-wins does not warn, and the one test meant to prove the native layer works had been proving it for a pairing nobody ships.
On macOS also reference Optris.StaticGraphics.AvaloniaNative, which carries
libAvaloniaNative.a. It is tier-independent, but it contains Avalonia's own native library, so its
version has to match the Avalonia version the application uses:
<PackageReference Include="Optris.StaticGraphics.AvaloniaNative" Version="12.1.1.1" />dotnet publish -c Release -r win-x64 \
-p:PublishAot=true \
-p:SelfContained=true \
-p:PublishSingleFile=true \
-p:StripSymbols=trueAny of win-x64, win-x86, win-arm64, linux-x64, linux-arm64, linux-musl-x64,
linux-musl-arm64, osx-x64, osx-arm64.
The whole point of the tiers is that a project can state its requirement and have the build enforce it, instead of discovering the answer as an empty window on a customer's machine.
<PropertyGroup>
<OptrisStaticGraphicsRequiredBackends>Vulkan;OpenGL;Software</OptrisStaticGraphicsRequiredBackends>
</PropertyGroup>- OSG0001 is an error: the referenced tier does not contain one of the backends you asked for.
- OSG0002 is a warning: a reduced tier is in use and nothing said that was intended. Set
OptrisStaticGraphicsAcknowledgeReducedTiertotrueto accept it.
Every package also publishes what it contains to the consuming build, through buildTransitive:
| Property | Value |
|---|---|
OptrisStaticGraphicsTier |
Vulkan, OpenGL or Software |
OptrisStaticGraphicsBackends |
semicolon list of the backends in the archive |
OptrisStaticGraphicsHasVulkan |
true or false |
OptrisStaticGraphicsHasAngle |
true or false |
OptrisStaticGraphicsAngleBranch |
the chromium branch ANGLE was built from |
The runtime counterpart checks the backends Avalonia is actually configured with against the tier that was linked, and fails loudly rather than letting a null GPU context paint nothing:
using Optris.StaticGraphics;
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithOptrisStaticGraphics(); // OptrisBackendPolicy.Strict by defaultSmoke/OptrisStaticGraphicsSmoke forces one backend per run, renders a known picture, reads the
pixels back out of the surface Skia drew into, and asserts that what came back is the picture rather
than one flat colour. It also asserts that Skia's GRContext exists and reports the backend that was
asked for, which is the null-context stub caught directly.
Upstream's smoke test asserts that the process is still alive after fifteen seconds. So does a blank
window. CI judges every run by the app's exit code instead, and runs the app's own negative control
alongside it so that a harness which has stopped asserting fails the build rather than passing it.
scripts/run-smoke.sh is the one entry point all four platforms use. See
Smoke/README.md.
scripts/build-windows-static-graphics.ps1 takes -Tier; the Linux, musl and macOS shell scripts
read TIER from the environment and default to Vulkan. Each tier builds into its own directory, so
the multi-gigabyte Skia checkout is shared rather than re-cloned, and the payload
lands in External/NativeStatic/static-$Tier/<rid>/native/. Packing is
dotnet pack -p:Tier=<tier>.
Building Skia needs per-OS toolchains and hours, and ANGLE on Windows needs depot_tools as well; in
practice it happens in CI.
.github/workflows/release.yml runs the preflight, then the platform workflows, packs
each tier, runs the smoke tests, and only then publishes. The platform workflows
(static-graphics-windows.yml, -linux, -musl, -macos) can be dispatched on their own when
diagnosing one platform.
avalonia-watch.yml polls nuget.org daily for the newest stable Avalonia major, reads the
SkiaSharp version out of that release's Avalonia.Skia nuspec, and starts a full validation build if
that Avalonia has not passed one yet. It never publishes.
The trigger is the Avalonia version, not the SkiaSharp version, and that is deliberate. Most Avalonia patches keep the same SkiaSharp, so keying on SkiaSharp would skip them - and the packages would then be claiming compatibility with an Avalonia release nothing had ever rendered a frame against. The native payload really is unchanged in that case; what changes is how Avalonia calls it, which is exactly what the smoke tests check. So 12.1.2 costs a rebuild that produces near-identical archives, and buys the right to say it was tested.
What has been validated is recorded as the name of an artifact, validated-avalonia-<version>,
written only by a green build. Not a file in the repo, which could be edited or reverted; not a
published version, which would answer the wrong question. If that lookup fails or the artifact has
expired, the run rebuilds rather than assuming a pass.
On Mondays the same run also rebuilds the Avalonia it has already validated, as a canary. Everything
the build depends on besides Avalonia - runner images, Xcode and MSVC, brew, choco and apt packages,
depot_tools for ANGLE - changes on its own schedule, and a watch that builds only when Avalonia moves
notices none of it until a release needs validating. Avalonia has not changed between two canaries,
so a red one means the environment moved. revalidate on a manual dispatch does the same on demand.
Releasing is still a human step: set BuildRev, review OptrisStaticGraphicsAngleBranch, and push a
v<skiasharp>.<rev> tag. SkiaSharpVersion needs no edit - preflight derives it from Avalonia, and
refuses a build where an explicitly pinned SkiaSharp disagrees with the Avalonia being targeted.
The build scripts, the ANGLE patch and the MSBuild integration began as greepar/StaticLink.Avalonia. A GL-only static build is a perfectly reasonable thing to ship, and that project is clear that it is about single-file output rather than about backends; our requirements are simply narrower. MIT, as upstream is.