using System.Collections.ObjectModel; using System.Globalization; using System.Text.Json; namespace XFEEliteDangerous.Client; /// UI and operator messages only. Never use translated text as a protocol identifier. public static class AppText { private static readonly string SystemLanguage = Resolve(CultureInfo.CurrentUICulture.Name); private static string processLanguage = SystemLanguage; private static readonly AsyncLocal RequestLanguage = new(); private static readonly IReadOnlyDictionary English = Load("en-US"); private static readonly IReadOnlyDictionary Chinese = Load("zh-CN"); public static string Language => RequestLanguage.Value ?? Volatile.Read(ref processLanguage); public static CultureInfo DisplayCulture => CultureInfo.GetCultureInfo(Language); public static IReadOnlyDictionary Catalog(string language) => Resolve(language) == "zh-CN" ? Chinese : English; public static string NormalizePreference(string? language) => language?.Trim().ToLowerInvariant() switch { null or "" or "system" or "auto" => "system", "en" or "en-us" or "en-gb" => "en-US", "zh" or "zh-cn" or "zh-hans" or "zh-hans-cn" => "zh-CN", _ => throw new ArgumentException("Supported languages: system, en-US, zh-CN.", nameof(language)) }; public static void Apply(string? preference) { var normalized = NormalizePreference(preference); var culture = CultureInfo.GetCultureInfo(normalized == "system" ? SystemLanguage : normalized); Volatile.Write(ref processLanguage, culture.Name); // Wire serializers and native parsers keep their existing numeric culture. CultureInfo.DefaultThreadCurrentUICulture = culture; CultureInfo.CurrentUICulture = culture; } public static string[] ConfigureArguments(string[] arguments, string? defaultLanguage = "system") { var remaining = new List(); var language = Environment.GetEnvironmentVariable("XFE_LANGUAGE") ?? defaultLanguage; var found = false; for (var i = 0; i < arguments.Length; i++) { var argument = arguments[i]; if (argument != "--language" && !argument.StartsWith("--language=", StringComparison.Ordinal)) { remaining.Add(argument); continue; } if (found) throw new ArgumentException("Duplicate --language option."); found = true; language = argument == "--language" ? ++i < arguments.Length ? arguments[i] : throw new ArgumentException("Missing --language value.") : argument["--language=".Length..]; _ = NormalizePreference(language); } Apply(language); return remaining.ToArray(); } public static string Get(string key) { ArgumentNullException.ThrowIfNull(key); var catalog = Language == "zh-CN" ? Chinese : English; return catalog.TryGetValue(key, out var text) || English.TryGetValue(key, out text) ? text : throw new KeyNotFoundException("Missing localization key: " + key); } public static string Format(string key, params object?[] values) => string.Format(DisplayCulture, Get(key), values); /// Request-local culture; disposing restores the caller without changing process defaults. public static IDisposable UseLanguage(string? acceptLanguage) { var prior = CultureInfo.CurrentUICulture; var priorRequest = RequestLanguage.Value; var language = SelectRequestLanguage(acceptLanguage); RequestLanguage.Value = language; CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(language); return new CultureScope(prior, priorRequest); } private static string SelectRequestLanguage(string? header) { if (string.IsNullOrWhiteSpace(header)) return Language; // Bound untrusted HTTP headers and honor quality values, including exclusions (q=0). if (header.Length > 1024) return "en-US"; var candidates = new List<(string language, double weight, int order)>(); foreach (var part in header.Split(',').Take(20)) { var fields = part.Trim().Split(';'); var tag = fields[0].Trim(); double weight = 1; if (fields.Length > 1 && (!fields[1].Trim().StartsWith("q=", StringComparison.OrdinalIgnoreCase) || !double.TryParse(fields[1].Trim()[2..], NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out weight))) continue; if (weight <= 0 || weight > 1 || !double.IsFinite(weight)) continue; var language = tag.StartsWith("zh", StringComparison.OrdinalIgnoreCase) ? "zh-CN" : tag == "*" || tag.Equals("en", StringComparison.OrdinalIgnoreCase) || tag.StartsWith("en-", StringComparison.OrdinalIgnoreCase) ? "en-US" : null; if (language is not null) candidates.Add((language, weight, candidates.Count)); } return candidates.OrderByDescending(x => x.weight).ThenBy(x => x.order).Select(x => x.language).FirstOrDefault() ?? "en-US"; } private static string Resolve(string name) => name.StartsWith("zh", StringComparison.OrdinalIgnoreCase) ? "zh-CN" : "en-US"; private static IReadOnlyDictionary Load(string language) { using var stream = typeof(AppText).Assembly.GetManifestResourceStream("Localization." + language + ".json") ?? throw new InvalidOperationException("Missing embedded language: " + language); using var document = JsonDocument.Parse(stream); var entries = new Dictionary(StringComparer.Ordinal); foreach (var property in document.RootElement.EnumerateObject()) entries.Add(property.Name, property.Value.GetString()!); return new ReadOnlyDictionary(entries); } private sealed class CultureScope(CultureInfo prior, string? priorRequest) : IDisposable { public void Dispose() { RequestLanguage.Value = priorRequest; CultureInfo.CurrentUICulture = prior; } } }