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
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,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=""$(MSBuildThisFileDirectory)build-broadcast-extension.sh" "$(Configuration)" "$(VoiceCatBroadcastSdk)" "$(VoiceCatBroadcastArch)" "$(VoiceCatBroadcastOutput)"" />
|
||||
</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
|
||||
Reference in New Issue
Block a user