diff --git a/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/FluentLauncher.Infra.LocalizedStrings.SourceGenerators.csproj b/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/FluentLauncher.Infra.LocalizedStrings.SourceGenerators.csproj
new file mode 100644
index 0000000..ca55a47
--- /dev/null
+++ b/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/FluentLauncher.Infra.LocalizedStrings.SourceGenerators.csproj
@@ -0,0 +1,24 @@
+
+
+
+ netstandard2.0
+ true
+ 13.0
+ enable
+ true
+
+
+
+
+ AnyCPU
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
diff --git a/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/LocalizedStringsGenerator.cs b/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/LocalizedStringsGenerator.cs
new file mode 100644
index 0000000..37fa4b7
--- /dev/null
+++ b/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/LocalizedStringsGenerator.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Resources;
+using System.Text;
+using System.Xml.Linq;
+using CommunityToolkit.Mvvm.SourceGenerators.Extensions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+
+namespace FluentLauncher.Infra.Settings.SourceGenerators;
+
+internal record struct ClassInfo(string Namespace, string ClassName);
+
+internal record struct ReswFileInfo(string FilePath)
+{
+ public string Filename => Path.GetFileNameWithoutExtension(FilePath);
+ public string ResourceMapName => Filename.Split('.')[0];
+ public string Qualifier => Filename.Substring(ResourceMapName.Length, Filename.Length - ResourceMapName.Length - ".resw".Length);
+}
+
+[Generator(LanguageNames.CSharp)]
+public class LocalizedStringsGenerator : IIncrementalGenerator
+{
+ public LocalizedStringsGenerator()
+ {
+#if DEBUG
+ if (!Debugger.IsAttached)
+ {
+ //Debugger.Launch();
+ }
+#endif
+ }
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ // Find all classes with the [GeneratedLocalizedStrings] attribute
+ var classDeclarations = context.SyntaxProvider.ForAttributeWithMetadataName(
+ "FluentLauncher.Infra.LocalizedStrings.GeneratedLocalizedStringsAttribute",
+ static (node, _) => node is ClassDeclarationSyntax,
+ static (ctx, token) =>
+ {
+ // Extract class info
+ ITypeSymbol localizedStringClassSymbol = (ITypeSymbol)ctx.TargetSymbol;
+ string containingNamespace = localizedStringClassSymbol.ContainingNamespace.ToDisplayString();
+ string className = localizedStringClassSymbol.Name;
+ return new ClassInfo(containingNamespace, className);
+ })
+ .Collect();
+
+ // Find all .resw files, group by resource map name and keep the neutral one (or the first in alphabetical order)
+ var reswFilesProvider = context.AdditionalTextsProvider
+ // Find all .resw files
+ .Where(file => file.Path.EndsWith(".resw", StringComparison.OrdinalIgnoreCase))
+ .Select((file, token) =>
+ {
+ var reswFile = new ReswFileInfo(file.Path);
+ return (reswFile.ResourceMapName, reswFile);
+ })
+ .GroupBy(
+ static item => item.Left,
+ static item => item.Right
+ )
+ .Select((group, token) => group.Right
+ .ToList()
+ .OrderBy(file => file.Filename)
+ .First()
+ )
+ .Collect();
+
+ context.RegisterSourceOutput(classDeclarations.Combine(reswFilesProvider), Execute);
+ }
+
+ private static void Execute(SourceProductionContext context, (ImmutableArray classInfos, ImmutableArray reswFiles) input)
+ {
+ var (classes, reswFiles) = input;
+ if (classes.IsDefaultOrEmpty || reswFiles.IsDefaultOrEmpty)
+ return;
+
+ foreach (var classInfo in classes)
+ {
+ var namespaceName = classInfo.Namespace;
+ var className = classInfo.ClassName;
+
+ // Parse and generate properties for each .resw file
+ IEnumerable defaultStringIds = []; // Strings in Resources.resw
+ var otherStringIds = new Dictionary>();
+
+ foreach (var reswFile in reswFiles)
+ {
+ string resourceMapName = reswFile.ResourceMapName;
+ if (reswFile.Filename.Equals("Resources", StringComparison.OrdinalIgnoreCase))
+ defaultStringIds = ParseReswFile(reswFile);
+ else
+ otherStringIds[resourceMapName] = ParseReswFile(reswFile);
+ }
+
+ // Generate the class in the detected namespace
+ string source = GenerateClass(namespaceName, className, defaultStringIds, otherStringIds);
+
+ // Add the generated source to the compilation
+ context.AddSource($"{namespaceName}.{className}.g.cs", SourceText.From(source, Encoding.UTF8));
+ }
+ }
+
+ private static IEnumerable ParseReswFile(ReswFileInfo reswFile)
+ {
+ using var reader = new StreamReader(reswFile.FilePath);
+
+ IEnumerable? stringIds = System.Xml.Linq.XDocument.Load(reader).Root?
+ .Elements("data")
+ .Select(node => node.Attribute("name")?.Value.Replace(".", "/"))
+ .Where(name => !string.IsNullOrWhiteSpace(name))!;
+
+ return stringIds ?? [];
+ //properties.Add($"public static string {propertyName} => s_resourceMap.GetValue(\"{namespaceName}/{name}\").ValueAsString;");
+ }
+
+ private static string GenerateClass(
+ string namespaceName,
+ string className,
+ IEnumerable defaultStringIds,
+ Dictionary> otherStringIds)
+ {
+ var propertyBuilder = new StringBuilder();
+
+ propertyBuilder.AppendLine("// Default resource map (Resources.resw)");
+ foreach (var id in defaultStringIds)
+ {
+ string propertyName = id.Replace('/', '_').Replace(' ', '_');
+ propertyBuilder.AppendLine($" public static string {propertyName} => s_resourceMap.GetValue(\"/Resources/{id}\").ValueAsString;");
+ }
+
+ propertyBuilder.AppendLine("\n // Other resource maps");
+ foreach (var item in otherStringIds)
+ {
+ string resourceMapName = item.Key;
+ IEnumerable stringIds = item.Value;
+ propertyBuilder.AppendLine($" public static class {resourceMapName}")
+ .AppendLine(" {");
+
+ foreach (string id in stringIds)
+ {
+ string propertyName = id.Replace('/', '_').Replace(' ', '_');
+ propertyBuilder.AppendLine($" public static string {propertyName} => s_resourceMap.GetValue(\"/{resourceMapName}/{id}\").ValueAsString;");
+ }
+
+ propertyBuilder.AppendLine(" }");
+ }
+
+ return $$"""
+ using global::Microsoft.Windows.ApplicationModel.Resources;
+
+ namespace {{namespaceName}}
+ {
+ static partial class {{className}}
+ {
+ private static ResourceManager s_resourceManager;
+ private static ResourceMap s_resourceMap;
+
+ static {{className}}()
+ {
+ s_resourceManager = new ResourceManager();
+ s_resourceMap = s_resourceManager.MainResourceMap;
+ }
+
+ {{propertyBuilder}}
+ }
+ }
+ """;
+ }
+}
diff --git a/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/MVVMTKHelpers/EquatableArray{T}.cs b/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/MVVMTKHelpers/EquatableArray{T}.cs
new file mode 100644
index 0000000..109e683
--- /dev/null
+++ b/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/MVVMTKHelpers/EquatableArray{T}.cs
@@ -0,0 +1,202 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Runtime.CompilerServices;
+
+namespace CommunityToolkit.Mvvm.SourceGenerators.Helpers;
+
+///
+/// Extensions for .
+///
+internal static class EquatableArray
+{
+ ///
+ /// Creates an instance from a given .
+ ///
+ /// The type of items in the input array.
+ /// The input instance.
+ /// An instance from a given .
+ public static EquatableArray AsEquatableArray(this ImmutableArray array)
+ where T : IEquatable
+ {
+ return new(array);
+ }
+}
+
+///
+/// An immutable, equatable array. This is equivalent to but with value equality support.
+///
+/// The type of values in the array.
+internal readonly struct EquatableArray : IEquatable>, IEnumerable
+ where T : IEquatable
+{
+ ///
+ /// The underlying array.
+ ///
+ private readonly T[]? array;
+
+ ///
+ /// Creates a new instance.
+ ///
+ /// The input to wrap.
+ public EquatableArray(ImmutableArray array)
+ {
+ this.array = Unsafe.As, T[]?>(ref array);
+ }
+
+ ///
+ /// Gets a reference to an item at a specified position within the array.
+ ///
+ /// The index of the item to retrieve a reference to.
+ /// A reference to an item at a specified position within the array.
+ public ref readonly T this[int index]
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get => ref AsImmutableArray().ItemRef(index);
+ }
+
+ ///
+ /// Gets a value indicating whether the current array is empty.
+ ///
+ public bool IsEmpty
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get => AsImmutableArray().IsEmpty;
+ }
+
+ ///
+ public bool Equals(EquatableArray array)
+ {
+ return AsSpan().SequenceEqual(array.AsSpan());
+ }
+
+ ///
+ public override bool Equals(/*[NotNullWhen(true)]*/ object? obj)
+ {
+ return obj is EquatableArray array && Equals(this, array);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ if (this.array is not T[] array)
+ {
+ return 0;
+ }
+
+ HashCode hashCode = default;
+
+ foreach (T item in array)
+ {
+ hashCode.Add(item);
+ }
+
+ return hashCode.ToHashCode();
+ }
+
+ ///
+ /// Gets an instance from the current .
+ ///
+ /// The from the current .
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public ImmutableArray AsImmutableArray()
+ {
+ return Unsafe.As>(ref Unsafe.AsRef(in this.array));
+ }
+
+ ///
+ /// Creates an instance from a given .
+ ///
+ /// The input instance.
+ /// An instance from a given .
+ public static EquatableArray FromImmutableArray(ImmutableArray array)
+ {
+ return new(array);
+ }
+
+ ///
+ /// Returns a wrapping the current items.
+ ///
+ /// A wrapping the current items.
+ public ReadOnlySpan AsSpan()
+ {
+ return AsImmutableArray().AsSpan();
+ }
+
+ ///
+ /// Copies the contents of this instance to a mutable array.
+ ///
+ /// The newly instantiated array.
+ public T[] ToArray()
+ {
+ return AsImmutableArray().ToArray();
+ }
+
+ ///
+ /// Gets an value to traverse items in the current array.
+ ///
+ /// An value to traverse items in the current array.
+ public ImmutableArray.Enumerator GetEnumerator()
+ {
+ return AsImmutableArray().GetEnumerator();
+ }
+
+ ///
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return ((IEnumerable)AsImmutableArray()).GetEnumerator();
+ }
+
+ ///
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return ((IEnumerable)AsImmutableArray()).GetEnumerator();
+ }
+
+ ///
+ /// Implicitly converts an to .
+ ///
+ /// An instance from a given .
+ public static implicit operator EquatableArray(ImmutableArray array)
+ {
+ return FromImmutableArray(array);
+ }
+
+ ///
+ /// Implicitly converts an to .
+ ///
+ /// An instance from a given .
+ public static implicit operator ImmutableArray(EquatableArray array)
+ {
+ return array.AsImmutableArray();
+ }
+
+ ///
+ /// Checks whether two values are the same.
+ ///
+ /// The first value.
+ /// The second value.
+ /// Whether and are equal.
+ public static bool operator ==(EquatableArray left, EquatableArray right)
+ {
+ return left.Equals(right);
+ }
+
+ ///
+ /// Checks whether two values are not the same.
+ ///
+ /// The first value.
+ /// The second value.
+ /// Whether and are not equal.
+ public static bool operator !=(EquatableArray left, EquatableArray right)
+ {
+ return !left.Equals(right);
+ }
+}
\ No newline at end of file
diff --git a/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/MVVMTKHelpers/HashCode.cs b/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/MVVMTKHelpers/HashCode.cs
new file mode 100644
index 0000000..2d275af
--- /dev/null
+++ b/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/MVVMTKHelpers/HashCode.cs
@@ -0,0 +1,188 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+
+#pragma warning disable CS0809
+
+namespace System;
+
+///
+/// A polyfill type that mirrors some methods from on .NET 6.
+///
+internal struct HashCode
+{
+ private const uint Prime1 = 2654435761U;
+ private const uint Prime2 = 2246822519U;
+ private const uint Prime3 = 3266489917U;
+ private const uint Prime4 = 668265263U;
+ private const uint Prime5 = 374761393U;
+
+ private static readonly uint seed = GenerateGlobalSeed();
+
+ private uint v1, v2, v3, v4;
+ private uint queue1, queue2, queue3;
+ private uint length;
+
+ ///
+ /// Initializes the default seed.
+ ///
+ /// A random seed.
+ private static unsafe uint GenerateGlobalSeed()
+ {
+ byte[] bytes = new byte[4];
+
+ using (RandomNumberGenerator generator = RandomNumberGenerator.Create())
+ {
+ generator.GetBytes(bytes);
+ }
+
+ return BitConverter.ToUInt32(bytes, 0);
+ }
+
+ ///
+ /// Adds a single value to the current hash.
+ ///
+ /// The type of the value to add into the hash code.
+ /// The value to add into the hash code.
+ public void Add(T value)
+ {
+ Add(value?.GetHashCode() ?? 0);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void Initialize(out uint v1, out uint v2, out uint v3, out uint v4)
+ {
+ v1 = seed + Prime1 + Prime2;
+ v2 = seed + Prime2;
+ v3 = seed;
+ v4 = seed - Prime1;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static uint Round(uint hash, uint input)
+ {
+ return RotateLeft(hash + input * Prime2, 13) * Prime1;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static uint QueueRound(uint hash, uint queuedValue)
+ {
+ return RotateLeft(hash + queuedValue * Prime3, 17) * Prime4;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static uint MixState(uint v1, uint v2, uint v3, uint v4)
+ {
+ return RotateLeft(v1, 1) + RotateLeft(v2, 7) + RotateLeft(v3, 12) + RotateLeft(v4, 18);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static uint MixEmptyState()
+ {
+ return seed + Prime5;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static uint MixFinal(uint hash)
+ {
+ hash ^= hash >> 15;
+ hash *= Prime2;
+ hash ^= hash >> 13;
+ hash *= Prime3;
+ hash ^= hash >> 16;
+
+ return hash;
+ }
+
+ private void Add(int value)
+ {
+ uint val = (uint)value;
+ uint previousLength = this.length++;
+ uint position = previousLength % 4;
+
+ if (position == 0)
+ {
+ this.queue1 = val;
+ }
+ else if (position == 1)
+ {
+ this.queue2 = val;
+ }
+ else if (position == 2)
+ {
+ this.queue3 = val;
+ }
+ else
+ {
+ if (previousLength == 3)
+ {
+ Initialize(out this.v1, out this.v2, out this.v3, out this.v4);
+ }
+
+ this.v1 = Round(this.v1, this.queue1);
+ this.v2 = Round(this.v2, this.queue2);
+ this.v3 = Round(this.v3, this.queue3);
+ this.v4 = Round(this.v4, val);
+ }
+ }
+
+ ///
+ /// Gets the resulting hashcode from the current instance.
+ ///
+ /// The resulting hashcode from the current instance.
+ public int ToHashCode()
+ {
+ uint length = this.length;
+ uint position = length % 4;
+ uint hash = length < 4 ? MixEmptyState() : MixState(this.v1, this.v2, this.v3, this.v4);
+
+ hash += length * 4;
+
+ if (position > 0)
+ {
+ hash = QueueRound(hash, this.queue1);
+
+ if (position > 1)
+ {
+ hash = QueueRound(hash, this.queue2);
+
+ if (position > 2)
+ {
+ hash = QueueRound(hash, this.queue3);
+ }
+ }
+ }
+
+ hash = MixFinal(hash);
+
+ return (int)hash;
+ }
+
+ ///
+ [Obsolete("HashCode is a mutable struct and should not be compared with other HashCodes. Use ToHashCode to retrieve the computed hash code.", error: true)]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public override int GetHashCode() => throw new NotSupportedException();
+
+ ///
+ [Obsolete("HashCode is a mutable struct and should not be compared with other HashCodes.", error: true)]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public override bool Equals(object? obj) => throw new NotSupportedException();
+
+ ///
+ /// Rotates the specified value left by the specified number of bits.
+ /// Similar in behavior to the x86 instruction ROL.
+ ///
+ /// The value to rotate.
+ /// The number of bits to rotate by.
+ /// Any value outside the range [0..31] is treated as congruent mod 32.
+ /// The rotated value.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static uint RotateLeft(uint value, int offset)
+ {
+ return (value << offset) | (value >> (32 - offset));
+ }
+}
diff --git a/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/MVVMTKHelpers/IncrementalValuesProviderExtensions.cs b/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/MVVMTKHelpers/IncrementalValuesProviderExtensions.cs
new file mode 100644
index 0000000..67f39ed
--- /dev/null
+++ b/FluentLauncher.Infra.LocalizedStrings.SourceGenerators/MVVMTKHelpers/IncrementalValuesProviderExtensions.cs
@@ -0,0 +1,73 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+// This file is ported and adapted from ComputeSharp (Sergio0694/ComputeSharp),
+// more info in ThirdPartyNotices.txt in the root of the project.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using CommunityToolkit.Mvvm.SourceGenerators.Helpers;
+using Microsoft.CodeAnalysis;
+
+namespace CommunityToolkit.Mvvm.SourceGenerators.Extensions;
+
+///
+/// Extension methods for .
+///
+internal static class IncrementalValuesProviderExtensions
+{
+ ///
+ /// Groups items in a given sequence by a specified key.
+ ///
+ /// The type of left items in each tuple.
+ /// The type of right items in each tuple.
+ /// The type of resulting key elements.
+ /// The type of resulting projected elements.
+ /// The input instance.
+ /// The key selection .
+ /// The element selection .
+ /// An with the grouped results.
+ public static IncrementalValuesProvider<(TKey Key, EquatableArray Right)> GroupBy(
+ this IncrementalValuesProvider<(TLeft Left, TRight Right)> source,
+ Func<(TLeft Left, TRight Right), TKey> keySelector,
+ Func<(TLeft Left, TRight Right), TElement> elementSelector)
+ where TLeft : IEquatable
+ where TRight : IEquatable
+ where TKey : IEquatable
+ where TElement : IEquatable
+ {
+ return source.Collect().SelectMany((item, token) =>
+ {
+ Dictionary.Builder> map = new();
+
+ foreach ((TLeft, TRight) pair in item)
+ {
+ TKey key = keySelector(pair);
+ TElement element = elementSelector(pair);
+
+ if (!map.TryGetValue(key, out ImmutableArray.Builder builder))
+ {
+ builder = ImmutableArray.CreateBuilder();
+
+ map.Add(key, builder);
+ }
+
+ builder.Add(element);
+ }
+
+ token.ThrowIfCancellationRequested();
+
+ ImmutableArray<(TKey Key, EquatableArray Elements)>.Builder result =
+ ImmutableArray.CreateBuilder<(TKey, EquatableArray)>();
+
+ foreach (KeyValuePair.Builder> entry in map)
+ {
+ result.Add((entry.Key, entry.Value.ToImmutable()));
+ }
+
+ return result;
+ });
+ }
+}
diff --git a/FluentLauncher.Infra.LocalizedStrings/FluentLauncher.Infra.LocalizedStrings.csproj b/FluentLauncher.Infra.LocalizedStrings/FluentLauncher.Infra.LocalizedStrings.csproj
new file mode 100644
index 0000000..0672ddb
--- /dev/null
+++ b/FluentLauncher.Infra.LocalizedStrings/FluentLauncher.Infra.LocalizedStrings.csproj
@@ -0,0 +1,8 @@
+
+
+
+ net8.0
+ enable
+
+
+
diff --git a/FluentLauncher.Infra.LocalizedStrings/GeneratedLocalizedStringsAttribute.cs b/FluentLauncher.Infra.LocalizedStrings/GeneratedLocalizedStringsAttribute.cs
new file mode 100644
index 0000000..37af6a9
--- /dev/null
+++ b/FluentLauncher.Infra.LocalizedStrings/GeneratedLocalizedStringsAttribute.cs
@@ -0,0 +1,6 @@
+using System;
+
+namespace FluentLauncher.Infra.LocalizedStrings;
+
+[AttributeUsage(AttributeTargets.Class)]
+public class GeneratedLocalizedStringsAttribute : Attribute { }