From ab460ae12b9dc56ce831801f0811a2d7591d74e4 Mon Sep 17 00:00:00 2001 From: Talon Date: Wed, 16 Sep 2026 22:24:52 +0200 Subject: [PATCH] Add private messaging to managed macOS client --- PROGRESS.md | 3 +- clients/apple/dotnet/README.md | 2 +- .../VoiceCat.Mac/MainWindowController.cs | 30 ++++++++++++++++--- docs/porting-to-dotnet.md | 5 ++-- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 7faf92f..000f9f0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -29,11 +29,12 @@ up instantly. Newest status at the top. joins restore the selected channel and voice session. Saved account profiles can opt into a macOS Keychain generic-password item keyed by profile ID; secrets are written only after successful authentication and removed when remembering is disabled or the profile is deleted. + The composer can now target the current channel or any connected user, and labels private + messages with the other participant. 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 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 ae93b4f..6e1faa1 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), 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. An account password can be remembered as a macOS Keychain generic-password item only after successful authentication; it is never written to profile JSON and is removed when remembering is disabled or the profile is deleted. 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. 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 and private 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. An account password can be remembered as a macOS Keychain generic-password item only after successful authentication; it is never written to profile JSON and is removed when remembering is disabled or the profile is deleted. 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. Non-default device selection, moderation sheets, 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 4d01a52..51346ca 100644 --- a/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs +++ b/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs @@ -15,7 +15,8 @@ internal sealed class MainWindowController : NSWindowController private readonly NSPopUpButton channels = new(new CGRect(20, 515, 300, 28), false); private readonly NSTextView users = new(new CGRect(0, 0, 190, 430)) { Editable = false, Selectable = true }; private readonly NSTextView chat = new(new CGRect(0, 0, 510, 390)) { Editable = false, Selectable = true }; - private readonly NSTextField compose = new(new CGRect(230, 55, 400, 28)) { PlaceholderString = "Message to current channel" }; + private readonly NSPopUpButton messageTarget = new(new CGRect(230, 55, 160, 28), false); + private readonly NSTextField compose = new(new CGRect(400, 55, 230, 28)) { PlaceholderString = "Message" }; private readonly NSButton send = new(new CGRect(640, 53, 90, 32)) { Title = "Send" }; private readonly NSButton voice = new(new CGRect(630, 510, 100, 32)) { Title = "Join voice" }; private readonly NSTextField status = NSTextField.CreateLabel("Connected"); @@ -37,6 +38,7 @@ internal sealed class MainWindowController : NSWindowController ((INSAccessibility)voice).AccessibilityLabel = "Join or leave voice"; voice.Activated += ToggleVoice; content.AddSubview(voice); var userScroll = new NSScrollView(new CGRect(20, 90, 190, 410)) { HasVerticalScroller = true, DocumentView = users }; userScroll.AccessibilityLabel = "Users in channel"; content.AddSubview(userScroll); var chatScroll = new NSScrollView(new CGRect(230, 90, 500, 410)) { HasVerticalScroller = true, DocumentView = chat }; chatScroll.AccessibilityLabel = "Channel messages"; content.AddSubview(chatScroll); + ((INSAccessibility)messageTarget).AccessibilityLabel = "Message recipient"; content.AddSubview(messageTarget); ((INSAccessibility)compose).AccessibilityLabel = "Message"; compose.Activated += Send; content.AddSubview(compose); ((INSAccessibility)send).AccessibilityLabel = "Send message"; send.Activated += Send; content.AddSubview(send); status.Frame = new CGRect(20, 22, 700, 22); status.AccessibilityLabel = "Connection status"; content.AddSubview(status); @@ -48,12 +50,12 @@ internal sealed class MainWindowController : NSWindowController { while (client.TryReadEvent(out Envelope? envelope)) { - if (envelope!.TextMessage is { } text) Append($"[{DateTime.Now:t}] {Name(text.SenderId)}: {text.Body}"); + if (envelope!.TextMessage is { } text) Append(FormatMessage(text)); if (envelope.ServerState is not null || envelope.ChannelEvent is not null || envelope.UserEvent is not null) RefreshState(); if (envelope.Disconnect is { } disconnected) { StopAudioDevices(); joinedVoice = false; voice.Title = "Join voice"; - status.StringValue = "Disconnected: " + disconnected.Reason; voice.Enabled = send.Enabled = false; + status.StringValue = "Disconnected: " + disconnected.Reason; voice.Enabled = send.Enabled = messageTarget.Enabled = false; } } if (client.Audio.Failure is { } failure) status.StringValue = "Audio stopped: " + failure.Message; @@ -61,6 +63,7 @@ internal sealed class MainWindowController : NSWindowController } private void RefreshState() { + uint previousTarget = uint.TryParse(messageTarget.SelectedItem?.RepresentedObject?.ToString(), out uint selectedTarget) ? selectedTarget : 0; channels.RemoveAllItems(); foreach (var channel in client.Channels.OrderBy(c => c.Order).ThenBy(c => c.Name)) { @@ -70,6 +73,14 @@ internal sealed class MainWindowController : NSWindowController 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)); + messageTarget.RemoveAllItems(); messageTarget.AddItem("Current channel"); messageTarget.LastItem!.RepresentedObject = new NSString("0"); + foreach (var user in client.Users.Where(user => user.Id != selfId).OrderBy(user => user.Nickname)) + { + messageTarget.AddItem("Private: " + user.Nickname); + messageTarget.LastItem!.RepresentedObject = new NSString(user.Id.ToString()); + } + int targetIndex = client.Users.Where(user => user.Id != selfId).OrderBy(user => user.Nickname).ToList().FindIndex(user => user.Id == previousTarget) + 1; + messageTarget.SelectItem(Math.Max(0, targetIndex)); status.StringValue = $"Connected · {client.Users.Count} users"; } private async void ChangeChannel(object? sender, EventArgs args) @@ -121,7 +132,8 @@ internal sealed class MainWindowController : NSWindowController private void Send(object? sender, EventArgs args) { string body = compose.StringValue.Trim(); if (body.Length == 0) return; - try { client.Send(new() { TextMessage = new() { Scope = TextScope.TextChannel, TargetId = currentChannel, Body = body, ClientMsgId = Guid.NewGuid().ToString("N") } }); compose.StringValue = ""; } + uint target = uint.TryParse(messageTarget.SelectedItem?.RepresentedObject?.ToString(), out uint selectedTarget) ? selectedTarget : 0; + try { client.Send(new() { TextMessage = new() { Scope = target == 0 ? TextScope.TextChannel : TextScope.TextPrivate, TargetId = target == 0 ? currentChannel : target, Body = body, ClientMsgId = Guid.NewGuid().ToString("N") } }); compose.StringValue = ""; } catch (Exception exception) { status.StringValue = exception.Message; } } private async void ToggleVoice(object? sender, EventArgs args) @@ -184,6 +196,16 @@ internal sealed class MainWindowController : NSWindowController Interlocked.Exchange(ref playback, null)?.Dispose(); } private string Name(uint id) => client.Users.FirstOrDefault(u => u.Id == id)?.Nickname ?? $"User {id}"; + private string FormatMessage(TextMessage text) + { + string prefix = text.Scope switch + { + TextScope.TextPrivate => $"[private: {Name(text.SenderId == selfId ? text.TargetId : text.SenderId)}] ", + TextScope.TextServer => "[server] ", + _ => "" + }; + return $"[{DateTime.Now:t}] {prefix}{Name(text.SenderId)}: {text.Body}"; + } private void Append(string line) { chat.Value = chat.Value.Length == 0 ? line : chat.Value + "\n" + line; diff --git a/docs/porting-to-dotnet.md b/docs/porting-to-dotnet.md index 1c37ba3..46fd7b6 100644 --- a/docs/porting-to-dotnet.md +++ b/docs/porting-to-dotnet.md @@ -853,7 +853,8 @@ 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, protected-channel password prompts, channel browsing, roster/chat, +interactive TOFU approval, protected-channel password prompts, channel browsing, roster, +channel/private text, voice subscription, native accessibility labels and default-device microphone/playback. Account passwords can be stored in macOS Keychain after successful authentication and are removed when the user disables remembering or deletes the profile. @@ -864,7 +865,7 @@ 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 selectable devices, the rest of the moderation/settings +app remains the release client until selectable devices and the moderation/settings surface, ScreenCaptureKit, VoiceOver validation, signing and notarization are complete.