using System.Text.Json; using System.Text.Json.Serialization; namespace VoiceCat.Core; public enum ServerAuthentication { Guest, Account } public sealed record ServerProfile(Guid Id, string Host, ushort Port, ServerAuthentication Authentication, string? Username, string? Nickname, [property: JsonIgnore] string? LegacyKeychainTag = null) { public static ServerProfile Create(string host, ushort port, ServerAuthentication authentication, string? username = null, string? nickname = null, Guid? id = null) { host = host.Trim(); username = Normalize(username); nickname = Normalize(nickname); if (host.Length == 0) throw new ArgumentException("Server host is required.", nameof(host)); if (port == 0) throw new ArgumentOutOfRangeException(nameof(port)); if (authentication == ServerAuthentication.Account && username is null) throw new ArgumentException("Username is required for account authentication.", nameof(username)); return new(id.GetValueOrDefault(Guid.NewGuid()), host, port, authentication, authentication == ServerAuthentication.Account ? username : null, authentication == ServerAuthentication.Guest ? nickname : null); } [JsonIgnore] public string DisplayName => Authentication == ServerAuthentication.Account ? $"{Username}@{Host}:{Port}" : $"{Host}:{Port} (Guest{(Nickname is null ? "" : $": {Nickname}")})"; internal bool IsValid => Id != Guid.Empty && !string.IsNullOrWhiteSpace(Host) && Port != 0 && (Authentication == ServerAuthentication.Guest || !string.IsNullOrWhiteSpace(Username)); private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } public sealed class ServerProfileStore(string path) { public IReadOnlyList Load() { try { if (!File.Exists(path)) return []; byte[] contents = File.ReadAllBytes(path); using JsonDocument document = JsonDocument.Parse(contents); if (document.RootElement.ValueKind == JsonValueKind.Array && document.RootElement.EnumerateArray().Any(LooksLegacy)) return LoadLegacy(document.RootElement); return (JsonSerializer.Deserialize(contents, ServerProfileJsonContext.Default.ServerProfileArray) ?? []) .Where(profile => profile.IsValid).ToArray(); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { return []; } } public void Save(IEnumerable profiles) { ArgumentNullException.ThrowIfNull(profiles); ServerProfile[] valid = profiles.Where(profile => profile is not null && profile.IsValid).ToArray(); string fullPath = Path.GetFullPath(path); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); PreserveLegacyBackup(fullPath); string temporary = fullPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; try { File.WriteAllBytes(temporary, JsonSerializer.SerializeToUtf8Bytes(valid, ServerProfileJsonContext.Default.ServerProfileArray)); File.Move(temporary, fullPath, true); } finally { if (File.Exists(temporary)) File.Delete(temporary); } } private static bool LooksLegacy(JsonElement item) => item.ValueKind == JsonValueKind.Object && item.TryGetProperty("authMode", out _); private static IReadOnlyList LoadLegacy(JsonElement root) { var profiles = new List(); foreach (JsonElement item in root.EnumerateArray()) { if (!item.TryGetProperty("id", out JsonElement idValue) || !Guid.TryParse(idValue.GetString(), out Guid id) || !item.TryGetProperty("host", out JsonElement hostValue) || !item.TryGetProperty("port", out JsonElement portValue) || !portValue.TryGetUInt16(out ushort port)) continue; string? mode = item.TryGetProperty("authMode", out JsonElement modeValue) ? modeValue.GetString() : null; string? username = item.TryGetProperty("savedUsername", out JsonElement usernameValue) ? usernameValue.GetString() : null; string? nickname = item.TryGetProperty("nickname", out JsonElement nicknameValue) ? nicknameValue.GetString() : null; string? keychainTag = item.TryGetProperty("keychainTag", out JsonElement tagValue) ? tagValue.GetString() : null; ServerAuthentication authentication = mode == "password" ? ServerAuthentication.Account : ServerAuthentication.Guest; try { profiles.Add(ServerProfile.Create(hostValue.GetString() ?? "", port, authentication, username, nickname, id) with { LegacyKeychainTag = keychainTag }); } catch (ArgumentException) { } } return profiles; } private static void PreserveLegacyBackup(string fullPath) { if (!File.Exists(fullPath)) return; try { using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(fullPath)); if (document.RootElement.ValueKind != JsonValueKind.Array || !document.RootElement.EnumerateArray().Any(LooksLegacy)) return; string backup = fullPath + ".swift-backup.json"; if (!File.Exists(backup)) File.Copy(fullPath, backup); } catch (JsonException) { } } } [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true, UseStringEnumConverter = true)] [JsonSerializable(typeof(ServerProfile[]))] internal sealed partial class ServerProfileJsonContext : JsonSerializerContext;