Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c7e535057 | |||
| 8f960be1e9 | |||
| 9a173792a4 | |||
| cb2c0495b9 | |||
| c3ef8e8800 | |||
| c862c2fe03 | |||
| 4faa62864d | |||
| 1cdd1c5d2b | |||
| 141de64767 | |||
| eb987ac672 | |||
| f8fd78d21a | |||
| 150c45c31a | |||
| 00adf39121 | |||
| 2b6e477c9a | |||
| 22d355a522 | |||
| 15c5952e29 | |||
| 781c05294f | |||
| 778e75bef5 | |||
| 7bf37ba510 | |||
| 3c04b5db26 | |||
| 45e0b09af8 | |||
| 106212ef3d |
@@ -1,4 +1,5 @@
|
|||||||
# 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
|
||||||
|
|||||||
43
.gitea/workflows/deploy-web-apps.yml
Normal file
43
.gitea/workflows/deploy-web-apps.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Deploy Web Apps
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: windows
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: powershell
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
env:
|
||||||
|
NODE_ENV: development
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install website dependencies
|
||||||
|
env:
|
||||||
|
NODE_ENV: development
|
||||||
|
run: npm ci --prefix website
|
||||||
|
|
||||||
|
- name: Build Toju web app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build Toju website
|
||||||
|
run: |
|
||||||
|
Push-Location website
|
||||||
|
npm run build
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
- name: Deploy both apps to IIS
|
||||||
|
run: >
|
||||||
|
./tools/deploy-web-apps.ps1
|
||||||
|
-WebsitePort 4341
|
||||||
|
-AppPort 4492
|
||||||
@@ -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
|
- `PORT=3001` changes the server port in local development and overrides the server app setting
|
||||||
|
|
||||||
If `SSL=true`, run `./generate-cert.sh` once.
|
If `SSL=true`, run `./generate-cert.sh` once.
|
||||||
|
|
||||||
@@ -25,6 +25,10 @@ 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 electron . --no-sandbox --disable-dev-shm-usage"
|
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage"
|
||||||
|
|||||||
58
electron/app/auto-start.ts
Normal file
58
electron/app/auto-start.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
121
electron/app/deep-links.ts
Normal file
121
electron/app/deep-links.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
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');
|
||||||
|
|
||||||
// Auto-detect Wayland vs X11 so the xdg-desktop-portal system picker
|
// Chromium chooses the Linux Ozone platform before Electron runs this file.
|
||||||
// works for screen capture on Wayland compositors
|
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland
|
||||||
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
|
// sessions so the browser process selects the correct backend early enough.
|
||||||
}
|
}
|
||||||
|
|
||||||
function networkFlags(): void {
|
function networkFlags(): void {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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,
|
||||||
@@ -24,6 +25,7 @@ export function registerAppLifecycle(): void {
|
|||||||
setupCqrsHandlers();
|
setupCqrsHandlers();
|
||||||
setupWindowControlHandlers();
|
setupWindowControlHandlers();
|
||||||
setupSystemHandlers();
|
setupSystemHandlers();
|
||||||
|
await synchronizeAutoStartSetting();
|
||||||
initializeDesktopUpdater();
|
initializeDesktopUpdater();
|
||||||
await createWindow();
|
await createWindow();
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ 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>;
|
||||||
@@ -44,6 +49,7 @@ 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 {
|
||||||
@@ -77,7 +83,8 @@ 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,
|
||||||
@@ -126,12 +133,21 @@ 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);
|
||||||
|
|
||||||
await setDefaultSink(SCREEN_SHARE_SINK_NAME);
|
// Set the default sink to the voice sink so that new app audio
|
||||||
await moveSinkInputs(SCREEN_SHARE_SINK_NAME, (sinkName) => !!sinkName && sinkName !== SCREEN_SHARE_SINK_NAME && sinkName !== VOICE_SINK_NAME);
|
// streams (received WebRTC voice) never land on the screenshare
|
||||||
|
// 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) {
|
||||||
@@ -148,6 +164,7 @@ 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();
|
||||||
|
|
||||||
@@ -166,6 +183,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -425,34 +443,52 @@ 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,
|
||||||
descendantProcessIds
|
descendantProcessInfo
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
listSinks(),
|
listSinks(),
|
||||||
listSinkInputDetails(),
|
listSinkInputDetails(),
|
||||||
collectDescendantProcessIds(process.pid)
|
collectDescendantProcessInfo(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) => {
|
||||||
if (!isAppOwnedSinkInput(sinkInput, descendantProcessIds)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
||||||
|
const appOwned = isAppOwnedSinkInput(sinkInput, descendantProcessInfo);
|
||||||
|
|
||||||
|
// App-owned streams must stay on the voice sink.
|
||||||
|
if (appOwned && sinkName !== VOICE_SINK_NAME) {
|
||||||
|
try {
|
||||||
|
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
||||||
|
} catch {
|
||||||
|
// Streams can disappear or be recreated while rerouting.
|
||||||
|
}
|
||||||
|
|
||||||
if (sinkName === VOICE_SINK_NAME) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Non-app streams sitting on the voice sink should be moved to the
|
||||||
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
// screenshare sink for desktop-audio capture.
|
||||||
} catch {
|
if (!appOwned && sinkName === VOICE_SINK_NAME) {
|
||||||
// Streams can disappear or be recreated while rerouting.
|
try {
|
||||||
|
await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME);
|
||||||
|
} catch {
|
||||||
|
// Streams can disappear or be recreated while rerouting.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -515,7 +551,7 @@ function startSinkInputRerouteLoop(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
routingState.rerouteIntervalId = setInterval(() => {
|
routingState.rerouteIntervalId = setInterval(() => {
|
||||||
void rerouteAppSinkInputsToVoiceSink();
|
void rerouteSinkInputs();
|
||||||
}, REROUTE_INTERVAL_MS);
|
}, REROUTE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,13 +564,108 @@ 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,
|
||||||
descendantProcessIds: ReadonlySet<string>
|
descendantProcessInfo: DescendantProcessInfo
|
||||||
): boolean {
|
): boolean {
|
||||||
const processId = sinkInput.properties['application.process.id'];
|
const processId = sinkInput.properties['application.process.id'];
|
||||||
|
|
||||||
return typeof processId === 'string' && descendantProcessIds.has(processId);
|
if (typeof processId === 'string' && descendantProcessInfo.ids.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(
|
||||||
@@ -697,31 +828,45 @@ async function listSinkInputDetails(): Promise<SinkInputDetails[]> {
|
|||||||
return entries.filter((entry) => !!entry.sinkIndex);
|
return entries.filter((entry) => !!entry.sinkIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<string>> {
|
async function collectDescendantProcessInfo(rootProcessId: number): Promise<DescendantProcessInfo> {
|
||||||
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid='], {
|
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm='], {
|
||||||
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 [pid, ppid] = line.split(/\s+/);
|
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||||
|
|
||||||
if (!pid || !ppid) {
|
if (!match) {
|
||||||
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) {
|
||||||
@@ -731,6 +876,12 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
|
|||||||
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;
|
||||||
@@ -741,7 +892,30 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return descendantIds;
|
return {
|
||||||
|
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,6 +12,7 @@ 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,
|
||||||
@@ -20,7 +21,10 @@ 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,6 +9,7 @@ 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,6 +57,7 @@ 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,
|
||||||
@@ -65,7 +66,10 @@ 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,6 +84,7 @@ 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;
|
||||||
@@ -93,6 +94,9 @@ 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,6 +6,7 @@ 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;
|
||||||
@@ -19,6 +20,7 @@ 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,
|
||||||
@@ -81,6 +83,9 @@ 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,
|
||||||
@@ -102,6 +107,9 @@ 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,6 +24,9 @@ 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;
|
||||||
|
|
||||||
@@ -50,4 +53,13 @@ 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,6 +30,8 @@ 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 = [
|
||||||
@@ -83,6 +85,57 @@ 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()
|
||||||
@@ -194,6 +247,10 @@ 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);
|
||||||
@@ -203,6 +260,8 @@ 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 };
|
||||||
@@ -271,6 +330,7 @@ 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,6 +1,10 @@
|
|||||||
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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,6 +4,7 @@ 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;
|
||||||
@@ -83,7 +84,24 @@ 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;
|
||||||
@@ -98,8 +116,10 @@ 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;
|
||||||
@@ -113,12 +133,14 @@ 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;
|
||||||
@@ -126,6 +148,7 @@ 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>;
|
||||||
@@ -139,6 +162,7 @@ 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'),
|
||||||
@@ -180,6 +204,7 @@ 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),
|
||||||
@@ -198,6 +223,17 @@ 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,6 +24,7 @@
|
|||||||
"@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",
|
||||||
@@ -45,11 +46,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.0.4",
|
"@angular/build": "^21.0.4",
|
||||||
"@angular/cli": "^21.2.1",
|
"@angular/cli": "^21.0.4",
|
||||||
"@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",
|
||||||
@@ -10816,6 +10818,13 @@
|
|||||||
"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",
|
||||||
@@ -12875,6 +12884,11 @@
|
|||||||
"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",
|
||||||
@@ -12968,6 +12982,22 @@
|
|||||||
"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",
|
||||||
@@ -22285,9 +22315,7 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -23745,7 +23773,6 @@
|
|||||||
"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"
|
||||||
@@ -29571,6 +29598,15 @@
|
|||||||
"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",
|
||||||
@@ -31161,6 +31197,12 @@
|
|||||||
"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 && electron . --no-sandbox --disable-dev-shm-usage",
|
"electron": "ng build && npm run build:electron && 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: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: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 electron . --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 node tools/launch-electron.js . --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,6 +70,7 @@
|
|||||||
"@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",
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
"@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",
|
||||||
@@ -120,6 +122,14 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
23
public/web.config
Normal file
23
public/web.config
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<system.webServer>
|
||||||
|
<staticContent>
|
||||||
|
<remove fileExtension=".wasm" />
|
||||||
|
<remove fileExtension=".webmanifest" />
|
||||||
|
<mimeMap fileExtension=".wasm" mimeType="application/wasm" />
|
||||||
|
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
|
||||||
|
</staticContent>
|
||||||
|
<rewrite>
|
||||||
|
<rules>
|
||||||
|
<rule name="Angular Routes" stopProcessing="true">
|
||||||
|
<match url=".*" />
|
||||||
|
<conditions logicalGrouping="MatchAll">
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="/index.html" />
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</rewrite>
|
||||||
|
</system.webServer>
|
||||||
|
</configuration>
|
||||||
Binary file not shown.
@@ -5,6 +5,7 @@ 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,13 +2,20 @@ 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() : '';
|
||||||
@@ -18,6 +25,51 @@ 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: {} };
|
||||||
@@ -52,10 +104,14 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { rawContents, parsed } = readRawVariables();
|
const { rawContents, parsed } = readRawVariables();
|
||||||
|
const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed;
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...parsed,
|
...remainingParsed,
|
||||||
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey),
|
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
||||||
releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl)
|
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.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';
|
||||||
|
|
||||||
@@ -65,7 +121,10 @@ 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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,3 +143,29 @@ 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,10 +1,19 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { ServerEntity, JoinRequestEntity } from '../../../entities';
|
import {
|
||||||
|
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,6 +11,7 @@ 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,6 +24,8 @@ 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,6 +34,8 @@ 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,7 +4,10 @@ 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';
|
||||||
@@ -51,7 +54,10 @@ 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,
|
||||||
|
|||||||
35
server/src/entities/ServerBanEntity.ts
Normal file
35
server/src/entities/ServerBanEntity.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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,6 +21,9 @@ 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;
|
||||||
|
|
||||||
|
|||||||
29
server/src/entities/ServerInviteEntity.ts
Normal file
29
server/src/entities/ServerInviteEntity.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
26
server/src/entities/ServerMembershipEntity.ts
Normal file
26
server/src/entities/ServerMembershipEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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,3 +1,6 @@
|
|||||||
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,23 +14,39 @@ import { deleteStaleJoinRequests } from './cqrs';
|
|||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
import {
|
import {
|
||||||
ensureVariablesConfig,
|
ensureVariablesConfig,
|
||||||
|
getServerHost,
|
||||||
getVariablesConfigPath,
|
getVariablesConfigPath,
|
||||||
hasKlipyApiKey
|
getServerPort,
|
||||||
|
getServerProtocol,
|
||||||
|
ServerHttpProtocol
|
||||||
} from './config/variables';
|
} from './config/variables';
|
||||||
import { setupWebSocket } from './websocket';
|
import { setupWebSocket } from './websocket';
|
||||||
|
|
||||||
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
|
function formatHostForUrl(host: string): string {
|
||||||
const PORT = process.env.PORT || 3001;
|
if (host.startsWith('[') || !host.includes(':')) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
function buildServer(app: ReturnType<typeof createApp>) {
|
return `[${host}]`;
|
||||||
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(`SSL=true but certs not found in ${certDir}`);
|
console.error(`HTTPS is enabled but certs were not found in ${certDir}`);
|
||||||
console.error('Run ./generate-cert.sh first.');
|
console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,17 +60,31 @@ function buildServer(app: ReturnType<typeof createApp>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
ensureVariablesConfig();
|
const variablesConfig = 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 (!hasKlipyApiKey()) {
|
if (
|
||||||
|
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);
|
const server = buildServer(app, serverProtocol);
|
||||||
|
|
||||||
setupWebSocket(server);
|
setupWebSocket(server);
|
||||||
|
|
||||||
@@ -64,14 +94,29 @@ 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);
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
const onListening = () => {
|
||||||
const proto = USE_SSL ? 'https' : 'http';
|
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||||
const wsProto = USE_SSL ? 'wss' : 'ws';
|
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
|
||||||
|
const localHostNames = [
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1',
|
||||||
|
'::1'
|
||||||
|
];
|
||||||
|
|
||||||
console.log(`MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`);
|
console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
|
||||||
console.log(` REST API: ${proto}://localhost:${PORT}/api`);
|
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
|
||||||
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
|
console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`);
|
||||||
});
|
|
||||||
|
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) => {
|
||||||
|
|||||||
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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,3 +1,7 @@
|
|||||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||||
|
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
||||||
|
|
||||||
export const serverMigrations = [InitialSchema1000000000000];
|
export const serverMigrations = [
|
||||||
|
InitialSchema1000000000000,
|
||||||
|
ServerAccessControl1000000000001
|
||||||
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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);
|
||||||
@@ -12,5 +13,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
57
server/src/routes/invite-utils.ts
Normal file
57
server/src/routes/invite-utils.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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)}`;
|
||||||
|
}
|
||||||
331
server/src/routes/invites.ts
Normal file
331
server/src/routes/invites.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
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,29 +1,90 @@
|
|||||||
import { Router } from 'express';
|
import { Response, Router } from 'express';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { ServerPayload, JoinRequestPayload } from '../cqrs/types';
|
import { ServerPayload } from '../cqrs/types';
|
||||||
import {
|
import {
|
||||||
getAllPublicServers,
|
getAllPublicServers,
|
||||||
getServerById,
|
getServerById,
|
||||||
getUserById,
|
getUserById,
|
||||||
upsertServer,
|
upsertServer,
|
||||||
deleteServer,
|
deleteServer,
|
||||||
createJoinRequest,
|
|
||||||
getPendingRequestsForServer
|
getPendingRequestsForServer
|
||||||
} from '../cqrs';
|
} from '../cqrs';
|
||||||
import { notifyServerOwner } from '../websocket/broadcast';
|
import {
|
||||||
|
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();
|
||||||
|
|
||||||
async function enrichServer(server: ServerPayload) {
|
function normalizeRole(role: unknown): string | null {
|
||||||
|
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 {
|
||||||
...server,
|
...publicServer,
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -54,17 +115,30 @@ router.get('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
|
const {
|
||||||
|
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,
|
||||||
@@ -74,25 +148,216 @@ router.post('/', async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await upsertServer(server);
|
await upsertServer(server);
|
||||||
res.status(201).json(server);
|
await ensureServerMembership(server.id, ownerId);
|
||||||
|
|
||||||
|
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 { currentOwnerId, ...updates } = req.body;
|
const {
|
||||||
|
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 (existing.ownerId !== authenticatedOwnerId)
|
if (
|
||||||
|
existing.ownerId !== authenticatedOwnerId &&
|
||||||
|
!isAllowedRole(normalizedRole, ['host', 'admin'])
|
||||||
|
) {
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() };
|
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
|
||||||
|
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(server);
|
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||||
|
});
|
||||||
|
|
||||||
|
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) => {
|
||||||
@@ -128,32 +393,6 @@ 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;
|
||||||
@@ -170,4 +409,15 @@ 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;
|
||||||
|
|||||||
390
server/src/services/server-access.service.ts
Normal file
390
server/src/services/server-access.service.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
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,36 +1,59 @@
|
|||||||
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;
|
||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
||||||
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||||
|
|
||||||
|
return normalized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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 && cu.displayName)
|
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
|
||||||
.map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' }));
|
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
||||||
|
|
||||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
user.oderId = String(message['oderId'] || connectionId);
|
user.oderId = String(message['oderId'] || connectionId);
|
||||||
user.displayName = String(message['displayName'] || 'Anonymous');
|
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<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);
|
||||||
user.viewedServerId = sid;
|
user.viewedServerId = sid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
||||||
|
|
||||||
sendServerUsers(user, sid);
|
sendServerUsers(user, sid);
|
||||||
|
|
||||||
@@ -38,7 +61,7 @@ function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId:
|
|||||||
broadcastToServer(sid, {
|
broadcastToServer(sid, {
|
||||||
type: 'user_joined',
|
type: 'user_joined',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName ?? 'Anonymous',
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
serverId: sid
|
serverId: sid
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
@@ -49,7 +72,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
|
|||||||
|
|
||||||
user.viewedServerId = viewSid;
|
user.viewedServerId = viewSid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`);
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
||||||
|
|
||||||
sendServerUsers(user, viewSid);
|
sendServerUsers(user, viewSid);
|
||||||
}
|
}
|
||||||
@@ -70,8 +93,9 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
|||||||
broadcastToServer(leaveSid, {
|
broadcastToServer(leaveSid, {
|
||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName ?? 'Anonymous',
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
serverId: leaveSid
|
serverId: leaveSid,
|
||||||
|
serverIds: Array.from(user.serverIds)
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +145,7 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleWebSocketMessage(connectionId: string, message: WsMessage): void {
|
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
if (!user)
|
if (!user)
|
||||||
@@ -133,7 +157,7 @@ export function handleWebSocketMessage(connectionId: string, message: WsMessage)
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'join_server':
|
case 'join_server':
|
||||||
handleJoinServer(user, message, connectionId);
|
await handleJoinServer(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'view_server':
|
case 'view_server':
|
||||||
|
|||||||
@@ -9,39 +9,87 @@ 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() });
|
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set(), lastPong: now });
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('pong', () => {
|
||||||
|
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());
|
||||||
|
|
||||||
handleWebSocketMessage(connectionId, message);
|
await 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', () => {
|
||||||
const user = connectedUsers.get(connectionId);
|
removeDeadConnection(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,4 +6,6 @@ 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,47 +50,6 @@
|
|||||||
<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,6 +17,11 @@ 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,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
inject,
|
inject,
|
||||||
HostListener
|
HostListener
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -35,6 +36,15 @@ 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: [
|
||||||
@@ -50,7 +60,7 @@ import {
|
|||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App implements OnInit {
|
export class App implements OnInit, OnDestroy {
|
||||||
store = inject(Store);
|
store = inject(Store);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
desktopUpdates = inject(DesktopAppUpdateService);
|
desktopUpdates = inject(DesktopAppUpdateService);
|
||||||
@@ -63,6 +73,7 @@ export class App implements OnInit {
|
|||||||
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 {
|
||||||
@@ -80,6 +91,8 @@ export class App implements OnInit {
|
|||||||
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());
|
||||||
@@ -87,8 +100,12 @@ export class App implements OnInit {
|
|||||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
if (this.router.url !== '/login' && this.router.url !== '/register') {
|
if (!this.isPublicRoute(this.router.url)) {
|
||||||
this.router.navigate(['/login']).catch(() => {});
|
this.router.navigate(['/login'], {
|
||||||
|
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);
|
||||||
@@ -116,6 +133,11 @@ export class App implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.deepLinkCleanup?.();
|
||||||
|
this.deepLinkCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
openNetworkSettings(): void {
|
openNetworkSettings(): void {
|
||||||
this.settingsModal.open('network');
|
this.settingsModal.open('network');
|
||||||
}
|
}
|
||||||
@@ -131,4 +153,78 @@ export class App implements OnInit {
|
|||||||
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,6 +71,7 @@ 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;
|
||||||
@@ -80,6 +81,9 @@ 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 {
|
||||||
@@ -88,6 +92,7 @@ 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[];
|
||||||
}
|
}
|
||||||
@@ -265,14 +270,14 @@ export interface ChatEvent {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
settings?: RoomSettings;
|
settings?: Partial<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?: Room;
|
room?: Partial<Room>;
|
||||||
channels?: Channel[];
|
channels?: Channel[];
|
||||||
members?: RoomMember[];
|
members?: RoomMember[];
|
||||||
ban?: BanEntry;
|
ban?: BanEntry;
|
||||||
@@ -292,11 +297,13 @@ 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,6 +5,7 @@ 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';
|
||||||
@@ -12,6 +13,7 @@ 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,
|
||||||
@@ -145,9 +147,14 @@ 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);
|
||||||
@@ -190,6 +197,24 @@ 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 {
|
||||||
@@ -201,6 +226,44 @@ 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) ?? [];
|
||||||
@@ -219,6 +282,7 @@ 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) {
|
||||||
@@ -276,8 +340,15 @@ 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)) {
|
||||||
@@ -306,6 +377,7 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,9 +447,9 @@ export class AttachmentService {
|
|||||||
* message to all connected peers.
|
* message to all connected peers.
|
||||||
*
|
*
|
||||||
* 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. Inline-preview media ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES}
|
* 3. Peers watching the message's server can request any
|
||||||
* are immediately streamed as chunked base-64.
|
* auto-download-eligible media on demand.
|
||||||
*
|
*
|
||||||
* @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.
|
||||||
@@ -437,10 +509,6 @@ 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) ?? [];
|
||||||
@@ -482,6 +550,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -772,6 +841,38 @@ 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}:`;
|
||||||
|
|
||||||
@@ -867,6 +968,12 @@ 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 ||
|
||||||
@@ -1167,6 +1274,38 @@ 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,7 +218,14 @@ 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)';
|
||||||
const consoleMetadata = this.extractConsoleMetadata(rawMessage);
|
// Use only string args for label/message extraction so that
|
||||||
|
// 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 = 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
|
export type SettingsPage = 'general' | '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>('network');
|
readonly activePage = signal<SettingsPage>('general');
|
||||||
readonly targetServerId = signal<string | null>(null);
|
readonly targetServerId = signal<string | null>(null);
|
||||||
|
|
||||||
open(page: SettingsPage = 'network', serverId?: string): void {
|
open(page: SettingsPage = 'general', 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,7 +19,12 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Observable, Subject } from 'rxjs';
|
import {
|
||||||
|
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';
|
||||||
@@ -71,6 +76,7 @@ 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;
|
||||||
@@ -87,13 +93,18 @@ 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 lastJoinedServer: JoinedServerInfo | null = null;
|
private readonly lastJoinedServerBySignalUrl = new Map<string, JoinedServerInfo>();
|
||||||
private readonly memberServerIds = new Set<string>();
|
private readonly memberServerIdsBySignalUrl = new Map<string, 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 server they were discovered from. */
|
/** Maps each remote peer ID to the shared servers they currently belong to. */
|
||||||
private readonly peerServerMap = new Map<string, string>();
|
private readonly peerServerMap = new Map<string, Set<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>();
|
||||||
@@ -109,6 +120,7 @@ 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);
|
||||||
@@ -131,6 +143,7 @@ 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(() => {
|
||||||
@@ -165,20 +178,12 @@ 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!);
|
||||||
@@ -187,7 +192,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.signalingManager.sendRawMessage(msg),
|
sendRawMessage: (msg: Record<string, unknown>) => this.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(),
|
||||||
@@ -220,6 +225,7 @@ 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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -227,23 +233,6 @@ 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));
|
||||||
|
|
||||||
@@ -272,6 +261,8 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,37 +279,145 @@ export class WebRTCService implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSignalingMessage(message: IncomingSignalingMessage): void {
|
private ensureSignalingManager(signalUrl: string): SignalingManager {
|
||||||
|
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', { type: message.type });
|
this.logger.info('Signaling message', {
|
||||||
|
signalUrl,
|
||||||
|
type: message.type
|
||||||
|
});
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case SIGNALING_TYPE_CONNECTED:
|
case SIGNALING_TYPE_CONNECTED:
|
||||||
this.handleConnectedSignalingMessage(message);
|
this.handleConnectedSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_SERVER_USERS:
|
case SIGNALING_TYPE_SERVER_USERS:
|
||||||
this.handleServerUsersSignalingMessage(message);
|
this.handleServerUsersSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_USER_JOINED:
|
case SIGNALING_TYPE_USER_JOINED:
|
||||||
this.handleUserJoinedSignalingMessage(message);
|
this.handleUserJoinedSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_USER_LEFT:
|
case SIGNALING_TYPE_USER_LEFT:
|
||||||
this.handleUserLeftSignalingMessage(message);
|
this.handleUserLeftSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_OFFER:
|
case SIGNALING_TYPE_OFFER:
|
||||||
this.handleOfferSignalingMessage(message);
|
this.handleOfferSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_ANSWER:
|
case SIGNALING_TYPE_ANSWER:
|
||||||
this.handleAnswerSignalingMessage(message);
|
this.handleAnswerSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case SIGNALING_TYPE_ICE_CANDIDATE:
|
case SIGNALING_TYPE_ICE_CANDIDATE:
|
||||||
this.handleIceCandidateSignalingMessage(message);
|
this.handleIceCandidateSignalingMessage(message, signalUrl);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -326,104 +425,161 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleConnectedSignalingMessage(message: IncomingSignalingMessage): void {
|
private handleConnectedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||||
this.logger.info('Server connected', { oderId: message.oderId });
|
this.logger.info('Server connected', {
|
||||||
|
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): void {
|
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): 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;
|
||||||
|
|
||||||
const existing = this.peerManager.activePeerConnections.get(user.oderId);
|
this.peerSignalingUrlMap.set(user.oderId, signalUrl);
|
||||||
const healthy = this.isPeerHealthy(existing);
|
|
||||||
|
|
||||||
if (existing && !healthy) {
|
if (message.serverId) {
|
||||||
this.logger.info('Removing stale peer before recreate', { oderId: user.oderId });
|
this.trackPeerInServer(user.oderId, message.serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.peerManager.activePeerConnections.get(user.oderId);
|
||||||
|
|
||||||
|
if (this.canReusePeerConnection(existing)) {
|
||||||
|
this.logger.info('Reusing active peer connection', {
|
||||||
|
connectionState: existing?.connection.connectionState ?? 'unknown',
|
||||||
|
dataChannelState: existing?.dataChannel?.readyState ?? 'missing',
|
||||||
|
oderId: user.oderId,
|
||||||
|
serverId: message.serverId,
|
||||||
|
signalUrl
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
this.logger.info('Removing failed peer before recreate', {
|
||||||
|
connectionState: existing.connection.connectionState,
|
||||||
|
dataChannelState: existing.dataChannel?.readyState ?? 'missing',
|
||||||
|
oderId: user.oderId,
|
||||||
|
serverId: message.serverId,
|
||||||
|
signalUrl
|
||||||
|
});
|
||||||
this.peerManager.removePeer(user.oderId);
|
this.peerManager.removePeer(user.oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (healthy)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
this.logger.info('Create peer connection to existing user', {
|
this.logger.info('Create peer connection to existing user', {
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
serverId: message.serverId
|
serverId: message.serverId,
|
||||||
|
signalUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
this.peerManager.createPeerConnection(user.oderId, true);
|
this.peerManager.createPeerConnection(user.oderId, true);
|
||||||
this.peerManager.createAndSendOffer(user.oderId);
|
void this.peerManager.createAndSendOffer(user.oderId);
|
||||||
|
|
||||||
if (message.serverId) {
|
|
||||||
this.peerServerMap.set(user.oderId, message.serverId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage): void {
|
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||||
this.logger.info('User joined', {
|
this.logger.info('User joined', {
|
||||||
displayName: message.displayName,
|
displayName: message.displayName,
|
||||||
oderId: message.oderId
|
oderId: message.oderId,
|
||||||
|
signalUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (message.serverId) {
|
||||||
|
this.serverSignalingUrlMap.set(message.serverId, signalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.oderId) {
|
||||||
|
this.peerSignalingUrlMap.set(message.oderId, signalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.oderId && message.serverId) {
|
||||||
|
this.trackPeerInServer(message.oderId, message.serverId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void {
|
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): 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) {
|
||||||
this.peerManager.removePeer(message.oderId);
|
const hasRemainingSharedServers = Array.isArray(message.serverIds)
|
||||||
this.peerServerMap.delete(message.oderId);
|
? this.replacePeerSharedServers(message.oderId, message.serverIds)
|
||||||
|
: (message.serverId
|
||||||
|
? this.untrackPeerFromServer(message.oderId, message.serverId)
|
||||||
|
: false);
|
||||||
|
|
||||||
|
if (!hasRemainingSharedServers) {
|
||||||
|
this.peerManager.removePeer(message.oderId);
|
||||||
|
this.peerServerMap.delete(message.oderId);
|
||||||
|
this.peerSignalingUrlMap.delete(message.oderId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleOfferSignalingMessage(message: IncomingSignalingMessage): void {
|
private handleOfferSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): 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.peerServerMap.set(fromUserId, offerEffectiveServer);
|
this.trackPeerInServer(fromUserId, offerEffectiveServer);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.peerManager.handleOffer(fromUserId, sdp);
|
this.peerManager.handleOffer(fromUserId, sdp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleAnswerSignalingMessage(message: IncomingSignalingMessage): void {
|
private handleAnswerSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): 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): void {
|
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,8 +594,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((peerServerId, peerId) => {
|
this.peerServerMap.forEach((peerServerIds, peerId) => {
|
||||||
if (peerServerId !== serverId) {
|
if (!peerServerIds.has(serverId)) {
|
||||||
peersToClose.push(peerId);
|
peersToClose.push(peerId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -450,6 +606,7 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +630,57 @@ 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> {
|
||||||
return this.signalingManager.connect(serverUrl);
|
const manager = this.ensureSignalingManager(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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -483,7 +690,17 @@ 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> {
|
||||||
return this.signalingManager.ensureConnected(timeoutMs);
|
if (this.isAnySignalingConnected()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const manager of this.signalingManagers.values()) {
|
||||||
|
if (await manager.ensureConnected(timeoutMs)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -492,7 +709,32 @@ 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 {
|
||||||
this.signalingManager.sendSignalingMessage(message, this._localPeerId());
|
const targetPeerId = message.to;
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -501,7 +743,50 @@ 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 {
|
||||||
this.signalingManager.sendRawMessage(message);
|
const targetPeerId = typeof message['targetUserId'] === 'string' ? message['targetUserId'] : null;
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -513,6 +798,24 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -521,13 +824,32 @@ 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): void {
|
identify(oderId: string, displayName: string, signalUrl?: string): void {
|
||||||
this.lastIdentifyCredentials = { oderId,
|
const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME;
|
||||||
displayName };
|
|
||||||
|
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
|
this.lastIdentifyCredentials = { oderId,
|
||||||
|
displayName: normalizedDisplayName };
|
||||||
|
|
||||||
|
const identifyMessage = {
|
||||||
|
type: SIGNALING_TYPE_IDENTIFY,
|
||||||
oderId,
|
oderId,
|
||||||
displayName });
|
displayName: normalizedDisplayName
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signalUrl) {
|
||||||
|
this.sendRawMessageToSignalUrl(signalUrl, identifyMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedManagers = this.getConnectedSignalingManagers();
|
||||||
|
|
||||||
|
if (connectedManagers.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { manager } of connectedManagers) {
|
||||||
|
manager.sendRawMessage(identifyMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -536,13 +858,27 @@ 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): void {
|
joinRoom(roomId: string, userId: string, signalUrl?: string): void {
|
||||||
this.lastJoinedServer = { serverId: roomId,
|
const resolvedSignalUrl = signalUrl
|
||||||
userId };
|
?? this.serverSignalingUrlMap.get(roomId)
|
||||||
|
?? this.getCurrentSignalingUrl();
|
||||||
|
|
||||||
this.memberServerIds.add(roomId);
|
if (!resolvedSignalUrl) {
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
this.logger.warn('[signaling] Cannot join room without a signaling URL', { roomId });
|
||||||
serverId: roomId });
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -552,26 +888,46 @@ 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): void {
|
switchServer(serverId: string, userId: string, signalUrl?: string): void {
|
||||||
this.lastJoinedServer = { serverId,
|
const resolvedSignalUrl = signalUrl
|
||||||
userId };
|
?? this.serverSignalingUrlMap.get(serverId)
|
||||||
|
?? this.getCurrentSignalingUrl();
|
||||||
|
|
||||||
if (this.memberServerIds.has(serverId)) {
|
if (!resolvedSignalUrl) {
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
|
this.logger.warn('[signaling] Cannot switch server without a signaling URL', { serverId });
|
||||||
serverId });
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
this.memberServerIds.add(serverId);
|
memberServerIds.add(serverId);
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
||||||
serverId });
|
type: SIGNALING_TYPE_JOIN_SERVER,
|
||||||
|
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()
|
||||||
});
|
});
|
||||||
@@ -588,25 +944,47 @@ export class WebRTCService implements OnDestroy {
|
|||||||
*/
|
*/
|
||||||
leaveRoom(serverId?: string): void {
|
leaveRoom(serverId?: string): void {
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
this.memberServerIds.delete(serverId);
|
const resolvedSignalUrl = this.serverSignalingUrlMap.get(serverId);
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
|
||||||
serverId });
|
if (resolvedSignalUrl) {
|
||||||
|
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.memberServerIds.size === 0) {
|
if (this.getJoinedServerCount() === 0) {
|
||||||
this.fullCleanup();
|
this.fullCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.memberServerIds.forEach((sid) => {
|
for (const [signalUrl, memberServerIds] of this.memberServerIdsBySignalUrl.entries()) {
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
for (const sid of memberServerIds) {
|
||||||
serverId: sid });
|
this.sendRawMessageToSignalUrl(signalUrl, {
|
||||||
});
|
type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||||
|
serverId: sid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.memberServerIds.clear();
|
this.memberServerIdsBySignalUrl.clear();
|
||||||
|
this.serverSignalingUrlMap.clear();
|
||||||
this.fullCleanup();
|
this.fullCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,12 +994,18 @@ 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.memberServerIds.has(serverId);
|
return this.isJoinedServer(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> {
|
||||||
return this.memberServerIds;
|
const joinedServerIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) {
|
||||||
|
memberServerIds.forEach((serverId) => joinedServerIds.add(serverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinedServerIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -876,11 +1260,15 @@ 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.leaveRoom();
|
this.peerSignalingUrlMap.clear();
|
||||||
|
this.lastJoinedServerBySignalUrl.clear();
|
||||||
|
this.memberServerIdsBySignalUrl.clear();
|
||||||
|
this.serverSignalingUrlMap.clear();
|
||||||
this.mediaManager.stopVoiceHeartbeat();
|
this.mediaManager.stopVoiceHeartbeat();
|
||||||
this.signalingManager.close();
|
this.destroyAllSignalingManagers();
|
||||||
this._isSignalingConnected.set(false);
|
this._isSignalingConnected.set(false);
|
||||||
this._hasEverConnected.set(false);
|
this._hasEverConnected.set(false);
|
||||||
this._hasConnectionError.set(false);
|
this._hasConnectionError.set(false);
|
||||||
@@ -896,6 +1284,7 @@ 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();
|
||||||
@@ -907,6 +1296,7 @@ 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. */
|
||||||
@@ -916,15 +1306,14 @@ export class WebRTCService implements OnDestroy {
|
|||||||
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
|
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if a peer connection exists and its data channel is open. */
|
/** Returns true if a peer connection is still alive enough to finish negotiating. */
|
||||||
private isPeerHealthy(peer: import('./webrtc').PeerData | undefined): boolean {
|
private canReusePeerConnection(peer: import('./webrtc').PeerData | undefined): boolean {
|
||||||
if (!peer)
|
if (!peer)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const connState = peer.connection?.connectionState;
|
const connState = peer.connection?.connectionState;
|
||||||
const dcState = peer.dataChannel?.readyState;
|
|
||||||
|
|
||||||
return connState === 'connected' && dcState === 'open';
|
return connState !== 'closed' && connState !== 'failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
private handlePeerControlMessage(event: ChatEvent): void {
|
private handlePeerControlMessage(event: ChatEvent): void {
|
||||||
@@ -973,10 +1362,25 @@ 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 cb - The new callback interface to wire into this manager.
|
* @param nextCallbacks - The new callback interface to wire into this manager.
|
||||||
*/
|
*/
|
||||||
setCallbacks(cb: MediaManagerCallbacks): void {
|
setCallbacks(nextCallbacks: MediaManagerCallbacks): void {
|
||||||
this.callbacks = cb;
|
this.callbacks = nextCallbacks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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,28 +485,21 @@ export class MediaManager {
|
|||||||
if (!this.localMediaStream)
|
if (!this.localMediaStream)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const localAudioTrack = this.localMediaStream.getAudioTracks()[0] || null;
|
const localStream = this.localMediaStream;
|
||||||
const localVideoTrack = this.localMediaStream.getVideoTracks()[0] || null;
|
const localAudioTrack = localStream.getAudioTracks()[0] || null;
|
||||||
|
const localVideoTrack = localStream.getVideoTracks()[0] || null;
|
||||||
|
|
||||||
peers.forEach((peerData, peerId) => {
|
peers.forEach((peerData, peerId) => {
|
||||||
if (localAudioTrack) {
|
if (localAudioTrack) {
|
||||||
let audioSender =
|
const audioTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_AUDIO, {
|
||||||
peerData.audioSender ||
|
preferredSender: peerData.audioSender,
|
||||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO);
|
excludedSenders: [peerData.screenAudioSender]
|
||||||
|
});
|
||||||
if (!audioSender) {
|
const audioSender = audioTransceiver.sender;
|
||||||
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 ||
|
||||||
@@ -515,29 +508,25 @@ 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((e) => this.logger.error('audio replaceTrack failed', e));
|
.catch((error) => this.logger.error('audio replaceTrack failed', error));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localVideoTrack) {
|
if (localVideoTrack) {
|
||||||
let videoSender =
|
const videoTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_VIDEO, {
|
||||||
peerData.videoSender ||
|
preferredSender: peerData.videoSender,
|
||||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_VIDEO);
|
excludedSenders: [peerData.screenVideoSender]
|
||||||
|
});
|
||||||
if (!videoSender) {
|
const videoSender = videoTransceiver.sender;
|
||||||
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 ||
|
||||||
@@ -546,16 +535,64 @@ 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((e) => this.logger.error('video replaceTrack failed', e));
|
.catch((error) => this.logger.error('video replaceTrack failed', error));
|
||||||
}
|
}
|
||||||
|
|
||||||
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,7 +127,9 @@ 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) {
|
||||||
@@ -151,6 +153,10 @@ 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 }))
|
||||||
@@ -158,6 +164,10 @@ 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,6 +9,7 @@ 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);
|
||||||
|
|
||||||
@@ -34,10 +35,10 @@ export function handleRemoteTrack(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const compositeStream = buildCompositeRemoteStream(state, remotePeerId, track);
|
const compositeStream = buildCompositeRemoteStream(state, remotePeerId, track);
|
||||||
const voiceStream = isVoiceAudioTrack(context, event, remotePeerId)
|
const voiceStream = isVoiceAudioTrack(track, isScreenAudio)
|
||||||
? buildAudioOnlyStream(state.remotePeerVoiceStreams.get(remotePeerId), track)
|
? buildAudioOnlyStream(state.remotePeerVoiceStreams.get(remotePeerId), track)
|
||||||
: null;
|
: null;
|
||||||
const screenShareStream = isScreenShareTrack(context, event, remotePeerId)
|
const screenShareStream = isScreenShareTrack(track, isScreenAudio)
|
||||||
? buildScreenShareStream(state.remotePeerScreenShareStreams.get(remotePeerId), track)
|
? buildScreenShareStream(state.remotePeerScreenShareStreams.get(remotePeerId), track)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -53,6 +54,12 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +68,7 @@ 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) {
|
||||||
@@ -79,6 +87,8 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,11 +162,20 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,20 +243,12 @@ function publishRemoteStreamUpdate(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVoiceAudioTrack(
|
function isVoiceAudioTrack(track: MediaStreamTrack, isScreenAudio: boolean): boolean {
|
||||||
context: PeerConnectionManagerContext,
|
return track.kind === TRACK_KIND_AUDIO && !isScreenAudio;
|
||||||
event: RTCTrackEvent,
|
|
||||||
remotePeerId: string
|
|
||||||
): boolean {
|
|
||||||
return event.track.kind === TRACK_KIND_AUDIO && !isScreenShareAudioTrack(context, event, remotePeerId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isScreenShareTrack(
|
function isScreenShareTrack(track: MediaStreamTrack, isScreenAudio: boolean): boolean {
|
||||||
context: PeerConnectionManagerContext,
|
return track.kind === TRACK_KIND_VIDEO || isScreenAudio;
|
||||||
event: RTCTrackEvent,
|
|
||||||
remotePeerId: string
|
|
||||||
): boolean {
|
|
||||||
return event.track.kind === TRACK_KIND_VIDEO || isScreenShareAudioTrack(context, event, remotePeerId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isScreenShareAudioTrack(
|
function isScreenShareAudioTrack(
|
||||||
@@ -255,12 +266,34 @@ 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 event.transceiver !== voiceAudioTransceiver;
|
return !matchesTransceiver(event.transceiver, voiceAudioTransceiver);
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioTransceivers = peerData.connection.getTransceivers().filter((transceiver) =>
|
const audioTransceivers = peerData.connection.getTransceivers().filter((transceiver) =>
|
||||||
@@ -272,3 +305,52 @@ 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
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,
|
||||||
|
usingElectronDesktopCapture: boolean
|
||||||
|
): boolean {
|
||||||
|
// Chromium display-media capture can use own-audio suppression on modern
|
||||||
|
// builds. The Electron desktop-capturer fallback cannot, so keep the old
|
||||||
|
// Windows mute behavior only for that fallback path.
|
||||||
|
return includeSystemAudio && usingElectronDesktopCapture && 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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,8 +9,7 @@ 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,
|
||||||
@@ -18,6 +17,10 @@ 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.
|
||||||
@@ -45,103 +48,9 @@ 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;
|
||||||
@@ -155,22 +64,39 @@ 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.
|
||||||
@@ -190,10 +116,12 @@ export class ScreenShareManager {
|
|||||||
/**
|
/**
|
||||||
* Begin screen sharing.
|
* Begin screen sharing.
|
||||||
*
|
*
|
||||||
* 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 other Electron builds, uses desktop capture. In browser contexts, uses
|
* On Windows Electron builds, prefers `getDisplayMedia` with system audio
|
||||||
* `getDisplayMedia`.
|
* so the separate mic `getUserMedia` stream is not disrupted; falls back to
|
||||||
|
* 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}.
|
||||||
@@ -205,7 +133,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.isElectronDesktopCaptureAvailable();
|
const electronDesktopCaptureAvailable = this.desktopElectronScreenShareCapture.isAvailable();
|
||||||
|
|
||||||
let captureMethod: ScreenShareCaptureMethod | null = null;
|
let captureMethod: ScreenShareCaptureMethod | null = null;
|
||||||
|
|
||||||
@@ -216,13 +144,13 @@ export class ScreenShareManager {
|
|||||||
this.stopScreenShare();
|
this.stopScreenShare();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.awaitPendingLinuxAudioRoutingReset();
|
await this.linuxElectronScreenShareCapture.awaitPendingReset();
|
||||||
|
|
||||||
this.activeScreenStream = null;
|
this.activeScreenStream = null;
|
||||||
|
|
||||||
if (shareOptions.includeSystemAudio && this.isLinuxElectronAudioRoutingSupported()) {
|
if (shareOptions.includeSystemAudio && this.linuxElectronScreenShareCapture.isSupported()) {
|
||||||
try {
|
try {
|
||||||
this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset);
|
this.activeScreenStream = await this.linuxElectronScreenShareCapture.startCapture(shareOptions, preset);
|
||||||
captureMethod = 'linux-electron';
|
captureMethod = 'linux-electron';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowIfScreenShareAborted(error);
|
this.rethrowIfScreenShareAborted(error);
|
||||||
@@ -230,16 +158,28 @@ export class ScreenShareManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.activeScreenStream && shareOptions.includeSystemAudio && !electronDesktopCaptureAvailable) {
|
if (!this.activeScreenStream && shareOptions.includeSystemAudio) {
|
||||||
try {
|
try {
|
||||||
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset);
|
this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset);
|
||||||
captureMethod = 'display-media';
|
captureMethod = 'display-media';
|
||||||
|
|
||||||
if (this.activeScreenStream.getAudioTracks().length === 0) {
|
if (this.activeScreenStream.getAudioTracks().length === 0) {
|
||||||
this.logger.warn('getDisplayMedia did not provide system audio; trying Electron desktop capture');
|
if (electronDesktopCaptureAvailable) {
|
||||||
this.activeScreenStream.getTracks().forEach((track) => track.stop());
|
// On Windows Electron, keep the getDisplayMedia stream for video
|
||||||
this.activeScreenStream = null;
|
// rather than falling through to getUserMedia desktop audio which
|
||||||
captureMethod = null;
|
// 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 = null;
|
||||||
|
captureMethod = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowIfScreenShareAborted(error);
|
this.rethrowIfScreenShareAborted(error);
|
||||||
@@ -249,7 +189,7 @@ export class ScreenShareManager {
|
|||||||
|
|
||||||
if (!this.activeScreenStream && electronDesktopCaptureAvailable) {
|
if (!this.activeScreenStream && electronDesktopCaptureAvailable) {
|
||||||
try {
|
try {
|
||||||
const electronCapture = await this.startWithElectronDesktopCapturer(shareOptions, preset);
|
const electronCapture = await this.desktopElectronScreenShareCapture.startCapture(shareOptions, preset);
|
||||||
|
|
||||||
this.activeScreenStream = electronCapture.stream;
|
this.activeScreenStream = electronCapture.stream;
|
||||||
shareOptions.includeSystemAudio = electronCapture.includeSystemAudio;
|
shareOptions.includeSystemAudio = electronCapture.includeSystemAudio;
|
||||||
@@ -261,7 +201,7 @@ export class ScreenShareManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.activeScreenStream) {
|
if (!this.activeScreenStream) {
|
||||||
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset);
|
this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset);
|
||||||
captureMethod = 'display-media';
|
captureMethod = 'display-media';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +248,7 @@ export class ScreenShareManager {
|
|||||||
this.activeScreenStream = null;
|
this.activeScreenStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scheduleLinuxAudioRoutingReset();
|
this.linuxElectronScreenShareCapture.scheduleReset();
|
||||||
|
|
||||||
this.screenAudioStream = null;
|
this.screenAudioStream = null;
|
||||||
this.activeScreenPreset = null;
|
this.activeScreenPreset = null;
|
||||||
@@ -390,26 +330,6 @@ 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
|
||||||
@@ -420,63 +340,16 @@ 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.shouldSuppressRemotePlaybackDuringShare(includeSystemAudio, captureMethod)
|
&& this.desktopElectronScreenShareCapture.shouldSuppressRemotePlaybackDuringShare(
|
||||||
|
includeSystemAudio,
|
||||||
|
captureMethod === 'electron-desktop'
|
||||||
|
),
|
||||||
|
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.
|
||||||
*
|
*
|
||||||
@@ -555,6 +428,11 @@ 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 });
|
||||||
@@ -585,6 +463,11 @@ 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));
|
||||||
@@ -628,109 +511,6 @@ 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');
|
||||||
@@ -742,425 +522,6 @@ 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,6 +20,10 @@ 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 { Router } from '@angular/router';
|
import { ActivatedRoute, 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,6 +42,7 @@ 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. */
|
||||||
@@ -72,6 +73,14 @@ 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) => {
|
||||||
@@ -82,6 +91,10 @@ export class LoginComponent {
|
|||||||
|
|
||||||
/** Navigate to the registration page. */
|
/** Navigate to the registration page. */
|
||||||
goRegister() {
|
goRegister() {
|
||||||
this.router.navigate(['/register']);
|
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||||
|
|
||||||
|
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 { Router } from '@angular/router';
|
import { ActivatedRoute, 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,6 +43,7 @@ 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. */
|
||||||
@@ -74,6 +75,14 @@ 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) => {
|
||||||
@@ -84,6 +93,10 @@ export class RegisterComponent {
|
|||||||
|
|
||||||
/** Navigate to the login page. */
|
/** Navigate to the login page. */
|
||||||
goLogin() {
|
goLogin() {
|
||||||
this.router.navigate(['/login']);
|
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||||
|
|
||||||
|
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' });
|
this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId });
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ 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,
|
||||||
@@ -23,6 +26,7 @@ interface TypingSignalingMessage {
|
|||||||
type: string;
|
type: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
oderId: string;
|
oderId: string;
|
||||||
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -36,6 +40,9 @@ 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);
|
||||||
@@ -47,8 +54,10 @@ 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();
|
||||||
|
|
||||||
@@ -77,6 +86,17 @@ 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 {
|
||||||
|
|||||||
85
src/app/features/invite/invite.component.html
Normal file
85
src/app/features/invite/invite.component.html
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<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>
|
||||||
192
src/app/features/invite/invite.component.ts
Normal file
192
src/app/features/invite/invite.component.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,11 +261,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- Other Online Users -->
|
<!-- Other Online Users -->
|
||||||
@if (onlineUsersFiltered().length > 0) {
|
@if (onlineRoomUsers().length > 0) {
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Online - {{ onlineUsersFiltered().length }}</h4>
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Online - {{ onlineRoomUsers().length }}</h4>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@for (user of onlineUsersFiltered(); track user.id) {
|
@for (user of onlineRoomUsers(); track user.id) {
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40 group/user"
|
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40 group/user"
|
||||||
(contextmenu)="openUserContextMenu($event, user)"
|
(contextmenu)="openUserContextMenu($event, user)"
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- No other users message -->
|
<!-- No other users message -->
|
||||||
@if (onlineUsersFiltered().length === 0 && offlineRoomMembers().length === 0) {
|
@if (onlineRoomUsers().length === 0 && offlineRoomMembers().length === 0) {
|
||||||
<div class="text-center py-4 text-muted-foreground">
|
<div class="text-center py-4 text-muted-foreground">
|
||||||
<p class="text-sm">No other users in this server</p>
|
<p class="text-sm">No other users in this server</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -104,15 +104,30 @@ export class RoomsSidePanelComponent {
|
|||||||
textChannels = this.store.selectSignal(selectTextChannels);
|
textChannels = this.store.selectSignal(selectTextChannels);
|
||||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||||
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
||||||
offlineRoomMembers = computed(() => {
|
roomMemberIdentifiers = computed(() => {
|
||||||
const current = this.currentUser();
|
const identifiers = new Set<string>();
|
||||||
const onlineIds = new Set(this.onlineUsers().map((user) => user.oderId || user.id));
|
|
||||||
|
|
||||||
if (current) {
|
for (const member of this.roomMembers()) {
|
||||||
onlineIds.add(current.oderId || current.id);
|
this.addIdentifiers(identifiers, member);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.roomMembers().filter((member) => !onlineIds.has(this.roomMemberKey(member)));
|
return identifiers;
|
||||||
|
});
|
||||||
|
onlineRoomUsers = computed(() => {
|
||||||
|
const memberIdentifiers = this.roomMemberIdentifiers();
|
||||||
|
|
||||||
|
return this.onlineUsers().filter((user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user));
|
||||||
|
});
|
||||||
|
offlineRoomMembers = computed(() => {
|
||||||
|
const onlineIdentifiers = new Set<string>();
|
||||||
|
|
||||||
|
for (const user of this.onlineRoomUsers()) {
|
||||||
|
this.addIdentifiers(onlineIdentifiers, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addIdentifiers(onlineIdentifiers, this.currentUser());
|
||||||
|
|
||||||
|
return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member));
|
||||||
});
|
});
|
||||||
knownUserCount = computed(() => {
|
knownUserCount = computed(() => {
|
||||||
const memberIds = new Set(
|
const memberIds = new Set(
|
||||||
@@ -151,18 +166,36 @@ export class RoomsSidePanelComponent {
|
|||||||
volumeMenuPeerId = signal('');
|
volumeMenuPeerId = signal('');
|
||||||
volumeMenuDisplayName = signal('');
|
volumeMenuDisplayName = signal('');
|
||||||
|
|
||||||
onlineUsersFiltered() {
|
|
||||||
const current = this.currentUser();
|
|
||||||
const currentId = current?.id;
|
|
||||||
const currentOderId = current?.oderId;
|
|
||||||
|
|
||||||
return this.onlineUsers().filter((user) => user.id !== currentId && user.oderId !== currentOderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private roomMemberKey(member: RoomMember): string {
|
private roomMemberKey(member: RoomMember): string {
|
||||||
return member.oderId || member.id;
|
return member.oderId || member.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string } | null | undefined): void {
|
||||||
|
if (!entity)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (entity.id) {
|
||||||
|
identifiers.add(entity.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.oderId) {
|
||||||
|
identifiers.add(entity.oderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchesIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string }): boolean {
|
||||||
|
return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
|
||||||
|
const current = this.currentUser();
|
||||||
|
|
||||||
|
return !!current && (
|
||||||
|
(typeof entity.id === 'string' && entity.id === current.id)
|
||||||
|
|| (typeof entity.oderId === 'string' && entity.oderId === current.oderId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
canManageChannels(): boolean {
|
canManageChannels(): boolean {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|||||||
@@ -117,6 +117,17 @@
|
|||||||
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"
|
||||||
@@ -153,6 +164,9 @@
|
|||||||
<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>
|
||||||
}
|
}
|
||||||
@@ -160,9 +174,9 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (error()) {
|
@if (joinErrorMessage() || 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">{{ error() }}</p>
|
<p class="text-sm text-destructive">{{ joinErrorMessage() || error() }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -181,6 +195,41 @@
|
|||||||
</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
|
||||||
@@ -249,6 +298,24 @@
|
|||||||
/>
|
/>
|
||||||
</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"
|
||||||
@@ -263,22 +330,21 @@
|
|||||||
>
|
>
|
||||||
</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">
|
||||||
@@ -291,7 +357,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="createServer()"
|
(click)="createServer()"
|
||||||
[disabled]="!newServerName()"
|
[disabled]="!newServerName() || !newServerSourceId"
|
||||||
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,6 +13,7 @@ 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';
|
||||||
@@ -39,6 +40,7 @@ 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';
|
||||||
@@ -73,6 +75,7 @@ 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;
|
||||||
|
|
||||||
@@ -82,9 +85,15 @@ 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);
|
||||||
@@ -93,6 +102,7 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
newServerTopic = signal('');
|
newServerTopic = signal('');
|
||||||
newServerPrivate = signal(false);
|
newServerPrivate = signal(false);
|
||||||
newServerPassword = signal('');
|
newServerPassword = signal('');
|
||||||
|
newServerSourceId = '';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -135,20 +145,12 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.dispatch(
|
await this.attemptJoinServer(server);
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +178,8 @@ 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.newServerPrivate() ? this.newServerPassword() : undefined
|
password: this.newServerPassword().trim() || undefined,
|
||||||
|
sourceId: this.newServerSourceId || undefined
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -198,6 +201,22 @@ 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];
|
||||||
}
|
}
|
||||||
@@ -223,12 +242,72 @@ 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,
|
||||||
isPrivate: !!room.password,
|
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!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;
|
||||||
|
|
||||||
@@ -271,5 +350,6 @@ 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,6 +70,41 @@
|
|||||||
</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,10 +7,12 @@ 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';
|
||||||
@@ -19,6 +21,7 @@ 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,
|
||||||
@@ -31,6 +34,7 @@ import {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
ContextMenuComponent,
|
ContextMenuComponent,
|
||||||
@@ -46,6 +50,7 @@ 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);
|
||||||
@@ -59,6 +64,10 @@ 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() {
|
||||||
@@ -105,27 +114,20 @@ export class ServersRailComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
const roomWsUrl = this.serverDirectory.getWebSocketUrl({
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
});
|
||||||
|
const currentWsUrl = this.webrtc.getCurrentSignalingUrl();
|
||||||
|
|
||||||
if (voiceServerId && voiceServerId !== room.id) {
|
this.prepareVoiceContext(room);
|
||||||
this.voiceSession.setViewingVoiceServer(false);
|
|
||||||
} else if (voiceServerId === room.id) {
|
|
||||||
this.voiceSession.setViewingVoiceServer(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.webrtc.hasJoinedServer(room.id)) {
|
if (this.webrtc.hasJoinedServer(room.id) && roomWsUrl === currentWsUrl) {
|
||||||
this.store.dispatch(RoomsActions.viewServer({ room }));
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||||
|
this.store.dispatch(RoomsActions.viewServer({ room,
|
||||||
|
skipBanCheck: true }));
|
||||||
} else {
|
} else {
|
||||||
this.store.dispatch(
|
await this.attemptJoinRoom(room);
|
||||||
RoomsActions.joinRoom({
|
|
||||||
roomId: room.id,
|
|
||||||
serverInfo: {
|
|
||||||
name: room.name,
|
|
||||||
description: room.description,
|
|
||||||
hostName: room.hostId || 'Unknown'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +136,22 @@ 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];
|
||||||
}
|
}
|
||||||
@@ -226,4 +244,106 @@ 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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/* 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,21 +9,35 @@
|
|||||||
/>
|
/>
|
||||||
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
|
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
(click)="testAllServers()"
|
@if (hasMissingDefaultServers()) {
|
||||||
[disabled]="isTesting()"
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
(click)="restoreDefaultServers()"
|
||||||
<ng-icon
|
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||||
name="lucideRefreshCw"
|
>
|
||||||
class="w-3.5 h-3.5"
|
Restore Defaults
|
||||||
[class.animate-spin]="isTesting()"
|
</button>
|
||||||
/>
|
}
|
||||||
Test All
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
(click)="testAllServers()"
|
||||||
|
[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"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideRefreshCw"
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
[class.animate-spin]="isTesting()"
|
||||||
|
/>
|
||||||
|
Test All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
<p class="text-xs text-muted-foreground mb-3">
|
||||||
|
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">
|
||||||
@@ -41,6 +55,7 @@
|
|||||||
[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">
|
||||||
@@ -53,13 +68,17 @@
|
|||||||
@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) {
|
@if (!server.isActive && server.status !== 'incompatible') {
|
||||||
<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="Set as active"
|
title="Activate"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideCheck"
|
name="lucideCheck"
|
||||||
@@ -67,8 +86,22 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (!server.isDefault) {
|
@if (server.isActive && hasMultipleActiveServers()) {
|
||||||
<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"
|
||||||
@@ -103,6 +136,7 @@
|
|||||||
/>
|
/>
|
||||||
</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,7 +2,8 @@
|
|||||||
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';
|
||||||
@@ -13,7 +14,8 @@ 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';
|
||||||
@@ -34,7 +36,8 @@ 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'
|
||||||
@@ -43,6 +46,10 @@ 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 = '';
|
||||||
@@ -91,6 +98,14 @@ 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,6 +95,84 @@
|
|||||||
[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,6 +53,10 @@ 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);
|
||||||
|
|
||||||
@@ -72,6 +76,10 @@ 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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -86,21 +94,67 @@ export class ServerSettingsComponent {
|
|||||||
if (!room)
|
if (!room)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
const normalizedPassword = this.roomPassword.trim();
|
||||||
|
const settings: {
|
||||||
|
description: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
|
isPrivate: boolean;
|
||||||
|
maxUsers: number;
|
||||||
|
name: string;
|
||||||
|
password?: string;
|
||||||
|
} = {
|
||||||
|
name: this.roomName,
|
||||||
|
description: this.roomDescription,
|
||||||
|
isPrivate: this.isPrivate(),
|
||||||
|
maxUsers: this.maxUsers
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.passwordAction() === 'remove') {
|
||||||
|
settings.password = '';
|
||||||
|
settings.hasPassword = false;
|
||||||
|
} else if (normalizedPassword) {
|
||||||
|
settings.password = normalizedPassword;
|
||||||
|
settings.hasPassword = true;
|
||||||
|
}
|
||||||
|
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
RoomsActions.updateRoomSettings({
|
RoomsActions.updateRoomSettings({
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
settings: {
|
settings
|
||||||
name: this.roomName,
|
|
||||||
description: this.roomDescription,
|
|
||||||
isPrivate: this.isPrivate(),
|
|
||||||
maxUsers: this.maxUsers
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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,6 +116,9 @@
|
|||||||
<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
|
||||||
}
|
}
|
||||||
@@ -157,6 +160,9 @@
|
|||||||
<!-- 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,6 +31,7 @@ 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';
|
||||||
@@ -48,6 +49,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
|
GeneralSettingsComponent,
|
||||||
NetworkSettingsComponent,
|
NetworkSettingsComponent,
|
||||||
VoiceSettingsComponent,
|
VoiceSettingsComponent,
|
||||||
UpdatesSettingsComponent,
|
UpdatesSettingsComponent,
|
||||||
@@ -89,6 +91,9 @@ 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,6 +1,7 @@
|
|||||||
<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"
|
||||||
@@ -27,23 +28,34 @@
|
|||||||
/>
|
/>
|
||||||
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
(click)="testAllServers()"
|
@if (hasMissingDefaultServers()) {
|
||||||
[disabled]="isTesting()"
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
(click)="restoreDefaultServers()"
|
||||||
<ng-icon
|
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||||
name="lucideRefreshCw"
|
>
|
||||||
class="w-4 h-4"
|
Restore Defaults
|
||||||
[class.animate-spin]="isTesting()"
|
</button>
|
||||||
/>
|
}
|
||||||
Test All
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
(click)="testAllServers()"
|
||||||
|
[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"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideRefreshCw"
|
||||||
|
class="w-4 h-4"
|
||||||
|
[class.animate-spin]="isTesting()"
|
||||||
|
/>
|
||||||
|
Test All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-muted-foreground mb-4">
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
Add multiple server directories to search for rooms across different networks. The active server will be used for creating and registering new
|
Active server endpoints stay enabled at the same time. You pick the endpoint when creating and registering a new server.
|
||||||
rooms.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Server List -->
|
<!-- Server List -->
|
||||||
@@ -63,6 +75,7 @@
|
|||||||
[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>
|
||||||
|
|
||||||
@@ -78,15 +91,19 @@
|
|||||||
@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) {
|
@if (!server.isActive && server.status !== 'incompatible') {
|
||||||
<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="Set as active"
|
title="Activate"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideCheck"
|
name="lucideCheck"
|
||||||
@@ -94,8 +111,22 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (!server.isDefault) {
|
@if (server.isActive && hasMultipleActiveServers()) {
|
||||||
<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"
|
||||||
@@ -130,6 +161,7 @@
|
|||||||
/>
|
/>
|
||||||
</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"
|
||||||
@@ -228,6 +260,7 @@
|
|||||||
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,7 +3,8 @@ 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';
|
||||||
@@ -61,6 +62,10 @@ 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);
|
||||||
|
|
||||||
@@ -122,6 +127,14 @@ 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,6 +13,22 @@
|
|||||||
/>
|
/>
|
||||||
<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() }}
|
||||||
@@ -21,9 +37,11 @@
|
|||||||
} @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>
|
||||||
@if (isReconnecting()) {
|
<span
|
||||||
<span class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
|
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
|
||||||
}
|
[class.hidden]="!isReconnecting()"
|
||||||
|
>Reconnecting…</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -31,16 +49,15 @@
|
|||||||
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"
|
||||||
@@ -55,8 +72,20 @@
|
|||||||
</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 bg-card border border-border rounded-lg shadow-lg w-48">
|
<div class="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border border-border bg-card shadow-lg">
|
||||||
@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()"
|
||||||
@@ -65,6 +94,12 @@
|
|||||||
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,6 +6,7 @@ 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 {
|
||||||
@@ -14,10 +15,15 @@ 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 { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import {
|
||||||
|
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';
|
||||||
@@ -25,6 +31,7 @@ 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;
|
||||||
@@ -50,7 +57,8 @@ type ElectronWindow = Window & {
|
|||||||
lucideX,
|
lucideX,
|
||||||
lucideChevronLeft,
|
lucideChevronLeft,
|
||||||
lucideHash,
|
lucideHash,
|
||||||
lucideMenu })
|
lucideMenu,
|
||||||
|
lucideRefreshCw })
|
||||||
],
|
],
|
||||||
templateUrl: './title-bar.component.html'
|
templateUrl: './title-bar.component.html'
|
||||||
})
|
})
|
||||||
@@ -78,12 +86,28 @@ 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() {
|
||||||
@@ -122,9 +146,44 @@ 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();
|
||||||
@@ -170,4 +229,44 @@ 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,13 +4,11 @@ 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,
|
||||||
@@ -28,6 +26,7 @@ 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',
|
||||||
@@ -55,9 +54,10 @@ import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../
|
|||||||
* 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, OnDestroy {
|
export class FloatingVoiceControlsComponent implements OnInit {
|
||||||
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);
|
||||||
@@ -69,25 +69,27 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||||
isMuted = signal(false);
|
isMuted = signal(false);
|
||||||
isDeafened = signal(false);
|
isDeafened = signal(false);
|
||||||
isScreenSharing = signal(false);
|
isScreenSharing = this.webrtcService.isScreenSharing;
|
||||||
includeSystemAudio = signal(false);
|
includeSystemAudio = signal(false);
|
||||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||||
askScreenShareQuality = signal(true);
|
askScreenShareQuality = signal(true);
|
||||||
showScreenShareQualityDialog = signal(false);
|
showScreenShareQualityDialog = signal(false);
|
||||||
|
|
||||||
private stateSubscription: Subscription | null = null;
|
/** Sync local mute/deafen 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
|
||||||
this.isMuted.set(this.webrtcService.isMuted());
|
this.isMuted.set(this.webrtcService.isMuted());
|
||||||
this.isDeafened.set(this.webrtcService.isDeafened());
|
this.isDeafened.set(this.webrtcService.isDeafened());
|
||||||
this.isScreenSharing.set(this.webrtcService.isScreenSharing());
|
|
||||||
this.syncScreenShareSettings();
|
this.syncScreenShareSettings();
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
const settings = loadVoiceSettingsFromStorage();
|
||||||
this.stateSubscription?.unsubscribe();
|
|
||||||
|
this.voicePlayback.updateOutputVolume(settings.outputVolume / 100);
|
||||||
|
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||||
|
|
||||||
|
if (settings.outputDevice) {
|
||||||
|
this.voicePlayback.applyOutputDevice(settings.outputDevice);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Navigate back to the voice-connected server. */
|
/** Navigate back to the voice-connected server. */
|
||||||
@@ -117,6 +119,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
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()) {
|
||||||
@@ -141,7 +144,6 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
async toggleScreenShare(): Promise<void> {
|
async toggleScreenShare(): Promise<void> {
|
||||||
if (this.isScreenSharing()) {
|
if (this.isScreenSharing()) {
|
||||||
this.webrtcService.stopScreenShare();
|
this.webrtcService.stopScreenShare();
|
||||||
this.isScreenSharing.set(false);
|
|
||||||
} else {
|
} else {
|
||||||
this.syncScreenShareSettings();
|
this.syncScreenShareSettings();
|
||||||
|
|
||||||
@@ -189,6 +191,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// 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();
|
||||||
@@ -208,7 +212,6 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.voiceSessionService.endSession();
|
this.voiceSessionService.endSession();
|
||||||
|
|
||||||
// Reset local state
|
// Reset local state
|
||||||
this.isScreenSharing.set(false);
|
|
||||||
this.isMuted.set(false);
|
this.isMuted.set(false);
|
||||||
this.isDeafened.set(false);
|
this.isDeafened.set(false);
|
||||||
}
|
}
|
||||||
@@ -282,8 +285,6 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
includeSystemAudio: this.includeSystemAudio(),
|
includeSystemAudio: this.includeSystemAudio(),
|
||||||
quality
|
quality
|
||||||
});
|
});
|
||||||
|
|
||||||
this.isScreenSharing.set(true);
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Screen share request was denied or failed
|
// Screen share request was denied or failed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ 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 element itself is silent - all audible output comes from
|
* audio. The priming element itself is silent; audible output is routed
|
||||||
* the GainNode -> AudioContext.destination path.
|
* through a separate output element fed by
|
||||||
|
* `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;
|
||||||
@@ -38,6 +41,7 @@ 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;
|
||||||
@@ -49,6 +53,36 @@ 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 {
|
||||||
@@ -147,6 +181,14 @@ 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:
|
||||||
*
|
*
|
||||||
@@ -154,11 +196,12 @@ export class VoicePlaybackService {
|
|||||||
* ↓
|
* ↓
|
||||||
* muted <audio> element (Chrome workaround - primes the stream)
|
* muted <audio> element (Chrome workaround - primes the stream)
|
||||||
* ↓
|
* ↓
|
||||||
* MediaStreamSource → GainNode → AudioContext.destination
|
* MediaStreamSource → GainNode → MediaStreamDestination → output <audio>
|
||||||
*/
|
*/
|
||||||
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;
|
||||||
@@ -167,12 +210,24 @@ 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(ctx.destination);
|
gainNode.connect(mediaDestination);
|
||||||
|
|
||||||
const pipeline: PeerAudioPipeline = { audioElement: audioEl, context: ctx, sourceNodes, gainNode };
|
outputEl.srcObject = mediaDestination.stream;
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -194,26 +249,20 @@ export class VoicePlaybackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const anyAudio = pipeline.audioElement as any;
|
const anyAudio = pipeline.outputElement 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.preferredOutputDeviceId;
|
return this.temporaryOutputDeviceId ?? this.preferredOutputDeviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private removePipeline(peerId: string): void {
|
private removePipeline(peerId: string): void {
|
||||||
@@ -238,6 +287,8 @@ 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,7 +10,6 @@ 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,
|
||||||
@@ -76,7 +75,6 @@ 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);
|
||||||
@@ -86,7 +84,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
connectionErrorMessage = computed(() => this.webrtcService.connectionErrorMessage());
|
connectionErrorMessage = computed(() => this.webrtcService.connectionErrorMessage());
|
||||||
isMuted = signal(false);
|
isMuted = signal(false);
|
||||||
isDeafened = signal(false);
|
isDeafened = signal(false);
|
||||||
isScreenSharing = signal(false);
|
isScreenSharing = this.webrtcService.isScreenSharing;
|
||||||
showSettings = signal(false);
|
showSettings = signal(false);
|
||||||
|
|
||||||
inputDevices = signal<AudioDevice[]>([]);
|
inputDevices = signal<AudioDevice[]>([]);
|
||||||
@@ -110,56 +108,18 @@ 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.isConnected()) {
|
if (!this.webrtcService.isVoiceConnected()) {
|
||||||
this.disconnect();
|
this.voicePlayback.teardownAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.voicePlayback.teardownAll();
|
|
||||||
|
|
||||||
this.remoteStreamSubscription?.unsubscribe();
|
|
||||||
this.voiceConnectedSubscription?.unsubscribe();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadAudioDevices(): Promise<void> {
|
async loadAudioDevices(): Promise<void> {
|
||||||
@@ -304,6 +264,7 @@ 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();
|
||||||
|
|
||||||
@@ -325,7 +286,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
// End voice session for floating controls
|
// End voice session for floating controls
|
||||||
this.voiceSessionService.endSession();
|
this.voiceSessionService.endSession();
|
||||||
|
|
||||||
this.isScreenSharing.set(false);
|
|
||||||
this.isMuted.set(false);
|
this.isMuted.set(false);
|
||||||
this.isDeafened.set(false);
|
this.isDeafened.set(false);
|
||||||
}
|
}
|
||||||
@@ -407,7 +367,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
async toggleScreenShare(): Promise<void> {
|
async toggleScreenShare(): Promise<void> {
|
||||||
if (this.isScreenSharing()) {
|
if (this.isScreenSharing()) {
|
||||||
this.webrtcService.stopScreenShare();
|
this.webrtcService.stopScreenShare();
|
||||||
this.isScreenSharing.set(false);
|
|
||||||
} else {
|
} else {
|
||||||
this.syncScreenShareSettings();
|
this.syncScreenShareSettings();
|
||||||
|
|
||||||
@@ -578,8 +537,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
includeSystemAudio: this.includeSystemAudio(),
|
includeSystemAudio: this.includeSystemAudio(),
|
||||||
quality
|
quality
|
||||||
});
|
});
|
||||||
|
|
||||||
this.isScreenSharing.set(true);
|
|
||||||
} catch (_error) {}
|
} catch (_error) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,65 @@
|
|||||||
{{ 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,11 +1,14 @@
|
|||||||
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,
|
||||||
@@ -15,6 +18,7 @@ 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;
|
||||||
@@ -34,6 +38,7 @@ interface DebugNetworkSummary {
|
|||||||
imports: [CommonModule, NgIcon],
|
imports: [CommonModule, NgIcon],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
|
lucideDownload,
|
||||||
lucideFilter,
|
lucideFilter,
|
||||||
lucidePause,
|
lucidePause,
|
||||||
lucidePlay,
|
lucidePlay,
|
||||||
@@ -64,6 +69,10 @@ 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',
|
||||||
@@ -75,6 +84,17 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -111,6 +131,24 @@ 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,10 +102,11 @@
|
|||||||
[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)="startWidthResize($event)"
|
(mousedown)="startLeftResize($event)"
|
||||||
aria-label="Resize debug console width"
|
aria-label="Resize debug console width"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -113,10 +114,23 @@
|
|||||||
></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)="startResize($event)"
|
(mousedown)="startTopResize($event)"
|
||||||
aria-label="Resize debug console"
|
aria-label="Resize debug console"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -154,6 +168,8 @@
|
|||||||
(autoScrollToggled)="toggleAutoScroll()"
|
(autoScrollToggled)="toggleAutoScroll()"
|
||||||
(clearRequested)="clearLogs()"
|
(clearRequested)="clearLogs()"
|
||||||
(closeRequested)="closeConsole()"
|
(closeRequested)="closeConsole()"
|
||||||
|
(exportLogsRequested)="exportLogs($event)"
|
||||||
|
(exportNetworkRequested)="exportNetwork($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@if (activeTab() === 'logs') {
|
@if (activeTab() === 'logs') {
|
||||||
@@ -168,6 +184,48 @@
|
|||||||
[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,6 +15,9 @@ 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>;
|
||||||
|
|
||||||
@@ -44,6 +47,9 @@ 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;
|
||||||
@@ -56,10 +62,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 = signal(360);
|
readonly panelHeight = this.resizeService.panelHeight;
|
||||||
readonly panelWidth = signal(832);
|
readonly panelWidth = this.resizeService.panelWidth;
|
||||||
readonly panelLeft = signal(0);
|
readonly panelLeft = this.resizeService.panelLeft;
|
||||||
readonly panelTop = signal(0);
|
readonly panelTop = this.resizeService.panelTop;
|
||||||
readonly levelState = signal<DebugLevelState>({
|
readonly levelState = signal<DebugLevelState>({
|
||||||
event: true,
|
event: true,
|
||||||
info: true,
|
info: true,
|
||||||
@@ -123,18 +129,8 @@ 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.syncPanelBounds();
|
this.resizeService.syncBounds(this.detached());
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const selectedSource = this.selectedSource();
|
const selectedSource = this.selectedSource();
|
||||||
@@ -147,32 +143,17 @@ export class DebugConsoleComponent {
|
|||||||
|
|
||||||
@HostListener('window:mousemove', ['$event'])
|
@HostListener('window:mousemove', ['$event'])
|
||||||
onResizeMove(event: MouseEvent): void {
|
onResizeMove(event: MouseEvent): void {
|
||||||
if (this.dragging) {
|
this.resizeService.onMouseMove(event, this.detached());
|
||||||
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.dragging = false;
|
this.resizeService.onMouseUp();
|
||||||
this.resizingHeight = false;
|
|
||||||
this.resizingWidth = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:resize')
|
@HostListener('window:resize')
|
||||||
onWindowResize(): void {
|
onWindowResize(): void {
|
||||||
this.syncPanelBounds();
|
this.resizeService.syncBounds(this.detached());
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleConsole(): void {
|
toggleConsole(): void {
|
||||||
@@ -195,14 +176,38 @@ 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.syncPanelBounds();
|
this.resizeService.syncBounds(nextDetached);
|
||||||
|
|
||||||
if (nextDetached)
|
if (nextDetached)
|
||||||
this.initializeDetachedPosition();
|
this.resizeService.initializeDetachedPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleLevel(level: DebugLogLevel): void {
|
toggleLevel(level: DebugLogLevel): void {
|
||||||
@@ -220,35 +225,31 @@ export class DebugConsoleComponent {
|
|||||||
this.debugging.clear();
|
this.debugging.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
startResize(event: MouseEvent): void {
|
startTopResize(event: MouseEvent): void {
|
||||||
event.preventDefault();
|
this.resizeService.startTopResize(event);
|
||||||
event.stopPropagation();
|
|
||||||
this.resizingHeight = true;
|
|
||||||
this.resizeOriginY = event.clientY;
|
|
||||||
this.resizeOriginHeight = this.panelHeight();
|
|
||||||
this.panelOriginTop = this.panelTop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startWidthResize(event: MouseEvent): void {
|
startBottomResize(event: MouseEvent): void {
|
||||||
event.preventDefault();
|
this.resizeService.startBottomResize(event);
|
||||||
event.stopPropagation();
|
}
|
||||||
this.resizingWidth = true;
|
|
||||||
this.resizeOriginX = event.clientX;
|
startLeftResize(event: MouseEvent): void {
|
||||||
this.resizeOriginWidth = this.panelWidth();
|
this.resizeService.startLeftResize(event);
|
||||||
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;
|
||||||
|
|
||||||
event.preventDefault();
|
this.resizeService.startDrag(event);
|
||||||
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 {
|
||||||
@@ -257,92 +258,4 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
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,10 +24,16 @@
|
|||||||
<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 id="screen-share-source-picker-title" class="text-lg font-semibold text-foreground">
|
<h2
|
||||||
|
id="screen-share-source-picker-title"
|
||||||
|
class="text-lg font-semibold text-foreground"
|
||||||
|
>
|
||||||
Choose what to share
|
Choose what to share
|
||||||
</h2>
|
</h2>
|
||||||
<p id="screen-share-source-picker-description" class="mt-1 text-sm text-muted-foreground">
|
<p
|
||||||
|
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>
|
||||||
@@ -55,7 +61,11 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-2" role="tablist" aria-label="Share source type">
|
<div
|
||||||
|
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"
|
||||||
@@ -129,7 +139,11 @@
|
|||||||
<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 [ngSrc]="source.thumbnail" [alt]="source.name" fill />
|
<img
|
||||||
|
[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>
|
||||||
@@ -156,13 +170,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">
|
<p class="text-sm font-medium text-foreground">No {{ activeTab() === 'screen' ? 'screens' : 'windows' }} available</p>
|
||||||
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'
|
{{
|
||||||
? 'No displays were reported by Electron right now.'
|
activeTab() === 'screen'
|
||||||
: 'Restore the window you want to share and try again.' }}
|
? 'No displays were reported by Electron right now.'
|
||||||
|
: 'Restore the window you want to share and try again.'
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -256,7 +256,10 @@ function handleSyncBatch(
|
|||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
if (hasAttachmentMetaMap(event.attachments)) {
|
if (hasAttachmentMetaMap(event.attachments)) {
|
||||||
attachments.registerSyncedAttachments(event.attachments);
|
attachments.registerSyncedAttachments(
|
||||||
|
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(
|
||||||
@@ -277,6 +280,8 @@ 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) {
|
||||||
@@ -292,40 +297,31 @@ async function processSyncBatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasAttachmentMetaMap(event.attachments)) {
|
if (hasAttachmentMetaMap(event.attachments)) {
|
||||||
requestMissingImages(event.attachments, attachments);
|
queueWatchedAttachmentDownloads(event.attachments, attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
return toUpsert;
|
return toUpsert;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-requests any unavailable image attachments from any connected peer. */
|
/** Queue best-effort auto-downloads for watched-room attachments. */
|
||||||
function requestMissingImages(
|
function queueWatchedAttachmentDownloads(
|
||||||
attachmentMap: AttachmentMetaMap,
|
attachmentMap: AttachmentMetaMap,
|
||||||
attachments: AttachmentService
|
attachments: AttachmentService
|
||||||
): void {
|
): void {
|
||||||
for (const [msgId, metas] of Object.entries(attachmentMap)) {
|
for (const msgId of Object.keys(attachmentMap)) {
|
||||||
for (const meta of metas) {
|
attachments.queueAutoDownloadsForMessage(msgId);
|
||||||
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;
|
||||||
|
|
||||||
@@ -340,6 +336,8 @@ 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,
|
||||||
@@ -492,6 +490,11 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user