Add managed server profiles and account login
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-16 22:13:34 +02:00
parent edd7783a5c
commit 52f7f51e59
7 changed files with 267 additions and 26 deletions
+66
View File
@@ -0,0 +1,66 @@
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)
{
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)
{
private static readonly JsonSerializerOptions Json = new()
{
Converters = { new JsonStringEnumConverter() },
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public IReadOnlyList<ServerProfile> Load()
{
try
{
if (!File.Exists(path)) return [];
return (JsonSerializer.Deserialize<ServerProfile[]>(File.ReadAllBytes(path), Json) ?? [])
.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)!);
string temporary = fullPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
File.WriteAllBytes(temporary, JsonSerializer.SerializeToUtf8Bytes(valid, Json));
File.Move(temporary, fullPath, true);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
}
@@ -0,0 +1,50 @@
using VoiceCat.Core;
namespace VoiceCat.Tests;
public sealed class ServerProfileTests
{
[Fact]
public void ProfilesRoundTripWithoutPasswordMaterial()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-profile-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "servers.json");
try
{
var guest = ServerProfile.Create(" voice.example ", 8384, ServerAuthentication.Guest, nickname: " Cat ");
var account = ServerProfile.Create("secure.example", 9443, ServerAuthentication.Account, username: " talon ");
var store = new ServerProfileStore(path);
store.Save([guest, account]);
Assert.Equal([guest, account], store.Load());
string json = File.ReadAllText(path);
Assert.Contains("\"authentication\": \"Account\"", json);
Assert.DoesNotContain("password", json, StringComparison.OrdinalIgnoreCase);
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
[Fact]
public void MissingCorruptAndInvalidProfilesDoNotBreakStartup()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-profile-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "servers.json");
try
{
var store = new ServerProfileStore(path);
Assert.Empty(store.Load());
Directory.CreateDirectory(directory);
File.WriteAllText(path, "not json");
Assert.Empty(store.Load());
File.WriteAllText(path, "[{\"id\":\"00000000-0000-0000-0000-000000000000\",\"host\":\"\",\"port\":0,\"authentication\":\"Guest\"}]");
Assert.Empty(store.Load());
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
[Fact]
public void AccountProfilesRequireAUsername()
{
Assert.Throws<ArgumentException>(() => ServerProfile.Create("voice.example", 8384, ServerAuthentication.Account));
}
}