Compare commits
52 Commits
v1.0.22
...
ae0ee8fac7
| Author | SHA1 | Date | |
|---|---|---|---|
| ae0ee8fac7 | |||
| 37cac95b38 | |||
| 314a26325f | |||
| 5d7e045764 | |||
| bbb6deb0a2 | |||
| 65b9419869 | |||
| fed270d28d | |||
| 8b6578da3c | |||
| 851d6ae759 | |||
| 1e833ec7f2 | |||
| 64e34ad586 | |||
| e3b23247a9 | |||
| 42ac712571 | |||
| b7d4bf20e3 | |||
| 727059fb52 | |||
| 83694570e3 | |||
| 109402cdd6 | |||
| eb23fd71ec | |||
| 11917f3412 | |||
| 8162e0444a | |||
| 0467a7b612 | |||
| 971a5afb8b | |||
| fe9c1dd1c0 | |||
| 429bb9d8ff | |||
| b5d676fb78 | |||
| aa595c45d8 | |||
| 1c7e535057 | |||
| 8f960be1e9 | |||
| 9a173792a4 | |||
| cb2c0495b9 | |||
| c3ef8e8800 | |||
| c862c2fe03 | |||
| 4faa62864d | |||
| 1cdd1c5d2b | |||
| 141de64767 | |||
| eb987ac672 | |||
| f8fd78d21a | |||
| 150c45c31a | |||
| 00adf39121 | |||
| 2b6e477c9a | |||
| 22d355a522 | |||
| 15c5952e29 | |||
| 781c05294f | |||
| 778e75bef5 | |||
| 7bf37ba510 | |||
| 3c04b5db26 | |||
| 45e0b09af8 | |||
| 106212ef3d | |||
| be465fd297 | |||
| 7b3caa0b61 | |||
| 22f583e6b3 | |||
| 091c7b2601 |
@@ -1,4 +1,5 @@
|
||||
# Toggle SSL for local development (true/false)
|
||||
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
|
||||
# When false: plain HTTP everywhere (only works on localhost)
|
||||
# Overrides server/data/variables.json for local development only
|
||||
SSL=true
|
||||
|
||||
43
.gitea/workflows/deploy-web-apps.yml
Normal file
43
.gitea/workflows/deploy-web-apps.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Deploy Web Apps
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: windows
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: powershell
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Install root dependencies
|
||||
env:
|
||||
NODE_ENV: development
|
||||
run: npm ci
|
||||
|
||||
- name: Install website dependencies
|
||||
env:
|
||||
NODE_ENV: development
|
||||
run: npm ci --prefix website
|
||||
|
||||
- name: Build Toju web app
|
||||
run: npm run build
|
||||
|
||||
- name: Build Toju website
|
||||
run: |
|
||||
Push-Location website
|
||||
npm run build
|
||||
Pop-Location
|
||||
|
||||
- name: Deploy both apps to IIS
|
||||
run: >
|
||||
./tools/deploy-web-apps.ps1
|
||||
-WebsitePort 4341
|
||||
-AppPort 4492
|
||||
@@ -15,10 +15,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
# - name: Setup Node.js
|
||||
# uses: https://github.com/actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: 20
|
||||
|
||||
- name: Publish draft release
|
||||
env:
|
||||
|
||||
@@ -55,6 +55,11 @@ jobs:
|
||||
npm ci
|
||||
cd server && npm ci
|
||||
|
||||
- name: Install zip utility
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y zip
|
||||
|
||||
- name: Set CI release version
|
||||
run: >
|
||||
node tools/set-release-version.js
|
||||
@@ -62,8 +67,10 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: |
|
||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
|
||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
|
||||
cd toju-app
|
||||
npx ng build --configuration production --base-href='./'
|
||||
cd ..
|
||||
npx --package typescript tsc -p tsconfig.electron.json
|
||||
cd server
|
||||
node ../tools/sync-server-build-version.js
|
||||
@@ -73,6 +80,10 @@ jobs:
|
||||
run: |
|
||||
npx electron-builder --linux --publish never
|
||||
node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64
|
||||
rm -f dist-electron/metoyou-client-${{ needs.prepare.outputs.release_version }}.zip
|
||||
rm -f dist-server/metoyou-server-linux-x64-${{ needs.prepare.outputs.release_version }}.zip
|
||||
(cd dist && zip -rq ../dist-electron/metoyou-client-${{ needs.prepare.outputs.release_version }}.zip client)
|
||||
(cd dist-server && zip -q metoyou-server-linux-x64-${{ needs.prepare.outputs.release_version }}.zip metoyou-server-linux-x64 sql-wasm.wasm)
|
||||
|
||||
- name: Upload Linux assets
|
||||
env:
|
||||
@@ -111,8 +122,10 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: |
|
||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
|
||||
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
|
||||
Push-Location "toju-app"
|
||||
npx ng build --configuration production --base-href='./'
|
||||
Pop-Location
|
||||
npx --package typescript tsc -p tsconfig.electron.json
|
||||
Push-Location server
|
||||
node ../tools/sync-server-build-version.js
|
||||
@@ -121,8 +134,68 @@ jobs:
|
||||
|
||||
- name: Build Windows assets
|
||||
run: |
|
||||
npx electron-builder --win --publish never
|
||||
$projectRoot = $PWD.ProviderPath
|
||||
$electronBuilderWorkspace = Join-Path $env:TEMP ([guid]::NewGuid().ToString('N'))
|
||||
$electronBuilderCache = Join-Path $electronBuilderWorkspace 'electron-builder-cache'
|
||||
$electronCache = Join-Path $electronBuilderWorkspace 'electron-cache'
|
||||
$locationPushed = $false
|
||||
|
||||
function Invoke-RoboCopy {
|
||||
param(
|
||||
[string]$Source,
|
||||
[string]$Destination
|
||||
)
|
||||
|
||||
robocopy $Source $Destination /MIR /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
|
||||
if ($LASTEXITCODE -gt 7) {
|
||||
throw "robocopy failed from $Source to $Destination with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
# Stage the packaging inputs into a real short-path directory.
|
||||
# electron-builder rejects junction-backed files during asar creation
|
||||
# because their resolved path sits outside the package root.
|
||||
New-Item -ItemType Directory -Path $electronBuilderWorkspace | Out-Null
|
||||
New-Item -ItemType Directory -Path $electronBuilderCache | Out-Null
|
||||
New-Item -ItemType Directory -Path $electronCache | Out-Null
|
||||
$env:ELECTRON_BUILDER_CACHE = $electronBuilderCache
|
||||
$env:ELECTRON_CACHE = $electronCache
|
||||
|
||||
try {
|
||||
Copy-Item -Path (Join-Path $projectRoot 'package.json') -Destination (Join-Path $electronBuilderWorkspace 'package.json') -Force
|
||||
Copy-Item -Path (Join-Path $projectRoot 'package-lock.json') -Destination (Join-Path $electronBuilderWorkspace 'package-lock.json') -Force
|
||||
Invoke-RoboCopy (Join-Path $projectRoot 'dist') (Join-Path $electronBuilderWorkspace 'dist')
|
||||
Invoke-RoboCopy (Join-Path $projectRoot 'images') (Join-Path $electronBuilderWorkspace 'images')
|
||||
Invoke-RoboCopy (Join-Path $projectRoot 'node_modules') (Join-Path $electronBuilderWorkspace 'node_modules')
|
||||
|
||||
Push-Location $electronBuilderWorkspace
|
||||
$locationPushed = $true
|
||||
npx electron-builder --win --publish never
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "electron-builder failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Invoke-RoboCopy (Join-Path $electronBuilderWorkspace 'dist-electron') (Join-Path $projectRoot 'dist-electron')
|
||||
} finally {
|
||||
if ($locationPushed) {
|
||||
Pop-Location
|
||||
}
|
||||
if (Test-Path $electronBuilderWorkspace) {
|
||||
cmd /c rmdir /s /q "$electronBuilderWorkspace" | Out-Null
|
||||
}
|
||||
}
|
||||
node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe
|
||||
$serverArchivePath = Join-Path $PWD "dist-server/metoyou-server-win-x64-${{ needs.prepare.outputs.release_version }}.zip"
|
||||
if (Test-Path $serverArchivePath) {
|
||||
Remove-Item $serverArchivePath -Force
|
||||
}
|
||||
Push-Location dist-server
|
||||
try {
|
||||
Compress-Archive -Path metoyou-server-win-x64.exe, sql-wasm.wasm -DestinationPath $serverArchivePath -CompressionLevel Optimal
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
- name: Upload Windows assets
|
||||
env:
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -6,7 +6,9 @@
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
*.sqlite
|
||||
*/architecture.md
|
||||
/docs
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
@@ -51,3 +53,6 @@ Thumbs.db
|
||||
.certs/
|
||||
/server/data/variables.json
|
||||
dist-server/*
|
||||
|
||||
AGENTS.md
|
||||
doc/**
|
||||
|
||||
168
README.md
168
README.md
@@ -1,10 +1,14 @@
|
||||
<img src="./images/icon.png" width="100" height="100">
|
||||
|
||||
|
||||
# Toju / Zoracord
|
||||
|
||||
Desktop chat app with three parts:
|
||||
Desktop chat app with four parts:
|
||||
|
||||
- `src/` Angular client
|
||||
- `electron/` desktop shell, IPC, and local database
|
||||
- `server/` directory server, join request API, and websocket events
|
||||
- `website/` Toju website served at toju.app
|
||||
|
||||
## Install
|
||||
|
||||
@@ -17,7 +21,7 @@ Desktop chat app with three parts:
|
||||
Root `.env`:
|
||||
|
||||
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
|
||||
- `PORT=3001` changes the server port
|
||||
- `PORT=3001` changes the server port in local development and overrides the server app setting
|
||||
|
||||
If `SSL=true`, run `./generate-cert.sh` once.
|
||||
|
||||
@@ -25,101 +29,10 @@ Server files:
|
||||
|
||||
- `server/data/variables.json` holds `klipyApiKey`
|
||||
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
|
||||
|
||||
## Desktop auto updates
|
||||
|
||||
The packaged Electron app now reads a hosted release manifest from the active server's `/api/health` response.
|
||||
|
||||
Release flow:
|
||||
|
||||
1. Build the desktop packages with `npm run electron:build` or the platform-specific Electron Builder commands.
|
||||
2. Upload one version folder that contains the generated `latest.yml`, `latest-mac.yml`, `latest-linux.yml`, and the matching installers/artifacts.
|
||||
3. Generate or update the hosted manifest JSON with:
|
||||
|
||||
`npm run release:manifest -- --feed-url https://your-cdn.example.com/metoyou/1.2.3`
|
||||
|
||||
4. Set `releaseManifestUrl` in `server/data/variables.json` to the hosted manifest JSON URL.
|
||||
|
||||
### GitHub / Gitea release assets
|
||||
|
||||
If you publish desktop builds as release assets, use the release download URL as the manifest `feedUrl`.
|
||||
|
||||
Examples:
|
||||
|
||||
- GitHub tag `v1.2.3`:
|
||||
|
||||
`https://github.com/OWNER/REPO/releases/download/v1.2.3`
|
||||
|
||||
- Gitea tag `v1.2.3`:
|
||||
|
||||
`https://gitea.example.com/OWNER/REPO/releases/download/v1.2.3`
|
||||
|
||||
That release must include these assets with their normal Electron Builder names:
|
||||
|
||||
- `latest.yml`
|
||||
- `latest-mac.yml`
|
||||
- `latest-linux.yml`
|
||||
- Windows installer assets (`.exe`, `.blockmap`)
|
||||
- macOS assets (`.dmg`, `.zip`)
|
||||
- Linux assets (`.AppImage`, `.deb`)
|
||||
|
||||
You should also upload `release-manifest.json` as a release asset.
|
||||
|
||||
For a stable manifest URL, point the server at the latest-release asset URL:
|
||||
|
||||
- GitHub:
|
||||
|
||||
`https://github.com/OWNER/REPO/releases/latest/download/release-manifest.json`
|
||||
|
||||
- Gitea: use the equivalent latest-release asset URL if your instance supports it, otherwise publish `release-manifest.json` at a separate stable URL.
|
||||
|
||||
If you want the in-app "Specific version" option to list older releases too, keep one cumulative manifest and merge the previous file when generating the next one:
|
||||
|
||||
`npm run release:manifest -- --existing ./release-manifest.json --feed-url https://github.com/OWNER/REPO/releases/download/v1.2.3 --version 1.2.3`
|
||||
|
||||
The manifest format is:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-03-10T12:00:00.000Z",
|
||||
"minimumServerVersion": "1.0.0",
|
||||
"pollIntervalMinutes": 30,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"feedUrl": "https://your-cdn.example.com/metoyou/1.2.3",
|
||||
"publishedAt": "2026-03-10T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`feedUrl` must point to a directory that contains the Electron Builder update descriptors for Windows, macOS, and Linux.
|
||||
|
||||
### Automated Gitea release queue
|
||||
|
||||
The Gitea workflows in `.gitea/workflows/release-draft.yml` and `.gitea/workflows/publish-draft-release.yml` keep the existing desktop auto-update flow intact.
|
||||
|
||||
On every push to `main` or `master`, the release workflow:
|
||||
|
||||
1. Computes a semver release version from the current `package.json` major/minor version and the workflow run number.
|
||||
2. Builds the Linux and Windows Electron packages.
|
||||
3. Builds standalone server executables for Linux and Windows.
|
||||
4. Downloads the latest published `release-manifest.json`, merges the new release feed URL, and uploads the updated manifest to the draft release.
|
||||
5. Uploads the desktop installers, update descriptors, server executables, and `release-manifest.json` to the matching Gitea release page.
|
||||
|
||||
The draft release uses the standard Gitea download path as its `feedUrl`:
|
||||
|
||||
`https://YOUR_GITEA_HOST/OWNER/REPO/releases/download/vX.Y.Z`
|
||||
|
||||
That means the current desktop auto-updater keeps working without any client-side changes once the draft release is approved and published.
|
||||
|
||||
To enable the workflow:
|
||||
|
||||
- Add a repository secret named `GITEA_RELEASE_TOKEN` with permission to create releases and upload release assets.
|
||||
- Make sure your Gitea runner labels match the workflow `runs-on` values (`linux` and `windows`).
|
||||
- After the draft release is reviewed, publish it either from the Gitea release page or by running the `Publish Draft Release` workflow with the queued release tag.
|
||||
- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to)
|
||||
- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`)
|
||||
- `server/data/variables.json` can now also hold `serverPort` (1-65535)
|
||||
- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP
|
||||
|
||||
## Main commands
|
||||
|
||||
@@ -143,3 +56,64 @@ Inside `server/`:
|
||||
- `npm run dev` starts the server with reload
|
||||
- `npm run build` compiles to `dist/`
|
||||
- `npm run start` runs the compiled server
|
||||
|
||||
# Images
|
||||
<img src="./website/src/images/screenshots/gif.png" width="700" height="400">
|
||||
<img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
|
||||
|
||||
## Main Toju app Structure
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `src/app/` | Main application root |
|
||||
| `src/app/core/` | Core utilities, services, models |
|
||||
| `src/app/domains/` | Domain-driven modules |
|
||||
| `src/app/features/` | UI feature modules |
|
||||
| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) |
|
||||
| `src/app/shared/` | Shared UI components |
|
||||
| `src/app/shared-kernel/` | Shared domain contracts & models |
|
||||
| `src/app/store/` | Global state management |
|
||||
| `src/assets/` | Static assets |
|
||||
| `src/environments/` | Environment configs |
|
||||
|
||||
---
|
||||
|
||||
### Domains
|
||||
|
||||
| Path | Link |
|
||||
|------|------|
|
||||
| Attachment | [app/domains/attachment/README.md](src/app/domains/attachment/README.md) |
|
||||
| Auth | [app/domains/auth/README.md](src/app/domains/auth/README.md) |
|
||||
| Chat | [app/domains/chat/README.md](src/app/domains/chat/README.md) |
|
||||
| Screen Share | [app/domains/screen-share/README.md](src/app/domains/screen-share/README.md) |
|
||||
| Server Directory | [app/domains/server-directory/README.md](src/app/domains/server-directory/README.md) |
|
||||
| Voice Connection | [app/domains/voice-connection/README.md](src/app/domains/voice-connection/README.md) |
|
||||
| Voice Session | [app/domains/voice-session/README.md](src/app/domains/voice-session/README.md) |
|
||||
| Domains Root | [app/domains/README.md](src/app/domains/README.md) |
|
||||
|
||||
---
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Path | Link |
|
||||
|------|------|
|
||||
| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) |
|
||||
| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) |
|
||||
|
||||
---
|
||||
|
||||
### Shared Kernel
|
||||
|
||||
| Path | Link |
|
||||
|------|------|
|
||||
| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) |
|
||||
|
||||
---
|
||||
|
||||
### Entry Points
|
||||
|
||||
| File | Link |
|
||||
|------|------|
|
||||
| Main | [main.ts](src/main.ts) |
|
||||
| Index HTML | [index.html](src/index.html) |
|
||||
| App Root | [app/app.ts](src/app/app.ts) |
|
||||
|
||||
6
dev.sh
6
dev.sh
@@ -20,12 +20,12 @@ if [ "$SSL" = "true" ]; then
|
||||
"$DIR/generate-cert.sh"
|
||||
fi
|
||||
|
||||
NG_SERVE="ng serve --host=0.0.0.0 --ssl --ssl-cert=.certs/localhost.crt --ssl-key=.certs/localhost.key"
|
||||
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key"
|
||||
WAIT_URL="https://localhost:4200"
|
||||
HEALTH_URL="https://localhost:3001/api/health"
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
else
|
||||
NG_SERVE="ng serve --host=0.0.0.0"
|
||||
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
|
||||
WAIT_URL="http://localhost:4200"
|
||||
HEALTH_URL="http://localhost:3001/api/health"
|
||||
fi
|
||||
@@ -33,4 +33,4 @@ fi
|
||||
exec npx concurrently --kill-others \
|
||||
"cd server && npm run dev" \
|
||||
"$NG_SERVE" \
|
||||
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron . --no-sandbox --disable-dev-shm-usage"
|
||||
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage"
|
||||
|
||||
129
electron/app/auto-start.ts
Normal file
129
electron/app/auto-start.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { app } from 'electron';
|
||||
import AutoLaunch from 'auto-launch';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
let autoLauncher: AutoLaunch | null = null;
|
||||
let autoLaunchPath = '';
|
||||
|
||||
const LINUX_AUTO_START_ARGUMENTS = ['--no-sandbox', '%U'];
|
||||
|
||||
function resolveLaunchPath(): string {
|
||||
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
|
||||
const appImagePath = process.platform === 'linux'
|
||||
? String(process.env['APPIMAGE'] || '').trim()
|
||||
: '';
|
||||
|
||||
return appImagePath || process.execPath;
|
||||
}
|
||||
|
||||
function escapeDesktopEntryExecArgument(argument: string): string {
|
||||
const escapedArgument = argument.replace(/(["\\$`])/g, '\\$1');
|
||||
|
||||
return /[\s"]/u.test(argument)
|
||||
? `"${escapedArgument}"`
|
||||
: escapedArgument;
|
||||
}
|
||||
|
||||
function getLinuxAutoStartDesktopEntryPath(launchPath: string): string {
|
||||
return path.join(app.getPath('home'), '.config', 'autostart', `${path.basename(launchPath)}.desktop`);
|
||||
}
|
||||
|
||||
function buildLinuxAutoStartExecLine(launchPath: string): string {
|
||||
return `Exec=${[escapeDesktopEntryExecArgument(launchPath), ...LINUX_AUTO_START_ARGUMENTS].join(' ')}`;
|
||||
}
|
||||
|
||||
function buildLinuxAutoStartDesktopEntry(launchPath: string): string {
|
||||
const appName = path.basename(launchPath);
|
||||
|
||||
return [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Version=1.0',
|
||||
`Name=${appName}`,
|
||||
`Comment=${appName}startup script`,
|
||||
buildLinuxAutoStartExecLine(launchPath),
|
||||
'StartupNotify=false',
|
||||
'Terminal=false'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function synchronizeLinuxAutoStartDesktopEntry(launchPath: string): Promise<void> {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
const desktopEntryPath = getLinuxAutoStartDesktopEntryPath(launchPath);
|
||||
const execLine = buildLinuxAutoStartExecLine(launchPath);
|
||||
|
||||
let currentDesktopEntry = '';
|
||||
|
||||
try {
|
||||
currentDesktopEntry = await fsp.readFile(desktopEntryPath, 'utf8');
|
||||
} catch {
|
||||
// Create the desktop entry if auto-launch did not leave one behind.
|
||||
}
|
||||
|
||||
const nextDesktopEntry = currentDesktopEntry
|
||||
? /^Exec=.*$/m.test(currentDesktopEntry)
|
||||
? currentDesktopEntry.replace(/^Exec=.*$/m, execLine)
|
||||
: `${currentDesktopEntry.trimEnd()}\n${execLine}\n`
|
||||
: buildLinuxAutoStartDesktopEntry(launchPath);
|
||||
|
||||
if (nextDesktopEntry === currentDesktopEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fsp.mkdir(path.dirname(desktopEntryPath), { recursive: true });
|
||||
await fsp.writeFile(desktopEntryPath, nextDesktopEntry, 'utf8');
|
||||
}
|
||||
|
||||
function getAutoLauncher(): AutoLaunch | null {
|
||||
if (!app.isPackaged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!autoLauncher) {
|
||||
autoLaunchPath = resolveLaunchPath();
|
||||
autoLauncher = new AutoLaunch({
|
||||
name: app.getName(),
|
||||
path: autoLaunchPath
|
||||
});
|
||||
}
|
||||
|
||||
return autoLauncher;
|
||||
}
|
||||
|
||||
async function setAutoStartEnabled(enabled: boolean): Promise<void> {
|
||||
const launcher = getAutoLauncher();
|
||||
|
||||
if (!launcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentlyEnabled = await launcher.isEnabled();
|
||||
|
||||
if (!enabled && currentlyEnabled === enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
if (!currentlyEnabled) {
|
||||
await launcher.enable();
|
||||
}
|
||||
|
||||
await synchronizeLinuxAutoStartDesktopEntry(autoLaunchPath);
|
||||
return;
|
||||
}
|
||||
|
||||
await launcher.disable();
|
||||
}
|
||||
|
||||
export async function synchronizeAutoStartSetting(enabled = readDesktopSettings().autoStart): Promise<void> {
|
||||
try {
|
||||
await setAutoStartEnabled(enabled);
|
||||
} catch {
|
||||
// Auto-launch integration should never block app startup or settings saves.
|
||||
}
|
||||
}
|
||||
121
electron/app/deep-links.ts
Normal file
121
electron/app/deep-links.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { createWindow, getMainWindow } from '../window/create-window';
|
||||
|
||||
const CUSTOM_PROTOCOL = 'toju';
|
||||
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
||||
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
||||
|
||||
let pendingDeepLink: string | null = null;
|
||||
|
||||
function resolveDevSingleInstanceExitCode(): number | null {
|
||||
const rawValue = process.env[DEV_SINGLE_INSTANCE_EXIT_CODE_ENV];
|
||||
|
||||
if (!rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0
|
||||
? parsedValue
|
||||
: null;
|
||||
}
|
||||
|
||||
function extractDeepLink(argv: string[]): string | null {
|
||||
return argv.find((argument) => typeof argument === 'string' && argument.startsWith(DEEP_LINK_PREFIX)) || null;
|
||||
}
|
||||
|
||||
function focusMainWindow(): void {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
function forwardDeepLink(url: string): void {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isLoadingMainFrame()) {
|
||||
pendingDeepLink = url;
|
||||
|
||||
if (app.isReady() && (!mainWindow || mainWindow.isDestroyed())) {
|
||||
void createWindow();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
focusMainWindow();
|
||||
mainWindow.webContents.send('deep-link-received', url);
|
||||
}
|
||||
|
||||
function registerProtocolClient(): void {
|
||||
if (process.defaultApp) {
|
||||
const appEntrypoint = process.argv[1];
|
||||
|
||||
if (appEntrypoint) {
|
||||
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL, process.execPath, [path.resolve(appEntrypoint)]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL);
|
||||
}
|
||||
|
||||
export function initializeDeepLinkHandling(): boolean {
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
const devExitCode = resolveDevSingleInstanceExitCode();
|
||||
|
||||
if (devExitCode != null) {
|
||||
app.exit(devExitCode);
|
||||
} else {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
registerProtocolClient();
|
||||
|
||||
const initialDeepLink = extractDeepLink(process.argv);
|
||||
|
||||
if (initialDeepLink) {
|
||||
pendingDeepLink = initialDeepLink;
|
||||
}
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
focusMainWindow();
|
||||
|
||||
const deepLink = extractDeepLink(argv);
|
||||
|
||||
if (deepLink) {
|
||||
forwardDeepLink(deepLink);
|
||||
}
|
||||
});
|
||||
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
forwardDeepLink(url);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function consumePendingDeepLink(): string | null {
|
||||
const deepLink = pendingDeepLink;
|
||||
|
||||
pendingDeepLink = null;
|
||||
|
||||
return deepLink;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
export function configureAppFlags(): void {
|
||||
linuxSpecificFlags();
|
||||
audioFlags();
|
||||
networkFlags();
|
||||
setupGpuEncodingFlags();
|
||||
chromiumFlags();
|
||||
@@ -15,21 +14,40 @@ function chromiumFlags(): void {
|
||||
|
||||
// Suppress Autofill devtools errors
|
||||
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
|
||||
}
|
||||
|
||||
function audioFlags(): void {
|
||||
// Collect all enabled features into a single switch to avoid later calls overwriting earlier ones
|
||||
const enabledFeatures: string[] = [];
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
// Use the new PipeWire-based audio pipeline on Linux for better screen share audio capture support
|
||||
app.commandLine.appendSwitch('enable-features', 'AudioServiceOutOfProcess');
|
||||
// PipeWire-based audio pipeline for screen share audio capture
|
||||
enabledFeatures.push('AudioServiceOutOfProcess');
|
||||
// PipeWire-based screen capture so the xdg-desktop-portal system picker works
|
||||
enabledFeatures.push('WebRTCPipeWireCapturer');
|
||||
}
|
||||
|
||||
const desktopSettings = readDesktopSettings();
|
||||
|
||||
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
|
||||
enabledFeatures.push('VaapiVideoEncode');
|
||||
}
|
||||
|
||||
if (enabledFeatures.length > 0) {
|
||||
app.commandLine.appendSwitch('enable-features', enabledFeatures.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
function linuxSpecificFlags(): void {
|
||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
|
||||
// Chromium chooses the Linux Ozone platform before Electron runs this file.
|
||||
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland
|
||||
// sessions so the browser process selects the correct backend early enough.
|
||||
}
|
||||
|
||||
function networkFlags(): void {
|
||||
@@ -46,11 +64,6 @@ function setupGpuEncodingFlags(): void {
|
||||
app.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
|
||||
// Enable VA-API hardware video encoding on Linux
|
||||
app.commandLine.appendSwitch('enable-features', 'VaapiVideoEncode');
|
||||
}
|
||||
|
||||
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||
app.commandLine.appendSwitch('enable-zero-copy');
|
||||
app.commandLine.appendSwitch('enable-native-gpu-memory-buffers');
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
||||
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
||||
import { synchronizeAutoStartSetting } from './auto-start';
|
||||
import {
|
||||
initializeDatabase,
|
||||
destroyDatabase,
|
||||
getDataSource
|
||||
} from '../db/database';
|
||||
import { createWindow, getDockIconPath } from '../window/create-window';
|
||||
import {
|
||||
createWindow,
|
||||
getDockIconPath,
|
||||
getMainWindow,
|
||||
prepareWindowForAppQuit,
|
||||
showMainWindow
|
||||
} from '../window/create-window';
|
||||
import {
|
||||
setupCqrsHandlers,
|
||||
setupSystemHandlers,
|
||||
@@ -24,12 +31,18 @@ export function registerAppLifecycle(): void {
|
||||
setupCqrsHandlers();
|
||||
setupWindowControlHandlers();
|
||||
setupSystemHandlers();
|
||||
await synchronizeAutoStartSetting();
|
||||
initializeDesktopUpdater();
|
||||
await createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (getMainWindow()) {
|
||||
void showMainWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (BrowserWindow.getAllWindows().length === 0)
|
||||
createWindow();
|
||||
void createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +52,8 @@ export function registerAppLifecycle(): void {
|
||||
});
|
||||
|
||||
app.on('before-quit', async (event) => {
|
||||
prepareWindowForAppQuit();
|
||||
|
||||
if (getDataSource()?.isInitialized) {
|
||||
event.preventDefault();
|
||||
shutdownDesktopUpdater();
|
||||
|
||||
@@ -32,6 +32,11 @@ interface SinkInputDetails extends ShortSinkInputEntry {
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DescendantProcessInfo {
|
||||
ids: ReadonlySet<string>;
|
||||
binaryNames: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface PactlJsonSinkInputEntry {
|
||||
index?: number | string;
|
||||
properties?: Record<string, unknown>;
|
||||
@@ -44,6 +49,7 @@ interface LinuxScreenShareAudioRoutingState {
|
||||
screenShareLoopbackModuleId: string | null;
|
||||
voiceLoopbackModuleId: string | null;
|
||||
rerouteIntervalId: ReturnType<typeof setInterval> | null;
|
||||
subscribeProcess: ChildProcess | null;
|
||||
}
|
||||
|
||||
interface LinuxScreenShareMonitorCaptureState {
|
||||
@@ -77,7 +83,8 @@ const routingState: LinuxScreenShareAudioRoutingState = {
|
||||
restoreSinkName: null,
|
||||
screenShareLoopbackModuleId: null,
|
||||
voiceLoopbackModuleId: null,
|
||||
rerouteIntervalId: null
|
||||
rerouteIntervalId: null,
|
||||
subscribeProcess: null
|
||||
};
|
||||
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
|
||||
captureId: null,
|
||||
@@ -126,12 +133,21 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
||||
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
|
||||
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
|
||||
|
||||
await setDefaultSink(SCREEN_SHARE_SINK_NAME);
|
||||
await moveSinkInputs(SCREEN_SHARE_SINK_NAME, (sinkName) => !!sinkName && sinkName !== SCREEN_SHARE_SINK_NAME && sinkName !== VOICE_SINK_NAME);
|
||||
// Set the default sink to the voice sink so that new app audio
|
||||
// streams (received WebRTC voice) never land on the screenshare
|
||||
// capture sink. This prevents the feedback loop where remote
|
||||
// voice audio was picked up by parec before the reroute interval
|
||||
// could move the stream away.
|
||||
await setDefaultSink(VOICE_SINK_NAME);
|
||||
|
||||
routingState.active = true;
|
||||
await rerouteAppSinkInputsToVoiceSink();
|
||||
|
||||
// Let the combined reroute decide placement for every existing
|
||||
// stream. This avoids briefly shoving the app's own playback to the
|
||||
// screenshare sink before ownership detection can move it back.
|
||||
await rerouteSinkInputs();
|
||||
startSinkInputRerouteLoop();
|
||||
startSubscribeWatcher();
|
||||
|
||||
return buildRoutingInfo(true, true);
|
||||
} catch (error) {
|
||||
@@ -148,6 +164,7 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
||||
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
|
||||
const restoreSinkName = routingState.restoreSinkName;
|
||||
|
||||
stopSubscribeWatcher();
|
||||
stopSinkInputRerouteLoop();
|
||||
await stopLinuxScreenShareMonitorCapture();
|
||||
|
||||
@@ -166,6 +183,7 @@ export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean>
|
||||
routingState.restoreSinkName = null;
|
||||
routingState.screenShareLoopbackModuleId = null;
|
||||
routingState.voiceLoopbackModuleId = null;
|
||||
routingState.subscribeProcess = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -425,34 +443,52 @@ async function setDefaultSink(sinkName: string): Promise<void> {
|
||||
await runPactl('set-default-sink', sinkName);
|
||||
}
|
||||
|
||||
async function rerouteAppSinkInputsToVoiceSink(): Promise<void> {
|
||||
/**
|
||||
* Combined reroute that enforces sink placement in both directions:
|
||||
* - App-owned sink inputs that are NOT on the voice sink are moved there.
|
||||
* - Non-app sink inputs that ARE on the voice sink are moved to the
|
||||
* screenshare sink so they are captured by parec.
|
||||
*
|
||||
* This two-way approach, combined with the voice sink being the PulseAudio
|
||||
* default, ensures that received WebRTC voice audio can never leak into the
|
||||
* screenshare monitor source.
|
||||
*/
|
||||
async function rerouteSinkInputs(): Promise<void> {
|
||||
const [
|
||||
sinks,
|
||||
sinkInputs,
|
||||
descendantProcessIds
|
||||
descendantProcessInfo
|
||||
] = await Promise.all([
|
||||
listSinks(),
|
||||
listSinkInputDetails(),
|
||||
collectDescendantProcessIds(process.pid)
|
||||
collectDescendantProcessInfo(process.pid)
|
||||
]);
|
||||
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
||||
|
||||
await Promise.all(
|
||||
sinkInputs.map(async (sinkInput) => {
|
||||
if (!isAppOwnedSinkInput(sinkInput, descendantProcessIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
||||
const appOwned = isAppOwnedSinkInput(sinkInput, descendantProcessInfo);
|
||||
|
||||
// App-owned streams must stay on the voice sink.
|
||||
if (appOwned && sinkName !== VOICE_SINK_NAME) {
|
||||
try {
|
||||
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
||||
} catch {
|
||||
// Streams can disappear or be recreated while rerouting.
|
||||
}
|
||||
|
||||
if (sinkName === VOICE_SINK_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
||||
} catch {
|
||||
// Streams can disappear or be recreated while rerouting.
|
||||
// Non-app streams sitting on the voice sink should be moved to the
|
||||
// screenshare sink for desktop-audio capture.
|
||||
if (!appOwned && sinkName === VOICE_SINK_NAME) {
|
||||
try {
|
||||
await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME);
|
||||
} catch {
|
||||
// Streams can disappear or be recreated while rerouting.
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -515,7 +551,7 @@ function startSinkInputRerouteLoop(): void {
|
||||
}
|
||||
|
||||
routingState.rerouteIntervalId = setInterval(() => {
|
||||
void rerouteAppSinkInputsToVoiceSink();
|
||||
void rerouteSinkInputs();
|
||||
}, REROUTE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -528,13 +564,108 @@ function stopSinkInputRerouteLoop(): void {
|
||||
routingState.rerouteIntervalId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns `pactl subscribe` to receive PulseAudio events in real time.
|
||||
* When a new or changed sink-input is detected, a reroute is triggered
|
||||
* immediately instead of waiting for the next interval tick. This
|
||||
* drastically reduces the time non-app desktop audio spends on the
|
||||
* voice sink before being moved to the screenshare sink.
|
||||
*/
|
||||
function startSubscribeWatcher(): void {
|
||||
if (routingState.subscribeProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
let proc: ChildProcess;
|
||||
|
||||
try {
|
||||
proc = spawn('pactl', ['subscribe'], {
|
||||
env: process.env,
|
||||
stdio: [
|
||||
'ignore',
|
||||
'pipe',
|
||||
'ignore'
|
||||
]
|
||||
});
|
||||
} catch {
|
||||
// If pactl subscribe fails to spawn, the interval loop still covers us.
|
||||
return;
|
||||
}
|
||||
|
||||
routingState.subscribeProcess = proc;
|
||||
|
||||
let pending = false;
|
||||
|
||||
proc.stdout?.on('data', (chunk: Buffer) => {
|
||||
if (!routingState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = chunk.toString();
|
||||
|
||||
if (/Event '(?:new|change)' on sink-input/.test(text)) {
|
||||
if (!pending) {
|
||||
pending = true;
|
||||
|
||||
// Batch rapid-fire events with a short delay.
|
||||
setTimeout(() => {
|
||||
pending = false;
|
||||
void rerouteSinkInputs();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('close', () => {
|
||||
if (routingState.subscribeProcess === proc) {
|
||||
routingState.subscribeProcess = null;
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', () => {
|
||||
if (routingState.subscribeProcess === proc) {
|
||||
routingState.subscribeProcess = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stopSubscribeWatcher(): void {
|
||||
const proc = routingState.subscribeProcess;
|
||||
|
||||
if (!proc) {
|
||||
return;
|
||||
}
|
||||
|
||||
routingState.subscribeProcess = null;
|
||||
|
||||
if (!proc.killed) {
|
||||
proc.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
function isAppOwnedSinkInput(
|
||||
sinkInput: SinkInputDetails,
|
||||
descendantProcessIds: ReadonlySet<string>
|
||||
descendantProcessInfo: DescendantProcessInfo
|
||||
): boolean {
|
||||
const processId = sinkInput.properties['application.process.id'];
|
||||
|
||||
return typeof processId === 'string' && descendantProcessIds.has(processId);
|
||||
if (typeof processId === 'string' && descendantProcessInfo.ids.has(processId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const processBinary = normalizeProcessBinary(sinkInput.properties['application.process.binary']);
|
||||
|
||||
if (processBinary && descendantProcessInfo.binaryNames.has(processBinary)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const applicationName = normalizeProcessBinary(sinkInput.properties['application.name']);
|
||||
|
||||
if (applicationName && descendantProcessInfo.binaryNames.has(applicationName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function moveSinkInputs(
|
||||
@@ -697,31 +828,45 @@ async function listSinkInputDetails(): Promise<SinkInputDetails[]> {
|
||||
return entries.filter((entry) => !!entry.sinkIndex);
|
||||
}
|
||||
|
||||
async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<string>> {
|
||||
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid='], {
|
||||
async function collectDescendantProcessInfo(rootProcessId: number): Promise<DescendantProcessInfo> {
|
||||
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm='], {
|
||||
env: process.env
|
||||
});
|
||||
const childrenByParentId = new Map<string, string[]>();
|
||||
const binaryNameByProcessId = new Map<string, string>();
|
||||
|
||||
stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const [pid, ppid] = line.split(/\s+/);
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||
|
||||
if (!pid || !ppid) {
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [
|
||||
,
|
||||
pid,
|
||||
ppid,
|
||||
command
|
||||
] = match;
|
||||
const siblings = childrenByParentId.get(ppid) ?? [];
|
||||
|
||||
siblings.push(pid);
|
||||
childrenByParentId.set(ppid, siblings);
|
||||
|
||||
const normalizedBinaryName = normalizeProcessBinary(command);
|
||||
|
||||
if (normalizedBinaryName) {
|
||||
binaryNameByProcessId.set(pid, normalizedBinaryName);
|
||||
}
|
||||
});
|
||||
|
||||
const rootId = `${rootProcessId}`;
|
||||
const descendantIds = new Set<string>([rootId]);
|
||||
const descendantBinaryNames = new Set<string>();
|
||||
const queue = [rootId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
@@ -731,6 +876,12 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
|
||||
continue;
|
||||
}
|
||||
|
||||
const binaryName = binaryNameByProcessId.get(currentId);
|
||||
|
||||
if (binaryName) {
|
||||
descendantBinaryNames.add(binaryName);
|
||||
}
|
||||
|
||||
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
||||
if (descendantIds.has(childId)) {
|
||||
continue;
|
||||
@@ -741,7 +892,30 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
|
||||
}
|
||||
}
|
||||
|
||||
return descendantIds;
|
||||
return {
|
||||
ids: descendantIds,
|
||||
binaryNames: descendantBinaryNames
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProcessBinary(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const basename = trimmed
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.trim()
|
||||
.toLowerCase() ?? '';
|
||||
|
||||
return basename || null;
|
||||
}
|
||||
|
||||
function stripSurroundingQuotes(value: string): string {
|
||||
|
||||
@@ -3,6 +3,11 @@ import {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
@@ -13,6 +18,11 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
|
||||
await dataSource.getRepository(MessageEntity).clear();
|
||||
await dataSource.getRepository(UserEntity).clear();
|
||||
await dataSource.getRepository(RoomEntity).clear();
|
||||
await dataSource.getRepository(RoomChannelEntity).clear();
|
||||
await dataSource.getRepository(RoomMemberEntity).clear();
|
||||
await dataSource.getRepository(RoomRoleEntity).clear();
|
||||
await dataSource.getRepository(RoomUserRoleEntity).clear();
|
||||
await dataSource.getRepository(RoomChannelPermissionEntity).clear();
|
||||
await dataSource.getRepository(ReactionEntity).clear();
|
||||
await dataSource.getRepository(BanEntity).clear();
|
||||
await dataSource.getRepository(AttachmentEntity).clear();
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity, MessageEntity } from '../../../entities';
|
||||
import {
|
||||
RoomChannelPermissionEntity,
|
||||
RoomChannelEntity,
|
||||
RoomEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
MessageEntity
|
||||
} from '../../../entities';
|
||||
import { DeleteRoomCommand } from '../../types';
|
||||
|
||||
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const { roomId } = command.payload;
|
||||
|
||||
await dataSource.getRepository(RoomEntity).delete({ id: roomId });
|
||||
await dataSource.getRepository(MessageEntity).delete({ roomId });
|
||||
await dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(RoomChannelPermissionEntity).delete({ roomId });
|
||||
await manager.getRepository(RoomChannelEntity).delete({ roomId });
|
||||
await manager.getRepository(RoomMemberEntity).delete({ roomId });
|
||||
await manager.getRepository(RoomRoleEntity).delete({ roomId });
|
||||
await manager.getRepository(RoomUserRoleEntity).delete({ roomId });
|
||||
await manager.getRepository(RoomEntity).delete({ id: roomId });
|
||||
await manager.getRepository(MessageEntity).delete({ roomId });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { replaceMessageReactions } from '../../relations';
|
||||
import { SaveMessageCommand } from '../../types';
|
||||
|
||||
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const { message } = command.payload;
|
||||
const entity = repo.create({
|
||||
id: message.id,
|
||||
roomId: message.roomId,
|
||||
channelId: message.channelId ?? null,
|
||||
senderId: message.senderId,
|
||||
senderName: message.senderName,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
editedAt: message.editedAt ?? null,
|
||||
reactions: JSON.stringify(message.reactions ?? []),
|
||||
isDeleted: message.isDeleted ? 1 : 0,
|
||||
replyToId: message.replyToId ?? null
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(MessageEntity);
|
||||
const entity = repo.create({
|
||||
id: message.id,
|
||||
roomId: message.roomId,
|
||||
channelId: message.channelId ?? null,
|
||||
senderId: message.senderId,
|
||||
senderName: message.senderName,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
editedAt: message.editedAt ?? null,
|
||||
isDeleted: message.isDeleted ? 1 : 0,
|
||||
replyToId: message.replyToId ?? null
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await replaceMessageReactions(manager, message.id, message.reactions ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,27 +1,55 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { replaceRoomRelations } from '../../relations';
|
||||
import { SaveRoomCommand } from '../../types';
|
||||
|
||||
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const { room } = command.payload;
|
||||
const entity = repo.create({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description ?? null,
|
||||
topic: room.topic ?? null,
|
||||
hostId: room.hostId,
|
||||
password: room.password ?? null,
|
||||
isPrivate: room.isPrivate ? 1 : 0,
|
||||
createdAt: room.createdAt,
|
||||
userCount: room.userCount ?? 0,
|
||||
maxUsers: room.maxUsers ?? null,
|
||||
icon: room.icon ?? null,
|
||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
||||
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
||||
members: room.members != null ? JSON.stringify(room.members) : null
|
||||
});
|
||||
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
|
||||
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
|
||||
return room.slowModeInterval;
|
||||
}
|
||||
|
||||
await repo.save(entity);
|
||||
const permissions = room.permissions && typeof room.permissions === 'object'
|
||||
? room.permissions as { slowModeInterval?: unknown }
|
||||
: null;
|
||||
|
||||
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
|
||||
? permissions.slowModeInterval
|
||||
: 0;
|
||||
}
|
||||
|
||||
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const { room } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(RoomEntity);
|
||||
const entity = repo.create({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description ?? null,
|
||||
topic: room.topic ?? null,
|
||||
hostId: room.hostId,
|
||||
password: room.password ?? null,
|
||||
hasPassword: room.hasPassword ? 1 : 0,
|
||||
isPrivate: room.isPrivate ? 1 : 0,
|
||||
createdAt: room.createdAt,
|
||||
userCount: room.userCount ?? 0,
|
||||
maxUsers: room.maxUsers ?? null,
|
||||
icon: room.icon ?? null,
|
||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||
slowModeInterval: extractSlowModeInterval(room),
|
||||
sourceId: room.sourceId ?? null,
|
||||
sourceName: room.sourceName ?? null,
|
||||
sourceUrl: room.sourceUrl ?? null
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await replaceRoomRelations(manager, room.id, {
|
||||
channels: room.channels ?? [],
|
||||
members: room.members ?? [],
|
||||
roles: room.roles ?? [],
|
||||
roleAssignments: room.roleAssignments ?? [],
|
||||
channelPermissions: room.channelPermissions ?? [],
|
||||
permissions: room.permissions
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,41 +1,46 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { replaceMessageReactions } from '../../relations';
|
||||
import { UpdateMessageCommand } from '../../types';
|
||||
|
||||
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const { messageId, updates } = command.payload;
|
||||
const existing = await repo.findOne({ where: { id: messageId } });
|
||||
|
||||
if (!existing)
|
||||
return;
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(MessageEntity);
|
||||
const existing = await repo.findOne({ where: { id: messageId } });
|
||||
|
||||
if (updates.channelId !== undefined)
|
||||
existing.channelId = updates.channelId ?? null;
|
||||
if (!existing)
|
||||
return;
|
||||
|
||||
if (updates.senderId !== undefined)
|
||||
existing.senderId = updates.senderId;
|
||||
if (updates.channelId !== undefined)
|
||||
existing.channelId = updates.channelId ?? null;
|
||||
|
||||
if (updates.senderName !== undefined)
|
||||
existing.senderName = updates.senderName;
|
||||
if (updates.senderId !== undefined)
|
||||
existing.senderId = updates.senderId;
|
||||
|
||||
if (updates.content !== undefined)
|
||||
existing.content = updates.content;
|
||||
if (updates.senderName !== undefined)
|
||||
existing.senderName = updates.senderName;
|
||||
|
||||
if (updates.timestamp !== undefined)
|
||||
existing.timestamp = updates.timestamp;
|
||||
if (updates.content !== undefined)
|
||||
existing.content = updates.content;
|
||||
|
||||
if (updates.editedAt !== undefined)
|
||||
existing.editedAt = updates.editedAt ?? null;
|
||||
if (updates.timestamp !== undefined)
|
||||
existing.timestamp = updates.timestamp;
|
||||
|
||||
if (updates.reactions !== undefined)
|
||||
existing.reactions = JSON.stringify(updates.reactions ?? []);
|
||||
if (updates.editedAt !== undefined)
|
||||
existing.editedAt = updates.editedAt ?? null;
|
||||
|
||||
if (updates.isDeleted !== undefined)
|
||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||
if (updates.isDeleted !== undefined)
|
||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||
|
||||
if (updates.replyToId !== undefined)
|
||||
existing.replyToId = updates.replyToId ?? null;
|
||||
if (updates.replyToId !== undefined)
|
||||
existing.replyToId = updates.replyToId ?? null;
|
||||
|
||||
await repo.save(existing);
|
||||
await repo.save(existing);
|
||||
|
||||
if (updates.reactions !== undefined) {
|
||||
await replaceMessageReactions(manager, messageId, updates.reactions ?? []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,30 +1,68 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { replaceRoomRelations } from '../../relations';
|
||||
import { UpdateRoomCommand } from '../../types';
|
||||
import {
|
||||
applyUpdates,
|
||||
boolToInt,
|
||||
jsonOrNull,
|
||||
TransformMap
|
||||
} from './utils/applyUpdates';
|
||||
|
||||
const ROOM_TRANSFORMS: TransformMap = {
|
||||
hasPassword: boolToInt,
|
||||
isPrivate: boolToInt,
|
||||
userCount: (val) => (val ?? 0),
|
||||
permissions: jsonOrNull,
|
||||
channels: jsonOrNull,
|
||||
members: jsonOrNull
|
||||
userCount: (val) => (val ?? 0)
|
||||
};
|
||||
|
||||
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const { roomId, updates } = command.payload;
|
||||
const existing = await repo.findOne({ where: { id: roomId } });
|
||||
function extractSlowModeInterval(updates: UpdateRoomCommand['payload']['updates']): number | undefined {
|
||||
if (typeof updates.slowModeInterval === 'number' && Number.isFinite(updates.slowModeInterval)) {
|
||||
return updates.slowModeInterval;
|
||||
}
|
||||
|
||||
if (!existing)
|
||||
return;
|
||||
const permissions = updates.permissions && typeof updates.permissions === 'object'
|
||||
? updates.permissions as { slowModeInterval?: unknown }
|
||||
: null;
|
||||
|
||||
applyUpdates(existing, updates, ROOM_TRANSFORMS);
|
||||
await repo.save(existing);
|
||||
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
|
||||
? permissions.slowModeInterval
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const { roomId, updates } = command.payload;
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(RoomEntity);
|
||||
const existing = await repo.findOne({ where: { id: roomId } });
|
||||
|
||||
if (!existing)
|
||||
return;
|
||||
|
||||
const {
|
||||
channels,
|
||||
members,
|
||||
roles,
|
||||
roleAssignments,
|
||||
channelPermissions,
|
||||
permissions: rawPermissions,
|
||||
...entityUpdates
|
||||
} = updates;
|
||||
const slowModeInterval = extractSlowModeInterval(updates);
|
||||
|
||||
if (slowModeInterval !== undefined) {
|
||||
entityUpdates.slowModeInterval = slowModeInterval;
|
||||
}
|
||||
|
||||
applyUpdates(existing, entityUpdates, ROOM_TRANSFORMS);
|
||||
await repo.save(existing);
|
||||
await replaceRoomRelations(manager, roomId, {
|
||||
channels,
|
||||
members,
|
||||
roles,
|
||||
roleAssignments,
|
||||
channelPermissions,
|
||||
permissions: rawPermissions
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,19 @@ import { RoomEntity } from '../entities/RoomEntity';
|
||||
import { ReactionEntity } from '../entities/ReactionEntity';
|
||||
import { BanEntity } from '../entities/BanEntity';
|
||||
import { AttachmentEntity } from '../entities/AttachmentEntity';
|
||||
import { ReactionPayload } from './types';
|
||||
import {
|
||||
relationRecordToRoomPayload,
|
||||
RoomChannelPermissionRecord,
|
||||
RoomChannelRecord,
|
||||
RoomMemberRecord,
|
||||
RoomRoleAssignmentRecord,
|
||||
RoomRoleRecord
|
||||
} from './relations';
|
||||
|
||||
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||
|
||||
export function rowToMessage(row: MessageEntity) {
|
||||
export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] = []) {
|
||||
const isDeleted = !!row.isDeleted;
|
||||
|
||||
return {
|
||||
@@ -24,7 +33,7 @@ export function rowToMessage(row: MessageEntity) {
|
||||
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
|
||||
timestamp: row.timestamp,
|
||||
editedAt: row.editedAt ?? undefined,
|
||||
reactions: isDeleted ? [] : JSON.parse(row.reactions || '[]') as unknown[],
|
||||
reactions: isDeleted ? [] : reactions,
|
||||
isDeleted,
|
||||
replyToId: row.replyToId ?? undefined
|
||||
};
|
||||
@@ -49,7 +58,30 @@ export function rowToUser(row: UserEntity) {
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToRoom(row: RoomEntity) {
|
||||
export function rowToRoom(
|
||||
row: RoomEntity,
|
||||
relations: {
|
||||
channels?: RoomChannelRecord[];
|
||||
members?: RoomMemberRecord[];
|
||||
roles?: RoomRoleRecord[];
|
||||
roleAssignments?: RoomRoleAssignmentRecord[];
|
||||
channelPermissions?: RoomChannelPermissionRecord[];
|
||||
} = {
|
||||
channels: [],
|
||||
members: [],
|
||||
roles: [],
|
||||
roleAssignments: [],
|
||||
channelPermissions: []
|
||||
}
|
||||
) {
|
||||
const relationPayload = relationRecordToRoomPayload({ slowModeInterval: row.slowModeInterval }, {
|
||||
channels: relations.channels ?? [],
|
||||
members: relations.members ?? [],
|
||||
roles: relations.roles ?? [],
|
||||
roleAssignments: relations.roleAssignments ?? [],
|
||||
channelPermissions: relations.channelPermissions ?? []
|
||||
});
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -57,15 +89,23 @@ export function rowToRoom(row: RoomEntity) {
|
||||
topic: row.topic ?? undefined,
|
||||
hostId: row.hostId,
|
||||
password: row.password ?? undefined,
|
||||
hasPassword: !!row.hasPassword,
|
||||
isPrivate: !!row.isPrivate,
|
||||
createdAt: row.createdAt,
|
||||
userCount: row.userCount,
|
||||
maxUsers: row.maxUsers ?? undefined,
|
||||
icon: row.icon ?? undefined,
|
||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
||||
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
||||
members: row.members ? JSON.parse(row.members) : undefined
|
||||
slowModeInterval: row.slowModeInterval,
|
||||
permissions: relationPayload.permissions,
|
||||
channels: relationPayload.channels,
|
||||
members: relationPayload.members,
|
||||
roles: relationPayload.roles,
|
||||
roleAssignments: relationPayload.roleAssignments,
|
||||
channelPermissions: relationPayload.channelPermissions,
|
||||
sourceId: row.sourceId ?? undefined,
|
||||
sourceName: row.sourceName ?? undefined,
|
||||
sourceUrl: row.sourceUrl ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { rowToRoom } from '../../mappers';
|
||||
import { loadRoomRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetAllRooms(dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const rows = await repo.find();
|
||||
const relationsByRoomId = await loadRoomRelationsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map(rowToRoom);
|
||||
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id)));
|
||||
}
|
||||
|
||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { GetMessageByIdQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
|
||||
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.messageId } });
|
||||
|
||||
return row ? rowToMessage(row) : null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, [row.id]);
|
||||
|
||||
return rowToMessage(row, reactionsByMessageId.get(row.id) ?? []);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { GetMessagesQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
|
||||
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
@@ -12,6 +13,7 @@ export async function handleGetMessages(query: GetMessagesQuery, dataSource: Dat
|
||||
take: limit,
|
||||
skip: offset
|
||||
});
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map(rowToMessage);
|
||||
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
|
||||
}
|
||||
|
||||
20
electron/cqrs/queries/handlers/getMessagesSince.ts
Normal file
20
electron/cqrs/queries/handlers/getMessagesSince.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { DataSource, MoreThan } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { GetMessagesSinceQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
|
||||
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' }
|
||||
});
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
|
||||
}
|
||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { GetRoomQuery } from '../../types';
|
||||
import { rowToRoom } from '../../mappers';
|
||||
import { loadRoomRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.roomId } });
|
||||
|
||||
return row ? rowToRoom(row) : null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relationsByRoomId = await loadRoomRelationsMap(dataSource, [row.id]);
|
||||
|
||||
return rowToRoom(row, relationsByRoomId.get(row.id));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
QueryTypeKey,
|
||||
Query,
|
||||
GetMessagesQuery,
|
||||
GetMessagesSinceQuery,
|
||||
GetMessageByIdQuery,
|
||||
GetReactionsForMessageQuery,
|
||||
GetUserQuery,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
GetAttachmentsForMessageQuery
|
||||
} from '../types';
|
||||
import { handleGetMessages } from './handlers/getMessages';
|
||||
import { handleGetMessagesSince } from './handlers/getMessagesSince';
|
||||
import { handleGetMessageById } from './handlers/getMessageById';
|
||||
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
|
||||
import { handleGetUser } from './handlers/getUser';
|
||||
@@ -27,6 +29,7 @@ import { handleGetAllAttachments } from './handlers/getAllAttachments';
|
||||
|
||||
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
|
||||
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
|
||||
[QueryType.GetMessagesSince]: (query) => handleGetMessagesSince(query as GetMessagesSinceQuery, dataSource),
|
||||
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
|
||||
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
|
||||
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
|
||||
|
||||
1002
electron/cqrs/relations.ts
Normal file
1002
electron/cqrs/relations.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
|
||||
|
||||
export const QueryType = {
|
||||
GetMessages: 'get-messages',
|
||||
GetMessagesSince: 'get-messages-since',
|
||||
GetMessageById: 'get-message-by-id',
|
||||
GetReactionsForMessage: 'get-reactions-for-message',
|
||||
GetUser: 'get-user',
|
||||
@@ -60,6 +61,44 @@ export interface ReactionPayload {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
|
||||
|
||||
export type RoomPermissionKeyPayload =
|
||||
| 'manageServer'
|
||||
| 'manageRoles'
|
||||
| 'manageChannels'
|
||||
| 'manageIcon'
|
||||
| 'kickMembers'
|
||||
| 'banMembers'
|
||||
| 'manageBans'
|
||||
| 'deleteMessages'
|
||||
| 'joinVoice'
|
||||
| 'shareScreen'
|
||||
| 'uploadFiles';
|
||||
|
||||
export interface AccessRolePayload {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
position: number;
|
||||
isSystem?: boolean;
|
||||
permissions?: Partial<Record<RoomPermissionKeyPayload, PermissionStatePayload>>;
|
||||
}
|
||||
|
||||
export interface RoleAssignmentPayload {
|
||||
userId: string;
|
||||
oderId?: string;
|
||||
roleIds: string[];
|
||||
}
|
||||
|
||||
export interface ChannelPermissionPayload {
|
||||
channelId: string;
|
||||
targetType: 'role' | 'user';
|
||||
targetId: string;
|
||||
permission: RoomPermissionKeyPayload;
|
||||
value: PermissionStatePayload;
|
||||
}
|
||||
|
||||
export interface UserPayload {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
@@ -84,15 +123,23 @@ export interface RoomPayload {
|
||||
topic?: string;
|
||||
hostId: string;
|
||||
password?: string;
|
||||
hasPassword?: boolean;
|
||||
isPrivate?: boolean;
|
||||
createdAt: number;
|
||||
userCount?: number;
|
||||
maxUsers?: number;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
slowModeInterval?: number;
|
||||
permissions?: unknown;
|
||||
channels?: unknown[];
|
||||
members?: unknown[];
|
||||
roles?: AccessRolePayload[];
|
||||
roleAssignments?: RoleAssignmentPayload[];
|
||||
channelPermissions?: ChannelPermissionPayload[];
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface BanPayload {
|
||||
@@ -156,6 +203,7 @@ export type Command =
|
||||
| ClearAllDataCommand;
|
||||
|
||||
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
|
||||
export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } }
|
||||
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
|
||||
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
||||
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
|
||||
@@ -170,6 +218,7 @@ export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachmen
|
||||
|
||||
export type Query =
|
||||
| GetMessagesQuery
|
||||
| GetMessagesSinceQuery
|
||||
| GetMessageByIdQuery
|
||||
| GetReactionsForMessageQuery
|
||||
| GetUserQuery
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
@@ -38,6 +43,11 @@ export const AppDataSource = new DataSource({
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
@@ -40,6 +45,11 @@ export async function initializeDatabase(): Promise<void> {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
RoomRoleEntity,
|
||||
RoomUserRoleEntity,
|
||||
RoomChannelPermissionEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
|
||||
@@ -6,6 +6,8 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||
|
||||
export interface DesktopSettings {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
autoStart: boolean;
|
||||
closeToTray: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
@@ -19,6 +21,8 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
||||
|
||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
autoUpdateMode: 'auto',
|
||||
autoStart: true,
|
||||
closeToTray: true,
|
||||
hardwareAcceleration: true,
|
||||
manifestUrls: [],
|
||||
preferredVersion: null,
|
||||
@@ -81,6 +85,12 @@ export function readDesktopSettings(): DesktopSettings {
|
||||
|
||||
return {
|
||||
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
||||
autoStart: typeof parsed.autoStart === 'boolean'
|
||||
? parsed.autoStart
|
||||
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||
closeToTray: typeof parsed.closeToTray === 'boolean'
|
||||
? parsed.closeToTray
|
||||
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
|
||||
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
||||
? parsed.vaapiVideoEncode
|
||||
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
||||
@@ -102,6 +112,12 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
||||
};
|
||||
const nextSettings: DesktopSettings = {
|
||||
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
||||
autoStart: typeof mergedSettings.autoStart === 'boolean'
|
||||
? mergedSettings.autoStart
|
||||
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||
closeToTray: typeof mergedSettings.closeToTray === 'boolean'
|
||||
? mergedSettings.closeToTray
|
||||
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
|
||||
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||
? mergedSettings.hardwareAcceleration
|
||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||
|
||||
@@ -30,9 +30,6 @@ export class MessageEntity {
|
||||
@Column('integer', { nullable: true })
|
||||
editedAt!: number | null;
|
||||
|
||||
@Column('text', { default: '[]' })
|
||||
reactions!: string;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
isDeleted!: number;
|
||||
|
||||
|
||||
23
electron/entities/RoomChannelEntity.ts
Normal file
23
electron/entities/RoomChannelEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('room_channels')
|
||||
export class RoomChannelEntity {
|
||||
@PrimaryColumn('text')
|
||||
roomId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
channelId!: string;
|
||||
|
||||
@Column('text')
|
||||
name!: string;
|
||||
|
||||
@Column('text')
|
||||
type!: 'text' | 'voice';
|
||||
|
||||
@Column('integer')
|
||||
position!: number;
|
||||
}
|
||||
26
electron/entities/RoomChannelPermissionEntity.ts
Normal file
26
electron/entities/RoomChannelPermissionEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('room_channel_permissions')
|
||||
export class RoomChannelPermissionEntity {
|
||||
@PrimaryColumn('text')
|
||||
roomId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
channelId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
targetType!: 'role' | 'user';
|
||||
|
||||
@PrimaryColumn('text')
|
||||
targetId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
permission!: string;
|
||||
|
||||
@Column('text')
|
||||
value!: 'allow' | 'deny' | 'inherit';
|
||||
}
|
||||
@@ -24,6 +24,9 @@ export class RoomEntity {
|
||||
@Column('text', { nullable: true })
|
||||
password!: string | null;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
hasPassword!: number;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
isPrivate!: number;
|
||||
|
||||
@@ -42,12 +45,15 @@ export class RoomEntity {
|
||||
@Column('integer', { nullable: true })
|
||||
iconUpdatedAt!: number | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
permissions!: string | null;
|
||||
@Column('integer', { default: 0 })
|
||||
slowModeInterval!: number;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
channels!: string | null;
|
||||
sourceId!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
members!: string | null;
|
||||
sourceName!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
sourceUrl!: string | null;
|
||||
}
|
||||
|
||||
38
electron/entities/RoomMemberEntity.ts
Normal file
38
electron/entities/RoomMemberEntity.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('room_members')
|
||||
export class RoomMemberEntity {
|
||||
@PrimaryColumn('text')
|
||||
roomId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
memberKey!: string;
|
||||
|
||||
@Column('text')
|
||||
id!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
oderId!: string | null;
|
||||
|
||||
@Column('text')
|
||||
username!: string;
|
||||
|
||||
@Column('text')
|
||||
displayName!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
|
||||
@Column('text')
|
||||
role!: 'host' | 'admin' | 'moderator' | 'member';
|
||||
|
||||
@Column('integer')
|
||||
joinedAt!: number;
|
||||
|
||||
@Column('integer')
|
||||
lastSeenAt!: number;
|
||||
}
|
||||
59
electron/entities/RoomRoleEntity.ts
Normal file
59
electron/entities/RoomRoleEntity.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('room_roles')
|
||||
export class RoomRoleEntity {
|
||||
@PrimaryColumn('text')
|
||||
roomId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
roleId!: string;
|
||||
|
||||
@Column('text')
|
||||
name!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
color!: string | null;
|
||||
|
||||
@Column('integer')
|
||||
position!: number;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
isSystem!: number;
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
manageServer!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
manageRoles!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
manageChannels!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
manageIcon!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
kickMembers!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
banMembers!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
manageBans!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
deleteMessages!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
joinVoice!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
shareScreen!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
uploadFiles!: 'allow' | 'deny' | 'inherit';
|
||||
}
|
||||
23
electron/entities/RoomUserRoleEntity.ts
Normal file
23
electron/entities/RoomUserRoleEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('room_user_roles')
|
||||
export class RoomUserRoleEntity {
|
||||
@PrimaryColumn('text')
|
||||
roomId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
userKey!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
roleId!: string;
|
||||
|
||||
@Column('text')
|
||||
userId!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
oderId!: string | null;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
export { MessageEntity } from './MessageEntity';
|
||||
export { UserEntity } from './UserEntity';
|
||||
export { RoomEntity } from './RoomEntity';
|
||||
export { RoomChannelEntity } from './RoomChannelEntity';
|
||||
export { RoomMemberEntity } from './RoomMemberEntity';
|
||||
export { RoomRoleEntity } from './RoomRoleEntity';
|
||||
export { RoomUserRoleEntity } from './RoomUserRoleEntity';
|
||||
export { RoomChannelPermissionEntity } from './RoomChannelPermissionEntity';
|
||||
export { ReactionEntity } from './ReactionEntity';
|
||||
export { BanEntity } from './BanEntity';
|
||||
export { AttachmentEntity } from './AttachmentEntity';
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
desktopCapturer,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Notification,
|
||||
shell
|
||||
} from 'electron';
|
||||
import * as fs from 'fs';
|
||||
@@ -28,8 +29,23 @@ import {
|
||||
getDesktopUpdateState,
|
||||
handleDesktopSettingsChanged,
|
||||
restartToApplyUpdate,
|
||||
readDesktopUpdateServerHealth,
|
||||
type DesktopUpdateServerContext
|
||||
} from '../update/desktop-updater';
|
||||
import { consumePendingDeepLink } from '../app/deep-links';
|
||||
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
||||
import {
|
||||
getMainWindow,
|
||||
getWindowIconPath,
|
||||
updateCloseToTraySetting
|
||||
} from '../window/create-window';
|
||||
import {
|
||||
deleteSavedTheme,
|
||||
getSavedThemesPath,
|
||||
listSavedThemes,
|
||||
readSavedTheme,
|
||||
writeSavedTheme
|
||||
} from '../theme-library';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const FILE_CLIPBOARD_FORMATS = [
|
||||
@@ -83,6 +99,63 @@ interface ClipboardFilePayload {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface DesktopNotificationPayload {
|
||||
body: string;
|
||||
requestAttention?: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function resolveLinuxDisplayServer(): string {
|
||||
if (process.platform !== 'linux') {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (ozonePlatform === 'wayland') {
|
||||
return 'Wayland';
|
||||
}
|
||||
|
||||
if (ozonePlatform === 'x11') {
|
||||
return 'X11';
|
||||
}
|
||||
|
||||
const ozonePlatformHint = app.commandLine.getSwitchValue('ozone-platform-hint')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (ozonePlatformHint === 'wayland') {
|
||||
return 'Wayland';
|
||||
}
|
||||
|
||||
if (ozonePlatformHint === 'x11') {
|
||||
return 'X11';
|
||||
}
|
||||
|
||||
const sessionType = String(process.env['XDG_SESSION_TYPE'] || '').trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (sessionType === 'wayland') {
|
||||
return 'Wayland';
|
||||
}
|
||||
|
||||
if (sessionType === 'x11') {
|
||||
return 'X11';
|
||||
}
|
||||
|
||||
if (String(process.env['WAYLAND_DISPLAY'] || '').trim().length > 0) {
|
||||
return 'Wayland';
|
||||
}
|
||||
|
||||
if (String(process.env['DISPLAY'] || '').trim().length > 0) {
|
||||
return 'X11';
|
||||
}
|
||||
|
||||
return 'Unknown (Linux)';
|
||||
}
|
||||
|
||||
function isSupportedClipboardFileFormat(format: string): boolean {
|
||||
return FILE_CLIPBOARD_FORMATS.some(
|
||||
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
||||
@@ -194,6 +267,10 @@ async function readClipboardFiles(): Promise<ClipboardFilePayload[]> {
|
||||
}
|
||||
|
||||
export function setupSystemHandlers(): void {
|
||||
ipcMain.on('get-linux-display-server', (event) => {
|
||||
event.returnValue = resolveLinuxDisplayServer();
|
||||
});
|
||||
|
||||
ipcMain.handle('open-external', async (_event, url: string) => {
|
||||
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||
await shell.openExternal(url);
|
||||
@@ -203,17 +280,35 @@ export function setupSystemHandlers(): void {
|
||||
return false;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-sources', async () => {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
thumbnailSize: { width: 150, height: 150 }
|
||||
});
|
||||
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
|
||||
|
||||
return sources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
thumbnail: source.thumbnail.toDataURL()
|
||||
}));
|
||||
ipcMain.handle('get-sources', async () => {
|
||||
try {
|
||||
const thumbnailSize = { width: 240, height: 150 };
|
||||
const [screenSources, windowSources] = await Promise.all([
|
||||
desktopCapturer.getSources({
|
||||
types: ['screen'],
|
||||
thumbnailSize
|
||||
}),
|
||||
desktopCapturer.getSources({
|
||||
types: ['window'],
|
||||
thumbnailSize,
|
||||
fetchWindowIcons: true
|
||||
})
|
||||
]);
|
||||
const sources = [...screenSources, ...windowSources];
|
||||
const uniqueSources = new Map(sources.map((source) => [source.id, source]));
|
||||
|
||||
return [...uniqueSources.values()].map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
thumbnail: source.thumbnail.toDataURL()
|
||||
}));
|
||||
} catch {
|
||||
// desktopCapturer.getSources fails on Wayland; return empty so the
|
||||
// renderer falls through to getDisplayMedia with the system picker.
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||
@@ -237,11 +332,91 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
||||
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
||||
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
||||
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
|
||||
return await writeSavedTheme(fileName, text);
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-saved-theme', async (_event, fileName: string) => {
|
||||
return await deleteSavedTheme(fileName);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
|
||||
|
||||
ipcMain.handle('show-desktop-notification', async (_event, payload: DesktopNotificationPayload) => {
|
||||
const title = typeof payload?.title === 'string' ? payload.title.trim() : '';
|
||||
const body = typeof payload?.body === 'string' ? payload.body : '';
|
||||
const mainWindow = getMainWindow();
|
||||
const suppressSystemNotification = mainWindow?.isVisible() === true
|
||||
&& !mainWindow.isMinimized()
|
||||
&& mainWindow.isMaximized();
|
||||
|
||||
if (!title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!suppressSystemNotification && Notification.isSupported()) {
|
||||
try {
|
||||
const notification = new Notification({
|
||||
title,
|
||||
body,
|
||||
icon: getWindowIconPath(),
|
||||
silent: true
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
if (!mainWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show();
|
||||
}
|
||||
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
notification.show();
|
||||
} catch {
|
||||
// Ignore notification center failures and still attempt taskbar attention.
|
||||
}
|
||||
}
|
||||
|
||||
if (payload?.requestAttention && mainWindow && (mainWindow.isMinimized() || !mainWindow.isFocused())) {
|
||||
mainWindow.flashFrame(true);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('request-window-attention', () => {
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (!mainWindow || (!mainWindow.isMinimized() && mainWindow.isFocused())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
mainWindow.flashFrame(true);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('clear-window-attention', () => {
|
||||
getMainWindow()?.flashFrame(false);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
|
||||
|
||||
ipcMain.handle('get-auto-update-server-health', async (_event, serverUrl: string) => {
|
||||
return await readDesktopUpdateServerHealth(serverUrl);
|
||||
});
|
||||
|
||||
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
|
||||
return await configureDesktopUpdaterContext(context);
|
||||
});
|
||||
@@ -255,6 +430,8 @@ export function setupSystemHandlers(): void {
|
||||
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
||||
const snapshot = updateDesktopSettings(patch);
|
||||
|
||||
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||
updateCloseToTraySetting(snapshot.closeToTray);
|
||||
await handleDesktopSettingsChanged();
|
||||
return snapshot;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'reflect-metadata';
|
||||
import { initializeDeepLinkHandling } from './app/deep-links';
|
||||
import { configureAppFlags } from './app/flags';
|
||||
import { registerAppLifecycle } from './app/lifecycle';
|
||||
|
||||
configureAppFlags();
|
||||
registerAppLifecycle();
|
||||
|
||||
if (initializeDeepLinkHandling()) {
|
||||
registerAppLifecycle();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRoomSourceAndPasswordState1000000000002 implements MigrationInterface {
|
||||
name = 'AddRoomSourceAndPasswordState1000000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "hasPassword" INTEGER NOT NULL DEFAULT 0`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceId" TEXT`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceName" TEXT`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceUrl" TEXT`);
|
||||
await queryRunner.query(`
|
||||
UPDATE "rooms"
|
||||
SET "hasPassword" = CASE
|
||||
WHEN "password" IS NOT NULL AND TRIM("password") <> '' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceUrl"`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceName"`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceId"`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "hasPassword"`);
|
||||
}
|
||||
}
|
||||
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal file
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
type LegacyMessageRow = {
|
||||
id: string;
|
||||
reactions: string | null;
|
||||
};
|
||||
|
||||
type LegacyRoomRow = {
|
||||
id: string;
|
||||
channels: string | null;
|
||||
members: string | null;
|
||||
};
|
||||
|
||||
type ChannelType = 'text' | 'voice';
|
||||
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
|
||||
|
||||
type LegacyReaction = {
|
||||
id?: unknown;
|
||||
oderId?: unknown;
|
||||
userId?: unknown;
|
||||
emoji?: unknown;
|
||||
timestamp?: unknown;
|
||||
};
|
||||
|
||||
type LegacyRoomChannel = {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
type?: unknown;
|
||||
position?: unknown;
|
||||
};
|
||||
|
||||
type LegacyRoomMember = {
|
||||
id?: unknown;
|
||||
oderId?: unknown;
|
||||
username?: unknown;
|
||||
displayName?: unknown;
|
||||
avatarUrl?: unknown;
|
||||
role?: unknown;
|
||||
joinedAt?: unknown;
|
||||
lastSeenAt?: unknown;
|
||||
};
|
||||
|
||||
function parseArray<T>(raw: string | null): T[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
return Array.isArray(parsed) ? parsed as T[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function normalizeChannelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function channelNameKey(type: ChannelType, name: string): string {
|
||||
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function memberKey(member: { id?: string; oderId?: string }): string {
|
||||
return member.oderId?.trim() || member.id?.trim() || '';
|
||||
}
|
||||
|
||||
function fallbackDisplayName(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
|
||||
return member.displayName || member.username || member.oderId || member.id || 'User';
|
||||
}
|
||||
|
||||
function fallbackUsername(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
|
||||
const base = fallbackDisplayName(member)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_');
|
||||
|
||||
return base || member.oderId || member.id || 'user';
|
||||
}
|
||||
|
||||
function normalizeRoomMemberRole(value: unknown): RoomMemberRole {
|
||||
return value === 'host' || value === 'admin' || value === 'moderator' || value === 'member'
|
||||
? value
|
||||
: 'member';
|
||||
}
|
||||
|
||||
function mergeRoomMemberRole(
|
||||
existingRole: RoomMemberRole,
|
||||
incomingRole: RoomMemberRole,
|
||||
preferIncoming: boolean
|
||||
): RoomMemberRole {
|
||||
if (existingRole === incomingRole) {
|
||||
return existingRole;
|
||||
}
|
||||
|
||||
if (incomingRole === 'member' && existingRole !== 'member') {
|
||||
return existingRole;
|
||||
}
|
||||
|
||||
if (existingRole === 'member' && incomingRole !== 'member') {
|
||||
return incomingRole;
|
||||
}
|
||||
|
||||
return preferIncoming ? incomingRole : existingRole;
|
||||
}
|
||||
|
||||
function compareRoomMembers(
|
||||
firstMember: {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
displayName: string;
|
||||
},
|
||||
secondMember: {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
displayName: string;
|
||||
}
|
||||
): number {
|
||||
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
|
||||
|
||||
if (displayNameCompare !== 0) {
|
||||
return displayNameCompare;
|
||||
}
|
||||
|
||||
return memberKey(firstMember).localeCompare(memberKey(secondMember));
|
||||
}
|
||||
|
||||
function normalizeMessageReactions(messageId: string, raw: string | null) {
|
||||
const reactions = parseArray<LegacyReaction>(raw);
|
||||
const seen = new Set<string>();
|
||||
|
||||
return reactions.flatMap((reaction) => {
|
||||
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
|
||||
const userId = typeof reaction.userId === 'string' ? reaction.userId : '';
|
||||
const dedupeKey = `${userId}:${emoji}`;
|
||||
|
||||
if (!emoji || seen.has(dedupeKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seen.add(dedupeKey);
|
||||
|
||||
return [{
|
||||
id: typeof reaction.id === 'string' && reaction.id.trim() ? reaction.id : randomUUID(),
|
||||
messageId,
|
||||
oderId: typeof reaction.oderId === 'string' ? reaction.oderId : null,
|
||||
userId: userId || null,
|
||||
emoji,
|
||||
timestamp: isFiniteNumber(reaction.timestamp) ? reaction.timestamp : 0
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoomChannels(raw: string | null) {
|
||||
const channels = parseArray<LegacyRoomChannel>(raw);
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
return channels.flatMap((channel, index) => {
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = isFiniteNumber(channel.position) ? channel.position : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
|
||||
return [{
|
||||
channelId: id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoomMembers(raw: string | null, now = Date.now()) {
|
||||
const members = parseArray<LegacyRoomMember>(raw);
|
||||
const membersByKey = new Map<string, {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
role: RoomMemberRole;
|
||||
joinedAt: number;
|
||||
lastSeenAt: number;
|
||||
}>();
|
||||
|
||||
for (const rawMember of members) {
|
||||
const normalizedId = typeof rawMember.id === 'string' ? rawMember.id.trim() : '';
|
||||
const normalizedOderId = typeof rawMember.oderId === 'string' ? rawMember.oderId.trim() : '';
|
||||
const key = normalizedOderId || normalizedId;
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastSeenAt = isFiniteNumber(rawMember.lastSeenAt)
|
||||
? rawMember.lastSeenAt
|
||||
: isFiniteNumber(rawMember.joinedAt)
|
||||
? rawMember.joinedAt
|
||||
: now;
|
||||
const joinedAt = isFiniteNumber(rawMember.joinedAt) ? rawMember.joinedAt : lastSeenAt;
|
||||
const username = typeof rawMember.username === 'string' ? rawMember.username.trim() : '';
|
||||
const displayName = typeof rawMember.displayName === 'string' ? rawMember.displayName.trim() : '';
|
||||
const avatarUrl = typeof rawMember.avatarUrl === 'string' ? rawMember.avatarUrl.trim() : '';
|
||||
const nextMember = {
|
||||
id: normalizedId || key,
|
||||
oderId: normalizedOderId || undefined,
|
||||
username: username || fallbackUsername({ id: normalizedId || key, oderId: normalizedOderId || undefined, displayName }),
|
||||
displayName: displayName || fallbackDisplayName({ id: normalizedId || key, oderId: normalizedOderId || undefined, username }),
|
||||
avatarUrl: avatarUrl || undefined,
|
||||
role: normalizeRoomMemberRole(rawMember.role),
|
||||
joinedAt,
|
||||
lastSeenAt
|
||||
};
|
||||
const existingMember = membersByKey.get(key);
|
||||
|
||||
if (!existingMember) {
|
||||
membersByKey.set(key, nextMember);
|
||||
continue;
|
||||
}
|
||||
|
||||
const preferIncoming = nextMember.lastSeenAt >= existingMember.lastSeenAt;
|
||||
|
||||
membersByKey.set(key, {
|
||||
id: existingMember.id || nextMember.id,
|
||||
oderId: nextMember.oderId || existingMember.oderId,
|
||||
username: preferIncoming
|
||||
? (nextMember.username || existingMember.username)
|
||||
: (existingMember.username || nextMember.username),
|
||||
displayName: preferIncoming
|
||||
? (nextMember.displayName || existingMember.displayName)
|
||||
: (existingMember.displayName || nextMember.displayName),
|
||||
avatarUrl: preferIncoming
|
||||
? (nextMember.avatarUrl || existingMember.avatarUrl)
|
||||
: (existingMember.avatarUrl || nextMember.avatarUrl),
|
||||
role: mergeRoomMemberRole(existingMember.role, nextMember.role, preferIncoming),
|
||||
joinedAt: Math.min(existingMember.joinedAt, nextMember.joinedAt),
|
||||
lastSeenAt: Math.max(existingMember.lastSeenAt, nextMember.lastSeenAt)
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(membersByKey.values()).sort(compareRoomMembers);
|
||||
}
|
||||
|
||||
export class NormalizeArrayColumns1000000000003 implements MigrationInterface {
|
||||
name = 'NormalizeArrayColumns1000000000003';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "room_channels" (
|
||||
"roomId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("roomId", "channelId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channels_roomId" ON "room_channels" ("roomId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "room_members" (
|
||||
"roomId" TEXT NOT NULL,
|
||||
"memberKey" TEXT NOT NULL,
|
||||
"id" TEXT NOT NULL,
|
||||
"oderId" TEXT,
|
||||
"username" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"avatarUrl" TEXT,
|
||||
"role" TEXT NOT NULL,
|
||||
"joinedAt" INTEGER NOT NULL,
|
||||
"lastSeenAt" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("roomId", "memberKey")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_members_roomId" ON "room_members" ("roomId")`);
|
||||
|
||||
const messageRows = await queryRunner.query(`SELECT "id", "reactions" FROM "messages"`) as LegacyMessageRow[];
|
||||
|
||||
for (const row of messageRows) {
|
||||
const reactions = normalizeMessageReactions(row.id, row.reactions);
|
||||
|
||||
for (const reaction of reactions) {
|
||||
const existing = await queryRunner.query(
|
||||
`SELECT 1 FROM "reactions" WHERE "messageId" = ? AND "userId" IS ? AND "emoji" = ? LIMIT 1`,
|
||||
[reaction.messageId, reaction.userId, reaction.emoji]
|
||||
) as Array<{ 1: number }>;
|
||||
|
||||
if (existing.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "reactions" ("id", "messageId", "oderId", "userId", "emoji", "timestamp") VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[reaction.id, reaction.messageId, reaction.oderId, reaction.userId, reaction.emoji, reaction.timestamp]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const roomRows = await queryRunner.query(`SELECT "id", "channels", "members" FROM "rooms"`) as LegacyRoomRow[];
|
||||
|
||||
for (const row of roomRows) {
|
||||
for (const channel of normalizeRoomChannels(row.channels)) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "room_channels" ("roomId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
|
||||
[row.id, channel.channelId, channel.name, channel.type, channel.position]
|
||||
);
|
||||
}
|
||||
|
||||
for (const member of normalizeRoomMembers(row.members)) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "room_members" ("roomId", "memberKey", "id", "oderId", "username", "displayName", "avatarUrl", "role", "joinedAt", "lastSeenAt") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.id,
|
||||
memberKey(member),
|
||||
member.id,
|
||||
member.oderId ?? null,
|
||||
member.username,
|
||||
member.displayName,
|
||||
member.avatarUrl ?? null,
|
||||
member.role,
|
||||
member.joinedAt,
|
||||
member.lastSeenAt
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "messages_next" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"channelId" TEXT,
|
||||
"senderId" TEXT NOT NULL,
|
||||
"senderName" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"timestamp" INTEGER NOT NULL,
|
||||
"editedAt" INTEGER,
|
||||
"isDeleted" INTEGER NOT NULL DEFAULT 0,
|
||||
"replyToId" TEXT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "messages_next" ("id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId")
|
||||
SELECT "id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId"
|
||||
FROM "messages"
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE "messages"`);
|
||||
await queryRunner.query(`ALTER TABLE "messages_next" RENAME TO "messages"`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_messages_roomId" ON "messages" ("roomId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "rooms_next" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"topic" TEXT,
|
||||
"hostId" TEXT NOT NULL,
|
||||
"password" TEXT,
|
||||
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"userCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxUsers" INTEGER,
|
||||
"icon" TEXT,
|
||||
"iconUpdatedAt" INTEGER,
|
||||
"permissions" TEXT,
|
||||
"hasPassword" INTEGER NOT NULL DEFAULT 0,
|
||||
"sourceId" TEXT,
|
||||
"sourceName" TEXT,
|
||||
"sourceUrl" TEXT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl")
|
||||
SELECT "id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl"
|
||||
FROM "rooms"
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE "rooms"`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "room_members"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "room_channels"`);
|
||||
}
|
||||
}
|
||||
310
electron/migrations/1000000000004-NormalizeRoomAccessControl.ts
Normal file
310
electron/migrations/1000000000004-NormalizeRoomAccessControl.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
type LegacyRoomRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
topic: string | null;
|
||||
hostId: string;
|
||||
password: string | null;
|
||||
hasPassword: number;
|
||||
isPrivate: number;
|
||||
createdAt: number;
|
||||
userCount: number;
|
||||
maxUsers: number | null;
|
||||
icon: string | null;
|
||||
iconUpdatedAt: number | null;
|
||||
permissions: string | null;
|
||||
sourceId: string | null;
|
||||
sourceName: string | null;
|
||||
sourceUrl: string | null;
|
||||
};
|
||||
|
||||
type RoomMemberRow = {
|
||||
roomId: string;
|
||||
memberKey: string;
|
||||
id: string;
|
||||
oderId: string | null;
|
||||
role: string;
|
||||
};
|
||||
|
||||
type LegacyRoomPermissions = {
|
||||
adminsManageRooms?: boolean;
|
||||
moderatorsManageRooms?: boolean;
|
||||
adminsManageIcon?: boolean;
|
||||
moderatorsManageIcon?: boolean;
|
||||
allowVoice?: boolean;
|
||||
allowScreenShare?: boolean;
|
||||
allowFileUploads?: boolean;
|
||||
slowModeInterval?: number;
|
||||
};
|
||||
|
||||
const SYSTEM_ROLE_IDS = {
|
||||
everyone: 'system-everyone',
|
||||
moderator: 'system-moderator',
|
||||
admin: 'system-admin'
|
||||
} as const;
|
||||
|
||||
function parseLegacyPermissions(rawPermissions: string | null): LegacyRoomPermissions {
|
||||
try {
|
||||
const parsed = JSON.parse(rawPermissions || '{}') as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
adminsManageRooms: parsed['adminsManageRooms'] === true,
|
||||
moderatorsManageRooms: parsed['moderatorsManageRooms'] === true,
|
||||
adminsManageIcon: parsed['adminsManageIcon'] === true,
|
||||
moderatorsManageIcon: parsed['moderatorsManageIcon'] === true,
|
||||
allowVoice: parsed['allowVoice'] !== false,
|
||||
allowScreenShare: parsed['allowScreenShare'] !== false,
|
||||
allowFileUploads: parsed['allowFileUploads'] !== false,
|
||||
slowModeInterval: typeof parsed['slowModeInterval'] === 'number' && Number.isFinite(parsed['slowModeInterval'])
|
||||
? parsed['slowModeInterval']
|
||||
: 0
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
allowVoice: true,
|
||||
allowScreenShare: true,
|
||||
allowFileUploads: true,
|
||||
slowModeInterval: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultRoomRoles(legacyPermissions: LegacyRoomPermissions) {
|
||||
return [
|
||||
{
|
||||
roleId: SYSTEM_ROLE_IDS.everyone,
|
||||
name: '@everyone',
|
||||
color: '#6b7280',
|
||||
position: 0,
|
||||
isSystem: 1,
|
||||
manageServer: 'inherit',
|
||||
manageRoles: 'inherit',
|
||||
manageChannels: 'inherit',
|
||||
manageIcon: 'inherit',
|
||||
kickMembers: 'inherit',
|
||||
banMembers: 'inherit',
|
||||
manageBans: 'inherit',
|
||||
deleteMessages: 'inherit',
|
||||
joinVoice: legacyPermissions.allowVoice === false ? 'deny' : 'allow',
|
||||
shareScreen: legacyPermissions.allowScreenShare === false ? 'deny' : 'allow',
|
||||
uploadFiles: legacyPermissions.allowFileUploads === false ? 'deny' : 'allow'
|
||||
},
|
||||
{
|
||||
roleId: SYSTEM_ROLE_IDS.moderator,
|
||||
name: 'Moderator',
|
||||
color: '#10b981',
|
||||
position: 200,
|
||||
isSystem: 1,
|
||||
manageServer: 'inherit',
|
||||
manageRoles: 'inherit',
|
||||
manageChannels: legacyPermissions.moderatorsManageRooms ? 'allow' : 'inherit',
|
||||
manageIcon: legacyPermissions.moderatorsManageIcon ? 'allow' : 'inherit',
|
||||
kickMembers: 'allow',
|
||||
banMembers: 'inherit',
|
||||
manageBans: 'inherit',
|
||||
deleteMessages: 'allow',
|
||||
joinVoice: 'inherit',
|
||||
shareScreen: 'inherit',
|
||||
uploadFiles: 'inherit'
|
||||
},
|
||||
{
|
||||
roleId: SYSTEM_ROLE_IDS.admin,
|
||||
name: 'Admin',
|
||||
color: '#60a5fa',
|
||||
position: 300,
|
||||
isSystem: 1,
|
||||
manageServer: 'inherit',
|
||||
manageRoles: 'inherit',
|
||||
manageChannels: legacyPermissions.adminsManageRooms ? 'allow' : 'inherit',
|
||||
manageIcon: legacyPermissions.adminsManageIcon ? 'allow' : 'inherit',
|
||||
kickMembers: 'allow',
|
||||
banMembers: 'allow',
|
||||
manageBans: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
joinVoice: 'inherit',
|
||||
shareScreen: 'inherit',
|
||||
uploadFiles: 'inherit'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function roleIdsForMemberRole(role: string): string[] {
|
||||
if (role === 'admin') {
|
||||
return [SYSTEM_ROLE_IDS.admin];
|
||||
}
|
||||
|
||||
if (role === 'moderator') {
|
||||
return [SYSTEM_ROLE_IDS.moderator];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export class NormalizeRoomAccessControl1000000000004 implements MigrationInterface {
|
||||
name = 'NormalizeRoomAccessControl1000000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "room_roles" (
|
||||
"roomId" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"color" TEXT,
|
||||
"position" INTEGER NOT NULL,
|
||||
"isSystem" INTEGER NOT NULL DEFAULT 0,
|
||||
"manageServer" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"manageRoles" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"manageChannels" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"manageIcon" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"kickMembers" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"banMembers" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"manageBans" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"deleteMessages" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"joinVoice" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"shareScreen" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"uploadFiles" TEXT NOT NULL DEFAULT 'inherit',
|
||||
PRIMARY KEY ("roomId", "roleId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_roles_roomId" ON "room_roles" ("roomId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "room_user_roles" (
|
||||
"roomId" TEXT NOT NULL,
|
||||
"userKey" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"oderId" TEXT,
|
||||
PRIMARY KEY ("roomId", "userKey", "roleId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_user_roles_roomId" ON "room_user_roles" ("roomId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "room_channel_permissions" (
|
||||
"roomId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"targetType" TEXT NOT NULL,
|
||||
"targetId" TEXT NOT NULL,
|
||||
"permission" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
PRIMARY KEY ("roomId", "channelId", "targetType", "targetId", "permission")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channel_permissions_roomId" ON "room_channel_permissions" ("roomId")`);
|
||||
|
||||
const rooms = await queryRunner.query(`
|
||||
SELECT "id", "name", "description", "topic", "hostId", "password", "hasPassword", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "sourceId", "sourceName", "sourceUrl"
|
||||
FROM "rooms"
|
||||
`) as LegacyRoomRow[];
|
||||
const members = await queryRunner.query(`
|
||||
SELECT "roomId", "memberKey", "id", "oderId", "role"
|
||||
FROM "room_members"
|
||||
`) as RoomMemberRow[];
|
||||
|
||||
for (const room of rooms) {
|
||||
const legacyPermissions = parseLegacyPermissions(room.permissions);
|
||||
const roles = buildDefaultRoomRoles(legacyPermissions);
|
||||
|
||||
for (const role of roles) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "room_roles" ("roomId", "roleId", "name", "color", "position", "isSystem", "manageServer", "manageRoles", "manageChannels", "manageIcon", "kickMembers", "banMembers", "manageBans", "deleteMessages", "joinVoice", "shareScreen", "uploadFiles") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
room.id,
|
||||
role.roleId,
|
||||
role.name,
|
||||
role.color,
|
||||
role.position,
|
||||
role.isSystem,
|
||||
role.manageServer,
|
||||
role.manageRoles,
|
||||
role.manageChannels,
|
||||
role.manageIcon,
|
||||
role.kickMembers,
|
||||
role.banMembers,
|
||||
role.manageBans,
|
||||
role.deleteMessages,
|
||||
role.joinVoice,
|
||||
role.shareScreen,
|
||||
role.uploadFiles
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
for (const member of members.filter((candidateMember) => candidateMember.roomId === room.id)) {
|
||||
for (const roleId of roleIdsForMemberRole(member.role)) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "room_user_roles" ("roomId", "userKey", "roleId", "userId", "oderId") VALUES (?, ?, ?, ?, ?)`,
|
||||
[
|
||||
room.id,
|
||||
member.memberKey,
|
||||
roleId,
|
||||
member.id,
|
||||
member.oderId
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "rooms_next" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"topic" TEXT,
|
||||
"hostId" TEXT NOT NULL,
|
||||
"password" TEXT,
|
||||
"hasPassword" INTEGER NOT NULL DEFAULT 0,
|
||||
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"userCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxUsers" INTEGER,
|
||||
"icon" TEXT,
|
||||
"iconUpdatedAt" INTEGER,
|
||||
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
|
||||
"sourceId" TEXT,
|
||||
"sourceName" TEXT,
|
||||
"sourceUrl" TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
for (const room of rooms) {
|
||||
const legacyPermissions = parseLegacyPermissions(room.permissions);
|
||||
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "hasPassword", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "slowModeInterval", "sourceId", "sourceName", "sourceUrl") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
room.id,
|
||||
room.name,
|
||||
room.description,
|
||||
room.topic,
|
||||
room.hostId,
|
||||
room.password,
|
||||
room.hasPassword,
|
||||
room.isPrivate,
|
||||
room.createdAt,
|
||||
room.userCount,
|
||||
room.maxUsers,
|
||||
room.icon,
|
||||
room.iconUpdatedAt,
|
||||
legacyPermissions.slowModeInterval ?? 0,
|
||||
room.sourceId,
|
||||
room.sourceName,
|
||||
room.sourceUrl
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.query(`DROP TABLE "rooms"`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "room_channel_permissions"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "room_user_roles"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "room_roles"`);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { Command, Query } from './cqrs/types';
|
||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
||||
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||
|
||||
export interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
@@ -49,6 +51,12 @@ export interface DesktopUpdateServerContext {
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateServerHealthSnapshot {
|
||||
manifestUrl: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateState {
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
availableVersions: string[];
|
||||
@@ -83,7 +91,41 @@ export interface DesktopUpdateState {
|
||||
targetVersion: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopNotificationPayload {
|
||||
body: string;
|
||||
requestAttention: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface WindowStateSnapshot {
|
||||
isFocused: boolean;
|
||||
isMinimized: boolean;
|
||||
}
|
||||
|
||||
export interface SavedThemeFileDescriptor {
|
||||
fileName: string;
|
||||
modifiedAt: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function readLinuxDisplayServer(): string {
|
||||
if (process.platform !== 'linux') {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
try {
|
||||
const displayServer = ipcRenderer.sendSync('get-linux-display-server');
|
||||
|
||||
return typeof displayServer === 'string' && displayServer.trim().length > 0
|
||||
? displayServer
|
||||
: 'Unknown (Linux)';
|
||||
} catch {
|
||||
return 'Unknown (Linux)';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
maximizeWindow: () => void;
|
||||
closeWindow: () => void;
|
||||
@@ -98,27 +140,44 @@ export interface ElectronAPI {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
getSavedThemesPath: () => Promise<string>;
|
||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||
readSavedTheme: (fileName: string) => Promise<string>;
|
||||
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||
deleteSavedTheme: (fileName: string) => Promise<boolean>;
|
||||
consumePendingDeepLink: () => Promise<string | null>;
|
||||
getDesktopSettings: () => Promise<{
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
autoStart: boolean;
|
||||
closeToTray: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
runtimeHardwareAcceleration: boolean;
|
||||
restartRequired: boolean;
|
||||
}>;
|
||||
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
||||
requestWindowAttention: () => Promise<boolean>;
|
||||
clearWindowAttention: () => Promise<boolean>;
|
||||
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
|
||||
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
||||
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
|
||||
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
||||
restartToApplyUpdate: () => Promise<boolean>;
|
||||
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||
setDesktopSettings: (patch: {
|
||||
autoUpdateMode?: 'auto' | 'off' | 'version';
|
||||
autoStart?: boolean;
|
||||
closeToTray?: boolean;
|
||||
hardwareAcceleration?: boolean;
|
||||
manifestUrls?: string[];
|
||||
preferredVersion?: string | null;
|
||||
vaapiVideoEncode?: boolean;
|
||||
}) => Promise<{
|
||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||
autoStart: boolean;
|
||||
closeToTray: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
@@ -126,6 +185,7 @@ export interface ElectronAPI {
|
||||
restartRequired: boolean;
|
||||
}>;
|
||||
relaunchApp: () => Promise<boolean>;
|
||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
@@ -139,6 +199,7 @@ export interface ElectronAPI {
|
||||
}
|
||||
|
||||
const electronAPI: ElectronAPI = {
|
||||
linuxDisplayServer: readLinuxDisplayServer(),
|
||||
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
||||
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
||||
closeWindow: () => ipcRenderer.send('window-close'),
|
||||
@@ -180,8 +241,29 @@ const electronAPI: ElectronAPI = {
|
||||
};
|
||||
},
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
||||
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
|
||||
deleteSavedTheme: (fileName) => ipcRenderer.invoke('delete-saved-theme', fileName),
|
||||
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
|
||||
requestWindowAttention: () => ipcRenderer.invoke('request-window-attention'),
|
||||
clearWindowAttention: () => ipcRenderer.invoke('clear-window-attention'),
|
||||
onWindowStateChanged: (listener) => {
|
||||
const wrappedListener = (_event: Electron.IpcRendererEvent, state: WindowStateSnapshot) => {
|
||||
listener(state);
|
||||
};
|
||||
|
||||
ipcRenderer.on(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
|
||||
};
|
||||
},
|
||||
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
|
||||
getAutoUpdateServerHealth: (serverUrl) => ipcRenderer.invoke('get-auto-update-server-health', serverUrl),
|
||||
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
||||
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
|
||||
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
|
||||
@@ -198,6 +280,17 @@ const electronAPI: ElectronAPI = {
|
||||
},
|
||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
||||
onDeepLinkReceived: (listener) => {
|
||||
const wrappedListener = (_event: Electron.IpcRendererEvent, url: string) => {
|
||||
listener(url);
|
||||
};
|
||||
|
||||
ipcRenderer.on(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
|
||||
};
|
||||
},
|
||||
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||
|
||||
91
electron/theme-library.ts
Normal file
91
electron/theme-library.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { app } from 'electron';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface SavedThemeFileDescriptor {
|
||||
fileName: string;
|
||||
modifiedAt: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const SAVED_THEME_FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
|
||||
|
||||
function resolveSavedThemesPath(): string {
|
||||
return path.join(app.getPath('userData'), 'themes');
|
||||
}
|
||||
|
||||
async function ensureSavedThemesPath(): Promise<string> {
|
||||
const themesPath = resolveSavedThemesPath();
|
||||
|
||||
await fsp.mkdir(themesPath, { recursive: true });
|
||||
|
||||
return themesPath;
|
||||
}
|
||||
|
||||
function assertSavedThemeFileName(fileName: string): string {
|
||||
const normalized = typeof fileName === 'string'
|
||||
? fileName.trim()
|
||||
: '';
|
||||
|
||||
if (!SAVED_THEME_FILE_NAME_PATTERN.test(normalized) || normalized.includes('..')) {
|
||||
throw new Error('Invalid saved theme file name.');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function resolveSavedThemeFilePath(fileName: string): Promise<string> {
|
||||
const themesPath = await ensureSavedThemesPath();
|
||||
|
||||
return path.join(themesPath, assertSavedThemeFileName(fileName));
|
||||
}
|
||||
|
||||
export async function getSavedThemesPath(): Promise<string> {
|
||||
return await ensureSavedThemesPath();
|
||||
}
|
||||
|
||||
export async function listSavedThemes(): Promise<SavedThemeFileDescriptor[]> {
|
||||
const themesPath = await ensureSavedThemesPath();
|
||||
const entries = await fsp.readdir(themesPath, { withFileTypes: true });
|
||||
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'));
|
||||
const descriptors = await Promise.all(files.map(async (entry) => {
|
||||
const filePath = path.join(themesPath, entry.name);
|
||||
const stats = await fsp.stat(filePath);
|
||||
|
||||
return {
|
||||
fileName: entry.name,
|
||||
modifiedAt: Math.round(stats.mtimeMs),
|
||||
path: filePath
|
||||
} satisfies SavedThemeFileDescriptor;
|
||||
}));
|
||||
|
||||
return descriptors.sort((left, right) => right.modifiedAt - left.modifiedAt || left.fileName.localeCompare(right.fileName));
|
||||
}
|
||||
|
||||
export async function readSavedTheme(fileName: string): Promise<string> {
|
||||
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||
|
||||
return await fsp.readFile(filePath, 'utf8');
|
||||
}
|
||||
|
||||
export async function writeSavedTheme(fileName: string, text: string): Promise<boolean> {
|
||||
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||
|
||||
await fsp.writeFile(filePath, text, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteSavedTheme(fileName: string): Promise<boolean> {
|
||||
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === 'ENOENT') {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ interface ReleaseManifestEntry {
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface ServerHealthResponse {
|
||||
releaseManifestUrl?: string;
|
||||
serverVersion?: string;
|
||||
}
|
||||
|
||||
interface UpdateVersionInfo {
|
||||
version: string;
|
||||
}
|
||||
@@ -53,6 +58,12 @@ export interface DesktopUpdateServerContext {
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateServerHealthSnapshot {
|
||||
manifestUrl: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateState {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
availableVersions: string[];
|
||||
@@ -78,6 +89,8 @@ export interface DesktopUpdateState {
|
||||
|
||||
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||
|
||||
const SERVER_HEALTH_TIMEOUT_MS = 5_000;
|
||||
|
||||
let currentCheckPromise: Promise<void> | null = null;
|
||||
let currentContext: DesktopUpdateServerContext = {
|
||||
manifestUrls: [],
|
||||
@@ -388,6 +401,47 @@ async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest
|
||||
return parseReleaseManifest(payload);
|
||||
}
|
||||
|
||||
function createUnavailableServerHealthSnapshot(): DesktopUpdateServerHealthSnapshot {
|
||||
return {
|
||||
manifestUrl: null,
|
||||
serverVersion: null,
|
||||
serverVersionStatus: 'unavailable'
|
||||
};
|
||||
}
|
||||
|
||||
async function loadServerHealth(serverUrl: string): Promise<DesktopUpdateServerHealthSnapshot> {
|
||||
const sanitizedServerUrl = sanitizeHttpUrl(serverUrl);
|
||||
|
||||
if (!sanitizedServerUrl) {
|
||||
return createUnavailableServerHealthSnapshot();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await net.fetch(`${sanitizedServerUrl}/api/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
accept: 'application/json'
|
||||
},
|
||||
signal: AbortSignal.timeout(SERVER_HEALTH_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return createUnavailableServerHealthSnapshot();
|
||||
}
|
||||
|
||||
const payload = await response.json() as ServerHealthResponse;
|
||||
const serverVersion = normalizeSemanticVersion(payload.serverVersion);
|
||||
|
||||
return {
|
||||
manifestUrl: sanitizeHttpUrl(payload.releaseManifestUrl),
|
||||
serverVersion,
|
||||
serverVersionStatus: serverVersion ? 'reported' : 'missing'
|
||||
};
|
||||
} catch {
|
||||
return createUnavailableServerHealthSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
function formatManifestLoadErrors(errors: string[]): string {
|
||||
if (errors.length === 0) {
|
||||
return 'No valid release manifest could be loaded.';
|
||||
@@ -724,12 +778,18 @@ export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
|
||||
return desktopUpdateState;
|
||||
}
|
||||
|
||||
export async function readDesktopUpdateServerHealth(
|
||||
serverUrl: string
|
||||
): Promise<DesktopUpdateServerHealthSnapshot> {
|
||||
return await loadServerHealth(serverUrl);
|
||||
}
|
||||
|
||||
export function restartToApplyUpdate(): boolean {
|
||||
if (!desktopUpdateState.restartRequired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
autoUpdater.quitAndInstall(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
shell
|
||||
desktopCapturer,
|
||||
Menu,
|
||||
session,
|
||||
shell,
|
||||
Tray
|
||||
} from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let closeToTrayEnabled = true;
|
||||
let appQuitting = false;
|
||||
|
||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||
|
||||
function getAssetPath(...segments: string[]): string {
|
||||
const basePath = app.isPackaged
|
||||
@@ -36,13 +46,124 @@ export function getDockIconPath(): string | undefined {
|
||||
return getExistingAssetPath('macos', '1024x1024.png');
|
||||
}
|
||||
|
||||
function getTrayIconPath(): string | undefined {
|
||||
if (process.platform === 'win32')
|
||||
return getExistingAssetPath('windows', 'icon.ico');
|
||||
|
||||
return getExistingAssetPath('icon.png');
|
||||
}
|
||||
|
||||
export { getWindowIconPath };
|
||||
|
||||
export function getMainWindow(): BrowserWindow | null {
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function destroyTray(): void {
|
||||
if (!tray) {
|
||||
return;
|
||||
}
|
||||
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
|
||||
function requestAppQuit(): void {
|
||||
prepareWindowForAppQuit();
|
||||
app.quit();
|
||||
}
|
||||
|
||||
function ensureTray(): void {
|
||||
if (tray) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trayIconPath = getTrayIconPath();
|
||||
|
||||
if (!trayIconPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
tray = new Tray(trayIconPath);
|
||||
tray.setToolTip('MetoYou');
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open MetoYou',
|
||||
click: () => {
|
||||
void showMainWindow();
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Close MetoYou',
|
||||
click: () => {
|
||||
requestAppQuit();
|
||||
}
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
tray.on('click', () => {
|
||||
void showMainWindow();
|
||||
});
|
||||
}
|
||||
|
||||
function hideWindowToTray(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.hide();
|
||||
emitWindowState();
|
||||
}
|
||||
|
||||
export function updateCloseToTraySetting(enabled: boolean): void {
|
||||
closeToTrayEnabled = enabled;
|
||||
}
|
||||
|
||||
export function prepareWindowForAppQuit(): void {
|
||||
appQuitting = true;
|
||||
destroyTray();
|
||||
}
|
||||
|
||||
export async function showMainWindow(): Promise<void> {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
await createWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
|
||||
if (!mainWindow.isVisible()) {
|
||||
mainWindow.show();
|
||||
}
|
||||
|
||||
mainWindow.focus();
|
||||
emitWindowState();
|
||||
}
|
||||
|
||||
function emitWindowState(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.webContents.send(WINDOW_STATE_CHANGED_CHANNEL, {
|
||||
isFocused: mainWindow.isFocused(),
|
||||
isMinimized: mainWindow.isMinimized()
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWindow(): Promise<void> {
|
||||
const windowIconPath = getWindowIconPath();
|
||||
|
||||
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
||||
ensureTray();
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
@@ -61,6 +182,34 @@ export async function createWindow(): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
async (_request, respond) => {
|
||||
// On Linux/Wayland the system picker (useSystemPicker: true) handles
|
||||
// the portal. This handler is only reached if the system picker is
|
||||
// unavailable (e.g. X11 without a portal). Fall back to
|
||||
// desktopCapturer so the user still gets something.
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
thumbnailSize: { width: 150, height: 150 }
|
||||
});
|
||||
const firstSource = sources[0];
|
||||
|
||||
if (firstSource) {
|
||||
respond({ video: firstSource });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// desktopCapturer also unavailable
|
||||
}
|
||||
|
||||
respond({});
|
||||
},
|
||||
{ useSystemPicker: true }
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env['NODE_ENV'] === 'development') {
|
||||
const devUrl = process.env['SSL'] === 'true'
|
||||
? 'https://localhost:4200'
|
||||
@@ -75,10 +224,46 @@ export async function createWindow(): Promise<void> {
|
||||
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
|
||||
}
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (appQuitting || !closeToTrayEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
hideWindowToTray();
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.on('focus', () => {
|
||||
mainWindow?.flashFrame(false);
|
||||
emitWindowState();
|
||||
});
|
||||
|
||||
mainWindow.on('blur', () => {
|
||||
emitWindowState();
|
||||
});
|
||||
|
||||
mainWindow.on('minimize', () => {
|
||||
emitWindowState();
|
||||
});
|
||||
|
||||
mainWindow.on('restore', () => {
|
||||
emitWindowState();
|
||||
});
|
||||
|
||||
mainWindow.on('show', () => {
|
||||
emitWindowState();
|
||||
});
|
||||
|
||||
mainWindow.on('hide', () => {
|
||||
emitWindowState();
|
||||
});
|
||||
|
||||
emitWindowState();
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
|
||||
@@ -199,7 +199,7 @@ module.exports = tseslint.config(
|
||||
},
|
||||
// HTML template formatting rules (external Angular templates only)
|
||||
{
|
||||
files: ['src/app/**/*.html'],
|
||||
files: ['toju-app/src/app/**/*.html'],
|
||||
plugins: { 'no-dashes': noDashPlugin },
|
||||
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
||||
rules: {
|
||||
|
||||
234
package-lock.json
generated
234
package-lock.json
generated
@@ -14,6 +14,12 @@
|
||||
"@angular/forms": "^21.0.0",
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/router": "^21.0.0",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@ng-icons/core": "^33.0.0",
|
||||
"@ng-icons/lucide": "^33.0.0",
|
||||
"@ngrx/effects": "^21.0.1",
|
||||
@@ -24,8 +30,10 @@
|
||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||
"auto-launch": "^5.0.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"cytoscape": "^3.33.1",
|
||||
"electron-updater": "^6.6.2",
|
||||
"mermaid": "^11.12.3",
|
||||
@@ -45,11 +53,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.0.4",
|
||||
"@angular/cli": "^21.2.1",
|
||||
"@angular/cli": "^21.0.4",
|
||||
"@angular/compiler-cli": "^21.0.0",
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||
"@types/auto-launch": "^5.0.5",
|
||||
"@types/simple-peer": "^9.11.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"angular-eslint": "21.2.0",
|
||||
@@ -2695,6 +2704,109 @@
|
||||
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
|
||||
"integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
|
||||
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.35.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
|
||||
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/theme-one-dark": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.41.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
|
||||
"integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@develar/schema-utils": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||
@@ -5670,6 +5782,41 @@
|
||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
|
||||
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
|
||||
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@listr2/prompt-adapter-inquirer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
|
||||
@@ -5863,6 +6010,12 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz",
|
||||
@@ -10816,6 +10969,13 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/auto-launch": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/auto-launch/-/auto-launch-5.0.5.tgz",
|
||||
"integrity": "sha512-/nGvQZSzM/pvCMCh4Gt2kIeiUmOP/cKGJbjlInI+A+5MoV/7XmT56DJ6EU8bqc3+ItxEe4UC2GVspmPzcCc8cg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -12875,6 +13035,11 @@
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/applescript": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/applescript/-/applescript-1.0.0.tgz",
|
||||
"integrity": "sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ=="
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
@@ -12968,6 +13133,22 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/auto-launch": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/auto-launch/-/auto-launch-5.0.6.tgz",
|
||||
"integrity": "sha512-OgxiAm4q9EBf9EeXdPBiVNENaWE3jUZofwrhAkWjHDYGezu1k3FRZHU8V2FBxGuSJOHzKmTJEd0G7L7/0xDGFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"applescript": "^1.0.0",
|
||||
"mkdirp": "^0.5.1",
|
||||
"path-is-absolute": "^1.0.0",
|
||||
"untildify": "^3.0.2",
|
||||
"winreg": "1.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.23",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||
@@ -14108,6 +14289,21 @@
|
||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -14736,6 +14932,12 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
@@ -22285,9 +22487,7 @@
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -23745,7 +23945,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -27755,6 +27954,12 @@
|
||||
"webpack": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stylehacks": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
|
||||
@@ -29571,6 +29776,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/untildify": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz",
|
||||
"integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/upath": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
|
||||
@@ -30338,6 +30552,12 @@
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
|
||||
@@ -31161,6 +31381,12 @@
|
||||
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/winreg": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/winreg/-/winreg-1.2.4.tgz",
|
||||
"integrity": "sha512-IHpzORub7kYlb8A43Iig3reOvlcBJGX9gZ0WycHhghHtA65X0LYnMRuJs+aH1abVnMJztQkvQNlltnbPi5aGIA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
45
package.json
45
package.json
@@ -7,24 +7,24 @@
|
||||
"homepage": "https://git.azaaxin.com/myxelium/Toju",
|
||||
"main": "dist/electron/main.js",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"ng": "cd \"toju-app\" && ng",
|
||||
"prebuild": "npm run bundle:rnnoise",
|
||||
"prestart": "npm run bundle:rnnoise",
|
||||
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
|
||||
"start": "npm run ng serve",
|
||||
"build": "npm run ng build",
|
||||
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js",
|
||||
"start": "cd \"toju-app\" && ng serve",
|
||||
"build": "cd \"toju-app\" && ng build",
|
||||
"build:electron": "tsc -p tsconfig.electron.json",
|
||||
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
||||
"build:prod": "npm run ng build --configuration production --base-href='./'",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
||||
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
||||
"test": "cd \"toju-app\" && ng test",
|
||||
"server:build": "cd server && npm run build",
|
||||
"server:start": "cd server && npm start",
|
||||
"server:dev": "cd server && npm run dev",
|
||||
"electron": "npm run ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
|
||||
"electron:dev": "concurrently \"npm run ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --no-sandbox --disable-dev-shm-usage\"",
|
||||
"electron": "npm run build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
|
||||
"electron:dev": "concurrently \"npm run start\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
||||
"electron:full": "./dev.sh",
|
||||
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron . --no-sandbox --disable-dev-shm-usage\"",
|
||||
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
||||
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
||||
"migration:create": "typeorm migration:create electron/migrations/New",
|
||||
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
|
||||
@@ -36,12 +36,12 @@
|
||||
"electron:build:all": "npm run build:prod && npm run build:electron && electron-builder --win --mac --linux",
|
||||
"build:prod:all": "npm run build:prod && npm run build:electron && cd server && npm run build",
|
||||
"build:prod:win": "npm run build:prod:all && electron-builder --win",
|
||||
"dev": "npm run electron:full",
|
||||
"dev": "npm run build:electron && npm run electron:full",
|
||||
"dev:app": "npm run electron:dev",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
||||
"format": "prettier --write \"src/app/**/*.html\"",
|
||||
"format:check": "prettier --check \"src/app/**/*.html\"",
|
||||
"format": "prettier --write \"toju-app/src/app/**/*.html\"",
|
||||
"format:check": "prettier --check \"toju-app/src/app/**/*.html\"",
|
||||
"release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux",
|
||||
"release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win",
|
||||
"release:manifest": "node tools/generate-release-manifest.js",
|
||||
@@ -60,6 +60,12 @@
|
||||
"@angular/forms": "^21.0.0",
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/router": "^21.0.0",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@ng-icons/core": "^33.0.0",
|
||||
"@ng-icons/lucide": "^33.0.0",
|
||||
"@ngrx/effects": "^21.0.1",
|
||||
@@ -70,8 +76,10 @@
|
||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||
"auto-launch": "^5.0.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"cytoscape": "^3.33.1",
|
||||
"electron-updater": "^6.6.2",
|
||||
"mermaid": "^11.12.3",
|
||||
@@ -96,6 +104,7 @@
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||
"@types/auto-launch": "^5.0.5",
|
||||
"@types/simple-peer": "^9.11.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"angular-eslint": "21.2.0",
|
||||
@@ -120,6 +129,14 @@
|
||||
"build": {
|
||||
"appId": "com.metoyou.app",
|
||||
"productName": "MetoYou",
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Toju Invite Links",
|
||||
"schemes": [
|
||||
"toju"
|
||||
]
|
||||
}
|
||||
],
|
||||
"directories": {
|
||||
"output": "dist-electron"
|
||||
},
|
||||
@@ -147,7 +164,7 @@
|
||||
"publish": [
|
||||
{
|
||||
"provider": "generic",
|
||||
"url": "https://updates.metoyou.invalid"
|
||||
"url": "https://tojusignal.azaaxin.com"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
|
||||
Binary file not shown.
@@ -5,6 +5,7 @@ import { registerRoutes } from './routes';
|
||||
export function createApp(): express.Express {
|
||||
const app = express();
|
||||
|
||||
app.set('trust proxy', true);
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
|
||||
@@ -2,13 +2,20 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { resolveRuntimePath } from '../runtime-paths';
|
||||
|
||||
export type ServerHttpProtocol = 'http' | 'https';
|
||||
|
||||
export interface ServerVariablesConfig {
|
||||
klipyApiKey: string;
|
||||
releaseManifestUrl: string;
|
||||
serverPort: number;
|
||||
serverProtocol: ServerHttpProtocol;
|
||||
serverHost: string;
|
||||
}
|
||||
|
||||
const DATA_DIR = resolveRuntimePath('data');
|
||||
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
||||
const DEFAULT_SERVER_PORT = 3001;
|
||||
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
||||
|
||||
function normalizeKlipyApiKey(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
@@ -18,6 +25,51 @@ function normalizeReleaseManifestUrl(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeServerHost(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeServerProtocol(
|
||||
value: unknown,
|
||||
fallback: ServerHttpProtocol = DEFAULT_SERVER_PROTOCOL
|
||||
): ServerHttpProtocol {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'https' : 'http';
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
if (normalized === 'https' || normalized === 'true') {
|
||||
return 'https';
|
||||
}
|
||||
|
||||
if (normalized === 'http' || normalized === 'false') {
|
||||
return 'http';
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): number {
|
||||
const parsed = typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? Number.parseInt(value.trim(), 10)
|
||||
: Number.NaN;
|
||||
|
||||
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
|
||||
if (!fs.existsSync(VARIABLES_FILE)) {
|
||||
return { rawContents: '', parsed: {} };
|
||||
@@ -52,10 +104,14 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
}
|
||||
|
||||
const { rawContents, parsed } = readRawVariables();
|
||||
const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed;
|
||||
const normalized = {
|
||||
...parsed,
|
||||
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey),
|
||||
releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl)
|
||||
...remainingParsed,
|
||||
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
||||
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress)
|
||||
};
|
||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||
|
||||
@@ -65,7 +121,10 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
|
||||
return {
|
||||
klipyApiKey: normalized.klipyApiKey,
|
||||
releaseManifestUrl: normalized.releaseManifestUrl
|
||||
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||
serverPort: normalized.serverPort,
|
||||
serverProtocol: normalized.serverProtocol,
|
||||
serverHost: normalized.serverHost
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,3 +143,29 @@ export function hasKlipyApiKey(): boolean {
|
||||
export function getReleaseManifestUrl(): string {
|
||||
return getVariablesConfig().releaseManifestUrl;
|
||||
}
|
||||
|
||||
export function getServerProtocol(): ServerHttpProtocol {
|
||||
if (hasEnvironmentOverride(process.env.SSL)) {
|
||||
return normalizeServerProtocol(process.env.SSL);
|
||||
}
|
||||
|
||||
return getVariablesConfig().serverProtocol;
|
||||
}
|
||||
|
||||
export function getServerPort(): number {
|
||||
if (hasEnvironmentOverride(process.env.PORT)) {
|
||||
return normalizeServerPort(process.env.PORT);
|
||||
}
|
||||
|
||||
return getVariablesConfig().serverPort;
|
||||
}
|
||||
|
||||
export function getServerHost(): string | undefined {
|
||||
const serverHost = getVariablesConfig().serverHost;
|
||||
|
||||
return serverHost || undefined;
|
||||
}
|
||||
|
||||
export function isHttpsServerEnabled(): boolean {
|
||||
return getServerProtocol() === 'https';
|
||||
}
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerEntity, JoinRequestEntity } from '../../../entities';
|
||||
import {
|
||||
ServerChannelPermissionEntity,
|
||||
ServerChannelEntity,
|
||||
ServerEntity,
|
||||
ServerRoleEntity,
|
||||
ServerTagEntity,
|
||||
ServerUserRoleEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity
|
||||
} from '../../../entities';
|
||||
import { DeleteServerCommand } from '../../types';
|
||||
|
||||
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
||||
const { serverId } = command.payload;
|
||||
|
||||
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerEntity).delete(serverId);
|
||||
await dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(ServerTagEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerChannelEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerRoleEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerUserRoleEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerChannelPermissionEntity).delete({ serverId });
|
||||
await manager.getRepository(JoinRequestEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerMembershipEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerInviteEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerBanEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerEntity).delete(serverId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { replaceServerRelations } from '../../relations';
|
||||
import { UpsertServerCommand } from '../../types';
|
||||
|
||||
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(ServerEntity);
|
||||
const { server } = command.payload;
|
||||
const entity = repo.create({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description ?? null,
|
||||
ownerId: server.ownerId,
|
||||
ownerPublicKey: server.ownerPublicKey,
|
||||
isPrivate: server.isPrivate ? 1 : 0,
|
||||
maxUsers: server.maxUsers,
|
||||
currentUsers: server.currentUsers,
|
||||
tags: JSON.stringify(server.tags),
|
||||
createdAt: server.createdAt,
|
||||
lastSeen: server.lastSeen
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(ServerEntity);
|
||||
const entity = repo.create({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description ?? null,
|
||||
ownerId: server.ownerId,
|
||||
ownerPublicKey: server.ownerPublicKey,
|
||||
passwordHash: server.passwordHash ?? null,
|
||||
isPrivate: server.isPrivate ? 1 : 0,
|
||||
maxUsers: server.maxUsers,
|
||||
currentUsers: server.currentUsers,
|
||||
slowModeInterval: server.slowModeInterval ?? 0,
|
||||
createdAt: server.createdAt,
|
||||
lastSeen: server.lastSeen
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await replaceServerRelations(manager, server.id, {
|
||||
tags: server.tags,
|
||||
channels: server.channels ?? [],
|
||||
roles: server.roles ?? [],
|
||||
roleAssignments: server.roleAssignments ?? [],
|
||||
channelPermissions: server.channelPermissions ?? []
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ServerPayload,
|
||||
JoinRequestPayload
|
||||
} from './types';
|
||||
import { relationRecordToServerPayload } from './relations';
|
||||
|
||||
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||
return {
|
||||
@@ -17,17 +18,41 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToServer(row: ServerEntity): ServerPayload {
|
||||
export function rowToServer(
|
||||
row: ServerEntity,
|
||||
relations: Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions'> = {
|
||||
tags: [],
|
||||
channels: [],
|
||||
roles: [],
|
||||
roleAssignments: [],
|
||||
channelPermissions: []
|
||||
}
|
||||
): ServerPayload {
|
||||
const relationPayload = relationRecordToServerPayload({ slowModeInterval: row.slowModeInterval }, {
|
||||
tags: relations.tags ?? [],
|
||||
channels: relations.channels ?? [],
|
||||
roles: relations.roles ?? [],
|
||||
roleAssignments: relations.roleAssignments ?? [],
|
||||
channelPermissions: relations.channelPermissions ?? []
|
||||
});
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description ?? undefined,
|
||||
ownerId: row.ownerId,
|
||||
ownerPublicKey: row.ownerPublicKey,
|
||||
hasPassword: !!row.passwordHash,
|
||||
passwordHash: row.passwordHash ?? undefined,
|
||||
isPrivate: !!row.isPrivate,
|
||||
maxUsers: row.maxUsers,
|
||||
currentUsers: row.currentUsers,
|
||||
tags: JSON.parse(row.tags || '[]'),
|
||||
slowModeInterval: relationPayload.slowModeInterval,
|
||||
tags: relationPayload.tags,
|
||||
channels: relationPayload.channels,
|
||||
roles: relationPayload.roles,
|
||||
roleAssignments: relationPayload.roleAssignments,
|
||||
channelPermissions: relationPayload.channelPermissions,
|
||||
createdAt: row.createdAt,
|
||||
lastSeen: row.lastSeen
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { rowToServer } from '../../mappers';
|
||||
import { loadServerRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetAllPublicServers(dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(ServerEntity);
|
||||
const rows = await repo.find({ where: { isPrivate: 0 } });
|
||||
const relationsByServerId = await loadServerRelationsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map(rowToServer);
|
||||
return rows.map((row) => rowToServer(row, relationsByServerId.get(row.id)));
|
||||
}
|
||||
|
||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { GetServerByIdQuery } from '../../types';
|
||||
import { rowToServer } from '../../mappers';
|
||||
import { loadServerRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(ServerEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.serverId } });
|
||||
|
||||
return row ? rowToServer(row) : null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relationsByServerId = await loadServerRelationsMap(dataSource, [row.id]);
|
||||
|
||||
return rowToServer(row, relationsByServerId.get(row.id));
|
||||
}
|
||||
|
||||
603
server/src/cqrs/relations.ts
Normal file
603
server/src/cqrs/relations.ts
Normal file
@@ -0,0 +1,603 @@
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
In
|
||||
} from 'typeorm';
|
||||
import {
|
||||
ServerChannelEntity,
|
||||
ServerTagEntity,
|
||||
ServerRoleEntity,
|
||||
ServerUserRoleEntity,
|
||||
ServerChannelPermissionEntity
|
||||
} from '../entities';
|
||||
import {
|
||||
AccessRolePayload,
|
||||
ChannelPermissionPayload,
|
||||
RoleAssignmentPayload,
|
||||
ServerChannelPayload,
|
||||
ServerPayload,
|
||||
ServerPermissionKeyPayload,
|
||||
PermissionStatePayload
|
||||
} from './types';
|
||||
|
||||
const SERVER_PERMISSION_KEYS: ServerPermissionKeyPayload[] = [
|
||||
'manageServer',
|
||||
'manageRoles',
|
||||
'manageChannels',
|
||||
'manageIcon',
|
||||
'kickMembers',
|
||||
'banMembers',
|
||||
'manageBans',
|
||||
'deleteMessages',
|
||||
'joinVoice',
|
||||
'shareScreen',
|
||||
'uploadFiles'
|
||||
];
|
||||
const SYSTEM_ROLE_IDS = {
|
||||
everyone: 'system-everyone',
|
||||
moderator: 'system-moderator',
|
||||
admin: 'system-admin'
|
||||
} as const;
|
||||
|
||||
interface ServerRelationRecord {
|
||||
tags: string[];
|
||||
channels: ServerChannelPayload[];
|
||||
roles: AccessRolePayload[];
|
||||
roleAssignments: RoleAssignmentPayload[];
|
||||
channelPermissions: ChannelPermissionPayload[];
|
||||
}
|
||||
|
||||
function normalizeChannelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function compareText(firstValue: string, secondValue: string): number {
|
||||
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function uniqueStrings(values: readonly string[] | undefined): string[] {
|
||||
return Array.from(new Set((values ?? [])
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => value.trim())));
|
||||
}
|
||||
|
||||
function normalizePermissionState(value: unknown): PermissionStatePayload {
|
||||
return value === 'allow' || value === 'deny' || value === 'inherit'
|
||||
? value
|
||||
: 'inherit';
|
||||
}
|
||||
|
||||
function normalizePermissionMatrix(rawMatrix: unknown): Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> {
|
||||
const matrix = rawMatrix && typeof rawMatrix === 'object'
|
||||
? rawMatrix as Record<string, unknown>
|
||||
: {};
|
||||
const normalized: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> = {};
|
||||
|
||||
for (const key of SERVER_PERMISSION_KEYS) {
|
||||
const value = normalizePermissionState(matrix[key]);
|
||||
|
||||
if (value !== 'inherit') {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildDefaultServerRoles(): AccessRolePayload[] {
|
||||
return [
|
||||
{
|
||||
id: SYSTEM_ROLE_IDS.everyone,
|
||||
name: '@everyone',
|
||||
color: '#6b7280',
|
||||
position: 0,
|
||||
isSystem: true,
|
||||
permissions: {
|
||||
joinVoice: 'allow',
|
||||
shareScreen: 'allow',
|
||||
uploadFiles: 'allow'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: SYSTEM_ROLE_IDS.moderator,
|
||||
name: 'Moderator',
|
||||
color: '#10b981',
|
||||
position: 200,
|
||||
isSystem: true,
|
||||
permissions: {
|
||||
kickMembers: 'allow',
|
||||
deleteMessages: 'allow'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: SYSTEM_ROLE_IDS.admin,
|
||||
name: 'Admin',
|
||||
color: '#60a5fa',
|
||||
position: 300,
|
||||
isSystem: true,
|
||||
permissions: {
|
||||
kickMembers: 'allow',
|
||||
banMembers: 'allow',
|
||||
manageBans: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
manageChannels: 'allow',
|
||||
manageIcon: 'allow'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeServerRole(rawRole: Partial<AccessRolePayload>, fallbackRole?: AccessRolePayload): AccessRolePayload | null {
|
||||
const id = typeof rawRole.id === 'string' ? rawRole.id.trim() : fallbackRole?.id ?? '';
|
||||
const name = typeof rawRole.name === 'string' ? rawRole.name.trim().replace(/\s+/g, ' ') : fallbackRole?.name ?? '';
|
||||
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
color: typeof rawRole.color === 'string' && rawRole.color.trim() ? rawRole.color.trim() : fallbackRole?.color,
|
||||
position: isFiniteNumber(rawRole.position) ? rawRole.position : fallbackRole?.position ?? 0,
|
||||
isSystem: typeof rawRole.isSystem === 'boolean' ? rawRole.isSystem : fallbackRole?.isSystem,
|
||||
permissions: normalizePermissionMatrix(rawRole.permissions ?? fallbackRole?.permissions)
|
||||
};
|
||||
}
|
||||
|
||||
function compareRoles(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
|
||||
if (firstRole.position !== secondRole.position) {
|
||||
return firstRole.position - secondRole.position;
|
||||
}
|
||||
|
||||
return compareText(firstRole.name, secondRole.name);
|
||||
}
|
||||
|
||||
function compareAssignments(firstAssignment: RoleAssignmentPayload, secondAssignment: RoleAssignmentPayload): number {
|
||||
return compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId);
|
||||
}
|
||||
|
||||
export function normalizeServerTags(rawTags: unknown): string[] {
|
||||
if (!Array.isArray(rawTags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawTags
|
||||
.filter((tag): tag is string => typeof tag === 'string')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayload[] {
|
||||
if (!Array.isArray(rawChannels)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
const channels: ServerChannelPayload[] = [];
|
||||
|
||||
for (const [index, rawChannel] of rawChannels.entries()) {
|
||||
if (!rawChannel || typeof rawChannel !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const channel = rawChannel as Record<string, unknown>;
|
||||
const id = typeof channel['id'] === 'string' ? channel['id'].trim() : '';
|
||||
const name = typeof channel['name'] === 'string' ? normalizeChannelName(channel['name']) : '';
|
||||
const type = channel['type'] === 'text' || channel['type'] === 'voice' ? channel['type'] : null;
|
||||
const position = isFiniteNumber(channel['position']) ? channel['position'] : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
channels.push({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
});
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
export function normalizeServerRoles(rawRoles: unknown): AccessRolePayload[] {
|
||||
const rolesById = new Map<string, AccessRolePayload>();
|
||||
|
||||
if (Array.isArray(rawRoles)) {
|
||||
for (const rawRole of rawRoles) {
|
||||
if (!rawRole || typeof rawRole !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRole = normalizeServerRole(rawRole as Record<string, unknown>);
|
||||
|
||||
if (normalizedRole) {
|
||||
rolesById.set(normalizedRole.id, normalizedRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const defaultRole of buildDefaultServerRoles()) {
|
||||
const mergedRole = normalizeServerRole(rolesById.get(defaultRole.id) ?? defaultRole, defaultRole) ?? defaultRole;
|
||||
|
||||
rolesById.set(defaultRole.id, mergedRole);
|
||||
}
|
||||
|
||||
return Array.from(rolesById.values()).sort(compareRoles);
|
||||
}
|
||||
|
||||
export function normalizeServerRoleAssignments(rawAssignments: unknown, roles: readonly AccessRolePayload[]): RoleAssignmentPayload[] {
|
||||
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
|
||||
const assignmentsByKey = new Map<string, RoleAssignmentPayload>();
|
||||
|
||||
if (!Array.isArray(rawAssignments)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const rawAssignment of rawAssignments) {
|
||||
if (!rawAssignment || typeof rawAssignment !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const assignment = rawAssignment as Record<string, unknown>;
|
||||
const userId = typeof assignment['userId'] === 'string' ? assignment['userId'].trim() : '';
|
||||
const oderId = typeof assignment['oderId'] === 'string' ? assignment['oderId'].trim() : undefined;
|
||||
const key = oderId || userId;
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const roleIds = uniqueStrings(Array.isArray(assignment['roleIds']) ? assignment['roleIds'] as string[] : undefined)
|
||||
.filter((roleId) => validRoleIds.has(roleId));
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assignmentsByKey.set(key, {
|
||||
userId: userId || key,
|
||||
oderId,
|
||||
roleIds
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(assignmentsByKey.values()).sort(compareAssignments);
|
||||
}
|
||||
|
||||
export function normalizeServerChannelPermissions(
|
||||
rawChannelPermissions: unknown,
|
||||
roles: readonly AccessRolePayload[]
|
||||
): ChannelPermissionPayload[] {
|
||||
if (!Array.isArray(rawChannelPermissions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const validRoleIds = new Set(roles.map((role) => role.id));
|
||||
const overridesByKey = new Map<string, ChannelPermissionPayload>();
|
||||
|
||||
for (const rawOverride of rawChannelPermissions) {
|
||||
if (!rawOverride || typeof rawOverride !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const override = rawOverride as Record<string, unknown>;
|
||||
const channelId = typeof override['channelId'] === 'string' ? override['channelId'].trim() : '';
|
||||
const targetType = override['targetType'] === 'role' || override['targetType'] === 'user' ? override['targetType'] : null;
|
||||
const targetId = typeof override['targetId'] === 'string' ? override['targetId'].trim() : '';
|
||||
const permission = SERVER_PERMISSION_KEYS.find((key) => key === override['permission']);
|
||||
const value = normalizePermissionState(override['value']);
|
||||
|
||||
if (!channelId || !targetType || !targetId || !permission || value === 'inherit') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (targetType === 'role' && !validRoleIds.has(targetId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${channelId}:${targetType}:${targetId}:${permission}`;
|
||||
|
||||
overridesByKey.set(key, {
|
||||
channelId,
|
||||
targetType,
|
||||
targetId,
|
||||
permission,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(overridesByKey.values()).sort((firstOverride, secondOverride) => {
|
||||
const channelCompare = compareText(firstOverride.channelId, secondOverride.channelId);
|
||||
|
||||
if (channelCompare !== 0) {
|
||||
return channelCompare;
|
||||
}
|
||||
|
||||
if (firstOverride.targetType !== secondOverride.targetType) {
|
||||
return compareText(firstOverride.targetType, secondOverride.targetType);
|
||||
}
|
||||
|
||||
const targetCompare = compareText(firstOverride.targetId, secondOverride.targetId);
|
||||
|
||||
if (targetCompare !== 0) {
|
||||
return targetCompare;
|
||||
}
|
||||
|
||||
return compareText(firstOverride.permission, secondOverride.permission);
|
||||
});
|
||||
}
|
||||
|
||||
export async function replaceServerRelations(
|
||||
manager: EntityManager,
|
||||
serverId: string,
|
||||
options: {
|
||||
tags: unknown;
|
||||
channels: unknown;
|
||||
roles?: unknown;
|
||||
roleAssignments?: unknown;
|
||||
channelPermissions?: unknown;
|
||||
}
|
||||
): Promise<void> {
|
||||
const tagRepo = manager.getRepository(ServerTagEntity);
|
||||
const channelRepo = manager.getRepository(ServerChannelEntity);
|
||||
const roleRepo = manager.getRepository(ServerRoleEntity);
|
||||
const userRoleRepo = manager.getRepository(ServerUserRoleEntity);
|
||||
const channelPermissionRepo = manager.getRepository(ServerChannelPermissionEntity);
|
||||
const tags = normalizeServerTags(options.tags);
|
||||
const channels = normalizeServerChannels(options.channels);
|
||||
const roles = options.roles !== undefined ? normalizeServerRoles(options.roles) : [];
|
||||
|
||||
await tagRepo.delete({ serverId });
|
||||
await channelRepo.delete({ serverId });
|
||||
|
||||
if (options.roles !== undefined) {
|
||||
await roleRepo.delete({ serverId });
|
||||
}
|
||||
|
||||
if (options.roleAssignments !== undefined) {
|
||||
await userRoleRepo.delete({ serverId });
|
||||
}
|
||||
|
||||
if (options.channelPermissions !== undefined) {
|
||||
await channelPermissionRepo.delete({ serverId });
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
await tagRepo.insert(
|
||||
tags.map((tag, position) => ({
|
||||
serverId,
|
||||
position,
|
||||
value: tag
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (channels.length > 0) {
|
||||
await channelRepo.insert(
|
||||
channels.map((channel) => ({
|
||||
serverId,
|
||||
channelId: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
position: channel.position
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (options.roles !== undefined && roles.length > 0) {
|
||||
await roleRepo.insert(
|
||||
roles.map((role) => ({
|
||||
serverId,
|
||||
roleId: role.id,
|
||||
name: role.name,
|
||||
color: role.color ?? null,
|
||||
position: role.position,
|
||||
isSystem: role.isSystem ? 1 : 0,
|
||||
manageServer: normalizePermissionState(role.permissions?.manageServer),
|
||||
manageRoles: normalizePermissionState(role.permissions?.manageRoles),
|
||||
manageChannels: normalizePermissionState(role.permissions?.manageChannels),
|
||||
manageIcon: normalizePermissionState(role.permissions?.manageIcon),
|
||||
kickMembers: normalizePermissionState(role.permissions?.kickMembers),
|
||||
banMembers: normalizePermissionState(role.permissions?.banMembers),
|
||||
manageBans: normalizePermissionState(role.permissions?.manageBans),
|
||||
deleteMessages: normalizePermissionState(role.permissions?.deleteMessages),
|
||||
joinVoice: normalizePermissionState(role.permissions?.joinVoice),
|
||||
shareScreen: normalizePermissionState(role.permissions?.shareScreen),
|
||||
uploadFiles: normalizePermissionState(role.permissions?.uploadFiles)
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (options.roleAssignments !== undefined) {
|
||||
const roleAssignments = normalizeServerRoleAssignments(options.roleAssignments, roles.length > 0 ? roles : normalizeServerRoles([]));
|
||||
const rows = roleAssignments.flatMap((assignment) =>
|
||||
assignment.roleIds.map((roleId) => ({
|
||||
serverId,
|
||||
userId: assignment.userId,
|
||||
roleId,
|
||||
oderId: assignment.oderId ?? null
|
||||
}))
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
await userRoleRepo.insert(rows);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.channelPermissions !== undefined) {
|
||||
const channelPermissions = normalizeServerChannelPermissions(
|
||||
options.channelPermissions,
|
||||
roles.length > 0 ? roles : normalizeServerRoles([])
|
||||
);
|
||||
|
||||
if (channelPermissions.length > 0) {
|
||||
await channelPermissionRepo.insert(
|
||||
channelPermissions.map((channelPermission) => ({
|
||||
serverId,
|
||||
channelId: channelPermission.channelId,
|
||||
targetType: channelPermission.targetType,
|
||||
targetId: channelPermission.targetId,
|
||||
permission: channelPermission.permission,
|
||||
value: channelPermission.value
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadServerRelationsMap(
|
||||
dataSource: DataSource,
|
||||
serverIds: readonly string[]
|
||||
): Promise<Map<string, ServerRelationRecord>> {
|
||||
const groupedRelations = new Map<string, ServerRelationRecord>();
|
||||
|
||||
if (serverIds.length === 0) {
|
||||
return groupedRelations;
|
||||
}
|
||||
|
||||
const [
|
||||
tagRows,
|
||||
channelRows,
|
||||
roleRows,
|
||||
userRoleRows,
|
||||
channelPermissionRows
|
||||
] = await Promise.all([
|
||||
dataSource.getRepository(ServerTagEntity).find({
|
||||
where: { serverId: In([...serverIds]) }
|
||||
}),
|
||||
dataSource.getRepository(ServerChannelEntity).find({
|
||||
where: { serverId: In([...serverIds]) }
|
||||
}),
|
||||
dataSource.getRepository(ServerRoleEntity).find({
|
||||
where: { serverId: In([...serverIds]) }
|
||||
}),
|
||||
dataSource.getRepository(ServerUserRoleEntity).find({
|
||||
where: { serverId: In([...serverIds]) }
|
||||
}),
|
||||
dataSource.getRepository(ServerChannelPermissionEntity).find({
|
||||
where: { serverId: In([...serverIds]) }
|
||||
})
|
||||
]);
|
||||
|
||||
for (const serverId of serverIds) {
|
||||
groupedRelations.set(serverId, {
|
||||
tags: [],
|
||||
channels: [],
|
||||
roles: [],
|
||||
roleAssignments: [],
|
||||
channelPermissions: []
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of tagRows) {
|
||||
groupedRelations.get(row.serverId)?.tags.push(row.value);
|
||||
}
|
||||
|
||||
for (const row of channelRows) {
|
||||
groupedRelations.get(row.serverId)?.channels.push({
|
||||
id: row.channelId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
position: row.position
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of roleRows) {
|
||||
groupedRelations.get(row.serverId)?.roles.push({
|
||||
id: row.roleId,
|
||||
name: row.name,
|
||||
color: row.color ?? undefined,
|
||||
position: row.position,
|
||||
isSystem: !!row.isSystem,
|
||||
permissions: normalizePermissionMatrix({
|
||||
manageServer: row.manageServer,
|
||||
manageRoles: row.manageRoles,
|
||||
manageChannels: row.manageChannels,
|
||||
manageIcon: row.manageIcon,
|
||||
kickMembers: row.kickMembers,
|
||||
banMembers: row.banMembers,
|
||||
manageBans: row.manageBans,
|
||||
deleteMessages: row.deleteMessages,
|
||||
joinVoice: row.joinVoice,
|
||||
shareScreen: row.shareScreen,
|
||||
uploadFiles: row.uploadFiles
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of userRoleRows) {
|
||||
const relation = groupedRelations.get(row.serverId);
|
||||
|
||||
if (!relation) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = relation.roleAssignments.find((assignment) => assignment.userId === row.userId || assignment.oderId === row.oderId);
|
||||
|
||||
if (existing) {
|
||||
existing.roleIds = uniqueStrings([...existing.roleIds, row.roleId]);
|
||||
continue;
|
||||
}
|
||||
|
||||
relation.roleAssignments.push({
|
||||
userId: row.userId,
|
||||
oderId: row.oderId ?? undefined,
|
||||
roleIds: [row.roleId]
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of channelPermissionRows) {
|
||||
groupedRelations.get(row.serverId)?.channelPermissions.push({
|
||||
channelId: row.channelId,
|
||||
targetType: row.targetType,
|
||||
targetId: row.targetId,
|
||||
permission: row.permission as ServerPermissionKeyPayload,
|
||||
value: normalizePermissionState(row.value)
|
||||
});
|
||||
}
|
||||
|
||||
for (const [serverId, relation] of groupedRelations) {
|
||||
relation.tags = tagRows
|
||||
.filter((row) => row.serverId === serverId)
|
||||
.sort((firstTag, secondTag) => firstTag.position - secondTag.position)
|
||||
.map((row) => row.value);
|
||||
|
||||
relation.channels.sort(
|
||||
(firstChannel, secondChannel) => firstChannel.position - secondChannel.position || compareText(firstChannel.name, secondChannel.name)
|
||||
);
|
||||
|
||||
relation.roles.sort(compareRoles);
|
||||
relation.roleAssignments.sort(compareAssignments);
|
||||
}
|
||||
|
||||
return groupedRelations;
|
||||
}
|
||||
|
||||
export function relationRecordToServerPayload(
|
||||
row: Pick<ServerPayload, 'slowModeInterval'>,
|
||||
relations: ServerRelationRecord
|
||||
): Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions' | 'slowModeInterval'> {
|
||||
return {
|
||||
tags: relations.tags,
|
||||
channels: relations.channels,
|
||||
roles: relations.roles,
|
||||
roleAssignments: relations.roleAssignments,
|
||||
channelPermissions: relations.channelPermissions,
|
||||
slowModeInterval: row.slowModeInterval ?? 0
|
||||
};
|
||||
}
|
||||
@@ -28,16 +28,70 @@ export interface AuthUserPayload {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type ServerChannelType = 'text' | 'voice';
|
||||
|
||||
export interface ServerChannelPayload {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ServerChannelType;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
|
||||
|
||||
export type ServerPermissionKeyPayload =
|
||||
| 'manageServer'
|
||||
| 'manageRoles'
|
||||
| 'manageChannels'
|
||||
| 'manageIcon'
|
||||
| 'kickMembers'
|
||||
| 'banMembers'
|
||||
| 'manageBans'
|
||||
| 'deleteMessages'
|
||||
| 'joinVoice'
|
||||
| 'shareScreen'
|
||||
| 'uploadFiles';
|
||||
|
||||
export interface AccessRolePayload {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
position: number;
|
||||
isSystem?: boolean;
|
||||
permissions?: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>>;
|
||||
}
|
||||
|
||||
export interface RoleAssignmentPayload {
|
||||
userId: string;
|
||||
oderId?: string;
|
||||
roleIds: string[];
|
||||
}
|
||||
|
||||
export interface ChannelPermissionPayload {
|
||||
channelId: string;
|
||||
targetType: 'role' | 'user';
|
||||
targetId: string;
|
||||
permission: ServerPermissionKeyPayload;
|
||||
value: PermissionStatePayload;
|
||||
}
|
||||
|
||||
export interface ServerPayload {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
ownerId: string;
|
||||
ownerPublicKey: string;
|
||||
hasPassword?: boolean;
|
||||
passwordHash?: string | null;
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
slowModeInterval?: number;
|
||||
tags: string[];
|
||||
channels: ServerChannelPayload[];
|
||||
roles?: AccessRolePayload[];
|
||||
roleAssignments?: RoleAssignmentPayload[];
|
||||
channelPermissions?: ChannelPermissionPayload[];
|
||||
createdAt: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,15 @@ import { DataSource } from 'typeorm';
|
||||
import {
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
JoinRequestEntity
|
||||
ServerTagEntity,
|
||||
ServerChannelEntity,
|
||||
ServerRoleEntity,
|
||||
ServerUserRoleEntity,
|
||||
ServerChannelPermissionEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity
|
||||
} from '../entities';
|
||||
import { serverMigrations } from '../migrations';
|
||||
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
||||
@@ -51,7 +59,15 @@ export async function initDatabase(): Promise<void> {
|
||||
entities: [
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
JoinRequestEntity
|
||||
ServerTagEntity,
|
||||
ServerChannelEntity,
|
||||
ServerRoleEntity,
|
||||
ServerUserRoleEntity,
|
||||
ServerChannelPermissionEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity
|
||||
],
|
||||
migrations: serverMigrations,
|
||||
synchronize: false,
|
||||
|
||||
35
server/src/entities/ServerBanEntity.ts
Normal file
35
server/src/entities/ServerBanEntity.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_bans')
|
||||
export class ServerBanEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
serverId!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
userId!: string;
|
||||
|
||||
@Column('text')
|
||||
bannedBy!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
displayName!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
reason!: string | null;
|
||||
|
||||
@Column('integer', { nullable: true })
|
||||
expiresAt!: number | null;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
}
|
||||
23
server/src/entities/ServerChannelEntity.ts
Normal file
23
server/src/entities/ServerChannelEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_channels')
|
||||
export class ServerChannelEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
channelId!: string;
|
||||
|
||||
@Column('text')
|
||||
name!: string;
|
||||
|
||||
@Column('text')
|
||||
type!: 'text' | 'voice';
|
||||
|
||||
@Column('integer')
|
||||
position!: number;
|
||||
}
|
||||
26
server/src/entities/ServerChannelPermissionEntity.ts
Normal file
26
server/src/entities/ServerChannelPermissionEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_channel_permissions')
|
||||
export class ServerChannelPermissionEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
channelId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
targetType!: 'role' | 'user';
|
||||
|
||||
@PrimaryColumn('text')
|
||||
targetId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
permission!: string;
|
||||
|
||||
@Column('text')
|
||||
value!: 'allow' | 'deny' | 'inherit';
|
||||
}
|
||||
@@ -21,6 +21,9 @@ export class ServerEntity {
|
||||
@Column('text')
|
||||
ownerPublicKey!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
passwordHash!: string | null;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
isPrivate!: number;
|
||||
|
||||
@@ -30,8 +33,8 @@ export class ServerEntity {
|
||||
@Column('integer', { default: 0 })
|
||||
currentUsers!: number;
|
||||
|
||||
@Column('text', { default: '[]' })
|
||||
tags!: string;
|
||||
@Column('integer', { default: 0 })
|
||||
slowModeInterval!: number;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
29
server/src/entities/ServerInviteEntity.ts
Normal file
29
server/src/entities/ServerInviteEntity.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_invites')
|
||||
export class ServerInviteEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
serverId!: string;
|
||||
|
||||
@Column('text')
|
||||
createdBy!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
createdByDisplayName!: string | null;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
@Index()
|
||||
@Column('integer')
|
||||
expiresAt!: number;
|
||||
}
|
||||
26
server/src/entities/ServerMembershipEntity.ts
Normal file
26
server/src/entities/ServerMembershipEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
Index
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_memberships')
|
||||
export class ServerMembershipEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
serverId!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
userId!: string;
|
||||
|
||||
@Column('integer')
|
||||
joinedAt!: number;
|
||||
|
||||
@Column('integer')
|
||||
lastAccessAt!: number;
|
||||
}
|
||||
59
server/src/entities/ServerRoleEntity.ts
Normal file
59
server/src/entities/ServerRoleEntity.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_roles')
|
||||
export class ServerRoleEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
roleId!: string;
|
||||
|
||||
@Column('text')
|
||||
name!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
color!: string | null;
|
||||
|
||||
@Column('integer')
|
||||
position!: number;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
isSystem!: number;
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
manageServer!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
manageRoles!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
manageChannels!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
manageIcon!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
kickMembers!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
banMembers!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
manageBans!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
deleteMessages!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
joinVoice!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
shareScreen!: 'allow' | 'deny' | 'inherit';
|
||||
|
||||
@Column('text', { default: 'inherit' })
|
||||
uploadFiles!: 'allow' | 'deny' | 'inherit';
|
||||
}
|
||||
17
server/src/entities/ServerTagEntity.ts
Normal file
17
server/src/entities/ServerTagEntity.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_tags')
|
||||
export class ServerTagEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('integer')
|
||||
position!: number;
|
||||
|
||||
@Column('text')
|
||||
value!: string;
|
||||
}
|
||||
20
server/src/entities/ServerUserRoleEntity.ts
Normal file
20
server/src/entities/ServerUserRoleEntity.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_user_roles')
|
||||
export class ServerUserRoleEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
userId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
roleId!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
oderId!: string | null;
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
export { AuthUserEntity } from './AuthUserEntity';
|
||||
export { ServerEntity } from './ServerEntity';
|
||||
export { ServerTagEntity } from './ServerTagEntity';
|
||||
export { ServerChannelEntity } from './ServerChannelEntity';
|
||||
export { ServerRoleEntity } from './ServerRoleEntity';
|
||||
export { ServerUserRoleEntity } from './ServerUserRoleEntity';
|
||||
export { ServerChannelPermissionEntity } from './ServerChannelPermissionEntity';
|
||||
export { JoinRequestEntity } from './JoinRequestEntity';
|
||||
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||
export { ServerBanEntity } from './ServerBanEntity';
|
||||
|
||||
@@ -14,23 +14,39 @@ import { deleteStaleJoinRequests } from './cqrs';
|
||||
import { createApp } from './app';
|
||||
import {
|
||||
ensureVariablesConfig,
|
||||
getServerHost,
|
||||
getVariablesConfigPath,
|
||||
hasKlipyApiKey
|
||||
getServerPort,
|
||||
getServerProtocol,
|
||||
ServerHttpProtocol
|
||||
} from './config/variables';
|
||||
import { setupWebSocket } from './websocket';
|
||||
|
||||
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
|
||||
const PORT = process.env.PORT || 3001;
|
||||
function formatHostForUrl(host: string): string {
|
||||
if (host.startsWith('[') || !host.includes(':')) {
|
||||
return host;
|
||||
}
|
||||
|
||||
function buildServer(app: ReturnType<typeof createApp>) {
|
||||
if (USE_SSL) {
|
||||
return `[${host}]`;
|
||||
}
|
||||
|
||||
function getDisplayHost(serverHost: string | undefined): string {
|
||||
if (!serverHost || serverHost === '0.0.0.0' || serverHost === '::') {
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
return serverHost;
|
||||
}
|
||||
|
||||
function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHttpProtocol) {
|
||||
if (serverProtocol === 'https') {
|
||||
const certDir = resolveCertificateDirectory();
|
||||
const certFile = path.join(certDir, 'localhost.crt');
|
||||
const keyFile = path.join(certDir, 'localhost.key');
|
||||
|
||||
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
||||
console.error(`SSL=true but certs not found in ${certDir}`);
|
||||
console.error('Run ./generate-cert.sh first.');
|
||||
console.error(`HTTPS is enabled but certs were not found in ${certDir}`);
|
||||
console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -44,17 +60,31 @@ function buildServer(app: ReturnType<typeof createApp>) {
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
ensureVariablesConfig();
|
||||
const variablesConfig = ensureVariablesConfig();
|
||||
const serverProtocol = getServerProtocol();
|
||||
const serverPort = getServerPort();
|
||||
const serverHost = getServerHost();
|
||||
const bindHostLabel = serverHost || 'default interface';
|
||||
|
||||
console.log('[Config] Variables loaded from:', getVariablesConfigPath());
|
||||
|
||||
if (!hasKlipyApiKey()) {
|
||||
if (
|
||||
variablesConfig.serverProtocol !== serverProtocol
|
||||
|| variablesConfig.serverPort !== serverPort
|
||||
) {
|
||||
console.log(`[Config] Server runtime override active: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
|
||||
} else {
|
||||
console.log(`[Config] Server runtime config: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
|
||||
}
|
||||
|
||||
if (!variablesConfig.klipyApiKey) {
|
||||
console.log('[KLIPY] API key not configured. GIF search is disabled.');
|
||||
}
|
||||
|
||||
await initDatabase();
|
||||
|
||||
const app = createApp();
|
||||
const server = buildServer(app);
|
||||
const server = buildServer(app, serverProtocol);
|
||||
|
||||
setupWebSocket(server);
|
||||
|
||||
@@ -64,14 +94,29 @@ async function bootstrap(): Promise<void> {
|
||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||
}, 60 * 1000);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
const proto = USE_SSL ? 'https' : 'http';
|
||||
const wsProto = USE_SSL ? 'wss' : 'ws';
|
||||
const onListening = () => {
|
||||
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
|
||||
const localHostNames = [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1'
|
||||
];
|
||||
|
||||
console.log(`MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`);
|
||||
console.log(` REST API: ${proto}://localhost:${PORT}/api`);
|
||||
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
|
||||
});
|
||||
console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
|
||||
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
|
||||
console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`);
|
||||
|
||||
if (serverProtocol === 'https' && serverHost && !localHostNames.includes(serverHost)) {
|
||||
console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.');
|
||||
}
|
||||
};
|
||||
|
||||
if (serverHost) {
|
||||
server.listen(serverPort, serverHost, onListening);
|
||||
} else {
|
||||
server.listen(serverPort, onListening);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export class InitialSchema1000000000000 implements MigrationInterface {
|
||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"tags" TEXT NOT NULL DEFAULT '[]',
|
||||
"channels" TEXT NOT NULL DEFAULT '[]',
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"lastSeen" INTEGER NOT NULL
|
||||
)
|
||||
|
||||
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ServerAccessControl1000000000001 implements MigrationInterface {
|
||||
name = 'ServerAccessControl1000000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "passwordHash" TEXT`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_memberships" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"joinedAt" INTEGER NOT NULL,
|
||||
"lastAccessAt" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_serverId" ON "server_memberships" ("serverId")`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_userId" ON "server_memberships" ("userId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_invites" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"createdBy" TEXT NOT NULL,
|
||||
"createdByDisplayName" TEXT,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"expiresAt" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_serverId" ON "server_invites" ("serverId")`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_expiresAt" ON "server_invites" ("expiresAt")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_bans" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"bannedBy" TEXT NOT NULL,
|
||||
"displayName" TEXT,
|
||||
"reason" TEXT,
|
||||
"expiresAt" INTEGER,
|
||||
"createdAt" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_serverId" ON "server_bans" ("serverId")`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_userId" ON "server_bans" ("userId")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_bans"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_invites"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_memberships"`);
|
||||
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "passwordHash"`);
|
||||
}
|
||||
}
|
||||
13
server/src/migrations/1000000000002-ServerChannels.ts
Normal file
13
server/src/migrations/1000000000002-ServerChannels.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ServerChannels1000000000002 implements MigrationInterface {
|
||||
name = 'ServerChannels1000000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "channels"`);
|
||||
}
|
||||
}
|
||||
119
server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts
Normal file
119
server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
interface LegacyServerRow {
|
||||
id: string;
|
||||
channels: string | null;
|
||||
}
|
||||
|
||||
interface LegacyServerChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'voice';
|
||||
position: number;
|
||||
}
|
||||
|
||||
function normalizeLegacyChannels(raw: string | null): LegacyServerChannel[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
return parsed
|
||||
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
|
||||
.map((channel, index) => {
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = typeof channel.position === 'number' ? channel.position : index;
|
||||
const nameKey = type ? `${type}:${name.toLocaleLowerCase()}` : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
} satisfies LegacyServerChannel;
|
||||
})
|
||||
.filter((channel): channel is LegacyServerChannel => !!channel);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRestoreLegacyVoiceGeneral(channels: LegacyServerChannel[]): boolean {
|
||||
const hasTextGeneral = channels.some(
|
||||
(channel) => channel.type === 'text' && (channel.id === 'general' || channel.name.toLocaleLowerCase() === 'general')
|
||||
);
|
||||
const hasVoiceAfk = channels.some(
|
||||
(channel) => channel.type === 'voice' && (channel.id === 'vc-afk' || channel.name.toLocaleLowerCase() === 'afk')
|
||||
);
|
||||
const hasVoiceGeneral = channels.some(
|
||||
(channel) => channel.type === 'voice' && (channel.id === 'vc-general' || channel.name.toLocaleLowerCase() === 'general')
|
||||
);
|
||||
|
||||
return hasTextGeneral && hasVoiceAfk && !hasVoiceGeneral;
|
||||
}
|
||||
|
||||
function repairLegacyVoiceChannels(channels: LegacyServerChannel[]): LegacyServerChannel[] {
|
||||
if (!shouldRestoreLegacyVoiceGeneral(channels)) {
|
||||
return channels;
|
||||
}
|
||||
|
||||
const textChannels = channels.filter((channel) => channel.type === 'text');
|
||||
const voiceChannels = channels.filter((channel) => channel.type === 'voice');
|
||||
const repairedVoiceChannels = [
|
||||
{
|
||||
id: 'vc-general',
|
||||
name: 'General',
|
||||
type: 'voice' as const,
|
||||
position: 0
|
||||
},
|
||||
...voiceChannels
|
||||
].map((channel, index) => ({
|
||||
...channel,
|
||||
position: index
|
||||
}));
|
||||
|
||||
return [
|
||||
...textChannels,
|
||||
...repairedVoiceChannels
|
||||
];
|
||||
}
|
||||
|
||||
export class RepairLegacyVoiceChannels1000000000003 implements MigrationInterface {
|
||||
name = 'RepairLegacyVoiceChannels1000000000003';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const rows = await queryRunner.query(`SELECT "id", "channels" FROM "servers"`) as LegacyServerRow[];
|
||||
|
||||
for (const row of rows) {
|
||||
const channels = normalizeLegacyChannels(row.channels);
|
||||
const repaired = repairLegacyVoiceChannels(channels);
|
||||
|
||||
if (JSON.stringify(repaired) === JSON.stringify(channels)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "servers" SET "channels" = ? WHERE "id" = ?`,
|
||||
[JSON.stringify(repaired), row.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// Forward-only data repair migration.
|
||||
}
|
||||
}
|
||||
142
server/src/migrations/1000000000004-NormalizeServerArrays.ts
Normal file
142
server/src/migrations/1000000000004-NormalizeServerArrays.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
type LegacyServerRow = {
|
||||
id: string;
|
||||
tags: string | null;
|
||||
channels: string | null;
|
||||
};
|
||||
|
||||
type LegacyServerChannel = {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
type?: unknown;
|
||||
position?: unknown;
|
||||
};
|
||||
|
||||
function parseArray<T>(raw: string | null): T[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
return Array.isArray(parsed) ? parsed as T[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeChannelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function channelNameKey(type: 'text' | 'voice', name: string): string {
|
||||
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function normalizeServerTags(raw: string | null): string[] {
|
||||
return parseArray<unknown>(raw).filter((tag): tag is string => typeof tag === 'string');
|
||||
}
|
||||
|
||||
function normalizeServerChannels(raw: string | null) {
|
||||
const channels = parseArray<LegacyServerChannel>(raw);
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
return channels.flatMap((channel, index) => {
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = isFiniteNumber(channel.position) ? channel.position : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
|
||||
return [{
|
||||
channelId: id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export class NormalizeServerArrays1000000000004 implements MigrationInterface {
|
||||
name = 'NormalizeServerArrays1000000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_tags" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
PRIMARY KEY ("serverId", "position")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_tags_serverId" ON "server_tags" ("serverId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_channels" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("serverId", "channelId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channels_serverId" ON "server_channels" ("serverId")`);
|
||||
|
||||
const rows = await queryRunner.query(`SELECT "id", "tags", "channels" FROM "servers"`) as LegacyServerRow[];
|
||||
|
||||
for (const row of rows) {
|
||||
for (const [position, tag] of normalizeServerTags(row.tags).entries()) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "server_tags" ("serverId", "position", "value") VALUES (?, ?, ?)`,
|
||||
[row.id, position, tag]
|
||||
);
|
||||
}
|
||||
|
||||
for (const channel of normalizeServerChannels(row.channels)) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "server_channels" ("serverId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
|
||||
[row.id, channel.channelId, channel.name, channel.type, channel.position]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "servers_next" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"ownerPublicKey" TEXT NOT NULL,
|
||||
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"lastSeen" INTEGER NOT NULL,
|
||||
"passwordHash" TEXT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash")
|
||||
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash"
|
||||
FROM "servers"
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE "servers"`);
|
||||
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_channels"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_tags"`);
|
||||
}
|
||||
}
|
||||
196
server/src/migrations/1000000000005-ServerRoleAccessControl.ts
Normal file
196
server/src/migrations/1000000000005-ServerRoleAccessControl.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
type LegacyServerRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
ownerId: string;
|
||||
ownerPublicKey: string;
|
||||
passwordHash: string | null;
|
||||
isPrivate: number;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
createdAt: number;
|
||||
lastSeen: number;
|
||||
};
|
||||
|
||||
const SYSTEM_ROLE_IDS = {
|
||||
everyone: 'system-everyone',
|
||||
moderator: 'system-moderator',
|
||||
admin: 'system-admin'
|
||||
} as const;
|
||||
|
||||
function buildDefaultServerRoles() {
|
||||
return [
|
||||
{
|
||||
roleId: SYSTEM_ROLE_IDS.everyone,
|
||||
name: '@everyone',
|
||||
color: '#6b7280',
|
||||
position: 0,
|
||||
isSystem: 1,
|
||||
manageServer: 'inherit',
|
||||
manageRoles: 'inherit',
|
||||
manageChannels: 'inherit',
|
||||
manageIcon: 'inherit',
|
||||
kickMembers: 'inherit',
|
||||
banMembers: 'inherit',
|
||||
manageBans: 'inherit',
|
||||
deleteMessages: 'inherit',
|
||||
joinVoice: 'allow',
|
||||
shareScreen: 'allow',
|
||||
uploadFiles: 'allow'
|
||||
},
|
||||
{
|
||||
roleId: SYSTEM_ROLE_IDS.moderator,
|
||||
name: 'Moderator',
|
||||
color: '#10b981',
|
||||
position: 200,
|
||||
isSystem: 1,
|
||||
manageServer: 'inherit',
|
||||
manageRoles: 'inherit',
|
||||
manageChannels: 'inherit',
|
||||
manageIcon: 'inherit',
|
||||
kickMembers: 'allow',
|
||||
banMembers: 'inherit',
|
||||
manageBans: 'inherit',
|
||||
deleteMessages: 'allow',
|
||||
joinVoice: 'inherit',
|
||||
shareScreen: 'inherit',
|
||||
uploadFiles: 'inherit'
|
||||
},
|
||||
{
|
||||
roleId: SYSTEM_ROLE_IDS.admin,
|
||||
name: 'Admin',
|
||||
color: '#60a5fa',
|
||||
position: 300,
|
||||
isSystem: 1,
|
||||
manageServer: 'inherit',
|
||||
manageRoles: 'inherit',
|
||||
manageChannels: 'allow',
|
||||
manageIcon: 'allow',
|
||||
kickMembers: 'allow',
|
||||
banMembers: 'allow',
|
||||
manageBans: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
joinVoice: 'inherit',
|
||||
shareScreen: 'inherit',
|
||||
uploadFiles: 'inherit'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export class ServerRoleAccessControl1000000000005 implements MigrationInterface {
|
||||
name = 'ServerRoleAccessControl1000000000005';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_roles" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"color" TEXT,
|
||||
"position" INTEGER NOT NULL,
|
||||
"isSystem" INTEGER NOT NULL DEFAULT 0,
|
||||
"manageServer" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"manageRoles" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"manageChannels" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"manageIcon" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"kickMembers" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"banMembers" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"manageBans" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"deleteMessages" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"joinVoice" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"shareScreen" TEXT NOT NULL DEFAULT 'inherit',
|
||||
"uploadFiles" TEXT NOT NULL DEFAULT 'inherit',
|
||||
PRIMARY KEY ("serverId", "roleId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_roles_serverId" ON "server_roles" ("serverId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_user_roles" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"oderId" TEXT,
|
||||
PRIMARY KEY ("serverId", "userId", "roleId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_user_roles_serverId" ON "server_user_roles" ("serverId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_channel_permissions" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"targetType" TEXT NOT NULL,
|
||||
"targetId" TEXT NOT NULL,
|
||||
"permission" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
PRIMARY KEY ("serverId", "channelId", "targetType", "targetId", "permission")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channel_permissions_serverId" ON "server_channel_permissions" ("serverId")`);
|
||||
|
||||
const servers = await queryRunner.query(`
|
||||
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen"
|
||||
FROM "servers"
|
||||
`) as LegacyServerRow[];
|
||||
|
||||
for (const server of servers) {
|
||||
for (const role of buildDefaultServerRoles()) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "server_roles" ("serverId", "roleId", "name", "color", "position", "isSystem", "manageServer", "manageRoles", "manageChannels", "manageIcon", "kickMembers", "banMembers", "manageBans", "deleteMessages", "joinVoice", "shareScreen", "uploadFiles") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
server.id,
|
||||
role.roleId,
|
||||
role.name,
|
||||
role.color,
|
||||
role.position,
|
||||
role.isSystem,
|
||||
role.manageServer,
|
||||
role.manageRoles,
|
||||
role.manageChannels,
|
||||
role.manageIcon,
|
||||
role.kickMembers,
|
||||
role.banMembers,
|
||||
role.manageBans,
|
||||
role.deleteMessages,
|
||||
role.joinVoice,
|
||||
role.shareScreen,
|
||||
role.uploadFiles
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "servers_next" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"ownerPublicKey" TEXT NOT NULL,
|
||||
"passwordHash" TEXT,
|
||||
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"lastSeen" INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "slowModeInterval", "createdAt", "lastSeen")
|
||||
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", 0, "createdAt", "lastSeen"
|
||||
FROM "servers"
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE "servers"`);
|
||||
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_channel_permissions"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_user_roles"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_roles"`);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
||||
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
|
||||
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
|
||||
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
||||
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
||||
|
||||
export const serverMigrations = [InitialSchema1000000000000];
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
ServerAccessControl1000000000001,
|
||||
ServerChannels1000000000002,
|
||||
RepairLegacyVoiceChannels1000000000003,
|
||||
NormalizeServerArrays1000000000004,
|
||||
ServerRoleAccessControl1000000000005
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ import proxyRouter from './proxy';
|
||||
import usersRouter from './users';
|
||||
import serversRouter from './servers';
|
||||
import joinRequestsRouter from './join-requests';
|
||||
import { invitesApiRouter, invitePageRouter } from './invites';
|
||||
|
||||
export function registerRoutes(app: Express): void {
|
||||
app.use('/api', healthRouter);
|
||||
@@ -12,5 +13,7 @@ export function registerRoutes(app: Express): void {
|
||||
app.use('/api', proxyRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/servers', serversRouter);
|
||||
app.use('/api/invites', invitesApiRouter);
|
||||
app.use('/api/requests', joinRequestsRouter);
|
||||
app.use('/invite', invitePageRouter);
|
||||
}
|
||||
|
||||
57
server/src/routes/invite-utils.ts
Normal file
57
server/src/routes/invite-utils.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
function buildOrigin(protocol: string, host: string): string {
|
||||
return `${protocol}://${host}`.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function originFromUrl(url: URL): string {
|
||||
return buildOrigin(url.protocol.replace(':', ''), url.host);
|
||||
}
|
||||
|
||||
export function getRequestOrigin(request: Request): string {
|
||||
const forwardedProtoHeader = request.get('x-forwarded-proto');
|
||||
const forwardedHostHeader = request.get('x-forwarded-host');
|
||||
const protocol = forwardedProtoHeader?.split(',')[0]?.trim() || request.protocol;
|
||||
const host = forwardedHostHeader?.split(',')[0]?.trim() || request.get('host') || 'localhost';
|
||||
|
||||
return buildOrigin(protocol, host);
|
||||
}
|
||||
|
||||
export function deriveWebAppOrigin(signalOrigin: string): string {
|
||||
const url = new URL(signalOrigin);
|
||||
|
||||
if (url.hostname === 'signal.toju.app' && !url.port) {
|
||||
return 'https://web.toju.app';
|
||||
}
|
||||
|
||||
if (url.hostname.startsWith('signal.')) {
|
||||
url.hostname = url.hostname.replace(/^signal\./, 'web.');
|
||||
|
||||
if (url.port === '3001') {
|
||||
url.port = '4200';
|
||||
}
|
||||
|
||||
return originFromUrl(url);
|
||||
}
|
||||
|
||||
if (url.port === '3001') {
|
||||
url.port = '4200';
|
||||
return originFromUrl(url);
|
||||
}
|
||||
|
||||
return 'https://web.toju.app';
|
||||
}
|
||||
|
||||
export function buildInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||
return `${signalOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}`;
|
||||
}
|
||||
|
||||
export function buildBrowserInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||
const browserOrigin = deriveWebAppOrigin(signalOrigin);
|
||||
|
||||
return `${browserOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
||||
}
|
||||
|
||||
export function buildAppInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||
return `toju://invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
||||
}
|
||||
330
server/src/routes/invites.ts
Normal file
330
server/src/routes/invites.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { Router } from 'express';
|
||||
import { getUserById } from '../cqrs';
|
||||
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 = 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 = 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()
|
||||
}));
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateJoinRequestStatus
|
||||
} from '../cqrs';
|
||||
import { notifyUser } from '../websocket/broadcast';
|
||||
import { resolveServerPermission } from '../services/server-permissions.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -19,7 +20,7 @@ router.put('/:id', async (req, res) => {
|
||||
|
||||
const server = await getServerById(request.serverId);
|
||||
|
||||
if (!server || server.ownerId !== ownerId)
|
||||
if (!server || !ownerId || !resolveServerPermission(server, String(ownerId), 'manageServer'))
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
|
||||
await updateJoinRequestStatus(id, status as JoinRequestPayload['status']);
|
||||
|
||||
@@ -1,29 +1,128 @@
|
||||
import { Router } from 'express';
|
||||
import { Response, Router } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ServerPayload, JoinRequestPayload } from '../cqrs/types';
|
||||
import { ServerChannelPayload, ServerPayload } from '../cqrs/types';
|
||||
import {
|
||||
getAllPublicServers,
|
||||
getServerById,
|
||||
getUserById,
|
||||
upsertServer,
|
||||
deleteServer,
|
||||
createJoinRequest,
|
||||
getPendingRequestsForServer
|
||||
} from '../cqrs';
|
||||
import { notifyServerOwner } from '../websocket/broadcast';
|
||||
import {
|
||||
banServerUser,
|
||||
buildSignalingUrl,
|
||||
createServerInvite,
|
||||
joinServerWithAccess,
|
||||
leaveServerUser,
|
||||
passwordHashForInput,
|
||||
ServerAccessError,
|
||||
kickServerUser,
|
||||
ensureServerMembership,
|
||||
unbanServerUser
|
||||
} from '../services/server-access.service';
|
||||
import {
|
||||
buildAppInviteUrl,
|
||||
buildBrowserInviteUrl,
|
||||
buildInviteUrl,
|
||||
getRequestOrigin
|
||||
} from './invite-utils';
|
||||
import {
|
||||
canManageServerUpdate,
|
||||
canModerateServerMember,
|
||||
resolveServerPermission
|
||||
} from '../services/server-permissions.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
async function enrichServer(server: ServerPayload) {
|
||||
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||
return `${type}:${name.toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
const channels: ServerChannelPayload[] = [];
|
||||
|
||||
for (const [index, channel] of value.entries()) {
|
||||
if (!channel || typeof channel !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = typeof channel.position === 'number' ? channel.position : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seen.has(id) || seenNames.has(nameKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(id);
|
||||
seenNames.add(nameKey);
|
||||
channels.push({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
});
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||
const owner = await getUserById(server.ownerId);
|
||||
const { passwordHash, ...publicServer } = server;
|
||||
|
||||
return {
|
||||
...server,
|
||||
...publicServer,
|
||||
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||
ownerName: owner?.displayName,
|
||||
sourceUrl,
|
||||
userCount: server.currentUsers
|
||||
};
|
||||
}
|
||||
|
||||
function sendAccessError(error: unknown, res: Response) {
|
||||
if (error instanceof ServerAccessError) {
|
||||
res.status(error.status).json({ error: error.message, errorCode: error.code });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Unhandled server access error:', error);
|
||||
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
|
||||
}
|
||||
|
||||
async function buildInviteResponse(invite: {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
createdBy: string;
|
||||
createdByDisplayName: string | null;
|
||||
serverId: string;
|
||||
}, server: ServerPayload, signalOrigin: string) {
|
||||
return {
|
||||
id: invite.id,
|
||||
serverId: invite.serverId,
|
||||
createdAt: invite.createdAt,
|
||||
expiresAt: invite.expiresAt,
|
||||
inviteUrl: buildInviteUrl(signalOrigin, invite.id),
|
||||
browserUrl: buildBrowserInviteUrl(signalOrigin, invite.id),
|
||||
appUrl: buildAppInviteUrl(signalOrigin, invite.id),
|
||||
sourceUrl: signalOrigin,
|
||||
createdBy: invite.createdBy,
|
||||
createdByDisplayName: invite.createdByDisplayName ?? undefined,
|
||||
isExpired: invite.expiresAt <= Date.now(),
|
||||
server: await enrichServer(server, signalOrigin)
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||
|
||||
@@ -54,45 +153,238 @@ router.get('/', async (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
|
||||
const {
|
||||
id: clientId,
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
ownerPublicKey,
|
||||
isPrivate,
|
||||
maxUsers,
|
||||
password,
|
||||
tags,
|
||||
channels
|
||||
} = req.body;
|
||||
|
||||
if (!name || !ownerId || !ownerPublicKey)
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
|
||||
const passwordHash = passwordHashForInput(password);
|
||||
const server: ServerPayload = {
|
||||
id: clientId || uuidv4(),
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
ownerPublicKey,
|
||||
hasPassword: !!passwordHash,
|
||||
passwordHash,
|
||||
isPrivate: isPrivate ?? false,
|
||||
maxUsers: maxUsers ?? 0,
|
||||
currentUsers: 0,
|
||||
tags: tags ?? [],
|
||||
channels: normalizeServerChannels(channels),
|
||||
createdAt: Date.now(),
|
||||
lastSeen: Date.now()
|
||||
};
|
||||
|
||||
await upsertServer(server);
|
||||
res.status(201).json(server);
|
||||
await ensureServerMembership(server.id, ownerId);
|
||||
|
||||
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
|
||||
});
|
||||
|
||||
router.put('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { currentOwnerId, ...updates } = req.body;
|
||||
const {
|
||||
currentOwnerId,
|
||||
actingRole,
|
||||
password,
|
||||
hasPassword: _ignoredHasPassword,
|
||||
passwordHash: _ignoredPasswordHash,
|
||||
channels,
|
||||
...updates
|
||||
} = req.body;
|
||||
const existing = await getServerById(id);
|
||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||
|
||||
if (!existing)
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (existing.ownerId !== authenticatedOwnerId)
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
if (!authenticatedOwnerId) {
|
||||
return res.status(400).json({ error: 'Missing currentOwnerId' });
|
||||
}
|
||||
|
||||
const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() };
|
||||
if (!canManageServerUpdate(existing, authenticatedOwnerId, {
|
||||
...updates,
|
||||
channels,
|
||||
password,
|
||||
actingRole
|
||||
})) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
|
||||
const hasChannelsUpdate = Object.prototype.hasOwnProperty.call(req.body, 'channels');
|
||||
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
|
||||
const server: ServerPayload = {
|
||||
...existing,
|
||||
...updates,
|
||||
channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels,
|
||||
hasPassword: !!nextPasswordHash,
|
||||
passwordHash: nextPasswordHash,
|
||||
lastSeen: Date.now()
|
||||
};
|
||||
|
||||
await upsertServer(server);
|
||||
res.json(server);
|
||||
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||
});
|
||||
|
||||
router.post('/:id/join', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { userId, password, inviteId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await joinServerWithAccess({
|
||||
serverId,
|
||||
userId: String(userId),
|
||||
password: typeof password === 'string' ? password : undefined,
|
||||
inviteId: typeof inviteId === 'string' ? inviteId : undefined
|
||||
});
|
||||
const origin = getRequestOrigin(req);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
signalingUrl: buildSignalingUrl(origin),
|
||||
joinedBefore: result.joinedBefore,
|
||||
via: result.via,
|
||||
server: await enrichServer(result.server, origin)
|
||||
});
|
||||
} catch (error) {
|
||||
sendAccessError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/invites', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { requesterUserId, requesterDisplayName } = req.body;
|
||||
|
||||
if (!requesterUserId) {
|
||||
return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' });
|
||||
}
|
||||
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
try {
|
||||
const invite = await createServerInvite(
|
||||
serverId,
|
||||
String(requesterUserId),
|
||||
typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined
|
||||
);
|
||||
|
||||
res.status(201).json(await buildInviteResponse(invite, server, getRequestOrigin(req)));
|
||||
} catch (error) {
|
||||
sendAccessError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/moderation/kick', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, 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 (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) {
|
||||
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, 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 (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) {
|
||||
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, 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 (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
await unbanServerUser({
|
||||
serverId,
|
||||
banId: typeof banId === 'string' ? banId : undefined,
|
||||
userId: typeof targetUserId === 'string' ? targetUserId : undefined
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/:id/leave', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { userId } = req.body;
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
||||
}
|
||||
|
||||
await leaveServerUser(serverId, String(userId));
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/:id/heartbeat', async (req, res) => {
|
||||
@@ -128,32 +420,6 @@ router.delete('/:id', async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/:id/join', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { userId, userPublicKey, displayName } = req.body;
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server)
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
const request: JoinRequestPayload = {
|
||||
id: uuidv4(),
|
||||
serverId,
|
||||
userId,
|
||||
userPublicKey,
|
||||
displayName,
|
||||
status: server.isPrivate ? 'pending' : 'approved',
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
await createJoinRequest(request);
|
||||
|
||||
if (server.isPrivate)
|
||||
notifyServerOwner(server.ownerId, { type: 'join_request', request });
|
||||
|
||||
res.status(201).json(request);
|
||||
});
|
||||
|
||||
router.get('/:id/requests', async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { ownerId } = req.query;
|
||||
@@ -170,4 +436,15 @@ router.get('/:id/requests', async (req, res) => {
|
||||
res.json({ requests });
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const server = await getServerById(id);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
400
server/src/services/server-access.service.ts
Normal file
400
server/src/services/server-access.service.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
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 { loadServerRelationsMap } from '../cqrs/relations';
|
||||
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);
|
||||
}
|
||||
|
||||
async function toServerPayload(server: ServerEntity): Promise<ServerPayload> {
|
||||
const relationsByServerId = await loadServerRelationsMap(getDataSource(), [server.id]);
|
||||
|
||||
return rowToServer(server, relationsByServerId.get(server.id));
|
||||
}
|
||||
|
||||
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: ServerPayload } | 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: await toServerPayload(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: await toServerPayload(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: await toServerPayload(server),
|
||||
via: 'invite'
|
||||
};
|
||||
}
|
||||
|
||||
const membership = await findServerMembership(server.id, options.userId);
|
||||
|
||||
if (membership) {
|
||||
await ensureServerMembership(server.id, options.userId);
|
||||
|
||||
return {
|
||||
joinedBefore: true,
|
||||
server: await toServerPayload(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: await toServerPayload(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: await toServerPayload(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 });
|
||||
}
|
||||
}
|
||||
191
server/src/services/server-permissions.service.ts
Normal file
191
server/src/services/server-permissions.service.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type {
|
||||
AccessRolePayload,
|
||||
PermissionStatePayload,
|
||||
RoleAssignmentPayload,
|
||||
ServerPayload,
|
||||
ServerPermissionKeyPayload
|
||||
} from '../cqrs/types';
|
||||
import { normalizeServerRoleAssignments, normalizeServerRoles } from '../cqrs/relations';
|
||||
|
||||
const SYSTEM_ROLE_IDS = {
|
||||
everyone: 'system-everyone'
|
||||
} as const;
|
||||
|
||||
interface ServerIdentity {
|
||||
userId: string;
|
||||
oderId?: string;
|
||||
}
|
||||
|
||||
function getServerRoles(server: Pick<ServerPayload, 'roles'>): AccessRolePayload[] {
|
||||
return normalizeServerRoles(server.roles);
|
||||
}
|
||||
|
||||
function getServerAssignments(server: Pick<ServerPayload, 'roleAssignments' | 'roles'>): RoleAssignmentPayload[] {
|
||||
return normalizeServerRoleAssignments(server.roleAssignments, getServerRoles(server));
|
||||
}
|
||||
|
||||
function matchesIdentity(identity: ServerIdentity, assignment: RoleAssignmentPayload): boolean {
|
||||
return assignment.userId === identity.userId
|
||||
|| assignment.oderId === identity.userId
|
||||
|| (!!identity.oderId && (assignment.userId === identity.oderId || assignment.oderId === identity.oderId));
|
||||
}
|
||||
|
||||
function resolveAssignedRoleIds(server: Pick<ServerPayload, 'roleAssignments' | 'roles'>, identity: ServerIdentity): string[] {
|
||||
const assignment = getServerAssignments(server).find((candidateAssignment) => matchesIdentity(identity, candidateAssignment));
|
||||
|
||||
return assignment?.roleIds ?? [];
|
||||
}
|
||||
|
||||
function compareRolePosition(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
|
||||
if (firstRole.position !== secondRole.position) {
|
||||
return firstRole.position - secondRole.position;
|
||||
}
|
||||
|
||||
return firstRole.name.localeCompare(secondRole.name, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function resolveRolePermissionState(
|
||||
roles: readonly AccessRolePayload[],
|
||||
assignedRoleIds: readonly string[],
|
||||
permission: ServerPermissionKeyPayload
|
||||
): PermissionStatePayload {
|
||||
const roleLookup = new Map(roles.map((role) => [role.id, role]));
|
||||
const effectiveRoles = [roleLookup.get(SYSTEM_ROLE_IDS.everyone), ...assignedRoleIds.map((roleId) => roleLookup.get(roleId))]
|
||||
.filter((role): role is AccessRolePayload => !!role)
|
||||
.sort(compareRolePosition);
|
||||
|
||||
let state: PermissionStatePayload = 'inherit';
|
||||
|
||||
for (const role of effectiveRoles) {
|
||||
const nextState = role.permissions?.[permission] ?? 'inherit';
|
||||
|
||||
if (nextState !== 'inherit') {
|
||||
state = nextState;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function resolveHighestRole(
|
||||
server: Pick<ServerPayload, 'roleAssignments' | 'roles'>,
|
||||
identity: ServerIdentity
|
||||
): AccessRolePayload | null {
|
||||
const roles = getServerRoles(server);
|
||||
const assignedRoleIds = resolveAssignedRoleIds(server, identity);
|
||||
const roleLookup = new Map(roles.map((role) => [role.id, role]));
|
||||
const assignedRoles = assignedRoleIds
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is AccessRolePayload => !!role)
|
||||
.sort((firstRole, secondRole) => compareRolePosition(secondRole, firstRole));
|
||||
|
||||
return assignedRoles[0] ?? roleLookup.get(SYSTEM_ROLE_IDS.everyone) ?? null;
|
||||
}
|
||||
|
||||
export function isServerOwner(server: Pick<ServerPayload, 'ownerId'>, actorUserId: string): boolean {
|
||||
return server.ownerId === actorUserId;
|
||||
}
|
||||
|
||||
export function resolveServerPermission(
|
||||
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
|
||||
actorUserId: string,
|
||||
permission: ServerPermissionKeyPayload,
|
||||
actorOderId?: string
|
||||
): boolean {
|
||||
if (isServerOwner(server, actorUserId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const roles = getServerRoles(server);
|
||||
const assignedRoleIds = resolveAssignedRoleIds(server, {
|
||||
userId: actorUserId,
|
||||
oderId: actorOderId
|
||||
});
|
||||
|
||||
return resolveRolePermissionState(roles, assignedRoleIds, permission) === 'allow';
|
||||
}
|
||||
|
||||
export function canManageServerUpdate(
|
||||
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
|
||||
actorUserId: string,
|
||||
updates: Record<string, unknown>,
|
||||
actorOderId?: string
|
||||
): boolean {
|
||||
if (isServerOwner(server, actorUserId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof updates['ownerId'] === 'string' || typeof updates['ownerPublicKey'] === 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requiredPermissions = new Set<ServerPermissionKeyPayload>();
|
||||
|
||||
if (
|
||||
Array.isArray(updates['roles'])
|
||||
|| Array.isArray(updates['roleAssignments'])
|
||||
|| Array.isArray(updates['channelPermissions'])
|
||||
) {
|
||||
requiredPermissions.add('manageRoles');
|
||||
}
|
||||
|
||||
if (Array.isArray(updates['channels'])) {
|
||||
requiredPermissions.add('manageChannels');
|
||||
}
|
||||
|
||||
if (typeof updates['icon'] === 'string') {
|
||||
requiredPermissions.add('manageIcon');
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updates['name'] === 'string'
|
||||
|| typeof updates['description'] === 'string'
|
||||
|| typeof updates['isPrivate'] === 'boolean'
|
||||
|| typeof updates['maxUsers'] === 'number'
|
||||
|| typeof updates['password'] === 'string'
|
||||
|| typeof updates['passwordHash'] === 'string'
|
||||
|| typeof updates['slowModeInterval'] === 'number'
|
||||
) {
|
||||
requiredPermissions.add('manageServer');
|
||||
}
|
||||
|
||||
return Array.from(requiredPermissions).every((permission) =>
|
||||
resolveServerPermission(server, actorUserId, permission, actorOderId)
|
||||
);
|
||||
}
|
||||
|
||||
export function canModerateServerMember(
|
||||
server: Pick<ServerPayload, 'ownerId' | 'roleAssignments' | 'roles'>,
|
||||
actorUserId: string,
|
||||
targetUserId: string,
|
||||
permission: 'kickMembers' | 'banMembers' | 'manageBans',
|
||||
actorOderId?: string,
|
||||
targetOderId?: string
|
||||
): boolean {
|
||||
if (!actorUserId || !targetUserId || actorUserId === targetUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isServerOwner(server, targetUserId) && !isServerOwner(server, actorUserId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isServerOwner(server, actorUserId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resolveServerPermission(server, actorUserId, permission, actorOderId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actorRole = resolveHighestRole(server, {
|
||||
userId: actorUserId,
|
||||
oderId: actorOderId
|
||||
});
|
||||
const targetRole = resolveHighestRole(server, {
|
||||
userId: targetUserId,
|
||||
oderId: targetOderId
|
||||
});
|
||||
|
||||
return (actorRole?.position ?? 0) > (targetRole?.position ?? 0);
|
||||
}
|
||||
@@ -1,36 +1,59 @@
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import { broadcastToServer, findUserByOderId } from './broadcast';
|
||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||
|
||||
interface WsMessage {
|
||||
[key: string]: unknown;
|
||||
type: string;
|
||||
}
|
||||
|
||||
function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
/** Sends the current user list for a given server to a single connected user. */
|
||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
const users = Array.from(connectedUsers.values())
|
||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId && cu.displayName)
|
||||
.map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' }));
|
||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
|
||||
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
||||
|
||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||
}
|
||||
|
||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
user.oderId = String(message['oderId'] || connectionId);
|
||||
user.displayName = String(message['displayName'] || 'Anonymous');
|
||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
}
|
||||
|
||||
function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const sid = String(message['serverId']);
|
||||
|
||||
if (!sid)
|
||||
return;
|
||||
|
||||
const authorization = await authorizeWebSocketJoin(sid, user.oderId);
|
||||
|
||||
if (!authorization.allowed) {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'access_denied',
|
||||
serverId: sid,
|
||||
reason: authorization.reason
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const isNew = !user.serverIds.has(sid);
|
||||
|
||||
user.serverIds.add(sid);
|
||||
user.viewedServerId = sid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
||||
|
||||
sendServerUsers(user, sid);
|
||||
|
||||
@@ -38,7 +61,7 @@ function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId:
|
||||
broadcastToServer(sid, {
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName ?? 'Anonymous',
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
serverId: sid
|
||||
}, user.oderId);
|
||||
}
|
||||
@@ -49,7 +72,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
|
||||
|
||||
user.viewedServerId = viewSid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`);
|
||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
||||
|
||||
sendServerUsers(user, viewSid);
|
||||
}
|
||||
@@ -70,8 +93,9 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
||||
broadcastToServer(leaveSid, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName ?? 'Anonymous',
|
||||
serverId: leaveSid
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
serverId: leaveSid,
|
||||
serverIds: Array.from(user.serverIds)
|
||||
}, user.oderId);
|
||||
}
|
||||
|
||||
@@ -110,18 +134,22 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
|
||||
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim()
|
||||
? message['channelId'].trim()
|
||||
: 'general';
|
||||
|
||||
if (typingSid && user.serverIds.has(typingSid)) {
|
||||
broadcastToServer(typingSid, {
|
||||
type: 'user_typing',
|
||||
serverId: typingSid,
|
||||
channelId,
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName
|
||||
}, user.oderId);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleWebSocketMessage(connectionId: string, message: WsMessage): void {
|
||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
if (!user)
|
||||
@@ -133,7 +161,7 @@ export function handleWebSocketMessage(connectionId: string, message: WsMessage)
|
||||
break;
|
||||
|
||||
case 'join_server':
|
||||
handleJoinServer(user, message, connectionId);
|
||||
await handleJoinServer(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'view_server':
|
||||
|
||||
@@ -9,39 +9,87 @@ import { connectedUsers } from './state';
|
||||
import { broadcastToServer } from './broadcast';
|
||||
import { handleWebSocketMessage } from './handler';
|
||||
|
||||
/** How often to ping all connected clients (ms). */
|
||||
const PING_INTERVAL_MS = 30_000;
|
||||
/** Maximum time a client can go without a pong before we consider it dead (ms). */
|
||||
const PONG_TIMEOUT_MS = 45_000;
|
||||
|
||||
function removeDeadConnection(connectionId: string): void {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
if (user) {
|
||||
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||
|
||||
user.serverIds.forEach((sid) => {
|
||||
broadcastToServer(sid, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
serverId: sid,
|
||||
serverIds: []
|
||||
}, user.oderId);
|
||||
});
|
||||
|
||||
try {
|
||||
user.ws.terminate();
|
||||
} catch {
|
||||
console.warn(`Failed to terminate WebSocket for ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||
}
|
||||
}
|
||||
|
||||
connectedUsers.delete(connectionId);
|
||||
}
|
||||
|
||||
export function setupWebSocket(server: Server<typeof IncomingMessage, typeof ServerResponse>): void {
|
||||
const wss = new WebSocketServer({ server });
|
||||
// Periodically ping all clients and reap dead connections
|
||||
const pingInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
connectedUsers.forEach((user, connectionId) => {
|
||||
if (now - user.lastPong > PONG_TIMEOUT_MS) {
|
||||
removeDeadConnection(connectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
user.ws.ping();
|
||||
} catch {
|
||||
console.warn(`Failed to ping client ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, PING_INTERVAL_MS);
|
||||
|
||||
wss.on('close', () => clearInterval(pingInterval));
|
||||
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
const connectionId = uuidv4();
|
||||
const now = Date.now();
|
||||
|
||||
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
|
||||
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set(), lastPong: now });
|
||||
|
||||
ws.on('message', (data) => {
|
||||
ws.on('pong', () => {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
if (user) {
|
||||
user.lastPong = Date.now();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
handleWebSocketMessage(connectionId, message);
|
||||
await handleWebSocketMessage(connectionId, message);
|
||||
} catch (err) {
|
||||
console.error('Invalid WebSocket message:', err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
if (user) {
|
||||
user.serverIds.forEach((sid) => {
|
||||
broadcastToServer(sid, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
serverId: sid
|
||||
}, user.oderId);
|
||||
});
|
||||
}
|
||||
|
||||
connectedUsers.delete(connectionId);
|
||||
removeDeadConnection(connectionId);
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
|
||||
|
||||
@@ -6,4 +6,6 @@ export interface ConnectedUser {
|
||||
serverIds: Set<string>;
|
||||
viewedServerId?: string;
|
||||
displayName?: string;
|
||||
/** Timestamp of the last pong received (used to detect dead connections). */
|
||||
lastPong: number;
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<div class="h-screen bg-background text-foreground flex">
|
||||
<!-- Global left servers rail always visible -->
|
||||
<aside class="w-16 flex-shrink-0 border-r border-border bg-card">
|
||||
<app-servers-rail class="h-full" />
|
||||
</aside>
|
||||
<main class="flex-1 min-w-0 relative overflow-hidden">
|
||||
<!-- Custom draggable title bar -->
|
||||
<app-title-bar />
|
||||
|
||||
@if (desktopUpdateState().restartRequired) {
|
||||
<div class="absolute inset-x-0 top-10 z-20 px-4 pt-4 pointer-events-none">
|
||||
<div class="pointer-events-auto mx-auto max-w-4xl rounded-xl border border-primary/30 bg-primary/10 p-4 shadow-2xl backdrop-blur-sm">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-foreground">Update ready to install</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="openUpdatesSettings()"
|
||||
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"
|
||||
>
|
||||
Update settings
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="restartToApplyUpdate()"
|
||||
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"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Content area fills below the title bar without global scroll -->
|
||||
<div class="absolute inset-x-0 top-10 bottom-0 overflow-auto">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Floating voice controls - shown when connected to voice and navigated away from server -->
|
||||
<app-floating-voice-controls />
|
||||
</div>
|
||||
|
||||
@if (desktopUpdateState().serverBlocked) {
|
||||
<div class="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 px-6 py-10 backdrop-blur-sm">
|
||||
<div class="w-full max-w-xl rounded-2xl border border-red-500/30 bg-card p-6 shadow-2xl">
|
||||
<h2 class="text-xl font-semibold text-foreground">Server update required</h2>
|
||||
<p class="mt-3 text-sm text-muted-foreground">
|
||||
{{ desktopUpdateState().serverBlockMessage || 'The connected server must be updated before this desktop app can continue.' }}
|
||||
</p>
|
||||
|
||||
<div class="mt-5 grid gap-4 rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
|
||||
<p class="mt-2 text-foreground">{{ desktopUpdateState().serverVersion || 'Not reported' }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
|
||||
<p class="mt-2 text-foreground">{{ desktopUpdateState().minimumServerVersion || 'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
(click)="refreshDesktopUpdateContext()"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="openNetworkSettings()"
|
||||
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Open network settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Unified Settings Modal -->
|
||||
<app-settings-modal />
|
||||
|
||||
<!-- Shared Debug Console -->
|
||||
<app-debug-console [showLauncher]="false" />
|
||||
132
src/app/app.ts
132
src/app/app.ts
@@ -1,132 +0,0 @@
|
||||
/* eslint-disable @angular-eslint/component-class-suffix */
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
inject,
|
||||
HostListener
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Router,
|
||||
RouterOutlet,
|
||||
NavigationEnd
|
||||
} from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
import { DatabaseService } from './core/services/database.service';
|
||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
||||
import { ServerDirectoryService } from './core/services/server-directory.service';
|
||||
import { TimeSyncService } from './core/services/time-sync.service';
|
||||
import { VoiceSessionService } from './core/services/voice-session.service';
|
||||
import { ExternalLinkService } from './core/services/external-link.service';
|
||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||
import { UsersActions } from './store/users/users.actions';
|
||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
import {
|
||||
ROOM_URL_PATTERN,
|
||||
STORAGE_KEY_CURRENT_USER_ID,
|
||||
STORAGE_KEY_LAST_VISITED_ROUTE
|
||||
} from './core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
ServersRailComponent,
|
||||
TitleBarComponent,
|
||||
FloatingVoiceControlsComponent,
|
||||
SettingsModalComponent,
|
||||
DebugConsoleComponent
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App implements OnInit {
|
||||
store = inject(Store);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
desktopUpdates = inject(DesktopAppUpdateService);
|
||||
desktopUpdateState = this.desktopUpdates.state;
|
||||
|
||||
private databaseService = inject(DatabaseService);
|
||||
private router = inject(Router);
|
||||
private servers = inject(ServerDirectoryService);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private timeSync = inject(TimeSyncService);
|
||||
private voiceSession = inject(VoiceSessionService);
|
||||
private externalLinks = inject(ExternalLinkService);
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onGlobalLinkClick(evt: MouseEvent): void {
|
||||
this.externalLinks.handleClick(evt);
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
void this.desktopUpdates.initialize();
|
||||
|
||||
await this.databaseService.initialize();
|
||||
|
||||
try {
|
||||
const apiBase = this.servers.getApiBaseUrl();
|
||||
|
||||
await this.timeSync.syncWithEndpoint(apiBase);
|
||||
} catch {}
|
||||
|
||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
|
||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
|
||||
if (!currentUserId) {
|
||||
if (this.router.url !== '/login' && this.router.url !== '/register') {
|
||||
this.router.navigate(['/login']).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
||||
|
||||
if (last && typeof last === 'string') {
|
||||
const current = this.router.url;
|
||||
|
||||
if (current === '/' || current === '/search') {
|
||||
this.router.navigate([last], { replaceUrl: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.router.events.subscribe((evt) => {
|
||||
if (evt instanceof NavigationEnd) {
|
||||
const url = evt.urlAfterRedirects || evt.url;
|
||||
|
||||
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);
|
||||
|
||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
||||
|
||||
this.voiceSession.checkCurrentRoute(currentRoomId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openNetworkSettings(): void {
|
||||
this.settingsModal.open('network');
|
||||
}
|
||||
|
||||
openUpdatesSettings(): void {
|
||||
this.settingsModal.open('updates');
|
||||
}
|
||||
|
||||
async refreshDesktopUpdateContext(): Promise<void> {
|
||||
await this.desktopUpdates.refreshServerContext();
|
||||
}
|
||||
|
||||
async restartToApplyUpdate(): Promise<void> {
|
||||
await this.desktopUpdates.restartToApplyUpdate();
|
||||
}
|
||||
}
|
||||
@@ -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,23 +0,0 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
export type SettingsPage = 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsModalService {
|
||||
readonly isOpen = signal(false);
|
||||
readonly activePage = signal<SettingsPage>('network');
|
||||
readonly targetServerId = signal<string | null>(null);
|
||||
|
||||
open(page: SettingsPage = 'network', serverId?: string): void {
|
||||
this.activePage.set(page);
|
||||
this.targetServerId.set(serverId ?? null);
|
||||
this.isOpen.set(true);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.isOpen.set(false);
|
||||
}
|
||||
|
||||
navigate(page: SettingsPage): void {
|
||||
this.activePage.set(page);
|
||||
}
|
||||
}
|
||||
@@ -1,976 +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 {
|
||||
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 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 _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 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()
|
||||
});
|
||||
|
||||
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> {
|
||||
const stream = await this.screenShareManager.startScreenShare(options);
|
||||
|
||||
this._isScreenSharing.set(true);
|
||||
this._screenStreamSignal.set(stream);
|
||||
return stream;
|
||||
}
|
||||
|
||||
/** Stop screen sharing and restore microphone audio on all peers. */
|
||||
stopScreenShare(): void {
|
||||
this.screenShareManager.stopScreenShare();
|
||||
this._isScreenSharing.set(false);
|
||||
this._screenStreamSignal.set(null);
|
||||
}
|
||||
|
||||
/** Disconnect from the signaling server and clean up all state. */
|
||||
disconnect(): void {
|
||||
this.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);
|
||||
}
|
||||
|
||||
/** 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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user