diff --git a/Client/tauri-client/src-tauri/capabilities/default.json b/Client/tauri-client/src-tauri/capabilities/default.json index 9f7831bf..d51d6c58 100644 --- a/Client/tauri-client/src-tauri/capabilities/default.json +++ b/Client/tauri-client/src-tauri/capabilities/default.json @@ -16,8 +16,10 @@ "core:window:allow-set-size", "core:window:allow-maximize", "core:window:allow-is-maximized", + "core:window:allow-is-minimized", "core:window:allow-outer-position", "core:window:allow-outer-size", + "core:window:allow-available-monitors", "store:default", "global-shortcut:default", "global-shortcut:allow-register", diff --git a/Client/tauri-client/src-tauri/src/main.rs b/Client/tauri-client/src-tauri/src/main.rs index a427a3ef..c2e04c93 100644 --- a/Client/tauri-client/src-tauri/src/main.rs +++ b/Client/tauri-client/src-tauri/src/main.rs @@ -2,5 +2,16 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // WebKitGTK's DMABUF renderer is known to crash or produce a blank window + // under Wayland (notably on NVIDIA). Disable it on Wayland sessions unless + // the user has already set the variable themselves (any value wins). + #[cfg(target_os = "linux")] + { + let is_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some() + || std::env::var("XDG_SESSION_TYPE").is_ok_and(|v| v.eq_ignore_ascii_case("wayland")); + if is_wayland && std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } + } owncord_client_lib::run() } diff --git a/Client/tauri-client/src-tauri/src/ptt.rs b/Client/tauri-client/src-tauri/src/ptt.rs index 44283dfd..e0f8ba9d 100644 --- a/Client/tauri-client/src-tauri/src/ptt.rs +++ b/Client/tauri-client/src-tauri/src/ptt.rs @@ -82,13 +82,24 @@ fn is_key_down(vk: i32) -> bool { use device_query::{DeviceQuery, DeviceState}; // Cache DeviceState per thread — creating it on every call would open/close // /dev/input/ file descriptors every 20ms in the polling loop. + // checked_new() returns None when no X11 display is reachable (e.g. a + // pure-Wayland session without XWayland), so PTT degrades to "key never + // pressed" instead of panicking on every poll. thread_local! { - static DEVICE_STATE: DeviceState = DeviceState::new(); + static DEVICE_STATE: Option = { + let ds = DeviceState::checked_new(); + if ds.is_none() { + log::warn!( + "PTT unavailable: no X11/XWayland display for global key state" + ); + } + ds + }; } let Some(keycode) = linux::vk_to_keycode(vk) else { return false; }; - DEVICE_STATE.with(|ds| ds.get_keys().contains(&keycode)) + DEVICE_STATE.with(|ds| ds.as_ref().is_some_and(|ds| ds.get_keys().contains(&keycode))) } #[cfg(not(any(windows, target_os = "linux")))] @@ -400,7 +411,10 @@ pub async fn ptt_listen_for_key() -> i32 { #[cfg(target_os = "linux")] { use device_query::{DeviceQuery, DeviceState}; - let device_state = DeviceState::new(); + let Some(device_state) = DeviceState::checked_new() else { + log::warn!("PTT key capture unavailable: no X11/XWayland display"); + return 0; + }; let deadline = std::time::Instant::now() + Duration::from_secs(10); while std::time::Instant::now() < deadline { diff --git a/Client/tauri-client/src/components/VideoGrid.ts b/Client/tauri-client/src/components/VideoGrid.ts index 23500363..19cf4391 100644 --- a/Client/tauri-client/src/components/VideoGrid.ts +++ b/Client/tauri-client/src/components/VideoGrid.ts @@ -6,6 +6,8 @@ import { createElement, appendChildren } from "@lib/dom"; import { createIcon } from "@lib/icons"; import { + getScreenshareAudioMuted, + getScreenshareAudioVolume, muteScreenshareAudio, setScreenshareAudioVolume, setUserVolume, @@ -304,8 +306,13 @@ export function createVideoGrid(): VideoGridComponent { // Add audio control overlay for remote tiles if (config !== undefined && !config.isSelf) { - let muted = false; - let currentVolume = 100; + // Screenshare audio state survives tile rebuilds — initialize from it. + // Screenshare sliders are 0-100 (HTMLAudioElement.volume caps at 1.0); + // mic sliders keep 0-200 (LiveKit setVolume supports boost up to 2.0). + let muted = config.isScreenshare ? getScreenshareAudioMuted(config.audioUserId) : false; + let currentVolume = config.isScreenshare + ? Math.round(getScreenshareAudioVolume(config.audioUserId) * 100) + : 100; const overlay = createElement("div", { class: "video-tile-overlay" }); @@ -313,8 +320,8 @@ export function createVideoGrid(): VideoGridComponent { const volumeSlider = createElement("input", { type: "range", min: "0", - max: "200", - value: "100", + max: config.isScreenshare ? "100" : "200", + value: String(currentVolume), class: "tile-volume-slider", "aria-label": "Volume", }); @@ -324,9 +331,10 @@ export function createVideoGrid(): VideoGridComponent { const wasMuted = muted; muted = currentVolume === 0; if (config.isScreenshare) { - // BUG-102: Set actual volume, not just mute toggle. + // BUG-102: Set actual volume, not just mute toggle. Slider 100 maps + // to element volume 1.0 (the attach-time default). muteScreenshareAudio(config.audioUserId, muted); - setScreenshareAudioVolume(config.audioUserId, currentVolume / 200); + setScreenshareAudioVolume(config.audioUserId, currentVolume / 100); } else { setUserVolume(config.audioUserId, currentVolume); } @@ -340,9 +348,10 @@ export function createVideoGrid(): VideoGridComponent { // Mute button const muteBtn = createElement("button", { class: "tile-mute-btn", - "aria-label": "Mute", + "aria-label": muted ? "Unmute" : "Mute", }); - muteBtn.appendChild(volumeIcon()); + muteBtn.appendChild(muted ? volumeXIcon() : volumeIcon()); + if (muted) overlay.classList.add("muted"); muteBtn.addEventListener("click", () => { muted = !muted; @@ -357,6 +366,7 @@ export function createVideoGrid(): VideoGridComponent { if (currentVolume === 0) currentVolume = 100; if (config.isScreenshare) { muteScreenshareAudio(config.audioUserId, false); + setScreenshareAudioVolume(config.audioUserId, currentVolume / 100); } else { setUserVolume(config.audioUserId, currentVolume); } diff --git a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts index d715f8de..72147633 100644 --- a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts +++ b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts @@ -269,6 +269,43 @@ function buildVoiceAudioTabInner( section.appendChild(qualityDesc); section.appendChild(qualitySelect); + // Screen share FPS selector + const fpsHeader = createElement("h3", {}, "Screen Share FPS"); + const fpsDesc = createElement( + "p", + { + style: "color:var(--text-muted);font-size:12px;margin:0 0 8px", + }, + "Higher frame rates use more bandwidth and depend on what the capture source and display can deliver. Takes effect the next time you start sharing.", + ); + const fpsSelect = createElement("select", { + class: "form-input", + style: "width:100%;margin-bottom:16px", + }); + const fpsOptions: Array<[number, string]> = [ + [30, "30 FPS (default)"], + [60, "60 FPS"], + [120, "120 FPS"], + ]; + const savedFpsRaw = loadPref("screenShareFps", 30); + const savedFps = savedFpsRaw === 60 || savedFpsRaw === 120 ? savedFpsRaw : 30; + for (const [value, label] of fpsOptions) { + const opt = createElement("option", { value: String(value) }, label); + if (value === savedFps) opt.setAttribute("selected", ""); + fpsSelect.appendChild(opt); + } + fpsSelect.value = String(savedFps); + fpsSelect.addEventListener( + "change", + () => { + savePref("screenShareFps", Number(fpsSelect.value)); + }, + { signal }, + ); + section.appendChild(fpsHeader); + section.appendChild(fpsDesc); + section.appendChild(fpsSelect); + // Video device selector const videoHeader = createElement("h3", {}, "Video Device"); const videoSelect = createElement("select", { diff --git a/Client/tauri-client/src/lib/audioElements.ts b/Client/tauri-client/src/lib/audioElements.ts index 7e2335bf..b78580b3 100644 --- a/Client/tauri-client/src/lib/audioElements.ts +++ b/Client/tauri-client/src/lib/audioElements.ts @@ -31,6 +31,9 @@ export class AudioElements { private screenshareAudioElements = new Map>(); /** Persisted mute state for screenshare audio so replacement tracks inherit UI state. */ private screenshareAudioMutedByUser = new Map(); + /** Per-user screenshare volume (0-1, default 1) — kept independent of the + * element map so a volume chosen before the track attaches still applies. */ + private screenshareVolumeByUser = new Map(); /** Master output volume multiplier (0-2.0). Per-user volumes are scaled by this. */ private outputVolumeMultiplier: number; @@ -54,8 +57,12 @@ export class AudioElements { return (userVol / 100) * this.outputVolumeMultiplier; } - private getScreenshareOutputVolume(): number { - return Math.max(0, Math.min(1, this.outputVolumeMultiplier)); + /** Effective element volume for a user's screenshare audio: per-user volume + * scaled by the master output multiplier, clamped to the 0-1 range that + * HTMLAudioElement.volume supports. */ + private getEffectiveScreenshareVolume(userId: number): number { + const userVol = this.screenshareVolumeByUser.get(userId) ?? 1; + return Math.max(0, Math.min(1, userVol * this.outputVolumeMultiplier)); } // --- Track subscription handlers --- @@ -80,7 +87,7 @@ export class AudioElements { const audioEl = track.attach(); audioEl.style.display = "none"; document.body.appendChild(audioEl); - audioEl.volume = this.getScreenshareOutputVolume(); + audioEl.volume = this.getEffectiveScreenshareVolume(userId); audioEl.muted = this.screenshareAudioMutedByUser.get(userId) ?? false; let audioEls = this.screenshareAudioElements.get(userId); if (audioEls === undefined) { @@ -187,10 +194,12 @@ export class AudioElements { savePref("outputVolume", clamped); this.outputVolumeMultiplier = clamped / 100; this.applyAllVolumes(); - const screenshareVolume = this.getScreenshareOutputVolume(); - for (const audioEls of this.screenshareAudioElements.values()) { + // Re-apply per-user screenshare volumes scaled by the new master value + // (BUG: previously overwrote them with just the master multiplier). + for (const [userId, audioEls] of this.screenshareAudioElements) { + const effective = this.getEffectiveScreenshareVolume(userId); for (const audioEl of audioEls) { - audioEl.volume = screenshareVolume; + audioEl.volume = effective; } } } @@ -198,10 +207,19 @@ export class AudioElements { // --- Screenshare audio --- setScreenshareAudioVolume(userId: number, volume: number): void { + const clamped = Math.max(0, Math.min(1, volume)); + // Always store, even before the audio track attaches — the stored value + // is applied in handleTrackSubscribedAudio when the element appears. + this.screenshareVolumeByUser.set(userId, clamped); const audioEls = this.screenshareAudioElements.get(userId); if (audioEls === undefined) return; - const clamped = Math.max(0, Math.min(1, volume)); - for (const el of audioEls) el.volume = clamped; + const effective = this.getEffectiveScreenshareVolume(userId); + for (const el of audioEls) el.volume = effective; + } + + /** Stored per-user screenshare volume (0-1, default 1) for slider init. */ + getScreenshareAudioVolume(userId: number): number { + return this.screenshareVolumeByUser.get(userId) ?? 1; } muteScreenshareAudio(userId: number, muted: boolean): void { @@ -242,9 +260,10 @@ export class AudioElements { this.screenshareAudioElements.clear(); } - /** Full cleanup including screenshare mute state — used on intentional leave. */ + /** Full cleanup including screenshare mute/volume state — used on intentional leave. */ cleanupAllAudioElementsFull(): void { this.cleanupAllAudioElements(); this.screenshareAudioMutedByUser.clear(); + this.screenshareVolumeByUser.clear(); } } diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index f8ab99d5..669ed9f9 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -32,8 +32,10 @@ import { type ScreenTrackState, CAMERA_PRESETS, CAMERA_PUBLISH_BITRATES, - SCREENSHARE_PUBLISH_BITRATES, getStreamQuality, + getScreenShareFps, + getEffectiveScreenShareFps, + getScreenShareMaxBitrate, enableCamera as doEnableCamera, disableCamera as doDisableCamera, stopManualCameraTrack, @@ -344,9 +346,11 @@ export class LiveKitSession { maxBitrate: CAMERA_PUBLISH_BITRATES[quality], maxFramerate: quality === "low" ? 15 : 30, }, + // Fallback for setScreenShareEnabled paths — the manual publish in + // screenShare.ts passes explicit per-track encoding that overrides this. screenShareEncoding: { - maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality], - maxFramerate: quality === "low" ? 5 : quality === "medium" ? 15 : 30, + maxBitrate: getScreenShareMaxBitrate(quality, getScreenShareFps()), + maxFramerate: getEffectiveScreenShareFps(quality, getScreenShareFps()), }, }, // End-to-end encryption: SFrame-based E2EE using a server-distributed @@ -1590,6 +1594,10 @@ export class LiveKitSession { this._audioElements.setScreenshareAudioVolume(userId, volume); } + getScreenshareAudioVolume(userId: number): number { + return this._audioElements.getScreenshareAudioVolume(userId); + } + muteScreenshareAudio(userId: number, muted: boolean): void { this._audioElements.muteScreenshareAudio(userId, muted); } @@ -1697,6 +1705,7 @@ export const getLocalScreenshareStream = session.getLocalScreenshareStream.bind( export const getRemoteVideoStream = session.getRemoteVideoStream.bind(session); export const getSessionDebugInfo = session.getSessionDebugInfo.bind(session); export const setScreenshareAudioVolume = session.setScreenshareAudioVolume.bind(session); +export const getScreenshareAudioVolume = session.getScreenshareAudioVolume.bind(session); export const muteScreenshareAudio = session.muteScreenshareAudio.bind(session); export const getScreenshareAudioMuted = session.getScreenshareAudioMuted.bind(session); diff --git a/Client/tauri-client/src/lib/screenShare.ts b/Client/tauri-client/src/lib/screenShare.ts index e0d1e18a..f1da456c 100644 --- a/Client/tauri-client/src/lib/screenShare.ts +++ b/Client/tauri-client/src/lib/screenShare.ts @@ -72,6 +72,67 @@ export function getStreamQuality(): StreamQuality { return "high"; } +// --------------------------------------------------------------------------- +// Screen share frame rate +// --------------------------------------------------------------------------- + +/** Saved screen share FPS preference. 30 is the default and preserves the + * historical per-quality caps (5/15/30); 60/120 are explicit overrides. */ +export function getScreenShareFps(): number { + const saved = loadPref("screenShareFps", 30); + return saved === 60 || saved === 120 ? saved : 30; +} + +/** Effective capture/publish frame rate for a quality + fps preference. */ +export function getEffectiveScreenShareFps(quality: StreamQuality, fps: number): number { + if (fps !== 60 && fps !== 120) { + return quality === "low" ? 5 : quality === "medium" ? 15 : 30; + } + return fps; +} + +/** High frame rates need proportionally more bitrate to stay sharp. */ +const FPS_BITRATE_MULTIPLIER: Readonly> = { 60: 1.5, 120: 2 }; + +export function getScreenShareMaxBitrate(quality: StreamQuality, fps: number): number { + const multiplier = FPS_BITRATE_MULTIPLIER[fps] ?? 1; + return Math.round(SCREENSHARE_PUBLISH_BITRATES[quality] * multiplier); +} + +/** Capture options for a quality with the fps preference applied. Presets with + * a fixed resolution get frameRate injected into the getDisplayMedia + * constraints. Always returns a copy: createLocalScreenTracks mutates the + * options object in place (it injects a default 1080p30 resolution when none + * is set), which would otherwise corrupt the shared presets. + * + * For "source" (no resolution cap) with an explicit 60/120 override, a + * zero-size resolution suppresses the library's 1080p30 default (the + * constraint translation treats 0 as uncapped) and the frame rate is passed + * through the raw video constraints instead. */ +export function getScreenShareCaptureOptions( + quality: StreamQuality, + fps: number, +): ScreenShareCaptureOptions { + const preset = SCREENSHARE_PRESETS[quality]; + const effectiveFps = getEffectiveScreenShareFps(quality, fps); + if (preset.resolution === undefined) { + if (fps === 60 || fps === 120) { + return { + ...preset, + resolution: { width: 0, height: 0, frameRate: effectiveFps }, + // Runtime passes this object verbatim to getDisplayMedia; the declared + // type is narrower than what the library actually accepts. + video: { frameRate: effectiveFps } as ScreenShareCaptureOptions["video"], + }; + } + return { ...preset }; + } + return { + ...preset, + resolution: { ...preset.resolution, frameRate: effectiveFps }, + }; +} + // --------------------------------------------------------------------------- // Dependencies injected by the caller (LiveKitSession) // --------------------------------------------------------------------------- @@ -203,9 +264,12 @@ export async function enableScreenshare( } setLocalScreenshare(true); const quality = getStreamQuality(); + const fps = getScreenShareFps(); + const effectiveFps = getEffectiveScreenShareFps(quality, fps); + const maxBitrate = getScreenShareMaxBitrate(quality, fps); try { stopManualScreenTracks(state, room); - const screenTracks = await createLocalScreenTracks(SCREENSHARE_PRESETS[quality]); + const screenTracks = await createLocalScreenTracks(getScreenShareCaptureOptions(quality, fps)); state.manualScreenTracks = screenTracks; for (const track of screenTracks) { const isVideo = track.kind === Track.Kind.Video; @@ -216,8 +280,8 @@ export async function enableScreenshare( ...(isVideo ? { videoEncoding: { - maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality], - maxFramerate: quality === "low" ? 5 : quality === "medium" ? 15 : 30, + maxBitrate, + maxFramerate: effectiveFps, }, } : {}), @@ -237,7 +301,7 @@ export async function enableScreenshare( } ws.send({ type: "voice_screenshare", payload: { enabled: true } }); deps.reapplyAudioPipeline(); - log.info("Screenshare enabled", { quality, maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality] }); + log.info("Screenshare enabled", { quality, fps: effectiveFps, maxBitrate }); } catch (err) { // BUG-100: Stop created tracks to release screen capture if publish failed. for (const t of state.manualScreenTracks) { diff --git a/Client/tauri-client/src/lib/window-state.ts b/Client/tauri-client/src/lib/window-state.ts index 98e2cf0e..d0573f5d 100644 --- a/Client/tauri-client/src/lib/window-state.ts +++ b/Client/tauri-client/src/lib/window-state.ts @@ -18,6 +18,34 @@ export interface WindowState { const STORAGE_KEY = "windowState"; const SAVE_DEBOUNCE_MS = 500; +/** Minimum horizontal overlap (physical px) required with some monitor. */ +const MIN_VISIBLE_WIDTH = 100; +/** Allow the title bar to sit slightly above a monitor's top edge. */ +const TITLEBAR_TOP_TOLERANCE = 8; +/** The title bar must be at least this far above a monitor's bottom edge. */ +const TITLEBAR_GRAB_MARGIN = 40; + +interface MonitorRect { + readonly position: { x: number; y: number }; + readonly size: { width: number; height: number }; +} + +/** + * Check whether a saved window rect is reachable on one of the given + * monitors: enough horizontal overlap to grab, and the title bar row within + * the monitor's vertical range. All values are physical pixels. + */ +export function isRectOnScreen(monitors: readonly MonitorRect[], rect: WindowState): boolean { + return monitors.some((m) => { + const overlapX = + Math.min(rect.x + rect.width, m.position.x + m.size.width) - Math.max(rect.x, m.position.x); + const titleBarReachable = + rect.y >= m.position.y - TITLEBAR_TOP_TOLERANCE && + rect.y <= m.position.y + m.size.height - TITLEBAR_GRAB_MARGIN; + return overlapX >= MIN_VISIBLE_WIDTH && titleBarReachable; + }); +} + const invokePromise: Promise< ((cmd: string, args?: Record) => Promise) | null > = import("@tauri-apps/api/core") @@ -56,7 +84,13 @@ async function loadState(): Promise { typeof s.y === "number" && typeof s.width === "number" && typeof s.height === "number" && - typeof s.maximized === "boolean" + typeof s.maximized === "boolean" && + Number.isFinite(s.x) && + Number.isFinite(s.y) && + Number.isFinite(s.width) && + Number.isFinite(s.height) && + s.width >= 1 && + s.height >= 1 ) { return { x: s.x, @@ -74,6 +108,27 @@ async function loadState(): Promise { } } +/** + * Check whether the saved rect is visible on a connected monitor. Fails open: + * if monitors cannot be queried, restore proceeds as before. + */ +async function isSavedRectVisible( + tauriWindow: typeof import("@tauri-apps/api/window"), + saved: WindowState, +): Promise { + let monitors: MonitorRect[]; + try { + monitors = await tauriWindow.availableMonitors(); + } catch (err) { + log.warn("Could not query monitors; restoring window state unchecked", { + error: String(err), + }); + return true; + } + if (monitors.length === 0) return true; + return isRectOnScreen(monitors, saved); +} + /** * Initialize window state persistence. * Restores saved position/size on startup and listens for changes. @@ -96,18 +151,28 @@ export async function initWindowState(): Promise<() => void> { try { if (saved.maximized) { await win.maximize(); - } else { + log.info("Restored window state (maximized)"); + } else if (await isSavedRectVisible(tauriWindow, saved)) { const pos = new tauriWindow.PhysicalPosition(saved.x, saved.y); const size = new tauriWindow.PhysicalSize(saved.width, saved.height); await win.setPosition(pos); await win.setSize(size); + log.info("Restored window state", { + x: saved.x, + y: saved.y, + width: saved.width, + height: saved.height, + }); + } else { + // Saved rect is not reachable on any connected monitor (e.g. a + // disconnected display) — keep the default centered placement. + log.warn("Saved window position is off-screen; using default placement", { + x: saved.x, + y: saved.y, + width: saved.width, + height: saved.height, + }); } - log.info("Restored window state", { - x: saved.x, - y: saved.y, - width: saved.width, - height: saved.height, - }); } catch (err) { log.warn("Failed to restore window state", { error: String(err) }); } @@ -123,6 +188,17 @@ export async function initWindowState(): Promise<() => void> { saveTimer = setTimeout(() => { void (async () => { try { + // A minimized window reports placeholder coordinates (-32000 on + // Windows) — skip so the last real geometry survives a minimized + // exit. Checked separately so platforms without isMinimized still + // save normally. + let minimized = false; + try { + minimized = await win.isMinimized(); + } catch { + // Treat as not minimized + } + if (minimized) return; const pos = await win.outerPosition(); const size = await win.outerSize(); const maximized = await win.isMaximized(); diff --git a/Client/tauri-client/src/styles/base.css b/Client/tauri-client/src/styles/base.css index a81f4eb7..18300c70 100644 --- a/Client/tauri-client/src/styles/base.css +++ b/Client/tauri-client/src/styles/base.css @@ -57,6 +57,18 @@ select { outline: none; } +/* WebView2/Edge injects a native password-reveal eye; the app ships its own + toggle (LoginForm), producing two icons. Hide the native controls. + Kept as standalone rules — an unknown pseudo-element in a shared selector + list would invalidate the other selectors. */ +input::-ms-reveal { + display: none; +} + +input::-ms-clear { + display: none; +} + a { color: var(--text-link); text-decoration: none; diff --git a/Client/tauri-client/tests/unit/audio-elements.test.ts b/Client/tauri-client/tests/unit/audio-elements.test.ts index dfdf4ced..1088ffcc 100644 --- a/Client/tauri-client/tests/unit/audio-elements.test.ts +++ b/Client/tauri-client/tests/unit/audio-elements.test.ts @@ -237,6 +237,43 @@ describe("AudioElements", () => { expect(audioEl.volume).toBe(1); }); + it("getScreenshareAudioVolume defaults to 1 and reflects stored value", () => { + expect(elements.getScreenshareAudioVolume(42)).toBe(1); + elements.setScreenshareAudioVolume(42, 0.4); + expect(elements.getScreenshareAudioVolume(42)).toBe(0.4); + }); + + it("volume chosen before the track attaches applies on attach", () => { + elements.setScreenshareAudioVolume(42, 0.25); + + const { track, audioEl } = createMockTrack("audio", "track-ss-early"); + const publication = { source: "screenShareAudio" }; + const participant = { identity: "user-42", setVolume: vi.fn() }; + elements.handleTrackSubscribedAudio(track as any, publication as any, participant as any); + + expect(audioEl.volume).toBe(0.25); + }); + + it("setOutputVolume preserves per-user screenshare volume", () => { + const audioEl = document.createElement("audio"); + (elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]); + + elements.setScreenshareAudioVolume(42, 0.5); + elements.setOutputVolume(50); + + expect(audioEl.volume).toBe(0.25); // 0.5 user * 0.5 master + }); + + it("setScreenshareAudioVolume scales by the master output multiplier", () => { + const audioEl = document.createElement("audio"); + (elements as any).screenshareAudioElements = new Map([[42, new Set([audioEl])]]); + + elements.setOutputVolume(50); + elements.setScreenshareAudioVolume(42, 0.8); + + expect(audioEl.volume).toBe(0.4); // 0.8 user * 0.5 master + }); + it("muteScreenshareAudio persists muted state", () => { elements.muteScreenshareAudio(42, true); expect(elements.getScreenshareAudioMuted(42)).toBe(true); diff --git a/Client/tauri-client/tests/unit/screen-share-fps.test.ts b/Client/tauri-client/tests/unit/screen-share-fps.test.ts new file mode 100644 index 00000000..d3958b92 --- /dev/null +++ b/Client/tauri-client/tests/unit/screen-share-fps.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockLoadPref } = vi.hoisted(() => ({ + mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal), +})); + +vi.mock("@components/settings/helpers", () => ({ + loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal), + savePref: vi.fn(), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("@stores/voice.store", () => ({ + setLocalCamera: vi.fn(), + setLocalScreenshare: vi.fn(), +})); + +import { + getScreenShareFps, + getEffectiveScreenShareFps, + getScreenShareMaxBitrate, + getScreenShareCaptureOptions, + SCREENSHARE_PRESETS, + SCREENSHARE_PUBLISH_BITRATES, + type StreamQuality, +} from "@lib/screenShare"; + +describe("screen share FPS", () => { + beforeEach(() => { + mockLoadPref.mockReset(); + mockLoadPref.mockImplementation((_key: string, defaultVal: unknown) => defaultVal); + }); + + describe("getScreenShareFps", () => { + it("defaults to 30", () => { + expect(getScreenShareFps()).toBe(30); + }); + + it("accepts 60 and 120", () => { + mockLoadPref.mockReturnValue(60); + expect(getScreenShareFps()).toBe(60); + mockLoadPref.mockReturnValue(120); + expect(getScreenShareFps()).toBe(120); + }); + + it("falls back to 30 on garbage values", () => { + for (const garbage of [45, 0, -1, "60", null, undefined, NaN]) { + mockLoadPref.mockReturnValue(garbage); + expect(getScreenShareFps()).toBe(30); + } + }); + }); + + describe("getEffectiveScreenShareFps", () => { + it("keeps historical per-quality caps at the default 30", () => { + expect(getEffectiveScreenShareFps("low", 30)).toBe(5); + expect(getEffectiveScreenShareFps("medium", 30)).toBe(15); + expect(getEffectiveScreenShareFps("high", 30)).toBe(30); + expect(getEffectiveScreenShareFps("source", 30)).toBe(30); + }); + + it("applies explicit 60/120 overrides to every quality", () => { + const qualities: StreamQuality[] = ["low", "medium", "high", "source"]; + for (const q of qualities) { + expect(getEffectiveScreenShareFps(q, 60)).toBe(60); + expect(getEffectiveScreenShareFps(q, 120)).toBe(120); + } + }); + }); + + describe("getScreenShareMaxBitrate", () => { + it("returns the base bitrate at 30 fps", () => { + expect(getScreenShareMaxBitrate("high", 30)).toBe(SCREENSHARE_PUBLISH_BITRATES.high); + }); + + it("scales bitrate up for 60 and 120 fps", () => { + expect(getScreenShareMaxBitrate("high", 60)).toBe(SCREENSHARE_PUBLISH_BITRATES.high * 1.5); + expect(getScreenShareMaxBitrate("source", 120)).toBe(SCREENSHARE_PUBLISH_BITRATES.source * 2); + }); + }); + + describe("getScreenShareCaptureOptions", () => { + it("injects frameRate into presets that have a resolution", () => { + const opts = getScreenShareCaptureOptions("high", 60); + expect(opts.resolution?.frameRate).toBe(60); + expect(opts.resolution?.width).toBe(SCREENSHARE_PRESETS.high.resolution?.width); + expect(opts.audio).toBe(true); + }); + + it("keeps the per-quality fps at the default setting", () => { + expect(getScreenShareCaptureOptions("low", 30).resolution?.frameRate).toBe(5); + expect(getScreenShareCaptureOptions("medium", 30).resolution?.frameRate).toBe(15); + }); + + it("returns a copy of the source preset at the default fps", () => { + const opts = getScreenShareCaptureOptions("source", 30); + expect(opts).not.toBe(SCREENSHARE_PRESETS.source); + expect(opts.resolution).toBeUndefined(); + expect(opts.audio).toBe(true); + }); + + it("uses a zero-size resolution sentinel for source with explicit fps", () => { + const opts = getScreenShareCaptureOptions("source", 120); + expect(opts).not.toBe(SCREENSHARE_PRESETS.source); + // Zero width/height = uncapped in livekit's constraint translation, and + // a defined resolution stops the library injecting its 1080p30 default. + expect(opts.resolution).toEqual({ width: 0, height: 0, frameRate: 120 }); + expect(opts.video).toEqual({ frameRate: 120 }); + }); + + it("does not mutate the shared presets", () => { + const before = SCREENSHARE_PRESETS.high.resolution?.frameRate; + getScreenShareCaptureOptions("high", 120); + expect(SCREENSHARE_PRESETS.high.resolution?.frameRate).toBe(before); + }); + }); +}); diff --git a/Client/tauri-client/tests/unit/settings-overlay.test.ts b/Client/tauri-client/tests/unit/settings-overlay.test.ts index d6d3f4ad..b0868785 100644 --- a/Client/tauri-client/tests/unit/settings-overlay.test.ts +++ b/Client/tauri-client/tests/unit/settings-overlay.test.ts @@ -256,8 +256,8 @@ describe("SettingsOverlay", () => { getTab(container, 5).click(); const selects = container.querySelectorAll("select.form-input"); - // input device, output device, video quality, video device = 4 - expect(selects.length).toBe(4); + // input device, output device, video quality, screen share fps, video device = 5 + expect(selects.length).toBe(5); const sliders = container.querySelectorAll(".settings-slider"); expect(sliders.length).toBeGreaterThanOrEqual(1); diff --git a/Client/tauri-client/tests/unit/video-grid.test.ts b/Client/tauri-client/tests/unit/video-grid.test.ts index bcc2c903..ccc80b7e 100644 --- a/Client/tauri-client/tests/unit/video-grid.test.ts +++ b/Client/tauri-client/tests/unit/video-grid.test.ts @@ -7,11 +7,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const mockMuteScreenshareAudio = vi.fn(); const mockSetScreenshareAudioVolume = vi.fn(); const mockSetUserVolume = vi.fn(); +const mockGetScreenshareAudioMuted = vi.fn((_userId?: unknown) => false); +const mockGetScreenshareAudioVolume = vi.fn((_userId?: unknown) => 1); vi.mock("@lib/livekitSession", () => ({ muteScreenshareAudio: (...args: unknown[]) => mockMuteScreenshareAudio(...args), setScreenshareAudioVolume: (...args: unknown[]) => mockSetScreenshareAudioVolume(...args), setUserVolume: (...args: unknown[]) => mockSetUserVolume(...args), + getScreenshareAudioMuted: (userId: unknown) => mockGetScreenshareAudioMuted(userId), + getScreenshareAudioVolume: (userId: unknown) => mockGetScreenshareAudioVolume(userId), })); // --------------------------------------------------------------------------- @@ -487,6 +491,45 @@ describe("VideoGrid", () => { expect(mockMuteScreenshareAudio).toHaveBeenCalledWith(88, false); }); + it("screenshare slider uses 0-100 range where 100 maps to volume 1.0", () => { + const config = makeTileConfig({ isSelf: false, audioUserId: 88, isScreenshare: true }); + grid.addStream(88, "screen", fakeStream(), config); + + const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement; + expect(slider.max).toBe("100"); + expect(slider.value).toBe("100"); // stored default 1.0 → 100 + + slider.value = "50"; + slider.dispatchEvent(new Event("input")); + expect(mockSetScreenshareAudioVolume).toHaveBeenCalledWith(88, 0.5); + + slider.value = "100"; + slider.dispatchEvent(new Event("input")); + expect(mockSetScreenshareAudioVolume).toHaveBeenCalledWith(88, 1); + }); + + it("screenshare slider initializes from stored volume and mute state", () => { + mockGetScreenshareAudioVolume.mockReturnValueOnce(0.3); + mockGetScreenshareAudioMuted.mockReturnValueOnce(true); + const config = makeTileConfig({ isSelf: false, audioUserId: 88, isScreenshare: true }); + grid.addStream(88, "screen", fakeStream(), config); + + const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement; + const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement; + const overlay = container.querySelector(".video-tile-overlay"); + expect(slider.value).toBe("30"); + expect(muteBtn.getAttribute("aria-label")).toBe("Unmute"); + expect(overlay!.classList.contains("muted")).toBe(true); + }); + + it("mic slider keeps the 0-200 boost range", () => { + const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false }); + grid.addStream(77, "dave", fakeStream(), config); + + const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement; + expect(slider.max).toBe("200"); + }); + it("mute button unmutes with previous volume when currentVolume was non-zero", () => { const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false }); grid.addStream(77, "dave", fakeStream(), config); diff --git a/Client/tauri-client/tests/unit/voice-audio-tab.test.ts b/Client/tauri-client/tests/unit/voice-audio-tab.test.ts index d9753185..08e5d996 100644 --- a/Client/tauri-client/tests/unit/voice-audio-tab.test.ts +++ b/Client/tauri-client/tests/unit/voice-audio-tab.test.ts @@ -197,8 +197,8 @@ describe("VoiceAudioTab UI structure", () => { document.body.appendChild(el); const selects = el.querySelectorAll("select"); - // Input, output, stream quality, video = 4 selects - expect(selects.length).toBe(4); + // Input, output, stream quality, screen share fps, video = 5 selects + expect(selects.length).toBe(5); ac.abort(); }); @@ -357,6 +357,24 @@ describe("VoiceAudioTab UI structure", () => { ac.abort(); }); + it("screen share fps select persists the preference as a number", () => { + stubNavigator(); + const ac = new AbortController(); + const tab = createVoiceAudioTab(ac.signal); + const el = tab.build(); + document.body.appendChild(el); + + // Screen share FPS is the 4th select (index 3) + const fpsSelect = el.querySelectorAll("select")[3] as HTMLSelectElement; + expect(fpsSelect.value).toBe("30"); // default + fpsSelect.value = "60"; + fpsSelect.dispatchEvent(new Event("change")); + + const saved = localStorage.getItem("owncord:settings:screenShareFps"); + expect(saved).toBe("60"); + ac.abort(); + }); + it("contains audio processing toggles", () => { stubNavigator(); const ac = new AbortController(); @@ -488,11 +506,11 @@ describe("VoiceAudioTab UI structure", () => { // Wait for devices to load await vi.waitFor(() => { - const videoSelect = el.querySelectorAll("select")[3] as HTMLSelectElement; + const videoSelect = el.querySelectorAll("select")[4] as HTMLSelectElement; expect(videoSelect.querySelectorAll("option").length).toBeGreaterThan(1); }); - const videoSelect = el.querySelectorAll("select")[3] as HTMLSelectElement; + const videoSelect = el.querySelectorAll("select")[4] as HTMLSelectElement; videoSelect.value = "cam-1"; videoSelect.dispatchEvent(new Event("change")); diff --git a/Client/tauri-client/tests/unit/window-state-restore.test.ts b/Client/tauri-client/tests/unit/window-state-restore.test.ts new file mode 100644 index 00000000..8cf2f2ac --- /dev/null +++ b/Client/tauri-client/tests/unit/window-state-restore.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Shared mutable state read lazily by the mock factories below. +const h = vi.hoisted(() => ({ + settings: {} as Record, + monitors: [] as Array<{ + position: { x: number; y: number }; + size: { width: number; height: number }; + }>, + monitorsError: null as Error | null, + setPosition: vi.fn(), + setSize: vi.fn(), + maximize: vi.fn(), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string) => { + if (cmd === "get_settings") return Promise.resolve(h.settings); + return Promise.resolve(undefined); + }, +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ + maximize: h.maximize, + setPosition: h.setPosition, + setSize: h.setSize, + onMoved: vi.fn().mockResolvedValue(() => {}), + onResized: vi.fn().mockResolvedValue(() => {}), + outerPosition: vi.fn(), + outerSize: vi.fn(), + isMaximized: vi.fn().mockResolvedValue(false), + isMinimized: vi.fn().mockResolvedValue(false), + }), + availableMonitors: () => + h.monitorsError !== null ? Promise.reject(h.monitorsError) : Promise.resolve(h.monitors), + PhysicalPosition: class { + constructor( + public x: number, + public y: number, + ) {} + }, + PhysicalSize: class { + constructor( + public width: number, + public height: number, + ) {} + }, +})); + +const PRIMARY = { position: { x: 0, y: 0 }, size: { width: 1920, height: 1080 } }; + +function setSaved(state: Record): void { + h.settings = { windowState: state }; +} + +describe("window-state restore validation", () => { + beforeEach(() => { + vi.resetModules(); + h.settings = {}; + h.monitors = [PRIMARY]; + h.monitorsError = null; + h.setPosition.mockClear(); + h.setSize.mockClear(); + h.maximize.mockClear(); + }); + + describe("isRectOnScreen", () => { + it("accepts a rect fully inside a monitor", async () => { + const { isRectOnScreen } = await import("@lib/window-state"); + expect( + isRectOnScreen([PRIMARY], { x: 100, y: 100, width: 1280, height: 720, maximized: false }), + ).toBe(true); + }); + + it("rejects a rect far off-screen", async () => { + const { isRectOnScreen } = await import("@lib/window-state"); + expect( + isRectOnScreen([PRIMARY], { x: -5000, y: 100, width: 1280, height: 720, maximized: false }), + ).toBe(false); + }); + + it("accepts a rect on a secondary monitor left of primary", async () => { + const secondary = { position: { x: -1920, y: 0 }, size: { width: 1920, height: 1080 } }; + const { isRectOnScreen } = await import("@lib/window-state"); + expect( + isRectOnScreen([PRIMARY, secondary], { + x: -1800, + y: 50, + width: 1280, + height: 720, + maximized: false, + }), + ).toBe(true); + }); + + it("rejects a rect whose title bar is below every monitor", async () => { + const { isRectOnScreen } = await import("@lib/window-state"); + expect( + isRectOnScreen([PRIMARY], { x: 100, y: 1075, width: 1280, height: 720, maximized: false }), + ).toBe(false); + }); + + it("rejects a rect with too little horizontal overlap", async () => { + const { isRectOnScreen } = await import("@lib/window-state"); + // Only 50px of the window remains on-screen at the right edge. + expect( + isRectOnScreen([PRIMARY], { x: 1870, y: 100, width: 1280, height: 720, maximized: false }), + ).toBe(false); + }); + }); + + describe("initWindowState", () => { + it("restores an on-screen saved position", async () => { + setSaved({ x: 200, y: 150, width: 1280, height: 720, maximized: false }); + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.setPosition).toHaveBeenCalledTimes(1); + expect(h.setPosition.mock.calls[0]?.[0]).toMatchObject({ x: 200, y: 150 }); + expect(h.setSize).toHaveBeenCalledTimes(1); + expect(h.setSize.mock.calls[0]?.[0]).toMatchObject({ width: 1280, height: 720 }); + }); + + it("skips restore when the saved position is off-screen", async () => { + setSaved({ x: -5000, y: -5000, width: 1280, height: 720, maximized: false }); + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.setPosition).not.toHaveBeenCalled(); + expect(h.setSize).not.toHaveBeenCalled(); + }); + + it("restores unchecked when availableMonitors fails", async () => { + setSaved({ x: -5000, y: -5000, width: 1280, height: 720, maximized: false }); + h.monitorsError = new Error("not supported"); + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.setPosition).toHaveBeenCalledTimes(1); + expect(h.setSize).toHaveBeenCalledTimes(1); + }); + + it("restores unchecked when no monitors are reported", async () => { + setSaved({ x: 300, y: 300, width: 1280, height: 720, maximized: false }); + h.monitors = []; + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.setPosition).toHaveBeenCalledTimes(1); + }); + + it("maximizes without querying position when saved maximized", async () => { + setSaved({ x: -5000, y: -5000, width: 1280, height: 720, maximized: true }); + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.maximize).toHaveBeenCalledTimes(1); + expect(h.setPosition).not.toHaveBeenCalled(); + }); + + it("ignores saved state with non-finite coordinates", async () => { + setSaved({ x: NaN, y: 100, width: 1280, height: 720, maximized: false }); + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.setPosition).not.toHaveBeenCalled(); + expect(h.maximize).not.toHaveBeenCalled(); + }); + + it("ignores saved state with non-positive size", async () => { + setSaved({ x: 100, y: 100, width: 0, height: 720, maximized: false }); + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.setPosition).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/Server/admin/api.go b/Server/admin/api.go index 276bbb7e..8db87f8f 100644 --- a/Server/admin/api.go +++ b/Server/admin/api.go @@ -46,6 +46,9 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater r.Post("/channels", handleCreateChannel(database, hub)) r.Patch("/channels/{id}", handlePatchChannel(database, hub)) r.Delete("/channels/{id}", handleDeleteChannel(database, hub)) + r.Get("/channels/{id}/permissions", handleGetChannelPermissions(database)) + r.Put("/channels/{id}/permissions/{roleId}", handlePutChannelPermission(database, hub, permInvalidator)) + r.Delete("/channels/{id}/permissions/{roleId}", handleDeleteChannelPermission(database, hub, permInvalidator)) r.Get("/audit-log", handleGetAuditLog(database)) r.Get("/settings", handleGetSettings(database)) r.Patch("/settings", handlePatchSettings(database)) diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index 3107b3d3..71558e11 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -87,6 +87,15 @@ CREATE TABLE IF NOT EXISTS channels ( voice_max_video INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS channel_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + UNIQUE(channel_id, role_id) +); + CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, @@ -1107,13 +1116,14 @@ func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) { // mockHub records which broadcast methods were called and with what arguments. type mockHub struct { - restartCalls []restartCall - channelCreates []*db.Channel - channelUpdates []*db.Channel - channelDeleteIDs []int64 - memberBanIDs []int64 - memberUpdates []memberUpdateCall - clientCount int + restartCalls []restartCall + channelCreates []*db.Channel + channelUpdates []*db.Channel + channelDeleteIDs []int64 + memberBanIDs []int64 + memberUpdates []memberUpdateCall + visibilityRefreshes []*db.Channel + clientCount int } type memberUpdateCall struct { @@ -1150,6 +1160,10 @@ func (m *mockHub) BroadcastMemberUpdate(userID int64, roleName string) { m.memberUpdates = append(m.memberUpdates, memberUpdateCall{userID, roleName}) } +func (m *mockHub) RefreshChannelVisibility(ch *db.Channel) { + m.visibilityRefreshes = append(m.visibilityRefreshes, ch) +} + func (m *mockHub) ClientCount() int { return m.clientCount } diff --git a/Server/admin/handlers_channel_perms.go b/Server/admin/handlers_channel_perms.go new file mode 100644 index 00000000..0676f51c --- /dev/null +++ b/Server/admin/handlers_channel_perms.go @@ -0,0 +1,161 @@ +package admin + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// ─── Channel Permission Override Handlers ──────────────────────────────────── +// +// These endpoints manage per-role allow/deny permission overrides on a +// channel (the channel_overrides table). Denying ReadMessages hides the +// channel from a role entirely ("private channel"); the read side is already +// enforced by ListVisibleChannels, the WS ready payload, and the per-message +// permission checks. + +// getPermChannel loads the channel for an override request and writes the +// appropriate error response when it is missing or a DM. Returns nil when a +// response has already been written. +func getPermChannel(database *db.DB, w http.ResponseWriter, r *http.Request) *db.Channel { + id, err := pathInt64(r, "id") + if err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id") + return nil + } + ch, err := database.GetChannel(id) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel") + return nil + } + if ch == nil { + writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found") + return nil + } + if ch.Type == "dm" { + writeErr(w, http.StatusBadRequest, "INVALID_INPUT", "DM channels do not support permission overrides") + return nil + } + return ch +} + +// channelPermissionsResponse is the JSON shape for GET .../permissions. +type channelPermissionsResponse struct { + ChannelID int64 `json:"channel_id"` + Roles []db.ChannelRoleOverride `json:"roles"` +} + +func handleGetChannelPermissions(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ch := getPermChannel(database, w, r) + if ch == nil { + return + } + overrides, err := database.ListChannelRoleOverrides(ch.ID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channel permissions") + return + } + writeJSON(w, http.StatusOK, channelPermissionsResponse{ChannelID: ch.ID, Roles: overrides}) + } +} + +// putChannelPermissionRequest is the JSON body for PUT .../permissions/{roleId}. +type putChannelPermissionRequest struct { + Allow int64 `json:"allow"` + Deny int64 `json:"deny"` +} + +func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ch := getPermChannel(database, w, r) + if ch == nil { + return + } + roleID, err := pathInt64(r, "roleId") + if err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id") + return + } + role, err := database.GetRoleByID(roleID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch role") + return + } + if role == nil { + writeErr(w, http.StatusNotFound, "NOT_FOUND", "role not found") + return + } + + var req putChannelPermissionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") + return + } + // Drop unknown bits so garbage input cannot persist undefined perms. + allow := req.Allow & permissions.AllPerms + deny := req.Deny & permissions.AllPerms + + if err := database.UpsertChannelOverride(ch.ID, roleID, allow, deny); err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save channel permission") + return + } + + actor := actorFromContext(r) + slog.Info("channel permissions updated", "actor_id", actor, "channel_id", ch.ID, + "role_id", roleID, "allow", allow, "deny", deny) + _ = database.LogAudit(actor, "channel_perms_update", "channel", ch.ID, + fmt.Sprintf("set overrides for role %s on #%s (allow=%#x deny=%#x)", role.Name, ch.Name, allow, deny)) + + if permInvalidator != nil { + permInvalidator.InvalidateAll() + } + if hub != nil { + hub.RefreshChannelVisibility(ch) + } + writeJSON(w, http.StatusOK, db.ChannelRoleOverride{ + RoleID: role.ID, + RoleName: role.Name, + Position: role.Position, + Permissions: role.Permissions, + Allow: allow, + Deny: deny, + }) + } +} + +func handleDeleteChannelPermission(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ch := getPermChannel(database, w, r) + if ch == nil { + return + } + roleID, err := pathInt64(r, "roleId") + if err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id") + return + } + + if err := database.DeleteChannelOverride(ch.ID, roleID); err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel permission") + return + } + + actor := actorFromContext(r) + slog.Info("channel permissions cleared", "actor_id", actor, "channel_id", ch.ID, "role_id", roleID) + _ = database.LogAudit(actor, "channel_perms_clear", "channel", ch.ID, + fmt.Sprintf("cleared overrides for role %d on #%s", roleID, ch.Name)) + + if permInvalidator != nil { + permInvalidator.InvalidateAll() + } + if hub != nil { + hub.RefreshChannelVisibility(ch) + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/Server/admin/handlers_channel_perms_test.go b/Server/admin/handlers_channel_perms_test.go new file mode 100644 index 00000000..dc48095d --- /dev/null +++ b/Server/admin/handlers_channel_perms_test.go @@ -0,0 +1,255 @@ +package admin_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/owncord/server/admin" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// mockPermInvalidator records permission-cache invalidation calls. +type mockPermInvalidator struct { + invalidateUserIDs []int64 + invalidateAllN int +} + +func (m *mockPermInvalidator) InvalidateUser(userID int64) { + m.invalidateUserIDs = append(m.invalidateUserIDs, userID) +} + +func (m *mockPermInvalidator) InvalidateAll() { + m.invalidateAllN++ +} + +// ─── GET /channels/{id}/permissions ────────────────────────────────────────── + +func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel("secret", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + w := doRequest(t, handler, http.MethodGet, "/channels/1/permissions", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + var resp struct { + ChannelID int64 `json:"channel_id"` + Roles []db.ChannelRoleOverride `json:"roles"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.ChannelID != chID && resp.ChannelID != 1 { + t.Errorf("channel_id = %d", resp.ChannelID) + } + if len(resp.Roles) != 3 { + t.Fatalf("expected 3 roles, got %d", len(resp.Roles)) + } + if resp.Roles[0].RoleName != "Owner" { + t.Errorf("first role = %q, want Owner (position desc)", resp.Roles[0].RoleName) + } + for _, role := range resp.Roles { + if role.Allow != 0 || role.Deny != 0 { + t.Errorf("role %d: expected zero overrides, got (%#x, %#x)", role.RoleID, role.Allow, role.Deny) + } + } +} + +func TestGetChannelPermissions_NotFound(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + w := doRequest(t, handler, http.MethodGet, "/channels/9999/permissions", token, nil) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestGetChannelPermissions_DMRejected(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel("dm-chan", "dm", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel dm: %v", err) + } + + w := doRequest(t, handler, http.MethodGet, + "/channels/"+itoa(chID)+"/permissions", token, nil) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String()) + } +} + +// ─── PUT /channels/{id}/permissions/{roleId} ───────────────────────────────── + +func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) { + database := openAdminTestDB(t) + hub := &mockHub{} + inv := &mockPermInvalidator{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel("secret", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + denyPrivate := permissions.ReadMessages | permissions.ConnectVoice + body := map[string]any{"allow": 0, "deny": denyPrivate} + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/permissions/3", token, body) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetChannelPermissions(chID, 3) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != denyPrivate { + t.Errorf("persisted override = (%#x, %#x), want (0, %#x)", allow, deny, denyPrivate) + } + + if inv.invalidateAllN != 1 { + t.Errorf("InvalidateAll calls = %d, want 1", inv.invalidateAllN) + } + if len(hub.visibilityRefreshes) != 1 || hub.visibilityRefreshes[0].ID != chID { + t.Errorf("RefreshChannelVisibility not called for channel %d", chID) + } + + entries, err := database.GetAuditLog(10, 0) + if err != nil { + t.Fatalf("GetAuditLog: %v", err) + } + found := false + for _, e := range entries { + if e.Action == "channel_perms_update" { + found = true + } + } + if !found { + t.Error("expected channel_perms_update audit entry") + } +} + +func TestPutChannelPermission_MasksUnknownBits(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel("secret2", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + // 0x4 and 0x8 are undefined bits — they must be dropped. + body := map[string]any{"allow": 0x4 | permissions.SendMessages, "deny": 0x8} + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/permissions/3", token, body) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetChannelPermissions(chID, 3) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != permissions.SendMessages { + t.Errorf("allow = %#x, want %#x (unknown bits dropped)", allow, permissions.SendMessages) + } + if deny != 0 { + t.Errorf("deny = %#x, want 0 (unknown bits dropped)", deny) + } +} + +func TestPutChannelPermission_UnknownRole(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel("secret3", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/permissions/999", token, map[string]any{"allow": 0, "deny": 2}) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404; body: %s", w.Code, w.Body.String()) + } +} + +func TestPutChannelPermission_NonAdminForbidden(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) + _ = createAdminUser(t, database) + memberToken := createMemberUser(t, database) + + chID, err := database.CreateChannel("secret4", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + w := doRequest(t, handler, http.MethodPut, + "/channels/"+itoa(chID)+"/permissions/3", memberToken, map[string]any{"allow": 0, "deny": 2}) + if w.Code != http.StatusForbidden && w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 403/401; body: %s", w.Code, w.Body.String()) + } +} + +// ─── DELETE /channels/{id}/permissions/{roleId} ────────────────────────────── + +func TestDeleteChannelPermission_ClearsOverride(t *testing.T) { + database := openAdminTestDB(t) + hub := &mockHub{} + inv := &mockPermInvalidator{} + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database)) + token := createAdminUser(t, database) + + chID, err := database.CreateChannel("secret5", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if err := database.UpsertChannelOverride(chID, 3, 0, permissions.ReadMessages); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + + w := doRequest(t, handler, http.MethodDelete, + "/channels/"+itoa(chID)+"/permissions/3", token, nil) + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) + } + + allow, deny, err := database.GetChannelPermissions(chID, 3) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != 0 { + t.Errorf("override still present: (%#x, %#x)", allow, deny) + } + if inv.invalidateAllN != 1 { + t.Errorf("InvalidateAll calls = %d, want 1", inv.invalidateAllN) + } + if len(hub.visibilityRefreshes) != 1 { + t.Errorf("RefreshChannelVisibility calls = %d, want 1", len(hub.visibilityRefreshes)) + } + + // Deleting again is idempotent. + w = doRequest(t, handler, http.MethodDelete, + "/channels/"+itoa(chID)+"/permissions/3", token, nil) + if w.Code != http.StatusNoContent { + t.Errorf("second delete status = %d, want 204", w.Code) + } +} diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index 8b287829..40d1c733 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -402,6 +402,7 @@ func (m *mockHubWB) BroadcastChannelUpdate(ch *db.Channel) {} func (m *mockHubWB) BroadcastChannelDelete(channelID int64) {} func (m *mockHubWB) BroadcastMemberBan(userID int64) {} func (m *mockHubWB) BroadcastMemberUpdate(userID int64, roleName string) {} +func (m *mockHubWB) RefreshChannelVisibility(ch *db.Channel) {} func (m *mockHubWB) ClientCount() int { return 0 } // TestSpawnDetached_ValidExecutable verifies that spawnDetached can start a diff --git a/Server/admin/static/index.html b/Server/admin/static/index.html index a81aadfa..73cca80f 100644 --- a/Server/admin/static/index.html +++ b/Server/admin/static/index.html @@ -277,6 +277,7 @@ const I={ voice:'', megaphone:'', logs:'', + lock:'', }; /* ═══ State ═══ */ @@ -300,6 +301,10 @@ async function api(method,path,body){ /* ═══ Utilities ═══ */ function esc(s){if(s===null||s===undefined)return'';return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"')} +/* Escape for embedding inside a single-quoted JS string in an inline onclick + attribute: JS-escape backslashes and single quotes first, then HTML-escape. + Without this a name containing ' breaks out of the string literal (XSS). */ +function jsq(s){return esc(String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'"))} function fmtBytes(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';if(b<1073741824)return(b/1048576).toFixed(1)+' MB';return(b/1073741824).toFixed(2)+' GB'} function actionBadge(a){if(!a)return'badge-muted';if(a.includes('ban')||a.includes('kick')||a.includes('delete'))return'badge-red';if(a.includes('create'))return'badge-green';if(a.includes('update'))return'badge-yellow';return'badge-accent'} function actionColor(a){if(!a)return'var(--accent)';if(a.includes('ban')||a.includes('kick')||a.includes('delete'))return'var(--red)';if(a.includes('create'))return'var(--green)';if(a.includes('update'))return'var(--yellow)';return'var(--accent)'} @@ -466,10 +471,10 @@ async function renderUsers(){ html+=''+statusLabel+''; html+=''+(banned?'Yes':'No')+''; html+='
'; - html+=''; + html+=''; html+=''; if(banned)html+=''; - else html+=''; + else html+=''; html+='
'; }); html+=''; @@ -527,7 +532,8 @@ async function renderChannels(){ html+=''+esc(type)+''; html+=''+esc(cat)+''; html+=''+(archived?'Yes':'No')+''; - html+='
'; + const lockBtn=type==='dm'?'':''; + html+='
'+lockBtn+'
'; }); html+=''; return html; @@ -560,6 +566,47 @@ async function confirmDeleteChannel(id){ try{await api('DELETE','/channels/'+id);closeModal();showToast('Channel deleted');renderContent()}catch(e){showToast(e.message,'error')} } +/* ═══ Channel Access (private channels) ═══ */ +const DENY_PRIVATE=0x202; /* READ_MESSAGES | CONNECT_VOICE */ +const ADMIN_BIT=0x40000000; + +async function openChannelPermsModal(id,name){ + let data; + try{data=await api('GET','/channels/'+id+'/permissions')}catch(e){showToast(e.message,'error');return} + const roles=data.roles||[]; + state.permChannelRoles=roles; + let rows=''; + roles.forEach(role=>{ + const isAdmin=(role.permissions&ADMIN_BIT)!==0; + const canAccess=isAdmin||((role.deny&0x2)===0); + rows+='
' + +''+esc(role.role_name)+'' + +(isAdmin + ?'always has access' + :'') + +'
'; + }); + openModal('' + +'' + +''); +} + +async function saveChannelPerms(id){ + const roles=state.permChannelRoles||[]; + try{ + for(const role of roles){ + if((role.permissions&ADMIN_BIT)!==0)continue; + const box=document.getElementById('permRole'+role.role_id); + if(!box)continue; + const wasHidden=(role.deny&0x2)!==0; + if(!box.checked)await api('PUT','/channels/'+id+'/permissions/'+role.role_id,{allow:0,deny:DENY_PRIVATE}); + else if(wasHidden)await api('DELETE','/channels/'+id+'/permissions/'+role.role_id); + } + closeModal();showToast('Channel access updated');renderContent(); + }catch(e){showToast(e.message,'error')} +} + /* ═══ Audit Log ═══ */ async function renderAudit(){ const offset=(state.auditPage-1)*PAGE_SIZE; @@ -804,7 +851,7 @@ async function createBackup(){ } function openRestoreModal(name){ - openModal(''); + openModal(''); } async function confirmRestore(name){ diff --git a/Server/admin/types.go b/Server/admin/types.go index c41953aa..299b0a17 100644 --- a/Server/admin/types.go +++ b/Server/admin/types.go @@ -40,6 +40,10 @@ type HubBroadcaster interface { BroadcastChannelDelete(channelID int64) BroadcastMemberBan(userID int64) BroadcastMemberUpdate(userID int64, roleName string) + // RefreshChannelVisibility sends targeted channel_create/channel_delete + // messages after a channel permission override change so each connected + // client's sidebar reflects its new visibility without a reconnect. + RefreshChannelVisibility(ch *db.Channel) ClientCount() int } diff --git a/Server/config/config.go b/Server/config/config.go index 2bd99919..9dd03548 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -97,7 +97,11 @@ type VoiceConfig struct { LiveKitURL string `koanf:"livekit_url"` // LiveKit server WebSocket URL (e.g. ws://localhost:7880) LiveKitBinaryPath string `koanf:"livekit_binary"` // path to livekit-server binary; empty = don't auto-start NodeIP string `koanf:"node_ip"` // public IP for WebRTC ICE candidates; empty = auto-detect - Quality string `koanf:"quality"` // low | medium | high + // AdvertiseInternalIP makes LiveKit advertise internal (LAN) host candidates + // in addition to the external node_ip mapping, so clients on the local + // network can connect while remote clients use the public IP. + AdvertiseInternalIP bool `koanf:"advertise_internal_ip"` + Quality string `koanf:"quality"` // low | medium | high } // ServerConfig holds HTTP server settings. @@ -248,6 +252,7 @@ voice: livekit_url: "ws://localhost:7880" # LiveKit server WebSocket URL # livekit_binary: "" # path to livekit-server binary; empty = don't auto-start # node_ip: "" # public IP for WebRTC media (required for remote users behind NAT) + # advertise_internal_ip: false # also advertise LAN IPs so local-network clients can connect # quality: "medium" # low | medium | high # github: diff --git a/Server/config/config_test.go b/Server/config/config_test.go index 879cafdb..dcd6666b 100644 --- a/Server/config/config_test.go +++ b/Server/config/config_test.go @@ -266,6 +266,7 @@ voice: livekit_api_key: "mykey" livekit_api_secret: "mysecret" livekit_url: "ws://lk.example.com:7880" + advertise_internal_ip: true ` if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { t.Fatalf("failed to write yaml: %v", err) @@ -288,6 +289,27 @@ voice: if cfg.Voice.LiveKitURL != "ws://lk.example.com:7880" { t.Errorf("Voice.LiveKitURL = %q, want 'ws://lk.example.com:7880'", cfg.Voice.LiveKitURL) } + if !cfg.Voice.AdvertiseInternalIP { + t.Error("Voice.AdvertiseInternalIP = false, want true") + } +} + +func TestLoadVoiceAdvertiseInternalIPFromEnv(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte("voice:\n quality: high\n"), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + t.Setenv("OWNCORD_VOICE_ADVERTISE_INTERNAL_IP", "true") + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if !cfg.Voice.AdvertiseInternalIP { + t.Error("Voice.AdvertiseInternalIP = false, want true from env override") + } } func TestLoadEnvOverridesPrecedenceOverYAML(t *testing.T) { diff --git a/Server/db/channel_queries.go b/Server/db/channel_queries.go index 4daccf5c..eae17e32 100644 --- a/Server/db/channel_queries.go +++ b/Server/db/channel_queries.go @@ -174,6 +174,81 @@ func (d *DB) GetAllChannelPermissionsForRole(roleID int64) (map[int64]ChannelOve return result, nil } +// UpsertChannelOverride inserts or updates the allow/deny permission override +// for a role on a channel. +func (d *DB) UpsertChannelOverride(channelID, roleID, allow, deny int64) error { + _, err := d.sqlDB.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) + VALUES (?, ?, ?, ?) + ON CONFLICT(channel_id, role_id) + DO UPDATE SET allow = excluded.allow, deny = excluded.deny`, + channelID, roleID, allow, deny, + ) + if err != nil { + return fmt.Errorf("UpsertChannelOverride: %w", err) + } + return nil +} + +// DeleteChannelOverride removes the permission override for a role on a +// channel. Deleting a non-existent override is a no-op. +func (d *DB) DeleteChannelOverride(channelID, roleID int64) error { + _, err := d.sqlDB.Exec( + `DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = ?`, + channelID, roleID, + ) + if err != nil { + return fmt.Errorf("DeleteChannelOverride: %w", err) + } + return nil +} + +// ChannelRoleOverride pairs a role with its (possibly zero) permission +// override on a specific channel. Permissions carries the role's base bits so +// callers can tell which roles bypass overrides via Administrator. +type ChannelRoleOverride struct { + RoleID int64 `json:"role_id"` + RoleName string `json:"role_name"` + Position int `json:"position"` + Permissions int64 `json:"permissions"` + Allow int64 `json:"allow"` + Deny int64 `json:"deny"` +} + +// ListChannelRoleOverrides returns every role together with its override bits +// on the given channel (zero allow/deny when no override row exists), ordered +// by role position descending. +func (d *DB) ListChannelRoleOverrides(channelID int64) ([]ChannelRoleOverride, error) { + rows, err := d.sqlDB.Query( + `SELECT r.id, r.name, r.position, r.permissions, + COALESCE(o.allow, 0), COALESCE(o.deny, 0) + FROM roles r + LEFT JOIN channel_overrides o ON o.role_id = r.id AND o.channel_id = ? + ORDER BY r.position DESC, r.id ASC`, + channelID, + ) + if err != nil { + return nil, fmt.Errorf("ListChannelRoleOverrides: %w", err) + } + defer rows.Close() //nolint:errcheck + + var result []ChannelRoleOverride + for rows.Next() { + var o ChannelRoleOverride + if scanErr := rows.Scan(&o.RoleID, &o.RoleName, &o.Position, &o.Permissions, &o.Allow, &o.Deny); scanErr != nil { + return nil, fmt.Errorf("ListChannelRoleOverrides scan: %w", scanErr) + } + result = append(result, o) + } + if rows.Err() != nil { + return nil, fmt.Errorf("ListChannelRoleOverrides rows: %w", rows.Err()) + } + if result == nil { + result = []ChannelRoleOverride{} + } + return result, nil +} + // ─── helpers ────────────────────────────────────────────────────────────────── // scanChannel scans a single channel row from *sql.Rows. diff --git a/Server/db/channel_queries_test.go b/Server/db/channel_queries_test.go index 3f2fcbca..eabd8d07 100644 --- a/Server/db/channel_queries_test.go +++ b/Server/db/channel_queries_test.go @@ -234,6 +234,96 @@ func TestGetChannelPermissions_WithOverride(t *testing.T) { } } +// ─── Channel override write path ───────────────────────────────────────────── + +func TestUpsertChannelOverride_InsertAndUpdate(t *testing.T) { + database := openMigratedMemory(t) + chID, _ := database.CreateChannel("private", "text", "", "", 0) + + if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil { + t.Fatalf("UpsertChannelOverride insert: %v", err) + } + allow, deny, err := database.GetChannelPermissions(chID, 4) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != 0x202 { + t.Errorf("after insert: got (%#x, %#x), want (0, 0x202)", allow, deny) + } + + // Upsert again with different bits — must update, not duplicate. + if err := database.UpsertChannelOverride(chID, 4, 0x2, 0x200); err != nil { + t.Fatalf("UpsertChannelOverride update: %v", err) + } + allow, deny, err = database.GetChannelPermissions(chID, 4) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0x2 || deny != 0x200 { + t.Errorf("after update: got (%#x, %#x), want (0x2, 0x200)", allow, deny) + } +} + +func TestDeleteChannelOverride(t *testing.T) { + database := openMigratedMemory(t) + chID, _ := database.CreateChannel("private2", "text", "", "", 0) + + if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + if err := database.DeleteChannelOverride(chID, 4); err != nil { + t.Fatalf("DeleteChannelOverride: %v", err) + } + allow, deny, err := database.GetChannelPermissions(chID, 4) + if err != nil { + t.Fatalf("GetChannelPermissions: %v", err) + } + if allow != 0 || deny != 0 { + t.Errorf("after delete: got (%#x, %#x), want (0, 0)", allow, deny) + } + + // Deleting again is a no-op. + if err := database.DeleteChannelOverride(chID, 4); err != nil { + t.Errorf("DeleteChannelOverride non-existent should not error: %v", err) + } +} + +func TestListChannelRoleOverrides(t *testing.T) { + database := openMigratedMemory(t) + chID, _ := database.CreateChannel("private3", "text", "", "", 0) + + if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil { + t.Fatalf("UpsertChannelOverride: %v", err) + } + + overrides, err := database.ListChannelRoleOverrides(chID) + if err != nil { + t.Fatalf("ListChannelRoleOverrides: %v", err) + } + // All four seeded roles must be present, position descending (Owner first). + if len(overrides) != 4 { + t.Fatalf("expected 4 roles, got %d", len(overrides)) + } + if overrides[0].RoleID != 1 || overrides[0].RoleName != "Owner" { + t.Errorf("first role = (%d, %q), want (1, Owner)", overrides[0].RoleID, overrides[0].RoleName) + } + for _, o := range overrides { + switch o.RoleID { + case 4: + if o.Deny != 0x202 || o.Allow != 0 { + t.Errorf("member override = (%#x, %#x), want (0, 0x202)", o.Allow, o.Deny) + } + default: + if o.Allow != 0 || o.Deny != 0 { + t.Errorf("role %d override = (%#x, %#x), want zeros", o.RoleID, o.Allow, o.Deny) + } + } + if o.Permissions == 0 { + t.Errorf("role %d permissions should be non-zero", o.RoleID) + } + } +} + // ─── SetChannelSlowMode ───────────────────────────────────────────────────── func TestSetChannelSlowMode(t *testing.T) { diff --git a/Server/livekit.yaml.example b/Server/livekit.yaml.example index 31001e55..bebd61b4 100644 --- a/Server/livekit.yaml.example +++ b/Server/livekit.yaml.example @@ -16,6 +16,11 @@ rtc: node_ip: "YOUR_SERVER_PUBLIC_IP" # use_external_ip: true # uncomment on cloud VMs instead of node_ip + # If the server is reachable via both a LAN IP and a public IP (dual-homed), + # uncomment this so LiveKit also advertises the internal address — clients on + # the local network then connect via LAN while remote clients use node_ip. + # advertise_internal_ip: true + keys: # Must match LIVEKIT_API_KEY / LIVEKIT_API_SECRET in your .env file YOUR_API_KEY: YOUR_API_SECRET diff --git a/Server/permissions/permissions.go b/Server/permissions/permissions.go index a74bb721..1068d532 100644 --- a/Server/permissions/permissions.go +++ b/Server/permissions/permissions.go @@ -26,6 +26,13 @@ const ( Administrator = int64(0x40000000) // bit 30 — bypasses all permission checks ) +// AllPerms is the union of every defined permission bit. Use it to mask +// externally supplied permission values so unknown bits are dropped. +const AllPerms = SendMessages | ReadMessages | AttachFiles | AddReactions | + ConnectVoice | SpeakVoice | UseVideo | ShareScreen | + ManageMessages | ManageChannels | KickMembers | BanMembers | MuteMembers | + ManageRoles | ManageServer | ManageInvites | ViewAuditLog | Administrator + // ─── Role ID constants (default roles inserted on first run) ───────────────── const ( diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index a8a10e9c..7da0195d 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -217,3 +217,8 @@ func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64, } h.handleWebhookParticipantLeft(context.Background(), event) } + +// MustFullResyncForTest exposes mustFullResync for external tests. +func (h *Hub) MustFullResyncForTest(lastSeq uint64) bool { + return h.mustFullResync(lastSeq) +} diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 43453778..7e4b5e8d 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -74,6 +74,13 @@ type Hub struct { reconnectTierDB atomic.Uint64 reconnectTierFull atomic.Uint64 + // Sequence watermark of the last channel-visibility change. Visibility + // updates are sent as targeted, unsequenced messages, so clients resuming + // from a seq at or before this point must take the full-ready path to + // converge (replay cannot deliver them). Reset on restart — a fresh + // connection always gets a correctly filtered ready payload anyway. + visibilityChangeSeq atomic.Uint64 + // Settings cache — avoids per-connection DB queries for server_name/motd. settingsMu syncutil.RWMutex settingsName string @@ -509,6 +516,92 @@ func (h *Hub) BroadcastChannelDelete(channelID int64) { h.BroadcastToAll(buildChannelDelete(channelID)) } +// RefreshChannelVisibility re-evaluates which connected clients may see ch +// after a channel_overrides change and sends targeted channel_create / +// channel_delete messages so sidebars converge without a reconnect. Clients +// that lose visibility are also unsubscribed from the channel topic and have +// their focused channel cleared so live messages stop flowing. +// +// The sends deliberately bypass the sequenced broadcast/replay path: a +// replayed channel_delete would be filtered by the allowed-channel set +// computed at replay time, which after an override change is exactly the +// inverse of the intended audience. Clients tolerate seq-less messages. +func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { + if ch == nil { + return + } + + h.mu.RLock() + clients := make([]*Client, 0, len(h.clients)) + for _, c := range h.clients { + clients = append(clients, c) + } + h.mu.RUnlock() + + // Visibility is a function of the role, so resolve each role once. + visibleByRole := make(map[int64]bool) + roleVisible := func(roleID int64) bool { + if v, ok := visibleByRole[roleID]; ok { + return v + } + visible := false + role, err := h.db.GetRoleByID(roleID) + if err == nil && role != nil { + if permissions.HasAdmin(role.Permissions) { + visible = true + } else { + allow, deny, permErr := h.db.GetChannelPermissions(ch.ID, roleID) + // Fail closed: an error hides the channel rather than leaking it. + visible = permErr == nil && + permissions.EffectivePerms(role.Permissions, allow, deny)&permissions.ReadMessages != 0 + } + } + visibleByRole[roleID] = visible + return visible + } + + for _, c := range clients { + if c.user == nil { + continue + } + // c.user is a connect-time snapshot; an admin may have changed the + // user's role mid-session, so resolve the current role from the DB. + // Fail closed: on error send nothing rather than mis-target. + fresh, err := h.db.GetUserByID(c.user.ID) + if err != nil || fresh == nil { + slog.Warn("hub: RefreshChannelVisibility could not resolve user role", + "user_id", c.user.ID, "err", err) + continue + } + if roleVisible(fresh.RoleID) { + // Idempotent add on the client; also refreshes channel metadata. + c.sendMsg(buildChannelCreate(ch)) + continue + } + c.sendMsg(buildChannelDelete(ch.ID)) + h.pubsub.Unsubscribe(c, ChannelTopic(ch.ID)) + c.mu.Lock() + if c.channelID == ch.ID { + c.channelID = 0 + } + c.mu.Unlock() + } + + // Clients not connected right now missed the targeted sends above. Move + // the watermark so any resume from a seq at or before this point is + // forced onto the full-ready path instead of replay (stored after the + // sends so a concurrent seq advance errs toward re-syncing more clients). + h.visibilityChangeSeq.Store(atomic.LoadUint64(&h.seq)) +} + +// mustFullResync reports whether a client resuming from lastSeq predates the +// most recent channel-visibility change and therefore cannot converge via +// replay. +func (h *Hub) mustFullResync(lastSeq uint64) bool { + w := h.visibilityChangeSeq.Load() + return w > 0 && lastSeq <= w +} + // BroadcastMemberBan sends a member_ban message to all connected clients // and immediately disconnects the banned user's WebSocket connection (BUG-113). func (h *Hub) BroadcastMemberBan(userID int64) { diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index ecbbe1aa..6bbfebbb 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -860,6 +860,130 @@ func TestHub_VoiceSessionCount(t *testing.T) { } } +// ─── RefreshChannelVisibility ───────────────────────────────────────────────── + +// drainForMsgType reads messages from send until one with the given type +// arrives or the timeout expires. Returns the decoded payload-bearing message. +func drainForMsgType(t *testing.T, send chan []byte, msgType string) map[string]any { + t.Helper() + deadline := time.After(500 * time.Millisecond) + for { + select { + case raw := <-send: + var msg map[string]any + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + if msg["type"] == msgType { + return msg + } + case <-deadline: + t.Fatalf("timed out waiting for %q message", msgType) + return nil + } + } +} + +// assertNoMsgType asserts that no message of the given type is buffered. +func assertNoMsgType(t *testing.T, send chan []byte, msgType string) { + t.Helper() + for { + select { + case raw := <-send: + var msg map[string]any + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + if msg["type"] == msgType { + t.Fatalf("unexpected %q message", msgType) + } + default: + return + } + } +} + +func TestRefreshChannelVisibility_TargetedSends(t *testing.T) { + hub, database := newTestHub(t) + go hub.Run() + defer hub.Stop() + + chID := seedTestChannel(t, database, "secret-room") + ch, err := database.GetChannel(chID) + if err != nil || ch == nil { + t.Fatalf("GetChannel: %v", err) + } + + owner := seedOwnerUser(t, database, "vis-owner") + memberID := seedTestUser(t, database, "vis-member") + member, err := database.GetUserByID(memberID) + if err != nil || member == nil { + t.Fatalf("GetUserByID: %v", err) + } + + ownerSend := make(chan []byte, 16) + memberSend := make(chan []byte, 16) + ownerClient := ws.NewTestClientWithUser(hub, owner, chID, ownerSend) + memberClient := ws.NewTestClientWithUser(hub, member, chID, memberSend) + hub.Register(ownerClient) + hub.Register(memberClient) + time.Sleep(30 * time.Millisecond) + + // Hide the channel from the Member role (deny ReadMessages). + if _, err := database.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`, + chID, + ); err != nil { + t.Fatalf("insert override: %v", err) + } + + hub.RefreshChannelVisibility(ch) + + // Member loses the channel; owner (admin bit) keeps it. + drainForMsgType(t, memberSend, "channel_delete") + drainForMsgType(t, ownerSend, "channel_create") + + // Restore visibility — the member gets the channel back. + if _, err := database.Exec( + `DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = 4`, chID, + ); err != nil { + t.Fatalf("delete override: %v", err) + } + hub.RefreshChannelVisibility(ch) + drainForMsgType(t, memberSend, "channel_create") + assertNoMsgType(t, memberSend, "channel_delete") +} + +func TestRefreshChannelVisibility_ForcesFullResyncForStaleResumes(t *testing.T) { + hub, database := newTestHub(t) + + chID := seedTestChannel(t, database, "watermark-room") + ch, err := database.GetChannel(chID) + if err != nil || ch == nil { + t.Fatalf("GetChannel: %v", err) + } + + // No visibility change yet — resume is allowed regardless of seq. + if hub.MustFullResyncForTest(1) { + t.Error("expected replay allowed before any visibility change") + } + + hub.SeedSeq(41) + hub.RefreshChannelVisibility(ch) + + // Clients resuming from at/before the change must take the full path. + if !hub.MustFullResyncForTest(41) { + t.Error("expected forced full resync for lastSeq at the watermark") + } + if !hub.MustFullResyncForTest(10) { + t.Error("expected forced full resync for lastSeq before the watermark") + } + // Clients that saw sequenced traffic after the change may replay. + if hub.MustFullResyncForTest(42) { + t.Error("expected replay allowed for lastSeq after the watermark") + } +} + // hubTestSchema is the minimal schema needed for hub tests. var hubTestSchema = []byte(` CREATE TABLE IF NOT EXISTS roles ( diff --git a/Server/ws/livekit_process.go b/Server/ws/livekit_process.go index 767b874c..28560f5f 100644 --- a/Server/ws/livekit_process.go +++ b/Server/ws/livekit_process.go @@ -13,6 +13,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" "github.com/owncord/server/config" @@ -51,10 +52,30 @@ func NewLiveKitProcess(cfg *config.VoiceConfig, tlsCfg *config.TLSConfig, dataDi } } +// autoGeneratedMarker identifies a livekit.yaml written by OwnCord. A file +// without this marker is treated as user-managed and never overwritten. +const autoGeneratedMarker = "Auto-generated by OwnCord" + // generateConfig writes a minimal livekit.yaml for the companion process. +// If the file exists and lacks the auto-generated marker, it is treated as +// user-managed and left untouched so operators can set LiveKit options +// OwnCord does not model (ips.includes, interfaces, stun_servers, ...). func (p *LiveKitProcess) generateConfig() (string, error) { cfgPath := filepath.Join(p.dataDir, "livekit.yaml") + if existing, err := os.ReadFile(cfgPath); err == nil { + // An empty/whitespace-only file is a truncated leftover, not a + // user-managed config — regenerate it rather than wedging LiveKit. + if len(strings.TrimSpace(string(existing))) > 0 && + !strings.Contains(string(existing), autoGeneratedMarker) { + slog.Info("livekit: livekit.yaml is user-managed (auto-generated marker absent), not overwriting", "path", cfgPath) + slog.Warn("livekit: ensure the keys entry in your livekit.yaml matches voice.livekit_api_key / voice.livekit_api_secret") + return cfgPath, nil + } + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("reading existing livekit config: %w", err) + } + // No TURN TLS config — LiveKit signaling is proxied through OwnCord's // HTTPS server at /livekit/*, so no separate TLS is needed on LiveKit. @@ -78,14 +99,24 @@ func (p *LiveKitProcess) generateConfig() (string, error) { } nodeIPLine = fmt.Sprintf("\n node_ip: %q", p.cfg.NodeIP) } + // Advertise LAN host candidates alongside the external mapping so clients + // on the local network can reach a dual-homed (LAN + public IP) server. + advertiseInternalLine := "" + if p.cfg.AdvertiseInternalIP { + advertiseInternalLine = "\n advertise_internal_ip: true" + } - content := fmt.Sprintf(`# Auto-generated by OwnCord — do not edit manually. + content := fmt.Sprintf(`# Auto-generated by OwnCord — regenerated on every server start. +# To manage this file yourself (custom rtc options, multiple interfaces, etc.), +# delete the first line above; OwnCord will then leave the file untouched. +# Your keys entry must still match voice.livekit_api_key / +# voice.livekit_api_secret in config.yaml. port: 7880 rtc: port_range_start: 50000 port_range_end: 60000 - use_external_ip: true%s + use_external_ip: true%s%s pli_throttle: low_quality: 500ms mid_quality: 1s @@ -96,7 +127,7 @@ keys: logging: level: info -`, nodeIPLine, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret) +`, nodeIPLine, advertiseInternalLine, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret) if err := os.MkdirAll(p.dataDir, 0o750); err != nil { return "", fmt.Errorf("creating data dir: %w", err) diff --git a/Server/ws/livekit_test.go b/Server/ws/livekit_test.go index f4fd591c..ebb1b5b6 100644 --- a/Server/ws/livekit_test.go +++ b/Server/ws/livekit_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "testing" "time" @@ -646,6 +647,158 @@ func TestGenerateConfig_UnsafeNodeIPChars(t *testing.T) { } } +func TestGenerateConfig_WithAdvertiseInternalIP(t *testing.T) { + t.Parallel() + + cfg := &config.VoiceConfig{ + LiveKitAPIKey: "key1", + LiveKitAPISecret: "secret1", + LiveKitURL: "ws://localhost:7880", + NodeIP: "203.0.113.10", + AdvertiseInternalIP: true, + } + proc := ws.NewLiveKitProcess(cfg, &config.TLSConfig{}, t.TempDir()) + + cfgPath, err := proc.GenerateConfigForTest() + if err != nil { + t.Fatalf("generateConfig: %v", err) + } + content, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading config file: %v", err) + } + + got := string(content) + if !strings.Contains(got, "advertise_internal_ip: true") { + t.Errorf("expected advertise_internal_ip in config.\nGot:\n%s", got) + } + if !strings.Contains(got, `node_ip: "203.0.113.10"`) { + t.Errorf("expected node_ip alongside advertise_internal_ip.\nGot:\n%s", got) + } +} + +func TestGenerateConfig_DefaultOmitsAdvertiseInternalIP(t *testing.T) { + t.Parallel() + + cfg := &config.VoiceConfig{ + LiveKitAPIKey: "key1", + LiveKitAPISecret: "secret1", + LiveKitURL: "ws://localhost:7880", + } + proc := ws.NewLiveKitProcess(cfg, &config.TLSConfig{}, t.TempDir()) + + cfgPath, err := proc.GenerateConfigForTest() + if err != nil { + t.Fatalf("generateConfig: %v", err) + } + content, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading config file: %v", err) + } + + if strings.Contains(string(content), "advertise_internal_ip") { + t.Error("config should not contain advertise_internal_ip by default") + } +} + +func TestGenerateConfig_PreservesUserManagedFile(t *testing.T) { + t.Parallel() + + dataDir := t.TempDir() + cfgPath := filepath.Join(dataDir, "livekit.yaml") + userContent := "# my custom livekit config\nport: 7880\nrtc:\n ips:\n includes: [10.0.0.0/8]\n" + if err := os.WriteFile(cfgPath, []byte(userContent), 0o600); err != nil { + t.Fatalf("writing user config: %v", err) + } + + cfg := &config.VoiceConfig{ + LiveKitAPIKey: "key1", + LiveKitAPISecret: "secret1", + LiveKitURL: "ws://localhost:7880", + } + proc := ws.NewLiveKitProcess(cfg, &config.TLSConfig{}, dataDir) + + gotPath, err := proc.GenerateConfigForTest() + if err != nil { + t.Fatalf("generateConfig: %v", err) + } + if gotPath != cfgPath { + t.Errorf("expected path %q, got %q", cfgPath, gotPath) + } + + content, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading config file: %v", err) + } + if string(content) != userContent { + t.Errorf("user-managed livekit.yaml was modified.\nWant:\n%s\nGot:\n%s", userContent, content) + } +} + +func TestGenerateConfig_RegeneratesEmptyFile(t *testing.T) { + t.Parallel() + + dataDir := t.TempDir() + cfgPath := filepath.Join(dataDir, "livekit.yaml") + // A zero-byte/whitespace-only file is a truncated leftover, not a + // user-managed config — it must be regenerated. + if err := os.WriteFile(cfgPath, []byte(" \n"), 0o600); err != nil { + t.Fatalf("writing empty config: %v", err) + } + + cfg := &config.VoiceConfig{ + LiveKitAPIKey: "key1", + LiveKitAPISecret: "secret1", + LiveKitURL: "ws://localhost:7880", + } + proc := ws.NewLiveKitProcess(cfg, &config.TLSConfig{}, dataDir) + + if _, err := proc.GenerateConfigForTest(); err != nil { + t.Fatalf("generateConfig: %v", err) + } + content, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading config file: %v", err) + } + if !strings.Contains(string(content), `"key1": "secret1"`) { + t.Errorf("empty livekit.yaml was not regenerated.\nGot:\n%s", content) + } +} + +func TestGenerateConfig_OverwritesAutoGeneratedFile(t *testing.T) { + t.Parallel() + + dataDir := t.TempDir() + cfgPath := filepath.Join(dataDir, "livekit.yaml") + old := "# Auto-generated by OwnCord — do not edit manually.\nport: 7880\nstale: true\n" + if err := os.WriteFile(cfgPath, []byte(old), 0o600); err != nil { + t.Fatalf("writing old config: %v", err) + } + + cfg := &config.VoiceConfig{ + LiveKitAPIKey: "key1", + LiveKitAPISecret: "secret1", + LiveKitURL: "ws://localhost:7880", + } + proc := ws.NewLiveKitProcess(cfg, &config.TLSConfig{}, dataDir) + + if _, err := proc.GenerateConfigForTest(); err != nil { + t.Fatalf("generateConfig: %v", err) + } + + content, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading config file: %v", err) + } + got := string(content) + if strings.Contains(got, "stale: true") { + t.Error("auto-generated livekit.yaml was not regenerated") + } + if !strings.Contains(got, `"key1": "secret1"`) { + t.Errorf("regenerated config missing keys.\nGot:\n%s", got) + } +} + // --------------------------------------------------------------------------- // livekit_process.go – Start guard tests // --------------------------------------------------------------------------- diff --git a/Server/ws/serve.go b/Server/ws/serve.go index f6fc588b..5ecb6894 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -112,6 +112,16 @@ func (h *Hub) upgradeAndAuth( func (h *Hub) handleReconnect( ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB, lastSeq uint64, ) bool { + // Channel-visibility changes are delivered as targeted, unsequenced + // messages, so replay cannot bring a client that missed one back into a + // coherent state — force the full-ready path instead. + if h.mustFullResync(lastSeq) { + slog.Info("ws replay skipped (visibility changed since last_seq), sending full ready", + "user_id", c.userID, "last_seq", lastSeq) + h.reconnectTierFull.Add(1) + telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full")) + return false + } // Compute the set of channel IDs the reconnecting user can access so that // channel-scoped replay events are filtered by current permissions (M3). allowedChannelIDs, err := h.computeAllowedChannels(database, c.user) diff --git a/docs/livekit-setup.md b/docs/livekit-setup.md index 9fcdbd60..10446235 100644 --- a/docs/livekit-setup.md +++ b/docs/livekit-setup.md @@ -86,6 +86,8 @@ voice: | `livekit_api_secret` | Shared secret for JWT signing (min 32 chars) | `"owncord-dev-secret-key-min-32chars"` | | `livekit_url` | LiveKit WebSocket URL | `ws://localhost:7880` | | `livekit_binary` | Path to `livekit-server` binary. Empty = assume externally managed | `""` (disabled) | +| `node_ip` | Public IP for WebRTC ICE candidates (remote users behind NAT) | `""` (auto-detect) | +| `advertise_internal_ip` | Also advertise LAN IPs — enable on dual-homed servers (LAN + public IP) so local clients can connect | `false` | | `quality` | Default voice quality preset | `"medium"` | Environment variable overrides use the `OWNCORD_` prefix: `OWNCORD_VOICE_LIVEKIT_API_KEY`, `OWNCORD_VOICE_LIVEKIT_API_SECRET`, etc. @@ -110,7 +112,7 @@ For LAN-only setups, ensure these ports are open on Windows Firewall. For remote When `livekit_binary` is set, OwnCord manages LiveKit as a companion process: -1. **Config generation**: OwnCord auto-generates `data/livekit.yaml` with the API key/secret, port 7880, and UDP range 50000-60000 +1. **Config generation**: OwnCord auto-generates `data/livekit.yaml` with the API key/secret, port 7880, and UDP range 50000-60000. To manage the file yourself (custom `rtc` options, multiple interfaces, ...), delete the header line containing the auto-generated marker — OwnCord then leaves the file untouched on future starts. Your `keys:` entry must still match `voice.livekit_api_key` / `voice.livekit_api_secret`. 2. **Process launch**: `livekit-server --config data/livekit.yaml` 3. **Crash recovery**: Exponential backoff restart (3s -> 6s -> 12s ... up to 60s), gives up after 10 consecutive rapid failures 4. **Health checks**: `GET http://localhost:7880/` verifies LiveKit is responding @@ -169,6 +171,7 @@ LiveKit sends webhooks to `POST /api/v1/livekit/webhook`. The endpoint verifies | "backend unavailable" from `/livekit` proxy | LiveKit not running on port 7880 | Check `livekit_binary` path or start LiveKit manually | | "too many rapid failures, giving up" in logs | LiveKit binary crashes on startup | Run `livekit-server --config data/livekit.yaml` manually to see errors | | Mixed content / insecure WS error | Client using direct URL over HTTPS page | Client should use the `/livekit` proxy path | +| Voice works via public IP but not on the LAN (dual-homed server) | LiveKit only advertises the public `node_ip` | Set `voice.advertise_internal_ip: true` so LAN host candidates are advertised too | | `GET /api/v1/livekit/health` returns degraded | LiveKit server not reachable | Verify LiveKit is running: `curl http://localhost:7880` | --- diff --git a/docs/quick-start.md b/docs/quick-start.md index 1f1e8590..b80e335d 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -78,6 +78,9 @@ npm run tauri build - The desktop client uses TOFU certificate pinning: - First connection prompts for trust. - Future connections require the same cert fingerprint. +- Linux/Wayland: the client automatically sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` + on Wayland sessions to work around WebKitGTK rendering crashes. Export the + variable yourself (any value) before launching to override this. ## If Remote Users Cannot Connect diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 8f10d8ea..73cd38ad 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -57,10 +57,17 @@ Configuration is loaded in three layers (later layers override earlier ones): | `voice.livekit_url` | string | `"ws://localhost:7880"` | LiveKit server WebSocket URL | | `voice.livekit_binary` | string | `""` | Path to `livekit-server` binary; empty = don't auto-start | | `voice.node_ip` | string | `""` | Public IP for WebRTC ICE candidates; empty = auto-detect. Required for remote users behind NAT. | +| `voice.advertise_internal_ip` | bool | `false` | Also advertise internal (LAN) IPs as ICE candidates. Enable when the server is reachable via both a LAN IP and a public IP so local-network clients can connect to voice. | | `voice.quality` | string | `"medium"` | Voice quality preset: `low`, `medium`, `high` | > **Warning:** If `livekit_api_key` or `livekit_api_secret` are left empty, random credentials are generated on each startup. This means voice tokens break on restart. Always set stable credentials in production. See [LiveKit Setup](livekit-setup.md) for details. +#### Server with both a LAN and a public IP + +If your server is dual-homed (e.g. `192.168.1.10` on the LAN and `47.x.x.x` public), set `voice.node_ip` to the public IP **and** `voice.advertise_internal_ip: true`. LiveKit then advertises the LAN address in addition to the public one, so clients on the local network connect directly while remote clients use the public IP. + +For LiveKit options OwnCord does not model, you can take ownership of the auto-started server's config: edit `data/livekit.yaml` and delete the header line containing the auto-generated marker — OwnCord will stop regenerating the file on startup (your `keys:` entry must still match `voice.livekit_api_key` / `voice.livekit_api_secret`). + ### GitHub / Updates (`github`) | Key | Type | Default | Description | @@ -128,6 +135,7 @@ Every config key can be overridden via environment variables using the prefix `O | `OWNCORD_VOICE_LIVEKIT_API_SECRET` | `voice.livekit_api_secret` | | `OWNCORD_VOICE_LIVEKIT_URL` | `voice.livekit_url` | | `OWNCORD_VOICE_NODE_IP` | `voice.node_ip` | +| `OWNCORD_VOICE_ADVERTISE_INTERNAL_IP` | `voice.advertise_internal_ip` | | `OWNCORD_VOICE_QUALITY` | `voice.quality` | | `OWNCORD_GITHUB_TOKEN` | `github.token` | | `OWNCORD_EVENT_PERSISTENCE_ENABLED` | `event_persistence.enabled` | @@ -176,6 +184,7 @@ voice: livekit_url: "ws://localhost:7880" livekit_binary: "" # path to livekit-server binary node_ip: "" # public IP for remote users behind NAT + advertise_internal_ip: false # also advertise LAN IPs (dual-homed servers) quality: "medium" # low | medium | high github: