From 5a30018aeb9912888b429321be9fb95b175bb72f Mon Sep 17 00:00:00 2001 From: Talon Date: Wed, 16 Sep 2026 22:16:18 +0200 Subject: [PATCH] Prompt for protected macOS channels --- PROGRESS.md | 8 +-- clients/apple/dotnet/README.md | 2 +- .../VoiceCat.Mac/MainWindowController.cs | 50 ++++++++++++++++--- docs/porting-to-dotnet.md | 9 ++-- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index ab59cc6..356d432 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -24,11 +24,13 @@ up instantly. Newest status at the top. 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 + profile round trips, corrupt input and account validation. Protected channels are labeled + and prompt for a session-only password before interrupting active voice; cancel and rejected + joins restore the selected channel and voice session. 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 protected-channel - prompts, optional Keychain credentials, non-default devices, private messages, + macOS CI/device capture-playback test and ten-minute listen gate, then add optional Keychain + credentials, non-default devices, private messages, moderation/settings, ScreenCaptureKit, VoiceOver verification, signing and notarization. diff --git a/clients/apple/dotnet/README.md b/clients/apple/dotnet/README.md index 511eb5a..97d8574 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 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. +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), protected-channel password prompts, 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. 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/MainWindowController.cs b/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs index 627716f..4d01a52 100644 --- a/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs +++ b/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs @@ -23,6 +23,7 @@ internal sealed class MainWindowController : NSWindowController private uint currentChannel = 1; private uint microphoneStreamId; private bool joinedVoice; + private bool changingChannel; private IAudioCapture? microphone; private IAudioPlayback? playback; @@ -61,7 +62,11 @@ internal sealed class MainWindowController : NSWindowController private void RefreshState() { channels.RemoveAllItems(); - foreach (var channel in client.Channels.OrderBy(c => c.Order).ThenBy(c => c.Name)) { channels.AddItem(channel.Name); channels.LastItem!.RepresentedObject = new NSString(channel.Id.ToString()); } + foreach (var channel in client.Channels.OrderBy(c => c.Order).ThenBy(c => c.Name)) + { + channels.AddItem(channel.Name + (channel.PasswordProtected ? " [password]" : "")); + channels.LastItem!.RepresentedObject = new NSString(channel.Id.ToString()); + } currentChannel = client.Users.FirstOrDefault(u => u.Id == selfId)?.ChannelId ?? currentChannel; int selectedIndex = client.Channels.ToList().FindIndex(c => c.Id == currentChannel); if (selectedIndex >= 0) channels.SelectItem(selectedIndex); users.Value = string.Join("\n", client.Users.Where(u => u.ChannelId == currentChannel).OrderBy(u => u.Nickname).Select(u => (u.Id == selfId ? "You — " : "") + u.Nickname)); @@ -69,16 +74,49 @@ internal sealed class MainWindowController : NSWindowController } private async void ChangeChannel(object? sender, EventArgs args) { - if (!uint.TryParse(channels.SelectedItem?.RepresentedObject?.ToString(), out uint id) || id == currentChannel) return; + if (changingChannel || !uint.TryParse(channels.SelectedItem?.RepresentedObject?.ToString(), out uint id) || id == currentChannel) return; + var channel = client.Channels.FirstOrDefault(item => item.Id == id); + if (channel is null) { RefreshState(); return; } + string? password = channel.PasswordProtected ? PromptForChannelPassword(channel.Name) : ""; + if (password is null) { RefreshState(); return; } + bool resumeVoice = joinedVoice; try { - bool resumeVoice = joinedVoice; + changingChannel = true; channels.Enabled = false; if (resumeVoice) await LeaveVoice(); - var result = (await client.RequestAsync(new() { JoinChannel = new() { ChannelId = id } })).JoinChannelResult; - if (!result.Ok) status.StringValue = result.Error; + var result = (await client.RequestAsync(new() { JoinChannel = new() { ChannelId = id, Password = password } })).JoinChannelResult; + if (result.Ok) currentChannel = id; if (resumeVoice) await JoinVoice(); + RefreshState(); + status.StringValue = result.Ok ? $"Joined {channel.Name}" : result.Error; } - catch (Exception exception) { status.StringValue = exception.Message; } + catch (Exception exception) + { + string message = exception.Message; + if (resumeVoice && !joinedVoice) + { + try { await JoinVoice(); } + catch (Exception restoreException) { message += " Voice could not be restored: " + restoreException.Message; } + } + RefreshState(); status.StringValue = message; + } + finally { changingChannel = false; channels.Enabled = true; } + } + + private string? PromptForChannelPassword(string channelName) + { + var field = new NSSecureTextField(new CGRect(0, 0, 320, 26)) { PlaceholderString = "Channel password" }; + ((INSAccessibility)field).AccessibilityLabel = $"Password for {channelName}"; + var alert = new NSAlert + { + MessageText = $"Join {channelName}", + InformativeText = "Enter the channel password.", + AlertStyle = NSAlertStyle.Informational, + AccessoryView = field + }; + alert.AddButton("Join"); alert.AddButton("Cancel"); + if (alert.RunModal() != 1000) { field.StringValue = ""; return null; } + string value = field.StringValue; field.StringValue = ""; return value; } private void Send(object? sender, EventArgs args) { diff --git a/docs/porting-to-dotnet.md b/docs/porting-to-dotnet.md index d24d25b..1da2ea7 100644 --- a/docs/porting-to-dotnet.md +++ b/docs/porting-to-dotnet.md @@ -853,16 +853,17 @@ 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 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 +interactive TOFU approval, protected-channel password prompts, 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 playback uses `AVAudioSourceNode` and the shared bounded PCM ring; its render callback does not allocate, lock or block. Voice stream lifetime follows subscription, disconnect and 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 protected-channel prompts, optional Keychain credentials, -selectable devices, the rest of the moderation/settings surface, ScreenCaptureKit, VoiceOver +app remains the release client until optional Keychain credentials, selectable devices, the +rest of the moderation/settings surface, ScreenCaptureKit, VoiceOver validation, signing and notarization are complete. ---