Store macOS account passwords in Keychain
.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:
+6
-3
@@ -26,11 +26,14 @@ up instantly. Newest status at the top.
|
|||||||
and passwords are deliberately excluded from the model and JSON. Three behavior tests cover
|
and passwords are deliberately excluded from the model and JSON. Three behavior tests cover
|
||||||
profile round trips, corrupt input and account validation. Protected channels are labeled
|
profile round trips, corrupt input and account validation. Protected channels are labeled
|
||||||
and prompt for a session-only password before interrupting active voice; cancel and rejected
|
and prompt for a session-only password before interrupting active voice; cancel and rejected
|
||||||
joins restore the selected channel and voice session. Workload installation remains
|
joins restore the selected channel and voice session. Saved account profiles can opt into a
|
||||||
|
macOS Keychain generic-password item keyed by profile ID; secrets are written only after
|
||||||
|
successful authentication and removed when remembering is disabled or the profile is deleted.
|
||||||
|
Workload installation remains
|
||||||
blocked by this Windows machine's unrelated Visual Studio
|
blocked by this Windows machine's unrelated Visual Studio
|
||||||
iOS/Android MSI repair failure, so device execution is not claimed. **Next:** run the
|
iOS/Android MSI repair failure, so device execution is not claimed. **Next:** run the
|
||||||
macOS CI/device capture-playback test and ten-minute listen gate, then add optional Keychain
|
macOS CI/device capture-playback test and ten-minute listen gate, then add non-default devices,
|
||||||
credentials, non-default devices, private messages,
|
private messages,
|
||||||
moderation/settings, ScreenCaptureKit,
|
moderation/settings, ScreenCaptureKit,
|
||||||
VoiceOver verification, signing and notarization.
|
VoiceOver verification, signing and notarization.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
`VoiceCat.Mac` is the native AppKit C# port. It targets `net10.0-macos` and references the same `VoiceCat.Core` and `VoiceCat.Audio` assemblies used by the Windows client and managed CLI.
|
`VoiceCat.Mac` is the native AppKit C# port. It targets `net10.0-macos` and references the same `VoiceCat.Core` and `VoiceCat.Audio` assemblies used by the Windows client and managed CLI.
|
||||||
|
|
||||||
The current checkpoint is a functional client: AppKit launch/menu lifecycle, saved server profiles, guest and account authentication, explicit TOFU approval (including changed-key warning), protected-channel password prompts, channel selection, user roster, channel text, voice subscription, managed microphone-stream lifecycle, default-device Core Audio capture/playback, disconnect reporting, native accessibility labels, sandbox/network/audio entitlements, and deterministic disposal. Profiles save the host, port, authentication mode, username or guest nickname; account passwords remain session-only and are never written to the profile JSON. Capture is converted to interleaved 48 kHz int16 PCM through `AVAudioConverter`; playback uses an `AVAudioSourceNode` and the shared bounded `PcmRing`, so its render callback does not allocate, lock, or block. It does not yet replace the Swift release. Optional Keychain credentials, non-default device selection, moderation sheets, private messages, settings, ScreenCaptureKit sharing, VoiceOver verification, signing and notarization remain.
|
The current checkpoint is a functional client: AppKit launch/menu lifecycle, saved server profiles, guest and account authentication, explicit TOFU approval (including changed-key warning), protected-channel password prompts, channel selection, user roster, channel text, voice subscription, managed microphone-stream lifecycle, default-device Core Audio capture/playback, disconnect reporting, native accessibility labels, sandbox/network/audio entitlements, and deterministic disposal. Profiles save the host, port, authentication mode, username or guest nickname. An account password can be remembered as a macOS Keychain generic-password item only after successful authentication; it is never written to profile JSON and is removed when remembering is disabled or the profile is deleted. Capture is converted to interleaved 48 kHz int16 PCM through `AVAudioConverter`; playback uses an `AVAudioSourceNode` and the shared bounded `PcmRing`, so its render callback does not allocate, lock, or block. It does not yet replace the Swift release. Non-default device selection, moderation sheets, private messages, settings, ScreenCaptureKit sharing, VoiceOver verification, signing and notarization remain.
|
||||||
|
|
||||||
Build on Apple Silicon macOS 15.6+ with Xcode 26 and the .NET 10 macOS workload:
|
Build on Apple Silicon macOS 15.6+ with Xcode 26 and the .NET 10 macOS workload:
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ internal sealed class ConnectWindowController : NSWindowController
|
|||||||
{
|
{
|
||||||
private static readonly string SupportDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat");
|
private static readonly string SupportDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat");
|
||||||
private readonly ServerProfileStore profileStore = new(Path.Combine(SupportDirectory, "servers.json"));
|
private readonly ServerProfileStore profileStore = new(Path.Combine(SupportDirectory, "servers.json"));
|
||||||
|
private readonly MacKeychainPasswordStore keychain = new();
|
||||||
private readonly NSPopUpButton profiles = new(new CGRect(120, 315, 330, 28), false);
|
private readonly NSPopUpButton profiles = new(new CGRect(120, 315, 330, 28), false);
|
||||||
private readonly NSTextField host = new(new CGRect(120, 270, 330, 26)) { StringValue = "127.0.0.1:8384", PlaceholderString = "Host:port" };
|
private readonly NSTextField host = new(new CGRect(120, 270, 330, 26)) { StringValue = "127.0.0.1:8384", PlaceholderString = "Host:port" };
|
||||||
private readonly NSPopUpButton authentication = new(new CGRect(120, 225, 180, 28), false);
|
private readonly NSPopUpButton authentication = new(new CGRect(120, 225, 180, 28), false);
|
||||||
private readonly NSTextField identity = new(new CGRect(120, 180, 330, 26)) { StringValue = Environment.UserName };
|
private readonly NSTextField identity = new(new CGRect(120, 180, 330, 26)) { StringValue = Environment.UserName };
|
||||||
private readonly NSTextField identityLabel;
|
private readonly NSTextField identityLabel;
|
||||||
private readonly NSSecureTextField password = new(new CGRect(120, 135, 330, 26)) { PlaceholderString = "Password" };
|
private readonly NSSecureTextField password = new(new CGRect(120, 135, 330, 26)) { PlaceholderString = "Password" };
|
||||||
|
private readonly NSButton rememberPassword = NSButton.CreateCheckbox("Remember in Keychain", () => { });
|
||||||
private readonly NSTextField status = NSTextField.CreateLabel("Ready to connect");
|
private readonly NSTextField status = NSTextField.CreateLabel("Ready to connect");
|
||||||
private readonly NSButton save = new(new CGRect(120, 75, 90, 32)) { Title = "Save" };
|
private readonly NSButton save = new(new CGRect(120, 75, 90, 32)) { Title = "Save" };
|
||||||
private readonly NSButton remove = new(new CGRect(220, 75, 90, 32)) { Title = "Remove" };
|
private readonly NSButton remove = new(new CGRect(220, 75, 90, 32)) { Title = "Remove" };
|
||||||
@@ -38,7 +40,9 @@ internal sealed class ConnectWindowController : NSWindowController
|
|||||||
((INSAccessibility)authentication).AccessibilityLabel = "Authentication mode";
|
((INSAccessibility)authentication).AccessibilityLabel = "Authentication mode";
|
||||||
((INSAccessibility)identity).AccessibilityLabel = "Guest nickname";
|
((INSAccessibility)identity).AccessibilityLabel = "Guest nickname";
|
||||||
((INSAccessibility)password).AccessibilityLabel = "Account password";
|
((INSAccessibility)password).AccessibilityLabel = "Account password";
|
||||||
view.AddSubview(profiles); view.AddSubview(host); view.AddSubview(authentication); view.AddSubview(identity); view.AddSubview(password);
|
rememberPassword.Frame = new CGRect(310, 225, 180, 24);
|
||||||
|
((INSAccessibility)rememberPassword).AccessibilityLabel = "Remember account password in Keychain";
|
||||||
|
view.AddSubview(profiles); view.AddSubview(host); view.AddSubview(authentication); view.AddSubview(identity); view.AddSubview(password); view.AddSubview(rememberPassword);
|
||||||
status.Frame = new CGRect(25, 30, 520, 24); status.AccessibilityLabel = "Connection status"; view.AddSubview(status);
|
status.Frame = new CGRect(25, 30, 520, 24); status.AccessibilityLabel = "Connection status"; view.AddSubview(status);
|
||||||
((INSAccessibility)save).AccessibilityLabel = "Save server profile"; save.Activated += SaveProfile; view.AddSubview(save);
|
((INSAccessibility)save).AccessibilityLabel = "Save server profile"; save.Activated += SaveProfile; view.AddSubview(save);
|
||||||
((INSAccessibility)remove).AccessibilityLabel = "Remove server profile"; remove.Activated += RemoveProfile; view.AddSubview(remove);
|
((INSAccessibility)remove).AccessibilityLabel = "Remove server profile"; remove.Activated += RemoveProfile; view.AddSubview(remove);
|
||||||
@@ -69,13 +73,17 @@ internal sealed class ConnectWindowController : NSWindowController
|
|||||||
int index = checked((int)profiles.IndexOfSelectedItem) - 1;
|
int index = checked((int)profiles.IndexOfSelectedItem) - 1;
|
||||||
if (index < 0 || index >= saved.Count)
|
if (index < 0 || index >= saved.Count)
|
||||||
{
|
{
|
||||||
editingId = null; remove.Enabled = false; return;
|
editingId = null; remove.Enabled = false; password.StringValue = "";
|
||||||
|
rememberPassword.State = NSCellStateValue.Off; UpdateAuthentication(); return;
|
||||||
}
|
}
|
||||||
ServerProfile profile = saved[index]; editingId = profile.Id; remove.Enabled = true;
|
ServerProfile profile = saved[index]; editingId = profile.Id; remove.Enabled = true;
|
||||||
host.StringValue = FormatEndpoint(profile.Host, profile.Port);
|
host.StringValue = FormatEndpoint(profile.Host, profile.Port);
|
||||||
authentication.SelectItem(profile.Authentication == ServerAuthentication.Guest ? 0 : 1);
|
authentication.SelectItem(profile.Authentication == ServerAuthentication.Guest ? 0 : 1);
|
||||||
identity.StringValue = profile.Authentication == ServerAuthentication.Guest ? profile.Nickname ?? Environment.UserName : profile.Username ?? "";
|
identity.StringValue = profile.Authentication == ServerAuthentication.Guest ? profile.Nickname ?? Environment.UserName : profile.Username ?? "";
|
||||||
password.StringValue = ""; UpdateAuthentication();
|
string? storedPassword = profile.Authentication == ServerAuthentication.Account ? keychain.Load(profile.Id) : null;
|
||||||
|
password.StringValue = storedPassword ?? "";
|
||||||
|
rememberPassword.State = storedPassword is null ? NSCellStateValue.Off : NSCellStateValue.On;
|
||||||
|
UpdateAuthentication();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AuthenticationChanged(object? sender, EventArgs args) => UpdateAuthentication();
|
private void AuthenticationChanged(object? sender, EventArgs args) => UpdateAuthentication();
|
||||||
@@ -87,16 +95,26 @@ internal sealed class ConnectWindowController : NSWindowController
|
|||||||
identity.PlaceholderString = account ? "Username" : "Nickname";
|
identity.PlaceholderString = account ? "Username" : "Nickname";
|
||||||
((INSAccessibility)identity).AccessibilityLabel = account ? "Account username" : "Guest nickname";
|
((INSAccessibility)identity).AccessibilityLabel = account ? "Account username" : "Guest nickname";
|
||||||
password.Enabled = account;
|
password.Enabled = account;
|
||||||
|
rememberPassword.Enabled = account && editingId is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SaveProfile(object? sender, EventArgs args)
|
private void SaveProfile(object? sender, EventArgs args)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ServerProfile profile = ReadProfile(false);
|
ServerProfile profile = ReadProfile();
|
||||||
|
string pendingPassword = password.StringValue;
|
||||||
|
bool shouldRemember = rememberPassword.State == NSCellStateValue.On;
|
||||||
int index = saved.FindIndex(item => item.Id == profile.Id);
|
int index = saved.FindIndex(item => item.Id == profile.Id);
|
||||||
if (index < 0) saved.Add(profile); else saved[index] = profile;
|
if (index < 0) saved.Add(profile); else saved[index] = profile;
|
||||||
profileStore.Save(saved); ReloadProfiles(profile.Id); status.StringValue = "Server profile saved. Passwords are not stored.";
|
if (profile.Authentication == ServerAuthentication.Guest || !shouldRemember) keychain.Remove(profile.Id);
|
||||||
|
profileStore.Save(saved); ReloadProfiles(profile.Id);
|
||||||
|
if (profile.Authentication == ServerAuthentication.Account && shouldRemember)
|
||||||
|
{
|
||||||
|
password.StringValue = pendingPassword;
|
||||||
|
rememberPassword.State = NSCellStateValue.On;
|
||||||
|
}
|
||||||
|
status.StringValue = "Server profile saved. Passwords are stored only after successful login.";
|
||||||
}
|
}
|
||||||
catch (Exception exception) { status.StringValue = exception.Message; }
|
catch (Exception exception) { status.StringValue = exception.Message; }
|
||||||
}
|
}
|
||||||
@@ -107,7 +125,7 @@ internal sealed class ConnectWindowController : NSWindowController
|
|||||||
ServerProfile? profile = saved.FirstOrDefault(item => item.Id == id); if (profile is null) return;
|
ServerProfile? profile = saved.FirstOrDefault(item => item.Id == id); if (profile is null) return;
|
||||||
var alert = new NSAlert { MessageText = "Remove server?", InformativeText = profile.DisplayName, AlertStyle = NSAlertStyle.Warning };
|
var alert = new NSAlert { MessageText = "Remove server?", InformativeText = profile.DisplayName, AlertStyle = NSAlertStyle.Warning };
|
||||||
alert.AddButton("Remove"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return;
|
alert.AddButton("Remove"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return;
|
||||||
saved.Remove(profile); profileStore.Save(saved); editingId = null; ReloadProfiles(); status.StringValue = "Server profile removed.";
|
keychain.Remove(profile.Id); saved.Remove(profile); profileStore.Save(saved); editingId = null; ReloadProfiles(); status.StringValue = "Server profile removed.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void Connect(object? sender, EventArgs args)
|
private async void Connect(object? sender, EventArgs args)
|
||||||
@@ -116,15 +134,24 @@ internal sealed class ConnectWindowController : NSWindowController
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
SetBusy(true); status.StringValue = "Connecting…";
|
SetBusy(true); status.StringValue = "Connecting…";
|
||||||
ServerProfile profile = ReadProfile(true);
|
ServerProfile profile = ReadProfile();
|
||||||
string pins = Path.Combine(SupportDirectory, "tofu.txt");
|
string pins = Path.Combine(SupportDirectory, "tofu.txt");
|
||||||
client = new("VoiceCat macOS", "0.1.0", pins);
|
client = new("VoiceCat macOS", "0.1.0", pins);
|
||||||
await client.ConnectAsync(profile.Host, profile.Port, ConfirmIdentity);
|
await client.ConnectAsync(profile.Host, profile.Port, ConfirmIdentity);
|
||||||
string accountPassword = password.StringValue; password.StringValue = "";
|
string accountPassword = password.StringValue;
|
||||||
|
if (profile.Authentication == ServerAuthentication.Account && accountPassword.Length == 0) accountPassword = keychain.Load(profile.Id) ?? "";
|
||||||
|
if (profile.Authentication == ServerAuthentication.Account && accountPassword.Length == 0) throw new ArgumentException("Enter the account password.");
|
||||||
|
password.StringValue = "";
|
||||||
var result = profile.Authentication == ServerAuthentication.Guest
|
var result = profile.Authentication == ServerAuthentication.Guest
|
||||||
? await client.AuthenticateGuestAsync(profile.Nickname ?? Environment.UserName)
|
? await client.AuthenticateGuestAsync(profile.Nickname ?? Environment.UserName)
|
||||||
: await client.AuthenticateUserAsync(profile.Username!, accountPassword);
|
: await client.AuthenticateUserAsync(profile.Username!, accountPassword);
|
||||||
if (!result.Ok) throw new InvalidOperationException(result.Error);
|
if (!result.Ok) throw new InvalidOperationException(result.Error);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (profile.Authentication == ServerAuthentication.Account && rememberPassword.State == NSCellStateValue.On) keychain.Save(profile.Id, accountPassword);
|
||||||
|
else keychain.Remove(profile.Id);
|
||||||
|
}
|
||||||
|
catch (Exception exception) { ShowKeychainWarning(exception.Message); }
|
||||||
main = new(client, result.Self.Id, result.Self.Nickname);
|
main = new(client, result.Self.Id, result.Self.Nickname);
|
||||||
client = null; main.ShowWindow(this); Window.Close();
|
client = null; main.ShowWindow(this); Window.Close();
|
||||||
}
|
}
|
||||||
@@ -135,12 +162,11 @@ internal sealed class ConnectWindowController : NSWindowController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ServerProfile ReadProfile(bool requirePassword)
|
private ServerProfile ReadProfile()
|
||||||
{
|
{
|
||||||
(string serverHost, ushort port) = ParseEndpoint(host.StringValue);
|
(string serverHost, ushort port) = ParseEndpoint(host.StringValue);
|
||||||
bool account = authentication.IndexOfSelectedItem == 1;
|
bool account = authentication.IndexOfSelectedItem == 1;
|
||||||
string name = identity.StringValue.Trim();
|
string name = identity.StringValue.Trim();
|
||||||
if (account && requirePassword && password.StringValue.Length == 0) throw new ArgumentException("Enter the account password. It is not saved to disk.");
|
|
||||||
return ServerProfile.Create(serverHost, port, account ? ServerAuthentication.Account : ServerAuthentication.Guest,
|
return ServerProfile.Create(serverHost, port, account ? ServerAuthentication.Account : ServerAuthentication.Guest,
|
||||||
username: account ? name : null, nickname: account ? null : name, id: editingId);
|
username: account ? name : null, nickname: account ? null : name, id: editingId);
|
||||||
}
|
}
|
||||||
@@ -150,6 +176,7 @@ internal sealed class ConnectWindowController : NSWindowController
|
|||||||
profiles.Enabled = host.Enabled = authentication.Enabled = identity.Enabled = save.Enabled = connect.Enabled = !busy;
|
profiles.Enabled = host.Enabled = authentication.Enabled = identity.Enabled = save.Enabled = connect.Enabled = !busy;
|
||||||
remove.Enabled = !busy && editingId is not null;
|
remove.Enabled = !busy && editingId is not null;
|
||||||
password.Enabled = !busy && authentication.IndexOfSelectedItem == 1;
|
password.Enabled = !busy && authentication.IndexOfSelectedItem == 1;
|
||||||
|
rememberPassword.Enabled = !busy && authentication.IndexOfSelectedItem == 1 && editingId is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ValueTask<bool> ConfirmIdentity(ServerIdentityChallenge challenge, CancellationToken cancellationToken)
|
private ValueTask<bool> ConfirmIdentity(ServerIdentityChallenge challenge, CancellationToken cancellationToken)
|
||||||
@@ -164,6 +191,17 @@ internal sealed class ConnectWindowController : NSWindowController
|
|||||||
return new(completion.Task.WaitAsync(cancellationToken));
|
return new(completion.Task.WaitAsync(cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ShowKeychainWarning(string message)
|
||||||
|
{
|
||||||
|
var alert = new NSAlert
|
||||||
|
{
|
||||||
|
MessageText = "Password storage failed",
|
||||||
|
InformativeText = message + " You are still signed in, but the Keychain setting was not changed.",
|
||||||
|
AlertStyle = NSAlertStyle.Warning
|
||||||
|
};
|
||||||
|
alert.RunModal();
|
||||||
|
}
|
||||||
|
|
||||||
private static (string Host, ushort Port) ParseEndpoint(string value)
|
private static (string Host, ushort Port) ParseEndpoint(string value)
|
||||||
{
|
{
|
||||||
int separator = value.LastIndexOf(':');
|
int separator = value.LastIndexOf(':');
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Foundation;
|
||||||
|
using Security;
|
||||||
|
|
||||||
|
namespace VoiceCat.Mac;
|
||||||
|
|
||||||
|
internal sealed class MacKeychainPasswordStore
|
||||||
|
{
|
||||||
|
private const string Service = "net.iamtalon.voicecat";
|
||||||
|
|
||||||
|
internal string? Load(Guid profileId)
|
||||||
|
{
|
||||||
|
using var query = Query(profileId);
|
||||||
|
using SecRecord? result = SecKeyChain.QueryAsRecord(query, out SecStatusCode status);
|
||||||
|
if (status != SecStatusCode.Success || result?.ValueData is not { } data) return null;
|
||||||
|
byte[] encoded = data.ToArray();
|
||||||
|
try { return Encoding.UTF8.GetString(encoded); }
|
||||||
|
finally { CryptographicOperations.ZeroMemory(encoded); }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Save(Guid profileId, string password)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(password);
|
||||||
|
byte[] encoded = Encoding.UTF8.GetBytes(password);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var data = NSData.FromArray(encoded);
|
||||||
|
using var query = Query(profileId);
|
||||||
|
using var attributes = new SecRecord
|
||||||
|
{
|
||||||
|
Label = "VoiceCat server password",
|
||||||
|
Accessible = SecAccessible.WhenUnlocked,
|
||||||
|
ValueData = data
|
||||||
|
};
|
||||||
|
SecStatusCode status = SecKeyChain.Update(query, attributes);
|
||||||
|
if (status == SecStatusCode.ItemNotFound)
|
||||||
|
{
|
||||||
|
using var record = Query(profileId);
|
||||||
|
record.Label = attributes.Label; record.Accessible = attributes.Accessible; record.ValueData = data;
|
||||||
|
status = SecKeyChain.Add(record);
|
||||||
|
}
|
||||||
|
if (status != SecStatusCode.Success) throw new InvalidOperationException($"Keychain save failed ({status}).");
|
||||||
|
}
|
||||||
|
finally { CryptographicOperations.ZeroMemory(encoded); }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Remove(Guid profileId)
|
||||||
|
{
|
||||||
|
using var query = Query(profileId);
|
||||||
|
SecStatusCode status = SecKeyChain.Remove(query);
|
||||||
|
if (status is not (SecStatusCode.Success or SecStatusCode.ItemNotFound))
|
||||||
|
throw new InvalidOperationException($"Keychain removal failed ({status}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SecRecord Query(Guid profileId) => new(SecKind.GenericPassword)
|
||||||
|
{
|
||||||
|
Service = Service,
|
||||||
|
Account = profileId.ToString("D")
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -855,6 +855,8 @@ AppKit application that consumes `VoiceCat.Core` directly. It implements guest a
|
|||||||
connection, validated saved server profiles whose JSON never contains passwords,
|
connection, validated saved server profiles whose JSON never contains passwords,
|
||||||
interactive TOFU approval, protected-channel password prompts, channel browsing, roster/chat,
|
interactive TOFU approval, protected-channel password prompts, channel browsing, roster/chat,
|
||||||
voice subscription, native accessibility labels and default-device microphone/playback.
|
voice subscription, native accessibility labels and default-device microphone/playback.
|
||||||
|
Account passwords can be stored in macOS Keychain after successful authentication and are
|
||||||
|
removed when the user disables remembering or deletes the profile.
|
||||||
Core Audio capture is resampled
|
Core Audio capture is resampled
|
||||||
and converted through `AVAudioConverter` before entering the managed audio engine. Stereo
|
and converted through `AVAudioConverter` before entering the managed audio engine. Stereo
|
||||||
playback uses `AVAudioSourceNode` and the shared bounded PCM ring; its render callback does
|
playback uses `AVAudioSourceNode` and the shared bounded PCM ring; its render callback does
|
||||||
@@ -862,8 +864,8 @@ not allocate, lock or block. Voice stream lifetime follows subscription, disconn
|
|||||||
channel changes. Current Apple API calls compile warning-free against Microsoft's
|
channel changes. Current Apple API calls compile warning-free against Microsoft's
|
||||||
26.4.10259 macOS reference assembly, and a macOS CI job builds the native codec/DSP shim and
|
26.4.10259 macOS reference assembly, and a macOS CI job builds the native codec/DSP shim and
|
||||||
Apple solution. A real macOS device run and listen test remain required. The existing Swift
|
Apple solution. A real macOS device run and listen test remain required. The existing Swift
|
||||||
app remains the release client until optional Keychain credentials, selectable devices, the
|
app remains the release client until selectable devices, the rest of the moderation/settings
|
||||||
rest of the moderation/settings surface, ScreenCaptureKit, VoiceOver
|
surface, ScreenCaptureKit, VoiceOver
|
||||||
validation, signing and notarization are complete.
|
validation, signing and notarization are complete.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user