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
.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:
+9
-4
@@ -11,7 +11,7 @@ up instantly. Newest status at the top.
|
||||
## ▶ Where we left off / next action
|
||||
|
||||
- **In progress (2026-09-16): managed macOS client and default audio path.** Added a
|
||||
separate .NET 10 AppKit solution with application/menu lifecycle, guest connection,
|
||||
separate .NET 10 AppKit solution with application/menu lifecycle, guest/account connection,
|
||||
explicit TOFU approval, channel selection, roster/chat, disconnect state, accessibility
|
||||
labels, sandbox entitlements and deterministic disposal. Voice join now opens bounded
|
||||
stereo playback, subscribes, announces a managed microphone stream, converts the default
|
||||
@@ -21,10 +21,15 @@ up instantly. Newest status at the top.
|
||||
allocates no managed memory and never locks or blocks. Current AppKit/AVFoundation calls
|
||||
compile warning-free against Microsoft's 26.4.10259 macOS reference assembly. Added a
|
||||
macOS CI gate for the real workload, native Opus/RNNoise shim and Apple solution. Local
|
||||
workload installation remains blocked by this Windows machine's unrelated Visual Studio
|
||||
server profiles now persist validated host, port, authentication mode and identity through
|
||||
`VoiceCat.Core`; malformed profile files do not prevent startup, writes replace atomically,
|
||||
and passwords are deliberately excluded from the model and JSON. Three behavior tests cover
|
||||
profile round trips, corrupt input and account validation. Workload installation remains
|
||||
blocked by this Windows machine's unrelated Visual Studio
|
||||
iOS/Android MSI repair failure, so device execution is not claimed. **Next:** run the
|
||||
macOS CI/device capture-playback test and ten-minute listen gate, then add saved accounts/
|
||||
servers, non-default devices, private messages, moderation/settings, ScreenCaptureKit,
|
||||
macOS CI/device capture-playback test and ten-minute listen gate, then add protected-channel
|
||||
prompts, optional Keychain credentials, non-default devices, private messages,
|
||||
moderation/settings, ScreenCaptureKit,
|
||||
VoiceOver verification, signing and notarization.
|
||||
|
||||
- **Done (2026-09-16): Linux production packaging checkpoint.** Added a real TLS 1.3
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
`VoiceCat.Mac` is the native AppKit C# port. It targets `net10.0-macos` and references the same `VoiceCat.Core` and `VoiceCat.Audio` assemblies used by the Windows client and managed CLI.
|
||||
|
||||
The current checkpoint is a functional guest client: AppKit launch/menu lifecycle, host and nickname entry, explicit TOFU approval (including changed-key warning), channel selection, user roster, channel text, voice subscription, managed microphone-stream lifecycle, default-device Core Audio capture/playback, disconnect reporting, native accessibility labels, sandbox/network/audio entitlements, and deterministic disposal. Capture is converted to interleaved 48 kHz int16 PCM through `AVAudioConverter`; playback uses an `AVAudioSourceNode` and the shared bounded `PcmRing`, so its render callback does not allocate, lock, or block. It does not yet replace the Swift release. Saved servers/accounts, non-default device selection, moderation sheets, private messages, settings, ScreenCaptureKit sharing, VoiceOver verification, signing and notarization remain.
|
||||
The current checkpoint is a functional client: AppKit launch/menu lifecycle, saved server profiles, guest and account authentication, explicit TOFU approval (including changed-key warning), channel selection, user roster, channel text, voice subscription, managed microphone-stream lifecycle, default-device Core Audio capture/playback, disconnect reporting, native accessibility labels, sandbox/network/audio entitlements, and deterministic disposal. Profiles save the host, port, authentication mode, username or guest nickname; account passwords remain session-only and are never written to the profile JSON. Capture is converted to interleaved 48 kHz int16 PCM through `AVAudioConverter`; playback uses an `AVAudioSourceNode` and the shared bounded `PcmRing`, so its render callback does not allocate, lock, or block. It does not yet replace the Swift release. Protected-channel prompts, optional Keychain credentials, non-default device selection, moderation sheets, private messages, settings, ScreenCaptureKit sharing, VoiceOver verification, signing and notarization remain.
|
||||
|
||||
Build on Apple Silicon macOS 15.6+ with Xcode 26 and the .NET 10 macOS workload:
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using AppKit;
|
||||
using CoreGraphics;
|
||||
using Foundation;
|
||||
using VoiceCat.Core;
|
||||
using VoiceCat.Crypto;
|
||||
|
||||
@@ -8,23 +7,107 @@ namespace VoiceCat.Mac;
|
||||
|
||||
internal sealed class ConnectWindowController : NSWindowController
|
||||
{
|
||||
private readonly NSTextField host = new(new CGRect(120, 180, 280, 26)) { StringValue = "127.0.0.1:8384", PlaceholderString = "Host:port" };
|
||||
private readonly NSTextField nickname = new(new CGRect(120, 140, 280, 26)) { StringValue = Environment.UserName, PlaceholderString = "Nickname" };
|
||||
private static readonly string SupportDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat");
|
||||
private readonly ServerProfileStore profileStore = new(Path.Combine(SupportDirectory, "servers.json"));
|
||||
private readonly NSPopUpButton profiles = new(new CGRect(120, 315, 330, 28), false);
|
||||
private readonly NSTextField host = new(new CGRect(120, 270, 330, 26)) { StringValue = "127.0.0.1:8384", PlaceholderString = "Host:port" };
|
||||
private readonly NSPopUpButton authentication = new(new CGRect(120, 225, 180, 28), false);
|
||||
private readonly NSTextField identity = new(new CGRect(120, 180, 330, 26)) { StringValue = Environment.UserName };
|
||||
private readonly NSTextField identityLabel;
|
||||
private readonly NSSecureTextField password = new(new CGRect(120, 135, 330, 26)) { PlaceholderString = "Password" };
|
||||
private readonly NSTextField status = NSTextField.CreateLabel("Ready to connect");
|
||||
private readonly NSButton connect = new(new CGRect(300, 55, 100, 32)) { Title = "Connect", BezelStyle = NSBezelStyle.Rounded };
|
||||
private readonly NSButton save = new(new CGRect(120, 75, 90, 32)) { Title = "Save" };
|
||||
private readonly NSButton remove = new(new CGRect(220, 75, 90, 32)) { Title = "Remove" };
|
||||
private readonly NSButton connect = new(new CGRect(360, 75, 90, 32)) { Title = "Connect", BezelStyle = NSBezelStyle.Rounded };
|
||||
private List<ServerProfile> saved = [];
|
||||
private Guid? editingId;
|
||||
private VoiceCatClient? client;
|
||||
private MainWindowController? main;
|
||||
|
||||
internal ConnectWindowController() : base(new NSWindow(new CGRect(0, 0, 520, 260), NSWindowStyle.Titled | NSWindowStyle.Closable,
|
||||
internal ConnectWindowController() : base(new NSWindow(new CGRect(0, 0, 570, 390), NSWindowStyle.Titled | NSWindowStyle.Closable,
|
||||
NSBackingStore.Buffered, false))
|
||||
{
|
||||
Window!.Title = "Connect to VoiceCat"; Window.Center();
|
||||
var view = Window.ContentView!;
|
||||
var hostLabel = NSTextField.CreateLabel("Server"); hostLabel.Frame = new CGRect(30, 185, 80, 20); view.AddSubview(hostLabel); view.AddSubview(host);
|
||||
var nicknameLabel = NSTextField.CreateLabel("Nickname"); nicknameLabel.Frame = new CGRect(30, 145, 80, 20); view.AddSubview(nicknameLabel); view.AddSubview(nickname);
|
||||
status.Frame = new CGRect(30, 95, 370, 22); status.AccessibilityLabel = "Connection status"; view.AddSubview(status);
|
||||
AddLabel(view, "Saved server", 25, 320); AddLabel(view, "Server", 25, 275); AddLabel(view, "Authentication", 25, 230);
|
||||
identityLabel = AddLabel(view, "Nickname", 25, 185); AddLabel(view, "Password", 25, 140);
|
||||
authentication.AddItem("Guest"); authentication.AddItem("Account"); authentication.Activated += AuthenticationChanged;
|
||||
profiles.Activated += ProfileChanged;
|
||||
((INSAccessibility)profiles).AccessibilityLabel = "Saved servers";
|
||||
((INSAccessibility)host).AccessibilityLabel = "Server hostname and port";
|
||||
((INSAccessibility)authentication).AccessibilityLabel = "Authentication mode";
|
||||
((INSAccessibility)identity).AccessibilityLabel = "Guest nickname";
|
||||
((INSAccessibility)password).AccessibilityLabel = "Account password";
|
||||
view.AddSubview(profiles); view.AddSubview(host); view.AddSubview(authentication); view.AddSubview(identity); view.AddSubview(password);
|
||||
status.Frame = new CGRect(25, 30, 520, 24); status.AccessibilityLabel = "Connection status"; view.AddSubview(status);
|
||||
((INSAccessibility)save).AccessibilityLabel = "Save server profile"; save.Activated += SaveProfile; view.AddSubview(save);
|
||||
((INSAccessibility)remove).AccessibilityLabel = "Remove server profile"; remove.Activated += RemoveProfile; view.AddSubview(remove);
|
||||
((INSAccessibility)connect).AccessibilityLabel = "Connect to server"; connect.Activated += Connect; view.AddSubview(connect);
|
||||
Window.DefaultButtonCell = connect.Cell;
|
||||
ReloadProfiles(); UpdateAuthentication();
|
||||
}
|
||||
|
||||
private static NSTextField AddLabel(NSView view, string text, double x, double y)
|
||||
{
|
||||
var label = NSTextField.CreateLabel(text); label.Frame = new CGRect(x, y, 90, 20); view.AddSubview(label); return label;
|
||||
}
|
||||
|
||||
private void ReloadProfiles(Guid? select = null)
|
||||
{
|
||||
saved = profileStore.Load().ToList();
|
||||
profiles.RemoveAllItems(); profiles.AddItem("New server…");
|
||||
foreach (ServerProfile profile in saved) profiles.AddItem(profile.DisplayName);
|
||||
int index = select is null ? (saved.Count == 0 ? 0 : 1) : saved.FindIndex(item => item.Id == select) + 1;
|
||||
profiles.SelectItem(Math.Max(0, index));
|
||||
ApplySelectedProfile();
|
||||
}
|
||||
|
||||
private void ProfileChanged(object? sender, EventArgs args) => ApplySelectedProfile();
|
||||
|
||||
private void ApplySelectedProfile()
|
||||
{
|
||||
int index = checked((int)profiles.IndexOfSelectedItem) - 1;
|
||||
if (index < 0 || index >= saved.Count)
|
||||
{
|
||||
editingId = null; remove.Enabled = false; return;
|
||||
}
|
||||
ServerProfile profile = saved[index]; editingId = profile.Id; remove.Enabled = true;
|
||||
host.StringValue = FormatEndpoint(profile.Host, profile.Port);
|
||||
authentication.SelectItem(profile.Authentication == ServerAuthentication.Guest ? 0 : 1);
|
||||
identity.StringValue = profile.Authentication == ServerAuthentication.Guest ? profile.Nickname ?? Environment.UserName : profile.Username ?? "";
|
||||
password.StringValue = ""; UpdateAuthentication();
|
||||
}
|
||||
|
||||
private void AuthenticationChanged(object? sender, EventArgs args) => UpdateAuthentication();
|
||||
|
||||
private void UpdateAuthentication()
|
||||
{
|
||||
bool account = authentication.IndexOfSelectedItem == 1;
|
||||
identityLabel.StringValue = account ? "Username" : "Nickname";
|
||||
identity.PlaceholderString = account ? "Username" : "Nickname";
|
||||
((INSAccessibility)identity).AccessibilityLabel = account ? "Account username" : "Guest nickname";
|
||||
password.Enabled = account;
|
||||
}
|
||||
|
||||
private void SaveProfile(object? sender, EventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
ServerProfile profile = ReadProfile(false);
|
||||
int index = saved.FindIndex(item => item.Id == profile.Id);
|
||||
if (index < 0) saved.Add(profile); else saved[index] = profile;
|
||||
profileStore.Save(saved); ReloadProfiles(profile.Id); status.StringValue = "Server profile saved. Passwords are not stored.";
|
||||
}
|
||||
catch (Exception exception) { status.StringValue = exception.Message; }
|
||||
}
|
||||
|
||||
private void RemoveProfile(object? sender, EventArgs args)
|
||||
{
|
||||
if (editingId is not { } id) return;
|
||||
ServerProfile? profile = saved.FirstOrDefault(item => item.Id == id); if (profile is null) return;
|
||||
var alert = new NSAlert { MessageText = "Remove server?", InformativeText = profile.DisplayName, AlertStyle = NSAlertStyle.Warning };
|
||||
alert.AddButton("Remove"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return;
|
||||
saved.Remove(profile); profileStore.Save(saved); editingId = null; ReloadProfiles(); status.StringValue = "Server profile removed.";
|
||||
}
|
||||
|
||||
private async void Connect(object? sender, EventArgs args)
|
||||
@@ -32,23 +115,43 @@ internal sealed class ConnectWindowController : NSWindowController
|
||||
if (client is not null) return;
|
||||
try
|
||||
{
|
||||
connect.Enabled = false; status.StringValue = "Connecting…";
|
||||
(string serverHost, ushort port) = ParseEndpoint(host.StringValue);
|
||||
string pins = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat", "tofu.txt");
|
||||
SetBusy(true); status.StringValue = "Connecting…";
|
||||
ServerProfile profile = ReadProfile(true);
|
||||
string pins = Path.Combine(SupportDirectory, "tofu.txt");
|
||||
client = new("VoiceCat macOS", "0.1.0", pins);
|
||||
await client.ConnectAsync(serverHost, port, ConfirmIdentity);
|
||||
var auth = await client.AuthenticateGuestAsync(nickname.StringValue);
|
||||
if (!auth.Ok) throw new InvalidOperationException(auth.Error);
|
||||
main = new(client, auth.Self.Id, nickname.StringValue);
|
||||
await client.ConnectAsync(profile.Host, profile.Port, ConfirmIdentity);
|
||||
string accountPassword = password.StringValue; password.StringValue = "";
|
||||
var result = profile.Authentication == ServerAuthentication.Guest
|
||||
? await client.AuthenticateGuestAsync(profile.Nickname ?? Environment.UserName)
|
||||
: await client.AuthenticateUserAsync(profile.Username!, accountPassword);
|
||||
if (!result.Ok) throw new InvalidOperationException(result.Error);
|
||||
main = new(client, result.Self.Id, result.Self.Nickname);
|
||||
client = null; main.ShowWindow(this); Window.Close();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (client is not null) await client.DisposeAsync(); client = null;
|
||||
status.StringValue = exception.Message; connect.Enabled = true;
|
||||
status.StringValue = exception.Message; SetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
private ServerProfile ReadProfile(bool requirePassword)
|
||||
{
|
||||
(string serverHost, ushort port) = ParseEndpoint(host.StringValue);
|
||||
bool account = authentication.IndexOfSelectedItem == 1;
|
||||
string name = identity.StringValue.Trim();
|
||||
if (account && requirePassword && password.StringValue.Length == 0) throw new ArgumentException("Enter the account password. It is not saved to disk.");
|
||||
return ServerProfile.Create(serverHost, port, account ? ServerAuthentication.Account : ServerAuthentication.Guest,
|
||||
username: account ? name : null, nickname: account ? null : name, id: editingId);
|
||||
}
|
||||
|
||||
private void SetBusy(bool busy)
|
||||
{
|
||||
profiles.Enabled = host.Enabled = authentication.Enabled = identity.Enabled = save.Enabled = connect.Enabled = !busy;
|
||||
remove.Enabled = !busy && editingId is not null;
|
||||
password.Enabled = !busy && authentication.IndexOfSelectedItem == 1;
|
||||
}
|
||||
|
||||
private ValueTask<bool> ConfirmIdentity(ServerIdentityChallenge challenge, CancellationToken cancellationToken)
|
||||
{
|
||||
var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@@ -65,6 +168,10 @@ internal sealed class ConnectWindowController : NSWindowController
|
||||
{
|
||||
int separator = value.LastIndexOf(':');
|
||||
if (separator <= 0 || !ushort.TryParse(value[(separator + 1)..], out ushort port) || port == 0) throw new ArgumentException("Enter a server as host:port.");
|
||||
return (value[..separator].Trim('[', ']'), port);
|
||||
string parsedHost = value[..separator].Trim().Trim('[', ']');
|
||||
if (parsedHost.Length == 0) throw new ArgumentException("Enter a server as host:port.");
|
||||
return (parsedHost, port);
|
||||
}
|
||||
|
||||
private static string FormatEndpoint(string serverHost, ushort port) => serverHost.Contains(':') ? $"[{serverHost}]:{port}" : $"{serverHost}:{port}";
|
||||
}
|
||||
|
||||
@@ -96,6 +96,18 @@ Only explicit `Pin` or `Remove` changes the file. Pin replacement requires an
|
||||
explicit caller decision; malformed files fail closed. Changes replace the file
|
||||
atomically before updating memory. Use one owner per store/file.
|
||||
|
||||
`ServerProfile` is the app-facing saved-server model shared by platform clients. Construct
|
||||
profiles with `ServerProfile.Create`, which trims values and requires a host, nonzero port,
|
||||
and username for account authentication. A profile contains only its stable ID, endpoint,
|
||||
authentication mode, username or guest nickname. It deliberately has no password field.
|
||||
Platform clients own any secret storage, such as macOS Keychain integration.
|
||||
|
||||
`ServerProfileStore` serializes profiles as camel-case JSON with string authentication
|
||||
values. Missing, malformed and unreadable files load as an empty list; invalid individual
|
||||
profiles are discarded. Saving filters invalid entries and replaces the file through a
|
||||
same-directory temporary file. Use one owner per store/file and do not treat it as a
|
||||
credential store.
|
||||
|
||||
`ServerIdentity` reads and writes the native 96-byte Ed25519 format:
|
||||
`public-key[32] || seed[32] || public-key[32]`. Loading verifies both public-key
|
||||
copies against the seed. Disposal clears the owned seed.
|
||||
|
||||
@@ -851,7 +851,8 @@ Per §8.2. AppKit port, ScreenCaptureKit per-app audio selection, VoiceOver pari
|
||||
build produced.
|
||||
|
||||
**Checkpoint (2026-09-16):** `clients/apple/dotnet/VoiceCat.Mac` is a separate .NET 10
|
||||
AppKit application that consumes `VoiceCat.Core` directly. It implements guest connection,
|
||||
AppKit application that consumes `VoiceCat.Core` directly. It implements guest and account
|
||||
connection, validated saved server profiles whose JSON never contains passwords,
|
||||
interactive TOFU approval, channel browsing, roster/chat, voice subscription, native
|
||||
accessibility labels and default-device microphone/playback. Core Audio capture is resampled
|
||||
and converted through `AVAudioConverter` before entering the managed audio engine. Stereo
|
||||
@@ -860,9 +861,9 @@ not allocate, lock or block. Voice stream lifetime follows subscription, disconn
|
||||
channel changes. Current Apple API calls compile warning-free against Microsoft's
|
||||
26.4.10259 macOS reference assembly, and a macOS CI job builds the native codec/DSP shim and
|
||||
Apple solution. A real macOS device run and listen test remain required. The existing Swift
|
||||
app remains the release client until saved servers/accounts, selectable devices, the rest of
|
||||
the moderation/settings surface, ScreenCaptureKit, VoiceOver validation, signing and
|
||||
notarization are complete.
|
||||
app remains the release client until protected-channel prompts, optional Keychain credentials,
|
||||
selectable devices, the rest of the moderation/settings surface, ScreenCaptureKit, VoiceOver
|
||||
validation, signing and notarization are complete.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user