This is the contract for localizing Reframe's UI. Wave 1 (infrastructure + MainWindow +
SettingsPage) is done. Later waves localize the remaining pages (DashboardPage,
ProfilesPage / ProfileEditorPage, LayoutsPage / LayoutEditorPage) by following this doc
exactly. New contributors adding strings or languages follow it too.
The technical approach is standard resw + MRT Core + x:Uid + PrimaryLanguageOverride, and it
is verified working in this unpackaged WinAppSDK 1.8 app (three-state screenshot test, see end).
| Concern | Decision |
|---|---|
| Resource format | .resw (ResX) under Strings\<bcp-47>\, one file per page + Common.resw |
| XAML strings | x:Uid="/<File>/<Key>" subtree syntax (no code) |
| Code strings | Services/Loc.cs → Loc.T("<File>/<Key>"), Loc.T("<File>/<Key>", args…) |
| Runtime language | Microsoft.Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride, set once at App startup before any XAML loads |
| User setting | AppConfig.Language = "system" (default) | "zh-CN" | "en-US" |
| Switching language | Write Config.Language, then restart the app (WinUI does not hot-swap x:Uid reliably) |
| Languages today | zh-CN (default fallback authored first), en-US |
MRT Core resolves resources against the runtime context. In unpackaged apps that context uses
the system display language, not the user's preferred-language list (this differs from
packaged apps). PrimaryLanguageOverride (the WinAppSDK Microsoft.Windows.Globalization one — the
UWP Windows.Globalization namesake throws when called unpackaged) overrides that choice. We set it
at the very top of the App constructor, before InitializeComponent(), so the whole UI — including
x:Uid resolution — comes up in the chosen language.
The PRI (Reframe.pri, named after the assembly, primary="true") is generated automatically by the
WinUI/MSIX MSBuild targets from Strings\**\*.resw and copied next to the exe. The unpackaged runtime
auto-locates it. No manual makepri step and no <PRIResource> item are needed — the .NET SDK's
EnableDefaultPriItems already globs **\*.resw into PRIResource (adding an explicit item causes
NETSDK1022 duplicate PRIResource). Convention over configuration: drop a .resw in the right folder
and it ships.
Strings/
zh-CN/ <- BCP-47 tag = folder name; zh-CN is the default fallback language
Common.resw <- shared, page-agnostic words (OK / Cancel / Save / Delete / On / Off …)
MainWindow.resw <- one file per page/window; file base name == x:Uid subtree name
SettingsPage.resw
en-US/
Common.resw <- EVERY language folder has the SAME set of files & the SAME keys
MainWindow.resw
SettingsPage.resw
Rules:
- One
.reswper page/window. Name it after the page class without namespace (DashboardPage.resw,ProfilesPage.resw,ProfileEditorPage.resw,LayoutsPage.resw,LayoutEditorPage.resw). Truly shared words go inCommon.resw, nothing else. - Key parity across languages is mandatory. When you add a key to one language's file, add it to
all language files. A missing key falls back to the default language (
zh-CN); foren-USparity is your job, not the runtime's. - Keys are stable identifiers, not English text. Renaming a key is a retranslation event — don't.
- The big
<xsd:schema>block at the top of every.reswis the fixed ResX preamble. Copy it verbatim from an existing file; only the<data>entries differ.
- Static control text used from XAML → property identifier:
<data name="Title.Text">,<data name="SaveButton.Content">,<data name="NameBox.Header">,<data name="NameBox.PlaceholderText">. The suffix is the XAML property the value sets. - Pure string used only from code → simple identifier:
<data name="HotkeySaved">,<data name="VersionFormat">. - Composite-format strings take
{0},{1}… placeholders and are formatted byLoc.T(key, args…).
For any .resw other than the default Resources.resw (we don't use a default file — every file
is named), scope the Uid with the file: x:Uid="/<File>/<Key>".
<!-- Sets .Content from SettingsPage.resw key "RestoreAllButton.Content" -->
<Button x:Uid="/SettingsPage/RestoreAllButton" Click="RestoreAllButton_Click"/>
<!-- TextBlock.Text from "Title.Text"; ComboBoxItem.Content from "ThemeLight.Content" -->
<TextBlock x:Uid="/SettingsPage/Title" FontSize="28"/>
<ComboBoxItem x:Uid="/SettingsPage/ThemeLight"/>
<!-- Nav item .Content from MainWindow.resw "NavDashboard.Content" -->
<NavigationViewItem x:Uid="/MainWindow/NavDashboard" Tag="dashboard"/>The leading / + file segment is required because our files aren't the default Resources.resw.
Remove any literal Content="…"/Text="…" you replace — the resw value is what ships; leaving a
literal is just confusing.
Inline <Run> inside a <TextBlock> can carry its own x:Uid (we do this for multi-run paragraphs;
see SettingsPage.xaml SteamGridDB block). URLs / brand names in a <Hyperlink> stay literal.
Attached-property identifiers in resw (e.g.
Greeting.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name) are supported on paper but
brittle in MRT Core and easy to get subtly wrong. For these, set the value from code-behind via
Loc.T. Wave 1 does this for:
- the title-bar nav button's
AutomationProperties.Name+ToolTipService.ToolTip(MainWindow.xaml.cs→InitLocalizedAccessibility) ToggleSwitch.OnContent/OffContent(SettingsPage.xaml.cs→OnLoaded, usingCommon/On|Off)
Plain control properties (.Text, .Content, .Header, .PlaceholderText) via x:Uid are
reliable — prefer them.
Loc is a thin, cached wrapper over Microsoft.Windows.ApplicationModel.Resources.ResourceLoader.
Use it for strings built at runtime (status lines, dialogs, formatted text). Prefer x:Uid for
anything static in XAML.
using Reframe.Services;
string saved = Loc.T("SettingsPage/HotkeySaved"); // simple lookup
string version = Loc.T("SettingsPage/VersionFormat", "1.1.0.0"); // string.Format with {0}
string ok = Loc.T("Common/Ok"); // shared wordContract:
- Key shape is
"File/Key": the part before the first/is the.reswbase name; the rest is the resource key.Loc.Tsplits on the first/. - On any failure (missing key, no PRI, resource system down)
Loc.Treturns the input id unchanged so a bug shows up as a visibleFile/Keymarker instead of an empty control or a crash. Loc.T(key, args…)runs the resolved value throughstring.Format(CurrentCulture, …); if the placeholder count is wrong it returns the unformatted value rather than throwing.- Internally it uses the two-arg
ResourceLoader(GetDefaultResourceFilePath(), file)constructor. Do not usenew ResourceLoader("File")— the single-arg overload takes a .pri path, not a map name, and will silently resolve nothing.
Loc lives in Services, never in Core (see §6).
AppConfig.Language(Core/Models.cs):"system"(default) /"zh-CN"/"en-US". Core declares the field but does not consume it — only the UI layer (App,SettingsPage) reads/writes it.- Startup (
App.xaml.cs→ApplyLanguageOverride, called first thing in the constructor beforeInitializeComponent()): readsConfig.LanguageviaConfigStore.TryLoad()(pure read, no side effects, no default-write on first run). If it's a real language tag, setsApplicationLanguages.PrimaryLanguageOverride."system"or unreadable → leave unset (follow system). All exceptions are swallowed — a localization failure must never block startup. - Switching (
SettingsPagelanguageComboBox): writesConfig.Language,Save(), then shows aContentDialog("restart to apply") with a Restart now button. Restart =Process.Start(self, UseShellExecute=true)thenApp.RequestExit()(the normal tray-exit chain, which restores windows, clears the tray, releases the single-instance mutex, andApplication.Exit()s). Because the app isrequireAdministrator, the self-relaunch triggers one UAC prompt — expected, and the button label says so. The languageComboBoxitems show each language's endonym (简体中文,English) and are not themselves localized.
Do not attempt live x:Uid re-resolution on language change — WinUI is unreliable there (it can take
two restarts to settle). The restart path is the supported, reliable approach.
Core/ must never reference MRT / ResourceLoader / x:Uid. Core is the engine; the test
project links Core's source files directly (Tests/Reframe.Core.Tests.csproj) and has no WinAppSDK
runtime. Pulling MRT into Core would break the tests and the layering.
Consequently:
- Engine/diagnostic log strings stay hard-coded English literals in
Core/. This is the standard open-source split: the engine logs in English; only the UI is localized. (Wave 1 left Core's existing Chinese log strings alone; the Wave-2 Core agent converts them to English literals.) - UI labels that originate from Core (e.g.
HotkeyService.ActionInfo.DisplayName) are localized at the UI boundary, not in Core:SettingsPagelooks upSettingsPage/Hotkey_<ActionId>and falls back to the CoreDisplayNameif the key is missing (SettingsPage.xaml.cs→HotkeyLabel). This keeps Core MRT-free while the rows still localize.
Services/Loc.cs is in Services, which the test project does not link — so Loc never reaches the
tests.
- Create
Strings\zh-CN\<Page>.reswandStrings\en-US\<Page>.resw(copy the schema preamble from an existing file). Keep the same keys in both. - In
<Page>.xaml, replace every literalText=/Content=/Header=/PlaceholderText=withx:Uid="/<Page>/<Key>"and a<data name="<Key>.<Prop>">entry in both.resw. - In
<Page>.xaml.cs, replace every hard-coded UI string withLoc.T("<Page>/<Key>")(orCommon/<Key>for shared words). Use{0}placeholders +Loc.T(key, args…)for composed text. - For attached props (AutomationProperties / ToolTipService) and
ToggleSwitchOn/OffContent, set them in code viaLoc.T, notx:Uid. - Strings that come from
Coretypes: look them up at the UI boundary with aHotkey_*-style keyed resource + fallback to the Core value. Never add MRT toCore. - Build
dotnet build -p:Platform=x64. Sanity-check the PRI if unsure:makepri dump /if <out>\Reframe.pri /of dump.xml /oand confirm your subtree + bothLanguage-ZH-CN/Language-EN-USqualifiers are present. - Verify visually in all three language states (see §9).
- Create
Strings\ja-JP\and copy every.reswfromStrings\en-US\into it. - Translate the
<value>of each<data>(leavenamekeys and<comment>untouched). Keep the{0}/{1}placeholders intact and in a natural order for the language. - Add the option to the
SettingsPagelanguageComboBox:- add a
<ComboBoxItem Content="日本語"/>(endonym, not localized) inSettingsPage.xaml; - extend
_languageByIndexinSettingsPage.xaml.cswith"ja-JP"at the matching index.
- add a
- No csproj or
makeprichange needed — the default glob picks the new folder up. - Build + 3-state-verify (now 4 states).
That's it: PrimaryLanguageOverride = "ja-JP" at startup will select the new resources.
Use an isolated instance (never the production app, never touching real windows):
%TEMP% copy of the build output, asInvoker manifest, a temp single-instance mutex name, an
isolated config.json with "EngineEnabled": false. Set "Language" to each of "en-US",
"zh-CN", "system", launch, screenshot the nav rail + Settings page, confirm the language. Restore
everything afterward; the real tree must still dotnet build -p:Platform=x64 0-error and
dotnet test green. Do not commit.
Wave-1 result: PrimaryLanguageOverride set at App-construction time drives x:Uid and Loc.T
correctly in the unpackaged build for all three states. Conclusion: the resw + MRT Core + x:Uid +
PrimaryLanguageOverride stack is the right approach here; no JSON fallback needed.