Skip to main content

Toolkit Extension Development

A Toolkit Extension adds a tool to Test Utilities — the page behind the Tools entry in the Studio footer. It is the only extension type that ships a user interface, and the only one that does not reference an Aethrix assembly.

A pack is one definition.json and one DLL. The DLL is an ordinary Avalonia class library that references Avalonia and FluentAvalonia and nothing else. You write a UserControl; the host puts it on the page.

The quickest way in​

Create a class library, add the two package references, and write a control.

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.2" PrivateAssets="all" />
<PackageReference Include="FluentAvaloniaUI" Version="3.1.0" PrivateAssets="all" />
</ItemGroup>
</Project>

PrivateAssets="all" is not a style preference. Without it the build output carries Avalonia's assemblies, and a pack that ships its own copy of the framework loads into its own context beside the host's — its control then implements a different Control type, the host cannot place it on the page, and the tool simply never appears. That is the one failure in this system that produces no error, so the packaging step refuses to build such a package.

public partial class MyView : UserControl
{
public MyView() => InitializeComponent();
}

Then declare it. definition.json sits beside the DLL:

{
"Id": "Acme.CodecTools",
"Name": "Codec Tools",
"Developer": "Your Name",
"Version": "1.0.0",
"Type": 8,
"PackageSource": 2,
"AssemblyFiles": [ "Acme.CodecTools.dll" ],
"Files": [ "definition.json", "Acme.CodecTools.dll" ],
"ui": { "avalonia": "[12.1.2,13.0.0)", "fluentAvalonia": "[3.1.0,4.0.0)" },
"tools": [
{
"id": "my-tool",
"view": "Acme.CodecTools.MyView",
"category": "codec",
"icon": "Icon_Calculate",
"name": "My Tool",
"description": "What it does, in one line."
}
]
}

Files is the package's contents, not a note about them: Studio builds the archive from that list, the repository validates against it, and the host reads it at load. A file the folder holds but the list omits does not ship; a file the list names that the folder does not hold stops the packing.

Zip the two files into a file named <Id>-<Version>.pkg — Acme.CodecTools-1.0.0.pkg — and install it from the Extensions page: Add Extension → Local extensions → Load From…, then Install. The card appears under its category heading without a restart, whether the pack came from that dialog or from the repository.

There is no interface to implement and no attribute to register. The host resolves the type named in view, checks it is a control of this installation with a public parameterless constructor, and lists it.

Alternatives worth knowing first​

Use the template instead of starting from scratch. The Aethrix.Toolkits repository holds a dotnet new template with the two package references, the manifest and a build-time check already wired up:

dotnet new install ./templates/toolkit-tool
dotnet new aeth-toolkit -n Acme.Base32 -o src/Acme.Base32 --ToolId base32 --DisplayName "Base32" --Category codec

The install path is the template folder itself, not ./templates. The generated project carries its own target framework and package versions, so it builds outside that repository too, and Aethrix.Toolkit.Guard.targets fails dotnet publish if the output picks up a copy of the UI framework.

Pack from inside Studio. If you would rather not assemble the zip yourself, the Extensions page has a Pack a folder button beside Add Extension: point it at your build output and it checks the folder against the same rules the host applies when it loads a pack, then writes <Id>-<Version>.pkg beside it — or lists every reason it refused. It judges what is actually on disk, so a stray Avalonia.dll sitting in the output is caught there rather than at the repository.

Check whether the tool already exists. Base64, URL encoding, escaping, case conversion, JWT, XPath, JsonPath, regex, timestamps, GUIDs and hashing are built in. A pack is for what the built-in set does not cover — see Test Utilities overview.

Install by hand while developing. Drop the .pkg in Studio's extensions folder under your AppData and restart. This skips the repository entirely and is the faster loop when you are iterating on a view.

A pack is not the only kind of extension. If the thing you are building is a test step, a protocol adapter or an API definition, that is a different extension type: see Automation Extensions, Service Extensions and API Extensions.

Reference​

What a pack may and may not carry​

The rule is enforced three times — when you build the package, when it is published to the repository, and when the host loads it — because a developer can bypass either of the first two.

A pack must not contain a binary named:

RootWhy
Avalonia.*the host already loads it; a second copy breaks the control silently
FluentAvalonia.*same
SkiaSharp.*, HarfBuzzSharp.*rendering natives, including libSkiaSharp.so and libHarfBuzzSharp.dylib
Aethrix.*the product's own assemblies, unless the file is the declaring package's own assembly

The last row is why a first-party pack named Aethrix.Toolkit.Hashes may ship Aethrix.Toolkit.Hashes.dll while nobody may ship Aethrix.Core.dll: the allowance is about ownership, not trust.

