diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index f971c2b..44b37e6 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -39,14 +39,16 @@ jobs: global-json-file: dotnet/global.json cache: true cache-dependency-path: dotnet/**/packages.lock.json - - name: Install macOS workload - run: dotnet workload install macos --version 10.0.401 + - name: Install Apple workloads + run: dotnet workload install macos ios --version 10.0.401 - name: Build and stage native codec/DSP shell: pwsh run: ./dotnet/build-native.ps1 - - name: Restore managed AppKit client + - name: Build and stage static iOS codec/DSP + run: ./dotnet/build-native-ios.sh + - name: Restore managed Apple clients run: dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx - - name: Build managed AppKit client + - name: Build managed AppKit and UIKit clients run: dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug --no-restore cpp-conformance: diff --git a/PROGRESS.md b/PROGRESS.md index 2dc06ba..d20fd38 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-19): managed iOS/UIKit replacement.** Chose native UIKit over MAUI + to preserve direct AVAudioSession/AVAudioEngine control and native VoiceOver semantics. + Added the .NET 10 iOS application, App Group profile/TOFU/Keychain migration, saved-server + and connected channel/chat/settings flows, managed client event/reconnect handling, and + foreground microphone/playback through an allocation-free callback and bounded PCM rings. + The Opus/RNNoise shim now cross-compiles as merged static device and simulator archives and + binds through `__Internal`. The Swift ReplayKit upload extension is retained, its ring ABI + is documented/versioned, and the managed pump drains it into a screen-audio stream; MSBuild + builds and embeds the appex. Corrected the historical App Group and extension bundle IDs. + CI now installs the iOS workload and builds both managed Apple clients. **Next:** run the + complete managed/native suite and Release/device packaging, then finish the remaining + advanced administration/audio controls and the physical-device VoiceOver/live-call matrix + before treating Swift as removable. + - **In progress (2026-09-19): managed macOS functional-parity checkpoint.** Extended the .NET AppKit client across the remaining Swift desktop surface: persistent audio and notification settings; VAD, focus-scoped configurable PTT and always-on modes; input, diff --git a/clients/apple/dotnet/README.md b/clients/apple/dotnet/README.md index a64af2b..d94466f 100644 --- a/clients/apple/dotnet/README.md +++ b/clients/apple/dotnet/README.md @@ -1,6 +1,6 @@ # Managed Apple clients -`VoiceCat.Mac` is the native AppKit C# port. It targets `net10.0-macos27.0` and references the same `VoiceCat.Core` and `VoiceCat.Audio` assemblies used by the Windows client and managed CLI. The explicit platform version selects Microsoft's Xcode 27 preview bindings; the deployed app still supports macOS 14 and later. +`VoiceCat.Mac` and `VoiceCat.iOS` are the native AppKit and UIKit C# clients. They target the .NET 10 Apple workloads and reference the same `VoiceCat.Core` and `VoiceCat.Audio` assemblies used by Windows and the managed CLI. UIKit was selected over MAUI to retain direct AVAudioSession/AVAudioEngine lifecycle control and native VoiceOver behavior without another UI abstraction. The managed client now implements the Swift client's functional surface: profiles and Keychain authentication, TOFU, protected channels, hierarchical channel presentation and roster state, channel and modeless private text, microphone/auxiliary/screen-audio streams, selectable Core Audio devices, VAD/PTT/always-on input, stereo microphone, RNNoise, per-stream receive tuning, self/server mute and deafen, full channel configuration, moderation, permissions, account administration, event sounds and speech. It imports the legacy Swift profile, TOFU and Keychain state during cutover. Capture is converted to interleaved 48 kHz int16 PCM through `AVAudioConverter`; playback converts the shared bounded `PcmRing` into Core Audio's native planar Float32 layout inside an allocation-free, non-blocking `AVAudioSourceNode` callback. @@ -19,4 +19,13 @@ dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug open clients/apple/dotnet/VoiceCat.Mac/bin/Debug/net10.0-macos27.0/osx-arm64/VoiceCat.app ``` +The iOS build first stages static device and simulator Opus/RNNoise archives, then builds the UIKit host. MSBuild also builds and embeds the existing Swift ReplayKit upload extension; that deliberately remains Swift because of the extension's tight memory budget and unsupported managed extension runtime. + +```bash +./dotnet/build-native-ios.sh +dotnet build clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj -c Debug -r iossimulator-arm64 +``` + +Profiles, TOFU state, passwords and the ReplayKit ring use the signed App Group `group.me.iamtalon.voicecat`; the managed client migrates the old app-private profile files on first use. The shared ring ABI is frozen in [`docs/broadcast-ring-format.md`](../../../docs/broadcast-ring-format.md). + The native build stages an `osx-arm64` `libvoicecat_media.dylib`; that shim contains only Opus/RNNoise. Debug builds deliberately omit hardened runtime so an ad-hoc-signed local app can load the separately ad-hoc-signed .NET runtime libraries without an Apple Development identity. Release builds retain hardened runtime for Developer ID signing and notarization. Only a macOS host can link, launch, grant microphone access and verify live devices. Final audio quality is validated with real multi-human calls after the feature surface is complete; a synthetic ten-minute sine-wave listen is deliberately not a release gate. diff --git a/clients/apple/dotnet/VoiceCat.Apple.slnx b/clients/apple/dotnet/VoiceCat.Apple.slnx index 5e2c0f6..c4fc7ea 100644 --- a/clients/apple/dotnet/VoiceCat.Apple.slnx +++ b/clients/apple/dotnet/VoiceCat.Apple.slnx @@ -1,6 +1,7 @@ + diff --git a/clients/apple/dotnet/VoiceCat.iOS/AppDelegate.cs b/clients/apple/dotnet/VoiceCat.iOS/AppDelegate.cs new file mode 100644 index 0000000..67dc92a --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/AppDelegate.cs @@ -0,0 +1,19 @@ +using Foundation; +using UIKit; + +namespace VoiceCat.iOS; + +[Register("AppDelegate")] +internal sealed class AppDelegate : UIApplicationDelegate +{ + public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions) + { + AppModel.Shared.Load(); + IosAudioRouter.Shared.Load(); + return true; + } + + public override UISceneConfiguration GetConfiguration(UIApplication application, UISceneSession connectingSceneSession, + UISceneConnectionOptions options) => new("Default Configuration", connectingSceneSession.Role) + { DelegateType = typeof(SceneDelegate) }; +} diff --git a/clients/apple/dotnet/VoiceCat.iOS/AppModel.cs b/clients/apple/dotnet/VoiceCat.iOS/AppModel.cs new file mode 100644 index 0000000..55043b0 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/AppModel.cs @@ -0,0 +1,175 @@ +using UIKit; +using VoiceCat.Audio; +using VoiceCat.Core; +using Voicecat.V1; +using Channel = Voicecat.V1.Channel; + +namespace VoiceCat.iOS; + +internal sealed record ChatEntry(DateTime Timestamp, string Sender, string Text, bool Private); + +internal sealed class AppModel +{ + internal static AppModel Shared { get; } = new(); + private readonly IosStorage storage = new(); + private readonly List profiles = []; + private readonly List messages = []; + private CancellationTokenSource? lifetime; + private VoiceCatClient? client; + private TaskCompletionSource? identityDecision; + private ServerProfile? connectedProfile; + private int reconnectAttempt; + private bool explicitDisconnect; + private uint microphoneStream; + private BroadcastAudioPump? broadcast; + + internal event Action? Changed; + internal event Action? IdentityRequested; + internal IReadOnlyList Profiles => profiles; + internal IReadOnlyList Messages => messages; + internal VoiceCatClient? Client => client; + internal bool IsConnected => client?.State == ClientConnectionState.Connected; + internal bool IsConnecting { get; private set; } + internal bool VoiceJoined => microphoneStream != 0; + internal string Status { get; private set; } = "Not connected"; + internal uint CurrentChannelId { get; private set; } + internal uint SelfUserId => client?.Authentication?.Self.Id ?? 0; + internal IReadOnlyList Channels => client?.Channels ?? []; + internal IReadOnlyList Users => client?.Users ?? []; + + private AppModel() { } + internal void Load() { profiles.Clear(); profiles.AddRange(storage.LoadProfiles()); Notify(); } + internal void Save() => storage.SaveProfiles(profiles); + + internal void UpsertProfile(ServerProfile profile, string? password) + { + int index = profiles.FindIndex(item => item.Id == profile.Id); + if (index < 0) profiles.Add(profile); else profiles[index] = profile; + if (!string.IsNullOrEmpty(password)) storage.SavePassword(profile.Id, password); + Save(); Notify(); + } + + internal void RemoveProfile(ServerProfile profile) + { + storage.RemovePassword(profile.Id); profiles.RemoveAll(item => item.Id == profile.Id); Save(); Notify(); + } + + internal async Task ConnectAsync(ServerProfile profile, string? suppliedPassword = null, bool restoring = false) + { + if (IsConnecting || IsConnected) return; + explicitDisconnect = false; IsConnecting = true; connectedProfile = profile; + Status = restoring ? "Reconnecting…" : "Connecting…"; Notify(); + lifetime?.Cancel(); lifetime?.Dispose(); lifetime = new(); + VoiceCatClient next = new("VoiceCat-iOS", "0.0.1", storage.TofuPath); + client = next; + try + { + await next.ConnectAsync(profile.Host, profile.Port, ConfirmIdentityAsync, lifetime.Token); + AuthResult auth = profile.Authentication == ServerAuthentication.Guest + ? await next.AuthenticateGuestAsync(profile.Nickname ?? "iOS User", lifetime.Token) + : await next.AuthenticateUserAsync(profile.Username!, suppliedPassword ?? storage.LoadPassword(profile) ?? "", lifetime.Token); + if (!auth.Ok) throw new InvalidOperationException(auth.Error); + CurrentChannelId = auth.Self.ChannelId; reconnectAttempt = 0; IsConnecting = false; Status = "Connected"; + IosAudioEngine.Shared.StartListening(next); + broadcast = new(); broadcast.Start(next); + _ = PumpEventsAsync(next, lifetime.Token); + Notify(); + } + catch (Exception exception) + { + IsConnecting = false; Status = exception.Message; Notify(); + await next.DisposeAsync(); if (ReferenceEquals(client, next)) client = null; + if (restoring && !explicitDisconnect) ScheduleReconnect(); + else throw; + } + } + + private ValueTask ConfirmIdentityAsync(ServerIdentityChallenge challenge, CancellationToken token) + { + identityDecision = new(TaskCreationOptions.RunContinuationsAsynchronously); + token.Register(() => identityDecision.TrySetCanceled(token)); + UIApplication.SharedApplication.BeginInvokeOnMainThread(() => IdentityRequested?.Invoke(challenge)); + return new(identityDecision.Task); + } + + internal void ResolveIdentity(bool accepted) { identityDecision?.TrySetResult(accepted); identityDecision = null; } + + private async Task PumpEventsAsync(VoiceCatClient owner, CancellationToken token) + { + try + { + await foreach (Envelope envelope in owner.ReadEventsAsync(token)) + { + if (envelope.JoinChannelResult?.Ok == true) CurrentChannelId = envelope.JoinChannelResult.ChannelId; + if (envelope.TextMessage is { } text) + { + User? sender = owner.Users.FirstOrDefault(user => user.Id == text.SenderId); + messages.Add(new(DateTime.Now, sender?.Nickname ?? $"User {text.SenderId}", text.Body, text.Scope == TextScope.TextPrivate)); + if (messages.Count > 500) messages.RemoveAt(0); + } + UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify); + } + } + catch (OperationCanceledException) { } + finally + { + if (ReferenceEquals(client, owner) && !explicitDisconnect) + { + IosAudioEngine.Shared.Stop(); client = null; Status = "Connection lost"; UIApplication.SharedApplication.BeginInvokeOnMainThread(Notify); + ScheduleReconnect(); + } + } + } + + internal async Task JoinChannelAsync(uint channelId, string password = "") + { + VoiceCatClient active = client ?? throw new InvalidOperationException("Not connected."); + Envelope response = await active.RequestAsync(new() { JoinChannel = new() { ChannelId = channelId, Password = password } }); + if (response.JoinChannelResult?.Ok != true) throw new InvalidOperationException(response.JoinChannelResult?.Error ?? "Join failed."); + CurrentChannelId = channelId; Notify(); + } + + internal void SendText(string body, uint targetUser = 0) + { + if (string.IsNullOrWhiteSpace(body)) return; + (client ?? throw new InvalidOperationException("Not connected.")).Send(new() + { + TextMessage = new() { Scope = targetUser == 0 ? TextScope.TextChannel : TextScope.TextPrivate, + TargetId = targetUser == 0 ? CurrentChannelId : targetUser, Body = body.Trim(), ClientMsgId = Guid.NewGuid().ToString("N") } + }); + } + + internal async Task ToggleVoiceAsync() + { + VoiceCatClient active = client ?? throw new InvalidOperationException("Not connected."); + if (microphoneStream != 0) + { + IosAudioEngine.Shared.StopMicrophone(); active.StopStream(microphoneStream); microphoneStream = 0; + await active.SubscribeVoiceAsync(false); Notify(); return; + } + if (AVFoundation.AVCaptureDevice.GetAuthorizationStatus(AVFoundation.AVAuthorizationMediaType.Audio) == AVFoundation.AVAuthorizationStatus.NotDetermined) + await AVFoundation.AVCaptureDevice.RequestAccessForMediaTypeAsync(AVFoundation.AVAuthorizationMediaType.Audio); + VoiceSubscriptionResult subscribed = await active.SubscribeVoiceAsync(); + if (!subscribed.Ok) throw new InvalidOperationException(subscribed.Error); + int channels = IosAudioRouter.Shared.CaptureChannels; + StreamInfo stream = await active.StartStreamAsync(StreamKind.StreamMic, "Microphone", channels); + microphoneStream = stream.StreamId; IosAudioEngine.Shared.StartMicrophone(stream.StreamId, channels); Notify(); + } + + internal async Task DisconnectAsync() + { + explicitDisconnect = true; lifetime?.Cancel(); IosAudioEngine.Shared.Stop(); + if (broadcast is { } pump) { broadcast = null; await pump.DisposeAsync(); } + VoiceCatClient? old = client; client = null; microphoneStream = 0; IsConnecting = false; Status = "Not connected"; Notify(); + if (old is not null) await old.DisposeAsync(); + } + + private void ScheduleReconnect() + { + ServerProfile? profile = connectedProfile; if (profile is null || explicitDisconnect) return; + int delay = Math.Min(1 << Math.Min(reconnectAttempt++, 5), 30); + _ = Task.Run(async () => { try { await Task.Delay(TimeSpan.FromSeconds(delay), lifetime?.Token ?? default); await ConnectAsync(profile, restoring: true); } catch { } }); + } + + private void Notify() => Changed?.Invoke(); +} diff --git a/clients/apple/dotnet/VoiceCat.iOS/BroadcastAudioPump.cs b/clients/apple/dotnet/VoiceCat.iOS/BroadcastAudioPump.cs new file mode 100644 index 0000000..1b126f0 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/BroadcastAudioPump.cs @@ -0,0 +1,70 @@ +using System.IO.MemoryMappedFiles; +using VoiceCat.Core; +using Voicecat.V1; + +namespace VoiceCat.iOS; + +internal sealed class BroadcastAudioPump : IAsyncDisposable +{ + private const uint Magic = 0x56434252, Version = 1; + private const int Header = 64, Capacity = 96_000, Frame = 960; + private readonly CancellationTokenSource stop = new(); + private Task worker = Task.CompletedTask; + private VoiceCatClient? client; + private uint streamId; + private readonly short[] scratch = new short[Frame * 2]; + + internal void Start(VoiceCatClient owner) { client = owner; worker = RunAsync(stop.Token); } + + private async Task RunAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try { await DrainAsync(token); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) { } + await Task.Delay(10, token).ConfigureAwait(false); + } + } + + private async Task DrainAsync(CancellationToken token) + { + NSUrl? root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup); + if (root?.Path is null) return; + string path = Path.Combine(root.Path, "voicecat", "broadcast_audio.ring"); if (!File.Exists(path)) return; + using MemoryMappedFile map = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite); + using MemoryMappedViewAccessor view = map.CreateViewAccessor(0, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite); + if (view.ReadUInt32(0) != Magic || view.ReadUInt32(4) != Version) throw new InvalidDataException("Unsupported broadcast ring."); + bool active = view.ReadUInt32(16) != 0; + if (!active) { StopStream(); return; } + VoiceCatClient owner = client ?? throw new IOException("Client disconnected."); + if (streamId == 0) + { + StreamInfo stream = await owner.StartStreamAsync(StreamKind.StreamScreenAudio, "Screen audio", 2, token).ConfigureAwait(false); + streamId = stream.StreamId; view.Write(32, view.ReadUInt64(24)); + } + ulong write = view.ReadUInt64(24), read = view.ReadUInt64(32); + if (write - read > Capacity) read = write - Capacity; + while (write - read >= Frame * 2) + { + for (int sample = 0; sample < scratch.Length; sample++) + { + ulong index = (read + (ulong)sample) % Capacity; + scratch[sample] = view.ReadInt16(Header + checked((long)index * sizeof(short))); + } + if (!owner.Audio.FeedPcm(streamId, scratch, 2)) break; + read += (ulong)scratch.Length; + view.Write(32, read); + } + } + + private void StopStream() + { + uint id = streamId; streamId = 0; if (id == 0 || client?.State != ClientConnectionState.Connected) return; + try { client.StopStream(id); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { } + } + + public async ValueTask DisposeAsync() + { + stop.Cancel(); try { await worker.ConfigureAwait(false); } catch (OperationCanceledException) { } StopStream(); stop.Dispose(); + } +} diff --git a/clients/apple/dotnet/VoiceCat.iOS/Entitlements.plist b/clients/apple/dotnet/VoiceCat.iOS/Entitlements.plist new file mode 100644 index 0000000..7b43c27 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/Entitlements.plist @@ -0,0 +1,5 @@ + + + + com.apple.security.application-groupsgroup.me.iamtalon.voicecat + diff --git a/clients/apple/dotnet/VoiceCat.iOS/Info.plist b/clients/apple/dotnet/VoiceCat.iOS/Info.plist new file mode 100644 index 0000000..0470fb9 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/Info.plist @@ -0,0 +1,23 @@ + + + + CFBundleDisplayNameVoiceCat + CFBundleIdentifierme.iamtalon.voicecat + CFBundleShortVersionString0.0.1 + CFBundleVersion1 + LSRequiresIPhoneOS + NSMicrophoneUsageDescriptionVoiceCat needs microphone access to transmit your voice in channels. + UIBackgroundModesaudio + UIRequiresFullScreen + UIDeviceFamily12 + UILaunchScreen + UISupportedInterfaceOrientationsUIInterfaceOrientationPortraitUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipadUIInterfaceOrientationPortraitUIInterfaceOrientationPortraitUpsideDownUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight + UIApplicationSceneManifest + UIApplicationSupportsMultipleScenes + UISceneConfigurationsUIWindowSceneSessionRoleApplication + UISceneConfigurationNameDefault Configuration + UISceneDelegateClassNameVoiceCat.iOS.SceneDelegate + + + diff --git a/clients/apple/dotnet/VoiceCat.iOS/IosAudioEngine.cs b/clients/apple/dotnet/VoiceCat.iOS/IosAudioEngine.cs new file mode 100644 index 0000000..23a3732 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/IosAudioEngine.cs @@ -0,0 +1,157 @@ +using System.Runtime.InteropServices; +using AVFoundation; +using VoiceCat.Audio; +using VoiceCat.Core; + +namespace VoiceCat.iOS; + +internal sealed class IosAudioEngine +{ + internal static IosAudioEngine Shared { get; } = new(); + private readonly PcmRing playbackRing = new(131_072); + private readonly short[] renderScratch = new short[16_384]; + private AVAudioEngine? engine; + private AVAudioSourceNode? source; + private AVAudioFormat? outputFormat; + private VoiceCatClient? client; + private uint microphoneStream; + private int microphoneChannels = 1; + private readonly PcmRing microphoneRing = new(131_072); + private readonly short[] microphoneFrame = new short[960 * 2]; + private readonly CancellationTokenSource microphoneStop = new(); + private readonly Task microphoneWorker; + private AVAudioFormat? microphoneFormat; + private AVAudioConverter? microphoneConverter; + private AVAudioPcmBuffer? convertedMicrophone; + private AVAudioPcmBuffer? pendingInput; + private AVAudioConverterInputHandler? inputProvider; + private bool inputProvided; + private bool tapInstalled; + internal bool IsConnected { get; private set; } + + private IosAudioEngine() { microphoneWorker = PumpMicrophoneAsync(microphoneStop.Token); } + + internal void StartListening(VoiceCatClient owner) + { + Stop(); client = owner; IsConnected = true; owner.Audio.MixedPcm += ReceiveMixedPcm; Rebuild(); + } + + internal void StartMicrophone(uint streamId, int channels) + { + microphoneStream = streamId; microphoneChannels = Math.Clamp(channels, 1, 2); Rebuild(); + } + + internal void StopMicrophone() { microphoneStream = 0; Rebuild(); } + internal void Reconfigure() { if (IsConnected) Rebuild(); } + + private void Rebuild() + { + DestroyGraph(); IosAudioRouter.Shared.Apply(); + var next = new AVAudioEngine(); + outputFormat = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false); + source = new(outputFormat, Render); + next.AttachNode(source); + NSError? connectionError = null; + if (OperatingSystem.IsIOSVersionAtLeast(27)) next.Connect(source, next.MainMixerNode, outputFormat, out connectionError); + else next.Connect(source, next.MainMixerNode, outputFormat); + if (connectionError is not null) throw new InvalidOperationException(connectionError.LocalizedDescription); + if (microphoneStream != 0) + { + AVAudioInputNode input = next.InputNode; + input.SetVoiceProcessingEnabled(IosAudioRouter.Shared.UsesVoiceProcessing, out _); + if (IosAudioRouter.Shared.UsesVoiceProcessing) input.VoiceProcessingAgcEnabled = IosAudioRouter.Shared.AutomaticGainControl; + AVAudioFormat inputFormat = input.GetBusOutputFormat(0); + microphoneFormat = new(AVAudioCommonFormat.PCMInt16, 48_000, (uint)microphoneChannels, true); + microphoneConverter = new(inputFormat, microphoneFormat); + uint capacity = checked((uint)Math.Ceiling(4_096 * 48_000 / inputFormat.SampleRate) + 64); + convertedMicrophone = new(microphoneFormat, capacity); + inputProvider = ProvideInput; + NSError? tapError = null; + if (OperatingSystem.IsIOSVersionAtLeast(27)) input.InstallTapOnBus(0, 960, inputFormat, out tapError, Capture); + else input.InstallTapOnBus(0, 960, inputFormat, Capture); + if (tapError is not null) throw new InvalidOperationException(tapError.LocalizedDescription); + tapInstalled = true; + } + next.Prepare(); + if (!next.StartAndReturnError(out NSError? error)) { next.Dispose(); throw new InvalidOperationException(error.LocalizedDescription); } + engine = next; + } + + private unsafe void Capture(AVAudioPcmBuffer buffer, AVAudioTime time) + { + VoiceCatClient? owner = client; uint stream = microphoneStream; + if (owner is null || stream == 0 || buffer.FrameLength == 0) return; + AVAudioConverter? converter = microphoneConverter; + AVAudioPcmBuffer? converted = convertedMicrophone; + AVAudioConverterInputHandler? provider = inputProvider; + if (converter is null || converted is null || provider is null) return; + pendingInput = buffer; inputProvided = false; converted.FrameLength = 0; + converter.ConvertToBuffer(converted, out _, provider); + if (converted.FrameLength == 0) return; + nint samples = Marshal.ReadIntPtr(converted.Int16ChannelData); + if (samples != 0) microphoneRing.TryWrite(new ReadOnlySpan((void*)samples, checked((int)converted.FrameLength * microphoneChannels))); + pendingInput = null; + } + + private AVAudioBuffer ProvideInput(uint _, out AVAudioConverterInputStatus status) + { + if (!inputProvided && pendingInput is { } input) { inputProvided = true; status = AVAudioConverterInputStatus.HaveData; return input; } + status = AVAudioConverterInputStatus.NoDataNow; return null!; + } + + private async Task PumpMicrophoneAsync(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(10)); + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + int channels = microphoneChannels, required = 960 * channels; + while (microphoneRing.Count >= required) + { + int read = microphoneRing.Read(microphoneFrame.AsSpan(0, required)); + VoiceCatClient? owner = client; uint stream = microphoneStream; + if (read == required && owner is not null && stream != 0) owner.Audio.FeedPcm(stream, microphoneFrame.AsSpan(0, required), channels); + } + } + } + + private void ReceiveMixedPcm(ReadOnlySpan pcm) => playbackRing.TryWrite(pcm); + + private unsafe int Render(IntPtr isSilence, IntPtr timestamp, uint frameCount, IntPtr outputData) + { + int frames = checked((int)frameCount), requested = checked(frames * 2); + if (requested > renderScratch.Length) return -1; + Span input = renderScratch.AsSpan(0, requested); + int read = playbackRing.Read(input); input[read..].Clear(); + int count = Marshal.ReadInt32(outputData), first = IntPtr.Size == 8 ? 8 : 4, stride = IntPtr.Size == 8 ? 16 : 12; + if (count != 2) return -1; + for (int channel = 0; channel < 2; channel++) + { + nint data = Marshal.ReadIntPtr(outputData, first + channel * stride + 8); + var output = new Span((void*)data, frames); + for (int frame = 0; frame < frames; frame++) output[frame] = input[frame * 2 + channel] / 32768f; + } + if (isSilence != IntPtr.Zero) Marshal.WriteByte(isSilence, read == 0 ? (byte)1 : (byte)0); + return 0; + } + + internal void Stop() + { + IsConnected = false; microphoneStream = 0; + if (client is { } owner) owner.Audio.MixedPcm -= ReceiveMixedPcm; + DestroyGraph(); client = null; IosAudioRouter.Shared.Deactivate(); + } + + private void DestroyGraph() + { + if (engine is { } old) + { + if (tapInstalled) old.InputNode.RemoveTapOnBus(0); + old.Stop(); if (source is not null) old.DetachNode(source); old.Dispose(); + } + tapInstalled = false; pendingInput = null; inputProvider = null; + convertedMicrophone?.Dispose(); convertedMicrophone = null; + microphoneConverter?.Dispose(); microphoneConverter = null; + microphoneFormat?.Dispose(); microphoneFormat = null; + source?.Dispose(); source = null; outputFormat?.Dispose(); outputFormat = null; engine = null; + } +} diff --git a/clients/apple/dotnet/VoiceCat.iOS/IosAudioRouter.cs b/clients/apple/dotnet/VoiceCat.iOS/IosAudioRouter.cs new file mode 100644 index 0000000..b5cd2b7 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/IosAudioRouter.cs @@ -0,0 +1,63 @@ +using AVFoundation; +using Foundation; + +namespace VoiceCat.iOS; + +internal enum IosAudioPreset { VoiceChat, StereoMicrophone, MonoMicrophone, Advanced } + +internal sealed class IosAudioRouter +{ + internal static IosAudioRouter Shared { get; } = new(); + private readonly NSUserDefaults defaults = NSUserDefaults.StandardUserDefaults; + internal IosAudioPreset Preset { get; private set; } = IosAudioPreset.VoiceChat; + internal bool ForceSpeaker { get; set; } + internal bool VoiceProcessing { get; set; } = true; + internal bool AutomaticGainControl { get; set; } = true; + internal int CaptureChannels => Preset == IosAudioPreset.StereoMicrophone ? 2 : 1; + internal bool UsesVoiceProcessing => VoiceProcessing && CaptureChannels == 1 && Preset != IosAudioPreset.MonoMicrophone; + + private IosAudioRouter() + { + NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.RouteChangeNotification, _ => Recover()); + NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.InterruptionNotification, note => HandleInterruption(note)); + NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.MediaServicesWereResetNotification, _ => Recover()); + } + + internal void Load() + { + string? preset = defaults.StringForKey("cat.voice.audio.preset"); + if (Enum.TryParse(preset, true, out IosAudioPreset value)) Preset = value; + ForceSpeaker = defaults.BoolForKey("cat.voice.audio.forceSpeaker"); + VoiceProcessing = defaults.ValueForKey(new NSString("cat.voice.audio.voiceProcessing")) is null || defaults.BoolForKey("cat.voice.audio.voiceProcessing"); + AutomaticGainControl = defaults.ValueForKey(new NSString("cat.voice.audio.agc")) is null || defaults.BoolForKey("cat.voice.audio.agc"); + } + + internal void SelectPreset(IosAudioPreset preset) + { + Preset = preset; defaults.SetString(preset.ToString(), "cat.voice.audio.preset"); defaults.Synchronize(); + if (IosAudioEngine.Shared.IsConnected) IosAudioEngine.Shared.Reconfigure(); + } + + internal void Apply() + { + AVAudioSession session = AVAudioSession.SharedInstance(); + AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptions.AllowBluetooth; + if (Preset is IosAudioPreset.StereoMicrophone or IosAudioPreset.MonoMicrophone) + options = AVAudioSessionCategoryOptions.AllowBluetoothA2DP | AVAudioSessionCategoryOptions.DefaultToSpeaker; + if (!session.SetCategory(AVAudioSessionCategory.PlayAndRecord, AVAudioSessionMode.Default, options, out NSError? categoryError)) + throw new InvalidOperationException(categoryError.LocalizedDescription); + session.SetPreferredSampleRate(48_000, out _); + session.SetPreferredIOBufferDuration(0.02, out _); + if (!session.SetActive(true, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? activeError)) + throw new InvalidOperationException(activeError.LocalizedDescription); + session.OverrideOutputAudioPort(ForceSpeaker ? AVAudioSessionPortOverride.Speaker : AVAudioSessionPortOverride.None, out _); + } + + internal void Deactivate() => AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _); + private void Recover() { if (IosAudioEngine.Shared.IsConnected) UIApplication.SharedApplication.BeginInvokeOnMainThread(IosAudioEngine.Shared.Reconfigure); } + private void HandleInterruption(NSNotification note) + { + NSNumber? type = note.UserInfo?[new NSString("AVAudioSessionInterruptionTypeKey")] as NSNumber; + if ((AVAudioSessionInterruptionType)(type?.UInt32Value ?? 0) == AVAudioSessionInterruptionType.Ended) Recover(); + } +} diff --git a/clients/apple/dotnet/VoiceCat.iOS/IosConstants.cs b/clients/apple/dotnet/VoiceCat.iOS/IosConstants.cs new file mode 100644 index 0000000..a93e5d1 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/IosConstants.cs @@ -0,0 +1,8 @@ +namespace VoiceCat.iOS; + +internal static class IosConstants +{ + internal const string AppGroup = "group.me.iamtalon.voicecat"; + internal const string BroadcastExtension = "me.iamtalon.voicecat.broadcast"; + internal const string PasswordService = "me.iamtalon.voicecat.ios"; +} diff --git a/clients/apple/dotnet/VoiceCat.iOS/IosStorage.cs b/clients/apple/dotnet/VoiceCat.iOS/IosStorage.cs new file mode 100644 index 0000000..32eb334 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/IosStorage.cs @@ -0,0 +1,90 @@ +using System.Security.Cryptography; +using System.Text; +using Foundation; +using Security; +using VoiceCat.Core; + +namespace VoiceCat.iOS; + +internal sealed class IosStorage +{ + private readonly string directory; + private readonly ServerProfileStore profiles; + + internal IosStorage() + { + NSUrl? group = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup); + string root = group?.Path ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + directory = Path.Combine(root, "voicecat"); + profiles = new(Path.Combine(directory, "servers.json")); + } + + internal string TofuPath => Path.Combine(directory, "tofu_pins.txt"); + internal IReadOnlyList LoadProfiles() + { + MigrateLegacyFiles(); + return profiles.Load(); + } + internal void SaveProfiles(IEnumerable values) => profiles.Save(values); + + private void MigrateLegacyFiles() + { + Directory.CreateDirectory(directory); + string legacy = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "voicecat"); + if (Path.GetFullPath(legacy) == Path.GetFullPath(directory) || !Directory.Exists(legacy)) return; + foreach (string name in new[] { "servers.json", "tofu_pins.txt" }) + { + string source = Path.Combine(legacy, name), destination = Path.Combine(directory, name); + if (File.Exists(source) && !File.Exists(destination)) File.Copy(source, destination); + } + } + + internal string? LoadPassword(ServerProfile profile) + { + string? value = ReadPassword(IosConstants.PasswordService, profile.Id.ToString("D"), IosConstants.AppGroup); + return value ?? (profile.LegacyKeychainTag is { Length: > 0 } tag + ? ReadPassword("cat.voice.VoiceCatiOS", tag, IosConstants.AppGroup) ?? ReadPassword("cat.voice.VoiceCatiOS", tag, null) + : null); + } + + internal void SavePassword(Guid id, string password) + { + byte[] encoded = Encoding.UTF8.GetBytes(password); + try + { + using var data = NSData.FromArray(encoded); + using var query = PasswordQuery(IosConstants.PasswordService, id.ToString("D"), IosConstants.AppGroup); + using var attributes = new SecRecord { ValueData = data, Label = "VoiceCat server password", Accessible = SecAccessible.AfterFirstUnlock }; + SecStatusCode status = SecKeyChain.Update(query, attributes); + if (status == SecStatusCode.ItemNotFound) + { + using var record = PasswordQuery(IosConstants.PasswordService, id.ToString("D"), IosConstants.AppGroup); + record.ValueData = data; record.Label = attributes.Label; record.Accessible = attributes.Accessible; + status = SecKeyChain.Add(record); + } + if (status != SecStatusCode.Success) throw new InvalidOperationException($"Keychain save failed ({status})."); + } + finally { CryptographicOperations.ZeroMemory(encoded); } + } + + internal void RemovePassword(Guid id) + { + using var query = PasswordQuery(IosConstants.PasswordService, id.ToString("D"), IosConstants.AppGroup); + SecStatusCode status = SecKeyChain.Remove(query); + if (status is not (SecStatusCode.Success or SecStatusCode.ItemNotFound)) + throw new InvalidOperationException($"Keychain removal failed ({status})."); + } + + private static string? ReadPassword(string service, string account, string? group) + { + using var query = PasswordQuery(service, account, group); + using SecRecord? result = SecKeyChain.QueryAsRecord(query, out SecStatusCode status); + if (status != SecStatusCode.Success || result?.ValueData is not { } data) return null; + byte[] bytes = data.ToArray(); + try { return Encoding.UTF8.GetString(bytes); } + finally { CryptographicOperations.ZeroMemory(bytes); } + } + + private static SecRecord PasswordQuery(string service, string account, string? group) => new(SecKind.GenericPassword) + { Service = service, Account = account, AccessGroup = group }; +} diff --git a/clients/apple/dotnet/VoiceCat.iOS/Main.cs b/clients/apple/dotnet/VoiceCat.iOS/Main.cs new file mode 100644 index 0000000..81d1899 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/Main.cs @@ -0,0 +1,3 @@ +using UIKit; + +UIApplication.Main(args, null, typeof(VoiceCat.iOS.AppDelegate)); diff --git a/clients/apple/dotnet/VoiceCat.iOS/MainControllers.cs b/clients/apple/dotnet/VoiceCat.iOS/MainControllers.cs new file mode 100644 index 0000000..c7e3d38 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/MainControllers.cs @@ -0,0 +1,128 @@ +using ReplayKit; +using UIKit; +using Voicecat.V1; +using Channel = Voicecat.V1.Channel; + +namespace VoiceCat.iOS; + +internal sealed class MainTabController : UITabBarController +{ + internal MainTabController(AppModel model) + { + UIViewController channels = new UINavigationController(new ChannelsController(model)); + channels.TabBarItem = new("Channels", UIImage.GetSystemImage("list.bullet.indent"), 0); + UIViewController chat = new UINavigationController(new ChatController(model)); + chat.TabBarItem = new("Chat", UIImage.GetSystemImage("message"), 1); + UIViewController settings = new UINavigationController(new SettingsController(model)); + settings.TabBarItem = new("Settings", UIImage.GetSystemImage("gear"), 2); + ViewControllers = [channels, chat, settings]; + } +} + +internal sealed class ChannelsController : UITableViewController +{ + private readonly AppModel model; + internal ChannelsController(AppModel model) { this.model = model; Title = "Channels"; model.Changed += () => TableView.ReloadData(); } + public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "channel"); NavigationItem.RightBarButtonItem = new("Users", UIBarButtonItemStyle.Plain, (_, _) => NavigationController?.PushViewController(new UsersController(model), true)); } + public override nint RowsInSection(UITableView tableView, nint section) => model.Channels.Count; + public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) + { + Channel channel = model.Channels.OrderBy(c => c.Order).ThenBy(c => c.Name).ElementAt(indexPath.Row); + UITableViewCell cell = tableView.DequeueReusableCell("channel", indexPath); int count = model.Users.Count(u => u.ChannelId == channel.Id); + var content = cell.DefaultContentConfiguration; content.Text = channel.Name; content.SecondaryText = $"{count} users" + (channel.PasswordProtected ? " • protected" : ""); content.Image = UIImage.GetSystemImage(channel.Id == model.CurrentChannelId ? "checkmark.circle.fill" : "bubble.left"); cell.ContentConfiguration = content; cell.AccessibilityLabel = $"{channel.Name}, {count} users"; return cell; + } + public override async void RowSelected(UITableView tableView, NSIndexPath indexPath) + { + Channel channel = model.Channels.OrderBy(c => c.Order).ThenBy(c => c.Name).ElementAt(indexPath.Row); tableView.DeselectRow(indexPath, true); + if (channel.PasswordProtected) + { + UIAlertController prompt = UIAlertController.Create("Channel Password", channel.Name, UIAlertControllerStyle.Alert); prompt.AddTextField(f => { f.SecureTextEntry = true; f.Placeholder = "Password"; }); + prompt.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); prompt.AddAction(UIAlertAction.Create("Join", UIAlertActionStyle.Default, async _ => { try { await model.JoinChannelAsync(channel.Id, prompt.TextFields![0].Text ?? ""); } catch (Exception e) { UiHelpers.ShowError(this, e); } })); PresentViewController(prompt, true, null); return; + } + try { await model.JoinChannelAsync(channel.Id); } catch (Exception e) { UiHelpers.ShowError(this, e); } + } +} + +internal sealed class UsersController : UITableViewController +{ + private readonly AppModel model; + private IReadOnlyList Visible => model.Users.Where(u => u.ChannelId == model.CurrentChannelId).ToArray(); + internal UsersController(AppModel model) { this.model = model; Title = "Users"; model.Changed += () => TableView.ReloadData(); } + public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "user"); } + public override nint RowsInSection(UITableView tableView, nint section) => Visible.Count; + public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) + { + User user = Visible[indexPath.Row]; UITableViewCell cell = tableView.DequeueReusableCell("user", indexPath); + var content = cell.DefaultContentConfiguration; content.Text = user.Nickname + (user.Id == model.SelfUserId ? " (you)" : ""); content.SecondaryText = user.ServerMuted ? "server muted" : user.SelfMicMuted ? "muted" : user.IsGuest ? "guest" : "account"; content.Image = UIImage.GetSystemImage(user.ServerMuted || user.SelfMicMuted ? "mic.slash.fill" : "mic.fill"); cell.ContentConfiguration = content; cell.AccessibilityLabel = $"{content.Text}, {content.SecondaryText}"; return cell; + } + public override void RowSelected(UITableView tableView, NSIndexPath indexPath) + { + User user = Visible[indexPath.Row]; tableView.DeselectRow(indexPath, true); if (user.Id == model.SelfUserId) return; + UIAlertController menu = UIAlertController.Create(user.Nickname, null, UIAlertControllerStyle.ActionSheet); + menu.AddAction(UIAlertAction.Create("Private message", UIAlertActionStyle.Default, _ => PromptPrivate(user))); + if (model.Client?.Permissions.CanKick == true || model.Client?.Permissions.IsAdmin == true) menu.AddAction(UIAlertAction.Create("Kick", UIAlertActionStyle.Destructive, async _ => await Run(() => model.Client!.KickUserAsync(user.Id)))); + if (model.Client?.Permissions.CanBan == true || model.Client?.Permissions.IsAdmin == true) menu.AddAction(UIAlertAction.Create("Ban", UIAlertActionStyle.Destructive, async _ => await Run(() => model.Client!.BanUserAsync(user.Id)))); + if (model.Client?.Permissions.IsAdmin == true) menu.AddAction(UIAlertAction.Create(user.ServerMuted ? "Server unmute" : "Server mute", UIAlertActionStyle.Default, async _ => await Run(() => model.Client!.SetServerMuteAsync(user.Id, !user.ServerMuted, user.ServerDeafened)))); + menu.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); menu.PopoverPresentationController!.SourceView = tableView.CellAt(indexPath); PresentViewController(menu, true, null); + } + private void PromptPrivate(User user) { UIAlertController p = UIAlertController.Create($"Message {user.Nickname}", null, UIAlertControllerStyle.Alert); p.AddTextField(f => f.Placeholder = "Message"); p.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); p.AddAction(UIAlertAction.Create("Send", UIAlertActionStyle.Default, _ => model.SendText(p.TextFields![0].Text ?? "", user.Id))); PresentViewController(p, true, null); } + private async Task Run(Func> command) { try { GenericResult result = await command(); if (!result.Ok) throw new InvalidOperationException(result.Message); } catch (Exception e) { UiHelpers.ShowError(this, e); } } +} + +internal sealed class ChatController : UIViewController +{ + private readonly AppModel model; private readonly UITextView log = new(); private readonly UITextField compose = UiHelpers.Field("Message"); + internal ChatController(AppModel model) { this.model = model; Title = "Chat"; model.Changed += Refresh; } + public override void ViewDidLoad() + { + base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; log.Editable = false; log.Font = UIFont.PreferredBody; log.TranslatesAutoresizingMaskIntoConstraints = false; + UIButton send = UIButton.FromType(UIButtonType.System); send.SetTitle("Send", UIControlState.Normal); send.AccessibilityLabel = "Send message"; send.TranslatesAutoresizingMaskIntoConstraints = false; send.TouchUpInside += (_, _) => { model.SendText(compose.Text ?? ""); compose.Text = ""; }; + compose.TranslatesAutoresizingMaskIntoConstraints = false; View.AddSubviews(log, compose, send); + NSLayoutConstraint.ActivateConstraints([log.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor), log.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), log.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), compose.TopAnchor.ConstraintEqualTo(log.BottomAnchor, 8), compose.LeadingAnchor.ConstraintEqualTo(View.LeadingAnchor, 12), compose.BottomAnchor.ConstraintEqualTo(View.KeyboardLayoutGuide.TopAnchor, -8), send.LeadingAnchor.ConstraintEqualTo(compose.TrailingAnchor, 8), send.TrailingAnchor.ConstraintEqualTo(View.TrailingAnchor, -12), send.CenterYAnchor.ConstraintEqualTo(compose.CenterYAnchor), compose.WidthAnchor.ConstraintGreaterThanOrEqualTo(120)]); Refresh(); + } + private void Refresh() { log.Text = string.Join("\n", model.Messages.Select(m => $"[{m.Timestamp:t}] {(m.Private ? "[private] " : "")}{m.Sender}: {m.Text}")); if (log.Text.Length > 0) log.ScrollRangeToVisible(new(log.Text.Length - 1, 1)); } +} + +internal sealed class SettingsController : UITableViewController +{ + private readonly AppModel model; private readonly string[] rows = ["Join Voice", "Audio Preset", "Speaker Output", "Mute Microphone", "Deafen", "Share Screen Audio", "Accounts", "Disconnect"]; + internal SettingsController(AppModel model) : base(UITableViewStyle.InsetGrouped) { this.model = model; Title = "Settings"; model.Changed += () => TableView.ReloadData(); } + public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "setting"); } + public override nint RowsInSection(UITableView tableView, nint section) => rows.Length; + public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) + { + UITableViewCell cell = tableView.DequeueReusableCell("setting", indexPath); string title = rows[indexPath.Row]; + if (indexPath.Row == 0) title = model.VoiceJoined ? "Leave Voice" : "Join Voice"; + var content = cell.DefaultContentConfiguration; content.Text = title; content.SecondaryText = indexPath.Row == 1 ? IosAudioRouter.Shared.Preset.ToString() : null; cell.ContentConfiguration = content; cell.Accessory = indexPath.Row is 1 or 6 ? UITableViewCellAccessory.DisclosureIndicator : UITableViewCellAccessory.None; return cell; + } + public override async void RowSelected(UITableView tableView, NSIndexPath indexPath) + { + tableView.DeselectRow(indexPath, true); try + { + switch (indexPath.Row) + { + case 0: await model.ToggleVoiceAsync(); break; + case 1: ShowPresets(); break; + case 2: IosAudioRouter.Shared.ForceSpeaker = !IosAudioRouter.Shared.ForceSpeaker; IosAudioEngine.Shared.Reconfigure(); break; + case 3: if (model.Client is { } c) c.SetSelfAudioState(!c.Audio.MicMuted, c.Audio.Deafened); break; + case 4: if (model.Client is { } d) d.SetSelfAudioState(d.Audio.MicMuted, !d.Audio.Deafened); break; + case 5: ShowBroadcastPicker(); break; + case 6: NavigationController?.PushViewController(new AccountsController(model), true); break; + case 7: await model.DisconnectAsync(); break; + } + } + catch (Exception e) { UiHelpers.ShowError(this, e); } + } + private void ShowPresets() { UIAlertController a = UIAlertController.Create("Audio Preset", null, UIAlertControllerStyle.ActionSheet); foreach (IosAudioPreset p in Enum.GetValues()) a.AddAction(UIAlertAction.Create(p.ToString(), UIAlertActionStyle.Default, _ => { IosAudioRouter.Shared.SelectPreset(p); TableView.ReloadData(); })); a.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); a.PopoverPresentationController!.SourceView = View; PresentViewController(a, true, null); } + private void ShowBroadcastPicker() { var picker = new RPSystemBroadcastPickerView(new CoreGraphics.CGRect(0, 0, 60, 60)) { PreferredExtension = IosConstants.BroadcastExtension, ShowsMicrophoneButton = false }; UIAlertController a = UIAlertController.Create("Screen Audio", "Tap the broadcast button, then choose Start Broadcast.", UIAlertControllerStyle.Alert); a.View.AddSubview(picker); picker.Center = new(a.View.Bounds.GetMidX(), 110); a.AddAction(UIAlertAction.Create("Done", UIAlertActionStyle.Cancel, null)); PresentViewController(a, true, null); } +} + +internal sealed class AccountsController : UITableViewController +{ + private readonly AppModel model; private IReadOnlyList accounts = []; + internal AccountsController(AppModel model) { this.model = model; Title = "Accounts"; } + public override async void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "account"); NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Add, (_, _) => PromptCreate()); try { accounts = await model.Client!.ListAccountsAsync(); TableView.ReloadData(); } catch (Exception e) { UiHelpers.ShowError(this, e); } } + public override nint RowsInSection(UITableView tableView, nint section) => accounts.Count; + public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { UITableViewCell c = tableView.DequeueReusableCell("account", indexPath); var x = c.DefaultContentConfiguration; x.Text = accounts[indexPath.Row].Username; x.SecondaryText = accounts[indexPath.Row].IsAdmin ? "Administrator" : "Account"; c.ContentConfiguration = x; return c; } + private void PromptCreate() { UIAlertController p = UIAlertController.Create("Create Account", null, UIAlertControllerStyle.Alert); p.AddTextField(f => f.Placeholder = "Username"); p.AddTextField(f => { f.Placeholder = "Password"; f.SecureTextEntry = true; }); p.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, null)); p.AddAction(UIAlertAction.Create("Create", UIAlertActionStyle.Default, async _ => { try { GenericResult result = await model.Client!.CreateAccountAsync(p.TextFields![0].Text ?? "", p.TextFields[1].Text ?? ""); if (!result.Ok) throw new InvalidOperationException(result.Message); accounts = await model.Client.ListAccountsAsync(); TableView.ReloadData(); } catch (Exception e) { UiHelpers.ShowError(this, e); } })); PresentViewController(p, true, null); } +} diff --git a/clients/apple/dotnet/VoiceCat.iOS/RootViewController.cs b/clients/apple/dotnet/VoiceCat.iOS/RootViewController.cs new file mode 100644 index 0000000..c7a5359 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/RootViewController.cs @@ -0,0 +1,30 @@ +using VoiceCat.Core; +using UIKit; + +namespace VoiceCat.iOS; + +internal sealed class RootViewController : UIViewController +{ + private readonly AppModel model; + private UIViewController? current; + internal RootViewController(AppModel model) { this.model = model; model.Changed += Refresh; model.IdentityRequested += ShowIdentity; } + public override void ViewDidLoad() { base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemBackground; Refresh(); } + + private void Refresh() + { + bool main = model.IsConnected; + if (current is MainTabController && main || current is UINavigationController && !main) return; + UIViewController next = main ? new MainTabController(model) : new UINavigationController(new ServerListController(model)); + if (current is not null) { current.WillMoveToParentViewController(null); current.View.RemoveFromSuperview(); current.RemoveFromParentViewController(); } + AddChildViewController(next); next.View.Frame = View!.Bounds; next.View.AutoresizingMask = UIViewAutoresizing.All; View.AddSubview(next.View); next.DidMoveToParentViewController(this); current = next; + } + + private void ShowIdentity(ServerIdentityChallenge challenge) + { + UIAlertController alert = UIAlertController.Create(challenge.Status == VoiceCat.Crypto.TofuStatus.Mismatch ? "Server Identity Changed" : "New Server Identity", + $"{challenge.Host}:{challenge.Port}\n\nSHA-256\n{challenge.CertificateFingerprint}", UIAlertControllerStyle.Alert); + alert.AddAction(UIAlertAction.Create("Reject", UIAlertActionStyle.Destructive, _ => model.ResolveIdentity(false))); + alert.AddAction(UIAlertAction.Create("Trust", UIAlertActionStyle.Default, _ => model.ResolveIdentity(true))); + PresentViewController(alert, true, null); + } +} diff --git a/clients/apple/dotnet/VoiceCat.iOS/SceneDelegate.cs b/clients/apple/dotnet/VoiceCat.iOS/SceneDelegate.cs new file mode 100644 index 0000000..1fe119a --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/SceneDelegate.cs @@ -0,0 +1,22 @@ +using Foundation; +using UIKit; + +namespace VoiceCat.iOS; + +[Register("SceneDelegate")] +internal sealed class SceneDelegate : UIResponder, IUIWindowSceneDelegate +{ + [Export("window")] + public UIWindow? Window { get; set; } + + [Export("scene:willConnectToSession:options:")] + public void WillConnect(UIScene scene, UISceneSession session, UISceneConnectionOptions options) + { + if (scene is not UIWindowScene windowScene) return; + Window = new(windowScene) { RootViewController = new RootViewController(AppModel.Shared) }; + Window.MakeKeyAndVisible(); + } + + [Export("sceneDidEnterBackground:")] + public void DidEnterBackground(UIScene scene) => AppModel.Shared.Save(); +} diff --git a/clients/apple/dotnet/VoiceCat.iOS/ServerListController.cs b/clients/apple/dotnet/VoiceCat.iOS/ServerListController.cs new file mode 100644 index 0000000..cb13b0c --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/ServerListController.cs @@ -0,0 +1,67 @@ +using VoiceCat.Core; +using UIKit; + +namespace VoiceCat.iOS; + +internal sealed class ServerListController : UITableViewController +{ + private readonly AppModel model; + internal ServerListController(AppModel model) { this.model = model; Title = "Servers"; TabBarItem = new(UITabBarSystemItem.Favorites, 0); } + public override void ViewDidLoad() + { + base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "server"); + NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Add, (_, _) => PresentEditor(null)); model.Changed += Reload; + } + public override nint RowsInSection(UITableView tableView, nint section) => model.Profiles.Count; + public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) + { + UITableViewCell cell = tableView.DequeueReusableCell("server", indexPath); + ServerProfile p = model.Profiles[indexPath.Row]; + var content = cell.DefaultContentConfiguration; content.Text = p.DisplayName; content.SecondaryText = p.Authentication.ToString(); cell.ContentConfiguration = content; + cell.Accessory = UITableViewCellAccessory.DisclosureIndicator; return cell; + } + public override async void RowSelected(UITableView tableView, NSIndexPath indexPath) + { + tableView.DeselectRow(indexPath, true); try { await model.ConnectAsync(model.Profiles[indexPath.Row]); } catch (Exception e) { UiHelpers.ShowError(this, e); } + } + public override UISwipeActionsConfiguration GetTrailingSwipeActionsConfiguration(UITableView tableView, NSIndexPath indexPath) + { + ServerProfile profile = model.Profiles[indexPath.Row]; + UIContextualAction edit = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Normal, "Edit", (_, _, done) => { PresentEditor(profile); done(true); }); + UIContextualAction delete = UIContextualAction.FromContextualActionStyle(UIContextualActionStyle.Destructive, "Delete", (_, _, done) => { model.RemoveProfile(profile); done(true); }); + return UISwipeActionsConfiguration.FromActions([delete, edit]); + } + private void PresentEditor(ServerProfile? profile) => PresentViewController(new UINavigationController(new ServerEditorController(model, profile)), true, null); + private void Reload() { TableView.ReloadData(); NavigationItem.Prompt = model.IsConnecting ? model.Status : null; } +} + +internal sealed class ServerEditorController : UIViewController +{ + private readonly AppModel model; private readonly ServerProfile? existing; + private readonly UITextField host = UiHelpers.Field("Hostname or IP address"); + private readonly UITextField port = UiHelpers.Field("Port"); + private readonly UISegmentedControl mode = new(["Guest", "Account"]); + private readonly UITextField name = UiHelpers.Field("Nickname or username"); + private readonly UITextField password = UiHelpers.Field("Password (optional)", true); + internal ServerEditorController(AppModel model, ServerProfile? existing) { this.model = model; this.existing = existing; Title = existing is null ? "Add Server" : "Edit Server"; } + public override void ViewDidLoad() + { + base.ViewDidLoad(); View!.BackgroundColor = UIColor.SystemGroupedBackground; port.KeyboardType = UIKeyboardType.NumberPad; + UIStackView stack = new([host, port, mode, name, password]) { Axis = UILayoutConstraintAxis.Vertical, Spacing = 12, TranslatesAutoresizingMaskIntoConstraints = false }; + View.AddSubview(stack); NSLayoutConstraint.ActivateConstraints([stack.TopAnchor.ConstraintEqualTo(View.SafeAreaLayoutGuide.TopAnchor, 24), stack.LeadingAnchor.ConstraintEqualTo(View.LayoutMarginsGuide.LeadingAnchor), stack.TrailingAnchor.ConstraintEqualTo(View.LayoutMarginsGuide.TrailingAnchor)]); + mode.SelectedSegment = existing?.Authentication == ServerAuthentication.Account ? 1 : 0; host.Text = existing?.Host; port.Text = (existing?.Port ?? 8384).ToString(); name.Text = existing?.Username ?? existing?.Nickname; + NavigationItem.LeftBarButtonItem = new(UIBarButtonSystemItem.Cancel, (_, _) => DismissViewController(true, null)); + NavigationItem.RightBarButtonItem = new(UIBarButtonSystemItem.Save, (_, _) => Save()); + } + private void Save() + { + try + { + if (!ushort.TryParse(port.Text, out ushort number)) throw new ArgumentException("Enter a valid port."); + ServerAuthentication auth = mode.SelectedSegment == 1 ? ServerAuthentication.Account : ServerAuthentication.Guest; + ServerProfile p = ServerProfile.Create(host.Text ?? "", number, auth, auth == ServerAuthentication.Account ? name.Text : null, auth == ServerAuthentication.Guest ? name.Text : null, existing?.Id); + model.UpsertProfile(p, password.Text); DismissViewController(true, null); + } + catch (Exception e) { UiHelpers.ShowError(this, e); } + } +} diff --git a/clients/apple/dotnet/VoiceCat.iOS/UiHelpers.cs b/clients/apple/dotnet/VoiceCat.iOS/UiHelpers.cs new file mode 100644 index 0000000..fb94087 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/UiHelpers.cs @@ -0,0 +1,20 @@ +using UIKit; + +namespace VoiceCat.iOS; + +internal static class UiHelpers +{ + internal static void ShowError(UIViewController owner, Exception exception) => ShowMessage(owner, "VoiceCat", exception.Message); + internal static void ShowMessage(UIViewController owner, string title, string message) + { + UIAlertController alert = UIAlertController.Create(title, message, UIAlertControllerStyle.Alert); + alert.AddAction(UIAlertAction.Create("OK", UIAlertActionStyle.Default, null)); owner.PresentViewController(alert, true, null); + } + + internal static UITextField Field(string placeholder, bool secure = false) + { + var field = new UITextField { Placeholder = placeholder, BorderStyle = UITextBorderStyle.RoundedRect, + SecureTextEntry = secure, TranslatesAutoresizingMaskIntoConstraints = false }; + field.AccessibilityLabel = placeholder; return field; + } +} diff --git a/clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj b/clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj new file mode 100644 index 0000000..ca61d18 --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj @@ -0,0 +1,51 @@ + + + Exe + net10.0-ios27.0 + iossimulator-arm64 + 18.0 + enable + enable + true + VoiceCat + me.iamtalon.voicecat + 1 + 0.0.1 + Entitlements.plist + Info.plist + full + $(NoWarn);XCODE_27_0_PREVIEW + true + iossimulator-arm64 + ios-arm64 + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../../dotnet/artifacts/native/runtimes/$(VoiceCatNativeRid)/native/libvoicecat_media.a')) + iphonesimulator + iphoneos + arm64 + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)obj/$(Configuration)/$(TargetFramework)/$(RuntimeIdentifier)/broadcast')) + <_ComputePublishLocationDependsOn>VoiceCatPrepareIosNativeAssets;$(_ComputePublishLocationDependsOn) + + + + + Static + true + + + VoiceCatBroadcast + . + $(MSBuildThisFileDirectory)../../iOS/VoiceCatBroadcast/VoiceCatBroadcast.entitlements + + + + + + + + + + + + + + diff --git a/clients/apple/dotnet/VoiceCat.iOS/build-broadcast-extension.sh b/clients/apple/dotnet/VoiceCat.iOS/build-broadcast-extension.sh new file mode 100755 index 0000000..c9d934a --- /dev/null +++ b/clients/apple/dotnet/VoiceCat.iOS/build-broadcast-extension.sh @@ -0,0 +1,20 @@ +#!/bin/zsh +set -euo pipefail + +configuration="${1:?configuration is required}" +sdk="${2:?sdk is required}" +architecture="${3:?architecture is required}" +output="${4:?output directory is required}" +script_dir="${0:A:h}" +project="$script_dir/../../iOS/VoiceCatiOS.xcodeproj" + +mkdir -p "$output" +xcodebuild \ + -project "$project" \ + -target VoiceCatBroadcast \ + -configuration "$configuration" \ + -sdk "$sdk" \ + -arch "$architecture" \ + CONFIGURATION_BUILD_DIR="$output" \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/clients/apple/iOS/Shared/BroadcastAudioRing.swift b/clients/apple/iOS/Shared/BroadcastAudioRing.swift index eca4e67..8c24f4a 100644 --- a/clients/apple/iOS/Shared/BroadcastAudioRing.swift +++ b/clients/apple/iOS/Shared/BroadcastAudioRing.swift @@ -27,7 +27,7 @@ enum BroadcastNotification { // calls `drainStale()` to discard any pre-roll, then drains in whole 20 ms frames. final class BroadcastAudioRing { - static let appGroupId = "group.cat.voice.VoiceCat" + static let appGroupId = "group.me.iamtalon.voicecat" enum RingError: Error { case noContainer, openFailed, mapFailed } diff --git a/clients/apple/iOS/VoiceCatiOS/ServerListStore.swift b/clients/apple/iOS/VoiceCatiOS/ServerListStore.swift index 018528c..9118d77 100644 --- a/clients/apple/iOS/VoiceCatiOS/ServerListStore.swift +++ b/clients/apple/iOS/VoiceCatiOS/ServerListStore.swift @@ -4,7 +4,7 @@ import Security final class ServerListStore { static let shared = ServerListStore() - private let groupId = "group.cat.voice.VoiceCat" + private let groupId = "group.me.iamtalon.voicecat" private let keychainService = "cat.voice.VoiceCatiOS" // MARK: - App Group Container diff --git a/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift index c30d4d4..cf30d52 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift @@ -139,7 +139,7 @@ private struct BroadcastPickerButton: UIViewRepresentable { func makeUIView(context: Context) -> RPSystemBroadcastPickerView { let picker = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 32, height: 32)) - picker.preferredExtension = "cat.voice.VoiceCatiOS.broadcast" + picker.preferredExtension = "me.iamtalon.voicecat.broadcast" picker.showsMicrophoneButton = false return picker } diff --git a/docs/broadcast-ring-format.md b/docs/broadcast-ring-format.md new file mode 100644 index 0000000..6506087 --- /dev/null +++ b/docs/broadcast-ring-format.md @@ -0,0 +1,23 @@ +# iOS broadcast-audio ring format + +The ReplayKit extension and host app exchange audio through `broadcast_audio.ring` in the +`group.me.iamtalon.voicecat` App Group. Version 1 is a 64-byte little-endian header followed by +96,000 signed 16-bit PCM samples (one second of stereo at 48 kHz). + +| Offset | Type | Meaning | +|---:|---|---| +| 0 | `uint32` | Magic `0x56434252` (`VCBR`) | +| 4 | `uint32` | Version, currently `1` | +| 8 | `uint32` | Channel count; active writers use `2` | +| 12 | `uint32` | Sample rate; active writers use `48000` | +| 16 | `uint32` | Active flag (`0` or `1`) | +| 20 | 4 bytes | Reserved, zero | +| 24 | `uint64` | Monotonic producer/write sample index | +| 32 | `uint64` | Monotonic consumer/read sample index | +| 40 | 24 bytes | Reserved, zero | + +The payload starts at byte 64 and contains interleaved little-endian `int16` PCM. The aligned +indices are single-producer/single-consumer counters; producer and consumer publish their owned +index with a release barrier and read the other index with an acquire barrier. A writer drops a +whole input chunk when it cannot fit. Changing this layout requires a new version and compatible +readers; offsets in version 1 must never be repurposed. diff --git a/docs/building.md b/docs/building.md index a57cc52..073448b 100644 --- a/docs/building.md +++ b/docs/building.md @@ -31,7 +31,7 @@ and *how to drive the binaries by hand*. | Windows client (C# / WinForms) | [§7](#7-windows-client-c--winforms) | `dotnet build clients/windows/VoiceCat.slnx` | | Managed macOS client (C# AppKit) | [§8](#8-macos-client-appkit) | `dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx` | | Swift macOS migration oracle | [§8](#8-macos-client-appkit) | `scripts/build-macos-client.sh` | -| iOS client (SwiftUI / simulator) | [§9](#9-ios-client-swiftui) | `scripts/build-ios-client.sh` | +| Managed iOS client (C# UIKit / simulator) | [§9](#9-ios-client-uikit) | `./dotnet/build-native-ios.sh && dotnet build clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj` | | Launch iOS app on simulator | [§9](#9-ios-client-swiftui) | `scripts/run-ios-simulator.sh` | | Swift core + tests | [§8](#8-macos-client-appkit) | `cd clients/apple && swift test` | @@ -419,7 +419,21 @@ Or use the per-artifact script which builds and stages to `dist/macos-client/`: scripts/build-macos-client.sh ``` -## 9. iOS client (SwiftUI) +## 9. iOS client (UIKit) + +The replacement iOS client is a native C# UIKit app at +`clients/apple/dotnet/VoiceCat.iOS`. Build its static media shim and simulator bundle with: + +```bash +./dotnet/build-native-ios.sh +dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx +dotnet build clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj \ + -c Debug -r iossimulator-arm64 --no-restore +``` + +The build invokes Xcode for the retained Swift ReplayKit extension and embeds the resulting +appex. The older SwiftUI application below remains the migration oracle during parity and +physical-device accessibility testing. The iOS client is an Xcode project (`clients/apple/iOS/VoiceCatiOS.xcodeproj`) that links `libvoicecat` via the same `VoiceCatCore` Swift Package as the macOS client. diff --git a/docs/porting-to-dotnet.md b/docs/porting-to-dotnet.md index a743a74..8ec896c 100644 --- a/docs/porting-to-dotnet.md +++ b/docs/porting-to-dotnet.md @@ -497,7 +497,7 @@ The Swift AppKit code maps almost line-for-line: NativeAOT for macOS app bundles is supported but adds a step. Nothing blocking; just not free. -### 8.3 iOS — the SwiftUI gap +### 8.3 iOS — the SwiftUI gap (decision implemented 2026-09-19) This is the only client with no mechanical path, because **SwiftUI has no C# equivalent.** Three options: @@ -512,6 +512,11 @@ Three options: with the accessibility commitments already made twice in the docs. Budget it as the largest single client task. +The decision is now implemented in `clients/apple/dotnet/VoiceCat.iOS`: a `net10.0-ios` +UIKit host uses the managed core, statically links the Opus/RNNoise shim through +`__Internal`, and embeds the retained Swift ReplayKit extension. The Swift app remains the +migration oracle until the UIKit client completes its device, live-call and VoiceOver gates. + What ports cleanly regardless: - `IOSAudioRouter.swift` (31 KB) — `AVAudioSession` is fully bound. `SetPreferredDataSource`, `SetPreferredPolarPattern`, `AllowBluetoothA2DP`, `MeasurementMode` all exist in C#. The diff --git a/docs/roadmap.md b/docs/roadmap.md index 99e9b2a..16449b6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -85,13 +85,14 @@ exists from M1 so the protocol can be exercised long before any GUI. deployment target. - AVAudioSession not needed on macOS (CoreAudio via the core directly). -**iOS (Swift/SwiftUI) — pending:** -- SwiftUI app consuming the same `VoiceCatCore` package. +**iOS (.NET/UIKit) — replacement in progress 2026-09-19:** +- Native C# UIKit app consuming `VoiceCat.Core`; UIKit was chosen over MAUI for direct audio + lifecycle control and the strongest VoiceOver surface. - ~~AVAudioSession, mic permission, foreground voice.~~ ✓ Done — `IOSAudioRouter` drives all iOS audio routing (input ports, orientation/polar patterns, HFP/A2DP, Standard/Raw mic mode, stereo capture), `vc_audio_suspend`/`vc_audio_resume` for interruptions. -- ReplayKit broadcast extension for `SCREEN_AUDIO` — feeds `CMSampleBuffer` audio via - `vc_stream_feed_pcm` (see architecture.md §4). +- The small Swift ReplayKit broadcast extension remains and writes its versioned App Group + PCM ring; the C# host drains it into `SCREEN_AUDIO`. See `broadcast-ring-format.md`. **Exit:** non-technical user installs a client, saves a server, and joins. diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 2300e5e..91d78e1 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -37,16 +37,16 @@ explicit resampling (speexdsp/libsamplerate) is only needed when a device can't ## 2. Clients -### macOS / iOS — Swift +### Apple clients — .NET native UI with a Swift ReplayKit exception | Concern | Choice | Notes | |---------|--------|-------| | Language | **Swift 5.9+** | Direct **Swift↔C interop** — the C ABI (`voicecat.h`) is imported as a Clang module (`import VoiceCatC`) via a module map in the XCFramework headers; no manual struct/function redeclaration (unlike the C# P/Invoke layer). A Swift wrapper (`VoiceCatCore` package) provides Swift-idiomatic types on top. | | UI — macOS | **AppKit** | Chosen over SwiftUI for the most mature, granular **VoiceOver** accessibility story (per-control `accessibilityLabel`/`accessibilityHelp`/`accessibilityRole`, `NSAccessibility.post(.announcement)` for live announcements) — the same rationale that drove the Windows client to WinForms over WinUI 3 for screen-reader (NVDA/JAWS/Narrator) UIA support (resolved decision in `docs/roadmap.md`). macOS 14 (Sonoma) deployment target. | -| UI — iOS | **SwiftUI** | iOS has a narrower control surface (no channel-tree moderation, etc.) and SwiftUI's VoiceOver support is sufficient; revisit if gaps emerge. iOS 18.0 deployment target (unlocks newest AVAudioSession APIs: stereo capture, polar patterns, data sources). | +| UI — iOS | **C# / UIKit (`net10.0-ios`)** | Native UIKit keeps direct lifecycle/audio control and predictable VoiceOver semantics. MAUI was rejected because a cross-platform abstraction provides no benefit for this platform-specific client. iOS 18.0 deployment target. | | Shared core | **VoiceCatCore** Swift Package | One Swift library wrapping the C ABI, consumed by both the macOS AppKit app and the iOS SwiftUI app. Mirrors the C# `VoiceCat.Interop` layer. Events delivered on `@MainActor` via a coalesced `DispatchQueue.main` drain (the Swift analog of C#'s `Channel` + 30ms WinForms Timer pump). | -| Audio session (iOS) | **AVAudioSession** + **IOSAudioRouter** | App owns category `.playAndRecord`, mic permission, interruption/route-change handling; calls `vc_audio_suspend`/`vc_audio_resume`/`vc_audio_restart` (implemented) on the core. All iOS audio routing (input port selection, mic orientation/polar patterns, HFP vs A2DP, measurement/raw mode, stereo capture via `.stereo` polar pattern + `setPreferredInput` + `setInputDataSource`) is driven from Swift via `AVAudioSession` *before* the core (miniaudio) opens its device — miniaudio does NOT touch `AVAudioSession` on iOS. The `IOSAudioRouter` singleton owns this; the core is told the channel count via `vc_set_capture_channels`. When settings change mid-session, devices are suspended (`vc_audio_suspend`), the session is reconfigured, and devices are restarted (`vc_audio_restart`) to pick up the new route. macOS uses CoreAudio via the core directly. | -| Packaging | Swift Package + Xcode project | Core shipped as an **XCFramework** binary target — a fat static library (`libvoicecat-fat.a`) bundling `libvoicecat.a` + all vcpkg static deps (protobuf/mbedtls/sodium/opus/sqlite3/spdlog/asio), so the Swift Package links a single self-contained `.a` per slice. macOS slice validated; iOS device + sim slices are scaffolding. | +| Audio session (iOS) | **AVAudioSession + AVAudioEngine from C#** | The UIKit host owns category, permission, interruptions, routes, VPIO capture and planar playback. Converted PCM crosses bounded managed rings; callbacks allocate no managed objects, lock, or block. | +| Packaging | .NET Apple workloads + Xcode appex | The managed iOS host statically links merged Opus/RNNoise archives. MSBuild invokes Xcode to build/embed the small Swift ReplayKit extension, which communicates through the versioned App Group PCM ring. | | Future | CallKit / PushKit | For background VoIP + incoming-call UX on iOS. Post-v1. | ### Windows — C# (shipped in M4, 2026-06-17) diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props index 37d9341..2768f2c 100644 --- a/dotnet/Directory.Build.props +++ b/dotnet/Directory.Build.props @@ -7,4 +7,7 @@ latest true + + $(DefineConstants);VOICECAT_IOS_STATIC + diff --git a/dotnet/build-native-ios.sh b/dotnet/build-native-ios.sh new file mode 100755 index 0000000..72736d0 --- /dev/null +++ b/dotnet/build-native-ios.sh @@ -0,0 +1,33 @@ +#!/bin/zsh +set -euo pipefail + +script_dir=${0:A:h} +build_one() { + local sdk=$1 + local rid=$2 + local sdk_path + sdk_path=$(xcrun --sdk "$sdk" --show-sdk-path) + local compiler + compiler=$(xcrun --sdk "$sdk" --find clang) + local build_dir="$script_dir/artifacts/native-build-$rid-cmake" + cmake -S "$script_dir/native" -B "$build_dir" \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_C_COMPILER="$compiler" \ + -DCMAKE_OSX_SYSROOT="$sdk_path" \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=18.0 \ + -DVOICECAT_DOTNET_RID="$rid" \ + -DVOICECAT_BUNDLED_OPUS=ON + cmake --build "$build_dir" --config Release --target voicecat_media --parallel 2 + cmake --install "$build_dir" --config Release \ + --component DotnetMedia --prefix "$script_dir/artifacts/native" + local staged="$script_dir/artifacts/native/runtimes/$rid/native/libvoicecat_media.a" + local opus="$build_dir/_deps/opus-build/libopus.a" + local rnnoise="$build_dir/librnnoise.a" + local combined="$staged.combined" + xcrun --sdk "$sdk" libtool -static -o "$combined" "$build_dir/libvoicecat_media.a" "$opus" "$rnnoise" + mv "$combined" "$staged" +} + +build_one iphoneos ios-arm64 +build_one iphonesimulator iossimulator-arm64 diff --git a/dotnet/native/CMakeLists.txt b/dotnet/native/CMakeLists.txt index 7737494..51ee49a 100644 --- a/dotnet/native/CMakeLists.txt +++ b/dotnet/native/CMakeLists.txt @@ -6,8 +6,9 @@ if(MSVC) set(OPUS_STATIC_RUNTIME ON CACHE BOOL "" FORCE) endif() +set(VOICECAT_MEDIA_LIBRARY_TYPE SHARED) if(CMAKE_SYSTEM_NAME STREQUAL "iOS") - message(FATAL_ERROR "iOS static NativeReference packaging belongs to the later client phase.") + set(VOICECAT_MEDIA_LIBRARY_TYPE STATIC) endif() if(NOT TARGET Opus::opus) @@ -59,7 +60,7 @@ if(NOT TARGET rnnoise) set_target_properties(rnnoise PROPERTIES POSITION_INDEPENDENT_CODE ON C_VISIBILITY_PRESET hidden) endif() -add_library(voicecat_media SHARED media.c) +add_library(voicecat_media ${VOICECAT_MEDIA_LIBRARY_TYPE} media.c) target_compile_features(voicecat_media PRIVATE c_std_99) target_link_libraries(voicecat_media PRIVATE Opus::opus rnnoise) set_target_properties(voicecat_media PROPERTIES C_VISIBILITY_PRESET hidden) @@ -93,7 +94,8 @@ endif() install(TARGETS voicecat_media RUNTIME DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia - LIBRARY DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia) + LIBRARY DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia + ARCHIVE DESTINATION runtimes/${VOICECAT_DOTNET_RID}/native COMPONENT DotnetMedia) install(FILES ${RNNOISE_DIR}/COPYING DESTINATION licenses RENAME RNNoise.txt COMPONENT DotnetMedia) install(FILES ${CMAKE_CURRENT_LIST_DIR}/NOTICE.txt DESTINATION licenses COMPONENT DotnetMedia) if(VOICECAT_OPUS_LICENSE) diff --git a/dotnet/src/VoiceCat.Audio/packages.lock.json b/dotnet/src/VoiceCat.Audio/packages.lock.json index b7dde5e..1e3f42c 100644 --- a/dotnet/src/VoiceCat.Audio/packages.lock.json +++ b/dotnet/src/VoiceCat.Audio/packages.lock.json @@ -19,6 +19,7 @@ "Google.Protobuf": "[3.36.1, )" } } - } + }, + "net10.0/iossimulator-arm64": {} } } \ No newline at end of file diff --git a/dotnet/src/VoiceCat.Codec/NativeMethods.cs b/dotnet/src/VoiceCat.Codec/NativeMethods.cs index e292aa0..b8d54a7 100644 --- a/dotnet/src/VoiceCat.Codec/NativeMethods.cs +++ b/dotnet/src/VoiceCat.Codec/NativeMethods.cs @@ -4,7 +4,11 @@ namespace VoiceCat.Codec; internal static unsafe partial class NativeMethods { +#if VOICECAT_IOS_STATIC + private const string Library = "__Internal"; +#else private const string Library = "voicecat_media"; +#endif [LibraryImport(Library, EntryPoint = "vcm_opus_version")] internal static partial nint Version(); [LibraryImport(Library, EntryPoint = "vcm_opus_error")] diff --git a/dotnet/src/VoiceCat.Codec/packages.lock.json b/dotnet/src/VoiceCat.Codec/packages.lock.json index 4a91a8c..47b97f3 100644 --- a/dotnet/src/VoiceCat.Codec/packages.lock.json +++ b/dotnet/src/VoiceCat.Codec/packages.lock.json @@ -1,6 +1,7 @@ { "version": 1, "dependencies": { - "net10.0": {} + "net10.0": {}, + "net10.0/iossimulator-arm64": {} } } \ No newline at end of file diff --git a/dotnet/src/VoiceCat.Core/ServerProfile.cs b/dotnet/src/VoiceCat.Core/ServerProfile.cs index a818726..cd9a866 100644 --- a/dotnet/src/VoiceCat.Core/ServerProfile.cs +++ b/dotnet/src/VoiceCat.Core/ServerProfile.cs @@ -32,13 +32,6 @@ public sealed record ServerProfile(Guid Id, string Host, ushort Port, ServerAuth public sealed class ServerProfileStore(string path) { - private static readonly JsonSerializerOptions Json = new() - { - Converters = { new JsonStringEnumConverter() }, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true - }; - public IReadOnlyList Load() { try @@ -48,7 +41,7 @@ public sealed class ServerProfileStore(string path) using JsonDocument document = JsonDocument.Parse(contents); if (document.RootElement.ValueKind == JsonValueKind.Array && document.RootElement.EnumerateArray().Any(LooksLegacy)) return LoadLegacy(document.RootElement); - return (JsonSerializer.Deserialize(contents, Json) ?? []) + return (JsonSerializer.Deserialize(contents, ServerProfileJsonContext.Default.ServerProfileArray) ?? []) .Where(profile => profile.IsValid).ToArray(); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { return []; } @@ -64,7 +57,7 @@ public sealed class ServerProfileStore(string path) string temporary = fullPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; try { - File.WriteAllBytes(temporary, JsonSerializer.SerializeToUtf8Bytes(valid, Json)); + File.WriteAllBytes(temporary, JsonSerializer.SerializeToUtf8Bytes(valid, ServerProfileJsonContext.Default.ServerProfileArray)); File.Move(temporary, fullPath, true); } finally { if (File.Exists(temporary)) File.Delete(temporary); } @@ -104,3 +97,7 @@ public sealed class ServerProfileStore(string path) catch (JsonException) { } } } + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true, UseStringEnumConverter = true)] +[JsonSerializable(typeof(ServerProfile[]))] +internal sealed partial class ServerProfileJsonContext : JsonSerializerContext; diff --git a/dotnet/src/VoiceCat.Core/packages.lock.json b/dotnet/src/VoiceCat.Core/packages.lock.json index e02c357..3ee0b1c 100644 --- a/dotnet/src/VoiceCat.Core/packages.lock.json +++ b/dotnet/src/VoiceCat.Core/packages.lock.json @@ -39,6 +39,7 @@ "Google.Protobuf": "[3.36.1, )" } } - } + }, + "net10.0/iossimulator-arm64": {} } } \ No newline at end of file diff --git a/dotnet/src/VoiceCat.Crypto/packages.lock.json b/dotnet/src/VoiceCat.Crypto/packages.lock.json index 11b81a5..d383291 100644 --- a/dotnet/src/VoiceCat.Crypto/packages.lock.json +++ b/dotnet/src/VoiceCat.Crypto/packages.lock.json @@ -19,6 +19,7 @@ "Google.Protobuf": "[3.36.1, )" } } - } + }, + "net10.0/iossimulator-arm64": {} } } \ No newline at end of file diff --git a/dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs b/dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs index 2b50558..2d60007 100644 --- a/dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs +++ b/dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs @@ -5,6 +5,11 @@ namespace VoiceCat.Dsp; public sealed unsafe partial class RnnoiseProcessor : IDisposable { +#if VOICECAT_IOS_STATIC + private const string NativeLibrary = "__Internal"; +#else + private const string NativeLibrary = "voicecat_media"; +#endif public const int SampleRate = 48000; public const int FrameSamples = 480; private readonly RnnoiseHandle handle; @@ -37,11 +42,11 @@ public sealed unsafe partial class RnnoiseProcessor : IDisposable public void Dispose() => handle.Dispose(); - [LibraryImport("voicecat_media", EntryPoint = "vcm_rnnoise_create")] + [LibraryImport(NativeLibrary, EntryPoint = "vcm_rnnoise_create")] private static partial nint Create(); - [LibraryImport("voicecat_media", EntryPoint = "vcm_rnnoise_destroy")] + [LibraryImport(NativeLibrary, EntryPoint = "vcm_rnnoise_destroy")] private static partial void Destroy(nint state); - [LibraryImport("voicecat_media", EntryPoint = "vcm_rnnoise_process")] + [LibraryImport(NativeLibrary, EntryPoint = "vcm_rnnoise_process")] private static partial float ProcessFrame(RnnoiseHandle state, float* output, float* input); private sealed class RnnoiseHandle : SafeHandleZeroOrMinusOneIsInvalid diff --git a/dotnet/src/VoiceCat.Dsp/packages.lock.json b/dotnet/src/VoiceCat.Dsp/packages.lock.json index 4a91a8c..47b97f3 100644 --- a/dotnet/src/VoiceCat.Dsp/packages.lock.json +++ b/dotnet/src/VoiceCat.Dsp/packages.lock.json @@ -1,6 +1,7 @@ { "version": 1, "dependencies": { - "net10.0": {} + "net10.0": {}, + "net10.0/iossimulator-arm64": {} } } \ No newline at end of file diff --git a/dotnet/src/VoiceCat.Protocol/packages.lock.json b/dotnet/src/VoiceCat.Protocol/packages.lock.json index 3b34259..f820553 100644 --- a/dotnet/src/VoiceCat.Protocol/packages.lock.json +++ b/dotnet/src/VoiceCat.Protocol/packages.lock.json @@ -14,6 +14,7 @@ "resolved": "2.83.0", "contentHash": "vK2Go/83W0v2Nn7tTP9fGrX4IjmOa93s3M0SZeFimU1vIIr2wL9yNJlIyK21y85SGm3++JncB8IF751cjoLHuQ==" } - } + }, + "net10.0/iossimulator-arm64": {} } } \ No newline at end of file