diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..343f94f --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,40 @@ +# Builds the Linux packages and the Windows installer when a version tag (v1.2.3) is pushed, +# and attaches them to a Gitea release of that tag. macOS builds need a Mac and are not made here. +name: release + +on: + push: + tags: ['v*'] + +jobs: + build: + runs-on: ubuntu-latest + # Has Wine, so the Windows installer builds on Linux + container: electronuserland/builder:wine + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm ci + - run: npm run check + - run: npm test + - run: npm run build + - run: npx electron-builder --linux --win --publish never + - name: Upload to the release + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -eu + release=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" || true) + if [ -z "$release" ]; then + release=$(curl -sf -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"mumh5 $TAG\"}" "$API/releases") + fi + id=$(printf '%s' "$release" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).id))") + for f in release/*.AppImage release/*.deb release/*.tar.gz release/*.exe; do + curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@$f" "$API/releases/$id/assets?name=$(basename "$f")" >/dev/null + echo "uploaded $(basename "$f")" + done diff --git a/CLAUDE.md b/CLAUDE.md index 7fe3aed..503fa0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ A reused test server keeps registrations and channels from earlier runs; tests m ## Architecture -- `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox). +- `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox). - `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here. - `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text. - `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store). diff --git a/README.md b/README.md index 534a86b..c3c7ae5 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ mumh5 keeps the foundation and replaces the experience. | | Mumble desktop | mumh5 | |---|---|---| -| Interface | Qt widgets, dense dialogs | Modern layout: server rail, channel tree, chat, side panels | +| Interface | Qt widgets, dense dialogs | Modern layout (server rail, channel tree, chat, side panels), or the classic and stacked layouts of the desktop client | | Several servers at the same time | One connection | Many; voice on one, text on all, unread badges | | Sharing files | Images only, pasted inline and heavily limited | Any allowed file through your own [f0ckm](#file-sharing-with-f0ckm) host, inline players for images, video and audio. Images still work without one | | YouTube links | Plain links | Click-to-play player (privacy mode), optional thumbnails | @@ -41,7 +41,7 @@ mumh5 keeps the foundation and replaces the experience. | Look | One Qt style (plus skins) | Five built-in color schemes, including Windows 95 | | Channel and permission editing | Dialogs, drag and drop | Same power: create, edit, link, drag and drop, rule and group editor | -**Where the desktop client is still ahead, today:** UDP voice transport (mumh5 sends voice through the encrypted TCP connection for now, which adds a little delay on bad networks), system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. These are on the roadmap below. +**Where the desktop client is still ahead, today:** system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. These are on the roadmap below. --- @@ -55,6 +55,7 @@ mumh5 keeps the foundation and replaces the experience. - Per-person volume (0 to 300%) and "mute for me" - Speaking indicators in the channel tree, member list and your own panel - On small windows, your voice channel appears as tiles above the chat, lighting up as people talk, with mute and deafen at hand +- Low-latency voice over encrypted UDP (Mumble's OCB2-AES128), with automatic fallback to the TCP connection and a TCP-only switch - Bitrate automatically limited to what the server allows - Right-click the mute or deafen button for quick device and volume options - Hear-yourself microphone test @@ -110,7 +111,18 @@ Dark, Light, Midnight (true black), Frost, and a faithful **Windows 95** theme w ## Getting started -There are no release builds yet. For now, build it yourself; it takes a few minutes. +Download a build from the [releases page](https://git.lat/kibi/mumh5/releases): + +| System | File | +|---|---| +| Windows | `mumh5--win-x64.exe` (installer; not signed yet, so Windows SmartScreen warns once) | +| Linux, any distribution | `mumh5--linux-x86_64.AppImage` (make it executable, then run it) | +| Debian, Ubuntu | `mumh5--linux-amd64.deb` | +| Linux, portable | `mumh5--linux-x64.tar.gz` | + +There is no macOS build yet; build it yourself on a Mac (below). + +### Running from source You need [Node.js](https://nodejs.org/) 22 or newer and git. @@ -131,7 +143,7 @@ npm run dist:win # NSIS installer npm run dist:mac # .dmg ``` -Installers are written to `release/`. Build each platform on that platform for the best results. +Installers are written to `release/`. The Windows installer also builds on Linux with Wine installed; the macOS one needs a Mac. Pushing a version tag (`git tag v0.1.0 && git push origin v0.1.0`) makes the Gitea workflow in `.gitea/workflows/release.yml` build the Linux and Windows files and attach them to a release. --- @@ -164,7 +176,6 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim ## Roadmap -- UDP voice with Mumble's OCB2-AES128 encryption, for the lowest latency - System-wide push to talk - Whisper and shout - Rich chat between mumh5 users: replies, reactions, edits, typing indicators diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000..fa5ef81 Binary files /dev/null and b/build/icon.png differ diff --git a/build/icon.svg b/build/icon.svg new file mode 100644 index 0000000..cb3707d --- /dev/null +++ b/build/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/electron/main.ts b/electron/main.ts index 363698b..f3aba65 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -8,6 +8,7 @@ import * as tray from './tray.ts'; import { fetchLinkPreview } from './link-preview.ts'; import { fetchPublicListWith, pingServer } from './publist.ts'; import { openTls } from './tls-transport.ts'; +import { udpChannel } from './udp-voice.ts'; const devUrl = process.env.VITE_DEV_SERVER_URL; @@ -16,7 +17,7 @@ const identities = () => (identityStore ??= new IdentityStore(app.getPath('userD // ─── Mumble TLS connections, one per renderer request ───────────────────────── -type Conn = ReturnType & { owner: WebContents }; +type Conn = ReturnType & { owner: WebContents; udp?: ReturnType }; const conns = new Map(); // ─── Identities (client certificates) ───────────────────────────────────────── @@ -54,9 +55,19 @@ ipcMain.handle('mumble:open', async (e, connId: string, host: string, port: numb const owner = e.sender; const emit = (channel: string, ...args: unknown[]) => { if (!owner.isDestroyed()) owner.send(channel, connId, ...args); }; const conn = openTls(String(host), Number(port) || 64738, id.certPem, id.keyPem, { - onSecure: info => emit('mumble:secure', info), + onSecure: info => { + // Encrypted UDP voice to the address the TLS connection actually reached + const udp = udpChannel(info.address, info.port); + udp.onVoice = p => emit('mumble:udpVoice', p); + udp.onState = (ok, rtt) => emit('mumble:udpState', ok, rtt); + udp.onResync = () => emit('mumble:udpResync'); + const c = conns.get(connId); + if (c) c.udp = udp; + else udp.close(); + emit('mumble:secure', info); + }, onData: chunk => emit('mumble:data', chunk), - onClose: reason => { conns.delete(connId); emit('mumble:close', reason); } + onClose: reason => { conns.get(connId)?.udp?.close(); conns.delete(connId); emit('mumble:close', reason); } }); conns.set(connId, Object.assign(conn, { owner })); owner.once('destroyed', () => conn.close()); @@ -67,6 +78,17 @@ ipcMain.on('mumble:send', (e, connId: string, bytes: Uint8Array) => { if (conn && conn.owner === e.sender) conn.send(bytes); }); +// UDP voice: keys from the server's CryptSetup, voice packets, nonce resync +const ownUdp = (e: Electron.IpcMainEvent | Electron.IpcMainInvokeEvent, connId: string) => { + const conn = conns.get(connId); + return conn && conn.owner === e.sender ? conn.udp : undefined; +}; +ipcMain.on('mumble:udpSetup', (e, connId: string, key: Uint8Array, cn: Uint8Array, sn: Uint8Array, protobuf: boolean) => + ownUdp(e, connId)?.setup(new Uint8Array(key), new Uint8Array(cn), new Uint8Array(sn), !!protobuf)); +ipcMain.on('mumble:udpNonce', (e, connId: string, sn: Uint8Array) => ownUdp(e, connId)?.setServerNonce(new Uint8Array(sn))); +ipcMain.on('mumble:udpSend', (e, connId: string, bytes: Uint8Array) => ownUdp(e, connId)?.send(new Uint8Array(bytes))); +ipcMain.handle('mumble:udpClientNonce', (e, connId: string) => ownUdp(e, connId)?.clientNonce() ?? null); + ipcMain.on('mumble:close', (e, connId: string) => { const conn = conns.get(connId); if (conn && conn.owner === e.sender) conn.close(); @@ -114,6 +136,8 @@ function createWindow() { minHeight: 480, backgroundColor: '#111214', title: 'mumh5', + // Window and taskbar icon on Linux (Windows and macOS take it from the package) + icon: path.join(__dirname, '../dist/icon.png'), autoHideMenuBar: true, webPreferences: { preload: path.join(__dirname, 'preload.cjs'), diff --git a/electron/ocb2.ts b/electron/ocb2.ts new file mode 100644 index 0000000..cdaf365 --- /dev/null +++ b/electron/ocb2.ts @@ -0,0 +1,199 @@ +import { createCipheriv, createDecipheriv } from 'node:crypto'; + +// Mumble's UDP encryption: OCB2-AES128 with 4-byte packet headers (IV byte + 3 tag bytes), +// ported from Mumble's CryptStateOCB2.cpp including its counter-measures against the +// XEX* attack (https://eprint.iacr.org/2019/311, section 9). + +const BLOCK = 16; + +function aes(key: Buffer, block: Buffer): Buffer { + const c = createCipheriv('aes-128-ecb', key, null); + c.setAutoPadding(false); + return c.update(block); +} + +function aesDecrypt(key: Buffer, block: Buffer): Buffer { + const d = createDecipheriv('aes-128-ecb', key, null); + d.setAutoPadding(false); + return d.update(block); +} + +function xor(a: Buffer, b: Buffer): Buffer { + const out = Buffer.alloc(BLOCK); + for (let i = 0; i < BLOCK; i++) out[i] = a[i] ^ b[i]; + return out; +} + +// Multiply by x in GF(2^128), big endian +function times2(b: Buffer): Buffer { + const out = Buffer.alloc(BLOCK); + const carry = b[0] >> 7; + for (let i = 0; i < BLOCK - 1; i++) out[i] = ((b[i] << 1) | (b[i + 1] >> 7)) & 0xff; + out[BLOCK - 1] = ((b[BLOCK - 1] << 1) ^ (carry * 0x87)) & 0xff; + return out; +} + +const times3 = (b: Buffer) => xor(b, times2(b)); + +// Returns [ciphertext, tag, ok]; ok is false only when an attack pattern was seen and +// modifyOnAttack is off (used by tests) +export function ocbEncrypt(key: Buffer, plain: Buffer, nonce: Buffer, modifyOnAttack = true): [Buffer, Buffer, boolean] { + let delta: Buffer = aes(key, nonce); + let checksum: Buffer = Buffer.alloc(BLOCK); + const out = Buffer.alloc(plain.length); + let ok = true; + let off = 0; + let len = plain.length; + while (len > BLOCK) { + const block = plain.subarray(off, off + BLOCK); + let flip = false; + if (len - BLOCK <= BLOCK) { + let sum = 0; + for (let i = 0; i < BLOCK - 1; i++) sum |= block[i]; + if (sum === 0) { + if (modifyOnAttack) flip = true; + else ok = false; + } + } + delta = times2(delta); + const tmp = xor(delta, block); + if (flip) tmp[0] ^= 1; + xor(delta, aes(key, tmp)).copy(out, off); + checksum = xor(checksum, block); + if (flip) checksum[0] ^= 1; + len -= BLOCK; + off += BLOCK; + } + delta = times2(delta); + const lenBlock = Buffer.alloc(BLOCK); + lenBlock[BLOCK - 1] = (len * 8) & 0xff; + const pad = aes(key, xor(lenBlock, delta)); + const tmp = Buffer.from(pad); + plain.copy(tmp, 0, off, off + len); + checksum = xor(checksum, tmp); + xor(pad, tmp).copy(out, off, 0, len); + delta = times3(delta); + const tag = aes(key, xor(delta, checksum)); + return [out, tag, ok]; +} + +export function ocbDecrypt(key: Buffer, encrypted: Buffer, nonce: Buffer): [Buffer, Buffer, boolean] { + let delta: Buffer = aes(key, nonce); + let checksum: Buffer = Buffer.alloc(BLOCK); + const out = Buffer.alloc(encrypted.length); + let off = 0; + let len = encrypted.length; + while (len > BLOCK) { + delta = times2(delta); + const plainBlock = xor(delta, aesDecrypt(key, xor(delta, encrypted.subarray(off, off + BLOCK)))); + plainBlock.copy(out, off); + checksum = xor(checksum, plainBlock); + len -= BLOCK; + off += BLOCK; + } + delta = times2(delta); + const lenBlock = Buffer.alloc(BLOCK); + lenBlock[BLOCK - 1] = (len * 8) & 0xff; + const pad = aes(key, xor(lenBlock, delta)); + const tmp = Buffer.alloc(BLOCK); + encrypted.copy(tmp, 0, off, off + len); + const last = xor(tmp, pad); + checksum = xor(checksum, last); + last.copy(out, off, 0, len); + // Attack check: the decrypted last block must not equal delta (all but the length byte) + const ok = !last.subarray(0, BLOCK - 1).equals(delta.subarray(0, BLOCK - 1)); + delta = times3(delta); + const tag = aes(key, xor(delta, checksum)); + return [out, tag, ok]; +} + +export class CryptState { + private key = Buffer.alloc(BLOCK); + encryptIv = Buffer.alloc(BLOCK); + decryptIv = Buffer.alloc(BLOCK); + private history = new Uint8Array(256); + valid = false; + good = 0; + late = 0; + lost = 0; + resync = 0; + lastGood = 0; + + setKey(key: Uint8Array, clientNonce: Uint8Array, serverNonce: Uint8Array): void { + this.key = Buffer.from(key); + this.encryptIv = Buffer.from(clientNonce); + this.decryptIv = Buffer.from(serverNonce); + this.history.fill(0); + this.valid = this.key.length === BLOCK && this.encryptIv.length === BLOCK && this.decryptIv.length === BLOCK; + } + + setDecryptIv(iv: Uint8Array): void { + this.decryptIv = Buffer.from(iv); + this.resync++; + } + + encrypt(plain: Uint8Array): Buffer { + for (let i = 0; i < BLOCK; i++) { + this.encryptIv[i] = (this.encryptIv[i] + 1) & 0xff; + if (this.encryptIv[i]) break; + } + const [ct, tag] = ocbEncrypt(this.key, Buffer.from(plain), this.encryptIv); + const out = Buffer.alloc(ct.length + 4); + out[0] = this.encryptIv[0]; + tag.copy(out, 1, 0, 3); + ct.copy(out, 4); + return out; + } + + // Mirrors CryptStateOCB2::decrypt: accepts late and out-of-order packets, rejects replays + decrypt(packet: Uint8Array): Buffer | null { + if (!this.valid || packet.length < 4) return null; + const src = Buffer.from(packet); + const save = Buffer.from(this.decryptIv); + const ivbyte = src[0]; + const iv = this.decryptIv; + let restore = false; + let late = 0, lost = 0; + + if (((iv[0] + 1) & 0xff) === ivbyte) { + if (ivbyte > iv[0]) iv[0] = ivbyte; + else if (ivbyte < iv[0]) { + iv[0] = ivbyte; + for (let i = 1; i < BLOCK; i++) if ((iv[i] = (iv[i] + 1) & 0xff)) break; + } else return null; + } else { + let diff = ivbyte - iv[0]; + if (diff > 128) diff -= 256; + else if (diff < -128) diff += 256; + if (ivbyte < iv[0] && diff > -30 && diff < 0) { + late = 1; lost = -1; iv[0] = ivbyte; restore = true; + } else if (ivbyte > iv[0] && diff > -30 && diff < 0) { + late = 1; lost = -1; iv[0] = ivbyte; + for (let i = 1; i < BLOCK; i++) { const was = iv[i]; iv[i] = (was - 1) & 0xff; if (was) break; } + restore = true; + } else if (ivbyte > iv[0] && diff > 0) { + lost = ivbyte - iv[0] - 1; iv[0] = ivbyte; + } else if (ivbyte < iv[0] && diff > 0) { + lost = 256 - iv[0] + ivbyte - 1; iv[0] = ivbyte; + for (let i = 1; i < BLOCK; i++) if ((iv[i] = (iv[i] + 1) & 0xff)) break; + } else return null; + if (this.history[iv[0]] === iv[1]) { + save.copy(this.decryptIv); + return null; + } + } + + const [plain, tag, ok] = ocbDecrypt(this.key, src.subarray(4), iv); + if (!ok || !tag.subarray(0, 3).equals(src.subarray(1, 4))) { + save.copy(this.decryptIv); + return null; + } + this.history[iv[0]] = iv[1]; + if (restore) save.copy(this.decryptIv); + this.good++; + this.late = Math.max(0, this.late + late); + this.lost = Math.max(0, this.lost + lost); + this.lastGood = Date.now(); + return plain; + } +} diff --git a/electron/preload.ts b/electron/preload.ts index 831df59..704d66c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,7 +1,10 @@ import { contextBridge, ipcRenderer } from 'electron'; type Listener = (connId: string, ...args: any[]) => void; -const listeners = { secure: new Set(), data: new Set(), close: new Set() }; +const listeners = { + secure: new Set(), data: new Set(), close: new Set(), + udpVoice: new Set(), udpState: new Set(), udpResync: new Set() +}; for (const key of Object.keys(listeners) as (keyof typeof listeners)[]) { ipcRenderer.on(`mumble:${key}`, (_e, connId: string, ...args: any[]) => { for (const fn of listeners[key]) fn(connId, ...args); @@ -34,6 +37,10 @@ contextBridge.exposeInMainWorld('mumh5Native', { open: (connId: string, host: string, port: number, identityId?: string) => ipcRenderer.invoke('mumble:open', connId, host, port, identityId), send: (connId: string, bytes: Uint8Array) => ipcRenderer.send('mumble:send', connId, bytes), close: (connId: string) => ipcRenderer.send('mumble:close', connId), + udpSetup: (connId: string, key: Uint8Array, cn: Uint8Array, sn: Uint8Array, protobuf: boolean) => ipcRenderer.send('mumble:udpSetup', connId, key, cn, sn, protobuf), + udpNonce: (connId: string, sn: Uint8Array) => ipcRenderer.send('mumble:udpNonce', connId, sn), + udpSend: (connId: string, bytes: Uint8Array) => ipcRenderer.send('mumble:udpSend', connId, bytes), + udpClientNonce: (connId: string) => ipcRenderer.invoke('mumble:udpClientNonce', connId), on: (event: keyof typeof listeners, fn: Listener) => { listeners[event].add(fn); return () => listeners[event].delete(fn); diff --git a/electron/tls-transport.ts b/electron/tls-transport.ts index e9eaf9e..d1c93ac 100644 --- a/electron/tls-transport.ts +++ b/electron/tls-transport.ts @@ -2,7 +2,7 @@ import tls from 'node:tls'; import { describeCert, type CertDetails } from './certs.ts'; export interface TlsHandlers { - onSecure(info: { fingerprint: string; authorized: boolean; authError: string | null; chain: CertDetails[] }): void; + onSecure(info: { fingerprint: string; authorized: boolean; authError: string | null; chain: CertDetails[]; address: string; port: number }): void; onData(chunk: Uint8Array): void; onClose(reason: string): void; } @@ -35,6 +35,9 @@ export function openTls(host: string, port: number, cert: string, key: string, h } h.onSecure({ chain, + // The server's actual IP, so UDP voice goes to the same machine as the TCP connection + address: socket.remoteAddress ?? host, + port: socket.remotePort ?? port, fingerprint: chain[0]?.fingerprint256 ?? '', authorized: socket.authorized, authError: socket.authorizationError ? String(socket.authorizationError) : null diff --git a/electron/udp-voice.ts b/electron/udp-voice.ts new file mode 100644 index 0000000..3e609fb --- /dev/null +++ b/electron/udp-voice.ts @@ -0,0 +1,145 @@ +import dgram from 'node:dgram'; +import net from 'node:net'; +import { CryptState } from './ocb2.ts'; +import { writeVarint, readVarint } from '../src/core/voice-packet.ts'; +import { MumbleUDP } from '../src/core/mumble-udp-pb.js'; +import type { UdpChannel } from '../src/core/transport.ts'; + +// Encrypted UDP voice channel to a Mumble server. Voice uses UDP only while the server +// answers our UDP pings; until then (and if it stops) the client keeps using the TCP tunnel. + +export interface UdpCallbacks { + onVoice(plain: Uint8Array): void; + onState(ok: boolean, rtt: number): void; + onResync(): void; // too many decrypt failures: ask the server for a fresh nonce +} + +const PING_MS = 2000; +const DEAD_MS = 8000; + +export class UdpVoice { + readonly crypt = new CryptState(); + ok = false; + rtt = 0; + private sock: dgram.Socket; + private protobuf = true; + private pingTimer: ReturnType | null = null; + private lastPong = 0; + private failures = 0; + private lastResync = 0; + private closed = false; + private host: string; + private port: number; + private cb: UdpCallbacks; + + constructor(host: string, port: number, cb: UdpCallbacks) { + this.host = host; + this.port = port; + this.cb = cb; + this.sock = dgram.createSocket(net.isIPv6(host) ? 'udp6' : 'udp4'); + this.sock.on('message', msg => this.receive(msg)); + this.sock.on('error', () => this.setOk(false)); + } + + setup(key: Uint8Array, clientNonce: Uint8Array, serverNonce: Uint8Array, protobuf: boolean): void { + this.crypt.setKey(key, clientNonce, serverNonce); + this.protobuf = protobuf; + if (!this.pingTimer) { + this.ping(); + this.pingTimer = setInterval(() => this.ping(), PING_MS); + } + } + + setServerNonce(nonce: Uint8Array): void { + this.crypt.setDecryptIv(nonce); + this.failures = 0; + } + + send(plain: Uint8Array): void { + if (this.closed || !this.crypt.valid) return; + const packet = this.crypt.encrypt(plain); + this.sock.send(packet, this.port, this.host); + } + + private ping(): void { + if (this.lastPong && Date.now() - this.lastPong > DEAD_MS) this.setOk(false); + const ts = Date.now(); + if (this.protobuf) { + const body: Uint8Array = MumbleUDP.Ping.encode(MumbleUDP.Ping.fromObject({ timestamp: ts })).finish(); + const out = new Uint8Array(body.length + 1); + out[0] = 1; + out.set(body, 1); + this.send(out); + } else { + const out: number[] = [1 << 5]; + writeVarint(out, ts); + this.send(new Uint8Array(out)); + } + } + + private receive(msg: Buffer): void { + const plain = this.crypt.decrypt(msg); + if (!plain) { + // Repeated failures mean the nonces drifted apart; ask for a resync now and then + if (++this.failures > 8 && Date.now() - this.lastResync > 5000) { + this.lastResync = Date.now(); + this.failures = 0; + this.cb.onResync(); + } + return; + } + this.failures = 0; + const pingTs = this.pingTimestamp(plain); + if (pingTs != null) { + this.lastPong = Date.now(); + this.rtt = Math.max(0, Date.now() - pingTs); + this.setOk(true); + return; + } + this.cb.onVoice(new Uint8Array(plain)); + } + + private pingTimestamp(p: Buffer): number | null { + try { + if (p[0] === 1 && this.protobuf) return Number(MumbleUDP.Ping.toObject(MumbleUDP.Ping.decode(p.subarray(1)), { longs: Number }).timestamp); + if (p[0] >> 5 === 1 && !this.protobuf) return readVarint(p, 1)[0]; + } catch { /* not a ping */ } + return null; + } + + private setOk(ok: boolean): void { + if (ok === this.ok) { + if (ok) this.cb.onState(true, this.rtt); + return; + } + this.ok = ok; + this.cb.onState(ok, this.rtt); + } + + close(): void { + this.closed = true; + if (this.pingTimer) clearInterval(this.pingTimer); + try { this.sock.close(); } catch { /* closed */ } + } +} + +// UdpVoice behind the client's UdpChannel interface (used directly in Node, bridged over IPC in the app) +export function udpChannel(host: string, port: number): UdpChannel & { close(): void; voice: UdpVoice } { + const ch: UdpChannel & { close(): void; voice: UdpVoice } = { + onVoice: null, onState: null, onResync: null, + setup: (k, c, s, p) => voice.setup(k, c, s, p), + setServerNonce: n => voice.setServerNonce(n), + clientNonce: () => Promise.resolve(voice.crypt.valid ? new Uint8Array(voice.crypt.encryptIv) : null), + send: p => voice.send(p), + close: () => voice.close(), + voice: null as unknown as UdpVoice + }; + const voice = new UdpVoice(host, port, { + onVoice: p => ch.onVoice?.(p), + onState: (ok, rtt) => ch.onState?.(ok, rtt), + onResync: () => ch.onResync?.() + }); + ch.voice = voice; + return ch; +} + diff --git a/package.json b/package.json index 51e3272..890a4fd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mumh5", "productName": "mumh5", "version": "0.1.0", - "description": "Modern Mumble client with rich chat, file sharing and video", + "description": "A modern Mumble client: Discord-like interface, file sharing, voice and more, for any Mumble server", "license": "MIT", "type": "module", "main": "dist-electron/main.cjs", @@ -26,24 +26,37 @@ "files": [ "dist/**", "dist-electron/**", + "!dist-electron/*.map", "package.json", "LICENSE", "THIRD_PARTY_NOTICES.md" ], "directories": { - "output": "release" + "output": "release", + "buildResources": "build" }, "linux": { "target": [ "AppImage", - "deb" + "deb", + "tar.gz" ], - "category": "Network" + "category": "Network", + "executableName": "mumh5", + "synopsis": "Modern Mumble client", + "desktop": { + "entry": { + "StartupWMClass": "mumh5", + "Keywords": "mumble;voice;chat;voip;" + } + }, + "syncDesktopName": true }, "win": { "target": [ "nsis" - ] + ], + "icon": "build/icon.png" }, "mac": { "target": [ @@ -54,6 +67,22 @@ "NSMicrophoneUsageDescription": "mumh5 uses the microphone for voice chat.", "NSCameraUsageDescription": "mumh5 uses the camera for video chat." } + }, + "icon": "build/icon.png", + "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", + "deb": { + "depends": [ + "libnotify4", + "libxtst6", + "libnss3", + "libasound2 | libasound2t64" + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "perMachine": false, + "shortcutName": "mumh5" } }, "devDependencies": { @@ -81,9 +110,14 @@ "esbuild@0.28.2": true, "electron-winstaller": false }, - "author": "Kibi Kelburton", + "author": { + "name": "Kibi Kelburton", + "email": "git@f0ck.it" + }, "repository": { "type": "git", "url": "gitea@git.lat:kibi/mumh5.git" - } + }, + "homepage": "https://git.lat/kibi/mumh5", + "desktopName": "mumh5.desktop" } diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..5b7f54d Binary files /dev/null and b/public/icon.png differ diff --git a/src/App.svelte b/src/App.svelte index ebe0237..ac110f2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -14,6 +14,7 @@ import UserInfoDialog from './ui/UserInfoDialog.svelte'; import ChannelDialog from './ui/ChannelDialog.svelte'; import PublicServers from './ui/PublicServers.svelte'; + import ResizeHandle from './ui/ResizeHandle.svelte'; import { identities } from './lib/identities.svelte.ts'; import { session } from './lib/session.svelte.ts'; import { ui } from './lib/ui.svelte.ts'; @@ -23,6 +24,13 @@ // wide: rail + sidebar + chat + panel; medium: panel as an overlay; narrow: everything but chat in drawers const layout = $derived(width >= 1100 ? 'wide' : width >= 760 ? 'medium' : 'narrow'); + // Arrangement (Appearance setting): side is the default, classic puts the channel list right + // of the chat, stacked above it. Phones always use drawers. + const arrangement = $derived(layout === 'narrow' ? 'side' : store.settings.arrangement ?? 'side'); + // The member panel sits inline only in the side arrangement on wide windows, otherwise it overlays + const panelInline = $derived(layout === 'wide' && arrangement === 'side'); + let treeHeight = $state(Math.max(120, Number((() => { try { return localStorage.getItem('mumh5.treeHeight'); } catch { return null; } })()) || 280)); + let navOpen = $state(false); let editing = $state<{ server: SavedServer | null } | null>(null); @@ -35,12 +43,13 @@ else delete document.documentElement.dataset.theme; }); - // The panel starts open on wide layouts only + // The panel starts open only where it sits inline let lastLayout = ''; $effect(() => { - if (layout === lastLayout) return; - ui.panelOpen = layout === 'wide'; - lastLayout = layout; + const key = `${layout}/${arrangement}`; + if (key === lastLayout) return; + ui.panelOpen = panelInline; + lastLayout = key; }); // On phones one drawer at a time $effect(() => { if (layout === 'narrow' && ui.panelOpen) navOpen = false; }); @@ -53,26 +62,48 @@ const closeOverlays = () => { navOpen = false; - if (layout !== 'wide') ui.panelOpen = false; + if (!panelInline) ui.panelOpen = false; }; -
- {#if layout !== 'narrow' || navOpen} -