[GUI] now languages are embedded in a single assembly

This commit is contained in:
ParantezTech
2026-07-14 02:07:31 +03:00
parent aaebfe017b
commit 6c8c98ed7d
3 changed files with 102 additions and 39 deletions

View File

@@ -48,7 +48,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<TargetPath>LICENSE.txt</TargetPath> <TargetPath>LICENSE.txt</TargetPath>
<Visible>False</Visible> <Visible>False</Visible>
</Content> </Content>
<Content Include="..\SharpEmu.GUI\Languages\*.json"> <Content Include="..\SharpEmu.GUI\Languages\*.json" Condition="'$(Configuration)' != 'Release'">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<TargetPath>Languages\%(Filename)%(Extension)</TargetPath> <TargetPath>Languages\%(Filename)%(Extension)</TargetPath>
<Visible>False</Visible> <Visible>False</Visible>

View File

@@ -6,10 +6,11 @@ using System.Text.Json;
namespace SharpEmu.GUI; namespace SharpEmu.GUI;
/// <summary> /// <summary>
/// Loads UI strings for the launcher from Languages/&lt;code&gt;.json next to the /// Loads UI strings for the launcher. Every language ships embedded in the
/// executable. The files are plain, pretty-printed JSON (a flat key/value /// assembly (see SharpEmu.GUI.csproj) so a release build is fully
/// map) so they stay easy to open and translate by hand; a missing or /// self-contained; an optional Languages/&lt;code&gt;.json file next to the
/// unreadable key falls back to the key name itself. /// executable overrides the embedded copy for that code, so a translation
/// fix or a brand-new language never needs a rebuild.
/// </summary> /// </summary>
public sealed class Localization public sealed class Localization
{ {
@@ -17,13 +18,16 @@ public sealed class Localization
public sealed record LanguageInfo(string Code, string NativeName); public sealed record LanguageInfo(string Code, string NativeName);
private const string EmbeddedResourcePrefix = "Languages.";
private const string EmbeddedResourceSuffix = ".json";
private Dictionary<string, string> _strings = new(); private Dictionary<string, string> _strings = new();
private Localization() private Localization()
{ {
} }
/// <summary>Directory holding the *.json language files; user-editable.</summary> /// <summary>Directory holding optional *.json language overrides, next to the executable.</summary>
public static string LanguagesDirectory => Path.Combine(AppContext.BaseDirectory, "Languages"); public static string LanguagesDirectory => Path.Combine(AppContext.BaseDirectory, "Languages");
public string CurrentCode { get; private set; } = "en"; public string CurrentCode { get; private set; } = "en";
@@ -32,32 +36,73 @@ public sealed class Localization
public string Format(string key, params object?[] args) => string.Format(Get(key), args); public string Format(string key, params object?[] args) => string.Format(Get(key), args);
/// <summary>Languages discovered under <see cref="LanguagesDirectory"/>, sorted by code.</summary> /// <summary>
/// Languages available either embedded in the binary or as a loose
/// override file, sorted by code. A loose file's declared name wins when
/// the same code exists in both places.
/// </summary>
public List<LanguageInfo> DiscoverLanguages() public List<LanguageInfo> DiscoverLanguages()
{ {
var languages = new List<LanguageInfo>(); var languages = new Dictionary<string, LanguageInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var code in EmbeddedLanguageCodes())
{
using var stream = OpenEmbeddedLanguageStream(code);
if (stream is not null)
{
languages[code] = new LanguageInfo(code, ReadLanguageName(stream) ?? code);
}
}
try try
{ {
foreach (var file in Directory.EnumerateFiles(LanguagesDirectory, "*.json")) foreach (var file in Directory.EnumerateFiles(LanguagesDirectory, "*.json"))
{ {
var code = Path.GetFileNameWithoutExtension(file); var code = Path.GetFileNameWithoutExtension(file);
languages.Add(new LanguageInfo(code, ReadLanguageName(file) ?? code)); using var stream = File.OpenRead(file);
languages[code] = new LanguageInfo(code, ReadLanguageName(stream) ?? code);
} }
} }
catch (Exception) catch (Exception)
{ {
// Missing Languages directory: no languages to offer. // No loose Languages directory: the embedded languages still stand.
} }
languages.Sort((a, b) => string.CompareOrdinal(a.Code, b.Code)); var result = languages.Values.ToList();
return languages; result.Sort((a, b) => string.CompareOrdinal(a.Code, b.Code));
return result;
} }
private static string? ReadLanguageName(string path) /// <summary>Loads a language by code (e.g. "en"): a loose override file first, then the embedded copy.</summary>
public void Load(string code)
{
if (!TryLoadLooseFile(code) && !TryLoadEmbedded(code) &&
!string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
{
_ = TryLoadLooseFile("en") || TryLoadEmbedded("en");
}
}
private static IEnumerable<string> EmbeddedLanguageCodes()
{
foreach (var name in typeof(Localization).Assembly.GetManifestResourceNames())
{
if (name.StartsWith(EmbeddedResourcePrefix, StringComparison.Ordinal) &&
name.EndsWith(EmbeddedResourceSuffix, StringComparison.Ordinal))
{
yield return name[EmbeddedResourcePrefix.Length..^EmbeddedResourceSuffix.Length];
}
}
}
private static Stream? OpenEmbeddedLanguageStream(string code) =>
typeof(Localization).Assembly.GetManifestResourceStream($"{EmbeddedResourcePrefix}{code}{EmbeddedResourceSuffix}");
private static string? ReadLanguageName(Stream stream)
{ {
try try
{ {
using var document = JsonDocument.Parse(File.ReadAllText(path)); using var document = JsonDocument.Parse(stream);
if (document.RootElement.TryGetProperty("_languageName", out var name) && if (document.RootElement.TryGetProperty("_languageName", out var name) &&
name.ValueKind == JsonValueKind.String) name.ValueKind == JsonValueKind.String)
{ {
@@ -66,44 +111,54 @@ public sealed class Localization
} }
catch (Exception) catch (Exception)
{ {
// Malformed file: fall back to the file's code as its display name. // Malformed file: fall back to the code as its own display name.
} }
return null; return null;
} }
/// <summary>Loads a language by file code (e.g. "en"), falling back to English.</summary> private bool TryLoadLooseFile(string code)
public void Load(string code)
{
if (!TryLoadFile(code) && !string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
{
TryLoadFile("en");
}
}
private bool TryLoadFile(string code)
{ {
try try
{ {
var path = Path.Combine(LanguagesDirectory, $"{code}.json"); var path = Path.Combine(LanguagesDirectory, $"{code}.json");
if (!File.Exists(path)) return File.Exists(path) && TryLoad(code, File.ReadAllText(path));
{
return false;
}
var loaded = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(path));
if (loaded is null)
{
return false;
}
_strings = loaded;
CurrentCode = code;
return true;
} }
catch (Exception) catch (Exception)
{ {
return false; return false;
} }
} }
private bool TryLoadEmbedded(string code)
{
try
{
using var stream = OpenEmbeddedLanguageStream(code);
if (stream is null)
{
return false;
}
using var reader = new StreamReader(stream);
return TryLoad(code, reader.ReadToEnd());
}
catch (Exception)
{
return false;
}
}
private bool TryLoad(string code, string json)
{
var loaded = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
if (loaded is null)
{
return false;
}
_strings = loaded;
CurrentCode = code;
return true;
}
} }

View File

@@ -30,6 +30,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" /> <AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Languages\*.json">
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
</ItemGroup>
<!-- The controller readers (DualSense raw HID + Xbox XInput) are shared <!-- The controller readers (DualSense raw HID + Xbox XInput) are shared
with the emulator's pad HLE. They are dependency-free, so they are with the emulator's pad HLE. They are dependency-free, so they are
compiled in directly rather than pulling a reference to all of compiled in directly rather than pulling a reference to all of