diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index bdb4ff6..87cfccb 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -2,9 +2,9 @@ name: .NET port
on:
push:
- paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
+ paths: ['dotnet/**', 'clients/apple/dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
pull_request:
- paths: ['dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
+ paths: ['dotnet/**', 'clients/apple/dotnet/**', 'core/**', 'server/**', 'tests/**', 'third_party/**', 'cmake/**', 'CMakeLists.txt', 'vcpkg.json', '.github/workflows/dotnet.yml']
workflow_dispatch:
jobs:
@@ -30,6 +30,25 @@ jobs:
- shell: pwsh
run: ./dotnet/check-licenses.ps1
+ apple-client:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: dotnet/global.json
+ cache: true
+ cache-dependency-path: dotnet/**/packages.lock.json
+ - name: Install macOS workload
+ run: dotnet workload install macos --skip-manifest-update
+ - name: Build and stage native codec/DSP
+ shell: pwsh
+ run: ./dotnet/build-native.ps1
+ - name: Restore managed AppKit client
+ run: dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx
+ - name: Build managed AppKit client
+ run: dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug --no-restore
+
cpp-conformance:
runs-on: ubuntu-24.04
steps:
diff --git a/PROGRESS.md b/PROGRESS.md
index 03b33a9..f5b509d 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -10,6 +10,20 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action
+- **In progress (2026-09-16): managed macOS client started.** Added a separate .NET 10
+ AppKit solution with native application/menu lifecycle, guest connection, explicit TOFU
+ approval, channel selection, roster, channel chat, voice subscription, disconnect state,
+ accessibility labels, sandbox entitlements and deterministic client disposal. The shared
+ protocol, crypto, audio and client-core projects are referenced directly; the Swift app
+ remains the release client while parity work continues. Added a macOS CI gate that builds
+ the native Opus/RNNoise shim and the AppKit solution. Local Windows compilation of AppKit
+ remains unavailable: installing the macOS workload rolled back after the machine's
+ Visual Studio workload manager failed while repairing an unrelated iOS/Android MSI.
+ **Next:** use the macOS CI compiler to correct any binding issues, then add Core Audio
+ playback/microphone capture and start/stop the managed microphone stream. Follow with
+ saved accounts/servers, private messages, moderation/settings, ScreenCaptureKit sharing,
+ VoiceOver verification, signing and notarization.
+
- **Done (2026-09-16): Linux production packaging checkpoint.** Added a real TLS 1.3
`--health-check` with optional certificate pin verification. Linux x64 now has separate
locked self-contained publish graphs and invariant-globalization startup without a system
diff --git a/clients/apple/dotnet/README.md b/clients/apple/dotnet/README.md
new file mode 100644
index 0000000..8c943f6
--- /dev/null
+++ b/clients/apple/dotnet/README.md
@@ -0,0 +1,15 @@
+# Managed Apple clients
+
+`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 guest-client shell: AppKit launch/menu lifecycle, host and nickname entry, explicit TOFU approval (including changed-key warning), channel selection, user roster, channel text, voice subscription, disconnect reporting, native accessibility labels, sandbox/network/audio entitlements, and deterministic disposal. It does not yet replace the Swift release. CoreAudio capture/playback, saved servers/accounts, 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:
+
+```bash
+dotnet workload install macos
+dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx
+dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug
+```
+
+The repository's `dotnet/build-native.ps1` must first stage an `osx-arm64` `libvoicecat_media.dylib`; that shim contains only Opus/RNNoise. Windows and Linux cannot compile or validate AppKit bindings, so the macOS build is a required CI/release gate.
diff --git a/clients/apple/dotnet/VoiceCat.Apple.slnx b/clients/apple/dotnet/VoiceCat.Apple.slnx
new file mode 100644
index 0000000..5e2c0f6
--- /dev/null
+++ b/clients/apple/dotnet/VoiceCat.Apple.slnx
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/clients/apple/dotnet/VoiceCat.Mac/AppDelegate.cs b/clients/apple/dotnet/VoiceCat.Mac/AppDelegate.cs
new file mode 100644
index 0000000..8298250
--- /dev/null
+++ b/clients/apple/dotnet/VoiceCat.Mac/AppDelegate.cs
@@ -0,0 +1,23 @@
+using AppKit;
+using Foundation;
+
+namespace VoiceCat.Mac;
+
+internal sealed class AppDelegate : NSApplicationDelegate
+{
+ private ConnectWindowController? connect;
+ public override void DidFinishLaunching(NSNotification notification)
+ {
+ NSApplication.SharedApplication.ActivationPolicy = NSApplicationActivationPolicy.Regular;
+ BuildMenu();
+ connect = new(); connect.ShowWindow(this);
+ NSApplication.SharedApplication.ActivateIgnoringOtherApps(true);
+ }
+ public override bool ApplicationShouldTerminateAfterLastWindowClosed(NSApplication sender) => true;
+ private static void BuildMenu()
+ {
+ var menu = new NSMenu(); var root = new NSMenuItem(); menu.AddItem(root);
+ var application = new NSMenu(); application.AddItem(new NSMenuItem("Quit VoiceCat", "q", (_, _) => NSApplication.SharedApplication.Terminate(null)));
+ root.Submenu = application; NSApplication.SharedApplication.MainMenu = menu;
+ }
+}
diff --git a/clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs b/clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs
new file mode 100644
index 0000000..efa0bf1
--- /dev/null
+++ b/clients/apple/dotnet/VoiceCat.Mac/ConnectWindowController.cs
@@ -0,0 +1,70 @@
+using AppKit;
+using CoreGraphics;
+using Foundation;
+using VoiceCat.Core;
+using VoiceCat.Crypto;
+
+namespace VoiceCat.Mac;
+
+internal sealed class ConnectWindowController : NSWindowController
+{
+ private readonly NSTextField host = new(new CGRect(120, 180, 280, 26)) { StringValue = "127.0.0.1:8384", PlaceholderString = "Host:port" };
+ private readonly NSTextField nickname = new(new CGRect(120, 140, 280, 26)) { StringValue = NSProcessInfo.ProcessInfo.UserName, PlaceholderString = "Nickname" };
+ private readonly NSTextField status = NSTextField.CreateLabel("Ready to connect");
+ private readonly NSButton connect = new(new CGRect(300, 55, 100, 32)) { Title = "Connect", BezelStyle = NSBezelStyle.Rounded };
+ private VoiceCatClient? client;
+ private MainWindowController? main;
+
+ internal ConnectWindowController() : base(new NSWindow(new CGRect(0, 0, 520, 260), NSWindowStyle.Titled | NSWindowStyle.Closable,
+ NSBackingStore.Buffered, false))
+ {
+ Window!.Title = "Connect to VoiceCat"; Window.Center();
+ var view = Window.ContentView!;
+ var hostLabel = NSTextField.CreateLabel("Server"); hostLabel.Frame = new CGRect(30, 185, 80, 20); view.AddSubview(hostLabel); view.AddSubview(host);
+ var nicknameLabel = NSTextField.CreateLabel("Nickname"); nicknameLabel.Frame = new CGRect(30, 145, 80, 20); view.AddSubview(nicknameLabel); view.AddSubview(nickname);
+ status.Frame = new CGRect(30, 95, 370, 22); status.AccessibilityLabel = "Connection status"; view.AddSubview(status);
+ connect.AccessibilityLabel = "Connect to server"; connect.Activated += Connect; view.AddSubview(connect);
+ Window.DefaultButtonCell = connect.Cell;
+ }
+
+ private async void Connect(object? sender, EventArgs args)
+ {
+ if (client is not null) return;
+ try
+ {
+ connect.Enabled = false; status.StringValue = "Connecting…";
+ (string serverHost, ushort port) = ParseEndpoint(host.StringValue);
+ string pins = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat", "tofu.txt");
+ client = new("VoiceCat macOS", "0.1.0", pins);
+ await client.ConnectAsync(serverHost, port, ConfirmIdentity);
+ var auth = await client.AuthenticateGuestAsync(nickname.StringValue);
+ if (!auth.Ok) throw new InvalidOperationException(auth.Error);
+ main = new(client, auth.Self.Id, nickname.StringValue);
+ client = null; main.ShowWindow(this); Window.Close();
+ }
+ catch (Exception exception)
+ {
+ if (client is not null) await client.DisposeAsync(); client = null;
+ status.StringValue = exception.Message; connect.Enabled = true;
+ }
+ }
+
+ private ValueTask ConfirmIdentity(ServerIdentityChallenge challenge, CancellationToken cancellationToken)
+ {
+ var completion = new TaskCompletionSource(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 (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.");
+ return (value[..separator].Trim('[', ']'), port);
+ }
+}
diff --git a/clients/apple/dotnet/VoiceCat.Mac/Info.plist b/clients/apple/dotnet/VoiceCat.Mac/Info.plist
new file mode 100644
index 0000000..054f606
--- /dev/null
+++ b/clients/apple/dotnet/VoiceCat.Mac/Info.plist
@@ -0,0 +1,13 @@
+
+
+
+ CFBundleDisplayNameVoiceCat
+ CFBundleIdentifiernet.iamtalon.voicecat
+ CFBundleNameVoiceCat
+ CFBundleVersion1
+ CFBundleShortVersionString0.1.0
+ LSMinimumSystemVersion14.0
+ NSMicrophoneUsageDescriptionVoiceCat uses the microphone when you join voice and enable a microphone stream.
+ NSScreenCaptureUsageDescriptionVoiceCat uses ScreenCaptureKit only when you share desktop or application audio.
+ NSPrincipalClassNSApplication
+
diff --git a/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs b/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs
new file mode 100644
index 0000000..010dbb4
--- /dev/null
+++ b/clients/apple/dotnet/VoiceCat.Mac/MainWindowController.cs
@@ -0,0 +1,89 @@
+using AppKit;
+using CoreGraphics;
+using Foundation;
+using VoiceCat.Core;
+using Voicecat.V1;
+
+namespace VoiceCat.Mac;
+
+internal sealed class MainWindowController : NSWindowController
+{
+ private readonly VoiceCatClient client;
+ private readonly uint selfId;
+ 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 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");
+ private readonly NSTimer timer;
+ private uint currentChannel = 1;
+ private bool joinedVoice;
+
+ internal MainWindowController(VoiceCatClient client, uint selfId, string nickname) : base(new NSWindow(new CGRect(0, 0, 760, 570),
+ NSWindowStyle.Titled | NSWindowStyle.Closable | NSWindowStyle.Resizable | NSWindowStyle.Miniaturizable, NSBackingStore.Buffered, false))
+ {
+ this.client = client; this.selfId = selfId;
+ Window!.Title = $"VoiceCat — {nickname}"; Window.Center(); Window.MinSize = new CGSize(680, 480);
+ var content = Window.ContentView!;
+ channels.AccessibilityLabel = "Channel"; channels.Activated += ChangeChannel; content.AddSubview(channels);
+ 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);
+ compose.AccessibilityLabel = "Message"; compose.Activated += Send; content.AddSubview(compose);
+ 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);
+ timer = NSTimer.CreateRepeatingScheduledTimer(TimeSpan.FromMilliseconds(50), _ => Pump());
+ RefreshState();
+ }
+
+ private void Pump()
+ {
+ while (client.TryReadEvent(out Envelope? envelope))
+ {
+ if (envelope!.TextMessage is { } text) Append($"[{DateTime.Now:t}] {Name(text.SenderId)}: {text.Body}");
+ if (envelope.ServerState is not null || envelope.ChannelEvent is not null || envelope.UserEvent is not null) RefreshState();
+ if (envelope.Disconnect is { } disconnected) { status.StringValue = "Disconnected: " + disconnected.Reason; voice.Enabled = send.Enabled = false; }
+ }
+ if (client.Audio.Failure is { } failure) status.StringValue = "Audio stopped: " + failure.Message;
+ }
+ private void RefreshState()
+ {
+ string? selected = channels.SelectedItem?.RepresentedObject?.ToString();
+ 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()); }
+ 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));
+ status.StringValue = $"Connected · {client.Users.Count} users";
+ }
+ private async void ChangeChannel(object? sender, EventArgs args)
+ {
+ if (!uint.TryParse(channels.SelectedItem?.RepresentedObject?.ToString(), out uint id) || id == currentChannel) return;
+ try { var result = (await client.RequestAsync(new() { JoinChannel = new() { ChannelId = id } })).JoinChannelResult; if (!result.Ok) status.StringValue = result.Error; }
+ catch (Exception exception) { status.StringValue = exception.Message; }
+ }
+ 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 = ""; }
+ catch (Exception exception) { status.StringValue = exception.Message; }
+ }
+ private async void ToggleVoice(object? sender, EventArgs args)
+ {
+ try
+ {
+ if (!joinedVoice) { var result = await client.SubscribeVoiceAsync(); if (!result.Ok) throw new InvalidOperationException(result.Error); joinedVoice = true; voice.Title = "Leave voice"; }
+ else { await client.RequestAsync(new() { UnsubscribeVoice = new() }); joinedVoice = false; voice.Title = "Join voice"; }
+ }
+ catch (Exception exception) { status.StringValue = exception.Message; }
+ }
+ private string Name(uint id) => client.Users.FirstOrDefault(u => u.Id == id)?.Nickname ?? $"User {id}";
+ private void Append(string line) { chat.Value = chat.Value.Length == 0 ? line : chat.Value + "\n" + line; chat.ScrollToEndOfDocument(this); }
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) { timer.Invalidate(); client.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
+ base.Dispose(disposing);
+ }
+}
diff --git a/clients/apple/dotnet/VoiceCat.Mac/Program.cs b/clients/apple/dotnet/VoiceCat.Mac/Program.cs
new file mode 100644
index 0000000..d80c1ef
--- /dev/null
+++ b/clients/apple/dotnet/VoiceCat.Mac/Program.cs
@@ -0,0 +1,5 @@
+using AppKit;
+
+NSApplication.Init();
+NSApplication.SharedApplication.Delegate = new VoiceCat.Mac.AppDelegate();
+NSApplication.SharedApplication.Run();
diff --git a/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj b/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj
new file mode 100644
index 0000000..9fa36ea
--- /dev/null
+++ b/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.csproj
@@ -0,0 +1,18 @@
+
+
+ Exe
+ net10.0-macos
+ osx-arm64
+ 14.0
+ enable
+ enable
+ VoiceCat
+ net.iamtalon.voicecat
+ true
+ Info.plist
+ VoiceCat.Mac.entitlements
+
+
+
+
+
diff --git a/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.entitlements b/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.entitlements
new file mode 100644
index 0000000..e839509
--- /dev/null
+++ b/clients/apple/dotnet/VoiceCat.Mac/VoiceCat.Mac.entitlements
@@ -0,0 +1,8 @@
+
+
+
+ com.apple.security.app-sandbox
+ com.apple.security.network.client
+ com.apple.security.device.audio-input
+ com.apple.security.files.user-selected.read-write
+
diff --git a/docs/porting-to-dotnet.md b/docs/porting-to-dotnet.md
index c7e1629..976ad61 100644
--- a/docs/porting-to-dotnet.md
+++ b/docs/porting-to-dotnet.md
@@ -850,6 +850,14 @@ Per §8.2. AppKit port, ScreenCaptureKit per-app audio selection, VoiceOver pari
**Exit criterion:** feature parity with `VoiceCatMac`, VoiceOver smoke-tested, notarized
build produced.
+**Checkpoint (2026-09-16):** `clients/apple/dotnet/VoiceCat.Mac` is a separate .NET 10
+AppKit application that consumes `VoiceCat.Core` directly. Its first shell implements guest
+connection, interactive TOFU approval, channel browsing, roster/chat, voice subscription,
+disconnect state and native accessibility labels. A macOS CI job stages the shared native
+codec/DSP shim and compiles the Apple solution. The existing Swift app remains the release
+client until Core Audio input/output, the rest of the account/moderation/settings surface,
+ScreenCaptureKit sharing, VoiceOver validation, signing and notarization are complete.
+
---
### Phase 9 — iOS client (est. 5–7 weeks)