Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b6578da3c | |||
| 851d6ae759 | |||
| 1e833ec7f2 | |||
| 64e34ad586 | |||
| e3b23247a9 | |||
| 42ac712571 | |||
| b7d4bf20e3 | |||
| 727059fb52 | |||
| 83694570e3 | |||
| 109402cdd6 | |||
| eb23fd71ec | |||
| 11917f3412 | |||
| 8162e0444a | |||
| 0467a7b612 | |||
| 971a5afb8b | |||
| fe9c1dd1c0 | |||
| 429bb9d8ff | |||
| b5d676fb78 | |||
| aa595c45d8 | |||
| 1c7e535057 | |||
| 8f960be1e9 | |||
| 9a173792a4 | |||
| cb2c0495b9 | |||
| c3ef8e8800 | |||
| c862c2fe03 | |||
| 4faa62864d | |||
| 1cdd1c5d2b | |||
| 141de64767 | |||
| eb987ac672 | |||
| f8fd78d21a | |||
| 150c45c31a | |||
| 00adf39121 | |||
| 2b6e477c9a | |||
| 22d355a522 | |||
| 15c5952e29 | |||
| 781c05294f | |||
| 778e75bef5 | |||
| 7bf37ba510 | |||
| 3c04b5db26 | |||
| 45e0b09af8 | |||
| 106212ef3d |
@@ -1,4 +1,5 @@
|
||||
# Toggle SSL for local development (true/false)
|
||||
# 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
|
||||
@@ -67,8 +67,10 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: |
|
||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
|
||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
|
||||
cd toju-app
|
||||
npx ng build --configuration production --base-href='./'
|
||||
cd ..
|
||||
npx --package typescript tsc -p tsconfig.electron.json
|
||||
cd server
|
||||
node ../tools/sync-server-build-version.js
|
||||
@@ -120,8 +122,10 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: |
|
||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
|
||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
|
||||
Push-Location "toju-app"
|
||||
npx ng build --configuration production --base-href='./'
|
||||
Pop-Location
|
||||
npx --package typescript tsc -p tsconfig.electron.json
|
||||
Push-Location server
|
||||
node ../tools/sync-server-build-version.js
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -6,7 +6,9 @@
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
*.sqlite
|
||||
*/architecture.md
|
||||
/docs
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
@@ -51,3 +53,6 @@ Thumbs.db
|
||||
.certs/
|
||||
/server/data/variables.json
|
||||
dist-server/*
|
||||
|
||||
AGENTS.md
|
||||
doc/**
|
||||
|
||||
73
README.md
73
README.md
@@ -1,10 +1,14 @@
|
||||
<img src="./images/icon.png" width="100" height="100">
|
||||
|
||||
|
||||
# Toju / Zoracord
|
||||
|
||||
Desktop chat app with three parts:
|
||||
Desktop chat app with four parts:
|
||||
|
||||
- `src/` Angular client
|
||||
- `electron/` desktop shell, IPC, and local database
|
||||
- `server/` directory server, join request API, and websocket events
|
||||
- `website/` Toju website served at toju.app
|
||||
|
||||
## Install
|
||||
|
||||
@@ -17,7 +21,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,6 +29,10 @@ Server files:
|
||||
|
||||
- `server/data/variables.json` holds `klipyApiKey`
|
||||
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
|
||||
- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to)
|
||||
- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`)
|
||||
- `server/data/variables.json` can now also hold `serverPort` (1-65535)
|
||||
- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP
|
||||
|
||||
## Main commands
|
||||
|
||||
@@ -48,3 +56,64 @@ Inside `server/`:
|
||||
- `npm run dev` starts the server with reload
|
||||
- `npm run build` compiles to `dist/`
|
||||
- `npm run start` runs the compiled server
|
||||
|
||||
# Images
|
||||
<img src="./website/src/images/screenshots/gif.png" width="700" height="400">
|
||||
<img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
|
||||
|
||||
## Main Toju app Structure
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `src/app/` | Main application root |
|
||||
| `src/app/core/` | Core utilities, services, models |
|
||||
| `src/app/domains/` | Domain-driven modules |
|
||||
| `src/app/features/` | UI feature modules |
|
||||
| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) |
|
||||
| `src/app/shared/` | Shared UI components |
|
||||
| `src/app/shared-kernel/` | Shared domain contracts & models |
|
||||
| `src/app/store/` | Global state management |
|
||||
| `src/assets/` | Static assets |
|
||||
| `src/environments/` | Environment configs |
|
||||
|
||||
---
|
||||
|
||||
### Domains
|
||||
|
||||
| Path | Link |
|
||||
|------|------|
|
||||
| Attachment | [app/domains/attachment/README.md](src/app/domains/attachment/README.md) |
|
||||
| Auth | [app/domains/auth/README.md](src/app/domains/auth/README.md) |
|
||||
| Chat | [app/domains/chat/README.md](src/app/domains/chat/README.md) |
|
||||
| Screen Share | [app/domains/screen-share/README.md](src/app/domains/screen-share/README.md) |
|
||||
| Server Directory | [app/domains/server-directory/README.md](src/app/domains/server-directory/README.md) |
|
||||
| Voice Connection | [app/domains/voice-connection/README.md](src/app/domains/voice-connection/README.md) |
|
||||
| Voice Session | [app/domains/voice-session/README.md](src/app/domains/voice-session/README.md) |
|
||||
| Domains Root | [app/domains/README.md](src/app/domains/README.md) |
|
||||
|
||||
---
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Path | Link |
|
||||
|------|------|
|
||||
| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) |
|
||||
| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) |
|
||||
|
||||
---
|
||||
|
||||
### Shared Kernel
|
||||
|
||||
| Path | Link |
|
||||
|------|------|
|
||||
| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) |
|
||||
|
||||
---
|
||||
|
||||
### Entry Points
|
||||
|
||||
| File | Link |
|
||||
|------|------|
|
||||
| Main | [main.ts](src/main.ts) |
|
||||
| Index HTML | [index.html](src/index.html) |
|
||||
| App Root | [app/app.ts](src/app/app.ts) |
|
||||
|
||||
6
dev.sh
6
dev.sh
@@ -20,12 +20,12 @@ if [ "$SSL" = "true" ]; then
|
||||
"$DIR/generate-cert.sh"
|
||||
fi
|
||||
|
||||
NG_SERVE="ng serve --host=0.0.0.0 --ssl --ssl-cert=.certs/localhost.crt --ssl-key=.certs/localhost.key"
|
||||
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key"
|
||||
WAIT_URL="https://localhost:4200"
|
||||
HEALTH_URL="https://localhost:3001/api/health"
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
else
|
||||
NG_SERVE="ng serve --host=0.0.0.0"
|
||||
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
|
||||
WAIT_URL="http://localhost:4200"
|
||||
HEALTH_URL="http://localhost:3001/api/health"
|
||||
fi
|
||||
@@ -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"
|
||||
|
||||
129
electron/app/auto-start.ts
Normal file
129
electron/app/auto-start.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { app } from 'electron';
|
||||
import AutoLaunch from 'auto-launch';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
let autoLauncher: AutoLaunch | null = null;
|
||||
let autoLaunchPath = '';
|
||||
|
||||
const LINUX_AUTO_START_ARGUMENTS = ['--no-sandbox', '%U'];
|
||||
|
||||
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 escapeDesktopEntryExecArgument(argument: string): string {
|
||||
const escapedArgument = argument.replace(/(["\\$`])/g, '\\$1');
|
||||
|
||||
return /[\s"]/u.test(argument)
|
||||
? `"${escapedArgument}"`
|
||||
: escapedArgument;
|
||||
}
|
||||
|
||||
function getLinuxAutoStartDesktopEntryPath(launchPath: string): string {
|
||||
return path.join(app.getPath('home'), '.config', 'autostart', `${path.basename(launchPath)}.desktop`);
|
||||
}
|
||||
|
||||
function buildLinuxAutoStartExecLine(launchPath: string): string {
|
||||
return `Exec=${[escapeDesktopEntryExecArgument(launchPath), ...LINUX_AUTO_START_ARGUMENTS].join(' ')}`;
|
||||
}
|
||||
|
||||
function buildLinuxAutoStartDesktopEntry(launchPath: string): string {
|
||||
const appName = path.basename(launchPath);
|
||||
|
||||
return [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Version=1.0',
|
||||
`Name=${appName}`,
|
||||
`Comment=${appName}startup script`,
|
||||
buildLinuxAutoStartExecLine(launchPath),
|
||||
'StartupNotify=false',
|
||||
'Terminal=false'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function synchronizeLinuxAutoStartDesktopEntry(launchPath: string): Promise<void> {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
const desktopEntryPath = getLinuxAutoStartDesktopEntryPath(launchPath);
|
||||
const execLine = buildLinuxAutoStartExecLine(launchPath);
|
||||
|
||||
let currentDesktopEntry = '';
|
||||
|
||||
try {
|
||||
currentDesktopEntry = await fsp.readFile(desktopEntryPath, 'utf8');
|
||||
} catch {
|
||||
// Create the desktop entry if auto-launch did not leave one behind.
|
||||
}
|
||||
|
||||
const nextDesktopEntry = currentDesktopEntry
|
||||
? /^Exec=.*$/m.test(currentDesktopEntry)
|
||||
? currentDesktopEntry.replace(/^Exec=.*$/m, execLine)
|
||||
: `${currentDesktopEntry.trimEnd()}\n${execLine}\n`
|
||||
: buildLinuxAutoStartDesktopEntry(launchPath);
|
||||
|
||||
if (nextDesktopEntry === currentDesktopEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fsp.mkdir(path.dirname(desktopEntryPath), { recursive: true });
|
||||
await fsp.writeFile(desktopEntryPath, nextDesktopEntry, 'utf8');
|
||||
}
|
||||
|
||||
function getAutoLauncher(): AutoLaunch | null {
|
||||
if (!app.isPackaged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!autoLauncher) {
|
||||
autoLaunchPath = resolveLaunchPath();
|
||||
autoLauncher = new AutoLaunch({
|
||||
name: app.getName(),
|
||||
path: autoLaunchPath
|
||||
});
|
||||
}
|
||||
|
||||
return autoLauncher;
|
||||
}
|
||||
|
||||
async function setAutoStartEnabled(enabled: boolean): Promise<void> {
|
||||
const launcher = getAutoLauncher();
|
||||
|
||||
if (!launcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentlyEnabled = await launcher.isEnabled();
|
||||
|
||||
if (!enabled && currentlyEnabled === enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
if (!currentlyEnabled) {
|
||||
await launcher.enable();
|
||||
}
|
||||
|
||||
await synchronizeLinuxAutoStartDesktopEntry(autoLaunchPath);
|
||||
return;
|
||||
}
|
||||
|
||||
await launcher.disable();
|
||||
}
|
||||
|
||||
export async function synchronizeAutoStartSetting(enabled = readDesktopSettings().autoStart): Promise<void> {
|
||||
try {
|
||||
await setAutoStartEnabled(enabled);
|
||||
} catch {
|
||||
// Auto-launch integration should never block app startup or settings saves.
|
||||
}
|
||||
}
|
||||
121
electron/app/deep-links.ts
Normal file
121
electron/app/deep-links.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { createWindow, getMainWindow } from '../window/create-window';
|
||||
|
||||
const CUSTOM_PROTOCOL = 'toju';
|
||||
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
||||
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
||||
|
||||
let pendingDeepLink: string | null = null;
|
||||
|
||||
function resolveDevSingleInstanceExitCode(): number | null {
|
||||
const rawValue = process.env[DEV_SINGLE_INSTANCE_EXIT_CODE_ENV];
|
||||
|
||||
if (!rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0
|
||||
? parsedValue
|
||||
: null;
|
||||
}
|
||||
|
||||
function extractDeepLink(argv: string[]): string | null {
|
||||
return argv.find((argument) => typeof argument === 'string' && argument.startsWith(DEEP_LINK_PREFIX)) || null;
|
||||
}
|
||||
|
||||
function focusMainWindow(): void {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
function forwardDeepLink(url: string): void {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isLoadingMainFrame()) {
|
||||
pendingDeepLink = url;
|
||||
|
||||
if (app.isReady() && (!mainWindow || mainWindow.isDestroyed())) {
|
||||
void createWindow();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
focusMainWindow();
|
||||
mainWindow.webContents.send('deep-link-received', url);
|
||||
}
|
||||
|
||||
function registerProtocolClient(): void {
|
||||
if (process.defaultApp) {
|
||||
const appEntrypoint = process.argv[1];
|
||||
|
||||
if (appEntrypoint) {
|
||||
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL, process.execPath, [path.resolve(appEntrypoint)]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL);
|
||||
}
|
||||
|
||||
export function initializeDeepLinkHandling(): boolean {
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
const devExitCode = resolveDevSingleInstanceExitCode();
|
||||
|
||||
if (devExitCode != null) {
|
||||
app.exit(devExitCode);
|
||||
} else {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
registerProtocolClient();
|
||||
|
||||
const initialDeepLink = extractDeepLink(process.argv);
|
||||
|
||||
if (initialDeepLink) {
|
||||
pendingDeepLink = initialDeepLink;
|
||||
}
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
focusMainWindow();
|
||||
|
||||
const deepLink = extractDeepLink(argv);
|
||||
|
||||
if (deepLink) {
|
||||
forwardDeepLink(deepLink);
|
||||
}
|
||||
});
|
||||
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
forwardDeepLink(url);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function consumePendingDeepLink(): string | null {
|
||||
const deepLink = pendingDeepLink;
|
||||
|
||||
pendingDeepLink = null;
|
||||
|
||||
return deepLink;
|
||||
}
|
||||
@@ -45,9 +45,9 @@ function linuxSpecificFlags(): void {
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
|
||||
// Auto-detect Wayland vs X11 so the xdg-desktop-portal system picker
|
||||
// works for screen capture on Wayland compositors
|
||||
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
|
||||
// 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 {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
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,
|
||||
getDataSource
|
||||
} from '../db/database';
|
||||
import { createWindow, getDockIconPath } from '../window/create-window';
|
||||
import {
|
||||
createWindow,
|
||||
getDockIconPath,
|
||||
getMainWindow,
|
||||
prepareWindowForAppQuit,
|
||||
showMainWindow
|
||||
} from '../window/create-window';
|
||||
import {
|
||||
setupCqrsHandlers,
|
||||
setupSystemHandlers,
|
||||
@@ -24,12 +31,18 @@ export function registerAppLifecycle(): void {
|
||||
setupCqrsHandlers();
|
||||
setupWindowControlHandlers();
|
||||
setupSystemHandlers();
|
||||
await synchronizeAutoStartSetting();
|
||||
initializeDesktopUpdater();
|
||||
await createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (getMainWindow()) {
|
||||
void showMainWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (BrowserWindow.getAllWindows().length === 0)
|
||||
createWindow();
|
||||
void createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +52,8 @@ export function registerAppLifecycle(): void {
|
||||
});
|
||||
|
||||
app.on('before-quit', async (event) => {
|
||||
prepareWindowForAppQuit();
|
||||
|
||||
if (getDataSource()?.isInitialized) {
|
||||
event.preventDefault();
|
||||
shutdownDesktopUpdater();
|
||||
|
||||
@@ -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,35 +443,53 @@ 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);
|
||||
|
||||
if (sinkName === VOICE_SINK_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-app streams sitting on the voice sink should be moved to the
|
||||
// screenshare sink for desktop-audio capture.
|
||||
if (!appOwned && sinkName === VOICE_SINK_NAME) {
|
||||
try {
|
||||
await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME);
|
||||
} catch {
|
||||
// Streams can disappear or be recreated while rerouting.
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
18
electron/cqrs/queries/handlers/getMessagesSince.ts
Normal file
18
electron/cqrs/queries/handlers/getMessagesSince.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { DataSource, MoreThan } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { GetMessagesSinceQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
|
||||
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const { roomId, sinceTimestamp } = query.payload;
|
||||
const rows = await repo.find({
|
||||
where: {
|
||||
roomId,
|
||||
timestamp: MoreThan(sinceTimestamp)
|
||||
},
|
||||
order: { timestamp: 'ASC' }
|
||||
});
|
||||
|
||||
return rows.map(rowToMessage);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
QueryTypeKey,
|
||||
Query,
|
||||
GetMessagesQuery,
|
||||
GetMessagesSinceQuery,
|
||||
GetMessageByIdQuery,
|
||||
GetReactionsForMessageQuery,
|
||||
GetUserQuery,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
GetAttachmentsForMessageQuery
|
||||
} from '../types';
|
||||
import { handleGetMessages } from './handlers/getMessages';
|
||||
import { handleGetMessagesSince } from './handlers/getMessagesSince';
|
||||
import { handleGetMessageById } from './handlers/getMessageById';
|
||||
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
|
||||
import { handleGetUser } from './handlers/getUser';
|
||||
@@ -27,6 +29,7 @@ import { handleGetAllAttachments } from './handlers/getAllAttachments';
|
||||
|
||||
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
|
||||
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
|
||||
[QueryType.GetMessagesSince]: (query) => handleGetMessagesSince(query as GetMessagesSinceQuery, dataSource),
|
||||
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
|
||||
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
|
||||
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
|
||||
|
||||
@@ -22,6 +22,7 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
|
||||
|
||||
export const QueryType = {
|
||||
GetMessages: 'get-messages',
|
||||
GetMessagesSince: 'get-messages-since',
|
||||
GetMessageById: 'get-message-by-id',
|
||||
GetReactionsForMessage: 'get-reactions-for-message',
|
||||
GetUser: 'get-user',
|
||||
@@ -84,6 +85,7 @@ export interface RoomPayload {
|
||||
topic?: string;
|
||||
hostId: string;
|
||||
password?: string;
|
||||
hasPassword?: boolean;
|
||||
isPrivate?: boolean;
|
||||
createdAt: number;
|
||||
userCount?: number;
|
||||
@@ -93,6 +95,9 @@ export interface RoomPayload {
|
||||
permissions?: unknown;
|
||||
channels?: unknown[];
|
||||
members?: unknown[];
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface BanPayload {
|
||||
@@ -156,6 +161,7 @@ export type Command =
|
||||
| ClearAllDataCommand;
|
||||
|
||||
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
|
||||
export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } }
|
||||
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
|
||||
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
||||
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
|
||||
@@ -170,6 +176,7 @@ export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachmen
|
||||
|
||||
export type Query =
|
||||
| GetMessagesQuery
|
||||
| GetMessagesSinceQuery
|
||||
| GetMessageByIdQuery
|
||||
| GetReactionsForMessageQuery
|
||||
| GetUserQuery
|
||||
|
||||
@@ -6,6 +6,8 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||
|
||||
export interface DesktopSettings {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
autoStart: boolean;
|
||||
closeToTray: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
@@ -19,6 +21,8 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
||||
|
||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
autoUpdateMode: 'auto',
|
||||
autoStart: true,
|
||||
closeToTray: true,
|
||||
hardwareAcceleration: true,
|
||||
manifestUrls: [],
|
||||
preferredVersion: null,
|
||||
@@ -81,6 +85,12 @@ export function readDesktopSettings(): DesktopSettings {
|
||||
|
||||
return {
|
||||
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
||||
autoStart: typeof parsed.autoStart === 'boolean'
|
||||
? parsed.autoStart
|
||||
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||
closeToTray: typeof parsed.closeToTray === 'boolean'
|
||||
? parsed.closeToTray
|
||||
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
|
||||
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
||||
? parsed.vaapiVideoEncode
|
||||
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
||||
@@ -102,6 +112,12 @@ 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,
|
||||
closeToTray: typeof mergedSettings.closeToTray === 'boolean'
|
||||
? mergedSettings.closeToTray
|
||||
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
desktopCapturer,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Notification,
|
||||
shell
|
||||
} from 'electron';
|
||||
import * as fs from 'fs';
|
||||
@@ -28,8 +29,16 @@ import {
|
||||
getDesktopUpdateState,
|
||||
handleDesktopSettingsChanged,
|
||||
restartToApplyUpdate,
|
||||
readDesktopUpdateServerHealth,
|
||||
type DesktopUpdateServerContext
|
||||
} from '../update/desktop-updater';
|
||||
import { consumePendingDeepLink } from '../app/deep-links';
|
||||
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
||||
import {
|
||||
getMainWindow,
|
||||
getWindowIconPath,
|
||||
updateCloseToTraySetting
|
||||
} from '../window/create-window';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const FILE_CLIPBOARD_FORMATS = [
|
||||
@@ -83,6 +92,63 @@ interface ClipboardFilePayload {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface DesktopNotificationPayload {
|
||||
body: string;
|
||||
requestAttention?: boolean;
|
||||
title: 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 +260,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,6 +273,8 @@ export function setupSystemHandlers(): void {
|
||||
return false;
|
||||
});
|
||||
|
||||
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
|
||||
|
||||
ipcMain.handle('get-sources', async () => {
|
||||
try {
|
||||
const thumbnailSize = { width: 240, height: 150 };
|
||||
@@ -256,8 +328,78 @@ export function setupSystemHandlers(): void {
|
||||
|
||||
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
|
||||
|
||||
ipcMain.handle('show-desktop-notification', async (_event, payload: DesktopNotificationPayload) => {
|
||||
const title = typeof payload?.title === 'string' ? payload.title.trim() : '';
|
||||
const body = typeof payload?.body === 'string' ? payload.body : '';
|
||||
const mainWindow = getMainWindow();
|
||||
const suppressSystemNotification = mainWindow?.isVisible() === true
|
||||
&& !mainWindow.isMinimized()
|
||||
&& mainWindow.isMaximized();
|
||||
|
||||
if (!title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!suppressSystemNotification && Notification.isSupported()) {
|
||||
try {
|
||||
const notification = new Notification({
|
||||
title,
|
||||
body,
|
||||
icon: getWindowIconPath(),
|
||||
silent: true
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
if (!mainWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show();
|
||||
}
|
||||
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
notification.show();
|
||||
} catch {
|
||||
// Ignore notification center failures and still attempt taskbar attention.
|
||||
}
|
||||
}
|
||||
|
||||
if (payload?.requestAttention && mainWindow && (mainWindow.isMinimized() || !mainWindow.isFocused())) {
|
||||
mainWindow.flashFrame(true);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('request-window-attention', () => {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (!mainWindow || (!mainWindow.isMinimized() && mainWindow.isFocused())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
mainWindow.flashFrame(true);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('clear-window-attention', () => {
|
||||
getMainWindow()?.flashFrame(false);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
|
||||
|
||||
ipcMain.handle('get-auto-update-server-health', async (_event, serverUrl: string) => {
|
||||
return await readDesktopUpdateServerHealth(serverUrl);
|
||||
});
|
||||
|
||||
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
|
||||
return await configureDesktopUpdaterContext(context);
|
||||
});
|
||||
@@ -271,6 +413,8 @@ export function setupSystemHandlers(): void {
|
||||
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
||||
const snapshot = updateDesktopSettings(patch);
|
||||
|
||||
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||
updateCloseToTraySetting(snapshot.closeToTray);
|
||||
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,8 @@ 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';
|
||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||
|
||||
export interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
@@ -49,6 +51,12 @@ export interface DesktopUpdateServerContext {
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateServerHealthSnapshot {
|
||||
manifestUrl: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateState {
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
availableVersions: string[];
|
||||
@@ -83,7 +91,35 @@ export interface DesktopUpdateState {
|
||||
targetVersion: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopNotificationPayload {
|
||||
body: string;
|
||||
requestAttention: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface WindowStateSnapshot {
|
||||
isFocused: boolean;
|
||||
isMinimized: boolean;
|
||||
}
|
||||
|
||||
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,27 +134,39 @@ 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;
|
||||
closeToTray: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
runtimeHardwareAcceleration: boolean;
|
||||
restartRequired: boolean;
|
||||
}>;
|
||||
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
||||
requestWindowAttention: () => Promise<boolean>;
|
||||
clearWindowAttention: () => Promise<boolean>;
|
||||
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
|
||||
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
||||
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
|
||||
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
||||
restartToApplyUpdate: () => Promise<boolean>;
|
||||
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||
setDesktopSettings: (patch: {
|
||||
autoUpdateMode?: 'auto' | 'off' | 'version';
|
||||
autoStart?: boolean;
|
||||
closeToTray?: boolean;
|
||||
hardwareAcceleration?: boolean;
|
||||
manifestUrls?: string[];
|
||||
preferredVersion?: string | null;
|
||||
vaapiVideoEncode?: boolean;
|
||||
}) => Promise<{
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
autoStart: boolean;
|
||||
closeToTray: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
@@ -126,6 +174,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 +188,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,8 +230,24 @@ const electronAPI: ElectronAPI = {
|
||||
};
|
||||
},
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
|
||||
requestWindowAttention: () => ipcRenderer.invoke('request-window-attention'),
|
||||
clearWindowAttention: () => ipcRenderer.invoke('clear-window-attention'),
|
||||
onWindowStateChanged: (listener) => {
|
||||
const wrappedListener = (_event: Electron.IpcRendererEvent, state: WindowStateSnapshot) => {
|
||||
listener(state);
|
||||
};
|
||||
|
||||
ipcRenderer.on(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||
};
|
||||
},
|
||||
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
|
||||
getAutoUpdateServerHealth: (serverUrl) => ipcRenderer.invoke('get-auto-update-server-health', serverUrl),
|
||||
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
||||
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
|
||||
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
|
||||
@@ -198,6 +264,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),
|
||||
|
||||
@@ -18,6 +18,11 @@ interface ReleaseManifestEntry {
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface ServerHealthResponse {
|
||||
releaseManifestUrl?: string;
|
||||
serverVersion?: string;
|
||||
}
|
||||
|
||||
interface UpdateVersionInfo {
|
||||
version: string;
|
||||
}
|
||||
@@ -53,6 +58,12 @@ export interface DesktopUpdateServerContext {
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateServerHealthSnapshot {
|
||||
manifestUrl: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateState {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
availableVersions: string[];
|
||||
@@ -78,6 +89,8 @@ export interface DesktopUpdateState {
|
||||
|
||||
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||
|
||||
const SERVER_HEALTH_TIMEOUT_MS = 5_000;
|
||||
|
||||
let currentCheckPromise: Promise<void> | null = null;
|
||||
let currentContext: DesktopUpdateServerContext = {
|
||||
manifestUrls: [],
|
||||
@@ -388,6 +401,47 @@ async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest
|
||||
return parseReleaseManifest(payload);
|
||||
}
|
||||
|
||||
function createUnavailableServerHealthSnapshot(): DesktopUpdateServerHealthSnapshot {
|
||||
return {
|
||||
manifestUrl: null,
|
||||
serverVersion: null,
|
||||
serverVersionStatus: 'unavailable'
|
||||
};
|
||||
}
|
||||
|
||||
async function loadServerHealth(serverUrl: string): Promise<DesktopUpdateServerHealthSnapshot> {
|
||||
const sanitizedServerUrl = sanitizeHttpUrl(serverUrl);
|
||||
|
||||
if (!sanitizedServerUrl) {
|
||||
return createUnavailableServerHealthSnapshot();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await net.fetch(`${sanitizedServerUrl}/api/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
accept: 'application/json'
|
||||
},
|
||||
signal: AbortSignal.timeout(SERVER_HEALTH_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return createUnavailableServerHealthSnapshot();
|
||||
}
|
||||
|
||||
const payload = await response.json() as ServerHealthResponse;
|
||||
const serverVersion = normalizeSemanticVersion(payload.serverVersion);
|
||||
|
||||
return {
|
||||
manifestUrl: sanitizeHttpUrl(payload.releaseManifestUrl),
|
||||
serverVersion,
|
||||
serverVersionStatus: serverVersion ? 'reported' : 'missing'
|
||||
};
|
||||
} catch {
|
||||
return createUnavailableServerHealthSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
function formatManifestLoadErrors(errors: string[]): string {
|
||||
if (errors.length === 0) {
|
||||
return 'No valid release manifest could be loaded.';
|
||||
@@ -724,6 +778,12 @@ export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
|
||||
return desktopUpdateState;
|
||||
}
|
||||
|
||||
export async function readDesktopUpdateServerHealth(
|
||||
serverUrl: string
|
||||
): Promise<DesktopUpdateServerHealthSnapshot> {
|
||||
return await loadServerHealth(serverUrl);
|
||||
}
|
||||
|
||||
export function restartToApplyUpdate(): boolean {
|
||||
if (!desktopUpdateState.restartRequired) {
|
||||
return false;
|
||||
|
||||
@@ -2,13 +2,21 @@ import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
Menu,
|
||||
session,
|
||||
shell
|
||||
shell,
|
||||
Tray
|
||||
} from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let closeToTrayEnabled = true;
|
||||
let appQuitting = false;
|
||||
|
||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||
|
||||
function getAssetPath(...segments: string[]): string {
|
||||
const basePath = app.isPackaged
|
||||
@@ -38,13 +46,124 @@ export function getDockIconPath(): string | undefined {
|
||||
return getExistingAssetPath('macos', '1024x1024.png');
|
||||
}
|
||||
|
||||
function getTrayIconPath(): string | undefined {
|
||||
if (process.platform === 'win32')
|
||||
return getExistingAssetPath('windows', 'icon.ico');
|
||||
|
||||
return getExistingAssetPath('icon.png');
|
||||
}
|
||||
|
||||
export { getWindowIconPath };
|
||||
|
||||
export function getMainWindow(): BrowserWindow | null {
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function destroyTray(): void {
|
||||
if (!tray) {
|
||||
return;
|
||||
}
|
||||
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
|
||||
function requestAppQuit(): void {
|
||||
prepareWindowForAppQuit();
|
||||
app.quit();
|
||||
}
|
||||
|
||||
function ensureTray(): void {
|
||||
if (tray) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trayIconPath = getTrayIconPath();
|
||||
|
||||
if (!trayIconPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
tray = new Tray(trayIconPath);
|
||||
tray.setToolTip('MetoYou');
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open MetoYou',
|
||||
click: () => {
|
||||
void showMainWindow();
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Close MetoYou',
|
||||
click: () => {
|
||||
requestAppQuit();
|
||||
}
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
tray.on('click', () => {
|
||||
void showMainWindow();
|
||||
});
|
||||
}
|
||||
|
||||
function hideWindowToTray(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.hide();
|
||||
emitWindowState();
|
||||
}
|
||||
|
||||
export function updateCloseToTraySetting(enabled: boolean): void {
|
||||
closeToTrayEnabled = enabled;
|
||||
}
|
||||
|
||||
export function prepareWindowForAppQuit(): void {
|
||||
appQuitting = true;
|
||||
destroyTray();
|
||||
}
|
||||
|
||||
export async function showMainWindow(): Promise<void> {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
await createWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show();
|
||||
}
|
||||
|
||||
mainWindow.focus();
|
||||
emitWindowState();
|
||||
}
|
||||
|
||||
function emitWindowState(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.webContents.send(WINDOW_STATE_CHANGED_CHANNEL, {
|
||||
isFocused: mainWindow.isFocused(),
|
||||
isMinimized: mainWindow.isMinimized()
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWindow(): Promise<void> {
|
||||
const windowIconPath = getWindowIconPath();
|
||||
|
||||
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
||||
ensureTray();
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
@@ -105,10 +224,46 @@ export async function createWindow(): Promise<void> {
|
||||
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
|
||||
}
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (appQuitting || !closeToTrayEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
hideWindowToTray();
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.on('focus', () => {
|
||||
mainWindow?.flashFrame(false);
|
||||
emitWindowState();
|
||||
});
|
||||
|
||||
mainWindow.on('blur', () => {
|
||||
emitWindowState();
|
||||
});
|
||||
|
||||
mainWindow.on('minimize', () => {
|
||||
emitWindowState();
|
||||
});
|
||||
|
||||
mainWindow.on('restore', () => {
|
||||
emitWindowState();
|
||||
});
|
||||
|
||||
mainWindow.on('show', () => {
|
||||
emitWindowState();
|
||||
});
|
||||
|
||||
mainWindow.on('hide', () => {
|
||||
emitWindowState();
|
||||
});
|
||||
|
||||
emitWindowState();
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
|
||||
@@ -199,7 +199,7 @@ module.exports = tseslint.config(
|
||||
},
|
||||
// HTML template formatting rules (external Angular templates only)
|
||||
{
|
||||
files: ['src/app/**/*.html'],
|
||||
files: ['toju-app/src/app/**/*.html'],
|
||||
plugins: { 'no-dashes': noDashPlugin },
|
||||
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
||||
rules: {
|
||||
|
||||
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",
|
||||
|
||||
34
package.json
34
package.json
@@ -7,24 +7,24 @@
|
||||
"homepage": "https://git.azaaxin.com/myxelium/Toju",
|
||||
"main": "dist/electron/main.js",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"ng": "cd \"toju-app\" && ng",
|
||||
"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": "ng serve",
|
||||
"build": "ng build",
|
||||
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js",
|
||||
"start": "cd \"toju-app\" && ng serve",
|
||||
"build": "cd \"toju-app\" && 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": "ng build --configuration production --base-href='./'",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
||||
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
||||
"test": "cd \"toju-app\" && ng test",
|
||||
"server:build": "cd server && npm run build",
|
||||
"server:start": "cd server && npm start",
|
||||
"server:dev": "cd server && npm run dev",
|
||||
"electron": "ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
|
||||
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --no-sandbox --disable-dev-shm-usage\"",
|
||||
"electron": "npm run build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
|
||||
"electron:dev": "concurrently \"npm run start\" \"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",
|
||||
@@ -40,8 +40,8 @@
|
||||
"dev:app": "npm run electron:dev",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
||||
"format": "prettier --write \"src/app/**/*.html\"",
|
||||
"format:check": "prettier --check \"src/app/**/*.html\"",
|
||||
"format": "prettier --write \"toju-app/src/app/**/*.html\"",
|
||||
"format:check": "prettier --check \"toju-app/src/app/**/*.html\"",
|
||||
"release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux",
|
||||
"release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win",
|
||||
"release:manifest": "node tools/generate-release-manifest.js",
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
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,10 +11,12 @@ 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,
|
||||
tags: JSON.stringify(server.tags),
|
||||
channels: JSON.stringify(server.channels ?? []),
|
||||
createdAt: server.createdAt,
|
||||
lastSeen: server.lastSeen
|
||||
});
|
||||
|
||||
@@ -3,10 +3,67 @@ import { ServerEntity } from '../entities/ServerEntity';
|
||||
import { JoinRequestEntity } from '../entities/JoinRequestEntity';
|
||||
import {
|
||||
AuthUserPayload,
|
||||
ServerChannelPayload,
|
||||
ServerPayload,
|
||||
JoinRequestPayload
|
||||
} from './types';
|
||||
|
||||
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||
return `${type}:${name.toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function parseStringArray(raw: string | null | undefined): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((value): value is string => typeof value === 'string')
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseServerChannels(raw: string | null | undefined): ServerChannelPayload[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
return parsed
|
||||
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
|
||||
.map((channel, index) => {
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = typeof channel.position === 'number' ? channel.position : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
} satisfies ServerChannelPayload;
|
||||
})
|
||||
.filter((channel): channel is ServerChannelPayload => !!channel);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -24,10 +81,13 @@ 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,
|
||||
tags: JSON.parse(row.tags || '[]'),
|
||||
tags: parseStringArray(row.tags),
|
||||
channels: parseServerChannels(row.channels),
|
||||
createdAt: row.createdAt,
|
||||
lastSeen: row.lastSeen
|
||||
};
|
||||
|
||||
@@ -28,16 +28,28 @@ export interface AuthUserPayload {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type ServerChannelType = 'text' | 'voice';
|
||||
|
||||
export interface ServerChannelPayload {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ServerChannelType;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface ServerPayload {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
ownerId: string;
|
||||
ownerPublicKey: string;
|
||||
hasPassword?: boolean;
|
||||
passwordHash?: string | null;
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
tags: string[];
|
||||
channels: ServerChannelPayload[];
|
||||
createdAt: number;
|
||||
lastSeen: 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;
|
||||
|
||||
@@ -33,6 +36,9 @@ export class ServerEntity {
|
||||
@Column('text', { default: '[]' })
|
||||
tags!: string;
|
||||
|
||||
@Column('text', { default: '[]' })
|
||||
channels!: string;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: 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) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export class InitialSchema1000000000000 implements MigrationInterface {
|
||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"tags" TEXT NOT NULL DEFAULT '[]',
|
||||
"channels" TEXT NOT NULL DEFAULT '[]',
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"lastSeen" INTEGER NOT NULL
|
||||
)
|
||||
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
13
server/src/migrations/1000000000002-ServerChannels.ts
Normal file
13
server/src/migrations/1000000000002-ServerChannels.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ServerChannels1000000000002 implements MigrationInterface {
|
||||
name = 'ServerChannels1000000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "channels"`);
|
||||
}
|
||||
}
|
||||
119
server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts
Normal file
119
server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
interface LegacyServerRow {
|
||||
id: string;
|
||||
channels: string | null;
|
||||
}
|
||||
|
||||
interface LegacyServerChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'voice';
|
||||
position: number;
|
||||
}
|
||||
|
||||
function normalizeLegacyChannels(raw: string | null): LegacyServerChannel[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
return parsed
|
||||
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
|
||||
.map((channel, index) => {
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = typeof channel.position === 'number' ? channel.position : index;
|
||||
const nameKey = type ? `${type}:${name.toLocaleLowerCase()}` : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
} satisfies LegacyServerChannel;
|
||||
})
|
||||
.filter((channel): channel is LegacyServerChannel => !!channel);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRestoreLegacyVoiceGeneral(channels: LegacyServerChannel[]): boolean {
|
||||
const hasTextGeneral = channels.some(
|
||||
(channel) => channel.type === 'text' && (channel.id === 'general' || channel.name.toLocaleLowerCase() === 'general')
|
||||
);
|
||||
const hasVoiceAfk = channels.some(
|
||||
(channel) => channel.type === 'voice' && (channel.id === 'vc-afk' || channel.name.toLocaleLowerCase() === 'afk')
|
||||
);
|
||||
const hasVoiceGeneral = channels.some(
|
||||
(channel) => channel.type === 'voice' && (channel.id === 'vc-general' || channel.name.toLocaleLowerCase() === 'general')
|
||||
);
|
||||
|
||||
return hasTextGeneral && hasVoiceAfk && !hasVoiceGeneral;
|
||||
}
|
||||
|
||||
function repairLegacyVoiceChannels(channels: LegacyServerChannel[]): LegacyServerChannel[] {
|
||||
if (!shouldRestoreLegacyVoiceGeneral(channels)) {
|
||||
return channels;
|
||||
}
|
||||
|
||||
const textChannels = channels.filter((channel) => channel.type === 'text');
|
||||
const voiceChannels = channels.filter((channel) => channel.type === 'voice');
|
||||
const repairedVoiceChannels = [
|
||||
{
|
||||
id: 'vc-general',
|
||||
name: 'General',
|
||||
type: 'voice' as const,
|
||||
position: 0
|
||||
},
|
||||
...voiceChannels
|
||||
].map((channel, index) => ({
|
||||
...channel,
|
||||
position: index
|
||||
}));
|
||||
|
||||
return [
|
||||
...textChannels,
|
||||
...repairedVoiceChannels
|
||||
];
|
||||
}
|
||||
|
||||
export class RepairLegacyVoiceChannels1000000000003 implements MigrationInterface {
|
||||
name = 'RepairLegacyVoiceChannels1000000000003';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const rows = await queryRunner.query(`SELECT "id", "channels" FROM "servers"`) as LegacyServerRow[];
|
||||
|
||||
for (const row of rows) {
|
||||
const channels = normalizeLegacyChannels(row.channels);
|
||||
const repaired = repairLegacyVoiceChannels(channels);
|
||||
|
||||
if (JSON.stringify(repaired) === JSON.stringify(channels)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "servers" SET "channels" = ? WHERE "id" = ?`,
|
||||
[JSON.stringify(repaired), row.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// Forward-only data repair migration.
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
||||
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
|
||||
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
|
||||
|
||||
export const serverMigrations = [InitialSchema1000000000000];
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
ServerAccessControl1000000000001,
|
||||
ServerChannels1000000000002,
|
||||
RepairLegacyVoiceChannels1000000000003
|
||||
];
|
||||
|
||||
@@ -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,134 @@
|
||||
import { Router } from 'express';
|
||||
import { Response, Router } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ServerPayload, JoinRequestPayload } from '../cqrs/types';
|
||||
import {
|
||||
ServerChannelPayload,
|
||||
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 channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||
return `${type}:${name.toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
|
||||
return !!role && allowedRoles.includes(role);
|
||||
}
|
||||
|
||||
function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
const channels: ServerChannelPayload[] = [];
|
||||
|
||||
for (const [index, channel] of value.entries()) {
|
||||
if (!channel || typeof channel !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = typeof channel.position === 'number' ? channel.position : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seen.has(id) || seenNames.has(nameKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(id);
|
||||
seenNames.add(nameKey);
|
||||
channels.push({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
});
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
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,45 +159,254 @@ 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,
|
||||
channels
|
||||
} = 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,
|
||||
tags: tags ?? [],
|
||||
channels: normalizeServerChannels(channels),
|
||||
createdAt: Date.now(),
|
||||
lastSeen: Date.now()
|
||||
};
|
||||
|
||||
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,
|
||||
channels,
|
||||
...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 hasChannelsUpdate = Object.prototype.hasOwnProperty.call(req.body, 'channels');
|
||||
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
|
||||
const server: ServerPayload = {
|
||||
...existing,
|
||||
...updates,
|
||||
channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels,
|
||||
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 +442,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 +458,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);
|
||||
}
|
||||
|
||||
@@ -110,18 +134,22 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
|
||||
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim()
|
||||
? message['channelId'].trim()
|
||||
: 'general';
|
||||
|
||||
if (typingSid && user.serverIds.has(typingSid)) {
|
||||
broadcastToServer(typingSid, {
|
||||
type: 'user_typing',
|
||||
serverId: typingSid,
|
||||
channelId,
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName
|
||||
}, user.oderId);
|
||||
}
|
||||
}
|
||||
|
||||
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 +161,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;
|
||||
}
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
|
||||
|
||||
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
|
||||
|
||||
export type ChannelType = 'text' | 'voice';
|
||||
|
||||
export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
oderId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
status: UserStatus;
|
||||
role: UserRole;
|
||||
joinedAt: number;
|
||||
peerId?: string;
|
||||
isOnline?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isRoomOwner?: boolean;
|
||||
voiceState?: VoiceState;
|
||||
screenShareState?: ScreenShareState;
|
||||
}
|
||||
|
||||
export interface RoomMember {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
role: UserRole;
|
||||
joinedAt: number;
|
||||
lastSeenAt: number;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
roomId: string;
|
||||
channelId?: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
editedAt?: number;
|
||||
reactions: Reaction[];
|
||||
isDeleted: boolean;
|
||||
replyToId?: string;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
id: string;
|
||||
messageId: string;
|
||||
oderId: string;
|
||||
userId: string;
|
||||
emoji: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
hostId: string;
|
||||
password?: string;
|
||||
isPrivate: boolean;
|
||||
createdAt: number;
|
||||
userCount: number;
|
||||
maxUsers?: number;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
permissions?: RoomPermissions;
|
||||
channels?: Channel[];
|
||||
members?: RoomMember[];
|
||||
}
|
||||
|
||||
export interface RoomSettings {
|
||||
name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
isPrivate: boolean;
|
||||
password?: string;
|
||||
maxUsers?: number;
|
||||
rules?: string[];
|
||||
}
|
||||
|
||||
export interface RoomPermissions {
|
||||
adminsManageRooms?: boolean;
|
||||
moderatorsManageRooms?: boolean;
|
||||
adminsManageIcon?: boolean;
|
||||
moderatorsManageIcon?: boolean;
|
||||
allowVoice?: boolean;
|
||||
allowScreenShare?: boolean;
|
||||
allowFileUploads?: boolean;
|
||||
slowModeInterval?: number;
|
||||
}
|
||||
|
||||
export interface BanEntry {
|
||||
oderId: string;
|
||||
userId: string;
|
||||
roomId: string;
|
||||
bannedBy: string;
|
||||
displayName?: string;
|
||||
reason?: string;
|
||||
expiresAt?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface PeerConnection {
|
||||
peerId: string;
|
||||
userId: string;
|
||||
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
|
||||
dataChannel?: RTCDataChannel;
|
||||
connection?: RTCPeerConnection;
|
||||
}
|
||||
|
||||
export interface VoiceState {
|
||||
isConnected: boolean;
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
isSpeaking: boolean;
|
||||
isMutedByAdmin?: boolean;
|
||||
volume?: number;
|
||||
roomId?: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export interface ScreenShareState {
|
||||
isSharing: boolean;
|
||||
streamId?: string;
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
}
|
||||
|
||||
export type SignalingMessageType =
|
||||
| 'offer'
|
||||
| 'answer'
|
||||
| 'ice-candidate'
|
||||
| 'join'
|
||||
| 'leave'
|
||||
| 'chat'
|
||||
| 'state-sync'
|
||||
| 'kick'
|
||||
| 'ban'
|
||||
| 'host-change'
|
||||
| 'room-update';
|
||||
|
||||
export interface SignalingMessage {
|
||||
type: SignalingMessageType;
|
||||
from: string;
|
||||
to?: string;
|
||||
payload: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type ChatEventType =
|
||||
| 'message'
|
||||
| 'chat-message'
|
||||
| 'edit'
|
||||
| 'message-edited'
|
||||
| 'delete'
|
||||
| 'message-deleted'
|
||||
| 'reaction'
|
||||
| 'reaction-added'
|
||||
| 'reaction-removed'
|
||||
| 'kick'
|
||||
| 'ban'
|
||||
| 'room-deleted'
|
||||
| 'host-change'
|
||||
| 'room-settings-update'
|
||||
| 'voice-state'
|
||||
| 'chat-inventory-request'
|
||||
| 'chat-inventory'
|
||||
| 'chat-sync-request-ids'
|
||||
| 'chat-sync-batch'
|
||||
| 'chat-sync-summary'
|
||||
| 'chat-sync-request'
|
||||
| 'chat-sync-full'
|
||||
| 'file-announce'
|
||||
| 'file-chunk'
|
||||
| 'file-request'
|
||||
| 'file-cancel'
|
||||
| 'file-not-found'
|
||||
| 'member-roster-request'
|
||||
| 'member-roster'
|
||||
| 'member-leave'
|
||||
| 'voice-state-request'
|
||||
| 'state-request'
|
||||
| 'screen-state'
|
||||
| 'screen-share-request'
|
||||
| 'screen-share-stop'
|
||||
| 'role-change'
|
||||
| 'room-permissions-update'
|
||||
| 'server-icon-summary'
|
||||
| 'server-icon-request'
|
||||
| 'server-icon-full'
|
||||
| 'server-icon-update'
|
||||
| 'server-state-request'
|
||||
| 'server-state-full'
|
||||
| 'unban'
|
||||
| 'channels-update';
|
||||
|
||||
export interface ChatInventoryItem {
|
||||
id: string;
|
||||
ts: number;
|
||||
rc: number;
|
||||
ac?: number;
|
||||
}
|
||||
|
||||
export interface ChatAttachmentAnnouncement {
|
||||
id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
isImage: boolean;
|
||||
uploaderPeerId?: string;
|
||||
}
|
||||
|
||||
export interface ChatAttachmentMeta extends ChatAttachmentAnnouncement {
|
||||
messageId: string;
|
||||
filePath?: string;
|
||||
savedPath?: string;
|
||||
}
|
||||
|
||||
/** Optional fields depend on `type`. */
|
||||
export interface ChatEvent {
|
||||
type: ChatEventType;
|
||||
fromPeerId?: string;
|
||||
messageId?: string;
|
||||
message?: Message;
|
||||
reaction?: Reaction;
|
||||
data?: string | Partial<Message>;
|
||||
timestamp?: number;
|
||||
targetUserId?: string;
|
||||
roomId?: string;
|
||||
items?: ChatInventoryItem[];
|
||||
ids?: string[];
|
||||
messages?: Message[];
|
||||
attachments?: Record<string, ChatAttachmentMeta[]>;
|
||||
total?: number;
|
||||
index?: number;
|
||||
count?: number;
|
||||
lastUpdated?: number;
|
||||
file?: ChatAttachmentAnnouncement;
|
||||
fileId?: string;
|
||||
hostId?: string;
|
||||
hostOderId?: string;
|
||||
previousHostId?: string;
|
||||
previousHostOderId?: string;
|
||||
kickedBy?: string;
|
||||
bannedBy?: string;
|
||||
content?: string;
|
||||
editedAt?: number;
|
||||
deletedAt?: number;
|
||||
deletedBy?: string;
|
||||
oderId?: string;
|
||||
displayName?: string;
|
||||
emoji?: string;
|
||||
reason?: string;
|
||||
settings?: RoomSettings;
|
||||
permissions?: Partial<RoomPermissions>;
|
||||
voiceState?: Partial<VoiceState>;
|
||||
isScreenSharing?: boolean;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
role?: UserRole;
|
||||
room?: Room;
|
||||
channels?: Channel[];
|
||||
members?: RoomMember[];
|
||||
ban?: BanEntry;
|
||||
bans?: BanEntry[];
|
||||
banOderId?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
hostName: string;
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
ownerPublicKey?: string;
|
||||
userCount: number;
|
||||
maxUsers: number;
|
||||
isPrivate: boolean;
|
||||
tags?: string[];
|
||||
createdAt: number;
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
currentUser: User | null;
|
||||
currentRoom: Room | null;
|
||||
isConnecting: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
export * from './notification-audio.service';
|
||||
export * from './platform.service';
|
||||
export * from './browser-database.service';
|
||||
export * from './electron-database.service';
|
||||
export * from './database.service';
|
||||
export * from '../models/debugging.models';
|
||||
export * from './debugging/debugging.service';
|
||||
export * from './webrtc.service';
|
||||
export * from './server-directory.service';
|
||||
export * from './klipy.service';
|
||||
export * from './voice-session.service';
|
||||
export * from './voice-activity.service';
|
||||
export * from './external-link.service';
|
||||
export * from './settings-modal.service';
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
type ElectronPlatformWindow = Window & {
|
||||
electronAPI?: unknown;
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlatformService {
|
||||
readonly isElectron: boolean;
|
||||
readonly isBrowser: boolean;
|
||||
|
||||
constructor() {
|
||||
this.isElectron =
|
||||
typeof window !== 'undefined' && !!(window as ElectronPlatformWindow).electronAPI;
|
||||
|
||||
this.isBrowser = !this.isElectron;
|
||||
}
|
||||
}
|
||||
@@ -1,650 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @angular-eslint/prefer-inject, @typescript-eslint/no-invalid-void-type */
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import {
|
||||
Observable,
|
||||
of,
|
||||
throwError,
|
||||
forkJoin
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import {
|
||||
ServerInfo,
|
||||
JoinRequest,
|
||||
User
|
||||
} from '../models/index';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
/**
|
||||
* A configured server endpoint that the user can connect to.
|
||||
*/
|
||||
export interface ServerEndpoint {
|
||||
/** Unique endpoint identifier. */
|
||||
id: string;
|
||||
/** Human-readable label shown in the UI. */
|
||||
name: string;
|
||||
/** Base URL (e.g. `http://localhost:3001`). */
|
||||
url: string;
|
||||
/** Whether this is the currently selected endpoint. */
|
||||
isActive: boolean;
|
||||
/** Whether this is the built-in default endpoint. */
|
||||
isDefault: boolean;
|
||||
/** Most recent health-check result. */
|
||||
status: 'online' | 'offline' | 'checking' | 'unknown';
|
||||
/** Last measured round-trip latency (ms). */
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
/** localStorage key that persists the user's configured endpoints. */
|
||||
const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
|
||||
/** Timeout (ms) for server health-check and alternative-endpoint pings. */
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 5000;
|
||||
|
||||
function getDefaultHttpProtocol(): 'http' | 'https' {
|
||||
return typeof window !== 'undefined' && window.location?.protocol === 'https:'
|
||||
? 'https'
|
||||
: 'http';
|
||||
}
|
||||
|
||||
function normaliseDefaultServerUrl(rawUrl: string): string {
|
||||
let cleaned = rawUrl.trim();
|
||||
|
||||
if (!cleaned)
|
||||
return '';
|
||||
|
||||
if (cleaned.toLowerCase().startsWith('ws://')) {
|
||||
cleaned = `http://${cleaned.slice(5)}`;
|
||||
} else if (cleaned.toLowerCase().startsWith('wss://')) {
|
||||
cleaned = `https://${cleaned.slice(6)}`;
|
||||
} else if (cleaned.startsWith('//')) {
|
||||
cleaned = `${getDefaultHttpProtocol()}:${cleaned}`;
|
||||
} else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) {
|
||||
cleaned = `${getDefaultHttpProtocol()}://${cleaned}`;
|
||||
}
|
||||
|
||||
cleaned = cleaned.replace(/\/+$/, '');
|
||||
|
||||
if (cleaned.toLowerCase().endsWith('/api')) {
|
||||
cleaned = cleaned.slice(0, -4);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the default server URL from the environment when provided,
|
||||
* otherwise match the current page protocol automatically.
|
||||
*/
|
||||
function buildDefaultServerUrl(): string {
|
||||
const configuredUrl = environment.defaultServerUrl?.trim();
|
||||
|
||||
if (configuredUrl) {
|
||||
return normaliseDefaultServerUrl(configuredUrl);
|
||||
}
|
||||
|
||||
return `${getDefaultHttpProtocol()}://localhost:3001`;
|
||||
}
|
||||
|
||||
/** Blueprint for the built-in default endpoint. */
|
||||
const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
|
||||
name: 'Local Server',
|
||||
url: buildDefaultServerUrl(),
|
||||
isActive: true,
|
||||
isDefault: true,
|
||||
status: 'unknown'
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages the user's list of configured server endpoints and
|
||||
* provides an HTTP client for server-directory API calls
|
||||
* (search, register, join/leave, heartbeat, etc.).
|
||||
*
|
||||
* Endpoints are persisted in `localStorage` and exposed as
|
||||
* Angular signals for reactive consumption.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerDirectoryService {
|
||||
private readonly _servers = signal<ServerEndpoint[]>([]);
|
||||
|
||||
/** Whether search queries should be fanned out to all non-offline endpoints. */
|
||||
private shouldSearchAllServers = false;
|
||||
|
||||
/** Reactive list of all configured endpoints. */
|
||||
readonly servers = computed(() => this._servers());
|
||||
|
||||
/** The currently active endpoint, falling back to the first in the list. */
|
||||
readonly activeServer = computed(
|
||||
() => this._servers().find((endpoint) => endpoint.isActive) ?? this._servers()[0]
|
||||
);
|
||||
|
||||
constructor(private readonly http: HttpClient) {
|
||||
this.loadEndpoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new server endpoint (inactive by default).
|
||||
*
|
||||
* @param server - Name and URL of the endpoint to add.
|
||||
*/
|
||||
addServer(server: { name: string; url: string }): void {
|
||||
const sanitisedUrl = this.sanitiseUrl(server.url);
|
||||
const newEndpoint: ServerEndpoint = {
|
||||
id: uuidv4(),
|
||||
name: server.name,
|
||||
url: sanitisedUrl,
|
||||
isActive: false,
|
||||
isDefault: false,
|
||||
status: 'unknown'
|
||||
};
|
||||
|
||||
this._servers.update((endpoints) => [...endpoints, newEndpoint]);
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an endpoint by ID.
|
||||
* The built-in default endpoint cannot be removed. If the removed
|
||||
* endpoint was active, the first remaining endpoint is activated.
|
||||
*/
|
||||
removeServer(endpointId: string): void {
|
||||
const endpoints = this._servers();
|
||||
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
|
||||
|
||||
if (target?.isDefault)
|
||||
return;
|
||||
|
||||
const wasActive = target?.isActive;
|
||||
|
||||
this._servers.update((list) => list.filter((endpoint) => endpoint.id !== endpointId));
|
||||
|
||||
if (wasActive) {
|
||||
this._servers.update((list) => {
|
||||
if (list.length > 0)
|
||||
list[0].isActive = true;
|
||||
|
||||
return [...list];
|
||||
});
|
||||
}
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
/** Activate a specific endpoint and deactivate all others. */
|
||||
setActiveServer(endpointId: string): void {
|
||||
this._servers.update((endpoints) =>
|
||||
endpoints.map((endpoint) => ({
|
||||
...endpoint,
|
||||
isActive: endpoint.id === endpointId
|
||||
}))
|
||||
);
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
/** Update the health status and optional latency of an endpoint. */
|
||||
updateServerStatus(
|
||||
endpointId: string,
|
||||
status: ServerEndpoint['status'],
|
||||
latency?: number
|
||||
): void {
|
||||
this._servers.update((endpoints) =>
|
||||
endpoints.map((endpoint) =>
|
||||
endpoint.id === endpointId ? { ...endpoint,
|
||||
status,
|
||||
latency } : endpoint
|
||||
)
|
||||
);
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
/** Enable or disable fan-out search across all endpoints. */
|
||||
setSearchAllServers(enabled: boolean): void {
|
||||
this.shouldSearchAllServers = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a single endpoint's health and update its status.
|
||||
*
|
||||
* @param endpointId - ID of the endpoint to test.
|
||||
* @returns `true` if the server responded successfully.
|
||||
*/
|
||||
async testServer(endpointId: string): Promise<boolean> {
|
||||
const endpoint = this._servers().find((entry) => entry.id === endpointId);
|
||||
|
||||
if (!endpoint)
|
||||
return false;
|
||||
|
||||
this.updateServerStatus(endpointId, 'checking');
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${endpoint.url}/api/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS)
|
||||
});
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
this.updateServerStatus(endpointId, 'online', latency);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.updateServerStatus(endpointId, 'offline');
|
||||
return false;
|
||||
} catch {
|
||||
// Fall back to the /servers endpoint
|
||||
try {
|
||||
const response = await fetch(`${endpoint.url}/api/servers`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS)
|
||||
});
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
this.updateServerStatus(endpointId, 'online', latency);
|
||||
return true;
|
||||
}
|
||||
} catch { /* both checks failed */ }
|
||||
|
||||
this.updateServerStatus(endpointId, 'offline');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe all configured endpoints in parallel. */
|
||||
async testAllServers(): Promise<void> {
|
||||
const endpoints = this._servers();
|
||||
|
||||
await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id)));
|
||||
}
|
||||
|
||||
/** Expose the API base URL for external consumers. */
|
||||
getApiBaseUrl(): string {
|
||||
return this.buildApiBaseUrl();
|
||||
}
|
||||
|
||||
/** Get the WebSocket URL derived from the active endpoint. */
|
||||
getWebSocketUrl(): string {
|
||||
const active = this.activeServer();
|
||||
|
||||
if (!active)
|
||||
return buildDefaultServerUrl().replace(/^http/, 'ws');
|
||||
|
||||
return active.url.replace(/^http/, 'ws');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for public servers matching a query string.
|
||||
* When {@link shouldSearchAllServers} is `true`, the search is
|
||||
* fanned out to every non-offline endpoint.
|
||||
*/
|
||||
searchServers(query: string): Observable<ServerInfo[]> {
|
||||
if (this.shouldSearchAllServers) {
|
||||
return this.searchAllEndpoints(query);
|
||||
}
|
||||
|
||||
return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer());
|
||||
}
|
||||
|
||||
/** Retrieve the full list of public servers. */
|
||||
getServers(): Observable<ServerInfo[]> {
|
||||
if (this.shouldSearchAllServers) {
|
||||
return this.getAllServersFromAllEndpoints();
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, this.activeServer())),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetch details for a single server. */
|
||||
getServer(serverId: string): Observable<ServerInfo | null> {
|
||||
return this.http
|
||||
.get<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
|
||||
.pipe(
|
||||
map((server) => this.normalizeServerInfo(server, this.activeServer())),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server:', error);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Register a new server listing in the directory. */
|
||||
registerServer(
|
||||
server: Omit<ServerInfo, 'createdAt'> & { id?: string }
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.post<ServerInfo>(`${this.buildApiBaseUrl()}/servers`, server)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to register server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Update an existing server listing. */
|
||||
updateServer(
|
||||
serverId: string,
|
||||
updates: Partial<ServerInfo> & { currentOwnerId: string }
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.put<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Remove a server listing from the directory. */
|
||||
unregisterServer(serverId: string): Observable<void> {
|
||||
return this.http
|
||||
.delete<void>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unregister server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Retrieve users currently connected to a server. */
|
||||
getServerUsers(serverId: string): Observable<User[]> {
|
||||
return this.http
|
||||
.get<User[]>(`${this.buildApiBaseUrl()}/servers/${serverId}/users`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server users:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Send a join request for a server and receive the signaling URL. */
|
||||
requestJoin(
|
||||
request: JoinRequest
|
||||
): Observable<{ success: boolean; signalingUrl?: string }> {
|
||||
return this.http
|
||||
.post<{ success: boolean; signalingUrl?: string }>(
|
||||
`${this.buildApiBaseUrl()}/servers/${request.roomId}/join`,
|
||||
request
|
||||
)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send join request:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Notify the directory that a user has left a server. */
|
||||
notifyLeave(serverId: string, userId: string): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.buildApiBaseUrl()}/servers/${serverId}/leave`, { userId })
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to notify leave:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Update the live user count for a server listing. */
|
||||
updateUserCount(serverId: string, count: number): Observable<void> {
|
||||
return this.http
|
||||
.patch<void>(`${this.buildApiBaseUrl()}/servers/${serverId}/user-count`, { count })
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update user count:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Send a heartbeat to keep the server listing active. */
|
||||
sendHeartbeat(serverId: string): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.buildApiBaseUrl()}/servers/${serverId}/heartbeat`, {})
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send heartbeat:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the active endpoint's API base URL, stripping trailing
|
||||
* slashes and accidental `/api` suffixes.
|
||||
*/
|
||||
private buildApiBaseUrl(): string {
|
||||
const active = this.activeServer();
|
||||
const rawUrl = active ? active.url : buildDefaultServerUrl();
|
||||
|
||||
let base = rawUrl.replace(/\/+$/, '');
|
||||
|
||||
if (base.toLowerCase().endsWith('/api')) {
|
||||
base = base.slice(0, -4);
|
||||
}
|
||||
|
||||
return `${base}/api`;
|
||||
}
|
||||
|
||||
/** Strip trailing slashes and `/api` suffix from a URL. */
|
||||
private sanitiseUrl(rawUrl: string): string {
|
||||
let cleaned = rawUrl.trim().replace(/\/+$/, '');
|
||||
|
||||
if (cleaned.toLowerCase().endsWith('/api')) {
|
||||
cleaned = cleaned.slice(0, -4);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle both `{ servers: [...] }` and direct `ServerInfo[]`
|
||||
* response shapes from the directory API.
|
||||
*/
|
||||
private unwrapServersResponse(
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[]
|
||||
): ServerInfo[] {
|
||||
if (Array.isArray(response))
|
||||
return response;
|
||||
|
||||
return response.servers ?? [];
|
||||
}
|
||||
|
||||
/** Search a single endpoint for servers matching a query. */
|
||||
private searchSingleEndpoint(
|
||||
query: string,
|
||||
apiBaseUrl: string,
|
||||
source?: ServerEndpoint | null
|
||||
): Observable<ServerInfo[]> {
|
||||
const params = new HttpParams().set('q', query);
|
||||
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, source)),
|
||||
catchError((error) => {
|
||||
console.error('Failed to search servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Fan-out search across all non-offline endpoints, deduplicating results. */
|
||||
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this._servers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline'
|
||||
);
|
||||
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer());
|
||||
}
|
||||
|
||||
const requests = onlineEndpoints.map((endpoint) =>
|
||||
this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint)
|
||||
);
|
||||
|
||||
return forkJoin(requests).pipe(
|
||||
map((resultArrays) => resultArrays.flat()),
|
||||
map((servers) => this.deduplicateById(servers))
|
||||
);
|
||||
}
|
||||
|
||||
/** Retrieve all servers from all non-offline endpoints. */
|
||||
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this._servers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline'
|
||||
);
|
||||
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, this.activeServer())),
|
||||
catchError(() => of([]))
|
||||
);
|
||||
}
|
||||
|
||||
const requests = onlineEndpoints.map((endpoint) =>
|
||||
this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, endpoint)),
|
||||
catchError(() => of([] as ServerInfo[]))
|
||||
)
|
||||
);
|
||||
|
||||
return forkJoin(requests).pipe(map((resultArrays) => resultArrays.flat()));
|
||||
}
|
||||
|
||||
/** Remove duplicate servers (by `id`), keeping the first occurrence. */
|
||||
private deduplicateById<T extends { id: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.id))
|
||||
return false;
|
||||
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeServerList(
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[],
|
||||
source?: ServerEndpoint | null
|
||||
): ServerInfo[] {
|
||||
return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source));
|
||||
}
|
||||
|
||||
private normalizeServerInfo(
|
||||
server: ServerInfo | Record<string, unknown>,
|
||||
source?: ServerEndpoint | null
|
||||
): ServerInfo {
|
||||
const candidate = server as Record<string, unknown>;
|
||||
const userCount = typeof candidate['userCount'] === 'number'
|
||||
? candidate['userCount']
|
||||
: (typeof candidate['currentUsers'] === 'number' ? candidate['currentUsers'] : 0);
|
||||
const maxUsers = typeof candidate['maxUsers'] === 'number' ? candidate['maxUsers'] : 0;
|
||||
const isPrivate = typeof candidate['isPrivate'] === 'boolean'
|
||||
? candidate['isPrivate']
|
||||
: candidate['isPrivate'] === 1;
|
||||
|
||||
return {
|
||||
id: typeof candidate['id'] === 'string' ? candidate['id'] : '',
|
||||
name: typeof candidate['name'] === 'string' ? candidate['name'] : 'Unnamed server',
|
||||
description: typeof candidate['description'] === 'string' ? candidate['description'] : undefined,
|
||||
topic: typeof candidate['topic'] === 'string' ? candidate['topic'] : undefined,
|
||||
hostName:
|
||||
typeof candidate['hostName'] === 'string'
|
||||
? candidate['hostName']
|
||||
: (typeof candidate['sourceName'] === 'string'
|
||||
? candidate['sourceName']
|
||||
: (source?.name ?? 'Unknown API')),
|
||||
ownerId: typeof candidate['ownerId'] === 'string' ? candidate['ownerId'] : undefined,
|
||||
ownerName: typeof candidate['ownerName'] === 'string' ? candidate['ownerName'] : undefined,
|
||||
ownerPublicKey:
|
||||
typeof candidate['ownerPublicKey'] === 'string' ? candidate['ownerPublicKey'] : undefined,
|
||||
userCount,
|
||||
maxUsers,
|
||||
isPrivate,
|
||||
tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [],
|
||||
createdAt: typeof candidate['createdAt'] === 'number' ? candidate['createdAt'] : Date.now(),
|
||||
sourceId:
|
||||
typeof candidate['sourceId'] === 'string'
|
||||
? candidate['sourceId']
|
||||
: source?.id,
|
||||
sourceName:
|
||||
typeof candidate['sourceName'] === 'string'
|
||||
? candidate['sourceName']
|
||||
: source?.name
|
||||
};
|
||||
}
|
||||
|
||||
/** Load endpoints from localStorage, syncing the built-in default endpoint if needed. */
|
||||
private loadEndpoints(): void {
|
||||
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);
|
||||
|
||||
if (!stored) {
|
||||
this.initialiseDefaultEndpoint();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let endpoints = JSON.parse(stored) as ServerEndpoint[];
|
||||
|
||||
// Ensure at least one endpoint is active
|
||||
if (endpoints.length > 0 && !endpoints.some((ep) => ep.isActive)) {
|
||||
endpoints[0].isActive = true;
|
||||
}
|
||||
|
||||
const defaultServerUrl = buildDefaultServerUrl();
|
||||
|
||||
endpoints = endpoints.map((endpoint) => {
|
||||
if (endpoint.isDefault) {
|
||||
return { ...endpoint,
|
||||
url: defaultServerUrl };
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
});
|
||||
|
||||
this._servers.set(endpoints);
|
||||
this.saveEndpoints();
|
||||
} catch {
|
||||
this.initialiseDefaultEndpoint();
|
||||
}
|
||||
}
|
||||
|
||||
/** Create and persist the built-in default endpoint. */
|
||||
private initialiseDefaultEndpoint(): void {
|
||||
const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT,
|
||||
id: uuidv4() };
|
||||
|
||||
this._servers.set([defaultEndpoint]);
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
/** Persist the current endpoint list to localStorage. */
|
||||
private saveEndpoints(): void {
|
||||
localStorage.setItem(ENDPOINTS_STORAGE_KEY, JSON.stringify(this._servers()));
|
||||
}
|
||||
}
|
||||
@@ -1,984 +0,0 @@
|
||||
/**
|
||||
* WebRTCService - thin Angular service that composes specialised managers.
|
||||
*
|
||||
* Each concern lives in its own file under `./webrtc/`:
|
||||
* • SignalingManager - WebSocket lifecycle & reconnection
|
||||
* • PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels
|
||||
* • MediaManager - mic voice, mute, deafen, bitrate
|
||||
* • ScreenShareManager - screen capture & mixed audio
|
||||
* • WebRTCLogger - debug / diagnostic logging
|
||||
*
|
||||
* This file wires them together and exposes a public API that is
|
||||
* identical to the old monolithic service so consumers don't change.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars */
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { Observable, Subject } 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,
|
||||
PeerConnectionManager,
|
||||
MediaManager,
|
||||
ScreenShareManager,
|
||||
WebRTCLogger,
|
||||
IdentifyCredentials,
|
||||
JoinedServerInfo,
|
||||
VoiceStateSnapshot,
|
||||
LatencyProfile,
|
||||
ScreenShareStartOptions,
|
||||
SIGNALING_TYPE_IDENTIFY,
|
||||
SIGNALING_TYPE_JOIN_SERVER,
|
||||
SIGNALING_TYPE_VIEW_SERVER,
|
||||
SIGNALING_TYPE_LEAVE_SERVER,
|
||||
SIGNALING_TYPE_OFFER,
|
||||
SIGNALING_TYPE_ANSWER,
|
||||
SIGNALING_TYPE_ICE_CANDIDATE,
|
||||
SIGNALING_TYPE_CONNECTED,
|
||||
SIGNALING_TYPE_SERVER_USERS,
|
||||
SIGNALING_TYPE_USER_JOINED,
|
||||
SIGNALING_TYPE_USER_LEFT,
|
||||
DEFAULT_DISPLAY_NAME,
|
||||
P2P_TYPE_SCREEN_SHARE_REQUEST,
|
||||
P2P_TYPE_SCREEN_SHARE_STOP,
|
||||
P2P_TYPE_VOICE_STATE,
|
||||
P2P_TYPE_SCREEN_STATE
|
||||
} from './webrtc';
|
||||
|
||||
interface SignalingUserSummary {
|
||||
oderId: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface IncomingSignalingPayload {
|
||||
sdp?: RTCSessionDescriptionInit;
|
||||
candidate?: RTCIceCandidateInit;
|
||||
}
|
||||
|
||||
type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payload'> & {
|
||||
type: string;
|
||||
payload?: IncomingSignalingPayload;
|
||||
oderId?: string;
|
||||
serverTime?: number;
|
||||
serverId?: string;
|
||||
users?: SignalingUserSummary[];
|
||||
displayName?: string;
|
||||
fromUserId?: string;
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
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 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>();
|
||||
private readonly serviceDestroyed$ = new Subject<void>();
|
||||
private remoteScreenShareRequestsEnabled = false;
|
||||
private readonly desiredRemoteScreenSharePeers = new Set<string>();
|
||||
private readonly activeRemoteScreenSharePeers = new Set<string>();
|
||||
|
||||
private readonly _localPeerId = signal<string>(uuidv4());
|
||||
private readonly _isSignalingConnected = signal(false);
|
||||
private readonly _isVoiceConnected = signal(false);
|
||||
private readonly _connectedPeers = signal<string[]>([]);
|
||||
private readonly _isMuted = signal(false);
|
||||
private readonly _isDeafened = signal(false);
|
||||
private readonly _isScreenSharing = signal(false);
|
||||
private readonly _isNoiseReductionEnabled = signal(false);
|
||||
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
|
||||
private readonly _isScreenShareRemotePlaybackSuppressed = signal(false);
|
||||
private readonly _hasConnectionError = signal(false);
|
||||
private readonly _connectionErrorMessage = signal<string | null>(null);
|
||||
private readonly _hasEverConnected = signal(false);
|
||||
/**
|
||||
* Reactive snapshot of per-peer latencies (ms).
|
||||
* Updated whenever a ping/pong round-trip completes.
|
||||
* Keyed by remote peer (oderId).
|
||||
*/
|
||||
private readonly _peerLatencies = signal<ReadonlyMap<string, number>>(new Map());
|
||||
|
||||
// Public computed signals (unchanged external API)
|
||||
readonly peerId = computed(() => this._localPeerId());
|
||||
readonly isConnected = computed(() => this._isSignalingConnected());
|
||||
readonly hasEverConnected = computed(() => this._hasEverConnected());
|
||||
readonly isVoiceConnected = computed(() => this._isVoiceConnected());
|
||||
readonly connectedPeers = computed(() => this._connectedPeers());
|
||||
readonly isMuted = computed(() => this._isMuted());
|
||||
readonly isDeafened = computed(() => this._isDeafened());
|
||||
readonly isScreenSharing = computed(() => this._isScreenSharing());
|
||||
readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled());
|
||||
readonly screenStream = computed(() => this._screenStreamSignal());
|
||||
readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed());
|
||||
readonly hasConnectionError = computed(() => this._hasConnectionError());
|
||||
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
|
||||
readonly shouldShowConnectionError = computed(() => {
|
||||
if (!this._hasConnectionError())
|
||||
return false;
|
||||
|
||||
if (this._isVoiceConnected() && this._connectedPeers().length > 0)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
/** Per-peer latency map (ms). Read via `peerLatencies()`. */
|
||||
readonly peerLatencies = computed(() => this._peerLatencies());
|
||||
|
||||
private readonly signalingMessage$ = new Subject<IncomingSignalingMessage>();
|
||||
readonly onSignalingMessage = this.signalingMessage$.asObservable();
|
||||
|
||||
// Delegates to managers
|
||||
get onMessageReceived(): Observable<ChatEvent> {
|
||||
return this.peerManager.messageReceived$.asObservable();
|
||||
}
|
||||
get onPeerConnected(): Observable<string> {
|
||||
return this.peerManager.peerConnected$.asObservable();
|
||||
}
|
||||
get onPeerDisconnected(): Observable<string> {
|
||||
return this.peerManager.peerDisconnected$.asObservable();
|
||||
}
|
||||
get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> {
|
||||
return this.peerManager.remoteStream$.asObservable();
|
||||
}
|
||||
get onVoiceConnected(): Observable<void> {
|
||||
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!);
|
||||
|
||||
this.screenShareManager = new ScreenShareManager(this.logger, null!);
|
||||
|
||||
// Now wire up cross-references (all managers are instantiated)
|
||||
this.peerManager.setCallbacks({
|
||||
sendRawMessage: (msg: Record<string, unknown>) => this.signalingManager.sendRawMessage(msg),
|
||||
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
|
||||
isSignalingConnected: (): boolean => this._isSignalingConnected(),
|
||||
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
|
||||
getIdentifyCredentials: (): IdentifyCredentials | null => this.lastIdentifyCredentials,
|
||||
getLocalPeerId: (): string => this._localPeerId(),
|
||||
isScreenSharingActive: (): boolean => this._isScreenSharing()
|
||||
});
|
||||
|
||||
this.mediaManager.setCallbacks({
|
||||
getActivePeers: (): Map<string, import('./webrtc').PeerData> =>
|
||||
this.peerManager.activePeerConnections,
|
||||
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
|
||||
broadcastMessage: (event: ChatEvent): void => this.peerManager.broadcastMessage(event),
|
||||
getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(),
|
||||
getIdentifyDisplayName: (): string =>
|
||||
this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME
|
||||
});
|
||||
|
||||
this.screenShareManager.setCallbacks({
|
||||
getActivePeers: (): Map<string, import('./webrtc').PeerData> =>
|
||||
this.peerManager.activePeerConnections,
|
||||
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
|
||||
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
|
||||
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.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));
|
||||
|
||||
// Peer manager → connected peers signal
|
||||
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) =>
|
||||
this._connectedPeers.set(peers)
|
||||
);
|
||||
|
||||
// If we are already sharing when a new peer connection finishes, push the
|
||||
// current screen-share tracks to that peer and renegotiate.
|
||||
this.peerManager.peerConnected$.subscribe((peerId) => {
|
||||
if (!this.screenShareManager.getIsScreenActive()) {
|
||||
if (this.remoteScreenShareRequestsEnabled && this.desiredRemoteScreenSharePeers.has(peerId)) {
|
||||
this.requestRemoteScreenShares([peerId]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.screenShareManager.syncScreenShareToPeer(peerId);
|
||||
|
||||
if (this.remoteScreenShareRequestsEnabled && this.desiredRemoteScreenSharePeers.has(peerId)) {
|
||||
this.requestRemoteScreenShares([peerId]);
|
||||
}
|
||||
});
|
||||
|
||||
this.peerManager.peerDisconnected$.subscribe((peerId) => {
|
||||
this.activeRemoteScreenSharePeers.delete(peerId);
|
||||
this.screenShareManager.clearScreenShareRequest(peerId);
|
||||
});
|
||||
|
||||
// Media manager → voice connected signal
|
||||
this.mediaManager.voiceConnected$.subscribe(() => {
|
||||
this._isVoiceConnected.set(true);
|
||||
});
|
||||
|
||||
// Peer manager → latency updates
|
||||
this.peerManager.peerLatencyChanged$.subscribe(({ peerId, latencyMs }) => {
|
||||
const next = new Map(this.peerManager.peerLatencies);
|
||||
|
||||
this._peerLatencies.set(next);
|
||||
});
|
||||
}
|
||||
|
||||
private handleSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
this.signalingMessage$.next(message);
|
||||
this.logger.info('Signaling message', { type: message.type });
|
||||
|
||||
switch (message.type) {
|
||||
case SIGNALING_TYPE_CONNECTED:
|
||||
this.handleConnectedSignalingMessage(message);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_SERVER_USERS:
|
||||
this.handleServerUsersSignalingMessage(message);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_USER_JOINED:
|
||||
this.handleUserJoinedSignalingMessage(message);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_USER_LEFT:
|
||||
this.handleUserLeftSignalingMessage(message);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_OFFER:
|
||||
this.handleOfferSignalingMessage(message);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_ANSWER:
|
||||
this.handleAnswerSignalingMessage(message);
|
||||
return;
|
||||
|
||||
case SIGNALING_TYPE_ICE_CANDIDATE:
|
||||
this.handleIceCandidateSignalingMessage(message);
|
||||
return;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnectedSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
this.logger.info('Server connected', { oderId: message.oderId });
|
||||
|
||||
if (typeof message.serverTime === 'number') {
|
||||
this.timeSync.setFromServerTime(message.serverTime);
|
||||
}
|
||||
}
|
||||
|
||||
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
const users = Array.isArray(message.users) ? message.users : [];
|
||||
|
||||
this.logger.info('Server users', {
|
||||
count: users.length,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.oderId)
|
||||
continue;
|
||||
|
||||
const existing = this.peerManager.activePeerConnections.get(user.oderId);
|
||||
const healthy = this.isPeerHealthy(existing);
|
||||
|
||||
if (existing && !healthy) {
|
||||
this.logger.info('Removing stale peer before recreate', { oderId: user.oderId });
|
||||
this.peerManager.removePeer(user.oderId);
|
||||
}
|
||||
|
||||
if (healthy)
|
||||
continue;
|
||||
|
||||
this.logger.info('Create peer connection to existing user', {
|
||||
oderId: user.oderId,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
this.peerManager.createPeerConnection(user.oderId, true);
|
||||
this.peerManager.createAndSendOffer(user.oderId);
|
||||
|
||||
if (message.serverId) {
|
||||
this.peerServerMap.set(user.oderId, message.serverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
this.logger.info('User joined', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId
|
||||
});
|
||||
}
|
||||
|
||||
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
this.logger.info('User left', {
|
||||
displayName: message.displayName,
|
||||
oderId: message.oderId,
|
||||
serverId: message.serverId
|
||||
});
|
||||
|
||||
if (message.oderId) {
|
||||
this.peerManager.removePeer(message.oderId);
|
||||
this.peerServerMap.delete(message.oderId);
|
||||
}
|
||||
}
|
||||
|
||||
private handleOfferSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
const fromUserId = message.fromUserId;
|
||||
const sdp = message.payload?.sdp;
|
||||
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
|
||||
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
|
||||
|
||||
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
|
||||
this.peerServerMap.set(fromUserId, offerEffectiveServer);
|
||||
}
|
||||
|
||||
this.peerManager.handleOffer(fromUserId, sdp);
|
||||
}
|
||||
|
||||
private handleAnswerSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
const fromUserId = message.fromUserId;
|
||||
const sdp = message.payload?.sdp;
|
||||
|
||||
if (!fromUserId || !sdp)
|
||||
return;
|
||||
|
||||
this.peerManager.handleAnswer(fromUserId, sdp);
|
||||
}
|
||||
|
||||
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage): void {
|
||||
const fromUserId = message.fromUserId;
|
||||
const candidate = message.payload?.candidate;
|
||||
|
||||
if (!fromUserId || !candidate)
|
||||
return;
|
||||
|
||||
this.peerManager.handleIceCandidate(fromUserId, candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all peer connections that were discovered from a server
|
||||
* other than `serverId`. Also removes their entries from
|
||||
* {@link peerServerMap} so the bookkeeping stays clean.
|
||||
*
|
||||
* This ensures audio (and data channels) are scoped to only
|
||||
* the voice-active (or currently viewed) server.
|
||||
*/
|
||||
private closePeersNotInServer(serverId: string): void {
|
||||
const peersToClose: string[] = [];
|
||||
|
||||
this.peerServerMap.forEach((peerServerId, peerId) => {
|
||||
if (peerServerId !== serverId) {
|
||||
peersToClose.push(peerId);
|
||||
}
|
||||
});
|
||||
|
||||
for (const peerId of peersToClose) {
|
||||
this.logger.info('Closing peer from different server', { peerId,
|
||||
currentServer: serverId });
|
||||
|
||||
this.peerManager.removePeer(peerId);
|
||||
this.peerServerMap.delete(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentVoiceState(): VoiceStateSnapshot {
|
||||
return {
|
||||
isConnected: this._isVoiceConnected(),
|
||||
isMuted: this._isMuted(),
|
||||
isDeafened: this._isDeafened(),
|
||||
isScreenSharing: this._isScreenSharing(),
|
||||
roomId: this.mediaManager.getCurrentVoiceRoomId(),
|
||||
serverId: this.mediaManager.getCurrentVoiceServerId()
|
||||
};
|
||||
}
|
||||
|
||||
// PUBLIC API - matches the old monolithic service's interface
|
||||
|
||||
/**
|
||||
* Connect to a signaling server via WebSocket.
|
||||
*
|
||||
* @param serverUrl - The WebSocket URL of the signaling server.
|
||||
* @returns An observable that emits `true` once connected.
|
||||
*/
|
||||
connectToSignalingServer(serverUrl: string): Observable<boolean> {
|
||||
return this.signalingManager.connect(serverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the signaling WebSocket is connected, reconnecting if needed.
|
||||
*
|
||||
* @param timeoutMs - Maximum time (ms) to wait for the connection.
|
||||
* @returns `true` if connected within the timeout.
|
||||
*/
|
||||
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
|
||||
return this.signalingManager.ensureConnected(timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a signaling-level message (with `from` and `timestamp` auto-populated).
|
||||
*
|
||||
* @param message - The signaling message payload (excluding `from` / `timestamp`).
|
||||
*/
|
||||
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
|
||||
this.signalingManager.sendSignalingMessage(message, this._localPeerId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a raw JSON payload through the signaling WebSocket.
|
||||
*
|
||||
* @param message - Arbitrary JSON message.
|
||||
*/
|
||||
sendRawMessage(message: Record<string, unknown>): void {
|
||||
this.signalingManager.sendRawMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track the currently-active server ID (for server-scoped operations).
|
||||
*
|
||||
* @param serverId - The server to mark as active.
|
||||
*/
|
||||
setCurrentServer(serverId: string): void {
|
||||
this.activeServerId = serverId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an identify message to the signaling server.
|
||||
*
|
||||
* The credentials are cached so they can be replayed after a reconnect.
|
||||
*
|
||||
* @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 };
|
||||
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
|
||||
oderId,
|
||||
displayName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a server (room) on the signaling server.
|
||||
*
|
||||
* @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 };
|
||||
|
||||
this.memberServerIds.add(roomId);
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId: roomId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different server. If already a member, sends a view event;
|
||||
* otherwise joins the server.
|
||||
*
|
||||
* @param serverId - The target server ID.
|
||||
* @param userId - The local user ID.
|
||||
*/
|
||||
switchServer(serverId: string, userId: string): void {
|
||||
this.lastJoinedServer = { serverId,
|
||||
userId };
|
||||
|
||||
if (this.memberServerIds.has(serverId)) {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
|
||||
serverId });
|
||||
|
||||
this.logger.info('Viewed server (already joined)', {
|
||||
serverId,
|
||||
userId,
|
||||
voiceConnected: this._isVoiceConnected()
|
||||
});
|
||||
} else {
|
||||
this.memberServerIds.add(serverId);
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
|
||||
serverId });
|
||||
|
||||
this.logger.info('Joined new server via switch', {
|
||||
serverId,
|
||||
userId,
|
||||
voiceConnected: this._isVoiceConnected()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave one or all servers.
|
||||
*
|
||||
* If `serverId` is provided, leaves only that server.
|
||||
* Otherwise leaves every joined server and performs a full cleanup.
|
||||
*
|
||||
* @param serverId - Optional server to leave; omit to leave all.
|
||||
*/
|
||||
leaveRoom(serverId?: string): void {
|
||||
if (serverId) {
|
||||
this.memberServerIds.delete(serverId);
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||
serverId });
|
||||
|
||||
this.logger.info('Left server', { serverId });
|
||||
|
||||
if (this.memberServerIds.size === 0) {
|
||||
this.fullCleanup();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.memberServerIds.forEach((sid) => {
|
||||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
|
||||
serverId: sid });
|
||||
});
|
||||
|
||||
this.memberServerIds.clear();
|
||||
this.fullCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the local client has joined a given server.
|
||||
*
|
||||
* @param serverId - The server to check.
|
||||
*/
|
||||
hasJoinedServer(serverId: string): boolean {
|
||||
return this.memberServerIds.has(serverId);
|
||||
}
|
||||
|
||||
/** Returns a read-only set of all currently-joined server IDs. */
|
||||
getJoinedServerIds(): ReadonlySet<string> {
|
||||
return this.memberServerIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a {@link ChatEvent} to every connected peer.
|
||||
*
|
||||
* @param event - The chat event to send.
|
||||
*/
|
||||
broadcastMessage(event: ChatEvent): void {
|
||||
this.peerManager.broadcastMessage(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a {@link ChatEvent} to a specific peer.
|
||||
*
|
||||
* @param peerId - The target peer ID.
|
||||
* @param event - The chat event to send.
|
||||
*/
|
||||
sendToPeer(peerId: string, event: ChatEvent): void {
|
||||
this.peerManager.sendToPeer(peerId, event);
|
||||
}
|
||||
|
||||
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
|
||||
const nextDesiredPeers = new Set(
|
||||
peerIds.filter((peerId): peerId is string => !!peerId)
|
||||
);
|
||||
|
||||
if (!enabled) {
|
||||
this.remoteScreenShareRequestsEnabled = false;
|
||||
this.desiredRemoteScreenSharePeers.clear();
|
||||
this.stopRemoteScreenShares([...this.activeRemoteScreenSharePeers]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.remoteScreenShareRequestsEnabled = true;
|
||||
|
||||
for (const activePeerId of [...this.activeRemoteScreenSharePeers]) {
|
||||
if (!nextDesiredPeers.has(activePeerId)) {
|
||||
this.stopRemoteScreenShares([activePeerId]);
|
||||
}
|
||||
}
|
||||
|
||||
this.desiredRemoteScreenSharePeers.clear();
|
||||
nextDesiredPeers.forEach((peerId) => this.desiredRemoteScreenSharePeers.add(peerId));
|
||||
this.requestRemoteScreenShares([...nextDesiredPeers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a {@link ChatEvent} to a peer with back-pressure awareness.
|
||||
*
|
||||
* @param peerId - The target peer ID.
|
||||
* @param event - The chat event to send.
|
||||
*/
|
||||
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
|
||||
return this.peerManager.sendToPeerBuffered(peerId, event);
|
||||
}
|
||||
|
||||
/** Returns an array of currently-connected peer IDs. */
|
||||
getConnectedPeers(): string[] {
|
||||
return this.peerManager.getConnectedPeerIds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the composite remote {@link MediaStream} for a connected peer.
|
||||
*
|
||||
* @param peerId - The remote peer whose stream to retrieve.
|
||||
* @returns The stream, or `null` if the peer has no active stream.
|
||||
*/
|
||||
getRemoteStream(peerId: string): MediaStream | null {
|
||||
return this.peerManager.remotePeerStreams.get(peerId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the remote voice-only stream for a connected peer.
|
||||
*
|
||||
* @param peerId - The remote peer whose voice stream to retrieve.
|
||||
* @returns The stream, or `null` if the peer has no active voice audio.
|
||||
*/
|
||||
getRemoteVoiceStream(peerId: string): MediaStream | null {
|
||||
return this.peerManager.remotePeerVoiceStreams.get(peerId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the remote screen-share stream for a connected peer.
|
||||
*
|
||||
* This contains the screen video track and any audio track that belongs to
|
||||
* the screen share itself, not the peer's normal voice-chat audio.
|
||||
*
|
||||
* @param peerId - The remote peer whose screen-share stream to retrieve.
|
||||
* @returns The stream, or `null` if the peer has no active screen share.
|
||||
*/
|
||||
getRemoteScreenShareStream(peerId: string): MediaStream | null {
|
||||
return this.peerManager.remotePeerScreenShareStreams.get(peerId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current local media stream (microphone audio).
|
||||
*
|
||||
* @returns The local {@link MediaStream}, or `null` if voice is not active.
|
||||
*/
|
||||
getLocalStream(): MediaStream | null {
|
||||
return this.mediaManager.getLocalStream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw local microphone stream before gain / RNNoise processing.
|
||||
*
|
||||
* @returns The raw microphone {@link MediaStream}, or `null` if voice is not active.
|
||||
*/
|
||||
getRawMicStream(): MediaStream | null {
|
||||
return this.mediaManager.getRawMicStream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request microphone access and start sending audio to all peers.
|
||||
*
|
||||
* @returns The captured local {@link MediaStream}.
|
||||
*/
|
||||
async enableVoice(): Promise<MediaStream> {
|
||||
const stream = await this.mediaManager.enableVoice();
|
||||
|
||||
this.syncMediaSignals();
|
||||
return stream;
|
||||
}
|
||||
|
||||
/** Stop local voice capture and remove audio senders from peers. */
|
||||
disableVoice(): void {
|
||||
this.voiceServerId = null;
|
||||
this.mediaManager.disableVoice();
|
||||
this._isVoiceConnected.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject an externally-obtained media stream as the local voice source.
|
||||
*
|
||||
* @param stream - The media stream to use.
|
||||
*/
|
||||
async setLocalStream(stream: MediaStream): Promise<void> {
|
||||
await this.mediaManager.setLocalStream(stream);
|
||||
this.syncMediaSignals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the local microphone mute state.
|
||||
*
|
||||
* @param muted - Explicit state; if omitted, the current state is toggled.
|
||||
*/
|
||||
toggleMute(muted?: boolean): void {
|
||||
this.mediaManager.toggleMute(muted);
|
||||
this._isMuted.set(this.mediaManager.getIsMicMuted());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle self-deafen (suppress incoming audio playback).
|
||||
*
|
||||
* @param deafened - Explicit state; if omitted, the current state is toggled.
|
||||
*/
|
||||
toggleDeafen(deafened?: boolean): void {
|
||||
this.mediaManager.toggleDeafen(deafened);
|
||||
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle RNNoise noise reduction on the local microphone.
|
||||
*
|
||||
* When enabled, the raw mic audio is routed through an AudioWorklet
|
||||
* that applies neural-network noise suppression before being sent
|
||||
* to peers.
|
||||
*
|
||||
* @param enabled - Explicit state; if omitted, the current state is toggled.
|
||||
*/
|
||||
async toggleNoiseReduction(enabled?: boolean): Promise<void> {
|
||||
await this.mediaManager.toggleNoiseReduction(enabled);
|
||||
this._isNoiseReductionEnabled.set(this.mediaManager.getIsNoiseReductionEnabled());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the output volume for remote audio playback.
|
||||
*
|
||||
* @param volume - Normalised volume (0-1).
|
||||
*/
|
||||
setOutputVolume(volume: number): void {
|
||||
this.mediaManager.setOutputVolume(volume);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the input (microphone) volume.
|
||||
*
|
||||
* Adjusts a Web Audio GainNode on the local mic stream so the level
|
||||
* sent to peers changes in real time without renegotiation.
|
||||
*
|
||||
* @param volume - Normalised volume (0-1).
|
||||
*/
|
||||
setInputVolume(volume: number): void {
|
||||
this.mediaManager.setInputVolume(volume);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the maximum audio bitrate for all peer connections.
|
||||
*
|
||||
* @param kbps - Target bitrate in kilobits per second.
|
||||
*/
|
||||
async setAudioBitrate(kbps: number): Promise<void> {
|
||||
return this.mediaManager.setAudioBitrate(kbps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a predefined latency profile that maps to a specific bitrate.
|
||||
*
|
||||
* @param profile - One of `'low'`, `'balanced'`, or `'high'`.
|
||||
*/
|
||||
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
|
||||
return this.mediaManager.setLatencyProfile(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start broadcasting voice-presence heartbeats to all peers.
|
||||
*
|
||||
* Also marks the given server as the active voice server and closes
|
||||
* any peer connections that belong to other servers so that audio
|
||||
* is isolated to the correct voice channel.
|
||||
*
|
||||
* @param roomId - The voice channel room ID.
|
||||
* @param serverId - The voice channel server ID.
|
||||
*/
|
||||
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
|
||||
if (serverId) {
|
||||
this.voiceServerId = serverId;
|
||||
}
|
||||
|
||||
this.mediaManager.startVoiceHeartbeat(roomId, serverId);
|
||||
}
|
||||
|
||||
/** Stop the voice-presence heartbeat. */
|
||||
stopVoiceHeartbeat(): void {
|
||||
this.mediaManager.stopVoiceHeartbeat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start sharing the screen (or a window) with all connected peers.
|
||||
*
|
||||
* @param options - Screen-share capture options.
|
||||
* @returns The screen-capture {@link MediaStream}.
|
||||
*/
|
||||
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
|
||||
return await this.screenShareManager.startScreenShare(options);
|
||||
}
|
||||
|
||||
/** Stop screen sharing and restore microphone audio on all peers. */
|
||||
stopScreenShare(): void {
|
||||
this.screenShareManager.stopScreenShare();
|
||||
}
|
||||
|
||||
/** Disconnect from the signaling server and clean up all state. */
|
||||
disconnect(): void {
|
||||
this.voiceServerId = null;
|
||||
this.peerServerMap.clear();
|
||||
this.leaveRoom();
|
||||
this.mediaManager.stopVoiceHeartbeat();
|
||||
this.signalingManager.close();
|
||||
this._isSignalingConnected.set(false);
|
||||
this._hasEverConnected.set(false);
|
||||
this._hasConnectionError.set(false);
|
||||
this._connectionErrorMessage.set(null);
|
||||
this.serviceDestroyed$.next();
|
||||
}
|
||||
|
||||
/** Alias for {@link disconnect}. */
|
||||
disconnectAll(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
private fullCleanup(): void {
|
||||
this.voiceServerId = null;
|
||||
this.peerServerMap.clear();
|
||||
this.remoteScreenShareRequestsEnabled = false;
|
||||
this.desiredRemoteScreenSharePeers.clear();
|
||||
this.activeRemoteScreenSharePeers.clear();
|
||||
this.peerManager.closeAllPeers();
|
||||
this._connectedPeers.set([]);
|
||||
this.mediaManager.disableVoice();
|
||||
this._isVoiceConnected.set(false);
|
||||
this.screenShareManager.stopScreenShare();
|
||||
this._isScreenSharing.set(false);
|
||||
this._screenStreamSignal.set(null);
|
||||
this._isScreenShareRemotePlaybackSuppressed.set(false);
|
||||
}
|
||||
|
||||
/** Synchronise Angular signals from the MediaManager's internal state. */
|
||||
private syncMediaSignals(): void {
|
||||
this._isVoiceConnected.set(this.mediaManager.getIsVoiceActive());
|
||||
this._isMuted.set(this.mediaManager.getIsMicMuted());
|
||||
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 {
|
||||
if (!peer)
|
||||
return false;
|
||||
|
||||
const connState = peer.connection?.connectionState;
|
||||
const dcState = peer.dataChannel?.readyState;
|
||||
|
||||
return connState === 'connected' && dcState === 'open';
|
||||
}
|
||||
|
||||
private handlePeerControlMessage(event: ChatEvent): void {
|
||||
if (!event.fromPeerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === P2P_TYPE_SCREEN_STATE && event.isScreenSharing === false) {
|
||||
this.peerManager.clearRemoteScreenShareStream(event.fromPeerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === P2P_TYPE_SCREEN_SHARE_REQUEST) {
|
||||
this.screenShareManager.requestScreenShareForPeer(event.fromPeerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === P2P_TYPE_SCREEN_SHARE_STOP) {
|
||||
this.screenShareManager.stopScreenShareForPeer(event.fromPeerId);
|
||||
}
|
||||
}
|
||||
|
||||
private requestRemoteScreenShares(peerIds: string[]): void {
|
||||
const connectedPeerIds = new Set(this.peerManager.getConnectedPeerIds());
|
||||
|
||||
for (const peerId of peerIds) {
|
||||
if (!connectedPeerIds.has(peerId) || this.activeRemoteScreenSharePeers.has(peerId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.peerManager.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_REQUEST });
|
||||
this.activeRemoteScreenSharePeers.add(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
private stopRemoteScreenShares(peerIds: string[]): void {
|
||||
const connectedPeerIds = new Set(this.peerManager.getConnectedPeerIds());
|
||||
|
||||
for (const peerId of peerIds) {
|
||||
if (this.activeRemoteScreenSharePeers.has(peerId) && connectedPeerIds.has(peerId)) {
|
||||
this.peerManager.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_STOP });
|
||||
}
|
||||
|
||||
this.activeRemoteScreenSharePeers.delete(peerId);
|
||||
this.peerManager.clearRemoteScreenShareStream(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
this.serviceDestroyed$.complete();
|
||||
this.signalingManager.destroy();
|
||||
this.peerManager.destroy();
|
||||
this.mediaManager.destroy();
|
||||
this.screenShareManager.destroy();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Barrel export for the WebRTC sub-module.
|
||||
*
|
||||
* Other modules should import from here:
|
||||
* import { ... } from './webrtc';
|
||||
*/
|
||||
export * from './webrtc.constants';
|
||||
export * from './webrtc.types';
|
||||
export * from './webrtc-logger';
|
||||
export * from './signaling.manager';
|
||||
export * from './peer-connection.manager';
|
||||
export * from './media.manager';
|
||||
export * from './screen-share.manager';
|
||||
export * from './screen-share.config';
|
||||
export * from './noise-reduction.manager';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
.chat-textarea {
|
||||
--textarea-bg: hsl(40deg 3.7% 15.9% / 87%);
|
||||
background: var(--textarea-bg);
|
||||
height: 62px;
|
||||
min-height: 62px;
|
||||
max-height: 520px;
|
||||
overflow-y: hidden;
|
||||
resize: none;
|
||||
transition: height 0.12s ease;
|
||||
|
||||
&.ctrl-resize {
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0.85);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
|
||||
<!-- Create button -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
title="Create Server"
|
||||
(click)="createServer()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Saved servers icons -->
|
||||
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
||||
@for (room of visibleSavedRooms(); track room.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
|
||||
[title]="room.name"
|
||||
(click)="joinSavedRoom(room)"
|
||||
(contextmenu)="openContextMenu($event, room)"
|
||||
>
|
||||
@if (room.icon) {
|
||||
<img
|
||||
[ngSrc]="room.icon"
|
||||
[alt]="room.name"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
} @else {
|
||||
<div class="w-full h-full flex items-center justify-center bg-secondary">
|
||||
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Context menu -->
|
||||
@if (showMenu()) {
|
||||
<app-context-menu
|
||||
[x]="menuX()"
|
||||
[y]="menuY()"
|
||||
(closed)="closeMenu()"
|
||||
[width]="'w-44'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
(click)="openLeaveConfirm()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Leave Server
|
||||
</button>
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
@if (showBannedDialog()) {
|
||||
<app-confirm-dialog
|
||||
title="Banned"
|
||||
confirmLabel="OK"
|
||||
cancelLabel="Close"
|
||||
variant="danger"
|
||||
[widthClass]="'w-96 max-w-[90vw]'"
|
||||
(confirmed)="closeBannedDialog()"
|
||||
(cancelled)="closeBannedDialog()"
|
||||
>
|
||||
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
@if (showLeaveConfirm() && contextRoom()) {
|
||||
<app-leave-server-dialog
|
||||
[room]="contextRoom()!"
|
||||
[currentUser]="currentUser() ?? null"
|
||||
(confirmed)="confirmLeave($event)"
|
||||
(cancelled)="cancelLeave()"
|
||||
/>
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { User } from '../../../core/models';
|
||||
|
||||
export interface ScreenShareWorkspaceStreamItem {
|
||||
id: string;
|
||||
peerKey: string;
|
||||
user: User;
|
||||
stream: MediaStream;
|
||||
isLocal: boolean;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
defaultServerUrl: 'https://tojusignal.azaaxin.com'
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
defaultServerUrl: 'https://46.59.68.77:3001'
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"$schema": "../node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
@@ -62,27 +62,28 @@
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss",
|
||||
"node_modules/prismjs/themes/prism-okaidia.css"
|
||||
"../node_modules/prismjs/themes/prism-okaidia.css"
|
||||
],
|
||||
"scripts": [
|
||||
"node_modules/prismjs/prism.js",
|
||||
"node_modules/prismjs/components/prism-markup.min.js",
|
||||
"node_modules/prismjs/components/prism-clike.min.js",
|
||||
"node_modules/prismjs/components/prism-javascript.min.js",
|
||||
"node_modules/prismjs/components/prism-typescript.min.js",
|
||||
"node_modules/prismjs/components/prism-css.min.js",
|
||||
"node_modules/prismjs/components/prism-scss.min.js",
|
||||
"node_modules/prismjs/components/prism-json.min.js",
|
||||
"node_modules/prismjs/components/prism-bash.min.js",
|
||||
"node_modules/prismjs/components/prism-markdown.min.js",
|
||||
"node_modules/prismjs/components/prism-yaml.min.js",
|
||||
"node_modules/prismjs/components/prism-python.min.js",
|
||||
"node_modules/prismjs/components/prism-csharp.min.js"
|
||||
"../node_modules/prismjs/prism.js",
|
||||
"../node_modules/prismjs/components/prism-markup.min.js",
|
||||
"../node_modules/prismjs/components/prism-clike.min.js",
|
||||
"../node_modules/prismjs/components/prism-javascript.min.js",
|
||||
"../node_modules/prismjs/components/prism-typescript.min.js",
|
||||
"../node_modules/prismjs/components/prism-css.min.js",
|
||||
"../node_modules/prismjs/components/prism-scss.min.js",
|
||||
"../node_modules/prismjs/components/prism-json.min.js",
|
||||
"../node_modules/prismjs/components/prism-bash.min.js",
|
||||
"../node_modules/prismjs/components/prism-markdown.min.js",
|
||||
"../node_modules/prismjs/components/prism-yaml.min.js",
|
||||
"../node_modules/prismjs/components/prism-python.min.js",
|
||||
"../node_modules/prismjs/components/prism-csharp.min.js"
|
||||
],
|
||||
"allowedCommonJsDependencies": [
|
||||
"simple-peer",
|
||||
"uuid"
|
||||
]
|
||||
],
|
||||
"outputPath": "../dist/client"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -96,7 +97,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "1MB",
|
||||
"maximumError": "2MB"
|
||||
"maximumError": "2.1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
23
toju-app/public/web.config
Normal file
23
toju-app/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>
|
||||
@@ -13,6 +13,7 @@ import { routes } from './app.routes';
|
||||
import { messagesReducer } from './store/messages/messages.reducer';
|
||||
import { usersReducer } from './store/users/users.reducer';
|
||||
import { roomsReducer } from './store/rooms/rooms.reducer';
|
||||
import { NotificationsEffects } from './domains/notifications';
|
||||
import { MessagesEffects } from './store/messages/messages.effects';
|
||||
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
||||
import { UsersEffects } from './store/users/users.effects';
|
||||
@@ -32,6 +33,7 @@ export const appConfig: ApplicationConfig = {
|
||||
rooms: roomsReducer
|
||||
}),
|
||||
provideEffects([
|
||||
NotificationsEffects,
|
||||
MessagesEffects,
|
||||
MessagesSyncEffects,
|
||||
UsersEffects,
|
||||
@@ -50,47 +50,6 @@
|
||||
<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 />
|
||||
|
||||
@@ -10,17 +10,22 @@ export const routes: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/login/login.component').then((module) => module.LoginComponent)
|
||||
import('./domains/auth/feature/login/login.component').then((module) => module.LoginComponent)
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/register/register.component').then((module) => module.RegisterComponent)
|
||||
import('./domains/auth/feature/register/register.component').then((module) => module.RegisterComponent)
|
||||
},
|
||||
{
|
||||
path: 'invite/:inviteId',
|
||||
loadComponent: () =>
|
||||
import('./domains/server-directory/feature/invite/invite.component').then((module) => module.InviteComponent)
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
loadComponent: () =>
|
||||
import('./features/server-search/server-search.component').then(
|
||||
import('./domains/server-directory/feature/server-search/server-search.component').then(
|
||||
(module) => module.ServerSearchComponent
|
||||
)
|
||||
},
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
inject,
|
||||
HostListener
|
||||
} from '@angular/core';
|
||||
@@ -13,16 +14,18 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
import { DatabaseService } from './core/services/database.service';
|
||||
import { DatabaseService } from './infrastructure/persistence';
|
||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
||||
import { ServerDirectoryService } from './core/services/server-directory.service';
|
||||
import { ServerDirectoryFacade } from './domains/server-directory';
|
||||
import { NotificationsFacade } from './domains/notifications';
|
||||
import { TimeSyncService } from './core/services/time-sync.service';
|
||||
import { VoiceSessionService } from './core/services/voice-session.service';
|
||||
import { ExternalLinkService } from './core/services/external-link.service';
|
||||
import { VoiceSessionFacade } from './domains/voice-session';
|
||||
import { ExternalLinkService } from './core/platform';
|
||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
||||
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/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';
|
||||
@@ -50,7 +53,7 @@ import {
|
||||
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);
|
||||
@@ -58,11 +61,14 @@ export class App implements OnInit {
|
||||
|
||||
private databaseService = inject(DatabaseService);
|
||||
private router = inject(Router);
|
||||
private servers = inject(ServerDirectoryService);
|
||||
private servers = inject(ServerDirectoryFacade);
|
||||
private notifications = inject(NotificationsFacade);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private timeSync = inject(TimeSyncService);
|
||||
private voiceSession = inject(VoiceSessionService);
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
private externalLinks = inject(ExternalLinkService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private deepLinkCleanup: (() => void) | null = null;
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onGlobalLinkClick(evt: MouseEvent): void {
|
||||
@@ -80,6 +86,10 @@ export class App implements OnInit {
|
||||
await this.timeSync.syncWithEndpoint(apiBase);
|
||||
} catch {}
|
||||
|
||||
await this.notifications.initialize();
|
||||
|
||||
await this.setupDesktopDeepLinks();
|
||||
|
||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
@@ -87,8 +97,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);
|
||||
@@ -116,6 +130,11 @@ export class App implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.deepLinkCleanup?.();
|
||||
this.deepLinkCleanup = null;
|
||||
}
|
||||
|
||||
openNetworkSettings(): void {
|
||||
this.settingsModal.open('network');
|
||||
}
|
||||
@@ -131,4 +150,72 @@ export class App implements OnInit {
|
||||
async restartToApplyUpdate(): Promise<void> {
|
||||
await this.desktopUpdates.restartToApplyUpdate();
|
||||
}
|
||||
|
||||
private async setupDesktopDeepLinks(): Promise<void> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
|
||||
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
|
||||
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
||||
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
|
||||
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
||||
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
|
||||
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
||||
52
toju-app/src/app/core/models/index.ts
Normal file
52
toju-app/src/app/core/models/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Transitional compatibility barrel.
|
||||
*
|
||||
* All business types now live in `src/app/shared-kernel/` (organised by concept)
|
||||
* or in their owning domain. This file re-exports everything so existing
|
||||
* `import { X } from 'core/models'` lines keep working while the codebase
|
||||
* migrates to direct shared-kernel imports.
|
||||
*
|
||||
* NEW CODE should import from `@shared-kernel` or the owning domain barrel
|
||||
* instead of this file.
|
||||
*/
|
||||
|
||||
export type {
|
||||
User,
|
||||
UserStatus,
|
||||
UserRole,
|
||||
RoomMember
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export type {
|
||||
Room,
|
||||
RoomSettings,
|
||||
RoomPermissions,
|
||||
Channel,
|
||||
ChannelType
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export type { Message, Reaction } from '../../shared-kernel';
|
||||
export { DELETED_MESSAGE_CONTENT } from '../../shared-kernel';
|
||||
|
||||
export type { BanEntry } from '../../shared-kernel';
|
||||
|
||||
export type { VoiceState, ScreenShareState } from '../../shared-kernel';
|
||||
|
||||
export type {
|
||||
ChatEventBase,
|
||||
ChatEventType,
|
||||
ChatEvent,
|
||||
ChatInventoryItem
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export type {
|
||||
SignalingMessage,
|
||||
SignalingMessageType
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export type {
|
||||
ChatAttachmentAnnouncement,
|
||||
ChatAttachmentMeta
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export type { ServerInfo } from '../../domains/server-directory';
|
||||
174
toju-app/src/app/core/platform/electron/electron-api.models.ts
Normal file
174
toju-app/src/app/core/platform/electron/electron-api.models.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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 ClipboardFilePayload {
|
||||
data: string;
|
||||
lastModified: number;
|
||||
mime: string;
|
||||
name: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||
|
||||
export type DesktopUpdateStatus =
|
||||
| 'idle'
|
||||
| 'disabled'
|
||||
| 'checking'
|
||||
| 'downloading'
|
||||
| 'up-to-date'
|
||||
| 'restart-required'
|
||||
| 'unsupported'
|
||||
| 'no-manifest'
|
||||
| 'target-unavailable'
|
||||
| 'target-older-than-installed'
|
||||
| 'error';
|
||||
|
||||
export type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
|
||||
|
||||
export interface DesktopUpdateServerContext {
|
||||
manifestUrls: string[];
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateServerHealthSnapshot {
|
||||
manifestUrl: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateState {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
availableVersions: string[];
|
||||
configuredManifestUrls: string[];
|
||||
currentVersion: string;
|
||||
defaultManifestUrls: string[];
|
||||
isSupported: boolean;
|
||||
lastCheckedAt: number | null;
|
||||
latestVersion: string | null;
|
||||
manifestUrl: string | null;
|
||||
manifestUrls: string[];
|
||||
minimumServerVersion: string | null;
|
||||
preferredVersion: string | null;
|
||||
restartRequired: boolean;
|
||||
serverBlocked: boolean;
|
||||
serverBlockMessage: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
status: DesktopUpdateStatus;
|
||||
statusMessage: string | null;
|
||||
targetVersion: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopSettingsSnapshot {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
autoStart: boolean;
|
||||
closeToTray: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
runtimeHardwareAcceleration: boolean;
|
||||
restartRequired: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopSettingsPatch {
|
||||
autoUpdateMode?: AutoUpdateMode;
|
||||
autoStart?: boolean;
|
||||
closeToTray?: boolean;
|
||||
hardwareAcceleration?: boolean;
|
||||
manifestUrls?: string[];
|
||||
preferredVersion?: string | null;
|
||||
vaapiVideoEncode?: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopNotificationPayload {
|
||||
body: string;
|
||||
requestAttention: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface WindowStateSnapshot {
|
||||
isFocused: boolean;
|
||||
isMinimized: boolean;
|
||||
}
|
||||
|
||||
export interface ElectronCommand {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface ElectronQuery {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
maximizeWindow: () => void;
|
||||
closeWindow: () => void;
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||
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;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
consumePendingDeepLink: () => Promise<string | null>;
|
||||
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
|
||||
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
||||
requestWindowAttention: () => Promise<boolean>;
|
||||
clearWindowAttention: () => Promise<boolean>;
|
||||
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
|
||||
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
||||
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
|
||||
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
||||
restartToApplyUpdate: () => Promise<boolean>;
|
||||
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||
setDesktopSettings: (patch: DesktopSettingsPatch) => Promise<DesktopSettingsSnapshot>;
|
||||
relaunchApp: () => Promise<boolean>;
|
||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
fileExists: (filePath: string) => Promise<boolean>;
|
||||
deleteFile: (filePath: string) => Promise<boolean>;
|
||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
|
||||
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
|
||||
}
|
||||
|
||||
export type ElectronWindow = Window & {
|
||||
electronAPI?: ElectronApi;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import type { ElectronApi } from './electron-api.models';
|
||||
import { getElectronApi } from './get-electron-api';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ElectronBridgeService {
|
||||
get isAvailable(): boolean {
|
||||
return this.getApi() !== null;
|
||||
}
|
||||
|
||||
getApi(): ElectronApi | null {
|
||||
return getElectronApi();
|
||||
}
|
||||
|
||||
requireApi(): ElectronApi {
|
||||
const api = this.getApi();
|
||||
|
||||
if (!api) {
|
||||
throw new Error('Electron API is not available in this runtime.');
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ElectronApi, ElectronWindow } from './electron-api.models';
|
||||
|
||||
export function getElectronApi(): ElectronApi | null {
|
||||
return typeof window !== 'undefined'
|
||||
? (window as ElectronWindow).electronAPI ?? null
|
||||
: null;
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
interface ExternalLinkElectronApi {
|
||||
openExternal?: (url: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
type ExternalLinkWindow = Window & {
|
||||
electronAPI?: ExternalLinkElectronApi;
|
||||
};
|
||||
import { ElectronBridgeService } from './electron/electron-bridge.service';
|
||||
|
||||
/**
|
||||
* Opens URLs in the system default browser (Electron) or a new tab (browser).
|
||||
@@ -17,18 +9,21 @@ type ExternalLinkWindow = Window & {
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExternalLinkService {
|
||||
private platform = inject(PlatformService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
/** Open a URL externally. Only http/https URLs are allowed. */
|
||||
open(url: string): void {
|
||||
if (!url || !(url.startsWith('http://') || url.startsWith('https://')))
|
||||
return;
|
||||
|
||||
if (this.platform.isElectron) {
|
||||
(window as ExternalLinkWindow).electronAPI?.openExternal?.(url);
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (electronApi) {
|
||||
void electronApi.openExternal(url);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,22 +36,19 @@ export class ExternalLinkService {
|
||||
if (!target)
|
||||
return false;
|
||||
|
||||
const href = target.href; // resolved full URL
|
||||
const href = target.href;
|
||||
|
||||
if (!href)
|
||||
return false;
|
||||
|
||||
// Skip non-navigable URLs
|
||||
if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:'))
|
||||
return false;
|
||||
|
||||
// Skip same-page anchors
|
||||
const rawAttr = target.getAttribute('href');
|
||||
|
||||
if (rawAttr?.startsWith('#'))
|
||||
return false;
|
||||
|
||||
// Skip Angular router links
|
||||
if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link'))
|
||||
return false;
|
||||
|
||||
2
toju-app/src/app/core/platform/index.ts
Normal file
2
toju-app/src/app/core/platform/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './platform.service';
|
||||
export * from './external-link.service';
|
||||
15
toju-app/src/app/core/platform/platform.service.ts
Normal file
15
toju-app/src/app/core/platform/platform.service.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ElectronBridgeService } from './electron/electron-bridge.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlatformService {
|
||||
readonly isElectron: boolean;
|
||||
readonly isBrowser: boolean;
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
constructor() {
|
||||
this.isElectron = this.electronBridge.isAvailable;
|
||||
|
||||
this.isBrowser = !this.isElectron;
|
||||
}
|
||||
}
|
||||
8
toju-app/src/app/core/realtime/index.ts
Normal file
8
toju-app/src/app/core/realtime/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Transitional application-facing boundary over the shared realtime runtime.
|
||||
* Keep business domains depending on this technical API rather than reaching
|
||||
* into low-level infrastructure implementations directly.
|
||||
*/
|
||||
export { WebRTCService as RealtimeSessionFacade } from '../../infrastructure/realtime/realtime-session.service';
|
||||
export * from '../../infrastructure/realtime/realtime.constants';
|
||||
export * from '../../infrastructure/realtime/realtime.types';
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable complexity, padding-line-between-statements */
|
||||
import { getDebugNetworkMetricSnapshot } from '../debug-network-metrics.service';
|
||||
import { getDebugNetworkMetricSnapshot } from '../../../infrastructure/realtime/logging/debug-network-metrics';
|
||||
import type { Room, User } from '../../models/index';
|
||||
import {
|
||||
LOCAL_NETWORK_NODE_ID,
|
||||
@@ -433,7 +433,7 @@ class DebugNetworkSnapshotBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'screen-state') {
|
||||
if (type === 'screen-state' || type === 'camera-state') {
|
||||
const subjectNode = direction === 'outbound'
|
||||
? this.ensureLocalNetworkNode(
|
||||
state,
|
||||
@@ -442,12 +442,14 @@ class DebugNetworkSnapshotBuilder {
|
||||
this.getPayloadString(payload, 'displayName')
|
||||
)
|
||||
: peerNode;
|
||||
const isScreenSharing = this.getPayloadBoolean(payload, 'isScreenSharing');
|
||||
const isStreaming = type === 'screen-state'
|
||||
? this.getPayloadBoolean(payload, 'isScreenSharing')
|
||||
: this.getPayloadBoolean(payload, 'isCameraEnabled');
|
||||
|
||||
if (isScreenSharing !== null) {
|
||||
subjectNode.isStreaming = isScreenSharing;
|
||||
if (isStreaming !== null) {
|
||||
subjectNode.isStreaming = isStreaming;
|
||||
|
||||
if (!isScreenSharing)
|
||||
if (!isStreaming)
|
||||
subjectNode.streams.video = 0;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -5,70 +5,17 @@ import {
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { PlatformService } from './platform.service';
|
||||
import { ServerDirectoryService, type ServerEndpoint } from './server-directory.service';
|
||||
|
||||
type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||
type DesktopUpdateStatus =
|
||||
| 'idle'
|
||||
| 'disabled'
|
||||
| 'checking'
|
||||
| 'downloading'
|
||||
| 'up-to-date'
|
||||
| 'restart-required'
|
||||
| 'unsupported'
|
||||
| 'no-manifest'
|
||||
| 'target-unavailable'
|
||||
| 'target-older-than-installed'
|
||||
| 'error';
|
||||
type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
|
||||
|
||||
interface DesktopUpdateState {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
availableVersions: string[];
|
||||
configuredManifestUrls: string[];
|
||||
currentVersion: string;
|
||||
defaultManifestUrls: string[];
|
||||
isSupported: boolean;
|
||||
lastCheckedAt: number | null;
|
||||
latestVersion: string | null;
|
||||
manifestUrl: string | null;
|
||||
manifestUrls: string[];
|
||||
minimumServerVersion: string | null;
|
||||
preferredVersion: string | null;
|
||||
restartRequired: boolean;
|
||||
serverBlocked: boolean;
|
||||
serverBlockMessage: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
status: DesktopUpdateStatus;
|
||||
statusMessage: string | null;
|
||||
targetVersion: string | null;
|
||||
}
|
||||
|
||||
interface DesktopUpdateServerContext {
|
||||
manifestUrls: string[];
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
interface DesktopUpdateElectronApi {
|
||||
checkForAppUpdates?: () => Promise<DesktopUpdateState>;
|
||||
configureAutoUpdateContext?: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||
getAutoUpdateState?: () => Promise<DesktopUpdateState>;
|
||||
onAutoUpdateStateChanged?: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||
restartToApplyUpdate?: () => Promise<boolean>;
|
||||
setDesktopSettings?: (patch: {
|
||||
autoUpdateMode?: AutoUpdateMode;
|
||||
manifestUrls?: string[];
|
||||
preferredVersion?: string | null;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface ServerHealthResponse {
|
||||
releaseManifestUrl?: string;
|
||||
serverVersion?: string;
|
||||
}
|
||||
import { PlatformService } from '../platform';
|
||||
import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import {
|
||||
type AutoUpdateMode,
|
||||
type DesktopUpdateServerContext,
|
||||
type DesktopUpdateServerHealthSnapshot,
|
||||
type DesktopUpdateServerVersionStatus,
|
||||
type DesktopUpdateState,
|
||||
type ElectronApi
|
||||
} from '../platform/electron/electron-api.models';
|
||||
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||
|
||||
interface ServerHealthSnapshot {
|
||||
endpointId: string;
|
||||
@@ -77,12 +24,7 @@ interface ServerHealthSnapshot {
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
type DesktopUpdateWindow = Window & {
|
||||
electronAPI?: DesktopUpdateElectronApi;
|
||||
};
|
||||
|
||||
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
|
||||
const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
|
||||
|
||||
function createInitialState(): DesktopUpdateState {
|
||||
return {
|
||||
@@ -153,7 +95,8 @@ export class DesktopAppUpdateService {
|
||||
readonly state = signal<DesktopUpdateState>(createInitialState());
|
||||
|
||||
private injector = inject(Injector);
|
||||
private servers = inject(ServerDirectoryService);
|
||||
private servers = inject(ServerDirectoryFacade);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private initialized = false;
|
||||
private refreshTimerId: number | null = null;
|
||||
private removeStateListener: (() => void) | null = null;
|
||||
@@ -344,14 +287,9 @@ export class DesktopAppUpdateService {
|
||||
|
||||
private async readServerHealth(endpoint: ServerEndpoint): Promise<ServerHealthSnapshot> {
|
||||
const sanitizedServerUrl = endpoint.url.replace(/\/+$/, '');
|
||||
const api = this.getElectronApi();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${sanitizedServerUrl}/api/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(SERVER_CONTEXT_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (!api?.getAutoUpdateServerHealth) {
|
||||
return {
|
||||
endpointId: endpoint.id,
|
||||
manifestUrl: null,
|
||||
@@ -360,14 +298,12 @@ export class DesktopAppUpdateService {
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await response.json() as ServerHealthResponse;
|
||||
const serverVersion = normalizeOptionalString(payload.serverVersion);
|
||||
try {
|
||||
const payload = await api.getAutoUpdateServerHealth(sanitizedServerUrl);
|
||||
|
||||
return {
|
||||
endpointId: endpoint.id,
|
||||
manifestUrl: normalizeOptionalHttpUrl(payload.releaseManifestUrl),
|
||||
serverVersion,
|
||||
serverVersionStatus: serverVersion ? 'reported' : 'missing'
|
||||
...this.normalizeHealthSnapshot(payload)
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
@@ -379,6 +315,22 @@ export class DesktopAppUpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeHealthSnapshot(
|
||||
snapshot: DesktopUpdateServerHealthSnapshot
|
||||
): Omit<ServerHealthSnapshot, 'endpointId'> {
|
||||
const serverVersion = normalizeOptionalString(snapshot.serverVersion);
|
||||
|
||||
return {
|
||||
manifestUrl: normalizeOptionalHttpUrl(snapshot.manifestUrl),
|
||||
serverVersion,
|
||||
serverVersionStatus: serverVersion
|
||||
? snapshot.serverVersionStatus
|
||||
: snapshot.serverVersionStatus === 'reported'
|
||||
? 'missing'
|
||||
: snapshot.serverVersionStatus
|
||||
};
|
||||
}
|
||||
|
||||
private async pushContext(context: Partial<DesktopUpdateServerContext>): Promise<void> {
|
||||
const api = this.getElectronApi();
|
||||
|
||||
@@ -393,9 +345,7 @@ export class DesktopAppUpdateService {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private getElectronApi(): DesktopUpdateElectronApi | null {
|
||||
return typeof window !== 'undefined'
|
||||
? (window as DesktopUpdateWindow).electronAPI ?? null
|
||||
: null;
|
||||
private getElectronApi(): ElectronApi | null {
|
||||
return this.electronBridge.getApi();
|
||||
}
|
||||
}
|
||||
4
toju-app/src/app/core/services/index.ts
Normal file
4
toju-app/src/app/core/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './notification-audio.service';
|
||||
export * from '../models/debugging.models';
|
||||
export * from './debugging/debugging.service';
|
||||
export * from './settings-modal.service';
|
||||
@@ -13,7 +13,7 @@ export enum AppSound {
|
||||
}
|
||||
|
||||
/** Path prefix for audio assets (served from the `assets/audio/` folder). */
|
||||
const AUDIO_BASE = '/assets/audio';
|
||||
const AUDIO_BASE = 'assets/audio';
|
||||
/** File extension used for all sound-effect assets. */
|
||||
const AUDIO_EXT = 'wav';
|
||||
/** localStorage key for persisting notification volume. */
|
||||
@@ -36,6 +36,8 @@ export class NotificationAudioService {
|
||||
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
|
||||
private readonly cache = new Map<AppSound, HTMLAudioElement>();
|
||||
|
||||
private readonly sources = new Map<AppSound, string>();
|
||||
|
||||
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
||||
readonly notificationVolume = signal(this.loadVolume());
|
||||
|
||||
@@ -46,13 +48,22 @@ export class NotificationAudioService {
|
||||
/** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */
|
||||
private preload(): void {
|
||||
for (const sound of Object.values(AppSound)) {
|
||||
const audio = new Audio(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`);
|
||||
const src = this.resolveAudioUrl(sound);
|
||||
const audio = new Audio();
|
||||
|
||||
audio.preload = 'auto';
|
||||
audio.src = src;
|
||||
audio.load();
|
||||
|
||||
this.sources.set(sound, src);
|
||||
this.cache.set(sound, audio);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAudioUrl(sound: AppSound): string {
|
||||
return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString();
|
||||
}
|
||||
|
||||
/** Read persisted volume from localStorage, falling back to the default. */
|
||||
private loadVolume(): number {
|
||||
try {
|
||||
@@ -96,8 +107,9 @@ export class NotificationAudioService {
|
||||
*/
|
||||
play(sound: AppSound, volumeOverride?: number): void {
|
||||
const cached = this.cache.get(sound);
|
||||
const src = this.sources.get(sound);
|
||||
|
||||
if (!cached)
|
||||
if (!cached || !src)
|
||||
return;
|
||||
|
||||
const vol = volumeOverride ?? this.notificationVolume();
|
||||
@@ -105,12 +117,23 @@ export class NotificationAudioService {
|
||||
if (vol === 0)
|
||||
return; // skip playback when muted
|
||||
|
||||
if (cached.readyState === HTMLMediaElement.HAVE_NOTHING) {
|
||||
cached.load();
|
||||
}
|
||||
|
||||
// Clone so overlapping plays don't cut each other off.
|
||||
const clone = cached.cloneNode(true) as HTMLAudioElement;
|
||||
|
||||
clone.preload = 'auto';
|
||||
clone.volume = Math.max(0, Math.min(1, vol));
|
||||
clone.play().catch(() => {
|
||||
const fallback = new Audio(src);
|
||||
|
||||
fallback.preload = 'auto';
|
||||
fallback.volume = clone.volume;
|
||||
fallback.play().catch(() => {
|
||||
/* swallow autoplay errors */
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,24 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
export type SettingsPage = 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
|
||||
|
||||
export type SettingsPage =
|
||||
| 'general'
|
||||
| 'network'
|
||||
| 'notifications'
|
||||
| '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);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user