Files
voice-cat/clients/apple/VoiceCat.Mac/AdministrationWindowController.cs
T

167 lines
13 KiB
C#
Raw Normal View History

using AppKit;
using CoreGraphics;
using VoiceCat.Core;
using Voicecat.V1;
using Channel = Voicecat.V1.Channel;
namespace VoiceCat.Mac;
internal sealed class AdministrationWindowController : NSWindowController
{
private readonly VoiceCatClient client;
private readonly uint selfId;
private readonly NSPopUpButton users = new(new CGRect(25, 335, 310, 28), false);
private readonly NSPopUpButton channels = new(new CGRect(25, 175, 310, 28), false);
private readonly NSTextField status = NSTextField.CreateLabel("");
internal AdministrationWindowController(VoiceCatClient client, uint selfId) : base(new NSWindow(new CGRect(0, 0, 560, 420),
NSWindowStyle.Titled | NSWindowStyle.Closable, NSBackingStore.Buffered, false))
{
this.client = client; this.selfId = selfId; Window!.Title = "VoiceCat Administration"; Window.Center();
NSView view = Window.ContentView!;
AddLabel(view, "User moderation", 25, 375); view.AddSubview(users);
AddButton(view, "Move…", 350, 333, Move); AddButton(view, "Kick…", 440, 333, Kick);
AddButton(view, "Ban…", 350, 293, Ban); AddButton(view, "Permissions…", 440, 293, Permissions);
AddButton(view, "Server mute", 350, 253, ServerMute); AddButton(view, "Server deafen", 440, 253, ServerDeafen);
AddButton(view, "Audio tuning…", 25, 253, Tune);
AddLabel(view, "Channel administration", 25, 215); view.AddSubview(channels);
AddButton(view, "Create…", 350, 173, CreateChannel); AddButton(view, "Edit…", 440, 173, EditChannel);
AddButton(view, "Delete", 350, 133, DeleteChannel); AddButton(view, "Accounts…", 440, 133, Accounts);
var refresh = new NSButton(new CGRect(25, 75, 100, 32)) { Title = "Refresh" }; refresh.Activated += (_, _) => Refresh(); view.AddSubview(refresh);
status.Frame = new CGRect(25, 25, 510, 35); status.AccessibilityLabel = "Administration result"; view.AddSubview(status);
((INSAccessibility)users).AccessibilityLabel = "User to administer"; ((INSAccessibility)channels).AccessibilityLabel = "Channel to administer";
Refresh(); ApplyPermissions();
}
private void Refresh()
{
uint selectedUser = Selected(users), selectedChannel = Selected(channels);
users.RemoveAllItems(); foreach (User user in client.Users.Where(user => user.Id != selfId).OrderBy(user => user.Nickname)) Add(users, user.Nickname, user.Id);
channels.RemoveAllItems(); foreach (Channel channel in client.Channels.OrderBy(channel => channel.Order).ThenBy(channel => channel.Name)) Add(channels, channel.Name, channel.Id);
Select(users, selectedUser); Select(channels, selectedChannel);
}
private void ApplyPermissions()
{
Permissions permissions = client.Permissions;
status.StringValue = permissions.IsAdmin ? "Connected as administrator" : "Only actions allowed by your server permissions will succeed.";
}
private async void Move(object? sender, EventArgs args)
{
uint userId = Selected(users); if (userId == 0) return;
string[] names = client.Channels.Select(channel => channel.Name).ToArray(); int index = PromptChoice("Move user", "Destination channel", names); if (index < 0) return;
Show(await client.MoveUserAsync(userId, client.Channels[index].Id));
}
private async void Kick(object? sender, EventArgs args) { uint id = Selected(users); string? reason = PromptText("Kick user", "Reason", "Kicked by moderator"); if (id != 0 && reason is not null) Show(await client.KickUserAsync(id, reason)); }
private async void Ban(object? sender, EventArgs args)
{
uint id = Selected(users); if (id == 0) return;
string? reason = PromptText("Ban user", "Reason (append @1h, @1d, or @permanent)", "Banned by moderator @1d"); if (reason is null) return;
ulong expiry = 0; string lowered = reason.ToLowerInvariant();
if (lowered.EndsWith("@1h")) expiry = checked((ulong)DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds());
else if (lowered.EndsWith("@1d")) expiry = checked((ulong)DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeMilliseconds());
Show(await client.BanUserAsync(id, reason.Replace("@1h", "", StringComparison.OrdinalIgnoreCase).Replace("@1d", "", StringComparison.OrdinalIgnoreCase).Replace("@permanent", "", StringComparison.OrdinalIgnoreCase).Trim(), expiry));
}
private async void Permissions(object? sender, EventArgs args)
{
uint id = Selected(users); if (id == 0) return;
Permissions? permissions = PermissionEditor.Run(); if (permissions is not null) Show(await client.SetPermissionsAsync(id, permissions));
}
private async void ServerMute(object? sender, EventArgs args) { User? user = User(); if (user is not null) Show(await client.SetServerMuteAsync(user.Id, !user.ServerMuted, user.ServerDeafened)); }
private async void ServerDeafen(object? sender, EventArgs args) { User? user = User(); if (user is not null) Show(await client.SetServerMuteAsync(user.Id, user.ServerMuted, !user.ServerDeafened)); }
private void Tune(object? sender, EventArgs args) { User? user = User(); if (user is not null) PerUserTuning.Run(client, user); }
2026-09-22 16:54:49 +02:00
private async void CreateChannel(object? sender, EventArgs args) { ChannelEdit? edit = ChannelEditor.Run(null, client.Channels, client.SupportsAdaptivePacketLoss); if (edit is not null) { Show(await client.CreateChannelAsync(edit.Channel, edit.Password)); Refresh(); } }
private async void EditChannel(object? sender, EventArgs args) { Channel? channel = Channel(); if (channel is null) return; ChannelEdit? edit = ChannelEditor.Run(channel, client.Channels, client.SupportsAdaptivePacketLoss); if (edit is not null) { Show(await client.EditChannelAsync(edit.Channel, edit.Password)); Refresh(); } }
private async void DeleteChannel(object? sender, EventArgs args)
{
Channel? channel = Channel(); if (channel is null || channel.Id == 1) return;
if (!Confirm("Delete channel?", channel.Name)) return; Show(await client.DeleteChannelAsync(channel.Id)); Refresh();
}
private async void Accounts(object? sender, EventArgs args) => await AccountEditor.RunAsync(client);
private User? User() => client.Users.FirstOrDefault(user => user.Id == Selected(users));
private Channel? Channel() => client.Channels.FirstOrDefault(channel => channel.Id == Selected(channels));
private void Show(GenericResult result) { status.StringValue = result.Ok ? result.Message.Length == 0 ? "Operation completed." : result.Message : result.Message; Refresh(); }
private static void Add(NSPopUpButton picker, string title, uint id) { picker.AddItem(title); picker.LastItem!.RepresentedObject = new Foundation.NSString(id.ToString()); }
private static uint Selected(NSPopUpButton picker) => uint.TryParse(picker.SelectedItem?.RepresentedObject?.ToString(), out uint id) ? id : 0;
private static void Select(NSPopUpButton picker, uint id) { for (nint i = 0; i < picker.ItemCount; i++) if (picker.ItemAtIndex(i)?.RepresentedObject?.ToString() == id.ToString()) { picker.SelectItem(i); break; } }
private static void AddLabel(NSView view, string value, double x, double y) { NSTextField label = NSTextField.CreateLabel(value); label.Frame = new CGRect(x, y, 300, 22); label.Font = NSFont.BoldSystemFontOfSize(13)!; view.AddSubview(label); }
private static void AddButton(NSView view, string title, double x, double y, EventHandler handler) { var button = new NSButton(new CGRect(x, y, 95, 32)) { Title = title }; button.Activated += handler; view.AddSubview(button); }
internal static string? PromptText(string title, string prompt, string value = "", bool secure = false)
{
NSTextField field = secure ? new NSSecureTextField(new CGRect(0, 0, 340, 26)) : new NSTextField(new CGRect(0, 0, 340, 26)); field.StringValue = value;
var alert = new NSAlert { MessageText = title, InformativeText = prompt, AccessoryView = field }; alert.AddButton("OK"); alert.AddButton("Cancel");
return alert.RunModal() == 1000 ? field.StringValue : null;
}
internal static int PromptChoice(string title, string prompt, string[] values)
{
var picker = new NSPopUpButton(new CGRect(0, 0, 340, 28), false); picker.AddItems(values);
var alert = new NSAlert { MessageText = title, InformativeText = prompt, AccessoryView = picker }; alert.AddButton("OK"); alert.AddButton("Cancel");
return alert.RunModal() == 1000 ? checked((int)picker.IndexOfSelectedItem) : -1;
}
internal static bool Confirm(string title, string detail) { var alert = new NSAlert { MessageText = title, InformativeText = detail, AlertStyle = NSAlertStyle.Warning }; alert.AddButton("Continue"); alert.AddButton("Cancel"); return alert.RunModal() == 1000; }
}
internal sealed record ChannelEdit(Channel Channel, string Password);
internal static class PermissionEditor
{
internal static Permissions? Run()
{
string[] labels = ["Create temporary channels", "Kick users", "Ban users", "Move users", "Manage accounts", "Full administrator"];
var view = new NSView(new CGRect(0, 0, 330, 160)); var checks = new List<NSButton>();
for (int i = 0; i < labels.Length; i++) { NSButton check = NSButton.CreateCheckbox(labels[i], () => { }); check.Frame = new CGRect(0, 135 - i * 25, 320, 22); view.AddSubview(check); checks.Add(check); }
var alert = new NSAlert { MessageText = "Set permissions", AccessoryView = view }; alert.AddButton("Save"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return null;
return new() { CanCreateTempChannel = On(0), CanKick = On(1), CanBan = On(2), CanMoveUsers = On(3), CanAdminAccounts = On(4), IsAdmin = On(5) };
bool On(int i) => checks[i].State == NSCellStateValue.On;
}
}
internal static class PerUserTuning
{
internal static void Run(VoiceCatClient client, User user)
{
if (user.Streams.Count == 0) { AdministrationWindowController.PromptText("Audio tuning", "This user has no active streams."); return; }
var stream = new NSPopUpButton(new CGRect(0, 95, 340, 28), false); stream.AddItems(user.Streams.Select(value => $"{value.Label} ({value.Kind})").ToArray());
var gain = new NSSlider(new CGRect(0, 60, 340, 24)) { MinValue = 0, MaxValue = 400, DoubleValue = 100 };
var muted = NSButton.CreateCheckbox("Mute this stream", () => { }); muted.Frame = new CGRect(0, 30, 160, 24);
var noise = NSButton.CreateCheckbox("Receive noise reduction", () => { }); noise.Frame = new CGRect(170, 30, 170, 24);
var view = new NSView(new CGRect(0, 0, 340, 130)); view.AddSubview(stream); view.AddSubview(gain); view.AddSubview(muted); view.AddSubview(noise);
void LoadSelected()
{
if (stream.IndexOfSelectedItem < 0) return;
StreamInfo selected = user.Streams[checked((int)stream.IndexOfSelectedItem)];
(float Gain, bool Muted, bool NoiseReduction)? state = client.Audio.GetRemotePlayback(user.Id, selected.StreamId);
gain.DoubleValue = (state?.Gain ?? 1) * 100; muted.State = state?.Muted == true ? NSCellStateValue.On : NSCellStateValue.Off;
noise.State = state?.NoiseReduction == true ? NSCellStateValue.On : NSCellStateValue.Off;
}
stream.Activated += (_, _) => LoadSelected(); LoadSelected();
var alert = new NSAlert { MessageText = $"Audio — {user.Nickname}", InformativeText = "Stream, volume, mute, and receive noise reduction", AccessoryView = view };
alert.AddButton("Apply"); alert.AddButton("Cancel"); if (alert.RunModal() != 1000) return;
StreamInfo selected = user.Streams[checked((int)stream.IndexOfSelectedItem)]; client.Audio.SetRemotePlayback(user.Id, selected.StreamId, (float)gain.DoubleValue / 100,
muted.State == NSCellStateValue.On, noise.State == NSCellStateValue.On);
}
}
internal static class AccountEditor
{
internal static async Task RunAsync(VoiceCatClient client)
{
IReadOnlyList<AccountEntry> accounts = await client.ListAccountsAsync();
string[] actions = ["Create account", "Reset password", "Delete account", "Close"];
int action = AdministrationWindowController.PromptChoice("Server accounts", string.Join("\n", accounts.Select(a => $"{a.Username}{(a.IsAdmin ? " (admin)" : "")}")), actions);
if (action is < 0 or 3) return;
if (action == 0)
{
string? name = AdministrationWindowController.PromptText("Create account", "Username"); if (string.IsNullOrWhiteSpace(name)) return;
string? password = AdministrationWindowController.PromptText("Create account", "Password", secure: true); if (!string.IsNullOrEmpty(password)) await client.CreateAccountAsync(name.Trim(), password);
return;
}
int selected = AdministrationWindowController.PromptChoice(action == 1 ? "Reset password" : "Delete account", "Account", accounts.Select(a => a.Username).ToArray()); if (selected < 0) return;
if (action == 1) { string? password = AdministrationWindowController.PromptText("Reset password", "New password", secure: true); if (!string.IsNullOrEmpty(password)) await client.ResetPasswordAsync(accounts[selected].Username, password); }
else if (AdministrationWindowController.Confirm("Delete account?", accounts[selected].Username)) await client.DeleteAccountAsync(accounts[selected].Username);
}
}