Start managed macOS AppKit 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 17:34:16 +02:00
parent 7be93e81a1
commit e5ff484cac
12 changed files with 297 additions and 2 deletions
+21 -2
View File
@@ -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:
+14
View File
@@ -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
+15
View File
@@ -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.
+13
View File
@@ -0,0 +1,13 @@
<Solution>
<Folder Name="/apps/">
<Project Path="VoiceCat.Mac/VoiceCat.Mac.csproj" />
</Folder>
<Folder Name="/managed/">
<Project Path="../../../dotnet/src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
<Project Path="../../../dotnet/src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
<Project Path="../../../dotnet/src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
<Project Path="../../../dotnet/src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
<Project Path="../../../dotnet/src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
<Project Path="../../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" />
</Folder>
</Solution>
@@ -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;
}
}
@@ -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<bool> ConfirmIdentity(ServerIdentityChallenge challenge, CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<bool>(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);
}
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleDisplayName</key><string>VoiceCat</string>
<key>CFBundleIdentifier</key><string>net.iamtalon.voicecat</string>
<key>CFBundleName</key><string>VoiceCat</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleShortVersionString</key><string>0.1.0</string>
<key>LSMinimumSystemVersion</key><string>14.0</string>
<key>NSMicrophoneUsageDescription</key><string>VoiceCat uses the microphone when you join voice and enable a microphone stream.</string>
<key>NSScreenCaptureUsageDescription</key><string>VoiceCat uses ScreenCaptureKit only when you share desktop or application audio.</string>
<key>NSPrincipalClass</key><string>NSApplication</string>
</dict></plist>
@@ -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);
}
}
@@ -0,0 +1,5 @@
using AppKit;
NSApplication.Init();
NSApplication.SharedApplication.Delegate = new VoiceCat.Mac.AppDelegate();
NSApplication.SharedApplication.Run();
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-macos</TargetFramework>
<RuntimeIdentifier>osx-arm64</RuntimeIdentifier>
<SupportedOSPlatformVersion>14.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationTitle>VoiceCat</ApplicationTitle>
<ApplicationId>net.iamtalon.voicecat</ApplicationId>
<UseHardenedRuntime>true</UseHardenedRuntime>
<ApplicationManifest>Info.plist</ApplicationManifest>
<CodesignEntitlements>VoiceCat.Mac.entitlements</CodesignEntitlements>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.device.audio-input</key><true/>
<key>com.apple.security.files.user-selected.read-write</key><true/>
</dict></plist>
+8
View File
@@ -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. 57 weeks)