M5: Windows client moderation UI; add C ABI getters for account list, user mute/deafen, channel topic

This commit is contained in:
2026-06-17 16:31:29 +02:00
parent 3990f63f0f
commit 9b321d0d4f
24 changed files with 1723 additions and 30 deletions

View File

@@ -20,8 +20,10 @@ up instantly. Newest status at the top.
`--username`/`--password` for account auth and `--self-mute`/`--self-deafen`. Docs updated: `--username`/`--password` for account auth and `--self-mute`/`--self-deafen`. Docs updated:
`docs/protocol.md` (envelope tags for `ServerMuteRequest`/`ListAccountsResult`, `User.server_deafened`, `docs/protocol.md` (envelope tags for `ServerMuteRequest`/`ListAccountsResult`, `User.server_deafened`,
`GenericResult` usage), `docs/security.md` (BLAKE2b channel passwords, `bans` schema). `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 `ctest --preset m1-dev`**18/18 green**. Windows WinForms UI now exposes all M5 operations:
admin/moderation UI. 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 - **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` 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). 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 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. `channels_[id] = std::move(entry)`. Fixed by building the event before moving into the map.
**Still to do:** **Still to do:**
- DRED/audio-quality polish. - DRED/audio-quality polish.
- Windows admin/moderation UI in the WinForms client. - macOS/iOS Swift client (carried from M4).
- macOS/iOS Swift client (carried from M4).
--- ---

View File

@@ -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; - 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). 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 ## Known limitations
- **PTT is focus-scoped** — the push-to-talk key only works while the VoiceCat window has - **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 - **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 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`). `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 - **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 shown in the identity dialog, but the cert fingerprint is the value that is actually
verified on reconnect. See `docs/security.md §1.1`. verified on reconnect. See `docs/security.md §1.1`.

View File

@@ -0,0 +1,200 @@
using VoiceCat.Interop;
namespace VoiceCat.App.Forms;
/// <summary>
/// Admin account management: list, create, reset password, and delete server accounts.
/// </summary>
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<AccountInfo>? 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();
}
}

View File

@@ -0,0 +1,94 @@
namespace VoiceCat.App.Forms;
/// <summary>
/// Prompt for a ban reason and duration.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,384 @@
using VoiceCat.Interop;
namespace VoiceCat.App.Forms;
/// <summary>
/// Create or edit a channel, including its full per-channel Opus audio config.
/// Returns DialogResult.OK with Result set.
/// </summary>
public sealed class ChannelEditDialog : Form
{
private readonly bool _isCreate;
private readonly uint _editingId;
private readonly List<ChannelInfo> _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<ChannelInfo> 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 (010):", 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;
}
}

View File

@@ -0,0 +1,60 @@
namespace VoiceCat.App.Forms;
/// <summary>
/// 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.
/// </summary>
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;
}
}

View File

@@ -4,6 +4,9 @@ partial class MainForm
{ {
private System.ComponentModel.IContainer components = null!; private System.ComponentModel.IContainer components = null!;
// Menu bar
private MenuStrip menuStrip = null!;
// Status bar // Status bar
private Label lblStatus = null!; private Label lblStatus = null!;
@@ -349,6 +352,13 @@ partial class MainForm
pnlVoice.Controls.Add(flpVoiceBottom); // Fill — added first pnlVoice.Controls.Add(flpVoiceBottom); // Fill — added first
pnlVoice.Controls.Add(flpVoiceTop); // Top — added last 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 ────────────────────────────────────────────────────────────── // ── Form ──────────────────────────────────────────────────────────────
AutoScaleMode = AutoScaleMode.Font; AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(960, 680); ClientSize = new Size(960, 680);
@@ -358,6 +368,7 @@ partial class MainForm
Controls.Add(splitMain); // DockStyle.Fill Controls.Add(splitMain); // DockStyle.Fill
Controls.Add(pnlVoice); // DockStyle.Bottom Controls.Add(pnlVoice); // DockStyle.Bottom
Controls.Add(lblStatus); // DockStyle.Top Controls.Add(lblStatus); // DockStyle.Top
Controls.Add(menuStrip); // DockStyle.Top (placed at the very top)
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
Text = "VoiceCat"; Text = "VoiceCat";
} }

