2026-09-16 22:13:34 +02:00
using System.Text.Json ;
using System.Text.Json.Serialization ;
namespace VoiceCat.Core ;
public enum ServerAuthentication { Guest , Account }
2026-09-19 00:39:04 +02:00
public sealed record ServerProfile ( Guid Id , string Host , ushort Port , ServerAuthentication Authentication , string? Username , string? Nickname ,
[property: JsonIgnore] string? LegacyKeychainTag = null )
2026-09-16 22:13:34 +02:00
{
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 < ServerProfile > Load ()
{
try
{
if (! File . Exists ( path )) return [];
2026-09-19 00:39:04 +02:00
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 );
2026-09-19 15:43:37 +02:00
return ( JsonSerializer . Deserialize ( contents , ServerProfileJsonContext . Default . ServerProfileArray ) ?? [])
2026-09-16 22:13:34 +02:00
. Where ( profile => profile . IsValid ). ToArray ();
}
catch ( Exception exception ) when ( exception is IOException or UnauthorizedAccessException or JsonException ) { return []; }
}
public void Save ( IEnumerable < ServerProfile > 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 )!);
2026-09-19 00:39:04 +02:00
PreserveLegacyBackup ( fullPath );
2026-09-16 22:13:34 +02:00
string temporary = fullPath + "." + Guid . NewGuid (). ToString ( "N" ) + ".tmp" ;
try
{
2026-09-19 15:43:37 +02:00
File . WriteAllBytes ( temporary , JsonSerializer . SerializeToUtf8Bytes ( valid , ServerProfileJsonContext . Default . ServerProfileArray ));
2026-09-16 22:13:34 +02:00
File . Move ( temporary , fullPath , true );
}
finally { if ( File . Exists ( temporary )) File . Delete ( temporary ); }
}
2026-09-19 00:39:04 +02:00
private static bool LooksLegacy ( JsonElement item ) => item . ValueKind == JsonValueKind . Object && item . TryGetProperty ( "authMode" , out _ );
private static IReadOnlyList < ServerProfile > LoadLegacy ( JsonElement root )
{
var profiles = new List < ServerProfile >();
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 ) { }
}
2026-09-16 22:13:34 +02:00
}
2026-09-19 15:43:37 +02:00
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true, UseStringEnumConverter = true)]
[JsonSerializable(typeof(ServerProfile[] ))]
internal sealed partial class ServerProfileJsonContext : JsonSerializerContext ;