Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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)
|
# Toggle SSL for local development (true/false)
|
||||||
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
|
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
|
||||||
# When false: plain HTTP everywhere (only works on localhost)
|
# When false: plain HTTP everywhere (only works on localhost)
|
||||||
|
# Overrides server/data/variables.json for local development only
|
||||||
SSL=true
|
SSL=true
|
||||||
|
|||||||
43
.gitea/workflows/deploy-web-apps.yml
Normal file
43
.gitea/workflows/deploy-web-apps.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Deploy Web Apps
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: windows
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: powershell
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
env:
|
||||||
|
NODE_ENV: development
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install website dependencies
|
||||||
|
env:
|
||||||
|
NODE_ENV: development
|
||||||
|
run: npm ci --prefix website
|
||||||
|
|
||||||
|
- name: Build Toju web app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build Toju website
|
||||||
|
run: |
|
||||||
|
Push-Location website
|
||||||
|
npm run build
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
- name: Deploy both apps to IIS
|
||||||
|
run: >
|
||||||
|
./tools/deploy-web-apps.ps1
|
||||||
|
-WebsitePort 4341
|
||||||
|
-AppPort 4492
|
||||||
@@ -67,8 +67,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: |
|
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='./'
|
npx ng build --configuration production --base-href='./'
|
||||||
|
cd ..
|
||||||
npx --package typescript tsc -p tsconfig.electron.json
|
npx --package typescript tsc -p tsconfig.electron.json
|
||||||
cd server
|
cd server
|
||||||
node ../tools/sync-server-build-version.js
|
node ../tools/sync-server-build-version.js
|
||||||
@@ -120,8 +122,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: |
|
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='./'
|
npx ng build --configuration production --base-href='./'
|
||||||
|
Pop-Location
|
||||||
npx --package typescript tsc -p tsconfig.electron.json
|
npx --package typescript tsc -p tsconfig.electron.json
|
||||||
Push-Location server
|
Push-Location server
|
||||||
node ../tools/sync-server-build-version.js
|
node ../tools/sync-server-build-version.js
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -6,7 +6,9 @@
|
|||||||
/tmp
|
/tmp
|
||||||
/out-tsc
|
/out-tsc
|
||||||
/bazel-out
|
/bazel-out
|
||||||
|
*.sqlite
|
||||||
|
*/architecture.md
|
||||||
|
/docs
|
||||||
# Node
|
# Node
|
||||||
/node_modules
|
/node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
@@ -51,3 +53,6 @@ Thumbs.db
|
|||||||
.certs/
|
.certs/
|
||||||
/server/data/variables.json
|
/server/data/variables.json
|
||||||
dist-server/*
|
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
|
# Toju / Zoracord
|
||||||
|
|
||||||
Desktop chat app with three parts:
|
Desktop chat app with four parts:
|
||||||
|
|
||||||
- `src/` Angular client
|
- `src/` Angular client
|
||||||
- `electron/` desktop shell, IPC, and local database
|
- `electron/` desktop shell, IPC, and local database
|
||||||
- `server/` directory server, join request API, and websocket events
|
- `server/` directory server, join request API, and websocket events
|
||||||
|
- `website/` Toju website served at toju.app
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -17,7 +21,7 @@ Desktop chat app with three parts:
|
|||||||
Root `.env`:
|
Root `.env`:
|
||||||
|
|
||||||
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
|
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
|
||||||
- `PORT=3001` changes the server port
|
- `PORT=3001` changes the server port in local development and overrides the server app setting
|
||||||
|
|
||||||
If `SSL=true`, run `./generate-cert.sh` once.
|
If `SSL=true`, run `./generate-cert.sh` once.
|
||||||
|
|
||||||
@@ -25,6 +29,10 @@ Server files:
|
|||||||
|
|
||||||
- `server/data/variables.json` holds `klipyApiKey`
|
- `server/data/variables.json` holds `klipyApiKey`
|
||||||
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
|
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
|
||||||
|
- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to)
|
||||||
|
- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`)
|
||||||
|
- `server/data/variables.json` can now also hold `serverPort` (1-65535)
|
||||||
|
- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP
|
||||||
|
|
||||||
## Main commands
|
## Main commands
|
||||||
|
|
||||||
@@ -48,3 +56,64 @@ Inside `server/`:
|
|||||||
- `npm run dev` starts the server with reload
|
- `npm run dev` starts the server with reload
|
||||||
- `npm run build` compiles to `dist/`
|
- `npm run build` compiles to `dist/`
|
||||||
- `npm run start` runs the compiled server
|
- `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"
|
"$DIR/generate-cert.sh"
|
||||||
fi
|
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"
|
WAIT_URL="https://localhost:4200"
|
||||||
HEALTH_URL="https://localhost:3001/api/health"
|
HEALTH_URL="https://localhost:3001/api/health"
|
||||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
else
|
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"
|
WAIT_URL="http://localhost:4200"
|
||||||
HEALTH_URL="http://localhost:3001/api/health"
|
HEALTH_URL="http://localhost:3001/api/health"
|
||||||
fi
|
fi
|
||||||
@@ -33,4 +33,4 @@ fi
|
|||||||
exec npx concurrently --kill-others \
|
exec npx concurrently --kill-others \
|
||||||
"cd server && npm run dev" \
|
"cd server && npm run dev" \
|
||||||
"$NG_SERVE" \
|
"$NG_SERVE" \
|
||||||
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron . --no-sandbox --disable-dev-shm-usage"
|
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage"
|
||||||
|
|||||||
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('no-sandbox');
|
||||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||||
|
|
||||||
// Auto-detect Wayland vs X11 so the xdg-desktop-portal system picker
|
// Chromium chooses the Linux Ozone platform before Electron runs this file.
|
||||||
// works for screen capture on Wayland compositors
|
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland
|
||||||
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
|
// sessions so the browser process selects the correct backend early enough.
|
||||||
}
|
}
|
||||||
|
|
||||||
function networkFlags(): void {
|
function networkFlags(): void {
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
||||||
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
||||||
|
import { synchronizeAutoStartSetting } from './auto-start';
|
||||||
import {
|
import {
|
||||||
initializeDatabase,
|
initializeDatabase,
|
||||||
destroyDatabase,
|
destroyDatabase,
|
||||||
getDataSource
|
getDataSource
|
||||||
} from '../db/database';
|
} from '../db/database';
|
||||||
import { createWindow, getDockIconPath } from '../window/create-window';
|
import {
|
||||||
|
createWindow,
|
||||||
|
getDockIconPath,
|
||||||
|
getMainWindow,
|
||||||
|
prepareWindowForAppQuit,
|
||||||
|
showMainWindow
|
||||||
|
} from '../window/create-window';
|
||||||
import {
|
import {
|
||||||
setupCqrsHandlers,
|
setupCqrsHandlers,
|
||||||
setupSystemHandlers,
|
setupSystemHandlers,
|
||||||
@@ -24,12 +31,18 @@ export function registerAppLifecycle(): void {
|
|||||||
setupCqrsHandlers();
|
setupCqrsHandlers();
|
||||||
setupWindowControlHandlers();
|
setupWindowControlHandlers();
|
||||||
setupSystemHandlers();
|
setupSystemHandlers();
|
||||||
|
await synchronizeAutoStartSetting();
|
||||||
initializeDesktopUpdater();
|
initializeDesktopUpdater();
|
||||||
await createWindow();
|
await createWindow();
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
|
if (getMainWindow()) {
|
||||||
|
void showMainWindow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (BrowserWindow.getAllWindows().length === 0)
|
if (BrowserWindow.getAllWindows().length === 0)
|
||||||
createWindow();
|
void createWindow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,6 +52,8 @@ export function registerAppLifecycle(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', async (event) => {
|
app.on('before-quit', async (event) => {
|
||||||
|
prepareWindowForAppQuit();
|
||||||
|
|
||||||
if (getDataSource()?.isInitialized) {
|
if (getDataSource()?.isInitialized) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
shutdownDesktopUpdater();
|
shutdownDesktopUpdater();
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ interface SinkInputDetails extends ShortSinkInputEntry {
|
|||||||
properties: Record<string, string>;
|
properties: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DescendantProcessInfo {
|
||||||
|
ids: ReadonlySet<string>;
|
||||||
|
binaryNames: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
interface PactlJsonSinkInputEntry {
|
interface PactlJsonSinkInputEntry {
|
||||||
index?: number | string;
|
index?: number | string;
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
@@ -44,6 +49,7 @@ interface LinuxScreenShareAudioRoutingState {
|
|||||||
screenShareLoopbackModuleId: string | null;
|
screenShareLoopbackModuleId: string | null;
|
||||||
voiceLoopbackModuleId: string | null;
|
voiceLoopbackModuleId: string | null;
|
||||||
rerouteIntervalId: ReturnType<typeof setInterval> | null;
|
rerouteIntervalId: ReturnType<typeof setInterval> | null;
|
||||||
|
subscribeProcess: ChildProcess | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinuxScreenShareMonitorCaptureState {
|
interface LinuxScreenShareMonitorCaptureState {
|
||||||
@@ -77,7 +83,8 @@ const routingState: LinuxScreenShareAudioRoutingState = {
|
|||||||
restoreSinkName: null,
|
restoreSinkName: null,
|
||||||
screenShareLoopbackModuleId: null,
|
screenShareLoopbackModuleId: null,
|
||||||
voiceLoopbackModuleId: null,
|
voiceLoopbackModuleId: null,
|
||||||
rerouteIntervalId: null
|
rerouteIntervalId: null,
|
||||||
|
subscribeProcess: null
|
||||||
};
|
};
|
||||||
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
|
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
|
||||||
captureId: null,
|
captureId: null,
|
||||||
@@ -126,12 +133,21 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
|||||||
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
|
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
|
||||||
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
|
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
|
||||||
|
|
||||||
await setDefaultSink(SCREEN_SHARE_SINK_NAME);
|
// Set the default sink to the voice sink so that new app audio
|
||||||
await moveSinkInputs(SCREEN_SHARE_SINK_NAME, (sinkName) => !!sinkName && sinkName !== SCREEN_SHARE_SINK_NAME && sinkName !== VOICE_SINK_NAME);
|
// streams (received WebRTC voice) never land on the screenshare
|
||||||
|
// capture sink. This prevents the feedback loop where remote
|
||||||
|
// voice audio was picked up by parec before the reroute interval
|
||||||
|
// could move the stream away.
|
||||||
|
await setDefaultSink(VOICE_SINK_NAME);
|
||||||
|
|
||||||
routingState.active = true;
|
routingState.active = true;
|
||||||
await rerouteAppSinkInputsToVoiceSink();
|
|
||||||
|
// Let the combined reroute decide placement for every existing
|
||||||
|
// stream. This avoids briefly shoving the app's own playback to the
|
||||||
|
// screenshare sink before ownership detection can move it back.
|
||||||
|
await rerouteSinkInputs();
|
||||||
startSinkInputRerouteLoop();
|
startSinkInputRerouteLoop();
|
||||||
|
startSubscribeWatcher();
|
||||||
|
|
||||||
return buildRoutingInfo(true, true);
|
return buildRoutingInfo(true, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -148,6 +164,7 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
|||||||
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
|
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
|
||||||
const restoreSinkName = routingState.restoreSinkName;
|
const restoreSinkName = routingState.restoreSinkName;
|
||||||
|
|
||||||
|
stopSubscribeWatcher();
|
||||||
stopSinkInputRerouteLoop();
|
stopSinkInputRerouteLoop();
|
||||||
await stopLinuxScreenShareMonitorCapture();
|
await stopLinuxScreenShareMonitorCapture();
|
||||||
|
|
||||||
@@ -166,6 +183,7 @@ export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean>
|
|||||||
routingState.restoreSinkName = null;
|
routingState.restoreSinkName = null;
|
||||||
routingState.screenShareLoopbackModuleId = null;
|
routingState.screenShareLoopbackModuleId = null;
|
||||||
routingState.voiceLoopbackModuleId = null;
|
routingState.voiceLoopbackModuleId = null;
|
||||||
|
routingState.subscribeProcess = null;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -425,34 +443,52 @@ async function setDefaultSink(sinkName: string): Promise<void> {
|
|||||||
await runPactl('set-default-sink', sinkName);
|
await runPactl('set-default-sink', sinkName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rerouteAppSinkInputsToVoiceSink(): Promise<void> {
|
/**
|
||||||
|
* Combined reroute that enforces sink placement in both directions:
|
||||||
|
* - App-owned sink inputs that are NOT on the voice sink are moved there.
|
||||||
|
* - Non-app sink inputs that ARE on the voice sink are moved to the
|
||||||
|
* screenshare sink so they are captured by parec.
|
||||||
|
*
|
||||||
|
* This two-way approach, combined with the voice sink being the PulseAudio
|
||||||
|
* default, ensures that received WebRTC voice audio can never leak into the
|
||||||
|
* screenshare monitor source.
|
||||||
|
*/
|
||||||
|
async function rerouteSinkInputs(): Promise<void> {
|
||||||
const [
|
const [
|
||||||
sinks,
|
sinks,
|
||||||
sinkInputs,
|
sinkInputs,
|
||||||
descendantProcessIds
|
descendantProcessInfo
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
listSinks(),
|
listSinks(),
|
||||||
listSinkInputDetails(),
|
listSinkInputDetails(),
|
||||||
collectDescendantProcessIds(process.pid)
|
collectDescendantProcessInfo(process.pid)
|
||||||
]);
|
]);
|
||||||
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
sinkInputs.map(async (sinkInput) => {
|
sinkInputs.map(async (sinkInput) => {
|
||||||
if (!isAppOwnedSinkInput(sinkInput, descendantProcessIds)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
||||||
|
const appOwned = isAppOwnedSinkInput(sinkInput, descendantProcessInfo);
|
||||||
|
|
||||||
|
// App-owned streams must stay on the voice sink.
|
||||||
|
if (appOwned && sinkName !== VOICE_SINK_NAME) {
|
||||||
|
try {
|
||||||
|
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
||||||
|
} catch {
|
||||||
|
// Streams can disappear or be recreated while rerouting.
|
||||||
|
}
|
||||||
|
|
||||||
if (sinkName === VOICE_SINK_NAME) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Non-app streams sitting on the voice sink should be moved to the
|
||||||
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
// screenshare sink for desktop-audio capture.
|
||||||
} catch {
|
if (!appOwned && sinkName === VOICE_SINK_NAME) {
|
||||||
// Streams can disappear or be recreated while rerouting.
|
try {
|
||||||
|
await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME);
|
||||||
|
} catch {
|
||||||
|
// Streams can disappear or be recreated while rerouting.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -515,7 +551,7 @@ function startSinkInputRerouteLoop(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
routingState.rerouteIntervalId = setInterval(() => {
|
routingState.rerouteIntervalId = setInterval(() => {
|
||||||
void rerouteAppSinkInputsToVoiceSink();
|
void rerouteSinkInputs();
|
||||||
}, REROUTE_INTERVAL_MS);
|
}, REROUTE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,13 +564,108 @@ function stopSinkInputRerouteLoop(): void {
|
|||||||
routingState.rerouteIntervalId = null;
|
routingState.rerouteIntervalId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns `pactl subscribe` to receive PulseAudio events in real time.
|
||||||
|
* When a new or changed sink-input is detected, a reroute is triggered
|
||||||
|
* immediately instead of waiting for the next interval tick. This
|
||||||
|
* drastically reduces the time non-app desktop audio spends on the
|
||||||
|
* voice sink before being moved to the screenshare sink.
|
||||||
|
*/
|
||||||
|
function startSubscribeWatcher(): void {
|
||||||
|
if (routingState.subscribeProcess) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let proc: ChildProcess;
|
||||||
|
|
||||||
|
try {
|
||||||
|
proc = spawn('pactl', ['subscribe'], {
|
||||||
|
env: process.env,
|
||||||
|
stdio: [
|
||||||
|
'ignore',
|
||||||
|
'pipe',
|
||||||
|
'ignore'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If pactl subscribe fails to spawn, the interval loop still covers us.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
routingState.subscribeProcess = proc;
|
||||||
|
|
||||||
|
let pending = false;
|
||||||
|
|
||||||
|
proc.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
if (!routingState.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = chunk.toString();
|
||||||
|
|
||||||
|
if (/Event '(?:new|change)' on sink-input/.test(text)) {
|
||||||
|
if (!pending) {
|
||||||
|
pending = true;
|
||||||
|
|
||||||
|
// Batch rapid-fire events with a short delay.
|
||||||
|
setTimeout(() => {
|
||||||
|
pending = false;
|
||||||
|
void rerouteSinkInputs();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', () => {
|
||||||
|
if (routingState.subscribeProcess === proc) {
|
||||||
|
routingState.subscribeProcess = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', () => {
|
||||||
|
if (routingState.subscribeProcess === proc) {
|
||||||
|
routingState.subscribeProcess = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSubscribeWatcher(): void {
|
||||||
|
const proc = routingState.subscribeProcess;
|
||||||
|
|
||||||
|
if (!proc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
routingState.subscribeProcess = null;
|
||||||
|
|
||||||
|
if (!proc.killed) {
|
||||||
|
proc.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isAppOwnedSinkInput(
|
function isAppOwnedSinkInput(
|
||||||
sinkInput: SinkInputDetails,
|
sinkInput: SinkInputDetails,
|
||||||
descendantProcessIds: ReadonlySet<string>
|
descendantProcessInfo: DescendantProcessInfo
|
||||||
): boolean {
|
): boolean {
|
||||||
const processId = sinkInput.properties['application.process.id'];
|
const processId = sinkInput.properties['application.process.id'];
|
||||||
|
|
||||||
return typeof processId === 'string' && descendantProcessIds.has(processId);
|
if (typeof processId === 'string' && descendantProcessInfo.ids.has(processId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processBinary = normalizeProcessBinary(sinkInput.properties['application.process.binary']);
|
||||||
|
|
||||||
|
if (processBinary && descendantProcessInfo.binaryNames.has(processBinary)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicationName = normalizeProcessBinary(sinkInput.properties['application.name']);
|
||||||
|
|
||||||
|
if (applicationName && descendantProcessInfo.binaryNames.has(applicationName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveSinkInputs(
|
async function moveSinkInputs(
|
||||||
@@ -697,31 +828,45 @@ async function listSinkInputDetails(): Promise<SinkInputDetails[]> {
|
|||||||
return entries.filter((entry) => !!entry.sinkIndex);
|
return entries.filter((entry) => !!entry.sinkIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<string>> {
|
async function collectDescendantProcessInfo(rootProcessId: number): Promise<DescendantProcessInfo> {
|
||||||
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid='], {
|
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm='], {
|
||||||
env: process.env
|
env: process.env
|
||||||
});
|
});
|
||||||
const childrenByParentId = new Map<string, string[]>();
|
const childrenByParentId = new Map<string, string[]>();
|
||||||
|
const binaryNameByProcessId = new Map<string, string>();
|
||||||
|
|
||||||
stdout
|
stdout
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.forEach((line) => {
|
.forEach((line) => {
|
||||||
const [pid, ppid] = line.split(/\s+/);
|
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||||
|
|
||||||
if (!pid || !ppid) {
|
if (!match) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
,
|
||||||
|
pid,
|
||||||
|
ppid,
|
||||||
|
command
|
||||||
|
] = match;
|
||||||
const siblings = childrenByParentId.get(ppid) ?? [];
|
const siblings = childrenByParentId.get(ppid) ?? [];
|
||||||
|
|
||||||
siblings.push(pid);
|
siblings.push(pid);
|
||||||
childrenByParentId.set(ppid, siblings);
|
childrenByParentId.set(ppid, siblings);
|
||||||
|
|
||||||
|
const normalizedBinaryName = normalizeProcessBinary(command);
|
||||||
|
|
||||||
|
if (normalizedBinaryName) {
|
||||||
|
binaryNameByProcessId.set(pid, normalizedBinaryName);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const rootId = `${rootProcessId}`;
|
const rootId = `${rootProcessId}`;
|
||||||
const descendantIds = new Set<string>([rootId]);
|
const descendantIds = new Set<string>([rootId]);
|
||||||
|
const descendantBinaryNames = new Set<string>();
|
||||||
const queue = [rootId];
|
const queue = [rootId];
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
@@ -731,6 +876,12 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const binaryName = binaryNameByProcessId.get(currentId);
|
||||||
|
|
||||||
|
if (binaryName) {
|
||||||
|
descendantBinaryNames.add(binaryName);
|
||||||
|
}
|
||||||
|
|
||||||
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
||||||
if (descendantIds.has(childId)) {
|
if (descendantIds.has(childId)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -741,7 +892,30 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return descendantIds;
|
return {
|
||||||
|
ids: descendantIds,
|
||||||
|
binaryNames: descendantBinaryNames
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcessBinary(value: string | undefined): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basename = trimmed
|
||||||
|
.split(/[\\/]/)
|
||||||
|
.pop()
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
return basename || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripSurroundingQuotes(value: string): string {
|
function stripSurroundingQuotes(value: string): string {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
|||||||
topic: room.topic ?? null,
|
topic: room.topic ?? null,
|
||||||
hostId: room.hostId,
|
hostId: room.hostId,
|
||||||
password: room.password ?? null,
|
password: room.password ?? null,
|
||||||
|
hasPassword: room.hasPassword ? 1 : 0,
|
||||||
isPrivate: room.isPrivate ? 1 : 0,
|
isPrivate: room.isPrivate ? 1 : 0,
|
||||||
createdAt: room.createdAt,
|
createdAt: room.createdAt,
|
||||||
userCount: room.userCount ?? 0,
|
userCount: room.userCount ?? 0,
|
||||||
@@ -20,7 +21,10 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
|||||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||||
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
||||||
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
||||||
members: room.members != null ? JSON.stringify(room.members) : null
|
members: room.members != null ? JSON.stringify(room.members) : null,
|
||||||
|
sourceId: room.sourceId ?? null,
|
||||||
|
sourceName: room.sourceName ?? null,
|
||||||
|
sourceUrl: room.sourceUrl ?? null
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
await repo.save(entity);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from './utils/applyUpdates';
|
} from './utils/applyUpdates';
|
||||||
|
|
||||||
const ROOM_TRANSFORMS: TransformMap = {
|
const ROOM_TRANSFORMS: TransformMap = {
|
||||||
|
hasPassword: boolToInt,
|
||||||
isPrivate: boolToInt,
|
isPrivate: boolToInt,
|
||||||
userCount: (val) => (val ?? 0),
|
userCount: (val) => (val ?? 0),
|
||||||
permissions: jsonOrNull,
|
permissions: jsonOrNull,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function rowToRoom(row: RoomEntity) {
|
|||||||
topic: row.topic ?? undefined,
|
topic: row.topic ?? undefined,
|
||||||
hostId: row.hostId,
|
hostId: row.hostId,
|
||||||
password: row.password ?? undefined,
|
password: row.password ?? undefined,
|
||||||
|
hasPassword: !!row.hasPassword,
|
||||||
isPrivate: !!row.isPrivate,
|
isPrivate: !!row.isPrivate,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
userCount: row.userCount,
|
userCount: row.userCount,
|
||||||
@@ -65,7 +66,10 @@ export function rowToRoom(row: RoomEntity) {
|
|||||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||||
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
||||||
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
||||||
members: row.members ? JSON.parse(row.members) : undefined
|
members: row.members ? JSON.parse(row.members) : undefined,
|
||||||
|
sourceId: row.sourceId ?? undefined,
|
||||||
|
sourceName: row.sourceName ?? undefined,
|
||||||
|
sourceUrl: row.sourceUrl ?? undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,
|
QueryTypeKey,
|
||||||
Query,
|
Query,
|
||||||
GetMessagesQuery,
|
GetMessagesQuery,
|
||||||
|
GetMessagesSinceQuery,
|
||||||
GetMessageByIdQuery,
|
GetMessageByIdQuery,
|
||||||
GetReactionsForMessageQuery,
|
GetReactionsForMessageQuery,
|
||||||
GetUserQuery,
|
GetUserQuery,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
GetAttachmentsForMessageQuery
|
GetAttachmentsForMessageQuery
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { handleGetMessages } from './handlers/getMessages';
|
import { handleGetMessages } from './handlers/getMessages';
|
||||||
|
import { handleGetMessagesSince } from './handlers/getMessagesSince';
|
||||||
import { handleGetMessageById } from './handlers/getMessageById';
|
import { handleGetMessageById } from './handlers/getMessageById';
|
||||||
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
|
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
|
||||||
import { handleGetUser } from './handlers/getUser';
|
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>> => ({
|
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
|
||||||
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
|
[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.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
|
||||||
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
|
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
|
||||||
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
|
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
|
|||||||
|
|
||||||
export const QueryType = {
|
export const QueryType = {
|
||||||
GetMessages: 'get-messages',
|
GetMessages: 'get-messages',
|
||||||
|
GetMessagesSince: 'get-messages-since',
|
||||||
GetMessageById: 'get-message-by-id',
|
GetMessageById: 'get-message-by-id',
|
||||||
GetReactionsForMessage: 'get-reactions-for-message',
|
GetReactionsForMessage: 'get-reactions-for-message',
|
||||||
GetUser: 'get-user',
|
GetUser: 'get-user',
|
||||||
@@ -84,6 +85,7 @@ export interface RoomPayload {
|
|||||||
topic?: string;
|
topic?: string;
|
||||||
hostId: string;
|
hostId: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
isPrivate?: boolean;
|
isPrivate?: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
userCount?: number;
|
userCount?: number;
|
||||||
@@ -93,6 +95,9 @@ export interface RoomPayload {
|
|||||||
permissions?: unknown;
|
permissions?: unknown;
|
||||||
channels?: unknown[];
|
channels?: unknown[];
|
||||||
members?: unknown[];
|
members?: unknown[];
|
||||||
|
sourceId?: string;
|
||||||
|
sourceName?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanPayload {
|
export interface BanPayload {
|
||||||
@@ -156,6 +161,7 @@ export type Command =
|
|||||||
| ClearAllDataCommand;
|
| ClearAllDataCommand;
|
||||||
|
|
||||||
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
|
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 GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
|
||||||
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
||||||
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: 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 =
|
export type Query =
|
||||||
| GetMessagesQuery
|
| GetMessagesQuery
|
||||||
|
| GetMessagesSinceQuery
|
||||||
| GetMessageByIdQuery
|
| GetMessageByIdQuery
|
||||||
| GetReactionsForMessageQuery
|
| GetReactionsForMessageQuery
|
||||||
| GetUserQuery
|
| GetUserQuery
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
|||||||
|
|
||||||
export interface DesktopSettings {
|
export interface DesktopSettings {
|
||||||
autoUpdateMode: AutoUpdateMode;
|
autoUpdateMode: AutoUpdateMode;
|
||||||
|
autoStart: boolean;
|
||||||
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -19,6 +21,8 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
|||||||
|
|
||||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||||
autoUpdateMode: 'auto',
|
autoUpdateMode: 'auto',
|
||||||
|
autoStart: true,
|
||||||
|
closeToTray: true,
|
||||||
hardwareAcceleration: true,
|
hardwareAcceleration: true,
|
||||||
manifestUrls: [],
|
manifestUrls: [],
|
||||||
preferredVersion: null,
|
preferredVersion: null,
|
||||||
@@ -81,6 +85,12 @@ export function readDesktopSettings(): DesktopSettings {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
||||||
|
autoStart: typeof parsed.autoStart === 'boolean'
|
||||||
|
? parsed.autoStart
|
||||||
|
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||||
|
closeToTray: typeof parsed.closeToTray === 'boolean'
|
||||||
|
? parsed.closeToTray
|
||||||
|
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
|
||||||
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
||||||
? parsed.vaapiVideoEncode
|
? parsed.vaapiVideoEncode
|
||||||
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
||||||
@@ -102,6 +112,12 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
|||||||
};
|
};
|
||||||
const nextSettings: DesktopSettings = {
|
const nextSettings: DesktopSettings = {
|
||||||
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
||||||
|
autoStart: typeof mergedSettings.autoStart === 'boolean'
|
||||||
|
? mergedSettings.autoStart
|
||||||
|
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||||
|
closeToTray: typeof mergedSettings.closeToTray === 'boolean'
|
||||||
|
? mergedSettings.closeToTray
|
||||||
|
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
|
||||||
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||||
? mergedSettings.hardwareAcceleration
|
? mergedSettings.hardwareAcceleration
|
||||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export class RoomEntity {
|
|||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
password!: string | null;
|
password!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { default: 0 })
|
||||||
|
hasPassword!: number;
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
isPrivate!: number;
|
isPrivate!: number;
|
||||||
|
|
||||||
@@ -50,4 +53,13 @@ export class RoomEntity {
|
|||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
members!: string | null;
|
members!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
sourceId!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
sourceName!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
sourceUrl!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
dialog,
|
dialog,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
|
Notification,
|
||||||
shell
|
shell
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -28,8 +29,16 @@ import {
|
|||||||
getDesktopUpdateState,
|
getDesktopUpdateState,
|
||||||
handleDesktopSettingsChanged,
|
handleDesktopSettingsChanged,
|
||||||
restartToApplyUpdate,
|
restartToApplyUpdate,
|
||||||
|
readDesktopUpdateServerHealth,
|
||||||
type DesktopUpdateServerContext
|
type DesktopUpdateServerContext
|
||||||
} from '../update/desktop-updater';
|
} 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 DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const FILE_CLIPBOARD_FORMATS = [
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
@@ -83,6 +92,63 @@ interface ClipboardFilePayload {
|
|||||||
path?: string;
|
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 {
|
function isSupportedClipboardFileFormat(format: string): boolean {
|
||||||
return FILE_CLIPBOARD_FORMATS.some(
|
return FILE_CLIPBOARD_FORMATS.some(
|
||||||
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
||||||
@@ -194,6 +260,10 @@ async function readClipboardFiles(): Promise<ClipboardFilePayload[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setupSystemHandlers(): void {
|
export function setupSystemHandlers(): void {
|
||||||
|
ipcMain.on('get-linux-display-server', (event) => {
|
||||||
|
event.returnValue = resolveLinuxDisplayServer();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('open-external', async (_event, url: string) => {
|
ipcMain.handle('open-external', async (_event, url: string) => {
|
||||||
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||||
await shell.openExternal(url);
|
await shell.openExternal(url);
|
||||||
@@ -203,6 +273,8 @@ export function setupSystemHandlers(): void {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
|
||||||
|
|
||||||
ipcMain.handle('get-sources', async () => {
|
ipcMain.handle('get-sources', async () => {
|
||||||
try {
|
try {
|
||||||
const thumbnailSize = { width: 240, height: 150 };
|
const thumbnailSize = { width: 240, height: 150 };
|
||||||
@@ -256,8 +328,75 @@ export function setupSystemHandlers(): void {
|
|||||||
|
|
||||||
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
|
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();
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (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-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>) => {
|
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
|
||||||
return await configureDesktopUpdaterContext(context);
|
return await configureDesktopUpdaterContext(context);
|
||||||
});
|
});
|
||||||
@@ -271,6 +410,8 @@ export function setupSystemHandlers(): void {
|
|||||||
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
||||||
const snapshot = updateDesktopSettings(patch);
|
const snapshot = updateDesktopSettings(patch);
|
||||||
|
|
||||||
|
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||||
|
updateCloseToTraySetting(snapshot.closeToTray);
|
||||||
await handleDesktopSettingsChanged();
|
await handleDesktopSettingsChanged();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
import { initializeDeepLinkHandling } from './app/deep-links';
|
||||||
import { configureAppFlags } from './app/flags';
|
import { configureAppFlags } from './app/flags';
|
||||||
import { registerAppLifecycle } from './app/lifecycle';
|
import { registerAppLifecycle } from './app/lifecycle';
|
||||||
|
|
||||||
configureAppFlags();
|
configureAppFlags();
|
||||||
registerAppLifecycle();
|
|
||||||
|
if (initializeDeepLinkHandling()) {
|
||||||
|
registerAppLifecycle();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddRoomSourceAndPasswordState1000000000002 implements MigrationInterface {
|
||||||
|
name = 'AddRoomSourceAndPasswordState1000000000002';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "hasPassword" INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceId" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceName" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceUrl" TEXT`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE "rooms"
|
||||||
|
SET "hasPassword" = CASE
|
||||||
|
WHEN "password" IS NOT NULL AND TRIM("password") <> '' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceUrl"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceName"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceId"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "hasPassword"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,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_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
||||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
||||||
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||||
|
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||||
|
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||||
|
|
||||||
export interface LinuxScreenShareAudioRoutingInfo {
|
export interface LinuxScreenShareAudioRoutingInfo {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -49,6 +51,12 @@ export interface DesktopUpdateServerContext {
|
|||||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopUpdateServerHealthSnapshot {
|
||||||
|
manifestUrl: string | null;
|
||||||
|
serverVersion: string | null;
|
||||||
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopUpdateState {
|
export interface DesktopUpdateState {
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
availableVersions: string[];
|
availableVersions: string[];
|
||||||
@@ -83,7 +91,35 @@ export interface DesktopUpdateState {
|
|||||||
targetVersion: string | null;
|
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 {
|
export interface ElectronAPI {
|
||||||
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
maximizeWindow: () => void;
|
maximizeWindow: () => void;
|
||||||
closeWindow: () => void;
|
closeWindow: () => void;
|
||||||
@@ -98,27 +134,39 @@ export interface ElectronAPI {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
|
consumePendingDeepLink: () => Promise<string | null>;
|
||||||
getDesktopSettings: () => Promise<{
|
getDesktopSettings: () => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
|
autoStart: boolean;
|
||||||
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
runtimeHardwareAcceleration: boolean;
|
runtimeHardwareAcceleration: boolean;
|
||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
||||||
|
requestWindowAttention: () => Promise<boolean>;
|
||||||
|
clearWindowAttention: () => Promise<boolean>;
|
||||||
|
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
|
||||||
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
||||||
|
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
|
||||||
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||||
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
||||||
restartToApplyUpdate: () => Promise<boolean>;
|
restartToApplyUpdate: () => Promise<boolean>;
|
||||||
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||||
setDesktopSettings: (patch: {
|
setDesktopSettings: (patch: {
|
||||||
autoUpdateMode?: 'auto' | 'off' | 'version';
|
autoUpdateMode?: 'auto' | 'off' | 'version';
|
||||||
|
autoStart?: boolean;
|
||||||
|
closeToTray?: boolean;
|
||||||
hardwareAcceleration?: boolean;
|
hardwareAcceleration?: boolean;
|
||||||
manifestUrls?: string[];
|
manifestUrls?: string[];
|
||||||
preferredVersion?: string | null;
|
preferredVersion?: string | null;
|
||||||
vaapiVideoEncode?: boolean;
|
vaapiVideoEncode?: boolean;
|
||||||
}) => Promise<{
|
}) => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
|
autoStart: boolean;
|
||||||
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -126,6 +174,7 @@ export interface ElectronAPI {
|
|||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
}>;
|
}>;
|
||||||
relaunchApp: () => Promise<boolean>;
|
relaunchApp: () => Promise<boolean>;
|
||||||
|
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||||
readFile: (filePath: string) => Promise<string>;
|
readFile: (filePath: string) => Promise<string>;
|
||||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
@@ -139,6 +188,7 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const electronAPI: ElectronAPI = {
|
const electronAPI: ElectronAPI = {
|
||||||
|
linuxDisplayServer: readLinuxDisplayServer(),
|
||||||
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
||||||
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
||||||
closeWindow: () => ipcRenderer.send('window-close'),
|
closeWindow: () => ipcRenderer.send('window-close'),
|
||||||
@@ -180,8 +230,24 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
|
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
||||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||||
|
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'),
|
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),
|
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
||||||
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
|
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
|
||||||
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
|
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
|
||||||
@@ -198,6 +264,17 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||||
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
||||||
|
onDeepLinkReceived: (listener) => {
|
||||||
|
const wrappedListener = (_event: Electron.IpcRendererEvent, url: string) => {
|
||||||
|
listener(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcRenderer.on(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
||||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ interface ReleaseManifestEntry {
|
|||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ServerHealthResponse {
|
||||||
|
releaseManifestUrl?: string;
|
||||||
|
serverVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface UpdateVersionInfo {
|
interface UpdateVersionInfo {
|
||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
@@ -53,6 +58,12 @@ export interface DesktopUpdateServerContext {
|
|||||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopUpdateServerHealthSnapshot {
|
||||||
|
manifestUrl: string | null;
|
||||||
|
serverVersion: string | null;
|
||||||
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopUpdateState {
|
export interface DesktopUpdateState {
|
||||||
autoUpdateMode: AutoUpdateMode;
|
autoUpdateMode: AutoUpdateMode;
|
||||||
availableVersions: string[];
|
availableVersions: string[];
|
||||||
@@ -78,6 +89,8 @@ export interface DesktopUpdateState {
|
|||||||
|
|
||||||
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
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 currentCheckPromise: Promise<void> | null = null;
|
||||||
let currentContext: DesktopUpdateServerContext = {
|
let currentContext: DesktopUpdateServerContext = {
|
||||||
manifestUrls: [],
|
manifestUrls: [],
|
||||||
@@ -388,6 +401,47 @@ async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest
|
|||||||
return parseReleaseManifest(payload);
|
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 {
|
function formatManifestLoadErrors(errors: string[]): string {
|
||||||
if (errors.length === 0) {
|
if (errors.length === 0) {
|
||||||
return 'No valid release manifest could be loaded.';
|
return 'No valid release manifest could be loaded.';
|
||||||
@@ -724,6 +778,12 @@ export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
|
|||||||
return desktopUpdateState;
|
return desktopUpdateState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readDesktopUpdateServerHealth(
|
||||||
|
serverUrl: string
|
||||||
|
): Promise<DesktopUpdateServerHealthSnapshot> {
|
||||||
|
return await loadServerHealth(serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
export function restartToApplyUpdate(): boolean {
|
export function restartToApplyUpdate(): boolean {
|
||||||
if (!desktopUpdateState.restartRequired) {
|
if (!desktopUpdateState.restartRequired) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -2,13 +2,21 @@ import {
|
|||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
|
Menu,
|
||||||
session,
|
session,
|
||||||
shell
|
shell,
|
||||||
|
Tray
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { readDesktopSettings } from '../desktop-settings';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
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 {
|
function getAssetPath(...segments: string[]): string {
|
||||||
const basePath = app.isPackaged
|
const basePath = app.isPackaged
|
||||||
@@ -38,13 +46,124 @@ export function getDockIconPath(): string | undefined {
|
|||||||
return getExistingAssetPath('macos', '1024x1024.png');
|
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 {
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
return mainWindow;
|
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> {
|
export async function createWindow(): Promise<void> {
|
||||||
const windowIconPath = getWindowIconPath();
|
const windowIconPath = getWindowIconPath();
|
||||||
|
|
||||||
|
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
||||||
|
ensureTray();
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
height: 900,
|
height: 900,
|
||||||
@@ -105,10 +224,46 @@ export async function createWindow(): Promise<void> {
|
|||||||
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
|
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.on('closed', () => {
|
||||||
mainWindow = null;
|
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 }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ module.exports = tseslint.config(
|
|||||||
},
|
},
|
||||||
// HTML template formatting rules (external Angular templates only)
|
// HTML template formatting rules (external Angular templates only)
|
||||||
{
|
{
|
||||||
files: ['src/app/**/*.html'],
|
files: ['toju-app/src/app/**/*.html'],
|
||||||
plugins: { 'no-dashes': noDashPlugin },
|
plugins: { 'no-dashes': noDashPlugin },
|
||||||
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
||||||
rules: {
|
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/cli": "^0.0.1-alpha.589",
|
||||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||||
|
"auto-launch": "^5.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
@@ -45,11 +46,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.0.4",
|
"@angular/build": "^21.0.4",
|
||||||
"@angular/cli": "^21.2.1",
|
"@angular/cli": "^21.0.4",
|
||||||
"@angular/compiler-cli": "^21.0.0",
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
|
"@types/auto-launch": "^5.0.5",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -10816,6 +10818,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/auto-launch": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/auto-launch/-/auto-launch-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-/nGvQZSzM/pvCMCh4Gt2kIeiUmOP/cKGJbjlInI+A+5MoV/7XmT56DJ6EU8bqc3+ItxEe4UC2GVspmPzcCc8cg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -12875,6 +12884,11 @@
|
|||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/applescript": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/applescript/-/applescript-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ=="
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -12968,6 +12982,22 @@
|
|||||||
"node": ">= 4.0.0"
|
"node": ">= 4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/auto-launch": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/auto-launch/-/auto-launch-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-OgxiAm4q9EBf9EeXdPBiVNENaWE3jUZofwrhAkWjHDYGezu1k3FRZHU8V2FBxGuSJOHzKmTJEd0G7L7/0xDGFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"applescript": "^1.0.0",
|
||||||
|
"mkdirp": "^0.5.1",
|
||||||
|
"path-is-absolute": "^1.0.0",
|
||||||
|
"untildify": "^3.0.2",
|
||||||
|
"winreg": "1.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.23",
|
"version": "10.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||||
@@ -22285,9 +22315,7 @@
|
|||||||
"version": "0.5.6",
|
"version": "0.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.6"
|
"minimist": "^1.2.6"
|
||||||
},
|
},
|
||||||
@@ -23745,7 +23773,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -29571,6 +29598,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/untildify": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/upath": {
|
"node_modules/upath": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
|
||||||
@@ -31161,6 +31197,12 @@
|
|||||||
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/winreg": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/winreg/-/winreg-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-IHpzORub7kYlb8A43Iig3reOvlcBJGX9gZ0WycHhghHtA65X0LYnMRuJs+aH1abVnMJztQkvQNlltnbPi5aGIA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
34
package.json
34
package.json
@@ -7,24 +7,24 @@
|
|||||||
"homepage": "https://git.azaaxin.com/myxelium/Toju",
|
"homepage": "https://git.azaaxin.com/myxelium/Toju",
|
||||||
"main": "dist/electron/main.js",
|
"main": "dist/electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "cd \"toju-app\" && ng",
|
||||||
"prebuild": "npm run bundle:rnnoise",
|
"prebuild": "npm run bundle:rnnoise",
|
||||||
"prestart": "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",
|
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js",
|
||||||
"start": "ng serve",
|
"start": "cd \"toju-app\" && ng serve",
|
||||||
"build": "ng build",
|
"build": "cd \"toju-app\" && ng build",
|
||||||
"build:electron": "tsc -p tsconfig.electron.json",
|
"build:electron": "tsc -p tsconfig.electron.json",
|
||||||
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
||||||
"build:prod": "ng build --configuration production --base-href='./'",
|
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "cd \"toju-app\" && ng test",
|
||||||
"server:build": "cd server && npm run build",
|
"server:build": "cd server && npm run build",
|
||||||
"server:start": "cd server && npm start",
|
"server:start": "cd server && npm start",
|
||||||
"server:dev": "cd server && npm run dev",
|
"server:dev": "cd server && npm run dev",
|
||||||
"electron": "ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
|
"electron": "npm run build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
|
||||||
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --no-sandbox --disable-dev-shm-usage\"",
|
"electron:dev": "concurrently \"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": "./dev.sh",
|
||||||
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron . --no-sandbox --disable-dev-shm-usage\"",
|
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
||||||
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
||||||
"migration:create": "typeorm migration:create electron/migrations/New",
|
"migration:create": "typeorm migration:create electron/migrations/New",
|
||||||
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
|
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
|
||||||
@@ -40,8 +40,8 @@
|
|||||||
"dev:app": "npm run electron:dev",
|
"dev:app": "npm run electron:dev",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
||||||
"format": "prettier --write \"src/app/**/*.html\"",
|
"format": "prettier --write \"toju-app/src/app/**/*.html\"",
|
||||||
"format:check": "prettier --check \"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: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:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win",
|
||||||
"release:manifest": "node tools/generate-release-manifest.js",
|
"release:manifest": "node tools/generate-release-manifest.js",
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||||
|
"auto-launch": "^5.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
|
"@types/auto-launch": "^5.0.5",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -120,6 +122,14 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"appId": "com.metoyou.app",
|
"appId": "com.metoyou.app",
|
||||||
"productName": "MetoYou",
|
"productName": "MetoYou",
|
||||||
|
"protocols": [
|
||||||
|
{
|
||||||
|
"name": "Toju Invite Links",
|
||||||
|
"schemes": [
|
||||||
|
"toju"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron"
|
"output": "dist-electron"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
@@ -5,6 +5,7 @@ import { registerRoutes } from './routes';
|
|||||||
export function createApp(): express.Express {
|
export function createApp(): express.Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
app.set('trust proxy', true);
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { resolveRuntimePath } from '../runtime-paths';
|
import { resolveRuntimePath } from '../runtime-paths';
|
||||||
|
|
||||||
|
export type ServerHttpProtocol = 'http' | 'https';
|
||||||
|
|
||||||
export interface ServerVariablesConfig {
|
export interface ServerVariablesConfig {
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
releaseManifestUrl: string;
|
releaseManifestUrl: string;
|
||||||
|
serverPort: number;
|
||||||
|
serverProtocol: ServerHttpProtocol;
|
||||||
|
serverHost: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATA_DIR = resolveRuntimePath('data');
|
const DATA_DIR = resolveRuntimePath('data');
|
||||||
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
||||||
|
const DEFAULT_SERVER_PORT = 3001;
|
||||||
|
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
||||||
|
|
||||||
function normalizeKlipyApiKey(value: unknown): string {
|
function normalizeKlipyApiKey(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -18,6 +25,51 @@ function normalizeReleaseManifestUrl(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeServerHost(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerProtocol(
|
||||||
|
value: unknown,
|
||||||
|
fallback: ServerHttpProtocol = DEFAULT_SERVER_PROTOCOL
|
||||||
|
): ServerHttpProtocol {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? 'https' : 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (normalized === 'https' || normalized === 'true') {
|
||||||
|
return 'https';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === 'http' || normalized === 'false') {
|
||||||
|
return 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): number {
|
||||||
|
const parsed = typeof value === 'number'
|
||||||
|
? value
|
||||||
|
: typeof value === 'string'
|
||||||
|
? Number.parseInt(value.trim(), 10)
|
||||||
|
: Number.NaN;
|
||||||
|
|
||||||
|
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535
|
||||||
|
? parsed
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||||
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
|
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
|
||||||
if (!fs.existsSync(VARIABLES_FILE)) {
|
if (!fs.existsSync(VARIABLES_FILE)) {
|
||||||
return { rawContents: '', parsed: {} };
|
return { rawContents: '', parsed: {} };
|
||||||
@@ -52,10 +104,14 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { rawContents, parsed } = readRawVariables();
|
const { rawContents, parsed } = readRawVariables();
|
||||||
|
const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed;
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...parsed,
|
...remainingParsed,
|
||||||
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey),
|
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
||||||
releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl)
|
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||||
|
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||||
|
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||||
|
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress)
|
||||||
};
|
};
|
||||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||||
|
|
||||||
@@ -65,7 +121,10 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
klipyApiKey: normalized.klipyApiKey,
|
klipyApiKey: normalized.klipyApiKey,
|
||||||
releaseManifestUrl: normalized.releaseManifestUrl
|
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||||
|
serverPort: normalized.serverPort,
|
||||||
|
serverProtocol: normalized.serverProtocol,
|
||||||
|
serverHost: normalized.serverHost
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,3 +143,29 @@ export function hasKlipyApiKey(): boolean {
|
|||||||
export function getReleaseManifestUrl(): string {
|
export function getReleaseManifestUrl(): string {
|
||||||
return getVariablesConfig().releaseManifestUrl;
|
return getVariablesConfig().releaseManifestUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getServerProtocol(): ServerHttpProtocol {
|
||||||
|
if (hasEnvironmentOverride(process.env.SSL)) {
|
||||||
|
return normalizeServerProtocol(process.env.SSL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVariablesConfig().serverProtocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerPort(): number {
|
||||||
|
if (hasEnvironmentOverride(process.env.PORT)) {
|
||||||
|
return normalizeServerPort(process.env.PORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVariablesConfig().serverPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerHost(): string | undefined {
|
||||||
|
const serverHost = getVariablesConfig().serverHost;
|
||||||
|
|
||||||
|
return serverHost || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHttpsServerEnabled(): boolean {
|
||||||
|
return getServerProtocol() === 'https';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { ServerEntity, JoinRequestEntity } from '../../../entities';
|
import {
|
||||||
|
ServerEntity,
|
||||||
|
JoinRequestEntity,
|
||||||
|
ServerMembershipEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerBanEntity
|
||||||
|
} from '../../../entities';
|
||||||
import { DeleteServerCommand } from '../../types';
|
import { DeleteServerCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { serverId } = command.payload;
|
const { serverId } = command.payload;
|
||||||
|
|
||||||
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
||||||
|
await dataSource.getRepository(ServerMembershipEntity).delete({ serverId });
|
||||||
|
await dataSource.getRepository(ServerInviteEntity).delete({ serverId });
|
||||||
|
await dataSource.getRepository(ServerBanEntity).delete({ serverId });
|
||||||
await dataSource.getRepository(ServerEntity).delete(serverId);
|
await dataSource.getRepository(ServerEntity).delete(serverId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
|
|||||||
description: server.description ?? null,
|
description: server.description ?? null,
|
||||||
ownerId: server.ownerId,
|
ownerId: server.ownerId,
|
||||||
ownerPublicKey: server.ownerPublicKey,
|
ownerPublicKey: server.ownerPublicKey,
|
||||||
|
passwordHash: server.passwordHash ?? null,
|
||||||
isPrivate: server.isPrivate ? 1 : 0,
|
isPrivate: server.isPrivate ? 1 : 0,
|
||||||
maxUsers: server.maxUsers,
|
maxUsers: server.maxUsers,
|
||||||
currentUsers: server.currentUsers,
|
currentUsers: server.currentUsers,
|
||||||
tags: JSON.stringify(server.tags),
|
tags: JSON.stringify(server.tags),
|
||||||
|
channels: JSON.stringify(server.channels ?? []),
|
||||||
createdAt: server.createdAt,
|
createdAt: server.createdAt,
|
||||||
lastSeen: server.lastSeen
|
lastSeen: server.lastSeen
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,67 @@ import { ServerEntity } from '../entities/ServerEntity';
|
|||||||
import { JoinRequestEntity } from '../entities/JoinRequestEntity';
|
import { JoinRequestEntity } from '../entities/JoinRequestEntity';
|
||||||
import {
|
import {
|
||||||
AuthUserPayload,
|
AuthUserPayload,
|
||||||
|
ServerChannelPayload,
|
||||||
ServerPayload,
|
ServerPayload,
|
||||||
JoinRequestPayload
|
JoinRequestPayload
|
||||||
} from './types';
|
} 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 {
|
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -24,10 +81,13 @@ export function rowToServer(row: ServerEntity): ServerPayload {
|
|||||||
description: row.description ?? undefined,
|
description: row.description ?? undefined,
|
||||||
ownerId: row.ownerId,
|
ownerId: row.ownerId,
|
||||||
ownerPublicKey: row.ownerPublicKey,
|
ownerPublicKey: row.ownerPublicKey,
|
||||||
|
hasPassword: !!row.passwordHash,
|
||||||
|
passwordHash: row.passwordHash ?? undefined,
|
||||||
isPrivate: !!row.isPrivate,
|
isPrivate: !!row.isPrivate,
|
||||||
maxUsers: row.maxUsers,
|
maxUsers: row.maxUsers,
|
||||||
currentUsers: row.currentUsers,
|
currentUsers: row.currentUsers,
|
||||||
tags: JSON.parse(row.tags || '[]'),
|
tags: parseStringArray(row.tags),
|
||||||
|
channels: parseServerChannels(row.channels),
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
lastSeen: row.lastSeen
|
lastSeen: row.lastSeen
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,16 +28,28 @@ export interface AuthUserPayload {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServerChannelType = 'text' | 'voice';
|
||||||
|
|
||||||
|
export interface ServerChannelPayload {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: ServerChannelType;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerPayload {
|
export interface ServerPayload {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
ownerPublicKey: string;
|
ownerPublicKey: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
|
passwordHash?: string | null;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
maxUsers: number;
|
maxUsers: number;
|
||||||
currentUsers: number;
|
currentUsers: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
channels: ServerChannelPayload[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
lastSeen: number;
|
lastSeen: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { DataSource } from 'typeorm';
|
|||||||
import {
|
import {
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
JoinRequestEntity
|
JoinRequestEntity,
|
||||||
|
ServerMembershipEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerBanEntity
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { serverMigrations } from '../migrations';
|
import { serverMigrations } from '../migrations';
|
||||||
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
||||||
@@ -51,7 +54,10 @@ export async function initDatabase(): Promise<void> {
|
|||||||
entities: [
|
entities: [
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
JoinRequestEntity
|
JoinRequestEntity,
|
||||||
|
ServerMembershipEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerBanEntity
|
||||||
],
|
],
|
||||||
migrations: serverMigrations,
|
migrations: serverMigrations,
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
|
|||||||
35
server/src/entities/ServerBanEntity.ts
Normal file
35
server/src/entities/ServerBanEntity.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column,
|
||||||
|
Index
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_bans')
|
||||||
|
export class ServerBanEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
bannedBy!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
displayName!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
reason!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
expiresAt!: number | null;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
createdAt!: number;
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ export class ServerEntity {
|
|||||||
@Column('text')
|
@Column('text')
|
||||||
ownerPublicKey!: string;
|
ownerPublicKey!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
passwordHash!: string | null;
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
isPrivate!: number;
|
isPrivate!: number;
|
||||||
|
|
||||||
@@ -33,6 +36,9 @@ export class ServerEntity {
|
|||||||
@Column('text', { default: '[]' })
|
@Column('text', { default: '[]' })
|
||||||
tags!: string;
|
tags!: string;
|
||||||
|
|
||||||
|
@Column('text', { default: '[]' })
|
||||||
|
channels!: string;
|
||||||
|
|
||||||
@Column('integer')
|
@Column('integer')
|
||||||
createdAt!: number;
|
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 { AuthUserEntity } from './AuthUserEntity';
|
||||||
export { ServerEntity } from './ServerEntity';
|
export { ServerEntity } from './ServerEntity';
|
||||||
export { JoinRequestEntity } from './JoinRequestEntity';
|
export { JoinRequestEntity } from './JoinRequestEntity';
|
||||||
|
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||||
|
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||||
|
export { ServerBanEntity } from './ServerBanEntity';
|
||||||
|
|||||||
@@ -14,23 +14,39 @@ import { deleteStaleJoinRequests } from './cqrs';
|
|||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
import {
|
import {
|
||||||
ensureVariablesConfig,
|
ensureVariablesConfig,
|
||||||
|
getServerHost,
|
||||||
getVariablesConfigPath,
|
getVariablesConfigPath,
|
||||||
hasKlipyApiKey
|
getServerPort,
|
||||||
|
getServerProtocol,
|
||||||
|
ServerHttpProtocol
|
||||||
} from './config/variables';
|
} from './config/variables';
|
||||||
import { setupWebSocket } from './websocket';
|
import { setupWebSocket } from './websocket';
|
||||||
|
|
||||||
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
|
function formatHostForUrl(host: string): string {
|
||||||
const PORT = process.env.PORT || 3001;
|
if (host.startsWith('[') || !host.includes(':')) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
function buildServer(app: ReturnType<typeof createApp>) {
|
return `[${host}]`;
|
||||||
if (USE_SSL) {
|
}
|
||||||
|
|
||||||
|
function getDisplayHost(serverHost: string | undefined): string {
|
||||||
|
if (!serverHost || serverHost === '0.0.0.0' || serverHost === '::') {
|
||||||
|
return 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHttpProtocol) {
|
||||||
|
if (serverProtocol === 'https') {
|
||||||
const certDir = resolveCertificateDirectory();
|
const certDir = resolveCertificateDirectory();
|
||||||
const certFile = path.join(certDir, 'localhost.crt');
|
const certFile = path.join(certDir, 'localhost.crt');
|
||||||
const keyFile = path.join(certDir, 'localhost.key');
|
const keyFile = path.join(certDir, 'localhost.key');
|
||||||
|
|
||||||
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
||||||
console.error(`SSL=true but certs not found in ${certDir}`);
|
console.error(`HTTPS is enabled but certs were not found in ${certDir}`);
|
||||||
console.error('Run ./generate-cert.sh first.');
|
console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,17 +60,31 @@ function buildServer(app: ReturnType<typeof createApp>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
ensureVariablesConfig();
|
const variablesConfig = ensureVariablesConfig();
|
||||||
|
const serverProtocol = getServerProtocol();
|
||||||
|
const serverPort = getServerPort();
|
||||||
|
const serverHost = getServerHost();
|
||||||
|
const bindHostLabel = serverHost || 'default interface';
|
||||||
|
|
||||||
console.log('[Config] Variables loaded from:', getVariablesConfigPath());
|
console.log('[Config] Variables loaded from:', getVariablesConfigPath());
|
||||||
|
|
||||||
if (!hasKlipyApiKey()) {
|
if (
|
||||||
|
variablesConfig.serverProtocol !== serverProtocol
|
||||||
|
|| variablesConfig.serverPort !== serverPort
|
||||||
|
) {
|
||||||
|
console.log(`[Config] Server runtime override active: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Config] Server runtime config: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!variablesConfig.klipyApiKey) {
|
||||||
console.log('[KLIPY] API key not configured. GIF search is disabled.');
|
console.log('[KLIPY] API key not configured. GIF search is disabled.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await initDatabase();
|
await initDatabase();
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const server = buildServer(app);
|
const server = buildServer(app, serverProtocol);
|
||||||
|
|
||||||
setupWebSocket(server);
|
setupWebSocket(server);
|
||||||
|
|
||||||
@@ -64,14 +94,29 @@ async function bootstrap(): Promise<void> {
|
|||||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
const onListening = () => {
|
||||||
const proto = USE_SSL ? 'https' : 'http';
|
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||||
const wsProto = USE_SSL ? 'wss' : 'ws';
|
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
|
||||||
|
const localHostNames = [
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1',
|
||||||
|
'::1'
|
||||||
|
];
|
||||||
|
|
||||||
console.log(`MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`);
|
console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
|
||||||
console.log(` REST API: ${proto}://localhost:${PORT}/api`);
|
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
|
||||||
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
|
console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`);
|
||||||
});
|
|
||||||
|
if (serverProtocol === 'https' && serverHost && !localHostNames.includes(serverHost)) {
|
||||||
|
console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (serverHost) {
|
||||||
|
server.listen(serverPort, serverHost, onListening);
|
||||||
|
} else {
|
||||||
|
server.listen(serverPort, onListening);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap().catch((err) => {
|
bootstrap().catch((err) => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class InitialSchema1000000000000 implements MigrationInterface {
|
|||||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||||
"tags" TEXT NOT NULL DEFAULT '[]',
|
"tags" TEXT NOT NULL DEFAULT '[]',
|
||||||
|
"channels" TEXT NOT NULL DEFAULT '[]',
|
||||||
"createdAt" INTEGER NOT NULL,
|
"createdAt" INTEGER NOT NULL,
|
||||||
"lastSeen" 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 { 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 usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
import joinRequestsRouter from './join-requests';
|
import joinRequestsRouter from './join-requests';
|
||||||
|
import { invitesApiRouter, invitePageRouter } from './invites';
|
||||||
|
|
||||||
export function registerRoutes(app: Express): void {
|
export function registerRoutes(app: Express): void {
|
||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
@@ -12,5 +13,7 @@ export function registerRoutes(app: Express): void {
|
|||||||
app.use('/api', proxyRouter);
|
app.use('/api', proxyRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/servers', serversRouter);
|
app.use('/api/servers', serversRouter);
|
||||||
|
app.use('/api/invites', invitesApiRouter);
|
||||||
app.use('/api/requests', joinRequestsRouter);
|
app.use('/api/requests', joinRequestsRouter);
|
||||||
|
app.use('/invite', invitePageRouter);
|
||||||
}
|
}
|
||||||
|
|||||||
57
server/src/routes/invite-utils.ts
Normal file
57
server/src/routes/invite-utils.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
function buildOrigin(protocol: string, host: string): string {
|
||||||
|
return `${protocol}://${host}`.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function originFromUrl(url: URL): string {
|
||||||
|
return buildOrigin(url.protocol.replace(':', ''), url.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequestOrigin(request: Request): string {
|
||||||
|
const forwardedProtoHeader = request.get('x-forwarded-proto');
|
||||||
|
const forwardedHostHeader = request.get('x-forwarded-host');
|
||||||
|
const protocol = forwardedProtoHeader?.split(',')[0]?.trim() || request.protocol;
|
||||||
|
const host = forwardedHostHeader?.split(',')[0]?.trim() || request.get('host') || 'localhost';
|
||||||
|
|
||||||
|
return buildOrigin(protocol, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveWebAppOrigin(signalOrigin: string): string {
|
||||||
|
const url = new URL(signalOrigin);
|
||||||
|
|
||||||
|
if (url.hostname === 'signal.toju.app' && !url.port) {
|
||||||
|
return 'https://web.toju.app';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.hostname.startsWith('signal.')) {
|
||||||
|
url.hostname = url.hostname.replace(/^signal\./, 'web.');
|
||||||
|
|
||||||
|
if (url.port === '3001') {
|
||||||
|
url.port = '4200';
|
||||||
|
}
|
||||||
|
|
||||||
|
return originFromUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.port === '3001') {
|
||||||
|
url.port = '4200';
|
||||||
|
return originFromUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'https://web.toju.app';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||||
|
return `${signalOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBrowserInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||||
|
const browserOrigin = deriveWebAppOrigin(signalOrigin);
|
||||||
|
|
||||||
|
return `${browserOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAppInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||||
|
return `toju://invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
||||||
|
}
|
||||||
331
server/src/routes/invites.ts
Normal file
331
server/src/routes/invites.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getUserById } from '../cqrs';
|
||||||
|
import { rowToServer } from '../cqrs/mappers';
|
||||||
|
import { ServerPayload } from '../cqrs/types';
|
||||||
|
import { getActiveServerInvite } from '../services/server-access.service';
|
||||||
|
import {
|
||||||
|
buildAppInviteUrl,
|
||||||
|
buildBrowserInviteUrl,
|
||||||
|
buildInviteUrl,
|
||||||
|
getRequestOrigin
|
||||||
|
} from './invite-utils';
|
||||||
|
|
||||||
|
export const invitesApiRouter = Router();
|
||||||
|
export const invitePageRouter = Router();
|
||||||
|
|
||||||
|
async function enrichServer(server: ServerPayload, sourceUrl: string) {
|
||||||
|
const owner = await getUserById(server.ownerId);
|
||||||
|
const { passwordHash, ...publicServer } = server;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...publicServer,
|
||||||
|
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||||
|
ownerName: owner?.displayName,
|
||||||
|
sourceUrl,
|
||||||
|
userCount: server.currentUsers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInvitePage(options: {
|
||||||
|
appUrl?: string;
|
||||||
|
browserUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
inviteUrl?: string;
|
||||||
|
isExpired: boolean;
|
||||||
|
ownerName?: string;
|
||||||
|
serverDescription?: string;
|
||||||
|
serverName: string;
|
||||||
|
}) {
|
||||||
|
const expiryLabel = options.expiresAt
|
||||||
|
? new Date(options.expiresAt).toLocaleString('en-US', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const statusLabel = options.isExpired ? 'Expired' : 'Active';
|
||||||
|
const statusColor = options.isExpired ? '#f87171' : '#4ade80';
|
||||||
|
const buttonOpacity = options.isExpired ? 'opacity:0.5;pointer-events:none;' : '';
|
||||||
|
const errorBlock = options.error
|
||||||
|
? `<div class="notice notice-error">${options.error}</div>`
|
||||||
|
: '';
|
||||||
|
const description = options.serverDescription
|
||||||
|
? `<p class="description">${options.serverDescription}</p>`
|
||||||
|
: '<p class="description">You have been invited to join a Toju server.</p>';
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Invite to ${options.serverName}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #050816;
|
||||||
|
--bg-soft: rgba(11, 18, 42, 0.78);
|
||||||
|
--card: rgba(15, 23, 42, 0.92);
|
||||||
|
--border: rgba(148, 163, 184, 0.18);
|
||||||
|
--text: #f8fafc;
|
||||||
|
--muted: #cbd5e1;
|
||||||
|
--primary: #8b5cf6;
|
||||||
|
--primary-soft: rgba(139, 92, 246, 0.16);
|
||||||
|
--secondary: rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(59, 130, 246, 0.28), transparent 32%),
|
||||||
|
radial-gradient(circle at top right, rgba(139, 92, 246, 0.24), transparent 30%),
|
||||||
|
linear-gradient(180deg, #050816 0%, #0b1120 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
width: min(100%, 760px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
backdrop-filter: blur(22px);
|
||||||
|
box-shadow: 0 30px 90px rgba(15, 23, 42, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 36px 36px 28px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: linear-gradient(180deg, rgba(15, 23, 42, 0.8), rgba(15, 23, 42, 0.55));
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--secondary);
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: ${statusColor};
|
||||||
|
box-shadow: 0 0 0 6px color-mix(in srgb, ${statusColor} 18%, transparent);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 18px 0 10px;
|
||||||
|
font-size: clamp(2rem, 3vw, 3.25rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 28px 36px 36px;
|
||||||
|
}
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
.meta-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--card);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.meta-label {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.meta-value {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.button-primary {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6, #6366f1);
|
||||||
|
box-shadow: 0 18px 36px rgba(99, 102, 241, 0.28);
|
||||||
|
}
|
||||||
|
.button-secondary {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.notice-error {
|
||||||
|
border-color: rgba(248, 113, 113, 0.32);
|
||||||
|
background: rgba(127, 29, 29, 0.18);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 18px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #c4b5fd;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
color: #ddd6fe;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hero, .content { padding-inline: 22px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero">
|
||||||
|
<div class="eyebrow"><span class="status-dot"></span>${statusLabel} invite</div>
|
||||||
|
<h1>Join ${options.serverName}</h1>
|
||||||
|
${description}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content">
|
||||||
|
${errorBlock}
|
||||||
|
<div class="meta-grid">
|
||||||
|
<article class="meta-card">
|
||||||
|
<div class="meta-label">Server</div>
|
||||||
|
<div class="meta-value">${options.serverName}</div>
|
||||||
|
</article>
|
||||||
|
<article class="meta-card">
|
||||||
|
<div class="meta-label">Owner</div>
|
||||||
|
<div class="meta-value">${options.ownerName || 'Unknown'}</div>
|
||||||
|
</article>
|
||||||
|
<article class="meta-card">
|
||||||
|
<div class="meta-label">Expires</div>
|
||||||
|
<div class="meta-value">${expiryLabel || 'Expired'}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions" style="${buttonOpacity}">
|
||||||
|
<a class="button button-primary" href="${options.browserUrl || '#'}">Join in browser</a>
|
||||||
|
<a class="button button-secondary" href="${options.appUrl || '#'}">Open with Toju</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notice">
|
||||||
|
Invite links bypass private and password restrictions, but banned users still cannot join.
|
||||||
|
If Toju is not installed yet, use the desktop button after installing from <a href="https://toju.app/downloads">toju.app/downloads</a>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>Share link: <code>${options.inviteUrl || 'Unavailable'}</code></span>
|
||||||
|
<a href="https://toju.app/downloads">Download Toju</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
invitesApiRouter.get('/:id', async (req, res) => {
|
||||||
|
const signalOrigin = getRequestOrigin(req);
|
||||||
|
const bundle = await getActiveServerInvite(req.params['id']);
|
||||||
|
|
||||||
|
if (!bundle) {
|
||||||
|
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = rowToServer(bundle.server);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: bundle.invite.id,
|
||||||
|
serverId: bundle.invite.serverId,
|
||||||
|
createdAt: bundle.invite.createdAt,
|
||||||
|
expiresAt: bundle.invite.expiresAt,
|
||||||
|
inviteUrl: buildInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
browserUrl: buildBrowserInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
appUrl: buildAppInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
sourceUrl: signalOrigin,
|
||||||
|
createdBy: bundle.invite.createdBy,
|
||||||
|
createdByDisplayName: bundle.invite.createdByDisplayName ?? undefined,
|
||||||
|
isExpired: bundle.invite.expiresAt <= Date.now(),
|
||||||
|
server: await enrichServer(server, signalOrigin)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
invitePageRouter.get('/:id', async (req, res) => {
|
||||||
|
const signalOrigin = getRequestOrigin(req);
|
||||||
|
const bundle = await getActiveServerInvite(req.params['id']);
|
||||||
|
|
||||||
|
if (!bundle) {
|
||||||
|
res.status(404).send(renderInvitePage({
|
||||||
|
error: 'This invite has expired or is no longer available.',
|
||||||
|
isExpired: true,
|
||||||
|
serverName: 'Toju server'
|
||||||
|
}));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = rowToServer(bundle.server);
|
||||||
|
const owner = await getUserById(server.ownerId);
|
||||||
|
|
||||||
|
res.send(renderInvitePage({
|
||||||
|
serverName: server.name,
|
||||||
|
serverDescription: server.description,
|
||||||
|
ownerName: owner?.displayName,
|
||||||
|
expiresAt: bundle.invite.expiresAt,
|
||||||
|
inviteUrl: buildInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
browserUrl: buildBrowserInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
appUrl: buildAppInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
isExpired: bundle.invite.expiresAt <= Date.now()
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -1,29 +1,134 @@
|
|||||||
import { Router } from 'express';
|
import { Response, Router } from 'express';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { ServerPayload, JoinRequestPayload } from '../cqrs/types';
|
import {
|
||||||
|
ServerChannelPayload,
|
||||||
|
ServerPayload
|
||||||
|
} from '../cqrs/types';
|
||||||
import {
|
import {
|
||||||
getAllPublicServers,
|
getAllPublicServers,
|
||||||
getServerById,
|
getServerById,
|
||||||
getUserById,
|
getUserById,
|
||||||
upsertServer,
|
upsertServer,
|
||||||
deleteServer,
|
deleteServer,
|
||||||
createJoinRequest,
|
|
||||||
getPendingRequestsForServer
|
getPendingRequestsForServer
|
||||||
} from '../cqrs';
|
} from '../cqrs';
|
||||||
import { notifyServerOwner } from '../websocket/broadcast';
|
import {
|
||||||
|
banServerUser,
|
||||||
|
buildSignalingUrl,
|
||||||
|
createServerInvite,
|
||||||
|
joinServerWithAccess,
|
||||||
|
leaveServerUser,
|
||||||
|
passwordHashForInput,
|
||||||
|
ServerAccessError,
|
||||||
|
kickServerUser,
|
||||||
|
ensureServerMembership,
|
||||||
|
unbanServerUser
|
||||||
|
} from '../services/server-access.service';
|
||||||
|
import {
|
||||||
|
buildAppInviteUrl,
|
||||||
|
buildBrowserInviteUrl,
|
||||||
|
buildInviteUrl,
|
||||||
|
getRequestOrigin
|
||||||
|
} from './invite-utils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
async function enrichServer(server: ServerPayload) {
|
function normalizeRole(role: unknown): string | null {
|
||||||
|
return typeof role === 'string' ? role.trim().toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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 owner = await getUserById(server.ownerId);
|
||||||
|
const { passwordHash, ...publicServer } = server;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...server,
|
...publicServer,
|
||||||
|
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||||
ownerName: owner?.displayName,
|
ownerName: owner?.displayName,
|
||||||
|
sourceUrl,
|
||||||
userCount: server.currentUsers
|
userCount: server.currentUsers
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendAccessError(error: unknown, res: Response) {
|
||||||
|
if (error instanceof ServerAccessError) {
|
||||||
|
res.status(error.status).json({ error: error.message, errorCode: error.code });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Unhandled server access error:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildInviteResponse(invite: {
|
||||||
|
id: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
createdByDisplayName: string | null;
|
||||||
|
serverId: string;
|
||||||
|
}, server: ServerPayload, signalOrigin: string) {
|
||||||
|
return {
|
||||||
|
id: invite.id,
|
||||||
|
serverId: invite.serverId,
|
||||||
|
createdAt: invite.createdAt,
|
||||||
|
expiresAt: invite.expiresAt,
|
||||||
|
inviteUrl: buildInviteUrl(signalOrigin, invite.id),
|
||||||
|
browserUrl: buildBrowserInviteUrl(signalOrigin, invite.id),
|
||||||
|
appUrl: buildAppInviteUrl(signalOrigin, invite.id),
|
||||||
|
sourceUrl: signalOrigin,
|
||||||
|
createdBy: invite.createdBy,
|
||||||
|
createdByDisplayName: invite.createdByDisplayName ?? undefined,
|
||||||
|
isExpired: invite.expiresAt <= Date.now(),
|
||||||
|
server: await enrichServer(server, signalOrigin)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||||
|
|
||||||
@@ -54,45 +159,254 @@ router.get('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
|
const {
|
||||||
|
id: clientId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
ownerId,
|
||||||
|
ownerPublicKey,
|
||||||
|
isPrivate,
|
||||||
|
maxUsers,
|
||||||
|
password,
|
||||||
|
tags,
|
||||||
|
channels
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
if (!name || !ownerId || !ownerPublicKey)
|
if (!name || !ownerId || !ownerPublicKey)
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
|
||||||
|
const passwordHash = passwordHashForInput(password);
|
||||||
const server: ServerPayload = {
|
const server: ServerPayload = {
|
||||||
id: clientId || uuidv4(),
|
id: clientId || uuidv4(),
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
ownerId,
|
ownerId,
|
||||||
ownerPublicKey,
|
ownerPublicKey,
|
||||||
|
hasPassword: !!passwordHash,
|
||||||
|
passwordHash,
|
||||||
isPrivate: isPrivate ?? false,
|
isPrivate: isPrivate ?? false,
|
||||||
maxUsers: maxUsers ?? 0,
|
maxUsers: maxUsers ?? 0,
|
||||||
currentUsers: 0,
|
currentUsers: 0,
|
||||||
tags: tags ?? [],
|
tags: tags ?? [],
|
||||||
|
channels: normalizeServerChannels(channels),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
lastSeen: Date.now()
|
lastSeen: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
await upsertServer(server);
|
await upsertServer(server);
|
||||||
res.status(201).json(server);
|
await ensureServerMembership(server.id, ownerId);
|
||||||
|
|
||||||
|
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { currentOwnerId, ...updates } = req.body;
|
const {
|
||||||
|
currentOwnerId,
|
||||||
|
actingRole,
|
||||||
|
password,
|
||||||
|
hasPassword: _ignoredHasPassword,
|
||||||
|
passwordHash: _ignoredPasswordHash,
|
||||||
|
channels,
|
||||||
|
...updates
|
||||||
|
} = req.body;
|
||||||
const existing = await getServerById(id);
|
const existing = await getServerById(id);
|
||||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||||
|
const normalizedRole = normalizeRole(actingRole);
|
||||||
|
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
if (existing.ownerId !== authenticatedOwnerId)
|
if (
|
||||||
|
existing.ownerId !== authenticatedOwnerId &&
|
||||||
|
!isAllowedRole(normalizedRole, ['host', 'admin'])
|
||||||
|
) {
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() };
|
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
|
||||||
|
const 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);
|
await upsertServer(server);
|
||||||
res.json(server);
|
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/join', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { userId, password, inviteId } = req.body;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await joinServerWithAccess({
|
||||||
|
serverId,
|
||||||
|
userId: String(userId),
|
||||||
|
password: typeof password === 'string' ? password : undefined,
|
||||||
|
inviteId: typeof inviteId === 'string' ? inviteId : undefined
|
||||||
|
});
|
||||||
|
const origin = getRequestOrigin(req);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
signalingUrl: buildSignalingUrl(origin),
|
||||||
|
joinedBefore: result.joinedBefore,
|
||||||
|
via: result.via,
|
||||||
|
server: await enrichServer(result.server, origin)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sendAccessError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/invites', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { requesterUserId, requesterDisplayName } = req.body;
|
||||||
|
|
||||||
|
if (!requesterUserId) {
|
||||||
|
return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invite = await createServerInvite(
|
||||||
|
serverId,
|
||||||
|
String(requesterUserId),
|
||||||
|
typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(await buildInviteResponse(invite, server, getRequestOrigin(req)));
|
||||||
|
} catch (error) {
|
||||||
|
sendAccessError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/moderation/kick', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { actorUserId, actorRole, targetUserId } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUserId) {
|
||||||
|
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
server.ownerId !== actorUserId &&
|
||||||
|
!isAllowedRole(normalizeRole(actorRole), [
|
||||||
|
'host',
|
||||||
|
'admin',
|
||||||
|
'moderator'
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await kickServerUser(serverId, String(targetUserId));
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/moderation/ban', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { actorUserId, actorRole, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUserId) {
|
||||||
|
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
server.ownerId !== actorUserId &&
|
||||||
|
!isAllowedRole(normalizeRole(actorRole), [
|
||||||
|
'host',
|
||||||
|
'admin',
|
||||||
|
'moderator'
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await banServerUser({
|
||||||
|
serverId,
|
||||||
|
userId: String(targetUserId),
|
||||||
|
banId: typeof banId === 'string' ? banId : undefined,
|
||||||
|
bannedBy: String(actorUserId || ''),
|
||||||
|
displayName: typeof displayName === 'string' ? displayName : undefined,
|
||||||
|
reason: typeof reason === 'string' ? reason : undefined,
|
||||||
|
expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/moderation/unban', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { actorUserId, actorRole, banId, targetUserId } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
server.ownerId !== actorUserId &&
|
||||||
|
!isAllowedRole(normalizeRole(actorRole), [
|
||||||
|
'host',
|
||||||
|
'admin',
|
||||||
|
'moderator'
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await unbanServerUser({
|
||||||
|
serverId,
|
||||||
|
banId: typeof banId === 'string' ? banId : undefined,
|
||||||
|
userId: typeof targetUserId === 'string' ? targetUserId : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/leave', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { userId } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await leaveServerUser(serverId, String(userId));
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/heartbeat', async (req, res) => {
|
router.post('/:id/heartbeat', async (req, res) => {
|
||||||
@@ -128,32 +442,6 @@ router.delete('/:id', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/join', async (req, res) => {
|
|
||||||
const { id: serverId } = req.params;
|
|
||||||
const { userId, userPublicKey, displayName } = req.body;
|
|
||||||
const server = await getServerById(serverId);
|
|
||||||
|
|
||||||
if (!server)
|
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
|
||||||
|
|
||||||
const request: JoinRequestPayload = {
|
|
||||||
id: uuidv4(),
|
|
||||||
serverId,
|
|
||||||
userId,
|
|
||||||
userPublicKey,
|
|
||||||
displayName,
|
|
||||||
status: server.isPrivate ? 'pending' : 'approved',
|
|
||||||
createdAt: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
await createJoinRequest(request);
|
|
||||||
|
|
||||||
if (server.isPrivate)
|
|
||||||
notifyServerOwner(server.ownerId, { type: 'join_request', request });
|
|
||||||
|
|
||||||
res.status(201).json(request);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id/requests', async (req, res) => {
|
router.get('/:id/requests', async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { ownerId } = req.query;
|
const { ownerId } = req.query;
|
||||||
@@ -170,4 +458,15 @@ router.get('/:id/requests', async (req, res) => {
|
|||||||
res.json({ requests });
|
res.json({ requests });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const server = await getServerById(id);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
390
server/src/services/server-access.service.ts
Normal file
390
server/src/services/server-access.service.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { getDataSource } from '../db/database';
|
||||||
|
import {
|
||||||
|
ServerBanEntity,
|
||||||
|
ServerEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerMembershipEntity
|
||||||
|
} from '../entities';
|
||||||
|
import { rowToServer } from '../cqrs/mappers';
|
||||||
|
import { ServerPayload } from '../cqrs/types';
|
||||||
|
|
||||||
|
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type JoinAccessVia = 'membership' | 'password' | 'invite' | 'public';
|
||||||
|
|
||||||
|
export interface JoinServerAccessResult {
|
||||||
|
joinedBefore: boolean;
|
||||||
|
server: ServerPayload;
|
||||||
|
via: JoinAccessVia;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BanServerUserOptions {
|
||||||
|
banId?: string;
|
||||||
|
bannedBy: string;
|
||||||
|
displayName?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
reason?: string;
|
||||||
|
serverId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerAccessError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ServerAccessError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerRepository() {
|
||||||
|
return getDataSource().getRepository(ServerEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMembershipRepository() {
|
||||||
|
return getDataSource().getRepository(ServerMembershipEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInviteRepository() {
|
||||||
|
return getDataSource().getRepository(ServerInviteEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBanRepository() {
|
||||||
|
return getDataSource().getRepository(ServerBanEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePassword(password?: string | null): string | null {
|
||||||
|
const normalized = password?.trim() ?? '';
|
||||||
|
|
||||||
|
return normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isServerOwner(server: ServerEntity, userId: string): boolean {
|
||||||
|
return server.ownerId === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashServerPassword(password: string): string {
|
||||||
|
return crypto.createHash('sha256').update(password)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passwordHashForInput(password?: string | null): string | null {
|
||||||
|
const normalized = normalizePassword(password);
|
||||||
|
|
||||||
|
return normalized ? hashServerPassword(normalized) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSignalingUrl(origin: string): string {
|
||||||
|
return origin.replace(/^http/i, 'ws');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneExpiredServerAccessArtifacts(now: number = Date.now()): Promise<void> {
|
||||||
|
await getInviteRepository()
|
||||||
|
.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.where('expiresAt <= :now', { now })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await getBanRepository()
|
||||||
|
.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.where('expiresAt IS NOT NULL AND expiresAt <= :now', { now })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerRecord(serverId: string): Promise<ServerEntity | null> {
|
||||||
|
return await getServerRepository().findOne({ where: { id: serverId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveServerBan(serverId: string, userId: string): Promise<ServerBanEntity | null> {
|
||||||
|
const banRepo = getBanRepository();
|
||||||
|
const ban = await banRepo.findOne({ where: { serverId, userId } });
|
||||||
|
|
||||||
|
if (!ban)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (ban.expiresAt && ban.expiresAt <= Date.now()) {
|
||||||
|
await banRepo.delete({ id: ban.id });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ban;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isServerUserBanned(serverId: string, userId: string): Promise<boolean> {
|
||||||
|
return !!(await getActiveServerBan(serverId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity | null> {
|
||||||
|
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
||||||
|
const repo = getMembershipRepository();
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = await repo.findOne({ where: { serverId, userId } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.lastAccessAt = now;
|
||||||
|
await repo.save(existing);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = repo.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
serverId,
|
||||||
|
userId,
|
||||||
|
joinedAt: now,
|
||||||
|
lastAccessAt: now
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeServerMembership(serverId: string, userId: string): Promise<void> {
|
||||||
|
await getMembershipRepository().delete({ serverId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertCanCreateInvite(serverId: string, requesterUserId: string): Promise<ServerEntity> {
|
||||||
|
const server = await getServerRecord(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isServerUserBanned(serverId, requesterUserId)) {
|
||||||
|
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot create invites');
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await findServerMembership(serverId, requesterUserId);
|
||||||
|
|
||||||
|
if (server.ownerId !== requesterUserId && !membership) {
|
||||||
|
throw new ServerAccessError(403, 'NOT_MEMBER', 'Only joined users can create invites');
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServerInvite(
|
||||||
|
serverId: string,
|
||||||
|
createdBy: string,
|
||||||
|
createdByDisplayName?: string
|
||||||
|
): Promise<ServerInviteEntity> {
|
||||||
|
await assertCanCreateInvite(serverId, createdBy);
|
||||||
|
|
||||||
|
const repo = getInviteRepository();
|
||||||
|
const now = Date.now();
|
||||||
|
const invite = repo.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
serverId,
|
||||||
|
createdBy,
|
||||||
|
createdByDisplayName: createdByDisplayName ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + SERVER_INVITE_EXPIRY_MS
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(invite);
|
||||||
|
return invite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveServerInvite(
|
||||||
|
inviteId: string
|
||||||
|
): Promise<{ invite: ServerInviteEntity; server: ServerEntity } | null> {
|
||||||
|
await pruneExpiredServerAccessArtifacts();
|
||||||
|
|
||||||
|
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite.expiresAt <= Date.now()) {
|
||||||
|
await getInviteRepository().delete({ id: invite.id });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await getServerRecord(invite.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { invite, server };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinServerWithAccess(options: {
|
||||||
|
inviteId?: string;
|
||||||
|
password?: string;
|
||||||
|
serverId: string;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<JoinServerAccessResult> {
|
||||||
|
await pruneExpiredServerAccessArtifacts();
|
||||||
|
|
||||||
|
const server = await getServerRecord(options.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isServerUserBanned(server.id, options.userId)) {
|
||||||
|
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot join this server');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServerOwner(server, options.userId)) {
|
||||||
|
const existingMembership = await findServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: !!existingMembership,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'membership'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.inviteId) {
|
||||||
|
const inviteBundle = await getActiveServerInvite(options.inviteId);
|
||||||
|
|
||||||
|
if (!inviteBundle || inviteBundle.server.id !== server.id) {
|
||||||
|
throw new ServerAccessError(410, 'INVITE_EXPIRED', 'Invite link has expired or is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMembership = await findServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: !!existingMembership,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'invite'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await findServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
if (membership) {
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: true,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'membership'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.passwordHash) {
|
||||||
|
const passwordHash = passwordHashForInput(options.password);
|
||||||
|
|
||||||
|
if (!passwordHash || passwordHash !== server.passwordHash) {
|
||||||
|
throw new ServerAccessError(403, 'PASSWORD_REQUIRED', 'Password required to join this server');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: false,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'password'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.isPrivate) {
|
||||||
|
throw new ServerAccessError(403, 'PRIVATE_SERVER', 'Private servers require an invite link');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: false,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'public'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authorizeWebSocketJoin(serverId: string, userId: string): Promise<{ allowed: boolean; reason?: string }> {
|
||||||
|
await pruneExpiredServerAccessArtifacts();
|
||||||
|
|
||||||
|
const server = await getServerRecord(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return { allowed: false,
|
||||||
|
reason: 'SERVER_NOT_FOUND' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isServerUserBanned(serverId, userId)) {
|
||||||
|
return { allowed: false,
|
||||||
|
reason: 'BANNED' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServerOwner(server, userId)) {
|
||||||
|
await ensureServerMembership(serverId, userId);
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await findServerMembership(serverId, userId);
|
||||||
|
|
||||||
|
if (membership) {
|
||||||
|
await ensureServerMembership(serverId, userId);
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server.isPrivate && !server.passwordHash) {
|
||||||
|
await ensureServerMembership(serverId, userId);
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: server.isPrivate ? 'PRIVATE_SERVER' : 'PASSWORD_REQUIRED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kickServerUser(serverId: string, userId: string): Promise<void> {
|
||||||
|
await removeServerMembership(serverId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function leaveServerUser(serverId: string, userId: string): Promise<void> {
|
||||||
|
await removeServerMembership(serverId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function banServerUser(options: BanServerUserOptions): Promise<ServerBanEntity> {
|
||||||
|
await removeServerMembership(options.serverId, options.userId);
|
||||||
|
|
||||||
|
const repo = getBanRepository();
|
||||||
|
const existing = await repo.findOne({ where: { serverId: options.serverId, userId: options.userId } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await repo.delete({ id: existing.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = repo.create({
|
||||||
|
id: options.banId ?? uuidv4(),
|
||||||
|
serverId: options.serverId,
|
||||||
|
userId: options.userId,
|
||||||
|
bannedBy: options.bannedBy,
|
||||||
|
displayName: options.displayName ?? null,
|
||||||
|
reason: options.reason ?? null,
|
||||||
|
expiresAt: options.expiresAt ?? null,
|
||||||
|
createdAt: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unbanServerUser(options: { banId?: string; serverId: string; userId?: string }): Promise<void> {
|
||||||
|
const repo = getBanRepository();
|
||||||
|
|
||||||
|
if (options.banId) {
|
||||||
|
await repo.delete({ id: options.banId, serverId: options.serverId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.userId) {
|
||||||
|
await repo.delete({ serverId: options.serverId, userId: options.userId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,59 @@
|
|||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
import { ConnectedUser } from './types';
|
import { ConnectedUser } from './types';
|
||||||
import { broadcastToServer, findUserByOderId } from './broadcast';
|
import { broadcastToServer, findUserByOderId } from './broadcast';
|
||||||
|
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||||
|
|
||||||
interface WsMessage {
|
interface WsMessage {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
||||||
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||||
|
|
||||||
|
return normalized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
/** Sends the current user list for a given server to a single connected user. */
|
/** Sends the current user list for a given server to a single connected user. */
|
||||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||||
const users = Array.from(connectedUsers.values())
|
const users = Array.from(connectedUsers.values())
|
||||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId && cu.displayName)
|
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
|
||||||
.map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' }));
|
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
||||||
|
|
||||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
user.oderId = String(message['oderId'] || connectionId);
|
user.oderId = String(message['oderId'] || connectionId);
|
||||||
user.displayName = String(message['displayName'] || 'Anonymous');
|
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
const sid = String(message['serverId']);
|
const sid = String(message['serverId']);
|
||||||
|
|
||||||
|
if (!sid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const authorization = await authorizeWebSocketJoin(sid, user.oderId);
|
||||||
|
|
||||||
|
if (!authorization.allowed) {
|
||||||
|
user.ws.send(JSON.stringify({
|
||||||
|
type: 'access_denied',
|
||||||
|
serverId: sid,
|
||||||
|
reason: authorization.reason
|
||||||
|
}));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isNew = !user.serverIds.has(sid);
|
const isNew = !user.serverIds.has(sid);
|
||||||
|
|
||||||
user.serverIds.add(sid);
|
user.serverIds.add(sid);
|
||||||
user.viewedServerId = sid;
|
user.viewedServerId = sid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
||||||
|
|
||||||
sendServerUsers(user, sid);
|
sendServerUsers(user, sid);
|
||||||
|
|
||||||
@@ -38,7 +61,7 @@ function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId:
|
|||||||
broadcastToServer(sid, {
|
broadcastToServer(sid, {
|
||||||
type: 'user_joined',
|
type: 'user_joined',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName ?? 'Anonymous',
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
serverId: sid
|
serverId: sid
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
@@ -49,7 +72,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
|
|||||||
|
|
||||||
user.viewedServerId = viewSid;
|
user.viewedServerId = viewSid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`);
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
||||||
|
|
||||||
sendServerUsers(user, viewSid);
|
sendServerUsers(user, viewSid);
|
||||||
}
|
}
|
||||||
@@ -70,8 +93,9 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
|||||||
broadcastToServer(leaveSid, {
|
broadcastToServer(leaveSid, {
|
||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName ?? 'Anonymous',
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
serverId: leaveSid
|
serverId: leaveSid,
|
||||||
|
serverIds: Array.from(user.serverIds)
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,18 +134,22 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
|||||||
|
|
||||||
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||||
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
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)) {
|
if (typingSid && user.serverIds.has(typingSid)) {
|
||||||
broadcastToServer(typingSid, {
|
broadcastToServer(typingSid, {
|
||||||
type: 'user_typing',
|
type: 'user_typing',
|
||||||
serverId: typingSid,
|
serverId: typingSid,
|
||||||
|
channelId,
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName
|
displayName: user.displayName
|
||||||
}, user.oderId);
|
}, 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);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
if (!user)
|
if (!user)
|
||||||
@@ -133,7 +161,7 @@ export function handleWebSocketMessage(connectionId: string, message: WsMessage)
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'join_server':
|
case 'join_server':
|
||||||
handleJoinServer(user, message, connectionId);
|
await handleJoinServer(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'view_server':
|
case 'view_server':
|
||||||
|
|||||||
@@ -9,39 +9,87 @@ import { connectedUsers } from './state';
|
|||||||
import { broadcastToServer } from './broadcast';
|
import { broadcastToServer } from './broadcast';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
|
/** How often to ping all connected clients (ms). */
|
||||||
|
const PING_INTERVAL_MS = 30_000;
|
||||||
|
/** Maximum time a client can go without a pong before we consider it dead (ms). */
|
||||||
|
const PONG_TIMEOUT_MS = 45_000;
|
||||||
|
|
||||||
|
function removeDeadConnection(connectionId: string): void {
|
||||||
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||||
|
|
||||||
|
user.serverIds.forEach((sid) => {
|
||||||
|
broadcastToServer(sid, {
|
||||||
|
type: 'user_left',
|
||||||
|
oderId: user.oderId,
|
||||||
|
displayName: user.displayName,
|
||||||
|
serverId: sid,
|
||||||
|
serverIds: []
|
||||||
|
}, user.oderId);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
user.ws.terminate();
|
||||||
|
} catch {
|
||||||
|
console.warn(`Failed to terminate WebSocket for ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedUsers.delete(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
export function setupWebSocket(server: Server<typeof IncomingMessage, typeof ServerResponse>): void {
|
export function setupWebSocket(server: Server<typeof IncomingMessage, typeof ServerResponse>): void {
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
// Periodically ping all clients and reap dead connections
|
||||||
|
const pingInterval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
connectedUsers.forEach((user, connectionId) => {
|
||||||
|
if (now - user.lastPong > PONG_TIMEOUT_MS) {
|
||||||
|
removeDeadConnection(connectionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.ws.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
user.ws.ping();
|
||||||
|
} catch {
|
||||||
|
console.warn(`Failed to ping client ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, PING_INTERVAL_MS);
|
||||||
|
|
||||||
|
wss.on('close', () => clearInterval(pingInterval));
|
||||||
|
|
||||||
wss.on('connection', (ws: WebSocket) => {
|
wss.on('connection', (ws: WebSocket) => {
|
||||||
const connectionId = uuidv4();
|
const connectionId = uuidv4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
|
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set(), lastPong: now });
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('pong', () => {
|
||||||
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
user.lastPong = Date.now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', async (data) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(data.toString());
|
const message = JSON.parse(data.toString());
|
||||||
|
|
||||||
handleWebSocketMessage(connectionId, message);
|
await handleWebSocketMessage(connectionId, message);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Invalid WebSocket message:', err);
|
console.error('Invalid WebSocket message:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
const user = connectedUsers.get(connectionId);
|
removeDeadConnection(connectionId);
|
||||||
|
|
||||||
if (user) {
|
|
||||||
user.serverIds.forEach((sid) => {
|
|
||||||
broadcastToServer(sid, {
|
|
||||||
type: 'user_left',
|
|
||||||
oderId: user.oderId,
|
|
||||||
displayName: user.displayName,
|
|
||||||
serverId: sid
|
|
||||||
}, user.oderId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedUsers.delete(connectionId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
|
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ export interface ConnectedUser {
|
|||||||
serverIds: Set<string>;
|
serverIds: Set<string>;
|
||||||
viewedServerId?: string;
|
viewedServerId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
/** Timestamp of the last pong received (used to detect dead connections). */
|
||||||
|
lastPong: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "npm",
|
"packageManager": "npm",
|
||||||
@@ -62,27 +62,28 @@
|
|||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss",
|
"src/styles.scss",
|
||||||
"node_modules/prismjs/themes/prism-okaidia.css"
|
"../node_modules/prismjs/themes/prism-okaidia.css"
|
||||||
],
|
],
|
||||||
"scripts": [
|
"scripts": [
|
||||||
"node_modules/prismjs/prism.js",
|
"../node_modules/prismjs/prism.js",
|
||||||
"node_modules/prismjs/components/prism-markup.min.js",
|
"../node_modules/prismjs/components/prism-markup.min.js",
|
||||||
"node_modules/prismjs/components/prism-clike.min.js",
|
"../node_modules/prismjs/components/prism-clike.min.js",
|
||||||
"node_modules/prismjs/components/prism-javascript.min.js",
|
"../node_modules/prismjs/components/prism-javascript.min.js",
|
||||||
"node_modules/prismjs/components/prism-typescript.min.js",
|
"../node_modules/prismjs/components/prism-typescript.min.js",
|
||||||
"node_modules/prismjs/components/prism-css.min.js",
|
"../node_modules/prismjs/components/prism-css.min.js",
|
||||||
"node_modules/prismjs/components/prism-scss.min.js",
|
"../node_modules/prismjs/components/prism-scss.min.js",
|
||||||
"node_modules/prismjs/components/prism-json.min.js",
|
"../node_modules/prismjs/components/prism-json.min.js",
|
||||||
"node_modules/prismjs/components/prism-bash.min.js",
|
"../node_modules/prismjs/components/prism-bash.min.js",
|
||||||
"node_modules/prismjs/components/prism-markdown.min.js",
|
"../node_modules/prismjs/components/prism-markdown.min.js",
|
||||||
"node_modules/prismjs/components/prism-yaml.min.js",
|
"../node_modules/prismjs/components/prism-yaml.min.js",
|
||||||
"node_modules/prismjs/components/prism-python.min.js",
|
"../node_modules/prismjs/components/prism-python.min.js",
|
||||||
"node_modules/prismjs/components/prism-csharp.min.js"
|
"../node_modules/prismjs/components/prism-csharp.min.js"
|
||||||
],
|
],
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"simple-peer",
|
"simple-peer",
|
||||||
"uuid"
|
"uuid"
|
||||||
]
|
],
|
||||||
|
"outputPath": "../dist/client"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "1MB",
|
"maximumWarning": "1MB",
|
||||||
"maximumError": "2MB"
|
"maximumError": "2.1MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"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 { messagesReducer } from './store/messages/messages.reducer';
|
||||||
import { usersReducer } from './store/users/users.reducer';
|
import { usersReducer } from './store/users/users.reducer';
|
||||||
import { roomsReducer } from './store/rooms/rooms.reducer';
|
import { roomsReducer } from './store/rooms/rooms.reducer';
|
||||||
|
import { NotificationsEffects } from './domains/notifications';
|
||||||
import { MessagesEffects } from './store/messages/messages.effects';
|
import { MessagesEffects } from './store/messages/messages.effects';
|
||||||
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
||||||
import { UsersEffects } from './store/users/users.effects';
|
import { UsersEffects } from './store/users/users.effects';
|
||||||
@@ -32,6 +33,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
rooms: roomsReducer
|
rooms: roomsReducer
|
||||||
}),
|
}),
|
||||||
provideEffects([
|
provideEffects([
|
||||||
|
NotificationsEffects,
|
||||||
MessagesEffects,
|
MessagesEffects,
|
||||||
MessagesSyncEffects,
|
MessagesSyncEffects,
|
||||||
UsersEffects,
|
UsersEffects,
|
||||||
@@ -50,47 +50,6 @@
|
|||||||
<app-floating-voice-controls />
|
<app-floating-voice-controls />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (desktopUpdateState().serverBlocked) {
|
|
||||||
<div class="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 px-6 py-10 backdrop-blur-sm">
|
|
||||||
<div class="w-full max-w-xl rounded-2xl border border-red-500/30 bg-card p-6 shadow-2xl">
|
|
||||||
<h2 class="text-xl font-semibold text-foreground">Server update required</h2>
|
|
||||||
<p class="mt-3 text-sm text-muted-foreground">
|
|
||||||
{{ desktopUpdateState().serverBlockMessage || 'The connected server must be updated before this desktop app can continue.' }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-5 grid gap-4 rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
|
|
||||||
<p class="mt-2 text-foreground">{{ desktopUpdateState().serverVersion || 'Not reported' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
|
|
||||||
<p class="mt-2 text-foreground">{{ desktopUpdateState().minimumServerVersion || 'Unknown' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex flex-wrap gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="refreshDesktopUpdateContext()"
|
|
||||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="openNetworkSettings()"
|
|
||||||
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Open network settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Unified Settings Modal -->
|
<!-- Unified Settings Modal -->
|
||||||
<app-settings-modal />
|
<app-settings-modal />
|
||||||
|
|
||||||
@@ -10,17 +10,22 @@ export const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/auth/login/login.component').then((module) => module.LoginComponent)
|
import('./domains/auth/feature/login/login.component').then((module) => module.LoginComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'register',
|
path: 'register',
|
||||||
loadComponent: () =>
|
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',
|
path: 'search',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/server-search/server-search.component').then(
|
import('./domains/server-directory/feature/server-search/server-search.component').then(
|
||||||
(module) => module.ServerSearchComponent
|
(module) => module.ServerSearchComponent
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
inject,
|
inject,
|
||||||
HostListener
|
HostListener
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -13,16 +14,18 @@ import {
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
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 { 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 { TimeSyncService } from './core/services/time-sync.service';
|
||||||
import { VoiceSessionService } from './core/services/voice-session.service';
|
import { VoiceSessionFacade } from './domains/voice-session';
|
||||||
import { ExternalLinkService } from './core/services/external-link.service';
|
import { ExternalLinkService } from './core/platform';
|
||||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
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 { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||||
import { TitleBarComponent } from './features/shell/title-bar.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 { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||||
@@ -50,7 +53,7 @@ import {
|
|||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App implements OnInit {
|
export class App implements OnInit, OnDestroy {
|
||||||
store = inject(Store);
|
store = inject(Store);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
desktopUpdates = inject(DesktopAppUpdateService);
|
desktopUpdates = inject(DesktopAppUpdateService);
|
||||||
@@ -58,11 +61,14 @@ export class App implements OnInit {
|
|||||||
|
|
||||||
private databaseService = inject(DatabaseService);
|
private databaseService = inject(DatabaseService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private servers = inject(ServerDirectoryService);
|
private servers = inject(ServerDirectoryFacade);
|
||||||
|
private notifications = inject(NotificationsFacade);
|
||||||
private settingsModal = inject(SettingsModalService);
|
private settingsModal = inject(SettingsModalService);
|
||||||
private timeSync = inject(TimeSyncService);
|
private timeSync = inject(TimeSyncService);
|
||||||
private voiceSession = inject(VoiceSessionService);
|
private voiceSession = inject(VoiceSessionFacade);
|
||||||
private externalLinks = inject(ExternalLinkService);
|
private externalLinks = inject(ExternalLinkService);
|
||||||
|
private electronBridge = inject(ElectronBridgeService);
|
||||||
|
private deepLinkCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
onGlobalLinkClick(evt: MouseEvent): void {
|
onGlobalLinkClick(evt: MouseEvent): void {
|
||||||
@@ -80,6 +86,10 @@ export class App implements OnInit {
|
|||||||
await this.timeSync.syncWithEndpoint(apiBase);
|
await this.timeSync.syncWithEndpoint(apiBase);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
await this.notifications.initialize();
|
||||||
|
|
||||||
|
await this.setupDesktopDeepLinks();
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.loadRooms());
|
this.store.dispatch(RoomsActions.loadRooms());
|
||||||
@@ -87,8 +97,12 @@ export class App implements OnInit {
|
|||||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
if (this.router.url !== '/login' && this.router.url !== '/register') {
|
if (!this.isPublicRoute(this.router.url)) {
|
||||||
this.router.navigate(['/login']).catch(() => {});
|
this.router.navigate(['/login'], {
|
||||||
|
queryParams: {
|
||||||
|
returnUrl: this.router.url
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
||||||
@@ -116,6 +130,11 @@ export class App implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.deepLinkCleanup?.();
|
||||||
|
this.deepLinkCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
openNetworkSettings(): void {
|
openNetworkSettings(): void {
|
||||||
this.settingsModal.open('network');
|
this.settingsModal.open('network');
|
||||||
}
|
}
|
||||||
@@ -131,4 +150,72 @@ export class App implements OnInit {
|
|||||||
async restartToApplyUpdate(): Promise<void> {
|
async restartToApplyUpdate(): Promise<void> {
|
||||||
await this.desktopUpdates.restartToApplyUpdate();
|
await this.desktopUpdates.restartToApplyUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async setupDesktopDeepLinks(): Promise<void> {
|
||||||
|
const electronApi = this.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_CURRENT_USER_ID = 'metoyou_currentUserId';
|
||||||
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
|
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
|
||||||
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
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_VOICE_SETTINGS = 'metoyou_voice_settings';
|
||||||
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
|
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
|
||||||
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
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 { Injectable, inject } from '@angular/core';
|
||||||
import { PlatformService } from './platform.service';
|
import { ElectronBridgeService } from './electron/electron-bridge.service';
|
||||||
|
|
||||||
interface ExternalLinkElectronApi {
|
|
||||||
openExternal?: (url: string) => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExternalLinkWindow = Window & {
|
|
||||||
electronAPI?: ExternalLinkElectronApi;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens URLs in the system default browser (Electron) or a new tab (browser).
|
* Opens URLs in the system default browser (Electron) or a new tab (browser).
|
||||||
@@ -17,18 +9,21 @@ type ExternalLinkWindow = Window & {
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ExternalLinkService {
|
export class ExternalLinkService {
|
||||||
private platform = inject(PlatformService);
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
/** Open a URL externally. Only http/https URLs are allowed. */
|
/** Open a URL externally. Only http/https URLs are allowed. */
|
||||||
open(url: string): void {
|
open(url: string): void {
|
||||||
if (!url || !(url.startsWith('http://') || url.startsWith('https://')))
|
if (!url || !(url.startsWith('http://') || url.startsWith('https://')))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (this.platform.isElectron) {
|
const electronApi = this.electronBridge.getApi();
|
||||||
(window as ExternalLinkWindow).electronAPI?.openExternal?.(url);
|
|
||||||
} else {
|
if (electronApi) {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
void electronApi.openExternal(url);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,22 +36,19 @@ export class ExternalLinkService {
|
|||||||
if (!target)
|
if (!target)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const href = target.href; // resolved full URL
|
const href = target.href;
|
||||||
|
|
||||||
if (!href)
|
if (!href)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Skip non-navigable URLs
|
|
||||||
if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:'))
|
if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:'))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Skip same-page anchors
|
|
||||||
const rawAttr = target.getAttribute('href');
|
const rawAttr = target.getAttribute('href');
|
||||||
|
|
||||||
if (rawAttr?.startsWith('#'))
|
if (rawAttr?.startsWith('#'))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Skip Angular router links
|
|
||||||
if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link'))
|
if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link'))
|
||||||
return false;
|
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 */
|
/* 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 type { Room, User } from '../../models/index';
|
||||||
import {
|
import {
|
||||||
LOCAL_NETWORK_NODE_ID,
|
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'
|
const subjectNode = direction === 'outbound'
|
||||||
? this.ensureLocalNetworkNode(
|
? this.ensureLocalNetworkNode(
|
||||||
state,
|
state,
|
||||||
@@ -442,12 +442,14 @@ class DebugNetworkSnapshotBuilder {
|
|||||||
this.getPayloadString(payload, 'displayName')
|
this.getPayloadString(payload, 'displayName')
|
||||||
)
|
)
|
||||||
: peerNode;
|
: peerNode;
|
||||||
const isScreenSharing = this.getPayloadBoolean(payload, 'isScreenSharing');
|
const isStreaming = type === 'screen-state'
|
||||||
|
? this.getPayloadBoolean(payload, 'isScreenSharing')
|
||||||
|
: this.getPayloadBoolean(payload, 'isCameraEnabled');
|
||||||
|
|
||||||
if (isScreenSharing !== null) {
|
if (isStreaming !== null) {
|
||||||
subjectNode.isStreaming = isScreenSharing;
|
subjectNode.isStreaming = isStreaming;
|
||||||
|
|
||||||
if (!isScreenSharing)
|
if (!isStreaming)
|
||||||
subjectNode.streams.video = 0;
|
subjectNode.streams.video = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,14 @@ export class DebuggingService {
|
|||||||
|
|
||||||
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
|
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
|
||||||
.trim() || '(empty console call)';
|
.trim() || '(empty console call)';
|
||||||
const consoleMetadata = this.extractConsoleMetadata(rawMessage);
|
// Use only string args for label/message extraction so that
|
||||||
|
// stringified object payloads don't pollute the parsed message.
|
||||||
|
// Object payloads are captured separately via extractConsolePayload.
|
||||||
|
const metadataSource = args
|
||||||
|
.filter((arg): arg is string => typeof arg === 'string')
|
||||||
|
.join(' ')
|
||||||
|
.trim() || rawMessage;
|
||||||
|
const consoleMetadata = this.extractConsoleMetadata(metadataSource);
|
||||||
const payload = this.extractConsolePayload(args);
|
const payload = this.extractConsolePayload(args);
|
||||||
const payloadText = payload === undefined
|
const payloadText = payload === undefined
|
||||||
? null
|
? null
|
||||||
@@ -5,70 +5,17 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PlatformService } from './platform.service';
|
import { PlatformService } from '../platform';
|
||||||
import { ServerDirectoryService, type ServerEndpoint } from './server-directory.service';
|
import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server-directory';
|
||||||
|
import {
|
||||||
type AutoUpdateMode = 'auto' | 'off' | 'version';
|
type AutoUpdateMode,
|
||||||
type DesktopUpdateStatus =
|
type DesktopUpdateServerContext,
|
||||||
| 'idle'
|
type DesktopUpdateServerHealthSnapshot,
|
||||||
| 'disabled'
|
type DesktopUpdateServerVersionStatus,
|
||||||
| 'checking'
|
type DesktopUpdateState,
|
||||||
| 'downloading'
|
type ElectronApi
|
||||||
| 'up-to-date'
|
} from '../platform/electron/electron-api.models';
|
||||||
| 'restart-required'
|
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||||
| '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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerHealthSnapshot {
|
interface ServerHealthSnapshot {
|
||||||
endpointId: string;
|
endpointId: string;
|
||||||
@@ -77,12 +24,7 @@ interface ServerHealthSnapshot {
|
|||||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DesktopUpdateWindow = Window & {
|
|
||||||
electronAPI?: DesktopUpdateElectronApi;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
|
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
|
||||||
const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
|
|
||||||
|
|
||||||
function createInitialState(): DesktopUpdateState {
|
function createInitialState(): DesktopUpdateState {
|
||||||
return {
|
return {
|
||||||
@@ -153,7 +95,8 @@ export class DesktopAppUpdateService {
|
|||||||
readonly state = signal<DesktopUpdateState>(createInitialState());
|
readonly state = signal<DesktopUpdateState>(createInitialState());
|
||||||
|
|
||||||
private injector = inject(Injector);
|
private injector = inject(Injector);
|
||||||
private servers = inject(ServerDirectoryService);
|
private servers = inject(ServerDirectoryFacade);
|
||||||
|
private electronBridge = inject(ElectronBridgeService);
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
private refreshTimerId: number | null = null;
|
private refreshTimerId: number | null = null;
|
||||||
private removeStateListener: (() => void) | null = null;
|
private removeStateListener: (() => void) | null = null;
|
||||||
@@ -344,30 +287,23 @@ export class DesktopAppUpdateService {
|
|||||||
|
|
||||||
private async readServerHealth(endpoint: ServerEndpoint): Promise<ServerHealthSnapshot> {
|
private async readServerHealth(endpoint: ServerEndpoint): Promise<ServerHealthSnapshot> {
|
||||||
const sanitizedServerUrl = endpoint.url.replace(/\/+$/, '');
|
const sanitizedServerUrl = endpoint.url.replace(/\/+$/, '');
|
||||||
|
const api = this.getElectronApi();
|
||||||
|
|
||||||
|
if (!api?.getAutoUpdateServerHealth) {
|
||||||
|
return {
|
||||||
|
endpointId: endpoint.id,
|
||||||
|
manifestUrl: null,
|
||||||
|
serverVersion: null,
|
||||||
|
serverVersionStatus: 'unavailable'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${sanitizedServerUrl}/api/health`, {
|
const payload = await api.getAutoUpdateServerHealth(sanitizedServerUrl);
|
||||||
method: 'GET',
|
|
||||||
signal: AbortSignal.timeout(SERVER_CONTEXT_TIMEOUT_MS)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return {
|
|
||||||
endpointId: endpoint.id,
|
|
||||||
manifestUrl: null,
|
|
||||||
serverVersion: null,
|
|
||||||
serverVersionStatus: 'unavailable'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json() as ServerHealthResponse;
|
|
||||||
const serverVersion = normalizeOptionalString(payload.serverVersion);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
endpointId: endpoint.id,
|
endpointId: endpoint.id,
|
||||||
manifestUrl: normalizeOptionalHttpUrl(payload.releaseManifestUrl),
|
...this.normalizeHealthSnapshot(payload)
|
||||||
serverVersion,
|
|
||||||
serverVersionStatus: serverVersion ? 'reported' : 'missing'
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
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> {
|
private async pushContext(context: Partial<DesktopUpdateServerContext>): Promise<void> {
|
||||||
const api = this.getElectronApi();
|
const api = this.getElectronApi();
|
||||||
|
|
||||||
@@ -393,9 +345,7 @@ export class DesktopAppUpdateService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getElectronApi(): DesktopUpdateElectronApi | null {
|
private getElectronApi(): ElectronApi | null {
|
||||||
return typeof window !== 'undefined'
|
return this.electronBridge.getApi();
|
||||||
? (window as DesktopUpdateWindow).electronAPI ?? null
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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). */
|
/** 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. */
|
/** File extension used for all sound-effect assets. */
|
||||||
const AUDIO_EXT = 'wav';
|
const AUDIO_EXT = 'wav';
|
||||||
/** localStorage key for persisting notification volume. */
|
/** localStorage key for persisting notification volume. */
|
||||||
@@ -36,6 +36,8 @@ export class NotificationAudioService {
|
|||||||
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
|
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
|
||||||
private readonly cache = new Map<AppSound, HTMLAudioElement>();
|
private readonly cache = new Map<AppSound, HTMLAudioElement>();
|
||||||
|
|
||||||
|
private readonly sources = new Map<AppSound, string>();
|
||||||
|
|
||||||
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
||||||
readonly notificationVolume = signal(this.loadVolume());
|
readonly notificationVolume = signal(this.loadVolume());
|
||||||
|
|
||||||
@@ -46,13 +48,22 @@ export class NotificationAudioService {
|
|||||||
/** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */
|
/** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */
|
||||||
private preload(): void {
|
private preload(): void {
|
||||||
for (const sound of Object.values(AppSound)) {
|
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.preload = 'auto';
|
||||||
|
audio.src = src;
|
||||||
|
audio.load();
|
||||||
|
|
||||||
|
this.sources.set(sound, src);
|
||||||
this.cache.set(sound, audio);
|
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. */
|
/** Read persisted volume from localStorage, falling back to the default. */
|
||||||
private loadVolume(): number {
|
private loadVolume(): number {
|
||||||
try {
|
try {
|
||||||
@@ -96,8 +107,9 @@ export class NotificationAudioService {
|
|||||||
*/
|
*/
|
||||||
play(sound: AppSound, volumeOverride?: number): void {
|
play(sound: AppSound, volumeOverride?: number): void {
|
||||||
const cached = this.cache.get(sound);
|
const cached = this.cache.get(sound);
|
||||||
|
const src = this.sources.get(sound);
|
||||||
|
|
||||||
if (!cached)
|
if (!cached || !src)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const vol = volumeOverride ?? this.notificationVolume();
|
const vol = volumeOverride ?? this.notificationVolume();
|
||||||
@@ -105,12 +117,23 @@ export class NotificationAudioService {
|
|||||||
if (vol === 0)
|
if (vol === 0)
|
||||||
return; // skip playback when muted
|
return; // skip playback when muted
|
||||||
|
|
||||||
|
if (cached.readyState === HTMLMediaElement.HAVE_NOTHING) {
|
||||||
|
cached.load();
|
||||||
|
}
|
||||||
|
|
||||||
// Clone so overlapping plays don't cut each other off.
|
// Clone so overlapping plays don't cut each other off.
|
||||||
const clone = cached.cloneNode(true) as HTMLAudioElement;
|
const clone = cached.cloneNode(true) as HTMLAudioElement;
|
||||||
|
|
||||||
|
clone.preload = 'auto';
|
||||||
clone.volume = Math.max(0, Math.min(1, vol));
|
clone.volume = Math.max(0, Math.min(1, vol));
|
||||||
clone.play().catch(() => {
|
clone.play().catch(() => {
|
||||||
/* swallow autoplay errors */
|
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';
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsModalService {
|
export class SettingsModalService {
|
||||||
readonly isOpen = signal(false);
|
readonly isOpen = signal(false);
|
||||||
readonly activePage = signal<SettingsPage>('network');
|
readonly activePage = signal<SettingsPage>('general');
|
||||||
readonly targetServerId = signal<string | null>(null);
|
readonly targetServerId = signal<string | null>(null);
|
||||||
|
|
||||||
open(page: SettingsPage = 'network', serverId?: string): void {
|
open(page: SettingsPage = 'general', serverId?: string): void {
|
||||||
this.activePage.set(page);
|
this.activePage.set(page);
|
||||||
this.targetServerId.set(serverId ?? null);
|
this.targetServerId.set(serverId ?? null);
|
||||||
this.isOpen.set(true);
|
this.isOpen.set(true);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user