Extend managed macOS client toward feature parity
.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:
@@ -92,6 +92,12 @@ public sealed class AudioEngine : IDisposable
|
||||
foreach (LocalStream stream in Volatile.Read(ref routes).Local) if (stream.Info.StreamId == streamId) return (stream.Level, stream.Talking);
|
||||
return default;
|
||||
}
|
||||
public void SetLocalGain(uint streamId, float gain)
|
||||
{
|
||||
if (!float.IsFinite(gain) || gain < 0 || gain > 4) throw new ArgumentOutOfRangeException(nameof(gain));
|
||||
foreach (LocalStream stream in Volatile.Read(ref routes).Local)
|
||||
if (stream.Info.StreamId == streamId) { stream.Gain = gain; return; }
|
||||
}
|
||||
public void SetRemotePlayback(uint userId, uint streamId, float gain, bool muted, bool noiseReduction)
|
||||
{
|
||||
if (!float.IsFinite(gain) || gain < 0 || gain > 4) throw new ArgumentOutOfRangeException(nameof(gain));
|
||||
|
||||
@@ -18,6 +18,7 @@ internal sealed class LocalStream : IDisposable
|
||||
internal readonly PcmRing Input = new(16384);
|
||||
internal volatile float Level;
|
||||
internal volatile bool Talking;
|
||||
internal volatile float Gain = 1;
|
||||
private readonly OpusEncoder encoder;
|
||||
private readonly RnnoiseProcessor left, right;
|
||||
private readonly EnergyVadProcessor vad = new();
|
||||
@@ -79,7 +80,7 @@ internal sealed class LocalStream : IDisposable
|
||||
for (int i = 0; i < 960; i++) input[2 * i + 1] = mono[i];
|
||||
}
|
||||
}
|
||||
float gain = mic ? engine.InputGain : 1;
|
||||
float gain = mic ? engine.InputGain : Gain;
|
||||
double energy = 0;
|
||||
for (int i = 0; i < input.Length; i++) { input[i] = (short)Math.Clamp((int)(input[i] * gain), short.MinValue, short.MaxValue); energy += (double)input[i] * input[i]; }
|
||||
Level = (float)(Math.Sqrt(energy / input.Length) / 32768);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Core;
|
||||
|
||||
public sealed partial class VoiceCatClient
|
||||
{
|
||||
public Permissions Permissions => Authentication?.Permissions?.Clone() ?? new Permissions();
|
||||
|
||||
public Task<GenericResult> KickUserAsync(uint userId, string reason = "", CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { Kick = new() { UserId = userId, Reason = reason } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> BanUserAsync(uint userId, string reason = "", ulong expiresUnixMs = 0, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { Ban = new() { UserId = userId, Reason = reason, ExpiresUnixMs = expiresUnixMs } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> MoveUserAsync(uint userId, uint channelId, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { MoveUser = new() { UserId = userId, ChannelId = channelId } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> SetServerMuteAsync(uint userId, bool muted, bool deafened, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { ServerMute = new() { UserId = userId, Muted = muted, Deafened = deafened } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> SetPermissionsAsync(uint userId, Permissions permissions, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { SetPermission = new() { UserId = userId, Permissions = permissions.Clone() } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> CreateChannelAsync(Channel channel, string password = "", CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { CreateChannel = new() { Channel = channel.Clone(), Password = password } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> EditChannelAsync(Channel channel, string password = "", CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { EditChannel = new() { Channel = channel.Clone(), Password = password } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> DeleteChannelAsync(uint channelId, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { DeleteChannel = new() { ChannelId = channelId } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> CreateAccountAsync(string username, string password, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { CreateAccount = new() { Username = username, Password = password } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> ResetPasswordAsync(string username, string password, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { ResetPassword = new() { Username = username, NewPassword = password } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> DeleteAccountAsync(string username, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { DeleteAccount = new() { Username = username } }, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<AccountEntry>> ListAccountsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Envelope response = await RequestAsync(new() { ListAccounts = new() }, cancellationToken).ConfigureAwait(false);
|
||||
if (response.ListAccountsResult is null) throw new IOException("Unexpected account-list response.");
|
||||
return response.ListAccountsResult.Accounts.Select(account => account.Clone()).ToArray();
|
||||
}
|
||||
|
||||
public void SetSelfAudioState(bool microphoneMuted, bool deafened)
|
||||
{
|
||||
Audio.MicMuted = microphoneMuted;
|
||||
Audio.Deafened = deafened;
|
||||
foreach (StreamInfo stream in LocalStreams)
|
||||
{
|
||||
(float _, bool talking) = Audio.GetLocalLevel(stream.StreamId);
|
||||
Send(new() { StreamState = new() { StreamId = stream.StreamId, Muted = microphoneMuted, Talking = talking } });
|
||||
}
|
||||
}
|
||||
|
||||
public void PublishStreamState(uint streamId, bool talking)
|
||||
{
|
||||
if (!LocalStreams.Any(stream => stream.StreamId == streamId)) return;
|
||||
Send(new() { StreamState = new() { StreamId = streamId, Muted = Audio.MicMuted, Talking = talking } });
|
||||
}
|
||||
|
||||
private async Task<GenericResult> RequestGenericAsync(Envelope request, CancellationToken cancellationToken)
|
||||
{
|
||||
Envelope response = await RequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return response.GenericResult?.Clone() ?? throw new IOException("Unexpected administration response.");
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ namespace VoiceCat.Core;
|
||||
|
||||
public enum ServerAuthentication { Guest, Account }
|
||||
|
||||
public sealed record ServerProfile(Guid Id, string Host, ushort Port, ServerAuthentication Authentication, string? Username, string? Nickname)
|
||||
public sealed record ServerProfile(Guid Id, string Host, ushort Port, ServerAuthentication Authentication, string? Username, string? Nickname,
|
||||
[property: JsonIgnore] string? LegacyKeychainTag = null)
|
||||
{
|
||||
public static ServerProfile Create(string host, ushort port, ServerAuthentication authentication, string? username = null, string? nickname = null, Guid? id = null)
|
||||
{
|
||||
@@ -43,7 +44,11 @@ public sealed class ServerProfileStore(string path)
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return [];
|
||||
return (JsonSerializer.Deserialize<ServerProfile[]>(File.ReadAllBytes(path), Json) ?? [])
|
||||
byte[] contents = File.ReadAllBytes(path);
|
||||
using JsonDocument document = JsonDocument.Parse(contents);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Array && document.RootElement.EnumerateArray().Any(LooksLegacy))
|
||||
return LoadLegacy(document.RootElement);
|
||||
return (JsonSerializer.Deserialize<ServerProfile[]>(contents, Json) ?? [])
|
||||
.Where(profile => profile.IsValid).ToArray();
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { return []; }
|
||||
@@ -55,6 +60,7 @@ public sealed class ServerProfileStore(string path)
|
||||
ServerProfile[] valid = profiles.Where(profile => profile is not null && profile.IsValid).ToArray();
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
PreserveLegacyBackup(fullPath);
|
||||
string temporary = fullPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
@@ -63,4 +69,38 @@ public sealed class ServerProfileStore(string path)
|
||||
}
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
|
||||
private static bool LooksLegacy(JsonElement item) => item.ValueKind == JsonValueKind.Object && item.TryGetProperty("authMode", out _);
|
||||
|
||||
private static IReadOnlyList<ServerProfile> LoadLegacy(JsonElement root)
|
||||
{
|
||||
var profiles = new List<ServerProfile>();
|
||||
foreach (JsonElement item in root.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("id", out JsonElement idValue) || !Guid.TryParse(idValue.GetString(), out Guid id) ||
|
||||
!item.TryGetProperty("host", out JsonElement hostValue) || !item.TryGetProperty("port", out JsonElement portValue) ||
|
||||
!portValue.TryGetUInt16(out ushort port)) continue;
|
||||
string? mode = item.TryGetProperty("authMode", out JsonElement modeValue) ? modeValue.GetString() : null;
|
||||
string? username = item.TryGetProperty("savedUsername", out JsonElement usernameValue) ? usernameValue.GetString() : null;
|
||||
string? nickname = item.TryGetProperty("nickname", out JsonElement nicknameValue) ? nicknameValue.GetString() : null;
|
||||
string? keychainTag = item.TryGetProperty("keychainTag", out JsonElement tagValue) ? tagValue.GetString() : null;
|
||||
ServerAuthentication authentication = mode == "password" ? ServerAuthentication.Account : ServerAuthentication.Guest;
|
||||
try { profiles.Add(ServerProfile.Create(hostValue.GetString() ?? "", port, authentication, username, nickname, id) with { LegacyKeychainTag = keychainTag }); }
|
||||
catch (ArgumentException) { }
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
private static void PreserveLegacyBackup(string fullPath)
|
||||
{
|
||||
if (!File.Exists(fullPath)) return;
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(fullPath));
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array || !document.RootElement.EnumerateArray().Any(LooksLegacy)) return;
|
||||
string backup = fullPath + ".swift-backup.json";
|
||||
if (!File.Exists(backup)) File.Copy(fullPath, backup);
|
||||
}
|
||||
catch (JsonException) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using VoiceCat.Core;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Server.Data;
|
||||
using Voicecat.V1;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
using static VoiceCat.Tests.MediaRelayTests;
|
||||
@@ -77,4 +78,26 @@ public class ManagedClientTests
|
||||
Assert.True(managed.TrySendEncodedVoice(local.Ssrc, 960, [4, 5, 6]));
|
||||
Assert.Equal(new byte[] { 4, 5, 6 }, (await peer.ReceiveVoiceAsync()).Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManagedAdministrationHelpersRoundTripTypedResults()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
using (var store = new AccountStore(Path.Combine(fixture.Directory, "voicecat.db")))
|
||||
await store.CreateAccountAsync("Admin", "secret", true);
|
||||
await using var admin = NewClient(fixture, "ManagedAdmin"); await Connect(admin, fixture);
|
||||
Assert.True((await admin.AuthenticateUserAsync("Admin", "secret")).Ok);
|
||||
await Event(admin, envelope => envelope.ServerState is not null);
|
||||
Assert.True(admin.Permissions.IsAdmin);
|
||||
|
||||
Assert.True((await admin.CreateAccountAsync("managed-ui", "first")).Ok);
|
||||
Assert.Contains(await admin.ListAccountsAsync(), account => account.Username == "managed-ui");
|
||||
Assert.True((await admin.ResetPasswordAsync("managed-ui", "second")).Ok);
|
||||
var room = new Voicecat.V1.Channel { Name = "Managed UI room", Audio = new()
|
||||
{ SampleRate = 48000, BitrateBps = 64000, FrameMs = 20, Complexity = 10, Fec = true } };
|
||||
Assert.True((await admin.CreateChannelAsync(room, "protected")).Ok);
|
||||
Envelope created = await Event(admin, envelope => envelope.ChannelEvent?.Channel?.Name == room.Name);
|
||||
Assert.True((await admin.DeleteChannelAsync(created.ChannelEvent.Channel.Id)).Ok);
|
||||
Assert.True((await admin.DeleteAccountAsync("managed-ui")).Ok);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,4 +47,31 @@ public sealed class ServerProfileTests
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => ServerProfile.Create("voice.example", 8384, ServerAuthentication.Account));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwiftProfilesImportAndAreBackedUpOnManagedSave()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "voicecat-profile-" + Guid.NewGuid().ToString("N"));
|
||||
string path = Path.Combine(directory, "servers.json");
|
||||
Guid accountId = Guid.NewGuid();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, $$"""
|
||||
[{"id":"{{accountId:D}}","host":"voice.example","port":8384,"authMode":"password","savedUsername":"talon","nickname":null,"keychainTag":"voicecat.server.legacy"}]
|
||||
""");
|
||||
var store = new ServerProfileStore(path);
|
||||
ServerProfile profile = Assert.Single(store.Load());
|
||||
Assert.Equal(ServerAuthentication.Account, profile.Authentication);
|
||||
Assert.Equal("talon", profile.Username);
|
||||
Assert.Equal("voicecat.server.legacy", profile.LegacyKeychainTag);
|
||||
|
||||
store.Save([profile]);
|
||||
Assert.True(File.Exists(path + ".swift-backup.json"));
|
||||
string managed = File.ReadAllText(path);
|
||||
Assert.Contains("\"authentication\": \"Account\"", managed);
|
||||
Assert.DoesNotContain("keychainTag", managed);
|
||||
}
|
||||
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user