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
.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:
+2
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user