Store macOS account passwords in Keychain
.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:22:03 +02:00
parent 5a30018aeb
commit 28f1dbb991
5 changed files with 120 additions and 16 deletions
@@ -9,12 +9,14 @@ internal sealed class ConnectWindowController : NSWindowController
{
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 MacKeychainPasswordStore keychain = new();
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 NSButton rememberPassword = NSButton.CreateCheckbox("Remember in Keychain", () => { });
private readonly NSTextField status = NSTextField.CreateLabel("Ready to connect");
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" };
@@ -38,7 +40,9 @@ internal sealed class ConnectWindowController : NSWindowController
((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);
rememberPassword.Frame = new CGRect(310, 225, 180, 24);
((INSAccessibility)rememberPassword).AccessibilityLabel = "Remember account password in Keychain";
view.AddSubview(profiles); view.AddSubview(host); view.AddSubview(authentication); view.AddSubview(identity); view.AddSubview(password); view.AddSubview(rememberPassword);
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);
@@ -69,13 +73,17 @@ internal sealed class ConnectWindowController : NSWindowController
int index = checked((int)profiles.IndexOfSelectedItem) - 1;
if (index < 0 || index >= saved.Count)
{
editingId = null; remove.Enabled = false; return;
editingId = null; remove.Enabled = false; password.StringValue = "";
rememberPassword.State = NSCellStateValue.Off; UpdateAuthentication(); 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();
string? storedPassword = profile.Authentication == ServerAuthentication.Account ? keychain.Load(profile.Id) : null;
password.StringValue = storedPassword ?? "";
rememberPassword.State = storedPassword is null ? NSCellStateValue.Off : NSCellStateValue.On;
UpdateAuthentication();
}
private void AuthenticationChanged(object? sender, EventArgs args) => UpdateAuthentication();
@@ -87,16 +95,26 @@ internal sealed class ConnectWindowController : NSWindowController
identity.PlaceholderString = account ? "Username" : "Nickname";
((INSAccessibility)identity).AccessibilityLabel = account ? "Account username" : "Guest nickname";
password.Enabled = account;
rememberPassword.Enabled = account && editingId is not null;
}
private void SaveProfile(object? sender, EventArgs args)
{
try
{
ServerProfile profile = ReadProfile(false);
ServerProfile profile = ReadProfile();
string pendingPassword = password.StringValue;
bool shouldRemember = rememberPassword.State == NSCellStateValue.On;
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.";
if (profile.Authentication == ServerAuthentication.Guest || !shouldRemember) keychain.Remove(profile.Id);
profileStore.Save(saved); ReloadProfiles(profile.Id);
if (profile.Authentication == ServerAuthentication.Account && shouldRemember)
{
password.StringValue = pendingPassword;
rememberPassword.State = NSCellStateValue.On;
}
status.StringValue = "Server profile saved. Passwords are stored only after successful login.";
}
catch (Exception exception) { status.StringValue = exception.Message; }
}
@@ -107,7 +125,7 @@ internal sealed class ConnectWindowController : NSWindowController
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.";
keychain.Remove(profile.Id); saved.Remove(profile); profileStore.Save(saved); editingId = null; ReloadProfiles(); status.StringValue = "Server profile removed.";
}
private async void Connect(object? sender, EventArgs args)
@@ -116,15 +134,24 @@ internal sealed class ConnectWindowController : NSWindowController
try
{
SetBusy(true); status.StringValue = "Connecting…";
ServerProfile profile = ReadProfile(true);
ServerProfile profile = ReadProfile();
string pins = Path.Combine(SupportDirectory, "tofu.txt");
client = new("VoiceCat macOS", "0.1.0", pins);
await client.ConnectAsync(profile.Host, profile.Port, ConfirmIdentity);
string accountPassword = password.StringValue; password.StringValue = "";
string accountPassword = password.StringValue;
if (profile.Authentication == ServerAuthentication.Account && accountPassword.Length == 0) accountPassword = keychain.Load(profile.Id) ?? "";
if (profile.Authentication == ServerAuthentication.Account && accountPassword.Length == 0) throw new ArgumentException("Enter the account password.");
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);
try
{
if (profile.Authentication == ServerAuthentication.Account && rememberPassword.State == NSCellStateValue.On) keychain.Save(profile.Id, accountPassword);
else keychain.Remove(profile.Id);
}
catch (Exception exception) { ShowKeychainWarning(exception.Message); }
main = new(client, result.Self.Id, result.Self.Nickname);
client = null; main.ShowWindow(this); Window.Close();
}
@@ -135,12 +162,11 @@ internal sealed class ConnectWindowController : NSWindowController
}
}
private ServerProfile ReadProfile(bool requirePassword)
private ServerProfile ReadProfile()
{
(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);
}
@@ -150,6 +176,7 @@ internal sealed class ConnectWindowController : NSWindowController
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;
rememberPassword.Enabled = !busy && authentication.IndexOfSelectedItem == 1 && editingId is not null;
}
private ValueTask<bool> ConfirmIdentity(ServerIdentityChallenge challenge, CancellationToken cancellationToken)
@@ -164,6 +191,17 @@ internal sealed class ConnectWindowController : NSWindowController
return new(completion.Task.WaitAsync(cancellationToken));
}
private static void ShowKeychainWarning(string message)
{
var alert = new NSAlert
{
MessageText = "Password storage failed",
InformativeText = message + " You are still signed in, but the Keychain setting was not changed.",
AlertStyle = NSAlertStyle.Warning
};
alert.RunModal();
}
private static (string Host, ushort Port) ParseEndpoint(string value)
{
int separator = value.LastIndexOf(':');