diff --git a/PROGRESS.md b/PROGRESS.md index 8a98f6d..0d948eb 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -20,8 +20,10 @@ up instantly. Newest status at the top. `--username`/`--password` for account auth and `--self-mute`/`--self-deafen`. Docs updated: `docs/protocol.md` (envelope tags for `ServerMuteRequest`/`ListAccountsResult`, `User.server_deafened`, `GenericResult` usage), `docs/security.md` (BLAKE2b channel passwords, `bans` schema). - `ctest --preset m1-dev` — **18/18 green**. Still to do: DRED/audio-quality polish and Windows - admin/moderation UI. + `ctest --preset m1-dev` — **18/18 green**. Windows WinForms UI now exposes all M5 operations: + channel CRUD with full per-channel Opus audio config, user moderation (kick/ban/move/server + mute/server deafen/set permissions), and server account management. `dotnet test` of the + Windows solution passes. Still to do: DRED/audio-quality polish. - **Done:** **Fixed a *second* silent-playback bug — the playout clock free-ran and drifted off the stream** (2026-06-17, reported live: both `vccli` and the Windows client showed `talking=1/0` correctly on VAD/PTT, mic + screen-share were recognized by peers, but nothing was audible). @@ -499,10 +501,9 @@ DLLs remain — verified via `objdump -p`). broadcast `ChannelEvent::CREATED` from a moved-from `entry.proto` after `channels_[id] = std::move(entry)`. Fixed by building the event before moving into the map. -**Still to do:** -- DRED/audio-quality polish. -- Windows admin/moderation UI in the WinForms client. -- macOS/iOS Swift client (carried from M4). + **Still to do:** + - DRED/audio-quality polish. + - macOS/iOS Swift client (carried from M4). --- diff --git a/clients/windows/README.md b/clients/windows/README.md index 65c5f6a..b6b9c62 100644 --- a/clients/windows/README.md +++ b/clients/windows/README.md @@ -65,6 +65,24 @@ On first connect to a new server: - The server identity dialog will appear. The TLS leaf-cert SHA-256 fingerprint is shown; accept to pin it. Subsequent connects to the same server will be silent (MATCHED). +## M5 — Moderation & admin UI + +The WinForms client now exposes all M5 operations through the main menu and context menus: + +- **Admin → Server accounts…** — create, reset password, and delete server accounts + (requires `can_admin_accounts`). +- **Channel tree right-click** — create, edit, and delete channels. The edit dialog exposes the + full per-channel Opus configuration: mono/stereo, sample rate, bitrate, frame size, + application mode, FEC, expected packet loss, DTX, and complexity. +- **User list right-click** — move, kick, ban, server mute/deafen, and set permissions + (items are gated by your own permissions). +- **Activity log** shows async `GenericResult` feedback for every moderation request. +- **User list** shows text indicators for self-mute, self-deafen, server-mute, and + server-deafen states. + +These operations require an admin-provisioned account with the appropriate permissions; the +connect dialog already supports username/password auth. + ## Known limitations - **PTT is focus-scoped** — the push-to-talk key only works while the VoiceCat window has @@ -72,8 +90,6 @@ On first connect to a new server: - **Receive-side noise reduction** checkbox in per-user tuning is wired end-to-end but is a passthrough no-op until a real APM/NS backend is built (no working Windows/MSVC port of `webrtc-audio-processing` upstream — see `docs/tech-stack.md §1`). -- **Admin/moderation UI** is not present — kick/ban/permissions/account provisioning are M5 - scope (requires server-side dispatch first). - **TOFU pins the TLS leaf cert**, not the declared Ed25519 identity fingerprint. Both are shown in the identity dialog, but the cert fingerprint is the value that is actually verified on reconnect. See `docs/security.md §1.1`. diff --git a/clients/windows/VoiceCat.App/Forms/AccountsDialog.cs b/clients/windows/VoiceCat.App/Forms/AccountsDialog.cs new file mode 100644 index 0000000..90d181b --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/AccountsDialog.cs @@ -0,0 +1,200 @@ +using VoiceCat.Interop; + +namespace VoiceCat.App.Forms; + +/// +/// Admin account management: list, create, reset password, and delete server accounts. +/// +public sealed class AccountsDialog : Form +{ + private readonly VoiceCatClient _client; + private readonly ListView _lvAccounts; + private readonly Button _btnRefresh; + private readonly Button _btnAdd; + private readonly Button _btnResetPassword; + private readonly Button _btnDelete; + + public AccountsDialog(VoiceCatClient client) + { + _client = client; + + Text = "Server accounts"; + FormBorderStyle = FormBorderStyle.Sizable; + StartPosition = FormStartPosition.CenterParent; + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(560, 360); + MinimumSize = new Size(420, 260); + + _lvAccounts = new ListView + { + View = View.Details, + FullRowSelect = true, + GridLines = true, + Dock = DockStyle.Fill, + TabIndex = 0, + }; + _lvAccounts.Columns.Add("Username", 160); + _lvAccounts.Columns.Add("Admin", 50); + _lvAccounts.Columns.Add("Created", 130); + _lvAccounts.Columns.Add("Last login", 130); + _lvAccounts.SelectedIndexChanged += (_, _) => UpdateButtons(); + + var pnlButtons = new Panel + { + Dock = DockStyle.Bottom, + Height = 44, + }; + + _btnRefresh = new Button + { + Text = "&Refresh", + Location = new Point(12, 9), + Size = new Size(75, 27), + TabIndex = 1, + }; + _btnRefresh.Click += (_, _) => RefreshList(); + + _btnAdd = new Button + { + Text = "&Add...", + Location = new Point(100, 9), + Size = new Size(75, 27), + TabIndex = 2, + }; + _btnAdd.Click += (_, _) => AddAccount(); + + _btnResetPassword = new Button + { + Text = "&Reset password...", + Location = new Point(188, 9), + Size = new Size(120, 27), + TabIndex = 3, + Enabled = false, + }; + _btnResetPassword.Click += (_, _) => ResetPassword(); + + _btnDelete = new Button + { + Text = "&Delete", + Location = new Point(320, 9), + Size = new Size(75, 27), + TabIndex = 4, + Enabled = false, + }; + _btnDelete.Click += (_, _) => DeleteAccount(); + + pnlButtons.Controls.AddRange([_btnRefresh, _btnAdd, _btnResetPassword, _btnDelete]); + + Controls.Add(_lvAccounts); + Controls.Add(pnlButtons); + + Load += (_, _) => RefreshList(); + } + + private void UpdateButtons() + { + bool selected = _lvAccounts.SelectedItems.Count > 0; + _btnResetPassword.Enabled = selected; + _btnDelete.Enabled = selected; + } + + private void RefreshList() + { + _client.RequestAccountList(); + // The event will come back asynchronously; poll briefly for the list. + var deadline = DateTime.UtcNow.AddSeconds(2); + List? accounts = null; + while (DateTime.UtcNow < deadline) + { + _client.PumpEvents(); + accounts = _client.ListAccounts(); + if (accounts.Count > 0) break; + Thread.Sleep(30); + } + accounts ??= _client.ListAccounts(); + + _lvAccounts.BeginUpdate(); + _lvAccounts.Items.Clear(); + foreach (var a in accounts.OrderBy(a => a.Username)) + { + var item = new ListViewItem(a.Username); + item.SubItems.Add(a.IsAdmin ? "Yes" : "No"); + item.SubItems.Add(FormatDate(a.CreatedAtUnixMs)); + item.SubItems.Add(FormatDate(a.LastLoginUnixMs)); + _lvAccounts.Items.Add(item); + } + _lvAccounts.EndUpdate(); + UpdateButtons(); + } + + private static string FormatDate(ulong ms) + { + if (ms == 0) return "—"; + try + { + return DateTimeOffset.FromUnixTimeMilliseconds((long)ms).LocalDateTime.ToString("g"); + } + catch + { + return "—"; + } + } + + private void AddAccount() + { + using var userDlg = new InputDialog("Add account", "&Username:"); + if (userDlg.ShowDialog(this) != DialogResult.OK || string.IsNullOrWhiteSpace(userDlg.TextValue)) + return; + + using var pwDlg = new PasswordPromptDialog($"Password for {userDlg.TextValue}:"); + if (pwDlg.ShowDialog(this) != DialogResult.OK || string.IsNullOrEmpty(pwDlg.Password)) + return; + + var r = _client.CreateAccount(userDlg.TextValue, pwDlg.Password); + if (r != VcResult.Ok) + { + MessageBox.Show(this, $"Create account failed: {r}", "VoiceCat", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + RefreshList(); + } + + private void ResetPassword() + { + if (_lvAccounts.SelectedItems.Count == 0) return; + string username = _lvAccounts.SelectedItems[0].Text; + + using var dlg = new PasswordPromptDialog($"New password for {username}:"); + if (dlg.ShowDialog(this) != DialogResult.OK || string.IsNullOrEmpty(dlg.Password)) + return; + + var r = _client.ResetPassword(username, dlg.Password); + if (r != VcResult.Ok) + { + MessageBox.Show(this, $"Reset password failed: {r}", "VoiceCat", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + RefreshList(); + } + + private void DeleteAccount() + { + if (_lvAccounts.SelectedItems.Count == 0) return; + string username = _lvAccounts.SelectedItems[0].Text; + + var confirm = MessageBox.Show(this, $"Delete account '{username}'?", + "VoiceCat", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (confirm != DialogResult.Yes) return; + + var r = _client.DeleteAccount(username); + if (r != VcResult.Ok) + { + MessageBox.Show(this, $"Delete account failed: {r}", "VoiceCat", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + RefreshList(); + } +} diff --git a/clients/windows/VoiceCat.App/Forms/BanUserDialog.cs b/clients/windows/VoiceCat.App/Forms/BanUserDialog.cs new file mode 100644 index 0000000..fb8483c --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/BanUserDialog.cs @@ -0,0 +1,94 @@ +namespace VoiceCat.App.Forms; + +/// +/// Prompt for a ban reason and duration. +/// +public sealed class BanUserDialog : Form +{ + private readonly TextBox _txtReason; + private readonly ComboBox _cboDuration; + + public string Reason => _txtReason.Text.Trim(); + public ulong ExpiresUnixMs { get; private set; } + + public BanUserDialog(string nickname) + { + Text = $"Ban {nickname}"; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterParent; + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(384, 160); + + var lblReason = new Label + { + Text = "&Reason:", + Location = new Point(12, 12), + AutoSize = true, + }; + _txtReason = new TextBox + { + Location = new Point(12, 34), + Size = new Size(360, 23), + TabIndex = 1, + }; + + var lblDuration = new Label + { + Text = "&Duration:", + Location = new Point(12, 66), + AutoSize = true, + }; + _cboDuration = new ComboBox + { + Location = new Point(12, 88), + Size = new Size(200, 23), + DropDownStyle = ComboBoxStyle.DropDownList, + TabIndex = 2, + }; + _cboDuration.Items.AddRange(new object[] + { + new DurationItem("1 hour", TimeSpan.FromHours(1)), + new DurationItem("1 day", TimeSpan.FromDays(1)), + new DurationItem("1 week", TimeSpan.FromDays(7)), + new DurationItem("Permanent", TimeSpan.Zero), + }); + _cboDuration.SelectedIndex = 1; + + var btnOk = new Button + { + Text = "&Ban", + DialogResult = DialogResult.OK, + Location = new Point(216, 126), + Size = new Size(75, 27), + TabIndex = 3, + }; + var btnCancel = new Button + { + Text = "&Cancel", + DialogResult = DialogResult.Cancel, + Location = new Point(297, 126), + Size = new Size(75, 27), + TabIndex = 4, + }; + + AcceptButton = btnOk; + CancelButton = btnCancel; + Controls.AddRange([lblReason, _txtReason, lblDuration, _cboDuration, btnOk, btnCancel]); + + btnOk.Click += (_, _) => + { + var selected = _cboDuration.SelectedItem as DurationItem; + ExpiresUnixMs = selected?.Duration == TimeSpan.Zero + ? 0 + : (ulong)DateTimeOffset.UtcNow.Add(selected!.Duration).ToUnixTimeMilliseconds(); + }; + } + + private sealed class DurationItem(string label, TimeSpan duration) + { + public TimeSpan Duration { get; } = duration; + public override string ToString() => label; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs b/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs new file mode 100644 index 0000000..5d60d82 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs @@ -0,0 +1,384 @@ +using VoiceCat.Interop; + +namespace VoiceCat.App.Forms; + +/// +/// Create or edit a channel, including its full per-channel Opus audio config. +/// Returns DialogResult.OK with Result set. +/// +public sealed class ChannelEditDialog : Form +{ + private readonly bool _isCreate; + private readonly uint _editingId; + private readonly List _channels; + + private TextBox _txtName = null!; + private TextBox _txtTopic = null!; + private ComboBox _cboParent = null!; + private NumericUpDown _numMaxUsers = null!; + private NumericUpDown _numSortOrder = null!; + private CheckBox _chkPassword = null!; + private TextBox _txtPassword = null!; + + private ComboBox _cboMode = null!; + private NumericUpDown _numSampleRate = null!; + private NumericUpDown _numBitrate = null!; + private NumericUpDown _numFrameMs = null!; + private ComboBox _cboApplication = null!; + private CheckBox _chkFec = null!; + private NumericUpDown _numExpectedLoss = null!; + private CheckBox _chkDtx = null!; + private NumericUpDown _numComplexity = null!; + + public ChannelEditInfo? Result { get; private set; } + + public ChannelEditDialog(IEnumerable channels, ChannelEditInfo? existing = null) + { + _isCreate = existing is null; + _editingId = existing?.Id ?? 0; + _channels = channels.Where(c => c.Id != _editingId).ToList(); + + Text = _isCreate ? "Create channel" : "Edit channel"; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterParent; + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(520, 520); + + var tabs = new TabControl + { + Dock = DockStyle.Fill, + TabIndex = 0, + }; + + var pageGeneral = new TabPage("General"); + BuildGeneralPage(pageGeneral, existing); + var pageAudio = new TabPage("Audio config"); + BuildAudioPage(pageAudio, existing?.Audio); + + tabs.TabPages.Add(pageGeneral); + tabs.TabPages.Add(pageAudio); + + var btnOk = new Button + { + Text = "&OK", + DialogResult = DialogResult.OK, + Location = new Point(350, 486), + Size = new Size(75, 27), + TabIndex = 100, + }; + var btnCancel = new Button + { + Text = "&Cancel", + DialogResult = DialogResult.Cancel, + Location = new Point(431, 486), + Size = new Size(75, 27), + TabIndex = 101, + }; + + AcceptButton = btnOk; + CancelButton = btnCancel; + + Controls.Add(tabs); + Controls.Add(btnOk); + Controls.Add(btnCancel); + + btnOk.Click += BtnOk_Click; + } + + private void BuildGeneralPage(TabPage page, ChannelEditInfo? existing) + { + int y = 16; + int labelWidth = 110; + int inputX = 128; + + AddLabel(page, "&Name:", 12, y, labelWidth); + _txtName = new TextBox + { + Location = new Point(inputX, y - 2), + Size = new Size(360, 23), + Text = existing?.Name ?? "", + TabIndex = 1, + }; + page.Controls.Add(_txtName); + y += 36; + + AddLabel(page, "&Topic:", 12, y, labelWidth); + _txtTopic = new TextBox + { + Location = new Point(inputX, y - 2), + Size = new Size(360, 23), + Text = existing?.Topic ?? "", + TabIndex = 2, + }; + page.Controls.Add(_txtTopic); + y += 36; + + AddLabel(page, "&Parent channel:", 12, y, labelWidth); + _cboParent = new ComboBox + { + Location = new Point(inputX, y - 2), + Size = new Size(360, 23), + DropDownStyle = ComboBoxStyle.DropDownList, + TabIndex = 3, + }; + _cboParent.Items.Add(new ChannelItem("(root)", 0)); + foreach (var ch in _channels.OrderBy(c => c.Name)) + _cboParent.Items.Add(new ChannelItem(ch.Name, ch.Id)); + SelectParent(existing?.ParentId ?? 0); + page.Controls.Add(_cboParent); + y += 36; + + AddLabel(page, "&Max users:", 12, y, labelWidth); + _numMaxUsers = new NumericUpDown + { + Location = new Point(inputX, y - 2), + Size = new Size(120, 23), + Minimum = 0, + Maximum = 10000, + Value = existing?.MaxUsers ?? 0, + TabIndex = 4, + }; + page.Controls.Add(_numMaxUsers); + AddLabel(page, "(0 = unlimited)", inputX + 128, y, 100); + y += 36; + + AddLabel(page, "&Sort order:", 12, y, labelWidth); + _numSortOrder = new NumericUpDown + { + Location = new Point(inputX, y - 2), + Size = new Size(120, 23), + Minimum = 0, + Maximum = uint.MaxValue, + Value = existing?.SortOrder ?? 0, + TabIndex = 5, + }; + page.Controls.Add(_numSortOrder); + y += 36; + + _chkPassword = new CheckBox + { + Text = "&Password protected", + Location = new Point(inputX, y), + AutoSize = true, + Checked = existing?.PasswordProtected ?? false, + TabIndex = 6, + }; + page.Controls.Add(_chkPassword); + y += 28; + + _txtPassword = new TextBox + { + Location = new Point(inputX, y), + Size = new Size(360, 23), + UseSystemPasswordChar = true, + Enabled = _chkPassword.Checked, + Text = existing?.Password ?? "", + TabIndex = 7, + }; + page.Controls.Add(_txtPassword); + _chkPassword.CheckedChanged += (_, _) => _txtPassword.Enabled = _chkPassword.Checked; + } + + private void BuildAudioPage(TabPage page, AudioConfigInfo? audio) + { + audio ??= new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10); + + int y = 16; + int labelWidth = 150; + int inputX = 168; + + AddLabel(page, "Codec (0 = Opus):", 12, y, labelWidth); + var numCodec = new NumericUpDown + { + Location = new Point(inputX, y - 2), + Size = new Size(120, 23), + Minimum = 0, + Maximum = 0, + Value = audio.Codec, + Enabled = false, + }; + page.Controls.Add(numCodec); + y += 34; + + AddLabel(page, "&Mode:", 12, y, labelWidth); + _cboMode = new ComboBox + { + Location = new Point(inputX, y - 2), + Size = new Size(160, 23), + DropDownStyle = ComboBoxStyle.DropDownList, + TabIndex = 10, + }; + _cboMode.Items.AddRange(["Mono", "Stereo"]); + _cboMode.SelectedIndex = audio.Stereo ? 1 : 0; + page.Controls.Add(_cboMode); + y += 34; + + AddLabel(page, "Sample &rate (Hz):", 12, y, labelWidth); + _numSampleRate = new NumericUpDown + { + Location = new Point(inputX, y - 2), + Size = new Size(120, 23), + Minimum = 8000, + Maximum = 96000, + Value = audio.SampleRate, + TabIndex = 11, + }; + page.Controls.Add(_numSampleRate); + y += 34; + + AddLabel(page, "&Bitrate (bps, 0 = default):", 12, y, labelWidth); + _numBitrate = new NumericUpDown + { + Location = new Point(inputX, y - 2), + Size = new Size(120, 23), + Minimum = 0, + Maximum = 512000, + Increment = 1000, + Value = audio.BitrateBps, + TabIndex = 12, + }; + page.Controls.Add(_numBitrate); + y += 34; + + AddLabel(page, "Frame &ms:", 12, y, labelWidth); + _numFrameMs = new NumericUpDown + { + Location = new Point(inputX, y - 2), + Size = new Size(120, 23), + Minimum = 5, + Maximum = 120, + Value = audio.FrameMs, + TabIndex = 13, + }; + page.Controls.Add(_numFrameMs); + y += 34; + + AddLabel(page, "&Application:", 12, y, labelWidth); + _cboApplication = new ComboBox + { + Location = new Point(inputX, y - 2), + Size = new Size(160, 23), + DropDownStyle = ComboBoxStyle.DropDownList, + TabIndex = 14, + }; + _cboApplication.Items.AddRange(["VoIP", "Audio", "Low delay"]); + _cboApplication.SelectedIndex = (int)Math.Min(2, audio.Application); + page.Controls.Add(_cboApplication); + y += 34; + + AddLabel(page, "Expected packet loss (%):", 12, y, labelWidth); + _numExpectedLoss = new NumericUpDown + { + Location = new Point(inputX, y - 2), + Size = new Size(120, 23), + Minimum = 0, + Maximum = 100, + Value = audio.ExpectedPacketLoss, + TabIndex = 15, + }; + page.Controls.Add(_numExpectedLoss); + y += 34; + + AddLabel(page, "Com&plexity (0–10):", 12, y, labelWidth); + _numComplexity = new NumericUpDown + { + Location = new Point(inputX, y - 2), + Size = new Size(120, 23), + Minimum = 0, + Maximum = 10, + Value = audio.Complexity, + TabIndex = 16, + }; + page.Controls.Add(_numComplexity); + y += 34; + + _chkFec = new CheckBox + { + Text = "&FEC", + Location = new Point(inputX, y), + AutoSize = true, + Checked = audio.Fec, + TabIndex = 17, + }; + page.Controls.Add(_chkFec); + y += 28; + + _chkDtx = new CheckBox + { + Text = "&DTX", + Location = new Point(inputX, y), + AutoSize = true, + Checked = audio.Dtx, + TabIndex = 18, + }; + page.Controls.Add(_chkDtx); + } + + private static void AddLabel(Control parent, string text, int x, int y, int width) + { + parent.Controls.Add(new Label + { + Text = text, + Location = new Point(x, y), + Size = new Size(width, 17), + TextAlign = System.Drawing.ContentAlignment.MiddleLeft, + }); + } + + private void SelectParent(uint parentId) + { + for (int i = 0; i < _cboParent.Items.Count; i++) + { + if (_cboParent.Items[i] is ChannelItem item && item.Id == parentId) + { + _cboParent.SelectedIndex = i; + return; + } + } + _cboParent.SelectedIndex = 0; + } + + private void BtnOk_Click(object? sender, EventArgs e) + { + string name = _txtName.Text.Trim(); + if (string.IsNullOrEmpty(name)) + { + MessageBox.Show(this, "Channel name is required.", "VoiceCat", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + DialogResult = DialogResult.None; + return; + } + + uint parentId = (_cboParent.SelectedItem as ChannelItem)?.Id ?? 0; + var audio = new AudioConfigInfo( + Codec: 0, + Stereo: _cboMode.SelectedIndex == 1, + SampleRate: (uint)_numSampleRate.Value, + BitrateBps: (uint)_numBitrate.Value, + FrameMs: (uint)_numFrameMs.Value, + Application: (uint)_cboApplication.SelectedIndex, + Fec: _chkFec.Checked, + ExpectedPacketLoss: (uint)_numExpectedLoss.Value, + Dtx: _chkDtx.Checked, + Complexity: (uint)_numComplexity.Value); + + Result = new ChannelEditInfo( + Id: _editingId, + ParentId: parentId, + Name: name, + Topic: _txtTopic.Text.Trim(), + PasswordProtected: _chkPassword.Checked, + Password: _chkPassword.Checked ? _txtPassword.Text : null, + MaxUsers: (uint)_numMaxUsers.Value, + SortOrder: (uint)_numSortOrder.Value, + Audio: audio); + } + + private sealed class ChannelItem(string name, uint id) + { + public uint Id { get; } = id; + public override string ToString() => name; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/InputDialog.cs b/clients/windows/VoiceCat.App/Forms/InputDialog.cs new file mode 100644 index 0000000..42b3c23 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/InputDialog.cs @@ -0,0 +1,60 @@ +namespace VoiceCat.App.Forms; + +/// +/// A small reusable modal that prompts for a single line of text (e.g., kick/ban reason). +/// Returns DialogResult.OK with Text set, or DialogResult.Cancel. +/// +public sealed class InputDialog : Form +{ + private readonly TextBox _txtInput; + + public string TextValue => _txtInput.Text.Trim(); + + public InputDialog(string caption, string label, string defaultText = "") + { + var lbl = new Label + { + Text = label, + AutoSize = true, + Location = new Point(12, 12), + TabIndex = 0, + }; + + _txtInput = new TextBox + { + Text = defaultText, + Location = new Point(12, 36), + Size = new Size(360, 23), + TabIndex = 1, + }; + + var btnOk = new Button + { + Text = "&OK", + DialogResult = DialogResult.OK, + Location = new Point(216, 72), + Size = new Size(75, 27), + TabIndex = 2, + }; + + var btnCancel = new Button + { + Text = "&Cancel", + DialogResult = DialogResult.Cancel, + Location = new Point(297, 72), + Size = new Size(75, 27), + TabIndex = 3, + }; + + AcceptButton = btnOk; + CancelButton = btnCancel; + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(384, 112); + Controls.AddRange([lbl, _txtInput, btnOk, btnCancel]); + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterParent; + Text = caption; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs b/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs index f3112e0..4583297 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs @@ -4,6 +4,9 @@ partial class MainForm { private System.ComponentModel.IContainer components = null!; + // Menu bar + private MenuStrip menuStrip = null!; + // Status bar private Label lblStatus = null!; @@ -349,6 +352,13 @@ partial class MainForm pnlVoice.Controls.Add(flpVoiceBottom); // Fill — added first pnlVoice.Controls.Add(flpVoiceTop); // Top — added last + // ── Menu strip ──────────────────────────────────────────────────────── + menuStrip = new MenuStrip(); + menuStrip.AccessibleName = "Main menu"; + menuStrip.Dock = DockStyle.Top; + menuStrip.TabIndex = 0; + MainMenuStrip = menuStrip; + // ── Form ────────────────────────────────────────────────────────────── AutoScaleMode = AutoScaleMode.Font; ClientSize = new Size(960, 680); @@ -358,6 +368,7 @@ partial class MainForm Controls.Add(splitMain); // DockStyle.Fill Controls.Add(pnlVoice); // DockStyle.Bottom Controls.Add(lblStatus); // DockStyle.Top + Controls.Add(menuStrip); // DockStyle.Top (placed at the very top) StartPosition = FormStartPosition.CenterScreen; Text = "VoiceCat"; } diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs index a9c6e32..f4431c2 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs @@ -19,10 +19,13 @@ public partial class MainForm : Form private List _channels = []; private readonly Dictionary _users = []; private readonly HashSet _talkingUsers = []; + private PermissionsInfo _ownPermissions = new(false, false, false, false, false, false); // Voice state private uint _micStreamId; // 0 = not started private Keys _pttKey = Keys.F8; + private bool _serverMuted; + private bool _serverDeafened; public MainForm(VoiceCatClient client, uint selfUserId, string nickname) { @@ -36,19 +39,18 @@ public partial class MainForm : Form _client.EventReceived += OnEvent; _client.LevelChanged += OnLevelChanged; _pumpTimer.Tick += (_, _) => _client.PumpEvents(); + + // M5: load own permissions and build permission-aware menus. + _ownPermissions = SafeGetPermissions(); + BuildMenus(); + BuildChannelContextMenu(); + BuildUserContextMenu(); + _pumpTimer.Start(); // Channel tree tvChannels.DoubleClick += TvChannels_DoubleClick; tvChannels.KeyDown += TvChannels_KeyDown; - - // User list — double-click or Enter for per-user tuning, right-click for context menu - var ctxUsers = new ContextMenuStrip(); - var miTune = new ToolStripMenuItem("&Adjust volume and noise settings..."); - miTune.Click += (_, _) => OpenUserTuning(); - ctxUsers.Opening += (_, _) => miTune.Enabled = lstUsers.SelectedItem is UserListItem; - ctxUsers.Items.Add(miTune); - lstUsers.ContextMenuStrip = ctxUsers; lstUsers.DoubleClick += (_, _) => OpenUserTuning(); lstUsers.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) OpenUserTuning(); }; @@ -89,7 +91,11 @@ public partial class MainForm : Form foreach (var u in users) { _users[u.Id] = u; - if (u.Id == _selfUserId) _currentChannelId = u.ChannelId; + if (u.Id == _selfUserId) + { + _currentChannelId = u.ChannelId; + UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened); + } } RefreshChannelTree(); RefreshUserList(); @@ -106,6 +112,105 @@ public partial class MainForm : Form LoadInputDevices(); } + // ── Menu / context menu builders (M5) ───────────────────────────────────── + + private PermissionsInfo SafeGetPermissions() + { + try { return _client.GetPermissions(); } + catch { return new PermissionsInfo(false, false, false, false, false, false); } + } + + private void BuildMenus() + { + if (!_ownPermissions.CanAdminAccounts) return; + + var adminMenu = new ToolStripMenuItem("&Admin"); + var miAccounts = new ToolStripMenuItem("&Server accounts..."); + miAccounts.Click += (_, _) => + { + using var dlg = new AccountsDialog(_client); + dlg.ShowDialog(this); + }; + adminMenu.DropDownItems.Add(miAccounts); + menuStrip.Items.Add(adminMenu); + } + + private void BuildChannelContextMenu() + { + var ctx = new ContextMenuStrip(); + ctx.Opening += (_, _) => + { + ctx.Items.Clear(); + bool hasSelection = tvChannels.SelectedNode?.Tag is uint; + bool canCreate = _ownPermissions.CanCreateTempChannel || _ownPermissions.IsAdmin; + bool isAdmin = _ownPermissions.IsAdmin; + + if (hasSelection) + { + ctx.Items.Add("&Join", null, (_, _) => ChannelTreeJoinSelected()); + ctx.Items.Add(new ToolStripSeparator()); + } + + if (canCreate) + { + ctx.Items.Add("&Create channel...", null, (_, _) => CreateChannel()); + } + + if (hasSelection && isAdmin) + { + ctx.Items.Add("&Edit channel...", null, (_, _) => EditSelectedChannel()); + ctx.Items.Add("&Delete channel...", null, (_, _) => DeleteSelectedChannel()); + } + }; + tvChannels.ContextMenuStrip = ctx; + } + + private void BuildUserContextMenu() + { + var ctx = new ContextMenuStrip(); + ctx.Opening += (_, _) => + { + ctx.Items.Clear(); + if (lstUsers.SelectedItem is not UserListItem item) return; + if (!_users.TryGetValue(item.UserId, out var user)) return; + bool isSelf = user.Id == _selfUserId; + + var miTune = new ToolStripMenuItem("&Adjust volume and noise settings..."); + miTune.Click += (_, _) => OpenUserTuning(); + ctx.Items.Add(miTune); + + if (!isSelf) + { + ctx.Items.Add(new ToolStripSeparator()); + + if (_ownPermissions.CanMoveUsers || _ownPermissions.IsAdmin) + { + ctx.Items.Add("&Move to channel...", null, (_, _) => MoveSelectedUser()); + } + if (_ownPermissions.CanKick || _ownPermissions.IsAdmin) + { + ctx.Items.Add("&Kick...", null, (_, _) => KickSelectedUser()); + } + if (_ownPermissions.CanBan || _ownPermissions.IsAdmin) + { + ctx.Items.Add("&Ban...", null, (_, _) => BanSelectedUser()); + } + + ctx.Items.Add(new ToolStripSeparator()); + + if (_ownPermissions.IsAdmin) + { + ctx.Items.Add(user.ServerMuted ? "Server &unmute" : "Server &mute", + null, (_, _) => ToggleServerMuteSelected()); + ctx.Items.Add(user.ServerDeafened ? "Server un&deafen" : "Server &deafen", + null, (_, _) => ToggleServerDeafenSelected()); + ctx.Items.Add("&Set permissions...", null, (_, _) => SetPermissionsSelected()); + } + } + }; + lstUsers.ContextMenuStrip = ctx; + } + // ── Event dispatch ──────────────────────────────────────────────────────── private void OnEvent(VoiceCatEvent ev) @@ -127,6 +232,12 @@ public partial class MainForm : Form case VcEventType.JoinResult: HandleJoinResult(ev); break; + case VcEventType.GenericResult: + HandleGenericResult(ev); + break; + case VcEventType.AccountList: + AddActivity("Account list updated"); + break; case VcEventType.TextMessage: HandleTextMessage(ev); break; @@ -166,7 +277,8 @@ public partial class MainForm : Form private void HandleUserJoined(VoiceCatEvent ev) { - var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId); + var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId, + false, false, false, false); _users[ev.UserId] = user; RefreshUserList(); RebuildScopeCombo(); @@ -192,7 +304,11 @@ public partial class MainForm : Form foreach (var u in users) { _users[u.Id] = u; - if (u.Id == _selfUserId) _currentChannelId = u.ChannelId; + if (u.Id == _selfUserId) + { + _currentChannelId = u.ChannelId; + UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened); + } } RefreshChannelTree(); RefreshUserList(); @@ -221,6 +337,13 @@ public partial class MainForm : Form } } + private void HandleGenericResult(VoiceCatEvent ev) + { + string prefix = ev.Result == VcResult.Ok ? "Success" : "Failed"; + string detail = !string.IsNullOrEmpty(ev.Text) ? $": {ev.Text}" : ""; + AddActivity($"{prefix}{detail} ({ev.Result})"); + } + private void HandleTextMessage(VoiceCatEvent ev) { string time = ev.TimestampUnixMs > 0 @@ -341,6 +464,8 @@ public partial class MainForm : Form string label = user.Nickname; if (user.Id == _selfUserId) label += " (you)"; if (_talkingUsers.Contains(user.Id)) label += " (talking)"; + if (user.SelfMicMuted || user.ServerMuted) label += " (muted)"; + if (user.SelfDeafened || user.ServerDeafened) label += " (deafened)"; lstUsers.Items.Add(new UserListItem(user.Id, label)); } lstUsers.EndUpdate(); @@ -376,15 +501,19 @@ public partial class MainForm : Form private void UpdateStatusLabel() { + string suffix = ""; + if (_serverMuted) suffix += " [server muted]"; + if (_serverDeafened) suffix += " [server deafened]"; + if (_currentChannelId == 0) { - lblStatus.Text = $"Connected as {_nickname} — not in a channel."; + lblStatus.Text = $"Connected as {_nickname}{suffix} — not in a channel."; return; } string chanName = _channels.FirstOrDefault(c => c.Id == _currentChannelId)?.Name ?? $"Channel #{_currentChannelId}"; int count = _users.Values.Count(u => u.ChannelId == _currentChannelId); - lblStatus.Text = $"Connected as {_nickname} — {chanName} ({count} user{(count == 1 ? "" : "s")})"; + lblStatus.Text = $"Connected as {_nickname}{suffix} — {chanName} ({count} user{(count == 1 ? "" : "s")})"; } // ── Device management ───────────────────────────────────────────────────── @@ -591,6 +720,130 @@ public partial class MainForm : Form _client.JoinChannel(channelId, password); } + private void ChannelTreeJoinSelected() + { + if (tvChannels.SelectedNode?.Tag is uint channelId) + JoinChannelRequest(channelId); + } + + // ── M5: Moderation helpers ──────────────────────────────────────────────── + + private void UpdateSelfServerMuteState(bool muted, bool deafened) + { + bool wasMuted = _serverMuted; + bool wasDeafened = _serverDeafened; + _serverMuted = muted; + _serverDeafened = deafened; + + if (muted && !wasMuted) AddActivity("You have been server-muted"); + if (deafened && !wasDeafened) AddActivity("You have been server-deafened"); + if (!muted && wasMuted) AddActivity("Server mute cleared"); + if (!deafened && wasDeafened) AddActivity("Server deafen cleared"); + + UpdateStatusLabel(); + } + + private void CreateChannel() + { + using var dlg = new ChannelEditDialog(_channels); + if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return; + _client.CreateChannel(dlg.Result); + } + + private void EditSelectedChannel() + { + if (tvChannels.SelectedNode?.Tag is not uint channelId) return; + var channel = _channels.FirstOrDefault(c => c.Id == channelId); + if (channel is null) return; + + var editInfo = new ChannelEditInfo( + channel.Id, + channel.ParentId, + channel.Name, + channel.Topic, + channel.PasswordProtected, + null, + channel.MaxUsers, + 0, + new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10)); + + using var dlg = new ChannelEditDialog(_channels, editInfo); + if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return; + _client.EditChannel(dlg.Result); + } + + private void DeleteSelectedChannel() + { + if (tvChannels.SelectedNode?.Tag is not uint channelId) return; + var channel = _channels.FirstOrDefault(c => c.Id == channelId); + if (channel is null) return; + + var confirm = MessageBox.Show(this, $"Delete channel \"{channel.Name}\"?", + "VoiceCat", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (confirm != DialogResult.Yes) return; + + _client.DeleteChannel(channelId); + } + + private UserInfo? SelectedUser() + { + if (lstUsers.SelectedItem is not UserListItem item) return null; + _users.TryGetValue(item.UserId, out var user); + return user; + } + + private void MoveSelectedUser() + { + var user = SelectedUser(); + if (user is null) return; + using var dlg = new MoveUserDialog(_channels, user.ChannelId); + if (dlg.ShowDialog(this) != DialogResult.OK) return; + _client.MoveUser(user.Id, dlg.SelectedChannelId); + } + + private void KickSelectedUser() + { + var user = SelectedUser(); + if (user is null) return; + using var dlg = new InputDialog("Kick user", "&Reason:", "Kicked by admin"); + string? reason = dlg.ShowDialog(this) == DialogResult.OK ? dlg.TextValue : null; + _client.KickUser(user.Id, reason); + } + + private void BanSelectedUser() + { + var user = SelectedUser(); + if (user is null) return; + using var dlg = new BanUserDialog(user.Nickname); + if (dlg.ShowDialog(this) != DialogResult.OK) return; + _client.BanUser(user.Id, dlg.Reason, dlg.ExpiresUnixMs); + } + + private void ToggleServerMuteSelected() + { + var user = SelectedUser(); + if (user is null) return; + _client.SetServerMute(user.Id, !user.ServerMuted, user.ServerDeafened); + } + + private void ToggleServerDeafenSelected() + { + var user = SelectedUser(); + if (user is null) return; + _client.SetServerMute(user.Id, user.ServerMuted, !user.ServerDeafened); + } + + private void SetPermissionsSelected() + { + var user = SelectedUser(); + if (user is null) return; + // The C ABI does not expose a user's current permissions, so start unchecked. + var current = new PermissionsInfo(false, false, false, false, false, false); + using var dlg = new PermissionsDialog(user.Nickname, current); + if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return; + _client.SetPermission(user.Id, dlg.Result); + } + // ── Per-user tuning ─────────────────────────────────────────────────────── private void OpenUserTuning() diff --git a/clients/windows/VoiceCat.App/Forms/MoveUserDialog.cs b/clients/windows/VoiceCat.App/Forms/MoveUserDialog.cs new file mode 100644 index 0000000..05a3a20 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/MoveUserDialog.cs @@ -0,0 +1,79 @@ +using VoiceCat.Interop; + +namespace VoiceCat.App.Forms; + +/// +/// Prompt the moderator to pick a destination channel for a user move. +/// +public sealed class MoveUserDialog : Form +{ + private readonly ComboBox _cboChannel; + + public uint SelectedChannelId { get; private set; } + + public MoveUserDialog(IEnumerable channels, uint currentChannelId) + { + Text = "Move user to channel"; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterParent; + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(384, 120); + + var lbl = new Label + { + Text = "&Destination channel:", + Location = new Point(12, 12), + AutoSize = true, + }; + + _cboChannel = new ComboBox + { + Location = new Point(12, 36), + Size = new Size(360, 23), + DropDownStyle = ComboBoxStyle.DropDownList, + TabIndex = 1, + }; + + foreach (var ch in channels.OrderBy(c => c.Name)) + { + _cboChannel.Items.Add(new ChannelItem(ch.Name, ch.Id)); + if (ch.Id == currentChannelId) _cboChannel.SelectedIndex = _cboChannel.Items.Count - 1; + } + if (_cboChannel.SelectedIndex < 0 && _cboChannel.Items.Count > 0) + _cboChannel.SelectedIndex = 0; + + var btnOk = new Button + { + Text = "&OK", + DialogResult = DialogResult.OK, + Location = new Point(216, 72), + Size = new Size(75, 27), + TabIndex = 2, + }; + var btnCancel = new Button + { + Text = "&Cancel", + DialogResult = DialogResult.Cancel, + Location = new Point(297, 72), + Size = new Size(75, 27), + TabIndex = 3, + }; + + AcceptButton = btnOk; + CancelButton = btnCancel; + Controls.AddRange([lbl, _cboChannel, btnOk, btnCancel]); + + btnOk.Click += (_, _) => + { + SelectedChannelId = (_cboChannel.SelectedItem as ChannelItem)?.Id ?? 0; + }; + } + + private sealed class ChannelItem(string name, uint id) + { + public uint Id { get; } = id; + public override string ToString() => name; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/PermissionsDialog.cs b/clients/windows/VoiceCat.App/Forms/PermissionsDialog.cs new file mode 100644 index 0000000..3119fa6 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/PermissionsDialog.cs @@ -0,0 +1,110 @@ +using VoiceCat.Interop; + +namespace VoiceCat.App.Forms; + +/// +/// Edit a user's server permissions. +/// +public sealed class PermissionsDialog : Form +{ + private readonly CheckBox _chkCreateTemp; + private readonly CheckBox _chkKick; + private readonly CheckBox _chkBan; + private readonly CheckBox _chkMove; + private readonly CheckBox _chkAdminAccounts; + private readonly CheckBox _chkIsAdmin; + + public PermissionsInfo? Result { get; private set; } + + public PermissionsDialog(string nickname, PermissionsInfo current) + { + Text = $"Permissions — {nickname}"; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterParent; + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(320, 260); + + var lbl = new Label + { + Text = $"Set permissions for {nickname}:", + Location = new Point(12, 12), + AutoSize = true, + }; + + _chkCreateTemp = new CheckBox + { + Text = "Can create &temporary channels", + Location = new Point(12, 38), + AutoSize = true, + Checked = current.CanCreateTempChannel, + }; + _chkKick = new CheckBox + { + Text = "Can &kick users", + Location = new Point(12, 64), + AutoSize = true, + Checked = current.CanKick, + }; + _chkBan = new CheckBox + { + Text = "Can &ban users", + Location = new Point(12, 90), + AutoSize = true, + Checked = current.CanBan, + }; + _chkMove = new CheckBox + { + Text = "Can &move users", + Location = new Point(12, 116), + AutoSize = true, + Checked = current.CanMoveUsers, + }; + _chkAdminAccounts = new CheckBox + { + Text = "Can manage &accounts", + Location = new Point(12, 142), + AutoSize = true, + Checked = current.CanAdminAccounts, + }; + _chkIsAdmin = new CheckBox + { + Text = "Is &admin", + Location = new Point(12, 168), + AutoSize = true, + Checked = current.IsAdmin, + }; + + var btnOk = new Button + { + Text = "&OK", + DialogResult = DialogResult.OK, + Location = new Point(152, 210), + Size = new Size(75, 27), + }; + var btnCancel = new Button + { + Text = "&Cancel", + DialogResult = DialogResult.Cancel, + Location = new Point(233, 210), + Size = new Size(75, 27), + }; + + AcceptButton = btnOk; + CancelButton = btnCancel; + Controls.AddRange([lbl, _chkCreateTemp, _chkKick, _chkBan, _chkMove, + _chkAdminAccounts, _chkIsAdmin, btnOk, btnCancel]); + + btnOk.Click += (_, _) => + { + Result = new PermissionsInfo( + CanCreateTempChannel: _chkCreateTemp.Checked, + CanKick: _chkKick.Checked, + CanBan: _chkBan.Checked, + CanMoveUsers: _chkMove.Checked, + CanAdminAccounts: _chkAdminAccounts.Checked, + IsAdmin: _chkIsAdmin.Checked); + }; + } +} diff --git a/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs b/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs index 27e2a6b..0cc95a1 100644 --- a/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs +++ b/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs @@ -55,6 +55,22 @@ public sealed class VoiceCatClientSmokeTests : IDisposable } Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s."); _port = port!.Value; + + // M5: provision a known admin account so we can exercise moderation wrappers end-to-end. + string adminExe = Path.Combine(FindRepoRoot(), "build", "m1-dev", "bin", "voicecat-admin.exe"); + Assert.True(File.Exists(adminExe), "voicecat-admin.exe not found — build the m1-dev preset."); + var adminPsi = new ProcessStartInfo(adminExe) + { + Arguments = $"--data-dir \"{_tempDir}\" account add admin2 --admin --password testpassword123", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + using (var adminProc = Process.Start(adminPsi) ?? throw new InvalidOperationException("failed to start voicecat-admin.exe")) + { + Assert.True(adminProc.WaitForExit(10000), "voicecat-admin.exe did not exit within 10s."); + Assert.Equal(0, adminProc.ExitCode); + } } public void Dispose() @@ -124,6 +140,110 @@ public sealed class VoiceCatClientSmokeTests : IDisposable var channels = client.ListChannels(); Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby"); + // M5: permissions getter round-trip. + var perms = client.GetPermissions(); + Assert.False(perms.IsAdmin); + Assert.False(perms.CanKick); + + // M5: moderation request wrappers queue without error. As a guest, account listing + // is rejected by the server with a GenericResult, which proves the wrapper path works + // end-to-end and that the new event type is delivered through P/Invoke. + Assert.Equal(VcResult.Ok, client.RequestAccountList()); + Assert.True(PumpUntil(client, + () => events.Any(e => e.Type == VcEventType.GenericResult), 3000), + "did not receive VC_EVENT_GENERIC_RESULT for guest ListAccounts"); + var generic = events.First(e => e.Type == VcEventType.GenericResult); + Assert.Equal(VcResult.PermissionDenied, generic.Result); + + client.Disconnect(); + } + + [Fact] + public void Admin_ChannelCrud_AccountCrud_RoundTrips() + { + var events = new List(); + using var client = new VoiceCatClient("vc-csharp-admin", "0.1", VcLogLevel.Off, + tofuStorePath: Path.Combine(_tempDir, "tofu_pins_admin.txt")); + client.EventReceived += events.Add; + + Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port)); + Assert.Equal(VcResult.Ok, client.AuthenticateUser("admin2", "testpassword123")); + + Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000)); + Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true)); + Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000)); + Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result); + Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000)); + + var perms = client.GetPermissions(); + Assert.True(perms.IsAdmin || perms.CanAdminAccounts); + + // Channel CRUD + Assert.Equal(VcResult.Ok, client.CreateChannel(new ChannelEditInfo( + Id: 0, + ParentId: 0, + Name: "CSharp Test Channel", + Topic: "Created by C# smoke test", + PasswordProtected: false, + Password: null, + MaxUsers: 42, + SortOrder: 0, + Audio: new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10)))); + Assert.True(PumpUntil(client, + () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), + "CreateChannel did not succeed"); + + var channels = client.ListChannels(); + var created = channels.FirstOrDefault(c => c.Name == "CSharp Test Channel"); + Assert.NotNull(created); + Assert.Equal("Created by C# smoke test", created.Topic); + Assert.True(created.PasswordProtected == false); + + Assert.Equal(VcResult.Ok, client.EditChannel(new ChannelEditInfo( + created.Id, + created.ParentId, + created.Name, + "Updated topic", + created.PasswordProtected, + null, + 100, + 0, + new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10)))); + events.Clear(); + Assert.True(PumpUntil(client, + () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), + "EditChannel did not succeed"); + + Assert.Equal(VcResult.Ok, client.DeleteChannel(created.Id)); + events.Clear(); + Assert.True(PumpUntil(client, + () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), + "DeleteChannel did not succeed"); + + // Account CRUD + Assert.Equal(VcResult.Ok, client.CreateAccount("csharp_smoke_user", "initialpw")); + events.Clear(); + Assert.True(PumpUntil(client, + () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), + "CreateAccount did not succeed"); + + Assert.Equal(VcResult.Ok, client.RequestAccountList()); + Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AccountList), 3000)); + var accounts = client.ListAccounts(); + Assert.Contains(accounts, a => a.Username == "csharp_smoke_user"); + + Assert.Equal(VcResult.Ok, client.ResetPassword("csharp_smoke_user", "newpw123")); + events.Clear(); + Assert.True(PumpUntil(client, + () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), + "ResetPassword did not succeed"); + + Assert.Equal(VcResult.Ok, client.DeleteAccount("csharp_smoke_user")); + events.Clear(); + Assert.True(PumpUntil(client, + () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), + "DeleteAccount did not succeed"); + client.Disconnect(); } } diff --git a/clients/windows/VoiceCat.Interop/Enums.cs b/clients/windows/VoiceCat.Interop/Enums.cs index 41f9777..478e021 100644 --- a/clients/windows/VoiceCat.Interop/Enums.cs +++ b/clients/windows/VoiceCat.Interop/Enums.cs @@ -88,6 +88,10 @@ public enum VcEventType JoinResult = 12, /// M4: the TOFU server-identity gate — see VcTofuStatus. ServerIdentity = 13, + /// M5: async result for moderation/admin/channel operations. + GenericResult = 14, + /// M5: reply to VoiceCatClient.RequestAccountList — call ListAccounts() to read. + AccountList = 15, } /// diff --git a/clients/windows/VoiceCat.Interop/Marshaling.cs b/clients/windows/VoiceCat.Interop/Marshaling.cs index 651b3a9..ffd80da 100644 --- a/clients/windows/VoiceCat.Interop/Marshaling.cs +++ b/clients/windows/VoiceCat.Interop/Marshaling.cs @@ -36,6 +36,7 @@ internal static class Marshaling raw.Id, raw.ParentId, Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty, + Marshal.PtrToStringUTF8(raw.Topic) ?? string.Empty, raw.PasswordProtected != 0, raw.MaxUsers)); } @@ -54,7 +55,11 @@ internal static class Marshaling raw.Id, Marshal.PtrToStringUTF8(raw.Nickname) ?? string.Empty, raw.IsGuest != 0, - raw.ChannelId)); + raw.ChannelId, + raw.SelfMicMuted != 0, + raw.SelfDeafened != 0, + raw.ServerMuted != 0, + raw.ServerDeafened != 0)); } NativeMethods.vc_free_user_list(ref native); return result; @@ -87,4 +92,29 @@ internal static class Marshaling native.ExpectedPacketLoss, native.Dtx != 0, native.Complexity); + + public static PermissionsInfo ToManaged(in VcPermissionsNative native) => new( + native.CanCreateTempChannel != 0, + native.CanKick != 0, + native.CanBan != 0, + native.CanMoveUsers != 0, + native.CanAdminAccounts != 0, + native.IsAdmin != 0); + + public static List ToManaged(ref VcAccountListNative native) + { + var result = new List((int)native.Count); + int size = Marshal.SizeOf(); + for (nuint i = 0; i < native.Count; i++) + { + var raw = Marshal.PtrToStructure(native.Items + (int)i * size); + result.Add(new AccountInfo( + Marshal.PtrToStringUTF8(raw.Username) ?? string.Empty, + raw.IsAdmin != 0, + raw.CreatedAtUnixMs, + raw.LastLoginUnixMs)); + } + NativeMethods.vc_free_account_list(ref native); + return result; + } } diff --git a/clients/windows/VoiceCat.Interop/Models.cs b/clients/windows/VoiceCat.Interop/Models.cs index 89d7e35..26fb084 100644 --- a/clients/windows/VoiceCat.Interop/Models.cs +++ b/clients/windows/VoiceCat.Interop/Models.cs @@ -7,14 +7,44 @@ public sealed record ChannelInfo( uint Id, uint ParentId, string Name, + string Topic, bool PasswordProtected, uint MaxUsers); +public sealed record ChannelEditInfo( + uint Id, + uint ParentId, + string Name, + string Topic, + bool PasswordProtected, + string? Password, + uint MaxUsers, + uint SortOrder, + AudioConfigInfo Audio); + public sealed record UserInfo( uint Id, string Nickname, bool IsGuest, - uint ChannelId); + uint ChannelId, + bool SelfMicMuted, + bool SelfDeafened, + bool ServerMuted, + bool ServerDeafened); + +public sealed record PermissionsInfo( + bool CanCreateTempChannel, + bool CanKick, + bool CanBan, + bool CanMoveUsers, + bool CanAdminAccounts, + bool IsAdmin); + +public sealed record AccountInfo( + string Username, + bool IsAdmin, + ulong CreatedAtUnixMs, + ulong LastLoginUnixMs); public sealed record StreamSummary( uint StreamId, diff --git a/clients/windows/VoiceCat.Interop/NativeMethods.cs b/clients/windows/VoiceCat.Interop/NativeMethods.cs index 9e309ce..430f5a0 100644 --- a/clients/windows/VoiceCat.Interop/NativeMethods.cs +++ b/clients/windows/VoiceCat.Interop/NativeMethods.cs @@ -131,4 +131,54 @@ internal static partial class NativeMethods [LibraryImport(LibName)] internal static partial VcResult vc_get_server_identity_display(nint c, nint outBuf, nuint bufCap, out nuint outLen); + + // ── M5: Moderation & admin ───────────────────────────────────────────────────────────── + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + internal static partial VcResult vc_kick_user(nint c, uint userId, string? reason); + + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + internal static partial VcResult vc_ban_user(nint c, uint userId, string? reason, + ulong expiresUnixMs); + + [LibraryImport(LibName)] + internal static partial VcResult vc_set_permission(nint c, uint userId, + in VcPermissionsNative perms); + + [LibraryImport(LibName)] + internal static partial VcResult vc_set_server_mute(nint c, uint userId, int muted, + int deafened); + + [LibraryImport(LibName)] + internal static partial VcResult vc_move_user(nint c, uint userId, uint channelId); + + [LibraryImport(LibName)] + internal static partial VcResult vc_create_channel(nint c, in VcChannelInfoNative info); + + [LibraryImport(LibName)] + internal static partial VcResult vc_edit_channel(nint c, in VcChannelInfoNative info); + + [LibraryImport(LibName)] + internal static partial VcResult vc_delete_channel(nint c, uint channelId); + + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + internal static partial VcResult vc_create_account(nint c, string username, string password); + + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + internal static partial VcResult vc_reset_password(nint c, string username, + string newPassword); + + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + internal static partial VcResult vc_delete_account(nint c, string username); + + [LibraryImport(LibName)] + internal static partial VcResult vc_list_accounts(nint c); + + [LibraryImport(LibName)] + internal static partial VcResult vc_get_account_list(nint c, out VcAccountListNative outList); + + [LibraryImport(LibName)] + internal static partial void vc_free_account_list(ref VcAccountListNative list); + + [LibraryImport(LibName)] + internal static partial VcResult vc_get_permissions(nint c, out VcPermissionsNative outPerms); } diff --git a/clients/windows/VoiceCat.Interop/Structs.cs b/clients/windows/VoiceCat.Interop/Structs.cs index 4081042..8a14569 100644 --- a/clients/windows/VoiceCat.Interop/Structs.cs +++ b/clients/windows/VoiceCat.Interop/Structs.cs @@ -63,6 +63,31 @@ internal struct VcAudioConfigNative public uint Complexity; } +[StructLayout(LayoutKind.Sequential)] +internal struct VcPermissionsNative +{ + public int CanCreateTempChannel; + public int CanKick; + public int CanBan; + public int CanMoveUsers; + public int CanAdminAccounts; + public int IsAdmin; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcChannelInfoNative +{ + public uint Id; + public uint ParentId; + public IntPtr Name; + public IntPtr Topic; + public int PasswordProtected; + public IntPtr Password; + public uint MaxUsers; + public uint SortOrder; + public VcAudioConfigNative Audio; +} + [StructLayout(LayoutKind.Sequential)] internal struct VcDeviceNative { @@ -84,6 +109,7 @@ internal struct VcChannelNative public uint Id; public uint ParentId; public IntPtr Name; + public IntPtr Topic; public int PasswordProtected; public uint MaxUsers; } @@ -102,6 +128,10 @@ internal struct VcUserNative public IntPtr Nickname; public int IsGuest; public uint ChannelId; + public int SelfMicMuted; + public int SelfDeafened; + public int ServerMuted; + public int ServerDeafened; } [StructLayout(LayoutKind.Sequential)] @@ -125,3 +155,19 @@ internal struct VcStreamSummaryListNative public IntPtr Items; public nuint Count; } + +[StructLayout(LayoutKind.Sequential)] +internal struct VcAccountNative +{ + public IntPtr Username; + public int IsAdmin; + public ulong CreatedAtUnixMs; + public ulong LastLoginUnixMs; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcAccountListNative +{ + public IntPtr Items; + public nuint Count; +} diff --git a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs index 8db1fcb..cafafe0 100644 --- a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs +++ b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs @@ -215,6 +215,124 @@ public sealed class VoiceCatClient : IDisposable return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null); } + // ── M5: Moderation & admin ─────────────────────────────────────────────────────────── + public VcResult KickUser(uint userId, string? reason = null) => + NativeMethods.vc_kick_user(_handle.DangerousGetHandle(), userId, reason); + + public VcResult BanUser(uint userId, string? reason = null, ulong expiresUnixMs = 0) => + NativeMethods.vc_ban_user(_handle.DangerousGetHandle(), userId, reason, expiresUnixMs); + + public VcResult SetPermission(uint userId, PermissionsInfo perms) + { + var native = new VcPermissionsNative + { + CanCreateTempChannel = perms.CanCreateTempChannel ? 1 : 0, + CanKick = perms.CanKick ? 1 : 0, + CanBan = perms.CanBan ? 1 : 0, + CanMoveUsers = perms.CanMoveUsers ? 1 : 0, + CanAdminAccounts = perms.CanAdminAccounts ? 1 : 0, + IsAdmin = perms.IsAdmin ? 1 : 0, + }; + return NativeMethods.vc_set_permission(_handle.DangerousGetHandle(), userId, in native); + } + + public VcResult SetServerMute(uint userId, bool muted, bool deafened) => + NativeMethods.vc_set_server_mute(_handle.DangerousGetHandle(), userId, + muted ? 1 : 0, deafened ? 1 : 0); + + public VcResult MoveUser(uint userId, uint channelId) => + NativeMethods.vc_move_user(_handle.DangerousGetHandle(), userId, channelId); + + public VcResult CreateChannel(ChannelEditInfo info) + { + var native = ToNativeChannelInfo(info); + try + { + return NativeMethods.vc_create_channel(_handle.DangerousGetHandle(), in native); + } + finally + { + FreeChannelInfoStrings(native); + } + } + + public VcResult EditChannel(ChannelEditInfo info) + { + var native = ToNativeChannelInfo(info); + try + { + return NativeMethods.vc_edit_channel(_handle.DangerousGetHandle(), in native); + } + finally + { + FreeChannelInfoStrings(native); + } + } + + public VcResult DeleteChannel(uint channelId) => + NativeMethods.vc_delete_channel(_handle.DangerousGetHandle(), channelId); + + public VcResult CreateAccount(string username, string password) => + NativeMethods.vc_create_account(_handle.DangerousGetHandle(), username, password); + + public VcResult ResetPassword(string username, string newPassword) => + NativeMethods.vc_reset_password(_handle.DangerousGetHandle(), username, newPassword); + + public VcResult DeleteAccount(string username) => + NativeMethods.vc_delete_account(_handle.DangerousGetHandle(), username); + + public VcResult RequestAccountList() => + NativeMethods.vc_list_accounts(_handle.DangerousGetHandle()); + + public List ListAccounts() + { + NativeMethods.vc_get_account_list(_handle.DangerousGetHandle(), out var native); + return Marshaling.ToManaged(ref native); + } + + public PermissionsInfo GetPermissions() + { + NativeMethods.vc_get_permissions(_handle.DangerousGetHandle(), out var native); + return Marshaling.ToManaged(in native); + } + + private static VcChannelInfoNative ToNativeChannelInfo(ChannelEditInfo info) + { + return new VcChannelInfoNative + { + Id = info.Id, + ParentId = info.ParentId, + Name = Marshal.StringToCoTaskMemUTF8(info.Name), + Topic = Marshal.StringToCoTaskMemUTF8(info.Topic), + PasswordProtected = info.PasswordProtected ? 1 : 0, + Password = string.IsNullOrEmpty(info.Password) + ? 0 + : Marshal.StringToCoTaskMemUTF8(info.Password), + MaxUsers = info.MaxUsers, + SortOrder = info.SortOrder, + Audio = new VcAudioConfigNative + { + Codec = info.Audio.Codec, + Mode = info.Audio.Stereo ? 1u : 0u, + SampleRate = info.Audio.SampleRate, + BitrateBps = info.Audio.BitrateBps, + FrameMs = info.Audio.FrameMs, + Application = info.Audio.Application, + Fec = info.Audio.Fec ? 1 : 0, + ExpectedPacketLoss = info.Audio.ExpectedPacketLoss, + Dtx = info.Audio.Dtx ? 1 : 0, + Complexity = info.Audio.Complexity, + } + }; + } + + private static void FreeChannelInfoStrings(VcChannelInfoNative native) + { + if (native.Name != 0) Marshal.FreeCoTaskMem(native.Name); + if (native.Topic != 0) Marshal.FreeCoTaskMem(native.Topic); + if (native.Password != 0) Marshal.FreeCoTaskMem(native.Password); + } + // ── Text ───────────────────────────────────────────────────────────────────────────── public VcResult SendText(VcTextScope scope, uint targetId, string utf8) => NativeMethods.vc_send_text(_handle.DangerousGetHandle(), scope, targetId, utf8); diff --git a/core/include/voicecat.h b/core/include/voicecat.h index 27b1911..41f7a3f 100644 --- a/core/include/voicecat.h +++ b/core/include/voicecat.h @@ -233,6 +233,19 @@ typedef struct vc_permissions { int is_admin; /* bool */ } vc_permissions; +/* M5: account entry (reply to vc_list_accounts / vc_get_account_list). */ +typedef struct vc_account { + const char* username; + int is_admin; /* bool */ + uint64_t created_at_unix_ms; + uint64_t last_login_unix_ms; +} vc_account; + +typedef struct vc_account_list { + vc_account* items; + size_t count; +} vc_account_list; + /* M5: channel creation/edition descriptor. */ typedef struct vc_channel_info { uint32_t id; /* 0 = new channel for create */ @@ -267,6 +280,7 @@ typedef struct vc_channel { uint32_t id; uint32_t parent_id; /* 0 = root */ const char* name; + const char* topic; int password_protected; /* bool */ uint32_t max_users; /* 0 = unlimited */ } vc_channel; @@ -281,6 +295,10 @@ typedef struct vc_user { const char* nickname; int is_guest; /* bool */ uint32_t channel_id; + int self_mic_muted; /* bool */ + int self_deafened; /* bool */ + int server_muted; /* bool */ + int server_deafened; /* bool */ } vc_user; typedef struct vc_user_list { @@ -424,6 +442,11 @@ VC_API vc_result vc_reset_password(vc_client* c, const char* username, VC_API vc_result vc_delete_account(vc_client* c, const char* username); VC_API vc_result vc_list_accounts(vc_client* c); +/* Pull the last received account list (populated when VC_EVENT_ACCOUNT_LIST fires). + * Caller must free the list with vc_free_account_list. */ +VC_API vc_result vc_get_account_list(vc_client* c, vc_account_list* out); +VC_API void vc_free_account_list(vc_account_list* list); + /* Pull the caller's own permissions (from the last AuthResult). */ VC_API vc_result vc_get_permissions(vc_client* c, vc_permissions* out); diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index de2ab2a..99c15fc 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -425,6 +425,11 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) { break; } case voicecat::v1::Envelope::kListAccountsResult: { + { + std::lock_guard lk(account_list_mu_); + last_account_list_.assign(env.list_accounts_result().accounts().begin(), + env.list_accounts_result().accounts().end()); + } vc_event ev{}; ev.type = VC_EVENT_ACCOUNT_LIST; emit(ev); @@ -1303,11 +1308,14 @@ vc_result vc_client::list_channels(vc_channel_list* out) { auto* items = new vc_channel[channels.size()]; for (size_t i = 0; i < channels.size(); ++i) { const auto& ch = channels[i]; - auto* name = new char[ch.name.size() + 1]; + auto* name = new char[ch.name.size() + 1]; + auto* topic = new char[ch.topic.size() + 1]; std::memcpy(name, ch.name.c_str(), ch.name.size() + 1); + std::memcpy(topic, ch.topic.c_str(), ch.topic.size() + 1); items[i].id = ch.id; items[i].parent_id = ch.parent_id; items[i].name = name; + items[i].topic = topic; items[i].password_protected = ch.password_protected ? 1 : 0; items[i].max_users = ch.max_users; } @@ -1324,10 +1332,14 @@ vc_result vc_client::list_users(vc_user_list* out) { const auto& u = users[i]; auto* nick = new char[u.nickname.size() + 1]; std::memcpy(nick, u.nickname.c_str(), u.nickname.size() + 1); - items[i].id = u.id; - items[i].nickname = nick; - items[i].is_guest = u.is_guest ? 1 : 0; - items[i].channel_id = u.channel_id; + items[i].id = u.id; + items[i].nickname = nick; + items[i].is_guest = u.is_guest ? 1 : 0; + items[i].channel_id = u.channel_id; + items[i].self_mic_muted = u.self_mic_muted ? 1 : 0; + items[i].self_deafened = u.self_deafened ? 1 : 0; + items[i].server_muted = u.server_muted ? 1 : 0; + items[i].server_deafened = u.server_deafened ? 1 : 0; } out->items = items; out->count = users.size(); @@ -1519,6 +1531,24 @@ vc_result vc_client::list_accounts() { return VC_OK; } +vc_result vc_client::get_account_list(vc_account_list* out) { + if (!out) return VC_ERR_INVALID_ARG; + std::lock_guard lk(account_list_mu_); + auto* items = new vc_account[last_account_list_.size()]; + for (size_t i = 0; i < last_account_list_.size(); ++i) { + const auto& a = last_account_list_[i]; + auto* user = new char[a.username().size() + 1]; + std::memcpy(user, a.username().c_str(), a.username().size() + 1); + items[i].username = user; + items[i].is_admin = a.is_admin() ? 1 : 0; + items[i].created_at_unix_ms = a.created_at_unix_ms(); + items[i].last_login_unix_ms = a.last_login_unix_ms(); + } + out->items = items; + out->count = last_account_list_.size(); + return VC_OK; +} + vc_result vc_client::get_permissions(vc_permissions* out) { if (!out) return VC_ERR_INVALID_ARG; *out = own_permissions_; @@ -1644,6 +1674,7 @@ vc_result vc_client::create_account(const char*, const char*) { return VC_ERR_NO vc_result vc_client::reset_password(const char*, const char*) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::delete_account(const char*) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::list_accounts() { return VC_ERR_NOT_IMPLEMENTED; } +vc_result vc_client::get_account_list(vc_account_list*) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::get_permissions(vc_permissions*) { return VC_ERR_NOT_IMPLEMENTED; } #endif // VOICECAT_HAS_NET diff --git a/core/src/core/client.h b/core/src/core/client.h index 6196f8d..7816639 100644 --- a/core/src/core/client.h +++ b/core/src/core/client.h @@ -90,6 +90,7 @@ struct vc_client { vc_result reset_password(const char* username, const char* new_password); vc_result delete_account(const char* username); vc_result list_accounts(); + vc_result get_account_list(vc_account_list* out); vc_result get_permissions(vc_permissions* out); vc_connection_state state() const { @@ -145,6 +146,11 @@ struct vc_client { voicecat::session::SessionModel session_model_; mutable std::mutex session_model_mu_; + // M5: last ListAccountsResult snapshot, populated on io_thread_ when + // VC_EVENT_ACCOUNT_LIST fires and read by vc_get_account_list on caller threads. + std::vector last_account_list_; + mutable std::mutex account_list_mu_; + // ── M4: TOFU server-identity gate ─────────────────────────────────────────── std::unique_ptr tofu_store_; // owns the pin file std::mutex tofu_mu_; diff --git a/core/src/session/session.cpp b/core/src/session/session.cpp index 42fc245..09d556f 100644 --- a/core/src/session/session.cpp +++ b/core/src/session/session.cpp @@ -58,6 +58,7 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap) ch.id = pb.id(); ch.parent_id = pb.parent_id(); ch.name = pb.name(); + ch.topic = pb.topic(); ch.password_protected = pb.password_protected(); ch.max_users = pb.max_users(); channels_.push_back(std::move(ch)); @@ -70,6 +71,8 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap) u.nickname = pb.nickname(); u.is_guest = pb.is_guest(); u.channel_id = pb.channel_id(); + u.self_mic_muted = pb.self_mic_muted(); + u.self_deafened = pb.self_deafened(); u.server_muted = pb.server_muted(); u.server_deafened = pb.server_deafened(); u.streams = copy_streams(pb.streams()); @@ -87,6 +90,8 @@ void SessionModel::apply_user_event(const voicecat::v1::UserEvent& ev) { u.nickname = pb.nickname(); u.is_guest = pb.is_guest(); u.channel_id = pb.channel_id(); + u.self_mic_muted = pb.self_mic_muted(); + u.self_deafened = pb.self_deafened(); u.server_muted = pb.server_muted(); u.server_deafened = pb.server_deafened(); u.streams = copy_streams(pb.streams()); @@ -113,6 +118,7 @@ void SessionModel::apply_channel_event(const voicecat::v1::ChannelEvent& ev) { ch.id = pb.id(); ch.parent_id = pb.parent_id(); ch.name = pb.name(); + ch.topic = pb.topic(); ch.password_protected = pb.password_protected(); ch.max_users = pb.max_users(); diff --git a/core/src/session/session.h b/core/src/session/session.h index d7598dc..1cdf954 100644 --- a/core/src/session/session.h +++ b/core/src/session/session.h @@ -22,6 +22,7 @@ struct Channel { uint32_t id{0}; uint32_t parent_id{0}; std::string name; + std::string topic; bool password_protected{false}; uint32_t max_users{0}; }; @@ -51,6 +52,8 @@ struct User { std::string nickname; bool is_guest{true}; uint32_t channel_id{0}; + bool self_mic_muted{false}; + bool self_deafened{false}; bool server_muted{false}; bool server_deafened{false}; std::vector streams; diff --git a/core/src/voicecat.cpp b/core/src/voicecat.cpp index 1d885fb..b7af8dd 100644 --- a/core/src/voicecat.cpp +++ b/core/src/voicecat.cpp @@ -164,7 +164,10 @@ vc_result vc_list_channels(vc_client* c, vc_channel_list* out) { void vc_free_channel_list(vc_channel_list* list) { if (list == nullptr || list->items == nullptr) return; - for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].name; + for (size_t i = 0; i < list->count; ++i) { + delete[] list->items[i].name; + delete[] list->items[i].topic; + } delete[] list->items; list->items = nullptr; list->count = 0; @@ -268,6 +271,19 @@ vc_result vc_list_accounts(vc_client* c) { return c->list_accounts(); } +vc_result vc_get_account_list(vc_client* c, vc_account_list* out) { + if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; + return c->get_account_list(out); +} + +void vc_free_account_list(vc_account_list* list) { + if (list == nullptr || list->items == nullptr) return; + for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].username; + delete[] list->items; + list->items = nullptr; + list->count = 0; +} + vc_result vc_get_permissions(vc_client* c, vc_permissions* out) { if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; return c->get_permissions(out); diff --git a/docs/roadmap.md b/docs/roadmap.md index 0947bd8..7328d5c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -62,7 +62,9 @@ exists from M1 so the protocol can be exercised long before any GUI. **Exit:** non-technical user installs a client, saves a server, and joins. ### M5 — Moderation, polish, and beyond -- Permissions/roles, kick/ban/server-mute, channel passwords UI. +- Permissions/roles, kick/ban/server-mute, channel passwords UI. Windows WinForms UI complete + (channel CRUD with full Opus config, user moderation, server account management); macOS/iOS + Swift UI pending. - DRED toggle, audio-quality polish. (AEC and VAD/PTT already shipped in M2.) - **Then (post-v1, protocol already reserves space):** file transfer, E2EE option, CallKit/PushKit background voice, key-based identity, server-side text history,