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
+9 -4
View File
@@ -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
+1 -1
View File
@@ -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}";
}
+12
View File
@@ -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.
+5 -4
View File
@@ -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.
---
+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));
}
}