diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props
index b97095865..4d76f7123 100644
--- a/GenHub/Directory.Packages.props
+++ b/GenHub/Directory.Packages.props
@@ -13,6 +13,7 @@
+
diff --git a/GenHub/GenHub.Core/Constants/LocalizationConstants.cs b/GenHub/GenHub.Core/Constants/LocalizationConstants.cs
new file mode 100644
index 000000000..58b9b9530
--- /dev/null
+++ b/GenHub/GenHub.Core/Constants/LocalizationConstants.cs
@@ -0,0 +1,32 @@
+namespace GenHub.Core.Constants;
+
+///
+/// Constants used by the application localization infrastructure.
+///
+public static class LocalizationConstants
+{
+ ///
+ /// The neutral culture embedded in the main application assembly.
+ ///
+ public const string DefaultCultureName = "en";
+
+ ///
+ /// The property name used to notify bindings that all indexer values changed.
+ ///
+ public const string IndexerPropertyName = "Item";
+
+ ///
+ /// The application resource key used to expose the localization service to XAML.
+ ///
+ public const string ResourceServiceKey = "LocalizationService";
+
+ ///
+ /// The fully qualified base name of the application's string resources.
+ ///
+ public const string StringResourceBaseName = "GenHub.Resources.Localization.Strings";
+
+ ///
+ /// The suffix used by .NET satellite resource assemblies.
+ ///
+ public const string SatelliteAssemblySuffix = ".resources.dll";
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Common/ILocalizationService.cs b/GenHub/GenHub.Core/Interfaces/Common/ILocalizationService.cs
new file mode 100644
index 000000000..aedc00a6f
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Common/ILocalizationService.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Globalization;
+using GenHub.Core.Models.Results;
+
+namespace GenHub.Core.Interfaces.Common;
+
+///
+/// Provides localized application strings and runtime culture switching.
+///
+public interface ILocalizationService : INotifyPropertyChanged
+{
+ ///
+ /// Gets the cultures backed by the neutral resource or a deployed satellite assembly.
+ ///
+ IReadOnlyList AvailableCultures { get; }
+
+ ///
+ /// Gets the culture used for resource lookup and formatting performed by .
+ ///
+ CultureInfo CurrentCulture { get; }
+
+ ///
+ /// Gets a localized string by resource key.
+ ///
+ /// The resource key to resolve.
+ /// The localized value, its English fallback, or the key when no resource exists.
+ string this[string key] { get; }
+
+ ///
+ /// Gets and optionally formats a localized string.
+ ///
+ /// The resource key to resolve.
+ /// Optional format arguments.
+ /// The localized value, its English fallback, or the key when no resource exists.
+ string GetString(string key, params object?[] arguments);
+
+ ///
+ /// Changes the active culture when it has a deployed translation.
+ ///
+ /// The culture to activate.
+ /// A result indicating whether the requested culture was available and applied.
+ OperationResult SetCulture(CultureInfo culture);
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs
index 76bb26337..0e3d4988b 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/App/AppLifecycleTests.cs
@@ -1,3 +1,7 @@
+using Avalonia.Data;
+using Avalonia.Headless.XUnit;
+using GenHub.Common.Markup;
+using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Interfaces.GameProfiles;
using Microsoft.Extensions.DependencyInjection;
@@ -48,10 +52,12 @@ public void App_Constructor_WithValidServices_DoesNotThrow()
var services = new ServiceCollection();
var mockUserSettingsService = new Mock();
var mockConfigurationProvider = new Mock();
+ var mockLocalizationService = new Mock();
var mockProfileLauncherFacade = new Mock();
services.AddSingleton(typeof(IUserSettingsService), mockUserSettingsService.Object);
services.AddSingleton(typeof(IConfigurationProviderService), mockConfigurationProvider.Object);
+ services.AddSingleton(typeof(ILocalizationService), mockLocalizationService.Object);
services.AddSingleton(typeof(IProfileLauncherFacade), mockProfileLauncherFacade.Object);
var serviceProvider = services.BuildServiceProvider();
@@ -61,4 +67,20 @@ public void App_Constructor_WithValidServices_DoesNotThrow()
var app = Activator.CreateInstance(appType, serviceProvider);
Assert.NotNull(app);
}
+
+ ///
+ /// Verifies that application XAML loading exposes localization to markup extensions afterward.
+ ///
+ [AvaloniaFact]
+ public void App_Initialize_ExposesLocalizationServiceToMarkupExtensions()
+ {
+ var app = Assert.IsType(Avalonia.Application.Current);
+ Assert.Same(
+ TestAppBuilder.LocalizationService,
+ app.Resources[LocalizationConstants.ResourceServiceKey]);
+
+ var extension = new LocalizeExtension("App.Name");
+ var binding = Assert.IsType(extension.ProvideValue(Mock.Of()));
+ Assert.Same(TestAppBuilder.LocalizationService, binding.Source);
+ }
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/App/TestAppBuilder.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/App/TestAppBuilder.cs
new file mode 100644
index 000000000..5cd42a06d
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/App/TestAppBuilder.cs
@@ -0,0 +1,44 @@
+using Avalonia;
+using Avalonia.Headless;
+using Avalonia.Headless.XUnit;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Interfaces.GameProfiles;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+
+[assembly: AvaloniaTestApplication(typeof(GenHub.Tests.Core.App.TestAppBuilder))]
+
+namespace GenHub.Tests.Core.App;
+
+///
+/// Configures the GenHub application for cross-platform headless lifecycle tests.
+///
+internal static class TestAppBuilder
+{
+ private static readonly Mock LocalizationServiceMock = new();
+ private static readonly IServiceProvider ServiceProvider = CreateServiceProvider();
+
+ ///
+ /// Gets the localization service registered in the headless application.
+ ///
+ internal static ILocalizationService LocalizationService => LocalizationServiceMock.Object;
+
+ ///
+ /// Creates the Avalonia application builder used by headless tests.
+ ///
+ /// The configured application builder.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure(() => new global::GenHub.App(ServiceProvider))
+ .UseHeadless(new AvaloniaHeadlessPlatformOptions());
+
+ private static IServiceProvider CreateServiceProvider()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(Mock.Of());
+ services.AddSingleton(Mock.Of());
+ services.AddSingleton(LocalizationService);
+ services.AddSingleton(Mock.Of());
+
+ return services.BuildServiceProvider();
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Collections/LocalizationCultureCollection.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Collections/LocalizationCultureCollection.cs
new file mode 100644
index 000000000..f954de2a5
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Collections/LocalizationCultureCollection.cs
@@ -0,0 +1,13 @@
+namespace GenHub.Tests.Core.Collections;
+
+///
+/// Prevents culture-mutating localization tests from running beside unrelated tests.
+///
+[CollectionDefinition(Name, DisableParallelization = true)]
+public sealed class LocalizationCultureCollection
+{
+ ///
+ /// The collection name used by culture-mutating tests.
+ ///
+ public const string Name = "Localization culture";
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LocalizationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LocalizationServiceTests.cs
new file mode 100644
index 000000000..189af6419
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LocalizationServiceTests.cs
@@ -0,0 +1,309 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Resources;
+using Avalonia;
+using GenHub.Common.Markup;
+using GenHub.Common.Services;
+using GenHub.Core.Constants;
+using GenHub.Tests.Core.Collections;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace GenHub.Tests.Core.Common.Services;
+
+///
+/// Unit tests for resource fallback, discovery, and runtime culture switching.
+///
+[Collection(LocalizationCultureCollection.Name)]
+public sealed class LocalizationServiceTests : IDisposable
+{
+ private sealed class BindingTarget : AvaloniaObject
+ {
+ internal static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value));
+
+ internal string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+ }
+
+ private sealed class CountingLogger : ILogger
+ {
+ internal int WarningCount { get; private set; }
+
+ public IDisposable? BeginScope(TState state)
+ where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception? exception,
+ Func formatter)
+ {
+ if (logLevel == LogLevel.Warning)
+ {
+ WarningCount++;
+ }
+ }
+ }
+
+ private const string TestResourceBaseName = "GenHub.Tests.Core.Resources.Localization.TestStrings";
+
+ private readonly CultureInfo? _originalDefaultCulture;
+ private readonly CultureInfo? _originalDefaultUiCulture;
+ private readonly CultureInfo _originalThreadCulture;
+ private readonly CultureInfo _originalThreadUiCulture;
+ private readonly LocalizationService _service;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public LocalizationServiceTests()
+ {
+ _originalDefaultCulture = CultureInfo.DefaultThreadCurrentCulture;
+ _originalDefaultUiCulture = CultureInfo.DefaultThreadCurrentUICulture;
+ _originalThreadCulture = CultureInfo.CurrentCulture;
+ _originalThreadUiCulture = CultureInfo.CurrentUICulture;
+
+ _service = CreateService(AppContext.BaseDirectory, NullLogger.Instance);
+ }
+
+ ///
+ /// Restores process-wide culture defaults changed by localization tests.
+ ///
+ public void Dispose()
+ {
+ CultureInfo.CurrentCulture = _originalThreadCulture;
+ CultureInfo.CurrentUICulture = _originalThreadUiCulture;
+ CultureInfo.DefaultThreadCurrentCulture = _originalDefaultCulture;
+ CultureInfo.DefaultThreadCurrentUICulture = _originalDefaultUiCulture;
+ }
+
+ ///
+ /// Verifies that the neutral language and deployed test satellite are discovered automatically.
+ ///
+ [Fact]
+ public void AvailableCultures_DiscoversNeutralAndSatelliteCultures()
+ {
+ var cultureNames = _service.AvailableCultures.Select(culture => culture.Name).ToList();
+
+ Assert.Equal(["en", "fr"], cultureNames);
+ }
+
+ ///
+ /// Verifies that a translated value is loaded from the active satellite assembly.
+ ///
+ [Fact]
+ public void GetString_UsesActiveCultureTranslation()
+ {
+ var result = _service.SetCulture(CultureInfo.GetCultureInfo("fr"));
+
+ Assert.True(result.Success);
+ Assert.Equal("Bonjour", _service.GetString("Greeting"));
+ }
+
+ ///
+ /// Verifies that missing translated values fall back to the neutral English resource.
+ ///
+ [Fact]
+ public void GetString_MissingTranslation_FallsBackToEnglish()
+ {
+ var result = _service.SetCulture(CultureInfo.GetCultureInfo("fr"));
+
+ Assert.True(result.Success);
+ Assert.Equal("English fallback", _service.GetString("FallbackOnly"));
+ }
+
+ ///
+ /// Verifies that formatted translations use the active culture and supplied arguments.
+ ///
+ [Fact]
+ public void GetString_WithArguments_FormatsTranslatedValue()
+ {
+ var result = _service.SetCulture(CultureInfo.GetCultureInfo("fr"));
+
+ Assert.True(result.Success);
+ Assert.Equal("Bonjour, General!", _service.GetString("FormattedGreeting", "General"));
+ }
+
+ ///
+ /// Verifies that null format arguments remain supported by the public contract.
+ ///
+ [Fact]
+ public void GetString_WithNullArgument_FormatsAsEmptyText()
+ {
+ Assert.Equal("Hello, !", _service.GetString("FormattedGreeting", (object?)null));
+ }
+
+ ///
+ /// Verifies that an invalid translated format string remains visible instead of throwing.
+ ///
+ [Fact]
+ public void GetString_InvalidFormatString_ReturnsUnformattedValue()
+ {
+ Assert.Equal("Hello, {0", _service.GetString("MalformedGreeting", "General"));
+ }
+
+ ///
+ /// Verifies that the indexer delegates to resource lookup.
+ ///
+ [Fact]
+ public void Indexer_KnownKey_ReturnsLocalizedValue()
+ {
+ Assert.Equal("Hello", _service["Greeting"]);
+ }
+
+ ///
+ /// Verifies that invalid lookup and culture arguments fail at the contract boundary.
+ ///
+ [Fact]
+ public void PublicMethods_InvalidArguments_ThrowArgumentExceptions()
+ {
+ Assert.Throws(() => _service.GetString(" "));
+ Assert.Throws(() => _service.GetString("Greeting", (object?[])null!));
+ Assert.Throws(() => _service.SetCulture(null!));
+ }
+
+ ///
+ /// Verifies that a completely unknown key remains visible for diagnostics.
+ ///
+ [Fact]
+ public void GetString_UnknownKey_ReturnsKey()
+ {
+ Assert.Equal("Missing.Resource.Key", _service.GetString("Missing.Resource.Key"));
+ }
+
+ ///
+ /// Verifies that repeated binding evaluations do not flood logs with the same missing key.
+ ///
+ [Fact]
+ public void GetString_RepeatedMissingKey_LogsOncePerCulture()
+ {
+ var logger = new CountingLogger();
+ var service = CreateService(AppContext.BaseDirectory, logger);
+
+ service.GetString("Missing.Resource.Key");
+ service.GetString("Missing.Resource.Key");
+
+ Assert.Equal(1, logger.WarningCount);
+
+ var result = service.SetCulture(CultureInfo.GetCultureInfo("fr"));
+ service.GetString("Missing.Resource.Key");
+
+ Assert.True(result.Success);
+ Assert.Equal(2, logger.WarningCount);
+ }
+
+ ///
+ /// Verifies that changing culture refreshes both the culture and all indexer bindings.
+ ///
+ [Fact]
+ public void SetCulture_AvailableCulture_RaisesLiveBindingNotifications()
+ {
+ var propertyNames = new List();
+ var formattingCulture = CultureInfo.CurrentCulture;
+ _service.PropertyChanged += (_, eventArgs) => propertyNames.Add(eventArgs.PropertyName);
+
+ var result = _service.SetCulture(CultureInfo.GetCultureInfo("fr"));
+
+ Assert.True(result.Success);
+ Assert.Equal("fr", _service.CurrentCulture.Name);
+ Assert.Equal("fr", CultureInfo.CurrentUICulture.Name);
+ Assert.Equal("fr", CultureInfo.DefaultThreadCurrentUICulture?.Name);
+ Assert.Equal(formattingCulture, CultureInfo.CurrentCulture);
+ Assert.Equal(
+ [nameof(_service.CurrentCulture), LocalizationConstants.IndexerPropertyName],
+ propertyNames);
+ }
+
+ ///
+ /// Verifies that a dotted resource key resolves and refreshes through the Avalonia binding path.
+ ///
+ [Fact]
+ public void LocalizeExtension_DottedKeyBinding_RefreshesWhenCultureChanges()
+ {
+ var target = new BindingTarget();
+ var extension = new LocalizeExtension("Settings.Appearance.Title");
+ using (target.Bind(BindingTarget.ValueProperty, extension.CreateBinding(_service)))
+ {
+ Assert.Equal("Appearance", target.Value);
+
+ var result = _service.SetCulture(CultureInfo.GetCultureInfo("fr"));
+
+ Assert.True(result.Success);
+ Assert.Equal("Apparence", target.Value);
+ }
+ }
+
+ ///
+ /// Verifies that an unavailable culture returns a failure without changing state.
+ ///
+ [Fact]
+ public void SetCulture_UnavailableCulture_ReturnsFailureWithoutChangingCulture()
+ {
+ var originalCulture = _service.CurrentCulture;
+
+ var result = _service.SetCulture(CultureInfo.GetCultureInfo("es"));
+
+ Assert.True(result.Failed);
+ Assert.Equal(originalCulture, _service.CurrentCulture);
+ Assert.Contains("not available", result.FirstError, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that one invalid satellite cannot abort discovery of later valid cultures.
+ ///
+ [Fact]
+ public void AvailableCultures_InvalidSatellite_IgnoresItAndContinuesDiscovery()
+ {
+ var resourceAssemblyName = typeof(LocalizationServiceTests).Assembly.GetName().Name
+ ?? throw new InvalidOperationException("The test assembly name could not be resolved.");
+ var corruptCultureDirectory = Path.Combine(AppContext.BaseDirectory, "es-MX");
+ var corruptSatellitePath = Path.Combine(
+ corruptCultureDirectory,
+ $"{resourceAssemblyName}{LocalizationConstants.SatelliteAssemblySuffix}");
+
+ Directory.CreateDirectory(corruptCultureDirectory);
+ File.WriteAllBytes(corruptSatellitePath, [0x00, 0x01, 0x02, 0x03]);
+
+ try
+ {
+ var service = CreateService(AppContext.BaseDirectory, NullLogger.Instance);
+ var cultureNames = service.AvailableCultures.Select(culture => culture.Name).ToList();
+
+ Assert.DoesNotContain("es-MX", cultureNames);
+ Assert.Contains("fr", cultureNames);
+ }
+ finally
+ {
+ File.Delete(corruptSatellitePath);
+ if (!Directory.EnumerateFileSystemEntries(corruptCultureDirectory).Any())
+ {
+ Directory.Delete(corruptCultureDirectory);
+ }
+ }
+ }
+
+ private LocalizationService CreateService(string baseDirectory, ILogger logger)
+ {
+ var resourceAssembly = typeof(LocalizationServiceTests).Assembly;
+ var assemblyName = resourceAssembly.GetName().Name
+ ?? throw new InvalidOperationException("The test assembly name could not be resolved.");
+ var resources = new LocalizationResources(
+ new ResourceManager(TestResourceBaseName, resourceAssembly),
+ $"{assemblyName}{LocalizationConstants.SatelliteAssemblySuffix}",
+ baseDirectory,
+ CultureInfo.GetCultureInfo(LocalizationConstants.DefaultCultureName));
+
+ return new LocalizationService(resources, logger);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj b/GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj
index 185df6dc3..44715d911 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj
@@ -10,6 +10,7 @@
true
+
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/LocalizationModuleTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/LocalizationModuleTests.cs
new file mode 100644
index 000000000..b051fe537
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/LocalizationModuleTests.cs
@@ -0,0 +1,46 @@
+using System.Globalization;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Infrastructure.DependencyInjection;
+using GenHub.Tests.Core.Collections;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace GenHub.Tests.Core.Infrastructure.DependencyInjection;
+
+///
+/// Tests localization dependency injection registration.
+///
+[Collection(LocalizationCultureCollection.Name)]
+public sealed class LocalizationModuleTests : IDisposable
+{
+ private readonly CultureInfo? _originalDefaultUiCulture = CultureInfo.DefaultThreadCurrentUICulture;
+ private readonly CultureInfo _originalThreadUiCulture = CultureInfo.CurrentUICulture;
+
+ ///
+ /// Restores process-wide UI culture defaults changed by localization resolution.
+ ///
+ public void Dispose()
+ {
+ CultureInfo.CurrentUICulture = _originalThreadUiCulture;
+ CultureInfo.DefaultThreadCurrentUICulture = _originalDefaultUiCulture;
+ }
+
+ ///
+ /// Verifies that localization resolves as one shared service with embedded English resources.
+ ///
+ [Fact]
+ public void AddLocalizationServices_RegistersSingletonWithDefaultResources()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddLocalizationServices();
+
+ using var provider = services.BuildServiceProvider();
+ var first = provider.GetRequiredService();
+ var second = provider.GetRequiredService();
+
+ Assert.Same(first, second);
+ Assert.Equal("en", first.CurrentCulture.Name);
+ Assert.Equal("GenHub", first.GetString("App.Name"));
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.fr.resx b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.fr.resx
new file mode 100644
index 000000000..4e4b41438
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.fr.resx
@@ -0,0 +1,24 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Bonjour, {0}!
+
+
+ Bonjour
+
+
+ Apparence
+
+
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.resx b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.resx
new file mode 100644
index 000000000..a05b2b32e
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/Localization/TestStrings.resx
@@ -0,0 +1,30 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ English fallback
+
+
+ Hello, {0}!
+
+
+ Hello
+
+
+ Hello, {0
+
+
+ Appearance
+
+
diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs
index 434d14a6b..a71e116e9 100644
--- a/GenHub/GenHub/App.axaml.cs
+++ b/GenHub/GenHub/App.axaml.cs
@@ -26,6 +26,7 @@ public partial class App : Application
private readonly IServiceProvider _serviceProvider;
private readonly IUserSettingsService _userSettingsService;
private readonly IConfigurationProviderService _configurationProvider;
+ private readonly ILocalizationService _localizationService;
private readonly IProfileLauncherFacade _profileLauncherFacade;
private readonly IThemeService? _themeService;
@@ -38,6 +39,7 @@ public App(IServiceProvider serviceProvider)
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_userSettingsService = _serviceProvider.GetService() ?? throw new InvalidOperationException("IUserSettingsService not registered");
_configurationProvider = _serviceProvider.GetService() ?? throw new InvalidOperationException("IConfigurationProviderService not registered");
+ _localizationService = _serviceProvider.GetRequiredService();
_profileLauncherFacade = _serviceProvider.GetRequiredService();
_themeService = _serviceProvider.GetService();
}
@@ -47,7 +49,12 @@ public App(IServiceProvider serviceProvider)
///
public override void Initialize()
{
+ // Make localization available while application XAML resources are loading.
+ Resources[LocalizationConstants.ResourceServiceKey] = _localizationService;
AvaloniaXamlLoader.Load(this);
+
+ // App XAML replaces the resource dictionary, so restore the service for views loaded afterward.
+ Resources[LocalizationConstants.ResourceServiceKey] = _localizationService;
}
///
diff --git a/GenHub/GenHub/Common/Markup/LocalizeExtension.cs b/GenHub/GenHub/Common/Markup/LocalizeExtension.cs
new file mode 100644
index 000000000..fa0d77820
--- /dev/null
+++ b/GenHub/GenHub/Common/Markup/LocalizeExtension.cs
@@ -0,0 +1,58 @@
+using System;
+using Avalonia;
+using Avalonia.Data;
+using Avalonia.Markup.Xaml;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Common;
+
+namespace GenHub.Common.Markup;
+
+///
+/// Creates a live one-way binding to a localized resource key.
+///
+/// The resource key to bind.
+public sealed class LocalizeExtension(string key) : MarkupExtension
+{
+ ///
+ /// Gets the resource key resolved by the extension.
+ ///
+ public string Key { get; } = string.IsNullOrWhiteSpace(key)
+ ? throw new ArgumentException("A localization resource key is required.", nameof(key))
+ : key;
+
+ ///
+ /// Provides a live binding when localization is initialized, or the key for design-time fallback.
+ ///
+ /// The XAML service provider for the target object.
+ /// A localization binding or the unresolved resource key.
+ public override object ProvideValue(IServiceProvider serviceProvider)
+ {
+ ArgumentNullException.ThrowIfNull(serviceProvider);
+
+ if (Application.Current?.TryGetResource(
+ LocalizationConstants.ResourceServiceKey,
+ theme: null,
+ out var resource) != true ||
+ resource is not ILocalizationService localizationService)
+ {
+ return Key;
+ }
+
+ return CreateBinding(localizationService);
+ }
+
+ ///
+ /// Creates the binding used to resolve and refresh a resource key.
+ ///
+ /// The source localization service.
+ /// A live one-way localization binding.
+ internal Binding CreateBinding(ILocalizationService localizationService)
+ {
+ ArgumentNullException.ThrowIfNull(localizationService);
+
+ return new Binding($"[{Key}]", BindingMode.OneWay)
+ {
+ Source = localizationService,
+ };
+ }
+}
diff --git a/GenHub/GenHub/Common/Services/LocalizationCultureUtilities.cs b/GenHub/GenHub/Common/Services/LocalizationCultureUtilities.cs
new file mode 100644
index 000000000..351410b1e
--- /dev/null
+++ b/GenHub/GenHub/Common/Services/LocalizationCultureUtilities.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Resources;
+using Microsoft.Extensions.Logging;
+
+namespace GenHub.Common.Services;
+
+///
+/// Applies UI cultures and discovers deployed localization satellite assemblies.
+///
+internal static class LocalizationCultureUtilities
+{
+ ///
+ /// Applies a culture to resource lookup without changing regional parsing or formatting behavior.
+ ///
+ /// The UI culture to apply.
+ /// The applied culture.
+ internal static CultureInfo ApplyUiCulture(CultureInfo culture)
+ {
+ CultureInfo.CurrentUICulture = culture;
+ CultureInfo.DefaultThreadCurrentUICulture = culture;
+ return culture;
+ }
+
+ ///
+ /// Discovers the neutral culture and valid deployed satellite resource cultures.
+ ///
+ /// The localization resource description.
+ /// The logger used for invalid deployment diagnostics.
+ /// The available cultures in deterministic order.
+ internal static IReadOnlyList DiscoverAvailableCultures(
+ LocalizationResources resources,
+ ILogger logger)
+ {
+ var cultures = new List { resources.DefaultCulture };
+ var cultureNames = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ resources.DefaultCulture.Name,
+ };
+
+ try
+ {
+ var directories = Directory.GetDirectories(resources.BaseDirectory)
+ .OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var directory in directories)
+ {
+ TryAddCulture(directory, resources, logger, cultures, cultureNames);
+ }
+ }
+ catch (DirectoryNotFoundException ex)
+ {
+ logger.LogWarning(ex, "Localization base directory was not found");
+ }
+ catch (IOException ex)
+ {
+ logger.LogWarning(ex, "Failed to scan localization satellite assemblies");
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ logger.LogWarning(ex, "Access was denied while scanning localization satellite assemblies");
+ }
+
+ return cultures.AsReadOnly();
+ }
+
+ private static void TryAddCulture(
+ string directory,
+ LocalizationResources resources,
+ ILogger logger,
+ ICollection cultures,
+ ISet cultureNames)
+ {
+ var satelliteAssemblyPath = Path.Combine(directory, resources.SatelliteAssemblyFileName);
+ if (!File.Exists(satelliteAssemblyPath))
+ {
+ return;
+ }
+
+ var cultureName = Path.GetFileName(directory);
+ try
+ {
+ var culture = CultureInfo.GetCultureInfo(cultureName);
+ if (resources.ResourceManager.GetResourceSet(
+ culture,
+ createIfNotExists: true,
+ tryParents: false) is null)
+ {
+ logger.LogWarning(
+ "Ignoring satellite assembly without the GenHub string resource set for culture '{CultureName}'",
+ culture.Name);
+ return;
+ }
+
+ if (cultureNames.Add(culture.Name))
+ {
+ cultures.Add(culture);
+ }
+ }
+ catch (BadImageFormatException ex)
+ {
+ logger.LogWarning(ex, "Ignoring invalid localization satellite assembly for culture '{CultureName}'", cultureName);
+ }
+ catch (CultureNotFoundException ex)
+ {
+ logger.LogWarning(ex, "Ignoring localization directory with invalid culture name '{CultureName}'", cultureName);
+ }
+ catch (MissingManifestResourceException ex)
+ {
+ logger.LogWarning(ex, "Ignoring satellite assembly with missing localization resources for culture '{CultureName}'", cultureName);
+ }
+ catch (MissingSatelliteAssemblyException ex)
+ {
+ logger.LogWarning(ex, "Ignoring missing localization satellite assembly for culture '{CultureName}'", cultureName);
+ }
+ catch (IOException ex)
+ {
+ logger.LogWarning(ex, "Ignoring unreadable localization satellite assembly for culture '{CultureName}'", cultureName);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ logger.LogWarning(ex, "Access was denied to the localization satellite assembly for culture '{CultureName}'", cultureName);
+ }
+ }
+}
diff --git a/GenHub/GenHub/Common/Services/LocalizationResources.cs b/GenHub/GenHub/Common/Services/LocalizationResources.cs
new file mode 100644
index 000000000..0499b4160
--- /dev/null
+++ b/GenHub/GenHub/Common/Services/LocalizationResources.cs
@@ -0,0 +1,17 @@
+using System.Globalization;
+using System.Resources;
+
+namespace GenHub.Common.Services;
+
+///
+/// Describes the resource set and deployment location used by localization services.
+///
+/// The resource manager used for string lookup.
+/// The expected satellite assembly file name.
+/// The directory containing culture-specific satellite directories.
+/// The neutral fallback culture.
+internal sealed record LocalizationResources(
+ ResourceManager ResourceManager,
+ string SatelliteAssemblyFileName,
+ string BaseDirectory,
+ CultureInfo DefaultCulture);
diff --git a/GenHub/GenHub/Common/Services/LocalizationService.cs b/GenHub/GenHub/Common/Services/LocalizationService.cs
new file mode 100644
index 000000000..1f2539637
--- /dev/null
+++ b/GenHub/GenHub/Common/Services/LocalizationService.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Globalization;
+using System.Linq;
+using System.Resources;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Models.Results;
+using Microsoft.Extensions.Logging;
+
+namespace GenHub.Common.Services;
+
+///
+/// Provides resource-based localization with English fallback and live binding notifications.
+///
+internal sealed class LocalizationService(
+ LocalizationResources resources,
+ ILogger logger) : ILocalizationService
+{
+ private static readonly PropertyChangedEventArgs CurrentCultureChangedEventArgs = new(nameof(CurrentCulture));
+ private static readonly PropertyChangedEventArgs IndexerChangedEventArgs = new(LocalizationConstants.IndexerPropertyName);
+
+ private readonly IReadOnlyList _availableCultures =
+ LocalizationCultureUtilities.DiscoverAvailableCultures(resources, logger);
+
+ private readonly object _cultureLock = new();
+ private readonly ConcurrentDictionary<(string CultureName, string ResourceKey), byte> _missingResourceWarnings = new();
+
+ ///
+ public IReadOnlyList AvailableCultures => _availableCultures;
+
+ ///
+ public CultureInfo CurrentCulture { get; private set; } =
+ LocalizationCultureUtilities.ApplyUiCulture(resources.DefaultCulture);
+
+ ///
+ public string this[string key] => GetString(key);
+
+ ///
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ ///
+ public string GetString(string key, params object?[] arguments)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(key);
+ ArgumentNullException.ThrowIfNull(arguments);
+
+ var culture = CurrentCulture;
+
+ try
+ {
+ var value = resources.ResourceManager.GetString(key, culture);
+ if (value is null)
+ {
+ if (_missingResourceWarnings.TryAdd((culture.Name, key), 0))
+ {
+ logger.LogWarning(
+ "Localization resource '{ResourceKey}' was not found for culture '{CultureName}' or its English fallback",
+ key,
+ culture.Name);
+ }
+
+ return key;
+ }
+
+ if (arguments.Length == 0)
+ {
+ return value;
+ }
+
+ try
+ {
+ return string.Format(culture, value, arguments);
+ }
+ catch (FormatException ex)
+ {
+ logger.LogError(ex, "Localization resource '{ResourceKey}' contains an invalid format string", key);
+ return value;
+ }
+ }
+ catch (MissingManifestResourceException ex)
+ {
+ logger.LogError(ex, "The default localization resource set could not be loaded");
+ return key;
+ }
+ catch (MissingSatelliteAssemblyException ex)
+ {
+ logger.LogError(ex, "The fallback localization satellite assembly could not be loaded");
+ return key;
+ }
+ }
+
+ ///
+ public OperationResult SetCulture(CultureInfo culture)
+ {
+ ArgumentNullException.ThrowIfNull(culture);
+
+ var availableCulture = AvailableCultures.FirstOrDefault(candidate =>
+ string.Equals(candidate.Name, culture.Name, StringComparison.OrdinalIgnoreCase));
+ if (availableCulture is null)
+ {
+ return OperationResult.CreateFailure($"Culture '{culture.Name}' is not available.");
+ }
+
+ var cultureChanged = false;
+ lock (_cultureLock)
+ {
+ cultureChanged = !string.Equals(
+ CurrentCulture.Name,
+ availableCulture.Name,
+ StringComparison.OrdinalIgnoreCase);
+ CurrentCulture = LocalizationCultureUtilities.ApplyUiCulture(availableCulture);
+ }
+
+ if (cultureChanged)
+ {
+ PropertyChanged?.Invoke(this, CurrentCultureChangedEventArgs);
+ PropertyChanged?.Invoke(this, IndexerChangedEventArgs);
+ }
+
+ return OperationResult.CreateSuccess();
+ }
+}
diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs
index 08f4e8cd6..762f3fdbd 100644
--- a/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs
+++ b/GenHub/GenHub/Infrastructure/DependencyInjection/AppServices.cs
@@ -25,6 +25,7 @@ public static IServiceCollection ConfigureApplicationServices(
// Register core services in dependency order
services.AddLoggingModule();
+ services.AddLocalizationServices();
services.AddValidationServices();
services.AddGameDetectionService();
services.AddGameInstallation();
diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/LocalizationModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/LocalizationModule.cs
new file mode 100644
index 000000000..553b5c756
--- /dev/null
+++ b/GenHub/GenHub/Infrastructure/DependencyInjection/LocalizationModule.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Globalization;
+using System.Resources;
+using GenHub.Common.Services;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Common;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace GenHub.Infrastructure.DependencyInjection;
+
+///
+/// Dependency injection module for application localization services.
+///
+public static class LocalizationModule
+{
+ ///
+ /// Registers the shared resource-based localization services.
+ ///
+ /// The service collection to register services with.
+ /// The updated service collection.
+ public static IServiceCollection AddLocalizationServices(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ var resourceAssembly = typeof(LocalizationModule).Assembly;
+ var assemblyName = resourceAssembly.GetName().Name
+ ?? throw new InvalidOperationException("The GenHub assembly name could not be resolved.");
+ var localizationResources = new LocalizationResources(
+ new ResourceManager(LocalizationConstants.StringResourceBaseName, resourceAssembly),
+ $"{assemblyName}{LocalizationConstants.SatelliteAssemblySuffix}",
+ AppContext.BaseDirectory,
+ CultureInfo.GetCultureInfo(LocalizationConstants.DefaultCultureName));
+
+ services.AddSingleton(localizationResources);
+ services.AddSingleton();
+
+ return services;
+ }
+}
diff --git a/GenHub/GenHub/Properties/AssemblyInfo.cs b/GenHub/GenHub/Properties/AssemblyInfo.cs
new file mode 100644
index 000000000..4e92378b5
--- /dev/null
+++ b/GenHub/GenHub/Properties/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using System.Resources;
+using GenHub.Core.Constants;
+
+[assembly: NeutralResourcesLanguage(LocalizationConstants.DefaultCultureName)]
diff --git a/GenHub/GenHub/Resources/Localization/Strings.resx b/GenHub/GenHub/Resources/Localization/Strings.resx
new file mode 100644
index 000000000..59113bc85
--- /dev/null
+++ b/GenHub/GenHub/Resources/Localization/Strings.resx
@@ -0,0 +1,19 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ GenHub
+ Application name shown in window titles and other product identity surfaces.
+
+
diff --git a/docs/dev/constants.md b/docs/dev/constants.md
index d592b6d40..27ae0b4be 100644
--- a/docs/dev/constants.md
+++ b/docs/dev/constants.md
@@ -67,6 +67,20 @@ Application-wide constants for GenHub.
---
+## LocalizationConstants Class
+
+Constants used by the application localization foundation.
+
+| Constant | Value | Description |
+| --- | --- | --- |
+| `DefaultCultureName` | `"en"` | Neutral culture embedded in the main application assembly |
+| `IndexerPropertyName` | `"Item"` | Avalonia change-notification name used to refresh localized indexer bindings |
+| `ResourceServiceKey` | `"LocalizationService"` | Application resource key used by the Avalonia markup extension |
+| `StringResourceBaseName` | `"GenHub.Resources.Localization.Strings"` | Fully qualified .NET resource base name |
+| `SatelliteAssemblySuffix` | `".resources.dll"` | Standard suffix used to identify satellite assemblies |
+
+---
+
## AppUpdateConstants Class
Constants related to application updates and Velopack.
diff --git a/docs/dev/index.md b/docs/dev/index.md
index 0beaac591..efd37f5a0 100644
--- a/docs/dev/index.md
+++ b/docs/dev/index.md
@@ -121,6 +121,12 @@ public class ConfigurationProviderService : IConfigurationProviderService
---
+### Localization
+
+GenHub uses a shared [resource-based localization system](./localization.md) with English fallback, automatic satellite-language discovery, and live Avalonia binding updates.
+
+---
+
### Logging
Structured logging is provided via `Microsoft.Extensions.Logging`:
diff --git a/docs/dev/localization.md b/docs/dev/localization.md
new file mode 100644
index 000000000..fdaa93c7f
--- /dev/null
+++ b/docs/dev/localization.md
@@ -0,0 +1,99 @@
+---
+title: Localization
+description: Resource, fallback, discovery, and live-binding conventions for GenHub translations
+---
+
+# Localization
+
+GenHub localizes application UI text with .NET `.resx` resources. English is the neutral language embedded in `GenHub.dll`; translated resources are compiled into culture-specific satellite assemblies.
+
+The foundation is intentionally small:
+
+- `ILocalizationService` resolves and formats strings, exposes available cultures, and changes the active culture.
+- `LocalizationService` uses .NET `ResourceManager` fallback and raises `INotifyPropertyChanged` notifications when the culture changes.
+- `LocalizeExtension` gives Avalonia views a live binding to a resource key.
+- `LocalizationModule` registers one shared service for every platform host.
+
+Language selection, persisted preference, UI string migration, translation coverage, and right-to-left layout are separate feature concerns built on this foundation.
+
+## Resource layout
+
+The neutral English resource is:
+
+```text
+GenHub/GenHub/Resources/Localization/Strings.resx
+```
+
+Add translations beside it using a valid culture name:
+
+```text
+Strings.fr.resx
+Strings.de.resx
+Strings.ar.resx
+Strings.pt-BR.resx
+```
+
+At build time, .NET creates a satellite assembly under the matching culture directory. GenHub discovers those directories at startup, so there is no manually maintained supported-language list.
+
+If a translated resource omits a key, `ResourceManager` follows the normal culture hierarchy and ultimately uses the value from `Strings.resx`. If the key does not exist in any resource, GenHub logs the miss and displays the key so the problem remains visible.
+
+## Resource keys
+
+Use dot-separated keys that identify the feature and UI purpose:
+
+```text
+Settings.Appearance.Title
+Settings.Appearance.Language.Label
+GameProfiles.Create.Confirm
+Downloads.Status.Queued
+```
+
+Keep keys stable after release. Add translator comments when context or placeholders are not obvious. Every translation of a formatted string must preserve the same numbered placeholders as the English resource.
+
+Do not localize log templates, protocol values, manifest identifiers, command-line arguments, or other developer-facing technical strings.
+
+## Avalonia views
+
+Reference the markup namespace and bind the property to a key:
+
+```xml
+
+
+
+```
+
+The extension binds through the application-scoped localization service. When `SetCulture` changes the active culture, all localized indexer bindings are notified and refresh without recreating the view or restarting GenHub.
+
+## View models and services
+
+Inject `ILocalizationService` when text must be produced in code:
+
+```csharp
+var title = localizationService.GetString("GameProfiles.Create.Title");
+var status = localizationService.GetString("Downloads.Status.Progress", completed, total);
+```
+
+Only request cultures returned by `AvailableCultures`, and check the operation result:
+
+```csharp
+var result = localizationService.SetCulture(selectedCulture);
+if (result.Failed)
+{
+ // Surface result.FirstError through the caller's normal error path.
+}
+```
+
+Culture switching is synchronous because it performs no long-running I/O. Do not wrap it in `Task.Run`, block on a task, or introduce a reactive package solely for change notification.
+
+The selected language is applied to `CurrentUICulture` and `DefaultThreadCurrentUICulture` for resource lookup. It does not replace `CurrentCulture`, so changing the UI language cannot silently alter unrelated regional number, date, parsing, or serialization behavior. Format arguments passed to `GetString` use the selected localization culture.
+
+## Adding coverage
+
+Every localization change should test the behavior it introduces. At minimum:
+
+- a translated key resolves from the requested satellite assembly;
+- an omitted translated key falls back to English;
+- placeholders format correctly in the active culture;
+- an unavailable culture fails without changing the current culture;
+- a successful culture change refreshes live bindings;
+- an invalid satellite assembly is ignored without aborting discovery of other languages.