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
+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}";
}