View File

@@ -19,10 +19,13 @@ public partial class MainForm : Form
private List<ChannelInfo> _channels = []; private List<ChannelInfo> _channels = [];
private readonly Dictionary<uint, UserInfo> _users = []; private readonly Dictionary<uint, UserInfo> _users = [];
private readonly HashSet<uint> _talkingUsers = []; private readonly HashSet<uint> _talkingUsers = [];
private PermissionsInfo _ownPermissions = new(false, false, false, false, false, false);
// Voice state // Voice state
private uint _micStreamId; // 0 = not started private uint _micStreamId; // 0 = not started
private Keys _pttKey = Keys.F8; private Keys _pttKey = Keys.F8;
private bool _serverMuted;
private bool _serverDeafened;
public MainForm(VoiceCatClient client, uint selfUserId, string nickname) public MainForm(VoiceCatClient client, uint selfUserId, string nickname)
{ {
@@ -36,19 +39,18 @@ public partial class MainForm : Form
_client.EventReceived += OnEvent; _client.EventReceived += OnEvent;
_client.LevelChanged += OnLevelChanged; _client.LevelChanged += OnLevelChanged;
_pumpTimer.Tick += (_, _) => _client.PumpEvents(); _pumpTimer.Tick += (_, _) => _client.PumpEvents();
// M5: load own permissions and build permission-aware menus.
_ownPermissions = SafeGetPermissions();
BuildMenus();
BuildChannelContextMenu();
BuildUserContextMenu();
_pumpTimer.Start(); _pumpTimer.Start();
// Channel tree // Channel tree
tvChannels.DoubleClick += TvChannels_DoubleClick; tvChannels.DoubleClick += TvChannels_DoubleClick;
tvChannels.KeyDown += TvChannels_KeyDown; 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.DoubleClick += (_, _) => OpenUserTuning();
lstUsers.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) OpenUserTuning(); }; lstUsers.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) OpenUserTuning(); };
@@ -89,7 +91,11 @@ public partial class MainForm : Form
foreach (var u in users) foreach (var u in users)
{ {
_users[u.Id] = u; _users[u.Id] = u;
if (u.Id == _selfUserId) _currentChannelId = u.ChannelId; if (u.Id == _selfUserId)
{
_currentChannelId = u.ChannelId;
UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened);
}
} }
RefreshChannelTree(); RefreshChannelTree();
RefreshUserList(); RefreshUserList();
@@ -106,6 +112,105 @@ public partial class MainForm : Form
LoadInputDevices(); 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 ──────────────────────────────────────────────────────── // ── Event dispatch ────────────────────────────────────────────────────────
private void OnEvent(VoiceCatEvent ev) private void OnEvent(VoiceCatEvent ev)
@@ -127,6 +232,12 @@ public partial class MainForm : Form
case VcEventType.JoinResult: case VcEventType.JoinResult:
HandleJoinResult(ev); HandleJoinResult(ev);
break; break;
case VcEventType.GenericResult:
HandleGenericResult(ev);
break;
case VcEventType.AccountList:
AddActivity("Account list updated");
break;
case VcEventType.TextMessage: case VcEventType.TextMessage:
HandleTextMessage(ev); HandleTextMessage(ev);
break; break;
@@ -166,7 +277,8 @@ public partial class MainForm : Form
private void HandleUserJoined(VoiceCatEvent ev) 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; _users[ev.UserId] = user;
RefreshUserList(); RefreshUserList();
RebuildScopeCombo(); RebuildScopeCombo();
@@ -192,7 +304,11 @@ public partial class MainForm : Form
foreach (var u in users) foreach (var u in users)
{ {
_users[u.Id] = u; _users[u.Id] = u;
if (u.Id == _selfUserId) _currentChannelId = u.ChannelId; if (u.Id == _selfUserId)
{
_currentChannelId = u.ChannelId;
UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened);
}
} }
RefreshChannelTree(); RefreshChannelTree();
RefreshUserList(); 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) private void HandleTextMessage(VoiceCatEvent ev)
{ {
string time = ev.TimestampUnixMs > 0 string time = ev.TimestampUnixMs > 0
@@ -341,6 +464,8 @@ public partial class MainForm : Form
string label = user.Nickname; string label = user.Nickname;
if (user.Id == _selfUserId) label += " (you)"; if (user.Id == _selfUserId) label += " (you)";
if (_talkingUsers.Contains(user.Id)) label += " (talking)"; 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.Items.Add(new UserListItem(user.Id, label));
} }
lstUsers.EndUpdate(); lstUsers.EndUpdate();
@@ -376,15 +501,19 @@ public partial class MainForm : Form
private void UpdateStatusLabel() private void UpdateStatusLabel()
{ {
string suffix = "";
if (_serverMuted) suffix += " [server muted]";
if (_serverDeafened) suffix += " [server deafened]";
if (_currentChannelId == 0) if (_currentChannelId == 0)
{ {
lblStatus.Text = $"Connected as {_nickname} — not in a channel."; lblStatus.Text = $"Connected as {_nickname}{suffix} — not in a channel.";
return; return;
} }
string chanName = _channels.FirstOrDefault(c => c.Id == _currentChannelId)?.Name string chanName = _channels.FirstOrDefault(c => c.Id == _currentChannelId)?.Name
?? $"Channel #{_currentChannelId}"; ?? $"Channel #{_currentChannelId}";
int count = _users.Values.Count(u => u.ChannelId == _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 ───────────────────────────────────────────────────── // ── Device management ─────────────────────────────────────────────────────
@@ -591,6 +720,130 @@ public partial class MainForm : Form
_client.JoinChannel(channelId, password); _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 ─────────────────────────────────────────────────────── // ── Per-user tuning ───────────────────────────────────────────────────────
private void OpenUserTuning() private void OpenUserTuning()

View File

@@ -0,0 +1,79 @@
using VoiceCat.Interop;
namespace VoiceCat.App.Forms;
/// <summary>
/// Prompt the moderator to pick a destination channel for a user move.
/// </summary>
public sealed class MoveUserDialog : Form
{
private readonly ComboBox _cboChannel;
public uint SelectedChannelId { get; private set; }
public MoveUserDialog(IEnumerable<ChannelInfo> 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;
}
}

View File

@@ -0,0 +1,110 @@
using VoiceCat.Interop;
namespace VoiceCat.App.Forms;
/// <summary>
/// Edit a user's server permissions.
/// </summary>
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);
};
}
}

View File

@@ -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."); Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s.");
_port = port!.Value; _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() public void Dispose()
@@ -124,6 +140,110 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
var channels = client.ListChannels(); var channels = client.ListChannels();
Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby"); 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<VoiceCatEvent>();
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(); client.Disconnect();
} }
} }

View File

@@ -88,6 +88,10 @@ public enum VcEventType
JoinResult = 12, JoinResult = 12,
/// <summary>M4: the TOFU server-identity gate — see VcTofuStatus.</summary> /// <summary>M4: the TOFU server-identity gate — see VcTofuStatus.</summary>
ServerIdentity = 13, ServerIdentity = 13,
/// <summary>M5: async result for moderation/admin/channel operations.</summary>
GenericResult = 14,
/// <summary>M5: reply to VoiceCatClient.RequestAccountList — call ListAccounts() to read.</summary>
AccountList = 15,
} }
/// <summary> /// <summary>

View File

@@ -36,6 +36,7 @@ internal static class Marshaling
raw.Id, raw.Id,
raw.ParentId, raw.ParentId,
Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty, Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty,
Marshal.PtrToStringUTF8(raw.Topic) ?? string.Empty,
raw.PasswordProtected != 0, raw.PasswordProtected != 0,
raw.MaxUsers)); raw.MaxUsers));
} }
@@ -54,7 +55,11 @@ internal static class Marshaling
raw.Id, raw.Id,
Marshal.PtrToStringUTF8(raw.Nickname) ?? string.Empty, Marshal.PtrToStringUTF8(raw.Nickname) ?? string.Empty,
raw.IsGuest != 0, 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); NativeMethods.vc_free_user_list(ref native);
return result; return result;
@@ -87,4 +92,29 @@ internal static class Marshaling
native.ExpectedPacketLoss, native.ExpectedPacketLoss,
native.Dtx != 0, native.Dtx != 0,
native.Complexity); 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<AccountInfo> ToManaged(ref VcAccountListNative native)
{
var result = new List<AccountInfo>((int)native.Count);
int size = Marshal.SizeOf<VcAccountNative>();
for (nuint i = 0; i < native.Count; i++)
{
var raw = Marshal.PtrToStructure<VcAccountNative>(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;
}
} }

View File

@@ -7,14 +7,44 @@ public sealed record ChannelInfo(
uint Id, uint Id,
uint ParentId, uint ParentId,
string Name, string Name,
string Topic,
bool PasswordProtected, bool PasswordProtected,
uint MaxUsers); 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( public sealed record UserInfo(
uint Id, uint Id,
string Nickname, string Nickname,
bool IsGuest, 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( public sealed record StreamSummary(
uint StreamId, uint StreamId,

View File

@@ -131,4 +131,54 @@ internal static partial class NativeMethods
[LibraryImport(LibName)] [LibraryImport(LibName)]
internal static partial VcResult vc_get_server_identity_display(nint c, nint outBuf, internal static partial VcResult vc_get_server_identity_display(nint c, nint outBuf,
nuint bufCap, out nuint outLen); 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);
} }

View File

@@ -63,6 +63,31 @@ internal struct VcAudioConfigNative
public uint Complexity; 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)] [StructLayout(LayoutKind.Sequential)]
internal struct VcDeviceNative internal struct VcDeviceNative
{ {
@@ -84,6 +109,7 @@ internal struct VcChannelNative
public uint Id; public uint Id;
public uint ParentId; public uint ParentId;
public IntPtr Name; public IntPtr Name;
public IntPtr Topic;
public int PasswordProtected; public int PasswordProtected;
public uint MaxUsers; public uint MaxUsers;
} }
@@ -102,6 +128,10 @@ internal struct VcUserNative
public IntPtr Nickname; public IntPtr Nickname;
public int IsGuest; public int IsGuest;
public uint ChannelId; public uint ChannelId;
public int SelfMicMuted;
public int SelfDeafened;
public int ServerMuted;
public int ServerDeafened;
} }
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
@@ -125,3 +155,19 @@ internal struct VcStreamSummaryListNative
public IntPtr Items; public IntPtr Items;
public nuint Count; 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;
}

View File

@@ -215,6 +215,124 @@ public sealed class VoiceCatClient : IDisposable
return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null); 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<AccountInfo> 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 ───────────────────────────────────────────────────────────────────────────── // ── Text ─────────────────────────────────────────────────────────────────────────────
public VcResult SendText(VcTextScope scope, uint targetId, string utf8) => public VcResult SendText(VcTextScope scope, uint targetId, string utf8) =>
NativeMethods.vc_send_text(_handle.DangerousGetHandle(), scope, targetId, utf8); NativeMethods.vc_send_text(_handle.DangerousGetHandle(), scope, targetId, utf8);

View File

