Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Windows. Test your ISO in a VM before installing it.**
## More information

[Usage, compatibility and build instructions](docs/usage.md) ·
[Interface translations](docs/translations.md) ·
[Attribution and distribution notes](NOTICE.md)

The original PowerShell builders remain in this repository.
Expand Down
36 changes: 36 additions & 0 deletions docs/translations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Interface translations

The WinUI app resolves its interface text through `Strings\<language>\Resources.resw`.
English is the source language; every other language must contain the same keys.

| Language | Tag | File |
| --- | --- | --- |
| English | `en-US` | `src/Tiny11Builder.App/Strings/en-US/Resources.resw` |
| Traditional Chinese | `zh-Hant` | `src/Tiny11Builder.App/Strings/zh-Hant/Resources.resw` |
| Simplified Chinese | `zh-Hans` | `src/Tiny11Builder.App/Strings/zh-Hans/Resources.resw` |

Choose a language under **Language** in the sidebar. The choice is saved in
`%LOCALAPPDATA%\Tiny11Builder\settings.json` and applied after a restart, because
XAML resolves its text when a page is loaded.

## Add a language

1. Copy `Strings\en-US\Resources.resw` to `Strings\<tag>\Resources.resw`, using a
BCP-47 tag such as `ja-JP` or `de-DE`.
2. Translate every value. Keep the `name` attributes and the `{0}` placeholders
unchanged; a placeholder may move within a sentence.
3. Add the tag and its native name to `LanguageCatalog.All` in
`src/Tiny11Builder.App/LanguageCatalog.cs`.
4. Run `dotnet test tests/Tiny11Builder.Core.Tests`. `LocalizationTests` checks
that every language has exactly the English keys, no empty values, and the
same placeholders, and that every `{loc:Tr Key=…}` used by XAML exists.

Keys are flat and use `__` between words (`Build__Result__Saved`). A key must not
be a prefix of another key: MRT treats dots as resource-map separators, so flat
keys keep a resource from also becoming a scope.

## What stays English

Build-engine operation names, stage status words, the raw build recipe and the
build log are technical diagnostics. They are not translated by these files; the
interactive interface, removal catalog and user-facing notices are.
11 changes: 11 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,17 @@ as an unsupported operation. Removing a service deletes its offline registry
registration; shared `svchost.exe` and other shared binaries are never blindly
deleted. Test aggressive images in an isolated VM and keep known-good media.

## Interface languages

The app ships with English, Traditional Chinese (`zh-Hant`) and Simplified
Chinese (`zh-Hans`). Choose a language under **Language** in the sidebar; the
choice is saved and applied after a restart. The interface, removal catalog and
user-facing notices are translated. Build-engine operation names, stage status
words, the raw recipe and the build log remain English technical text.

See [translations.md](translations.md) for the resource layout and the steps to
add another language.

## Build and run

Requirements: Windows 10 2004 or newer as the host, .NET 8 SDK and a Windows SDK
Expand Down
13 changes: 12 additions & 1 deletion src/Tiny11Builder.App/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
using Microsoft.UI.Xaml;
using Microsoft.Windows.Globalization;

namespace Tiny11Builder.App;

public partial class App : Application
{
private Window? window;
public App() => InitializeComponent();

public App()
{
// The override must be in place before any XAML is loaded: markup-extension text is resolved
// when a page is parsed, not when the window is shown.
var language = LanguageCatalog.Normalize(AppSettings.Load().Language);
if (language.Length > 0) ApplicationLanguages.PrimaryLanguageOverride = language;
InitializeComponent();
ResourceProvider.Install();
}

protected override void OnLaunched(LaunchActivatedEventArgs args)
{
window = new MainWindow();
Expand Down
30 changes: 30 additions & 0 deletions src/Tiny11Builder.App/AppSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Text.Json;

namespace Tiny11Builder.App;

// The only user setting the app owns. It lives beside the downloaded tools, never in the build
// workspace, so a recovered or discarded job cannot change the interface language.
internal sealed class AppSettings
{
private static readonly string FilePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Tiny11Builder", "settings.json");

public string Language { get; set; } = "";

public static AppSettings Load()
{
try { return JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(FilePath)) ?? new(); }
catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException) { return new(); }
}

public bool TrySave()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this));
return true;
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException) { return false; }
}
}
23 changes: 15 additions & 8 deletions src/Tiny11Builder.App/Choices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,16 @@ public sealed class OptionChoice(CleanupOption option) : ObservableChoice
{
private bool selected = option.Balanced;
public CleanupOption Option { get; } = option;
public string Title => Option.Title;
public string Description => Option.Description;
public string Category => Option.Category;
public string Title => Loc.Get($"Catalog__{Option.Id}__Title");
public string Description => Loc.Get($"Catalog__{Option.Id}__Description");
public string Category => Option.Category switch
{
"Bundled apps" => Loc.Get("Catalog__Category__Apps"),
"Optional features" => Loc.Get("Catalog__Category__Features"),
"Windows settings" => Loc.Get("Catalog__Category__Settings"),
"Advanced / original builder" => Loc.Get("Catalog__Category__Advanced"),
_ => Option.Category
};
public bool Selected { get => selected; set => Set(ref selected, value); }
}

