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