Native libraries are caught wherever they sit, including under runtimes/<rid>/native/.

ui — the framework versions you were built against​

"ui": { "avalonia": "[12.1.2,13.0.0)", "fluentAvalonia": "[3.1.0,4.0.0)" }

One NuGet-style interval per framework: [min,max), [min,] or [,max). The lower bound is inclusive; an upper bound closed with ) is exclusive. A bare major is accepted, so [12.1.2,13) is valid.

The host compares this against the assemblies it actually has loaded. If the range is not satisfied the pack is refused with the versions named in the message, rather than loading and rendering nothing. A malformed interval is treated as a violation, so a typo cannot quietly disable the check.

MinFrameworkVersion / MaxFrameworkVersion remain what gates the Aethrix framework itself; ui is only for the UI stack.

PackageSource​

How the package came to exist: 0 generated by the API import wizard, 1 built in the Extension Designer, 2 hand-authored — which is what a toolkit pack is.

Declare 2. Omitting the field is not neutral: the property starts at 0, so a manifest that says nothing reports itself as a package the import wizard generated — and 0 is the value Studio skips when it asks the repository whether a newer version exists. A Toolkit pack is asked about whatever its manifest claims, because no spec import can produce one, so for a pack the field records where the package came from rather than switching anything: it is what aeth list-extensions prints as the source.

tools[] — one entry per contributed tool​

FieldRequiredMeaning
idyesCatalogue id. The host prefixes it with the package id, so two packs may both call theirs hash.
viewyesAssembly-qualified type name. Must derive from Avalonia.Controls.Control and have a public parameterless constructor.
nameyesDefault (English) title shown on the card.
categorynoOne of the closed set below, matched case-insensitively. Absent or unknown lands in more.
iconnoA host Icon_* resource key — a name, never a codepoint.
descriptionnoDefault (English) one to three lines under the title.
localesnoPer-locale overrides, keyed by BCP-47 tag.

id, view and name are the three the repository refuses a package over; the rest degrade to a sensible default rather than a rejection.

A pack that declares no tools is refused: it could contribute nothing.

Categories​

The set is closed and owned by the host, which is what keeps the number of headings bounded however many packs are installed. An unknown value lands in more rather than opening a new section.

codec · identity · query · testdesign · timeid · runtime · more

A category opens its own heading only once it holds two tools, so a pack that contributes the only tool in a category lands under Other — the category is still what the card carries and what search matches, and a second tool in it opens the heading.

Icons​

The host owns its icon font, and which font a codepoint belongs to is host-private knowledge — a pack that hard-codes a codepoint renders tofu. So a pack names a resource key and the host draws it.

A key is Icon_ followed by what the glyph means, in PascalCase: Icon_Key, Icon_Clock, Icon_Regex. The complete set is every x:String x:Key="Icon_…" in the host's IconResources.axaml — 152 keys, all of which were checked against both shipped font faces, so any of them draws. The shortlist below is the part of that set that reads well as a card glyph in each category.

CategoryKeys that suit a card
codecIcon_Swap · Icon_Link · Icon_TextSize · Icon_Code · Icon_Xml · Icon_Html · Icon_Base64 · Icon_Raw
identityIcon_Key · Icon_Lock · Icon_Password · Icon_Eye · Icon_Security · Icon_Verified · Icon_JWT · Icon_Login
queryIcon_Search · Icon_Regex · Icon_Data · Icon_DataObject · Icon_List · Icon_TableChart · Icon_Diff · Icon_Insights
testdesignIcon_Testing · Icon_DesignServices · Icon_Diversity · Icon_Variable · Icon_Queue · Icon_Build
timeidIcon_Clock · Icon_Label · Icon_Replay · Icon_Restore
runtimeIcon_Terminal · Icon_Report · Icon_ResponsePanel · Icon_Message · Icon_Play · Icon_Stop · Icon_Dashboard
moreIcon_Tools · Icon_Extension · Icon_Calculate · Icon_Settings · Icon_Article · Icon_AI

Pick by meaning rather than by novelty, and prefer the glyph the host itself puts on a comparable tool: Base64, URL, case, XML, JSON, timestamp and GUID each draw what Icon_Swap, Icon_Link, Icon_TextSize, Icon_Code, Icon_DataObject, Icon_Clock and Icon_Label name. Avoid borrowing a key that already labels a Studio area (Icon_Api, Icon_ServiceSimulation, Icon_TestAutomation) unless the tool belongs there.

Note that some keys share one glyph — Icon_Diff is Icon_Compare, Icon_Edit is Icon_Pencil is Icon_DiffEdit, and Icon_Api, Icon_ManualTest, Icon_ApiService and Icon_Playground are all the same picture — so two differently named tools can still look identical side by side.

