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,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 };
}