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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user