Compare commits
1 Commits
v1.0.74
...
c8814c3e2c
| Author | SHA1 | Date | |
|---|---|---|---|
| c8814c3e2c |
@@ -1,5 +1,4 @@
|
|||||||
# Toggle SSL for local development (true/false)
|
# Toggle SSL for local development (true/false)
|
||||||
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
|
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
|
||||||
# When false: plain HTTP everywhere (only works on localhost)
|
# When false: plain HTTP everywhere (only works on localhost)
|
||||||
# Overrides server/data/variables.json for local development only
|
|
||||||
SSL=true
|
SSL=true
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Desktop chat app with three parts:
|
|||||||
Root `.env`:
|
Root `.env`:
|
||||||
|
|
||||||
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
|
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
|
||||||
- `PORT=3001` changes the server port in local development and overrides the server app setting
|
- `PORT=3001` changes the server port
|
||||||
|
|
||||||
If `SSL=true`, run `./generate-cert.sh` once.
|
If `SSL=true`, run `./generate-cert.sh` once.
|
||||||
|
|
||||||
@@ -25,10 +25,6 @@ Server files:
|
|||||||
|
|
||||||
- `server/data/variables.json` holds `klipyApiKey`
|
- `server/data/variables.json` holds `klipyApiKey`
|
||||||
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
|
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
|
||||||
- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to)
|
|
||||||
- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`)
|
|
||||||
- `server/data/variables.json` can now also hold `serverPort` (1-65535)
|
|
||||||
- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP
|
|
||||||
|
|
||||||
## Main commands
|
## Main commands
|
||||||
|
|
||||||
|
|||||||
2
dev.sh
2
dev.sh
@@ -33,4 +33,4 @@ fi
|
|||||||
exec npx concurrently --kill-others \
|
exec npx concurrently --kill-others \
|
||||||
"cd server && npm run dev" \
|
"cd server && npm run dev" \
|
||||||
"$NG_SERVE" \
|
"$NG_SERVE" \
|
||||||
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage"
|
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron . --no-sandbox --disable-dev-shm-usage"
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { app } from 'electron';
|
|
||||||
import AutoLaunch from 'auto-launch';
|
|
||||||
import { readDesktopSettings } from '../desktop-settings';
|
|
||||||
|
|
||||||
let autoLauncher: AutoLaunch | null = null;
|
|
||||||
|
|
||||||
function resolveLaunchPath(): string {
|
|
||||||
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
|
|
||||||
const appImagePath = process.platform === 'linux'
|
|
||||||
? String(process.env['APPIMAGE'] || '').trim()
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return appImagePath || process.execPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAutoLauncher(): AutoLaunch | null {
|
|
||||||
if (!app.isPackaged) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!autoLauncher) {
|
|
||||||
autoLauncher = new AutoLaunch({
|
|
||||||
name: app.getName(),
|
|
||||||
path: resolveLaunchPath()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return autoLauncher;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setAutoStartEnabled(enabled: boolean): Promise<void> {
|
|
||||||
const launcher = getAutoLauncher();
|
|
||||||
|
|
||||||
if (!launcher) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentlyEnabled = await launcher.isEnabled();
|
|
||||||
|
|
||||||
if (currentlyEnabled === enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
await launcher.enable();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await launcher.disable();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function synchronizeAutoStartSetting(enabled = readDesktopSettings().autoStart): Promise<void> {
|
|
||||||
try {
|
|
||||||
await setAutoStartEnabled(enabled);
|
|
||||||
} catch {
|
|
||||||
// Auto-launch integration should never block app startup or settings saves.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { app } from 'electron';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { createWindow, getMainWindow } from '../window/create-window';
|
|
||||||
|
|
||||||
const CUSTOM_PROTOCOL = 'toju';
|
|
||||||
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
|
||||||
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
|
||||||
|
|
||||||
let pendingDeepLink: string | null = null;
|
|
||||||
|
|
||||||
function resolveDevSingleInstanceExitCode(): number | null {
|
|
||||||
const rawValue = process.env[DEV_SINGLE_INSTANCE_EXIT_CODE_ENV];
|
|
||||||
|
|
||||||
if (!rawValue) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedValue = Number.parseInt(rawValue, 10);
|
|
||||||
|
|
||||||
return Number.isInteger(parsedValue) && parsedValue > 0
|
|
||||||
? parsedValue
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractDeepLink(argv: string[]): string | null {
|
|
||||||
return argv.find((argument) => typeof argument === 'string' && argument.startsWith(DEEP_LINK_PREFIX)) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusMainWindow(): void {
|
|
||||||
const mainWindow = getMainWindow();
|
|
||||||
|
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mainWindow.isMinimized()) {
|
|
||||||
mainWindow.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
mainWindow.show();
|
|
||||||
mainWindow.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function forwardDeepLink(url: string): void {
|
|
||||||
const mainWindow = getMainWindow();
|
|
||||||
|
|
||||||
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isLoadingMainFrame()) {
|
|
||||||
pendingDeepLink = url;
|
|
||||||
|
|
||||||
if (app.isReady() && (!mainWindow || mainWindow.isDestroyed())) {
|
|
||||||
void createWindow();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
focusMainWindow();
|
|
||||||
mainWindow.webContents.send('deep-link-received', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerProtocolClient(): void {
|
|
||||||
if (process.defaultApp) {
|
|
||||||
const appEntrypoint = process.argv[1];
|
|
||||||
|
|
||||||
if (appEntrypoint) {
|
|
||||||
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL, process.execPath, [path.resolve(appEntrypoint)]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initializeDeepLinkHandling(): boolean {
|
|
||||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
|
||||||
|
|
||||||
if (!hasSingleInstanceLock) {
|
|
||||||
const devExitCode = resolveDevSingleInstanceExitCode();
|
|
||||||
|
|
||||||
if (devExitCode != null) {
|
|
||||||
app.exit(devExitCode);
|
|
||||||
} else {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
registerProtocolClient();
|
|
||||||
|
|
||||||
const initialDeepLink = extractDeepLink(process.argv);
|
|
||||||
|
|
||||||
if (initialDeepLink) {
|
|
||||||
pendingDeepLink = initialDeepLink;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.on('second-instance', (_event, argv) => {
|
|
||||||
focusMainWindow();
|
|
||||||
|
|
||||||
const deepLink = extractDeepLink(argv);
|
|
||||||
|
|
||||||
if (deepLink) {
|
|
||||||
forwardDeepLink(deepLink);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on('open-url', (event, url) => {
|
|
||||||
event.preventDefault();
|
|
||||||
forwardDeepLink(url);
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function consumePendingDeepLink(): string | null {
|
|
||||||
const deepLink = pendingDeepLink;
|
|
||||||
|
|
||||||
pendingDeepLink = null;
|
|
||||||
|
|
||||||
return deepLink;
|
|
||||||
}
|
|
||||||
@@ -45,9 +45,9 @@ function linuxSpecificFlags(): void {
|
|||||||
app.commandLine.appendSwitch('no-sandbox');
|
app.commandLine.appendSwitch('no-sandbox');
|
||||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||||
|
|
||||||
// Chromium chooses the Linux Ozone platform before Electron runs this file.
|
// Auto-detect Wayland vs X11 so the xdg-desktop-portal system picker
|
||||||
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland
|
// works for screen capture on Wayland compositors
|
||||||
// sessions so the browser process selects the correct backend early enough.
|
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
|
||||||
}
|
}
|
||||||
|
|
||||||
function networkFlags(): void {
|
function networkFlags(): void {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
||||||
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
||||||
import { synchronizeAutoStartSetting } from './auto-start';
|
|
||||||
import {
|
import {
|
||||||
initializeDatabase,
|
initializeDatabase,
|
||||||
destroyDatabase,
|
destroyDatabase,
|
||||||
@@ -25,7 +24,6 @@ export function registerAppLifecycle(): void {
|
|||||||
setupCqrsHandlers();
|
setupCqrsHandlers();
|
||||||
setupWindowControlHandlers();
|
setupWindowControlHandlers();
|
||||||
setupSystemHandlers();
|
setupSystemHandlers();
|
||||||
await synchronizeAutoStartSetting();
|
|
||||||
initializeDesktopUpdater();
|
initializeDesktopUpdater();
|
||||||
await createWindow();
|
await createWindow();
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,6 @@ interface SinkInputDetails extends ShortSinkInputEntry {
|
|||||||
properties: Record<string, string>;
|
properties: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DescendantProcessInfo {
|
|
||||||
ids: ReadonlySet<string>;
|
|
||||||
binaryNames: ReadonlySet<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PactlJsonSinkInputEntry {
|
interface PactlJsonSinkInputEntry {
|
||||||
index?: number | string;
|
index?: number | string;
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
@@ -49,7 +44,6 @@ interface LinuxScreenShareAudioRoutingState {
|
|||||||
screenShareLoopbackModuleId: string | null;
|
screenShareLoopbackModuleId: string | null;
|
||||||
voiceLoopbackModuleId: string | null;
|
voiceLoopbackModuleId: string | null;
|
||||||
rerouteIntervalId: ReturnType<typeof setInterval> | null;
|
rerouteIntervalId: ReturnType<typeof setInterval> | null;
|
||||||
subscribeProcess: ChildProcess | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinuxScreenShareMonitorCaptureState {
|
interface LinuxScreenShareMonitorCaptureState {
|
||||||
@@ -83,8 +77,7 @@ const routingState: LinuxScreenShareAudioRoutingState = {
|
|||||||
restoreSinkName: null,
|
restoreSinkName: null,
|
||||||
screenShareLoopbackModuleId: null,
|
screenShareLoopbackModuleId: null,
|
||||||
voiceLoopbackModuleId: null,
|
voiceLoopbackModuleId: null,
|
||||||
rerouteIntervalId: null,
|
rerouteIntervalId: null
|
||||||
subscribeProcess: null
|
|
||||||
};
|
};
|
||||||
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
|
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
|
||||||
captureId: null,
|
captureId: null,
|
||||||
@@ -133,21 +126,12 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
|||||||
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
|
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
|
||||||
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
|
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
|
||||||
|
|
||||||
// Set the default sink to the voice sink so that new app audio
|
await setDefaultSink(SCREEN_SHARE_SINK_NAME);
|
||||||
// streams (received WebRTC voice) never land on the screenshare
|
await moveSinkInputs(SCREEN_SHARE_SINK_NAME, (sinkName) => !!sinkName && sinkName !== SCREEN_SHARE_SINK_NAME && sinkName !== VOICE_SINK_NAME);
|
||||||
// capture sink. This prevents the feedback loop where remote
|
|
||||||
// voice audio was picked up by parec before the reroute interval
|
|
||||||
// could move the stream away.
|
|
||||||
await setDefaultSink(VOICE_SINK_NAME);
|
|
||||||
|
|
||||||
routingState.active = true;
|
routingState.active = true;
|
||||||
|
await rerouteAppSinkInputsToVoiceSink();
|
||||||
// Let the combined reroute decide placement for every existing
|
|
||||||
// stream. This avoids briefly shoving the app's own playback to the
|
|
||||||
// screenshare sink before ownership detection can move it back.
|
|
||||||
await rerouteSinkInputs();
|
|
||||||
startSinkInputRerouteLoop();
|
startSinkInputRerouteLoop();
|
||||||
startSubscribeWatcher();
|
|
||||||
|
|
||||||
return buildRoutingInfo(true, true);
|
return buildRoutingInfo(true, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -164,7 +148,6 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
|||||||
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
|
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
|
||||||
const restoreSinkName = routingState.restoreSinkName;
|
const restoreSinkName = routingState.restoreSinkName;
|
||||||
|
|
||||||
stopSubscribeWatcher();
|
|
||||||
stopSinkInputRerouteLoop();
|
stopSinkInputRerouteLoop();
|
||||||
await stopLinuxScreenShareMonitorCapture();
|
await stopLinuxScreenShareMonitorCapture();
|
||||||
|
|
||||||
@@ -183,7 +166,6 @@ export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean>
|
|||||||
routingState.restoreSinkName = null;
|
routingState.restoreSinkName = null;
|
||||||
routingState.screenShareLoopbackModuleId = null;
|
routingState.screenShareLoopbackModuleId = null;
|
||||||
routingState.voiceLoopbackModuleId = null;
|
routingState.voiceLoopbackModuleId = null;
|
||||||
routingState.subscribeProcess = null;
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -443,53 +425,35 @@ async function setDefaultSink(sinkName: string): Promise<void> {
|
|||||||
await runPactl('set-default-sink', sinkName);
|
await runPactl('set-default-sink', sinkName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function rerouteAppSinkInputsToVoiceSink(): Promise<void> {
|
||||||
* Combined reroute that enforces sink placement in both directions:
|
|
||||||
* - App-owned sink inputs that are NOT on the voice sink are moved there.
|
|
||||||
* - Non-app sink inputs that ARE on the voice sink are moved to the
|
|
||||||
* screenshare sink so they are captured by parec.
|
|
||||||
*
|
|
||||||
* This two-way approach, combined with the voice sink being the PulseAudio
|
|
||||||
* default, ensures that received WebRTC voice audio can never leak into the
|
|
||||||
* screenshare monitor source.
|
|
||||||
*/
|
|
||||||
async function rerouteSinkInputs(): Promise<void> {
|
|
||||||
const [
|
const [
|
||||||
sinks,
|
sinks,
|
||||||
sinkInputs,
|
sinkInputs,
|
||||||
descendantProcessInfo
|
descendantProcessIds
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
listSinks(),
|
listSinks(),
|
||||||
listSinkInputDetails(),
|
listSinkInputDetails(),
|
||||||
collectDescendantProcessInfo(process.pid)
|
collectDescendantProcessIds(process.pid)
|
||||||
]);
|
]);
|
||||||
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
sinkInputs.map(async (sinkInput) => {
|
sinkInputs.map(async (sinkInput) => {
|
||||||
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
if (!isAppOwnedSinkInput(sinkInput, descendantProcessIds)) {
|
||||||
const appOwned = isAppOwnedSinkInput(sinkInput, descendantProcessInfo);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
||||||
|
|
||||||
|
if (sinkName === VOICE_SINK_NAME) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// App-owned streams must stay on the voice sink.
|
|
||||||
if (appOwned && sinkName !== VOICE_SINK_NAME) {
|
|
||||||
try {
|
try {
|
||||||
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
||||||
} catch {
|
} catch {
|
||||||
// Streams can disappear or be recreated while rerouting.
|
// Streams can disappear or be recreated while rerouting.
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-app streams sitting on the voice sink should be moved to the
|
|
||||||
// screenshare sink for desktop-audio capture.
|
|
||||||
if (!appOwned && sinkName === VOICE_SINK_NAME) {
|
|
||||||
try {
|
|
||||||
await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME);
|
|
||||||
} catch {
|
|
||||||
// Streams can disappear or be recreated while rerouting.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -551,7 +515,7 @@ function startSinkInputRerouteLoop(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
routingState.rerouteIntervalId = setInterval(() => {
|
routingState.rerouteIntervalId = setInterval(() => {
|
||||||
void rerouteSinkInputs();
|
void rerouteAppSinkInputsToVoiceSink();
|
||||||
}, REROUTE_INTERVAL_MS);
|
}, REROUTE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,108 +528,13 @@ function stopSinkInputRerouteLoop(): void {
|
|||||||
routingState.rerouteIntervalId = null;
|
routingState.rerouteIntervalId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawns `pactl subscribe` to receive PulseAudio events in real time.
|
|
||||||
* When a new or changed sink-input is detected, a reroute is triggered
|
|
||||||
* immediately instead of waiting for the next interval tick. This
|
|
||||||
* drastically reduces the time non-app desktop audio spends on the
|
|
||||||
* voice sink before being moved to the screenshare sink.
|
|
||||||
*/
|
|
||||||
function startSubscribeWatcher(): void {
|
|
||||||
if (routingState.subscribeProcess) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let proc: ChildProcess;
|
|
||||||
|
|
||||||
try {
|
|
||||||
proc = spawn('pactl', ['subscribe'], {
|
|
||||||
env: process.env,
|
|
||||||
stdio: [
|
|
||||||
'ignore',
|
|
||||||
'pipe',
|
|
||||||
'ignore'
|
|
||||||
]
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// If pactl subscribe fails to spawn, the interval loop still covers us.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
routingState.subscribeProcess = proc;
|
|
||||||
|
|
||||||
let pending = false;
|
|
||||||
|
|
||||||
proc.stdout?.on('data', (chunk: Buffer) => {
|
|
||||||
if (!routingState.active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = chunk.toString();
|
|
||||||
|
|
||||||
if (/Event '(?:new|change)' on sink-input/.test(text)) {
|
|
||||||
if (!pending) {
|
|
||||||
pending = true;
|
|
||||||
|
|
||||||
// Batch rapid-fire events with a short delay.
|
|
||||||
setTimeout(() => {
|
|
||||||
pending = false;
|
|
||||||
void rerouteSinkInputs();
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('close', () => {
|
|
||||||
if (routingState.subscribeProcess === proc) {
|
|
||||||
routingState.subscribeProcess = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('error', () => {
|
|
||||||
if (routingState.subscribeProcess === proc) {
|
|
||||||
routingState.subscribeProcess = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSubscribeWatcher(): void {
|
|
||||||
const proc = routingState.subscribeProcess;
|
|
||||||
|
|
||||||
if (!proc) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
routingState.subscribeProcess = null;
|
|
||||||
|
|
||||||
if (!proc.killed) {
|
|
||||||
proc.kill('SIGTERM');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAppOwnedSinkInput(
|
function isAppOwnedSinkInput(
|
||||||
sinkInput: SinkInputDetails,
|
sinkInput: SinkInputDetails,
|
||||||
descendantProcessInfo: DescendantProcessInfo
|
descendantProcessIds: ReadonlySet<string>
|
||||||
): boolean {
|
): boolean {
|
||||||
const processId = sinkInput.properties['application.process.id'];
|
const processId = sinkInput.properties['application.process.id'];
|
||||||
|
|
||||||
if (typeof processId === 'string' && descendantProcessInfo.ids.has(processId)) {
|
return typeof processId === 'string' && descendantProcessIds.has(processId);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const processBinary = normalizeProcessBinary(sinkInput.properties['application.process.binary']);
|
|
||||||
|
|
||||||
if (processBinary && descendantProcessInfo.binaryNames.has(processBinary)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const applicationName = normalizeProcessBinary(sinkInput.properties['application.name']);
|
|
||||||
|
|
||||||
if (applicationName && descendantProcessInfo.binaryNames.has(applicationName)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveSinkInputs(
|
async function moveSinkInputs(
|
||||||
@@ -828,45 +697,31 @@ async function listSinkInputDetails(): Promise<SinkInputDetails[]> {
|
|||||||
return entries.filter((entry) => !!entry.sinkIndex);
|
return entries.filter((entry) => !!entry.sinkIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectDescendantProcessInfo(rootProcessId: number): Promise<DescendantProcessInfo> {
|
async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<string>> {
|
||||||
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm='], {
|
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid='], {
|
||||||
env: process.env
|
env: process.env
|
||||||
});
|
});
|
||||||
const childrenByParentId = new Map<string, string[]>();
|
const childrenByParentId = new Map<string, string[]>();
|
||||||
const binaryNameByProcessId = new Map<string, string>();
|
|
||||||
|
|
||||||
stdout
|
stdout
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.forEach((line) => {
|
.forEach((line) => {
|
||||||
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
const [pid, ppid] = line.split(/\s+/);
|
||||||
|
|
||||||
if (!match) {
|
if (!pid || !ppid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [
|
|
||||||
,
|
|
||||||
pid,
|
|
||||||
ppid,
|
|
||||||
command
|
|
||||||
] = match;
|
|
||||||
const siblings = childrenByParentId.get(ppid) ?? [];
|
const siblings = childrenByParentId.get(ppid) ?? [];
|
||||||
|
|
||||||
siblings.push(pid);
|
siblings.push(pid);
|
||||||
childrenByParentId.set(ppid, siblings);
|
childrenByParentId.set(ppid, siblings);
|
||||||
|
|
||||||
const normalizedBinaryName = normalizeProcessBinary(command);
|
|
||||||
|
|
||||||
if (normalizedBinaryName) {
|
|
||||||
binaryNameByProcessId.set(pid, normalizedBinaryName);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rootId = `${rootProcessId}`;
|
const rootId = `${rootProcessId}`;
|
||||||
const descendantIds = new Set<string>([rootId]);
|
const descendantIds = new Set<string>([rootId]);
|
||||||
const descendantBinaryNames = new Set<string>();
|
|
||||||
const queue = [rootId];
|
const queue = [rootId];
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
@@ -876,12 +731,6 @@ async function collectDescendantProcessInfo(rootProcessId: number): Promise<Desc
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const binaryName = binaryNameByProcessId.get(currentId);
|
|
||||||
|
|
||||||
if (binaryName) {
|
|
||||||
descendantBinaryNames.add(binaryName);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
||||||
if (descendantIds.has(childId)) {
|
if (descendantIds.has(childId)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -892,30 +741,7 @@ async function collectDescendantProcessInfo(rootProcessId: number): Promise<Desc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return descendantIds;
|
||||||
ids: descendantIds,
|
|
||||||
binaryNames: descendantBinaryNames
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProcessBinary(value: string | undefined): string | null {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = value.trim();
|
|
||||||
|
|
||||||
if (!trimmed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const basename = trimmed
|
|
||||||
.split(/[\\/]/)
|
|
||||||
.pop()
|
|
||||||
?.trim()
|
|
||||||
.toLowerCase() ?? '';
|
|
||||||
|
|
||||||
return basename || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripSurroundingQuotes(value: string): string {
|
function stripSurroundingQuotes(value: string): string {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
|||||||
topic: room.topic ?? null,
|
topic: room.topic ?? null,
|
||||||
hostId: room.hostId,
|
hostId: room.hostId,
|
||||||
password: room.password ?? null,
|
password: room.password ?? null,
|
||||||
hasPassword: room.hasPassword ? 1 : 0,
|
|
||||||
isPrivate: room.isPrivate ? 1 : 0,
|
isPrivate: room.isPrivate ? 1 : 0,
|
||||||
createdAt: room.createdAt,
|
createdAt: room.createdAt,
|
||||||
userCount: room.userCount ?? 0,
|
userCount: room.userCount ?? 0,
|
||||||
@@ -21,10 +20,7 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
|||||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||||
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
||||||
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
||||||
members: room.members != null ? JSON.stringify(room.members) : null,
|
members: room.members != null ? JSON.stringify(room.members) : null
|
||||||
sourceId: room.sourceId ?? null,
|
|
||||||
sourceName: room.sourceName ?? null,
|
|
||||||
sourceUrl: room.sourceUrl ?? null
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
await repo.save(entity);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from './utils/applyUpdates';
|
} from './utils/applyUpdates';
|
||||||
|
|
||||||
const ROOM_TRANSFORMS: TransformMap = {
|
const ROOM_TRANSFORMS: TransformMap = {
|
||||||
hasPassword: boolToInt,
|
|
||||||
isPrivate: boolToInt,
|
isPrivate: boolToInt,
|
||||||
userCount: (val) => (val ?? 0),
|
userCount: (val) => (val ?? 0),
|
||||||
permissions: jsonOrNull,
|
permissions: jsonOrNull,
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export function rowToRoom(row: RoomEntity) {
|
|||||||
topic: row.topic ?? undefined,
|
topic: row.topic ?? undefined,
|
||||||
hostId: row.hostId,
|
hostId: row.hostId,
|
||||||
password: row.password ?? undefined,
|
password: row.password ?? undefined,
|
||||||
hasPassword: !!row.hasPassword,
|
|
||||||
isPrivate: !!row.isPrivate,
|
isPrivate: !!row.isPrivate,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
userCount: row.userCount,
|
userCount: row.userCount,
|
||||||
@@ -66,10 +65,7 @@ export function rowToRoom(row: RoomEntity) {
|
|||||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||||
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
||||||
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
||||||
members: row.members ? JSON.parse(row.members) : undefined,
|
members: row.members ? JSON.parse(row.members) : undefined
|
||||||
sourceId: row.sourceId ?? undefined,
|
|
||||||
sourceName: row.sourceName ?? undefined,
|
|
||||||
sourceUrl: row.sourceUrl ?? undefined
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ export interface RoomPayload {
|
|||||||
topic?: string;
|
topic?: string;
|
||||||
hostId: string;
|
hostId: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
hasPassword?: boolean;
|
|
||||||
isPrivate?: boolean;
|
isPrivate?: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
userCount?: number;
|
userCount?: number;
|
||||||
@@ -94,9 +93,6 @@ export interface RoomPayload {
|
|||||||
permissions?: unknown;
|
permissions?: unknown;
|
||||||
channels?: unknown[];
|
channels?: unknown[];
|
||||||
members?: unknown[];
|
members?: unknown[];
|
||||||
sourceId?: string;
|
|
||||||
sourceName?: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanPayload {
|
export interface BanPayload {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
|||||||
|
|
||||||
export interface DesktopSettings {
|
export interface DesktopSettings {
|
||||||
autoUpdateMode: AutoUpdateMode;
|
autoUpdateMode: AutoUpdateMode;
|
||||||
autoStart: boolean;
|
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -20,7 +19,6 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
|||||||
|
|
||||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||||
autoUpdateMode: 'auto',
|
autoUpdateMode: 'auto',
|
||||||
autoStart: true,
|
|
||||||
hardwareAcceleration: true,
|
hardwareAcceleration: true,
|
||||||
manifestUrls: [],
|
manifestUrls: [],
|
||||||
preferredVersion: null,
|
preferredVersion: null,
|
||||||
@@ -83,9 +81,6 @@ export function readDesktopSettings(): DesktopSettings {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
||||||
autoStart: typeof parsed.autoStart === 'boolean'
|
|
||||||
? parsed.autoStart
|
|
||||||
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
|
||||||
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
||||||
? parsed.vaapiVideoEncode
|
? parsed.vaapiVideoEncode
|
||||||
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
||||||
@@ -107,9 +102,6 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
|||||||
};
|
};
|
||||||
const nextSettings: DesktopSettings = {
|
const nextSettings: DesktopSettings = {
|
||||||
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
||||||
autoStart: typeof mergedSettings.autoStart === 'boolean'
|
|
||||||
? mergedSettings.autoStart
|
|
||||||
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
|
||||||
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||||
? mergedSettings.hardwareAcceleration
|
? mergedSettings.hardwareAcceleration
|
||||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ export class RoomEntity {
|
|||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
password!: string | null;
|
password!: string | null;
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
|
||||||
hasPassword!: number;
|
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
isPrivate!: number;
|
isPrivate!: number;
|
||||||
|
|
||||||
@@ -53,13 +50,4 @@ export class RoomEntity {
|
|||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
members!: string | null;
|
members!: string | null;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
sourceId!: string | null;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
sourceName!: string | null;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
sourceUrl!: string | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ import {
|
|||||||
restartToApplyUpdate,
|
restartToApplyUpdate,
|
||||||
type DesktopUpdateServerContext
|
type DesktopUpdateServerContext
|
||||||
} from '../update/desktop-updater';
|
} from '../update/desktop-updater';
|
||||||
import { consumePendingDeepLink } from '../app/deep-links';
|
|
||||||
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const FILE_CLIPBOARD_FORMATS = [
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
@@ -85,57 +83,6 @@ interface ClipboardFilePayload {
|
|||||||
path?: string;
|
path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLinuxDisplayServer(): string {
|
|
||||||
if (process.platform !== 'linux') {
|
|
||||||
return 'N/A';
|
|
||||||
}
|
|
||||||
|
|
||||||
const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
if (ozonePlatform === 'wayland') {
|
|
||||||
return 'Wayland';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ozonePlatform === 'x11') {
|
|
||||||
return 'X11';
|
|
||||||
}
|
|
||||||
|
|
||||||
const ozonePlatformHint = app.commandLine.getSwitchValue('ozone-platform-hint')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
if (ozonePlatformHint === 'wayland') {
|
|
||||||
return 'Wayland';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ozonePlatformHint === 'x11') {
|
|
||||||
return 'X11';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionType = String(process.env['XDG_SESSION_TYPE'] || '').trim()
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
if (sessionType === 'wayland') {
|
|
||||||
return 'Wayland';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionType === 'x11') {
|
|
||||||
return 'X11';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String(process.env['WAYLAND_DISPLAY'] || '').trim().length > 0) {
|
|
||||||
return 'Wayland';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String(process.env['DISPLAY'] || '').trim().length > 0) {
|
|
||||||
return 'X11';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Unknown (Linux)';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSupportedClipboardFileFormat(format: string): boolean {
|
function isSupportedClipboardFileFormat(format: string): boolean {
|
||||||
return FILE_CLIPBOARD_FORMATS.some(
|
return FILE_CLIPBOARD_FORMATS.some(
|
||||||
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
||||||
@@ -247,10 +194,6 @@ async function readClipboardFiles(): Promise<ClipboardFilePayload[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setupSystemHandlers(): void {
|
export function setupSystemHandlers(): void {
|
||||||
ipcMain.on('get-linux-display-server', (event) => {
|
|
||||||
event.returnValue = resolveLinuxDisplayServer();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('open-external', async (_event, url: string) => {
|
ipcMain.handle('open-external', async (_event, url: string) => {
|
||||||
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||||
await shell.openExternal(url);
|
await shell.openExternal(url);
|
||||||
@@ -260,8 +203,6 @@ export function setupSystemHandlers(): void {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
|
|
||||||
|
|
||||||
ipcMain.handle('get-sources', async () => {
|
ipcMain.handle('get-sources', async () => {
|
||||||
try {
|
try {
|
||||||
const thumbnailSize = { width: 240, height: 150 };
|
const thumbnailSize = { width: 240, height: 150 };
|
||||||
@@ -330,7 +271,6 @@ export function setupSystemHandlers(): void {
|
|||||||
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
||||||
const snapshot = updateDesktopSettings(patch);
|
const snapshot = updateDesktopSettings(patch);
|
||||||
|
|
||||||
await synchronizeAutoStartSetting(snapshot.autoStart);
|
|
||||||
await handleDesktopSettingsChanged();
|
await handleDesktopSettingsChanged();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { initializeDeepLinkHandling } from './app/deep-links';
|
|
||||||
import { configureAppFlags } from './app/flags';
|
import { configureAppFlags } from './app/flags';
|
||||||
import { registerAppLifecycle } from './app/lifecycle';
|
import { registerAppLifecycle } from './app/lifecycle';
|
||||||
|
|
||||||
configureAppFlags();
|
configureAppFlags();
|
||||||
|
registerAppLifecycle();
|
||||||
if (initializeDeepLinkHandling()) {
|
|
||||||
registerAppLifecycle();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class AddRoomSourceAndPasswordState1000000000002 implements MigrationInterface {
|
|
||||||
name = 'AddRoomSourceAndPasswordState1000000000002';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "hasPassword" INTEGER NOT NULL DEFAULT 0`);
|
|
||||||
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceId" TEXT`);
|
|
||||||
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceName" TEXT`);
|
|
||||||
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceUrl" TEXT`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
UPDATE "rooms"
|
|
||||||
SET "hasPassword" = CASE
|
|
||||||
WHEN "password" IS NOT NULL AND TRIM("password") <> '' THEN 1
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceUrl"`);
|
|
||||||
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceName"`);
|
|
||||||
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceId"`);
|
|
||||||
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "hasPassword"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { Command, Query } from './cqrs/types';
|
|||||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
||||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
||||||
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||||
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
|
||||||
|
|
||||||
export interface LinuxScreenShareAudioRoutingInfo {
|
export interface LinuxScreenShareAudioRoutingInfo {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -84,24 +83,7 @@ export interface DesktopUpdateState {
|
|||||||
targetVersion: string | null;
|
targetVersion: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLinuxDisplayServer(): string {
|
|
||||||
if (process.platform !== 'linux') {
|
|
||||||
return 'N/A';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const displayServer = ipcRenderer.sendSync('get-linux-display-server');
|
|
||||||
|
|
||||||
return typeof displayServer === 'string' && displayServer.trim().length > 0
|
|
||||||
? displayServer
|
|
||||||
: 'Unknown (Linux)';
|
|
||||||
} catch {
|
|
||||||
return 'Unknown (Linux)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
linuxDisplayServer: string;
|
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
maximizeWindow: () => void;
|
maximizeWindow: () => void;
|
||||||
closeWindow: () => void;
|
closeWindow: () => void;
|
||||||
@@ -116,10 +98,8 @@ export interface ElectronAPI {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
consumePendingDeepLink: () => Promise<string | null>;
|
|
||||||
getDesktopSettings: () => Promise<{
|
getDesktopSettings: () => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
autoStart: boolean;
|
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -133,14 +113,12 @@ export interface ElectronAPI {
|
|||||||
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||||
setDesktopSettings: (patch: {
|
setDesktopSettings: (patch: {
|
||||||
autoUpdateMode?: 'auto' | 'off' | 'version';
|
autoUpdateMode?: 'auto' | 'off' | 'version';
|
||||||
autoStart?: boolean;
|
|
||||||
hardwareAcceleration?: boolean;
|
hardwareAcceleration?: boolean;
|
||||||
manifestUrls?: string[];
|
manifestUrls?: string[];
|
||||||
preferredVersion?: string | null;
|
preferredVersion?: string | null;
|
||||||
vaapiVideoEncode?: boolean;
|
vaapiVideoEncode?: boolean;
|
||||||
}) => Promise<{
|
}) => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
autoStart: boolean;
|
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -148,7 +126,6 @@ export interface ElectronAPI {
|
|||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
}>;
|
}>;
|
||||||
relaunchApp: () => Promise<boolean>;
|
relaunchApp: () => Promise<boolean>;
|
||||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
|
||||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||||
readFile: (filePath: string) => Promise<string>;
|
readFile: (filePath: string) => Promise<string>;
|
||||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
@@ -162,7 +139,6 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const electronAPI: ElectronAPI = {
|
const electronAPI: ElectronAPI = {
|
||||||
linuxDisplayServer: readLinuxDisplayServer(),
|
|
||||||
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
||||||
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
||||||
closeWindow: () => ipcRenderer.send('window-close'),
|
closeWindow: () => ipcRenderer.send('window-close'),
|
||||||
@@ -204,7 +180,6 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
|
||||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||||
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
|
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
|
||||||
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
||||||
@@ -223,17 +198,6 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||||
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
||||||
onDeepLinkReceived: (listener) => {
|
|
||||||
const wrappedListener = (_event: Electron.IpcRendererEvent, url: string) => {
|
|
||||||
listener(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
ipcRenderer.on(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ipcRenderer.removeListener(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
||||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||||
|
|||||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -24,7 +24,6 @@
|
|||||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||||
"auto-launch": "^5.0.6",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
@@ -46,12 +45,11 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.0.4",
|
"@angular/build": "^21.0.4",
|
||||||
"@angular/cli": "^21.0.4",
|
"@angular/cli": "^21.2.1",
|
||||||
"@angular/compiler-cli": "^21.0.0",
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
"@types/auto-launch": "^5.0.5",
|
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -10818,13 +10816,6 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/auto-launch": {
|
|
||||||
"version": "5.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/auto-launch/-/auto-launch-5.0.5.tgz",
|
|
||||||
"integrity": "sha512-/nGvQZSzM/pvCMCh4Gt2kIeiUmOP/cKGJbjlInI+A+5MoV/7XmT56DJ6EU8bqc3+ItxEe4UC2GVspmPzcCc8cg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -12884,11 +12875,6 @@
|
|||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/applescript": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/applescript/-/applescript-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ=="
|
|
||||||
},
|
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -12982,22 +12968,6 @@
|
|||||||
"node": ">= 4.0.0"
|
"node": ">= 4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/auto-launch": {
|
|
||||||
"version": "5.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/auto-launch/-/auto-launch-5.0.6.tgz",
|
|
||||||
"integrity": "sha512-OgxiAm4q9EBf9EeXdPBiVNENaWE3jUZofwrhAkWjHDYGezu1k3FRZHU8V2FBxGuSJOHzKmTJEd0G7L7/0xDGFA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"applescript": "^1.0.0",
|
|
||||||
"mkdirp": "^0.5.1",
|
|
||||||
"path-is-absolute": "^1.0.0",
|
|
||||||
"untildify": "^3.0.2",
|
|
||||||
"winreg": "1.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.23",
|
"version": "10.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||||
@@ -22315,7 +22285,9 @@
|
|||||||
"version": "0.5.6",
|
"version": "0.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.6"
|
"minimist": "^1.2.6"
|
||||||
},
|
},
|
||||||
@@ -23773,6 +23745,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -29598,15 +29571,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/untildify": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/upath": {
|
"node_modules/upath": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
|
||||||
@@ -31197,12 +31161,6 @@
|
|||||||
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/winreg": {
|
|
||||||
"version": "1.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/winreg/-/winreg-1.2.4.tgz",
|
|
||||||
"integrity": "sha512-IHpzORub7kYlb8A43Iig3reOvlcBJGX9gZ0WycHhghHtA65X0LYnMRuJs+aH1abVnMJztQkvQNlltnbPi5aGIA==",
|
|
||||||
"license": "BSD-2-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -21,10 +21,10 @@
|
|||||||
"server:build": "cd server && npm run build",
|
"server:build": "cd server && npm run build",
|
||||||
"server:start": "cd server && npm start",
|
"server:start": "cd server && npm start",
|
||||||
"server:dev": "cd server && npm run dev",
|
"server:dev": "cd server && npm run dev",
|
||||||
"electron": "ng build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
|
"electron": "ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
|
||||||
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --no-sandbox --disable-dev-shm-usage\"",
|
||||||
"electron:full": "./dev.sh",
|
"electron:full": "./dev.sh",
|
||||||
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron . --no-sandbox --disable-dev-shm-usage\"",
|
||||||
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
||||||
"migration:create": "typeorm migration:create electron/migrations/New",
|
"migration:create": "typeorm migration:create electron/migrations/New",
|
||||||
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
|
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
|
||||||
@@ -70,7 +70,6 @@
|
|||||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||||
"auto-launch": "^5.0.6",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
@@ -97,7 +96,6 @@
|
|||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
"@types/auto-launch": "^5.0.5",
|
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -122,14 +120,6 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"appId": "com.metoyou.app",
|
"appId": "com.metoyou.app",
|
||||||
"productName": "MetoYou",
|
"productName": "MetoYou",
|
||||||
"protocols": [
|
|
||||||
{
|
|
||||||
"name": "Toju Invite Links",
|
|
||||||
"schemes": [
|
|
||||||
"toju"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron"
|
"output": "dist-electron"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
@@ -5,7 +5,6 @@ import { registerRoutes } from './routes';
|
|||||||
export function createApp(): express.Express {
|
export function createApp(): express.Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.set('trust proxy', true);
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,13 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { resolveRuntimePath } from '../runtime-paths';
|
import { resolveRuntimePath } from '../runtime-paths';
|
||||||
|
|
||||||
export type ServerHttpProtocol = 'http' | 'https';
|
|
||||||
|
|
||||||
export interface ServerVariablesConfig {
|
export interface ServerVariablesConfig {
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
releaseManifestUrl: string;
|
releaseManifestUrl: string;
|
||||||
serverPort: number;
|
|
||||||
serverProtocol: ServerHttpProtocol;
|
|
||||||
serverHost: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATA_DIR = resolveRuntimePath('data');
|
const DATA_DIR = resolveRuntimePath('data');
|
||||||
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
||||||
const DEFAULT_SERVER_PORT = 3001;
|
|
||||||
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
|
||||||
|
|
||||||
function normalizeKlipyApiKey(value: unknown): string {
|
function normalizeKlipyApiKey(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -25,51 +18,6 @@ function normalizeReleaseManifestUrl(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerHost(value: unknown): string {
|
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeServerProtocol(
|
|
||||||
value: unknown,
|
|
||||||
fallback: ServerHttpProtocol = DEFAULT_SERVER_PROTOCOL
|
|
||||||
): ServerHttpProtocol {
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value ? 'https' : 'http';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
|
|
||||||
if (normalized === 'https' || normalized === 'true') {
|
|
||||||
return 'https';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized === 'http' || normalized === 'false') {
|
|
||||||
return 'http';
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): number {
|
|
||||||
const parsed = typeof value === 'number'
|
|
||||||
? value
|
|
||||||
: typeof value === 'string'
|
|
||||||
? Number.parseInt(value.trim(), 10)
|
|
||||||
: Number.NaN;
|
|
||||||
|
|
||||||
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535
|
|
||||||
? parsed
|
|
||||||
: fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasEnvironmentOverride(value: string | undefined): value is string {
|
|
||||||
return typeof value === 'string' && value.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
|
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
|
||||||
if (!fs.existsSync(VARIABLES_FILE)) {
|
if (!fs.existsSync(VARIABLES_FILE)) {
|
||||||
return { rawContents: '', parsed: {} };
|
return { rawContents: '', parsed: {} };
|
||||||
@@ -104,14 +52,10 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { rawContents, parsed } = readRawVariables();
|
const { rawContents, parsed } = readRawVariables();
|
||||||
const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed;
|
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...remainingParsed,
|
...parsed,
|
||||||
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey),
|
||||||
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl)
|
||||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
|
||||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
|
||||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress)
|
|
||||||
};
|
};
|
||||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||||
|
|
||||||
@@ -121,10 +65,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
klipyApiKey: normalized.klipyApiKey,
|
klipyApiKey: normalized.klipyApiKey,
|
||||||
releaseManifestUrl: normalized.releaseManifestUrl,
|
releaseManifestUrl: normalized.releaseManifestUrl
|
||||||
serverPort: normalized.serverPort,
|
|
||||||
serverProtocol: normalized.serverProtocol,
|
|
||||||
serverHost: normalized.serverHost
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,29 +84,3 @@ export function hasKlipyApiKey(): boolean {
|
|||||||
export function getReleaseManifestUrl(): string {
|
export function getReleaseManifestUrl(): string {
|
||||||
return getVariablesConfig().releaseManifestUrl;
|
return getVariablesConfig().releaseManifestUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getServerProtocol(): ServerHttpProtocol {
|
|
||||||
if (hasEnvironmentOverride(process.env.SSL)) {
|
|
||||||
return normalizeServerProtocol(process.env.SSL);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getVariablesConfig().serverProtocol;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getServerPort(): number {
|
|
||||||
if (hasEnvironmentOverride(process.env.PORT)) {
|
|
||||||
return normalizeServerPort(process.env.PORT);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getVariablesConfig().serverPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getServerHost(): string | undefined {
|
|
||||||
const serverHost = getVariablesConfig().serverHost;
|
|
||||||
|
|
||||||
return serverHost || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isHttpsServerEnabled(): boolean {
|
|
||||||
return getServerProtocol() === 'https';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import {
|
import { ServerEntity, JoinRequestEntity } from '../../../entities';
|
||||||
ServerEntity,
|
|
||||||
JoinRequestEntity,
|
|
||||||
ServerMembershipEntity,
|
|
||||||
ServerInviteEntity,
|
|
||||||
ServerBanEntity
|
|
||||||
} from '../../../entities';
|
|
||||||
import { DeleteServerCommand } from '../../types';
|
import { DeleteServerCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { serverId } = command.payload;
|
const { serverId } = command.payload;
|
||||||
|
|
||||||
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
||||||
await dataSource.getRepository(ServerMembershipEntity).delete({ serverId });
|
|
||||||
await dataSource.getRepository(ServerInviteEntity).delete({ serverId });
|
|
||||||
await dataSource.getRepository(ServerBanEntity).delete({ serverId });
|
|
||||||
await dataSource.getRepository(ServerEntity).delete(serverId);
|
await dataSource.getRepository(ServerEntity).delete(serverId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
|
|||||||
description: server.description ?? null,
|
description: server.description ?? null,
|
||||||
ownerId: server.ownerId,
|
ownerId: server.ownerId,
|
||||||
ownerPublicKey: server.ownerPublicKey,
|
ownerPublicKey: server.ownerPublicKey,
|
||||||
passwordHash: server.passwordHash ?? null,
|
|
||||||
isPrivate: server.isPrivate ? 1 : 0,
|
isPrivate: server.isPrivate ? 1 : 0,
|
||||||
maxUsers: server.maxUsers,
|
maxUsers: server.maxUsers,
|
||||||
currentUsers: server.currentUsers,
|
currentUsers: server.currentUsers,
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ export function rowToServer(row: ServerEntity): ServerPayload {
|
|||||||
description: row.description ?? undefined,
|
description: row.description ?? undefined,
|
||||||
ownerId: row.ownerId,
|
ownerId: row.ownerId,
|
||||||
ownerPublicKey: row.ownerPublicKey,
|
ownerPublicKey: row.ownerPublicKey,
|
||||||
hasPassword: !!row.passwordHash,
|
|
||||||
passwordHash: row.passwordHash ?? undefined,
|
|
||||||
isPrivate: !!row.isPrivate,
|
isPrivate: !!row.isPrivate,
|
||||||
maxUsers: row.maxUsers,
|
maxUsers: row.maxUsers,
|
||||||
currentUsers: row.currentUsers,
|
currentUsers: row.currentUsers,
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ export interface ServerPayload {
|
|||||||
description?: string;
|
description?: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
ownerPublicKey: string;
|
ownerPublicKey: string;
|
||||||
hasPassword?: boolean;
|
|
||||||
passwordHash?: string | null;
|
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
maxUsers: number;
|
maxUsers: number;
|
||||||
currentUsers: number;
|
currentUsers: number;
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import { DataSource } from 'typeorm';
|
|||||||
import {
|
import {
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
JoinRequestEntity,
|
JoinRequestEntity
|
||||||
ServerMembershipEntity,
|
|
||||||
ServerInviteEntity,
|
|
||||||
ServerBanEntity
|
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { serverMigrations } from '../migrations';
|
import { serverMigrations } from '../migrations';
|
||||||
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
||||||
@@ -54,10 +51,7 @@ export async function initDatabase(): Promise<void> {
|
|||||||
entities: [
|
entities: [
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
JoinRequestEntity,
|
JoinRequestEntity
|
||||||
ServerMembershipEntity,
|
|
||||||
ServerInviteEntity,
|
|
||||||
ServerBanEntity
|
|
||||||
],
|
],
|
||||||
migrations: serverMigrations,
|
migrations: serverMigrations,
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import {
|
|
||||||
Entity,
|
|
||||||
PrimaryColumn,
|
|
||||||
Column,
|
|
||||||
Index
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('server_bans')
|
|
||||||
export class ServerBanEntity {
|
|
||||||
@PrimaryColumn('text')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@Column('text')
|
|
||||||
serverId!: string;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@Column('text')
|
|
||||||
userId!: string;
|
|
||||||
|
|
||||||
@Column('text')
|
|
||||||
bannedBy!: string;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
displayName!: string | null;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
reason!: string | null;
|
|
||||||
|
|
||||||
@Column('integer', { nullable: true })
|
|
||||||
expiresAt!: number | null;
|
|
||||||
|
|
||||||
@Column('integer')
|
|
||||||
createdAt!: number;
|
|
||||||
}
|
|
||||||
@@ -21,9 +21,6 @@ export class ServerEntity {
|
|||||||
@Column('text')
|
@Column('text')
|
||||||
ownerPublicKey!: string;
|
ownerPublicKey!: string;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
passwordHash!: string | null;
|
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
isPrivate!: number;
|
isPrivate!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import {
|
|
||||||
Entity,
|
|
||||||
PrimaryColumn,
|
|
||||||
Column,
|
|
||||||
Index
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('server_invites')
|
|
||||||
export class ServerInviteEntity {
|
|
||||||
@PrimaryColumn('text')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@Column('text')
|
|
||||||
serverId!: string;
|
|
||||||
|
|
||||||
@Column('text')
|
|
||||||
createdBy!: string;
|
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
|
||||||
createdByDisplayName!: string | null;
|
|
||||||
|
|
||||||
@Column('integer')
|
|
||||||
createdAt!: number;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@Column('integer')
|
|
||||||
expiresAt!: number;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import {
|
|
||||||
Entity,
|
|
||||||
PrimaryColumn,
|
|
||||||
Column,
|
|
||||||
Index
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('server_memberships')
|
|
||||||
export class ServerMembershipEntity {
|
|
||||||
@PrimaryColumn('text')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@Column('text')
|
|
||||||
serverId!: string;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@Column('text')
|
|
||||||
userId!: string;
|
|
||||||
|
|
||||||
@Column('integer')
|
|
||||||
joinedAt!: number;
|
|
||||||
|
|
||||||
@Column('integer')
|
|
||||||
lastAccessAt!: number;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
export { AuthUserEntity } from './AuthUserEntity';
|
export { AuthUserEntity } from './AuthUserEntity';
|
||||||
export { ServerEntity } from './ServerEntity';
|
export { ServerEntity } from './ServerEntity';
|
||||||
export { JoinRequestEntity } from './JoinRequestEntity';
|
export { JoinRequestEntity } from './JoinRequestEntity';
|
||||||
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
|
||||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
|
||||||
export { ServerBanEntity } from './ServerBanEntity';
|
|
||||||
|
|||||||
@@ -14,39 +14,23 @@ import { deleteStaleJoinRequests } from './cqrs';
|
|||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
import {
|
import {
|
||||||
ensureVariablesConfig,
|
ensureVariablesConfig,
|
||||||
getServerHost,
|
|
||||||
getVariablesConfigPath,
|
getVariablesConfigPath,
|
||||||
getServerPort,
|
hasKlipyApiKey
|
||||||
getServerProtocol,
|
|
||||||
ServerHttpProtocol
|
|
||||||
} from './config/variables';
|
} from './config/variables';
|
||||||
import { setupWebSocket } from './websocket';
|
import { setupWebSocket } from './websocket';
|
||||||
|
|
||||||
function formatHostForUrl(host: string): string {
|
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
|
||||||
if (host.startsWith('[') || !host.includes(':')) {
|
const PORT = process.env.PORT || 3001;
|
||||||
return host;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `[${host}]`;
|
function buildServer(app: ReturnType<typeof createApp>) {
|
||||||
}
|
if (USE_SSL) {
|
||||||
|
|
||||||
function getDisplayHost(serverHost: string | undefined): string {
|
|
||||||
if (!serverHost || serverHost === '0.0.0.0' || serverHost === '::') {
|
|
||||||
return 'localhost';
|
|
||||||
}
|
|
||||||
|
|
||||||
return serverHost;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHttpProtocol) {
|
|
||||||
if (serverProtocol === 'https') {
|
|
||||||
const certDir = resolveCertificateDirectory();
|
const certDir = resolveCertificateDirectory();
|
||||||
const certFile = path.join(certDir, 'localhost.crt');
|
const certFile = path.join(certDir, 'localhost.crt');
|
||||||
const keyFile = path.join(certDir, 'localhost.key');
|
const keyFile = path.join(certDir, 'localhost.key');
|
||||||
|
|
||||||
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
||||||
console.error(`HTTPS is enabled but certs were not found in ${certDir}`);
|
console.error(`SSL=true but certs not found in ${certDir}`);
|
||||||
console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".');
|
console.error('Run ./generate-cert.sh first.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,31 +44,17 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const variablesConfig = ensureVariablesConfig();
|
ensureVariablesConfig();
|
||||||
const serverProtocol = getServerProtocol();
|
|
||||||
const serverPort = getServerPort();
|
|
||||||
const serverHost = getServerHost();
|
|
||||||
const bindHostLabel = serverHost || 'default interface';
|
|
||||||
|
|
||||||
console.log('[Config] Variables loaded from:', getVariablesConfigPath());
|
console.log('[Config] Variables loaded from:', getVariablesConfigPath());
|
||||||
|
|
||||||
if (
|
if (!hasKlipyApiKey()) {
|
||||||
variablesConfig.serverProtocol !== serverProtocol
|
|
||||||
|| variablesConfig.serverPort !== serverPort
|
|
||||||
) {
|
|
||||||
console.log(`[Config] Server runtime override active: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
|
|
||||||
} else {
|
|
||||||
console.log(`[Config] Server runtime config: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!variablesConfig.klipyApiKey) {
|
|
||||||
console.log('[KLIPY] API key not configured. GIF search is disabled.');
|
console.log('[KLIPY] API key not configured. GIF search is disabled.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await initDatabase();
|
await initDatabase();
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const server = buildServer(app, serverProtocol);
|
const server = buildServer(app);
|
||||||
|
|
||||||
setupWebSocket(server);
|
setupWebSocket(server);
|
||||||
|
|
||||||
@@ -94,29 +64,14 @@ async function bootstrap(): Promise<void> {
|
|||||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
const onListening = () => {
|
server.listen(PORT, () => {
|
||||||
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
const proto = USE_SSL ? 'https' : 'http';
|
||||||
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
|
const wsProto = USE_SSL ? 'wss' : 'ws';
|
||||||
const localHostNames = [
|
|
||||||
'localhost',
|
|
||||||
'127.0.0.1',
|
|
||||||
'::1'
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
|
console.log(`MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`);
|
||||||
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
|
console.log(` REST API: ${proto}://localhost:${PORT}/api`);
|
||||||
console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`);
|
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
|
||||||
|
});
|
||||||
if (serverProtocol === 'https' && serverHost && !localHostNames.includes(serverHost)) {
|
|
||||||
console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (serverHost) {
|
|
||||||
server.listen(serverPort, serverHost, onListening);
|
|
||||||
} else {
|
|
||||||
server.listen(serverPort, onListening);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap().catch((err) => {
|
bootstrap().catch((err) => {
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class ServerAccessControl1000000000001 implements MigrationInterface {
|
|
||||||
name = 'ServerAccessControl1000000000001';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "passwordHash" TEXT`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS "server_memberships" (
|
|
||||||
"id" TEXT PRIMARY KEY NOT NULL,
|
|
||||||
"serverId" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"joinedAt" INTEGER NOT NULL,
|
|
||||||
"lastAccessAt" INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_serverId" ON "server_memberships" ("serverId")`);
|
|
||||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_userId" ON "server_memberships" ("userId")`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS "server_invites" (
|
|
||||||
"id" TEXT PRIMARY KEY NOT NULL,
|
|
||||||
"serverId" TEXT NOT NULL,
|
|
||||||
"createdBy" TEXT NOT NULL,
|
|
||||||
"createdByDisplayName" TEXT,
|
|
||||||
"createdAt" INTEGER NOT NULL,
|
|
||||||
"expiresAt" INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_serverId" ON "server_invites" ("serverId")`);
|
|
||||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_expiresAt" ON "server_invites" ("expiresAt")`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS "server_bans" (
|
|
||||||
"id" TEXT PRIMARY KEY NOT NULL,
|
|
||||||
"serverId" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"bannedBy" TEXT NOT NULL,
|
|
||||||
"displayName" TEXT,
|
|
||||||
"reason" TEXT,
|
|
||||||
"expiresAt" INTEGER,
|
|
||||||
"createdAt" INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_serverId" ON "server_bans" ("serverId")`);
|
|
||||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_userId" ON "server_bans" ("userId")`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_bans"`);
|
|
||||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_invites"`);
|
|
||||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_memberships"`);
|
|
||||||
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "passwordHash"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||||
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
|
||||||
|
|
||||||
export const serverMigrations = [
|
export const serverMigrations = [InitialSchema1000000000000];
|
||||||
InitialSchema1000000000000,
|
|
||||||
ServerAccessControl1000000000001
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import proxyRouter from './proxy';
|
|||||||
import usersRouter from './users';
|
import usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
import joinRequestsRouter from './join-requests';
|
import joinRequestsRouter from './join-requests';
|
||||||
import { invitesApiRouter, invitePageRouter } from './invites';
|
|
||||||
|
|
||||||
export function registerRoutes(app: Express): void {
|
export function registerRoutes(app: Express): void {
|
||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
@@ -13,7 +12,5 @@ export function registerRoutes(app: Express): void {
|
|||||||
app.use('/api', proxyRouter);
|
app.use('/api', proxyRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/servers', serversRouter);
|
app.use('/api/servers', serversRouter);
|
||||||
app.use('/api/invites', invitesApiRouter);
|
|
||||||
app.use('/api/requests', joinRequestsRouter);
|
app.use('/api/requests', joinRequestsRouter);
|
||||||
app.use('/invite', invitePageRouter);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Request } from 'express';
|
|
||||||
|
|
||||||
function buildOrigin(protocol: string, host: string): string {
|
|
||||||
return `${protocol}://${host}`.replace(/\/+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function originFromUrl(url: URL): string {
|
|
||||||
return buildOrigin(url.protocol.replace(':', ''), url.host);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRequestOrigin(request: Request): string {
|
|
||||||
const forwardedProtoHeader = request.get('x-forwarded-proto');
|
|
||||||
const forwardedHostHeader = request.get('x-forwarded-host');
|
|
||||||
const protocol = forwardedProtoHeader?.split(',')[0]?.trim() || request.protocol;
|
|
||||||
const host = forwardedHostHeader?.split(',')[0]?.trim() || request.get('host') || 'localhost';
|
|
||||||
|
|
||||||
return buildOrigin(protocol, host);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deriveWebAppOrigin(signalOrigin: string): string {
|
|
||||||
const url = new URL(signalOrigin);
|
|
||||||
|
|
||||||
if (url.hostname === 'signal.toju.app' && !url.port) {
|
|
||||||
return 'https://web.toju.app';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.hostname.startsWith('signal.')) {
|
|
||||||
url.hostname = url.hostname.replace(/^signal\./, 'web.');
|
|
||||||
|
|
||||||
if (url.port === '3001') {
|
|
||||||
url.port = '4200';
|
|
||||||
}
|
|
||||||
|
|
||||||
return originFromUrl(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.port === '3001') {
|
|
||||||
url.port = '4200';
|
|
||||||
return originFromUrl(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'https://web.toju.app';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildInviteUrl(signalOrigin: string, inviteId: string): string {
|
|
||||||
return `${signalOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildBrowserInviteUrl(signalOrigin: string, inviteId: string): string {
|
|
||||||
const browserOrigin = deriveWebAppOrigin(signalOrigin);
|
|
||||||
|
|
||||||
return `${browserOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildAppInviteUrl(signalOrigin: string, inviteId: string): string {
|
|
||||||
return `toju://invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import { getUserById } from '../cqrs';
|
|
||||||
import { rowToServer } from '../cqrs/mappers';
|
|
||||||
import { ServerPayload } from '../cqrs/types';
|
|
||||||
import { getActiveServerInvite } from '../services/server-access.service';
|
|
||||||
import {
|
|
||||||
buildAppInviteUrl,
|
|
||||||
buildBrowserInviteUrl,
|
|
||||||
buildInviteUrl,
|
|
||||||
getRequestOrigin
|
|
||||||
} from './invite-utils';
|
|
||||||
|
|
||||||
export const invitesApiRouter = Router();
|
|
||||||
export const invitePageRouter = Router();
|
|
||||||
|
|
||||||
async function enrichServer(server: ServerPayload, sourceUrl: string) {
|
|
||||||
const owner = await getUserById(server.ownerId);
|
|
||||||
const { passwordHash, ...publicServer } = server;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...publicServer,
|
|
||||||
hasPassword: server.hasPassword ?? !!passwordHash,
|
|
||||||
ownerName: owner?.displayName,
|
|
||||||
sourceUrl,
|
|
||||||
userCount: server.currentUsers
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInvitePage(options: {
|
|
||||||
appUrl?: string;
|
|
||||||
browserUrl?: string;
|
|
||||||
error?: string;
|
|
||||||
expiresAt?: number;
|
|
||||||
inviteUrl?: string;
|
|
||||||
isExpired: boolean;
|
|
||||||
ownerName?: string;
|
|
||||||
serverDescription?: string;
|
|
||||||
serverName: string;
|
|
||||||
}) {
|
|
||||||
const expiryLabel = options.expiresAt
|
|
||||||
? new Date(options.expiresAt).toLocaleString('en-US', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short'
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const statusLabel = options.isExpired ? 'Expired' : 'Active';
|
|
||||||
const statusColor = options.isExpired ? '#f87171' : '#4ade80';
|
|
||||||
const buttonOpacity = options.isExpired ? 'opacity:0.5;pointer-events:none;' : '';
|
|
||||||
const errorBlock = options.error
|
|
||||||
? `<div class="notice notice-error">${options.error}</div>`
|
|
||||||
: '';
|
|
||||||
const description = options.serverDescription
|
|
||||||
? `<p class="description">${options.serverDescription}</p>`
|
|
||||||
: '<p class="description">You have been invited to join a Toju server.</p>';
|
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Invite to ${options.serverName}</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: dark;
|
|
||||||
--bg: #050816;
|
|
||||||
--bg-soft: rgba(11, 18, 42, 0.78);
|
|
||||||
--card: rgba(15, 23, 42, 0.92);
|
|
||||||
--border: rgba(148, 163, 184, 0.18);
|
|
||||||
--text: #f8fafc;
|
|
||||||
--muted: #cbd5e1;
|
|
||||||
--primary: #8b5cf6;
|
|
||||||
--primary-soft: rgba(139, 92, 246, 0.16);
|
|
||||||
--secondary: rgba(148, 163, 184, 0.16);
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
color: var(--text);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.28), transparent 32%),
|
|
||||||
radial-gradient(circle at top right, rgba(139, 92, 246, 0.24), transparent 30%),
|
|
||||||
linear-gradient(180deg, #050816 0%, #0b1120 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 32px 20px;
|
|
||||||
}
|
|
||||||
.shell {
|
|
||||||
width: min(100%, 760px);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 28px;
|
|
||||||
background: var(--bg-soft);
|
|
||||||
backdrop-filter: blur(22px);
|
|
||||||
box-shadow: 0 30px 90px rgba(15, 23, 42, 0.5);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.hero {
|
|
||||||
padding: 36px 36px 28px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.8), rgba(15, 23, 42, 0.55));
|
|
||||||
}
|
|
||||||
.eyebrow {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
|
||||||
background: var(--secondary);
|
|
||||||
}
|
|
||||||
.status-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: ${statusColor};
|
|
||||||
box-shadow: 0 0 0 6px color-mix(in srgb, ${statusColor} 18%, transparent);
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
margin: 18px 0 10px;
|
|
||||||
font-size: clamp(2rem, 3vw, 3.25rem);
|
|
||||||
line-height: 1.05;
|
|
||||||
}
|
|
||||||
.description {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
max-width: 44rem;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
display: grid;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 28px 36px 36px;
|
|
||||||
}
|
|
||||||
.meta-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
}
|
|
||||||
.meta-card {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 18px;
|
|
||||||
background: var(--card);
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
.meta-label {
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.meta-value {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
}
|
|
||||||
.button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-height: 56px;
|
|
||||||
padding: 0 18px;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
color: var(--text);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 700;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
|
||||||
}
|
|
||||||
.button:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
.button-primary {
|
|
||||||
background: linear-gradient(135deg, #8b5cf6, #6366f1);
|
|
||||||
box-shadow: 0 18px 36px rgba(99, 102, 241, 0.28);
|
|
||||||
}
|
|
||||||
.button-secondary {
|
|
||||||
border-color: var(--border);
|
|
||||||
background: rgba(15, 23, 42, 0.8);
|
|
||||||
}
|
|
||||||
.notice {
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: rgba(15, 23, 42, 0.72);
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.notice-error {
|
|
||||||
border-color: rgba(248, 113, 113, 0.32);
|
|
||||||
background: rgba(127, 29, 29, 0.18);
|
|
||||||
color: #fecaca;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px 18px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.footer a {
|
|
||||||
color: #c4b5fd;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.footer a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
code {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
color: #ddd6fe;
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.hero, .content { padding-inline: 22px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="shell">
|
|
||||||
<section class="hero">
|
|
||||||
<div class="eyebrow"><span class="status-dot"></span>${statusLabel} invite</div>
|
|
||||||
<h1>Join ${options.serverName}</h1>
|
|
||||||
${description}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="content">
|
|
||||||
${errorBlock}
|
|
||||||
<div class="meta-grid">
|
|
||||||
<article class="meta-card">
|
|
||||||
<div class="meta-label">Server</div>
|
|
||||||
<div class="meta-value">${options.serverName}</div>
|
|
||||||
</article>
|
|
||||||
<article class="meta-card">
|
|
||||||
<div class="meta-label">Owner</div>
|
|
||||||
<div class="meta-value">${options.ownerName || 'Unknown'}</div>
|
|
||||||
</article>
|
|
||||||
<article class="meta-card">
|
|
||||||
<div class="meta-label">Expires</div>
|
|
||||||
<div class="meta-value">${expiryLabel || 'Expired'}</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions" style="${buttonOpacity}">
|
|
||||||
<a class="button button-primary" href="${options.browserUrl || '#'}">Join in browser</a>
|
|
||||||
<a class="button button-secondary" href="${options.appUrl || '#'}">Open with Toju</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="notice">
|
|
||||||
Invite links bypass private and password restrictions, but banned users still cannot join.
|
|
||||||
If Toju is not installed yet, use the desktop button after installing from <a href="https://toju.app/downloads">toju.app/downloads</a>.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<span>Share link: <code>${options.inviteUrl || 'Unavailable'}</code></span>
|
|
||||||
<a href="https://toju.app/downloads">Download Toju</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
invitesApiRouter.get('/:id', async (req, res) => {
|
|
||||||
const signalOrigin = getRequestOrigin(req);
|
|
||||||
const bundle = await getActiveServerInvite(req.params['id']);
|
|
||||||
|
|
||||||
if (!bundle) {
|
|
||||||
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = rowToServer(bundle.server);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
id: bundle.invite.id,
|
|
||||||
serverId: bundle.invite.serverId,
|
|
||||||
createdAt: bundle.invite.createdAt,
|
|
||||||
expiresAt: bundle.invite.expiresAt,
|
|
||||||
inviteUrl: buildInviteUrl(signalOrigin, bundle.invite.id),
|
|
||||||
browserUrl: buildBrowserInviteUrl(signalOrigin, bundle.invite.id),
|
|
||||||
appUrl: buildAppInviteUrl(signalOrigin, bundle.invite.id),
|
|
||||||
sourceUrl: signalOrigin,
|
|
||||||
createdBy: bundle.invite.createdBy,
|
|
||||||
createdByDisplayName: bundle.invite.createdByDisplayName ?? undefined,
|
|
||||||
isExpired: bundle.invite.expiresAt <= Date.now(),
|
|
||||||
server: await enrichServer(server, signalOrigin)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
invitePageRouter.get('/:id', async (req, res) => {
|
|
||||||
const signalOrigin = getRequestOrigin(req);
|
|
||||||
const bundle = await getActiveServerInvite(req.params['id']);
|
|
||||||
|
|
||||||
if (!bundle) {
|
|
||||||
res.status(404).send(renderInvitePage({
|
|
||||||
error: 'This invite has expired or is no longer available.',
|
|
||||||
isExpired: true,
|
|
||||||
serverName: 'Toju server'
|
|
||||||
}));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = rowToServer(bundle.server);
|
|
||||||
const owner = await getUserById(server.ownerId);
|
|
||||||
|
|
||||||
res.send(renderInvitePage({
|
|
||||||
serverName: server.name,
|
|
||||||
serverDescription: server.description,
|
|
||||||
ownerName: owner?.displayName,
|
|
||||||
expiresAt: bundle.invite.expiresAt,
|
|
||||||
inviteUrl: buildInviteUrl(signalOrigin, bundle.invite.id),
|
|
||||||
browserUrl: buildBrowserInviteUrl(signalOrigin, bundle.invite.id),
|
|
||||||
appUrl: buildAppInviteUrl(signalOrigin, bundle.invite.id),
|
|
||||||
isExpired: bundle.invite.expiresAt <= Date.now()
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
@@ -1,90 +1,29 @@
|
|||||||
import { Response, Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { ServerPayload } from '../cqrs/types';
|
import { ServerPayload, JoinRequestPayload } from '../cqrs/types';
|
||||||
import {
|
import {
|
||||||
getAllPublicServers,
|
getAllPublicServers,
|
||||||
getServerById,
|
getServerById,
|
||||||
getUserById,
|
getUserById,
|
||||||
upsertServer,
|
upsertServer,
|
||||||
deleteServer,
|
deleteServer,
|
||||||
|
createJoinRequest,
|
||||||
getPendingRequestsForServer
|
getPendingRequestsForServer
|
||||||
} from '../cqrs';
|
} from '../cqrs';
|
||||||
import {
|
import { notifyServerOwner } from '../websocket/broadcast';
|
||||||
banServerUser,
|
|
||||||
buildSignalingUrl,
|
|
||||||
createServerInvite,
|
|
||||||
joinServerWithAccess,
|
|
||||||
leaveServerUser,
|
|
||||||
passwordHashForInput,
|
|
||||||
ServerAccessError,
|
|
||||||
kickServerUser,
|
|
||||||
ensureServerMembership,
|
|
||||||
unbanServerUser
|
|
||||||
} from '../services/server-access.service';
|
|
||||||
import {
|
|
||||||
buildAppInviteUrl,
|
|
||||||
buildBrowserInviteUrl,
|
|
||||||
buildInviteUrl,
|
|
||||||
getRequestOrigin
|
|
||||||
} from './invite-utils';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
function normalizeRole(role: unknown): string | null {
|
async function enrichServer(server: ServerPayload) {
|
||||||
return typeof role === 'string' ? role.trim().toLowerCase() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
|
|
||||||
return !!role && allowedRoles.includes(role);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
|
||||||
const owner = await getUserById(server.ownerId);
|
const owner = await getUserById(server.ownerId);
|
||||||
const { passwordHash, ...publicServer } = server;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...publicServer,
|
...server,
|
||||||
hasPassword: server.hasPassword ?? !!passwordHash,
|
|
||||||
ownerName: owner?.displayName,
|
ownerName: owner?.displayName,
|
||||||
sourceUrl,
|
|
||||||
userCount: server.currentUsers
|
userCount: server.currentUsers
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendAccessError(error: unknown, res: Response) {
|
|
||||||
if (error instanceof ServerAccessError) {
|
|
||||||
res.status(error.status).json({ error: error.message, errorCode: error.code });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Unhandled server access error:', error);
|
|
||||||
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildInviteResponse(invite: {
|
|
||||||
id: string;
|
|
||||||
createdAt: number;
|
|
||||||
expiresAt: number;
|
|
||||||
createdBy: string;
|
|
||||||
createdByDisplayName: string | null;
|
|
||||||
serverId: string;
|
|
||||||
}, server: ServerPayload, signalOrigin: string) {
|
|
||||||
return {
|
|
||||||
id: invite.id,
|
|
||||||
serverId: invite.serverId,
|
|
||||||
createdAt: invite.createdAt,
|
|
||||||
expiresAt: invite.expiresAt,
|
|
||||||
inviteUrl: buildInviteUrl(signalOrigin, invite.id),
|
|
||||||
browserUrl: buildBrowserInviteUrl(signalOrigin, invite.id),
|
|
||||||
appUrl: buildAppInviteUrl(signalOrigin, invite.id),
|
|
||||||
sourceUrl: signalOrigin,
|
|
||||||
createdBy: invite.createdBy,
|
|
||||||
createdByDisplayName: invite.createdByDisplayName ?? undefined,
|
|
||||||
isExpired: invite.expiresAt <= Date.now(),
|
|
||||||
server: await enrichServer(server, signalOrigin)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||||
|
|
||||||
@@ -115,30 +54,17 @@ router.get('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const {
|
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
|
||||||
id: clientId,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
ownerId,
|
|
||||||
ownerPublicKey,
|
|
||||||
isPrivate,
|
|
||||||
maxUsers,
|
|
||||||
password,
|
|
||||||
tags
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
if (!name || !ownerId || !ownerPublicKey)
|
if (!name || !ownerId || !ownerPublicKey)
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
|
||||||
const passwordHash = passwordHashForInput(password);
|
|
||||||
const server: ServerPayload = {
|
const server: ServerPayload = {
|
||||||
id: clientId || uuidv4(),
|
id: clientId || uuidv4(),
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
ownerId,
|
ownerId,
|
||||||
ownerPublicKey,
|
ownerPublicKey,
|
||||||
hasPassword: !!passwordHash,
|
|
||||||
passwordHash,
|
|
||||||
isPrivate: isPrivate ?? false,
|
isPrivate: isPrivate ?? false,
|
||||||
maxUsers: maxUsers ?? 0,
|
maxUsers: maxUsers ?? 0,
|
||||||
currentUsers: 0,
|
currentUsers: 0,
|
||||||
@@ -148,216 +74,25 @@ router.post('/', async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await upsertServer(server);
|
await upsertServer(server);
|
||||||
await ensureServerMembership(server.id, ownerId);
|
res.status(201).json(server);
|
||||||
|
|
||||||
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const {
|
const { currentOwnerId, ...updates } = req.body;
|
||||||
currentOwnerId,
|
|
||||||
actingRole,
|
|
||||||
password,
|
|
||||||
hasPassword: _ignoredHasPassword,
|
|
||||||
passwordHash: _ignoredPasswordHash,
|
|
||||||
...updates
|
|
||||||
} = req.body;
|
|
||||||
const existing = await getServerById(id);
|
const existing = await getServerById(id);
|
||||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||||
const normalizedRole = normalizeRole(actingRole);
|
|
||||||
|
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
if (
|
if (existing.ownerId !== authenticatedOwnerId)
|
||||||
existing.ownerId !== authenticatedOwnerId &&
|
|
||||||
!isAllowedRole(normalizedRole, ['host', 'admin'])
|
|
||||||
) {
|
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
}
|
|
||||||
|
|
||||||
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
|
const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() };
|
||||||
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
|
|
||||||
const server: ServerPayload = {
|
|
||||||
...existing,
|
|
||||||
...updates,
|
|
||||||
hasPassword: !!nextPasswordHash,
|
|
||||||
passwordHash: nextPasswordHash,
|
|
||||||
lastSeen: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
await upsertServer(server);
|
await upsertServer(server);
|
||||||
res.json(await enrichServer(server, getRequestOrigin(req)));
|
res.json(server);
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/join', async (req, res) => {
|
|
||||||
const { id: serverId } = req.params;
|
|
||||||
const { userId, password, inviteId } = req.body;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await joinServerWithAccess({
|
|
||||||
serverId,
|
|
||||||
userId: String(userId),
|
|
||||||
password: typeof password === 'string' ? password : undefined,
|
|
||||||
inviteId: typeof inviteId === 'string' ? inviteId : undefined
|
|
||||||
});
|
|
||||||
const origin = getRequestOrigin(req);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
signalingUrl: buildSignalingUrl(origin),
|
|
||||||
joinedBefore: result.joinedBefore,
|
|
||||||
via: result.via,
|
|
||||||
server: await enrichServer(result.server, origin)
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
sendAccessError(error, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/invites', async (req, res) => {
|
|
||||||
const { id: serverId } = req.params;
|
|
||||||
const { requesterUserId, requesterDisplayName } = req.body;
|
|
||||||
|
|
||||||
if (!requesterUserId) {
|
|
||||||
return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = await getServerById(serverId);
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const invite = await createServerInvite(
|
|
||||||
serverId,
|
|
||||||
String(requesterUserId),
|
|
||||||
typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
res.status(201).json(await buildInviteResponse(invite, server, getRequestOrigin(req)));
|
|
||||||
} catch (error) {
|
|
||||||
sendAccessError(error, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/moderation/kick', async (req, res) => {
|
|
||||||
const { id: serverId } = req.params;
|
|
||||||
const { actorUserId, actorRole, targetUserId } = req.body;
|
|
||||||
const server = await getServerById(serverId);
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetUserId) {
|
|
||||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
server.ownerId !== actorUserId &&
|
|
||||||
!isAllowedRole(normalizeRole(actorRole), [
|
|
||||||
'host',
|
|
||||||
'admin',
|
|
||||||
'moderator'
|
|
||||||
])
|
|
||||||
) {
|
|
||||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await kickServerUser(serverId, String(targetUserId));
|
|
||||||
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/moderation/ban', async (req, res) => {
|
|
||||||
const { id: serverId } = req.params;
|
|
||||||
const { actorUserId, actorRole, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
|
||||||
const server = await getServerById(serverId);
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetUserId) {
|
|
||||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
server.ownerId !== actorUserId &&
|
|
||||||
!isAllowedRole(normalizeRole(actorRole), [
|
|
||||||
'host',
|
|
||||||
'admin',
|
|
||||||
'moderator'
|
|
||||||
])
|
|
||||||
) {
|
|
||||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await banServerUser({
|
|
||||||
serverId,
|
|
||||||
userId: String(targetUserId),
|
|
||||||
banId: typeof banId === 'string' ? banId : undefined,
|
|
||||||
bannedBy: String(actorUserId || ''),
|
|
||||||
displayName: typeof displayName === 'string' ? displayName : undefined,
|
|
||||||
reason: typeof reason === 'string' ? reason : undefined,
|
|
||||||
expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/moderation/unban', async (req, res) => {
|
|
||||||
const { id: serverId } = req.params;
|
|
||||||
const { actorUserId, actorRole, banId, targetUserId } = req.body;
|
|
||||||
const server = await getServerById(serverId);
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
server.ownerId !== actorUserId &&
|
|
||||||
!isAllowedRole(normalizeRole(actorRole), [
|
|
||||||
'host',
|
|
||||||
'admin',
|
|
||||||
'moderator'
|
|
||||||
])
|
|
||||||
) {
|
|
||||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await unbanServerUser({
|
|
||||||
serverId,
|
|
||||||
banId: typeof banId === 'string' ? banId : undefined,
|
|
||||||
userId: typeof targetUserId === 'string' ? targetUserId : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/leave', async (req, res) => {
|
|
||||||
const { id: serverId } = req.params;
|
|
||||||
const { userId } = req.body;
|
|
||||||
const server = await getServerById(serverId);
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await leaveServerUser(serverId, String(userId));
|
|
||||||
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/heartbeat', async (req, res) => {
|
router.post('/:id/heartbeat', async (req, res) => {
|
||||||
@@ -393,6 +128,32 @@ router.delete('/:id', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/:id/join', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { userId, userPublicKey, displayName } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server)
|
||||||
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
|
const request: JoinRequestPayload = {
|
||||||
|
id: uuidv4(),
|
||||||
|
serverId,
|
||||||
|
userId,
|
||||||
|
userPublicKey,
|
||||||
|
displayName,
|
||||||
|
status: server.isPrivate ? 'pending' : 'approved',
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
await createJoinRequest(request);
|
||||||
|
|
||||||
|
if (server.isPrivate)
|
||||||
|
notifyServerOwner(server.ownerId, { type: 'join_request', request });
|
||||||
|
|
||||||
|
res.status(201).json(request);
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/:id/requests', async (req, res) => {
|
router.get('/:id/requests', async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { ownerId } = req.query;
|
const { ownerId } = req.query;
|
||||||
@@ -409,15 +170,4 @@ router.get('/:id/requests', async (req, res) => {
|
|||||||
res.json({ requests });
|
res.json({ requests });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id', async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const server = await getServerById(id);
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(await enrichServer(server, getRequestOrigin(req)));
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,390 +0,0 @@
|
|||||||
import crypto from 'crypto';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { getDataSource } from '../db/database';
|
|
||||||
import {
|
|
||||||
ServerBanEntity,
|
|
||||||
ServerEntity,
|
|
||||||
ServerInviteEntity,
|
|
||||||
ServerMembershipEntity
|
|
||||||
} from '../entities';
|
|
||||||
import { rowToServer } from '../cqrs/mappers';
|
|
||||||
import { ServerPayload } from '../cqrs/types';
|
|
||||||
|
|
||||||
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
export type JoinAccessVia = 'membership' | 'password' | 'invite' | 'public';
|
|
||||||
|
|
||||||
export interface JoinServerAccessResult {
|
|
||||||
joinedBefore: boolean;
|
|
||||||
server: ServerPayload;
|
|
||||||
via: JoinAccessVia;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BanServerUserOptions {
|
|
||||||
banId?: string;
|
|
||||||
bannedBy: string;
|
|
||||||
displayName?: string;
|
|
||||||
expiresAt?: number;
|
|
||||||
reason?: string;
|
|
||||||
serverId: string;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ServerAccessError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly status: number,
|
|
||||||
readonly code: string,
|
|
||||||
message: string
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'ServerAccessError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerRepository() {
|
|
||||||
return getDataSource().getRepository(ServerEntity);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMembershipRepository() {
|
|
||||||
return getDataSource().getRepository(ServerMembershipEntity);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInviteRepository() {
|
|
||||||
return getDataSource().getRepository(ServerInviteEntity);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBanRepository() {
|
|
||||||
return getDataSource().getRepository(ServerBanEntity);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePassword(password?: string | null): string | null {
|
|
||||||
const normalized = password?.trim() ?? '';
|
|
||||||
|
|
||||||
return normalized.length > 0 ? normalized : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isServerOwner(server: ServerEntity, userId: string): boolean {
|
|
||||||
return server.ownerId === userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hashServerPassword(password: string): string {
|
|
||||||
return crypto.createHash('sha256').update(password)
|
|
||||||
.digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function passwordHashForInput(password?: string | null): string | null {
|
|
||||||
const normalized = normalizePassword(password);
|
|
||||||
|
|
||||||
return normalized ? hashServerPassword(normalized) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSignalingUrl(origin: string): string {
|
|
||||||
return origin.replace(/^http/i, 'ws');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pruneExpiredServerAccessArtifacts(now: number = Date.now()): Promise<void> {
|
|
||||||
await getInviteRepository()
|
|
||||||
.createQueryBuilder()
|
|
||||||
.delete()
|
|
||||||
.where('expiresAt <= :now', { now })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await getBanRepository()
|
|
||||||
.createQueryBuilder()
|
|
||||||
.delete()
|
|
||||||
.where('expiresAt IS NOT NULL AND expiresAt <= :now', { now })
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getServerRecord(serverId: string): Promise<ServerEntity | null> {
|
|
||||||
return await getServerRepository().findOne({ where: { id: serverId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getActiveServerBan(serverId: string, userId: string): Promise<ServerBanEntity | null> {
|
|
||||||
const banRepo = getBanRepository();
|
|
||||||
const ban = await banRepo.findOne({ where: { serverId, userId } });
|
|
||||||
|
|
||||||
if (!ban)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if (ban.expiresAt && ban.expiresAt <= Date.now()) {
|
|
||||||
await banRepo.delete({ id: ban.id });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ban;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function isServerUserBanned(serverId: string, userId: string): Promise<boolean> {
|
|
||||||
return !!(await getActiveServerBan(serverId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity | null> {
|
|
||||||
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
|
||||||
const repo = getMembershipRepository();
|
|
||||||
const now = Date.now();
|
|
||||||
const existing = await repo.findOne({ where: { serverId, userId } });
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
existing.lastAccessAt = now;
|
|
||||||
await repo.save(existing);
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entity = repo.create({
|
|
||||||
id: uuidv4(),
|
|
||||||
serverId,
|
|
||||||
userId,
|
|
||||||
joinedAt: now,
|
|
||||||
lastAccessAt: now
|
|
||||||
});
|
|
||||||
|
|
||||||
await repo.save(entity);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeServerMembership(serverId: string, userId: string): Promise<void> {
|
|
||||||
await getMembershipRepository().delete({ serverId, userId });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function assertCanCreateInvite(serverId: string, requesterUserId: string): Promise<ServerEntity> {
|
|
||||||
const server = await getServerRecord(serverId);
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await isServerUserBanned(serverId, requesterUserId)) {
|
|
||||||
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot create invites');
|
|
||||||
}
|
|
||||||
|
|
||||||
const membership = await findServerMembership(serverId, requesterUserId);
|
|
||||||
|
|
||||||
if (server.ownerId !== requesterUserId && !membership) {
|
|
||||||
throw new ServerAccessError(403, 'NOT_MEMBER', 'Only joined users can create invites');
|
|
||||||
}
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createServerInvite(
|
|
||||||
serverId: string,
|
|
||||||
createdBy: string,
|
|
||||||
createdByDisplayName?: string
|
|
||||||
): Promise<ServerInviteEntity> {
|
|
||||||
await assertCanCreateInvite(serverId, createdBy);
|
|
||||||
|
|
||||||
const repo = getInviteRepository();
|
|
||||||
const now = Date.now();
|
|
||||||
const invite = repo.create({
|
|
||||||
id: uuidv4(),
|
|
||||||
serverId,
|
|
||||||
createdBy,
|
|
||||||
createdByDisplayName: createdByDisplayName ?? null,
|
|
||||||
createdAt: now,
|
|
||||||
expiresAt: now + SERVER_INVITE_EXPIRY_MS
|
|
||||||
});
|
|
||||||
|
|
||||||
await repo.save(invite);
|
|
||||||
return invite;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getActiveServerInvite(
|
|
||||||
inviteId: string
|
|
||||||
): Promise<{ invite: ServerInviteEntity; server: ServerEntity } | null> {
|
|
||||||
await pruneExpiredServerAccessArtifacts();
|
|
||||||
|
|
||||||
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
|
||||||
|
|
||||||
if (!invite) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (invite.expiresAt <= Date.now()) {
|
|
||||||
await getInviteRepository().delete({ id: invite.id });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = await getServerRecord(invite.serverId);
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { invite, server };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function joinServerWithAccess(options: {
|
|
||||||
inviteId?: string;
|
|
||||||
password?: string;
|
|
||||||
serverId: string;
|
|
||||||
userId: string;
|
|
||||||
}): Promise<JoinServerAccessResult> {
|
|
||||||
await pruneExpiredServerAccessArtifacts();
|
|
||||||
|
|
||||||
const server = await getServerRecord(options.serverId);
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await isServerUserBanned(server.id, options.userId)) {
|
|
||||||
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot join this server');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isServerOwner(server, options.userId)) {
|
|
||||||
const existingMembership = await findServerMembership(server.id, options.userId);
|
|
||||||
|
|
||||||
await ensureServerMembership(server.id, options.userId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
joinedBefore: !!existingMembership,
|
|
||||||
server: rowToServer(server),
|
|
||||||
via: 'membership'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.inviteId) {
|
|
||||||
const inviteBundle = await getActiveServerInvite(options.inviteId);
|
|
||||||
|
|
||||||
if (!inviteBundle || inviteBundle.server.id !== server.id) {
|
|
||||||
throw new ServerAccessError(410, 'INVITE_EXPIRED', 'Invite link has expired or is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingMembership = await findServerMembership(server.id, options.userId);
|
|
||||||
|
|
||||||
await ensureServerMembership(server.id, options.userId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
joinedBefore: !!existingMembership,
|
|
||||||
server: rowToServer(server),
|
|
||||||
via: 'invite'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const membership = await findServerMembership(server.id, options.userId);
|
|
||||||
|
|
||||||
if (membership) {
|
|
||||||
await ensureServerMembership(server.id, options.userId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
joinedBefore: true,
|
|
||||||
server: rowToServer(server),
|
|
||||||
via: 'membership'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (server.passwordHash) {
|
|
||||||
const passwordHash = passwordHashForInput(options.password);
|
|
||||||
|
|
||||||
if (!passwordHash || passwordHash !== server.passwordHash) {
|
|
||||||
throw new ServerAccessError(403, 'PASSWORD_REQUIRED', 'Password required to join this server');
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureServerMembership(server.id, options.userId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
joinedBefore: false,
|
|
||||||
server: rowToServer(server),
|
|
||||||
via: 'password'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (server.isPrivate) {
|
|
||||||
throw new ServerAccessError(403, 'PRIVATE_SERVER', 'Private servers require an invite link');
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureServerMembership(server.id, options.userId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
joinedBefore: false,
|
|
||||||
server: rowToServer(server),
|
|
||||||
via: 'public'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function authorizeWebSocketJoin(serverId: string, userId: string): Promise<{ allowed: boolean; reason?: string }> {
|
|
||||||
await pruneExpiredServerAccessArtifacts();
|
|
||||||
|
|
||||||
const server = await getServerRecord(serverId);
|
|
||||||
|
|
||||||
if (!server) {
|
|
||||||
return { allowed: false,
|
|
||||||
reason: 'SERVER_NOT_FOUND' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await isServerUserBanned(serverId, userId)) {
|
|
||||||
return { allowed: false,
|
|
||||||
reason: 'BANNED' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isServerOwner(server, userId)) {
|
|
||||||
await ensureServerMembership(serverId, userId);
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const membership = await findServerMembership(serverId, userId);
|
|
||||||
|
|
||||||
if (membership) {
|
|
||||||
await ensureServerMembership(serverId, userId);
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!server.isPrivate && !server.passwordHash) {
|
|
||||||
await ensureServerMembership(serverId, userId);
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: server.isPrivate ? 'PRIVATE_SERVER' : 'PASSWORD_REQUIRED'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function kickServerUser(serverId: string, userId: string): Promise<void> {
|
|
||||||
await removeServerMembership(serverId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function leaveServerUser(serverId: string, userId: string): Promise<void> {
|
|
||||||
await removeServerMembership(serverId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function banServerUser(options: BanServerUserOptions): Promise<ServerBanEntity> {
|
|
||||||
await removeServerMembership(options.serverId, options.userId);
|
|
||||||
|
|
||||||
const repo = getBanRepository();
|
|
||||||
const existing = await repo.findOne({ where: { serverId: options.serverId, userId: options.userId } });
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await repo.delete({ id: existing.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
const entity = repo.create({
|
|
||||||
id: options.banId ?? uuidv4(),
|
|
||||||
serverId: options.serverId,
|
|
||||||
userId: options.userId,
|
|
||||||
bannedBy: options.bannedBy,
|
|
||||||
displayName: options.displayName ?? null,
|
|
||||||
reason: options.reason ?? null,
|
|
||||||
expiresAt: options.expiresAt ?? null,
|
|
||||||
createdAt: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
await repo.save(entity);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unbanServerUser(options: { banId?: string; serverId: string; userId?: string }): Promise<void> {
|
|
||||||
const repo = getBanRepository();
|
|
||||||
|
|
||||||
if (options.banId) {
|
|
||||||
await repo.delete({ id: options.banId, serverId: options.serverId });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.userId) {
|
|
||||||
await repo.delete({ serverId: options.serverId, userId: options.userId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
import { ConnectedUser } from './types';
|
import { ConnectedUser } from './types';
|
||||||
import { broadcastToServer, findUserByOderId } from './broadcast';
|
import { broadcastToServer, findUserByOderId } from './broadcast';
|
||||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
|
||||||
|
|
||||||
interface WsMessage {
|
interface WsMessage {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -11,7 +10,7 @@ interface WsMessage {
|
|||||||
/** Sends the current user list for a given server to a single connected user. */
|
/** Sends the current user list for a given server to a single connected user. */
|
||||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||||
const users = Array.from(connectedUsers.values())
|
const users = Array.from(connectedUsers.values())
|
||||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
|
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId && cu.displayName)
|
||||||
.map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' }));
|
.map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' }));
|
||||||
|
|
||||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||||
@@ -24,24 +23,8 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
|||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const sid = String(message['serverId']);
|
const sid = String(message['serverId']);
|
||||||
|
|
||||||
if (!sid)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const authorization = await authorizeWebSocketJoin(sid, user.oderId);
|
|
||||||
|
|
||||||
if (!authorization.allowed) {
|
|
||||||
user.ws.send(JSON.stringify({
|
|
||||||
type: 'access_denied',
|
|
||||||
serverId: sid,
|
|
||||||
reason: authorization.reason
|
|
||||||
}));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isNew = !user.serverIds.has(sid);
|
const isNew = !user.serverIds.has(sid);
|
||||||
|
|
||||||
user.serverIds.add(sid);
|
user.serverIds.add(sid);
|
||||||
@@ -88,8 +71,7 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
|||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName ?? 'Anonymous',
|
displayName: user.displayName ?? 'Anonymous',
|
||||||
serverId: leaveSid,
|
serverId: leaveSid
|
||||||
serverIds: Array.from(user.serverIds)
|
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +121,7 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
export function handleWebSocketMessage(connectionId: string, message: WsMessage): void {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
if (!user)
|
if (!user)
|
||||||
@@ -151,7 +133,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'join_server':
|
case 'join_server':
|
||||||
await handleJoinServer(user, message, connectionId);
|
handleJoinServer(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'view_server':
|
case 'view_server':
|
||||||
|
|||||||
@@ -9,87 +9,39 @@ import { connectedUsers } from './state';
|
|||||||
import { broadcastToServer } from './broadcast';
|
import { broadcastToServer } from './broadcast';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
/** How often to ping all connected clients (ms). */
|
|
||||||
const PING_INTERVAL_MS = 30_000;
|
|
||||||
/** Maximum time a client can go without a pong before we consider it dead (ms). */
|
|
||||||
const PONG_TIMEOUT_MS = 45_000;
|
|
||||||
|
|
||||||
function removeDeadConnection(connectionId: string): void {
|
|
||||||
const user = connectedUsers.get(connectionId);
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
|
||||||
|
|
||||||
user.serverIds.forEach((sid) => {
|
|
||||||
broadcastToServer(sid, {
|
|
||||||
type: 'user_left',
|
|
||||||
oderId: user.oderId,
|
|
||||||
displayName: user.displayName,
|
|
||||||
serverId: sid,
|
|
||||||
serverIds: []
|
|
||||||
}, user.oderId);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
user.ws.terminate();
|
|
||||||
} catch {
|
|
||||||
console.warn(`Failed to terminate WebSocket for ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedUsers.delete(connectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupWebSocket(server: Server<typeof IncomingMessage, typeof ServerResponse>): void {
|
export function setupWebSocket(server: Server<typeof IncomingMessage, typeof ServerResponse>): void {
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
// Periodically ping all clients and reap dead connections
|
|
||||||
const pingInterval = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
connectedUsers.forEach((user, connectionId) => {
|
|
||||||
if (now - user.lastPong > PONG_TIMEOUT_MS) {
|
|
||||||
removeDeadConnection(connectionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.ws.readyState === WebSocket.OPEN) {
|
|
||||||
try {
|
|
||||||
user.ws.ping();
|
|
||||||
} catch {
|
|
||||||
console.warn(`Failed to ping client ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, PING_INTERVAL_MS);
|
|
||||||
|
|
||||||
wss.on('close', () => clearInterval(pingInterval));
|
|
||||||
|
|
||||||
wss.on('connection', (ws: WebSocket) => {
|
wss.on('connection', (ws: WebSocket) => {
|
||||||
const connectionId = uuidv4();
|
const connectionId = uuidv4();
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set(), lastPong: now });
|
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
|
||||||
|
|
||||||
ws.on('pong', () => {
|
ws.on('message', (data) => {
|
||||||
const user = connectedUsers.get(connectionId);
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
user.lastPong = Date.now();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('message', async (data) => {
|
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(data.toString());
|
const message = JSON.parse(data.toString());
|
||||||
|
|
||||||
await handleWebSocketMessage(connectionId, message);
|
handleWebSocketMessage(connectionId, message);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Invalid WebSocket message:', err);
|
console.error('Invalid WebSocket message:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
removeDeadConnection(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
user.serverIds.forEach((sid) => {
|
||||||
|
broadcastToServer(sid, {
|
||||||
|
type: 'user_left',
|
||||||
|
oderId: user.oderId,
|
||||||
|
displayName: user.displayName,
|
||||||
|
serverId: sid
|
||||||
|
}, user.oderId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedUsers.delete(connectionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
|
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
|
||||||
|
|||||||
@@ -6,6 +6,4 @@ export interface ConnectedUser {
|
|||||||
serverIds: Set<string>;
|
serverIds: Set<string>;
|
||||||
viewedServerId?: string;
|
viewedServerId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
/** Timestamp of the last pong received (used to detect dead connections). */
|
|
||||||
lastPong: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,47 @@
|
|||||||
<app-floating-voice-controls />
|
<app-floating-voice-controls />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (desktopUpdateState().serverBlocked) {
|
||||||
|
<div class="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 px-6 py-10 backdrop-blur-sm">
|
||||||
|
<div class="w-full max-w-xl rounded-2xl border border-red-500/30 bg-card p-6 shadow-2xl">
|
||||||
|
<h2 class="text-xl font-semibold text-foreground">Server update required</h2>
|
||||||
|
<p class="mt-3 text-sm text-muted-foreground">
|
||||||
|
{{ desktopUpdateState().serverBlockMessage || 'The connected server must be updated before this desktop app can continue.' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-4 rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
|
||||||
|
<p class="mt-2 text-foreground">{{ desktopUpdateState().serverVersion || 'Not reported' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
|
||||||
|
<p class="mt-2 text-foreground">{{ desktopUpdateState().minimumServerVersion || 'Unknown' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="refreshDesktopUpdateContext()"
|
||||||
|
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="openNetworkSettings()"
|
||||||
|
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Open network settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Unified Settings Modal -->
|
<!-- Unified Settings Modal -->
|
||||||
<app-settings-modal />
|
<app-settings-modal />
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/auth/register/register.component').then((module) => module.RegisterComponent)
|
import('./features/auth/register/register.component').then((module) => module.RegisterComponent)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'invite/:inviteId',
|
|
||||||
loadComponent: () =>
|
|
||||||
import('./features/invite/invite.component').then((module) => module.InviteComponent)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'search',
|
path: 'search',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
102
src/app/app.ts
102
src/app/app.ts
@@ -2,7 +2,6 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
OnInit,
|
OnInit,
|
||||||
OnDestroy,
|
|
||||||
inject,
|
inject,
|
||||||
HostListener
|
HostListener
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -36,15 +35,6 @@ import {
|
|||||||
STORAGE_KEY_LAST_VISITED_ROUTE
|
STORAGE_KEY_LAST_VISITED_ROUTE
|
||||||
} from './core/constants';
|
} from './core/constants';
|
||||||
|
|
||||||
interface DeepLinkElectronApi {
|
|
||||||
consumePendingDeepLink?: () => Promise<string | null>;
|
|
||||||
onDeepLinkReceived?: (listener: (url: string) => void) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeepLinkWindow = Window & {
|
|
||||||
electronAPI?: DeepLinkElectronApi;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [
|
imports: [
|
||||||
@@ -60,7 +50,7 @@ type DeepLinkWindow = Window & {
|
|||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App implements OnInit, OnDestroy {
|
export class App implements OnInit {
|
||||||
store = inject(Store);
|
store = inject(Store);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
desktopUpdates = inject(DesktopAppUpdateService);
|
desktopUpdates = inject(DesktopAppUpdateService);
|
||||||
@@ -73,7 +63,6 @@ export class App implements OnInit, OnDestroy {
|
|||||||
private timeSync = inject(TimeSyncService);
|
private timeSync = inject(TimeSyncService);
|
||||||
private voiceSession = inject(VoiceSessionService);
|
private voiceSession = inject(VoiceSessionService);
|
||||||
private externalLinks = inject(ExternalLinkService);
|
private externalLinks = inject(ExternalLinkService);
|
||||||
private deepLinkCleanup: (() => void) | null = null;
|
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
onGlobalLinkClick(evt: MouseEvent): void {
|
onGlobalLinkClick(evt: MouseEvent): void {
|
||||||
@@ -91,8 +80,6 @@ export class App implements OnInit, OnDestroy {
|
|||||||
await this.timeSync.syncWithEndpoint(apiBase);
|
await this.timeSync.syncWithEndpoint(apiBase);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
await this.setupDesktopDeepLinks();
|
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.loadRooms());
|
this.store.dispatch(RoomsActions.loadRooms());
|
||||||
@@ -100,12 +87,8 @@ export class App implements OnInit, OnDestroy {
|
|||||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
if (!this.isPublicRoute(this.router.url)) {
|
if (this.router.url !== '/login' && this.router.url !== '/register') {
|
||||||
this.router.navigate(['/login'], {
|
this.router.navigate(['/login']).catch(() => {});
|
||||||
queryParams: {
|
|
||||||
returnUrl: this.router.url
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
||||||
@@ -133,11 +116,6 @@ export class App implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.deepLinkCleanup?.();
|
|
||||||
this.deepLinkCleanup = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
openNetworkSettings(): void {
|
openNetworkSettings(): void {
|
||||||
this.settingsModal.open('network');
|
this.settingsModal.open('network');
|
||||||
}
|
}
|
||||||
@@ -153,78 +131,4 @@ export class App implements OnInit, OnDestroy {
|
|||||||
async restartToApplyUpdate(): Promise<void> {
|
async restartToApplyUpdate(): Promise<void> {
|
||||||
await this.desktopUpdates.restartToApplyUpdate();
|
await this.desktopUpdates.restartToApplyUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setupDesktopDeepLinks(): Promise<void> {
|
|
||||||
const electronApi = this.getDeepLinkElectronApi();
|
|
||||||
|
|
||||||
if (!electronApi) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.deepLinkCleanup = electronApi.onDeepLinkReceived?.((url) => {
|
|
||||||
void this.handleDesktopDeepLink(url);
|
|
||||||
}) || null;
|
|
||||||
|
|
||||||
const pendingDeepLink = await electronApi.consumePendingDeepLink?.();
|
|
||||||
|
|
||||||
if (pendingDeepLink) {
|
|
||||||
await this.handleDesktopDeepLink(pendingDeepLink);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleDesktopDeepLink(url: string): Promise<void> {
|
|
||||||
const invite = this.parseDesktopInviteUrl(url);
|
|
||||||
|
|
||||||
if (!invite) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.router.navigate(['/invite', invite.inviteId], {
|
|
||||||
queryParams: {
|
|
||||||
server: invite.sourceUrl
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDeepLinkElectronApi(): DeepLinkElectronApi | null {
|
|
||||||
return typeof window !== 'undefined'
|
|
||||||
? (window as DeepLinkWindow).electronAPI ?? null
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isPublicRoute(url: string): boolean {
|
|
||||||
return url === '/login' ||
|
|
||||||
url === '/register' ||
|
|
||||||
url.startsWith('/invite/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {
|
|
||||||
try {
|
|
||||||
const parsedUrl = new URL(url);
|
|
||||||
|
|
||||||
if (parsedUrl.protocol !== 'toju:') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathSegments = [parsedUrl.hostname, ...parsedUrl.pathname.split('/').filter(Boolean)]
|
|
||||||
.map((segment) => decodeURIComponent(segment));
|
|
||||||
|
|
||||||
if (pathSegments[0] !== 'invite' || !pathSegments[1]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceUrl = parsedUrl.searchParams.get('server')?.trim();
|
|
||||||
|
|
||||||
if (!sourceUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
inviteId: pathSegments[1],
|
|
||||||
sourceUrl
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ export interface Room {
|
|||||||
topic?: string;
|
topic?: string;
|
||||||
hostId: string;
|
hostId: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
hasPassword?: boolean;
|
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
@@ -81,9 +80,6 @@ export interface Room {
|
|||||||
permissions?: RoomPermissions;
|
permissions?: RoomPermissions;
|
||||||
channels?: Channel[];
|
channels?: Channel[];
|
||||||
members?: RoomMember[];
|
members?: RoomMember[];
|
||||||
sourceId?: string;
|
|
||||||
sourceName?: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomSettings {
|
export interface RoomSettings {
|
||||||
@@ -92,7 +88,6 @@ export interface RoomSettings {
|
|||||||
topic?: string;
|
topic?: string;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
hasPassword?: boolean;
|
|
||||||
maxUsers?: number;
|
maxUsers?: number;
|
||||||
rules?: string[];
|
rules?: string[];
|
||||||
}
|
}
|
||||||
@@ -270,14 +265,14 @@ export interface ChatEvent {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
settings?: Partial<RoomSettings>;
|
settings?: RoomSettings;
|
||||||
permissions?: Partial<RoomPermissions>;
|
permissions?: Partial<RoomPermissions>;
|
||||||
voiceState?: Partial<VoiceState>;
|
voiceState?: Partial<VoiceState>;
|
||||||
isScreenSharing?: boolean;
|
isScreenSharing?: boolean;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
iconUpdatedAt?: number;
|
iconUpdatedAt?: number;
|
||||||
role?: UserRole;
|
role?: UserRole;
|
||||||
room?: Partial<Room>;
|
room?: Room;
|
||||||
channels?: Channel[];
|
channels?: Channel[];
|
||||||
members?: RoomMember[];
|
members?: RoomMember[];
|
||||||
ban?: BanEntry;
|
ban?: BanEntry;
|
||||||
@@ -297,13 +292,11 @@ export interface ServerInfo {
|
|||||||
ownerPublicKey?: string;
|
ownerPublicKey?: string;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
maxUsers: number;
|
maxUsers: number;
|
||||||
hasPassword?: boolean;
|
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
sourceName?: string;
|
sourceName?: string;
|
||||||
sourceUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JoinRequest {
|
export interface JoinRequest {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
effect
|
effect
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NavigationEnd, Router } from '@angular/router';
|
|
||||||
import { take } from 'rxjs';
|
import { take } from 'rxjs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { WebRTCService } from './webrtc.service';
|
import { WebRTCService } from './webrtc.service';
|
||||||
@@ -13,7 +12,6 @@ import { Store } from '@ngrx/store';
|
|||||||
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
|
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
|
||||||
import { DatabaseService } from './database.service';
|
import { DatabaseService } from './database.service';
|
||||||
import { recordDebugNetworkFileChunk } from './debug-network-metrics.service';
|
import { recordDebugNetworkFileChunk } from './debug-network-metrics.service';
|
||||||
import { ROOM_URL_PATTERN } from '../constants';
|
|
||||||
import type {
|
import type {
|
||||||
ChatAttachmentAnnouncement,
|
ChatAttachmentAnnouncement,
|
||||||
ChatAttachmentMeta,
|
ChatAttachmentMeta,
|
||||||
@@ -147,14 +145,9 @@ export class AttachmentService {
|
|||||||
private readonly webrtc = inject(WebRTCService);
|
private readonly webrtc = inject(WebRTCService);
|
||||||
private readonly ngrxStore = inject(Store);
|
private readonly ngrxStore = inject(Store);
|
||||||
private readonly database = inject(DatabaseService);
|
private readonly database = inject(DatabaseService);
|
||||||
private readonly router = inject(Router);
|
|
||||||
|
|
||||||
/** Primary index: `messageId → Attachment[]`. */
|
/** Primary index: `messageId → Attachment[]`. */
|
||||||
private attachmentsByMessage = new Map<string, Attachment[]>();
|
private attachmentsByMessage = new Map<string, Attachment[]>();
|
||||||
/** Runtime cache of `messageId → roomId` for attachment gating. */
|
|
||||||
private messageRoomIds = new Map<string, string>();
|
|
||||||
/** Room currently being watched in the router, or `null` outside room routes. */
|
|
||||||
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
|
|
||||||
|
|
||||||
/** Incremented on every mutation so signal consumers re-render. */
|
/** Incremented on every mutation so signal consumers re-render. */
|
||||||
updated = signal<number>(0);
|
updated = signal<number>(0);
|
||||||
@@ -197,24 +190,6 @@ export class AttachmentService {
|
|||||||
this.initFromDatabase();
|
this.initFromDatabase();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.router.events.subscribe((event) => {
|
|
||||||
if (!(event instanceof NavigationEnd)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
|
|
||||||
|
|
||||||
if (this.watchedRoomId) {
|
|
||||||
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.webrtc.onPeerConnected.subscribe(() => {
|
|
||||||
if (this.watchedRoomId) {
|
|
||||||
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getElectronApi(): AttachmentElectronApi | undefined {
|
private getElectronApi(): AttachmentElectronApi | undefined {
|
||||||
@@ -226,44 +201,6 @@ export class AttachmentService {
|
|||||||
return this.attachmentsByMessage.get(messageId) ?? [];
|
return this.attachmentsByMessage.get(messageId) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cache the room that owns a message so background downloads can be gated by the watched server. */
|
|
||||||
rememberMessageRoom(messageId: string, roomId: string): void {
|
|
||||||
if (!messageId || !roomId)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.messageRoomIds.set(messageId, roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Queue best-effort auto-download checks for a message's eligible attachments. */
|
|
||||||
queueAutoDownloadsForMessage(messageId: string, attachmentId?: string): void {
|
|
||||||
void this.requestAutoDownloadsForMessage(messageId, attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Auto-request eligible missing attachments for the currently watched room. */
|
|
||||||
async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
|
|
||||||
if (!roomId || !this.isRoomWatched(roomId))
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (this.database.isReady()) {
|
|
||||||
const messages = await this.database.getMessages(roomId, 500, 0);
|
|
||||||
|
|
||||||
for (const message of messages) {
|
|
||||||
this.rememberMessageRoom(message.id, message.roomId);
|
|
||||||
await this.requestAutoDownloadsForMessage(message.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [messageId] of this.attachmentsByMessage) {
|
|
||||||
const attachmentRoomId = await this.resolveMessageRoomId(messageId);
|
|
||||||
|
|
||||||
if (attachmentRoomId === roomId) {
|
|
||||||
await this.requestAutoDownloadsForMessage(messageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove every attachment associated with a message. */
|
/** Remove every attachment associated with a message. */
|
||||||
async deleteForMessage(messageId: string): Promise<void> {
|
async deleteForMessage(messageId: string): Promise<void> {
|
||||||
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
|
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
|
||||||
@@ -282,7 +219,6 @@ export class AttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.attachmentsByMessage.delete(messageId);
|
this.attachmentsByMessage.delete(messageId);
|
||||||
this.messageRoomIds.delete(messageId);
|
|
||||||
this.clearMessageScopedState(messageId);
|
this.clearMessageScopedState(messageId);
|
||||||
|
|
||||||
if (hadCachedAttachments) {
|
if (hadCachedAttachments) {
|
||||||
@@ -340,15 +276,8 @@ export class AttachmentService {
|
|||||||
* @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
|
* @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
|
||||||
*/
|
*/
|
||||||
registerSyncedAttachments(
|
registerSyncedAttachments(
|
||||||
attachmentMap: Record<string, AttachmentMeta[]>,
|
attachmentMap: Record<string, AttachmentMeta[]>
|
||||||
messageRoomIds?: Record<string, string>
|
|
||||||
): void {
|
): void {
|
||||||
if (messageRoomIds) {
|
|
||||||
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
|
|
||||||
this.rememberMessageRoom(messageId, roomId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newAttachments: Attachment[] = [];
|
const newAttachments: Attachment[] = [];
|
||||||
|
|
||||||
for (const [messageId, metas] of Object.entries(attachmentMap)) {
|
for (const [messageId, metas] of Object.entries(attachmentMap)) {
|
||||||
@@ -377,7 +306,6 @@ export class AttachmentService {
|
|||||||
|
|
||||||
for (const attachment of newAttachments) {
|
for (const attachment of newAttachments) {
|
||||||
void this.persistAttachmentMeta(attachment);
|
void this.persistAttachmentMeta(attachment);
|
||||||
this.queueAutoDownloadsForMessage(attachment.messageId, attachment.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,8 +376,8 @@ export class AttachmentService {
|
|||||||
*
|
*
|
||||||
* 1. Each file is assigned a UUID.
|
* 1. Each file is assigned a UUID.
|
||||||
* 2. A `file-announce` event is broadcast to peers.
|
* 2. A `file-announce` event is broadcast to peers.
|
||||||
* 3. Peers watching the message's server can request any
|
* 3. Inline-preview media ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES}
|
||||||
* auto-download-eligible media on demand.
|
* are immediately streamed as chunked base-64.
|
||||||
*
|
*
|
||||||
* @param messageId - ID of the parent message.
|
* @param messageId - ID of the parent message.
|
||||||
* @param files - Array of user-selected `File` objects.
|
* @param files - Array of user-selected `File` objects.
|
||||||
@@ -509,6 +437,10 @@ export class AttachmentService {
|
|||||||
|
|
||||||
this.webrtc.broadcastMessage(fileAnnounceEvent);
|
this.webrtc.broadcastMessage(fileAnnounceEvent);
|
||||||
|
|
||||||
|
// Auto-stream small inline-preview media
|
||||||
|
if (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||||
|
await this.streamFileToPeers(messageId, fileId, file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingList = this.attachmentsByMessage.get(messageId) ?? [];
|
const existingList = this.attachmentsByMessage.get(messageId) ?? [];
|
||||||
@@ -550,7 +482,6 @@ export class AttachmentService {
|
|||||||
this.attachmentsByMessage.set(messageId, list);
|
this.attachmentsByMessage.set(messageId, list);
|
||||||
this.touch();
|
this.touch();
|
||||||
void this.persistAttachmentMeta(attachment);
|
void this.persistAttachmentMeta(attachment);
|
||||||
this.queueAutoDownloadsForMessage(messageId, attachment.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -841,38 +772,6 @@ export class AttachmentService {
|
|||||||
return `${messageId}:${fileId}`;
|
return `${messageId}:${fileId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
|
|
||||||
if (!messageId)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const roomId = await this.resolveMessageRoomId(messageId);
|
|
||||||
|
|
||||||
if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
|
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
|
||||||
if (attachmentId && attachment.id !== attachmentId)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!this.shouldAutoRequestWhenWatched(attachment))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (attachment.available)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if ((attachment.receivedBytes ?? 0) > 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (this.pendingRequests.has(this.buildRequestKey(messageId, attachment.id)))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
this.requestFromAnyPeer(messageId, attachment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearMessageScopedState(messageId: string): void {
|
private clearMessageScopedState(messageId: string): void {
|
||||||
const scopedPrefix = `${messageId}:`;
|
const scopedPrefix = `${messageId}:`;
|
||||||
|
|
||||||
@@ -968,12 +867,6 @@ export class AttachmentService {
|
|||||||
attachment.mime.startsWith('audio/');
|
attachment.mime.startsWith('audio/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-download only the assets that already supported eager loading when watched. */
|
|
||||||
private shouldAutoRequestWhenWatched(attachment: Attachment): boolean {
|
|
||||||
return attachment.isImage ||
|
|
||||||
(this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check whether a completed download should be cached on disk. */
|
/** Check whether a completed download should be cached on disk. */
|
||||||
private shouldPersistDownloadedAttachment(attachment: Attachment): boolean {
|
private shouldPersistDownloadedAttachment(attachment: Attachment): boolean {
|
||||||
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
|
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
|
||||||
@@ -1274,38 +1167,6 @@ export class AttachmentService {
|
|||||||
} catch { /* load is best-effort */ }
|
} catch { /* load is best-effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractWatchedRoomId(url: string): string | null {
|
|
||||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
|
||||||
|
|
||||||
return roomMatch ? roomMatch[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isRoomWatched(roomId: string | null | undefined): boolean {
|
|
||||||
return !!roomId && roomId === this.watchedRoomId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveMessageRoomId(messageId: string): Promise<string | null> {
|
|
||||||
const cachedRoomId = this.messageRoomIds.get(messageId);
|
|
||||||
|
|
||||||
if (cachedRoomId)
|
|
||||||
return cachedRoomId;
|
|
||||||
|
|
||||||
if (!this.database.isReady())
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const message = await this.database.getMessageById(messageId);
|
|
||||||
|
|
||||||
if (!message?.roomId)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
this.rememberMessageRoom(messageId, message.roomId);
|
|
||||||
return message.roomId;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One-time migration from localStorage to the database. */
|
/** One-time migration from localStorage to the database. */
|
||||||
private async migrateFromLocalStorage(): Promise<void> {
|
private async migrateFromLocalStorage(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -218,14 +218,7 @@ export class DebuggingService {
|
|||||||
|
|
||||||
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
|
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
|
||||||
.trim() || '(empty console call)';
|
.trim() || '(empty console call)';
|
||||||
// Use only string args for label/message extraction so that
|
const consoleMetadata = this.extractConsoleMetadata(rawMessage);
|
||||||
// stringified object payloads don't pollute the parsed message.
|
|
||||||
// Object payloads are captured separately via extractConsolePayload.
|
|
||||||
const metadataSource = args
|
|
||||||
.filter((arg): arg is string => typeof arg === 'string')
|
|
||||||
.join(' ')
|
|
||||||
.trim() || rawMessage;
|
|
||||||
const consoleMetadata = this.extractConsoleMetadata(metadataSource);
|
|
||||||
const payload = this.extractConsolePayload(args);
|
const payload = this.extractConsolePayload(args);
|
||||||
const payloadText = payload === undefined
|
const payloadText = payload === undefined
|
||||||
? null
|
? null
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,13 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
export type SettingsPage = 'general' | 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
|
export type SettingsPage = 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsModalService {
|
export class SettingsModalService {
|
||||||
readonly isOpen = signal(false);
|
readonly isOpen = signal(false);
|
||||||
readonly activePage = signal<SettingsPage>('general');
|
readonly activePage = signal<SettingsPage>('network');
|
||||||
readonly targetServerId = signal<string | null>(null);
|
readonly targetServerId = signal<string | null>(null);
|
||||||
|
|
||||||
open(page: SettingsPage = 'general', serverId?: string): void {
|
open(page: SettingsPage = 'network', serverId?: string): void {
|
||||||
this.activePage.set(page);
|
this.activePage.set(page);
|
||||||
this.targetServerId.set(serverId ?? null);
|
this.targetServerId.set(serverId ?? null);
|
||||||
this.isOpen.set(true);
|
this.isOpen.set(true);
|
||||||
|
|||||||
@@ -19,12 +19,7 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import { Observable, Subject } from 'rxjs';
|
||||||
Observable,
|
|
||||||
of,
|
|
||||||
Subject,
|
|
||||||
Subscription
|
|
||||||
} from 'rxjs';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { SignalingMessage, ChatEvent } from '../models/index';
|
import { SignalingMessage, ChatEvent } from '../models/index';
|
||||||
import { TimeSyncService } from './time-sync.service';
|
import { TimeSyncService } from './time-sync.service';
|
||||||
@@ -76,7 +71,6 @@ type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payloa
|
|||||||
oderId?: string;
|
oderId?: string;
|
||||||
serverTime?: number;
|
serverTime?: number;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
serverIds?: string[];
|
|
||||||
users?: SignalingUserSummary[];
|
users?: SignalingUserSummary[];
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
fromUserId?: string;
|
fromUserId?: string;
|
||||||
@@ -93,18 +87,13 @@ export class WebRTCService implements OnDestroy {
|
|||||||
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
|
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
|
||||||
|
|
||||||
private lastIdentifyCredentials: IdentifyCredentials | null = null;
|
private lastIdentifyCredentials: IdentifyCredentials | null = null;
|
||||||
private readonly lastJoinedServerBySignalUrl = new Map<string, JoinedServerInfo>();
|
private lastJoinedServer: JoinedServerInfo | null = null;
|
||||||
private readonly memberServerIdsBySignalUrl = new Map<string, Set<string>>();
|
private readonly memberServerIds = new Set<string>();
|
||||||
private readonly serverSignalingUrlMap = new Map<string, string>();
|
|
||||||
private readonly peerSignalingUrlMap = new Map<string, string>();
|
|
||||||
private readonly signalingManagers = new Map<string, SignalingManager>();
|
|
||||||
private readonly signalingSubscriptions = new Map<string, Subscription[]>();
|
|
||||||
private readonly signalingConnectionStates = new Map<string, boolean>();
|
|
||||||
private activeServerId: string | null = null;
|
private activeServerId: string | null = null;
|
||||||
/** The server ID where voice is currently active, or `null` when not in voice. */
|
/** The server ID where voice is currently active, or `null` when not in voice. */
|
||||||
private voiceServerId: string | null = null;
|
private voiceServerId: string | null = null;
|
||||||
/** Maps each remote peer ID to the shared servers they currently belong to. */
|
/** Maps each remote peer ID to the server they were discovered from. */
|
||||||
private readonly peerServerMap = new Map<string, Set<string>>();
|
private readonly peerServerMap = new Map<string, string>();
|
||||||
private readonly serviceDestroyed$ = new Subject<void>();
|
private readonly serviceDestroyed$ = new Subject<void>();
|
||||||
private remoteScreenShareRequestsEnabled = false;
|
private remoteScreenShareRequestsEnabled = false;
|
||||||
private readonly desiredRemoteScreenSharePeers = new Set<string>();
|
private readonly desiredRemoteScreenSharePeers = new Set<string>();
|
||||||
@@ -120,7 +109,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
private readonly _isNoiseReductionEnabled = signal(false);
|
private readonly _isNoiseReductionEnabled = signal(false);
|
||||||
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
|
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
|
||||||
private readonly _isScreenShareRemotePlaybackSuppressed = signal(false);
|
private readonly _isScreenShareRemotePlaybackSuppressed = signal(false);
|
||||||
private readonly _forceDefaultRemotePlaybackOutput = signal(false);
|
|
||||||
private readonly _hasConnectionError = signal(false);
|
private readonly _hasConnectionError = signal(false);
|
||||||
private readonly _connectionErrorMessage = signal<string | null>(null);
|
private readonly _connectionErrorMessage = signal<string | null>(null);
|
||||||
private readonly _hasEverConnected = signal(false);
|
private readonly _hasEverConnected = signal(false);
|
||||||
@@ -143,7 +131,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled());
|
readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled());
|
||||||
readonly screenStream = computed(() => this._screenStreamSignal());
|
readonly screenStream = computed(() => this._screenStreamSignal());
|
||||||
readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed());
|
readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed());
|
||||||
readonly forceDefaultRemotePlaybackOutput = computed(() => this._forceDefaultRemotePlaybackOutput());
|
|
||||||
readonly hasConnectionError = computed(() => this._hasConnectionError());
|
readonly hasConnectionError = computed(() => this._hasConnectionError());
|
||||||
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
|
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
|
||||||
readonly shouldShowConnectionError = computed(() => {
|
readonly shouldShowConnectionError = computed(() => {
|
||||||
@@ -178,12 +165,20 @@ export class WebRTCService implements OnDestroy {
|
|||||||
return this.mediaManager.voiceConnected$.asObservable();
|
return this.mediaManager.voiceConnected$.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly signalingManager: SignalingManager;
|
||||||
private readonly peerManager: PeerConnectionManager;
|
private readonly peerManager: PeerConnectionManager;
|
||||||
private readonly mediaManager: MediaManager;
|
private readonly mediaManager: MediaManager;
|
||||||
private readonly screenShareManager: ScreenShareManager;
|
private readonly screenShareManager: ScreenShareManager;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Create managers with null callbacks first to break circular initialization
|
// Create managers with null callbacks first to break circular initialization
|
||||||
|
this.signalingManager = new SignalingManager(
|
||||||
|
this.logger,
|
||||||
|
() => this.lastIdentifyCredentials,
|
||||||
|
() => this.lastJoinedServer,
|
||||||
|
() => this.memberServerIds
|
||||||
|
);
|
||||||
|
|
||||||
this.peerManager = new PeerConnectionManager(this.logger, null!);
|
this.peerManager = new PeerConnectionManager(this.logger, null!);
|
||||||
|
|
||||||
this.mediaManager = new MediaManager(this.logger, null!);
|
this.mediaManager = new MediaManager(this.logger, null!);
|
||||||
@@ -192,7 +187,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
|
|
||||||
// Now wire up cross-references (all managers are instantiated)
|
// Now wire up cross-references (all managers are instantiated)
|
||||||
this.peerManager.setCallbacks({
|
this.peerManager.setCallbacks({
|
||||||
sendRawMessage: (msg: Record<string, unknown>) => this.sendRawMessage(msg),
|
sendRawMessage: (msg: Record<string, unknown>) => this.signalingManager.sendRawMessage(msg),
|
||||||
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
|
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
|
||||||
isSignalingConnected: (): boolean => this._isSignalingConnected(),
|
isSignalingConnected: (): boolean => this._isSignalingConnected(),
|
||||||
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
|
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
|
||||||
@@ -225,7 +220,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
this._isScreenSharing.set(state.active);
|
this._isScreenSharing.set(state.active);
|
||||||
this._screenStreamSignal.set(state.stream);
|
this._screenStreamSignal.set(state.stream);
|
||||||
this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback);
|
this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback);
|
||||||
this._forceDefaultRemotePlaybackOutput.set(state.forceDefaultRemotePlaybackOutput);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -233,6 +227,23 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private wireManagerEvents(): void {
|
private wireManagerEvents(): void {
|
||||||
|
// Signaling → connection status
|
||||||
|
this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => {
|
||||||
|
this._isSignalingConnected.set(connected);
|
||||||
|
|
||||||
|
if (connected)
|
||||||
|
this._hasEverConnected.set(true);
|
||||||
|
|
||||||
|
this._hasConnectionError.set(!connected);
|
||||||
|
this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signaling → message routing
|
||||||
|
this.signalingManager.messageReceived$.subscribe((msg) => this.handleSignalingMessage(msg));
|
||||||
|
|
||||||
|
// Signaling → heartbeat → broadcast states
|
||||||
|
this.signalingManager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates());
|
||||||
|
|
||||||
// Internal control-plane messages for on-demand screen-share delivery.
|
// Internal control-plane messages for on-demand screen-share delivery.
|
||||||
this.peerManager.messageReceived$.subscribe((event) => this.handlePeerControlMessage(event));
|
this.peerManager.messageReceived$.subscribe((event) => this.handlePeerControlMessage(event));
|
||||||
|
|
||||||
@@ -261,8 +272,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
|
|
||||||
this.peerManager.peerDisconnected$.subscribe((peerId) => {
|
this.peerManager.peerDisconnected$.subscribe((peerId) => {
|
||||||
this.activeRemoteScreenSharePeers.delete(peerId);
|
this.activeRemoteScreenSharePeers.delete(peerId);
|
||||||
this.peerServerMap.delete(peerId);
|
|
||||||
this.peerSignalingUrlMap.delete(peerId);
|
|
||||||
this.screenShareManager.clearScreenShareRequest(peerId);
|
this.screenShareManager.clearScreenShareRequest(peerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,145 +288,37 @@ export class WebRTCService implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureSignalingManager(signalUrl: string): SignalingManager {
|
private handleSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
const existingManager = this.signalingManagers.get(signalUrl);
|
|
||||||
|
|
||||||
if (existingManager) {
|
|
||||||
return existingManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
const manager = new SignalingManager(
|
|
||||||
this.logger,
|
|
||||||
() => this.lastIdentifyCredentials,
|
|
||||||
() => this.lastJoinedServerBySignalUrl.get(signalUrl) ?? null,
|
|
||||||
() => this.getMemberServerIdsForSignalUrl(signalUrl)
|
|
||||||
);
|
|
||||||
const subscriptions: Subscription[] = [
|
|
||||||
manager.connectionStatus$.subscribe(({ connected, errorMessage }) =>
|
|
||||||
this.handleSignalingConnectionStatus(signalUrl, connected, errorMessage)
|
|
||||||
),
|
|
||||||
manager.messageReceived$.subscribe((message) => this.handleSignalingMessage(message, signalUrl)),
|
|
||||||
manager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates())
|
|
||||||
];
|
|
||||||
|
|
||||||
this.signalingManagers.set(signalUrl, manager);
|
|
||||||
this.signalingSubscriptions.set(signalUrl, subscriptions);
|
|
||||||
return manager;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleSignalingConnectionStatus(
|
|
||||||
signalUrl: string,
|
|
||||||
connected: boolean,
|
|
||||||
errorMessage?: string
|
|
||||||
): void {
|
|
||||||
this.signalingConnectionStates.set(signalUrl, connected);
|
|
||||||
|
|
||||||
if (connected)
|
|
||||||
this._hasEverConnected.set(true);
|
|
||||||
|
|
||||||
const anyConnected = this.isAnySignalingConnected();
|
|
||||||
|
|
||||||
this._isSignalingConnected.set(anyConnected);
|
|
||||||
this._hasConnectionError.set(!anyConnected);
|
|
||||||
this._connectionErrorMessage.set(anyConnected ? null : (errorMessage ?? 'Disconnected from signaling server'));
|
|
||||||
}
|
|
||||||
|
|
||||||
private isAnySignalingConnected(): boolean {
|
|
||||||
for (const manager of this.signalingManagers.values()) {
|
|
||||||
if (manager.isSocketOpen()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getConnectedSignalingManagers(): { signalUrl: string; manager: SignalingManager }[] {
|
|
||||||
const connectedManagers: { signalUrl: string; manager: SignalingManager }[] = [];
|
|
||||||
|
|
||||||
for (const [signalUrl, manager] of this.signalingManagers.entries()) {
|
|
||||||
if (!manager.isSocketOpen()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedManagers.push({ signalUrl,
|
|
||||||
manager });
|
|
||||||
}
|
|
||||||
|
|
||||||
return connectedManagers;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getOrCreateMemberServerSet(signalUrl: string): Set<string> {
|
|
||||||
const existingSet = this.memberServerIdsBySignalUrl.get(signalUrl);
|
|
||||||
|
|
||||||
if (existingSet) {
|
|
||||||
return existingSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdSet = new Set<string>();
|
|
||||||
|
|
||||||
this.memberServerIdsBySignalUrl.set(signalUrl, createdSet);
|
|
||||||
return createdSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMemberServerIdsForSignalUrl(signalUrl: string): ReadonlySet<string> {
|
|
||||||
return this.memberServerIdsBySignalUrl.get(signalUrl) ?? new Set<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
private isJoinedServer(serverId: string): boolean {
|
|
||||||
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
|
||||||
if (memberServerIds.has(serverId)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getJoinedServerCount(): number {
|
|
||||||
let joinedServerCount = 0;
|
|
||||||
|
|
||||||
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
|
||||||
joinedServerCount += memberServerIds.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
return joinedServerCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
|
||||||
this.signalingMessage$.next(message);
|
this.signalingMessage$.next(message);
|
||||||
this.logger.info('Signaling message', {
|
this.logger.info('Signaling message', { type: message.type });
|
||||||
signalUrl,
|
|
||||||
type: message.type
|
|
||||||
});
|
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case SIGNALING_TYPE_CONNECTED:
|
case SIGNALING_TYPE_CONNECTED:
|
||||||
this.handleConnectedSignalingMessage(message, signalUrl);
|
this.handleConnectedSignalingMessage(message);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_SERVER_USERS:
|
case SIGNALING_TYPE_SERVER_USERS:
|
||||||
this.handleServerUsersSignalingMessage(message, signalUrl);
|
this.handleServerUsersSignalingMessage(message);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_USER_JOINED:
|
case SIGNALING_TYPE_USER_JOINED:
|
||||||
this.handleUserJoinedSignalingMessage(message, signalUrl);
|
this.handleUserJoinedSignalingMessage(message);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_USER_LEFT:
|
case SIGNALING_TYPE_USER_LEFT:
|
||||||
this.handleUserLeftSignalingMessage(message, signalUrl);
|
this.handleUserLeftSignalingMessage(message);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_OFFER:
|
case SIGNALING_TYPE_OFFER:
|
||||||
this.handleOfferSignalingMessage(message, signalUrl);
|
this.handleOfferSignalingMessage(message);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_ANSWER:
|
case SIGNALING_TYPE_ANSWER:
|
||||||
this.handleAnswerSignalingMessage(message, signalUrl);
|
this.handleAnswerSignalingMessage(message);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_ICE_CANDIDATE:
|
case SIGNALING_TYPE_ICE_CANDIDATE:
|
||||||
this.handleIceCandidateSignalingMessage(message, signalUrl);
|
this.handleIceCandidateSignalingMessage(message);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -425,44 +326,26 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleConnectedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
private handleConnectedSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
this.logger.info('Server connected', {
|
this.logger.info('Server connected', { oderId: message.oderId });
|
||||||
oderId: message.oderId,
|
|
||||||
signalUrl
|
|
||||||
});
|
|
||||||
|
|
||||||
if (message.serverId) {
|
|
||||||
this.serverSignalingUrlMap.set(message.serverId, signalUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof message.serverTime === 'number') {
|
if (typeof message.serverTime === 'number') {
|
||||||
this.timeSync.setFromServerTime(message.serverTime);
|
this.timeSync.setFromServerTime(message.serverTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
const users = Array.isArray(message.users) ? message.users : [];
|
const users = Array.isArray(message.users) ? message.users : [];
|
||||||
|
|
||||||
this.logger.info('Server users', {
|
this.logger.info('Server users', {
|
||||||
count: users.length,
|
count: users.length,
|
||||||
signalUrl,
|
|
||||||
serverId: message.serverId
|
serverId: message.serverId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (message.serverId) {
|
|
||||||
this.serverSignalingUrlMap.set(message.serverId, signalUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (!user.oderId)
|
if (!user.oderId)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
this.peerSignalingUrlMap.set(user.oderId, signalUrl);
|
|
||||||
|
|
||||||
if (message.serverId) {
|
|
||||||
this.trackPeerInServer(user.oderId, message.serverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = this.peerManager.activePeerConnections.get(user.oderId);
|
const existing = this.peerManager.activePeerConnections.get(user.oderId);
|
||||||
const healthy = this.isPeerHealthy(existing);
|
const healthy = this.isPeerHealthy(existing);
|
||||||
|
|
||||||
@@ -481,91 +364,66 @@ export class WebRTCService implements OnDestroy {
|
|||||||
|
|
||||||
this.peerManager.createPeerConnection(user.oderId, true);
|
this.peerManager.createPeerConnection(user.oderId, true);
|
||||||
this.peerManager.createAndSendOffer(user.oderId);
|
this.peerManager.createAndSendOffer(user.oderId);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
|
||||||
this.logger.info('User joined', {
|
|
||||||
displayName: message.displayName,
|
|
||||||
oderId: message.oderId,
|
|
||||||
signalUrl
|
|
||||||
});
|
|
||||||
|
|
||||||
if (message.serverId) {
|
if (message.serverId) {
|
||||||
this.serverSignalingUrlMap.set(message.serverId, signalUrl);
|
this.peerServerMap.set(user.oderId, message.serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.oderId) {
|
|
||||||
this.peerSignalingUrlMap.set(message.oderId, signalUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.oderId && message.serverId) {
|
|
||||||
this.trackPeerInServer(message.oderId, message.serverId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
|
this.logger.info('User joined', {
|
||||||
|
displayName: message.displayName,
|
||||||
|
oderId: message.oderId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
this.logger.info('User left', {
|
this.logger.info('User left', {
|
||||||
displayName: message.displayName,
|
displayName: message.displayName,
|
||||||
oderId: message.oderId,
|
oderId: message.oderId,
|
||||||
signalUrl,
|
|
||||||
serverId: message.serverId
|
serverId: message.serverId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (message.oderId) {
|
if (message.oderId) {
|
||||||
const hasRemainingSharedServers = Array.isArray(message.serverIds)
|
|
||||||
? this.replacePeerSharedServers(message.oderId, message.serverIds)
|
|
||||||
: (message.serverId
|
|
||||||
? this.untrackPeerFromServer(message.oderId, message.serverId)
|
|
||||||
: false);
|
|
||||||
|
|
||||||
if (!hasRemainingSharedServers) {
|
|
||||||
this.peerManager.removePeer(message.oderId);
|
this.peerManager.removePeer(message.oderId);
|
||||||
this.peerServerMap.delete(message.oderId);
|
this.peerServerMap.delete(message.oderId);
|
||||||
this.peerSignalingUrlMap.delete(message.oderId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleOfferSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
private handleOfferSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
const fromUserId = message.fromUserId;
|
const fromUserId = message.fromUserId;
|
||||||
const sdp = message.payload?.sdp;
|
const sdp = message.payload?.sdp;
|
||||||
|
|
||||||
if (!fromUserId || !sdp)
|
if (!fromUserId || !sdp)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.peerSignalingUrlMap.set(fromUserId, signalUrl);
|
|
||||||
|
|
||||||
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
|
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
|
||||||
|
|
||||||
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
|
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
|
||||||
this.trackPeerInServer(fromUserId, offerEffectiveServer);
|
this.peerServerMap.set(fromUserId, offerEffectiveServer);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.peerManager.handleOffer(fromUserId, sdp);
|
this.peerManager.handleOffer(fromUserId, sdp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleAnswerSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
private handleAnswerSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
const fromUserId = message.fromUserId;
|
const fromUserId = message.fromUserId;
|
||||||
const sdp = message.payload?.sdp;
|
const sdp = message.payload?.sdp;
|
||||||
|
|
||||||
if (!fromUserId || !sdp)
|
if (!fromUserId || !sdp)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.peerSignalingUrlMap.set(fromUserId, signalUrl);
|
|
||||||
|
|
||||||
this.peerManager.handleAnswer(fromUserId, sdp);
|
this.peerManager.handleAnswer(fromUserId, sdp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage): void {
|
||||||
const fromUserId = message.fromUserId;
|
const fromUserId = message.fromUserId;
|
||||||
const candidate = message.payload?.candidate;
|
const candidate = message.payload?.candidate;
|
||||||
|
|
||||||
if (!fromUserId || !candidate)
|
if (!fromUserId || !candidate)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.peerSignalingUrlMap.set(fromUserId, signalUrl);
|
|
||||||
|
|
||||||
this.peerManager.handleIceCandidate(fromUserId, candidate);
|
this.peerManager.handleIceCandidate(fromUserId, candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,8 +438,8 @@ export class WebRTCService implements OnDestroy {
|
|||||||
private closePeersNotInServer(serverId: string): void {
|
private closePeersNotInServer(serverId: string): void {
|
||||||
const peersToClose: string[] = [];
|
const peersToClose: string[] = [];
|
||||||
|
|
||||||
this.peerServerMap.forEach((peerServerIds, peerId) => {
|
this.peerServerMap.forEach((peerServerId, peerId) => {
|
||||||
if (!peerServerIds.has(serverId)) {
|
if (peerServerId !== serverId) {
|
||||||
peersToClose.push(peerId);
|
peersToClose.push(peerId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -592,7 +450,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
|
|
||||||
this.peerManager.removePeer(peerId);
|
this.peerManager.removePeer(peerId);
|
||||||
this.peerServerMap.delete(peerId);
|
this.peerServerMap.delete(peerId);
|
||||||
this.peerSignalingUrlMap.delete(peerId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,57 +473,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @returns An observable that emits `true` once connected.
|
* @returns An observable that emits `true` once connected.
|
||||||
*/
|
*/
|
||||||
connectToSignalingServer(serverUrl: string): Observable<boolean> {
|
connectToSignalingServer(serverUrl: string): Observable<boolean> {
|
||||||
const manager = this.ensureSignalingManager(serverUrl);
|
return this.signalingManager.connect(serverUrl);
|
||||||
|
|
||||||
if (manager.isSocketOpen()) {
|
|
||||||
return of(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return manager.connect(serverUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns true when the signaling socket for a given URL is currently open. */
|
|
||||||
isSignalingConnectedTo(serverUrl: string): boolean {
|
|
||||||
return this.signalingManagers.get(serverUrl)?.isSocketOpen() ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private trackPeerInServer(peerId: string, serverId: string): void {
|
|
||||||
if (!peerId || !serverId)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const trackedServers = this.peerServerMap.get(peerId) ?? new Set<string>();
|
|
||||||
|
|
||||||
trackedServers.add(serverId);
|
|
||||||
this.peerServerMap.set(peerId, trackedServers);
|
|
||||||
}
|
|
||||||
|
|
||||||
private replacePeerSharedServers(peerId: string, serverIds: string[]): boolean {
|
|
||||||
const sharedServerIds = serverIds.filter((serverId) => this.isJoinedServer(serverId));
|
|
||||||
|
|
||||||
if (sharedServerIds.length === 0) {
|
|
||||||
this.peerServerMap.delete(peerId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.peerServerMap.set(peerId, new Set(sharedServerIds));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private untrackPeerFromServer(peerId: string, serverId: string): boolean {
|
|
||||||
const trackedServers = this.peerServerMap.get(peerId);
|
|
||||||
|
|
||||||
if (!trackedServers)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
trackedServers.delete(serverId);
|
|
||||||
|
|
||||||
if (trackedServers.size === 0) {
|
|
||||||
this.peerServerMap.delete(peerId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.peerServerMap.set(peerId, trackedServers);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -676,17 +483,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @returns `true` if connected within the timeout.
|
* @returns `true` if connected within the timeout.
|
||||||
*/
|
*/
|
||||||
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
|
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
|
||||||
if (this.isAnySignalingConnected()) {
|
return this.signalingManager.ensureConnected(timeoutMs);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const manager of this.signalingManagers.values()) {
|
|
||||||
if (await manager.ensureConnected(timeoutMs)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -695,32 +492,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param message - The signaling message payload (excluding `from` / `timestamp`).
|
* @param message - The signaling message payload (excluding `from` / `timestamp`).
|
||||||
*/
|
*/
|
||||||
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
|
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
|
||||||
const targetPeerId = message.to;
|
this.signalingManager.sendSignalingMessage(message, this._localPeerId());
|
||||||
|
|
||||||
if (targetPeerId) {
|
|
||||||
const targetSignalUrl = this.peerSignalingUrlMap.get(targetPeerId);
|
|
||||||
|
|
||||||
if (targetSignalUrl) {
|
|
||||||
const targetManager = this.ensureSignalingManager(targetSignalUrl);
|
|
||||||
|
|
||||||
targetManager.sendSignalingMessage(message, this._localPeerId());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectedManagers = this.getConnectedSignalingManagers();
|
|
||||||
|
|
||||||
if (connectedManagers.length === 0) {
|
|
||||||
this.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), {
|
|
||||||
type: message.type
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { manager } of connectedManagers) {
|
|
||||||
manager.sendSignalingMessage(message, this._localPeerId());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -729,50 +501,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param message - Arbitrary JSON message.
|
* @param message - Arbitrary JSON message.
|
||||||
*/
|
*/
|
||||||
sendRawMessage(message: Record<string, unknown>): void {
|
sendRawMessage(message: Record<string, unknown>): void {
|
||||||
const targetPeerId = typeof message['targetUserId'] === 'string' ? message['targetUserId'] : null;
|
this.signalingManager.sendRawMessage(message);
|
||||||
|
|
||||||
if (targetPeerId) {
|
|
||||||
const targetSignalUrl = this.peerSignalingUrlMap.get(targetPeerId);
|
|
||||||
|
|
||||||
if (targetSignalUrl && this.sendRawMessageToSignalUrl(targetSignalUrl, message)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverId = typeof message['serverId'] === 'string' ? message['serverId'] : null;
|
|
||||||
|
|
||||||
if (serverId) {
|
|
||||||
const serverSignalUrl = this.serverSignalingUrlMap.get(serverId);
|
|
||||||
|
|
||||||
if (serverSignalUrl && this.sendRawMessageToSignalUrl(serverSignalUrl, message)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectedManagers = this.getConnectedSignalingManagers();
|
|
||||||
|
|
||||||
if (connectedManagers.length === 0) {
|
|
||||||
this.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), {
|
|
||||||
type: typeof message['type'] === 'string' ? message['type'] : 'unknown'
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { manager } of connectedManagers) {
|
|
||||||
manager.sendRawMessage(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendRawMessageToSignalUrl(signalUrl: string, message: Record<string, unknown>): boolean {
|
|
||||||
const manager = this.signalingManagers.get(signalUrl);
|
|
||||||
|
|
||||||
if (!manager) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.sendRawMessage(message);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -784,24 +513,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
this.activeServerId = serverId;
|
this.activeServerId = serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The server ID currently being viewed / active, or `null`. */
|
|
||||||
get currentServerId(): string | null {
|
|
||||||
return this.activeServerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The last signaling URL used by the client, if any. */
|
|
||||||
getCurrentSignalingUrl(): string | null {
|
|
||||||
if (this.activeServerId) {
|
|
||||||
const activeServerSignalUrl = this.serverSignalingUrlMap.get(this.activeServerId);
|
|
||||||
|
|
||||||
if (activeServerSignalUrl) {
|
|
||||||
return activeServerSignalUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.getConnectedSignalingManagers()[0]?.signalUrl ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an identify message to the signaling server.
|
* Send an identify message to the signaling server.
|
||||||
*
|
*
|
||||||
@@ -810,22 +521,13 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param oderId - The user's unique order/peer ID.
|
* @param oderId - The user's unique order/peer ID.
|
||||||
* @param displayName - The user's display name.
|
* @param displayName - The user's display name.
|
||||||
*/
|
*/
|
||||||
identify(oderId: string, displayName: string, signalUrl?: string): void {
|
identify(oderId: string, displayName: string): void {
|
||||||
this.lastIdentifyCredentials = { oderId,
|
this.lastIdentifyCredentials = { oderId,
|
||||||
displayName };
|
displayName };
|
||||||
|
|
||||||
const identifyMessage = {
|
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
|
||||||
type: SIGNALING_TYPE_IDENTIFY,
|
|
||||||
oderId,
|
oderId,
|
||||||
displayName
|
displayName });
|
||||||
};
|
|
||||||
|
|
||||||
if (signalUrl) {
|
|
||||||
this.sendRawMessageToSignalUrl(signalUrl, identifyMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendRawMessage(identifyMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -834,27 +536,13 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param roomId - The server / room ID to join.
|
* @param roomId - The server / room ID to join.
|
||||||
* @param userId - The local user ID.
|
* @param userId - The local user ID.
|
||||||
*/
|
*/
|
||||||
joinRoom(roomId: string, userId: string, signalUrl?: string): void {
|
joinRoom(roomId: string, userId: string): void {
|
||||||
const resolvedSignalUrl = signalUrl
|
this.lastJoinedServer = { serverId: roomId,
|
||||||
?? this.serverSignalingUrlMap.get(roomId)
|
userId };
|
||||||
?? this.getCurrentSignalingUrl();
|
|
||||||
|
|
||||||
if (!resolvedSignalUrl) {
|
this.memberServerIds.add(roomId);
|
||||||
this.logger.warn('[signaling] Cannot join room without a signaling URL', { roomId });
|
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||||
return;
|
serverId: roomId });
|
||||||
}
|
|
||||||
|
|
||||||
this.serverSignalingUrlMap.set(roomId, resolvedSignalUrl);
|
|
||||||
this.lastJoinedServerBySignalUrl.set(resolvedSignalUrl, {
|
|
||||||
serverId: roomId,
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
|
|
||||||
this.getOrCreateMemberServerSet(resolvedSignalUrl).add(roomId);
|
|
||||||
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
|
||||||
type: SIGNALING_TYPE_JOIN_SERVER,
|
|
||||||
serverId: roomId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -864,46 +552,26 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param serverId - The target server ID.
|
* @param serverId - The target server ID.
|
||||||
* @param userId - The local user ID.
|
* @param userId - The local user ID.
|
||||||
*/
|
*/
|
||||||
switchServer(serverId: string, userId: string, signalUrl?: string): void {
|
switchServer(serverId: string, userId: string): void {
|
||||||
const resolvedSignalUrl = signalUrl
|
this.lastJoinedServer = { serverId,
|
||||||
?? this.serverSignalingUrlMap.get(serverId)
|
userId };
|
||||||
?? this.getCurrentSignalingUrl();
|
|
||||||
|
|
||||||
if (!resolvedSignalUrl) {
|
if (this.memberServerIds.has(serverId)) {
|
||||||
this.logger.warn('[signaling] Cannot switch server without a signaling URL', { serverId });
|
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
|
||||||
return;
|
serverId });
|
||||||
}
|
|
||||||
|
|
||||||
this.serverSignalingUrlMap.set(serverId, resolvedSignalUrl);
|
|
||||||
this.lastJoinedServerBySignalUrl.set(resolvedSignalUrl, {
|
|
||||||
serverId,
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberServerIds = this.getOrCreateMemberServerSet(resolvedSignalUrl);
|
|
||||||
|
|
||||||
if (memberServerIds.has(serverId)) {
|
|
||||||
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
|
||||||
type: SIGNALING_TYPE_VIEW_SERVER,
|
|
||||||
serverId
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.info('Viewed server (already joined)', {
|
this.logger.info('Viewed server (already joined)', {
|
||||||
serverId,
|
serverId,
|
||||||
signalUrl: resolvedSignalUrl,
|
|
||||||
userId,
|
userId,
|
||||||
voiceConnected: this._isVoiceConnected()
|
voiceConnected: this._isVoiceConnected()
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
memberServerIds.add(serverId);
|
this.memberServerIds.add(serverId);
|
||||||
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||||
type: SIGNALING_TYPE_JOIN_SERVER,
|
serverId });
|
||||||
serverId
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.info('Joined new server via switch', {
|
this.logger.info('Joined new server via switch', {
|
||||||
serverId,
|
serverId,
|
||||||
signalUrl: resolvedSignalUrl,
|
|
||||||
userId,
|
userId,
|
||||||
voiceConnected: this._isVoiceConnected()
|
voiceConnected: this._isVoiceConnected()
|
||||||
});
|
});
|
||||||
@@ -920,47 +588,25 @@ export class WebRTCService implements OnDestroy {
|
|||||||
*/
|
*/
|
||||||
leaveRoom(serverId?: string): void {
|
leaveRoom(serverId?: string): void {
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
const resolvedSignalUrl = this.serverSignalingUrlMap.get(serverId);
|
this.memberServerIds.delete(serverId);
|
||||||
|
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||||
if (resolvedSignalUrl) {
|
serverId });
|
||||||
this.getOrCreateMemberServerSet(resolvedSignalUrl).delete(serverId);
|
|
||||||
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
|
||||||
type: SIGNALING_TYPE_LEAVE_SERVER,
|
|
||||||
serverId
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.sendRawMessage({
|
|
||||||
type: SIGNALING_TYPE_LEAVE_SERVER,
|
|
||||||
serverId
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
|
||||||
memberServerIds.delete(serverId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.serverSignalingUrlMap.delete(serverId);
|
|
||||||
|
|
||||||
this.logger.info('Left server', { serverId });
|
this.logger.info('Left server', { serverId });
|
||||||
|
|
||||||
if (this.getJoinedServerCount() === 0) {
|
if (this.memberServerIds.size === 0) {
|
||||||
this.fullCleanup();
|
this.fullCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [signalUrl, memberServerIds] of this.memberServerIdsBySignalUrl.entries()) {
|
this.memberServerIds.forEach((sid) => {
|
||||||
for (const sid of memberServerIds) {
|
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||||
this.sendRawMessageToSignalUrl(signalUrl, {
|
serverId: sid });
|
||||||
type: SIGNALING_TYPE_LEAVE_SERVER,
|
|
||||||
serverId: sid
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.memberServerIdsBySignalUrl.clear();
|
this.memberServerIds.clear();
|
||||||
this.serverSignalingUrlMap.clear();
|
|
||||||
this.fullCleanup();
|
this.fullCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,18 +616,12 @@ export class WebRTCService implements OnDestroy {
|
|||||||
* @param serverId - The server to check.
|
* @param serverId - The server to check.
|
||||||
*/
|
*/
|
||||||
hasJoinedServer(serverId: string): boolean {
|
hasJoinedServer(serverId: string): boolean {
|
||||||
return this.isJoinedServer(serverId);
|
return this.memberServerIds.has(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns a read-only set of all currently-joined server IDs. */
|
/** Returns a read-only set of all currently-joined server IDs. */
|
||||||
getJoinedServerIds(): ReadonlySet<string> {
|
getJoinedServerIds(): ReadonlySet<string> {
|
||||||
const joinedServerIds = new Set<string>();
|
return this.memberServerIds;
|
||||||
|
|
||||||
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
|
||||||
memberServerIds.forEach((serverId) => joinedServerIds.add(serverId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return joinedServerIds;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1236,15 +876,11 @@ export class WebRTCService implements OnDestroy {
|
|||||||
|
|
||||||
/** Disconnect from the signaling server and clean up all state. */
|
/** Disconnect from the signaling server and clean up all state. */
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.leaveRoom();
|
|
||||||
this.voiceServerId = null;
|
this.voiceServerId = null;
|
||||||
this.peerServerMap.clear();
|
this.peerServerMap.clear();
|
||||||
this.peerSignalingUrlMap.clear();
|
this.leaveRoom();
|
||||||
this.lastJoinedServerBySignalUrl.clear();
|
|
||||||
this.memberServerIdsBySignalUrl.clear();
|
|
||||||
this.serverSignalingUrlMap.clear();
|
|
||||||
this.mediaManager.stopVoiceHeartbeat();
|
this.mediaManager.stopVoiceHeartbeat();
|
||||||
this.destroyAllSignalingManagers();
|
this.signalingManager.close();
|
||||||
this._isSignalingConnected.set(false);
|
this._isSignalingConnected.set(false);
|
||||||
this._hasEverConnected.set(false);
|
this._hasEverConnected.set(false);
|
||||||
this._hasConnectionError.set(false);
|
this._hasConnectionError.set(false);
|
||||||
@@ -1260,7 +896,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
private fullCleanup(): void {
|
private fullCleanup(): void {
|
||||||
this.voiceServerId = null;
|
this.voiceServerId = null;
|
||||||
this.peerServerMap.clear();
|
this.peerServerMap.clear();
|
||||||
this.peerSignalingUrlMap.clear();
|
|
||||||
this.remoteScreenShareRequestsEnabled = false;
|
this.remoteScreenShareRequestsEnabled = false;
|
||||||
this.desiredRemoteScreenSharePeers.clear();
|
this.desiredRemoteScreenSharePeers.clear();
|
||||||
this.activeRemoteScreenSharePeers.clear();
|
this.activeRemoteScreenSharePeers.clear();
|
||||||
@@ -1272,7 +907,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
this._isScreenSharing.set(false);
|
this._isScreenSharing.set(false);
|
||||||
this._screenStreamSignal.set(null);
|
this._screenStreamSignal.set(null);
|
||||||
this._isScreenShareRemotePlaybackSuppressed.set(false);
|
this._isScreenShareRemotePlaybackSuppressed.set(false);
|
||||||
this._forceDefaultRemotePlaybackOutput.set(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Synchronise Angular signals from the MediaManager's internal state. */
|
/** Synchronise Angular signals from the MediaManager's internal state. */
|
||||||
@@ -1339,25 +973,10 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private destroyAllSignalingManagers(): void {
|
|
||||||
for (const subscriptions of this.signalingSubscriptions.values()) {
|
|
||||||
for (const subscription of subscriptions) {
|
|
||||||
subscription.unsubscribe();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const manager of this.signalingManagers.values()) {
|
|
||||||
manager.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.signalingSubscriptions.clear();
|
|
||||||
this.signalingManagers.clear();
|
|
||||||
this.signalingConnectionStates.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
this.serviceDestroyed$.complete();
|
this.serviceDestroyed$.complete();
|
||||||
|
this.signalingManager.destroy();
|
||||||
this.peerManager.destroy();
|
this.peerManager.destroy();
|
||||||
this.mediaManager.destroy();
|
this.mediaManager.destroy();
|
||||||
this.screenShareManager.destroy();
|
this.screenShareManager.destroy();
|
||||||
|
|||||||
@@ -103,10 +103,10 @@ export class MediaManager {
|
|||||||
* Replace the callback set at runtime.
|
* Replace the callback set at runtime.
|
||||||
* Needed because of circular initialisation between managers.
|
* Needed because of circular initialisation between managers.
|
||||||
*
|
*
|
||||||
* @param nextCallbacks - The new callback interface to wire into this manager.
|
* @param cb - The new callback interface to wire into this manager.
|
||||||
*/
|
*/
|
||||||
setCallbacks(nextCallbacks: MediaManagerCallbacks): void {
|
setCallbacks(cb: MediaManagerCallbacks): void {
|
||||||
this.callbacks = nextCallbacks;
|
this.callbacks = cb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the current local media stream, or `null` if voice is disabled. */
|
/** Returns the current local media stream, or `null` if voice is disabled. */
|
||||||
@@ -485,21 +485,28 @@ export class MediaManager {
|
|||||||
if (!this.localMediaStream)
|
if (!this.localMediaStream)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const localStream = this.localMediaStream;
|
const localAudioTrack = this.localMediaStream.getAudioTracks()[0] || null;
|
||||||
const localAudioTrack = localStream.getAudioTracks()[0] || null;
|
const localVideoTrack = this.localMediaStream.getVideoTracks()[0] || null;
|
||||||
const localVideoTrack = localStream.getVideoTracks()[0] || null;
|
|
||||||
|
|
||||||
peers.forEach((peerData, peerId) => {
|
peers.forEach((peerData, peerId) => {
|
||||||
if (localAudioTrack) {
|
if (localAudioTrack) {
|
||||||
const audioTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_AUDIO, {
|
let audioSender =
|
||||||
preferredSender: peerData.audioSender,
|
peerData.audioSender ||
|
||||||
excludedSenders: [peerData.screenAudioSender]
|
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO);
|
||||||
});
|
|
||||||
const audioSender = audioTransceiver.sender;
|
if (!audioSender) {
|
||||||
|
audioSender = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, {
|
||||||
|
direction: TRANSCEIVER_SEND_RECV
|
||||||
|
}).sender;
|
||||||
|
}
|
||||||
|
|
||||||
peerData.audioSender = audioSender;
|
peerData.audioSender = audioSender;
|
||||||
|
|
||||||
// Restore direction after removeTrack (which sets it to recvonly)
|
// Restore direction after removeTrack (which sets it to recvonly)
|
||||||
|
const audioTransceiver = peerData.connection
|
||||||
|
.getTransceivers()
|
||||||
|
.find((t) => t.sender === audioSender);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
audioTransceiver &&
|
audioTransceiver &&
|
||||||
(audioTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
(audioTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||||
@@ -508,25 +515,29 @@ export class MediaManager {
|
|||||||
audioTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
audioTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof audioSender.setStreams === 'function') {
|
|
||||||
audioSender.setStreams(localStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
audioSender
|
audioSender
|
||||||
.replaceTrack(localAudioTrack)
|
.replaceTrack(localAudioTrack)
|
||||||
.then(() => this.logger.info('audio replaceTrack ok', { peerId }))
|
.then(() => this.logger.info('audio replaceTrack ok', { peerId }))
|
||||||
.catch((error) => this.logger.error('audio replaceTrack failed', error));
|
.catch((e) => this.logger.error('audio replaceTrack failed', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localVideoTrack) {
|
if (localVideoTrack) {
|
||||||
const videoTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_VIDEO, {
|
let videoSender =
|
||||||
preferredSender: peerData.videoSender,
|
peerData.videoSender ||
|
||||||
excludedSenders: [peerData.screenVideoSender]
|
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_VIDEO);
|
||||||
});
|
|
||||||
const videoSender = videoTransceiver.sender;
|
if (!videoSender) {
|
||||||
|
videoSender = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, {
|
||||||
|
direction: TRANSCEIVER_SEND_RECV
|
||||||
|
}).sender;
|
||||||
|
}
|
||||||
|
|
||||||
peerData.videoSender = videoSender;
|
peerData.videoSender = videoSender;
|
||||||
|
|
||||||
|
const videoTransceiver = peerData.connection
|
||||||
|
.getTransceivers()
|
||||||
|
.find((t) => t.sender === videoSender);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
videoTransceiver &&
|
videoTransceiver &&
|
||||||
(videoTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
(videoTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||||
@@ -535,64 +546,16 @@ export class MediaManager {
|
|||||||
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof videoSender.setStreams === 'function') {
|
|
||||||
videoSender.setStreams(localStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
videoSender
|
videoSender
|
||||||
.replaceTrack(localVideoTrack)
|
.replaceTrack(localVideoTrack)
|
||||||
.then(() => this.logger.info('video replaceTrack ok', { peerId }))
|
.then(() => this.logger.info('video replaceTrack ok', { peerId }))
|
||||||
.catch((error) => this.logger.error('video replaceTrack failed', error));
|
.catch((e) => this.logger.error('video replaceTrack failed', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.callbacks.renegotiate(peerId);
|
this.callbacks.renegotiate(peerId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getOrCreateReusableTransceiver(
|
|
||||||
peerData: PeerData,
|
|
||||||
kind: typeof TRACK_KIND_AUDIO | typeof TRACK_KIND_VIDEO,
|
|
||||||
options: {
|
|
||||||
preferredSender?: RTCRtpSender;
|
|
||||||
excludedSenders?: (RTCRtpSender | undefined)[];
|
|
||||||
}
|
|
||||||
): RTCRtpTransceiver {
|
|
||||||
const excludedSenders = new Set(
|
|
||||||
(options.excludedSenders ?? []).filter((sender): sender is RTCRtpSender => !!sender)
|
|
||||||
);
|
|
||||||
const existingTransceivers = peerData.connection.getTransceivers();
|
|
||||||
const preferredTransceiver = options.preferredSender
|
|
||||||
? existingTransceivers.find((transceiver) => transceiver.sender === options.preferredSender)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (preferredTransceiver) {
|
|
||||||
return preferredTransceiver;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachedSenderTransceiver = existingTransceivers.find((transceiver) =>
|
|
||||||
!excludedSenders.has(transceiver.sender)
|
|
||||||
&& transceiver.sender.track?.kind === kind
|
|
||||||
);
|
|
||||||
|
|
||||||
if (attachedSenderTransceiver) {
|
|
||||||
return attachedSenderTransceiver;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reusableReceiverTransceiver = existingTransceivers.find((transceiver) =>
|
|
||||||
!excludedSenders.has(transceiver.sender)
|
|
||||||
&& !transceiver.sender.track
|
|
||||||
&& transceiver.receiver.track?.kind === kind
|
|
||||||
);
|
|
||||||
|
|
||||||
if (reusableReceiverTransceiver) {
|
|
||||||
return reusableReceiverTransceiver;
|
|
||||||
}
|
|
||||||
|
|
||||||
return peerData.connection.addTransceiver(kind, {
|
|
||||||
direction: TRANSCEIVER_SEND_RECV
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Broadcast a voice-presence state event to all connected peers. */
|
/** Broadcast a voice-presence state event to all connected peers. */
|
||||||
private broadcastVoicePresence(): void {
|
private broadcastVoicePresence(): void {
|
||||||
const oderId = this.callbacks.getIdentifyOderId();
|
const oderId = this.callbacks.getIdentifyOderId();
|
||||||
|
|||||||
@@ -127,9 +127,7 @@ export function createPeerConnection(
|
|||||||
isInitiator,
|
isInitiator,
|
||||||
pendingIceCandidates: [],
|
pendingIceCandidates: [],
|
||||||
audioSender: undefined,
|
audioSender: undefined,
|
||||||
videoSender: undefined,
|
videoSender: undefined
|
||||||
remoteVoiceStreamIds: new Set<string>(),
|
|
||||||
remoteScreenShareStreamIds: new Set<string>()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isInitiator) {
|
if (isInitiator) {
|
||||||
@@ -153,10 +151,6 @@ export function createPeerConnection(
|
|||||||
|
|
||||||
localStream.getTracks().forEach((track) => {
|
localStream.getTracks().forEach((track) => {
|
||||||
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
||||||
if (typeof peerData.audioSender.setStreams === 'function') {
|
|
||||||
peerData.audioSender.setStreams(localStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
peerData.audioSender
|
peerData.audioSender
|
||||||
.replaceTrack(track)
|
.replaceTrack(track)
|
||||||
.then(() => logger.info('audio replaceTrack (init) ok', { remotePeerId }))
|
.then(() => logger.info('audio replaceTrack (init) ok', { remotePeerId }))
|
||||||
@@ -164,10 +158,6 @@ export function createPeerConnection(
|
|||||||
logger.error('audio replaceTrack failed at createPeerConnection', error)
|
logger.error('audio replaceTrack failed at createPeerConnection', error)
|
||||||
);
|
);
|
||||||
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
||||||
if (typeof peerData.videoSender.setStreams === 'function') {
|
|
||||||
peerData.videoSender.setStreams(localStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
peerData.videoSender
|
peerData.videoSender
|
||||||
.replaceTrack(track)
|
.replaceTrack(track)
|
||||||
.then(() => logger.info('video replaceTrack (init) ok', { remotePeerId }))
|
.then(() => logger.info('video replaceTrack (init) ok', { remotePeerId }))
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export function handleRemoteTrack(
|
|||||||
): void {
|
): void {
|
||||||
const { logger, state } = context;
|
const { logger, state } = context;
|
||||||
const track = event.track;
|
const track = event.track;
|
||||||
const isScreenAudio = isScreenShareAudioTrack(context, event, remotePeerId);
|
|
||||||
const settings =
|
const settings =
|
||||||
typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings);
|
typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings);
|
||||||
|
|
||||||
@@ -35,10 +34,10 @@ export function handleRemoteTrack(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const compositeStream = buildCompositeRemoteStream(state, remotePeerId, track);
|
const compositeStream = buildCompositeRemoteStream(state, remotePeerId, track);
|
||||||
const voiceStream = isVoiceAudioTrack(track, isScreenAudio)
|
const voiceStream = isVoiceAudioTrack(context, event, remotePeerId)
|
||||||
? buildAudioOnlyStream(state.remotePeerVoiceStreams.get(remotePeerId), track)
|
? buildAudioOnlyStream(state.remotePeerVoiceStreams.get(remotePeerId), track)
|
||||||
: null;
|
: null;
|
||||||
const screenShareStream = isScreenShareTrack(track, isScreenAudio)
|
const screenShareStream = isScreenShareTrack(context, event, remotePeerId)
|
||||||
? buildScreenShareStream(state.remotePeerScreenShareStreams.get(remotePeerId), track)
|
? buildScreenShareStream(state.remotePeerScreenShareStreams.get(remotePeerId), track)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -54,12 +53,6 @@ export function handleRemoteTrack(
|
|||||||
state.remotePeerScreenShareStreams.set(remotePeerId, screenShareStream);
|
state.remotePeerScreenShareStreams.set(remotePeerId, screenShareStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
rememberIncomingStreamIds(state, event, remotePeerId, {
|
|
||||||
isScreenAudio,
|
|
||||||
isVoiceAudio: !!voiceStream,
|
|
||||||
isScreenTrack: !!screenShareStream
|
|
||||||
});
|
|
||||||
|
|
||||||
publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
|
publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +61,6 @@ export function clearRemoteScreenShareStream(
|
|||||||
remotePeerId: string
|
remotePeerId: string
|
||||||
): void {
|
): void {
|
||||||
const { state } = context;
|
const { state } = context;
|
||||||
const peerData = state.activePeerConnections.get(remotePeerId);
|
|
||||||
const screenShareStream = state.remotePeerScreenShareStreams.get(remotePeerId);
|
const screenShareStream = state.remotePeerScreenShareStreams.get(remotePeerId);
|
||||||
|
|
||||||
if (!screenShareStream) {
|
if (!screenShareStream) {
|
||||||
@@ -87,8 +79,6 @@ export function clearRemoteScreenShareStream(
|
|||||||
removeTracksFromStreamMap(state.remotePeerVoiceStreams, remotePeerId, screenShareTrackIds);
|
removeTracksFromStreamMap(state.remotePeerVoiceStreams, remotePeerId, screenShareTrackIds);
|
||||||
state.remotePeerScreenShareStreams.delete(remotePeerId);
|
state.remotePeerScreenShareStreams.delete(remotePeerId);
|
||||||
|
|
||||||
peerData?.remoteScreenShareStreamIds.clear();
|
|
||||||
|
|
||||||
publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
|
publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,20 +152,11 @@ function removeRemoteTrack(
|
|||||||
trackId: string
|
trackId: string
|
||||||
): void {
|
): void {
|
||||||
const { state } = context;
|
const { state } = context;
|
||||||
const peerData = state.activePeerConnections.get(remotePeerId);
|
|
||||||
const compositeStream = removeTrackFromStreamMap(state.remotePeerStreams, remotePeerId, trackId);
|
const compositeStream = removeTrackFromStreamMap(state.remotePeerStreams, remotePeerId, trackId);
|
||||||
|
|
||||||
removeTrackFromStreamMap(state.remotePeerVoiceStreams, remotePeerId, trackId);
|
removeTrackFromStreamMap(state.remotePeerVoiceStreams, remotePeerId, trackId);
|
||||||
removeTrackFromStreamMap(state.remotePeerScreenShareStreams, remotePeerId, trackId);
|
removeTrackFromStreamMap(state.remotePeerScreenShareStreams, remotePeerId, trackId);
|
||||||
|
|
||||||
if (!state.remotePeerVoiceStreams.has(remotePeerId)) {
|
|
||||||
peerData?.remoteVoiceStreamIds.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.remotePeerScreenShareStreams.has(remotePeerId)) {
|
|
||||||
peerData?.remoteScreenShareStreamIds.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
|
publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,12 +224,20 @@ function publishRemoteStreamUpdate(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVoiceAudioTrack(track: MediaStreamTrack, isScreenAudio: boolean): boolean {
|
function isVoiceAudioTrack(
|
||||||
return track.kind === TRACK_KIND_AUDIO && !isScreenAudio;
|
context: PeerConnectionManagerContext,
|
||||||
|
event: RTCTrackEvent,
|
||||||
|
remotePeerId: string
|
||||||
|
): boolean {
|
||||||
|
return event.track.kind === TRACK_KIND_AUDIO && !isScreenShareAudioTrack(context, event, remotePeerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isScreenShareTrack(track: MediaStreamTrack, isScreenAudio: boolean): boolean {
|
function isScreenShareTrack(
|
||||||
return track.kind === TRACK_KIND_VIDEO || isScreenAudio;
|
context: PeerConnectionManagerContext,
|
||||||
|
event: RTCTrackEvent,
|
||||||
|
remotePeerId: string
|
||||||
|
): boolean {
|
||||||
|
return event.track.kind === TRACK_KIND_VIDEO || isScreenShareAudioTrack(context, event, remotePeerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isScreenShareAudioTrack(
|
function isScreenShareAudioTrack(
|
||||||
@@ -266,34 +255,12 @@ function isScreenShareAudioTrack(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomingStreamIds = getIncomingStreamIds(event);
|
|
||||||
|
|
||||||
if (incomingStreamIds.some((streamId) => peerData.remoteScreenShareStreamIds.has(streamId))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (incomingStreamIds.some((streamId) => peerData.remoteVoiceStreamIds.has(streamId))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.streams.some((stream) => stream.getVideoTracks().some((track) => track.readyState === 'live'))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const screenAudioTransceiver = peerData.connection.getTransceivers().find(
|
|
||||||
(transceiver) => transceiver.sender === peerData.screenAudioSender
|
|
||||||
);
|
|
||||||
|
|
||||||
if (screenAudioTransceiver && matchesTransceiver(event.transceiver, screenAudioTransceiver)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const voiceAudioTransceiver = peerData.connection.getTransceivers().find(
|
const voiceAudioTransceiver = peerData.connection.getTransceivers().find(
|
||||||
(transceiver) => transceiver.sender === peerData.audioSender
|
(transceiver) => transceiver.sender === peerData.audioSender
|
||||||
);
|
);
|
||||||
|
|
||||||
if (voiceAudioTransceiver) {
|
if (voiceAudioTransceiver) {
|
||||||
return !matchesTransceiver(event.transceiver, voiceAudioTransceiver);
|
return event.transceiver !== voiceAudioTransceiver;
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioTransceivers = peerData.connection.getTransceivers().filter((transceiver) =>
|
const audioTransceivers = peerData.connection.getTransceivers().filter((transceiver) =>
|
||||||
@@ -305,52 +272,3 @@ function isScreenShareAudioTrack(
|
|||||||
|
|
||||||
return transceiverIndex > 0;
|
return transceiverIndex > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rememberIncomingStreamIds(
|
|
||||||
state: PeerConnectionManagerContext['state'],
|
|
||||||
event: RTCTrackEvent,
|
|
||||||
remotePeerId: string,
|
|
||||||
options: {
|
|
||||||
isScreenAudio: boolean;
|
|
||||||
isVoiceAudio: boolean;
|
|
||||||
isScreenTrack: boolean;
|
|
||||||
}
|
|
||||||
): void {
|
|
||||||
const peerData = state.activePeerConnections.get(remotePeerId);
|
|
||||||
|
|
||||||
if (!peerData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const incomingStreamIds = getIncomingStreamIds(event);
|
|
||||||
|
|
||||||
if (incomingStreamIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.track.kind === TRACK_KIND_VIDEO || options.isScreenAudio || options.isScreenTrack) {
|
|
||||||
incomingStreamIds.forEach((streamId) => {
|
|
||||||
peerData.remoteScreenShareStreamIds.add(streamId);
|
|
||||||
peerData.remoteVoiceStreamIds.delete(streamId);
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.isVoiceAudio) {
|
|
||||||
incomingStreamIds.forEach((streamId) => {
|
|
||||||
peerData.remoteVoiceStreamIds.add(streamId);
|
|
||||||
peerData.remoteScreenShareStreamIds.delete(streamId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIncomingStreamIds(event: RTCTrackEvent): string[] {
|
|
||||||
return event.streams
|
|
||||||
.map((stream) => stream.id)
|
|
||||||
.filter((streamId): streamId is string => !!streamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesTransceiver(left: RTCRtpTransceiver, right: RTCRtpTransceiver): boolean {
|
|
||||||
return left === right || (!!left.mid && !!right.mid && left.mid === right.mid);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../screen-share.config';
|
|
||||||
import { WebRTCLogger } from '../webrtc-logger';
|
|
||||||
|
|
||||||
export class BrowserScreenShareCapture {
|
|
||||||
constructor(private readonly logger: WebRTCLogger) {}
|
|
||||||
|
|
||||||
async startCapture(
|
|
||||||
options: ScreenShareStartOptions,
|
|
||||||
preset: ScreenShareQualityPreset
|
|
||||||
): Promise<MediaStream> {
|
|
||||||
const displayConstraints = this.buildDisplayMediaConstraints(options, preset);
|
|
||||||
|
|
||||||
this.logger.info('getDisplayMedia constraints', displayConstraints);
|
|
||||||
|
|
||||||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
|
||||||
throw new Error('navigator.mediaDevices.getDisplayMedia is not available.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await navigator.mediaDevices.getDisplayMedia(displayConstraints);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildDisplayMediaConstraints(
|
|
||||||
options: ScreenShareStartOptions,
|
|
||||||
preset: ScreenShareQualityPreset
|
|
||||||
): DisplayMediaStreamOptions {
|
|
||||||
const supportedConstraints = navigator.mediaDevices?.getSupportedConstraints?.() as Record<string, boolean> | undefined;
|
|
||||||
const audioConstraints: Record<string, unknown> | false = options.includeSystemAudio
|
|
||||||
? {
|
|
||||||
echoCancellation: false,
|
|
||||||
noiseSuppression: false,
|
|
||||||
autoGainControl: false
|
|
||||||
}
|
|
||||||
: false;
|
|
||||||
|
|
||||||
if (audioConstraints && supportedConstraints?.['restrictOwnAudio']) {
|
|
||||||
audioConstraints['restrictOwnAudio'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioConstraints && supportedConstraints?.['suppressLocalAudioPlayback']) {
|
|
||||||
audioConstraints['suppressLocalAudioPlayback'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
video: {
|
|
||||||
width: { ideal: preset.width, max: preset.width },
|
|
||||||
height: { ideal: preset.height, max: preset.height },
|
|
||||||
frameRate: { ideal: preset.frameRate, max: preset.frameRate }
|
|
||||||
},
|
|
||||||
audio: audioConstraints,
|
|
||||||
monitorTypeSurfaces: 'include',
|
|
||||||
selfBrowserSurface: 'exclude',
|
|
||||||
surfaceSwitching: 'include',
|
|
||||||
systemAudio: options.includeSystemAudio ? 'include' : 'exclude'
|
|
||||||
} as DisplayMediaStreamOptions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../screen-share.config';
|
|
||||||
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../webrtc.constants';
|
|
||||||
import { WebRTCLogger } from '../webrtc-logger';
|
|
||||||
import {
|
|
||||||
DesktopSource,
|
|
||||||
ElectronDesktopCaptureResult,
|
|
||||||
ElectronDesktopMediaStreamConstraints,
|
|
||||||
ElectronDesktopSourceSelection,
|
|
||||||
ScreenShareElectronApi
|
|
||||||
} from './shared';
|
|
||||||
|
|
||||||
interface DesktopElectronScreenShareCaptureDependencies {
|
|
||||||
getElectronApi(): ScreenShareElectronApi | null;
|
|
||||||
getSelectDesktopSource(): ((
|
|
||||||
sources: readonly DesktopSource[],
|
|
||||||
options: { includeSystemAudio: boolean }
|
|
||||||
) => Promise<ElectronDesktopSourceSelection>) | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DesktopElectronScreenShareCapture {
|
|
||||||
constructor(
|
|
||||||
private readonly logger: WebRTCLogger,
|
|
||||||
private readonly dependencies: DesktopElectronScreenShareCaptureDependencies
|
|
||||||
) {}
|
|
||||||
|
|
||||||
isAvailable(): boolean {
|
|
||||||
return !!this.dependencies.getElectronApi()?.getSources && !this.isLinuxElectron();
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldSuppressRemotePlaybackDuringShare(includeSystemAudio: boolean): boolean {
|
|
||||||
return includeSystemAudio && this.isWindowsElectron();
|
|
||||||
}
|
|
||||||
|
|
||||||
async startCapture(
|
|
||||||
options: ScreenShareStartOptions,
|
|
||||||
preset: ScreenShareQualityPreset
|
|
||||||
): Promise<ElectronDesktopCaptureResult> {
|
|
||||||
const electronApi = this.dependencies.getElectronApi();
|
|
||||||
|
|
||||||
if (!electronApi?.getSources) {
|
|
||||||
throw new Error('Electron desktop capture is unavailable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sources = await electronApi.getSources();
|
|
||||||
const selection = await this.resolveSourceSelection(sources, options.includeSystemAudio);
|
|
||||||
const captureOptions = {
|
|
||||||
...options,
|
|
||||||
includeSystemAudio: selection.includeSystemAudio
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!selection.source) {
|
|
||||||
throw new Error('No desktop capture sources were available.');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.info('Selected Electron desktop source', {
|
|
||||||
includeSystemAudio: selection.includeSystemAudio,
|
|
||||||
sourceId: selection.source.id,
|
|
||||||
sourceName: selection.source.name
|
|
||||||
});
|
|
||||||
|
|
||||||
const constraints = this.buildConstraints(selection.source.id, captureOptions, preset);
|
|
||||||
|
|
||||||
this.logger.info('desktopCapturer constraints', constraints);
|
|
||||||
|
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
|
||||||
throw new Error('navigator.mediaDevices.getUserMedia is not available (requires HTTPS or localhost).');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
includeSystemAudio: selection.includeSystemAudio,
|
|
||||||
stream: await navigator.mediaDevices.getUserMedia(constraints)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveSourceSelection(
|
|
||||||
sources: DesktopSource[],
|
|
||||||
includeSystemAudio: boolean
|
|
||||||
): Promise<ElectronDesktopSourceSelection> {
|
|
||||||
const orderedSources = this.sortSources(sources);
|
|
||||||
const defaultSource = orderedSources.find((source) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME)
|
|
||||||
?? orderedSources[0];
|
|
||||||
|
|
||||||
if (orderedSources.length === 0) {
|
|
||||||
throw new Error('No desktop capture sources were available.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectDesktopSource = this.dependencies.getSelectDesktopSource();
|
|
||||||
|
|
||||||
if (!this.isWindowsElectron() || orderedSources.length < 2 || !selectDesktopSource) {
|
|
||||||
return {
|
|
||||||
includeSystemAudio,
|
|
||||||
source: defaultSource
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return await selectDesktopSource(orderedSources, { includeSystemAudio });
|
|
||||||
}
|
|
||||||
|
|
||||||
private sortSources(sources: DesktopSource[]): DesktopSource[] {
|
|
||||||
return [...sources].sort((left, right) => {
|
|
||||||
const weightDiff = this.getSourceWeight(left) - this.getSourceWeight(right);
|
|
||||||
|
|
||||||
if (weightDiff !== 0) {
|
|
||||||
return weightDiff;
|
|
||||||
}
|
|
||||||
|
|
||||||
return left.name.localeCompare(right.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSourceWeight(source: DesktopSource): number {
|
|
||||||
return source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME || source.id.startsWith('screen')
|
|
||||||
? 0
|
|
||||||
: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildConstraints(
|
|
||||||
sourceId: string,
|
|
||||||
options: ScreenShareStartOptions,
|
|
||||||
preset: ScreenShareQualityPreset
|
|
||||||
): ElectronDesktopMediaStreamConstraints {
|
|
||||||
const constraints: ElectronDesktopMediaStreamConstraints = {
|
|
||||||
video: {
|
|
||||||
mandatory: {
|
|
||||||
chromeMediaSource: 'desktop',
|
|
||||||
chromeMediaSourceId: sourceId,
|
|
||||||
maxWidth: preset.width,
|
|
||||||
maxHeight: preset.height,
|
|
||||||
maxFrameRate: preset.frameRate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options.includeSystemAudio) {
|
|
||||||
constraints.audio = {
|
|
||||||
mandatory: {
|
|
||||||
chromeMediaSource: 'desktop',
|
|
||||||
chromeMediaSourceId: sourceId
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
constraints.audio = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return constraints;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isLinuxElectron(): boolean {
|
|
||||||
if (!this.dependencies.getElectronApi() || typeof navigator === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return /linux/i.test(`${navigator.userAgent} ${navigator.platform}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isWindowsElectron(): boolean {
|
|
||||||
if (!this.isAvailable() || typeof navigator === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return /win/i.test(`${navigator.userAgent} ${navigator.platform}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../screen-share.config';
|
|
||||||
import { WebRTCLogger } from '../webrtc-logger';
|
|
||||||
import {
|
|
||||||
LinuxScreenShareAudioRoutingInfo,
|
|
||||||
LinuxScreenShareMonitorAudioChunkPayload,
|
|
||||||
LinuxScreenShareMonitorAudioEndedPayload,
|
|
||||||
LinuxScreenShareMonitorCaptureInfo,
|
|
||||||
ScreenShareElectronApi
|
|
||||||
} from './shared';
|
|
||||||
|
|
||||||
interface LinuxScreenShareMonitorAudioPipeline {
|
|
||||||
audioContext: AudioContext;
|
|
||||||
audioTrack: MediaStreamTrack;
|
|
||||||
bitsPerSample: number;
|
|
||||||
captureId: string;
|
|
||||||
channelCount: number;
|
|
||||||
mediaDestination: MediaStreamAudioDestinationNode;
|
|
||||||
nextStartTime: number;
|
|
||||||
pendingBytes: Uint8Array;
|
|
||||||
sampleRate: number;
|
|
||||||
unsubscribeChunk: () => void;
|
|
||||||
unsubscribeEnded: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LinuxElectronScreenShareCaptureDependencies {
|
|
||||||
getElectronApi(): ScreenShareElectronApi | null;
|
|
||||||
onCaptureEnded(): void;
|
|
||||||
startDisplayMedia(options: ScreenShareStartOptions, preset: ScreenShareQualityPreset): Promise<MediaStream>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LinuxElectronScreenShareCapture {
|
|
||||||
private audioRoutingActive = false;
|
|
||||||
private audioRoutingResetPromise: Promise<void> | null = null;
|
|
||||||
private monitorAudioPipeline: LinuxScreenShareMonitorAudioPipeline | null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly logger: WebRTCLogger,
|
|
||||||
private readonly dependencies: LinuxElectronScreenShareCaptureDependencies
|
|
||||||
) {}
|
|
||||||
|
|
||||||
isSupported(): boolean {
|
|
||||||
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const electronApi = this.dependencies.getElectronApi();
|
|
||||||
const platformHint = `${navigator.userAgent} ${navigator.platform}`;
|
|
||||||
|
|
||||||
return !!electronApi?.prepareLinuxScreenShareAudioRouting
|
|
||||||
&& !!electronApi?.activateLinuxScreenShareAudioRouting
|
|
||||||
&& !!electronApi?.deactivateLinuxScreenShareAudioRouting
|
|
||||||
&& !!electronApi?.startLinuxScreenShareMonitorCapture
|
|
||||||
&& !!electronApi?.stopLinuxScreenShareMonitorCapture
|
|
||||||
&& !!electronApi?.onLinuxScreenShareMonitorAudioChunk
|
|
||||||
&& !!electronApi?.onLinuxScreenShareMonitorAudioEnded
|
|
||||||
&& /linux/i.test(platformHint);
|
|
||||||
}
|
|
||||||
|
|
||||||
async awaitPendingReset(): Promise<void> {
|
|
||||||
if (!this.audioRoutingResetPromise) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.audioRoutingResetPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleReset(): void {
|
|
||||||
if (!this.audioRoutingActive || this.audioRoutingResetPromise) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.audioRoutingResetPromise = this.resetAudioRouting()
|
|
||||||
.catch((error) => {
|
|
||||||
this.logger.warn('Failed to reset Linux Electron audio routing', error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.audioRoutingResetPromise = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async startCapture(
|
|
||||||
options: ScreenShareStartOptions,
|
|
||||||
preset: ScreenShareQualityPreset
|
|
||||||
): Promise<MediaStream> {
|
|
||||||
const electronApi = this.getRequiredElectronApi();
|
|
||||||
const routingInfo = await electronApi.prepareLinuxScreenShareAudioRouting();
|
|
||||||
|
|
||||||
this.assertAudioRoutingReady(routingInfo, 'Linux Electron audio routing is unavailable.');
|
|
||||||
|
|
||||||
let desktopStream: MediaStream | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const activation = await electronApi.activateLinuxScreenShareAudioRouting();
|
|
||||||
|
|
||||||
this.assertAudioRoutingReady(activation, 'Failed to activate Linux Electron audio routing.');
|
|
||||||
|
|
||||||
if (!activation.active) {
|
|
||||||
throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.');
|
|
||||||
}
|
|
||||||
|
|
||||||
desktopStream = await this.dependencies.startDisplayMedia({
|
|
||||||
...options,
|
|
||||||
includeSystemAudio: false
|
|
||||||
}, preset);
|
|
||||||
|
|
||||||
const { audioTrack, captureInfo } = await this.startMonitorTrack();
|
|
||||||
const stream = new MediaStream([...desktopStream.getVideoTracks(), audioTrack]);
|
|
||||||
|
|
||||||
desktopStream.getAudioTracks().forEach((track) => track.stop());
|
|
||||||
|
|
||||||
this.audioRoutingActive = true;
|
|
||||||
this.logger.info('Linux Electron screen-share audio routing enabled', {
|
|
||||||
screenShareMonitorSourceName: captureInfo.sourceName,
|
|
||||||
voiceSinkName: activation.voiceSinkName
|
|
||||||
});
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
} catch (error) {
|
|
||||||
desktopStream?.getTracks().forEach((track) => track.stop());
|
|
||||||
await this.resetAudioRouting();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getRequiredElectronApi(): Required<Pick<
|
|
||||||
ScreenShareElectronApi,
|
|
||||||
| 'prepareLinuxScreenShareAudioRouting'
|
|
||||||
| 'activateLinuxScreenShareAudioRouting'
|
|
||||||
| 'deactivateLinuxScreenShareAudioRouting'
|
|
||||||
| 'startLinuxScreenShareMonitorCapture'
|
|
||||||
| 'stopLinuxScreenShareMonitorCapture'
|
|
||||||
| 'onLinuxScreenShareMonitorAudioChunk'
|
|
||||||
| 'onLinuxScreenShareMonitorAudioEnded'
|
|
||||||
>> {
|
|
||||||
const electronApi = this.dependencies.getElectronApi();
|
|
||||||
|
|
||||||
if (!electronApi?.prepareLinuxScreenShareAudioRouting
|
|
||||||
|| !electronApi.activateLinuxScreenShareAudioRouting
|
|
||||||
|| !electronApi.deactivateLinuxScreenShareAudioRouting
|
|
||||||
|| !electronApi.startLinuxScreenShareMonitorCapture
|
|
||||||
|| !electronApi.stopLinuxScreenShareMonitorCapture
|
|
||||||
|| !electronApi.onLinuxScreenShareMonitorAudioChunk
|
|
||||||
|| !electronApi.onLinuxScreenShareMonitorAudioEnded) {
|
|
||||||
throw new Error('Linux Electron audio routing is unavailable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
prepareLinuxScreenShareAudioRouting: electronApi.prepareLinuxScreenShareAudioRouting,
|
|
||||||
activateLinuxScreenShareAudioRouting: electronApi.activateLinuxScreenShareAudioRouting,
|
|
||||||
deactivateLinuxScreenShareAudioRouting: electronApi.deactivateLinuxScreenShareAudioRouting,
|
|
||||||
startLinuxScreenShareMonitorCapture: electronApi.startLinuxScreenShareMonitorCapture,
|
|
||||||
stopLinuxScreenShareMonitorCapture: electronApi.stopLinuxScreenShareMonitorCapture,
|
|
||||||
onLinuxScreenShareMonitorAudioChunk: electronApi.onLinuxScreenShareMonitorAudioChunk,
|
|
||||||
onLinuxScreenShareMonitorAudioEnded: electronApi.onLinuxScreenShareMonitorAudioEnded
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertAudioRoutingReady(
|
|
||||||
routingInfo: LinuxScreenShareAudioRoutingInfo,
|
|
||||||
unavailableReason: string
|
|
||||||
): void {
|
|
||||||
if (!routingInfo.available) {
|
|
||||||
throw new Error(routingInfo.reason || unavailableReason);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!routingInfo.monitorCaptureSupported) {
|
|
||||||
throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resetAudioRouting(): Promise<void> {
|
|
||||||
const electronApi = this.dependencies.getElectronApi();
|
|
||||||
const captureId = this.monitorAudioPipeline?.captureId;
|
|
||||||
|
|
||||||
this.audioRoutingActive = false;
|
|
||||||
|
|
||||||
this.disposeMonitorAudioPipeline();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (captureId && electronApi?.stopLinuxScreenShareMonitorCapture) {
|
|
||||||
await electronApi.stopLinuxScreenShareMonitorCapture(captureId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn('Failed to stop Linux screen-share monitor capture', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (electronApi?.deactivateLinuxScreenShareAudioRouting) {
|
|
||||||
await electronApi.deactivateLinuxScreenShareAudioRouting();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn('Failed to deactivate Linux Electron audio routing', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async startMonitorTrack(): Promise<{
|
|
||||||
audioTrack: MediaStreamTrack;
|
|
||||||
captureInfo: LinuxScreenShareMonitorCaptureInfo;
|
|
||||||
}> {
|
|
||||||
const electronApi = this.dependencies.getElectronApi();
|
|
||||||
|
|
||||||
if (!electronApi?.startLinuxScreenShareMonitorCapture
|
|
||||||
|| !electronApi?.stopLinuxScreenShareMonitorCapture
|
|
||||||
|| !electronApi?.onLinuxScreenShareMonitorAudioChunk
|
|
||||||
|| !electronApi?.onLinuxScreenShareMonitorAudioEnded) {
|
|
||||||
throw new Error('Linux screen-share monitor capture is unavailable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const queuedChunksByCaptureId = new Map<string, Uint8Array[]>();
|
|
||||||
const queuedEndedReasons = new Map<string, string | undefined>();
|
|
||||||
|
|
||||||
let pipeline: LinuxScreenShareMonitorAudioPipeline | null = null;
|
|
||||||
let captureInfo: LinuxScreenShareMonitorCaptureInfo | null = null;
|
|
||||||
|
|
||||||
const queueChunk = (captureId: string, chunk: Uint8Array): void => {
|
|
||||||
const queuedChunks = queuedChunksByCaptureId.get(captureId) || [];
|
|
||||||
|
|
||||||
queuedChunks.push(this.copyBytes(chunk));
|
|
||||||
queuedChunksByCaptureId.set(captureId, queuedChunks);
|
|
||||||
};
|
|
||||||
const onChunk = (payload: LinuxScreenShareMonitorAudioChunkPayload): void => {
|
|
||||||
if (!pipeline || payload.captureId !== pipeline.captureId) {
|
|
||||||
queueChunk(payload.captureId, payload.chunk);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.handleMonitorAudioChunk(pipeline, payload.chunk);
|
|
||||||
};
|
|
||||||
const onEnded = (payload: LinuxScreenShareMonitorAudioEndedPayload): void => {
|
|
||||||
if (!pipeline || payload.captureId !== pipeline.captureId) {
|
|
||||||
queuedEndedReasons.set(payload.captureId, payload.reason);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.warn('Linux screen-share monitor capture ended', payload);
|
|
||||||
this.dependencies.onCaptureEnded();
|
|
||||||
};
|
|
||||||
const unsubscribeChunk = electronApi.onLinuxScreenShareMonitorAudioChunk(onChunk) as () => void;
|
|
||||||
const unsubscribeEnded = electronApi.onLinuxScreenShareMonitorAudioEnded(onEnded) as () => void;
|
|
||||||
|
|
||||||
try {
|
|
||||||
captureInfo = await electronApi.startLinuxScreenShareMonitorCapture() as LinuxScreenShareMonitorCaptureInfo;
|
|
||||||
|
|
||||||
const audioContext = new AudioContext({ sampleRate: captureInfo.sampleRate });
|
|
||||||
const mediaDestination = audioContext.createMediaStreamDestination();
|
|
||||||
|
|
||||||
await audioContext.resume();
|
|
||||||
|
|
||||||
const audioTrack = mediaDestination.stream.getAudioTracks()[0];
|
|
||||||
|
|
||||||
if (!audioTrack) {
|
|
||||||
throw new Error('Renderer audio pipeline did not produce a screen-share monitor track.');
|
|
||||||
}
|
|
||||||
|
|
||||||
pipeline = {
|
|
||||||
audioContext,
|
|
||||||
audioTrack,
|
|
||||||
bitsPerSample: captureInfo.bitsPerSample,
|
|
||||||
captureId: captureInfo.captureId,
|
|
||||||
channelCount: captureInfo.channelCount,
|
|
||||||
mediaDestination,
|
|
||||||
nextStartTime: audioContext.currentTime + 0.05,
|
|
||||||
pendingBytes: new Uint8Array(0),
|
|
||||||
sampleRate: captureInfo.sampleRate,
|
|
||||||
unsubscribeChunk,
|
|
||||||
unsubscribeEnded
|
|
||||||
};
|
|
||||||
|
|
||||||
this.monitorAudioPipeline = pipeline;
|
|
||||||
const activeCaptureId = captureInfo.captureId;
|
|
||||||
|
|
||||||
audioTrack.addEventListener('ended', () => {
|
|
||||||
if (this.monitorAudioPipeline?.captureId === activeCaptureId) {
|
|
||||||
this.dependencies.onCaptureEnded();
|
|
||||||
}
|
|
||||||
}, { once: true });
|
|
||||||
|
|
||||||
const queuedChunks = queuedChunksByCaptureId.get(captureInfo.captureId) || [];
|
|
||||||
const activePipeline = pipeline;
|
|
||||||
|
|
||||||
queuedChunks.forEach((chunk) => {
|
|
||||||
this.handleMonitorAudioChunk(activePipeline, chunk);
|
|
||||||
});
|
|
||||||
|
|
||||||
queuedChunksByCaptureId.delete(captureInfo.captureId);
|
|
||||||
|
|
||||||
if (queuedEndedReasons.has(captureInfo.captureId)) {
|
|
||||||
throw new Error(queuedEndedReasons.get(captureInfo.captureId)
|
|
||||||
|| 'Linux screen-share monitor capture ended before audio initialisation completed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
audioTrack,
|
|
||||||
captureInfo
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (pipeline) {
|
|
||||||
this.disposeMonitorAudioPipeline(pipeline.captureId);
|
|
||||||
} else {
|
|
||||||
unsubscribeChunk();
|
|
||||||
unsubscribeEnded();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await electronApi.stopLinuxScreenShareMonitorCapture(captureInfo?.captureId);
|
|
||||||
} catch (stopError) {
|
|
||||||
this.logger.warn('Failed to stop Linux screen-share monitor capture after startup failure', stopError);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private disposeMonitorAudioPipeline(captureId?: string): void {
|
|
||||||
if (!this.monitorAudioPipeline) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (captureId && captureId !== this.monitorAudioPipeline.captureId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pipeline = this.monitorAudioPipeline;
|
|
||||||
|
|
||||||
this.monitorAudioPipeline = null;
|
|
||||||
pipeline.unsubscribeChunk();
|
|
||||||
pipeline.unsubscribeEnded();
|
|
||||||
pipeline.audioTrack.stop();
|
|
||||||
pipeline.pendingBytes = new Uint8Array(0);
|
|
||||||
|
|
||||||
void pipeline.audioContext.close().catch((error) => {
|
|
||||||
this.logger.warn('Failed to close Linux screen-share monitor audio context', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMonitorAudioChunk(
|
|
||||||
pipeline: LinuxScreenShareMonitorAudioPipeline,
|
|
||||||
chunk: Uint8Array
|
|
||||||
): void {
|
|
||||||
if (pipeline.bitsPerSample !== 16) {
|
|
||||||
this.logger.warn('Unsupported Linux screen-share monitor capture sample size', {
|
|
||||||
bitsPerSample: pipeline.bitsPerSample,
|
|
||||||
captureId: pipeline.captureId
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytesPerSample = pipeline.bitsPerSample / 8;
|
|
||||||
const bytesPerFrame = bytesPerSample * pipeline.channelCount;
|
|
||||||
|
|
||||||
if (!Number.isFinite(bytesPerFrame) || bytesPerFrame <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const combinedBytes = this.concatBytes(pipeline.pendingBytes, chunk);
|
|
||||||
const completeByteLength = combinedBytes.byteLength - (combinedBytes.byteLength % bytesPerFrame);
|
|
||||||
|
|
||||||
if (completeByteLength <= 0) {
|
|
||||||
pipeline.pendingBytes = combinedBytes;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const completeBytes = combinedBytes.subarray(0, completeByteLength);
|
|
||||||
|
|
||||||
pipeline.pendingBytes = this.copyBytes(combinedBytes.subarray(completeByteLength));
|
|
||||||
|
|
||||||
if (pipeline.audioContext.state !== 'running') {
|
|
||||||
void pipeline.audioContext.resume().catch((error) => {
|
|
||||||
this.logger.warn('Failed to resume Linux screen-share monitor audio context', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const frameCount = completeByteLength / bytesPerFrame;
|
|
||||||
const audioBuffer = this.createAudioBuffer(pipeline, completeBytes, frameCount);
|
|
||||||
const source = pipeline.audioContext.createBufferSource();
|
|
||||||
|
|
||||||
source.buffer = audioBuffer;
|
|
||||||
source.connect(pipeline.mediaDestination);
|
|
||||||
|
|
||||||
source.onended = () => {
|
|
||||||
source.disconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
const now = pipeline.audioContext.currentTime;
|
|
||||||
const startTime = Math.max(pipeline.nextStartTime, now + 0.02);
|
|
||||||
|
|
||||||
source.start(startTime);
|
|
||||||
pipeline.nextStartTime = startTime + audioBuffer.duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createAudioBuffer(
|
|
||||||
pipeline: LinuxScreenShareMonitorAudioPipeline,
|
|
||||||
bytes: Uint8Array,
|
|
||||||
frameCount: number
|
|
||||||
): AudioBuffer {
|
|
||||||
const audioBuffer = pipeline.audioContext.createBuffer(pipeline.channelCount, frameCount, pipeline.sampleRate);
|
|
||||||
const sampleData = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
||||||
const channelData = Array.from(
|
|
||||||
{ length: pipeline.channelCount },
|
|
||||||
(_, channelIndex) => audioBuffer.getChannelData(channelIndex)
|
|
||||||
);
|
|
||||||
const bytesPerSample = pipeline.bitsPerSample / 8;
|
|
||||||
const bytesPerFrame = bytesPerSample * pipeline.channelCount;
|
|
||||||
|
|
||||||
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
|
||||||
const frameOffset = frameIndex * bytesPerFrame;
|
|
||||||
|
|
||||||
for (let channelIndex = 0; channelIndex < pipeline.channelCount; channelIndex += 1) {
|
|
||||||
const sampleOffset = frameOffset + (channelIndex * bytesPerSample);
|
|
||||||
|
|
||||||
channelData[channelIndex][frameIndex] = sampleData.getInt16(sampleOffset, true) / 32768;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return audioBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
private concatBytes(first: Uint8Array, second: Uint8Array): Uint8Array {
|
|
||||||
if (first.byteLength === 0) {
|
|
||||||
return this.copyBytes(second);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (second.byteLength === 0) {
|
|
||||||
return this.copyBytes(first);
|
|
||||||
}
|
|
||||||
|
|
||||||
const combined = new Uint8Array(first.byteLength + second.byteLength);
|
|
||||||
|
|
||||||
combined.set(first, 0);
|
|
||||||
combined.set(second, first.byteLength);
|
|
||||||
|
|
||||||
return combined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private copyBytes(bytes: Uint8Array): Uint8Array {
|
|
||||||
return bytes.byteLength > 0 ? new Uint8Array(bytes) : new Uint8Array(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
export interface DesktopSource {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
thumbnail: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ElectronDesktopSourceSelection {
|
|
||||||
includeSystemAudio: boolean;
|
|
||||||
source: DesktopSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ElectronDesktopCaptureResult {
|
|
||||||
includeSystemAudio: boolean;
|
|
||||||
stream: MediaStream;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LinuxScreenShareAudioRoutingInfo {
|
|
||||||
available: boolean;
|
|
||||||
active: boolean;
|
|
||||||
monitorCaptureSupported: boolean;
|
|
||||||
screenShareSinkName: string;
|
|
||||||
screenShareMonitorSourceName: string;
|
|
||||||
voiceSinkName: string;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LinuxScreenShareMonitorCaptureInfo {
|
|
||||||
bitsPerSample: number;
|
|
||||||
captureId: string;
|
|
||||||
channelCount: number;
|
|
||||||
sampleRate: number;
|
|
||||||
sourceName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LinuxScreenShareMonitorAudioChunkPayload {
|
|
||||||
captureId: string;
|
|
||||||
chunk: Uint8Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LinuxScreenShareMonitorAudioEndedPayload {
|
|
||||||
captureId: string;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScreenShareElectronApi {
|
|
||||||
getSources?: () => Promise<DesktopSource[]>;
|
|
||||||
prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
|
||||||
activateLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
|
||||||
deactivateLinuxScreenShareAudioRouting?: () => Promise<boolean>;
|
|
||||||
startLinuxScreenShareMonitorCapture?: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
|
|
||||||
stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise<boolean>;
|
|
||||||
onLinuxScreenShareMonitorAudioChunk?: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
|
||||||
onLinuxScreenShareMonitorAudioEnded?: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ElectronDesktopVideoConstraint = MediaTrackConstraints & {
|
|
||||||
mandatory: {
|
|
||||||
chromeMediaSource: 'desktop';
|
|
||||||
chromeMediaSourceId: string;
|
|
||||||
maxWidth: number;
|
|
||||||
maxHeight: number;
|
|
||||||
maxFrameRate: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ElectronDesktopAudioConstraint = MediaTrackConstraints & {
|
|
||||||
mandatory: {
|
|
||||||
chromeMediaSource: 'desktop';
|
|
||||||
chromeMediaSourceId: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints {
|
|
||||||
video: ElectronDesktopVideoConstraint;
|
|
||||||
audio?: false | ElectronDesktopAudioConstraint;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ScreenShareWindow = Window & {
|
|
||||||
electronAPI?: ScreenShareElectronApi;
|
|
||||||
};
|
|
||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
TRACK_KIND_AUDIO,
|
TRACK_KIND_AUDIO,
|
||||||
TRACK_KIND_VIDEO,
|
TRACK_KIND_VIDEO,
|
||||||
TRANSCEIVER_SEND_RECV,
|
TRANSCEIVER_SEND_RECV,
|
||||||
TRANSCEIVER_RECV_ONLY
|
TRANSCEIVER_RECV_ONLY,
|
||||||
|
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
|
||||||
} from './webrtc.constants';
|
} from './webrtc.constants';
|
||||||
import {
|
import {
|
||||||
DEFAULT_SCREEN_SHARE_START_OPTIONS,
|
DEFAULT_SCREEN_SHARE_START_OPTIONS,
|
||||||
@@ -17,10 +18,6 @@ import {
|
|||||||
ScreenShareQualityPreset,
|
ScreenShareQualityPreset,
|
||||||
ScreenShareStartOptions
|
ScreenShareStartOptions
|
||||||
} from './screen-share.config';
|
} from './screen-share.config';
|
||||||
import { BrowserScreenShareCapture } from './screen-share-platforms/browser-screen-share.capture';
|
|
||||||
import { DesktopElectronScreenShareCapture } from './screen-share-platforms/desktop-electron-screen-share.capture';
|
|
||||||
import { LinuxElectronScreenShareCapture } from './screen-share-platforms/linux-electron-screen-share.capture';
|
|
||||||
import { ScreenShareElectronApi, ScreenShareWindow } from './screen-share-platforms/shared';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callbacks the ScreenShareManager needs from the owning service.
|
* Callbacks the ScreenShareManager needs from the owning service.
|
||||||
@@ -48,9 +45,103 @@ export interface LocalScreenShareState {
|
|||||||
includeSystemAudio: boolean;
|
includeSystemAudio: boolean;
|
||||||
stream: MediaStream | null;
|
stream: MediaStream | null;
|
||||||
suppressRemotePlayback: boolean;
|
suppressRemotePlayback: boolean;
|
||||||
forceDefaultRemotePlaybackOutput: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LinuxScreenShareAudioRoutingInfo {
|
||||||
|
available: boolean;
|
||||||
|
active: boolean;
|
||||||
|
monitorCaptureSupported: boolean;
|
||||||
|
screenShareSinkName: string;
|
||||||
|
screenShareMonitorSourceName: string;
|
||||||
|
voiceSinkName: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinuxScreenShareMonitorCaptureInfo {
|
||||||
|
bitsPerSample: number;
|
||||||
|
captureId: string;
|
||||||
|
channelCount: number;
|
||||||
|
sampleRate: number;
|
||||||
|
sourceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinuxScreenShareMonitorAudioChunkPayload {
|
||||||
|
captureId: string;
|
||||||
|
chunk: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinuxScreenShareMonitorAudioEndedPayload {
|
||||||
|
captureId: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinuxScreenShareMonitorAudioPipeline {
|
||||||
|
audioContext: AudioContext;
|
||||||
|
audioTrack: MediaStreamTrack;
|
||||||
|
bitsPerSample: number;
|
||||||
|
captureId: string;
|
||||||
|
channelCount: number;
|
||||||
|
mediaDestination: MediaStreamAudioDestinationNode;
|
||||||
|
nextStartTime: number;
|
||||||
|
pendingBytes: Uint8Array;
|
||||||
|
sampleRate: number;
|
||||||
|
unsubscribeChunk: () => void;
|
||||||
|
unsubscribeEnded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopSource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
thumbnail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopSourceSelection {
|
||||||
|
includeSystemAudio: boolean;
|
||||||
|
source: DesktopSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopCaptureResult {
|
||||||
|
includeSystemAudio: boolean;
|
||||||
|
stream: MediaStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenShareElectronApi {
|
||||||
|
getSources?: () => Promise<DesktopSource[]>;
|
||||||
|
prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
|
activateLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
|
deactivateLinuxScreenShareAudioRouting?: () => Promise<boolean>;
|
||||||
|
startLinuxScreenShareMonitorCapture?: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
|
||||||
|
stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise<boolean>;
|
||||||
|
onLinuxScreenShareMonitorAudioChunk?: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
|
onLinuxScreenShareMonitorAudioEnded?: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ElectronDesktopVideoConstraint = MediaTrackConstraints & {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: 'desktop';
|
||||||
|
chromeMediaSourceId: string;
|
||||||
|
maxWidth: number;
|
||||||
|
maxHeight: number;
|
||||||
|
maxFrameRate: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ElectronDesktopAudioConstraint = MediaTrackConstraints & {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: 'desktop';
|
||||||
|
chromeMediaSourceId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints {
|
||||||
|
video: ElectronDesktopVideoConstraint;
|
||||||
|
audio?: false | ElectronDesktopAudioConstraint;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScreenShareWindow = Window & {
|
||||||
|
electronAPI?: ScreenShareElectronApi;
|
||||||
|
};
|
||||||
|
|
||||||
export class ScreenShareManager {
|
export class ScreenShareManager {
|
||||||
/** The active screen-capture stream. */
|
/** The active screen-capture stream. */
|
||||||
private activeScreenStream: MediaStream | null = null;
|
private activeScreenStream: MediaStream | null = null;
|
||||||
@@ -64,39 +155,22 @@ export class ScreenShareManager {
|
|||||||
/** Remote peers that explicitly requested screen-share video. */
|
/** Remote peers that explicitly requested screen-share video. */
|
||||||
private readonly requestedViewerPeerIds = new Set<string>();
|
private readonly requestedViewerPeerIds = new Set<string>();
|
||||||
|
|
||||||
/** Browser `getDisplayMedia` capture path. */
|
|
||||||
private readonly browserScreenShareCapture: BrowserScreenShareCapture;
|
|
||||||
|
|
||||||
/** Desktop Electron capture path for non-Linux desktop builds. */
|
|
||||||
private readonly desktopElectronScreenShareCapture: DesktopElectronScreenShareCapture;
|
|
||||||
|
|
||||||
/** Linux Electron screen/audio capture path with isolated audio routing. */
|
|
||||||
private readonly linuxElectronScreenShareCapture: LinuxElectronScreenShareCapture;
|
|
||||||
|
|
||||||
/** Whether screen sharing is currently active. */
|
/** Whether screen sharing is currently active. */
|
||||||
private isScreenActive = false;
|
private isScreenActive = false;
|
||||||
|
|
||||||
|
/** Whether Linux-specific Electron audio routing is currently active. */
|
||||||
|
private linuxElectronAudioRoutingActive = false;
|
||||||
|
|
||||||
|
/** Pending teardown of Linux-specific Electron audio routing. */
|
||||||
|
private linuxAudioRoutingResetPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/** Renderer-side audio pipeline for Linux monitor-source capture. */
|
||||||
|
private linuxMonitorAudioPipeline: LinuxScreenShareMonitorAudioPipeline | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: WebRTCLogger,
|
private readonly logger: WebRTCLogger,
|
||||||
private callbacks: ScreenShareCallbacks
|
private callbacks: ScreenShareCallbacks
|
||||||
) {
|
) {}
|
||||||
this.browserScreenShareCapture = new BrowserScreenShareCapture(this.logger);
|
|
||||||
this.desktopElectronScreenShareCapture = new DesktopElectronScreenShareCapture(this.logger, {
|
|
||||||
getElectronApi: () => this.getElectronApi(),
|
|
||||||
getSelectDesktopSource: () => this.callbacks.selectDesktopSource
|
|
||||||
});
|
|
||||||
|
|
||||||
this.linuxElectronScreenShareCapture = new LinuxElectronScreenShareCapture(this.logger, {
|
|
||||||
getElectronApi: () => this.getElectronApi(),
|
|
||||||
onCaptureEnded: () => {
|
|
||||||
if (this.isScreenActive) {
|
|
||||||
this.stopScreenShare();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
startDisplayMedia: async (options, preset) =>
|
|
||||||
await this.browserScreenShareCapture.startCapture(options, preset)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the callback set at runtime.
|
* Replace the callback set at runtime.
|
||||||
@@ -118,10 +192,8 @@ export class ScreenShareManager {
|
|||||||
*
|
*
|
||||||
* On Linux Electron builds, prefers a dedicated PulseAudio/PipeWire routing
|
* On Linux Electron builds, prefers a dedicated PulseAudio/PipeWire routing
|
||||||
* path so remote voice playback is kept out of captured system audio.
|
* path so remote voice playback is kept out of captured system audio.
|
||||||
* On Windows Electron builds, prefers `getDisplayMedia` with system audio
|
* On other Electron builds, uses desktop capture. In browser contexts, uses
|
||||||
* so the separate mic `getUserMedia` stream is not disrupted; falls back to
|
* `getDisplayMedia`.
|
||||||
* Electron desktop capture only when `getDisplayMedia` fails entirely.
|
|
||||||
* In browser contexts, uses `getDisplayMedia`.
|
|
||||||
*
|
*
|
||||||
* @param options - Screen-share capture options.
|
* @param options - Screen-share capture options.
|
||||||
* @returns The captured screen {@link MediaStream}.
|
* @returns The captured screen {@link MediaStream}.
|
||||||
@@ -133,7 +205,7 @@ export class ScreenShareManager {
|
|||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
const preset = SCREEN_SHARE_QUALITY_PRESETS[shareOptions.quality];
|
const preset = SCREEN_SHARE_QUALITY_PRESETS[shareOptions.quality];
|
||||||
const electronDesktopCaptureAvailable = this.desktopElectronScreenShareCapture.isAvailable();
|
const electronDesktopCaptureAvailable = this.isElectronDesktopCaptureAvailable();
|
||||||
|
|
||||||
let captureMethod: ScreenShareCaptureMethod | null = null;
|
let captureMethod: ScreenShareCaptureMethod | null = null;
|
||||||
|
|
||||||
@@ -144,13 +216,13 @@ export class ScreenShareManager {
|
|||||||
this.stopScreenShare();
|
this.stopScreenShare();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.linuxElectronScreenShareCapture.awaitPendingReset();
|
await this.awaitPendingLinuxAudioRoutingReset();
|
||||||
|
|
||||||
this.activeScreenStream = null;
|
this.activeScreenStream = null;
|
||||||
|
|
||||||
if (shareOptions.includeSystemAudio && this.linuxElectronScreenShareCapture.isSupported()) {
|
if (shareOptions.includeSystemAudio && this.isLinuxElectronAudioRoutingSupported()) {
|
||||||
try {
|
try {
|
||||||
this.activeScreenStream = await this.linuxElectronScreenShareCapture.startCapture(shareOptions, preset);
|
this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset);
|
||||||
captureMethod = 'linux-electron';
|
captureMethod = 'linux-electron';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowIfScreenShareAborted(error);
|
this.rethrowIfScreenShareAborted(error);
|
||||||
@@ -158,29 +230,17 @@ export class ScreenShareManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.activeScreenStream && shareOptions.includeSystemAudio) {
|
if (!this.activeScreenStream && shareOptions.includeSystemAudio && !electronDesktopCaptureAvailable) {
|
||||||
try {
|
try {
|
||||||
this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset);
|
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset);
|
||||||
captureMethod = 'display-media';
|
captureMethod = 'display-media';
|
||||||
|
|
||||||
if (this.activeScreenStream.getAudioTracks().length === 0) {
|
if (this.activeScreenStream.getAudioTracks().length === 0) {
|
||||||
if (electronDesktopCaptureAvailable) {
|
this.logger.warn('getDisplayMedia did not provide system audio; trying Electron desktop capture');
|
||||||
// On Windows Electron, keep the getDisplayMedia stream for video
|
|
||||||
// rather than falling through to getUserMedia desktop audio which
|
|
||||||
// can replace or kill the active mic stream.
|
|
||||||
this.logger.warn(
|
|
||||||
'getDisplayMedia did not provide system audio; '
|
|
||||||
+ 'continuing without system audio to preserve mic stream'
|
|
||||||
);
|
|
||||||
|
|
||||||
shareOptions.includeSystemAudio = false;
|
|
||||||
} else {
|
|
||||||
this.logger.warn('getDisplayMedia did not provide system audio; trying next capture method');
|
|
||||||
this.activeScreenStream.getTracks().forEach((track) => track.stop());
|
this.activeScreenStream.getTracks().forEach((track) => track.stop());
|
||||||
this.activeScreenStream = null;
|
this.activeScreenStream = null;
|
||||||
captureMethod = null;
|
captureMethod = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowIfScreenShareAborted(error);
|
this.rethrowIfScreenShareAborted(error);
|
||||||
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error);
|
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error);
|
||||||
@@ -189,7 +249,7 @@ export class ScreenShareManager {
|
|||||||
|
|
||||||
if (!this.activeScreenStream && electronDesktopCaptureAvailable) {
|
if (!this.activeScreenStream && electronDesktopCaptureAvailable) {
|
||||||
try {
|
try {
|
||||||
const electronCapture = await this.desktopElectronScreenShareCapture.startCapture(shareOptions, preset);
|
const electronCapture = await this.startWithElectronDesktopCapturer(shareOptions, preset);
|
||||||
|
|
||||||
this.activeScreenStream = electronCapture.stream;
|
this.activeScreenStream = electronCapture.stream;
|
||||||
shareOptions.includeSystemAudio = electronCapture.includeSystemAudio;
|
shareOptions.includeSystemAudio = electronCapture.includeSystemAudio;
|
||||||
@@ -201,7 +261,7 @@ export class ScreenShareManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.activeScreenStream) {
|
if (!this.activeScreenStream) {
|
||||||
this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset);
|
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset);
|
||||||
captureMethod = 'display-media';
|
captureMethod = 'display-media';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +308,7 @@ export class ScreenShareManager {
|
|||||||
this.activeScreenStream = null;
|
this.activeScreenStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.linuxElectronScreenShareCapture.scheduleReset();
|
this.scheduleLinuxAudioRoutingReset();
|
||||||
|
|
||||||
this.screenAudioStream = null;
|
this.screenAudioStream = null;
|
||||||
this.activeScreenPreset = null;
|
this.activeScreenPreset = null;
|
||||||
@@ -330,6 +390,26 @@ export class ScreenShareManager {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isElectronDesktopCaptureAvailable(): boolean {
|
||||||
|
return !!this.getElectronApi()?.getSources && !this.isLinuxElectron();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isLinuxElectron(): boolean {
|
||||||
|
if (!this.getElectronApi() || typeof navigator === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /linux/i.test(`${navigator.userAgent} ${navigator.platform}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isWindowsElectron(): boolean {
|
||||||
|
if (!this.isElectronDesktopCaptureAvailable() || typeof navigator === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /win/i.test(`${navigator.userAgent} ${navigator.platform}`);
|
||||||
|
}
|
||||||
|
|
||||||
private publishLocalScreenShareState(
|
private publishLocalScreenShareState(
|
||||||
includeSystemAudio: boolean,
|
includeSystemAudio: boolean,
|
||||||
captureMethod: ScreenShareCaptureMethod | null
|
captureMethod: ScreenShareCaptureMethod | null
|
||||||
@@ -340,13 +420,63 @@ export class ScreenShareManager {
|
|||||||
includeSystemAudio: this.isScreenActive ? includeSystemAudio : false,
|
includeSystemAudio: this.isScreenActive ? includeSystemAudio : false,
|
||||||
stream: this.isScreenActive ? this.activeScreenStream : null,
|
stream: this.isScreenActive ? this.activeScreenStream : null,
|
||||||
suppressRemotePlayback: this.isScreenActive
|
suppressRemotePlayback: this.isScreenActive
|
||||||
&& this.desktopElectronScreenShareCapture.shouldSuppressRemotePlaybackDuringShare(includeSystemAudio),
|
&& this.shouldSuppressRemotePlaybackDuringShare(includeSystemAudio, captureMethod)
|
||||||
forceDefaultRemotePlaybackOutput: this.isScreenActive
|
|
||||||
&& includeSystemAudio
|
|
||||||
&& captureMethod === 'linux-electron'
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldSuppressRemotePlaybackDuringShare(
|
||||||
|
includeSystemAudio: boolean,
|
||||||
|
captureMethod: ScreenShareCaptureMethod | null
|
||||||
|
): boolean {
|
||||||
|
return includeSystemAudio && captureMethod === 'electron-desktop' && this.isWindowsElectron();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRequiredLinuxElectronApi(): Required<Pick<
|
||||||
|
ScreenShareElectronApi,
|
||||||
|
| 'prepareLinuxScreenShareAudioRouting'
|
||||||
|
| 'activateLinuxScreenShareAudioRouting'
|
||||||
|
| 'deactivateLinuxScreenShareAudioRouting'
|
||||||
|
| 'startLinuxScreenShareMonitorCapture'
|
||||||
|
| 'stopLinuxScreenShareMonitorCapture'
|
||||||
|
| 'onLinuxScreenShareMonitorAudioChunk'
|
||||||
|
| 'onLinuxScreenShareMonitorAudioEnded'
|
||||||
|
>> {
|
||||||
|
const electronApi = this.getElectronApi();
|
||||||
|
|
||||||
|
if (!electronApi?.prepareLinuxScreenShareAudioRouting
|
||||||
|
|| !electronApi.activateLinuxScreenShareAudioRouting
|
||||||
|
|| !electronApi.deactivateLinuxScreenShareAudioRouting
|
||||||
|
|| !electronApi.startLinuxScreenShareMonitorCapture
|
||||||
|
|| !electronApi.stopLinuxScreenShareMonitorCapture
|
||||||
|
|| !electronApi.onLinuxScreenShareMonitorAudioChunk
|
||||||
|
|| !electronApi.onLinuxScreenShareMonitorAudioEnded) {
|
||||||
|
throw new Error('Linux Electron audio routing is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prepareLinuxScreenShareAudioRouting: electronApi.prepareLinuxScreenShareAudioRouting,
|
||||||
|
activateLinuxScreenShareAudioRouting: electronApi.activateLinuxScreenShareAudioRouting,
|
||||||
|
deactivateLinuxScreenShareAudioRouting: electronApi.deactivateLinuxScreenShareAudioRouting,
|
||||||
|
startLinuxScreenShareMonitorCapture: electronApi.startLinuxScreenShareMonitorCapture,
|
||||||
|
stopLinuxScreenShareMonitorCapture: electronApi.stopLinuxScreenShareMonitorCapture,
|
||||||
|
onLinuxScreenShareMonitorAudioChunk: electronApi.onLinuxScreenShareMonitorAudioChunk,
|
||||||
|
onLinuxScreenShareMonitorAudioEnded: electronApi.onLinuxScreenShareMonitorAudioEnded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertLinuxAudioRoutingReady(
|
||||||
|
routingInfo: LinuxScreenShareAudioRoutingInfo,
|
||||||
|
unavailableReason: string
|
||||||
|
): void {
|
||||||
|
if (!routingInfo.available) {
|
||||||
|
throw new Error(routingInfo.reason || unavailableReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!routingInfo.monitorCaptureSupported) {
|
||||||
|
throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a dedicated stream for system audio captured alongside the screen.
|
* Create a dedicated stream for system audio captured alongside the screen.
|
||||||
*
|
*
|
||||||
@@ -425,11 +555,6 @@ export class ScreenShareManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
peerData.screenVideoSender = videoSender;
|
peerData.screenVideoSender = videoSender;
|
||||||
|
|
||||||
if (typeof videoSender.setStreams === 'function') {
|
|
||||||
videoSender.setStreams(this.activeScreenStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
videoSender.replaceTrack(screenVideoTrack)
|
videoSender.replaceTrack(screenVideoTrack)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.logger.info('screen video replaceTrack ok', { peerId });
|
this.logger.info('screen video replaceTrack ok', { peerId });
|
||||||
@@ -460,11 +585,6 @@ export class ScreenShareManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
peerData.screenAudioSender = screenAudioSender;
|
peerData.screenAudioSender = screenAudioSender;
|
||||||
|
|
||||||
if (typeof screenAudioSender.setStreams === 'function') {
|
|
||||||
screenAudioSender.setStreams(this.activeScreenStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
screenAudioSender.replaceTrack(screenAudioTrack)
|
screenAudioSender.replaceTrack(screenAudioTrack)
|
||||||
.then(() => this.logger.info('screen audio replaceTrack ok', { peerId }))
|
.then(() => this.logger.info('screen audio replaceTrack ok', { peerId }))
|
||||||
.catch((error) => this.logger.error('screen audio replaceTrack failed', error));
|
.catch((error) => this.logger.error('screen audio replaceTrack failed', error));
|
||||||
@@ -508,6 +628,109 @@ export class ScreenShareManager {
|
|||||||
this.callbacks.renegotiate(peerId);
|
this.callbacks.renegotiate(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async startWithDisplayMedia(
|
||||||
|
options: ScreenShareStartOptions,
|
||||||
|
preset: ScreenShareQualityPreset
|
||||||
|
): Promise<MediaStream> {
|
||||||
|
const displayConstraints = this.buildDisplayMediaConstraints(options, preset);
|
||||||
|
|
||||||
|
this.logger.info('getDisplayMedia constraints', displayConstraints);
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||||||
|
throw new Error('navigator.mediaDevices.getDisplayMedia is not available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await navigator.mediaDevices.getDisplayMedia(displayConstraints);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startWithElectronDesktopCapturer(
|
||||||
|
options: ScreenShareStartOptions,
|
||||||
|
preset: ScreenShareQualityPreset
|
||||||
|
): Promise<ElectronDesktopCaptureResult> {
|
||||||
|
const electronApi = this.getElectronApi();
|
||||||
|
|
||||||
|
if (!electronApi?.getSources) {
|
||||||
|
throw new Error('Electron desktop capture is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = await electronApi.getSources();
|
||||||
|
const selection = await this.resolveElectronDesktopSource(sources, options.includeSystemAudio);
|
||||||
|
const captureOptions = {
|
||||||
|
...options,
|
||||||
|
includeSystemAudio: selection.includeSystemAudio
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selection.source) {
|
||||||
|
throw new Error('No desktop capture sources were available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info('Selected Electron desktop source', {
|
||||||
|
includeSystemAudio: selection.includeSystemAudio,
|
||||||
|
sourceId: selection.source.id,
|
||||||
|
sourceName: selection.source.name
|
||||||
|
});
|
||||||
|
|
||||||
|
const electronConstraints = this.buildElectronDesktopConstraints(selection.source.id, captureOptions, preset);
|
||||||
|
|
||||||
|
this.logger.info('desktopCapturer constraints', electronConstraints);
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
throw new Error('navigator.mediaDevices.getUserMedia is not available (requires HTTPS or localhost).');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
includeSystemAudio: selection.includeSystemAudio,
|
||||||
|
stream: await navigator.mediaDevices.getUserMedia(electronConstraints)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveElectronDesktopSource(
|
||||||
|
sources: DesktopSource[],
|
||||||
|
includeSystemAudio: boolean
|
||||||
|
): Promise<ElectronDesktopSourceSelection> {
|
||||||
|
const orderedSources = this.sortElectronDesktopSources(sources);
|
||||||
|
const defaultSource = orderedSources.find((source) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME)
|
||||||
|
?? orderedSources[0];
|
||||||
|
|
||||||
|
if (orderedSources.length === 0) {
|
||||||
|
throw new Error('No desktop capture sources were available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isWindowsElectron() || orderedSources.length < 2) {
|
||||||
|
return {
|
||||||
|
includeSystemAudio,
|
||||||
|
source: defaultSource
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.callbacks.selectDesktopSource) {
|
||||||
|
return {
|
||||||
|
includeSystemAudio,
|
||||||
|
source: defaultSource
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.callbacks.selectDesktopSource(orderedSources, { includeSystemAudio });
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortElectronDesktopSources(sources: DesktopSource[]): DesktopSource[] {
|
||||||
|
return [...sources].sort((left, right) => {
|
||||||
|
const weightDiff = this.getElectronDesktopSourceWeight(left) - this.getElectronDesktopSourceWeight(right);
|
||||||
|
|
||||||
|
if (weightDiff !== 0) {
|
||||||
|
return weightDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.name.localeCompare(right.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getElectronDesktopSourceWeight(source: DesktopSource): number {
|
||||||
|
return source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME || source.id.startsWith('screen')
|
||||||
|
? 0
|
||||||
|
: 1;
|
||||||
|
}
|
||||||
|
|
||||||
private isScreenShareSelectionAborted(error: unknown): boolean {
|
private isScreenShareSelectionAborted(error: unknown): boolean {
|
||||||
return error instanceof Error
|
return error instanceof Error
|
||||||
&& (error.name === 'AbortError' || error.name === 'NotAllowedError');
|
&& (error.name === 'AbortError' || error.name === 'NotAllowedError');
|
||||||
@@ -519,6 +742,425 @@ export class ScreenShareManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isLinuxElectronAudioRoutingSupported(): boolean {
|
||||||
|
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const electronApi = this.getElectronApi();
|
||||||
|
const platformHint = `${navigator.userAgent} ${navigator.platform}`;
|
||||||
|
|
||||||
|
return !!electronApi?.prepareLinuxScreenShareAudioRouting
|
||||||
|
&& !!electronApi?.activateLinuxScreenShareAudioRouting
|
||||||
|
&& !!electronApi?.deactivateLinuxScreenShareAudioRouting
|
||||||
|
&& !!electronApi?.startLinuxScreenShareMonitorCapture
|
||||||
|
&& !!electronApi?.stopLinuxScreenShareMonitorCapture
|
||||||
|
&& !!electronApi?.onLinuxScreenShareMonitorAudioChunk
|
||||||
|
&& !!electronApi?.onLinuxScreenShareMonitorAudioEnded
|
||||||
|
&& /linux/i.test(platformHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startWithLinuxElectronAudioRouting(
|
||||||
|
options: ScreenShareStartOptions,
|
||||||
|
preset: ScreenShareQualityPreset
|
||||||
|
): Promise<MediaStream> {
|
||||||
|
const electronApi = this.getRequiredLinuxElectronApi();
|
||||||
|
const routingInfo = await electronApi.prepareLinuxScreenShareAudioRouting();
|
||||||
|
|
||||||
|
this.assertLinuxAudioRoutingReady(routingInfo, 'Linux Electron audio routing is unavailable.');
|
||||||
|
|
||||||
|
let desktopStream: MediaStream | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activation = await electronApi.activateLinuxScreenShareAudioRouting();
|
||||||
|
|
||||||
|
this.assertLinuxAudioRoutingReady(activation, 'Failed to activate Linux Electron audio routing.');
|
||||||
|
|
||||||
|
if (!activation.active) {
|
||||||
|
throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopStream = await this.startWithDisplayMedia({
|
||||||
|
...options,
|
||||||
|
includeSystemAudio: false
|
||||||
|
}, preset);
|
||||||
|
|
||||||
|
const { audioTrack, captureInfo } = await this.startLinuxScreenShareMonitorTrack();
|
||||||
|
const stream = new MediaStream([...desktopStream.getVideoTracks(), audioTrack]);
|
||||||
|
|
||||||
|
desktopStream.getAudioTracks().forEach((track) => track.stop());
|
||||||
|
|
||||||
|
this.linuxElectronAudioRoutingActive = true;
|
||||||
|
this.logger.info('Linux Electron screen-share audio routing enabled', {
|
||||||
|
screenShareMonitorSourceName: captureInfo.sourceName,
|
||||||
|
voiceSinkName: activation.voiceSinkName
|
||||||
|
});
|
||||||
|
|
||||||
|
return stream;
|
||||||
|
} catch (error) {
|
||||||
|
desktopStream?.getTracks().forEach((track) => track.stop());
|
||||||
|
await this.resetLinuxElectronAudioRouting();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleLinuxAudioRoutingReset(): void {
|
||||||
|
if (!this.linuxElectronAudioRoutingActive || this.linuxAudioRoutingResetPromise) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.linuxAudioRoutingResetPromise = this.resetLinuxElectronAudioRouting()
|
||||||
|
.catch((error) => {
|
||||||
|
this.logger.warn('Failed to reset Linux Electron audio routing', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.linuxAudioRoutingResetPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async awaitPendingLinuxAudioRoutingReset(): Promise<void> {
|
||||||
|
if (!this.linuxAudioRoutingResetPromise) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.linuxAudioRoutingResetPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resetLinuxElectronAudioRouting(): Promise<void> {
|
||||||
|
const electronApi = this.getElectronApi();
|
||||||
|
const captureId = this.linuxMonitorAudioPipeline?.captureId;
|
||||||
|
|
||||||
|
this.linuxElectronAudioRoutingActive = false;
|
||||||
|
|
||||||
|
this.disposeLinuxScreenShareMonitorAudioPipeline();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (captureId && electronApi?.stopLinuxScreenShareMonitorCapture) {
|
||||||
|
await electronApi.stopLinuxScreenShareMonitorCapture(captureId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('Failed to stop Linux screen-share monitor capture', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (electronApi?.deactivateLinuxScreenShareAudioRouting) {
|
||||||
|
await electronApi.deactivateLinuxScreenShareAudioRouting();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('Failed to deactivate Linux Electron audio routing', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startLinuxScreenShareMonitorTrack(): Promise<{
|
||||||
|
audioTrack: MediaStreamTrack;
|
||||||
|
captureInfo: LinuxScreenShareMonitorCaptureInfo;
|
||||||
|
}> {
|
||||||
|
const electronApi = this.getElectronApi();
|
||||||
|
|
||||||
|
if (!electronApi?.startLinuxScreenShareMonitorCapture
|
||||||
|
|| !electronApi?.stopLinuxScreenShareMonitorCapture
|
||||||
|
|| !electronApi?.onLinuxScreenShareMonitorAudioChunk
|
||||||
|
|| !electronApi?.onLinuxScreenShareMonitorAudioEnded) {
|
||||||
|
throw new Error('Linux screen-share monitor capture is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedChunksByCaptureId = new Map<string, Uint8Array[]>();
|
||||||
|
const queuedEndedReasons = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
let pipeline: LinuxScreenShareMonitorAudioPipeline | null = null;
|
||||||
|
let captureInfo: LinuxScreenShareMonitorCaptureInfo | null = null;
|
||||||
|
|
||||||
|
const queueChunk = (captureId: string, chunk: Uint8Array): void => {
|
||||||
|
const queuedChunks = queuedChunksByCaptureId.get(captureId) || [];
|
||||||
|
|
||||||
|
queuedChunks.push(this.copyLinuxMonitorAudioBytes(chunk));
|
||||||
|
queuedChunksByCaptureId.set(captureId, queuedChunks);
|
||||||
|
};
|
||||||
|
const onChunk = (payload: LinuxScreenShareMonitorAudioChunkPayload): void => {
|
||||||
|
if (!pipeline || payload.captureId !== pipeline.captureId) {
|
||||||
|
queueChunk(payload.captureId, payload.chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleLinuxScreenShareMonitorAudioChunk(pipeline, payload.chunk);
|
||||||
|
};
|
||||||
|
const onEnded = (payload: LinuxScreenShareMonitorAudioEndedPayload): void => {
|
||||||
|
if (!pipeline || payload.captureId !== pipeline.captureId) {
|
||||||
|
queuedEndedReasons.set(payload.captureId, payload.reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn('Linux screen-share monitor capture ended', payload);
|
||||||
|
|
||||||
|
if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === payload.captureId) {
|
||||||
|
this.stopScreenShare();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const unsubscribeChunk = electronApi.onLinuxScreenShareMonitorAudioChunk(onChunk) as () => void;
|
||||||
|
const unsubscribeEnded = electronApi.onLinuxScreenShareMonitorAudioEnded(onEnded) as () => void;
|
||||||
|
|
||||||
|
try {
|
||||||
|
captureInfo = await electronApi.startLinuxScreenShareMonitorCapture() as LinuxScreenShareMonitorCaptureInfo;
|
||||||
|
|
||||||
|
const audioContext = new AudioContext({ sampleRate: captureInfo.sampleRate });
|
||||||
|
const mediaDestination = audioContext.createMediaStreamDestination();
|
||||||
|
|
||||||
|
await audioContext.resume();
|
||||||
|
|
||||||
|
const audioTrack = mediaDestination.stream.getAudioTracks()[0];
|
||||||
|
|
||||||
|
if (!audioTrack) {
|
||||||
|
throw new Error('Renderer audio pipeline did not produce a screen-share monitor track.');
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline = {
|
||||||
|
audioContext,
|
||||||
|
audioTrack,
|
||||||
|
bitsPerSample: captureInfo.bitsPerSample,
|
||||||
|
captureId: captureInfo.captureId,
|
||||||
|
channelCount: captureInfo.channelCount,
|
||||||
|
mediaDestination,
|
||||||
|
nextStartTime: audioContext.currentTime + 0.05,
|
||||||
|
pendingBytes: new Uint8Array(0),
|
||||||
|
sampleRate: captureInfo.sampleRate,
|
||||||
|
unsubscribeChunk,
|
||||||
|
unsubscribeEnded
|
||||||
|
};
|
||||||
|
|
||||||
|
this.linuxMonitorAudioPipeline = pipeline;
|
||||||
|
const activeCaptureId = captureInfo.captureId;
|
||||||
|
|
||||||
|
audioTrack.addEventListener('ended', () => {
|
||||||
|
if (this.isScreenActive && this.linuxMonitorAudioPipeline?.captureId === activeCaptureId) {
|
||||||
|
this.stopScreenShare();
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
const queuedChunks = queuedChunksByCaptureId.get(captureInfo.captureId) || [];
|
||||||
|
const activePipeline = pipeline;
|
||||||
|
|
||||||
|
queuedChunks.forEach((chunk) => {
|
||||||
|
this.handleLinuxScreenShareMonitorAudioChunk(activePipeline, chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
queuedChunksByCaptureId.delete(captureInfo.captureId);
|
||||||
|
|
||||||
|
if (queuedEndedReasons.has(captureInfo.captureId)) {
|
||||||
|
throw new Error(queuedEndedReasons.get(captureInfo.captureId)
|
||||||
|
|| 'Linux screen-share monitor capture ended before audio initialisation completed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioTrack,
|
||||||
|
captureInfo
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (pipeline) {
|
||||||
|
this.disposeLinuxScreenShareMonitorAudioPipeline(pipeline.captureId);
|
||||||
|
} else {
|
||||||
|
unsubscribeChunk();
|
||||||
|
unsubscribeEnded();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await electronApi.stopLinuxScreenShareMonitorCapture(captureInfo?.captureId);
|
||||||
|
} catch (stopError) {
|
||||||
|
this.logger.warn('Failed to stop Linux screen-share monitor capture after startup failure', stopError);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private disposeLinuxScreenShareMonitorAudioPipeline(captureId?: string): void {
|
||||||
|
if (!this.linuxMonitorAudioPipeline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (captureId && captureId !== this.linuxMonitorAudioPipeline.captureId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipeline = this.linuxMonitorAudioPipeline;
|
||||||
|
|
||||||
|
this.linuxMonitorAudioPipeline = null;
|
||||||
|
pipeline.unsubscribeChunk();
|
||||||
|
pipeline.unsubscribeEnded();
|
||||||
|
pipeline.audioTrack.stop();
|
||||||
|
pipeline.pendingBytes = new Uint8Array(0);
|
||||||
|
|
||||||
|
void pipeline.audioContext.close().catch((error) => {
|
||||||
|
this.logger.warn('Failed to close Linux screen-share monitor audio context', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleLinuxScreenShareMonitorAudioChunk(
|
||||||
|
pipeline: LinuxScreenShareMonitorAudioPipeline,
|
||||||
|
chunk: Uint8Array
|
||||||
|
): void {
|
||||||
|
if (pipeline.bitsPerSample !== 16) {
|
||||||
|
this.logger.warn('Unsupported Linux screen-share monitor capture sample size', {
|
||||||
|
bitsPerSample: pipeline.bitsPerSample,
|
||||||
|
captureId: pipeline.captureId
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytesPerSample = pipeline.bitsPerSample / 8;
|
||||||
|
const bytesPerFrame = bytesPerSample * pipeline.channelCount;
|
||||||
|
|
||||||
|
if (!Number.isFinite(bytesPerFrame) || bytesPerFrame <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedBytes = this.concatLinuxMonitorAudioBytes(pipeline.pendingBytes, chunk);
|
||||||
|
const completeByteLength = combinedBytes.byteLength - (combinedBytes.byteLength % bytesPerFrame);
|
||||||
|
|
||||||
|
if (completeByteLength <= 0) {
|
||||||
|
pipeline.pendingBytes = combinedBytes;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completeBytes = combinedBytes.subarray(0, completeByteLength);
|
||||||
|
|
||||||
|
pipeline.pendingBytes = this.copyLinuxMonitorAudioBytes(combinedBytes.subarray(completeByteLength));
|
||||||
|
|
||||||
|
if (pipeline.audioContext.state !== 'running') {
|
||||||
|
void pipeline.audioContext.resume().catch((error) => {
|
||||||
|
this.logger.warn('Failed to resume Linux screen-share monitor audio context', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameCount = completeByteLength / bytesPerFrame;
|
||||||
|
const audioBuffer = this.createLinuxScreenShareAudioBuffer(pipeline, completeBytes, frameCount);
|
||||||
|
const source = pipeline.audioContext.createBufferSource();
|
||||||
|
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(pipeline.mediaDestination);
|
||||||
|
|
||||||
|
source.onended = () => {
|
||||||
|
source.disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = pipeline.audioContext.currentTime;
|
||||||
|
const startTime = Math.max(pipeline.nextStartTime, now + 0.02);
|
||||||
|
|
||||||
|
source.start(startTime);
|
||||||
|
pipeline.nextStartTime = startTime + audioBuffer.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createLinuxScreenShareAudioBuffer(
|
||||||
|
pipeline: LinuxScreenShareMonitorAudioPipeline,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
frameCount: number
|
||||||
|
): AudioBuffer {
|
||||||
|
const audioBuffer = pipeline.audioContext.createBuffer(pipeline.channelCount, frameCount, pipeline.sampleRate);
|
||||||
|
const sampleData = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
const channelData = Array.from({ length: pipeline.channelCount }, (_, channelIndex) => audioBuffer.getChannelData(channelIndex));
|
||||||
|
const bytesPerSample = pipeline.bitsPerSample / 8;
|
||||||
|
const bytesPerFrame = bytesPerSample * pipeline.channelCount;
|
||||||
|
|
||||||
|
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
||||||
|
const frameOffset = frameIndex * bytesPerFrame;
|
||||||
|
|
||||||
|
for (let channelIndex = 0; channelIndex < pipeline.channelCount; channelIndex += 1) {
|
||||||
|
const sampleOffset = frameOffset + (channelIndex * bytesPerSample);
|
||||||
|
|
||||||
|
channelData[channelIndex][frameIndex] = sampleData.getInt16(sampleOffset, true) / 32768;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private concatLinuxMonitorAudioBytes(first: Uint8Array, second: Uint8Array): Uint8Array {
|
||||||
|
if (first.byteLength === 0) {
|
||||||
|
return this.copyLinuxMonitorAudioBytes(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (second.byteLength === 0) {
|
||||||
|
return this.copyLinuxMonitorAudioBytes(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = new Uint8Array(first.byteLength + second.byteLength);
|
||||||
|
|
||||||
|
combined.set(first, 0);
|
||||||
|
combined.set(second, first.byteLength);
|
||||||
|
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private copyLinuxMonitorAudioBytes(bytes: Uint8Array): Uint8Array {
|
||||||
|
return bytes.byteLength > 0 ? new Uint8Array(bytes) : new Uint8Array(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDisplayMediaConstraints(
|
||||||
|
options: ScreenShareStartOptions,
|
||||||
|
preset: ScreenShareQualityPreset
|
||||||
|
): DisplayMediaStreamOptions {
|
||||||
|
const supportedConstraints = navigator.mediaDevices?.getSupportedConstraints?.() as Record<string, boolean> | undefined;
|
||||||
|
const audioConstraints: Record<string, unknown> | false = options.includeSystemAudio
|
||||||
|
? {
|
||||||
|
echoCancellation: false,
|
||||||
|
noiseSuppression: false,
|
||||||
|
autoGainControl: false
|
||||||
|
}
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (audioConstraints && supportedConstraints?.['restrictOwnAudio']) {
|
||||||
|
audioConstraints['restrictOwnAudio'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioConstraints && supportedConstraints?.['suppressLocalAudioPlayback']) {
|
||||||
|
audioConstraints['suppressLocalAudioPlayback'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
video: {
|
||||||
|
width: { ideal: preset.width, max: preset.width },
|
||||||
|
height: { ideal: preset.height, max: preset.height },
|
||||||
|
frameRate: { ideal: preset.frameRate, max: preset.frameRate }
|
||||||
|
},
|
||||||
|
audio: audioConstraints,
|
||||||
|
monitorTypeSurfaces: 'include',
|
||||||
|
selfBrowserSurface: 'exclude',
|
||||||
|
surfaceSwitching: 'include',
|
||||||
|
systemAudio: options.includeSystemAudio ? 'include' : 'exclude'
|
||||||
|
} as DisplayMediaStreamOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildElectronDesktopConstraints(
|
||||||
|
sourceId: string,
|
||||||
|
options: ScreenShareStartOptions,
|
||||||
|
preset: ScreenShareQualityPreset
|
||||||
|
): ElectronDesktopMediaStreamConstraints {
|
||||||
|
const electronConstraints: ElectronDesktopMediaStreamConstraints = {
|
||||||
|
video: {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: 'desktop',
|
||||||
|
chromeMediaSourceId: sourceId,
|
||||||
|
maxWidth: preset.width,
|
||||||
|
maxHeight: preset.height,
|
||||||
|
maxFrameRate: preset.frameRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.includeSystemAudio) {
|
||||||
|
electronConstraints.audio = {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: 'desktop',
|
||||||
|
chromeMediaSourceId: sourceId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
electronConstraints.audio = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return electronConstraints;
|
||||||
|
}
|
||||||
|
|
||||||
private configureScreenStream(preset: ScreenShareQualityPreset): void {
|
private configureScreenStream(preset: ScreenShareQualityPreset): void {
|
||||||
const screenVideoTrack = this.activeScreenStream?.getVideoTracks()[0];
|
const screenVideoTrack = this.activeScreenStream?.getVideoTracks()[0];
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ export interface PeerData {
|
|||||||
screenVideoSender?: RTCRtpSender;
|
screenVideoSender?: RTCRtpSender;
|
||||||
/** The RTP sender carrying the screen-share audio track. */
|
/** The RTP sender carrying the screen-share audio track. */
|
||||||
screenAudioSender?: RTCRtpSender;
|
screenAudioSender?: RTCRtpSender;
|
||||||
/** Known remote stream ids that carry the peer's voice audio. */
|
|
||||||
remoteVoiceStreamIds: Set<string>;
|
|
||||||
/** Known remote stream ids that carry the peer's screen-share audio/video. */
|
|
||||||
remoteScreenShareStreamIds: Set<string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Credentials cached for automatic re-identification after reconnect. */
|
/** Credentials cached for automatic re-identification after reconnect. */
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideLogIn } from '@ng-icons/lucide';
|
import { lucideLogIn } from '@ng-icons/lucide';
|
||||||
@@ -42,7 +42,6 @@ export class LoginComponent {
|
|||||||
|
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
/** TrackBy function for server list rendering. */
|
/** TrackBy function for server list rendering. */
|
||||||
@@ -73,14 +72,6 @@ export class LoginComponent {
|
|||||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
|
||||||
|
|
||||||
if (returnUrl?.startsWith('/')) {
|
|
||||||
this.router.navigateByUrl(returnUrl);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
@@ -91,10 +82,6 @@ export class LoginComponent {
|
|||||||
|
|
||||||
/** Navigate to the registration page. */
|
/** Navigate to the registration page. */
|
||||||
goRegister() {
|
goRegister() {
|
||||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
this.router.navigate(['/register']);
|
||||||
|
|
||||||
this.router.navigate(['/register'], {
|
|
||||||
queryParams: returnUrl ? { returnUrl } : undefined
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideUserPlus } from '@ng-icons/lucide';
|
import { lucideUserPlus } from '@ng-icons/lucide';
|
||||||
@@ -43,7 +43,6 @@ export class RegisterComponent {
|
|||||||
|
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
/** TrackBy function for server list rendering. */
|
/** TrackBy function for server list rendering. */
|
||||||
@@ -75,14 +74,6 @@ export class RegisterComponent {
|
|||||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
|
||||||
|
|
||||||
if (returnUrl?.startsWith('/')) {
|
|
||||||
this.router.navigateByUrl(returnUrl);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
@@ -93,10 +84,6 @@ export class RegisterComponent {
|
|||||||
|
|
||||||
/** Navigate to the login page. */
|
/** Navigate to the login page. */
|
||||||
goLogin() {
|
goLogin() {
|
||||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
this.router.navigate(['/login']);
|
||||||
|
|
||||||
this.router.navigate(['/login'], {
|
|
||||||
queryParams: returnUrl ? { returnUrl } : undefined
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export class ChatMessagesComponent {
|
|||||||
|
|
||||||
handleTypingStarted(): void {
|
handleTypingStarted(): void {
|
||||||
try {
|
try {
|
||||||
this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId });
|
this.webrtc.sendRawMessage({ type: 'typing' });
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
DestroyRef,
|
DestroyRef
|
||||||
effect
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
|
||||||
import {
|
import {
|
||||||
merge,
|
merge,
|
||||||
interval,
|
interval,
|
||||||
@@ -26,7 +23,6 @@ interface TypingSignalingMessage {
|
|||||||
type: string;
|
type: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
oderId: string;
|
oderId: string;
|
||||||
serverId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -40,9 +36,6 @@ interface TypingSignalingMessage {
|
|||||||
})
|
})
|
||||||
export class TypingIndicatorComponent {
|
export class TypingIndicatorComponent {
|
||||||
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
|
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
||||||
private lastRoomId: string | null = null;
|
|
||||||
|
|
||||||
typingDisplay = signal<string[]>([]);
|
typingDisplay = signal<string[]>([]);
|
||||||
typingOthersCount = signal<number>(0);
|
typingOthersCount = signal<number>(0);
|
||||||
@@ -54,10 +47,8 @@ export class TypingIndicatorComponent {
|
|||||||
filter((msg): msg is TypingSignalingMessage =>
|
filter((msg): msg is TypingSignalingMessage =>
|
||||||
msg?.type === 'user_typing' &&
|
msg?.type === 'user_typing' &&
|
||||||
typeof msg.displayName === 'string' &&
|
typeof msg.displayName === 'string' &&
|
||||||
typeof msg.oderId === 'string' &&
|
typeof msg.oderId === 'string'
|
||||||
typeof msg.serverId === 'string'
|
|
||||||
),
|
),
|
||||||
filter((msg) => msg.serverId === this.currentRoom()?.id),
|
|
||||||
tap((msg) => {
|
tap((msg) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -86,17 +77,6 @@ export class TypingIndicatorComponent {
|
|||||||
merge(typing$, purge$)
|
merge(typing$, purge$)
|
||||||
.pipe(takeUntilDestroyed(destroyRef))
|
.pipe(takeUntilDestroyed(destroyRef))
|
||||||
.subscribe(() => this.recomputeDisplay());
|
.subscribe(() => this.recomputeDisplay());
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
const roomId = this.currentRoom()?.id ?? null;
|
|
||||||
|
|
||||||
if (roomId === this.lastRoomId)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.lastRoomId = roomId;
|
|
||||||
this.typingMap.clear();
|
|
||||||
this.recomputeDisplay();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private recomputeDisplay(): void {
|
private recomputeDisplay(): void {
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
<div class="min-h-full bg-background px-4 py-8 sm:px-6 lg:px-8">
|
|
||||||
<div class="mx-auto flex min-h-[calc(100vh-8rem)] max-w-4xl items-center justify-center">
|
|
||||||
<div class="w-full overflow-hidden rounded-3xl border border-border bg-card/90 shadow-2xl backdrop-blur">
|
|
||||||
<div class="border-b border-border bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 px-6 py-8 sm:px-10">
|
|
||||||
<div
|
|
||||||
class="inline-flex items-center rounded-full border border-border bg-secondary/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.25em] text-muted-foreground"
|
|
||||||
>
|
|
||||||
Invite link
|
|
||||||
</div>
|
|
||||||
<h1 class="mt-4 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
|
|
||||||
@if (invite()) {
|
|
||||||
Join {{ invite()!.server.name }}
|
|
||||||
} @else {
|
|
||||||
Toju server invite
|
|
||||||
}
|
|
||||||
</h1>
|
|
||||||
<p class="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
|
|
||||||
@switch (status()) {
|
|
||||||
@case ('redirecting') {
|
|
||||||
Sign in to continue with this invite.
|
|
||||||
}
|
|
||||||
@case ('joining') {
|
|
||||||
We are connecting you to the invited server.
|
|
||||||
}
|
|
||||||
@case ('error') {
|
|
||||||
This invite could not be completed automatically.
|
|
||||||
}
|
|
||||||
@default {
|
|
||||||
Loading invite details and preparing the correct signal server.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 px-6 py-8 sm:px-10 lg:grid-cols-[1.2fr,0.8fr]">
|
|
||||||
<section class="space-y-4">
|
|
||||||
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
|
||||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Status</h2>
|
|
||||||
<p class="mt-3 text-lg font-medium text-foreground">{{ message() }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (invite()) {
|
|
||||||
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
|
||||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Server</h2>
|
|
||||||
<p class="mt-3 text-xl font-semibold text-foreground">{{ invite()!.server.name }}</p>
|
|
||||||
@if (invite()!.server.description) {
|
|
||||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ invite()!.server.description }}</p>
|
|
||||||
}
|
|
||||||
<div class="mt-4 flex flex-wrap gap-2 text-xs">
|
|
||||||
@if (invite()!.server.isPrivate) {
|
|
||||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Private</span>
|
|
||||||
}
|
|
||||||
@if (invite()!.server.hasPassword) {
|
|
||||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Password bypassed by invite</span>
|
|
||||||
}
|
|
||||||
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-primary"> Expires {{ invite()!.expiresAt | date: 'medium' }} </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside class="space-y-4">
|
|
||||||
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
|
||||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">What happens next</h2>
|
|
||||||
<ul class="mt-4 space-y-3 text-sm leading-6 text-muted-foreground">
|
|
||||||
<li>• The linked signal server is added to your configured server list if needed.</li>
|
|
||||||
<li>• Invite links bypass private and password restrictions.</li>
|
|
||||||
<li>• Banned users still cannot join through invites.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (status() === 'error') {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="goToSearch()"
|
|
||||||
class="inline-flex w-full items-center justify-center rounded-2xl bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Back to server search
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
OnInit,
|
|
||||||
inject,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
|
||||||
import { UsersActions } from '../../store/users/users.actions';
|
|
||||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
|
||||||
import { ServerDirectoryService, ServerInviteInfo } from '../../core/services/server-directory.service';
|
|
||||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
|
||||||
import { User } from '../../core/models/index';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-invite',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule],
|
|
||||||
templateUrl: './invite.component.html'
|
|
||||||
})
|
|
||||||
export class InviteComponent implements OnInit {
|
|
||||||
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
|
|
||||||
readonly invite = signal<ServerInviteInfo | null>(null);
|
|
||||||
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
|
|
||||||
readonly message = signal('Loading invite…');
|
|
||||||
|
|
||||||
private readonly route = inject(ActivatedRoute);
|
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly serverDirectory = inject(ServerDirectoryService);
|
|
||||||
private readonly databaseService = inject(DatabaseService);
|
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
|
||||||
const inviteContext = this.resolveInviteContext();
|
|
||||||
|
|
||||||
if (!inviteContext) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
|
||||||
|
|
||||||
if (!currentUserId) {
|
|
||||||
await this.redirectToLogin();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.joinInvite(inviteContext, currentUserId);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.applyInviteError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
goToSearch(): void {
|
|
||||||
this.router.navigate(['/search']).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildEndpointName(sourceUrl: string): string {
|
|
||||||
try {
|
|
||||||
const url = new URL(sourceUrl);
|
|
||||||
|
|
||||||
return url.hostname;
|
|
||||||
} catch {
|
|
||||||
return 'Signal Server';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyInviteError(error: unknown): void {
|
|
||||||
const inviteError = error as {
|
|
||||||
error?: { error?: string; errorCode?: string };
|
|
||||||
};
|
|
||||||
const errorCode = inviteError?.error?.errorCode;
|
|
||||||
const fallbackMessage = inviteError?.error?.error || 'Unable to accept this invite.';
|
|
||||||
|
|
||||||
this.status.set('error');
|
|
||||||
|
|
||||||
if (errorCode === 'BANNED') {
|
|
||||||
this.message.set('You are banned from this server and cannot accept this invite.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorCode === 'INVITE_EXPIRED') {
|
|
||||||
this.message.set('This invite has expired. Ask for a fresh invite link.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.message.set(fallbackMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async hydrateCurrentUser(): Promise<User | null> {
|
|
||||||
const currentUser = this.currentUser();
|
|
||||||
|
|
||||||
if (currentUser) {
|
|
||||||
return currentUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedUser = await this.databaseService.getCurrentUser();
|
|
||||||
|
|
||||||
if (storedUser) {
|
|
||||||
this.store.dispatch(UsersActions.setCurrentUser({ user: storedUser }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return storedUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async joinInvite(
|
|
||||||
context: { endpoint: { id: string; name: string }; inviteId: string; sourceUrl: string },
|
|
||||||
currentUserId: string
|
|
||||||
): Promise<void> {
|
|
||||||
const invite = await firstValueFrom(this.serverDirectory.getInvite(context.inviteId, {
|
|
||||||
sourceId: context.endpoint.id,
|
|
||||||
sourceUrl: context.sourceUrl
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.invite.set(invite);
|
|
||||||
this.status.set('joining');
|
|
||||||
this.message.set(`Joining ${invite.server.name}…`);
|
|
||||||
|
|
||||||
const currentUser = await this.hydrateCurrentUser();
|
|
||||||
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
|
|
||||||
roomId: invite.server.id,
|
|
||||||
userId: currentUserId,
|
|
||||||
userPublicKey: currentUser?.oderId || currentUserId,
|
|
||||||
displayName: currentUser?.displayName || 'Anonymous',
|
|
||||||
inviteId: context.inviteId
|
|
||||||
}, {
|
|
||||||
sourceId: context.endpoint.id,
|
|
||||||
sourceUrl: context.sourceUrl
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.store.dispatch(
|
|
||||||
RoomsActions.joinRoom({
|
|
||||||
roomId: joinResponse.server.id,
|
|
||||||
serverInfo: {
|
|
||||||
...joinResponse.server,
|
|
||||||
sourceId: context.endpoint.id,
|
|
||||||
sourceName: context.endpoint.name,
|
|
||||||
sourceUrl: context.sourceUrl
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async redirectToLogin(): Promise<void> {
|
|
||||||
this.status.set('redirecting');
|
|
||||||
this.message.set('Redirecting to login…');
|
|
||||||
|
|
||||||
await this.router.navigate(['/login'], {
|
|
||||||
queryParams: {
|
|
||||||
returnUrl: this.router.url
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveInviteContext(): {
|
|
||||||
endpoint: { id: string; name: string };
|
|
||||||
inviteId: string;
|
|
||||||
sourceUrl: string;
|
|
||||||
} | null {
|
|
||||||
const inviteId = this.route.snapshot.paramMap.get('inviteId')?.trim() || '';
|
|
||||||
const sourceUrl = this.route.snapshot.queryParamMap.get('server')?.trim() || '';
|
|
||||||
|
|
||||||
if (!inviteId || !sourceUrl) {
|
|
||||||
this.status.set('error');
|
|
||||||
this.message.set('This invite link is missing required server information.');
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
|
||||||
name: this.buildEndpointName(sourceUrl),
|
|
||||||
url: sourceUrl
|
|
||||||
}, {
|
|
||||||
setActive: !localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID)
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
endpoint: {
|
|
||||||
id: endpoint.id,
|
|
||||||
name: endpoint.name
|
|
||||||
},
|
|
||||||
inviteId,
|
|
||||||
sourceUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -117,17 +117,6 @@
|
|||||||
name="lucideLock"
|
name="lucideLock"
|
||||||
class="w-4 h-4 text-muted-foreground"
|
class="w-4 h-4 text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
|
||||||
>Private</span
|
|
||||||
>
|
|
||||||
} @else if (server.hasPassword) {
|
|
||||||
<ng-icon
|
|
||||||
name="lucideLock"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
|
||||||
>Password</span
|
|
||||||
>
|
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideGlobe"
|
name="lucideGlobe"
|
||||||
@@ -164,9 +153,6 @@
|
|||||||
<div class="text-muted-foreground">
|
<div class="text-muted-foreground">
|
||||||
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
|
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@if (server.hasPassword && !server.isPrivate && !isServerMarkedBanned(server)) {
|
|
||||||
<div class="text-muted-foreground">Access: <span class="text-foreground/80">Password required</span></div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -174,9 +160,9 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (joinErrorMessage() || error()) {
|
@if (error()) {
|
||||||
<div class="p-4 bg-destructive/10 border-t border-destructive">
|
<div class="p-4 bg-destructive/10 border-t border-destructive">
|
||||||
<p class="text-sm text-destructive">{{ joinErrorMessage() || error() }}</p>
|
<p class="text-sm text-destructive">{{ error() }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -195,41 +181,6 @@
|
|||||||
</app-confirm-dialog>
|
</app-confirm-dialog>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (showPasswordDialog() && passwordPromptServer()) {
|
|
||||||
<app-confirm-dialog
|
|
||||||
title="Password required"
|
|
||||||
confirmLabel="Join server"
|
|
||||||
cancelLabel="Cancel"
|
|
||||||
[widthClass]="'w-[420px] max-w-[92vw]'"
|
|
||||||
(confirmed)="confirmPasswordJoin()"
|
|
||||||
(cancelled)="closePasswordDialog()"
|
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<p>Enter the password to join {{ passwordPromptServer()!.name }}.</p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="join-server-password"
|
|
||||||
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
|
||||||
>
|
|
||||||
Server password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="join-server-password"
|
|
||||||
type="password"
|
|
||||||
[(ngModel)]="joinPassword"
|
|
||||||
placeholder="Enter password"
|
|
||||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (joinPasswordError()) {
|
|
||||||
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</app-confirm-dialog>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Create Server Dialog -->
|
<!-- Create Server Dialog -->
|
||||||
@if (showCreateDialog()) {
|
@if (showCreateDialog()) {
|
||||||
<div
|
<div
|
||||||
@@ -298,24 +249,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="create-server-signal-endpoint"
|
|
||||||
class="block text-sm font-medium text-foreground mb-1"
|
|
||||||
>Signal Server Endpoint</label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
id="create-server-signal-endpoint"
|
|
||||||
[(ngModel)]="newServerSourceId"
|
|
||||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
>
|
|
||||||
@for (endpoint of activeEndpoints(); track endpoint.id) {
|
|
||||||
<option [value]="endpoint.id">{{ endpoint.name }} ({{ endpoint.url }})</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">This endpoint handles all signaling for this chat server.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -330,21 +263,22 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (newServerPrivate()) {
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="create-server-password"
|
for="create-server-password"
|
||||||
class="block text-sm font-medium text-foreground mb-1"
|
class="block text-sm font-medium text-foreground mb-1"
|
||||||
>Password (optional)</label
|
>Password</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
[(ngModel)]="newServerPassword"
|
[(ngModel)]="newServerPassword"
|
||||||
placeholder="Leave blank to allow joining without a password"
|
placeholder="Enter password"
|
||||||
id="create-server-password"
|
id="create-server-password"
|
||||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">Users who already joined keep access even if you change the password later.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 mt-6">
|
<div class="flex gap-3 mt-6">
|
||||||
@@ -357,7 +291,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="createServer()"
|
(click)="createServer()"
|
||||||
[disabled]="!newServerName() || !newServerSourceId"
|
[disabled]="!newServerName()"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { Store } from '@ngrx/store';
|
|||||||
import {
|
import {
|
||||||
debounceTime,
|
debounceTime,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
firstValueFrom,
|
|
||||||
Subject
|
Subject
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
@@ -40,7 +39,6 @@ import {
|
|||||||
} from '../../core/models/index';
|
} from '../../core/models/index';
|
||||||
import { SettingsModalService } from '../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../core/services/settings-modal.service';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
|
||||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
import { ConfirmDialogComponent } from '../../shared';
|
import { ConfirmDialogComponent } from '../../shared';
|
||||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||||
@@ -75,7 +73,6 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private settingsModal = inject(SettingsModalService);
|
private settingsModal = inject(SettingsModalService);
|
||||||
private db = inject(DatabaseService);
|
private db = inject(DatabaseService);
|
||||||
private serverDirectory = inject(ServerDirectoryService);
|
|
||||||
private searchSubject = new Subject<string>();
|
private searchSubject = new Subject<string>();
|
||||||
private banLookupRequestVersion = 0;
|
private banLookupRequestVersion = 0;
|
||||||
|
|
||||||
@@ -85,15 +82,9 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
error = this.store.selectSignal(selectRoomsError);
|
error = this.store.selectSignal(selectRoomsError);
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
activeEndpoints = this.serverDirectory.activeServers;
|
|
||||||
bannedServerLookup = signal<Record<string, boolean>>({});
|
bannedServerLookup = signal<Record<string, boolean>>({});
|
||||||
bannedServerName = signal('');
|
bannedServerName = signal('');
|
||||||
showBannedDialog = signal(false);
|
showBannedDialog = signal(false);
|
||||||
showPasswordDialog = signal(false);
|
|
||||||
passwordPromptServer = signal<ServerInfo | null>(null);
|
|
||||||
joinPassword = signal('');
|
|
||||||
joinPasswordError = signal<string | null>(null);
|
|
||||||
joinErrorMessage = signal<string | null>(null);
|
|
||||||
|
|
||||||
// Create dialog state
|
// Create dialog state
|
||||||
showCreateDialog = signal(false);
|
showCreateDialog = signal(false);
|
||||||
@@ -102,7 +93,6 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
newServerTopic = signal('');
|
newServerTopic = signal('');
|
||||||
newServerPrivate = signal(false);
|
newServerPrivate = signal(false);
|
||||||
newServerPassword = signal('');
|
newServerPassword = signal('');
|
||||||
newServerSourceId = '';
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -145,12 +135,20 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.attemptJoinServer(server);
|
this.store.dispatch(
|
||||||
|
RoomsActions.joinRoom({
|
||||||
|
roomId: server.id,
|
||||||
|
serverInfo: {
|
||||||
|
name: server.name,
|
||||||
|
description: server.description,
|
||||||
|
hostName: server.sourceName || server.hostName
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the create-server dialog. */
|
/** Open the create-server dialog. */
|
||||||
openCreateDialog(): void {
|
openCreateDialog(): void {
|
||||||
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
|
|
||||||
this.showCreateDialog.set(true);
|
this.showCreateDialog.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,8 +176,7 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
description: this.newServerDescription() || undefined,
|
description: this.newServerDescription() || undefined,
|
||||||
topic: this.newServerTopic() || undefined,
|
topic: this.newServerTopic() || undefined,
|
||||||
isPrivate: this.newServerPrivate(),
|
isPrivate: this.newServerPrivate(),
|
||||||
password: this.newServerPassword().trim() || undefined,
|
password: this.newServerPrivate() ? this.newServerPassword() : undefined
|
||||||
sourceId: this.newServerSourceId || undefined
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -201,22 +198,6 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
this.bannedServerName.set('');
|
this.bannedServerName.set('');
|
||||||
}
|
}
|
||||||
|
|
||||||
closePasswordDialog(): void {
|
|
||||||
this.showPasswordDialog.set(false);
|
|
||||||
this.passwordPromptServer.set(null);
|
|
||||||
this.joinPassword.set('');
|
|
||||||
this.joinPasswordError.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async confirmPasswordJoin(): Promise<void> {
|
|
||||||
const server = this.passwordPromptServer();
|
|
||||||
|
|
||||||
if (!server)
|
|
||||||
return;
|
|
||||||
|
|
||||||
await this.attemptJoinServer(server, this.joinPassword());
|
|
||||||
}
|
|
||||||
|
|
||||||
isServerMarkedBanned(server: ServerInfo): boolean {
|
isServerMarkedBanned(server: ServerInfo): boolean {
|
||||||
return !!this.bannedServerLookup()[server.id];
|
return !!this.bannedServerLookup()[server.id];
|
||||||
}
|
}
|
||||||
@@ -242,72 +223,12 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
hostName: room.hostId || 'Unknown',
|
hostName: room.hostId || 'Unknown',
|
||||||
userCount: room.userCount ?? 0,
|
userCount: room.userCount ?? 0,
|
||||||
maxUsers: room.maxUsers ?? 50,
|
maxUsers: room.maxUsers ?? 50,
|
||||||
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
isPrivate: !!room.password,
|
||||||
isPrivate: room.isPrivate,
|
|
||||||
createdAt: room.createdAt,
|
createdAt: room.createdAt,
|
||||||
ownerId: room.hostId,
|
ownerId: room.hostId
|
||||||
sourceId: room.sourceId,
|
|
||||||
sourceName: room.sourceName,
|
|
||||||
sourceUrl: room.sourceUrl
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async attemptJoinServer(server: ServerInfo, password?: string): Promise<void> {
|
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
|
||||||
const currentUser = this.currentUser();
|
|
||||||
|
|
||||||
if (!currentUserId) {
|
|
||||||
this.router.navigate(['/login']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.joinErrorMessage.set(null);
|
|
||||||
this.joinPasswordError.set(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await firstValueFrom(this.serverDirectory.requestJoin({
|
|
||||||
roomId: server.id,
|
|
||||||
userId: currentUserId,
|
|
||||||
userPublicKey: currentUser?.oderId || currentUserId,
|
|
||||||
displayName: currentUser?.displayName || 'Anonymous',
|
|
||||||
password: password?.trim() || undefined
|
|
||||||
}, {
|
|
||||||
sourceId: server.sourceId,
|
|
||||||
sourceUrl: server.sourceUrl
|
|
||||||
}));
|
|
||||||
const resolvedServer = response.server ?? server;
|
|
||||||
|
|
||||||
this.closePasswordDialog();
|
|
||||||
this.store.dispatch(
|
|
||||||
RoomsActions.joinRoom({
|
|
||||||
roomId: resolvedServer.id,
|
|
||||||
serverInfo: resolvedServer
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const serverError = error as {
|
|
||||||
error?: { error?: string; errorCode?: string };
|
|
||||||
};
|
|
||||||
const errorCode = serverError?.error?.errorCode;
|
|
||||||
const message = serverError?.error?.error || 'Failed to join server';
|
|
||||||
|
|
||||||
if (errorCode === 'PASSWORD_REQUIRED') {
|
|
||||||
this.passwordPromptServer.set(server);
|
|
||||||
this.showPasswordDialog.set(true);
|
|
||||||
this.joinPasswordError.set(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorCode === 'BANNED') {
|
|
||||||
this.bannedServerName.set(server.name);
|
|
||||||
this.showBannedDialog.set(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.joinErrorMessage.set(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
||||||
const requestVersion = ++this.banLookupRequestVersion;
|
const requestVersion = ++this.banLookupRequestVersion;
|
||||||
|
|
||||||
@@ -350,6 +271,5 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
this.newServerTopic.set('');
|
this.newServerTopic.set('');
|
||||||
this.newServerPrivate.set(false);
|
this.newServerPrivate.set(false);
|
||||||
this.newServerPassword.set('');
|
this.newServerPassword.set('');
|
||||||
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,41 +70,6 @@
|
|||||||
</app-confirm-dialog>
|
</app-confirm-dialog>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (showPasswordDialog() && passwordPromptRoom()) {
|
|
||||||
<app-confirm-dialog
|
|
||||||
title="Password required"
|
|
||||||
confirmLabel="Join server"
|
|
||||||
cancelLabel="Cancel"
|
|
||||||
[widthClass]="'w-[420px] max-w-[92vw]'"
|
|
||||||
(confirmed)="confirmPasswordJoin()"
|
|
||||||
(cancelled)="closePasswordDialog()"
|
|
||||||
>
|
|
||||||
<div class="space-y-3 text-left">
|
|
||||||
<p>Enter the password to rejoin {{ passwordPromptRoom()!.name }}.</p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="rail-join-password"
|
|
||||||
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
|
||||||
>
|
|
||||||
Server password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="rail-join-password"
|
|
||||||
type="password"
|
|
||||||
[(ngModel)]="joinPassword"
|
|
||||||
placeholder="Enter password"
|
|
||||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (joinPasswordError()) {
|
|
||||||
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</app-confirm-dialog>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (showLeaveConfirm() && contextRoom()) {
|
@if (showLeaveConfirm() && contextRoom()) {
|
||||||
<app-leave-server-dialog
|
<app-leave-server-dialog
|
||||||
[room]="contextRoom()!"
|
[room]="contextRoom()!"
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ import {
|
|||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucidePlus } from '@ng-icons/lucide';
|
import { lucidePlus } from '@ng-icons/lucide';
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
|
|
||||||
import { Room, User } from '../../core/models/index';
|
import { Room, User } from '../../core/models/index';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
@@ -21,7 +19,6 @@ import { VoiceSessionService } from '../../core/services/voice-session.service';
|
|||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
|
||||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||||
import {
|
import {
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
@@ -34,7 +31,6 @@ import {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
|
||||||
NgIcon,
|
NgIcon,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
ContextMenuComponent,
|
ContextMenuComponent,
|
||||||
@@ -50,7 +46,6 @@ export class ServersRailComponent {
|
|||||||
private voiceSession = inject(VoiceSessionService);
|
private voiceSession = inject(VoiceSessionService);
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
private db = inject(DatabaseService);
|
private db = inject(DatabaseService);
|
||||||
private serverDirectory = inject(ServerDirectoryService);
|
|
||||||
private banLookupRequestVersion = 0;
|
private banLookupRequestVersion = 0;
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
@@ -64,10 +59,6 @@ export class ServersRailComponent {
|
|||||||
bannedRoomLookup = signal<Record<string, boolean>>({});
|
bannedRoomLookup = signal<Record<string, boolean>>({});
|
||||||
bannedServerName = signal('');
|
bannedServerName = signal('');
|
||||||
showBannedDialog = signal(false);
|
showBannedDialog = signal(false);
|
||||||
showPasswordDialog = signal(false);
|
|
||||||
passwordPromptRoom = signal<Room | null>(null);
|
|
||||||
joinPassword = signal('');
|
|
||||||
joinPasswordError = signal<string | null>(null);
|
|
||||||
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)));
|
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)));
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -114,20 +105,27 @@ export class ServersRailComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomWsUrl = this.serverDirectory.getWebSocketUrl({
|
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||||
sourceId: room.sourceId,
|
|
||||||
sourceUrl: room.sourceUrl
|
|
||||||
});
|
|
||||||
const currentWsUrl = this.webrtc.getCurrentSignalingUrl();
|
|
||||||
|
|
||||||
this.prepareVoiceContext(room);
|
if (voiceServerId && voiceServerId !== room.id) {
|
||||||
|
this.voiceSession.setViewingVoiceServer(false);
|
||||||
|
} else if (voiceServerId === room.id) {
|
||||||
|
this.voiceSession.setViewingVoiceServer(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.webrtc.hasJoinedServer(room.id) && roomWsUrl === currentWsUrl) {
|
if (this.webrtc.hasJoinedServer(room.id)) {
|
||||||
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
this.store.dispatch(RoomsActions.viewServer({ room }));
|
||||||
this.store.dispatch(RoomsActions.viewServer({ room,
|
|
||||||
skipBanCheck: true }));
|
|
||||||
} else {
|
} else {
|
||||||
await this.attemptJoinRoom(room);
|
this.store.dispatch(
|
||||||
|
RoomsActions.joinRoom({
|
||||||
|
roomId: room.id,
|
||||||
|
serverInfo: {
|
||||||
|
name: room.name,
|
||||||
|
description: room.description,
|
||||||
|
hostName: room.hostId || 'Unknown'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,22 +134,6 @@ export class ServersRailComponent {
|
|||||||
this.bannedServerName.set('');
|
this.bannedServerName.set('');
|
||||||
}
|
}
|
||||||
|
|
||||||
closePasswordDialog(): void {
|
|
||||||
this.showPasswordDialog.set(false);
|
|
||||||
this.passwordPromptRoom.set(null);
|
|
||||||
this.joinPassword.set('');
|
|
||||||
this.joinPasswordError.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async confirmPasswordJoin(): Promise<void> {
|
|
||||||
const room = this.passwordPromptRoom();
|
|
||||||
|
|
||||||
if (!room)
|
|
||||||
return;
|
|
||||||
|
|
||||||
await this.attemptJoinRoom(room, this.joinPassword());
|
|
||||||
}
|
|
||||||
|
|
||||||
isRoomMarkedBanned(room: Room): boolean {
|
isRoomMarkedBanned(room: Room): boolean {
|
||||||
return !!this.bannedRoomLookup()[room.id];
|
return !!this.bannedRoomLookup()[room.id];
|
||||||
}
|
}
|
||||||
@@ -244,106 +226,4 @@ export class ServersRailComponent {
|
|||||||
|
|
||||||
return hasRoomBanForUser(bans, currentUser, persistedUserId);
|
return hasRoomBanForUser(bans, currentUser, persistedUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private prepareVoiceContext(room: Room): void {
|
|
||||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
|
||||||
|
|
||||||
if (voiceServerId && voiceServerId !== room.id) {
|
|
||||||
this.voiceSession.setViewingVoiceServer(false);
|
|
||||||
} else if (voiceServerId === room.id) {
|
|
||||||
this.voiceSession.setViewingVoiceServer(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async attemptJoinRoom(room: Room, password?: string): Promise<void> {
|
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
|
||||||
const currentUser = this.currentUser();
|
|
||||||
|
|
||||||
if (!currentUserId)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.joinPasswordError.set(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await firstValueFrom(this.serverDirectory.requestJoin({
|
|
||||||
roomId: room.id,
|
|
||||||
userId: currentUserId,
|
|
||||||
userPublicKey: currentUser?.oderId || currentUserId,
|
|
||||||
displayName: currentUser?.displayName || 'Anonymous',
|
|
||||||
password: password?.trim() || undefined
|
|
||||||
}, {
|
|
||||||
sourceId: room.sourceId,
|
|
||||||
sourceUrl: room.sourceUrl
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.closePasswordDialog();
|
|
||||||
this.store.dispatch(
|
|
||||||
RoomsActions.joinRoom({
|
|
||||||
roomId: room.id,
|
|
||||||
serverInfo: {
|
|
||||||
...this.toServerInfo(room),
|
|
||||||
...response.server
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const serverError = error as {
|
|
||||||
error?: { error?: string; errorCode?: string };
|
|
||||||
};
|
|
||||||
const errorCode = serverError?.error?.errorCode;
|
|
||||||
const message = serverError?.error?.error || 'Failed to join server';
|
|
||||||
|
|
||||||
if (errorCode === 'PASSWORD_REQUIRED') {
|
|
||||||
this.passwordPromptRoom.set(room);
|
|
||||||
this.showPasswordDialog.set(true);
|
|
||||||
this.joinPasswordError.set(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorCode === 'BANNED') {
|
|
||||||
this.bannedServerName.set(room.name);
|
|
||||||
this.showBannedDialog.set(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.shouldFallbackToOfflineView(error)) {
|
|
||||||
this.closePasswordDialog();
|
|
||||||
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
|
|
||||||
this.store.dispatch(RoomsActions.viewServer({ room,
|
|
||||||
skipBanCheck: true }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private shouldFallbackToOfflineView(error: unknown): boolean {
|
|
||||||
const serverError = error as {
|
|
||||||
error?: { errorCode?: string };
|
|
||||||
status?: number;
|
|
||||||
};
|
|
||||||
const errorCode = serverError?.error?.errorCode;
|
|
||||||
const status = serverError?.status;
|
|
||||||
|
|
||||||
return errorCode === 'SERVER_NOT_FOUND'
|
|
||||||
|| status === 0
|
|
||||||
|| status === 404
|
|
||||||
|| (typeof status === 'number' && status >= 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
private toServerInfo(room: Room) {
|
|
||||||
return {
|
|
||||||
id: room.id,
|
|
||||||
name: room.name,
|
|
||||||
description: room.description,
|
|
||||||
hostName: room.hostId || 'Unknown',
|
|
||||||
userCount: room.userCount ?? 0,
|
|
||||||
maxUsers: room.maxUsers ?? 50,
|
|
||||||
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
|
||||||
isPrivate: room.isPrivate,
|
|
||||||
createdAt: room.createdAt,
|
|
||||||
ownerId: room.hostId,
|
|
||||||
sourceId: room.sourceId,
|
|
||||||
sourceName: room.sourceName,
|
|
||||||
sourceUrl: room.sourceUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<div class="space-y-6 max-w-xl">
|
|
||||||
<section>
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<ng-icon
|
|
||||||
name="lucidePower"
|
|
||||||
class="w-5 h-5 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<h4 class="text-sm font-semibold text-foreground">Application</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
|
|
||||||
[class.opacity-60]="!isElectron"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
|
|
||||||
|
|
||||||
@if (isElectron) {
|
|
||||||
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
|
|
||||||
} @else {
|
|
||||||
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label
|
|
||||||
class="relative inline-flex items-center"
|
|
||||||
[class.cursor-pointer]="isElectron && !savingAutoStart()"
|
|
||||||
[class.cursor-not-allowed]="!isElectron || savingAutoStart()"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[checked]="autoStart()"
|
|
||||||
[disabled]="!isElectron || savingAutoStart()"
|
|
||||||
(change)="onAutoStartChange($event)"
|
|
||||||
id="general-auto-start-toggle"
|
|
||||||
aria-label="Toggle launch on startup"
|
|
||||||
class="sr-only peer"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
|
||||||
></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
inject,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
||||||
import { lucidePower } from '@ng-icons/lucide';
|
|
||||||
|
|
||||||
import { PlatformService } from '../../../../core/services/platform.service';
|
|
||||||
|
|
||||||
interface DesktopSettingsSnapshot {
|
|
||||||
autoStart: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GeneralSettingsElectronApi {
|
|
||||||
getDesktopSettings?: () => Promise<DesktopSettingsSnapshot>;
|
|
||||||
setDesktopSettings?: (patch: { autoStart?: boolean }) => Promise<DesktopSettingsSnapshot>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GeneralSettingsWindow = Window & {
|
|
||||||
electronAPI?: GeneralSettingsElectronApi;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-general-settings',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, NgIcon],
|
|
||||||
viewProviders: [
|
|
||||||
provideIcons({
|
|
||||||
lucidePower
|
|
||||||
})
|
|
||||||
],
|
|
||||||
templateUrl: './general-settings.component.html'
|
|
||||||
})
|
|
||||||
export class GeneralSettingsComponent {
|
|
||||||
private platform = inject(PlatformService);
|
|
||||||
|
|
||||||
readonly isElectron = this.platform.isElectron;
|
|
||||||
autoStart = signal(false);
|
|
||||||
savingAutoStart = signal(false);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
if (this.isElectron) {
|
|
||||||
void this.loadDesktopSettings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onAutoStartChange(event: Event): Promise<void> {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const enabled = !!input.checked;
|
|
||||||
const api = this.getElectronApi();
|
|
||||||
|
|
||||||
if (!this.isElectron || !api?.setDesktopSettings) {
|
|
||||||
input.checked = this.autoStart();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.savingAutoStart.set(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const snapshot = await api.setDesktopSettings({ autoStart: enabled });
|
|
||||||
|
|
||||||
this.autoStart.set(snapshot.autoStart);
|
|
||||||
} catch {
|
|
||||||
input.checked = this.autoStart();
|
|
||||||
} finally {
|
|
||||||
this.savingAutoStart.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadDesktopSettings(): Promise<void> {
|
|
||||||
const api = this.getElectronApi();
|
|
||||||
|
|
||||||
if (!api?.getDesktopSettings) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const snapshot = await api.getDesktopSettings();
|
|
||||||
|
|
||||||
this.autoStart.set(snapshot.autoStart);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getElectronApi(): GeneralSettingsElectronApi | null {
|
|
||||||
return typeof window !== 'undefined'
|
|
||||||
? (window as GeneralSettingsWindow).electronAPI ?? null
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,18 +9,7 @@
|
|||||||
/>
|
/>
|
||||||
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
|
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
@if (hasMissingDefaultServers()) {
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
(click)="restoreDefaultServers()"
|
|
||||||
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
|
||||||
>
|
|
||||||
Restore Defaults
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="testAllServers()"
|
(click)="testAllServers()"
|
||||||
[disabled]="isTesting()"
|
[disabled]="isTesting()"
|
||||||
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
||||||
@@ -33,11 +22,8 @@
|
|||||||
Test All
|
Test All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-xs text-muted-foreground mb-3">
|
<p class="text-xs text-muted-foreground mb-3">Server directories to search for rooms. The active server is used for creating new rooms.</p>
|
||||||
Active server endpoints stay enabled at the same time. You pick the endpoint when creating a new server.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Server List -->
|
<!-- Server List -->
|
||||||
<div class="space-y-2 mb-3">
|
<div class="space-y-2 mb-3">
|
||||||
@@ -55,7 +41,6 @@
|
|||||||
[class.bg-red-500]="server.status === 'offline'"
|
[class.bg-red-500]="server.status === 'offline'"
|
||||||
[class.bg-yellow-500]="server.status === 'checking'"
|
[class.bg-yellow-500]="server.status === 'checking'"
|
||||||
[class.bg-muted]="server.status === 'unknown'"
|
[class.bg-muted]="server.status === 'unknown'"
|
||||||
[class.bg-orange-500]="server.status === 'incompatible'"
|
|
||||||
></div>
|
></div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -68,17 +53,13 @@
|
|||||||
@if (server.latency !== undefined && server.status === 'online') {
|
@if (server.latency !== undefined && server.status === 'online') {
|
||||||
<p class="text-[10px] text-muted-foreground">{{ server.latency }}ms</p>
|
<p class="text-[10px] text-muted-foreground">{{ server.latency }}ms</p>
|
||||||
}
|
}
|
||||||
@if (server.status === 'incompatible') {
|
|
||||||
<p class="text-[10px] text-destructive">Update the client in order to connect to other users</p>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 flex-shrink-0">
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
@if (!server.isActive && server.status !== 'incompatible') {
|
@if (!server.isActive) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
(click)="setActiveServer(server.id)"
|
(click)="setActiveServer(server.id)"
|
||||||
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
|
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
|
||||||
title="Activate"
|
title="Set as active"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideCheck"
|
name="lucideCheck"
|
||||||
@@ -86,22 +67,8 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (server.isActive && hasMultipleActiveServers()) {
|
@if (!server.isDefault) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
(click)="deactivateServer(server.id)"
|
|
||||||
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
|
|
||||||
title="Deactivate"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideX"
|
|
||||||
class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (hasMultipleServers()) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="removeServer(server.id)"
|
(click)="removeServer(server.id)"
|
||||||
class="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
|
class="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||||
title="Remove"
|
title="Remove"
|
||||||
@@ -136,7 +103,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
(click)="addServer()"
|
(click)="addServer()"
|
||||||
[disabled]="!newServerName || !newServerUrl"
|
[disabled]="!newServerName || !newServerUrl"
|
||||||
class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal
|
||||||
computed
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -14,8 +13,7 @@ import {
|
|||||||
lucideRefreshCw,
|
lucideRefreshCw,
|
||||||
lucidePlus,
|
lucidePlus,
|
||||||
lucideTrash2,
|
lucideTrash2,
|
||||||
lucideCheck,
|
lucideCheck
|
||||||
lucideX
|
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { ServerDirectoryService } from '../../../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../../../core/services/server-directory.service';
|
||||||
@@ -36,8 +34,7 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
|||||||
lucideRefreshCw,
|
lucideRefreshCw,
|
||||||
lucidePlus,
|
lucidePlus,
|
||||||
lucideTrash2,
|
lucideTrash2,
|
||||||
lucideCheck,
|
lucideCheck
|
||||||
lucideX
|
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
templateUrl: './network-settings.component.html'
|
templateUrl: './network-settings.component.html'
|
||||||
@@ -46,10 +43,6 @@ export class NetworkSettingsComponent {
|
|||||||
private serverDirectory = inject(ServerDirectoryService);
|
private serverDirectory = inject(ServerDirectoryService);
|
||||||
|
|
||||||
servers = this.serverDirectory.servers;
|
servers = this.serverDirectory.servers;
|
||||||
activeServers = this.serverDirectory.activeServers;
|
|
||||||
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
|
||||||
hasMultipleServers = computed(() => this.servers().length > 1);
|
|
||||||
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
|
||||||
isTesting = signal(false);
|
isTesting = signal(false);
|
||||||
addError = signal<string | null>(null);
|
addError = signal<string | null>(null);
|
||||||
newServerName = '';
|
newServerName = '';
|
||||||
@@ -98,14 +91,6 @@ export class NetworkSettingsComponent {
|
|||||||
this.serverDirectory.setActiveServer(id);
|
this.serverDirectory.setActiveServer(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
deactivateServer(id: string): void {
|
|
||||||
this.serverDirectory.deactivateServer(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreDefaultServers(): void {
|
|
||||||
this.serverDirectory.restoreDefaultServers();
|
|
||||||
}
|
|
||||||
|
|
||||||
async testAllServers(): Promise<void> {
|
async testAllServers(): Promise<void> {
|
||||||
this.isTesting.set(true);
|
this.isTesting.set(true);
|
||||||
await this.serverDirectory.testAllServers();
|
await this.serverDirectory.testAllServers();
|
||||||
|
|||||||
@@ -95,84 +95,6 @@
|
|||||||
[class.cursor-not-allowed]="!isAdmin()"
|
[class.cursor-not-allowed]="!isAdmin()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (isAdmin()) {
|
|
||||||
<div class="rounded-lg border border-border bg-secondary/40 p-4 space-y-3">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Server Password</p>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
@if (hasPassword() && passwordAction() !== 'remove') {
|
|
||||||
Joined members stay whitelisted until they are kicked or banned.
|
|
||||||
} @else {
|
|
||||||
Add an optional password so new members need it to join.
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (hasPassword() && passwordAction() !== 'remove') {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="markPasswordForRemoval()"
|
|
||||||
class="rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary"
|
|
||||||
>
|
|
||||||
Remove Password
|
|
||||||
</button>
|
|
||||||
} @else if (hasPassword() && passwordAction() === 'remove') {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="keepCurrentPassword()"
|
|
||||||
class="rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary"
|
|
||||||
>
|
|
||||||
Keep Password
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
@if (hasPassword() && passwordAction() !== 'remove') {
|
|
||||||
Password protection is currently enabled.
|
|
||||||
} @else if (hasPassword() && passwordAction() === 'remove') {
|
|
||||||
Password protection will be removed when you save.
|
|
||||||
} @else {
|
|
||||||
Password protection is currently disabled.
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="room-password"
|
|
||||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
|
||||||
>
|
|
||||||
{{ hasPassword() ? 'Set New Password' : 'Set Password' }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="room-password"
|
|
||||||
[ngModel]="roomPassword"
|
|
||||||
(ngModelChange)="onPasswordInput($event)"
|
|
||||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
[placeholder]="hasPassword() ? 'Leave blank to keep the current password' : 'Optional password required for new joins'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
@if (passwordAction() === 'update') {
|
|
||||||
<p class="mt-2 text-xs text-muted-foreground">The new password will replace the current one when you save.</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (passwordError()) {
|
|
||||||
<p class="mt-2 text-xs text-destructive">{{ passwordError() }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">Server Password</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Invite links bypass the password, but bans still apply.</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-muted-foreground">{{ hasPassword() ? 'Enabled' : 'Disabled' }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ export class ServerSettingsComponent {
|
|||||||
roomName = '';
|
roomName = '';
|
||||||
roomDescription = '';
|
roomDescription = '';
|
||||||
isPrivate = signal(false);
|
isPrivate = signal(false);
|
||||||
hasPassword = signal(false);
|
|
||||||
passwordAction = signal<'keep' | 'update' | 'remove'>('keep');
|
|
||||||
passwordError = signal<string | null>(null);
|
|
||||||
roomPassword = '';
|
|
||||||
maxUsers = 0;
|
maxUsers = 0;
|
||||||
showDeleteConfirm = signal(false);
|
showDeleteConfirm = signal(false);
|
||||||
|
|
||||||
@@ -76,10 +72,6 @@ export class ServerSettingsComponent {
|
|||||||
this.roomName = room.name;
|
this.roomName = room.name;
|
||||||
this.roomDescription = room.description || '';
|
this.roomDescription = room.description || '';
|
||||||
this.isPrivate.set(room.isPrivate);
|
this.isPrivate.set(room.isPrivate);
|
||||||
this.hasPassword.set(typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password);
|
|
||||||
this.passwordAction.set('keep');
|
|
||||||
this.passwordError.set(null);
|
|
||||||
this.roomPassword = '';
|
|
||||||
this.maxUsers = room.maxUsers || 0;
|
this.maxUsers = room.maxUsers || 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -94,67 +86,21 @@ export class ServerSettingsComponent {
|
|||||||
if (!room)
|
if (!room)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const normalizedPassword = this.roomPassword.trim();
|
this.store.dispatch(
|
||||||
const settings: {
|
RoomsActions.updateRoomSettings({
|
||||||
description: string;
|
roomId: room.id,
|
||||||
hasPassword?: boolean;
|
settings: {
|
||||||
isPrivate: boolean;
|
|
||||||
maxUsers: number;
|
|
||||||
name: string;
|
|
||||||
password?: string;
|
|
||||||
} = {
|
|
||||||
name: this.roomName,
|
name: this.roomName,
|
||||||
description: this.roomDescription,
|
description: this.roomDescription,
|
||||||
isPrivate: this.isPrivate(),
|
isPrivate: this.isPrivate(),
|
||||||
maxUsers: this.maxUsers
|
maxUsers: this.maxUsers
|
||||||
};
|
|
||||||
|
|
||||||
if (this.passwordAction() === 'remove') {
|
|
||||||
settings.password = '';
|
|
||||||
settings.hasPassword = false;
|
|
||||||
} else if (normalizedPassword) {
|
|
||||||
settings.password = normalizedPassword;
|
|
||||||
settings.hasPassword = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.dispatch(
|
|
||||||
RoomsActions.updateRoomSettings({
|
|
||||||
roomId: room.id,
|
|
||||||
settings
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.hasPassword.set(settings.hasPassword ?? this.hasPassword());
|
|
||||||
this.passwordAction.set('keep');
|
|
||||||
this.passwordError.set(null);
|
|
||||||
this.roomPassword = '';
|
|
||||||
this.showSaveSuccess('server');
|
this.showSaveSuccess('server');
|
||||||
}
|
}
|
||||||
|
|
||||||
markPasswordForRemoval(): void {
|
|
||||||
this.passwordAction.set('remove');
|
|
||||||
this.passwordError.set(null);
|
|
||||||
this.roomPassword = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
keepCurrentPassword(): void {
|
|
||||||
this.passwordAction.set('keep');
|
|
||||||
this.passwordError.set(null);
|
|
||||||
this.roomPassword = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
onPasswordInput(value: string): void {
|
|
||||||
this.roomPassword = value;
|
|
||||||
this.passwordError.set(null);
|
|
||||||
|
|
||||||
if (value.trim().length > 0) {
|
|
||||||
this.passwordAction.set('update');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.passwordAction.set('keep');
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmDeleteRoom(): void {
|
confirmDeleteRoom(): void {
|
||||||
this.showDeleteConfirm.set(true);
|
this.showDeleteConfirm.set(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,9 +116,6 @@
|
|||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
<div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||||
<h3 class="text-lg font-semibold text-foreground">
|
<h3 class="text-lg font-semibold text-foreground">
|
||||||
@switch (activePage()) {
|
@switch (activePage()) {
|
||||||
@case ('general') {
|
|
||||||
General
|
|
||||||
}
|
|
||||||
@case ('network') {
|
@case ('network') {
|
||||||
Network
|
Network
|
||||||
}
|
}
|
||||||
@@ -160,9 +157,6 @@
|
|||||||
<!-- Scrollable Content Area -->
|
<!-- Scrollable Content Area -->
|
||||||
<div class="flex-1 overflow-y-auto p-6">
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
@switch (activePage()) {
|
@switch (activePage()) {
|
||||||
@case ('general') {
|
|
||||||
<app-general-settings />
|
|
||||||
}
|
|
||||||
@case ('network') {
|
@case ('network') {
|
||||||
<app-network-settings />
|
<app-network-settings />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { Room, UserRole } from '../../../core/models/index';
|
|||||||
import { findRoomMember } from '../../../store/rooms/room-members.helpers';
|
import { findRoomMember } from '../../../store/rooms/room-members.helpers';
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
|
|
||||||
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
|
|
||||||
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
||||||
import { VoiceSettingsComponent } from './voice-settings/voice-settings.component';
|
import { VoiceSettingsComponent } from './voice-settings/voice-settings.component';
|
||||||
import { ServerSettingsComponent } from './server-settings/server-settings.component';
|
import { ServerSettingsComponent } from './server-settings/server-settings.component';
|
||||||
@@ -49,7 +48,6 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
GeneralSettingsComponent,
|
|
||||||
NetworkSettingsComponent,
|
NetworkSettingsComponent,
|
||||||
VoiceSettingsComponent,
|
VoiceSettingsComponent,
|
||||||
UpdatesSettingsComponent,
|
UpdatesSettingsComponent,
|
||||||
@@ -91,9 +89,6 @@ export class SettingsModalComponent {
|
|||||||
activePage = this.modal.activePage;
|
activePage = this.modal.activePage;
|
||||||
|
|
||||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||||
{ id: 'general',
|
|
||||||
label: 'General',
|
|
||||||
icon: 'lucideSettings' },
|
|
||||||
{ id: 'network',
|
{ id: 'network',
|
||||||
label: 'Network',
|
label: 'Network',
|
||||||
icon: 'lucideGlobe' },
|
icon: 'lucideGlobe' },
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<div class="p-6 max-w-2xl mx-auto">
|
<div class="p-6 max-w-2xl mx-auto">
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
(click)="goBack()"
|
(click)="goBack()"
|
||||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||||
title="Go back"
|
title="Go back"
|
||||||
@@ -28,18 +27,7 @@
|
|||||||
/>
|
/>
|
||||||
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
@if (hasMissingDefaultServers()) {
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
(click)="restoreDefaultServers()"
|
|
||||||
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
|
||||||
>
|
|
||||||
Restore Defaults
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="testAllServers()"
|
(click)="testAllServers()"
|
||||||
[disabled]="isTesting()"
|
[disabled]="isTesting()"
|
||||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
||||||
@@ -52,10 +40,10 @@
|
|||||||
Test All
|
Test All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-sm text-muted-foreground mb-4">
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
Active server endpoints stay enabled at the same time. You pick the endpoint when creating and registering a new server.
|
Add multiple server directories to search for rooms across different networks. The active server will be used for creating and registering new
|
||||||
|
rooms.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Server List -->
|
<!-- Server List -->
|
||||||
@@ -75,7 +63,6 @@
|
|||||||
[class.bg-red-500]="server.status === 'offline'"
|
[class.bg-red-500]="server.status === 'offline'"
|
||||||
[class.bg-yellow-500]="server.status === 'checking'"
|
[class.bg-yellow-500]="server.status === 'checking'"
|
||||||
[class.bg-muted]="server.status === 'unknown'"
|
[class.bg-muted]="server.status === 'unknown'"
|
||||||
[class.bg-orange-500]="server.status === 'incompatible'"
|
|
||||||
[title]="server.status"
|
[title]="server.status"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
@@ -91,19 +78,15 @@
|
|||||||
@if (server.latency !== undefined && server.status === 'online') {
|
@if (server.latency !== undefined && server.status === 'online') {
|
||||||
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
|
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
|
||||||
}
|
}
|
||||||
@if (server.status === 'incompatible') {
|
|
||||||
<p class="text-xs text-destructive">Update the client in order to connect to other users</p>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
@if (!server.isActive && server.status !== 'incompatible') {
|
@if (!server.isActive) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
(click)="setActiveServer(server.id)"
|
(click)="setActiveServer(server.id)"
|
||||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||||
title="Activate"
|
title="Set as active"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideCheck"
|
name="lucideCheck"
|
||||||
@@ -111,22 +94,8 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (server.isActive && hasMultipleActiveServers()) {
|
@if (!server.isDefault) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
(click)="deactivateServer(server.id)"
|
|
||||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
|
||||||
title="Deactivate"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideX"
|
|
||||||
class="w-4 h-4 text-muted-foreground hover:text-foreground"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (hasMultipleServers()) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="removeServer(server.id)"
|
(click)="removeServer(server.id)"
|
||||||
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
|
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||||
title="Remove server"
|
title="Remove server"
|
||||||
@@ -161,7 +130,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
(click)="addServer()"
|
(click)="addServer()"
|
||||||
[disabled]="!newServerName || !newServerUrl"
|
[disabled]="!newServerName || !newServerUrl"
|
||||||
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
||||||
@@ -260,7 +228,6 @@
|
|||||||
class="flex-1 h-2 rounded-full appearance-none bg-secondary accent-primary cursor-pointer"
|
class="flex-1 h-2 rounded-full appearance-none bg-secondary accent-primary cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
(click)="previewNotificationSound()"
|
(click)="previewNotificationSound()"
|
||||||
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||||
title="Preview sound"
|
title="Preview sound"
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
OnInit,
|
OnInit
|
||||||
computed
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -62,10 +61,6 @@ export class SettingsComponent implements OnInit {
|
|||||||
audioService = inject(NotificationAudioService);
|
audioService = inject(NotificationAudioService);
|
||||||
|
|
||||||
servers = this.serverDirectory.servers;
|
servers = this.serverDirectory.servers;
|
||||||
activeServers = this.serverDirectory.activeServers;
|
|
||||||
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
|
||||||
hasMultipleServers = computed(() => this.servers().length > 1);
|
|
||||||
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
|
||||||
isTesting = signal(false);
|
isTesting = signal(false);
|
||||||
addError = signal<string | null>(null);
|
addError = signal<string | null>(null);
|
||||||
|
|
||||||
@@ -127,14 +122,6 @@ export class SettingsComponent implements OnInit {
|
|||||||
this.serverDirectory.setActiveServer(id);
|
this.serverDirectory.setActiveServer(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
deactivateServer(id: string): void {
|
|
||||||
this.serverDirectory.deactivateServer(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreDefaultServers(): void {
|
|
||||||
this.serverDirectory.restoreDefaultServers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test connectivity to all configured servers. */
|
/** Test connectivity to all configured servers. */
|
||||||
async testAllServers(): Promise<void> {
|
async testAllServers(): Promise<void> {
|
||||||
this.isTesting.set(true);
|
this.isTesting.set(true);
|
||||||
|
|||||||
@@ -13,22 +13,6 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
||||||
|
|
||||||
@if (showRoomCompatibilityNotice()) {
|
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
|
|
||||||
{{ signalServerCompatibilityError() }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (showRoomReconnectNotice()) {
|
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
|
|
||||||
<ng-icon
|
|
||||||
name="lucideRefreshCw"
|
|
||||||
class="h-3.5 w-3.5 animate-spin"
|
|
||||||
/>
|
|
||||||
Reconnecting to signal server…
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (roomDescription()) {
|
@if (roomDescription()) {
|
||||||
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
||||||
{{ roomDescription() }}
|
{{ roomDescription() }}
|
||||||
@@ -37,11 +21,9 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
||||||
<span
|
@if (isReconnecting()) {
|
||||||
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
|
<span class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
|
||||||
[class.hidden]="!isReconnecting()"
|
}
|
||||||
>Reconnecting…</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -49,15 +31,16 @@
|
|||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
style="-webkit-app-region: no-drag"
|
style="-webkit-app-region: no-drag"
|
||||||
>
|
>
|
||||||
|
@if (!isAuthed()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
|
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
|
||||||
[class.hidden]="isAuthed()"
|
|
||||||
(click)="goLogin()"
|
(click)="goLogin()"
|
||||||
title="Login"
|
title="Login"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -72,20 +55,8 @@
|
|||||||
</button>
|
</button>
|
||||||
<!-- Anchored dropdown under the menu button -->
|
<!-- Anchored dropdown under the menu button -->
|
||||||
@if (showMenu()) {
|
@if (showMenu()) {
|
||||||
<div class="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border border-border bg-card shadow-lg">
|
<div class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
|
||||||
@if (inRoom()) {
|
@if (inRoom()) {
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="createInviteLink()"
|
|
||||||
[disabled]="creatingInvite()"
|
|
||||||
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
@if (creatingInvite()) {
|
|
||||||
Creating Invite Link…
|
|
||||||
} @else {
|
|
||||||
Create Invite Link
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="leaveServer()"
|
(click)="leaveServer()"
|
||||||
@@ -94,12 +65,6 @@
|
|||||||
Leave Server
|
Leave Server
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<div
|
|
||||||
class="border-t border-border px-3 py-2 text-xs leading-5 text-muted-foreground"
|
|
||||||
[class.hidden]="!inviteStatus()"
|
|
||||||
>
|
|
||||||
{{ inviteStatus() }}
|
|
||||||
</div>
|
|
||||||
<div class="border-t border-border"></div>
|
<div class="border-t border-border"></div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
@@ -15,15 +14,10 @@ import {
|
|||||||
lucideX,
|
lucideX,
|
||||||
lucideChevronLeft,
|
lucideChevronLeft,
|
||||||
lucideHash,
|
lucideHash,
|
||||||
lucideMenu,
|
lucideMenu
|
||||||
lucideRefreshCw
|
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import {
|
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
selectCurrentRoom,
|
|
||||||
selectIsSignalServerReconnecting,
|
|
||||||
selectSignalServerCompatibilityError
|
|
||||||
} from '../../store/rooms/rooms.selectors';
|
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||||
@@ -31,7 +25,6 @@ import { WebRTCService } from '../../core/services/webrtc.service';
|
|||||||
import { PlatformService } from '../../core/services/platform.service';
|
import { PlatformService } from '../../core/services/platform.service';
|
||||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||||
import { LeaveServerDialogComponent } from '../../shared';
|
import { LeaveServerDialogComponent } from '../../shared';
|
||||||
import { Room } from '../../core/models/index';
|
|
||||||
|
|
||||||
interface WindowControlsAPI {
|
interface WindowControlsAPI {
|
||||||
minimizeWindow?: () => void;
|
minimizeWindow?: () => void;
|
||||||
@@ -57,8 +50,7 @@ type ElectronWindow = Window & {
|
|||||||
lucideX,
|
lucideX,
|
||||||
lucideChevronLeft,
|
lucideChevronLeft,
|
||||||
lucideHash,
|
lucideHash,
|
||||||
lucideMenu,
|
lucideMenu })
|
||||||
lucideRefreshCw })
|
|
||||||
],
|
],
|
||||||
templateUrl: './title-bar.component.html'
|
templateUrl: './title-bar.component.html'
|
||||||
})
|
})
|
||||||
@@ -86,28 +78,12 @@ export class TitleBarComponent {
|
|||||||
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
||||||
isAuthed = computed(() => !!this.currentUser());
|
isAuthed = computed(() => !!this.currentUser());
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
|
|
||||||
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
|
|
||||||
inRoom = computed(() => !!this.currentRoom());
|
inRoom = computed(() => !!this.currentRoom());
|
||||||
roomName = computed(() => this.currentRoom()?.name || '');
|
roomName = computed(() => this.currentRoom()?.name || '');
|
||||||
roomDescription = computed(() => this.currentRoom()?.description || '');
|
roomDescription = computed(() => this.currentRoom()?.description || '');
|
||||||
showRoomCompatibilityNotice = computed(() =>
|
|
||||||
this.inRoom() && !!this.signalServerCompatibilityError()
|
|
||||||
);
|
|
||||||
showRoomReconnectNotice = computed(() =>
|
|
||||||
this.inRoom()
|
|
||||||
&& !this.signalServerCompatibilityError()
|
|
||||||
&& (
|
|
||||||
this.isSignalServerReconnecting()
|
|
||||||
|| this.webrtc.shouldShowConnectionError()
|
|
||||||
|| this.isReconnecting()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
private _showMenu = signal(false);
|
private _showMenu = signal(false);
|
||||||
showMenu = computed(() => this._showMenu());
|
showMenu = computed(() => this._showMenu());
|
||||||
showLeaveConfirm = signal(false);
|
showLeaveConfirm = signal(false);
|
||||||
inviteStatus = signal<string | null>(null);
|
|
||||||
creatingInvite = signal(false);
|
|
||||||
|
|
||||||
/** Minimize the Electron window. */
|
/** Minimize the Electron window. */
|
||||||
minimize() {
|
minimize() {
|
||||||
@@ -146,44 +122,9 @@ export class TitleBarComponent {
|
|||||||
|
|
||||||
/** Toggle the server dropdown menu. */
|
/** Toggle the server dropdown menu. */
|
||||||
toggleMenu() {
|
toggleMenu() {
|
||||||
this.inviteStatus.set(null);
|
|
||||||
this._showMenu.set(!this._showMenu());
|
this._showMenu.set(!this._showMenu());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a new invite link for the active room and copy it to the clipboard. */
|
|
||||||
async createInviteLink(): Promise<void> {
|
|
||||||
const room = this.currentRoom();
|
|
||||||
const user = this.currentUser();
|
|
||||||
|
|
||||||
if (!room || !user || this.creatingInvite()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.creatingInvite.set(true);
|
|
||||||
this.inviteStatus.set('Creating invite link…');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const invite = await firstValueFrom(this.serverDirectory.createInvite(
|
|
||||||
room.id,
|
|
||||||
{
|
|
||||||
requesterUserId: user.id,
|
|
||||||
requesterDisplayName: user.displayName,
|
|
||||||
requesterRole: user.role
|
|
||||||
},
|
|
||||||
this.toSourceSelector(room)
|
|
||||||
));
|
|
||||||
|
|
||||||
await this.copyInviteLink(invite.inviteUrl);
|
|
||||||
this.inviteStatus.set('Invite link copied to clipboard.');
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const inviteError = error as { error?: { error?: string } };
|
|
||||||
|
|
||||||
this.inviteStatus.set(inviteError?.error?.error || 'Unable to create invite link.');
|
|
||||||
} finally {
|
|
||||||
this.creatingInvite.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Leave the current server and navigate to the servers list. */
|
/** Leave the current server and navigate to the servers list. */
|
||||||
leaveServer() {
|
leaveServer() {
|
||||||
this.openLeaveConfirm();
|
this.openLeaveConfirm();
|
||||||
@@ -229,44 +170,4 @@ export class TitleBarComponent {
|
|||||||
|
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(inviteUrl);
|
|
||||||
return;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
|
|
||||||
textarea.value = inviteUrl;
|
|
||||||
textarea.setAttribute('readonly', 'true');
|
|
||||||
textarea.style.position = 'fixed';
|
|
||||||
textarea.style.opacity = '0';
|
|
||||||
textarea.style.pointerEvents = 'none';
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const copied = document.execCommand('copy');
|
|
||||||
|
|
||||||
if (copied) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* fall through to prompt fallback */
|
|
||||||
} finally {
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.prompt('Copy this invite link', inviteUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {
|
|
||||||
return {
|
|
||||||
sourceId: room.sourceId,
|
|
||||||
sourceUrl: room.sourceUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
computed,
|
computed,
|
||||||
OnInit
|
OnInit,
|
||||||
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
lucideMic,
|
lucideMic,
|
||||||
lucideMicOff,
|
lucideMicOff,
|
||||||
@@ -26,7 +28,6 @@ import { ScreenShareQuality } from '../../../core/services/webrtc';
|
|||||||
import { UsersActions } from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared';
|
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared';
|
||||||
import { VoicePlaybackService } from '../voice-controls/services/voice-playback.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-floating-voice-controls',
|
selector: 'app-floating-voice-controls',
|
||||||
@@ -54,10 +55,9 @@ import { VoicePlaybackService } from '../voice-controls/services/voice-playback.
|
|||||||
* Floating voice controls displayed when the user navigates away from the voice-connected server.
|
* Floating voice controls displayed when the user navigates away from the voice-connected server.
|
||||||
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
|
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
|
||||||
*/
|
*/
|
||||||
export class FloatingVoiceControlsComponent implements OnInit {
|
export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||||
private webrtcService = inject(WebRTCService);
|
private webrtcService = inject(WebRTCService);
|
||||||
private voiceSessionService = inject(VoiceSessionService);
|
private voiceSessionService = inject(VoiceSessionService);
|
||||||
private voicePlayback = inject(VoicePlaybackService);
|
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
@@ -75,6 +75,8 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
|||||||
askScreenShareQuality = signal(true);
|
askScreenShareQuality = signal(true);
|
||||||
showScreenShareQualityDialog = signal(false);
|
showScreenShareQualityDialog = signal(false);
|
||||||
|
|
||||||
|
private stateSubscription: Subscription | null = null;
|
||||||
|
|
||||||
/** Sync local mute/deafen/screen-share state from the WebRTC service on init. */
|
/** Sync local mute/deafen/screen-share state from the WebRTC service on init. */
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Sync mute/deafen state from webrtc service
|
// Sync mute/deafen state from webrtc service
|
||||||
@@ -82,15 +84,10 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
|||||||
this.isDeafened.set(this.webrtcService.isDeafened());
|
this.isDeafened.set(this.webrtcService.isDeafened());
|
||||||
this.isScreenSharing.set(this.webrtcService.isScreenSharing());
|
this.isScreenSharing.set(this.webrtcService.isScreenSharing());
|
||||||
this.syncScreenShareSettings();
|
this.syncScreenShareSettings();
|
||||||
|
|
||||||
const settings = loadVoiceSettingsFromStorage();
|
|
||||||
|
|
||||||
this.voicePlayback.updateOutputVolume(settings.outputVolume / 100);
|
|
||||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
|
||||||
|
|
||||||
if (settings.outputDevice) {
|
|
||||||
this.voicePlayback.applyOutputDevice(settings.outputDevice);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stateSubscription?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Navigate back to the voice-connected server. */
|
/** Navigate back to the voice-connected server. */
|
||||||
@@ -120,7 +117,6 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
|||||||
toggleDeafen(): void {
|
toggleDeafen(): void {
|
||||||
this.isDeafened.update((current) => !current);
|
this.isDeafened.update((current) => !current);
|
||||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
|
||||||
|
|
||||||
// When deafening, also mute
|
// When deafening, also mute
|
||||||
if (this.isDeafened() && !this.isMuted()) {
|
if (this.isDeafened() && !this.isMuted()) {
|
||||||
@@ -193,8 +189,6 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
|||||||
|
|
||||||
// Disable voice
|
// Disable voice
|
||||||
this.webrtcService.disableVoice();
|
this.webrtcService.disableVoice();
|
||||||
this.voicePlayback.teardownAll();
|
|
||||||
this.voicePlayback.updateDeafened(false);
|
|
||||||
|
|
||||||
// Update user voice state in store
|
// Update user voice state in store
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|||||||
@@ -18,14 +18,11 @@ export interface PlaybackOptions {
|
|||||||
*
|
*
|
||||||
* Chrome/Electron workaround: a muted HTMLAudioElement is attached to
|
* Chrome/Electron workaround: a muted HTMLAudioElement is attached to
|
||||||
* the stream first so that `createMediaStreamSource` actually outputs
|
* the stream first so that `createMediaStreamSource` actually outputs
|
||||||
* audio. The priming element itself is silent; audible output is routed
|
* audio. The element itself is silent - all audible output comes from
|
||||||
* through a separate output element fed by
|
* the GainNode -> AudioContext.destination path.
|
||||||
* `GainNode -> MediaStreamDestination` so output-device switching stays
|
|
||||||
* reliable during Linux screen sharing.
|
|
||||||
*/
|
*/
|
||||||
interface PeerAudioPipeline {
|
interface PeerAudioPipeline {
|
||||||
audioElement: HTMLAudioElement;
|
audioElement: HTMLAudioElement;
|
||||||
outputElement: HTMLAudioElement;
|
|
||||||
context: AudioContext;
|
context: AudioContext;
|
||||||
sourceNodes: MediaStreamAudioSourceNode[];
|
sourceNodes: MediaStreamAudioSourceNode[];
|
||||||
gainNode: GainNode;
|
gainNode: GainNode;
|
||||||
@@ -41,7 +38,6 @@ export class VoicePlaybackService {
|
|||||||
private userVolumes = new Map<string, number>();
|
private userVolumes = new Map<string, number>();
|
||||||
private userMuted = new Map<string, boolean>();
|
private userMuted = new Map<string, boolean>();
|
||||||
private preferredOutputDeviceId = 'default';
|
private preferredOutputDeviceId = 'default';
|
||||||
private temporaryOutputDeviceId: string | null = null;
|
|
||||||
private masterVolume = 1;
|
private masterVolume = 1;
|
||||||
private deafened = false;
|
private deafened = false;
|
||||||
private captureEchoSuppressed = false;
|
private captureEchoSuppressed = false;
|
||||||
@@ -53,36 +49,6 @@ export class VoicePlaybackService {
|
|||||||
this.captureEchoSuppressed = this.webrtc.isScreenShareRemotePlaybackSuppressed();
|
this.captureEchoSuppressed = this.webrtc.isScreenShareRemotePlaybackSuppressed();
|
||||||
this.recalcAllGains();
|
this.recalcAllGains();
|
||||||
});
|
});
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
this.temporaryOutputDeviceId = this.webrtc.forceDefaultRemotePlaybackOutput()
|
|
||||||
? 'default'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
void this.applyEffectiveOutputDeviceToAllPipelines();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.webrtc.onRemoteStream.subscribe(({ peerId }) => {
|
|
||||||
const voiceStream = this.webrtc.getRemoteVoiceStream(peerId);
|
|
||||||
|
|
||||||
if (!voiceStream) {
|
|
||||||
this.removeRemoteAudio(peerId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.handleRemoteStream(peerId, voiceStream, this.buildPlaybackOptions());
|
|
||||||
});
|
|
||||||
|
|
||||||
this.webrtc.onVoiceConnected.subscribe(() => {
|
|
||||||
const options = this.buildPlaybackOptions(true);
|
|
||||||
|
|
||||||
this.playPendingStreams(options);
|
|
||||||
this.ensureAllRemoteStreamsPlaying(options);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.webrtc.onPeerDisconnected.subscribe((peerId) => {
|
|
||||||
this.removeRemoteAudio(peerId);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
|
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
|
||||||
@@ -181,14 +147,6 @@ export class VoicePlaybackService {
|
|||||||
this.pendingRemoteStreams.clear();
|
this.pendingRemoteStreams.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildPlaybackOptions(forceConnected = this.webrtc.isVoiceConnected()): PlaybackOptions {
|
|
||||||
return {
|
|
||||||
isConnected: forceConnected,
|
|
||||||
outputVolume: this.masterVolume,
|
|
||||||
isDeafened: this.deafened
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the Web Audio graph for a remote peer:
|
* Build the Web Audio graph for a remote peer:
|
||||||
*
|
*
|
||||||
@@ -196,12 +154,11 @@ export class VoicePlaybackService {
|
|||||||
* ↓
|
* ↓
|
||||||
* muted <audio> element (Chrome workaround - primes the stream)
|
* muted <audio> element (Chrome workaround - primes the stream)
|
||||||
* ↓
|
* ↓
|
||||||
* MediaStreamSource → GainNode → MediaStreamDestination → output <audio>
|
* MediaStreamSource → GainNode → AudioContext.destination
|
||||||
*/
|
*/
|
||||||
private createPipeline(peerId: string, stream: MediaStream): void {
|
private createPipeline(peerId: string, stream: MediaStream): void {
|
||||||
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.
|
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.
|
||||||
const audioEl = new Audio();
|
const audioEl = new Audio();
|
||||||
const outputEl = new Audio();
|
|
||||||
const audioTracks = stream.getAudioTracks().filter((track) => track.readyState === 'live');
|
const audioTracks = stream.getAudioTracks().filter((track) => track.readyState === 'live');
|
||||||
|
|
||||||
audioEl.srcObject = stream;
|
audioEl.srcObject = stream;
|
||||||
@@ -210,24 +167,12 @@ export class VoicePlaybackService {
|
|||||||
|
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
const gainNode = ctx.createGain();
|
const gainNode = ctx.createGain();
|
||||||
const mediaDestination = ctx.createMediaStreamDestination();
|
|
||||||
const sourceNodes = audioTracks.map((track) => ctx.createMediaStreamSource(new MediaStream([track])));
|
const sourceNodes = audioTracks.map((track) => ctx.createMediaStreamSource(new MediaStream([track])));
|
||||||
|
|
||||||
sourceNodes.forEach((sourceNode) => sourceNode.connect(gainNode));
|
sourceNodes.forEach((sourceNode) => sourceNode.connect(gainNode));
|
||||||
gainNode.connect(mediaDestination);
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
outputEl.srcObject = mediaDestination.stream;
|
const pipeline: PeerAudioPipeline = { audioElement: audioEl, context: ctx, sourceNodes, gainNode };
|
||||||
outputEl.muted = false;
|
|
||||||
outputEl.volume = 1;
|
|
||||||
outputEl.play().catch(() => {});
|
|
||||||
|
|
||||||
const pipeline: PeerAudioPipeline = {
|
|
||||||
audioElement: audioEl,
|
|
||||||
outputElement: outputEl,
|
|
||||||
context: ctx,
|
|
||||||
sourceNodes,
|
|
||||||
gainNode
|
|
||||||
};
|
|
||||||
|
|
||||||
this.peerPipelines.set(peerId, pipeline);
|
this.peerPipelines.set(peerId, pipeline);
|
||||||
|
|
||||||
@@ -249,20 +194,26 @@ export class VoicePlaybackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const anyAudio = pipeline.outputElement as any;
|
const anyAudio = pipeline.audioElement as any;
|
||||||
|
// eslint-disable-next-line
|
||||||
|
const anyCtx = pipeline.context as any;
|
||||||
const tasks: Promise<unknown>[] = [];
|
const tasks: Promise<unknown>[] = [];
|
||||||
|
|
||||||
if (typeof anyAudio.setSinkId === 'function') {
|
if (typeof anyAudio.setSinkId === 'function') {
|
||||||
tasks.push(anyAudio.setSinkId(deviceId).catch(() => undefined));
|
tasks.push(anyAudio.setSinkId(deviceId).catch(() => undefined));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof anyCtx.setSinkId === 'function') {
|
||||||
|
tasks.push(anyCtx.setSinkId(deviceId).catch(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
if (tasks.length > 0) {
|
if (tasks.length > 0) {
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getEffectiveOutputDeviceId(): string {
|
private getEffectiveOutputDeviceId(): string {
|
||||||
return this.temporaryOutputDeviceId ?? this.preferredOutputDeviceId;
|
return this.preferredOutputDeviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private removePipeline(peerId: string): void {
|
private removePipeline(peerId: string): void {
|
||||||
@@ -287,8 +238,6 @@ export class VoicePlaybackService {
|
|||||||
|
|
||||||
pipeline.audioElement.srcObject = null;
|
pipeline.audioElement.srcObject = null;
|
||||||
pipeline.audioElement.remove();
|
pipeline.audioElement.remove();
|
||||||
pipeline.outputElement.srcObject = null;
|
|
||||||
pipeline.outputElement.remove();
|
|
||||||
|
|
||||||
if (pipeline.context.state !== 'closed') {
|
if (pipeline.context.state !== 'closed') {
|
||||||
pipeline.context.close().catch(() => {});
|
pipeline.context.close().catch(() => {});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
lucideMic,
|
lucideMic,
|
||||||
lucideMicOff,
|
lucideMicOff,
|
||||||
@@ -75,6 +76,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
private voicePlayback = inject(VoicePlaybackService);
|
private voicePlayback = inject(VoicePlaybackService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private settingsModal = inject(SettingsModalService);
|
private settingsModal = inject(SettingsModalService);
|
||||||
|
private remoteStreamSubscription: Subscription | null = null;
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
@@ -108,18 +110,56 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
isDeafened: this.isDeafened()
|
isDeafened: this.isDeafened()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private voiceConnectedSubscription: Subscription | null = null;
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
await this.loadAudioDevices();
|
await this.loadAudioDevices();
|
||||||
|
|
||||||
// Load persisted voice settings and apply
|
// Load persisted voice settings and apply
|
||||||
this.loadSettings();
|
this.loadSettings();
|
||||||
this.applySettingsToWebRTC();
|
this.applySettingsToWebRTC();
|
||||||
|
|
||||||
|
// Subscribe to remote streams to play audio from peers
|
||||||
|
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
|
||||||
|
({ peerId }) => {
|
||||||
|
const voiceStream = this.webrtcService.getRemoteVoiceStream(peerId);
|
||||||
|
|
||||||
|
if (!voiceStream) {
|
||||||
|
this.voicePlayback.removeRemoteAudio(peerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.voicePlayback.handleRemoteStream(peerId, voiceStream, this.playbackOptions());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
|
||||||
|
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
|
||||||
|
const options = this.playbackOptions();
|
||||||
|
|
||||||
|
this.voicePlayback.playPendingStreams(options);
|
||||||
|
// Also ensure all remote streams from connected peers are playing
|
||||||
|
// This handles the case where streams were received while voice was "connected"
|
||||||
|
// from a previous session but audio elements weren't set up
|
||||||
|
this.voicePlayback.ensureAllRemoteStreamsPlaying(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up audio when peer disconnects
|
||||||
|
this.webrtcService.onPeerDisconnected.subscribe((peerId) => {
|
||||||
|
this.voicePlayback.removeRemoteAudio(peerId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
if (!this.webrtcService.isVoiceConnected()) {
|
if (this.isConnected()) {
|
||||||
this.voicePlayback.teardownAll();
|
this.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.voicePlayback.teardownAll();
|
||||||
|
|
||||||
|
this.remoteStreamSubscription?.unsubscribe();
|
||||||
|
this.voiceConnectedSubscription?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadAudioDevices(): Promise<void> {
|
async loadAudioDevices(): Promise<void> {
|
||||||
@@ -264,7 +304,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
// Disable voice (stops audio tracks but keeps peer connections open for chat)
|
// Disable voice (stops audio tracks but keeps peer connections open for chat)
|
||||||
this.webrtcService.disableVoice();
|
this.webrtcService.disableVoice();
|
||||||
this.voicePlayback.teardownAll();
|
this.voicePlayback.teardownAll();
|
||||||
this.voicePlayback.updateDeafened(false);
|
|
||||||
|
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
|||||||
@@ -42,65 +42,6 @@
|
|||||||
{{ autoScroll() ? 'Pause auto-scroll' : 'Resume auto-scroll' }}
|
{{ autoScroll() ? 'Pause auto-scroll' : 'Resume auto-scroll' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Export dropdown -->
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
data-export-menu
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="toggleExportMenu()"
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-2.5 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
|
||||||
[attr.aria-expanded]="exportMenuOpen()"
|
|
||||||
aria-haspopup="true"
|
|
||||||
title="Export logs"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideDownload"
|
|
||||||
class="h-3.5 w-3.5"
|
|
||||||
/>
|
|
||||||
Export
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (exportMenuOpen()) {
|
|
||||||
<div class="absolute right-0 top-full z-10 mt-1 min-w-[11rem] rounded-lg border border-border bg-card p-1 shadow-xl">
|
|
||||||
@if (activeTab() === 'logs') {
|
|
||||||
<p class="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Logs</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="exportLogs('csv')"
|
|
||||||
class="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary"
|
|
||||||
>
|
|
||||||
Export as CSV
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="exportLogs('txt')"
|
|
||||||
class="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary"
|
|
||||||
>
|
|
||||||
Export as TXT
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<p class="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Network</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="exportNetwork('csv')"
|
|
||||||
class="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary"
|
|
||||||
>
|
|
||||||
Export as CSV
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="exportNetwork('txt')"
|
|
||||||
class="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary"
|
|
||||||
>
|
|
||||||
Export as TXT
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="clear()"
|
(click)="clear()"
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
HostListener,
|
|
||||||
input,
|
input,
|
||||||
output,
|
output
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
lucideDownload,
|
|
||||||
lucideFilter,
|
lucideFilter,
|
||||||
lucidePause,
|
lucidePause,
|
||||||
lucidePlay,
|
lucidePlay,
|
||||||
@@ -18,7 +15,6 @@ import {
|
|||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
type DebugLogLevel = 'event' | 'info' | 'warn' | 'error' | 'debug';
|
type DebugLogLevel = 'event' | 'info' | 'warn' | 'error' | 'debug';
|
||||||
type DebugExportFormat = 'csv' | 'txt';
|
|
||||||
|
|
||||||
interface DebugNetworkSummary {
|
interface DebugNetworkSummary {
|
||||||
clientCount: number;
|
clientCount: number;
|
||||||
@@ -38,7 +34,6 @@ interface DebugNetworkSummary {
|
|||||||
imports: [CommonModule, NgIcon],
|
imports: [CommonModule, NgIcon],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideDownload,
|
|
||||||
lucideFilter,
|
lucideFilter,
|
||||||
lucidePause,
|
lucidePause,
|
||||||
lucidePlay,
|
lucidePlay,
|
||||||
@@ -69,10 +64,6 @@ export class DebugConsoleToolbarComponent {
|
|||||||
readonly autoScrollToggled = output<undefined>();
|
readonly autoScrollToggled = output<undefined>();
|
||||||
readonly clearRequested = output<undefined>();
|
readonly clearRequested = output<undefined>();
|
||||||
readonly closeRequested = output<undefined>();
|
readonly closeRequested = output<undefined>();
|
||||||
readonly exportLogsRequested = output<DebugExportFormat>();
|
|
||||||
readonly exportNetworkRequested = output<DebugExportFormat>();
|
|
||||||
|
|
||||||
readonly exportMenuOpen = signal(false);
|
|
||||||
|
|
||||||
readonly levels: DebugLogLevel[] = [
|
readonly levels: DebugLogLevel[] = [
|
||||||
'event',
|
'event',
|
||||||
@@ -84,17 +75,6 @@ export class DebugConsoleToolbarComponent {
|
|||||||
|
|
||||||
readonly tabs: ('logs' | 'network')[] = ['logs', 'network'];
|
readonly tabs: ('logs' | 'network')[] = ['logs', 'network'];
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
|
||||||
onDocumentClick(event: MouseEvent): void {
|
|
||||||
if (!this.exportMenuOpen())
|
|
||||||
return;
|
|
||||||
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
|
|
||||||
if (!target.closest('[data-export-menu]'))
|
|
||||||
this.closeExportMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveTab(tab: 'logs' | 'network'): void {
|
setActiveTab(tab: 'logs' | 'network'): void {
|
||||||
this.activeTabChange.emit(tab);
|
this.activeTabChange.emit(tab);
|
||||||
}
|
}
|
||||||
@@ -131,24 +111,6 @@ export class DebugConsoleToolbarComponent {
|
|||||||
this.closeRequested.emit(undefined);
|
this.closeRequested.emit(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleExportMenu(): void {
|
|
||||||
this.exportMenuOpen.update((open) => !open);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeExportMenu(): void {
|
|
||||||
this.exportMenuOpen.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
exportLogs(format: DebugExportFormat): void {
|
|
||||||
this.exportLogsRequested.emit(format);
|
|
||||||
this.closeExportMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
exportNetwork(format: DebugExportFormat): void {
|
|
||||||
this.exportNetworkRequested.emit(format);
|
|
||||||
this.closeExportMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
getDetachLabel(): string {
|
getDetachLabel(): string {
|
||||||
return this.detached() ? 'Dock' : 'Detach';
|
return this.detached() ? 'Dock' : 'Detach';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,11 +102,10 @@
|
|||||||
[style.left.px]="detached() ? panelLeft() : null"
|
[style.left.px]="detached() ? panelLeft() : null"
|
||||||
[style.top.px]="detached() ? panelTop() : null"
|
[style.top.px]="detached() ? panelTop() : null"
|
||||||
>
|
>
|
||||||
<!-- Left resize bar -->
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="group absolute inset-y-0 left-0 z-[1] w-3 cursor-col-resize bg-transparent"
|
class="group absolute inset-y-0 left-0 z-[1] w-3 cursor-col-resize bg-transparent"
|
||||||
(mousedown)="startLeftResize($event)"
|
(mousedown)="startWidthResize($event)"
|
||||||
aria-label="Resize debug console width"
|
aria-label="Resize debug console width"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -114,23 +113,10 @@
|
|||||||
></span>
|
></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Right resize bar -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="group absolute inset-y-0 right-0 z-[1] w-3 cursor-col-resize bg-transparent"
|
|
||||||
(mousedown)="startRightResize($event)"
|
|
||||||
aria-label="Resize debug console width from right"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute left-1/2 top-1/2 h-20 w-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border/60 transition-colors group-hover:bg-primary/60"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Top resize bar -->
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="group relative h-3 w-full cursor-row-resize bg-transparent"
|
class="group relative h-3 w-full cursor-row-resize bg-transparent"
|
||||||
(mousedown)="startTopResize($event)"
|
(mousedown)="startResize($event)"
|
||||||
aria-label="Resize debug console"
|
aria-label="Resize debug console"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -168,8 +154,6 @@
|
|||||||
(autoScrollToggled)="toggleAutoScroll()"
|
(autoScrollToggled)="toggleAutoScroll()"
|
||||||
(clearRequested)="clearLogs()"
|
(clearRequested)="clearLogs()"
|
||||||
(closeRequested)="closeConsole()"
|
(closeRequested)="closeConsole()"
|
||||||
(exportLogsRequested)="exportLogs($event)"
|
|
||||||
(exportNetworkRequested)="exportNetwork($event)"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@if (activeTab() === 'logs') {
|
@if (activeTab() === 'logs') {
|
||||||
@@ -184,48 +168,6 @@
|
|||||||
[snapshot]="networkSnapshot()"
|
[snapshot]="networkSnapshot()"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Bottom resize bar -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="group relative h-3 w-full cursor-row-resize bg-transparent"
|
|
||||||
(mousedown)="startBottomResize($event)"
|
|
||||||
aria-label="Resize debug console height from bottom"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute left-1/2 top-1/2 h-1 w-16 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border transition-colors group-hover:bg-primary/50"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Bottom-right corner drag handle -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="group absolute bottom-0 right-0 z-[2] flex h-5 w-5 cursor-nwse-resize items-center justify-center bg-transparent"
|
|
||||||
(mousedown)="startCornerResize($event)"
|
|
||||||
aria-label="Resize debug console from corner"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-3 w-3 text-border/80 transition-colors group-hover:text-primary/70"
|
|
||||||
viewBox="0 0 10 10"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx="8"
|
|
||||||
cy="8"
|
|
||||||
r="1.2"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="4"
|
|
||||||
cy="8"
|
|
||||||
r="1.2"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="8"
|
|
||||||
cy="4"
|
|
||||||
r="1.2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ import { DebuggingService, type DebugLogLevel } from '../../../core/services/deb
|
|||||||
import { DebugConsoleEntryListComponent } from './debug-console-entry-list/debug-console-entry-list.component';
|
import { DebugConsoleEntryListComponent } from './debug-console-entry-list/debug-console-entry-list.component';
|
||||||
import { DebugConsoleNetworkMapComponent } from './debug-console-network-map/debug-console-network-map.component';
|
import { DebugConsoleNetworkMapComponent } from './debug-console-network-map/debug-console-network-map.component';
|
||||||
import { DebugConsoleToolbarComponent } from './debug-console-toolbar/debug-console-toolbar.component';
|
import { DebugConsoleToolbarComponent } from './debug-console-toolbar/debug-console-toolbar.component';
|
||||||
import { DebugConsoleResizeService } from './services/debug-console-resize.service';
|
|
||||||
import { DebugConsoleExportService, type DebugExportFormat } from './services/debug-console-export.service';
|
|
||||||
import { DebugConsoleEnvironmentService } from './services/debug-console-environment.service';
|
|
||||||
|
|
||||||
type DebugLevelState = Record<DebugLogLevel, boolean>;
|
type DebugLevelState = Record<DebugLogLevel, boolean>;
|
||||||
|
|
||||||
@@ -47,9 +44,6 @@ type DebugConsoleLauncherVariant = 'floating' | 'inline' | 'compact';
|
|||||||
})
|
})
|
||||||
export class DebugConsoleComponent {
|
export class DebugConsoleComponent {
|
||||||
readonly debugging = inject(DebuggingService);
|
readonly debugging = inject(DebuggingService);
|
||||||
readonly resizeService = inject(DebugConsoleResizeService);
|
|
||||||
readonly exportService = inject(DebugConsoleExportService);
|
|
||||||
readonly envService = inject(DebugConsoleEnvironmentService);
|
|
||||||
readonly entries = this.debugging.entries;
|
readonly entries = this.debugging.entries;
|
||||||
readonly isOpen = this.debugging.isConsoleOpen;
|
readonly isOpen = this.debugging.isConsoleOpen;
|
||||||
readonly networkSnapshot = this.debugging.networkSnapshot;
|
readonly networkSnapshot = this.debugging.networkSnapshot;
|
||||||
@@ -62,10 +56,10 @@ export class DebugConsoleComponent {
|
|||||||
readonly searchTerm = signal('');
|
readonly searchTerm = signal('');
|
||||||
readonly selectedSource = signal('all');
|
readonly selectedSource = signal('all');
|
||||||
readonly autoScroll = signal(true);
|
readonly autoScroll = signal(true);
|
||||||
readonly panelHeight = this.resizeService.panelHeight;
|
readonly panelHeight = signal(360);
|
||||||
readonly panelWidth = this.resizeService.panelWidth;
|
readonly panelWidth = signal(832);
|
||||||
readonly panelLeft = this.resizeService.panelLeft;
|
readonly panelLeft = signal(0);
|
||||||
readonly panelTop = this.resizeService.panelTop;
|
readonly panelTop = signal(0);
|
||||||
readonly levelState = signal<DebugLevelState>({
|
readonly levelState = signal<DebugLevelState>({
|
||||||
event: true,
|
event: true,
|
||||||
info: true,
|
info: true,
|
||||||
@@ -129,8 +123,18 @@ export class DebugConsoleComponent {
|
|||||||
readonly hasErrors = computed(() => this.levelCounts().error > 0);
|
readonly hasErrors = computed(() => this.levelCounts().error > 0);
|
||||||
readonly networkSummary = computed(() => this.networkSnapshot().summary);
|
readonly networkSummary = computed(() => this.networkSnapshot().summary);
|
||||||
|
|
||||||
|
private dragging = false;
|
||||||
|
private resizingHeight = false;
|
||||||
|
private resizingWidth = false;
|
||||||
|
private resizeOriginY = 0;
|
||||||
|
private resizeOriginX = 0;
|
||||||
|
private resizeOriginHeight = 360;
|
||||||
|
private resizeOriginWidth = 832;
|
||||||
|
private panelOriginLeft = 0;
|
||||||
|
private panelOriginTop = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.resizeService.syncBounds(this.detached());
|
this.syncPanelBounds();
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const selectedSource = this.selectedSource();
|
const selectedSource = this.selectedSource();
|
||||||
@@ -143,17 +147,32 @@ export class DebugConsoleComponent {
|
|||||||
|
|
||||||
@HostListener('window:mousemove', ['$event'])
|
@HostListener('window:mousemove', ['$event'])
|
||||||
onResizeMove(event: MouseEvent): void {
|
onResizeMove(event: MouseEvent): void {
|
||||||
this.resizeService.onMouseMove(event, this.detached());
|
if (this.dragging) {
|
||||||
|
this.updateDetachedPosition(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.resizingWidth) {
|
||||||
|
this.updatePanelWidth(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.resizingHeight)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.updatePanelHeight(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:mouseup')
|
@HostListener('window:mouseup')
|
||||||
onResizeEnd(): void {
|
onResizeEnd(): void {
|
||||||
this.resizeService.onMouseUp();
|
this.dragging = false;
|
||||||
|
this.resizingHeight = false;
|
||||||
|
this.resizingWidth = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:resize')
|
@HostListener('window:resize')
|
||||||
onWindowResize(): void {
|
onWindowResize(): void {
|
||||||
this.resizeService.syncBounds(this.detached());
|
this.syncPanelBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleConsole(): void {
|
toggleConsole(): void {
|
||||||
@@ -176,38 +195,14 @@ export class DebugConsoleComponent {
|
|||||||
this.activeTab.set(tab);
|
this.activeTab.set(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
exportLogs(format: DebugExportFormat): void {
|
|
||||||
const env = this.envService.getEnvironment();
|
|
||||||
const name = this.envService.getFilenameSafeDisplayName();
|
|
||||||
|
|
||||||
this.exportService.exportLogs(
|
|
||||||
this.filteredEntries(),
|
|
||||||
format,
|
|
||||||
env,
|
|
||||||
name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
exportNetwork(format: DebugExportFormat): void {
|
|
||||||
const env = this.envService.getEnvironment();
|
|
||||||
const name = this.envService.getFilenameSafeDisplayName();
|
|
||||||
|
|
||||||
this.exportService.exportNetwork(
|
|
||||||
this.networkSnapshot(),
|
|
||||||
format,
|
|
||||||
env,
|
|
||||||
name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleDetached(): void {
|
toggleDetached(): void {
|
||||||
const nextDetached = !this.detached();
|
const nextDetached = !this.detached();
|
||||||
|
|
||||||
this.detached.set(nextDetached);
|
this.detached.set(nextDetached);
|
||||||
this.resizeService.syncBounds(nextDetached);
|
this.syncPanelBounds();
|
||||||
|
|
||||||
if (nextDetached)
|
if (nextDetached)
|
||||||
this.resizeService.initializeDetachedPosition();
|
this.initializeDetachedPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleLevel(level: DebugLogLevel): void {
|
toggleLevel(level: DebugLogLevel): void {
|
||||||
@@ -225,31 +220,35 @@ export class DebugConsoleComponent {
|
|||||||
this.debugging.clear();
|
this.debugging.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
startTopResize(event: MouseEvent): void {
|
startResize(event: MouseEvent): void {
|
||||||
this.resizeService.startTopResize(event);
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.resizingHeight = true;
|
||||||
|
this.resizeOriginY = event.clientY;
|
||||||
|
this.resizeOriginHeight = this.panelHeight();
|
||||||
|
this.panelOriginTop = this.panelTop();
|
||||||
}
|
}
|
||||||
|
|
||||||
startBottomResize(event: MouseEvent): void {
|
startWidthResize(event: MouseEvent): void {
|
||||||
this.resizeService.startBottomResize(event);
|
event.preventDefault();
|
||||||
}
|
event.stopPropagation();
|
||||||
|
this.resizingWidth = true;
|
||||||
startLeftResize(event: MouseEvent): void {
|
this.resizeOriginX = event.clientX;
|
||||||
this.resizeService.startLeftResize(event);
|
this.resizeOriginWidth = this.panelWidth();
|
||||||
}
|
this.panelOriginLeft = this.panelLeft();
|
||||||
|
|
||||||
startRightResize(event: MouseEvent): void {
|
|
||||||
this.resizeService.startRightResize(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
startCornerResize(event: MouseEvent): void {
|
|
||||||
this.resizeService.startCornerResize(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startDrag(event: MouseEvent): void {
|
startDrag(event: MouseEvent): void {
|
||||||
if (!this.detached())
|
if (!this.detached())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.resizeService.startDrag(event);
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.dragging = true;
|
||||||
|
this.resizeOriginX = event.clientX;
|
||||||
|
this.resizeOriginY = event.clientY;
|
||||||
|
this.panelOriginLeft = this.panelLeft();
|
||||||
|
this.panelOriginTop = this.panelTop();
|
||||||
}
|
}
|
||||||
|
|
||||||
formatBadgeCount(count: number): string {
|
formatBadgeCount(count: number): string {
|
||||||
@@ -258,4 +257,92 @@ export class DebugConsoleComponent {
|
|||||||
|
|
||||||
return count.toString();
|
return count.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updatePanelHeight(event: MouseEvent): void {
|
||||||
|
const delta = this.resizeOriginY - event.clientY;
|
||||||
|
const nextHeight = this.clampPanelHeight(this.resizeOriginHeight + delta);
|
||||||
|
|
||||||
|
this.panelHeight.set(nextHeight);
|
||||||
|
|
||||||
|
if (!this.detached())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const originBottom = this.panelOriginTop + this.resizeOriginHeight;
|
||||||
|
const maxTop = this.getMaxPanelTop(nextHeight);
|
||||||
|
|
||||||
|
this.panelTop.set(this.clampValue(originBottom - nextHeight, 16, maxTop));
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePanelWidth(event: MouseEvent): void {
|
||||||
|
const delta = this.resizeOriginX - event.clientX;
|
||||||
|
const nextWidth = this.clampPanelWidth(this.resizeOriginWidth + delta);
|
||||||
|
|
||||||
|
this.panelWidth.set(nextWidth);
|
||||||
|
|
||||||
|
if (!this.detached())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const originRight = this.panelOriginLeft + this.resizeOriginWidth;
|
||||||
|
const maxLeft = this.getMaxPanelLeft(nextWidth);
|
||||||
|
|
||||||
|
this.panelLeft.set(this.clampValue(originRight - nextWidth, 16, maxLeft));
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDetachedPosition(event: MouseEvent): void {
|
||||||
|
const nextLeft = this.panelOriginLeft + (event.clientX - this.resizeOriginX);
|
||||||
|
const nextTop = this.panelOriginTop + (event.clientY - this.resizeOriginY);
|
||||||
|
|
||||||
|
this.panelLeft.set(this.clampValue(nextLeft, 16, this.getMaxPanelLeft(this.panelWidth())));
|
||||||
|
this.panelTop.set(this.clampValue(nextTop, 16, this.getMaxPanelTop(this.panelHeight())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeDetachedPosition(): void {
|
||||||
|
if (this.panelLeft() > 0 || this.panelTop() > 0) {
|
||||||
|
this.clampDetachedPosition();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.panelLeft.set(this.getMaxPanelLeft(this.panelWidth()));
|
||||||
|
this.panelTop.set(this.clampValue(window.innerHeight - this.panelHeight() - 96, 16, this.getMaxPanelTop(this.panelHeight())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampPanelHeight(height: number): number {
|
||||||
|
const maxHeight = this.detached()
|
||||||
|
? Math.max(260, window.innerHeight - 32)
|
||||||
|
: Math.floor(window.innerHeight * 0.75);
|
||||||
|
|
||||||
|
return Math.min(Math.max(height, 260), maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampPanelWidth(width: number): number {
|
||||||
|
const maxWidth = Math.max(360, window.innerWidth - 32);
|
||||||
|
const minWidth = Math.min(460, maxWidth);
|
||||||
|
|
||||||
|
return Math.min(Math.max(width, minWidth), maxWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampDetachedPosition(): void {
|
||||||
|
this.panelLeft.set(this.clampValue(this.panelLeft(), 16, this.getMaxPanelLeft(this.panelWidth())));
|
||||||
|
this.panelTop.set(this.clampValue(this.panelTop(), 16, this.getMaxPanelTop(this.panelHeight())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMaxPanelLeft(width: number): number {
|
||||||
|
return Math.max(16, window.innerWidth - width - 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMaxPanelTop(height: number): number {
|
||||||
|
return Math.max(16, window.innerHeight - height - 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncPanelBounds(): void {
|
||||||
|
this.panelWidth.update((width) => this.clampPanelWidth(width));
|
||||||
|
this.panelHeight.update((height) => this.clampPanelHeight(height));
|
||||||
|
|
||||||
|
if (this.detached())
|
||||||
|
this.clampDetachedPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampValue(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
|
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
|
||||||
import { PlatformService } from '../../../../core/services/platform.service';
|
|
||||||
|
|
||||||
export interface DebugExportEnvironment {
|
|
||||||
appVersion: string;
|
|
||||||
displayName: string;
|
|
||||||
displayServer: string;
|
|
||||||
gpu: string;
|
|
||||||
operatingSystem: string;
|
|
||||||
platform: string;
|
|
||||||
userAgent: string;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DebugConsoleElectronApi {
|
|
||||||
linuxDisplayServer?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class DebugConsoleEnvironmentService {
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly platformService = inject(PlatformService);
|
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
|
|
||||||
getEnvironment(): DebugExportEnvironment {
|
|
||||||
return {
|
|
||||||
appVersion: this.resolveAppVersion(),
|
|
||||||
displayName: this.resolveDisplayName(),
|
|
||||||
displayServer: this.resolveDisplayServer(),
|
|
||||||
gpu: this.resolveGpu(),
|
|
||||||
operatingSystem: this.resolveOperatingSystem(),
|
|
||||||
platform: this.resolvePlatform(),
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
userId: this.currentUser()?.id ?? 'Unknown'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getFilenameSafeDisplayName(): string {
|
|
||||||
const name = this.resolveDisplayName();
|
|
||||||
const sanitized = name
|
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
||||||
.replace(/_+/g, '_')
|
|
||||||
.replace(/^_|_$/g, '');
|
|
||||||
|
|
||||||
return sanitized || 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveDisplayName(): string {
|
|
||||||
return this.currentUser()?.displayName ?? 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveAppVersion(): string {
|
|
||||||
if (!this.platformService.isElectron)
|
|
||||||
return 'web';
|
|
||||||
|
|
||||||
const electronVersion = this.readElectronVersion();
|
|
||||||
|
|
||||||
return electronVersion
|
|
||||||
? `${electronVersion} (Electron)`
|
|
||||||
: 'Electron (unknown version)';
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolvePlatform(): string {
|
|
||||||
if (!this.platformService.isElectron)
|
|
||||||
return 'Browser';
|
|
||||||
|
|
||||||
const os = this.resolveOperatingSystem().toLowerCase();
|
|
||||||
|
|
||||||
if (os.includes('windows'))
|
|
||||||
return 'Windows Electron';
|
|
||||||
|
|
||||||
if (os.includes('linux'))
|
|
||||||
return 'Linux Electron';
|
|
||||||
|
|
||||||
if (os.includes('mac'))
|
|
||||||
return 'macOS Electron';
|
|
||||||
|
|
||||||
return 'Electron';
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveOperatingSystem(): string {
|
|
||||||
const ua = navigator.userAgent;
|
|
||||||
|
|
||||||
if (ua.includes('Windows NT 10.0'))
|
|
||||||
return 'Windows 10/11';
|
|
||||||
|
|
||||||
if (ua.includes('Windows NT'))
|
|
||||||
return 'Windows';
|
|
||||||
|
|
||||||
if (ua.includes('Mac OS X')) {
|
|
||||||
const match = ua.match(/Mac OS X ([\d._]+)/);
|
|
||||||
const version = match?.[1]?.replace(/_/g, '.') ?? '';
|
|
||||||
|
|
||||||
return version ? `macOS ${version}` : 'macOS';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ua.includes('Linux')) {
|
|
||||||
const parts: string[] = ['Linux'];
|
|
||||||
|
|
||||||
if (ua.includes('Ubuntu'))
|
|
||||||
parts.push('(Ubuntu)');
|
|
||||||
else if (ua.includes('Fedora'))
|
|
||||||
parts.push('(Fedora)');
|
|
||||||
else if (ua.includes('Debian'))
|
|
||||||
parts.push('(Debian)');
|
|
||||||
|
|
||||||
return parts.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
return navigator.platform || 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveDisplayServer(): string {
|
|
||||||
if (!navigator.userAgent.includes('Linux'))
|
|
||||||
return 'N/A';
|
|
||||||
|
|
||||||
const electronDisplayServer = this.readElectronDisplayServer();
|
|
||||||
|
|
||||||
if (electronDisplayServer)
|
|
||||||
return electronDisplayServer;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ua = navigator.userAgent.toLowerCase();
|
|
||||||
|
|
||||||
if (ua.includes('wayland'))
|
|
||||||
return 'Wayland';
|
|
||||||
|
|
||||||
const isOzone = ua.includes('ozone');
|
|
||||||
|
|
||||||
if (isOzone)
|
|
||||||
return 'Ozone (Wayland likely)';
|
|
||||||
|
|
||||||
if (ua.includes('x11'))
|
|
||||||
return 'X11';
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.detectDisplayServerFromEnv();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readElectronDisplayServer(): string | null {
|
|
||||||
try {
|
|
||||||
const displayServer = this.getElectronApi()?.linuxDisplayServer;
|
|
||||||
|
|
||||||
return typeof displayServer === 'string' && displayServer.trim().length > 0
|
|
||||||
? displayServer
|
|
||||||
: null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private detectDisplayServerFromEnv(): string {
|
|
||||||
try {
|
|
||||||
// Electron may expose env vars
|
|
||||||
const api = this.getElectronApi();
|
|
||||||
|
|
||||||
if (!api)
|
|
||||||
return 'Unknown (Linux)';
|
|
||||||
} catch {
|
|
||||||
// Not available
|
|
||||||
}
|
|
||||||
|
|
||||||
// Best-effort heuristic: check if WebGL context
|
|
||||||
// mentions wayland in renderer string
|
|
||||||
const gpu = this.resolveGpu().toLowerCase();
|
|
||||||
|
|
||||||
if (gpu.includes('wayland'))
|
|
||||||
return 'Wayland';
|
|
||||||
|
|
||||||
return 'Unknown (Linux)';
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveGpu(): string {
|
|
||||||
try {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const gl = canvas.getContext('webgl')
|
|
||||||
?? canvas.getContext('experimental-webgl');
|
|
||||||
|
|
||||||
if (!gl || !(gl instanceof WebGLRenderingContext))
|
|
||||||
return 'Unavailable';
|
|
||||||
|
|
||||||
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
|
||||||
|
|
||||||
if (!ext)
|
|
||||||
return 'Unavailable (no debug info)';
|
|
||||||
|
|
||||||
const vendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL);
|
|
||||||
const renderer = gl.getParameter(
|
|
||||||
ext.UNMASKED_RENDERER_WEBGL
|
|
||||||
);
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (typeof renderer === 'string' && renderer.length > 0)
|
|
||||||
parts.push(renderer);
|
|
||||||
|
|
||||||
if (typeof vendor === 'string' && vendor.length > 0)
|
|
||||||
parts.push(`(${vendor})`);
|
|
||||||
|
|
||||||
return parts.length > 0
|
|
||||||
? parts.join(' ')
|
|
||||||
: 'Unknown';
|
|
||||||
} catch {
|
|
||||||
return 'Unavailable';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private readElectronVersion(): string | null {
|
|
||||||
try {
|
|
||||||
const ua = navigator.userAgent;
|
|
||||||
const match = ua.match(/metoyou\/([\d.]+)/i)
|
|
||||||
?? ua.match(/Electron\/([\d.]+)/);
|
|
||||||
|
|
||||||
return match?.[1] ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getElectronApi(): DebugConsoleElectronApi | null {
|
|
||||||
try {
|
|
||||||
const win = window as Window &
|
|
||||||
{ electronAPI?: DebugConsoleElectronApi };
|
|
||||||
|
|
||||||
return win.electronAPI ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,517 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
DebugLogEntry,
|
|
||||||
DebugLogLevel,
|
|
||||||
DebugNetworkEdge,
|
|
||||||
DebugNetworkNode,
|
|
||||||
DebugNetworkSnapshot
|
|
||||||
} from '../../../../core/services/debugging.service';
|
|
||||||
import type { DebugExportEnvironment } from './debug-console-environment.service';
|
|
||||||
|
|
||||||
export type DebugExportFormat = 'csv' | 'txt';
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class DebugConsoleExportService {
|
|
||||||
exportLogs(
|
|
||||||
entries: readonly DebugLogEntry[],
|
|
||||||
format: DebugExportFormat,
|
|
||||||
env: DebugExportEnvironment,
|
|
||||||
filenameName: string
|
|
||||||
): void {
|
|
||||||
const content = format === 'csv'
|
|
||||||
? this.buildLogsCsv(entries, env)
|
|
||||||
: this.buildLogsTxt(entries, env);
|
|
||||||
const extension = format === 'csv' ? 'csv' : 'txt';
|
|
||||||
const mime = format === 'csv'
|
|
||||||
? 'text/csv;charset=utf-8;'
|
|
||||||
: 'text/plain;charset=utf-8;';
|
|
||||||
const filename = this.buildFilename(
|
|
||||||
'debug-logs',
|
|
||||||
filenameName,
|
|
||||||
extension
|
|
||||||
);
|
|
||||||
|
|
||||||
this.downloadFile(filename, content, mime);
|
|
||||||
}
|
|
||||||
|
|
||||||
exportNetwork(
|
|
||||||
snapshot: DebugNetworkSnapshot,
|
|
||||||
format: DebugExportFormat,
|
|
||||||
env: DebugExportEnvironment,
|
|
||||||
filenameName: string
|
|
||||||
): void {
|
|
||||||
const content = format === 'csv'
|
|
||||||
? this.buildNetworkCsv(snapshot, env)
|
|
||||||
: this.buildNetworkTxt(snapshot, env);
|
|
||||||
const extension = format === 'csv' ? 'csv' : 'txt';
|
|
||||||
const mime = format === 'csv'
|
|
||||||
? 'text/csv;charset=utf-8;'
|
|
||||||
: 'text/plain;charset=utf-8;';
|
|
||||||
const filename = this.buildFilename(
|
|
||||||
'debug-network',
|
|
||||||
filenameName,
|
|
||||||
extension
|
|
||||||
);
|
|
||||||
|
|
||||||
this.downloadFile(filename, content, mime);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildLogsCsv(
|
|
||||||
entries: readonly DebugLogEntry[],
|
|
||||||
env: DebugExportEnvironment
|
|
||||||
): string {
|
|
||||||
const meta = this.buildCsvMetaSection(env);
|
|
||||||
const header = 'Timestamp,DateTime,Level,Source,Message,Payload,Count';
|
|
||||||
const rows = entries.map((entry) =>
|
|
||||||
[
|
|
||||||
entry.timeLabel,
|
|
||||||
entry.dateTimeLabel,
|
|
||||||
entry.level,
|
|
||||||
this.escapeCsvField(entry.source),
|
|
||||||
this.escapeCsvField(entry.message),
|
|
||||||
this.escapeCsvField(entry.payloadText ?? ''),
|
|
||||||
entry.count
|
|
||||||
].join(',')
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
meta,
|
|
||||||
'',
|
|
||||||
header,
|
|
||||||
...rows
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildLogsTxt(
|
|
||||||
entries: readonly DebugLogEntry[],
|
|
||||||
env: DebugExportEnvironment
|
|
||||||
): string {
|
|
||||||
const lines: string[] = [
|
|
||||||
`Debug Logs Export - ${new Date().toISOString()}`,
|
|
||||||
this.buildSeparator(),
|
|
||||||
...this.buildTxtEnvLines(env),
|
|
||||||
this.buildSeparator(),
|
|
||||||
`Total entries: ${entries.length}`,
|
|
||||||
this.buildSeparator()
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const prefix = this.buildLevelPrefix(entry.level);
|
|
||||||
const countSuffix = entry.count > 1 ? ` (×${entry.count})` : '';
|
|
||||||
|
|
||||||
lines.push(`[${entry.dateTimeLabel}] ${prefix} [${entry.source}] ${entry.message}${countSuffix}`);
|
|
||||||
|
|
||||||
if (entry.payloadText)
|
|
||||||
lines.push(` Payload: ${entry.payloadText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildNetworkCsv(
|
|
||||||
snapshot: DebugNetworkSnapshot,
|
|
||||||
env: DebugExportEnvironment
|
|
||||||
): string {
|
|
||||||
const sections: string[] = [];
|
|
||||||
|
|
||||||
sections.push(this.buildCsvMetaSection(env));
|
|
||||||
sections.push('');
|
|
||||||
sections.push(this.buildNetworkNodesCsv(snapshot.nodes));
|
|
||||||
sections.push('');
|
|
||||||
sections.push(this.buildNetworkEdgesCsv(snapshot.edges));
|
|
||||||
sections.push('');
|
|
||||||
sections.push(this.buildNetworkConnectionsCsv(snapshot));
|
|
||||||
|
|
||||||
return sections.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildNetworkNodesCsv(nodes: readonly DebugNetworkNode[]): string {
|
|
||||||
const headerParts = [
|
|
||||||
'NodeId',
|
|
||||||
'Kind',
|
|
||||||
'Label',
|
|
||||||
'UserId',
|
|
||||||
'Identity',
|
|
||||||
'Active',
|
|
||||||
'VoiceConnected',
|
|
||||||
'Typing',
|
|
||||||
'Speaking',
|
|
||||||
'Muted',
|
|
||||||
'Deafened',
|
|
||||||
'Streaming',
|
|
||||||
'ConnectionDrops',
|
|
||||||
'PingMs',
|
|
||||||
'TextSent',
|
|
||||||
'TextReceived',
|
|
||||||
'AudioStreams',
|
|
||||||
'VideoStreams',
|
|
||||||
'OffersSent',
|
|
||||||
'OffersReceived',
|
|
||||||
'AnswersSent',
|
|
||||||
'AnswersReceived',
|
|
||||||
'IceSent',
|
|
||||||
'IceReceived',
|
|
||||||
'DownloadFileMbps',
|
|
||||||
'DownloadAudioMbps',
|
|
||||||
'DownloadVideoMbps'
|
|
||||||
];
|
|
||||||
const header = headerParts.join(',');
|
|
||||||
const rows = nodes.map((node) =>
|
|
||||||
[
|
|
||||||
this.escapeCsvField(node.id),
|
|
||||||
node.kind,
|
|
||||||
this.escapeCsvField(node.label),
|
|
||||||
this.escapeCsvField(node.userId ?? ''),
|
|
||||||
this.escapeCsvField(node.identity ?? ''),
|
|
||||||
node.isActive,
|
|
||||||
node.isVoiceConnected,
|
|
||||||
node.isTyping,
|
|
||||||
node.isSpeaking,
|
|
||||||
node.isMuted,
|
|
||||||
node.isDeafened,
|
|
||||||
node.isStreaming,
|
|
||||||
node.connectionDrops,
|
|
||||||
node.pingMs ?? '',
|
|
||||||
node.textMessages.sent,
|
|
||||||
node.textMessages.received,
|
|
||||||
node.streams.audio,
|
|
||||||
node.streams.video,
|
|
||||||
node.handshake.offersSent,
|
|
||||||
node.handshake.offersReceived,
|
|
||||||
node.handshake.answersSent,
|
|
||||||
node.handshake.answersReceived,
|
|
||||||
node.handshake.iceSent,
|
|
||||||
node.handshake.iceReceived,
|
|
||||||
node.downloads.fileMbps ?? '',
|
|
||||||
node.downloads.audioMbps ?? '',
|
|
||||||
node.downloads.videoMbps ?? ''
|
|
||||||
].join(',')
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'# Nodes',
|
|
||||||
header,
|
|
||||||
...rows
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildNetworkEdgesCsv(edges: readonly DebugNetworkEdge[]): string {
|
|
||||||
const header = 'EdgeId,Kind,SourceId,TargetId,SourceLabel,TargetLabel,Active,PingMs,State,MessageTotal';
|
|
||||||
const rows = edges.map((edge) =>
|
|
||||||
[
|
|
||||||
this.escapeCsvField(edge.id),
|
|
||||||
edge.kind,
|
|
||||||
this.escapeCsvField(edge.sourceId),
|
|
||||||
this.escapeCsvField(edge.targetId),
|
|
||||||
this.escapeCsvField(edge.sourceLabel),
|
|
||||||
this.escapeCsvField(edge.targetLabel),
|
|
||||||
edge.isActive,
|
|
||||||
edge.pingMs ?? '',
|
|
||||||
this.escapeCsvField(edge.stateLabel),
|
|
||||||
edge.messageTotal
|
|
||||||
].join(',')
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'# Edges',
|
|
||||||
header,
|
|
||||||
...rows
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildNetworkConnectionsCsv(snapshot: DebugNetworkSnapshot): string {
|
|
||||||
const header = 'SourceNode,TargetNode,EdgeKind,Direction,Active';
|
|
||||||
const rows: string[] = [];
|
|
||||||
|
|
||||||
for (const edge of snapshot.edges) {
|
|
||||||
rows.push(
|
|
||||||
[
|
|
||||||
this.escapeCsvField(edge.sourceLabel),
|
|
||||||
this.escapeCsvField(edge.targetLabel),
|
|
||||||
edge.kind,
|
|
||||||
`${edge.sourceLabel} → ${edge.targetLabel}`,
|
|
||||||
edge.isActive
|
|
||||||
].join(',')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'# Connections',
|
|
||||||
header,
|
|
||||||
...rows
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildNetworkTxt(
|
|
||||||
snapshot: DebugNetworkSnapshot,
|
|
||||||
env: DebugExportEnvironment
|
|
||||||
): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
lines.push(`Network Export - ${new Date().toISOString()}`);
|
|
||||||
lines.push(this.buildSeparator());
|
|
||||||
lines.push(...this.buildTxtEnvLines(env));
|
|
||||||
lines.push(this.buildSeparator());
|
|
||||||
|
|
||||||
lines.push('SUMMARY');
|
|
||||||
lines.push(` Clients: ${snapshot.summary.clientCount}`);
|
|
||||||
lines.push(` Servers: ${snapshot.summary.serverCount}`);
|
|
||||||
lines.push(` Signaling servers: ${snapshot.summary.signalingServerCount}`);
|
|
||||||
lines.push(` Peer connections: ${snapshot.summary.peerConnectionCount}`);
|
|
||||||
lines.push(` Memberships: ${snapshot.summary.membershipCount}`);
|
|
||||||
lines.push(` Messages: ${snapshot.summary.messageCount}`);
|
|
||||||
lines.push(` Typing: ${snapshot.summary.typingCount}`);
|
|
||||||
lines.push(` Speaking: ${snapshot.summary.speakingCount}`);
|
|
||||||
lines.push(` Streaming: ${snapshot.summary.streamingCount}`);
|
|
||||||
lines.push('');
|
|
||||||
|
|
||||||
lines.push(this.buildSeparator());
|
|
||||||
lines.push('NODES');
|
|
||||||
lines.push(this.buildSeparator());
|
|
||||||
|
|
||||||
for (const node of snapshot.nodes)
|
|
||||||
this.appendNodeTxt(lines, node);
|
|
||||||
|
|
||||||
lines.push(this.buildSeparator());
|
|
||||||
lines.push('EDGES / CONNECTIONS');
|
|
||||||
lines.push(this.buildSeparator());
|
|
||||||
|
|
||||||
for (const edge of snapshot.edges)
|
|
||||||
this.appendEdgeTxt(lines, edge);
|
|
||||||
|
|
||||||
lines.push(this.buildSeparator());
|
|
||||||
lines.push('CONNECTION MAP');
|
|
||||||
lines.push(this.buildSeparator());
|
|
||||||
this.appendConnectionMap(lines, snapshot);
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private appendNodeTxt(lines: string[], node: DebugNetworkNode): void {
|
|
||||||
lines.push(` [${node.kind}] ${node.label} (${node.id})`);
|
|
||||||
|
|
||||||
if (node.userId)
|
|
||||||
lines.push(` User ID: ${node.userId}`);
|
|
||||||
|
|
||||||
if (node.identity)
|
|
||||||
lines.push(` Identity: ${node.identity}`);
|
|
||||||
|
|
||||||
const statuses: string[] = [];
|
|
||||||
|
|
||||||
if (node.isActive)
|
|
||||||
statuses.push('Active');
|
|
||||||
|
|
||||||
if (node.isVoiceConnected)
|
|
||||||
statuses.push('Voice');
|
|
||||||
|
|
||||||
if (node.isTyping)
|
|
||||||
statuses.push('Typing');
|
|
||||||
|
|
||||||
if (node.isSpeaking)
|
|
||||||
statuses.push('Speaking');
|
|
||||||
|
|
||||||
if (node.isMuted)
|
|
||||||
statuses.push('Muted');
|
|
||||||
|
|
||||||
if (node.isDeafened)
|
|
||||||
statuses.push('Deafened');
|
|
||||||
|
|
||||||
if (node.isStreaming)
|
|
||||||
statuses.push('Streaming');
|
|
||||||
|
|
||||||
if (statuses.length > 0)
|
|
||||||
lines.push(` Status: ${statuses.join(', ')}`);
|
|
||||||
|
|
||||||
if (node.pingMs !== null)
|
|
||||||
lines.push(` Ping: ${node.pingMs} ms`);
|
|
||||||
|
|
||||||
lines.push(` Connection drops: ${node.connectionDrops}`);
|
|
||||||
lines.push(` Text messages: ↑${node.textMessages.sent} ↓${node.textMessages.received}`);
|
|
||||||
lines.push(` Streams: Audio ${node.streams.audio}, Video ${node.streams.video}`);
|
|
||||||
const handshakeLine = [
|
|
||||||
`Offers ${node.handshake.offersSent}/${node.handshake.offersReceived}`,
|
|
||||||
`Answers ${node.handshake.answersSent}/${node.handshake.answersReceived}`,
|
|
||||||
`ICE ${node.handshake.iceSent}/${node.handshake.iceReceived}`
|
|
||||||
].join(', ');
|
|
||||||
|
|
||||||
lines.push(` Handshake: ${handshakeLine}`);
|
|
||||||
|
|
||||||
if (node.downloads.fileMbps !== null || node.downloads.audioMbps !== null || node.downloads.videoMbps !== null) {
|
|
||||||
const parts = [
|
|
||||||
`File ${this.formatMbps(node.downloads.fileMbps)}`,
|
|
||||||
`Audio ${this.formatMbps(node.downloads.audioMbps)}`,
|
|
||||||
`Video ${this.formatMbps(node.downloads.videoMbps)}`
|
|
||||||
];
|
|
||||||
|
|
||||||
lines.push(` Downloads: ${parts.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
private appendEdgeTxt(lines: string[], edge: DebugNetworkEdge): void {
|
|
||||||
const activeLabel = edge.isActive ? 'active' : 'inactive';
|
|
||||||
|
|
||||||
lines.push(` [${edge.kind}] ${edge.sourceLabel} → ${edge.targetLabel} (${activeLabel})`);
|
|
||||||
|
|
||||||
if (edge.pingMs !== null)
|
|
||||||
lines.push(` Ping: ${edge.pingMs} ms`);
|
|
||||||
|
|
||||||
if (edge.stateLabel)
|
|
||||||
lines.push(` State: ${edge.stateLabel}`);
|
|
||||||
|
|
||||||
lines.push(` Total messages: ${edge.messageTotal}`);
|
|
||||||
|
|
||||||
if (edge.messageGroups.length > 0) {
|
|
||||||
lines.push(' Message groups:');
|
|
||||||
|
|
||||||
for (const group of edge.messageGroups) {
|
|
||||||
const dir = group.direction === 'outbound' ? '↑' : '↓';
|
|
||||||
|
|
||||||
lines.push(` ${dir} [${group.scope}] ${group.type} ×${group.count}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
private appendConnectionMap(lines: string[], snapshot: DebugNetworkSnapshot): void {
|
|
||||||
const nodeMap = new Map(snapshot.nodes.map((node) => [node.id, node]));
|
|
||||||
|
|
||||||
for (const node of snapshot.nodes) {
|
|
||||||
const outgoing = snapshot.edges.filter((edge) => edge.sourceId === node.id);
|
|
||||||
const incoming = snapshot.edges.filter((edge) => edge.targetId === node.id);
|
|
||||||
|
|
||||||
lines.push(` ${node.label} (${node.kind})`);
|
|
||||||
|
|
||||||
if (outgoing.length > 0) {
|
|
||||||
lines.push(' Outgoing:');
|
|
||||||
|
|
||||||
for (const edge of outgoing) {
|
|
||||||
const target = nodeMap.get(edge.targetId);
|
|
||||||
|
|
||||||
lines.push(` → ${target?.label ?? edge.targetId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (incoming.length > 0) {
|
|
||||||
lines.push(' Incoming:');
|
|
||||||
|
|
||||||
for (const edge of incoming) {
|
|
||||||
const source = nodeMap.get(edge.sourceId);
|
|
||||||
|
|
||||||
lines.push(` ← ${source?.label ?? edge.sourceId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outgoing.length === 0 && incoming.length === 0)
|
|
||||||
lines.push(' (no connections)');
|
|
||||||
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildCsvMetaSection(env: DebugExportEnvironment): string {
|
|
||||||
return [
|
|
||||||
'# Export Metadata',
|
|
||||||
'Property,Value',
|
|
||||||
`Exported By,${this.escapeCsvField(env.displayName)}`,
|
|
||||||
`User ID,${this.escapeCsvField(env.userId)}`,
|
|
||||||
`Export Date,${new Date().toISOString()}`,
|
|
||||||
`App Version,${this.escapeCsvField(env.appVersion)}`,
|
|
||||||
`Platform,${this.escapeCsvField(env.platform)}`,
|
|
||||||
`Operating System,${this.escapeCsvField(env.operatingSystem)}`,
|
|
||||||
`Display Server,${this.escapeCsvField(env.displayServer)}`,
|
|
||||||
`GPU,${this.escapeCsvField(env.gpu)}`,
|
|
||||||
`User Agent,${this.escapeCsvField(env.userAgent)}`
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildTxtEnvLines(
|
|
||||||
env: DebugExportEnvironment
|
|
||||||
): string[] {
|
|
||||||
return [
|
|
||||||
`Exported by: ${env.displayName}`,
|
|
||||||
`User ID: ${env.userId}`,
|
|
||||||
`App version: ${env.appVersion}`,
|
|
||||||
`Platform: ${env.platform}`,
|
|
||||||
`OS: ${env.operatingSystem}`,
|
|
||||||
`Display server: ${env.displayServer}`,
|
|
||||||
`GPU: ${env.gpu}`,
|
|
||||||
`User agent: ${env.userAgent}`
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildFilename(
|
|
||||||
prefix: string,
|
|
||||||
userLabel: string,
|
|
||||||
extension: string
|
|
||||||
): string {
|
|
||||||
const stamp = this.buildTimestamp();
|
|
||||||
|
|
||||||
return `${prefix}_${userLabel}_${stamp}.${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private escapeCsvField(value: string): string {
|
|
||||||
if (value.includes(',') || value.includes('"') || value.includes('\n'))
|
|
||||||
return `"${value.replace(/"/g, '""')}"`;
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildLevelPrefix(level: DebugLogLevel): string {
|
|
||||||
switch (level) {
|
|
||||||
case 'event':
|
|
||||||
return 'EVT';
|
|
||||||
case 'info':
|
|
||||||
return 'INF';
|
|
||||||
case 'warn':
|
|
||||||
return 'WRN';
|
|
||||||
case 'error':
|
|
||||||
return 'ERR';
|
|
||||||
case 'debug':
|
|
||||||
return 'DBG';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatMbps(value: number | null): string {
|
|
||||||
if (value === null)
|
|
||||||
return '-';
|
|
||||||
|
|
||||||
return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} Mbps`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildTimestamp(): string {
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(now.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(now.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
||||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
||||||
|
|
||||||
return `${year}${month}${day}-${hours}${minutes}${seconds}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildSeparator(): string {
|
|
||||||
return '─'.repeat(60);
|
|
||||||
}
|
|
||||||
|
|
||||||
private downloadFile(filename: string, content: string, mimeType: string): void {
|
|
||||||
const blob = new Blob([content], { type: mimeType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
anchor.style.display = 'none';
|
|
||||||
document.body.appendChild(anchor);
|
|
||||||
anchor.click();
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
document.body.removeChild(anchor);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'metoyou_debug_console_layout';
|
|
||||||
const DEFAULT_HEIGHT = 520;
|
|
||||||
const DEFAULT_WIDTH = 832;
|
|
||||||
const MIN_HEIGHT = 260;
|
|
||||||
const MIN_WIDTH = 460;
|
|
||||||
|
|
||||||
interface PersistedLayout {
|
|
||||||
height: number;
|
|
||||||
width: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class DebugConsoleResizeService {
|
|
||||||
readonly panelHeight = signal(DEFAULT_HEIGHT);
|
|
||||||
readonly panelWidth = signal(DEFAULT_WIDTH);
|
|
||||||
readonly panelLeft = signal(0);
|
|
||||||
readonly panelTop = signal(0);
|
|
||||||
|
|
||||||
private dragging = false;
|
|
||||||
private resizingTop = false;
|
|
||||||
private resizingBottom = false;
|
|
||||||
private resizingLeft = false;
|
|
||||||
private resizingRight = false;
|
|
||||||
private resizingCorner = false;
|
|
||||||
private resizeOriginX = 0;
|
|
||||||
private resizeOriginY = 0;
|
|
||||||
private resizeOriginHeight = DEFAULT_HEIGHT;
|
|
||||||
private resizeOriginWidth = DEFAULT_WIDTH;
|
|
||||||
private panelOriginLeft = 0;
|
|
||||||
private panelOriginTop = 0;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.loadLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
get isResizing(): boolean {
|
|
||||||
return this.resizingTop || this.resizingBottom || this.resizingLeft || this.resizingRight || this.resizingCorner;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isDragging(): boolean {
|
|
||||||
return this.dragging;
|
|
||||||
}
|
|
||||||
|
|
||||||
startTopResize(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.resizingTop = true;
|
|
||||||
this.resizeOriginY = event.clientY;
|
|
||||||
this.resizeOriginHeight = this.panelHeight();
|
|
||||||
this.panelOriginTop = this.panelTop();
|
|
||||||
}
|
|
||||||
|
|
||||||
startBottomResize(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.resizingBottom = true;
|
|
||||||
this.resizeOriginY = event.clientY;
|
|
||||||
this.resizeOriginHeight = this.panelHeight();
|
|
||||||
}
|
|
||||||
|
|
||||||
startLeftResize(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.resizingLeft = true;
|
|
||||||
this.resizeOriginX = event.clientX;
|
|
||||||
this.resizeOriginWidth = this.panelWidth();
|
|
||||||
this.panelOriginLeft = this.panelLeft();
|
|
||||||
}
|
|
||||||
|
|
||||||
startRightResize(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.resizingRight = true;
|
|
||||||
this.resizeOriginX = event.clientX;
|
|
||||||
this.resizeOriginWidth = this.panelWidth();
|
|
||||||
}
|
|
||||||
|
|
||||||
startCornerResize(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.resizingCorner = true;
|
|
||||||
this.resizeOriginX = event.clientX;
|
|
||||||
this.resizeOriginY = event.clientY;
|
|
||||||
this.resizeOriginWidth = this.panelWidth();
|
|
||||||
this.resizeOriginHeight = this.panelHeight();
|
|
||||||
}
|
|
||||||
|
|
||||||
startDrag(event: MouseEvent): void {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.dragging = true;
|
|
||||||
this.resizeOriginX = event.clientX;
|
|
||||||
this.resizeOriginY = event.clientY;
|
|
||||||
this.panelOriginLeft = this.panelLeft();
|
|
||||||
this.panelOriginTop = this.panelTop();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMouseMove(event: MouseEvent, detached: boolean): void {
|
|
||||||
if (this.dragging) {
|
|
||||||
this.updateDetachedPosition(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.resizingCorner) {
|
|
||||||
this.updateCornerResize(event, detached);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.resizingLeft) {
|
|
||||||
this.updateLeftResize(event, detached);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.resizingRight) {
|
|
||||||
this.updateRightResize(event, detached);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.resizingTop) {
|
|
||||||
this.updateTopResize(event, detached);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.resizingBottom) {
|
|
||||||
this.updateBottomResize(event, detached);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMouseUp(): void {
|
|
||||||
const wasActive = this.isResizing || this.dragging;
|
|
||||||
|
|
||||||
this.dragging = false;
|
|
||||||
this.resizingTop = false;
|
|
||||||
this.resizingBottom = false;
|
|
||||||
this.resizingLeft = false;
|
|
||||||
this.resizingRight = false;
|
|
||||||
this.resizingCorner = false;
|
|
||||||
|
|
||||||
if (wasActive)
|
|
||||||
this.persistLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
syncBounds(detached: boolean): void {
|
|
||||||
this.panelWidth.update((width) => this.clampWidth(width, detached));
|
|
||||||
this.panelHeight.update((height) => this.clampHeight(height, detached));
|
|
||||||
|
|
||||||
if (detached)
|
|
||||||
this.clampDetachedPosition();
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeDetachedPosition(): void {
|
|
||||||
if (this.panelLeft() > 0 || this.panelTop() > 0) {
|
|
||||||
this.clampDetachedPosition();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.panelLeft.set(this.getMaxLeft(this.panelWidth()));
|
|
||||||
this.panelTop.set(
|
|
||||||
this.clamp(window.innerHeight - this.panelHeight() - 96, 16, this.getMaxTop(this.panelHeight()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateTopResize(event: MouseEvent, detached: boolean): void {
|
|
||||||
const delta = this.resizeOriginY - event.clientY;
|
|
||||||
const nextHeight = this.clampHeight(this.resizeOriginHeight + delta, detached);
|
|
||||||
|
|
||||||
this.panelHeight.set(nextHeight);
|
|
||||||
|
|
||||||
if (!detached)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const originBottom = this.panelOriginTop + this.resizeOriginHeight;
|
|
||||||
|
|
||||||
this.panelTop.set(this.clamp(originBottom - nextHeight, 16, this.getMaxTop(nextHeight)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateBottomResize(event: MouseEvent, detached: boolean): void {
|
|
||||||
const delta = event.clientY - this.resizeOriginY;
|
|
||||||
|
|
||||||
this.panelHeight.set(this.clampHeight(this.resizeOriginHeight + delta, detached));
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateLeftResize(event: MouseEvent, detached: boolean): void {
|
|
||||||
const delta = this.resizeOriginX - event.clientX;
|
|
||||||
const nextWidth = this.clampWidth(this.resizeOriginWidth + delta, detached);
|
|
||||||
|
|
||||||
this.panelWidth.set(nextWidth);
|
|
||||||
|
|
||||||
if (!detached)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const originRight = this.panelOriginLeft + this.resizeOriginWidth;
|
|
||||||
|
|
||||||
this.panelLeft.set(this.clamp(originRight - nextWidth, 16, this.getMaxLeft(nextWidth)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateRightResize(event: MouseEvent, detached: boolean): void {
|
|
||||||
const delta = event.clientX - this.resizeOriginX;
|
|
||||||
|
|
||||||
this.panelWidth.set(this.clampWidth(this.resizeOriginWidth + delta, detached));
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateCornerResize(event: MouseEvent, detached: boolean): void {
|
|
||||||
const deltaX = event.clientX - this.resizeOriginX;
|
|
||||||
const deltaY = event.clientY - this.resizeOriginY;
|
|
||||||
|
|
||||||
this.panelWidth.set(this.clampWidth(this.resizeOriginWidth + deltaX, detached));
|
|
||||||
this.panelHeight.set(this.clampHeight(this.resizeOriginHeight + deltaY, detached));
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateDetachedPosition(event: MouseEvent): void {
|
|
||||||
const nextLeft = this.panelOriginLeft + (event.clientX - this.resizeOriginX);
|
|
||||||
const nextTop = this.panelOriginTop + (event.clientY - this.resizeOriginY);
|
|
||||||
|
|
||||||
this.panelLeft.set(this.clamp(nextLeft, 16, this.getMaxLeft(this.panelWidth())));
|
|
||||||
this.panelTop.set(this.clamp(nextTop, 16, this.getMaxTop(this.panelHeight())));
|
|
||||||
}
|
|
||||||
|
|
||||||
private clampHeight(height: number, detached?: boolean): number {
|
|
||||||
const maxHeight = detached
|
|
||||||
? Math.max(MIN_HEIGHT, window.innerHeight - 32)
|
|
||||||
: Math.floor(window.innerHeight * 0.75);
|
|
||||||
|
|
||||||
return Math.min(Math.max(height, MIN_HEIGHT), maxHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
private clampWidth(width: number, _detached?: boolean): number {
|
|
||||||
const maxWidth = Math.max(MIN_WIDTH, window.innerWidth - 32);
|
|
||||||
const minWidth = Math.min(MIN_WIDTH, maxWidth);
|
|
||||||
|
|
||||||
return Math.min(Math.max(width, minWidth), maxWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
private clampDetachedPosition(): void {
|
|
||||||
this.panelLeft.set(this.clamp(this.panelLeft(), 16, this.getMaxLeft(this.panelWidth())));
|
|
||||||
this.panelTop.set(this.clamp(this.panelTop(), 16, this.getMaxTop(this.panelHeight())));
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMaxLeft(width: number): number {
|
|
||||||
return Math.max(16, window.innerWidth - width - 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMaxTop(height: number): number {
|
|
||||||
return Math.max(16, window.innerHeight - height - 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
private clamp(value: number, min: number, max: number): number {
|
|
||||||
return Math.min(Math.max(value, min), max);
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadLayout(): void {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
|
|
||||||
if (!raw)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as PersistedLayout;
|
|
||||||
|
|
||||||
if (typeof parsed.height === 'number' && parsed.height >= MIN_HEIGHT)
|
|
||||||
this.panelHeight.set(parsed.height);
|
|
||||||
|
|
||||||
if (typeof parsed.width === 'number' && parsed.width >= MIN_WIDTH)
|
|
||||||
this.panelWidth.set(parsed.width);
|
|
||||||
} catch {
|
|
||||||
// Ignore corrupted storage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private persistLayout(): void {
|
|
||||||
try {
|
|
||||||
const layout: PersistedLayout = {
|
|
||||||
height: this.panelHeight(),
|
|
||||||
width: this.panelWidth()
|
|
||||||
};
|
|
||||||
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
|
|
||||||
} catch {
|
|
||||||
// Ignore storage failures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,16 +24,10 @@
|
|||||||
<header class="border-b border-border p-5">
|
<header class="border-b border-border p-5">
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2
|
<h2 id="screen-share-source-picker-title" class="text-lg font-semibold text-foreground">
|
||||||
id="screen-share-source-picker-title"
|
|
||||||
class="text-lg font-semibold text-foreground"
|
|
||||||
>
|
|
||||||
Choose what to share
|
Choose what to share
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p id="screen-share-source-picker-description" class="mt-1 text-sm text-muted-foreground">
|
||||||
id="screen-share-source-picker-description"
|
|
||||||
class="mt-1 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
Select a screen or window to start sharing.
|
Select a screen or window to start sharing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,11 +55,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="mt-4 flex flex-wrap gap-2" role="tablist" aria-label="Share source type">
|
||||||
class="mt-4 flex flex-wrap gap-2"
|
|
||||||
role="tablist"
|
|
||||||
aria-label="Share source type"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
class="inline-flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
@@ -139,11 +129,7 @@
|
|||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<span class="screen-share-source-picker__preview">
|
<span class="screen-share-source-picker__preview">
|
||||||
<img
|
<img [ngSrc]="source.thumbnail" [alt]="source.name" fill />
|
||||||
[ngSrc]="source.thumbnail"
|
|
||||||
[alt]="source.name"
|
|
||||||
fill
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<p class="mt-3 truncate font-medium">{{ source.name }}</p>
|
<p class="mt-3 truncate font-medium">{{ source.name }}</p>
|
||||||
@@ -170,13 +156,13 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<div class="flex min-h-52 items-center justify-center px-5 py-8 text-center">
|
<div class="flex min-h-52 items-center justify-center px-5 py-8 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-foreground">No {{ activeTab() === 'screen' ? 'screens' : 'windows' }} available</p>
|
<p class="text-sm font-medium text-foreground">
|
||||||
|
No {{ activeTab() === 'screen' ? 'screens' : 'windows' }} available
|
||||||
|
</p>
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
{{
|
{{ activeTab() === 'screen'
|
||||||
activeTab() === 'screen'
|
|
||||||
? 'No displays were reported by Electron right now.'
|
? 'No displays were reported by Electron right now.'
|
||||||
: 'Restore the window you want to share and try again.'
|
: 'Restore the window you want to share and try again.' }}
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -256,10 +256,7 @@ function handleSyncBatch(
|
|||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
if (hasAttachmentMetaMap(event.attachments)) {
|
if (hasAttachmentMetaMap(event.attachments)) {
|
||||||
attachments.registerSyncedAttachments(
|
attachments.registerSyncedAttachments(event.attachments);
|
||||||
event.attachments,
|
|
||||||
Object.fromEntries(event.messages.map((message) => [message.id, message.roomId]))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return from(processSyncBatch(event, db, attachments)).pipe(
|
return from(processSyncBatch(event, db, attachments)).pipe(
|
||||||
@@ -280,8 +277,6 @@ async function processSyncBatch(
|
|||||||
const toUpsert: Message[] = [];
|
const toUpsert: Message[] = [];
|
||||||
|
|
||||||
for (const incoming of event.messages) {
|
for (const incoming of event.messages) {
|
||||||
attachments.rememberMessageRoom(incoming.id, incoming.roomId);
|
|
||||||
|
|
||||||
const { message, changed } = await mergeIncomingMessage(incoming, db);
|
const { message, changed } = await mergeIncomingMessage(incoming, db);
|
||||||
|
|
||||||
if (incoming.isDeleted) {
|
if (incoming.isDeleted) {
|
||||||
@@ -297,31 +292,40 @@ async function processSyncBatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasAttachmentMetaMap(event.attachments)) {
|
if (hasAttachmentMetaMap(event.attachments)) {
|
||||||
queueWatchedAttachmentDownloads(event.attachments, attachments);
|
requestMissingImages(event.attachments, attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
return toUpsert;
|
return toUpsert;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Queue best-effort auto-downloads for watched-room attachments. */
|
/** Auto-requests any unavailable image attachments from any connected peer. */
|
||||||
function queueWatchedAttachmentDownloads(
|
function requestMissingImages(
|
||||||
attachmentMap: AttachmentMetaMap,
|
attachmentMap: AttachmentMetaMap,
|
||||||
attachments: AttachmentService
|
attachments: AttachmentService
|
||||||
): void {
|
): void {
|
||||||
for (const msgId of Object.keys(attachmentMap)) {
|
for (const [msgId, metas] of Object.entries(attachmentMap)) {
|
||||||
attachments.queueAutoDownloadsForMessage(msgId);
|
for (const meta of metas) {
|
||||||
|
if (!meta.isImage)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const atts = attachments.getForMessage(msgId);
|
||||||
|
const matchingAttachment = atts.find((attachment) => attachment.id === meta.id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
matchingAttachment &&
|
||||||
|
!matchingAttachment.available &&
|
||||||
|
!(matchingAttachment.receivedBytes && matchingAttachment.receivedBytes > 0)
|
||||||
|
) {
|
||||||
|
attachments.requestImageFromAnyPeer(msgId, matchingAttachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Saves an incoming chat message to DB and dispatches receiveMessage. */
|
/** Saves an incoming chat message to DB and dispatches receiveMessage. */
|
||||||
function handleChatMessage(
|
function handleChatMessage(
|
||||||
event: IncomingMessageEvent,
|
event: IncomingMessageEvent,
|
||||||
{
|
{ db, debugging, currentUser }: IncomingMessageContext
|
||||||
db,
|
|
||||||
debugging,
|
|
||||||
attachments,
|
|
||||||
currentUser
|
|
||||||
}: IncomingMessageContext
|
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
const msg = event.message;
|
const msg = event.message;
|
||||||
|
|
||||||
@@ -336,8 +340,6 @@ function handleChatMessage(
|
|||||||
if (isOwnMessage)
|
if (isOwnMessage)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
attachments.rememberMessageRoom(msg.id, msg.roomId);
|
|
||||||
|
|
||||||
trackBackgroundOperation(
|
trackBackgroundOperation(
|
||||||
db.saveMessage(msg),
|
db.saveMessage(msg),
|
||||||
debugging,
|
debugging,
|
||||||
@@ -490,11 +492,6 @@ function handleFileAnnounce(
|
|||||||
{ attachments }: IncomingMessageContext
|
{ attachments }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
attachments.handleFileAnnounce(event);
|
attachments.handleFileAnnounce(event);
|
||||||
|
|
||||||
if (event.messageId) {
|
|
||||||
attachments.queueAutoDownloadsForMessage(event.messageId, event.file?.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Sync-lifecycle effects for the messages store slice.
|
* Sync-lifecycle effects for the messages store slice.
|
||||||
*
|
*
|
||||||
* These effects manage the periodic sync polling, peer-connect
|
* These effects manage the periodic sync polling, peer-connect
|
||||||
* handshakes, and room-activation kickoff that keep message databases
|
* handshakes, and join-room kickoff that keep message databases
|
||||||
* in sync across peers.
|
* in sync across peers.
|
||||||
*
|
*
|
||||||
* Extracted from the monolithic MessagesEffects to keep each
|
* Extracted from the monolithic MessagesEffects to keep each
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
exhaustMap,
|
exhaustMap,
|
||||||
switchMap,
|
switchMap,
|
||||||
repeat,
|
repeat,
|
||||||
startWith
|
takeUntil
|
||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
import { MessagesActions } from './messages.actions';
|
import { MessagesActions } from './messages.actions';
|
||||||
import { RoomsActions } from '../rooms/rooms.actions';
|
import { RoomsActions } from '../rooms/rooms.actions';
|
||||||
@@ -103,13 +103,13 @@ export class MessagesSyncEffects {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When the user joins or views a room, sends a summary and inventory
|
* When the user joins a room, sends a summary and inventory
|
||||||
* request to every already-connected peer.
|
* request to every already-connected peer.
|
||||||
*/
|
*/
|
||||||
roomActivationSyncKickoff$ = createEffect(
|
joinRoomSyncKickoff$ = createEffect(
|
||||||
() =>
|
() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
ofType(RoomsActions.joinRoomSuccess),
|
||||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||||
mergeMap(([{ room }, currentRoom]) => {
|
mergeMap(([{ room }, currentRoom]) => {
|
||||||
const activeRoom = currentRoom || room;
|
const activeRoom = currentRoom || room;
|
||||||
@@ -152,30 +152,11 @@ export class MessagesSyncEffects {
|
|||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the polling cadence when the active room changes so the next
|
|
||||||
* room does not inherit a stale slow-poll delay.
|
|
||||||
*/
|
|
||||||
resetPeriodicSyncOnRoomActivation$ = createEffect(
|
|
||||||
() =>
|
|
||||||
this.actions$.pipe(
|
|
||||||
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
|
||||||
tap(() => {
|
|
||||||
this.lastSyncClean = false;
|
|
||||||
this.syncReset$.next();
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{ dispatch: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alternates between fast (10 s) and slow (15 min) sync intervals.
|
* Alternates between fast (10 s) and slow (15 min) sync intervals.
|
||||||
* Sends inventory requests to all connected peers for the active room.
|
* Sends inventory requests to all connected peers.
|
||||||
*/
|
*/
|
||||||
periodicSyncPoll$ = createEffect(() =>
|
periodicSyncPoll$ = createEffect(() =>
|
||||||
this.syncReset$.pipe(
|
|
||||||
startWith(undefined),
|
|
||||||
switchMap(() =>
|
|
||||||
timer(SYNC_POLL_FAST_MS).pipe(
|
timer(SYNC_POLL_FAST_MS).pipe(
|
||||||
repeat({
|
repeat({
|
||||||
delay: () =>
|
delay: () =>
|
||||||
@@ -183,6 +164,7 @@ export class MessagesSyncEffects {
|
|||||||
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS
|
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
takeUntil(this.syncReset$),
|
||||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||||
filter(
|
filter(
|
||||||
([, room]) =>
|
([, room]) =>
|
||||||
@@ -228,8 +210,6 @@ export class MessagesSyncEffects {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -64,12 +64,6 @@ export class MessagesEffects {
|
|||||||
mergeMap(async (messages) => {
|
mergeMap(async (messages) => {
|
||||||
const hydrated = await hydrateMessages(messages, this.db);
|
const hydrated = await hydrateMessages(messages, this.db);
|
||||||
|
|
||||||
for (const message of hydrated) {
|
|
||||||
this.attachments.rememberMessageRoom(message.id, message.roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
void this.attachments.requestAutoDownloadsForRoom(roomId);
|
|
||||||
|
|
||||||
return MessagesActions.loadMessagesSuccess({ messages: hydrated });
|
return MessagesActions.loadMessagesSuccess({ messages: hydrated });
|
||||||
}),
|
}),
|
||||||
catchError((error) =>
|
catchError((error) =>
|
||||||
@@ -110,8 +104,6 @@ export class MessagesEffects {
|
|||||||
replyToId
|
replyToId
|
||||||
};
|
};
|
||||||
|
|
||||||
this.attachments.rememberMessageRoom(message.id, message.roomId);
|
|
||||||
|
|
||||||
this.trackBackgroundOperation(
|
this.trackBackgroundOperation(
|
||||||
this.db.saveMessage(message),
|
this.db.saveMessage(message),
|
||||||
'Failed to persist outgoing chat message',
|
'Failed to persist outgoing chat message',
|
||||||
|
|||||||
@@ -25,26 +25,18 @@ export const RoomsActions = createActionGroup({
|
|||||||
'Search Servers Success': props<{ servers: ServerInfo[] }>(),
|
'Search Servers Success': props<{ servers: ServerInfo[] }>(),
|
||||||
'Search Servers Failure': props<{ error: string }>(),
|
'Search Servers Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
'Create Room': props<{
|
'Create Room': props<{ name: string; description?: string; topic?: string; isPrivate?: boolean; password?: string }>(),
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
topic?: string;
|
|
||||||
isPrivate?: boolean;
|
|
||||||
password?: string;
|
|
||||||
sourceId?: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
}>(),
|
|
||||||
'Create Room Success': props<{ room: Room }>(),
|
'Create Room Success': props<{ room: Room }>(),
|
||||||
'Create Room Failure': props<{ error: string }>(),
|
'Create Room Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
'Join Room': props<{ roomId: string; password?: string; serverInfo?: Partial<ServerInfo> & { name: string } }>(),
|
'Join Room': props<{ roomId: string; password?: string; serverInfo?: { name: string; description?: string; hostName?: string } }>(),
|
||||||
'Join Room Success': props<{ room: Room }>(),
|
'Join Room Success': props<{ room: Room }>(),
|
||||||
'Join Room Failure': props<{ error: string }>(),
|
'Join Room Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
'Leave Room': emptyProps(),
|
'Leave Room': emptyProps(),
|
||||||
'Leave Room Success': emptyProps(),
|
'Leave Room Success': emptyProps(),
|
||||||
|
|
||||||
'View Server': props<{ room: Room; skipBanCheck?: boolean }>(),
|
'View Server': props<{ room: Room }>(),
|
||||||
'View Server Success': props<{ room: Room }>(),
|
'View Server Success': props<{ room: Room }>(),
|
||||||
|
|
||||||
'Delete Room': props<{ roomId: string }>(),
|
'Delete Room': props<{ roomId: string }>(),
|
||||||
@@ -75,8 +67,6 @@ export const RoomsActions = createActionGroup({
|
|||||||
'Rename Channel': props<{ channelId: string; name: string }>(),
|
'Rename Channel': props<{ channelId: string; name: string }>(),
|
||||||
|
|
||||||
'Clear Search Results': emptyProps(),
|
'Clear Search Results': emptyProps(),
|
||||||
'Set Connecting': props<{ isConnecting: boolean }>(),
|
'Set Connecting': props<{ isConnecting: boolean }>()
|
||||||
'Set Signal Server Reconnecting': props<{ isReconnecting: boolean }>(),
|
|
||||||
'Set Signal Server Compatibility Error': props<{ message: string | null }>()
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,11 +32,7 @@ import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
|
|||||||
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
|
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import {
|
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||||
CLIENT_UPDATE_REQUIRED_MESSAGE,
|
|
||||||
ServerDirectoryService,
|
|
||||||
ServerSourceSelector
|
|
||||||
} from '../../core/services/server-directory.service';
|
|
||||||
import {
|
import {
|
||||||
ChatEvent,
|
ChatEvent,
|
||||||
Room,
|
Room,
|
||||||
@@ -48,7 +44,6 @@ import {
|
|||||||
} from '../../core/models/index';
|
} from '../../core/models/index';
|
||||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||||
import { ROOM_URL_PATTERN } from '../../core/constants';
|
|
||||||
import {
|
import {
|
||||||
findRoomMember,
|
findRoomMember,
|
||||||
removeRoomMember,
|
removeRoomMember,
|
||||||
@@ -100,7 +95,6 @@ function isWrongServer(
|
|||||||
|
|
||||||
interface RoomPresenceSignalingMessage {
|
interface RoomPresenceSignalingMessage {
|
||||||
type: string;
|
type: string;
|
||||||
reason?: string;
|
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
users?: { oderId: string; displayName: string }[];
|
users?: { oderId: string; displayName: string }[];
|
||||||
oderId?: string;
|
oderId?: string;
|
||||||
@@ -145,6 +139,7 @@ export class RoomsEffects {
|
|||||||
searchServers$ = createEffect(() =>
|
searchServers$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.searchServers),
|
ofType(RoomsActions.searchServers),
|
||||||
|
debounceTime(300),
|
||||||
switchMap(({ query }) =>
|
switchMap(({ query }) =>
|
||||||
this.serverDirectory.searchServers(query).pipe(
|
this.serverDirectory.searchServers(query).pipe(
|
||||||
map((servers) => RoomsActions.searchServersSuccess({ servers })),
|
map((servers) => RoomsActions.searchServersSuccess({ servers })),
|
||||||
@@ -154,69 +149,27 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Reconnects saved rooms so joined servers stay online while the app is running. */
|
|
||||||
keepSavedRoomsConnected$ = createEffect(
|
|
||||||
() =>
|
|
||||||
this.actions$.pipe(
|
|
||||||
ofType(
|
|
||||||
RoomsActions.loadRoomsSuccess,
|
|
||||||
RoomsActions.forgetRoomSuccess,
|
|
||||||
RoomsActions.deleteRoomSuccess,
|
|
||||||
UsersActions.loadCurrentUserSuccess,
|
|
||||||
UsersActions.setCurrentUser
|
|
||||||
),
|
|
||||||
withLatestFrom(
|
|
||||||
this.store.select(selectCurrentUser),
|
|
||||||
this.store.select(selectCurrentRoom),
|
|
||||||
this.store.select(selectSavedRooms)
|
|
||||||
),
|
|
||||||
tap(([
|
|
||||||
, user,
|
|
||||||
currentRoom,
|
|
||||||
savedRooms
|
|
||||||
]) => {
|
|
||||||
this.syncSavedRoomConnections(user ?? null, currentRoom, savedRooms);
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{ dispatch: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Creates a new room, saves it locally, and registers it with the server directory. */
|
/** Creates a new room, saves it locally, and registers it with the server directory. */
|
||||||
createRoom$ = createEffect(() =>
|
createRoom$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.createRoom),
|
ofType(RoomsActions.createRoom),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
switchMap(([{ name, description, topic, isPrivate, password, sourceId, sourceUrl }, currentUser]) => {
|
switchMap(([{ name, description, topic, isPrivate, password }, currentUser]) => {
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return of(RoomsActions.createRoomFailure({ error: 'Not logged in' }));
|
return of(RoomsActions.createRoomFailure({ error: 'Not logged in' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const allEndpoints = this.serverDirectory.servers();
|
|
||||||
const activeEndpoints = this.serverDirectory.activeServers();
|
|
||||||
const selectedEndpoint = allEndpoints.find((endpoint) =>
|
|
||||||
(sourceId && endpoint.id === sourceId)
|
|
||||||
|| (!!sourceUrl && endpoint.url === sourceUrl)
|
|
||||||
);
|
|
||||||
const endpoint = selectedEndpoint
|
|
||||||
?? activeEndpoints[0]
|
|
||||||
?? allEndpoints[0]
|
|
||||||
?? null;
|
|
||||||
const normalizedPassword = typeof password === 'string' ? password.trim() : '';
|
|
||||||
const room: Room = {
|
const room: Room = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
topic,
|
topic,
|
||||||
hostId: currentUser.id,
|
hostId: currentUser.id,
|
||||||
password: normalizedPassword || undefined,
|
|
||||||
hasPassword: normalizedPassword.length > 0,
|
|
||||||
isPrivate: isPrivate ?? false,
|
isPrivate: isPrivate ?? false,
|
||||||
|
password,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
userCount: 1,
|
userCount: 1,
|
||||||
maxUsers: 50,
|
maxUsers: 50
|
||||||
sourceId: endpoint?.id,
|
|
||||||
sourceName: endpoint?.name,
|
|
||||||
sourceUrl: endpoint?.url
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to local DB
|
// Save to local DB
|
||||||
@@ -231,17 +184,11 @@ export class RoomsEffects {
|
|||||||
ownerId: currentUser.id,
|
ownerId: currentUser.id,
|
||||||
ownerPublicKey: currentUser.oderId,
|
ownerPublicKey: currentUser.oderId,
|
||||||
hostName: currentUser.displayName,
|
hostName: currentUser.displayName,
|
||||||
password: normalizedPassword || null,
|
|
||||||
hasPassword: normalizedPassword.length > 0,
|
|
||||||
isPrivate: room.isPrivate,
|
isPrivate: room.isPrivate,
|
||||||
userCount: 1,
|
userCount: 1,
|
||||||
maxUsers: room.maxUsers || 50,
|
maxUsers: room.maxUsers || 50,
|
||||||
tags: []
|
tags: []
|
||||||
}, endpoint ? {
|
})
|
||||||
sourceId: endpoint.id,
|
|
||||||
sourceUrl: endpoint.url
|
|
||||||
} : undefined
|
|
||||||
)
|
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
return of(RoomsActions.createRoomSuccess({ room }));
|
return of(RoomsActions.createRoomSuccess({ room }));
|
||||||
@@ -269,35 +216,8 @@ export class RoomsEffects {
|
|||||||
// First check local DB
|
// First check local DB
|
||||||
return from(this.db.getRoom(roomId)).pipe(
|
return from(this.db.getRoom(roomId)).pipe(
|
||||||
switchMap((room) => {
|
switchMap((room) => {
|
||||||
const sourceSelector = serverInfo
|
|
||||||
? {
|
|
||||||
sourceId: serverInfo.sourceId,
|
|
||||||
sourceUrl: serverInfo.sourceUrl
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (room) {
|
if (room) {
|
||||||
const resolvedRoom: Room = {
|
return of(RoomsActions.joinRoomSuccess({ room }));
|
||||||
...room,
|
|
||||||
isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate,
|
|
||||||
sourceId: serverInfo?.sourceId ?? room.sourceId,
|
|
||||||
sourceName: serverInfo?.sourceName ?? room.sourceName,
|
|
||||||
sourceUrl: serverInfo?.sourceUrl ?? room.sourceUrl,
|
|
||||||
hasPassword:
|
|
||||||
typeof serverInfo?.hasPassword === 'boolean'
|
|
||||||
? serverInfo.hasPassword
|
|
||||||
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password)
|
|
||||||
};
|
|
||||||
|
|
||||||
this.db.updateRoom(room.id, {
|
|
||||||
sourceId: resolvedRoom.sourceId,
|
|
||||||
sourceName: resolvedRoom.sourceName,
|
|
||||||
sourceUrl: resolvedRoom.sourceUrl,
|
|
||||||
hasPassword: resolvedRoom.hasPassword,
|
|
||||||
isPrivate: resolvedRoom.isPrivate
|
|
||||||
});
|
|
||||||
|
|
||||||
return of(RoomsActions.joinRoomSuccess({ room: resolvedRoom }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in local DB but we have server info from search, create a room entry
|
// If not in local DB but we have server info from search, create a room entry
|
||||||
@@ -307,14 +227,11 @@ export class RoomsEffects {
|
|||||||
name: serverInfo.name,
|
name: serverInfo.name,
|
||||||
description: serverInfo.description,
|
description: serverInfo.description,
|
||||||
hostId: '', // Unknown, will be determined via signaling
|
hostId: '', // Unknown, will be determined via signaling
|
||||||
hasPassword: !!serverInfo.hasPassword,
|
isPrivate: !!password,
|
||||||
isPrivate: !!serverInfo.isPrivate,
|
password,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
userCount: 1,
|
userCount: 1,
|
||||||
maxUsers: 50,
|
maxUsers: 50
|
||||||
sourceId: serverInfo.sourceId,
|
|
||||||
sourceName: serverInfo.sourceName,
|
|
||||||
sourceUrl: serverInfo.sourceUrl
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to local DB for future reference
|
// Save to local DB for future reference
|
||||||
@@ -323,7 +240,7 @@ export class RoomsEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to get room info from server
|
// Try to get room info from server
|
||||||
return this.serverDirectory.getServer(roomId, sourceSelector).pipe(
|
return this.serverDirectory.getServer(roomId).pipe(
|
||||||
switchMap((serverData) => {
|
switchMap((serverData) => {
|
||||||
if (serverData) {
|
if (serverData) {
|
||||||
const newRoom: Room = {
|
const newRoom: Room = {
|
||||||
@@ -331,14 +248,11 @@ export class RoomsEffects {
|
|||||||
name: serverData.name,
|
name: serverData.name,
|
||||||
description: serverData.description,
|
description: serverData.description,
|
||||||
hostId: serverData.ownerId || '',
|
hostId: serverData.ownerId || '',
|
||||||
hasPassword: !!serverData.hasPassword,
|
|
||||||
isPrivate: serverData.isPrivate,
|
isPrivate: serverData.isPrivate,
|
||||||
|
password,
|
||||||
createdAt: serverData.createdAt || Date.now(),
|
createdAt: serverData.createdAt || Date.now(),
|
||||||
userCount: serverData.userCount,
|
userCount: serverData.userCount,
|
||||||
maxUsers: serverData.maxUsers,
|
maxUsers: serverData.maxUsers
|
||||||
sourceId: serverData.sourceId,
|
|
||||||
sourceName: serverData.sourceName,
|
|
||||||
sourceUrl: serverData.sourceUrl
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.db.saveRoom(newRoom);
|
this.db.saveRoom(newRoom);
|
||||||
@@ -364,15 +278,28 @@ export class RoomsEffects {
|
|||||||
() =>
|
() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess),
|
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectSavedRooms)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
tap(([
|
tap(([{ room }, user]) => {
|
||||||
{ room },
|
const wsUrl = this.serverDirectory.getWebSocketUrl();
|
||||||
user,
|
const oderId = user?.oderId || this.webrtc.peerId();
|
||||||
savedRooms
|
const displayName = user?.displayName || 'Anonymous';
|
||||||
]) => {
|
|
||||||
void this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, {
|
// Check if already connected to signaling server
|
||||||
showCompatibilityError: true
|
if (this.webrtc.isConnected()) {
|
||||||
|
this.webrtc.setCurrentServer(room.id);
|
||||||
|
this.webrtc.switchServer(room.id, oderId);
|
||||||
|
} else {
|
||||||
|
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
||||||
|
next: (connected) => {
|
||||||
|
if (connected) {
|
||||||
|
this.webrtc.setCurrentServer(room.id);
|
||||||
|
this.webrtc.identify(oderId, displayName);
|
||||||
|
this.webrtc.joinRoom(room.id, oderId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.router.navigate(['/room', room.id]);
|
this.router.navigate(['/room', room.id]);
|
||||||
})
|
})
|
||||||
@@ -384,38 +311,27 @@ export class RoomsEffects {
|
|||||||
viewServer$ = createEffect(() =>
|
viewServer$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.viewServer),
|
ofType(RoomsActions.viewServer),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectSavedRooms)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
switchMap(([
|
switchMap(([{ room }, user]) => {
|
||||||
{ room, skipBanCheck },
|
|
||||||
user,
|
|
||||||
savedRooms
|
|
||||||
]) => {
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
|
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const activateViewedRoom = () => {
|
|
||||||
const oderId = user.oderId || this.webrtc.peerId();
|
|
||||||
|
|
||||||
void this.connectToRoomSignaling(room, user, oderId, savedRooms, {
|
|
||||||
showCompatibilityError: true
|
|
||||||
});
|
|
||||||
|
|
||||||
this.router.navigate(['/room', room.id]);
|
|
||||||
return of(RoomsActions.viewServerSuccess({ room }));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (skipBanCheck) {
|
|
||||||
return activateViewedRoom();
|
|
||||||
}
|
|
||||||
|
|
||||||
return from(this.getBlockedRoomAccessActions(room.id, user)).pipe(
|
return from(this.getBlockedRoomAccessActions(room.id, user)).pipe(
|
||||||
switchMap((blockedActions) => {
|
switchMap((blockedActions) => {
|
||||||
if (blockedActions.length > 0) {
|
if (blockedActions.length > 0) {
|
||||||
return from(blockedActions);
|
return from(blockedActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return activateViewedRoom();
|
const oderId = user.oderId || this.webrtc.peerId();
|
||||||
|
|
||||||
|
if (this.webrtc.isConnected()) {
|
||||||
|
this.webrtc.setCurrentServer(room.id);
|
||||||
|
this.webrtc.switchServer(room.id, oderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['/room', room.id]);
|
||||||
|
return of(RoomsActions.viewServerSuccess({ room }));
|
||||||
}),
|
}),
|
||||||
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
|
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
|
||||||
);
|
);
|
||||||
@@ -516,12 +432,8 @@ export class RoomsEffects {
|
|||||||
|
|
||||||
this.serverDirectory.updateServer(roomId, {
|
this.serverDirectory.updateServer(roomId, {
|
||||||
currentOwnerId: currentUser.id,
|
currentOwnerId: currentUser.id,
|
||||||
actingRole: 'host',
|
|
||||||
ownerId: nextHostId,
|
ownerId: nextHostId,
|
||||||
ownerPublicKey: nextHostOderId
|
ownerPublicKey: nextHostOderId
|
||||||
}, {
|
|
||||||
sourceId: room.sourceId,
|
|
||||||
sourceUrl: room.sourceUrl
|
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
error: () => {}
|
error: () => {}
|
||||||
});
|
});
|
||||||
@@ -537,15 +449,6 @@ export class RoomsEffects {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUser && room) {
|
|
||||||
this.serverDirectory.notifyLeave(roomId, currentUser.id, {
|
|
||||||
sourceId: room.sourceId,
|
|
||||||
sourceUrl: room.sourceUrl
|
|
||||||
}).subscribe({
|
|
||||||
error: () => {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete from local DB
|
// Delete from local DB
|
||||||
this.db.deleteRoom(roomId);
|
this.db.deleteRoom(roomId);
|
||||||
|
|
||||||
@@ -582,11 +485,10 @@ export class RoomsEffects {
|
|||||||
if (!room)
|
if (!room)
|
||||||
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Room not found' }));
|
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Room not found' }));
|
||||||
|
|
||||||
const currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom);
|
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
|
||||||
const isOwner = currentUserRole === 'host';
|
const canManageCurrentRoom = currentRoom?.id === room.id && (currentUser.role === 'host' || currentUser.role === 'admin');
|
||||||
const canManageRoom = currentUserRole === 'host' || currentUserRole === 'admin';
|
|
||||||
|
|
||||||
if (!canManageRoom) {
|
if (!isOwner && !canManageCurrentRoom) {
|
||||||
return of(
|
return of(
|
||||||
RoomsActions.updateRoomSettingsFailure({
|
RoomsActions.updateRoomSettingsFailure({
|
||||||
error: 'Permission denied'
|
error: 'Permission denied'
|
||||||
@@ -594,57 +496,30 @@ export class RoomsEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(settings, 'password');
|
|
||||||
const normalizedPassword = typeof settings.password === 'string' ? settings.password.trim() : undefined;
|
|
||||||
const nextHasPassword = typeof settings.hasPassword === 'boolean'
|
|
||||||
? settings.hasPassword
|
|
||||||
: (hasPasswordUpdate
|
|
||||||
? !!normalizedPassword
|
|
||||||
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password));
|
|
||||||
const updatedSettings: RoomSettings = {
|
const updatedSettings: RoomSettings = {
|
||||||
name: settings.name ?? room.name,
|
name: settings.name ?? room.name,
|
||||||
description: settings.description ?? room.description,
|
description: settings.description ?? room.description,
|
||||||
topic: settings.topic ?? room.topic,
|
topic: settings.topic ?? room.topic,
|
||||||
isPrivate: settings.isPrivate ?? room.isPrivate,
|
isPrivate: settings.isPrivate ?? room.isPrivate,
|
||||||
password: hasPasswordUpdate ? (normalizedPassword || '') : room.password,
|
password: settings.password ?? room.password,
|
||||||
hasPassword: nextHasPassword,
|
|
||||||
maxUsers: settings.maxUsers ?? room.maxUsers
|
maxUsers: settings.maxUsers ?? room.maxUsers
|
||||||
};
|
};
|
||||||
const localRoomUpdates: Partial<Room> = {
|
|
||||||
...updatedSettings,
|
|
||||||
password: hasPasswordUpdate ? (normalizedPassword || undefined) : room.password,
|
|
||||||
hasPassword: nextHasPassword
|
|
||||||
};
|
|
||||||
const sharedSettings: RoomSettings = {
|
|
||||||
name: updatedSettings.name,
|
|
||||||
description: updatedSettings.description,
|
|
||||||
topic: updatedSettings.topic,
|
|
||||||
isPrivate: updatedSettings.isPrivate,
|
|
||||||
hasPassword: nextHasPassword,
|
|
||||||
maxUsers: updatedSettings.maxUsers,
|
|
||||||
password: hasPasswordUpdate ? (normalizedPassword || '') : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
this.db.updateRoom(room.id, localRoomUpdates);
|
this.db.updateRoom(room.id, updatedSettings);
|
||||||
|
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'room-settings-update',
|
type: 'room-settings-update',
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
settings: sharedSettings
|
settings: updatedSettings
|
||||||
});
|
});
|
||||||
|
|
||||||
if (canManageRoom) {
|
if (isOwner) {
|
||||||
this.serverDirectory.updateServer(room.id, {
|
this.serverDirectory.updateServer(room.id, {
|
||||||
currentOwnerId: currentUser.id,
|
currentOwnerId: currentUser.id,
|
||||||
actingRole: currentUserRole ?? undefined,
|
|
||||||
name: updatedSettings.name,
|
name: updatedSettings.name,
|
||||||
description: updatedSettings.description,
|
description: updatedSettings.description,
|
||||||
isPrivate: updatedSettings.isPrivate,
|
isPrivate: updatedSettings.isPrivate,
|
||||||
maxUsers: updatedSettings.maxUsers,
|
maxUsers: updatedSettings.maxUsers
|
||||||
password: hasPasswordUpdate ? (normalizedPassword || null) : undefined
|
|
||||||
}, {
|
|
||||||
sourceId: room.sourceId,
|
|
||||||
sourceUrl: room.sourceUrl
|
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
error: () => {}
|
error: () => {}
|
||||||
});
|
});
|
||||||
@@ -838,11 +713,7 @@ export class RoomsEffects {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [UsersActions.clearUsers(), ...joinActions];
|
||||||
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
|
|
||||||
UsersActions.clearUsers(),
|
|
||||||
...joinActions
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'user_joined': {
|
case 'user_joined': {
|
||||||
@@ -858,7 +729,6 @@ export class RoomsEffects {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
|
|
||||||
UsersActions.userJoined({
|
UsersActions.userJoined({
|
||||||
user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId))
|
user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId))
|
||||||
})
|
})
|
||||||
@@ -873,17 +743,7 @@ export class RoomsEffects {
|
|||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
this.knownVoiceUsers.delete(signalingMessage.oderId);
|
this.knownVoiceUsers.delete(signalingMessage.oderId);
|
||||||
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: false }), UsersActions.userLeft({ userId: signalingMessage.oderId })];
|
return [UsersActions.userLeft({ userId: signalingMessage.oderId })];
|
||||||
}
|
|
||||||
|
|
||||||
case 'access_denied': {
|
|
||||||
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
|
||||||
return EMPTY;
|
|
||||||
|
|
||||||
if (signalingMessage.reason !== 'SERVER_NOT_FOUND')
|
|
||||||
return EMPTY;
|
|
||||||
|
|
||||||
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -1085,20 +945,14 @@ export class RoomsEffects {
|
|||||||
description: typeof room.description === 'string' ? room.description : undefined,
|
description: typeof room.description === 'string' ? room.description : undefined,
|
||||||
topic: typeof room.topic === 'string' ? room.topic : undefined,
|
topic: typeof room.topic === 'string' ? room.topic : undefined,
|
||||||
hostId: typeof room.hostId === 'string' ? room.hostId : undefined,
|
hostId: typeof room.hostId === 'string' ? room.hostId : undefined,
|
||||||
hasPassword:
|
password: typeof room.password === 'string' ? room.password : undefined,
|
||||||
typeof room.hasPassword === 'boolean'
|
|
||||||
? room.hasPassword
|
|
||||||
: (typeof room.password === 'string' ? room.password.trim().length > 0 : undefined),
|
|
||||||
isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined,
|
isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined,
|
||||||
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
|
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
|
||||||
icon: typeof room.icon === 'string' ? room.icon : undefined,
|
icon: typeof room.icon === 'string' ? room.icon : undefined,
|
||||||
iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined,
|
iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined,
|
||||||
permissions: room.permissions ? { ...room.permissions } : undefined,
|
permissions: room.permissions ? { ...room.permissions } : undefined,
|
||||||
channels: Array.isArray(room.channels) ? room.channels : undefined,
|
channels: Array.isArray(room.channels) ? room.channels : undefined,
|
||||||
members: Array.isArray(room.members) ? room.members : undefined,
|
members: Array.isArray(room.members) ? room.members : undefined
|
||||||
sourceId: typeof room.sourceId === 'string' ? room.sourceId : undefined,
|
|
||||||
sourceName: typeof room.sourceName === 'string' ? room.sourceName : undefined,
|
|
||||||
sourceUrl: typeof room.sourceUrl === 'string' ? room.sourceUrl : undefined
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,7 +1005,7 @@ export class RoomsEffects {
|
|||||||
this.webrtc.sendToPeer(fromPeerId, {
|
this.webrtc.sendToPeer(fromPeerId, {
|
||||||
type: 'server-state-full',
|
type: 'server-state-full',
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
room: this.sanitizeRoomSnapshot(room),
|
room,
|
||||||
bans
|
bans
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -1221,11 +1075,7 @@ export class RoomsEffects {
|
|||||||
description: settings.description ?? room.description,
|
description: settings.description ?? room.description,
|
||||||
topic: settings.topic ?? room.topic,
|
topic: settings.topic ?? room.topic,
|
||||||
isPrivate: settings.isPrivate ?? room.isPrivate,
|
isPrivate: settings.isPrivate ?? room.isPrivate,
|
||||||
password: settings.password === '' ? undefined : room.password,
|
password: settings.password ?? room.password,
|
||||||
hasPassword:
|
|
||||||
typeof settings.hasPassword === 'boolean'
|
|
||||||
? settings.hasPassword
|
|
||||||
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password),
|
|
||||||
maxUsers: settings.maxUsers ?? room.maxUsers
|
maxUsers: settings.maxUsers ?? room.maxUsers
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1346,190 +1196,6 @@ export class RoomsEffects {
|
|||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
private async connectToRoomSignaling(
|
|
||||||
room: Room,
|
|
||||||
user: User | null,
|
|
||||||
resolvedOderId?: string,
|
|
||||||
savedRooms: Room[] = [],
|
|
||||||
options: { showCompatibilityError?: boolean } = {}
|
|
||||||
): Promise<void> {
|
|
||||||
const shouldShowCompatibilityError = options.showCompatibilityError ?? false;
|
|
||||||
const compatibilitySelector = this.resolveCompatibilitySelector(room);
|
|
||||||
const isCompatible = compatibilitySelector === null
|
|
||||||
? true
|
|
||||||
: await this.serverDirectory.ensureEndpointVersionCompatibility(compatibilitySelector);
|
|
||||||
|
|
||||||
if (!isCompatible) {
|
|
||||||
if (shouldShowCompatibilityError) {
|
|
||||||
this.store.dispatch(
|
|
||||||
RoomsActions.setSignalServerCompatibilityError({ message: CLIENT_UPDATE_REQUIRED_MESSAGE })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldShowCompatibilityError) {
|
|
||||||
this.store.dispatch(RoomsActions.setSignalServerCompatibilityError({ message: null }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsUrl = this.serverDirectory.getWebSocketUrl({
|
|
||||||
sourceId: room.sourceId,
|
|
||||||
sourceUrl: room.sourceUrl
|
|
||||||
});
|
|
||||||
const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId();
|
|
||||||
const displayName = user?.displayName || 'Anonymous';
|
|
||||||
const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl);
|
|
||||||
const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id);
|
|
||||||
const joinCurrentEndpointRooms = () => {
|
|
||||||
this.webrtc.setCurrentServer(room.id);
|
|
||||||
this.webrtc.identify(oderId, displayName, wsUrl);
|
|
||||||
|
|
||||||
for (const backgroundRoom of backgroundRooms) {
|
|
||||||
if (!this.webrtc.hasJoinedServer(backgroundRoom.id)) {
|
|
||||||
this.webrtc.joinRoom(backgroundRoom.id, oderId, wsUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.webrtc.hasJoinedServer(room.id)) {
|
|
||||||
this.webrtc.switchServer(room.id, oderId, wsUrl);
|
|
||||||
} else {
|
|
||||||
this.webrtc.joinRoom(room.id, oderId, wsUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.webrtc.isSignalingConnectedTo(wsUrl)) {
|
|
||||||
joinCurrentEndpointRooms();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
|
||||||
next: (connected) => {
|
|
||||||
if (!connected)
|
|
||||||
return;
|
|
||||||
|
|
||||||
joinCurrentEndpointRooms();
|
|
||||||
},
|
|
||||||
error: () => {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncSavedRoomConnections(user: User | null, currentRoom: Room | null, savedRooms: Room[]): void {
|
|
||||||
if (!user || savedRooms.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const watchedRoomId = this.extractRoomIdFromUrl(this.router.url);
|
|
||||||
const roomsToSync = currentRoom ? this.includeRoom(savedRooms, currentRoom) : savedRooms;
|
|
||||||
const roomsBySignalingUrl = new Map<string, Room[]>();
|
|
||||||
|
|
||||||
for (const room of roomsToSync) {
|
|
||||||
const wsUrl = this.serverDirectory.getWebSocketUrl({
|
|
||||||
sourceId: room.sourceId,
|
|
||||||
sourceUrl: room.sourceUrl
|
|
||||||
});
|
|
||||||
const groupedRooms = roomsBySignalingUrl.get(wsUrl) ?? [];
|
|
||||||
|
|
||||||
if (!groupedRooms.some((groupedRoom) => groupedRoom.id === room.id)) {
|
|
||||||
groupedRooms.push(room);
|
|
||||||
}
|
|
||||||
|
|
||||||
roomsBySignalingUrl.set(wsUrl, groupedRooms);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const groupedRooms of roomsBySignalingUrl.values()) {
|
|
||||||
const preferredRoom = groupedRooms.find((room) => room.id === watchedRoomId)
|
|
||||||
?? (currentRoom && groupedRooms.some((room) => room.id === currentRoom.id)
|
|
||||||
? currentRoom
|
|
||||||
: null)
|
|
||||||
?? groupedRooms[0]
|
|
||||||
?? null;
|
|
||||||
|
|
||||||
if (!preferredRoom) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldShowCompatibilityError = preferredRoom.id === watchedRoomId
|
|
||||||
|| (!!currentRoom && preferredRoom.id === currentRoom.id);
|
|
||||||
|
|
||||||
void this.connectToRoomSignaling(preferredRoom, user, user.oderId || this.webrtc.peerId(), roomsToSync, {
|
|
||||||
showCompatibilityError: shouldShowCompatibilityError
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveCompatibilitySelector(room: Room): ServerSourceSelector | undefined | null {
|
|
||||||
if (room.sourceId) {
|
|
||||||
const endpointById = this.serverDirectory.servers().find((entry) => entry.id === room.sourceId);
|
|
||||||
|
|
||||||
if (endpointById) {
|
|
||||||
return { sourceId: room.sourceId };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room.sourceUrl && this.serverDirectory.findServerByUrl(room.sourceUrl)) {
|
|
||||||
return { sourceUrl: room.sourceUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room.sourceUrl) {
|
|
||||||
return this.serverDirectory.findServerByUrl(room.sourceUrl)
|
|
||||||
? { sourceUrl: room.sourceUrl }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private includeRoom(rooms: Room[], room: Room): Room[] {
|
|
||||||
return rooms.some((candidate) => candidate.id === room.id)
|
|
||||||
? rooms
|
|
||||||
: [...rooms, room];
|
|
||||||
}
|
|
||||||
|
|
||||||
private getRoomsForSignalingUrl(rooms: Room[], wsUrl: string): Room[] {
|
|
||||||
const seenRoomIds = new Set<string>();
|
|
||||||
const matchingRooms: Room[] = [];
|
|
||||||
|
|
||||||
for (const room of rooms) {
|
|
||||||
if (seenRoomIds.has(room.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.serverDirectory.getWebSocketUrl({
|
|
||||||
sourceId: room.sourceId,
|
|
||||||
sourceUrl: room.sourceUrl
|
|
||||||
}) !== wsUrl) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
seenRoomIds.add(room.id);
|
|
||||||
matchingRooms.push(room);
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchingRooms;
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractRoomIdFromUrl(url: string): string | null {
|
|
||||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
|
||||||
|
|
||||||
return roomMatch ? roomMatch[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
|
|
||||||
if (room.hostId === currentUser.id || room.hostId === currentUser.oderId)
|
|
||||||
return 'host';
|
|
||||||
|
|
||||||
if (currentRoom?.id === room.id && currentUser.role)
|
|
||||||
return currentUser.role;
|
|
||||||
|
|
||||||
return findRoomMember(room.members ?? [], currentUser.id)?.role
|
|
||||||
|| findRoomMember(room.members ?? [], currentUser.oderId)?.role
|
|
||||||
|| null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPersistedCurrentUserId(): string | null {
|
private getPersistedCurrentUserId(): string | null {
|
||||||
return localStorage.getItem('metoyou_currentUserId');
|
return localStorage.getItem('metoyou_currentUserId');
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user