Compare commits
26 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 | |||
| be465fd297 | |||
| 7b3caa0b61 | |||
| 22f583e6b3 | |||
| 091c7b2601 |
@@ -1,4 +1,5 @@
|
||||
# Toggle SSL for local development (true/false)
|
||||
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
|
||||
# When false: plain HTTP everywhere (only works on localhost)
|
||||
# Overrides server/data/variables.json for local development only
|
||||
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
|
||||
@@ -15,10 +15,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
# - name: Setup Node.js
|
||||
# uses: https://github.com/actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: 20
|
||||
|
||||
- name: Publish draft release
|
||||
env:
|
||||
|
||||
@@ -55,6 +55,11 @@ jobs:
|
||||
npm ci
|
||||
cd server && npm ci
|
||||
|
||||
- name: Install zip utility
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y zip
|
||||
|
||||
- name: Set CI release version
|
||||
run: >
|
||||
node tools/set-release-version.js
|
||||
@@ -73,6 +78,10 @@ jobs:
|
||||
run: |
|
||||
npx electron-builder --linux --publish never
|
||||
node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64
|
||||
rm -f dist-electron/metoyou-client-${{ needs.prepare.outputs.release_version }}.zip
|
||||
rm -f dist-server/metoyou-server-linux-x64-${{ needs.prepare.outputs.release_version }}.zip
|
||||
(cd dist && zip -rq ../dist-electron/metoyou-client-${{ needs.prepare.outputs.release_version }}.zip client)
|
||||
(cd dist-server && zip -q metoyou-server-linux-x64-${{ needs.prepare.outputs.release_version }}.zip metoyou-server-linux-x64 sql-wasm.wasm)
|
||||
|
||||
- name: Upload Linux assets
|
||||
env:
|
||||
@@ -121,8 +130,68 @@ jobs:
|
||||
|
||||
- name: Build Windows assets
|
||||
run: |
|
||||
npx electron-builder --win --publish never
|
||||
$projectRoot = $PWD.ProviderPath
|
||||
$electronBuilderWorkspace = Join-Path $env:TEMP ([guid]::NewGuid().ToString('N'))
|
||||
$electronBuilderCache = Join-Path $electronBuilderWorkspace 'electron-builder-cache'
|
||||
$electronCache = Join-Path $electronBuilderWorkspace 'electron-cache'
|
||||
$locationPushed = $false
|
||||
|
||||
function Invoke-RoboCopy {
|
||||
param(
|
||||
[string]$Source,
|
||||
[string]$Destination
|
||||
)
|
||||
|
||||
robocopy $Source $Destination /MIR /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
|
||||
if ($LASTEXITCODE -gt 7) {
|
||||
throw "robocopy failed from $Source to $Destination with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
# Stage the packaging inputs into a real short-path directory.
|
||||
# electron-builder rejects junction-backed files during asar creation
|
||||
# because their resolved path sits outside the package root.
|
||||
New-Item -ItemType Directory -Path $electronBuilderWorkspace | Out-Null
|
||||
New-Item -ItemType Directory -Path $electronBuilderCache | Out-Null
|
||||
New-Item -ItemType Directory -Path $electronCache | Out-Null
|
||||
$env:ELECTRON_BUILDER_CACHE = $electronBuilderCache
|
||||
$env:ELECTRON_CACHE = $electronCache
|
||||
|
||||
try {
|
||||
Copy-Item -Path (Join-Path $projectRoot 'package.json') -Destination (Join-Path $electronBuilderWorkspace 'package.json') -Force
|
||||
Copy-Item -Path (Join-Path $projectRoot 'package-lock.json') -Destination (Join-Path $electronBuilderWorkspace 'package-lock.json') -Force
|
||||
Invoke-RoboCopy (Join-Path $projectRoot 'dist') (Join-Path $electronBuilderWorkspace 'dist')
|
||||
Invoke-RoboCopy (Join-Path $projectRoot 'images') (Join-Path $electronBuilderWorkspace 'images')
|
||||
Invoke-RoboCopy (Join-Path $projectRoot 'node_modules') (Join-Path $electronBuilderWorkspace 'node_modules')
|
||||
|
||||
Push-Location $electronBuilderWorkspace
|
||||
$locationPushed = $true
|
||||
npx electron-builder --win --publish never
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "electron-builder failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Invoke-RoboCopy (Join-Path $electronBuilderWorkspace 'dist-electron') (Join-Path $projectRoot 'dist-electron')
|
||||
} finally {
|
||||
if ($locationPushed) {
|
||||
Pop-Location
|
||||
}
|
||||
if (Test-Path $electronBuilderWorkspace) {
|
||||
cmd /c rmdir /s /q "$electronBuilderWorkspace" | Out-Null
|
||||
}
|
||||
}
|
||||
node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe
|
||||
$serverArchivePath = Join-Path $PWD "dist-server/metoyou-server-win-x64-${{ needs.prepare.outputs.release_version }}.zip"
|
||||
if (Test-Path $serverArchivePath) {
|
||||
Remove-Item $serverArchivePath -Force
|
||||
}
|
||||
Push-Location dist-server
|
||||
try {
|
||||
Compress-Archive -Path metoyou-server-win-x64.exe, sql-wasm.wasm -DestinationPath $serverArchivePath -CompressionLevel Optimal
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
- name: Upload Windows assets
|
||||
env:
|
||||
|
||||
101
README.md
101
README.md
@@ -17,7 +17,7 @@ Desktop chat app with three parts:
|
||||
Root `.env`:
|
||||
|
||||
- `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.
|
||||
|
||||
@@ -25,101 +25,10 @@ Server files:
|
||||
|
||||
- `server/data/variables.json` holds `klipyApiKey`
|
||||
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
|
||||
|
||||
## Desktop auto updates
|
||||
|
||||
The packaged Electron app now reads a hosted release manifest from the active server's `/api/health` response.
|
||||
|
||||
Release flow:
|
||||
|
||||
1. Build the desktop packages with `npm run electron:build` or the platform-specific Electron Builder commands.
|
||||
2. Upload one version folder that contains the generated `latest.yml`, `latest-mac.yml`, `latest-linux.yml`, and the matching installers/artifacts.
|
||||
3. Generate or update the hosted manifest JSON with:
|
||||
|
||||
`npm run release:manifest -- --feed-url https://your-cdn.example.com/metoyou/1.2.3`
|
||||
|
||||
4. Set `releaseManifestUrl` in `server/data/variables.json` to the hosted manifest JSON URL.
|
||||
|
||||
### GitHub / Gitea release assets
|
||||
|
||||
If you publish desktop builds as release assets, use the release download URL as the manifest `feedUrl`.
|
||||
|
||||
Examples:
|
||||
|
||||
- GitHub tag `v1.2.3`:
|
||||
|
||||
`https://github.com/OWNER/REPO/releases/download/v1.2.3`
|
||||
|
||||
- Gitea tag `v1.2.3`:
|
||||
|
||||
`https://gitea.example.com/OWNER/REPO/releases/download/v1.2.3`
|
||||
|
||||
That release must include these assets with their normal Electron Builder names:
|
||||
|
||||
- `latest.yml`
|
||||
- `latest-mac.yml`
|
||||
- `latest-linux.yml`
|
||||
- Windows installer assets (`.exe`, `.blockmap`)
|
||||
- macOS assets (`.dmg`, `.zip`)
|
||||
- Linux assets (`.AppImage`, `.deb`)
|
||||
|
||||
You should also upload `release-manifest.json` as a release asset.
|
||||
|
||||
For a stable manifest URL, point the server at the latest-release asset URL:
|
||||
|
||||
- GitHub:
|
||||
|
||||
`https://github.com/OWNER/REPO/releases/latest/download/release-manifest.json`
|
||||
|
||||
- Gitea: use the equivalent latest-release asset URL if your instance supports it, otherwise publish `release-manifest.json` at a separate stable URL.
|
||||
|
||||
If you want the in-app "Specific version" option to list older releases too, keep one cumulative manifest and merge the previous file when generating the next one:
|
||||
|
||||
`npm run release:manifest -- --existing ./release-manifest.json --feed-url https://github.com/OWNER/REPO/releases/download/v1.2.3 --version 1.2.3`
|
||||
|
||||
The manifest format is:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-03-10T12:00:00.000Z",
|
||||
"minimumServerVersion": "1.0.0",
|
||||
"pollIntervalMinutes": 30,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"feedUrl": "https://your-cdn.example.com/metoyou/1.2.3",
|
||||
"publishedAt": "2026-03-10T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`feedUrl` must point to a directory that contains the Electron Builder update descriptors for Windows, macOS, and Linux.
|
||||
|
||||
### Automated Gitea release queue
|
||||
|
||||
The Gitea workflows in `.gitea/workflows/release-draft.yml` and `.gitea/workflows/publish-draft-release.yml` keep the existing desktop auto-update flow intact.
|
||||
|
||||
On every push to `main` or `master`, the release workflow:
|
||||
|
||||
1. Computes a semver release version from the current `package.json` major/minor version and the workflow run number.
|
||||
2. Builds the Linux and Windows Electron packages.
|
||||
3. Builds standalone server executables for Linux and Windows.
|
||||
4. Downloads the latest published `release-manifest.json`, merges the new release feed URL, and uploads the updated manifest to the draft release.
|
||||
5. Uploads the desktop installers, update descriptors, server executables, and `release-manifest.json` to the matching Gitea release page.
|
||||
|
||||
The draft release uses the standard Gitea download path as its `feedUrl`:
|
||||
|
||||
`https://YOUR_GITEA_HOST/OWNER/REPO/releases/download/vX.Y.Z`
|
||||
|
||||
That means the current desktop auto-updater keeps working without any client-side changes once the draft release is approved and published.
|
||||
|
||||
To enable the workflow:
|
||||
|
||||
- Add a repository secret named `GITEA_RELEASE_TOKEN` with permission to create releases and upload release assets.
|
||||
- Make sure your Gitea runner labels match the workflow `runs-on` values (`linux` and `windows`).
|
||||
- After the draft release is reviewed, publish it either from the Gitea release page or by running the `Publish Draft Release` workflow with the queued release tag.
|
||||
- `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
|
||||
|
||||
|
||||
@@ -86,6 +86,12 @@
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
|
||||
2
dev.sh
2
dev.sh
@@ -33,4 +33,4 @@ fi
|
||||
exec npx concurrently --kill-others \
|
||||
"cd server && npm run dev" \
|
||||
"$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;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
export function configureAppFlags(): void {
|
||||
linuxSpecificFlags();
|
||||
audioFlags();
|
||||
networkFlags();
|
||||
setupGpuEncodingFlags();
|
||||
chromiumFlags();
|
||||
@@ -15,21 +14,40 @@ function chromiumFlags(): void {
|
||||
|
||||
// Suppress Autofill devtools errors
|
||||
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
|
||||
}
|
||||
|
||||
function audioFlags(): void {
|
||||
// Collect all enabled features into a single switch to avoid later calls overwriting earlier ones
|
||||
const enabledFeatures: string[] = [];
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
// Use the new PipeWire-based audio pipeline on Linux for better screen share audio capture support
|
||||
app.commandLine.appendSwitch('enable-features', 'AudioServiceOutOfProcess');
|
||||
// PipeWire-based audio pipeline for screen share audio capture
|
||||
enabledFeatures.push('AudioServiceOutOfProcess');
|
||||
// PipeWire-based screen capture so the xdg-desktop-portal system picker works
|
||||
enabledFeatures.push('WebRTCPipeWireCapturer');
|
||||
}
|
||||
|
||||
const desktopSettings = readDesktopSettings();
|
||||
|
||||
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
|
||||
enabledFeatures.push('VaapiVideoEncode');
|
||||
}
|
||||
|
||||
if (enabledFeatures.length > 0) {
|
||||
app.commandLine.appendSwitch('enable-features', enabledFeatures.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
function linuxSpecificFlags(): void {
|
||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
|
||||
// Chromium chooses the Linux Ozone platform before Electron runs this file.
|
||||
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland
|
||||
// sessions so the browser process selects the correct backend early enough.
|
||||
}
|
||||
|
||||
function networkFlags(): void {
|
||||
@@ -46,11 +64,6 @@ function setupGpuEncodingFlags(): void {
|
||||
app.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
|
||||
// Enable VA-API hardware video encoding on Linux
|
||||
app.commandLine.appendSwitch('enable-features', 'VaapiVideoEncode');
|
||||
}
|
||||
|
||||
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||
app.commandLine.appendSwitch('enable-zero-copy');
|
||||
app.commandLine.appendSwitch('enable-native-gpu-memory-buffers');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
||||
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
||||
import { synchronizeAutoStartSetting } from './auto-start';
|
||||
import {
|
||||
initializeDatabase,
|
||||
destroyDatabase,
|
||||
@@ -24,6 +25,7 @@ export function registerAppLifecycle(): void {
|
||||
setupCqrsHandlers();
|
||||
setupWindowControlHandlers();
|
||||
setupSystemHandlers();
|
||||
await synchronizeAutoStartSetting();
|
||||
initializeDesktopUpdater();
|
||||
await createWindow();
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ interface SinkInputDetails extends ShortSinkInputEntry {
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DescendantProcessInfo {
|
||||
ids: ReadonlySet<string>;
|
||||
binaryNames: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface PactlJsonSinkInputEntry {
|
||||
index?: number | string;
|
||||
properties?: Record<string, unknown>;
|
||||
@@ -44,6 +49,7 @@ interface LinuxScreenShareAudioRoutingState {
|
||||
screenShareLoopbackModuleId: string | null;
|
||||
voiceLoopbackModuleId: string | null;
|
||||
rerouteIntervalId: ReturnType<typeof setInterval> | null;
|
||||
subscribeProcess: ChildProcess | null;
|
||||
}
|
||||
|
||||
interface LinuxScreenShareMonitorCaptureState {
|
||||
@@ -77,7 +83,8 @@ const routingState: LinuxScreenShareAudioRoutingState = {
|
||||
restoreSinkName: null,
|
||||
screenShareLoopbackModuleId: null,
|
||||
voiceLoopbackModuleId: null,
|
||||
rerouteIntervalId: null
|
||||
rerouteIntervalId: null,
|
||||
subscribeProcess: null
|
||||
};
|
||||
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
|
||||
captureId: null,
|
||||
@@ -126,12 +133,21 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
||||
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
|
||||
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
|
||||
|
||||
await setDefaultSink(SCREEN_SHARE_SINK_NAME);
|
||||
await moveSinkInputs(SCREEN_SHARE_SINK_NAME, (sinkName) => !!sinkName && sinkName !== SCREEN_SHARE_SINK_NAME && sinkName !== VOICE_SINK_NAME);
|
||||
// Set the default sink to the voice sink so that new app audio
|
||||
// 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;
|
||||
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();
|
||||
startSubscribeWatcher();
|
||||
|
||||
return buildRoutingInfo(true, true);
|
||||
} catch (error) {
|
||||
@@ -148,6 +164,7 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
||||
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
|
||||
const restoreSinkName = routingState.restoreSinkName;
|
||||
|
||||
stopSubscribeWatcher();
|
||||
stopSinkInputRerouteLoop();
|
||||
await stopLinuxScreenShareMonitorCapture();
|
||||
|
||||
@@ -166,6 +183,7 @@ export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean>
|
||||
routingState.restoreSinkName = null;
|
||||
routingState.screenShareLoopbackModuleId = null;
|
||||
routingState.voiceLoopbackModuleId = null;
|
||||
routingState.subscribeProcess = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -425,34 +443,52 @@ async function setDefaultSink(sinkName: string): Promise<void> {
|
||||
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 [
|
||||
sinks,
|
||||
sinkInputs,
|
||||
descendantProcessIds
|
||||
descendantProcessInfo
|
||||
] = await Promise.all([
|
||||
listSinks(),
|
||||
listSinkInputDetails(),
|
||||
collectDescendantProcessIds(process.pid)
|
||||
collectDescendantProcessInfo(process.pid)
|
||||
]);
|
||||
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
||||
|
||||
await Promise.all(
|
||||
sinkInputs.map(async (sinkInput) => {
|
||||
if (!isAppOwnedSinkInput(sinkInput, descendantProcessIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
||||
} catch {
|
||||
// Streams can disappear or be recreated while rerouting.
|
||||
// Non-app streams sitting on the voice sink should be moved to the
|
||||
// screenshare sink for desktop-audio capture.
|
||||
if (!appOwned && sinkName === VOICE_SINK_NAME) {
|
||||
try {
|
||||
await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME);
|
||||
} catch {
|
||||
// Streams can disappear or be recreated while rerouting.
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -515,7 +551,7 @@ function startSinkInputRerouteLoop(): void {
|
||||
}
|
||||
|
||||
routingState.rerouteIntervalId = setInterval(() => {
|
||||
void rerouteAppSinkInputsToVoiceSink();
|
||||
void rerouteSinkInputs();
|
||||
}, REROUTE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -528,13 +564,108 @@ function stopSinkInputRerouteLoop(): void {
|
||||
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(
|
||||
sinkInput: SinkInputDetails,
|
||||
descendantProcessIds: ReadonlySet<string>
|
||||
descendantProcessInfo: DescendantProcessInfo
|
||||
): boolean {
|
||||
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(
|
||||
@@ -697,31 +828,45 @@ async function listSinkInputDetails(): Promise<SinkInputDetails[]> {
|
||||
return entries.filter((entry) => !!entry.sinkIndex);
|
||||
}
|
||||
|
||||
async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<string>> {
|
||||
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid='], {
|
||||
async function collectDescendantProcessInfo(rootProcessId: number): Promise<DescendantProcessInfo> {
|
||||
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm='], {
|
||||
env: process.env
|
||||
});
|
||||
const childrenByParentId = new Map<string, string[]>();
|
||||
const binaryNameByProcessId = new Map<string, string>();
|
||||
|
||||
stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const [pid, ppid] = line.split(/\s+/);
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||
|
||||
if (!pid || !ppid) {
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [
|
||||
,
|
||||
pid,
|
||||
ppid,
|
||||
command
|
||||
] = match;
|
||||
const siblings = childrenByParentId.get(ppid) ?? [];
|
||||
|
||||
siblings.push(pid);
|
||||
childrenByParentId.set(ppid, siblings);
|
||||
|
||||
const normalizedBinaryName = normalizeProcessBinary(command);
|
||||
|
||||
if (normalizedBinaryName) {
|
||||
binaryNameByProcessId.set(pid, normalizedBinaryName);
|
||||
}
|
||||
});
|
||||
|
||||
const rootId = `${rootProcessId}`;
|
||||
const descendantIds = new Set<string>([rootId]);
|
||||
const descendantBinaryNames = new Set<string>();
|
||||
const queue = [rootId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
@@ -731,6 +876,12 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
|
||||
continue;
|
||||
}
|
||||
|
||||
const binaryName = binaryNameByProcessId.get(currentId);
|
||||
|
||||
if (binaryName) {
|
||||
descendantBinaryNames.add(binaryName);
|
||||
}
|
||||
|
||||
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
||||
if (descendantIds.has(childId)) {
|
||||
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 {
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
||||
topic: room.topic ?? null,
|
||||
hostId: room.hostId,
|
||||
password: room.password ?? null,
|
||||
hasPassword: room.hasPassword ? 1 : 0,
|
||||
isPrivate: room.isPrivate ? 1 : 0,
|
||||
createdAt: room.createdAt,
|
||||
userCount: room.userCount ?? 0,
|
||||
@@ -20,7 +21,10 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||
permissions: room.permissions != null ? JSON.stringify(room.permissions) : 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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './utils/applyUpdates';
|
||||
|
||||
const ROOM_TRANSFORMS: TransformMap = {
|
||||
hasPassword: boolToInt,
|
||||
isPrivate: boolToInt,
|
||||
userCount: (val) => (val ?? 0),
|
||||
permissions: jsonOrNull,
|
||||
|
||||
@@ -57,6 +57,7 @@ export function rowToRoom(row: RoomEntity) {
|
||||
topic: row.topic ?? undefined,
|
||||
hostId: row.hostId,
|
||||
password: row.password ?? undefined,
|
||||
hasPassword: !!row.hasPassword,
|
||||
isPrivate: !!row.isPrivate,
|
||||
createdAt: row.createdAt,
|
||||
userCount: row.userCount,
|
||||
@@ -65,7 +66,10 @@ export function rowToRoom(row: RoomEntity) {
|
||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||
permissions: row.permissions ? JSON.parse(row.permissions) : 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;
|
||||
hostId: string;
|
||||
password?: string;
|
||||
hasPassword?: boolean;
|
||||
isPrivate?: boolean;
|
||||
createdAt: number;
|
||||
userCount?: number;
|
||||
@@ -93,6 +94,9 @@ export interface RoomPayload {
|
||||
permissions?: unknown;
|
||||
channels?: unknown[];
|
||||
members?: unknown[];
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface BanPayload {
|
||||
|
||||
@@ -6,6 +6,7 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||
|
||||
export interface DesktopSettings {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
autoStart: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
@@ -19,6 +20,7 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
||||
|
||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
autoUpdateMode: 'auto',
|
||||
autoStart: true,
|
||||
hardwareAcceleration: true,
|
||||
manifestUrls: [],
|
||||
preferredVersion: null,
|
||||
@@ -81,6 +83,9 @@ export function readDesktopSettings(): DesktopSettings {
|
||||
|
||||
return {
|
||||
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
||||
autoStart: typeof parsed.autoStart === 'boolean'
|
||||
? parsed.autoStart
|
||||
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
||||
? parsed.vaapiVideoEncode
|
||||
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
||||
@@ -102,6 +107,9 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
||||
};
|
||||
const nextSettings: DesktopSettings = {
|
||||
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
||||
autoStart: typeof mergedSettings.autoStart === 'boolean'
|
||||
? mergedSettings.autoStart
|
||||
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||
? mergedSettings.hardwareAcceleration
|
||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||
|
||||
@@ -24,6 +24,9 @@ export class RoomEntity {
|
||||
@Column('text', { nullable: true })
|
||||
password!: string | null;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
hasPassword!: number;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
isPrivate!: number;
|
||||
|
||||
@@ -50,4 +53,13 @@ export class RoomEntity {
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
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,
|
||||
type DesktopUpdateServerContext
|
||||
} from '../update/desktop-updater';
|
||||
import { consumePendingDeepLink } from '../app/deep-links';
|
||||
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const FILE_CLIPBOARD_FORMATS = [
|
||||
@@ -83,6 +85,57 @@ interface ClipboardFilePayload {
|
||||
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 {
|
||||
return FILE_CLIPBOARD_FORMATS.some(
|
||||
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
||||
@@ -194,6 +247,10 @@ async function readClipboardFiles(): Promise<ClipboardFilePayload[]> {
|
||||
}
|
||||
|
||||
export function setupSystemHandlers(): void {
|
||||
ipcMain.on('get-linux-display-server', (event) => {
|
||||
event.returnValue = resolveLinuxDisplayServer();
|
||||
});
|
||||
|
||||
ipcMain.handle('open-external', async (_event, url: string) => {
|
||||
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||
await shell.openExternal(url);
|
||||
@@ -203,17 +260,35 @@ export function setupSystemHandlers(): void {
|
||||
return false;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-sources', async () => {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
thumbnailSize: { width: 150, height: 150 }
|
||||
});
|
||||
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
|
||||
|
||||
return sources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
thumbnail: source.thumbnail.toDataURL()
|
||||
}));
|
||||
ipcMain.handle('get-sources', async () => {
|
||||
try {
|
||||
const thumbnailSize = { width: 240, height: 150 };
|
||||
const [screenSources, windowSources] = await Promise.all([
|
||||
desktopCapturer.getSources({
|
||||
types: ['screen'],
|
||||
thumbnailSize
|
||||
}),
|
||||
desktopCapturer.getSources({
|
||||
types: ['window'],
|
||||
thumbnailSize,
|
||||
fetchWindowIcons: true
|
||||
})
|
||||
]);
|
||||
const sources = [...screenSources, ...windowSources];
|
||||
const uniqueSources = new Map(sources.map((source) => [source.id, source]));
|
||||
|
||||
return [...uniqueSources.values()].map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
thumbnail: source.thumbnail.toDataURL()
|
||||
}));
|
||||
} catch {
|
||||
// desktopCapturer.getSources fails on Wayland; return empty so the
|
||||
// renderer falls through to getDisplayMedia with the system picker.
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||
@@ -255,6 +330,7 @@ export function setupSystemHandlers(): void {
|
||||
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
||||
const snapshot = updateDesktopSettings(patch);
|
||||
|
||||
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||
await handleDesktopSettingsChanged();
|
||||
return snapshot;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'reflect-metadata';
|
||||
import { initializeDeepLinkHandling } from './app/deep-links';
|
||||
import { configureAppFlags } from './app/flags';
|
||||
import { registerAppLifecycle } from './app/lifecycle';
|
||||
|
||||
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_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
||||
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||
|
||||
export interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
@@ -83,7 +84,24 @@ export interface DesktopUpdateState {
|
||||
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 {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
maximizeWindow: () => void;
|
||||
closeWindow: () => void;
|
||||
@@ -98,8 +116,10 @@ export interface ElectronAPI {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
consumePendingDeepLink: () => Promise<string | null>;
|
||||
getDesktopSettings: () => Promise<{
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
autoStart: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
@@ -113,12 +133,14 @@ export interface ElectronAPI {
|
||||
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||
setDesktopSettings: (patch: {
|
||||
autoUpdateMode?: 'auto' | 'off' | 'version';
|
||||
autoStart?: boolean;
|
||||
hardwareAcceleration?: boolean;
|
||||
manifestUrls?: string[];
|
||||
preferredVersion?: string | null;
|
||||
vaapiVideoEncode?: boolean;
|
||||
}) => Promise<{
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
autoStart: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
@@ -126,6 +148,7 @@ export interface ElectronAPI {
|
||||
restartRequired: boolean;
|
||||
}>;
|
||||
relaunchApp: () => Promise<boolean>;
|
||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
@@ -139,6 +162,7 @@ export interface ElectronAPI {
|
||||
}
|
||||
|
||||
const electronAPI: ElectronAPI = {
|
||||
linuxDisplayServer: readLinuxDisplayServer(),
|
||||
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
||||
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
||||
closeWindow: () => ipcRenderer.send('window-close'),
|
||||
@@ -180,6 +204,7 @@ const electronAPI: ElectronAPI = {
|
||||
};
|
||||
},
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
|
||||
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
||||
@@ -198,6 +223,17 @@ const electronAPI: ElectronAPI = {
|
||||
},
|
||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||
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'),
|
||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||
|
||||
@@ -729,7 +729,7 @@ export function restartToApplyUpdate(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
autoUpdater.quitAndInstall(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
session,
|
||||
shell
|
||||
} from 'electron';
|
||||
import * as fs from 'fs';
|
||||
@@ -61,6 +63,34 @@ export async function createWindow(): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
async (_request, respond) => {
|
||||
// On Linux/Wayland the system picker (useSystemPicker: true) handles
|
||||
// the portal. This handler is only reached if the system picker is
|
||||
// unavailable (e.g. X11 without a portal). Fall back to
|
||||
// desktopCapturer so the user still gets something.
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
thumbnailSize: { width: 150, height: 150 }
|
||||
});
|
||||
const firstSource = sources[0];
|
||||
|
||||
if (firstSource) {
|
||||
respond({ video: firstSource });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// desktopCapturer also unavailable
|
||||
}
|
||||
|
||||
respond({});
|
||||
},
|
||||
{ useSystemPicker: true }
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env['NODE_ENV'] === 'development') {
|
||||
const devUrl = process.env['SSL'] === 'true'
|
||||
? 'https://localhost:4200'
|
||||
|
||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -24,6 +24,7 @@
|
||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||
"auto-launch": "^5.0.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cytoscape": "^3.33.1",
|
||||
@@ -45,11 +46,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.0.4",
|
||||
"@angular/cli": "^21.2.1",
|
||||
"@angular/cli": "^21.0.4",
|
||||
"@angular/compiler-cli": "^21.0.0",
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||
"@types/auto-launch": "^5.0.5",
|
||||
"@types/simple-peer": "^9.11.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"angular-eslint": "21.2.0",
|
||||
@@ -10816,6 +10818,13 @@
|
||||
"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": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -12875,6 +12884,11 @@
|
||||
"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": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
@@ -12968,6 +12982,22 @@
|
||||
"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": {
|
||||
"version": "10.4.23",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||
@@ -22285,9 +22315,7 @@
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -23745,7 +23773,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -29571,6 +29598,15 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
|
||||
@@ -31161,6 +31197,12 @@
|
||||
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
||||
"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": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
26
package.json
26
package.json
@@ -11,20 +11,20 @@
|
||||
"prebuild": "npm run bundle:rnnoise",
|
||||
"prestart": "npm run bundle:rnnoise",
|
||||
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
|
||||
"start": "npm run ng serve",
|
||||
"build": "npm run ng build",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"build:electron": "tsc -p tsconfig.electron.json",
|
||||
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
||||
"build:prod": "npm run ng build --configuration production --base-href='./'",
|
||||
"build:prod": "ng build --configuration production --base-href='./'",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"server:build": "cd server && npm run build",
|
||||
"server:start": "cd server && npm start",
|
||||
"server:dev": "cd server && npm run dev",
|
||||
"electron": "npm run ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
|
||||
"electron:dev": "concurrently \"npm run ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development 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 node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
||||
"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:create": "typeorm migration:create electron/migrations/New",
|
||||
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
|
||||
@@ -36,7 +36,7 @@
|
||||
"electron:build:all": "npm run build:prod && npm run build:electron && electron-builder --win --mac --linux",
|
||||
"build:prod:all": "npm run build:prod && npm run build:electron && cd server && npm run build",
|
||||
"build:prod:win": "npm run build:prod:all && electron-builder --win",
|
||||
"dev": "npm run electron:full",
|
||||
"dev": "npm run build:electron && npm run electron:full",
|
||||
"dev:app": "npm run electron:dev",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
||||
@@ -70,6 +70,7 @@
|
||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||
"auto-launch": "^5.0.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cytoscape": "^3.33.1",
|
||||
@@ -96,6 +97,7 @@
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||
"@types/auto-launch": "^5.0.5",
|
||||
"@types/simple-peer": "^9.11.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"angular-eslint": "21.2.0",
|
||||
@@ -120,6 +122,14 @@
|
||||
"build": {
|
||||
"appId": "com.metoyou.app",
|
||||
"productName": "MetoYou",
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Toju Invite Links",
|
||||
"schemes": [
|
||||
"toju"
|
||||
]
|
||||
}
|
||||
],
|
||||
"directories": {
|
||||
"output": "dist-electron"
|
||||
},
|
||||
@@ -147,7 +157,7 @@
|
||||
"publish": [
|
||||
{
|
||||
"provider": "generic",
|
||||
"url": "https://updates.metoyou.invalid"
|
||||
"url": "https://tojusignal.azaaxin.com"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
|
||||
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 {
|
||||
const app = express();
|
||||
|
||||
app.set('trust proxy', true);
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
|
||||
@@ -2,13 +2,20 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { resolveRuntimePath } from '../runtime-paths';
|
||||
|
||||
export type ServerHttpProtocol = 'http' | 'https';
|
||||
|
||||
export interface ServerVariablesConfig {
|
||||
klipyApiKey: string;
|
||||
releaseManifestUrl: string;
|
||||
serverPort: number;
|
||||
serverProtocol: ServerHttpProtocol;
|
||||
serverHost: string;
|
||||
}
|
||||
|
||||
const DATA_DIR = resolveRuntimePath('data');
|
||||
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 {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
@@ -18,6 +25,51 @@ function normalizeReleaseManifestUrl(value: unknown): string {
|
||||
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> } {
|
||||
if (!fs.existsSync(VARIABLES_FILE)) {
|
||||
return { rawContents: '', parsed: {} };
|
||||
@@ -52,10 +104,14 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
}
|
||||
|
||||
const { rawContents, parsed } = readRawVariables();
|
||||
const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed;
|
||||
const normalized = {
|
||||
...parsed,
|
||||
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey),
|
||||
releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl)
|
||||
...remainingParsed,
|
||||
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
||||
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';
|
||||
|
||||
@@ -65,7 +121,10 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
|
||||
return {
|
||||
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 {
|
||||
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 { ServerEntity, JoinRequestEntity } from '../../../entities';
|
||||
import {
|
||||
ServerEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity
|
||||
} from '../../../entities';
|
||||
import { DeleteServerCommand } from '../../types';
|
||||
|
||||
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
||||
const { serverId } = command.payload;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
|
||||
description: server.description ?? null,
|
||||
ownerId: server.ownerId,
|
||||
ownerPublicKey: server.ownerPublicKey,
|
||||
passwordHash: server.passwordHash ?? null,
|
||||
isPrivate: server.isPrivate ? 1 : 0,
|
||||
maxUsers: server.maxUsers,
|
||||
currentUsers: server.currentUsers,
|
||||
|
||||
@@ -24,6 +24,8 @@ export function rowToServer(row: ServerEntity): ServerPayload {
|
||||
description: row.description ?? undefined,
|
||||
ownerId: row.ownerId,
|
||||
ownerPublicKey: row.ownerPublicKey,
|
||||
hasPassword: !!row.passwordHash,
|
||||
passwordHash: row.passwordHash ?? undefined,
|
||||
isPrivate: !!row.isPrivate,
|
||||
maxUsers: row.maxUsers,
|
||||
currentUsers: row.currentUsers,
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface ServerPayload {
|
||||
description?: string;
|
||||
ownerId: string;
|
||||
ownerPublicKey: string;
|
||||
hasPassword?: boolean;
|
||||
passwordHash?: string | null;
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
|
||||
@@ -4,7 +4,10 @@ import { DataSource } from 'typeorm';
|
||||
import {
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
JoinRequestEntity
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity
|
||||
} from '../entities';
|
||||
import { serverMigrations } from '../migrations';
|
||||
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
||||
@@ -51,7 +54,10 @@ export async function initDatabase(): Promise<void> {
|
||||
entities: [
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
JoinRequestEntity
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity
|
||||
],
|
||||
migrations: serverMigrations,
|
||||
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')
|
||||
ownerPublicKey!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
passwordHash!: string | null;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
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 { ServerEntity } from './ServerEntity';
|
||||
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 {
|
||||
ensureVariablesConfig,
|
||||
getServerHost,
|
||||
getVariablesConfigPath,
|
||||
hasKlipyApiKey
|
||||
getServerPort,
|
||||
getServerProtocol,
|
||||
ServerHttpProtocol
|
||||
} from './config/variables';
|
||||
import { setupWebSocket } from './websocket';
|
||||
|
||||
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
|
||||
const PORT = process.env.PORT || 3001;
|
||||
function formatHostForUrl(host: string): string {
|
||||
if (host.startsWith('[') || !host.includes(':')) {
|
||||
return host;
|
||||
}
|
||||
|
||||
function buildServer(app: ReturnType<typeof createApp>) {
|
||||
if (USE_SSL) {
|
||||
return `[${host}]`;
|
||||
}
|
||||
|
||||
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 certFile = path.join(certDir, 'localhost.crt');
|
||||
const keyFile = path.join(certDir, 'localhost.key');
|
||||
|
||||
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
||||
console.error(`SSL=true but certs not found in ${certDir}`);
|
||||
console.error('Run ./generate-cert.sh first.');
|
||||
console.error(`HTTPS is enabled but certs were not found in ${certDir}`);
|
||||
console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -44,17 +60,31 @@ function buildServer(app: ReturnType<typeof createApp>) {
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
await initDatabase();
|
||||
|
||||
const app = createApp();
|
||||
const server = buildServer(app);
|
||||
const server = buildServer(app, serverProtocol);
|
||||
|
||||
setupWebSocket(server);
|
||||
|
||||
@@ -64,14 +94,29 @@ async function bootstrap(): Promise<void> {
|
||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||
}, 60 * 1000);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
const proto = USE_SSL ? 'https' : 'http';
|
||||
const wsProto = USE_SSL ? 'wss' : 'ws';
|
||||
const onListening = () => {
|
||||
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||
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(` REST API: ${proto}://localhost:${PORT}/api`);
|
||||
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
|
||||
});
|
||||
console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
|
||||
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
|
||||
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) => {
|
||||
|
||||
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 { 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 serversRouter from './servers';
|
||||
import joinRequestsRouter from './join-requests';
|
||||
import { invitesApiRouter, invitePageRouter } from './invites';
|
||||
|
||||
export function registerRoutes(app: Express): void {
|
||||
app.use('/api', healthRouter);
|
||||
@@ -12,5 +13,7 @@ export function registerRoutes(app: Express): void {
|
||||
app.use('/api', proxyRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/servers', serversRouter);
|
||||
app.use('/api/invites', invitesApiRouter);
|
||||
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 { ServerPayload, JoinRequestPayload } from '../cqrs/types';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
import {
|
||||
getAllPublicServers,
|
||||
getServerById,
|
||||
getUserById,
|
||||
upsertServer,
|
||||
deleteServer,
|
||||
createJoinRequest,
|
||||
getPendingRequestsForServer
|
||||
} 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();
|
||||
|
||||
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 { passwordHash, ...publicServer } = server;
|
||||
|
||||
return {
|
||||
...server,
|
||||
...publicServer,
|
||||
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||
ownerName: owner?.displayName,
|
||||
sourceUrl,
|
||||
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) => {
|
||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||
|
||||
@@ -54,17 +115,30 @@ router.get('/', 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)
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
|
||||
const passwordHash = passwordHashForInput(password);
|
||||
const server: ServerPayload = {
|
||||
id: clientId || uuidv4(),
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
ownerPublicKey,
|
||||
hasPassword: !!passwordHash,
|
||||
passwordHash,
|
||||
isPrivate: isPrivate ?? false,
|
||||
maxUsers: maxUsers ?? 0,
|
||||
currentUsers: 0,
|
||||
@@ -74,25 +148,216 @@ router.post('/', async (req, res) => {
|
||||
};
|
||||
|
||||
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) => {
|
||||
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 authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||
const normalizedRole = normalizeRole(actingRole);
|
||||
|
||||
if (!existing)
|
||||
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' });
|
||||
}
|
||||
|
||||
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);
|
||||
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) => {
|
||||
@@ -128,32 +393,6 @@ router.delete('/:id', async (req, res) => {
|
||||
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) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { ownerId } = req.query;
|
||||
@@ -170,4 +409,15 @@ router.get('/:id/requests', async (req, res) => {
|
||||
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;
|
||||
|
||||
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 { ConnectedUser } from './types';
|
||||
import { broadcastToServer, findUserByOderId } from './broadcast';
|
||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||
|
||||
interface WsMessage {
|
||||
[key: string]: unknown;
|
||||
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. */
|
||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
const users = Array.from(connectedUsers.values())
|
||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId && cu.displayName)
|
||||
.map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' }));
|
||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
|
||||
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
||||
|
||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||
}
|
||||
|
||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
user.oderId = String(message['oderId'] || connectionId);
|
||||
user.displayName = String(message['displayName'] || 'Anonymous');
|
||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
connectedUsers.set(connectionId, user);
|
||||
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']);
|
||||
|
||||
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);
|
||||
|
||||
user.serverIds.add(sid);
|
||||
user.viewedServerId = sid;
|
||||
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);
|
||||
|
||||
@@ -38,7 +61,7 @@ function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId:
|
||||
broadcastToServer(sid, {
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName ?? 'Anonymous',
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
serverId: sid
|
||||
}, user.oderId);
|
||||
}
|
||||
@@ -49,7 +72,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
|
||||
|
||||
user.viewedServerId = viewSid;
|
||||
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);
|
||||
}
|
||||
@@ -70,8 +93,9 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
||||
broadcastToServer(leaveSid, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName ?? 'Anonymous',
|
||||
serverId: leaveSid
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
serverId: leaveSid,
|
||||
serverIds: Array.from(user.serverIds)
|
||||
}, 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);
|
||||
|
||||
if (!user)
|
||||
@@ -133,7 +157,7 @@ export function handleWebSocketMessage(connectionId: string, message: WsMessage)
|
||||
break;
|
||||
|
||||
case 'join_server':
|
||||
handleJoinServer(user, message, connectionId);
|
||||
await handleJoinServer(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'view_server':
|
||||
|
||||
@@ -9,39 +9,87 @@ import { connectedUsers } from './state';
|
||||
import { broadcastToServer } from './broadcast';
|
||||
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 {
|
||||
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) => {
|
||||
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 {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
handleWebSocketMessage(connectionId, message);
|
||||
await handleWebSocketMessage(connectionId, message);
|
||||
} catch (err) {
|
||||
console.error('Invalid WebSocket message:', err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
if (user) {
|
||||
user.serverIds.forEach((sid) => {
|
||||
broadcastToServer(sid, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
serverId: sid
|
||||
}, user.oderId);
|
||||
});
|
||||
}
|
||||
|
||||
connectedUsers.delete(connectionId);
|
||||
removeDeadConnection(connectionId);
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
|
||||
|
||||
@@ -6,4 +6,6 @@ export interface ConnectedUser {
|
||||
serverIds: Set<string>;
|
||||
viewedServerId?: string;
|
||||
displayName?: string;
|
||||
/** Timestamp of the last pong received (used to detect dead connections). */
|
||||
lastPong: number;
|
||||
}
|
||||
|
||||
@@ -50,49 +50,11 @@
|
||||
<app-floating-voice-controls />
|
||||
</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 -->
|
||||
<app-settings-modal />
|
||||
|
||||
<!-- Shared Screen Share Source Picker -->
|
||||
<app-screen-share-source-picker />
|
||||
|
||||
<!-- Shared Debug Console -->
|
||||
<app-debug-console [showLauncher]="false" />
|
||||
|
||||
@@ -17,6 +17,11 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
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',
|
||||
loadComponent: () =>
|
||||
|
||||
106
src/app/app.ts
106
src/app/app.ts
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
inject,
|
||||
HostListener
|
||||
} from '@angular/core';
|
||||
@@ -25,6 +26,7 @@ import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||
import { UsersActions } from './store/users/users.actions';
|
||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
@@ -34,6 +36,15 @@ import {
|
||||
STORAGE_KEY_LAST_VISITED_ROUTE
|
||||
} from './core/constants';
|
||||
|
||||
interface DeepLinkElectronApi {
|
||||
consumePendingDeepLink?: () => Promise<string | null>;
|
||||
onDeepLinkReceived?: (listener: (url: string) => void) => () => void;
|
||||
}
|
||||
|
||||
type DeepLinkWindow = Window & {
|
||||
electronAPI?: DeepLinkElectronApi;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
@@ -43,12 +54,13 @@ import {
|
||||
TitleBarComponent,
|
||||
FloatingVoiceControlsComponent,
|
||||
SettingsModalComponent,
|
||||
DebugConsoleComponent
|
||||
DebugConsoleComponent,
|
||||
ScreenShareSourcePickerComponent
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App implements OnInit {
|
||||
export class App implements OnInit, OnDestroy {
|
||||
store = inject(Store);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
desktopUpdates = inject(DesktopAppUpdateService);
|
||||
@@ -61,6 +73,7 @@ export class App implements OnInit {
|
||||
private timeSync = inject(TimeSyncService);
|
||||
private voiceSession = inject(VoiceSessionService);
|
||||
private externalLinks = inject(ExternalLinkService);
|
||||
private deepLinkCleanup: (() => void) | null = null;
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onGlobalLinkClick(evt: MouseEvent): void {
|
||||
@@ -78,6 +91,8 @@ export class App implements OnInit {
|
||||
await this.timeSync.syncWithEndpoint(apiBase);
|
||||
} catch {}
|
||||
|
||||
await this.setupDesktopDeepLinks();
|
||||
|
||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
@@ -85,8 +100,12 @@ export class App implements OnInit {
|
||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
|
||||
if (!currentUserId) {
|
||||
if (this.router.url !== '/login' && this.router.url !== '/register') {
|
||||
this.router.navigate(['/login']).catch(() => {});
|
||||
if (!this.isPublicRoute(this.router.url)) {
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: this.router.url
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
||||
@@ -114,6 +133,11 @@ export class App implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.deepLinkCleanup?.();
|
||||
this.deepLinkCleanup = null;
|
||||
}
|
||||
|
||||
openNetworkSettings(): void {
|
||||
this.settingsModal.open('network');
|
||||
}
|
||||
@@ -129,4 +153,78 @@ export class App implements OnInit {
|
||||
async restartToApplyUpdate(): Promise<void> {
|
||||
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;
|
||||
hostId: string;
|
||||
password?: string;
|
||||
hasPassword?: boolean;
|
||||
isPrivate: boolean;
|
||||
createdAt: number;
|
||||
userCount: number;
|
||||
@@ -80,6 +81,9 @@ export interface Room {
|
||||
permissions?: RoomPermissions;
|
||||
channels?: Channel[];
|
||||
members?: RoomMember[];
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface RoomSettings {
|
||||
@@ -88,6 +92,7 @@ export interface RoomSettings {
|
||||
topic?: string;
|
||||
isPrivate: boolean;
|
||||
password?: string;
|
||||
hasPassword?: boolean;
|
||||
maxUsers?: number;
|
||||
rules?: string[];
|
||||
}
|
||||
@@ -265,14 +270,14 @@ export interface ChatEvent {
|
||||
displayName?: string;
|
||||
emoji?: string;
|
||||
reason?: string;
|
||||
settings?: RoomSettings;
|
||||
settings?: Partial<RoomSettings>;
|
||||
permissions?: Partial<RoomPermissions>;
|
||||
voiceState?: Partial<VoiceState>;
|
||||
isScreenSharing?: boolean;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
role?: UserRole;
|
||||
room?: Room;
|
||||
room?: Partial<Room>;
|
||||
channels?: Channel[];
|
||||
members?: RoomMember[];
|
||||
ban?: BanEntry;
|
||||
@@ -292,11 +297,13 @@ export interface ServerInfo {
|
||||
ownerPublicKey?: string;
|
||||
userCount: number;
|
||||
maxUsers: number;
|
||||
hasPassword?: boolean;
|
||||
isPrivate: boolean;
|
||||
tags?: string[];
|
||||
createdAt: number;
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
signal,
|
||||
effect
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { take } from 'rxjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { WebRTCService } from './webrtc.service';
|
||||
@@ -12,6 +13,7 @@ import { Store } from '@ngrx/store';
|
||||
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
|
||||
import { DatabaseService } from './database.service';
|
||||
import { recordDebugNetworkFileChunk } from './debug-network-metrics.service';
|
||||
import { ROOM_URL_PATTERN } from '../constants';
|
||||
import type {
|
||||
ChatAttachmentAnnouncement,
|
||||
ChatAttachmentMeta,
|
||||
@@ -145,9 +147,14 @@ export class AttachmentService {
|
||||
private readonly webrtc = inject(WebRTCService);
|
||||
private readonly ngrxStore = inject(Store);
|
||||
private readonly database = inject(DatabaseService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
/** Primary index: `messageId → 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. */
|
||||
updated = signal<number>(0);
|
||||
@@ -190,6 +197,24 @@ export class AttachmentService {
|
||||
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 {
|
||||
@@ -201,6 +226,44 @@ export class AttachmentService {
|
||||
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. */
|
||||
async deleteForMessage(messageId: string): Promise<void> {
|
||||
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
|
||||
@@ -219,6 +282,7 @@ export class AttachmentService {
|
||||
}
|
||||
|
||||
this.attachmentsByMessage.delete(messageId);
|
||||
this.messageRoomIds.delete(messageId);
|
||||
this.clearMessageScopedState(messageId);
|
||||
|
||||
if (hadCachedAttachments) {
|
||||
@@ -276,8 +340,15 @@ export class AttachmentService {
|
||||
* @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
|
||||
*/
|
||||
registerSyncedAttachments(
|
||||
attachmentMap: Record<string, AttachmentMeta[]>
|
||||
attachmentMap: Record<string, AttachmentMeta[]>,
|
||||
messageRoomIds?: Record<string, string>
|
||||
): void {
|
||||
if (messageRoomIds) {
|
||||
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
|
||||
this.rememberMessageRoom(messageId, roomId);
|
||||
}
|
||||
}
|
||||
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
for (const [messageId, metas] of Object.entries(attachmentMap)) {
|
||||
@@ -306,6 +377,7 @@ export class AttachmentService {
|
||||
|
||||
for (const attachment of newAttachments) {
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
this.queueAutoDownloadsForMessage(attachment.messageId, attachment.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,9 +447,9 @@ export class AttachmentService {
|
||||
* message to all connected peers.
|
||||
*
|
||||
* 1. Each file is assigned a UUID.
|
||||
* 2. A `file-announce` event is broadcast to peers.
|
||||
* 3. Inline-preview media ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES}
|
||||
* are immediately streamed as chunked base-64.
|
||||
* 2. A `file-announce` event is broadcast to peers.
|
||||
* 3. Peers watching the message's server can request any
|
||||
* auto-download-eligible media on demand.
|
||||
*
|
||||
* @param messageId - ID of the parent message.
|
||||
* @param files - Array of user-selected `File` objects.
|
||||
@@ -437,10 +509,6 @@ export class AttachmentService {
|
||||
|
||||
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) ?? [];
|
||||
@@ -482,6 +550,7 @@ export class AttachmentService {
|
||||
this.attachmentsByMessage.set(messageId, list);
|
||||
this.touch();
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
this.queueAutoDownloadsForMessage(messageId, attachment.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -772,6 +841,38 @@ export class AttachmentService {
|
||||
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 {
|
||||
const scopedPrefix = `${messageId}:`;
|
||||
|
||||
@@ -867,6 +968,12 @@ export class AttachmentService {
|
||||
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. */
|
||||
private shouldPersistDownloadedAttachment(attachment: Attachment): boolean {
|
||||
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
|
||||
@@ -1167,6 +1274,38 @@ export class AttachmentService {
|
||||
} 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. */
|
||||
private async migrateFromLocalStorage(): Promise<void> {
|
||||
try {
|
||||
|
||||
@@ -218,7 +218,14 @@ export class DebuggingService {
|
||||
|
||||
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
|
||||
.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 payloadText = payload === undefined
|
||||
? null
|
||||
|
||||
134
src/app/core/services/screen-share-source-picker.service.ts
Normal file
134
src/app/core/services/screen-share-source-picker.service.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from './voice-settings.storage';
|
||||
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from './webrtc/webrtc.constants';
|
||||
|
||||
export type ScreenShareSourceKind = 'screen' | 'window';
|
||||
|
||||
export interface ScreenShareSourceOption {
|
||||
id: string;
|
||||
kind: ScreenShareSourceKind;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ScreenShareSourceSelection {
|
||||
includeSystemAudio: boolean;
|
||||
source: ScreenShareSourceOption;
|
||||
}
|
||||
|
||||
interface ScreenShareSourcePickerRequest {
|
||||
includeSystemAudio: boolean;
|
||||
sources: readonly ScreenShareSourceOption[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ScreenShareSourcePickerService {
|
||||
readonly request = computed(() => this._request());
|
||||
|
||||
private readonly _request = signal<ScreenShareSourcePickerRequest | null>(null);
|
||||
|
||||
private pendingResolve: ((selection: ScreenShareSourceSelection) => void) | null = null;
|
||||
private pendingReject: ((reason?: unknown) => void) | null = null;
|
||||
|
||||
open(
|
||||
sources: readonly Pick<ScreenShareSourceOption, 'id' | 'name' | 'thumbnail'>[],
|
||||
initialIncludeSystemAudio = loadVoiceSettingsFromStorage().includeSystemAudio
|
||||
): Promise<ScreenShareSourceSelection> {
|
||||
if (sources.length === 0) {
|
||||
throw new Error('No desktop capture sources were available.');
|
||||
}
|
||||
|
||||
this.cancelPendingRequest();
|
||||
|
||||
const normalizedSources = sources.map((source) => {
|
||||
const kind = this.getSourceKind(source);
|
||||
|
||||
return {
|
||||
...source,
|
||||
kind,
|
||||
name: this.getSourceDisplayName(source.name, kind)
|
||||
};
|
||||
});
|
||||
|
||||
this._request.set({
|
||||
includeSystemAudio: initialIncludeSystemAudio,
|
||||
sources: normalizedSources
|
||||
});
|
||||
|
||||
return new Promise<ScreenShareSourceSelection>((resolve, reject) => {
|
||||
this.pendingResolve = resolve;
|
||||
this.pendingReject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
confirm(sourceId: string, includeSystemAudio: boolean): void {
|
||||
const activeRequest = this._request();
|
||||
const source = activeRequest?.sources.find((entry) => entry.id === sourceId);
|
||||
const resolve = this.pendingResolve;
|
||||
|
||||
if (!source || !resolve) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearPendingRequest();
|
||||
saveVoiceSettingsToStorage({ includeSystemAudio });
|
||||
resolve({
|
||||
includeSystemAudio,
|
||||
source
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelPendingRequest();
|
||||
}
|
||||
|
||||
private cancelPendingRequest(): void {
|
||||
const reject = this.pendingReject;
|
||||
|
||||
this.clearPendingRequest();
|
||||
|
||||
if (reject) {
|
||||
reject(this.createAbortError());
|
||||
}
|
||||
}
|
||||
|
||||
private clearPendingRequest(): void {
|
||||
this._request.set(null);
|
||||
this.pendingResolve = null;
|
||||
this.pendingReject = null;
|
||||
}
|
||||
|
||||
private getSourceKind(
|
||||
source: Pick<ScreenShareSourceOption, 'id' | 'name'>
|
||||
): ScreenShareSourceKind {
|
||||
return source.id.startsWith('screen') || source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
|
||||
? 'screen'
|
||||
: 'window';
|
||||
}
|
||||
|
||||
private getSourceDisplayName(name: string, kind: ScreenShareSourceKind): string {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
if (trimmedName) {
|
||||
return trimmedName;
|
||||
}
|
||||
|
||||
return kind === 'screen' ? 'Entire screen' : 'Window';
|
||||
}
|
||||
|
||||
private createAbortError(): Error {
|
||||
if (typeof DOMException !== 'undefined') {
|
||||
return new DOMException('The user aborted a request.', 'AbortError');
|
||||
}
|
||||
|
||||
const error = new Error('The user aborted a request.');
|
||||
|
||||
error.name = 'AbortError';
|
||||
|
||||
return error;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,13 @@
|
||||
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' })
|
||||
export class SettingsModalService {
|
||||
readonly isOpen = signal(false);
|
||||
readonly activePage = signal<SettingsPage>('network');
|
||||
readonly activePage = signal<SettingsPage>('general');
|
||||
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.targetServerId.set(serverId ?? null);
|
||||
this.isOpen.set(true);
|
||||
|
||||
@@ -19,11 +19,17 @@ import {
|
||||
inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import {
|
||||
Observable,
|
||||
of,
|
||||
Subject,
|
||||
Subscription
|
||||
} from 'rxjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { SignalingMessage, ChatEvent } from '../models/index';
|
||||
import { TimeSyncService } from './time-sync.service';
|
||||
import { DebuggingService } from './debugging.service';
|
||||
import { ScreenShareSourcePickerService } from './screen-share-source-picker.service';
|
||||
|
||||
import {
|
||||
SignalingManager,
|
||||
@@ -70,6 +76,7 @@ type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payloa
|
||||
oderId?: string;
|
||||
serverTime?: number;
|
||||
serverId?: string;
|
||||
serverIds?: string[];
|
||||
users?: SignalingUserSummary[];
|
||||
displayName?: string;
|
||||
fromUserId?: string;
|
||||
@@ -81,17 +88,23 @@ type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payloa
|
||||
export class WebRTCService implements OnDestroy {
|
||||
private readonly timeSync = inject(TimeSyncService);
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
|
||||
|
||||
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
|
||||
|
||||
private lastIdentifyCredentials: IdentifyCredentials | null = null;
|
||||
private lastJoinedServer: JoinedServerInfo | null = null;
|
||||
private readonly memberServerIds = new Set<string>();
|
||||
private readonly lastJoinedServerBySignalUrl = new Map<string, JoinedServerInfo>();
|
||||
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;
|
||||
/** The server ID where voice is currently active, or `null` when not in voice. */
|
||||
private voiceServerId: string | null = null;
|
||||
/** Maps each remote peer ID to the server they were discovered from. */
|
||||
private readonly peerServerMap = new Map<string, string>();
|
||||
/** Maps each remote peer ID to the shared servers they currently belong to. */
|
||||
private readonly peerServerMap = new Map<string, Set<string>>();
|
||||
private readonly serviceDestroyed$ = new Subject<void>();
|
||||
private remoteScreenShareRequestsEnabled = false;
|
||||
private readonly desiredRemoteScreenSharePeers = new Set<string>();
|
||||
@@ -106,6 +119,8 @@ export class WebRTCService implements OnDestroy {
|
||||
private readonly _isScreenSharing = signal(false);
|
||||
private readonly _isNoiseReductionEnabled = signal(false);
|
||||
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
|
||||
private readonly _isScreenShareRemotePlaybackSuppressed = signal(false);
|
||||
private readonly _forceDefaultRemotePlaybackOutput = signal(false);
|
||||
private readonly _hasConnectionError = signal(false);
|
||||
private readonly _connectionErrorMessage = signal<string | null>(null);
|
||||
private readonly _hasEverConnected = signal(false);
|
||||
@@ -127,6 +142,8 @@ export class WebRTCService implements OnDestroy {
|
||||
readonly isScreenSharing = computed(() => this._isScreenSharing());
|
||||
readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled());
|
||||
readonly screenStream = computed(() => this._screenStreamSignal());
|
||||
readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed());
|
||||
readonly forceDefaultRemotePlaybackOutput = computed(() => this._forceDefaultRemotePlaybackOutput());
|
||||
readonly hasConnectionError = computed(() => this._hasConnectionError());
|
||||
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
|
||||
readonly shouldShowConnectionError = computed(() => {
|
||||
@@ -161,20 +178,12 @@ export class WebRTCService implements OnDestroy {
|
||||
return this.mediaManager.voiceConnected$.asObservable();
|
||||
}
|
||||
|
||||
private readonly signalingManager: SignalingManager;
|
||||
private readonly peerManager: PeerConnectionManager;
|
||||
private readonly mediaManager: MediaManager;
|
||||
private readonly screenShareManager: ScreenShareManager;
|
||||
|
||||
constructor() {
|
||||
// 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.mediaManager = new MediaManager(this.logger, null!);
|
||||
@@ -183,7 +192,7 @@ export class WebRTCService implements OnDestroy {
|
||||
|
||||
// Now wire up cross-references (all managers are instantiated)
|
||||
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(),
|
||||
isSignalingConnected: (): boolean => this._isSignalingConnected(),
|
||||
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
|
||||
@@ -207,30 +216,23 @@ export class WebRTCService implements OnDestroy {
|
||||
this.peerManager.activePeerConnections,
|
||||
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
|
||||
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
|
||||
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates()
|
||||
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates(),
|
||||
selectDesktopSource: async (sources, options) => await this.screenShareSourcePicker.open(
|
||||
sources,
|
||||
options.includeSystemAudio
|
||||
),
|
||||
updateLocalScreenShareState: (state): void => {
|
||||
this._isScreenSharing.set(state.active);
|
||||
this._screenStreamSignal.set(state.stream);
|
||||
this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback);
|
||||
this._forceDefaultRemotePlaybackOutput.set(state.forceDefaultRemotePlaybackOutput);
|
||||
}
|
||||
});
|
||||
|
||||
this.wireManagerEvents();
|
||||
}
|
||||
|
||||
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.
|
||||
this.peerManager.messageReceived$.subscribe((event) => this.handlePeerControlMessage(event));
|
||||
|
||||
@@ -259,6 +261,8 @@ export class WebRTCService implements OnDestroy {
|
||||
|
||||
this.peerManager.peerDisconnected$.subscribe((peerId) => {
|
||||
this.activeRemoteScreenSharePeers.delete(peerId);
|
||||
this.peerServerMap.delete(peerId);
|
||||
this.peerSignalingUrlMap.delete(peerId);
|
||||
this.screenShareManager.clearScreenShareRequest(peerId);
|
||||
});
|
||||
|
||||
@@ -275,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.logger.info('Signaling message', { type: message.type });
|
||||
this.logger.info('Signaling message', {
|
||||
signalUrl,
|
||||
type: message.type
|
||||
});
|
||||
|
||||
switch (message.type) {
|
||||
case SIGNALING_TYPE_CONNECTED:
|
||||
this.handleConnectedSignalingMessage(message);
|
||||
this.handleConnectedSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_SERVER_USERS:
|
||||
this.handleServerUsersSignalingMessage(message);
|
||||
this.handleServerUsersSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_USER_JOINED:
|
||||
this.handleUserJoinedSignalingMessage(message);
|
||||
this.handleUserJoinedSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_USER_LEFT:
|
||||
this.handleUserLeftSignalingMessage(message);
|
||||
this.handleUserLeftSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_OFFER:
|
||||
this.handleOfferSignalingMessage(message);
|
||||
this.handleOfferSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_ANSWER:
|
||||
this.handleAnswerSignalingMessage(message);
|
||||
this.handleAnswerSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_ICE_CANDIDATE:
|
||||
this.handleIceCandidateSignalingMessage(message);
|
||||
this.handleIceCandidateSignalingMessage(message, signalUrl);
|
||||
return;
|
||||
|
||||
default:
|
||||
@@ -313,104 +425,161 @@ export class WebRTCService implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnectedSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
this.logger.info('Server connected', { oderId: message.oderId });
|
||||
private handleConnectedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
this.logger.info('Server connected', {
|
||||
oderId: message.oderId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
if (message.serverId) {
|
||||
this.serverSignalingUrlMap.set(message.serverId, signalUrl);
|
||||
}
|
||||
|
||||
if (typeof message.serverTime === 'number') {
|
||||
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 : [];
|
||||
|
||||
this.logger.info('Server users', {
|
||||
count: users.length,
|
||||
signalUrl,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
if (message.serverId) {
|
||||
this.serverSignalingUrlMap.set(message.serverId, signalUrl);
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.oderId)
|
||||
continue;
|
||||
|
||||
const existing = this.peerManager.activePeerConnections.get(user.oderId);
|
||||
const healthy = this.isPeerHealthy(existing);
|
||||
this.peerSignalingUrlMap.set(user.oderId, signalUrl);
|
||||
|
||||
if (existing && !healthy) {
|
||||
this.logger.info('Removing stale peer before recreate', { oderId: user.oderId });
|
||||
if (message.serverId) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (healthy)
|
||||
continue;
|
||||
|
||||
this.logger.info('Create peer connection to existing user', {
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId
|
||||
serverId: message.serverId,
|
||||
signalUrl
|
||||
});
|
||||
|
||||
this.peerManager.createPeerConnection(user.oderId, true);
|
||||
this.peerManager.createAndSendOffer(user.oderId);
|
||||
|
||||
if (message.serverId) {
|
||||
this.peerServerMap.set(user.oderId, message.serverId);
|
||||
}
|
||||
void this.peerManager.createAndSendOffer(user.oderId);
|
||||
}
|
||||
}
|
||||
|
||||
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
this.logger.info('User joined', {
|
||||
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', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId,
|
||||
signalUrl,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
if (message.oderId) {
|
||||
this.peerManager.removePeer(message.oderId);
|
||||
this.peerServerMap.delete(message.oderId);
|
||||
const hasRemainingSharedServers = Array.isArray(message.serverIds)
|
||||
? this.replacePeerSharedServers(message.oderId, message.serverIds)
|
||||
: (message.serverId
|
||||
? this.untrackPeerFromServer(message.oderId, message.serverId)
|
||||
: false);
|
||||
|
||||
if (!hasRemainingSharedServers) {
|
||||
this.peerManager.removePeer(message.oderId);
|
||||
this.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 sdp = message.payload?.sdp;
|
||||
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
|
||||
this.peerSignalingUrlMap.set(fromUserId, signalUrl);
|
||||
|
||||
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
|
||||
|
||||
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
|
||||
this.peerServerMap.set(fromUserId, offerEffectiveServer);
|
||||
this.trackPeerInServer(fromUserId, offerEffectiveServer);
|
||||
}
|
||||
|
||||
this.peerManager.handleOffer(fromUserId, sdp);
|
||||
}
|
||||
|
||||
private handleAnswerSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
private handleAnswerSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
const fromUserId = message.fromUserId;
|
||||
const sdp = message.payload?.sdp;
|
||||
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
|
||||
this.peerSignalingUrlMap.set(fromUserId, signalUrl);
|
||||
|
||||
this.peerManager.handleAnswer(fromUserId, sdp);
|
||||
}
|
||||
|
||||
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
|
||||
const fromUserId = message.fromUserId;
|
||||
const candidate = message.payload?.candidate;
|
||||
|
||||
if (!fromUserId || !candidate)
|
||||
return;
|
||||
|
||||
this.peerSignalingUrlMap.set(fromUserId, signalUrl);
|
||||
|
||||
this.peerManager.handleIceCandidate(fromUserId, candidate);
|
||||
}
|
||||
|
||||
@@ -425,8 +594,8 @@ export class WebRTCService implements OnDestroy {
|
||||
private closePeersNotInServer(serverId: string): void {
|
||||
const peersToClose: string[] = [];
|
||||
|
||||
this.peerServerMap.forEach((peerServerId, peerId) => {
|
||||
if (peerServerId !== serverId) {
|
||||
this.peerServerMap.forEach((peerServerIds, peerId) => {
|
||||
if (!peerServerIds.has(serverId)) {
|
||||
peersToClose.push(peerId);
|
||||
}
|
||||
});
|
||||
@@ -437,6 +606,7 @@ export class WebRTCService implements OnDestroy {
|
||||
|
||||
this.peerManager.removePeer(peerId);
|
||||
this.peerServerMap.delete(peerId);
|
||||
this.peerSignalingUrlMap.delete(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,7 +630,57 @@ export class WebRTCService implements OnDestroy {
|
||||
* @returns An observable that emits `true` once connected.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,7 +690,17 @@ export class WebRTCService implements OnDestroy {
|
||||
* @returns `true` if connected within the timeout.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,7 +709,32 @@ export class WebRTCService implements OnDestroy {
|
||||
* @param message - The signaling message payload (excluding `from` / `timestamp`).
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -488,7 +743,50 @@ export class WebRTCService implements OnDestroy {
|
||||
* @param message - Arbitrary JSON message.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -500,6 +798,24 @@ export class WebRTCService implements OnDestroy {
|
||||
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.
|
||||
*
|
||||
@@ -508,13 +824,32 @@ export class WebRTCService implements OnDestroy {
|
||||
* @param oderId - The user's unique order/peer ID.
|
||||
* @param displayName - The user's display name.
|
||||
*/
|
||||
identify(oderId: string, displayName: string): void {
|
||||
this.lastIdentifyCredentials = { oderId,
|
||||
displayName };
|
||||
identify(oderId: string, displayName: string, signalUrl?: string): void {
|
||||
const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME;
|
||||
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
|
||||
this.lastIdentifyCredentials = { oderId,
|
||||
displayName: normalizedDisplayName };
|
||||
|
||||
const identifyMessage = {
|
||||
type: SIGNALING_TYPE_IDENTIFY,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -523,13 +858,27 @@ export class WebRTCService implements OnDestroy {
|
||||
* @param roomId - The server / room ID to join.
|
||||
* @param userId - The local user ID.
|
||||
*/
|
||||
joinRoom(roomId: string, userId: string): void {
|
||||
this.lastJoinedServer = { serverId: roomId,
|
||||
userId };
|
||||
joinRoom(roomId: string, userId: string, signalUrl?: string): void {
|
||||
const resolvedSignalUrl = signalUrl
|
||||
?? this.serverSignalingUrlMap.get(roomId)
|
||||
?? this.getCurrentSignalingUrl();
|
||||
|
||||
this.memberServerIds.add(roomId);
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId: roomId });
|
||||
if (!resolvedSignalUrl) {
|
||||
this.logger.warn('[signaling] Cannot join room without a signaling URL', { 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
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -539,26 +888,46 @@ export class WebRTCService implements OnDestroy {
|
||||
* @param serverId - The target server ID.
|
||||
* @param userId - The local user ID.
|
||||
*/
|
||||
switchServer(serverId: string, userId: string): void {
|
||||
this.lastJoinedServer = { serverId,
|
||||
userId };
|
||||
switchServer(serverId: string, userId: string, signalUrl?: string): void {
|
||||
const resolvedSignalUrl = signalUrl
|
||||
?? this.serverSignalingUrlMap.get(serverId)
|
||||
?? this.getCurrentSignalingUrl();
|
||||
|
||||
if (this.memberServerIds.has(serverId)) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
|
||||
serverId });
|
||||
if (!resolvedSignalUrl) {
|
||||
this.logger.warn('[signaling] Cannot switch server without a signaling URL', { 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)', {
|
||||
serverId,
|
||||
signalUrl: resolvedSignalUrl,
|
||||
userId,
|
||||
voiceConnected: this._isVoiceConnected()
|
||||
});
|
||||
} else {
|
||||
this.memberServerIds.add(serverId);
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId });
|
||||
memberServerIds.add(serverId);
|
||||
this.sendRawMessageToSignalUrl(resolvedSignalUrl, {
|
||||
type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId
|
||||
});
|
||||
|
||||
this.logger.info('Joined new server via switch', {
|
||||
serverId,
|
||||
signalUrl: resolvedSignalUrl,
|
||||
userId,
|
||||
voiceConnected: this._isVoiceConnected()
|
||||
});
|
||||
@@ -575,25 +944,47 @@ export class WebRTCService implements OnDestroy {
|
||||
*/
|
||||
leaveRoom(serverId?: string): void {
|
||||
if (serverId) {
|
||||
this.memberServerIds.delete(serverId);
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||
serverId });
|
||||
const resolvedSignalUrl = this.serverSignalingUrlMap.get(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 });
|
||||
|
||||
if (this.memberServerIds.size === 0) {
|
||||
if (this.getJoinedServerCount() === 0) {
|
||||
this.fullCleanup();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.memberServerIds.forEach((sid) => {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||
serverId: sid });
|
||||
});
|
||||
for (const [signalUrl, memberServerIds] of this.memberServerIdsBySignalUrl.entries()) {
|
||||
for (const sid of memberServerIds) {
|
||||
this.sendRawMessageToSignalUrl(signalUrl, {
|
||||
type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||
serverId: sid
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.memberServerIds.clear();
|
||||
this.memberServerIdsBySignalUrl.clear();
|
||||
this.serverSignalingUrlMap.clear();
|
||||
this.fullCleanup();
|
||||
}
|
||||
|
||||
@@ -603,12 +994,18 @@ export class WebRTCService implements OnDestroy {
|
||||
* @param serverId - The server to check.
|
||||
*/
|
||||
hasJoinedServer(serverId: string): boolean {
|
||||
return this.memberServerIds.has(serverId);
|
||||
return this.isJoinedServer(serverId);
|
||||
}
|
||||
|
||||
/** Returns a read-only set of all currently-joined server IDs. */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -853,27 +1250,25 @@ export class WebRTCService implements OnDestroy {
|
||||
* @returns The screen-capture {@link MediaStream}.
|
||||
*/
|
||||
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
|
||||
const stream = await this.screenShareManager.startScreenShare(options);
|
||||
|
||||
this._isScreenSharing.set(true);
|
||||
this._screenStreamSignal.set(stream);
|
||||
return stream;
|
||||
return await this.screenShareManager.startScreenShare(options);
|
||||
}
|
||||
|
||||
/** Stop screen sharing and restore microphone audio on all peers. */
|
||||
stopScreenShare(): void {
|
||||
this.screenShareManager.stopScreenShare();
|
||||
this._isScreenSharing.set(false);
|
||||
this._screenStreamSignal.set(null);
|
||||
}
|
||||
|
||||
/** Disconnect from the signaling server and clean up all state. */
|
||||
disconnect(): void {
|
||||
this.leaveRoom();
|
||||
this.voiceServerId = null;
|
||||
this.peerServerMap.clear();
|
||||
this.leaveRoom();
|
||||
this.peerSignalingUrlMap.clear();
|
||||
this.lastJoinedServerBySignalUrl.clear();
|
||||
this.memberServerIdsBySignalUrl.clear();
|
||||
this.serverSignalingUrlMap.clear();
|
||||
this.mediaManager.stopVoiceHeartbeat();
|
||||
this.signalingManager.close();
|
||||
this.destroyAllSignalingManagers();
|
||||
this._isSignalingConnected.set(false);
|
||||
this._hasEverConnected.set(false);
|
||||
this._hasConnectionError.set(false);
|
||||
@@ -889,6 +1284,7 @@ export class WebRTCService implements OnDestroy {
|
||||
private fullCleanup(): void {
|
||||
this.voiceServerId = null;
|
||||
this.peerServerMap.clear();
|
||||
this.peerSignalingUrlMap.clear();
|
||||
this.remoteScreenShareRequestsEnabled = false;
|
||||
this.desiredRemoteScreenSharePeers.clear();
|
||||
this.activeRemoteScreenSharePeers.clear();
|
||||
@@ -899,6 +1295,8 @@ export class WebRTCService implements OnDestroy {
|
||||
this.screenShareManager.stopScreenShare();
|
||||
this._isScreenSharing.set(false);
|
||||
this._screenStreamSignal.set(null);
|
||||
this._isScreenShareRemotePlaybackSuppressed.set(false);
|
||||
this._forceDefaultRemotePlaybackOutput.set(false);
|
||||
}
|
||||
|
||||
/** Synchronise Angular signals from the MediaManager's internal state. */
|
||||
@@ -908,15 +1306,14 @@ export class WebRTCService implements OnDestroy {
|
||||
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
|
||||
}
|
||||
|
||||
/** Returns true if a peer connection exists and its data channel is open. */
|
||||
private isPeerHealthy(peer: import('./webrtc').PeerData | undefined): boolean {
|
||||
/** Returns true if a peer connection is still alive enough to finish negotiating. */
|
||||
private canReusePeerConnection(peer: import('./webrtc').PeerData | undefined): boolean {
|
||||
if (!peer)
|
||||
return false;
|
||||
|
||||
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 {
|
||||
@@ -965,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 {
|
||||
this.disconnect();
|
||||
this.serviceDestroyed$.complete();
|
||||
this.signalingManager.destroy();
|
||||
this.peerManager.destroy();
|
||||
this.mediaManager.destroy();
|
||||
this.screenShareManager.destroy();
|
||||
|
||||
@@ -103,10 +103,10 @@ export class MediaManager {
|
||||
* Replace the callback set at runtime.
|
||||
* 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 {
|
||||
this.callbacks = cb;
|
||||
setCallbacks(nextCallbacks: MediaManagerCallbacks): void {
|
||||
this.callbacks = nextCallbacks;
|
||||
}
|
||||
|
||||
/** Returns the current local media stream, or `null` if voice is disabled. */
|
||||
@@ -485,28 +485,21 @@ export class MediaManager {
|
||||
if (!this.localMediaStream)
|
||||
return;
|
||||
|
||||
const localAudioTrack = this.localMediaStream.getAudioTracks()[0] || null;
|
||||
const localVideoTrack = this.localMediaStream.getVideoTracks()[0] || null;
|
||||
const localStream = this.localMediaStream;
|
||||
const localAudioTrack = localStream.getAudioTracks()[0] || null;
|
||||
const localVideoTrack = localStream.getVideoTracks()[0] || null;
|
||||
|
||||
peers.forEach((peerData, peerId) => {
|
||||
if (localAudioTrack) {
|
||||
let audioSender =
|
||||
peerData.audioSender ||
|
||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
if (!audioSender) {
|
||||
audioSender = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, {
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
}).sender;
|
||||
}
|
||||
const audioTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_AUDIO, {
|
||||
preferredSender: peerData.audioSender,
|
||||
excludedSenders: [peerData.screenAudioSender]
|
||||
});
|
||||
const audioSender = audioTransceiver.sender;
|
||||
|
||||
peerData.audioSender = audioSender;
|
||||
|
||||
// Restore direction after removeTrack (which sets it to recvonly)
|
||||
const audioTransceiver = peerData.connection
|
||||
.getTransceivers()
|
||||
.find((t) => t.sender === audioSender);
|
||||
|
||||
if (
|
||||
audioTransceiver &&
|
||||
(audioTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||
@@ -515,29 +508,25 @@ export class MediaManager {
|
||||
audioTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
|
||||
if (typeof audioSender.setStreams === 'function') {
|
||||
audioSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
audioSender
|
||||
.replaceTrack(localAudioTrack)
|
||||
.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) {
|
||||
let videoSender =
|
||||
peerData.videoSender ||
|
||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_VIDEO);
|
||||
|
||||
if (!videoSender) {
|
||||
videoSender = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, {
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
}).sender;
|
||||
}
|
||||
const videoTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_VIDEO, {
|
||||
preferredSender: peerData.videoSender,
|
||||
excludedSenders: [peerData.screenVideoSender]
|
||||
});
|
||||
const videoSender = videoTransceiver.sender;
|
||||
|
||||
peerData.videoSender = videoSender;
|
||||
|
||||
const videoTransceiver = peerData.connection
|
||||
.getTransceivers()
|
||||
.find((t) => t.sender === videoSender);
|
||||
|
||||
if (
|
||||
videoTransceiver &&
|
||||
(videoTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||
@@ -546,16 +535,64 @@ export class MediaManager {
|
||||
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
|
||||
if (typeof videoSender.setStreams === 'function') {
|
||||
videoSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
videoSender
|
||||
.replaceTrack(localVideoTrack)
|
||||
.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);
|
||||
});
|
||||
}
|
||||
|
||||
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. */
|
||||
private broadcastVoicePresence(): void {
|
||||
const oderId = this.callbacks.getIdentifyOderId();
|
||||
|
||||
@@ -127,7 +127,9 @@ export function createPeerConnection(
|
||||
isInitiator,
|
||||
pendingIceCandidates: [],
|
||||
audioSender: undefined,
|
||||
videoSender: undefined
|
||||
videoSender: undefined,
|
||||
remoteVoiceStreamIds: new Set<string>(),
|
||||
remoteScreenShareStreamIds: new Set<string>()
|
||||
};
|
||||
|
||||
if (isInitiator) {
|
||||
@@ -151,6 +153,10 @@ export function createPeerConnection(
|
||||
|
||||
localStream.getTracks().forEach((track) => {
|
||||
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
||||
if (typeof peerData.audioSender.setStreams === 'function') {
|
||||
peerData.audioSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
peerData.audioSender
|
||||
.replaceTrack(track)
|
||||
.then(() => logger.info('audio replaceTrack (init) ok', { remotePeerId }))
|
||||
@@ -158,6 +164,10 @@ export function createPeerConnection(
|
||||
logger.error('audio replaceTrack failed at createPeerConnection', error)
|
||||
);
|
||||
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
||||
if (typeof peerData.videoSender.setStreams === 'function') {
|
||||
peerData.videoSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
peerData.videoSender
|
||||
.replaceTrack(track)
|
||||
.then(() => logger.info('video replaceTrack (init) ok', { remotePeerId }))
|
||||
|
||||
@@ -9,6 +9,7 @@ export function handleRemoteTrack(
|
||||
): void {
|
||||
const { logger, state } = context;
|
||||
const track = event.track;
|
||||
const isScreenAudio = isScreenShareAudioTrack(context, event, remotePeerId);
|
||||
const settings =
|
||||
typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings);
|
||||
|
||||
@@ -34,10 +35,10 @@ export function handleRemoteTrack(
|
||||
}
|
||||
|
||||
const compositeStream = buildCompositeRemoteStream(state, remotePeerId, track);
|
||||
const voiceStream = isVoiceAudioTrack(context, event, remotePeerId)
|
||||
const voiceStream = isVoiceAudioTrack(track, isScreenAudio)
|
||||
? buildAudioOnlyStream(state.remotePeerVoiceStreams.get(remotePeerId), track)
|
||||
: null;
|
||||
const screenShareStream = isScreenShareTrack(context, event, remotePeerId)
|
||||
const screenShareStream = isScreenShareTrack(track, isScreenAudio)
|
||||
? buildScreenShareStream(state.remotePeerScreenShareStreams.get(remotePeerId), track)
|
||||
: null;
|
||||
|
||||
@@ -53,6 +54,12 @@ export function handleRemoteTrack(
|
||||
state.remotePeerScreenShareStreams.set(remotePeerId, screenShareStream);
|
||||
}
|
||||
|
||||
rememberIncomingStreamIds(state, event, remotePeerId, {
|
||||
isScreenAudio,
|
||||
isVoiceAudio: !!voiceStream,
|
||||
isScreenTrack: !!screenShareStream
|
||||
});
|
||||
|
||||
publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
|
||||
}
|
||||
|
||||
@@ -61,6 +68,7 @@ export function clearRemoteScreenShareStream(
|
||||
remotePeerId: string
|
||||
): void {
|
||||
const { state } = context;
|
||||
const peerData = state.activePeerConnections.get(remotePeerId);
|
||||
const screenShareStream = state.remotePeerScreenShareStreams.get(remotePeerId);
|
||||
|
||||
if (!screenShareStream) {
|
||||
@@ -79,6 +87,8 @@ export function clearRemoteScreenShareStream(
|
||||
removeTracksFromStreamMap(state.remotePeerVoiceStreams, remotePeerId, screenShareTrackIds);
|
||||
state.remotePeerScreenShareStreams.delete(remotePeerId);
|
||||
|
||||
peerData?.remoteScreenShareStreamIds.clear();
|
||||
|
||||
publishRemoteStreamUpdate(context, remotePeerId, compositeStream);
|
||||
}
|
||||
|
||||
@@ -152,11 +162,20 @@ function removeRemoteTrack(
|
||||
trackId: string
|
||||
): void {
|
||||
const { state } = context;
|
||||
const peerData = state.activePeerConnections.get(remotePeerId);
|
||||
const compositeStream = removeTrackFromStreamMap(state.remotePeerStreams, remotePeerId, trackId);
|
||||
|
||||
removeTrackFromStreamMap(state.remotePeerVoiceStreams, 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);
|
||||
}
|
||||
|
||||
@@ -224,20 +243,12 @@ function publishRemoteStreamUpdate(
|
||||
});
|
||||
}
|
||||
|
||||
function isVoiceAudioTrack(
|
||||
context: PeerConnectionManagerContext,
|
||||
event: RTCTrackEvent,
|
||||
remotePeerId: string
|
||||
): boolean {
|
||||
return event.track.kind === TRACK_KIND_AUDIO && !isScreenShareAudioTrack(context, event, remotePeerId);
|
||||
function isVoiceAudioTrack(track: MediaStreamTrack, isScreenAudio: boolean): boolean {
|
||||
return track.kind === TRACK_KIND_AUDIO && !isScreenAudio;
|
||||
}
|
||||
|
||||
function isScreenShareTrack(
|
||||
context: PeerConnectionManagerContext,
|
||||
event: RTCTrackEvent,
|
||||
remotePeerId: string
|
||||
): boolean {
|
||||
return event.track.kind === TRACK_KIND_VIDEO || isScreenShareAudioTrack(context, event, remotePeerId);
|
||||
function isScreenShareTrack(track: MediaStreamTrack, isScreenAudio: boolean): boolean {
|
||||
return track.kind === TRACK_KIND_VIDEO || isScreenAudio;
|
||||
}
|
||||
|
||||
function isScreenShareAudioTrack(
|
||||
@@ -255,12 +266,34 @@ function isScreenShareAudioTrack(
|
||||
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(
|
||||
(transceiver) => transceiver.sender === peerData.audioSender
|
||||
);
|
||||
|
||||
if (voiceAudioTransceiver) {
|
||||
return event.transceiver !== voiceAudioTransceiver;
|
||||
return !matchesTransceiver(event.transceiver, voiceAudioTransceiver);
|
||||
}
|
||||
|
||||
const audioTransceivers = peerData.connection.getTransceivers().filter((transceiver) =>
|
||||
@@ -272,3 +305,52 @@ function isScreenShareAudioTrack(
|
||||
|
||||
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_VIDEO,
|
||||
TRANSCEIVER_SEND_RECV,
|
||||
TRANSCEIVER_RECV_ONLY,
|
||||
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
|
||||
TRANSCEIVER_RECV_ONLY
|
||||
} from './webrtc.constants';
|
||||
import {
|
||||
DEFAULT_SCREEN_SHARE_START_OPTIONS,
|
||||
@@ -18,6 +17,10 @@ import {
|
||||
ScreenShareQualityPreset,
|
||||
ScreenShareStartOptions
|
||||
} 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.
|
||||
@@ -27,93 +30,27 @@ export interface ScreenShareCallbacks {
|
||||
getLocalMediaStream(): MediaStream | null;
|
||||
renegotiate(peerId: string): Promise<void>;
|
||||
broadcastCurrentStates(): void;
|
||||
selectDesktopSource?(
|
||||
sources: readonly { id: string; name: string; thumbnail: string }[],
|
||||
options: { includeSystemAudio: boolean }
|
||||
): Promise<{
|
||||
includeSystemAudio: boolean;
|
||||
source: { id: string; name: string; thumbnail: string };
|
||||
}>;
|
||||
updateLocalScreenShareState?(state: LocalScreenShareState): void;
|
||||
}
|
||||
|
||||
interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
type ScreenShareCaptureMethod = 'display-media' | 'electron-desktop' | 'linux-electron';
|
||||
|
||||
export interface LocalScreenShareState {
|
||||
active: boolean;
|
||||
monitorCaptureSupported: boolean;
|
||||
screenShareSinkName: string;
|
||||
screenShareMonitorSourceName: string;
|
||||
voiceSinkName: string;
|
||||
reason?: string;
|
||||
captureMethod: ScreenShareCaptureMethod | null;
|
||||
includeSystemAudio: boolean;
|
||||
stream: MediaStream | null;
|
||||
suppressRemotePlayback: boolean;
|
||||
forceDefaultRemotePlaybackOutput: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface DesktopSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
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 {
|
||||
/** The active screen-capture stream. */
|
||||
private activeScreenStream: MediaStream | null = null;
|
||||
@@ -127,22 +64,39 @@ export class ScreenShareManager {
|
||||
/** Remote peers that explicitly requested screen-share video. */
|
||||
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. */
|
||||
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(
|
||||
private readonly logger: WebRTCLogger,
|
||||
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.
|
||||
@@ -164,9 +118,10 @@ export class ScreenShareManager {
|
||||
*
|
||||
* On Linux Electron builds, prefers a dedicated PulseAudio/PipeWire routing
|
||||
* path so remote voice playback is kept out of captured system audio.
|
||||
* Otherwise prefers `getDisplayMedia` when system audio is requested so the
|
||||
* browser can filter MeToYou's own playback via `restrictOwnAudio`, then
|
||||
* falls back to Electron desktop capture when needed.
|
||||
* On Windows Electron builds, prefers `getDisplayMedia` with system audio
|
||||
* 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.
|
||||
* @returns The captured screen {@link MediaStream}.
|
||||
@@ -178,6 +133,9 @@ export class ScreenShareManager {
|
||||
...options
|
||||
};
|
||||
const preset = SCREEN_SHARE_QUALITY_PRESETS[shareOptions.quality];
|
||||
const electronDesktopCaptureAvailable = this.desktopElectronScreenShareCapture.isAvailable();
|
||||
|
||||
let captureMethod: ScreenShareCaptureMethod | null = null;
|
||||
|
||||
try {
|
||||
this.logger.info('startScreenShare invoked', shareOptions);
|
||||
@@ -186,42 +144,65 @@ export class ScreenShareManager {
|
||||
this.stopScreenShare();
|
||||
}
|
||||
|
||||
await this.awaitPendingLinuxAudioRoutingReset();
|
||||
await this.linuxElectronScreenShareCapture.awaitPendingReset();
|
||||
|
||||
this.activeScreenStream = null;
|
||||
|
||||
if (shareOptions.includeSystemAudio && this.isLinuxElectronAudioRoutingSupported()) {
|
||||
if (shareOptions.includeSystemAudio && this.linuxElectronScreenShareCapture.isSupported()) {
|
||||
try {
|
||||
this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset);
|
||||
this.activeScreenStream = await this.linuxElectronScreenShareCapture.startCapture(shareOptions, preset);
|
||||
captureMethod = 'linux-electron';
|
||||
} catch (error) {
|
||||
this.rethrowIfScreenShareAborted(error);
|
||||
this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.activeScreenStream && shareOptions.includeSystemAudio) {
|
||||
try {
|
||||
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset);
|
||||
this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset);
|
||||
captureMethod = 'display-media';
|
||||
|
||||
if (this.activeScreenStream.getAudioTracks().length === 0) {
|
||||
this.logger.warn('getDisplayMedia did not provide system audio; trying Electron desktop capture');
|
||||
this.activeScreenStream.getTracks().forEach((track) => track.stop());
|
||||
this.activeScreenStream = null;
|
||||
if (electronDesktopCaptureAvailable) {
|
||||
// On Windows Electron, keep the getDisplayMedia stream for video
|
||||
// rather than falling through to getUserMedia desktop audio which
|
||||
// can replace or kill the active mic stream.
|
||||
this.logger.warn(
|
||||
'getDisplayMedia did not provide system audio; '
|
||||
+ 'continuing without system audio to preserve mic stream'
|
||||
);
|
||||
|
||||
shareOptions.includeSystemAudio = false;
|
||||
} else {
|
||||
this.logger.warn('getDisplayMedia did not provide system audio; trying next capture method');
|
||||
this.activeScreenStream.getTracks().forEach((track) => track.stop());
|
||||
this.activeScreenStream = null;
|
||||
captureMethod = null;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.rethrowIfScreenShareAborted(error);
|
||||
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.activeScreenStream && this.getElectronApi()?.getSources) {
|
||||
if (!this.activeScreenStream && electronDesktopCaptureAvailable) {
|
||||
try {
|
||||
this.activeScreenStream = await this.startWithElectronDesktopCapturer(shareOptions, preset);
|
||||
const electronCapture = await this.desktopElectronScreenShareCapture.startCapture(shareOptions, preset);
|
||||
|
||||
this.activeScreenStream = electronCapture.stream;
|
||||
shareOptions.includeSystemAudio = electronCapture.includeSystemAudio;
|
||||
captureMethod = 'electron-desktop';
|
||||
} catch (error) {
|
||||
this.rethrowIfScreenShareAborted(error);
|
||||
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.activeScreenStream) {
|
||||
this.activeScreenStream = await this.startWithDisplayMedia(shareOptions, preset);
|
||||
this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset);
|
||||
captureMethod = 'display-media';
|
||||
}
|
||||
|
||||
this.configureScreenStream(preset);
|
||||
@@ -230,6 +211,7 @@ export class ScreenShareManager {
|
||||
this.attachScreenTracksToPeers(preset);
|
||||
|
||||
this.isScreenActive = true;
|
||||
this.publishLocalScreenShareState(shareOptions.includeSystemAudio, captureMethod);
|
||||
this.callbacks.broadcastCurrentStates();
|
||||
|
||||
const activeScreenStream = this.activeScreenStream;
|
||||
@@ -266,11 +248,12 @@ export class ScreenShareManager {
|
||||
this.activeScreenStream = null;
|
||||
}
|
||||
|
||||
this.scheduleLinuxAudioRoutingReset();
|
||||
this.linuxElectronScreenShareCapture.scheduleReset();
|
||||
|
||||
this.screenAudioStream = null;
|
||||
this.activeScreenPreset = null;
|
||||
this.isScreenActive = false;
|
||||
this.publishLocalScreenShareState(false, null);
|
||||
this.callbacks.broadcastCurrentStates();
|
||||
|
||||
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
||||
@@ -347,50 +330,24 @@ export class ScreenShareManager {
|
||||
: null;
|
||||
}
|
||||
|
||||
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
|
||||
private publishLocalScreenShareState(
|
||||
includeSystemAudio: boolean,
|
||||
captureMethod: ScreenShareCaptureMethod | null
|
||||
): 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.');
|
||||
}
|
||||
this.callbacks.updateLocalScreenShareState?.({
|
||||
active: this.isScreenActive,
|
||||
captureMethod: this.isScreenActive ? captureMethod : null,
|
||||
includeSystemAudio: this.isScreenActive ? includeSystemAudio : false,
|
||||
stream: this.isScreenActive ? this.activeScreenStream : null,
|
||||
suppressRemotePlayback: this.isScreenActive
|
||||
&& this.desktopElectronScreenShareCapture.shouldSuppressRemotePlaybackDuringShare(
|
||||
includeSystemAudio,
|
||||
captureMethod === 'electron-desktop'
|
||||
),
|
||||
forceDefaultRemotePlaybackOutput: this.isScreenActive
|
||||
&& includeSystemAudio
|
||||
&& captureMethod === 'linux-electron'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,6 +428,11 @@ export class ScreenShareManager {
|
||||
}
|
||||
|
||||
peerData.screenVideoSender = videoSender;
|
||||
|
||||
if (typeof videoSender.setStreams === 'function') {
|
||||
videoSender.setStreams(this.activeScreenStream);
|
||||
}
|
||||
|
||||
videoSender.replaceTrack(screenVideoTrack)
|
||||
.then(() => {
|
||||
this.logger.info('screen video replaceTrack ok', { peerId });
|
||||
@@ -501,6 +463,11 @@ export class ScreenShareManager {
|
||||
}
|
||||
|
||||
peerData.screenAudioSender = screenAudioSender;
|
||||
|
||||
if (typeof screenAudioSender.setStreams === 'function') {
|
||||
screenAudioSender.setStreams(this.activeScreenStream);
|
||||
}
|
||||
|
||||
screenAudioSender.replaceTrack(screenAudioTrack)
|
||||
.then(() => this.logger.info('screen audio replaceTrack ok', { peerId }))
|
||||
.catch((error) => this.logger.error('screen audio replaceTrack failed', error));
|
||||
@@ -544,468 +511,17 @@ export class ScreenShareManager {
|
||||
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 isScreenShareSelectionAborted(error: unknown): boolean {
|
||||
return error instanceof Error
|
||||
&& (error.name === 'AbortError' || error.name === 'NotAllowedError');
|
||||
}
|
||||
|
||||
private async startWithElectronDesktopCapturer(
|
||||
options: ScreenShareStartOptions,
|
||||
preset: ScreenShareQualityPreset
|
||||
): Promise<MediaStream> {
|
||||
const electronApi = this.getElectronApi();
|
||||
|
||||
if (!electronApi?.getSources) {
|
||||
throw new Error('Electron desktop capture is unavailable.');
|
||||
}
|
||||
|
||||
const sources = await electronApi.getSources();
|
||||
const screenSource = sources.find((source) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) ?? sources[0];
|
||||
|
||||
if (!screenSource) {
|
||||
throw new Error('No desktop capture sources were available.');
|
||||
}
|
||||
|
||||
const electronConstraints = this.buildElectronDesktopConstraints(screenSource.id, options, 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 await navigator.mediaDevices.getUserMedia(electronConstraints);
|
||||
}
|
||||
|
||||
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.startWithElectronDesktopCapturer({
|
||||
...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();
|
||||
private rethrowIfScreenShareAborted(error: unknown): void {
|
||||
if (this.isScreenShareSelectionAborted(error)) {
|
||||
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 {
|
||||
const screenVideoTrack = this.activeScreenStream?.getVideoTracks()[0];
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface PeerData {
|
||||
screenVideoSender?: RTCRtpSender;
|
||||
/** The RTP sender carrying the screen-share audio track. */
|
||||
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. */
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideLogIn } from '@ng-icons/lucide';
|
||||
@@ -42,6 +42,7 @@ export class LoginComponent {
|
||||
|
||||
private auth = inject(AuthService);
|
||||
private store = inject(Store);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
/** TrackBy function for server list rendering. */
|
||||
@@ -72,6 +73,14 @@ export class LoginComponent {
|
||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||
|
||||
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']);
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -82,6 +91,10 @@ export class LoginComponent {
|
||||
|
||||
/** Navigate to the registration page. */
|
||||
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';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideUserPlus } from '@ng-icons/lucide';
|
||||
@@ -43,6 +43,7 @@ export class RegisterComponent {
|
||||
|
||||
private auth = inject(AuthService);
|
||||
private store = inject(Store);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
/** TrackBy function for server list rendering. */
|
||||
@@ -74,6 +75,14 @@ export class RegisterComponent {
|
||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||
|
||||
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']);
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -84,6 +93,10 @@ export class RegisterComponent {
|
||||
|
||||
/** Navigate to the login page. */
|
||||
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 {
|
||||
try {
|
||||
this.webrtc.sendRawMessage({ type: 'typing' });
|
||||
this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
DestroyRef
|
||||
DestroyRef,
|
||||
effect
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
merge,
|
||||
interval,
|
||||
@@ -23,6 +26,7 @@ interface TypingSignalingMessage {
|
||||
type: string;
|
||||
displayName: string;
|
||||
oderId: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -36,6 +40,9 @@ interface TypingSignalingMessage {
|
||||
})
|
||||
export class TypingIndicatorComponent {
|
||||
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[]>([]);
|
||||
typingOthersCount = signal<number>(0);
|
||||
@@ -47,8 +54,10 @@ export class TypingIndicatorComponent {
|
||||
filter((msg): msg is TypingSignalingMessage =>
|
||||
msg?.type === 'user_typing' &&
|
||||
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) => {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -77,6 +86,17 @@ export class TypingIndicatorComponent {
|
||||
merge(typing$, purge$)
|
||||
.pipe(takeUntilDestroyed(destroyRef))
|
||||
.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 {
|
||||
|
||||
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 -->
|
||||
@if (onlineUsersFiltered().length > 0) {
|
||||
@if (onlineRoomUsers().length > 0) {
|
||||
<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">
|
||||
@for (user of onlineUsersFiltered(); track user.id) {
|
||||
@for (user of onlineRoomUsers(); track user.id) {
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40 group/user"
|
||||
(contextmenu)="openUserContextMenu($event, user)"
|
||||
@@ -354,7 +354,7 @@
|
||||
}
|
||||
|
||||
<!-- 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">
|
||||
<p class="text-sm">No other users in this server</p>
|
||||
</div>
|
||||
|
||||
@@ -104,15 +104,30 @@ export class RoomsSidePanelComponent {
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
||||
offlineRoomMembers = computed(() => {
|
||||
const current = this.currentUser();
|
||||
const onlineIds = new Set(this.onlineUsers().map((user) => user.oderId || user.id));
|
||||
roomMemberIdentifiers = computed(() => {
|
||||
const identifiers = new Set<string>();
|
||||
|
||||
if (current) {
|
||||
onlineIds.add(current.oderId || current.id);
|
||||
for (const member of this.roomMembers()) {
|
||||
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(() => {
|
||||
const memberIds = new Set(
|
||||
@@ -151,18 +166,36 @@ export class RoomsSidePanelComponent {
|
||||
volumeMenuPeerId = 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 {
|
||||
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 {
|
||||
const room = this.currentRoom();
|
||||
const user = this.currentUser();
|
||||
|
||||
@@ -117,6 +117,17 @@
|
||||
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"
|
||||
>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 {
|
||||
<ng-icon
|
||||
name="lucideGlobe"
|
||||
@@ -153,6 +164,9 @@
|
||||
<div class="text-muted-foreground">
|
||||
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
|
||||
</div>
|
||||
@if (server.hasPassword && !server.isPrivate && !isServerMarkedBanned(server)) {
|
||||
<div class="text-muted-foreground">Access: <span class="text-foreground/80">Password required</span></div>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
@@ -160,9 +174,9 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
@if (joinErrorMessage() || error()) {
|
||||
<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>
|
||||
@@ -181,6 +195,41 @@
|
||||
</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 -->
|
||||
@if (showCreateDialog()) {
|
||||
<div
|
||||
@@ -249,6 +298,24 @@
|
||||
/>
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -263,22 +330,21 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
@if (newServerPrivate()) {
|
||||
<div>
|
||||
<label
|
||||
for="create-server-password"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Password</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="newServerPassword"
|
||||
placeholder="Enter 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"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<label
|
||||
for="create-server-password"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Password (optional)</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="newServerPassword"
|
||||
placeholder="Leave blank to allow joining without a 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"
|
||||
/>
|
||||
<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 class="flex gap-3 mt-6">
|
||||
@@ -291,7 +357,7 @@
|
||||
</button>
|
||||
<button
|
||||
(click)="createServer()"
|
||||
[disabled]="!newServerName()"
|
||||
[disabled]="!newServerName() || !newServerSourceId"
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Store } from '@ngrx/store';
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
Subject
|
||||
} from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
} from '../../core/models/index';
|
||||
import { SettingsModalService } from '../../core/services/settings-modal.service';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { ConfirmDialogComponent } from '../../shared';
|
||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||
@@ -73,6 +75,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
private router = inject(Router);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private searchSubject = new Subject<string>();
|
||||
private banLookupRequestVersion = 0;
|
||||
|
||||
@@ -82,9 +85,15 @@ export class ServerSearchComponent implements OnInit {
|
||||
error = this.store.selectSignal(selectRoomsError);
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
activeEndpoints = this.serverDirectory.activeServers;
|
||||
bannedServerLookup = signal<Record<string, boolean>>({});
|
||||
bannedServerName = signal('');
|
||||
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
|
||||
showCreateDialog = signal(false);
|
||||
@@ -93,6 +102,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
newServerTopic = signal('');
|
||||
newServerPrivate = signal(false);
|
||||
newServerPassword = signal('');
|
||||
newServerSourceId = '';
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -135,20 +145,12 @@ export class ServerSearchComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: server.id,
|
||||
serverInfo: {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
hostName: server.sourceName || server.hostName
|
||||
}
|
||||
})
|
||||
);
|
||||
await this.attemptJoinServer(server);
|
||||
}
|
||||
|
||||
/** Open the create-server dialog. */
|
||||
openCreateDialog(): void {
|
||||
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
|
||||
this.showCreateDialog.set(true);
|
||||
}
|
||||
|
||||
@@ -176,7 +178,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
description: this.newServerDescription() || undefined,
|
||||
topic: this.newServerTopic() || undefined,
|
||||
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('');
|
||||
}
|
||||
|
||||
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 {
|
||||
return !!this.bannedServerLookup()[server.id];
|
||||
}
|
||||
@@ -223,12 +242,72 @@ export class ServerSearchComponent implements OnInit {
|
||||
hostName: room.hostId || 'Unknown',
|
||||
userCount: room.userCount ?? 0,
|
||||
maxUsers: room.maxUsers ?? 50,
|
||||
isPrivate: !!room.password,
|
||||
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
||||
isPrivate: room.isPrivate,
|
||||
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> {
|
||||
const requestVersion = ++this.banLookupRequestVersion;
|
||||
|
||||
@@ -271,5 +350,6 @@ export class ServerSearchComponent implements OnInit {
|
||||
this.newServerTopic.set('');
|
||||
this.newServerPrivate.set(false);
|
||||
this.newServerPassword.set('');
|
||||
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,41 @@
|
||||
</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()) {
|
||||
<app-leave-server-dialog
|
||||
[room]="contextRoom()!"
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePlus } from '@ng-icons/lucide';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { Room, User } from '../../core/models/index';
|
||||
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 { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||
import {
|
||||
ConfirmDialogComponent,
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent,
|
||||
ContextMenuComponent,
|
||||
@@ -46,6 +50,7 @@ export class ServersRailComponent {
|
||||
private voiceSession = inject(VoiceSessionService);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private banLookupRequestVersion = 0;
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
@@ -59,6 +64,10 @@ export class ServersRailComponent {
|
||||
bannedRoomLookup = signal<Record<string, boolean>>({});
|
||||
bannedServerName = signal('');
|
||||
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)));
|
||||
|
||||
constructor() {
|
||||
@@ -105,27 +114,20 @@ export class ServersRailComponent {
|
||||
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.voiceSession.setViewingVoiceServer(false);
|
||||
} else if (voiceServerId === room.id) {
|
||||
this.voiceSession.setViewingVoiceServer(true);
|
||||
}
|
||||
this.prepareVoiceContext(room);
|
||||
|
||||
if (this.webrtc.hasJoinedServer(room.id)) {
|
||||
this.store.dispatch(RoomsActions.viewServer({ room }));
|
||||
if (this.webrtc.hasJoinedServer(room.id) && roomWsUrl === currentWsUrl) {
|
||||
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||
this.store.dispatch(RoomsActions.viewServer({ room,
|
||||
skipBanCheck: true }));
|
||||
} else {
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: room.id,
|
||||
serverInfo: {
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown'
|
||||
}
|
||||
})
|
||||
);
|
||||
await this.attemptJoinRoom(room);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +136,22 @@ export class ServersRailComponent {
|
||||
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 {
|
||||
return !!this.bannedRoomLookup()[room.id];
|
||||
}
|
||||
@@ -226,4 +244,106 @@ export class ServersRailComponent {
|
||||
|
||||
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>
|
||||
</div>
|
||||
<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 class="flex items-center gap-2">
|
||||
@if (hasMissingDefaultServers()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="restoreDefaultServers()"
|
||||
class="px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Restore Defaults
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
(click)="testAllServers()"
|
||||
[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>
|
||||
|
||||
<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 -->
|
||||
<div class="space-y-2 mb-3">
|
||||
@@ -41,6 +55,7 @@
|
||||
[class.bg-red-500]="server.status === 'offline'"
|
||||
[class.bg-yellow-500]="server.status === 'checking'"
|
||||
[class.bg-muted]="server.status === 'unknown'"
|
||||
[class.bg-orange-500]="server.status === 'incompatible'"
|
||||
></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -53,13 +68,17 @@
|
||||
@if (server.latency !== undefined && server.status === 'online') {
|
||||
<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 class="flex items-center gap-1 flex-shrink-0">
|
||||
@if (!server.isActive) {
|
||||
@if (!server.isActive && server.status !== 'incompatible') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="setActiveServer(server.id)"
|
||||
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Set as active"
|
||||
title="Activate"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
@@ -67,8 +86,22 @@
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
@if (!server.isDefault) {
|
||||
@if (server.isActive && hasMultipleActiveServers()) {
|
||||
<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)"
|
||||
class="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Remove"
|
||||
@@ -103,6 +136,7 @@
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="addServer()"
|
||||
[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"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
signal,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -13,7 +14,8 @@ import {
|
||||
lucideRefreshCw,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideCheck
|
||||
lucideCheck,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryService } from '../../../../core/services/server-directory.service';
|
||||
@@ -34,7 +36,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||
lucideRefreshCw,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideCheck
|
||||
lucideCheck,
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './network-settings.component.html'
|
||||
@@ -43,6 +46,10 @@ export class NetworkSettingsComponent {
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
|
||||
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);
|
||||
addError = signal<string | null>(null);
|
||||
newServerName = '';
|
||||
@@ -91,6 +98,14 @@ export class NetworkSettingsComponent {
|
||||
this.serverDirectory.setActiveServer(id);
|
||||
}
|
||||
|
||||
deactivateServer(id: string): void {
|
||||
this.serverDirectory.deactivateServer(id);
|
||||
}
|
||||
|
||||
restoreDefaultServers(): void {
|
||||
this.serverDirectory.restoreDefaultServers();
|
||||
}
|
||||
|
||||
async testAllServers(): Promise<void> {
|
||||
this.isTesting.set(true);
|
||||
await this.serverDirectory.testAllServers();
|
||||
|
||||
@@ -95,6 +95,84 @@
|
||||
[class.cursor-not-allowed]="!isAdmin()"
|
||||
/>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -53,6 +53,10 @@ export class ServerSettingsComponent {
|
||||
roomName = '';
|
||||
roomDescription = '';
|
||||
isPrivate = signal(false);
|
||||
hasPassword = signal(false);
|
||||
passwordAction = signal<'keep' | 'update' | 'remove'>('keep');
|
||||
passwordError = signal<string | null>(null);
|
||||
roomPassword = '';
|
||||
maxUsers = 0;
|
||||
showDeleteConfirm = signal(false);
|
||||
|
||||
@@ -72,6 +76,10 @@ export class ServerSettingsComponent {
|
||||
this.roomName = room.name;
|
||||
this.roomDescription = room.description || '';
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -86,21 +94,67 @@ export class ServerSettingsComponent {
|
||||
if (!room)
|
||||
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(
|
||||
RoomsActions.updateRoomSettings({
|
||||
roomId: room.id,
|
||||
settings: {
|
||||
name: this.roomName,
|
||||
description: this.roomDescription,
|
||||
isPrivate: this.isPrivate(),
|
||||
maxUsers: this.maxUsers
|
||||
}
|
||||
settings
|
||||
})
|
||||
);
|
||||
|
||||
this.hasPassword.set(settings.hasPassword ?? this.hasPassword());
|
||||
this.passwordAction.set('keep');
|
||||
this.passwordError.set(null);
|
||||
this.roomPassword = '';
|
||||
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 {
|
||||
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">
|
||||
<h3 class="text-lg font-semibold text-foreground">
|
||||
@switch (activePage()) {
|
||||
@case ('general') {
|
||||
General
|
||||
}
|
||||
@case ('network') {
|
||||
Network
|
||||
}
|
||||
@@ -157,6 +160,9 @@
|
||||
<!-- Scrollable Content Area -->
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
@switch (activePage()) {
|
||||
@case ('general') {
|
||||
<app-general-settings />
|
||||
}
|
||||
@case ('network') {
|
||||
<app-network-settings />
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Room, UserRole } from '../../../core/models/index';
|
||||
import { findRoomMember } from '../../../store/rooms/room-members.helpers';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
|
||||
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
|
||||
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
||||
import { VoiceSettingsComponent } from './voice-settings/voice-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,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
GeneralSettingsComponent,
|
||||
NetworkSettingsComponent,
|
||||
VoiceSettingsComponent,
|
||||
UpdatesSettingsComponent,
|
||||
@@ -89,6 +91,9 @@ export class SettingsModalComponent {
|
||||
activePage = this.modal.activePage;
|
||||
|
||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'general',
|
||||
label: 'General',
|
||||
icon: 'lucideSettings' },
|
||||
{ id: 'network',
|
||||
label: 'Network',
|
||||
icon: 'lucideGlobe' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
(click)="goBack()"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Go back"
|
||||
@@ -27,23 +28,34 @@
|
||||
/>
|
||||
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
||||
</div>
|
||||
<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 class="flex items-center gap-2">
|
||||
@if (hasMissingDefaultServers()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="restoreDefaultServers()"
|
||||
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Restore Defaults
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
(click)="testAllServers()"
|
||||
[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>
|
||||
|
||||
<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
|
||||
rooms.
|
||||
Active server endpoints stay enabled at the same time. You pick the endpoint when creating and registering a new server.
|
||||
</p>
|
||||
|
||||
<!-- Server List -->
|
||||
@@ -63,6 +75,7 @@
|
||||
[class.bg-red-500]="server.status === 'offline'"
|
||||
[class.bg-yellow-500]="server.status === 'checking'"
|
||||
[class.bg-muted]="server.status === 'unknown'"
|
||||
[class.bg-orange-500]="server.status === 'incompatible'"
|
||||
[title]="server.status"
|
||||
></div>
|
||||
|
||||
@@ -78,15 +91,19 @@
|
||||
@if (server.latency !== undefined && server.status === 'online') {
|
||||
<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>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
@if (!server.isActive) {
|
||||
@if (!server.isActive && server.status !== 'incompatible') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="setActiveServer(server.id)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Set as active"
|
||||
title="Activate"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
@@ -94,8 +111,22 @@
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
@if (!server.isDefault) {
|
||||
@if (server.isActive && hasMultipleActiveServers()) {
|
||||
<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)"
|
||||
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Remove server"
|
||||
@@ -130,6 +161,7 @@
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="addServer()"
|
||||
[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"
|
||||
@@ -228,6 +260,7 @@
|
||||
class="flex-1 h-2 rounded-full appearance-none bg-secondary accent-primary cursor-pointer"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
(click)="previewNotificationSound()"
|
||||
class="px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
title="Preview sound"
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
OnInit
|
||||
OnInit,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -61,6 +62,10 @@ export class SettingsComponent implements OnInit {
|
||||
audioService = inject(NotificationAudioService);
|
||||
|
||||
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);
|
||||
addError = signal<string | null>(null);
|
||||
|
||||
@@ -122,6 +127,14 @@ export class SettingsComponent implements OnInit {
|
||||
this.serverDirectory.setActiveServer(id);
|
||||
}
|
||||
|
||||
deactivateServer(id: string): void {
|
||||
this.serverDirectory.deactivateServer(id);
|
||||
}
|
||||
|
||||
restoreDefaultServers(): void {
|
||||
this.serverDirectory.restoreDefaultServers();
|
||||
}
|
||||
|
||||
/** Test connectivity to all configured servers. */
|
||||
async testAllServers(): Promise<void> {
|
||||
this.isTesting.set(true);
|
||||
|
||||
@@ -13,6 +13,22 @@
|
||||
/>
|
||||
<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()) {
|
||||
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
||||
{{ roomDescription() }}
|
||||
@@ -21,9 +37,11 @@
|
||||
} @else {
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
||||
@if (isReconnecting()) {
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
|
||||
}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
|
||||
[class.hidden]="!isReconnecting()"
|
||||
>Reconnecting…</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -31,16 +49,15 @@
|
||||
class="flex items-center gap-2"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
@if (!isAuthed()) {
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
|
||||
(click)="goLogin()"
|
||||
title="Login"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
|
||||
[class.hidden]="isAuthed()"
|
||||
(click)="goLogin()"
|
||||
title="Login"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -55,8 +72,20 @@
|
||||
</button>
|
||||
<!-- Anchored dropdown under the menu button -->
|
||||
@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()) {
|
||||
<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
|
||||
type="button"
|
||||
(click)="leaveServer()"
|
||||
@@ -65,6 +94,12 @@
|
||||
Leave Server
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
@@ -14,10 +15,15 @@ import {
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
lucideHash,
|
||||
lucideMenu
|
||||
lucideMenu,
|
||||
lucideRefreshCw
|
||||
} from '@ng-icons/lucide';
|
||||
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 { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
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 { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||
import { LeaveServerDialogComponent } from '../../shared';
|
||||
import { Room } from '../../core/models/index';
|
||||
|
||||
interface WindowControlsAPI {
|
||||
minimizeWindow?: () => void;
|
||||
@@ -50,7 +57,8 @@ type ElectronWindow = Window & {
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
lucideHash,
|
||||
lucideMenu })
|
||||
lucideMenu,
|
||||
lucideRefreshCw })
|
||||
],
|
||||
templateUrl: './title-bar.component.html'
|
||||
})
|
||||
@@ -78,12 +86,28 @@ export class TitleBarComponent {
|
||||
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
||||
isAuthed = computed(() => !!this.currentUser());
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
|
||||
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
|
||||
inRoom = computed(() => !!this.currentRoom());
|
||||
roomName = computed(() => this.currentRoom()?.name || '');
|
||||
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);
|
||||
showMenu = computed(() => this._showMenu());
|
||||
showLeaveConfirm = signal(false);
|
||||
inviteStatus = signal<string | null>(null);
|
||||
creatingInvite = signal(false);
|
||||
|
||||
/** Minimize the Electron window. */
|
||||
minimize() {
|
||||
@@ -122,9 +146,44 @@ export class TitleBarComponent {
|
||||
|
||||
/** Toggle the server dropdown menu. */
|
||||
toggleMenu() {
|
||||
this.inviteStatus.set(null);
|
||||
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. */
|
||||
leaveServer() {
|
||||
this.openLeaveConfirm();
|
||||
@@ -170,4 +229,44 @@ export class TitleBarComponent {
|
||||
|
||||
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,
|
||||
signal,
|
||||
computed,
|
||||
OnInit,
|
||||
OnDestroy
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import {
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
@@ -28,6 +26,7 @@ import { ScreenShareQuality } from '../../../core/services/webrtc';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared';
|
||||
import { VoicePlaybackService } from '../voice-controls/services/voice-playback.service';
|
||||
|
||||
@Component({
|
||||
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.
|
||||
* 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 voiceSessionService = inject(VoiceSessionService);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private store = inject(Store);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
@@ -69,25 +69,27 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||
isMuted = signal(false);
|
||||
isDeafened = signal(false);
|
||||
isScreenSharing = signal(false);
|
||||
isScreenSharing = this.webrtcService.isScreenSharing;
|
||||
includeSystemAudio = signal(false);
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
askScreenShareQuality = signal(true);
|
||||
showScreenShareQualityDialog = signal(false);
|
||||
|
||||
private stateSubscription: Subscription | null = null;
|
||||
|
||||
/** Sync local mute/deafen/screen-share state from the WebRTC service on init. */
|
||||
/** Sync local mute/deafen state from the WebRTC service on init. */
|
||||
ngOnInit(): void {
|
||||
// Sync mute/deafen state from webrtc service
|
||||
this.isMuted.set(this.webrtcService.isMuted());
|
||||
this.isDeafened.set(this.webrtcService.isDeafened());
|
||||
this.isScreenSharing.set(this.webrtcService.isScreenSharing());
|
||||
this.syncScreenShareSettings();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stateSubscription?.unsubscribe();
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
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. */
|
||||
@@ -117,6 +119,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
toggleDeafen(): void {
|
||||
this.isDeafened.update((current) => !current);
|
||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
// When deafening, also mute
|
||||
if (this.isDeafened() && !this.isMuted()) {
|
||||
@@ -141,7 +144,6 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.webrtcService.stopScreenShare();
|
||||
this.isScreenSharing.set(false);
|
||||
} else {
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
@@ -189,6 +191,8 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Disable voice
|
||||
this.webrtcService.disableVoice();
|
||||
this.voicePlayback.teardownAll();
|
||||
this.voicePlayback.updateDeafened(false);
|
||||
|
||||
// Update user voice state in store
|
||||
const user = this.currentUser();
|
||||
@@ -208,7 +212,6 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.voiceSessionService.endSession();
|
||||
|
||||
// Reset local state
|
||||
this.isScreenSharing.set(false);
|
||||
this.isMuted.set(false);
|
||||
this.isDeafened.set(false);
|
||||
}
|
||||
@@ -282,8 +285,6 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
quality
|
||||
});
|
||||
|
||||
this.isScreenSharing.set(true);
|
||||
} catch (_error) {
|
||||
// Screen share request was denied or failed
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
effect,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants';
|
||||
|
||||
@@ -14,11 +18,14 @@ export interface PlaybackOptions {
|
||||
*
|
||||
* Chrome/Electron workaround: a muted HTMLAudioElement is attached to
|
||||
* the stream first so that `createMediaStreamSource` actually outputs
|
||||
* audio. The element itself is silent - all audible output comes from
|
||||
* the GainNode -> AudioContext.destination path.
|
||||
* audio. The priming element itself is silent; audible output is routed
|
||||
* through a separate output element fed by
|
||||
* `GainNode -> MediaStreamDestination` so output-device switching stays
|
||||
* reliable during Linux screen sharing.
|
||||
*/
|
||||
interface PeerAudioPipeline {
|
||||
audioElement: HTMLAudioElement;
|
||||
outputElement: HTMLAudioElement;
|
||||
context: AudioContext;
|
||||
sourceNodes: MediaStreamAudioSourceNode[];
|
||||
gainNode: GainNode;
|
||||
@@ -34,11 +41,48 @@ export class VoicePlaybackService {
|
||||
private userVolumes = new Map<string, number>();
|
||||
private userMuted = new Map<string, boolean>();
|
||||
private preferredOutputDeviceId = 'default';
|
||||
private temporaryOutputDeviceId: string | null = null;
|
||||
private masterVolume = 1;
|
||||
private deafened = false;
|
||||
private captureEchoSuppressed = false;
|
||||
|
||||
constructor() {
|
||||
this.loadPersistedVolumes();
|
||||
|
||||
effect(() => {
|
||||
this.captureEchoSuppressed = this.webrtc.isScreenShareRemotePlaybackSuppressed();
|
||||
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 {
|
||||
@@ -137,6 +181,14 @@ export class VoicePlaybackService {
|
||||
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:
|
||||
*
|
||||
@@ -144,11 +196,12 @@ export class VoicePlaybackService {
|
||||
* ↓
|
||||
* muted <audio> element (Chrome workaround - primes the stream)
|
||||
* ↓
|
||||
* MediaStreamSource → GainNode → AudioContext.destination
|
||||
* MediaStreamSource → GainNode → MediaStreamDestination → output <audio>
|
||||
*/
|
||||
private createPipeline(peerId: string, stream: MediaStream): void {
|
||||
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.
|
||||
const audioEl = new Audio();
|
||||
const outputEl = new Audio();
|
||||
const audioTracks = stream.getAudioTracks().filter((track) => track.readyState === 'live');
|
||||
|
||||
audioEl.srcObject = stream;
|
||||
@@ -157,12 +210,24 @@ export class VoicePlaybackService {
|
||||
|
||||
const ctx = new AudioContext();
|
||||
const gainNode = ctx.createGain();
|
||||
const mediaDestination = ctx.createMediaStreamDestination();
|
||||
const sourceNodes = audioTracks.map((track) => ctx.createMediaStreamSource(new MediaStream([track])));
|
||||
|
||||
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);
|
||||
|
||||
@@ -184,26 +249,20 @@ export class VoicePlaybackService {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
const anyAudio = pipeline.audioElement as any;
|
||||
// eslint-disable-next-line
|
||||
const anyCtx = pipeline.context as any;
|
||||
const anyAudio = pipeline.outputElement as any;
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
|
||||
if (typeof anyAudio.setSinkId === 'function') {
|
||||
tasks.push(anyAudio.setSinkId(deviceId).catch(() => undefined));
|
||||
}
|
||||
|
||||
if (typeof anyCtx.setSinkId === 'function') {
|
||||
tasks.push(anyCtx.setSinkId(deviceId).catch(() => undefined));
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
private getEffectiveOutputDeviceId(): string {
|
||||
return this.preferredOutputDeviceId;
|
||||
return this.temporaryOutputDeviceId ?? this.preferredOutputDeviceId;
|
||||
}
|
||||
|
||||
private removePipeline(peerId: string): void {
|
||||
@@ -228,6 +287,8 @@ export class VoicePlaybackService {
|
||||
|
||||
pipeline.audioElement.srcObject = null;
|
||||
pipeline.audioElement.remove();
|
||||
pipeline.outputElement.srcObject = null;
|
||||
pipeline.outputElement.remove();
|
||||
|
||||
if (pipeline.context.state !== 'closed') {
|
||||
pipeline.context.close().catch(() => {});
|
||||
@@ -242,7 +303,7 @@ export class VoicePlaybackService {
|
||||
if (!pipeline)
|
||||
return;
|
||||
|
||||
if (this.deafened || this.isUserMuted(peerId)) {
|
||||
if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId)) {
|
||||
pipeline.gainNode.gain.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import {
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
@@ -76,7 +75,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private store = inject(Store);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private remoteStreamSubscription: Subscription | null = null;
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
@@ -86,7 +84,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
connectionErrorMessage = computed(() => this.webrtcService.connectionErrorMessage());
|
||||
isMuted = signal(false);
|
||||
isDeafened = signal(false);
|
||||
isScreenSharing = signal(false);
|
||||
isScreenSharing = this.webrtcService.isScreenSharing;
|
||||
showSettings = signal(false);
|
||||
|
||||
inputDevices = signal<AudioDevice[]>([]);
|
||||
@@ -110,56 +108,18 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
isDeafened: this.isDeafened()
|
||||
};
|
||||
}
|
||||
|
||||
private voiceConnectedSubscription: Subscription | null = null;
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.loadAudioDevices();
|
||||
|
||||
// Load persisted voice settings and apply
|
||||
this.loadSettings();
|
||||
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 {
|
||||
if (this.isConnected()) {
|
||||
this.disconnect();
|
||||
if (!this.webrtcService.isVoiceConnected()) {
|
||||
this.voicePlayback.teardownAll();
|
||||
}
|
||||
|
||||
this.voicePlayback.teardownAll();
|
||||
|
||||
this.remoteStreamSubscription?.unsubscribe();
|
||||
this.voiceConnectedSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
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)
|
||||
this.webrtcService.disableVoice();
|
||||
this.voicePlayback.teardownAll();
|
||||
this.voicePlayback.updateDeafened(false);
|
||||
|
||||
const user = this.currentUser();
|
||||
|
||||
@@ -325,7 +286,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
// End voice session for floating controls
|
||||
this.voiceSessionService.endSession();
|
||||
|
||||
this.isScreenSharing.set(false);
|
||||
this.isMuted.set(false);
|
||||
this.isDeafened.set(false);
|
||||
}
|
||||
@@ -407,7 +367,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.webrtcService.stopScreenShare();
|
||||
this.isScreenSharing.set(false);
|
||||
} else {
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
@@ -578,8 +537,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
quality
|
||||
});
|
||||
|
||||
this.isScreenSharing.set(true);
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,65 @@
|
||||
{{ autoScroll() ? 'Pause auto-scroll' : 'Resume auto-scroll' }}
|
||||
</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
|
||||
type="button"
|
||||
(click)="clear()"
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
input,
|
||||
output
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideDownload,
|
||||
lucideFilter,
|
||||
lucidePause,
|
||||
lucidePlay,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
type DebugLogLevel = 'event' | 'info' | 'warn' | 'error' | 'debug';
|
||||
type DebugExportFormat = 'csv' | 'txt';
|
||||
|
||||
interface DebugNetworkSummary {
|
||||
clientCount: number;
|
||||
@@ -34,6 +38,7 @@ interface DebugNetworkSummary {
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideDownload,
|
||||
lucideFilter,
|
||||
lucidePause,
|
||||
lucidePlay,
|
||||
@@ -64,6 +69,10 @@ export class DebugConsoleToolbarComponent {
|
||||
readonly autoScrollToggled = output<undefined>();
|
||||
readonly clearRequested = output<undefined>();
|
||||
readonly closeRequested = output<undefined>();
|
||||
readonly exportLogsRequested = output<DebugExportFormat>();
|
||||
readonly exportNetworkRequested = output<DebugExportFormat>();
|
||||
|
||||
readonly exportMenuOpen = signal(false);
|
||||
|
||||
readonly levels: DebugLogLevel[] = [
|
||||
'event',
|
||||
@@ -75,6 +84,17 @@ export class DebugConsoleToolbarComponent {
|
||||
|
||||
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 {
|
||||
this.activeTabChange.emit(tab);
|
||||
}
|
||||
@@ -111,6 +131,24 @@ export class DebugConsoleToolbarComponent {
|
||||
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 {
|
||||
return this.detached() ? 'Dock' : 'Detach';
|
||||
}
|
||||
|
||||
@@ -102,10 +102,11 @@
|
||||
[style.left.px]="detached() ? panelLeft() : null"
|
||||
[style.top.px]="detached() ? panelTop() : null"
|
||||
>
|
||||
<!-- Left resize bar -->
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<span
|
||||
@@ -113,10 +114,23 @@
|
||||
></span>
|
||||
</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
|
||||
type="button"
|
||||
class="group relative h-3 w-full cursor-row-resize bg-transparent"
|
||||
(mousedown)="startResize($event)"
|
||||
(mousedown)="startTopResize($event)"
|
||||
aria-label="Resize debug console"
|
||||
>
|
||||
<span
|
||||
@@ -154,6 +168,8 @@
|
||||
(autoScrollToggled)="toggleAutoScroll()"
|
||||
(clearRequested)="clearLogs()"
|
||||
(closeRequested)="closeConsole()"
|
||||
(exportLogsRequested)="exportLogs($event)"
|
||||
(exportNetworkRequested)="exportNetwork($event)"
|
||||
/>
|
||||
|
||||
@if (activeTab() === 'logs') {
|
||||
@@ -168,6 +184,48 @@
|
||||
[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>
|
||||
</div>
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user