@@ -233,6 +233,19 @@ typedef struct vc_permissions {
int is_admin; /* bool */ int is_admin; /* bool */
} vc_permissions; } 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. */ /* M5: channel creation/edition descriptor. */
typedef struct vc_channel_info { typedef struct vc_channel_info {
uint32_t id; /* 0 = new channel for create */ uint32_t id; /* 0 = new channel for create */
@@ -267,6 +280,7 @@ typedef struct vc_channel {
uint32_t id; uint32_t id;
uint32_t parent_id; /* 0 = root */ uint32_t parent_id; /* 0 = root */
const char* name; const char* name;
const char* topic;
int password_protected; /* bool */ int password_protected; /* bool */
uint32_t max_users; /* 0 = unlimited */ uint32_t max_users; /* 0 = unlimited */
} vc_channel; } vc_channel;
@@ -281,6 +295,10 @@ typedef struct vc_user {
const char* nickname; const char* nickname;
int is_guest; /* bool */ int is_guest; /* bool */
uint32_t channel_id; uint32_t channel_id;
int self_mic_muted; /* bool */
int self_deafened; /* bool */
int server_muted; /* bool */
int server_deafened; /* bool */
} vc_user; } vc_user;
typedef struct vc_user_list { 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_delete_account(vc_client* c, const char* username);
VC_API vc_result vc_list_accounts(vc_client* c); 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). */ /* Pull the caller's own permissions (from the last AuthResult). */
VC_API vc_result vc_get_permissions(vc_client* c, vc_permissions* out); VC_API vc_result vc_get_permissions(vc_client* c, vc_permissions* out);

View File

