Add managed UIKit iOS client
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-19 15:43:37 +02:00
parent d0a72176ba
commit c6715028c1
41 changed files with 1110 additions and 40 deletions
@@ -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) };
}
@@ -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<ServerProfile> profiles = [];
private readonly List<ChatEntry> messages = [];
private CancellationTokenSource? lifetime;
private VoiceCatClient? client;
private TaskCompletionSource<bool>? identityDecision;
private ServerProfile? connectedProfile;
private int reconnectAttempt;
private bool explicitDisconnect;
private uint microphoneStream;
private BroadcastAudioPump? broadcast;
internal event Action? Changed;
internal event Action<ServerIdentityChallenge>? IdentityRequested;
internal IReadOnlyList<ServerProfile> Profiles => profiles;
internal IReadOnlyList<ChatEntry> 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<Channel> Channels => client?.Channels ?? [];
internal IReadOnlyList<User> 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<bool> 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();
}
@@ -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();
}
}
@@ -0,0 +1,5 @@
<?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.application-groups</key><array><string>group.me.iamtalon.voicecat</string></array>
</dict></plist>
@@ -0,0 +1,23 @@
<?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>me.iamtalon.voicecat</string>
<key>CFBundleShortVersionString</key><string>0.0.1</string>
<key>CFBundleVersion</key><string>1</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>NSMicrophoneUsageDescription</key><string>VoiceCat needs microphone access to transmit your voice in channels.</string>
<key>UIBackgroundModes</key><array><string>audio</string></array>
<key>UIRequiresFullScreen</key><false/>
<key>UIDeviceFamily</key><array><integer>1</integer><integer>2</integer></array>
<key>UILaunchScreen</key><dict/>
<key>UISupportedInterfaceOrientations</key><array><string>UIInterfaceOrientationPortrait</string><string>UIInterfaceOrientationLandscapeLeft</string><string>UIInterfaceOrientationLandscapeRight</string></array>
<key>UISupportedInterfaceOrientations~ipad</key><array><string>UIInterfaceOrientationPortrait</string><string>UIInterfaceOrientationPortraitUpsideDown</string><string>UIInterfaceOrientationLandscapeLeft</string><string>UIInterfaceOrientationLandscapeRight</string></array>
<key>UIApplicationSceneManifest</key><dict>
<key>UIApplicationSupportsMultipleScenes</key><false/>
<key>UISceneConfigurations</key><dict><key>UIWindowSceneSessionRoleApplication</key><array><dict>
<key>UISceneConfigurationName</key><string>Default Configuration</string>
<key>UISceneDelegateClassName</key><string>VoiceCat.iOS.SceneDelegate</string>
</dict></array></dict>
</dict>
</dict></plist>
@@ -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<short>((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<short> 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<short> 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<float>((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;
}
}
@@ -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();
}
}
@@ -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";
}
@@ -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<ServerProfile> LoadProfiles()
{
MigrateLegacyFiles();
return profiles.Load();
}
internal void SaveProfiles(IEnumerable<ServerProfile> 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 };
}
@@ -0,0 +1,3 @@
using UIKit;
UIApplication.Main(args, null, typeof(VoiceCat.iOS.AppDelegate));
@@ -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<User> 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<Task<GenericResult>> 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<IosAudioPreset>()) 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<AccountEntry> 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); }
}
@@ -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);
}
}
@@ -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();
}
@@ -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); }
}
}
@@ -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;
}
}
@@ -0,0 +1,51 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-ios27.0</TargetFramework>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">iossimulator-arm64</RuntimeIdentifier>
<SupportedOSPlatformVersion>18.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ApplicationTitle>VoiceCat</ApplicationTitle>
<ApplicationId>me.iamtalon.voicecat</ApplicationId>
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>0.0.1</ApplicationDisplayVersion>
<CodesignEntitlements>Entitlements.plist</CodesignEntitlements>
<ApplicationManifest>Info.plist</ApplicationManifest>
<TrimMode Condition="'$(Configuration)' == 'Release'">full</TrimMode>
<NoWarn>$(NoWarn);XCODE_27_0_PREVIEW</NoWarn>
<VoiceCatIosStatic>true</VoiceCatIosStatic>
<VoiceCatNativeRid Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">iossimulator-arm64</VoiceCatNativeRid>
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == ''">ios-arm64</VoiceCatNativeRid>
<VoiceCatNativeMediaPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../../dotnet/artifacts/native/runtimes/$(VoiceCatNativeRid)/native/libvoicecat_media.a'))</VoiceCatNativeMediaPath>
<VoiceCatBroadcastSdk Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">iphonesimulator</VoiceCatBroadcastSdk>
<VoiceCatBroadcastSdk Condition="'$(VoiceCatBroadcastSdk)' == ''">iphoneos</VoiceCatBroadcastSdk>
<VoiceCatBroadcastArch>arm64</VoiceCatBroadcastArch>
<VoiceCatBroadcastOutput>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)obj/$(Configuration)/$(TargetFramework)/$(RuntimeIdentifier)/broadcast'))</VoiceCatBroadcastOutput>
<_ComputePublishLocationDependsOn>VoiceCatPrepareIosNativeAssets;$(_ComputePublishLocationDependsOn)</_ComputePublishLocationDependsOn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" AdditionalProperties="VoiceCatIosStatic=true" />
<NativeReference Include="$(VoiceCatNativeMediaPath)" Condition="Exists('$(VoiceCatNativeMediaPath)')">
<Kind>Static</Kind>
<ForceLoad>true</ForceLoad>
</NativeReference>
<AdditionalAppExtensions Include="$(VoiceCatBroadcastOutput)">
<Name>VoiceCatBroadcast</Name>
<BuildOutput>.</BuildOutput>
<CodesignEntitlements>$(MSBuildThisFileDirectory)../../iOS/VoiceCatBroadcast/VoiceCatBroadcast.entitlements</CodesignEntitlements>
</AdditionalAppExtensions>
<BundleResource Include="../../Sources/VoiceCatCore/Sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
<ImageAsset Include="../../iOS/VoiceCatiOS/Assets.xcassets/**" Link="Assets.xcassets/%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<Target Name="VoiceCatBuildBroadcastExtension" BeforeTargets="_ResolveAppExtensionReferences">
<Exec Command="&quot;$(MSBuildThisFileDirectory)build-broadcast-extension.sh&quot; &quot;$(Configuration)&quot; &quot;$(VoiceCatBroadcastSdk)&quot; &quot;$(VoiceCatBroadcastArch)&quot; &quot;$(VoiceCatBroadcastOutput)&quot;" />
</Target>
<Target Name="VoiceCatPrepareIosNativeAssets">
<ItemGroup>
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(Filename)%(Extension)' == 'libvoicecat_media.dylib' or '%(Filename)%(Extension)' == 'voicecat_media.dll' or '%(Filename)%(Extension)' == 'libvoicecat_media.so'" />
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(Filename)%(Extension)' == 'NOTICE.txt' or '%(Filename)%(Extension)' == 'Opus.txt' or '%(Filename)%(Extension)' == 'RNNoise.txt'" />
</ItemGroup>
</Target>
</Project>
@@ -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