Add private messaging to managed macOS client
.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:24:52 +02:00
parent 28f1dbb991
commit ab460ae12b
4 changed files with 32 additions and 8 deletions
+2 -1
View File
@@ -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.
+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 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:
@@ -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;
+3 -2
View File
@@ -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.