@@ -425,6 +425,11 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
break; break;
} }
case voicecat::v1::Envelope::kListAccountsResult: { 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{}; vc_event ev{};
ev.type = VC_EVENT_ACCOUNT_LIST; ev.type = VC_EVENT_ACCOUNT_LIST;
emit(ev); emit(ev);
@@ -1303,11 +1308,14 @@ vc_result vc_client::list_channels(vc_channel_list* out) {
auto* items = new vc_channel[channels.size()]; auto* items = new vc_channel[channels.size()];
for (size_t i = 0; i < channels.size(); ++i) { for (size_t i = 0; i < channels.size(); ++i) {
const auto& ch = channels[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(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].id = ch.id;
items[i].parent_id = ch.parent_id; items[i].parent_id = ch.parent_id;
items[i].name = name; items[i].name = name;
items[i].topic = topic;
items[i].password_protected = ch.password_protected ? 1 : 0; items[i].password_protected = ch.password_protected ? 1 : 0;
items[i].max_users = ch.max_users; 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]; const auto& u = users[i];
auto* nick = new char[u.nickname.size() + 1]; auto* nick = new char[u.nickname.size() + 1];
std::memcpy(nick, u.nickname.c_str(), u.nickname.size() + 1); std::memcpy(nick, u.nickname.c_str(), u.nickname.size() + 1);
items[i].id = u.id; items[i].id = u.id;
items[i].nickname = nick; items[i].nickname = nick;
items[i].is_guest = u.is_guest ? 1 : 0; items[i].is_guest = u.is_guest ? 1 : 0;
items[i].channel_id = u.channel_id; 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->items = items;
out->count = users.size(); out->count = users.size();
@@ -1519,6 +1531,24 @@ vc_result vc_client::list_accounts() {
return VC_OK; 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) { vc_result vc_client::get_permissions(vc_permissions* out) {
if (!out) return VC_ERR_INVALID_ARG; if (!out) return VC_ERR_INVALID_ARG;
*out = own_permissions_; *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::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::delete_account(const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::list_accounts() { 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; } vc_result vc_client::get_permissions(vc_permissions*) { return VC_ERR_NOT_IMPLEMENTED; }
#endif // VOICECAT_HAS_NET #endif // VOICECAT_HAS_NET

View File

@@ -90,6 +90,7 @@ struct vc_client {
vc_result reset_password(const char* username, const char* new_password); vc_result reset_password(const char* username, const char* new_password);
vc_result delete_account(const char* username); vc_result delete_account(const char* username);
vc_result list_accounts(); vc_result list_accounts();
vc_result get_account_list(vc_account_list* out);
vc_result get_permissions(vc_permissions* out); vc_result get_permissions(vc_permissions* out);
vc_connection_state state() const { vc_connection_state state() const {
@@ -145,6 +146,11 @@ struct vc_client {
voicecat::session::SessionModel session_model_; voicecat::session::SessionModel session_model_;
mutable std::mutex session_model_mu_; 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<voicecat::v1::AccountEntry> last_account_list_;
mutable std::mutex account_list_mu_;
// ── M4: TOFU server-identity gate ─────────────────────────────────────────── // ── M4: TOFU server-identity gate ───────────────────────────────────────────
std::unique_ptr<voicecat::crypto::TofuStore> tofu_store_; // owns the pin file std::unique_ptr<voicecat::crypto::TofuStore> tofu_store_; // owns the pin file
std::mutex tofu_mu_; std::mutex tofu_mu_;

View File

@@ -58,6 +58,7 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap)
ch.id = pb.id(); ch.id = pb.id();
ch.parent_id = pb.parent_id(); ch.parent_id = pb.parent_id();
ch.name = pb.name(); ch.name = pb.name();
ch.topic = pb.topic();
ch.password_protected = pb.password_protected(); ch.password_protected = pb.password_protected();
ch.max_users = pb.max_users(); ch.max_users = pb.max_users();
channels_.push_back(std::move(ch)); channels_.push_back(std::move(ch));
@@ -70,6 +71,8 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap)
u.nickname = pb.nickname(); u.nickname = pb.nickname();
u.is_guest = pb.is_guest(); u.is_guest = pb.is_guest();
u.channel_id = pb.channel_id(); 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_muted = pb.server_muted();
u.server_deafened = pb.server_deafened(); u.server_deafened = pb.server_deafened();
u.streams = copy_streams(pb.streams()); 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.nickname = pb.nickname();
u.is_guest = pb.is_guest(); u.is_guest = pb.is_guest();
u.channel_id = pb.channel_id(); 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_muted = pb.server_muted();
u.server_deafened = pb.server_deafened(); u.server_deafened = pb.server_deafened();
u.streams = copy_streams(pb.streams()); 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.id = pb.id();
ch.parent_id = pb.parent_id(); ch.parent_id = pb.parent_id();
ch.name = pb.name(); ch.name = pb.name();
ch.topic = pb.topic();
ch.password_protected = pb.password_protected(); ch.password_protected = pb.password_protected();
ch.max_users = pb.max_users(); ch.max_users = pb.max_users();

View File

@@ -22,6 +22,7 @@ struct Channel {
uint32_t id{0}; uint32_t id{0};
uint32_t parent_id{0}; uint32_t parent_id{0};
std::string name; std::string name;
std::string topic;
bool password_protected{false}; bool password_protected{false};
uint32_t max_users{0}; uint32_t max_users{0};
}; };
@@ -51,6 +52,8 @@ struct User {
std::string nickname; std::string nickname;
bool is_guest{true}; bool is_guest{true};
uint32_t channel_id{0}; uint32_t channel_id{0};
bool self_mic_muted{false};
bool self_deafened{false};
bool server_muted{false}; bool server_muted{false};
bool server_deafened{false}; bool server_deafened{false};
std::vector<Stream> streams; std::vector<Stream> streams;

View File

@@ -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) { void vc_free_channel_list(vc_channel_list* list) {
if (list == nullptr || list->items == nullptr) return; 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; delete[] list->items;
list->items = nullptr; list->items = nullptr;
list->count = 0; list->count = 0;
@@ -268,6 +271,19 @@ vc_result vc_list_accounts(vc_client* c) {
return c->list_accounts(); 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) { vc_result vc_get_permissions(vc_client* c, vc_permissions* out) {
if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG;
return c->get_permissions(out); return c->get_permissions(out);

View File

@@ -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. **Exit:** non-technical user installs a client, saves a server, and joins.
### M5 — Moderation, polish, and beyond ### 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.) - DRED toggle, audio-quality polish. (AEC and VAD/PTT already shipped in M2.)
- **Then (post-v1, protocol already reserves space):** file transfer, E2EE option, - **Then (post-v1, protocol already reserves space):** file transfer, E2EE option,
CallKit/PushKit background voice, key-based identity, server-side text history, CallKit/PushKit background voice, key-based identity, server-side text history,