Expand All @@ -37,10 +44,10 @@ public sealed class InventoryChoice(ImageInventoryItem item) : ObservableChoice
public ImageInventoryItem Item { get; } = item;
public string Title => Item.Identity;
public string Detail => $"{Item.Kind} · {Item.Detail}";
public string Risk => Item.Protected ? "Protected / potentially boot-critical" : "Application";
public string ModeLabel => $"Action for {Title}";
public string Risk => Loc.Get(Item.Protected ? "Components__Inventory__Protected" : "Components__Inventory__Application");
public string ModeLabel => Loc.Format("Components__Inventory__ActionFor", Title);
public bool HasModes => Item.Kind is ImageItemKind.Service or ImageItemKind.Feature;
public string[] Modes { get; } = ["Disable", "Remove"];
public string[] Modes { get; } = [Loc.Get("Components__Inventory__Disable"), Loc.Get("Components__Inventory__Remove")];
public bool Selected { get => selected; set => Set(ref selected, value); }
public int ModeIndex { get => modeIndex; set => Set(ref modeIndex, value); }
public ExpertAction Action => new(Item.Kind, Item.Identity, ModeIndex == 0 ? ExpertActionMode.Disable : ExpertActionMode.Remove);
Expand All @@ -52,8 +59,8 @@ public sealed class BuildStageBoard
{
public BuildStageChoice[] Stages { get; } =
[
new("Prepare media", "\uE8B7"), new("Export and mount", "\uE7C5"), new("Remove components", "\uE74D"),
new("Commit image", "\uE74E"), new("Compress image", "\uE8B9"), new("Create ISO", "\uE958"), new("Verify output", "\uE73E")
new(Loc.Get("Build__Board__Prepare"), "\uE8B7"), new(Loc.Get("Build__Board__Export"), "\uE7C5"), new(Loc.Get("Build__Board__Remove"), "\uE74D"),
new(Loc.Get("Build__Board__Commit"), "\uE74E"), new(Loc.Get("Build__Board__Compress"), "\uE8B9"), new(Loc.Get("Build__Board__Iso"), "\uE958"), new(Loc.Get("Build__Board__Verify"), "\uE73E")
];

// Returns the phase the event belongs to, or -1 when it is not a displayed operation.
Expand Down
39 changes: 39 additions & 0 deletions src/Tiny11Builder.App/LanguageCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace Tiny11Builder.App;

internal sealed record AppLanguage(string Tag, string NativeName)
{
// The system choice has no native name of its own; it is the one label that follows the UI language.
public string DisplayName => Tag.Length == 0 ? Loc.Get("Language__System") : NativeName;
}

internal static class LanguageCatalog
{
// Tags match the Strings\<tag>\Resources.resw folders. English is the fallback and must stay first.
public static IReadOnlyList<AppLanguage> All { get; } =
[
new("", ""),
new("en-US", "English"),
new("zh-Hant", "繁體中文"),
new("zh-Hans", "简体中文"),
];

public static string Normalize(string? tag)
{
if (string.IsNullOrWhiteSpace(tag)) return "";
var exact = All.FirstOrDefault(language => language.Tag.Equals(tag, StringComparison.OrdinalIgnoreCase));
if (exact is not null) return exact.Tag;
return tag.ToLowerInvariant() switch
{
"zh-tw" or "zh-hk" or "zh-mo" or "zh-hant" => "zh-Hant",
"zh-cn" or "zh-sg" or "zh-hans" => "zh-Hans",
"en" => "en-US",
_ => ""
};
}

public static AppLanguage For(string? tag)
{
var normalized = Normalize(tag);
return All.First(language => language.Tag == normalized);
}
}
19 changes: 19 additions & 0 deletions src/Tiny11Builder.App/Localization.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Globalization;

namespace Tiny11Builder.App;

// UI text is resolved through this one seam. The app installs the Windows resource lookup at startup;
// the tests install the English resources, so the same code runs in both and no English copy is
// duplicated in C#.
internal static class Loc
{
private static Func<string, string?>? lookup;

public static void Use(Func<string, string?> resolver) => lookup = resolver;

public static string Get(string key) =>
lookup?.Invoke(key) is { Length: > 0 } text ? text : key;

public static string Format(string key, params object?[] values) =>
string.Format(CultureInfo.CurrentCulture, Get(key), values);
}
Loading