From 3ee0df11a9eabe99c5e1b115dd8204c46baf756d Mon Sep 17 00:00:00 2001 From: Talon Date: Sun, 20 Sep 2026 03:03:07 +0200 Subject: [PATCH] Fix managed iOS native media linking --- clients/apple/dotnet/VoiceCat.iOS/AppModel.cs | 9 ++++++++- .../apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj | 4 ++++ clients/apple/dotnet/build-ios-device.sh | 12 ++++++++++++ dotnet/Directory.Build.props | 1 - dotnet/src/VoiceCat.Codec/NativeMethods.cs | 13 +++++++++---- dotnet/src/VoiceCat.Core/VoiceCatClient.cs | 4 +++- dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs | 13 +++++++++---- 7 files changed, 45 insertions(+), 11 deletions(-) diff --git a/clients/apple/dotnet/VoiceCat.iOS/AppModel.cs b/clients/apple/dotnet/VoiceCat.iOS/AppModel.cs index 77a7d98..fe76eb5 100644 --- a/clients/apple/dotnet/VoiceCat.iOS/AppModel.cs +++ b/clients/apple/dotnet/VoiceCat.iOS/AppModel.cs @@ -98,6 +98,11 @@ internal sealed class AppModel Status = restoring ? "Reconnecting…" : "Connecting…"; Notify(); lifetime?.Cancel(); lifetime?.Dispose(); lifetime = new(); VoiceCatClient next = new("VoiceCat-iOS", "0.0.1", storage.TofuPath); + next.ConnectionStateChanged += state => + { + if (state == ClientConnectionState.Disconnected && next.ConnectionFailure is { } failure) + Console.Error.WriteLine($"VoiceCat control connection failed: {failure}"); + }; client = next; try { @@ -118,8 +123,10 @@ internal sealed class AppModel } catch (Exception exception) { - IsConnecting = false; Status = exception.Message; Notify(); + System.Diagnostics.Debug.WriteLine($"Connection failed: {exception}"); + IsConnecting = false; Status = exception.Message; await next.DisposeAsync(); if (ReferenceEquals(client, next)) client = null; + Notify(); if (restoring && !explicitDisconnect) ScheduleReconnect(); else throw; } diff --git a/clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj b/clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj index 510f70e..ec80c2c 100644 --- a/clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj +++ b/clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj @@ -32,6 +32,10 @@ Static true + + + Function + Static true diff --git a/clients/apple/dotnet/build-ios-device.sh b/clients/apple/dotnet/build-ios-device.sh index 3aa90d4..cdfee7c 100755 --- a/clients/apple/dotnet/build-ios-device.sh +++ b/clients/apple/dotnet/build-ios-device.sh @@ -30,6 +30,18 @@ if [ -n "${VOICECAT_CODESIGN_PROVISION:-}" ]; then set -- "$@" -p:CodesignProvis app="$root/clients/apple/dotnet/VoiceCat.iOS/bin/$configuration/net10.0-ios27.0/ios-arm64/VoiceCat.iOS.app" [ -d "$app" ] || { echo "device app was not produced at $app" >&2; exit 1; } +/usr/bin/strings "$app/VoiceCat.Codec.dll" | /usr/bin/grep -q GetMainProgramHandle || { + echo "device codec does not contain the iOS static-library resolver" >&2 + exit 1 +} +/usr/bin/nm -gU "$app/VoiceCat.iOS" | /usr/bin/grep -q _vcm_decoder_create || { + echo "device executable does not export the statically linked media codec" >&2 + exit 1 +} +/usr/bin/nm -gU "$app/VoiceCat.iOS" | /usr/bin/grep -q _vcm_rnnoise_create || { + echo "device executable does not export the statically linked RNNoise processor" >&2 + exit 1 +} mkdir -p "$output" /usr/bin/ditto "$app" "$output/VoiceCat.iOS.app" /usr/bin/codesign --verify --deep --strict "$output/VoiceCat.iOS.app" diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props index 8dbab2e..d07004b 100644 --- a/dotnet/Directory.Build.props +++ b/dotnet/Directory.Build.props @@ -8,7 +8,6 @@ true - $(DefineConstants);VOICECAT_IOS_STATIC ios-arm64;iossimulator-arm64 packages.ios.lock.json diff --git a/dotnet/src/VoiceCat.Codec/NativeMethods.cs b/dotnet/src/VoiceCat.Codec/NativeMethods.cs index b8d54a7..a782994 100644 --- a/dotnet/src/VoiceCat.Codec/NativeMethods.cs +++ b/dotnet/src/VoiceCat.Codec/NativeMethods.cs @@ -1,14 +1,19 @@ using System.Runtime.InteropServices; +using System.Reflection; namespace VoiceCat.Codec; internal static unsafe partial class NativeMethods { -#if VOICECAT_IOS_STATIC - private const string Library = "__Internal"; -#else + static NativeMethods() + { + if (OperatingSystem.IsIOS()) NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, ResolveIosStaticLibrary); + } + + private static nint ResolveIosStaticLibrary(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) => + libraryName == "voicecat_media" ? NativeLibrary.GetMainProgramHandle() : 0; + private const string Library = "voicecat_media"; -#endif [LibraryImport(Library, EntryPoint = "vcm_opus_version")] internal static partial nint Version(); [LibraryImport(Library, EntryPoint = "vcm_opus_error")] diff --git a/dotnet/src/VoiceCat.Core/VoiceCatClient.cs b/dotnet/src/VoiceCat.Core/VoiceCatClient.cs index b807a4a..e13317a 100644 --- a/dotnet/src/VoiceCat.Core/VoiceCatClient.cs +++ b/dotnet/src/VoiceCat.Core/VoiceCatClient.cs @@ -43,6 +43,7 @@ public sealed partial class VoiceCatClient : IAsyncDisposable public event Action? ConnectionStateChanged; public ClientConnectionState State { get { lock (stateGate) return state; } } + public Exception? ConnectionFailure { get; private set; } public Task Completion => reader; public AuthResult? Authentication { get { lock (stateGate) return authentication?.Clone(); } } public ServerHello? ServerHello { get { lock (stateGate) return hello?.Clone(); } } @@ -72,6 +73,7 @@ public sealed partial class VoiceCatClient : IAsyncDisposable ObjectDisposedException.ThrowIf(disposed.IsCancellationRequested, this); if (control is not null) throw new InvalidOperationException("Disconnect before reconnecting."); started = true; + ConnectionFailure = null; connectionLifetime = CancellationTokenSource.CreateLinkedTokenSource(disposed.Token); using var connecting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, connectionLifetime.Token); CancellationToken token = connecting.Token; @@ -201,7 +203,7 @@ public sealed partial class VoiceCatClient : IAsyncDisposable if (message.Disconnect is not null) { connection.CompleteWrites(); break; } } } - catch (Exception exception) when (exception is IOException or OperationCanceledException or SocketException or ObjectDisposedException) { failure = exception; } + catch (Exception exception) { failure = exception; ConnectionFailure = exception; } finally { connectionLifetime?.Cancel(); diff --git a/dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs b/dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs index 2d60007..2fbd67d 100644 --- a/dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs +++ b/dotnet/src/VoiceCat.Dsp/RnnoiseProcessor.cs @@ -1,21 +1,26 @@ using System.Runtime.InteropServices; +using System.Reflection; using Microsoft.Win32.SafeHandles; namespace VoiceCat.Dsp; public sealed unsafe partial class RnnoiseProcessor : IDisposable { -#if VOICECAT_IOS_STATIC - private const string NativeLibrary = "__Internal"; -#else private const string NativeLibrary = "voicecat_media"; -#endif public const int SampleRate = 48000; public const int FrameSamples = 480; private readonly RnnoiseHandle handle; private readonly float[] input = new float[FrameSamples]; private readonly float[] output = new float[FrameSamples]; + static RnnoiseProcessor() + { + if (OperatingSystem.IsIOS()) System.Runtime.InteropServices.NativeLibrary.SetDllImportResolver(typeof(RnnoiseProcessor).Assembly, ResolveIosStaticLibrary); + } + + private static nint ResolveIosStaticLibrary(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) => + libraryName == NativeLibrary ? System.Runtime.InteropServices.NativeLibrary.GetMainProgramHandle() : 0; + public RnnoiseProcessor() { handle = new(Create());