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