M5: Windows client moderation UI; add C ABI getters for account list, user mute/deafen, channel topic
This commit is contained in:
13
PROGRESS.md
13
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:
|
`--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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
200
clients/windows/VoiceCat.App/Forms/AccountsDialog.cs
Normal file
200
clients/windows/VoiceCat.App/Forms/AccountsDialog.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
94
clients/windows/VoiceCat.App/Forms/BanUserDialog.cs
Normal file
94
clients/windows/VoiceCat.App/Forms/BanUserDialog.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
384
clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs
Normal file
384
clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs
Normal 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 (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;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
clients/windows/VoiceCat.App/Forms/InputDialog.cs
Normal file
60
clients/windows/VoiceCat.App/Forms/InputDialog.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
79
clients/windows/VoiceCat.App/Forms/MoveUserDialog.cs
Normal file
79
clients/windows/VoiceCat.App/Forms/MoveUserDialog.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
clients/windows/VoiceCat.App/Forms/PermissionsDialog.cs
Normal file
110
clients/windows/VoiceCat.App/Forms/PermissionsDialog.cs
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1304,10 +1309,13 @@ vc_result vc_client::list_channels(vc_channel_list* out) {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -1328,6 +1336,10 @@ vc_result vc_client::list_users(vc_user_list* out) {
|
|||||||
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
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user