An unknown key falls back to Icon_Tools and logs a warning; the tool still appears.

Localising the card​

name and description are the defaults. locales overrides them per tag, using exactly the tags Studio's language list produces:

ar · de · es · fr · it · ja · ko · pt · ru · zh-Hans · zh-Hant

"locales": {
"fr": { "name": "Ma clé", "description": "Ce que ça fait." },
"zh-Hans": { "name": "我的工具" }
}

A tag that is present but omits one field falls back to the default for that field alone, so zh-Hans above shows a Chinese title over an English description.

Translate the phrase, not the terminology. Use what developers and testers who write that language actually write, and where they keep a term in its English form, keep it. endpoint, token, claim, hash, HMAC, X.509, PEM, JWK are not one decision but one per language: Japanese and Korean transliterate them (エンドポイント, 클레임), Russian has settled native forms (токен, конечная точка), German developers write Endpoint rather than the vendor translation Endpunkt, and in some languages the term simply stays in Latin script. A word-for-word translation that no practitioner uses reads worse than the English term it replaced. Algorithm and format names never change.

Note the spelling: these are BCP-47 tags (zh-Hans), not the _zhHans file-name suffix the in-assembly convention uses.

Localising strings inside your view​

Text your own UI shows — button labels, field headings — uses the same convention the host's own views use:

public class MyResources
{
public static MyResources Instance { get; set; } = new();

public static string RunButton => Instance.RunButtonLocal;
protected virtual string RunButtonLocal => "Run";
}

public class MyResources_fr : MyResources
{
protected override string RunButtonLocal => "Exécuter";
}

The rules the loader depends on: no underscore in the base class name, Instance public static and settable, locale classes named <BaseName>_<suffix>, and property names matching the base exactly. The host applies the current language to every Toolkit assembly after loading it.

Do not use satellite .resx files. They load as separate assemblies; in an isolated load context a missing satellite is not an error — the resource manager quietly returns the invariant values, so a French user sees English with nothing to debug.

Changing the display language requires a Studio restart.

Styling​

Design tokens and style classes reach your control for free, because they are merged at application scope. Use them by class name:

WantWrite
a text field at the standard tool height<TextBox Classes="row" />
a chromeless field in a tableClasses="flat"
body copy, prose, small data, captionClasses="body" / prose / data / caption
emphasis, muted, monospaceClasses="strong" / muted / mono
a headingClasses="h2" … h6
a card, panel, dividerClasses="card" / panel / divider
the primary buttonClasses="accent"; a toolbar button Classes="flat"
an icon<ui:FAFontIcon Glyph="{DynamicResource Icon_Copy}" />

Never set a literal FontSize, FontFamily, stroke colour or pixel height. Studio has an Interface Scale setting that moves text and control heights together, and a hard-coded value is the one thing that will not move with it.

Resolve icon keys with DynamicResource, not StaticResource — the dictionary lives in the host, above your control.

What you get when it goes wrong​

Nothing about a failed pack is silent, because a card that simply does not appear is indistinguishable from a pack that was never installed.

SituationWhat happens
Carries a forbidden binaryRefused at pack time, at publish, and at load. The package shows in the Extensions panel with an error badge naming the files.
ui range not satisfiedRefused at load, message names the host version and the declared range.
view type not found, not a control, or no parameterless constructorThat tool is not listed and the package carries an error naming it; the rest of the pack still loads.
Throws while openingStudio stays up, shows the error naming the tool, and records it as disabled so it is not loaded again on next start — that tool only, not its siblings in the same pack.
Throws while in useStudio absorbs it, closes the tool and names it in a notification; the session carries on. The tool is switched off for the next start once it has faulted three times in one session, and after that the host stops absorbing it.
Throws on a background thread nobody awaitsLost. .NET discards an unobserved task exception before either the host or the log can see it, so await your work or catch it yourself.
Unknown icon keyFalls back to Icon_Tools and logs a warning.
Pack deleted or uninstalledThe card stays until restart — deletion is deferred to shutdown, like every other extension type.

A tool the host switched off is not gone: open the pack's card on the Extensions page and its Tools included section lists it as switched off with an Enable again action. The card comes back in Test Utilities without a restart.

What a pack cannot reach​

A pack references no Aethrix assembly, and that is also its capability boundary:

  • It cannot read the current project, environment, or a signed-in token. A tool that needs workspace state is not a pack.
  • It is invisible to the AI assistant, which can only describe views it can reach through host types.
  • It cannot be invoked from a test step, the CLI, or a report.

Good fits: a self-contained converter or inspector with its own UI; a format the base class library cannot reach; a regional or vendor-specific algorithm with heavy or licensed dependencies; a tool for one team's workflow.