Merge pull request #1183 from J3vb/claude/repo-issues-features-w6r87a

Fix open issues: window restore, Wayland, password reveal, screenshare volume/FPS, dual-homed voice, private channels
This commit is contained in:
J3vb
2026-07-19 13:30:20 +02:00
committed by GitHub
38 changed files with 1835 additions and 57 deletions
@@ -16,8 +16,10 @@
"core:window:allow-set-size", "core:window:allow-set-size",
"core:window:allow-maximize", "core:window:allow-maximize",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-is-minimized",
"core:window:allow-outer-position", "core:window:allow-outer-position",
"core:window:allow-outer-size", "core:window:allow-outer-size",
"core:window:allow-available-monitors",
"store:default", "store:default",
"global-shortcut:default", "global-shortcut:default",
"global-shortcut:allow-register", "global-shortcut:allow-register",
+11
View File
@@ -2,5 +2,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { 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() owncord_client_lib::run()
} }
+17 -3
View File
@@ -82,13 +82,24 @@ fn is_key_down(vk: i32) -> bool {
use device_query::{DeviceQuery, DeviceState}; use device_query::{DeviceQuery, DeviceState};
// Cache DeviceState per thread — creating it on every call would open/close // Cache DeviceState per thread — creating it on every call would open/close
// /dev/input/ file descriptors every 20ms in the polling loop. // /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! { thread_local! {
static DEVICE_STATE: DeviceState = DeviceState::new(); static DEVICE_STATE: Option<DeviceState> = {
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 { let Some(keycode) = linux::vk_to_keycode(vk) else {
return false; 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")))] #[cfg(not(any(windows, target_os = "linux")))]
@@ -400,7 +411,10 @@ pub async fn ptt_listen_for_key() -> i32 {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
use device_query::{DeviceQuery, DeviceState}; 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); let deadline = std::time::Instant::now() + Duration::from_secs(10);
while std::time::Instant::now() < deadline { while std::time::Instant::now() < deadline {
@@ -6,6 +6,8 @@
import { createElement, appendChildren } from "@lib/dom"; import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons"; import { createIcon } from "@lib/icons";
import { import {
getScreenshareAudioMuted,
getScreenshareAudioVolume,
muteScreenshareAudio, muteScreenshareAudio,
setScreenshareAudioVolume, setScreenshareAudioVolume,
setUserVolume, setUserVolume,
@@ -304,8 +306,13 @@ export function createVideoGrid(): VideoGridComponent {
// Add audio control overlay for remote tiles // Add audio control overlay for remote tiles
if (config !== undefined && !config.isSelf) { if (config !== undefined && !config.isSelf) {
let muted = false; // Screenshare audio state survives tile rebuilds — initialize from it.
let currentVolume = 100; // 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" }); const overlay = createElement("div", { class: "video-tile-overlay" });
@@ -313,8 +320,8 @@ export function createVideoGrid(): VideoGridComponent {
const volumeSlider = createElement("input", { const volumeSlider = createElement("input", {
type: "range", type: "range",
min: "0", min: "0",
max: "200", max: config.isScreenshare ? "100" : "200",
value: "100", value: String(currentVolume),
class: "tile-volume-slider", class: "tile-volume-slider",
"aria-label": "Volume", "aria-label": "Volume",
}); });
@@ -324,9 +331,10 @@ export function createVideoGrid(): VideoGridComponent {
const wasMuted = muted; const wasMuted = muted;
muted = currentVolume === 0; muted = currentVolume === 0;
if (config.isScreenshare) { 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); muteScreenshareAudio(config.audioUserId, muted);
setScreenshareAudioVolume(config.audioUserId, currentVolume / 200); setScreenshareAudioVolume(config.audioUserId, currentVolume / 100);
} else { } else {
setUserVolume(config.audioUserId, currentVolume); setUserVolume(config.audioUserId, currentVolume);
} }
@@ -340,9 +348,10 @@ export function createVideoGrid(): VideoGridComponent {
// Mute button // Mute button
const muteBtn = createElement("button", { const muteBtn = createElement("button", {
class: "tile-mute-btn", 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", () => { muteBtn.addEventListener("click", () => {
muted = !muted; muted = !muted;
@@ -357,6 +366,7 @@ export function createVideoGrid(): VideoGridComponent {
if (currentVolume === 0) currentVolume = 100; if (currentVolume === 0) currentVolume = 100;
if (config.isScreenshare) { if (config.isScreenshare) {
muteScreenshareAudio(config.audioUserId, false); muteScreenshareAudio(config.audioUserId, false);
setScreenshareAudioVolume(config.audioUserId, currentVolume / 100);
} else { } else {
setUserVolume(config.audioUserId, currentVolume); setUserVolume(config.audioUserId, currentVolume);
} }
@@ -269,6 +269,43 @@ function buildVoiceAudioTabInner(
section.appendChild(qualityDesc); section.appendChild(qualityDesc);
section.appendChild(qualitySelect); 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<number>("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 // Video device selector
const videoHeader = createElement("h3", {}, "Video Device"); const videoHeader = createElement("h3", {}, "Video Device");
const videoSelect = createElement("select", { const videoSelect = createElement("select", {
+28 -9
View File
@@ -31,6 +31,9 @@ export class AudioElements {
private screenshareAudioElements = new Map<number, Set<HTMLAudioElement>>(); private screenshareAudioElements = new Map<number, Set<HTMLAudioElement>>();
/** Persisted mute state for screenshare audio so replacement tracks inherit UI state. */ /** Persisted mute state for screenshare audio so replacement tracks inherit UI state. */
private screenshareAudioMutedByUser = new Map<number, boolean>(); private screenshareAudioMutedByUser = new Map<number, boolean>();
/** 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<number, number>();
/** Master output volume multiplier (0-2.0). Per-user volumes are scaled by this. */ /** Master output volume multiplier (0-2.0). Per-user volumes are scaled by this. */
private outputVolumeMultiplier: number; private outputVolumeMultiplier: number;
@@ -54,8 +57,12 @@ export class AudioElements {
return (userVol / 100) * this.outputVolumeMultiplier; return (userVol / 100) * this.outputVolumeMultiplier;
} }
private getScreenshareOutputVolume(): number { /** Effective element volume for a user's screenshare audio: per-user volume
return Math.max(0, Math.min(1, this.outputVolumeMultiplier)); * 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 --- // --- Track subscription handlers ---
@@ -80,7 +87,7 @@ export class AudioElements {
const audioEl = track.attach(); const audioEl = track.attach();
audioEl.style.display = "none"; audioEl.style.display = "none";
document.body.appendChild(audioEl); document.body.appendChild(audioEl);
audioEl.volume = this.getScreenshareOutputVolume(); audioEl.volume = this.getEffectiveScreenshareVolume(userId);
audioEl.muted = this.screenshareAudioMutedByUser.get(userId) ?? false; audioEl.muted = this.screenshareAudioMutedByUser.get(userId) ?? false;
let audioEls = this.screenshareAudioElements.get(userId); let audioEls = this.screenshareAudioElements.get(userId);
if (audioEls === undefined) { if (audioEls === undefined) {
@@ -187,10 +194,12 @@ export class AudioElements {
savePref("outputVolume", clamped); savePref("outputVolume", clamped);
this.outputVolumeMultiplier = clamped / 100; this.outputVolumeMultiplier = clamped / 100;
this.applyAllVolumes(); this.applyAllVolumes();
const screenshareVolume = this.getScreenshareOutputVolume(); // Re-apply per-user screenshare volumes scaled by the new master value
for (const audioEls of this.screenshareAudioElements.values()) { // (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) { for (const audioEl of audioEls) {
audioEl.volume = screenshareVolume; audioEl.volume = effective;
} }
} }
} }
@@ -198,10 +207,19 @@ export class AudioElements {
// --- Screenshare audio --- // --- Screenshare audio ---
setScreenshareAudioVolume(userId: number, volume: number): void { 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); const audioEls = this.screenshareAudioElements.get(userId);
if (audioEls === undefined) return; if (audioEls === undefined) return;
const clamped = Math.max(0, Math.min(1, volume)); const effective = this.getEffectiveScreenshareVolume(userId);
for (const el of audioEls) el.volume = clamped; 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 { muteScreenshareAudio(userId: number, muted: boolean): void {
@@ -242,9 +260,10 @@ export class AudioElements {
this.screenshareAudioElements.clear(); 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 { cleanupAllAudioElementsFull(): void {
this.cleanupAllAudioElements(); this.cleanupAllAudioElements();
this.screenshareAudioMutedByUser.clear(); this.screenshareAudioMutedByUser.clear();
this.screenshareVolumeByUser.clear();
} }
} }
+12 -3
View File
@@ -32,8 +32,10 @@ import {
type ScreenTrackState, type ScreenTrackState,
CAMERA_PRESETS, CAMERA_PRESETS,
CAMERA_PUBLISH_BITRATES, CAMERA_PUBLISH_BITRATES,
SCREENSHARE_PUBLISH_BITRATES,
getStreamQuality, getStreamQuality,
getScreenShareFps,
getEffectiveScreenShareFps,
getScreenShareMaxBitrate,
enableCamera as doEnableCamera, enableCamera as doEnableCamera,
disableCamera as doDisableCamera, disableCamera as doDisableCamera,
stopManualCameraTrack, stopManualCameraTrack,
@@ -344,9 +346,11 @@ export class LiveKitSession {
maxBitrate: CAMERA_PUBLISH_BITRATES[quality], maxBitrate: CAMERA_PUBLISH_BITRATES[quality],
maxFramerate: quality === "low" ? 15 : 30, maxFramerate: quality === "low" ? 15 : 30,
}, },
// Fallback for setScreenShareEnabled paths — the manual publish in
// screenShare.ts passes explicit per-track encoding that overrides this.
screenShareEncoding: { screenShareEncoding: {
maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality], maxBitrate: getScreenShareMaxBitrate(quality, getScreenShareFps()),
maxFramerate: quality === "low" ? 5 : quality === "medium" ? 15 : 30, maxFramerate: getEffectiveScreenShareFps(quality, getScreenShareFps()),
}, },
}, },
// End-to-end encryption: SFrame-based E2EE using a server-distributed // End-to-end encryption: SFrame-based E2EE using a server-distributed
@@ -1590,6 +1594,10 @@ export class LiveKitSession {
this._audioElements.setScreenshareAudioVolume(userId, volume); this._audioElements.setScreenshareAudioVolume(userId, volume);
} }
getScreenshareAudioVolume(userId: number): number {
return this._audioElements.getScreenshareAudioVolume(userId);
}
muteScreenshareAudio(userId: number, muted: boolean): void { muteScreenshareAudio(userId: number, muted: boolean): void {
this._audioElements.muteScreenshareAudio(userId, muted); this._audioElements.muteScreenshareAudio(userId, muted);
} }
@@ -1697,6 +1705,7 @@ export const getLocalScreenshareStream = session.getLocalScreenshareStream.bind(
export const getRemoteVideoStream = session.getRemoteVideoStream.bind(session); export const getRemoteVideoStream = session.getRemoteVideoStream.bind(session);
export const getSessionDebugInfo = session.getSessionDebugInfo.bind(session); export const getSessionDebugInfo = session.getSessionDebugInfo.bind(session);
export const setScreenshareAudioVolume = session.setScreenshareAudioVolume.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 muteScreenshareAudio = session.muteScreenshareAudio.bind(session);
export const getScreenshareAudioMuted = session.getScreenshareAudioMuted.bind(session); export const getScreenshareAudioMuted = session.getScreenshareAudioMuted.bind(session);
+68 -4
View File
@@ -72,6 +72,67 @@ export function getStreamQuality(): StreamQuality {
return "high"; 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<number>("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<Record<number, number>> = { 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) // Dependencies injected by the caller (LiveKitSession)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -203,9 +264,12 @@ export async function enableScreenshare(
} }
setLocalScreenshare(true); setLocalScreenshare(true);
const quality = getStreamQuality(); const quality = getStreamQuality();
const fps = getScreenShareFps();
const effectiveFps = getEffectiveScreenShareFps(quality, fps);
const maxBitrate = getScreenShareMaxBitrate(quality, fps);
try { try {
stopManualScreenTracks(state, room); stopManualScreenTracks(state, room);
const screenTracks = await createLocalScreenTracks(SCREENSHARE_PRESETS[quality]); const screenTracks = await createLocalScreenTracks(getScreenShareCaptureOptions(quality, fps));
state.manualScreenTracks = screenTracks; state.manualScreenTracks = screenTracks;
for (const track of screenTracks) { for (const track of screenTracks) {
const isVideo = track.kind === Track.Kind.Video; const isVideo = track.kind === Track.Kind.Video;
@@ -216,8 +280,8 @@ export async function enableScreenshare(
...(isVideo ...(isVideo
? { ? {
videoEncoding: { videoEncoding: {
maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality], maxBitrate,
maxFramerate: quality === "low" ? 5 : quality === "medium" ? 15 : 30, maxFramerate: effectiveFps,
}, },
} }
: {}), : {}),
@@ -237,7 +301,7 @@ export async function enableScreenshare(
} }
ws.send({ type: "voice_screenshare", payload: { enabled: true } }); ws.send({ type: "voice_screenshare", payload: { enabled: true } });
deps.reapplyAudioPipeline(); deps.reapplyAudioPipeline();
log.info("Screenshare enabled", { quality, maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality] }); log.info("Screenshare enabled", { quality, fps: effectiveFps, maxBitrate });
} catch (err) { } catch (err) {
// BUG-100: Stop created tracks to release screen capture if publish failed. // BUG-100: Stop created tracks to release screen capture if publish failed.
for (const t of state.manualScreenTracks) { for (const t of state.manualScreenTracks) {
+79 -3
View File
@@ -18,6 +18,34 @@ export interface WindowState {
const STORAGE_KEY = "windowState"; const STORAGE_KEY = "windowState";
const SAVE_DEBOUNCE_MS = 500; 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< const invokePromise: Promise<
((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null
> = import("@tauri-apps/api/core") > = import("@tauri-apps/api/core")
@@ -56,7 +84,13 @@ async function loadState(): Promise<WindowState | null> {
typeof s.y === "number" && typeof s.y === "number" &&
typeof s.width === "number" && typeof s.width === "number" &&
typeof s.height === "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 { return {
x: s.x, x: s.x,
@@ -74,6 +108,27 @@ async function loadState(): Promise<WindowState | null> {
} }
} }
/**
* 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<boolean> {
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. * Initialize window state persistence.
* Restores saved position/size on startup and listens for changes. * Restores saved position/size on startup and listens for changes.
@@ -96,18 +151,28 @@ export async function initWindowState(): Promise<() => void> {
try { try {
if (saved.maximized) { if (saved.maximized) {
await win.maximize(); 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 pos = new tauriWindow.PhysicalPosition(saved.x, saved.y);
const size = new tauriWindow.PhysicalSize(saved.width, saved.height); const size = new tauriWindow.PhysicalSize(saved.width, saved.height);
await win.setPosition(pos); await win.setPosition(pos);
await win.setSize(size); await win.setSize(size);
}
log.info("Restored window state", { log.info("Restored window state", {
x: saved.x, x: saved.x,
y: saved.y, y: saved.y,
width: saved.width, width: saved.width,
height: saved.height, 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,
});
}
} catch (err) { } catch (err) {
log.warn("Failed to restore window state", { error: String(err) }); log.warn("Failed to restore window state", { error: String(err) });
} }
@@ -123,6 +188,17 @@ export async function initWindowState(): Promise<() => void> {
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
void (async () => { void (async () => {
try { 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 pos = await win.outerPosition();
const size = await win.outerSize(); const size = await win.outerSize();
const maximized = await win.isMaximized(); const maximized = await win.isMaximized();
+12
View File
@@ -57,6 +57,18 @@ select {
outline: none; 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 { a {
color: var(--text-link); color: var(--text-link);
text-decoration: none; text-decoration: none;
@@ -237,6 +237,43 @@ describe("AudioElements", () => {
expect(audioEl.volume).toBe(1); 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", () => { it("muteScreenshareAudio persists muted state", () => {
elements.muteScreenshareAudio(42, true); elements.muteScreenshareAudio(42, true);
expect(elements.getScreenshareAudioMuted(42)).toBe(true); expect(elements.getScreenshareAudioMuted(42)).toBe(true);
@@ -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);
});
});
});
@@ -256,8 +256,8 @@ describe("SettingsOverlay", () => {
getTab(container, 5).click(); getTab(container, 5).click();
const selects = container.querySelectorAll("select.form-input"); const selects = container.querySelectorAll("select.form-input");
// input device, output device, video quality, video device = 4 // input device, output device, video quality, screen share fps, video device = 5
expect(selects.length).toBe(4); expect(selects.length).toBe(5);
const sliders = container.querySelectorAll(".settings-slider"); const sliders = container.querySelectorAll(".settings-slider");
expect(sliders.length).toBeGreaterThanOrEqual(1); expect(sliders.length).toBeGreaterThanOrEqual(1);
@@ -7,11 +7,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockMuteScreenshareAudio = vi.fn(); const mockMuteScreenshareAudio = vi.fn();
const mockSetScreenshareAudioVolume = vi.fn(); const mockSetScreenshareAudioVolume = vi.fn();
const mockSetUserVolume = vi.fn(); const mockSetUserVolume = vi.fn();
const mockGetScreenshareAudioMuted = vi.fn((_userId?: unknown) => false);
const mockGetScreenshareAudioVolume = vi.fn((_userId?: unknown) => 1);
vi.mock("@lib/livekitSession", () => ({ vi.mock("@lib/livekitSession", () => ({
muteScreenshareAudio: (...args: unknown[]) => mockMuteScreenshareAudio(...args), muteScreenshareAudio: (...args: unknown[]) => mockMuteScreenshareAudio(...args),
setScreenshareAudioVolume: (...args: unknown[]) => mockSetScreenshareAudioVolume(...args), setScreenshareAudioVolume: (...args: unknown[]) => mockSetScreenshareAudioVolume(...args),
setUserVolume: (...args: unknown[]) => mockSetUserVolume(...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); 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", () => { it("mute button unmutes with previous volume when currentVolume was non-zero", () => {
const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false }); const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false });
grid.addStream(77, "dave", fakeStream(), config); grid.addStream(77, "dave", fakeStream(), config);
@@ -197,8 +197,8 @@ describe("VoiceAudioTab UI structure", () => {
document.body.appendChild(el); document.body.appendChild(el);
const selects = el.querySelectorAll("select"); const selects = el.querySelectorAll("select");
// Input, output, stream quality, video = 4 selects // Input, output, stream quality, screen share fps, video = 5 selects
expect(selects.length).toBe(4); expect(selects.length).toBe(5);
ac.abort(); ac.abort();
}); });
@@ -357,6 +357,24 @@ describe("VoiceAudioTab UI structure", () => {
ac.abort(); 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", () => { it("contains audio processing toggles", () => {
stubNavigator(); stubNavigator();
const ac = new AbortController(); const ac = new AbortController();
@@ -488,11 +506,11 @@ describe("VoiceAudioTab UI structure", () => {
// Wait for devices to load // Wait for devices to load
await vi.waitFor(() => { 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); 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.value = "cam-1";
videoSelect.dispatchEvent(new Event("change")); videoSelect.dispatchEvent(new Event("change"));
@@ -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<string, unknown>,
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<string, unknown>): 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();
});
});
});
+3
View File
@@ -46,6 +46,9 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
r.Post("/channels", handleCreateChannel(database, hub)) r.Post("/channels", handleCreateChannel(database, hub))
r.Patch("/channels/{id}", handlePatchChannel(database, hub)) r.Patch("/channels/{id}", handlePatchChannel(database, hub))
r.Delete("/channels/{id}", handleDeleteChannel(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("/audit-log", handleGetAuditLog(database))
r.Get("/settings", handleGetSettings(database)) r.Get("/settings", handleGetSettings(database))
r.Patch("/settings", handlePatchSettings(database)) r.Patch("/settings", handlePatchSettings(database))
+14
View File
@@ -87,6 +87,15 @@ CREATE TABLE IF NOT EXISTS channels (
voice_max_video INTEGER NOT NULL DEFAULT 0 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 ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
@@ -1113,6 +1122,7 @@ type mockHub struct {
channelDeleteIDs []int64 channelDeleteIDs []int64
memberBanIDs []int64 memberBanIDs []int64
memberUpdates []memberUpdateCall memberUpdates []memberUpdateCall
visibilityRefreshes []*db.Channel
clientCount int clientCount int
} }
@@ -1150,6 +1160,10 @@ func (m *mockHub) BroadcastMemberUpdate(userID int64, roleName string) {
m.memberUpdates = append(m.memberUpdates, memberUpdateCall{userID, roleName}) 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 { func (m *mockHub) ClientCount() int {
return m.clientCount return m.clientCount
} }
+161
View File
@@ -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)
}
}
+255
View File
@@ -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)
}
}
@@ -402,6 +402,7 @@ func (m *mockHubWB) BroadcastChannelUpdate(ch *db.Channel) {}
func (m *mockHubWB) BroadcastChannelDelete(channelID int64) {} func (m *mockHubWB) BroadcastChannelDelete(channelID int64) {}
func (m *mockHubWB) BroadcastMemberBan(userID int64) {} func (m *mockHubWB) BroadcastMemberBan(userID int64) {}
func (m *mockHubWB) BroadcastMemberUpdate(userID int64, roleName string) {} func (m *mockHubWB) BroadcastMemberUpdate(userID int64, roleName string) {}
func (m *mockHubWB) RefreshChannelVisibility(ch *db.Channel) {}
func (m *mockHubWB) ClientCount() int { return 0 } func (m *mockHubWB) ClientCount() int { return 0 }
// TestSpawnDetached_ValidExecutable verifies that spawnDetached can start a // TestSpawnDetached_ValidExecutable verifies that spawnDetached can start a
+51 -4
View File
@@ -277,6 +277,7 @@ const I={
voice:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>', voice:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>',
megaphone:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>', megaphone:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
logs:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>', logs:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
lock:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
}; };
/* ═══ State ═══ */ /* ═══ State ═══ */
@@ -300,6 +301,10 @@ async function api(method,path,body){
/* ═══ Utilities ═══ */ /* ═══ Utilities ═══ */
function esc(s){if(s===null||s===undefined)return'';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')} function esc(s){if(s===null||s===undefined)return'';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
/* 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 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 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)'} 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+='<td><span class="dot '+statusDot+'"></span>'+statusLabel+'</td>'; html+='<td><span class="dot '+statusDot+'"></span>'+statusLabel+'</td>';
html+='<td>'+(banned?'<span class="badge badge-red">Yes</span>':'<span class="badge badge-muted">No</span>')+'</td>'; html+='<td>'+(banned?'<span class="badge badge-red">Yes</span>':'<span class="badge badge-muted">No</span>')+'</td>';
html+='<td><div class="act-group" style="justify-content:flex-end">'; html+='<td><div class="act-group" style="justify-content:flex-end">';
html+='<button class="act-btn" title="Edit role" onclick="openEditUser('+uid+',\''+esc(uname)+'\','+rid+')">'+I.edit+'</button>'; html+='<button class="act-btn" title="Edit role" onclick="openEditUser('+uid+',\''+jsq(uname)+'\','+rid+')">'+I.edit+'</button>';
html+='<button class="act-btn" title="Force logout" onclick="forceLogout('+uid+')">'+I.disconnect+'</button>'; html+='<button class="act-btn" title="Force logout" onclick="forceLogout('+uid+')">'+I.disconnect+'</button>';
if(banned)html+='<button class="act-btn" title="Unban" onclick="unbanUser('+uid+')">'+I.check+'</button>'; if(banned)html+='<button class="act-btn" title="Unban" onclick="unbanUser('+uid+')">'+I.check+'</button>';
else html+='<button class="act-btn danger" title="Ban" onclick="openBanUser('+uid+',\''+esc(uname)+'\')">'+I.ban+'</button>'; else html+='<button class="act-btn danger" title="Ban" onclick="openBanUser('+uid+',\''+jsq(uname)+'\')">'+I.ban+'</button>';
html+='</div></td></tr>'; html+='</div></td></tr>';
}); });
html+='</tbody></table></div></div>'; html+='</tbody></table></div></div>';
@@ -527,7 +532,8 @@ async function renderChannels(){
html+='<td><span class="badge '+(type==='voice'?'badge-yellow':type==='announcement'?'badge-accent':'badge-muted')+'">'+esc(type)+'</span></td>'; html+='<td><span class="badge '+(type==='voice'?'badge-yellow':type==='announcement'?'badge-accent':'badge-muted')+'">'+esc(type)+'</span></td>';
html+='<td style="font-size:12px;color:var(--text-faint)">'+esc(cat)+'</td>'; html+='<td style="font-size:12px;color:var(--text-faint)">'+esc(cat)+'</td>';
html+='<td>'+(archived?'<span class="badge badge-muted">Yes</span>':'<span class="badge badge-green">No</span>')+'</td>'; html+='<td>'+(archived?'<span class="badge badge-muted">Yes</span>':'<span class="badge badge-green">No</span>')+'</td>';
html+='<td><div class="act-group" style="justify-content:flex-end"><button class="act-btn" title="Edit" onclick="openChannelEditModal('+id+',\''+esc(name)+'\')">'+I.edit+'</button><button class="act-btn danger" title="Delete" onclick="openDeleteChannel('+id+',\''+esc(name)+'\')">'+I.trash+'</button></div></td></tr>'; const lockBtn=type==='dm'?'':'<button class="act-btn" title="Access (private channel)" onclick="openChannelPermsModal('+id+',\''+jsq(name)+'\')">'+I.lock+'</button>';
html+='<td><div class="act-group" style="justify-content:flex-end"><button class="act-btn" title="Edit" onclick="openChannelEditModal('+id+',\''+jsq(name)+'\')">'+I.edit+'</button>'+lockBtn+'<button class="act-btn danger" title="Delete" onclick="openDeleteChannel('+id+',\''+jsq(name)+'\')">'+I.trash+'</button></div></td></tr>';
}); });
html+='</tbody></table></div></div>'; html+='</tbody></table></div></div>';
return 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')} 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+='<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--bg-active)">'
+'<span style="color:'+roleColor(role.role_id)+';font-weight:600">'+esc(role.role_name)+'</span>'
+(isAdmin
?'<span style="font-size:12px;color:var(--text-faint)">always has access</span>'
:'<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-muted);cursor:pointer"><input type="checkbox" id="permRole'+role.role_id+'" '+(canAccess?'checked':'')+'> Can access</label>')
+'</div>';
});
openModal('<div class="modal-header"><h3>Channel Access — #'+esc(name)+'</h3><button class="modal-close" onclick="closeModal()">&times;</button></div>'
+'<div class="modal-body"><p style="color:var(--text-muted);font-size:13px;margin-bottom:12px">Uncheck a role to hide this channel from it (private channel). Changes apply to connected users immediately; users already in the voice channel are not disconnected.</p>'
+rows+'</div>'
+'<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="saveChannelPerms('+id+')">Save</button></div>');
}
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 ═══ */ /* ═══ Audit Log ═══ */
async function renderAudit(){ async function renderAudit(){
const offset=(state.auditPage-1)*PAGE_SIZE; const offset=(state.auditPage-1)*PAGE_SIZE;
@@ -804,7 +851,7 @@ async function createBackup(){
} }
function openRestoreModal(name){ function openRestoreModal(name){
openModal('<div class="modal-header"><h3>Restore Backup</h3><button class="modal-close" onclick="closeModal()">&times;</button></div><div class="modal-body"><p style="color:var(--text-muted)">Overwrite the current database with <strong style="color:white">'+esc(name)+'</strong>? A pre-restore backup will be created. Server restart recommended after restore.</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmRestore(\''+esc(name)+'\')">Restore</button></div>'); openModal('<div class="modal-header"><h3>Restore Backup</h3><button class="modal-close" onclick="closeModal()">&times;</button></div><div class="modal-body"><p style="color:var(--text-muted)">Overwrite the current database with <strong style="color:white">'+esc(name)+'</strong>? A pre-restore backup will be created. Server restart recommended after restore.</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmRestore(\''+jsq(name)+'\')">Restore</button></div>');
} }
async function confirmRestore(name){ async function confirmRestore(name){
+4
View File
@@ -40,6 +40,10 @@ type HubBroadcaster interface {
BroadcastChannelDelete(channelID int64) BroadcastChannelDelete(channelID int64)
BroadcastMemberBan(userID int64) BroadcastMemberBan(userID int64)
BroadcastMemberUpdate(userID int64, roleName string) 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 ClientCount() int
} }
+5
View File
@@ -97,6 +97,10 @@ type VoiceConfig struct {
LiveKitURL string `koanf:"livekit_url"` // LiveKit server WebSocket URL (e.g. ws://localhost:7880) 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 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 NodeIP string `koanf:"node_ip"` // public IP for WebRTC ICE candidates; empty = auto-detect
// 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 Quality string `koanf:"quality"` // low | medium | high
} }
@@ -248,6 +252,7 @@ voice:
livekit_url: "ws://localhost:7880" # LiveKit server WebSocket URL livekit_url: "ws://localhost:7880" # LiveKit server WebSocket URL
# livekit_binary: "" # path to livekit-server binary; empty = don't auto-start # 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) # 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 # quality: "medium" # low | medium | high
# github: # github:
+22
View File
@@ -266,6 +266,7 @@ voice:
livekit_api_key: "mykey" livekit_api_key: "mykey"
livekit_api_secret: "mysecret" livekit_api_secret: "mysecret"
livekit_url: "ws://lk.example.com:7880" livekit_url: "ws://lk.example.com:7880"
advertise_internal_ip: true
` `
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
t.Fatalf("failed to write yaml: %v", err) t.Fatalf("failed to write yaml: %v", err)
@@ -288,6 +289,27 @@ voice:
if cfg.Voice.LiveKitURL != "ws://lk.example.com:7880" { if cfg.Voice.LiveKitURL != "ws://lk.example.com:7880" {
t.Errorf("Voice.LiveKitURL = %q, want 'ws://lk.example.com:7880'", cfg.Voice.LiveKitURL) 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) { func TestLoadEnvOverridesPrecedenceOverYAML(t *testing.T) {
+75
View File
@@ -174,6 +174,81 @@ func (d *DB) GetAllChannelPermissionsForRole(roleID int64) (map[int64]ChannelOve
return result, nil 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 ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
// scanChannel scans a single channel row from *sql.Rows. // scanChannel scans a single channel row from *sql.Rows.
+90
View File
@@ -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 ───────────────────────────────────────────────────── // ─── SetChannelSlowMode ─────────────────────────────────────────────────────
func TestSetChannelSlowMode(t *testing.T) { func TestSetChannelSlowMode(t *testing.T) {
+5
View File
@@ -16,6 +16,11 @@ rtc:
node_ip: "YOUR_SERVER_PUBLIC_IP" node_ip: "YOUR_SERVER_PUBLIC_IP"
# use_external_ip: true # uncomment on cloud VMs instead of node_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: keys:
# Must match LIVEKIT_API_KEY / LIVEKIT_API_SECRET in your .env file # Must match LIVEKIT_API_KEY / LIVEKIT_API_SECRET in your .env file
YOUR_API_KEY: YOUR_API_SECRET YOUR_API_KEY: YOUR_API_SECRET
+7
View File
@@ -26,6 +26,13 @@ const (
Administrator = int64(0x40000000) // bit 30 — bypasses all permission checks 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) ───────────────── // ─── Role ID constants (default roles inserted on first run) ─────────────────
const ( const (
+5
View File
@@ -217,3 +217,8 @@ func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64,
} }
h.handleWebhookParticipantLeft(context.Background(), event) h.handleWebhookParticipantLeft(context.Background(), event)
} }
// MustFullResyncForTest exposes mustFullResync for external tests.
func (h *Hub) MustFullResyncForTest(lastSeq uint64) bool {
return h.mustFullResync(lastSeq)
}
+93
View File
@@ -74,6 +74,13 @@ type Hub struct {
reconnectTierDB atomic.Uint64 reconnectTierDB atomic.Uint64
reconnectTierFull 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. // Settings cache — avoids per-connection DB queries for server_name/motd.
settingsMu syncutil.RWMutex settingsMu syncutil.RWMutex
settingsName string settingsName string
@@ -509,6 +516,92 @@ func (h *Hub) BroadcastChannelDelete(channelID int64) {
h.BroadcastToAll(buildChannelDelete(channelID)) 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 // BroadcastMemberBan sends a member_ban message to all connected clients
// and immediately disconnects the banned user's WebSocket connection (BUG-113). // and immediately disconnects the banned user's WebSocket connection (BUG-113).
func (h *Hub) BroadcastMemberBan(userID int64) { func (h *Hub) BroadcastMemberBan(userID int64) {
+124
View File
@@ -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. // hubTestSchema is the minimal schema needed for hub tests.
var hubTestSchema = []byte(` var hubTestSchema = []byte(`
CREATE TABLE IF NOT EXISTS roles ( CREATE TABLE IF NOT EXISTS roles (
+34 -3
View File
@@ -13,6 +13,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/owncord/server/config" "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. // 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) { func (p *LiveKitProcess) generateConfig() (string, error) {
cfgPath := filepath.Join(p.dataDir, "livekit.yaml") 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 // No TURN TLS config — LiveKit signaling is proxied through OwnCord's
// HTTPS server at /livekit/*, so no separate TLS is needed on LiveKit. // 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) 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 port: 7880
rtc: rtc:
port_range_start: 50000 port_range_start: 50000
port_range_end: 60000 port_range_end: 60000
use_external_ip: true%s use_external_ip: true%s%s
pli_throttle: pli_throttle:
low_quality: 500ms low_quality: 500ms
mid_quality: 1s mid_quality: 1s
@@ -96,7 +127,7 @@ keys:
logging: logging:
level: info 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 { if err := os.MkdirAll(p.dataDir, 0o750); err != nil {
return "", fmt.Errorf("creating data dir: %w", err) return "", fmt.Errorf("creating data dir: %w", err)
+153
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "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 // livekit_process.go Start guard tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+10
View File
@@ -112,6 +112,16 @@ func (h *Hub) upgradeAndAuth(
func (h *Hub) handleReconnect( func (h *Hub) handleReconnect(
ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB, lastSeq uint64, ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB, lastSeq uint64,
) bool { ) 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 // Compute the set of channel IDs the reconnecting user can access so that
// channel-scoped replay events are filtered by current permissions (M3). // channel-scoped replay events are filtered by current permissions (M3).
allowedChannelIDs, err := h.computeAllowedChannels(database, c.user) allowedChannelIDs, err := h.computeAllowedChannels(database, c.user)
+4 -1
View File
@@ -86,6 +86,8 @@ voice:
| `livekit_api_secret` | Shared secret for JWT signing (min 32 chars) | `"owncord-dev-secret-key-min-32chars"` | | `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_url` | LiveKit WebSocket URL | `ws://localhost:7880` |
| `livekit_binary` | Path to `livekit-server` binary. Empty = assume externally managed | `""` (disabled) | | `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"` | | `quality` | Default voice quality preset | `"medium"` |
Environment variable overrides use the `OWNCORD_` prefix: `OWNCORD_VOICE_LIVEKIT_API_KEY`, `OWNCORD_VOICE_LIVEKIT_API_SECRET`, etc. 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: 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` 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 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 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 | | "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 | | "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 | | 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` | | `GET /api/v1/livekit/health` returns degraded | LiveKit server not reachable | Verify LiveKit is running: `curl http://localhost:7880` |
--- ---
+3
View File
@@ -78,6 +78,9 @@ npm run tauri build
- The desktop client uses TOFU certificate pinning: - The desktop client uses TOFU certificate pinning:
- First connection prompts for trust. - First connection prompts for trust.
- Future connections require the same cert fingerprint. - 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 ## If Remote Users Cannot Connect
+9
View File
@@ -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_url` | string | `"ws://localhost:7880"` | LiveKit server WebSocket URL |
| `voice.livekit_binary` | string | `""` | Path to `livekit-server` binary; empty = don't auto-start | | `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.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` | | `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. > **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`) ### GitHub / Updates (`github`)
| Key | Type | Default | Description | | 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_API_SECRET` | `voice.livekit_api_secret` |
| `OWNCORD_VOICE_LIVEKIT_URL` | `voice.livekit_url` | | `OWNCORD_VOICE_LIVEKIT_URL` | `voice.livekit_url` |
| `OWNCORD_VOICE_NODE_IP` | `voice.node_ip` | | `OWNCORD_VOICE_NODE_IP` | `voice.node_ip` |
| `OWNCORD_VOICE_ADVERTISE_INTERNAL_IP` | `voice.advertise_internal_ip` |
| `OWNCORD_VOICE_QUALITY` | `voice.quality` | | `OWNCORD_VOICE_QUALITY` | `voice.quality` |
| `OWNCORD_GITHUB_TOKEN` | `github.token` | | `OWNCORD_GITHUB_TOKEN` | `github.token` |
| `OWNCORD_EVENT_PERSISTENCE_ENABLED` | `event_persistence.enabled` | | `OWNCORD_EVENT_PERSISTENCE_ENABLED` | `event_persistence.enabled` |
@@ -176,6 +184,7 @@ voice:
livekit_url: "ws://localhost:7880" livekit_url: "ws://localhost:7880"
livekit_binary: "" # path to livekit-server binary livekit_binary: "" # path to livekit-server binary
node_ip: "" # public IP for remote users behind NAT 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 quality: "medium" # low | medium | high
github: github: