.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
218 lines
12 KiB
C#
218 lines
12 KiB
C#
using AppKit;
|
|
using CoreGraphics;
|
|
using VoiceCat.Core;
|
|
using VoiceCat.Crypto;
|
|
|
|
namespace VoiceCat.Mac;
|
|
|
|
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" };
|
|
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, 570, 390), NSWindowStyle.Titled | NSWindowStyle.Closable,
|
|
NSBackingStore.Buffered, false))
|
|
{
|
|
Window!.Title = "Connect to VoiceCat"; Window.Center();
|
|
var view = Window.ContentView!;
|
|
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";
|
|
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);
|
|
((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; 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 ?? "";
|
|
string? storedPassword = profile.Authentication == ServerAuthentication.Account
|
|
? keychain.Load(profile.Id) ?? keychain.LoadLegacy(profile.LegacyKeychainTag) : null;
|
|
password.StringValue = storedPassword ?? "";
|
|
rememberPassword.State = storedPassword is null ? NSCellStateValue.Off : NSCellStateValue.On;
|
|
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;
|
|
rememberPassword.Enabled = account && editingId is not null;
|
|
}
|
|
|
|
private void SaveProfile(object? sender, EventArgs args)
|
|
{
|
|
try
|
|
{
|
|
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;
|
|
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; }
|
|
}
|
|
|
|
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;
|
|
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)
|
|
{
|
|
if (client is not null) return;
|
|
try
|
|
{
|
|
SetBusy(true); status.StringValue = "Connecting…";
|
|
ServerProfile profile = ReadProfile();
|
|
string pins = Path.Combine(SupportDirectory, "tofu_pins.txt");
|
|
client = new("VoiceCat macOS", "0.1.0", pins);
|
|
await client.ConnectAsync(profile.Host, profile.Port, ConfirmIdentity);
|
|
string accountPassword = password.StringValue;
|
|
if (profile.Authentication == ServerAuthentication.Account && accountPassword.Length == 0)
|
|
accountPassword = keychain.Load(profile.Id) ?? keychain.LoadLegacy(profile.LegacyKeychainTag) ?? "";
|
|
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();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
if (client is not null) await client.DisposeAsync(); client = null;
|
|
status.StringValue = exception.Message; SetBusy(false);
|
|
}
|
|
}
|
|
|
|
private ServerProfile ReadProfile()
|
|
{
|
|
(string serverHost, ushort port) = ParseEndpoint(host.StringValue);
|
|
bool account = authentication.IndexOfSelectedItem == 1;
|
|
string name = identity.StringValue.Trim();
|
|
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;
|
|
rememberPassword.Enabled = !busy && authentication.IndexOfSelectedItem == 1 && editingId is not null;
|
|
}
|
|
|
|
private ValueTask<bool> ConfirmIdentity(ServerIdentityChallenge challenge, CancellationToken cancellationToken)
|
|
{
|
|
var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
NSApplication.SharedApplication.InvokeOnMainThread(() =>
|
|
{
|
|
var alert = new NSAlert { MessageText = challenge.Status == TofuStatus.FirstConnect ? "Trust this VoiceCat server?" : "Server identity changed",
|
|
InformativeText = $"{challenge.Host}:{challenge.Port}\n\nCertificate SHA-256:\n{challenge.CertificateFingerprint}", AlertStyle = challenge.Status == TofuStatus.Mismatch ? NSAlertStyle.Critical : NSAlertStyle.Informational };
|
|
alert.AddButton("Trust and connect"); alert.AddButton("Cancel"); completion.TrySetResult(alert.RunModal() == 1000);
|
|
});
|
|
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(':');
|
|
if (separator <= 0 || !ushort.TryParse(value[(separator + 1)..], out ushort port) || port == 0) throw new ArgumentException("Enter a server as host: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}";
|
|
}
|