From 52f7f51e59fb15203de1468465397be09ce3efe9 Mon Sep 17 00:00:00 2001 From: Talon Date: Wed, 16 Sep 2026 22:13:34 +0200 Subject: [PATCH] Add managed server profiles and account login --- PROGRESS.md | 13 +- clients/apple/dotnet/README.md | 2 +- .../VoiceCat.Mac/ConnectWindowController.cs | 141 +++++++++++++++--- docs/api-dotnet.md | 12 ++ docs/porting-to-dotnet.md | 9 +- dotnet/src/VoiceCat.Core/ServerProfile.cs | 66 ++++++++ .../VoiceCat.Tests/ServerProfileTests.cs | 50 +++++++ 7 files changed, 267 insertions(+), 26 deletions(-) create mode 100644 dotnet/src/VoiceCat.Core/ServerProfile.cs create mode 100644 dotnet/tests/VoiceCat.Tests/ServerProfileTests.cs diff --git a/PROGRESS.md b/PROGRESS.md index 9f2e35a..ab59cc6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 diff --git a/clients/apple/dotnet/README.md b/clients/apple/dotnet/README.md index d13cb64..511eb5a 100644 --- a/clients/apple/dotnet/README.md +++ b/clients/apple/dotnet/README.md @@ -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: diff --git a/clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs b/clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs index 04302f7..7d16276 100644 --- a/clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs +++ b/clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs @@ -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 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 ConfirmIdentity(ServerIdentityChallenge challenge, CancellationToken cancellationToken) { var completion = new TaskCompletionSource(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}"; } diff --git a/docs/api-dotnet.md b/docs/api-dotnet.md index d7710c9..b60f425 100644 --- a/docs/api-dotnet.md +++ b/docs/api-dotnet.md @@ -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. diff --git a/docs/porting-to-dotnet.md b/docs/porting-to-dotnet.md index 9f448bc..d24d25b 100644 --- a/docs/porting-to-dotnet.md +++ b/docs/porting-to-dotnet.md @@ -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. --- diff --git a/dotnet/src/VoiceCat.Core/ServerProfile.cs b/dotnet/src/VoiceCat.Core/ServerProfile.cs new file mode 100644 index 0000000..7334077 --- /dev/null +++ b/dotnet/src/VoiceCat.Core/ServerProfile.cs @@ -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 Load() + { + try + { + if (!File.Exists(path)) return []; + return (JsonSerializer.Deserialize(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 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); } + } +} diff --git a/dotnet/tests/VoiceCat.Tests/ServerProfileTests.cs b/dotnet/tests/VoiceCat.Tests/ServerProfileTests.cs new file mode 100644 index 0000000..58fa974 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/ServerProfileTests.cs @@ -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(() => ServerProfile.Create("voice.example", 8384, ServerAuthentication.Account)); + } +}