Add channel and permission management, sounds, themes and the public server list

- Channels: create, edit, delete, link and move (drag and drop for channels and
  people), right-click on free space in the channel list
- Permission editor with inherited rules, allow/deny per permission, groups and
  members; menus grey out actions the server does not allow
- Notification sounds for connection, channel, chat, mute and push-to-talk events;
  custom files and TeamSpeak 3 style sound packs (settings.ini), stored in IndexedDB
- Color schemes: Dark, Light, Midnight, Frost and Windows 95
- Public server list with live UDP ping, fetched through Chromium's network stack
- Kick, ban and connection-lost card with Reconnect
- Right-click menus for text fields, links, images and message authors
- Formatting toolbar in the message box, shared with the description editor
- Resizable channel list, menu headers, registration from the self menu only
- Fix: client-side pacing now mirrors Murmur's whole-second leaky bucket exactly,
  so quick actions are no longer dropped silently

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:54:03 +02:00
co-authored by Claude Opus 5.5
parent aa679dddcc
commit 9a3aeb29ad
53 changed files with 2684 additions and 170 deletions
+73 -2
View File
@@ -12,7 +12,7 @@ import { encodeVoice, decodeVoice } from '../src/core/voice-packet.ts';
const target = process.env.MUMBLE_TEST_HOST;
const codec = createCodec();
function connect(name: string): Promise<MumbleClient> {
function connect(name: string, password = ''): Promise<MumbleClient> {
const [host, port] = target!.split(':');
const id = generateIdentity(name);
const client = new MumbleClient(codec);
@@ -22,7 +22,7 @@ function connect(name: string): Promise<MumbleClient> {
onData: c => transport.onData?.(c),
onClose: r => transport.onClose?.(r)
});
client.connect(transport, { username: name, os: 'test' });
client.connect(transport, { username: name, password, os: 'test' });
return new Promise((resolve, reject) => {
client.on('synced', () => resolve(client));
client.on('close', reject);
@@ -161,3 +161,74 @@ test('irregular bursts match the server bucket (whole-second leaks)', { skip: !t
}
});
const superPw = process.env.MUMBLE_SUPERUSER_PASSWORD;
test('channel management and ACLs as SuperUser', { skip: !target || !superPw }, async () => {
const admin = await connect('SuperUser', superPw);
const guest = await connect('guest' + Date.now() % 1000);
try {
const name = 'acltest' + Date.now() % 100000;
const byName = (n: string) => [...admin.channels.values()].find(c => c.name === n);
const channelEvent = (pred: () => boolean) => until<void>(res => {
if (pred()) return res();
const off = admin.on('channel', () => { if (pred()) { off(); res(); } });
});
// Create, rename, describe, move
admin.createChannel(0, name);
await channelEvent(() => !!byName(name));
const id = byName(name)!.id;
admin.updateChannel(id, { name: name + 'x', description: 'about <b>this</b>', position: 3 });
await channelEvent(() => admin.channels.get(id)?.name === name + 'x');
assert.equal(admin.channels.get(id)!.description, 'about <b>this</b>');
admin.createChannel(0, name + 'sub');
await channelEvent(() => !!byName(name + 'sub'));
const sub = byName(name + 'sub')!.id;
admin.updateChannel(sub, { parent: id });
await channelEvent(() => admin.channels.get(sub)?.parent === id);
// Link and unlink
admin.setLinks(id, [0], []);
await channelEvent(() => !!admin.channels.get(id)?.links.has(0));
admin.setLinks(id, [], [0]);
await channelEvent(() => !admin.channels.get(id)?.links.has(0));
// ACL: deny Enter for everyone in the channel, and a group with a registered member
const acl = until<any>(res => admin.on('acl', a => { if (a.channelId === id) res(a); }));
admin.queryAcl(id);
const current = await acl;
assert.ok(current.acls.some((a: any) => a.inherited), 'inherited rules from root are listed');
current.acls.push({ applyHere: true, applySubs: true, inherited: false, userId: null, group: 'all', grant: 0, deny: 0x4 });
current.groups.push({ name: 'mods', inherited: false, inherit: true, inheritable: true, add: [0], remove: [], inheritedMembers: [] });
admin.saveAcl(current);
const saved = until<any>(res => admin.on('acl', a => { if (a.channelId === id) res(a); }));
setTimeout(() => admin.queryAcl(id), 300);
const after = await saved;
assert.ok(after.acls.some((a: any) => !a.inherited && a.group === 'all' && a.deny === 0x4), 'rule saved');
assert.deepEqual(after.groups.find((g: any) => g.name === 'mods')?.add, [0], 'group member saved');
// The rule is enforced: a guest may not enter
const denied = until<number>(res => guest.on('permissionDenied', d => res(d.permission)));
guest.joinChannel(id);
assert.equal(await denied, 0x4);
// Permissions: a guest may traverse the channel but not enter it; SuperUser gets no
// permission messages at all and is treated as allowed everything
const perms = until<number>(res => guest.on('permissions', (c, bits) => { if (c === id) res(bits); }));
guest.requestPermissions(id);
const bits = await perms;
assert.equal(bits & 0x4, 0, 'guest cannot enter');
assert.ok(bits & 0x2, 'guest can traverse');
assert.equal(guest.can(id, 0x4), false);
assert.equal(admin.can(id, 0x1), true);
// Remove (subchannel goes with it)
admin.removeChannel(id);
await until<void>(res => admin.on('channelRemove', c => { if (c === id) res(); }));
assert.ok(!admin.channels.has(sub));
} finally {
admin.disconnect();
guest.disconnect();
}
});