Compare commits
30 Commits
v1.0.28
...
0467a7b612
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1,4 +1,5 @@
|
|||||||
# Toggle SSL for local development (true/false)
|
# Toggle SSL for local development (true/false)
|
||||||
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
|
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
|
||||||
# When false: plain HTTP everywhere (only works on localhost)
|
# When false: plain HTTP everywhere (only works on localhost)
|
||||||
|
# Overrides server/data/variables.json for local development only
|
||||||
SSL=true
|
SSL=true
|
||||||
|
|||||||
43
.gitea/workflows/deploy-web-apps.yml
Normal file
43
.gitea/workflows/deploy-web-apps.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Deploy Web Apps
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: windows
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: powershell
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
env:
|
||||||
|
NODE_ENV: development
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install website dependencies
|
||||||
|
env:
|
||||||
|
NODE_ENV: development
|
||||||
|
run: npm ci --prefix website
|
||||||
|
|
||||||
|
- name: Build Toju web app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build Toju website
|
||||||
|
run: |
|
||||||
|
Push-Location website
|
||||||
|
npm run build
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
- name: Deploy both apps to IIS
|
||||||
|
run: >
|
||||||
|
./tools/deploy-web-apps.ps1
|
||||||
|
-WebsitePort 4341
|
||||||
|
-AppPort 4492
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,7 +6,9 @@
|
|||||||
/tmp
|
/tmp
|
||||||
/out-tsc
|
/out-tsc
|
||||||
/bazel-out
|
/bazel-out
|
||||||
|
*.sqlite
|
||||||
|
*/architecture.md
|
||||||
|
/docs
|
||||||
# Node
|
# Node
|
||||||
/node_modules
|
/node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|||||||
168
README.md
168
README.md
@@ -1,10 +1,14 @@
|
|||||||
|
<img src="./images/icon.png" width="100" height="100">
|
||||||
|
|
||||||
|
|
||||||
# Toju / Zoracord
|
# Toju / Zoracord
|
||||||
|
|
||||||
Desktop chat app with three parts:
|
Desktop chat app with four parts:
|
||||||
|
|
||||||
- `src/` Angular client
|
- `src/` Angular client
|
||||||
- `electron/` desktop shell, IPC, and local database
|
- `electron/` desktop shell, IPC, and local database
|
||||||
- `server/` directory server, join request API, and websocket events
|
- `server/` directory server, join request API, and websocket events
|
||||||
|
- `website/` Toju website served at toju.app
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -17,7 +21,7 @@ Desktop chat app with three parts:
|
|||||||
Root `.env`:
|
Root `.env`:
|
||||||
|
|
||||||
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
|
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
|
||||||
- `PORT=3001` changes the server port
|
- `PORT=3001` changes the server port in local development and overrides the server app setting
|
||||||
|
|
||||||
If `SSL=true`, run `./generate-cert.sh` once.
|
If `SSL=true`, run `./generate-cert.sh` once.
|
||||||
|
|
||||||
@@ -25,101 +29,10 @@ Server files:
|
|||||||
|
|
||||||
- `server/data/variables.json` holds `klipyApiKey`
|
- `server/data/variables.json` holds `klipyApiKey`
|
||||||
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
|
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
|
||||||
|
- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to)
|
||||||
## Desktop auto updates
|
- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`)
|
||||||
|
- `server/data/variables.json` can now also hold `serverPort` (1-65535)
|
||||||
The packaged Electron app now reads a hosted release manifest from the active server's `/api/health` response.
|
- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Main commands
|
## Main commands
|
||||||
|
|
||||||
@@ -143,3 +56,64 @@ Inside `server/`:
|
|||||||
- `npm run dev` starts the server with reload
|
- `npm run dev` starts the server with reload
|
||||||
- `npm run build` compiles to `dist/`
|
- `npm run build` compiles to `dist/`
|
||||||
- `npm run start` runs the compiled server
|
- `npm run start` runs the compiled server
|
||||||
|
|
||||||
|
# Images
|
||||||
|
<img src="./website/src/images/screenshots/gif.png" width="700" height="400">
|
||||||
|
<img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
|
||||||
|
|
||||||
|
## Main Toju app Structure
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `src/app/` | Main application root |
|
||||||
|
| `src/app/core/` | Core utilities, services, models |
|
||||||
|
| `src/app/domains/` | Domain-driven modules |
|
||||||
|
| `src/app/features/` | UI feature modules |
|
||||||
|
| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) |
|
||||||
|
| `src/app/shared/` | Shared UI components |
|
||||||
|
| `src/app/shared-kernel/` | Shared domain contracts & models |
|
||||||
|
| `src/app/store/` | Global state management |
|
||||||
|
| `src/assets/` | Static assets |
|
||||||
|
| `src/environments/` | Environment configs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Domains
|
||||||
|
|
||||||
|
| Path | Link |
|
||||||
|
|------|------|
|
||||||
|
| Attachment | [app/domains/attachment/README.md](src/app/domains/attachment/README.md) |
|
||||||
|
| Auth | [app/domains/auth/README.md](src/app/domains/auth/README.md) |
|
||||||
|
| Chat | [app/domains/chat/README.md](src/app/domains/chat/README.md) |
|
||||||
|
| Screen Share | [app/domains/screen-share/README.md](src/app/domains/screen-share/README.md) |
|
||||||
|
| Server Directory | [app/domains/server-directory/README.md](src/app/domains/server-directory/README.md) |
|
||||||
|
| Voice Connection | [app/domains/voice-connection/README.md](src/app/domains/voice-connection/README.md) |
|
||||||
|
| Voice Session | [app/domains/voice-session/README.md](src/app/domains/voice-session/README.md) |
|
||||||
|
| Domains Root | [app/domains/README.md](src/app/domains/README.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
| Path | Link |
|
||||||
|
|------|------|
|
||||||
|
| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) |
|
||||||
|
| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Shared Kernel
|
||||||
|
|
||||||
|
| Path | Link |
|
||||||
|
|------|------|
|
||||||
|
| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Entry Points
|
||||||
|
|
||||||
|
| File | Link |
|
||||||
|
|------|------|
|
||||||
|
| Main | [main.ts](src/main.ts) |
|
||||||
|
| Index HTML | [index.html](src/index.html) |
|
||||||
|
| App Root | [app/app.ts](src/app/app.ts) |
|
||||||
|
|||||||
2
dev.sh
2
dev.sh
@@ -33,4 +33,4 @@ fi
|
|||||||
exec npx concurrently --kill-others \
|
exec npx concurrently --kill-others \
|
||||||
"cd server && npm run dev" \
|
"cd server && npm run dev" \
|
||||||
"$NG_SERVE" \
|
"$NG_SERVE" \
|
||||||
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron . --no-sandbox --disable-dev-shm-usage"
|
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage"
|
||||||
|
|||||||
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 {
|
export function configureAppFlags(): void {
|
||||||
linuxSpecificFlags();
|
linuxSpecificFlags();
|
||||||
audioFlags();
|
|
||||||
networkFlags();
|
networkFlags();
|
||||||
setupGpuEncodingFlags();
|
setupGpuEncodingFlags();
|
||||||
chromiumFlags();
|
chromiumFlags();
|
||||||
@@ -15,21 +14,40 @@ function chromiumFlags(): void {
|
|||||||
|
|
||||||
// Suppress Autofill devtools errors
|
// Suppress Autofill devtools errors
|
||||||
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
|
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') {
|
if (process.platform === 'linux') {
|
||||||
// Use the new PipeWire-based audio pipeline on Linux for better screen share audio capture support
|
// PipeWire-based audio pipeline for screen share audio capture
|
||||||
app.commandLine.appendSwitch('enable-features', 'AudioServiceOutOfProcess');
|
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 {
|
function linuxSpecificFlags(): void {
|
||||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
if (process.platform !== 'linux') {
|
||||||
if (process.platform === 'linux') {
|
return;
|
||||||
app.commandLine.appendSwitch('no-sandbox');
|
|
||||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
function networkFlags(): void {
|
||||||
@@ -46,11 +64,6 @@ function setupGpuEncodingFlags(): void {
|
|||||||
app.disableHardwareAcceleration();
|
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-gpu-rasterization');
|
||||||
app.commandLine.appendSwitch('enable-zero-copy');
|
app.commandLine.appendSwitch('enable-zero-copy');
|
||||||
app.commandLine.appendSwitch('enable-native-gpu-memory-buffers');
|
app.commandLine.appendSwitch('enable-native-gpu-memory-buffers');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
||||||
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
||||||
|
import { synchronizeAutoStartSetting } from './auto-start';
|
||||||
import {
|
import {
|
||||||
initializeDatabase,
|
initializeDatabase,
|
||||||
destroyDatabase,
|
destroyDatabase,
|
||||||
@@ -24,6 +25,7 @@ export function registerAppLifecycle(): void {
|
|||||||
setupCqrsHandlers();
|
setupCqrsHandlers();
|
||||||
setupWindowControlHandlers();
|
setupWindowControlHandlers();
|
||||||
setupSystemHandlers();
|
setupSystemHandlers();
|
||||||
|
await synchronizeAutoStartSetting();
|
||||||
initializeDesktopUpdater();
|
initializeDesktopUpdater();
|
||||||
await createWindow();
|
await createWindow();
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ interface SinkInputDetails extends ShortSinkInputEntry {
|
|||||||
properties: Record<string, string>;
|
properties: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DescendantProcessInfo {
|
||||||
|
ids: ReadonlySet<string>;
|
||||||
|
binaryNames: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
interface PactlJsonSinkInputEntry {
|
interface PactlJsonSinkInputEntry {
|
||||||
index?: number | string;
|
index?: number | string;
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
@@ -44,6 +49,7 @@ interface LinuxScreenShareAudioRoutingState {
|
|||||||
screenShareLoopbackModuleId: string | null;
|
screenShareLoopbackModuleId: string | null;
|
||||||
voiceLoopbackModuleId: string | null;
|
voiceLoopbackModuleId: string | null;
|
||||||
rerouteIntervalId: ReturnType<typeof setInterval> | null;
|
rerouteIntervalId: ReturnType<typeof setInterval> | null;
|
||||||
|
subscribeProcess: ChildProcess | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinuxScreenShareMonitorCaptureState {
|
interface LinuxScreenShareMonitorCaptureState {
|
||||||
@@ -77,7 +83,8 @@ const routingState: LinuxScreenShareAudioRoutingState = {
|
|||||||
restoreSinkName: null,
|
restoreSinkName: null,
|
||||||
screenShareLoopbackModuleId: null,
|
screenShareLoopbackModuleId: null,
|
||||||
voiceLoopbackModuleId: null,
|
voiceLoopbackModuleId: null,
|
||||||
rerouteIntervalId: null
|
rerouteIntervalId: null,
|
||||||
|
subscribeProcess: null
|
||||||
};
|
};
|
||||||
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
|
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
|
||||||
captureId: null,
|
captureId: null,
|
||||||
@@ -126,12 +133,21 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
|||||||
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
|
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
|
||||||
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
|
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
|
||||||
|
|
||||||
await setDefaultSink(SCREEN_SHARE_SINK_NAME);
|
// Set the default sink to the voice sink so that new app audio
|
||||||
await moveSinkInputs(SCREEN_SHARE_SINK_NAME, (sinkName) => !!sinkName && sinkName !== SCREEN_SHARE_SINK_NAME && sinkName !== VOICE_SINK_NAME);
|
// streams (received WebRTC voice) never land on the screenshare
|
||||||
|
// capture sink. This prevents the feedback loop where remote
|
||||||
|
// voice audio was picked up by parec before the reroute interval
|
||||||
|
// could move the stream away.
|
||||||
|
await setDefaultSink(VOICE_SINK_NAME);
|
||||||
|
|
||||||
routingState.active = true;
|
routingState.active = true;
|
||||||
await rerouteAppSinkInputsToVoiceSink();
|
|
||||||
|
// Let the combined reroute decide placement for every existing
|
||||||
|
// stream. This avoids briefly shoving the app's own playback to the
|
||||||
|
// screenshare sink before ownership detection can move it back.
|
||||||
|
await rerouteSinkInputs();
|
||||||
startSinkInputRerouteLoop();
|
startSinkInputRerouteLoop();
|
||||||
|
startSubscribeWatcher();
|
||||||
|
|
||||||
return buildRoutingInfo(true, true);
|
return buildRoutingInfo(true, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -148,6 +164,7 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
|||||||
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
|
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
|
||||||
const restoreSinkName = routingState.restoreSinkName;
|
const restoreSinkName = routingState.restoreSinkName;
|
||||||
|
|
||||||
|
stopSubscribeWatcher();
|
||||||
stopSinkInputRerouteLoop();
|
stopSinkInputRerouteLoop();
|
||||||
await stopLinuxScreenShareMonitorCapture();
|
await stopLinuxScreenShareMonitorCapture();
|
||||||
|
|
||||||
@@ -166,6 +183,7 @@ export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean>
|
|||||||
routingState.restoreSinkName = null;
|
routingState.restoreSinkName = null;
|
||||||
routingState.screenShareLoopbackModuleId = null;
|
routingState.screenShareLoopbackModuleId = null;
|
||||||
routingState.voiceLoopbackModuleId = null;
|
routingState.voiceLoopbackModuleId = null;
|
||||||
|
routingState.subscribeProcess = null;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -425,34 +443,52 @@ async function setDefaultSink(sinkName: string): Promise<void> {
|
|||||||
await runPactl('set-default-sink', sinkName);
|
await runPactl('set-default-sink', sinkName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rerouteAppSinkInputsToVoiceSink(): Promise<void> {
|
/**
|
||||||
|
* Combined reroute that enforces sink placement in both directions:
|
||||||
|
* - App-owned sink inputs that are NOT on the voice sink are moved there.
|
||||||
|
* - Non-app sink inputs that ARE on the voice sink are moved to the
|
||||||
|
* screenshare sink so they are captured by parec.
|
||||||
|
*
|
||||||
|
* This two-way approach, combined with the voice sink being the PulseAudio
|
||||||
|
* default, ensures that received WebRTC voice audio can never leak into the
|
||||||
|
* screenshare monitor source.
|
||||||
|
*/
|
||||||
|
async function rerouteSinkInputs(): Promise<void> {
|
||||||
const [
|
const [
|
||||||
sinks,
|
sinks,
|
||||||
sinkInputs,
|
sinkInputs,
|
||||||
descendantProcessIds
|
descendantProcessInfo
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
listSinks(),
|
listSinks(),
|
||||||
listSinkInputDetails(),
|
listSinkInputDetails(),
|
||||||
collectDescendantProcessIds(process.pid)
|
collectDescendantProcessInfo(process.pid)
|
||||||
]);
|
]);
|
||||||
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
sinkInputs.map(async (sinkInput) => {
|
sinkInputs.map(async (sinkInput) => {
|
||||||
if (!isAppOwnedSinkInput(sinkInput, descendantProcessIds)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
||||||
|
const appOwned = isAppOwnedSinkInput(sinkInput, descendantProcessInfo);
|
||||||
|
|
||||||
|
// App-owned streams must stay on the voice sink.
|
||||||
|
if (appOwned && sinkName !== VOICE_SINK_NAME) {
|
||||||
|
try {
|
||||||
|
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
||||||
|
} catch {
|
||||||
|
// Streams can disappear or be recreated while rerouting.
|
||||||
|
}
|
||||||
|
|
||||||
if (sinkName === VOICE_SINK_NAME) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Non-app streams sitting on the voice sink should be moved to the
|
||||||
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
// screenshare sink for desktop-audio capture.
|
||||||
} catch {
|
if (!appOwned && sinkName === VOICE_SINK_NAME) {
|
||||||
// Streams can disappear or be recreated while rerouting.
|
try {
|
||||||
|
await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME);
|
||||||
|
} catch {
|
||||||
|
// Streams can disappear or be recreated while rerouting.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -515,7 +551,7 @@ function startSinkInputRerouteLoop(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
routingState.rerouteIntervalId = setInterval(() => {
|
routingState.rerouteIntervalId = setInterval(() => {
|
||||||
void rerouteAppSinkInputsToVoiceSink();
|
void rerouteSinkInputs();
|
||||||
}, REROUTE_INTERVAL_MS);
|
}, REROUTE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,13 +564,108 @@ function stopSinkInputRerouteLoop(): void {
|
|||||||
routingState.rerouteIntervalId = null;
|
routingState.rerouteIntervalId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns `pactl subscribe` to receive PulseAudio events in real time.
|
||||||
|
* When a new or changed sink-input is detected, a reroute is triggered
|
||||||
|
* immediately instead of waiting for the next interval tick. This
|
||||||
|
* drastically reduces the time non-app desktop audio spends on the
|
||||||
|
* voice sink before being moved to the screenshare sink.
|
||||||
|
*/
|
||||||
|
function startSubscribeWatcher(): void {
|
||||||
|
if (routingState.subscribeProcess) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let proc: ChildProcess;
|
||||||
|
|
||||||
|
try {
|
||||||
|
proc = spawn('pactl', ['subscribe'], {
|
||||||
|
env: process.env,
|
||||||
|
stdio: [
|
||||||
|
'ignore',
|
||||||
|
'pipe',
|
||||||
|
'ignore'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If pactl subscribe fails to spawn, the interval loop still covers us.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
routingState.subscribeProcess = proc;
|
||||||
|
|
||||||
|
let pending = false;
|
||||||
|
|
||||||
|
proc.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
if (!routingState.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = chunk.toString();
|
||||||
|
|
||||||
|
if (/Event '(?:new|change)' on sink-input/.test(text)) {
|
||||||
|
if (!pending) {
|
||||||
|
pending = true;
|
||||||
|
|
||||||
|
// Batch rapid-fire events with a short delay.
|
||||||
|
setTimeout(() => {
|
||||||
|
pending = false;
|
||||||
|
void rerouteSinkInputs();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', () => {
|
||||||
|
if (routingState.subscribeProcess === proc) {
|
||||||
|
routingState.subscribeProcess = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', () => {
|
||||||
|
if (routingState.subscribeProcess === proc) {
|
||||||
|
routingState.subscribeProcess = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSubscribeWatcher(): void {
|
||||||
|
const proc = routingState.subscribeProcess;
|
||||||
|
|
||||||
|
if (!proc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
routingState.subscribeProcess = null;
|
||||||
|
|
||||||
|
if (!proc.killed) {
|
||||||
|
proc.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isAppOwnedSinkInput(
|
function isAppOwnedSinkInput(
|
||||||
sinkInput: SinkInputDetails,
|
sinkInput: SinkInputDetails,
|
||||||
descendantProcessIds: ReadonlySet<string>
|
descendantProcessInfo: DescendantProcessInfo
|
||||||
): boolean {
|
): boolean {
|
||||||
const processId = sinkInput.properties['application.process.id'];
|
const processId = sinkInput.properties['application.process.id'];
|
||||||
|
|
||||||
return typeof processId === 'string' && descendantProcessIds.has(processId);
|
if (typeof processId === 'string' && descendantProcessInfo.ids.has(processId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processBinary = normalizeProcessBinary(sinkInput.properties['application.process.binary']);
|
||||||
|
|
||||||
|
if (processBinary && descendantProcessInfo.binaryNames.has(processBinary)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicationName = normalizeProcessBinary(sinkInput.properties['application.name']);
|
||||||
|
|
||||||
|
if (applicationName && descendantProcessInfo.binaryNames.has(applicationName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveSinkInputs(
|
async function moveSinkInputs(
|
||||||
@@ -697,31 +828,45 @@ async function listSinkInputDetails(): Promise<SinkInputDetails[]> {
|
|||||||
return entries.filter((entry) => !!entry.sinkIndex);
|
return entries.filter((entry) => !!entry.sinkIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<string>> {
|
async function collectDescendantProcessInfo(rootProcessId: number): Promise<DescendantProcessInfo> {
|
||||||
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid='], {
|
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm='], {
|
||||||
env: process.env
|
env: process.env
|
||||||
});
|
});
|
||||||
const childrenByParentId = new Map<string, string[]>();
|
const childrenByParentId = new Map<string, string[]>();
|
||||||
|
const binaryNameByProcessId = new Map<string, string>();
|
||||||
|
|
||||||
stdout
|
stdout
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.forEach((line) => {
|
.forEach((line) => {
|
||||||
const [pid, ppid] = line.split(/\s+/);
|
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||||
|
|
||||||
if (!pid || !ppid) {
|
if (!match) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
,
|
||||||
|
pid,
|
||||||
|
ppid,
|
||||||
|
command
|
||||||
|
] = match;
|
||||||
const siblings = childrenByParentId.get(ppid) ?? [];
|
const siblings = childrenByParentId.get(ppid) ?? [];
|
||||||
|
|
||||||
siblings.push(pid);
|
siblings.push(pid);
|
||||||
childrenByParentId.set(ppid, siblings);
|
childrenByParentId.set(ppid, siblings);
|
||||||
|
|
||||||
|
const normalizedBinaryName = normalizeProcessBinary(command);
|
||||||
|
|
||||||
|
if (normalizedBinaryName) {
|
||||||
|
binaryNameByProcessId.set(pid, normalizedBinaryName);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const rootId = `${rootProcessId}`;
|
const rootId = `${rootProcessId}`;
|
||||||
const descendantIds = new Set<string>([rootId]);
|
const descendantIds = new Set<string>([rootId]);
|
||||||
|
const descendantBinaryNames = new Set<string>();
|
||||||
const queue = [rootId];
|
const queue = [rootId];
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
@@ -731,6 +876,12 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const binaryName = binaryNameByProcessId.get(currentId);
|
||||||
|
|
||||||
|
if (binaryName) {
|
||||||
|
descendantBinaryNames.add(binaryName);
|
||||||
|
}
|
||||||
|
|
||||||
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
||||||
if (descendantIds.has(childId)) {
|
if (descendantIds.has(childId)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -741,7 +892,30 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return descendantIds;
|
return {
|
||||||
|
ids: descendantIds,
|
||||||
|
binaryNames: descendantBinaryNames
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcessBinary(value: string | undefined): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basename = trimmed
|
||||||
|
.split(/[\\/]/)
|
||||||
|
.pop()
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
return basename || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripSurroundingQuotes(value: string): string {
|
function stripSurroundingQuotes(value: string): string {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
|||||||
topic: room.topic ?? null,
|
topic: room.topic ?? null,
|
||||||
hostId: room.hostId,
|
hostId: room.hostId,
|
||||||
password: room.password ?? null,
|
password: room.password ?? null,
|
||||||
|
hasPassword: room.hasPassword ? 1 : 0,
|
||||||
isPrivate: room.isPrivate ? 1 : 0,
|
isPrivate: room.isPrivate ? 1 : 0,
|
||||||
createdAt: room.createdAt,
|
createdAt: room.createdAt,
|
||||||
userCount: room.userCount ?? 0,
|
userCount: room.userCount ?? 0,
|
||||||
@@ -20,7 +21,10 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
|||||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||||
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
||||||
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
||||||
members: room.members != null ? JSON.stringify(room.members) : null
|
members: room.members != null ? JSON.stringify(room.members) : null,
|
||||||
|
sourceId: room.sourceId ?? null,
|
||||||
|
sourceName: room.sourceName ?? null,
|
||||||
|
sourceUrl: room.sourceUrl ?? null
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
await repo.save(entity);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from './utils/applyUpdates';
|
} from './utils/applyUpdates';
|
||||||
|
|
||||||
const ROOM_TRANSFORMS: TransformMap = {
|
const ROOM_TRANSFORMS: TransformMap = {
|
||||||
|
hasPassword: boolToInt,
|
||||||
isPrivate: boolToInt,
|
isPrivate: boolToInt,
|
||||||
userCount: (val) => (val ?? 0),
|
userCount: (val) => (val ?? 0),
|
||||||
permissions: jsonOrNull,
|
permissions: jsonOrNull,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function rowToRoom(row: RoomEntity) {
|
|||||||
topic: row.topic ?? undefined,
|
topic: row.topic ?? undefined,
|
||||||
hostId: row.hostId,
|
hostId: row.hostId,
|
||||||
password: row.password ?? undefined,
|
password: row.password ?? undefined,
|
||||||
|
hasPassword: !!row.hasPassword,
|
||||||
isPrivate: !!row.isPrivate,
|
isPrivate: !!row.isPrivate,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
userCount: row.userCount,
|
userCount: row.userCount,
|
||||||
@@ -65,7 +66,10 @@ export function rowToRoom(row: RoomEntity) {
|
|||||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||||
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
||||||
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
||||||
members: row.members ? JSON.parse(row.members) : undefined
|
members: row.members ? JSON.parse(row.members) : undefined,
|
||||||
|
sourceId: row.sourceId ?? undefined,
|
||||||
|
sourceName: row.sourceName ?? undefined,
|
||||||
|
sourceUrl: row.sourceUrl ?? undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export interface RoomPayload {
|
|||||||
topic?: string;
|
topic?: string;
|
||||||
hostId: string;
|
hostId: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
isPrivate?: boolean;
|
isPrivate?: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
userCount?: number;
|
userCount?: number;
|
||||||
@@ -93,6 +94,9 @@ export interface RoomPayload {
|
|||||||
permissions?: unknown;
|
permissions?: unknown;
|
||||||
channels?: unknown[];
|
channels?: unknown[];
|
||||||
members?: unknown[];
|
members?: unknown[];
|
||||||
|
sourceId?: string;
|
||||||
|
sourceName?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanPayload {
|
export interface BanPayload {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
|||||||
|
|
||||||
export interface DesktopSettings {
|
export interface DesktopSettings {
|
||||||
autoUpdateMode: AutoUpdateMode;
|
autoUpdateMode: AutoUpdateMode;
|
||||||
|
autoStart: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -19,6 +20,7 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
|||||||
|
|
||||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||||
autoUpdateMode: 'auto',
|
autoUpdateMode: 'auto',
|
||||||
|
autoStart: true,
|
||||||
hardwareAcceleration: true,
|
hardwareAcceleration: true,
|
||||||
manifestUrls: [],
|
manifestUrls: [],
|
||||||
preferredVersion: null,
|
preferredVersion: null,
|
||||||
@@ -81,6 +83,9 @@ export function readDesktopSettings(): DesktopSettings {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
||||||
|
autoStart: typeof parsed.autoStart === 'boolean'
|
||||||
|
? parsed.autoStart
|
||||||
|
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||||
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
||||||
? parsed.vaapiVideoEncode
|
? parsed.vaapiVideoEncode
|
||||||
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
||||||
@@ -102,6 +107,9 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
|||||||
};
|
};
|
||||||
const nextSettings: DesktopSettings = {
|
const nextSettings: DesktopSettings = {
|
||||||
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
||||||
|
autoStart: typeof mergedSettings.autoStart === 'boolean'
|
||||||
|
? mergedSettings.autoStart
|
||||||
|
: DEFAULT_DESKTOP_SETTINGS.autoStart,
|
||||||
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||||
? mergedSettings.hardwareAcceleration
|
? mergedSettings.hardwareAcceleration
|
||||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export class RoomEntity {
|
|||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
password!: string | null;
|
password!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { default: 0 })
|
||||||
|
hasPassword!: number;
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
isPrivate!: number;
|
isPrivate!: number;
|
||||||
|
|
||||||
@@ -50,4 +53,13 @@ export class RoomEntity {
|
|||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
members!: string | null;
|
members!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
sourceId!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
sourceName!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
sourceUrl!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
restartToApplyUpdate,
|
restartToApplyUpdate,
|
||||||
type DesktopUpdateServerContext
|
type DesktopUpdateServerContext
|
||||||
} from '../update/desktop-updater';
|
} from '../update/desktop-updater';
|
||||||
|
import { consumePendingDeepLink } from '../app/deep-links';
|
||||||
|
import { synchronizeAutoStartSetting } from '../app/auto-start';
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const FILE_CLIPBOARD_FORMATS = [
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
@@ -83,6 +85,57 @@ interface ClipboardFilePayload {
|
|||||||
path?: string;
|
path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLinuxDisplayServer(): string {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
if (ozonePlatform === 'wayland') {
|
||||||
|
return 'Wayland';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ozonePlatform === 'x11') {
|
||||||
|
return 'X11';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ozonePlatformHint = app.commandLine.getSwitchValue('ozone-platform-hint')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
if (ozonePlatformHint === 'wayland') {
|
||||||
|
return 'Wayland';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ozonePlatformHint === 'x11') {
|
||||||
|
return 'X11';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionType = String(process.env['XDG_SESSION_TYPE'] || '').trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
if (sessionType === 'wayland') {
|
||||||
|
return 'Wayland';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionType === 'x11') {
|
||||||
|
return 'X11';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(process.env['WAYLAND_DISPLAY'] || '').trim().length > 0) {
|
||||||
|
return 'Wayland';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(process.env['DISPLAY'] || '').trim().length > 0) {
|
||||||
|
return 'X11';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown (Linux)';
|
||||||
|
}
|
||||||
|
|
||||||
function isSupportedClipboardFileFormat(format: string): boolean {
|
function isSupportedClipboardFileFormat(format: string): boolean {
|
||||||
return FILE_CLIPBOARD_FORMATS.some(
|
return FILE_CLIPBOARD_FORMATS.some(
|
||||||
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
||||||
@@ -194,6 +247,10 @@ async function readClipboardFiles(): Promise<ClipboardFilePayload[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setupSystemHandlers(): void {
|
export function setupSystemHandlers(): void {
|
||||||
|
ipcMain.on('get-linux-display-server', (event) => {
|
||||||
|
event.returnValue = resolveLinuxDisplayServer();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('open-external', async (_event, url: string) => {
|
ipcMain.handle('open-external', async (_event, url: string) => {
|
||||||
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||||
await shell.openExternal(url);
|
await shell.openExternal(url);
|
||||||
@@ -203,17 +260,35 @@ export function setupSystemHandlers(): void {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-sources', async () => {
|
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
|
||||||
const sources = await desktopCapturer.getSources({
|
|
||||||
types: ['window', 'screen'],
|
|
||||||
thumbnailSize: { width: 150, height: 150 }
|
|
||||||
});
|
|
||||||
|
|
||||||
return sources.map((source) => ({
|
ipcMain.handle('get-sources', async () => {
|
||||||
id: source.id,
|
try {
|
||||||
name: source.name,
|
const thumbnailSize = { width: 240, height: 150 };
|
||||||
thumbnail: source.thumbnail.toDataURL()
|
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 () => {
|
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||||
@@ -255,6 +330,7 @@ export function setupSystemHandlers(): void {
|
|||||||
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
||||||
const snapshot = updateDesktopSettings(patch);
|
const snapshot = updateDesktopSettings(patch);
|
||||||
|
|
||||||
|
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||||
await handleDesktopSettingsChanged();
|
await handleDesktopSettingsChanged();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
import { initializeDeepLinkHandling } from './app/deep-links';
|
||||||
import { configureAppFlags } from './app/flags';
|
import { configureAppFlags } from './app/flags';
|
||||||
import { registerAppLifecycle } from './app/lifecycle';
|
import { registerAppLifecycle } from './app/lifecycle';
|
||||||
|
|
||||||
configureAppFlags();
|
configureAppFlags();
|
||||||
registerAppLifecycle();
|
|
||||||
|
if (initializeDeepLinkHandling()) {
|
||||||
|
registerAppLifecycle();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddRoomSourceAndPasswordState1000000000002 implements MigrationInterface {
|
||||||
|
name = 'AddRoomSourceAndPasswordState1000000000002';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "hasPassword" INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceId" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceName" TEXT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceUrl" TEXT`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE "rooms"
|
||||||
|
SET "hasPassword" = CASE
|
||||||
|
WHEN "password" IS NOT NULL AND TRIM("password") <> '' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceUrl"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceName"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceId"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "hasPassword"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Command, Query } from './cqrs/types';
|
|||||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
||||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
||||||
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||||
|
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||||
|
|
||||||
export interface LinuxScreenShareAudioRoutingInfo {
|
export interface LinuxScreenShareAudioRoutingInfo {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -83,7 +84,24 @@ export interface DesktopUpdateState {
|
|||||||
targetVersion: string | null;
|
targetVersion: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readLinuxDisplayServer(): string {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const displayServer = ipcRenderer.sendSync('get-linux-display-server');
|
||||||
|
|
||||||
|
return typeof displayServer === 'string' && displayServer.trim().length > 0
|
||||||
|
? displayServer
|
||||||
|
: 'Unknown (Linux)';
|
||||||
|
} catch {
|
||||||
|
return 'Unknown (Linux)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
maximizeWindow: () => void;
|
maximizeWindow: () => void;
|
||||||
closeWindow: () => void;
|
closeWindow: () => void;
|
||||||
@@ -98,8 +116,10 @@ export interface ElectronAPI {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
|
consumePendingDeepLink: () => Promise<string | null>;
|
||||||
getDesktopSettings: () => Promise<{
|
getDesktopSettings: () => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
|
autoStart: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -113,12 +133,14 @@ export interface ElectronAPI {
|
|||||||
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||||
setDesktopSettings: (patch: {
|
setDesktopSettings: (patch: {
|
||||||
autoUpdateMode?: 'auto' | 'off' | 'version';
|
autoUpdateMode?: 'auto' | 'off' | 'version';
|
||||||
|
autoStart?: boolean;
|
||||||
hardwareAcceleration?: boolean;
|
hardwareAcceleration?: boolean;
|
||||||
manifestUrls?: string[];
|
manifestUrls?: string[];
|
||||||
preferredVersion?: string | null;
|
preferredVersion?: string | null;
|
||||||
vaapiVideoEncode?: boolean;
|
vaapiVideoEncode?: boolean;
|
||||||
}) => Promise<{
|
}) => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
|
autoStart: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -126,6 +148,7 @@ export interface ElectronAPI {
|
|||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
}>;
|
}>;
|
||||||
relaunchApp: () => Promise<boolean>;
|
relaunchApp: () => Promise<boolean>;
|
||||||
|
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||||
readFile: (filePath: string) => Promise<string>;
|
readFile: (filePath: string) => Promise<string>;
|
||||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
@@ -139,6 +162,7 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const electronAPI: ElectronAPI = {
|
const electronAPI: ElectronAPI = {
|
||||||
|
linuxDisplayServer: readLinuxDisplayServer(),
|
||||||
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
||||||
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
||||||
closeWindow: () => ipcRenderer.send('window-close'),
|
closeWindow: () => ipcRenderer.send('window-close'),
|
||||||
@@ -180,6 +204,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
|
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
||||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||||
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
|
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
|
||||||
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
||||||
@@ -198,6 +223,17 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||||
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
||||||
|
onDeepLinkReceived: (listener) => {
|
||||||
|
const wrappedListener = (_event: Electron.IpcRendererEvent, url: string) => {
|
||||||
|
listener(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcRenderer.on(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
||||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||||
|
|||||||
@@ -729,7 +729,7 @@ export function restartToApplyUpdate(): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
autoUpdater.quitAndInstall(false, true);
|
autoUpdater.quitAndInstall(true, true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
|
desktopCapturer,
|
||||||
|
session,
|
||||||
shell
|
shell
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -61,6 +63,34 @@ export async function createWindow(): Promise<void> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
session.defaultSession.setDisplayMediaRequestHandler(
|
||||||
|
async (_request, respond) => {
|
||||||
|
// On Linux/Wayland the system picker (useSystemPicker: true) handles
|
||||||
|
// the portal. This handler is only reached if the system picker is
|
||||||
|
// unavailable (e.g. X11 without a portal). Fall back to
|
||||||
|
// desktopCapturer so the user still gets something.
|
||||||
|
try {
|
||||||
|
const sources = await desktopCapturer.getSources({
|
||||||
|
types: ['window', 'screen'],
|
||||||
|
thumbnailSize: { width: 150, height: 150 }
|
||||||
|
});
|
||||||
|
const firstSource = sources[0];
|
||||||
|
|
||||||
|
if (firstSource) {
|
||||||
|
respond({ video: firstSource });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// desktopCapturer also unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
respond({});
|
||||||
|
},
|
||||||
|
{ useSystemPicker: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env['NODE_ENV'] === 'development') {
|
if (process.env['NODE_ENV'] === 'development') {
|
||||||
const devUrl = process.env['SSL'] === 'true'
|
const devUrl = process.env['SSL'] === 'true'
|
||||||
? 'https://localhost:4200'
|
? 'https://localhost:4200'
|
||||||
|
|||||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||||
|
"auto-launch": "^5.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
@@ -45,11 +46,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.0.4",
|
"@angular/build": "^21.0.4",
|
||||||
"@angular/cli": "^21.2.1",
|
"@angular/cli": "^21.0.4",
|
||||||
"@angular/compiler-cli": "^21.0.0",
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
|
"@types/auto-launch": "^5.0.5",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -10816,6 +10818,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/auto-launch": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/auto-launch/-/auto-launch-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-/nGvQZSzM/pvCMCh4Gt2kIeiUmOP/cKGJbjlInI+A+5MoV/7XmT56DJ6EU8bqc3+ItxEe4UC2GVspmPzcCc8cg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -12875,6 +12884,11 @@
|
|||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/applescript": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/applescript/-/applescript-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ=="
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -12968,6 +12982,22 @@
|
|||||||
"node": ">= 4.0.0"
|
"node": ">= 4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/auto-launch": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/auto-launch/-/auto-launch-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-OgxiAm4q9EBf9EeXdPBiVNENaWE3jUZofwrhAkWjHDYGezu1k3FRZHU8V2FBxGuSJOHzKmTJEd0G7L7/0xDGFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"applescript": "^1.0.0",
|
||||||
|
"mkdirp": "^0.5.1",
|
||||||
|
"path-is-absolute": "^1.0.0",
|
||||||
|
"untildify": "^3.0.2",
|
||||||
|
"winreg": "1.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.23",
|
"version": "10.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||||
@@ -22285,9 +22315,7 @@
|
|||||||
"version": "0.5.6",
|
"version": "0.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.6"
|
"minimist": "^1.2.6"
|
||||||
},
|
},
|
||||||
@@ -23745,7 +23773,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -29571,6 +29598,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/untildify": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/upath": {
|
"node_modules/upath": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
|
||||||
@@ -31161,6 +31197,12 @@
|
|||||||
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/winreg": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/winreg/-/winreg-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-IHpzORub7kYlb8A43Iig3reOvlcBJGX9gZ0WycHhghHtA65X0LYnMRuJs+aH1abVnMJztQkvQNlltnbPi5aGIA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
24
package.json
24
package.json
@@ -11,20 +11,20 @@
|
|||||||
"prebuild": "npm run bundle:rnnoise",
|
"prebuild": "npm run bundle:rnnoise",
|
||||||
"prestart": "npm run bundle:rnnoise",
|
"prestart": "npm run bundle:rnnoise",
|
||||||
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
|
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
|
||||||
"start": "npm run ng serve",
|
"start": "ng serve",
|
||||||
"build": "npm run ng build",
|
"build": "ng build",
|
||||||
"build:electron": "tsc -p tsconfig.electron.json",
|
"build:electron": "tsc -p tsconfig.electron.json",
|
||||||
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
||||||
"build:prod": "npm run ng build --configuration production --base-href='./'",
|
"build:prod": "ng build --configuration production --base-href='./'",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"server:build": "cd server && npm run build",
|
"server:build": "cd server && npm run build",
|
||||||
"server:start": "cd server && npm start",
|
"server:start": "cd server && npm start",
|
||||||
"server:dev": "cd server && npm run dev",
|
"server:dev": "cd server && npm run dev",
|
||||||
"electron": "npm run ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
|
"electron": "ng build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
|
||||||
"electron:dev": "concurrently \"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:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
||||||
"electron:full": "./dev.sh",
|
"electron:full": "./dev.sh",
|
||||||
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron . --no-sandbox --disable-dev-shm-usage\"",
|
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
|
||||||
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
||||||
"migration:create": "typeorm migration:create electron/migrations/New",
|
"migration:create": "typeorm migration:create electron/migrations/New",
|
||||||
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
|
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"electron:build:all": "npm run build:prod && npm run build:electron && electron-builder --win --mac --linux",
|
"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: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",
|
"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",
|
"dev:app": "npm run electron:dev",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||||
|
"auto-launch": "^5.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
|
"@types/auto-launch": "^5.0.5",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -120,6 +122,14 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"appId": "com.metoyou.app",
|
"appId": "com.metoyou.app",
|
||||||
"productName": "MetoYou",
|
"productName": "MetoYou",
|
||||||
|
"protocols": [
|
||||||
|
{
|
||||||
|
"name": "Toju Invite Links",
|
||||||
|
"schemes": [
|
||||||
|
"toju"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron"
|
"output": "dist-electron"
|
||||||
},
|
},
|
||||||
|
|||||||
23
public/web.config
Normal file
23
public/web.config
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<system.webServer>
|
||||||
|
<staticContent>
|
||||||
|
<remove fileExtension=".wasm" />
|
||||||
|
<remove fileExtension=".webmanifest" />
|
||||||
|
<mimeMap fileExtension=".wasm" mimeType="application/wasm" />
|
||||||
|
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
|
||||||
|
</staticContent>
|
||||||
|
<rewrite>
|
||||||
|
<rules>
|
||||||
|
<rule name="Angular Routes" stopProcessing="true">
|
||||||
|
<match url=".*" />
|
||||||
|
<conditions logicalGrouping="MatchAll">
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="/index.html" />
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</rewrite>
|
||||||
|
</system.webServer>
|
||||||
|
</configuration>
|
||||||
Binary file not shown.
@@ -5,6 +5,7 @@ import { registerRoutes } from './routes';
|
|||||||
export function createApp(): express.Express {
|
export function createApp(): express.Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
app.set('trust proxy', true);
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { resolveRuntimePath } from '../runtime-paths';
|
import { resolveRuntimePath } from '../runtime-paths';
|
||||||
|
|
||||||
|
export type ServerHttpProtocol = 'http' | 'https';
|
||||||
|
|
||||||
export interface ServerVariablesConfig {
|
export interface ServerVariablesConfig {
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
releaseManifestUrl: string;
|
releaseManifestUrl: string;
|
||||||
|
serverPort: number;
|
||||||
|
serverProtocol: ServerHttpProtocol;
|
||||||
|
serverHost: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATA_DIR = resolveRuntimePath('data');
|
const DATA_DIR = resolveRuntimePath('data');
|
||||||
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
||||||
|
const DEFAULT_SERVER_PORT = 3001;
|
||||||
|
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
||||||
|
|
||||||
function normalizeKlipyApiKey(value: unknown): string {
|
function normalizeKlipyApiKey(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -18,6 +25,51 @@ function normalizeReleaseManifestUrl(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeServerHost(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerProtocol(
|
||||||
|
value: unknown,
|
||||||
|
fallback: ServerHttpProtocol = DEFAULT_SERVER_PROTOCOL
|
||||||
|
): ServerHttpProtocol {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? 'https' : 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (normalized === 'https' || normalized === 'true') {
|
||||||
|
return 'https';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === 'http' || normalized === 'false') {
|
||||||
|
return 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): number {
|
||||||
|
const parsed = typeof value === 'number'
|
||||||
|
? value
|
||||||
|
: typeof value === 'string'
|
||||||
|
? Number.parseInt(value.trim(), 10)
|
||||||
|
: Number.NaN;
|
||||||
|
|
||||||
|
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535
|
||||||
|
? parsed
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||||
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
|
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
|
||||||
if (!fs.existsSync(VARIABLES_FILE)) {
|
if (!fs.existsSync(VARIABLES_FILE)) {
|
||||||
return { rawContents: '', parsed: {} };
|
return { rawContents: '', parsed: {} };
|
||||||
@@ -52,10 +104,14 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { rawContents, parsed } = readRawVariables();
|
const { rawContents, parsed } = readRawVariables();
|
||||||
|
const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed;
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...parsed,
|
...remainingParsed,
|
||||||
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey),
|
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
||||||
releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl)
|
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||||
|
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||||
|
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||||
|
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress)
|
||||||
};
|
};
|
||||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||||
|
|
||||||
@@ -65,7 +121,10 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
klipyApiKey: normalized.klipyApiKey,
|
klipyApiKey: normalized.klipyApiKey,
|
||||||
releaseManifestUrl: normalized.releaseManifestUrl
|
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||||
|
serverPort: normalized.serverPort,
|
||||||
|
serverProtocol: normalized.serverProtocol,
|
||||||
|
serverHost: normalized.serverHost
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,3 +143,29 @@ export function hasKlipyApiKey(): boolean {
|
|||||||
export function getReleaseManifestUrl(): string {
|
export function getReleaseManifestUrl(): string {
|
||||||
return getVariablesConfig().releaseManifestUrl;
|
return getVariablesConfig().releaseManifestUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getServerProtocol(): ServerHttpProtocol {
|
||||||
|
if (hasEnvironmentOverride(process.env.SSL)) {
|
||||||
|
return normalizeServerProtocol(process.env.SSL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVariablesConfig().serverProtocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerPort(): number {
|
||||||
|
if (hasEnvironmentOverride(process.env.PORT)) {
|
||||||
|
return normalizeServerPort(process.env.PORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVariablesConfig().serverPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerHost(): string | undefined {
|
||||||
|
const serverHost = getVariablesConfig().serverHost;
|
||||||
|
|
||||||
|
return serverHost || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHttpsServerEnabled(): boolean {
|
||||||
|
return getServerProtocol() === 'https';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { ServerEntity, JoinRequestEntity } from '../../../entities';
|
import {
|
||||||
|
ServerEntity,
|
||||||
|
JoinRequestEntity,
|
||||||
|
ServerMembershipEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerBanEntity
|
||||||
|
} from '../../../entities';
|
||||||
import { DeleteServerCommand } from '../../types';
|
import { DeleteServerCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { serverId } = command.payload;
|
const { serverId } = command.payload;
|
||||||
|
|
||||||
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
||||||
|
await dataSource.getRepository(ServerMembershipEntity).delete({ serverId });
|
||||||
|
await dataSource.getRepository(ServerInviteEntity).delete({ serverId });
|
||||||
|
await dataSource.getRepository(ServerBanEntity).delete({ serverId });
|
||||||
await dataSource.getRepository(ServerEntity).delete(serverId);
|
await dataSource.getRepository(ServerEntity).delete(serverId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
|
|||||||
description: server.description ?? null,
|
description: server.description ?? null,
|
||||||
ownerId: server.ownerId,
|
ownerId: server.ownerId,
|
||||||
ownerPublicKey: server.ownerPublicKey,
|
ownerPublicKey: server.ownerPublicKey,
|
||||||
|
passwordHash: server.passwordHash ?? null,
|
||||||
isPrivate: server.isPrivate ? 1 : 0,
|
isPrivate: server.isPrivate ? 1 : 0,
|
||||||
maxUsers: server.maxUsers,
|
maxUsers: server.maxUsers,
|
||||||
currentUsers: server.currentUsers,
|
currentUsers: server.currentUsers,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export function rowToServer(row: ServerEntity): ServerPayload {
|
|||||||
description: row.description ?? undefined,
|
description: row.description ?? undefined,
|
||||||
ownerId: row.ownerId,
|
ownerId: row.ownerId,
|
||||||
ownerPublicKey: row.ownerPublicKey,
|
ownerPublicKey: row.ownerPublicKey,
|
||||||
|
hasPassword: !!row.passwordHash,
|
||||||
|
passwordHash: row.passwordHash ?? undefined,
|
||||||
isPrivate: !!row.isPrivate,
|
isPrivate: !!row.isPrivate,
|
||||||
maxUsers: row.maxUsers,
|
maxUsers: row.maxUsers,
|
||||||
currentUsers: row.currentUsers,
|
currentUsers: row.currentUsers,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface ServerPayload {
|
|||||||
description?: string;
|
description?: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
ownerPublicKey: string;
|
ownerPublicKey: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
|
passwordHash?: string | null;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
maxUsers: number;
|
maxUsers: number;
|
||||||
currentUsers: number;
|
currentUsers: number;
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { DataSource } from 'typeorm';
|
|||||||
import {
|
import {
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
JoinRequestEntity
|
JoinRequestEntity,
|
||||||
|
ServerMembershipEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerBanEntity
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { serverMigrations } from '../migrations';
|
import { serverMigrations } from '../migrations';
|
||||||
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
||||||
@@ -51,7 +54,10 @@ export async function initDatabase(): Promise<void> {
|
|||||||
entities: [
|
entities: [
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
JoinRequestEntity
|
JoinRequestEntity,
|
||||||
|
ServerMembershipEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerBanEntity
|
||||||
],
|
],
|
||||||
migrations: serverMigrations,
|
migrations: serverMigrations,
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
|
|||||||
35
server/src/entities/ServerBanEntity.ts
Normal file
35
server/src/entities/ServerBanEntity.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column,
|
||||||
|
Index
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_bans')
|
||||||
|
export class ServerBanEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
bannedBy!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
displayName!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
reason!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
expiresAt!: number | null;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
createdAt!: number;
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ export class ServerEntity {
|
|||||||
@Column('text')
|
@Column('text')
|
||||||
ownerPublicKey!: string;
|
ownerPublicKey!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
passwordHash!: string | null;
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
isPrivate!: number;
|
isPrivate!: number;
|
||||||
|
|
||||||
|
|||||||
29
server/src/entities/ServerInviteEntity.ts
Normal file
29
server/src/entities/ServerInviteEntity.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column,
|
||||||
|
Index
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_invites')
|
||||||
|
export class ServerInviteEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
createdBy!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
createdByDisplayName!: string | null;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
createdAt!: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('integer')
|
||||||
|
expiresAt!: number;
|
||||||
|
}
|
||||||
26
server/src/entities/ServerMembershipEntity.ts
Normal file
26
server/src/entities/ServerMembershipEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column,
|
||||||
|
Index
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_memberships')
|
||||||
|
export class ServerMembershipEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
joinedAt!: number;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
lastAccessAt!: number;
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
export { AuthUserEntity } from './AuthUserEntity';
|
export { AuthUserEntity } from './AuthUserEntity';
|
||||||
export { ServerEntity } from './ServerEntity';
|
export { ServerEntity } from './ServerEntity';
|
||||||
export { JoinRequestEntity } from './JoinRequestEntity';
|
export { JoinRequestEntity } from './JoinRequestEntity';
|
||||||
|
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||||
|
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||||
|
export { ServerBanEntity } from './ServerBanEntity';
|
||||||
|
|||||||
@@ -14,23 +14,39 @@ import { deleteStaleJoinRequests } from './cqrs';
|
|||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
import {
|
import {
|
||||||
ensureVariablesConfig,
|
ensureVariablesConfig,
|
||||||
|
getServerHost,
|
||||||
getVariablesConfigPath,
|
getVariablesConfigPath,
|
||||||
hasKlipyApiKey
|
getServerPort,
|
||||||
|
getServerProtocol,
|
||||||
|
ServerHttpProtocol
|
||||||
} from './config/variables';
|
} from './config/variables';
|
||||||
import { setupWebSocket } from './websocket';
|
import { setupWebSocket } from './websocket';
|
||||||
|
|
||||||
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
|
function formatHostForUrl(host: string): string {
|
||||||
const PORT = process.env.PORT || 3001;
|
if (host.startsWith('[') || !host.includes(':')) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
function buildServer(app: ReturnType<typeof createApp>) {
|
return `[${host}]`;
|
||||||
if (USE_SSL) {
|
}
|
||||||
|
|
||||||
|
function getDisplayHost(serverHost: string | undefined): string {
|
||||||
|
if (!serverHost || serverHost === '0.0.0.0' || serverHost === '::') {
|
||||||
|
return 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHttpProtocol) {
|
||||||
|
if (serverProtocol === 'https') {
|
||||||
const certDir = resolveCertificateDirectory();
|
const certDir = resolveCertificateDirectory();
|
||||||
const certFile = path.join(certDir, 'localhost.crt');
|
const certFile = path.join(certDir, 'localhost.crt');
|
||||||
const keyFile = path.join(certDir, 'localhost.key');
|
const keyFile = path.join(certDir, 'localhost.key');
|
||||||
|
|
||||||
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
||||||
console.error(`SSL=true but certs not found in ${certDir}`);
|
console.error(`HTTPS is enabled but certs were not found in ${certDir}`);
|
||||||
console.error('Run ./generate-cert.sh first.');
|
console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,17 +60,31 @@ function buildServer(app: ReturnType<typeof createApp>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
ensureVariablesConfig();
|
const variablesConfig = ensureVariablesConfig();
|
||||||
|
const serverProtocol = getServerProtocol();
|
||||||
|
const serverPort = getServerPort();
|
||||||
|
const serverHost = getServerHost();
|
||||||
|
const bindHostLabel = serverHost || 'default interface';
|
||||||
|
|
||||||
console.log('[Config] Variables loaded from:', getVariablesConfigPath());
|
console.log('[Config] Variables loaded from:', getVariablesConfigPath());
|
||||||
|
|
||||||
if (!hasKlipyApiKey()) {
|
if (
|
||||||
|
variablesConfig.serverProtocol !== serverProtocol
|
||||||
|
|| variablesConfig.serverPort !== serverPort
|
||||||
|
) {
|
||||||
|
console.log(`[Config] Server runtime override active: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Config] Server runtime config: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!variablesConfig.klipyApiKey) {
|
||||||
console.log('[KLIPY] API key not configured. GIF search is disabled.');
|
console.log('[KLIPY] API key not configured. GIF search is disabled.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await initDatabase();
|
await initDatabase();
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const server = buildServer(app);
|
const server = buildServer(app, serverProtocol);
|
||||||
|
|
||||||
setupWebSocket(server);
|
setupWebSocket(server);
|
||||||
|
|
||||||
@@ -64,14 +94,29 @@ async function bootstrap(): Promise<void> {
|
|||||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
const onListening = () => {
|
||||||
const proto = USE_SSL ? 'https' : 'http';
|
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||||
const wsProto = USE_SSL ? 'wss' : 'ws';
|
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
|
||||||
|
const localHostNames = [
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1',
|
||||||
|
'::1'
|
||||||
|
];
|
||||||
|
|
||||||
console.log(`MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`);
|
console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
|
||||||
console.log(` REST API: ${proto}://localhost:${PORT}/api`);
|
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
|
||||||
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
|
console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`);
|
||||||
});
|
|
||||||
|
if (serverProtocol === 'https' && serverHost && !localHostNames.includes(serverHost)) {
|
||||||
|
console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (serverHost) {
|
||||||
|
server.listen(serverPort, serverHost, onListening);
|
||||||
|
} else {
|
||||||
|
server.listen(serverPort, onListening);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap().catch((err) => {
|
bootstrap().catch((err) => {
|
||||||
|
|||||||
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class ServerAccessControl1000000000001 implements MigrationInterface {
|
||||||
|
name = 'ServerAccessControl1000000000001';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "passwordHash" TEXT`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_memberships" (
|
||||||
|
"id" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"joinedAt" INTEGER NOT NULL,
|
||||||
|
"lastAccessAt" INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_serverId" ON "server_memberships" ("serverId")`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_userId" ON "server_memberships" ("userId")`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_invites" (
|
||||||
|
"id" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"createdBy" TEXT NOT NULL,
|
||||||
|
"createdByDisplayName" TEXT,
|
||||||
|
"createdAt" INTEGER NOT NULL,
|
||||||
|
"expiresAt" INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_serverId" ON "server_invites" ("serverId")`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_expiresAt" ON "server_invites" ("expiresAt")`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_bans" (
|
||||||
|
"id" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"bannedBy" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT,
|
||||||
|
"reason" TEXT,
|
||||||
|
"expiresAt" INTEGER,
|
||||||
|
"createdAt" INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_serverId" ON "server_bans" ("serverId")`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_userId" ON "server_bans" ("userId")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_bans"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_invites"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_memberships"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "passwordHash"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||||
|
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
||||||
|
|
||||||
export const serverMigrations = [InitialSchema1000000000000];
|
export const serverMigrations = [
|
||||||
|
InitialSchema1000000000000,
|
||||||
|
ServerAccessControl1000000000001
|
||||||
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import proxyRouter from './proxy';
|
|||||||
import usersRouter from './users';
|
import usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
import joinRequestsRouter from './join-requests';
|
import joinRequestsRouter from './join-requests';
|
||||||
|
import { invitesApiRouter, invitePageRouter } from './invites';
|
||||||
|
|
||||||
export function registerRoutes(app: Express): void {
|
export function registerRoutes(app: Express): void {
|
||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
@@ -12,5 +13,7 @@ export function registerRoutes(app: Express): void {
|
|||||||
app.use('/api', proxyRouter);
|
app.use('/api', proxyRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/servers', serversRouter);
|
app.use('/api/servers', serversRouter);
|
||||||
|
app.use('/api/invites', invitesApiRouter);
|
||||||
app.use('/api/requests', joinRequestsRouter);
|
app.use('/api/requests', joinRequestsRouter);
|
||||||
|
app.use('/invite', invitePageRouter);
|
||||||
}
|
}
|
||||||
|
|||||||
57
server/src/routes/invite-utils.ts
Normal file
57
server/src/routes/invite-utils.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
function buildOrigin(protocol: string, host: string): string {
|
||||||
|
return `${protocol}://${host}`.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function originFromUrl(url: URL): string {
|
||||||
|
return buildOrigin(url.protocol.replace(':', ''), url.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequestOrigin(request: Request): string {
|
||||||
|
const forwardedProtoHeader = request.get('x-forwarded-proto');
|
||||||
|
const forwardedHostHeader = request.get('x-forwarded-host');
|
||||||
|
const protocol = forwardedProtoHeader?.split(',')[0]?.trim() || request.protocol;
|
||||||
|
const host = forwardedHostHeader?.split(',')[0]?.trim() || request.get('host') || 'localhost';
|
||||||
|
|
||||||
|
return buildOrigin(protocol, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveWebAppOrigin(signalOrigin: string): string {
|
||||||
|
const url = new URL(signalOrigin);
|
||||||
|
|
||||||
|
if (url.hostname === 'signal.toju.app' && !url.port) {
|
||||||
|
return 'https://web.toju.app';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.hostname.startsWith('signal.')) {
|
||||||
|
url.hostname = url.hostname.replace(/^signal\./, 'web.');
|
||||||
|
|
||||||
|
if (url.port === '3001') {
|
||||||
|
url.port = '4200';
|
||||||
|
}
|
||||||
|
|
||||||
|
return originFromUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.port === '3001') {
|
||||||
|
url.port = '4200';
|
||||||
|
return originFromUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'https://web.toju.app';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||||
|
return `${signalOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBrowserInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||||
|
const browserOrigin = deriveWebAppOrigin(signalOrigin);
|
||||||
|
|
||||||
|
return `${browserOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAppInviteUrl(signalOrigin: string, inviteId: string): string {
|
||||||
|
return `toju://invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
|
||||||
|
}
|
||||||
331
server/src/routes/invites.ts
Normal file
331
server/src/routes/invites.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getUserById } from '../cqrs';
|
||||||
|
import { rowToServer } from '../cqrs/mappers';
|
||||||
|
import { ServerPayload } from '../cqrs/types';
|
||||||
|
import { getActiveServerInvite } from '../services/server-access.service';
|
||||||
|
import {
|
||||||
|
buildAppInviteUrl,
|
||||||
|
buildBrowserInviteUrl,
|
||||||
|
buildInviteUrl,
|
||||||
|
getRequestOrigin
|
||||||
|
} from './invite-utils';
|
||||||
|
|
||||||
|
export const invitesApiRouter = Router();
|
||||||
|
export const invitePageRouter = Router();
|
||||||
|
|
||||||
|
async function enrichServer(server: ServerPayload, sourceUrl: string) {
|
||||||
|
const owner = await getUserById(server.ownerId);
|
||||||
|
const { passwordHash, ...publicServer } = server;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...publicServer,
|
||||||
|
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||||
|
ownerName: owner?.displayName,
|
||||||
|
sourceUrl,
|
||||||
|
userCount: server.currentUsers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInvitePage(options: {
|
||||||
|
appUrl?: string;
|
||||||
|
browserUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
inviteUrl?: string;
|
||||||
|
isExpired: boolean;
|
||||||
|
ownerName?: string;
|
||||||
|
serverDescription?: string;
|
||||||
|
serverName: string;
|
||||||
|
}) {
|
||||||
|
const expiryLabel = options.expiresAt
|
||||||
|
? new Date(options.expiresAt).toLocaleString('en-US', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const statusLabel = options.isExpired ? 'Expired' : 'Active';
|
||||||
|
const statusColor = options.isExpired ? '#f87171' : '#4ade80';
|
||||||
|
const buttonOpacity = options.isExpired ? 'opacity:0.5;pointer-events:none;' : '';
|
||||||
|
const errorBlock = options.error
|
||||||
|
? `<div class="notice notice-error">${options.error}</div>`
|
||||||
|
: '';
|
||||||
|
const description = options.serverDescription
|
||||||
|
? `<p class="description">${options.serverDescription}</p>`
|
||||||
|
: '<p class="description">You have been invited to join a Toju server.</p>';
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Invite to ${options.serverName}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #050816;
|
||||||
|
--bg-soft: rgba(11, 18, 42, 0.78);
|
||||||
|
--card: rgba(15, 23, 42, 0.92);
|
||||||
|
--border: rgba(148, 163, 184, 0.18);
|
||||||
|
--text: #f8fafc;
|
||||||
|
--muted: #cbd5e1;
|
||||||
|
--primary: #8b5cf6;
|
||||||
|
--primary-soft: rgba(139, 92, 246, 0.16);
|
||||||
|
--secondary: rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(59, 130, 246, 0.28), transparent 32%),
|
||||||
|
radial-gradient(circle at top right, rgba(139, 92, 246, 0.24), transparent 30%),
|
||||||
|
linear-gradient(180deg, #050816 0%, #0b1120 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
width: min(100%, 760px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
backdrop-filter: blur(22px);
|
||||||
|
box-shadow: 0 30px 90px rgba(15, 23, 42, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 36px 36px 28px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: linear-gradient(180deg, rgba(15, 23, 42, 0.8), rgba(15, 23, 42, 0.55));
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--secondary);
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: ${statusColor};
|
||||||
|
box-shadow: 0 0 0 6px color-mix(in srgb, ${statusColor} 18%, transparent);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 18px 0 10px;
|
||||||
|
font-size: clamp(2rem, 3vw, 3.25rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 28px 36px 36px;
|
||||||
|
}
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
.meta-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--card);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.meta-label {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.meta-value {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.button-primary {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6, #6366f1);
|
||||||
|
box-shadow: 0 18px 36px rgba(99, 102, 241, 0.28);
|
||||||
|
}
|
||||||
|
.button-secondary {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.notice-error {
|
||||||
|
border-color: rgba(248, 113, 113, 0.32);
|
||||||
|
background: rgba(127, 29, 29, 0.18);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 18px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #c4b5fd;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
color: #ddd6fe;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hero, .content { padding-inline: 22px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero">
|
||||||
|
<div class="eyebrow"><span class="status-dot"></span>${statusLabel} invite</div>
|
||||||
|
<h1>Join ${options.serverName}</h1>
|
||||||
|
${description}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content">
|
||||||
|
${errorBlock}
|
||||||
|
<div class="meta-grid">
|
||||||
|
<article class="meta-card">
|
||||||
|
<div class="meta-label">Server</div>
|
||||||
|
<div class="meta-value">${options.serverName}</div>
|
||||||
|
</article>
|
||||||
|
<article class="meta-card">
|
||||||
|
<div class="meta-label">Owner</div>
|
||||||
|
<div class="meta-value">${options.ownerName || 'Unknown'}</div>
|
||||||
|
</article>
|
||||||
|
<article class="meta-card">
|
||||||
|
<div class="meta-label">Expires</div>
|
||||||
|
<div class="meta-value">${expiryLabel || 'Expired'}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions" style="${buttonOpacity}">
|
||||||
|
<a class="button button-primary" href="${options.browserUrl || '#'}">Join in browser</a>
|
||||||
|
<a class="button button-secondary" href="${options.appUrl || '#'}">Open with Toju</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notice">
|
||||||
|
Invite links bypass private and password restrictions, but banned users still cannot join.
|
||||||
|
If Toju is not installed yet, use the desktop button after installing from <a href="https://toju.app/downloads">toju.app/downloads</a>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>Share link: <code>${options.inviteUrl || 'Unavailable'}</code></span>
|
||||||
|
<a href="https://toju.app/downloads">Download Toju</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
invitesApiRouter.get('/:id', async (req, res) => {
|
||||||
|
const signalOrigin = getRequestOrigin(req);
|
||||||
|
const bundle = await getActiveServerInvite(req.params['id']);
|
||||||
|
|
||||||
|
if (!bundle) {
|
||||||
|
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = rowToServer(bundle.server);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: bundle.invite.id,
|
||||||
|
serverId: bundle.invite.serverId,
|
||||||
|
createdAt: bundle.invite.createdAt,
|
||||||
|
expiresAt: bundle.invite.expiresAt,
|
||||||
|
inviteUrl: buildInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
browserUrl: buildBrowserInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
appUrl: buildAppInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
sourceUrl: signalOrigin,
|
||||||
|
createdBy: bundle.invite.createdBy,
|
||||||
|
createdByDisplayName: bundle.invite.createdByDisplayName ?? undefined,
|
||||||
|
isExpired: bundle.invite.expiresAt <= Date.now(),
|
||||||
|
server: await enrichServer(server, signalOrigin)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
invitePageRouter.get('/:id', async (req, res) => {
|
||||||
|
const signalOrigin = getRequestOrigin(req);
|
||||||
|
const bundle = await getActiveServerInvite(req.params['id']);
|
||||||
|
|
||||||
|
if (!bundle) {
|
||||||
|
res.status(404).send(renderInvitePage({
|
||||||
|
error: 'This invite has expired or is no longer available.',
|
||||||
|
isExpired: true,
|
||||||
|
serverName: 'Toju server'
|
||||||
|
}));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = rowToServer(bundle.server);
|
||||||
|
const owner = await getUserById(server.ownerId);
|
||||||
|
|
||||||
|
res.send(renderInvitePage({
|
||||||
|
serverName: server.name,
|
||||||
|
serverDescription: server.description,
|
||||||
|
ownerName: owner?.displayName,
|
||||||
|
expiresAt: bundle.invite.expiresAt,
|
||||||
|
inviteUrl: buildInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
browserUrl: buildBrowserInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
appUrl: buildAppInviteUrl(signalOrigin, bundle.invite.id),
|
||||||
|
isExpired: bundle.invite.expiresAt <= Date.now()
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -1,29 +1,90 @@
|
|||||||
import { Router } from 'express';
|
import { Response, Router } from 'express';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { ServerPayload, JoinRequestPayload } from '../cqrs/types';
|
import { ServerPayload } from '../cqrs/types';
|
||||||
import {
|
import {
|
||||||
getAllPublicServers,
|
getAllPublicServers,
|
||||||
getServerById,
|
getServerById,
|
||||||
getUserById,
|
getUserById,
|
||||||
upsertServer,
|
upsertServer,
|
||||||
deleteServer,
|
deleteServer,
|
||||||
createJoinRequest,
|
|
||||||
getPendingRequestsForServer
|
getPendingRequestsForServer
|
||||||
} from '../cqrs';
|
} from '../cqrs';
|
||||||
import { notifyServerOwner } from '../websocket/broadcast';
|
import {
|
||||||
|
banServerUser,
|
||||||
|
buildSignalingUrl,
|
||||||
|
createServerInvite,
|
||||||
|
joinServerWithAccess,
|
||||||
|
leaveServerUser,
|
||||||
|
passwordHashForInput,
|
||||||
|
ServerAccessError,
|
||||||
|
kickServerUser,
|
||||||
|
ensureServerMembership,
|
||||||
|
unbanServerUser
|
||||||
|
} from '../services/server-access.service';
|
||||||
|
import {
|
||||||
|
buildAppInviteUrl,
|
||||||
|
buildBrowserInviteUrl,
|
||||||
|
buildInviteUrl,
|
||||||
|
getRequestOrigin
|
||||||
|
} from './invite-utils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
async function enrichServer(server: ServerPayload) {
|
function normalizeRole(role: unknown): string | null {
|
||||||
|
return typeof role === 'string' ? role.trim().toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
|
||||||
|
return !!role && allowedRoles.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||||
const owner = await getUserById(server.ownerId);
|
const owner = await getUserById(server.ownerId);
|
||||||
|
const { passwordHash, ...publicServer } = server;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...server,
|
...publicServer,
|
||||||
|
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||||
ownerName: owner?.displayName,
|
ownerName: owner?.displayName,
|
||||||
|
sourceUrl,
|
||||||
userCount: server.currentUsers
|
userCount: server.currentUsers
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendAccessError(error: unknown, res: Response) {
|
||||||
|
if (error instanceof ServerAccessError) {
|
||||||
|
res.status(error.status).json({ error: error.message, errorCode: error.code });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Unhandled server access error:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildInviteResponse(invite: {
|
||||||
|
id: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
createdByDisplayName: string | null;
|
||||||
|
serverId: string;
|
||||||
|
}, server: ServerPayload, signalOrigin: string) {
|
||||||
|
return {
|
||||||
|
id: invite.id,
|
||||||
|
serverId: invite.serverId,
|
||||||
|
createdAt: invite.createdAt,
|
||||||
|
expiresAt: invite.expiresAt,
|
||||||
|
inviteUrl: buildInviteUrl(signalOrigin, invite.id),
|
||||||
|
browserUrl: buildBrowserInviteUrl(signalOrigin, invite.id),
|
||||||
|
appUrl: buildAppInviteUrl(signalOrigin, invite.id),
|
||||||
|
sourceUrl: signalOrigin,
|
||||||
|
createdBy: invite.createdBy,
|
||||||
|
createdByDisplayName: invite.createdByDisplayName ?? undefined,
|
||||||
|
isExpired: invite.expiresAt <= Date.now(),
|
||||||
|
server: await enrichServer(server, signalOrigin)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||||
|
|
||||||
@@ -54,17 +115,30 @@ router.get('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
|
const {
|
||||||
|
id: clientId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
ownerId,
|
||||||
|
ownerPublicKey,
|
||||||
|
isPrivate,
|
||||||
|
maxUsers,
|
||||||
|
password,
|
||||||
|
tags
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
if (!name || !ownerId || !ownerPublicKey)
|
if (!name || !ownerId || !ownerPublicKey)
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
|
||||||
|
const passwordHash = passwordHashForInput(password);
|
||||||
const server: ServerPayload = {
|
const server: ServerPayload = {
|
||||||
id: clientId || uuidv4(),
|
id: clientId || uuidv4(),
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
ownerId,
|
ownerId,
|
||||||
ownerPublicKey,
|
ownerPublicKey,
|
||||||
|
hasPassword: !!passwordHash,
|
||||||
|
passwordHash,
|
||||||
isPrivate: isPrivate ?? false,
|
isPrivate: isPrivate ?? false,
|
||||||
maxUsers: maxUsers ?? 0,
|
maxUsers: maxUsers ?? 0,
|
||||||
currentUsers: 0,
|
currentUsers: 0,
|
||||||
@@ -74,25 +148,216 @@ router.post('/', async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await upsertServer(server);
|
await upsertServer(server);
|
||||||
res.status(201).json(server);
|
await ensureServerMembership(server.id, ownerId);
|
||||||
|
|
||||||
|
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { currentOwnerId, ...updates } = req.body;
|
const {
|
||||||
|
currentOwnerId,
|
||||||
|
actingRole,
|
||||||
|
password,
|
||||||
|
hasPassword: _ignoredHasPassword,
|
||||||
|
passwordHash: _ignoredPasswordHash,
|
||||||
|
...updates
|
||||||
|
} = req.body;
|
||||||
const existing = await getServerById(id);
|
const existing = await getServerById(id);
|
||||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||||
|
const normalizedRole = normalizeRole(actingRole);
|
||||||
|
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
|
|
||||||
if (existing.ownerId !== authenticatedOwnerId)
|
if (
|
||||||
|
existing.ownerId !== authenticatedOwnerId &&
|
||||||
|
!isAllowedRole(normalizedRole, ['host', 'admin'])
|
||||||
|
) {
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
|
}
|
||||||
|
|
||||||
const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() };
|
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
|
||||||
|
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
|
||||||
|
const server: ServerPayload = {
|
||||||
|
...existing,
|
||||||
|
...updates,
|
||||||
|
hasPassword: !!nextPasswordHash,
|
||||||
|
passwordHash: nextPasswordHash,
|
||||||
|
lastSeen: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
await upsertServer(server);
|
await upsertServer(server);
|
||||||
res.json(server);
|
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/join', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { userId, password, inviteId } = req.body;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await joinServerWithAccess({
|
||||||
|
serverId,
|
||||||
|
userId: String(userId),
|
||||||
|
password: typeof password === 'string' ? password : undefined,
|
||||||
|
inviteId: typeof inviteId === 'string' ? inviteId : undefined
|
||||||
|
});
|
||||||
|
const origin = getRequestOrigin(req);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
signalingUrl: buildSignalingUrl(origin),
|
||||||
|
joinedBefore: result.joinedBefore,
|
||||||
|
via: result.via,
|
||||||
|
server: await enrichServer(result.server, origin)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sendAccessError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/invites', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { requesterUserId, requesterDisplayName } = req.body;
|
||||||
|
|
||||||
|
if (!requesterUserId) {
|
||||||
|
return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invite = await createServerInvite(
|
||||||
|
serverId,
|
||||||
|
String(requesterUserId),
|
||||||
|
typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(await buildInviteResponse(invite, server, getRequestOrigin(req)));
|
||||||
|
} catch (error) {
|
||||||
|
sendAccessError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/moderation/kick', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { actorUserId, actorRole, targetUserId } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUserId) {
|
||||||
|
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
server.ownerId !== actorUserId &&
|
||||||
|
!isAllowedRole(normalizeRole(actorRole), [
|
||||||
|
'host',
|
||||||
|
'admin',
|
||||||
|
'moderator'
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await kickServerUser(serverId, String(targetUserId));
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/moderation/ban', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { actorUserId, actorRole, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUserId) {
|
||||||
|
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
server.ownerId !== actorUserId &&
|
||||||
|
!isAllowedRole(normalizeRole(actorRole), [
|
||||||
|
'host',
|
||||||
|
'admin',
|
||||||
|
'moderator'
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await banServerUser({
|
||||||
|
serverId,
|
||||||
|
userId: String(targetUserId),
|
||||||
|
banId: typeof banId === 'string' ? banId : undefined,
|
||||||
|
bannedBy: String(actorUserId || ''),
|
||||||
|
displayName: typeof displayName === 'string' ? displayName : undefined,
|
||||||
|
reason: typeof reason === 'string' ? reason : undefined,
|
||||||
|
expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/moderation/unban', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { actorUserId, actorRole, banId, targetUserId } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
server.ownerId !== actorUserId &&
|
||||||
|
!isAllowedRole(normalizeRole(actorRole), [
|
||||||
|
'host',
|
||||||
|
'admin',
|
||||||
|
'moderator'
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await unbanServerUser({
|
||||||
|
serverId,
|
||||||
|
banId: typeof banId === 'string' ? banId : undefined,
|
||||||
|
userId: typeof targetUserId === 'string' ? targetUserId : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/leave', async (req, res) => {
|
||||||
|
const { id: serverId } = req.params;
|
||||||
|
const { userId } = req.body;
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await leaveServerUser(serverId, String(userId));
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/heartbeat', async (req, res) => {
|
router.post('/:id/heartbeat', async (req, res) => {
|
||||||
@@ -128,32 +393,6 @@ router.delete('/:id', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/join', async (req, res) => {
|
|
||||||
const { id: serverId } = req.params;
|
|
||||||
const { userId, userPublicKey, displayName } = req.body;
|
|
||||||
const server = await getServerById(serverId);
|
|
||||||
|
|
||||||
if (!server)
|
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
|
||||||
|
|
||||||
const request: JoinRequestPayload = {
|
|
||||||
id: uuidv4(),
|
|
||||||
serverId,
|
|
||||||
userId,
|
|
||||||
userPublicKey,
|
|
||||||
displayName,
|
|
||||||
status: server.isPrivate ? 'pending' : 'approved',
|
|
||||||
createdAt: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
await createJoinRequest(request);
|
|
||||||
|
|
||||||
if (server.isPrivate)
|
|
||||||
notifyServerOwner(server.ownerId, { type: 'join_request', request });
|
|
||||||
|
|
||||||
res.status(201).json(request);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id/requests', async (req, res) => {
|
router.get('/:id/requests', async (req, res) => {
|
||||||
const { id: serverId } = req.params;
|
const { id: serverId } = req.params;
|
||||||
const { ownerId } = req.query;
|
const { ownerId } = req.query;
|
||||||
@@ -170,4 +409,15 @@ router.get('/:id/requests', async (req, res) => {
|
|||||||
res.json({ requests });
|
res.json({ requests });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const server = await getServerById(id);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
390
server/src/services/server-access.service.ts
Normal file
390
server/src/services/server-access.service.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { getDataSource } from '../db/database';
|
||||||
|
import {
|
||||||
|
ServerBanEntity,
|
||||||
|
ServerEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerMembershipEntity
|
||||||
|
} from '../entities';
|
||||||
|
import { rowToServer } from '../cqrs/mappers';
|
||||||
|
import { ServerPayload } from '../cqrs/types';
|
||||||
|
|
||||||
|
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type JoinAccessVia = 'membership' | 'password' | 'invite' | 'public';
|
||||||
|
|
||||||
|
export interface JoinServerAccessResult {
|
||||||
|
joinedBefore: boolean;
|
||||||
|
server: ServerPayload;
|
||||||
|
via: JoinAccessVia;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BanServerUserOptions {
|
||||||
|
banId?: string;
|
||||||
|
bannedBy: string;
|
||||||
|
displayName?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
reason?: string;
|
||||||
|
serverId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerAccessError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ServerAccessError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerRepository() {
|
||||||
|
return getDataSource().getRepository(ServerEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMembershipRepository() {
|
||||||
|
return getDataSource().getRepository(ServerMembershipEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInviteRepository() {
|
||||||
|
return getDataSource().getRepository(ServerInviteEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBanRepository() {
|
||||||
|
return getDataSource().getRepository(ServerBanEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePassword(password?: string | null): string | null {
|
||||||
|
const normalized = password?.trim() ?? '';
|
||||||
|
|
||||||
|
return normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isServerOwner(server: ServerEntity, userId: string): boolean {
|
||||||
|
return server.ownerId === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashServerPassword(password: string): string {
|
||||||
|
return crypto.createHash('sha256').update(password)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passwordHashForInput(password?: string | null): string | null {
|
||||||
|
const normalized = normalizePassword(password);
|
||||||
|
|
||||||
|
return normalized ? hashServerPassword(normalized) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSignalingUrl(origin: string): string {
|
||||||
|
return origin.replace(/^http/i, 'ws');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneExpiredServerAccessArtifacts(now: number = Date.now()): Promise<void> {
|
||||||
|
await getInviteRepository()
|
||||||
|
.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.where('expiresAt <= :now', { now })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await getBanRepository()
|
||||||
|
.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.where('expiresAt IS NOT NULL AND expiresAt <= :now', { now })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerRecord(serverId: string): Promise<ServerEntity | null> {
|
||||||
|
return await getServerRepository().findOne({ where: { id: serverId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveServerBan(serverId: string, userId: string): Promise<ServerBanEntity | null> {
|
||||||
|
const banRepo = getBanRepository();
|
||||||
|
const ban = await banRepo.findOne({ where: { serverId, userId } });
|
||||||
|
|
||||||
|
if (!ban)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (ban.expiresAt && ban.expiresAt <= Date.now()) {
|
||||||
|
await banRepo.delete({ id: ban.id });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ban;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isServerUserBanned(serverId: string, userId: string): Promise<boolean> {
|
||||||
|
return !!(await getActiveServerBan(serverId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity | null> {
|
||||||
|
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
||||||
|
const repo = getMembershipRepository();
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = await repo.findOne({ where: { serverId, userId } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.lastAccessAt = now;
|
||||||
|
await repo.save(existing);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = repo.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
serverId,
|
||||||
|
userId,
|
||||||
|
joinedAt: now,
|
||||||
|
lastAccessAt: now
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeServerMembership(serverId: string, userId: string): Promise<void> {
|
||||||
|
await getMembershipRepository().delete({ serverId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertCanCreateInvite(serverId: string, requesterUserId: string): Promise<ServerEntity> {
|
||||||
|
const server = await getServerRecord(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isServerUserBanned(serverId, requesterUserId)) {
|
||||||
|
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot create invites');
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await findServerMembership(serverId, requesterUserId);
|
||||||
|
|
||||||
|
if (server.ownerId !== requesterUserId && !membership) {
|
||||||
|
throw new ServerAccessError(403, 'NOT_MEMBER', 'Only joined users can create invites');
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServerInvite(
|
||||||
|
serverId: string,
|
||||||
|
createdBy: string,
|
||||||
|
createdByDisplayName?: string
|
||||||
|
): Promise<ServerInviteEntity> {
|
||||||
|
await assertCanCreateInvite(serverId, createdBy);
|
||||||
|
|
||||||
|
const repo = getInviteRepository();
|
||||||
|
const now = Date.now();
|
||||||
|
const invite = repo.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
serverId,
|
||||||
|
createdBy,
|
||||||
|
createdByDisplayName: createdByDisplayName ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + SERVER_INVITE_EXPIRY_MS
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(invite);
|
||||||
|
return invite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveServerInvite(
|
||||||
|
inviteId: string
|
||||||
|
): Promise<{ invite: ServerInviteEntity; server: ServerEntity } | null> {
|
||||||
|
await pruneExpiredServerAccessArtifacts();
|
||||||
|
|
||||||
|
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite.expiresAt <= Date.now()) {
|
||||||
|
await getInviteRepository().delete({ id: invite.id });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await getServerRecord(invite.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { invite, server };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinServerWithAccess(options: {
|
||||||
|
inviteId?: string;
|
||||||
|
password?: string;
|
||||||
|
serverId: string;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<JoinServerAccessResult> {
|
||||||
|
await pruneExpiredServerAccessArtifacts();
|
||||||
|
|
||||||
|
const server = await getServerRecord(options.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isServerUserBanned(server.id, options.userId)) {
|
||||||
|
throw new ServerAccessError(403, 'BANNED', 'Banned users cannot join this server');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServerOwner(server, options.userId)) {
|
||||||
|
const existingMembership = await findServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: !!existingMembership,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'membership'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.inviteId) {
|
||||||
|
const inviteBundle = await getActiveServerInvite(options.inviteId);
|
||||||
|
|
||||||
|
if (!inviteBundle || inviteBundle.server.id !== server.id) {
|
||||||
|
throw new ServerAccessError(410, 'INVITE_EXPIRED', 'Invite link has expired or is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMembership = await findServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: !!existingMembership,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'invite'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await findServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
if (membership) {
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: true,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'membership'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.passwordHash) {
|
||||||
|
const passwordHash = passwordHashForInput(options.password);
|
||||||
|
|
||||||
|
if (!passwordHash || passwordHash !== server.passwordHash) {
|
||||||
|
throw new ServerAccessError(403, 'PASSWORD_REQUIRED', 'Password required to join this server');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: false,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'password'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.isPrivate) {
|
||||||
|
throw new ServerAccessError(403, 'PRIVATE_SERVER', 'Private servers require an invite link');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureServerMembership(server.id, options.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
joinedBefore: false,
|
||||||
|
server: rowToServer(server),
|
||||||
|
via: 'public'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authorizeWebSocketJoin(serverId: string, userId: string): Promise<{ allowed: boolean; reason?: string }> {
|
||||||
|
await pruneExpiredServerAccessArtifacts();
|
||||||
|
|
||||||
|
const server = await getServerRecord(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return { allowed: false,
|
||||||
|
reason: 'SERVER_NOT_FOUND' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isServerUserBanned(serverId, userId)) {
|
||||||
|
return { allowed: false,
|
||||||
|
reason: 'BANNED' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServerOwner(server, userId)) {
|
||||||
|
await ensureServerMembership(serverId, userId);
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await findServerMembership(serverId, userId);
|
||||||
|
|
||||||
|
if (membership) {
|
||||||
|
await ensureServerMembership(serverId, userId);
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server.isPrivate && !server.passwordHash) {
|
||||||
|
await ensureServerMembership(serverId, userId);
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: server.isPrivate ? 'PRIVATE_SERVER' : 'PASSWORD_REQUIRED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kickServerUser(serverId: string, userId: string): Promise<void> {
|
||||||
|
await removeServerMembership(serverId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function leaveServerUser(serverId: string, userId: string): Promise<void> {
|
||||||
|
await removeServerMembership(serverId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function banServerUser(options: BanServerUserOptions): Promise<ServerBanEntity> {
|
||||||
|
await removeServerMembership(options.serverId, options.userId);
|
||||||
|
|
||||||
|
const repo = getBanRepository();
|
||||||
|
const existing = await repo.findOne({ where: { serverId: options.serverId, userId: options.userId } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await repo.delete({ id: existing.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = repo.create({
|
||||||
|
id: options.banId ?? uuidv4(),
|
||||||
|
serverId: options.serverId,
|
||||||
|
userId: options.userId,
|
||||||
|
bannedBy: options.bannedBy,
|
||||||
|
displayName: options.displayName ?? null,
|
||||||
|
reason: options.reason ?? null,
|
||||||
|
expiresAt: options.expiresAt ?? null,
|
||||||
|
createdAt: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unbanServerUser(options: { banId?: string; serverId: string; userId?: string }): Promise<void> {
|
||||||
|
const repo = getBanRepository();
|
||||||
|
|
||||||
|
if (options.banId) {
|
||||||
|
await repo.delete({ id: options.banId, serverId: options.serverId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.userId) {
|
||||||
|
await repo.delete({ serverId: options.serverId, userId: options.userId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,59 @@
|
|||||||
import { connectedUsers } from './state';
|
import { connectedUsers } from './state';
|
||||||
import { ConnectedUser } from './types';
|
import { ConnectedUser } from './types';
|
||||||
import { broadcastToServer, findUserByOderId } from './broadcast';
|
import { broadcastToServer, findUserByOderId } from './broadcast';
|
||||||
|
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||||
|
|
||||||
interface WsMessage {
|
interface WsMessage {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
||||||
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||||
|
|
||||||
|
return normalized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
/** Sends the current user list for a given server to a single connected user. */
|
/** Sends the current user list for a given server to a single connected user. */
|
||||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||||
const users = Array.from(connectedUsers.values())
|
const users = Array.from(connectedUsers.values())
|
||||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId && cu.displayName)
|
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
|
||||||
.map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' }));
|
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
||||||
|
|
||||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
user.oderId = String(message['oderId'] || connectionId);
|
user.oderId = String(message['oderId'] || connectionId);
|
||||||
user.displayName = String(message['displayName'] || 'Anonymous');
|
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
const sid = String(message['serverId']);
|
const sid = String(message['serverId']);
|
||||||
|
|
||||||
|
if (!sid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const authorization = await authorizeWebSocketJoin(sid, user.oderId);
|
||||||
|
|
||||||
|
if (!authorization.allowed) {
|
||||||
|
user.ws.send(JSON.stringify({
|
||||||
|
type: 'access_denied',
|
||||||
|
serverId: sid,
|
||||||
|
reason: authorization.reason
|
||||||
|
}));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isNew = !user.serverIds.has(sid);
|
const isNew = !user.serverIds.has(sid);
|
||||||
|
|
||||||
user.serverIds.add(sid);
|
user.serverIds.add(sid);
|
||||||
user.viewedServerId = sid;
|
user.viewedServerId = sid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
||||||
|
|
||||||
sendServerUsers(user, sid);
|
sendServerUsers(user, sid);
|
||||||
|
|
||||||
@@ -38,7 +61,7 @@ function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId:
|
|||||||
broadcastToServer(sid, {
|
broadcastToServer(sid, {
|
||||||
type: 'user_joined',
|
type: 'user_joined',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName ?? 'Anonymous',
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
serverId: sid
|
serverId: sid
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
@@ -49,7 +72,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
|
|||||||
|
|
||||||
user.viewedServerId = viewSid;
|
user.viewedServerId = viewSid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`);
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
||||||
|
|
||||||
sendServerUsers(user, viewSid);
|
sendServerUsers(user, viewSid);
|
||||||
}
|
}
|
||||||
@@ -70,8 +93,9 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
|||||||
broadcastToServer(leaveSid, {
|
broadcastToServer(leaveSid, {
|
||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName ?? 'Anonymous',
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
serverId: leaveSid
|
serverId: leaveSid,
|
||||||
|
serverIds: Array.from(user.serverIds)
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +145,7 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleWebSocketMessage(connectionId: string, message: WsMessage): void {
|
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
if (!user)
|
if (!user)
|
||||||
@@ -133,7 +157,7 @@ export function handleWebSocketMessage(connectionId: string, message: WsMessage)
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'join_server':
|
case 'join_server':
|
||||||
handleJoinServer(user, message, connectionId);
|
await handleJoinServer(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'view_server':
|
case 'view_server':
|
||||||
|
|||||||
@@ -9,39 +9,87 @@ import { connectedUsers } from './state';
|
|||||||
import { broadcastToServer } from './broadcast';
|
import { broadcastToServer } from './broadcast';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
|
/** How often to ping all connected clients (ms). */
|
||||||
|
const PING_INTERVAL_MS = 30_000;
|
||||||
|
/** Maximum time a client can go without a pong before we consider it dead (ms). */
|
||||||
|
const PONG_TIMEOUT_MS = 45_000;
|
||||||
|
|
||||||
|
function removeDeadConnection(connectionId: string): void {
|
||||||
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||||
|
|
||||||
|
user.serverIds.forEach((sid) => {
|
||||||
|
broadcastToServer(sid, {
|
||||||
|
type: 'user_left',
|
||||||
|
oderId: user.oderId,
|
||||||
|
displayName: user.displayName,
|
||||||
|
serverId: sid,
|
||||||
|
serverIds: []
|
||||||
|
}, user.oderId);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
user.ws.terminate();
|
||||||
|
} catch {
|
||||||
|
console.warn(`Failed to terminate WebSocket for ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedUsers.delete(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
export function setupWebSocket(server: Server<typeof IncomingMessage, typeof ServerResponse>): void {
|
export function setupWebSocket(server: Server<typeof IncomingMessage, typeof ServerResponse>): void {
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
// Periodically ping all clients and reap dead connections
|
||||||
|
const pingInterval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
connectedUsers.forEach((user, connectionId) => {
|
||||||
|
if (now - user.lastPong > PONG_TIMEOUT_MS) {
|
||||||
|
removeDeadConnection(connectionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.ws.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
user.ws.ping();
|
||||||
|
} catch {
|
||||||
|
console.warn(`Failed to ping client ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, PING_INTERVAL_MS);
|
||||||
|
|
||||||
|
wss.on('close', () => clearInterval(pingInterval));
|
||||||
|
|
||||||
wss.on('connection', (ws: WebSocket) => {
|
wss.on('connection', (ws: WebSocket) => {
|
||||||
const connectionId = uuidv4();
|
const connectionId = uuidv4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
|
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set(), lastPong: now });
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('pong', () => {
|
||||||
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
user.lastPong = Date.now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', async (data) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(data.toString());
|
const message = JSON.parse(data.toString());
|
||||||
|
|
||||||
handleWebSocketMessage(connectionId, message);
|
await handleWebSocketMessage(connectionId, message);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Invalid WebSocket message:', err);
|
console.error('Invalid WebSocket message:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
const user = connectedUsers.get(connectionId);
|
removeDeadConnection(connectionId);
|
||||||
|
|
||||||
if (user) {
|
|
||||||
user.serverIds.forEach((sid) => {
|
|
||||||
broadcastToServer(sid, {
|
|
||||||
type: 'user_left',
|
|
||||||
oderId: user.oderId,
|
|
||||||
displayName: user.displayName,
|
|
||||||
serverId: sid
|
|
||||||
}, user.oderId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedUsers.delete(connectionId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
|
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ export interface ConnectedUser {
|
|||||||
serverIds: Set<string>;
|
serverIds: Set<string>;
|
||||||
viewedServerId?: string;
|
viewedServerId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
/** Timestamp of the last pong received (used to detect dead connections). */
|
||||||
|
lastPong: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,49 +50,11 @@
|
|||||||
<app-floating-voice-controls />
|
<app-floating-voice-controls />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (desktopUpdateState().serverBlocked) {
|
|
||||||
<div class="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 px-6 py-10 backdrop-blur-sm">
|
|
||||||
<div class="w-full max-w-xl rounded-2xl border border-red-500/30 bg-card p-6 shadow-2xl">
|
|
||||||
<h2 class="text-xl font-semibold text-foreground">Server update required</h2>
|
|
||||||
<p class="mt-3 text-sm text-muted-foreground">
|
|
||||||
{{ desktopUpdateState().serverBlockMessage || 'The connected server must be updated before this desktop app can continue.' }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-5 grid gap-4 rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
|
|
||||||
<p class="mt-2 text-foreground">{{ desktopUpdateState().serverVersion || 'Not reported' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
|
|
||||||
<p class="mt-2 text-foreground">{{ desktopUpdateState().minimumServerVersion || 'Unknown' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex flex-wrap gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="refreshDesktopUpdateContext()"
|
|
||||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="openNetworkSettings()"
|
|
||||||
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Open network settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Unified Settings Modal -->
|
<!-- Unified Settings Modal -->
|
||||||
<app-settings-modal />
|
<app-settings-modal />
|
||||||
|
|
||||||
|
<!-- Shared Screen Share Source Picker -->
|
||||||
|
<app-screen-share-source-picker />
|
||||||
|
|
||||||
<!-- Shared Debug Console -->
|
<!-- Shared Debug Console -->
|
||||||
<app-debug-console [showLauncher]="false" />
|
<app-debug-console [showLauncher]="false" />
|
||||||
|
|||||||
@@ -10,17 +10,22 @@ export const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/auth/login/login.component').then((module) => module.LoginComponent)
|
import('./domains/auth/feature/login/login.component').then((module) => module.LoginComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'register',
|
path: 'register',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/auth/register/register.component').then((module) => module.RegisterComponent)
|
import('./domains/auth/feature/register/register.component').then((module) => module.RegisterComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'invite/:inviteId',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./domains/server-directory/feature/invite/invite.component').then((module) => module.InviteComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'search',
|
path: 'search',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/server-search/server-search.component').then(
|
import('./domains/server-directory/feature/server-search/server-search.component').then(
|
||||||
(module) => module.ServerSearchComponent
|
(module) => module.ServerSearchComponent
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
107
src/app/app.ts
107
src/app/app.ts
@@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
inject,
|
inject,
|
||||||
HostListener
|
HostListener
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -13,18 +14,20 @@ import {
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
|
|
||||||
import { DatabaseService } from './core/services/database.service';
|
import { DatabaseService } from './infrastructure/persistence';
|
||||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
||||||
import { ServerDirectoryService } from './core/services/server-directory.service';
|
import { ServerDirectoryFacade } from './domains/server-directory';
|
||||||
import { TimeSyncService } from './core/services/time-sync.service';
|
import { TimeSyncService } from './core/services/time-sync.service';
|
||||||
import { VoiceSessionService } from './core/services/voice-session.service';
|
import { VoiceSessionFacade } from './domains/voice-session';
|
||||||
import { ExternalLinkService } from './core/services/external-link.service';
|
import { ExternalLinkService } from './core/platform';
|
||||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||||
|
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
||||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
||||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||||
|
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||||
import { UsersActions } from './store/users/users.actions';
|
import { UsersActions } from './store/users/users.actions';
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
@@ -43,12 +46,13 @@ import {
|
|||||||
TitleBarComponent,
|
TitleBarComponent,
|
||||||
FloatingVoiceControlsComponent,
|
FloatingVoiceControlsComponent,
|
||||||
SettingsModalComponent,
|
SettingsModalComponent,
|
||||||
DebugConsoleComponent
|
DebugConsoleComponent,
|
||||||
|
ScreenShareSourcePickerComponent
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App implements OnInit {
|
export class App implements OnInit, OnDestroy {
|
||||||
store = inject(Store);
|
store = inject(Store);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
desktopUpdates = inject(DesktopAppUpdateService);
|
desktopUpdates = inject(DesktopAppUpdateService);
|
||||||
@@ -56,11 +60,13 @@ export class App implements OnInit {
|
|||||||
|
|
||||||
private databaseService = inject(DatabaseService);
|
private databaseService = inject(DatabaseService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private servers = inject(ServerDirectoryService);
|
private servers = inject(ServerDirectoryFacade);
|
||||||
private settingsModal = inject(SettingsModalService);
|
private settingsModal = inject(SettingsModalService);
|
||||||
private timeSync = inject(TimeSyncService);
|
private timeSync = inject(TimeSyncService);
|
||||||
private voiceSession = inject(VoiceSessionService);
|
private voiceSession = inject(VoiceSessionFacade);
|
||||||
private externalLinks = inject(ExternalLinkService);
|
private externalLinks = inject(ExternalLinkService);
|
||||||
|
private electronBridge = inject(ElectronBridgeService);
|
||||||
|
private deepLinkCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
onGlobalLinkClick(evt: MouseEvent): void {
|
onGlobalLinkClick(evt: MouseEvent): void {
|
||||||
@@ -78,6 +84,8 @@ export class App implements OnInit {
|
|||||||
await this.timeSync.syncWithEndpoint(apiBase);
|
await this.timeSync.syncWithEndpoint(apiBase);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
await this.setupDesktopDeepLinks();
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.loadRooms());
|
this.store.dispatch(RoomsActions.loadRooms());
|
||||||
@@ -85,8 +93,12 @@ export class App implements OnInit {
|
|||||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
if (this.router.url !== '/login' && this.router.url !== '/register') {
|
if (!this.isPublicRoute(this.router.url)) {
|
||||||
this.router.navigate(['/login']).catch(() => {});
|
this.router.navigate(['/login'], {
|
||||||
|
queryParams: {
|
||||||
|
returnUrl: this.router.url
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
||||||
@@ -114,6 +126,11 @@ export class App implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.deepLinkCleanup?.();
|
||||||
|
this.deepLinkCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
openNetworkSettings(): void {
|
openNetworkSettings(): void {
|
||||||
this.settingsModal.open('network');
|
this.settingsModal.open('network');
|
||||||
}
|
}
|
||||||
@@ -129,4 +146,72 @@ export class App implements OnInit {
|
|||||||
async restartToApplyUpdate(): Promise<void> {
|
async restartToApplyUpdate(): Promise<void> {
|
||||||
await this.desktopUpdates.restartToApplyUpdate();
|
await this.desktopUpdates.restartToApplyUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async setupDesktopDeepLinks(): Promise<void> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!electronApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deepLinkCleanup = electronApi.onDeepLinkReceived?.((url) => {
|
||||||
|
void this.handleDesktopDeepLink(url);
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
const pendingDeepLink = await electronApi.consumePendingDeepLink?.();
|
||||||
|
|
||||||
|
if (pendingDeepLink) {
|
||||||
|
await this.handleDesktopDeepLink(pendingDeepLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDesktopDeepLink(url: string): Promise<void> {
|
||||||
|
const invite = this.parseDesktopInviteUrl(url);
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.router.navigate(['/invite', invite.inviteId], {
|
||||||
|
queryParams: {
|
||||||
|
server: invite.sourceUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPublicRoute(url: string): boolean {
|
||||||
|
return url === '/login' ||
|
||||||
|
url === '/register' ||
|
||||||
|
url.startsWith('/invite/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
|
||||||
|
if (parsedUrl.protocol !== 'toju:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathSegments = [parsedUrl.hostname, ...parsedUrl.pathname.split('/').filter(Boolean)]
|
||||||
|
.map((segment) => decodeURIComponent(segment));
|
||||||
|
|
||||||
|
if (pathSegments[0] !== 'invite' || !pathSegments[1]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceUrl = parsedUrl.searchParams.get('server')?.trim();
|
||||||
|
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inviteId: pathSegments[1],
|
||||||
|
sourceUrl
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,313 +1,52 @@
|
|||||||
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
|
/**
|
||||||
|
* Transitional compatibility barrel.
|
||||||
|
*
|
||||||
|
* All business types now live in `src/app/shared-kernel/` (organised by concept)
|
||||||
|
* or in their owning domain. This file re-exports everything so existing
|
||||||
|
* `import { X } from 'core/models'` lines keep working while the codebase
|
||||||
|
* migrates to direct shared-kernel imports.
|
||||||
|
*
|
||||||
|
* NEW CODE should import from `@shared-kernel` or the owning domain barrel
|
||||||
|
* instead of this file.
|
||||||
|
*/
|
||||||
|
|
||||||
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
|
export type {
|
||||||
|
User,
|
||||||
|
UserStatus,
|
||||||
|
UserRole,
|
||||||
|
RoomMember
|
||||||
|
} from '../../shared-kernel';
|
||||||
|
|
||||||
export type ChannelType = 'text' | 'voice';
|
export type {
|
||||||
|
Room,
|
||||||
|
RoomSettings,
|
||||||
|
RoomPermissions,
|
||||||
|
Channel,
|
||||||
|
ChannelType
|
||||||
|
} from '../../shared-kernel';
|
||||||
|
|
||||||
export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
export type { Message, Reaction } from '../../shared-kernel';
|
||||||
|
export { DELETED_MESSAGE_CONTENT } from '../../shared-kernel';
|
||||||
|
|
||||||
export interface User {
|
export type { BanEntry } from '../../shared-kernel';
|
||||||
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 {
|
export type { VoiceState, ScreenShareState } from '../../shared-kernel';
|
||||||
id: string;
|
|
||||||
oderId?: string;
|
|
||||||
username: string;
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
role: UserRole;
|
|
||||||
joinedAt: number;
|
|
||||||
lastSeenAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Channel {
|
export type {
|
||||||
id: string;
|
ChatEventBase,
|
||||||
name: string;
|
ChatEventType,
|
||||||
type: ChannelType;
|
ChatEvent,
|
||||||
position: number;
|
ChatInventoryItem
|
||||||
}
|
} from '../../shared-kernel';
|
||||||
|
|
||||||
export interface Message {
|
export type {
|
||||||
id: string;
|
SignalingMessage,
|
||||||
roomId: string;
|
SignalingMessageType
|
||||||
channelId?: string;
|
} from '../../shared-kernel';
|
||||||
senderId: string;
|
|
||||||
senderName: string;
|
|
||||||
content: string;
|
|
||||||
timestamp: number;
|
|
||||||
editedAt?: number;
|
|
||||||
reactions: Reaction[];
|
|
||||||
isDeleted: boolean;
|
|
||||||
replyToId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Reaction {
|
export type {
|
||||||
id: string;
|
ChatAttachmentAnnouncement,
|
||||||
messageId: string;
|
ChatAttachmentMeta
|
||||||
oderId: string;
|
} from '../../shared-kernel';
|
||||||
userId: string;
|
|
||||||
emoji: string;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Room {
|
export type { ServerInfo } from '../../domains/server-directory';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
150
src/app/core/platform/electron/electron-api.models.ts
Normal file
150
src/app/core/platform/electron/electron-api.models.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
export interface LinuxScreenShareAudioRoutingInfo {
|
||||||
|
available: boolean;
|
||||||
|
active: boolean;
|
||||||
|
monitorCaptureSupported: boolean;
|
||||||
|
screenShareSinkName: string;
|
||||||
|
screenShareMonitorSourceName: string;
|
||||||
|
voiceSinkName: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinuxScreenShareMonitorCaptureInfo {
|
||||||
|
bitsPerSample: number;
|
||||||
|
captureId: string;
|
||||||
|
channelCount: number;
|
||||||
|
sampleRate: number;
|
||||||
|
sourceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinuxScreenShareMonitorAudioChunkPayload {
|
||||||
|
captureId: string;
|
||||||
|
chunk: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinuxScreenShareMonitorAudioEndedPayload {
|
||||||
|
captureId: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClipboardFilePayload {
|
||||||
|
data: string;
|
||||||
|
lastModified: number;
|
||||||
|
mime: string;
|
||||||
|
name: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||||
|
|
||||||
|
export type DesktopUpdateStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'disabled'
|
||||||
|
| 'checking'
|
||||||
|
| 'downloading'
|
||||||
|
| 'up-to-date'
|
||||||
|
| 'restart-required'
|
||||||
|
| 'unsupported'
|
||||||
|
| 'no-manifest'
|
||||||
|
| 'target-unavailable'
|
||||||
|
| 'target-older-than-installed'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
|
||||||
|
|
||||||
|
export interface DesktopUpdateServerContext {
|
||||||
|
manifestUrls: string[];
|
||||||
|
serverVersion: string | null;
|
||||||
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopUpdateState {
|
||||||
|
autoUpdateMode: AutoUpdateMode;
|
||||||
|
availableVersions: string[];
|
||||||
|
configuredManifestUrls: string[];
|
||||||
|
currentVersion: string;
|
||||||
|
defaultManifestUrls: string[];
|
||||||
|
isSupported: boolean;
|
||||||
|
lastCheckedAt: number | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
manifestUrl: string | null;
|
||||||
|
manifestUrls: string[];
|
||||||
|
minimumServerVersion: string | null;
|
||||||
|
preferredVersion: string | null;
|
||||||
|
restartRequired: boolean;
|
||||||
|
serverBlocked: boolean;
|
||||||
|
serverBlockMessage: string | null;
|
||||||
|
serverVersion: string | null;
|
||||||
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
|
status: DesktopUpdateStatus;
|
||||||
|
statusMessage: string | null;
|
||||||
|
targetVersion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopSettingsSnapshot {
|
||||||
|
autoUpdateMode: AutoUpdateMode;
|
||||||
|
autoStart: boolean;
|
||||||
|
hardwareAcceleration: boolean;
|
||||||
|
manifestUrls: string[];
|
||||||
|
preferredVersion: string | null;
|
||||||
|
runtimeHardwareAcceleration: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopSettingsPatch {
|
||||||
|
autoUpdateMode?: AutoUpdateMode;
|
||||||
|
autoStart?: boolean;
|
||||||
|
hardwareAcceleration?: boolean;
|
||||||
|
manifestUrls?: string[];
|
||||||
|
preferredVersion?: string | null;
|
||||||
|
vaapiVideoEncode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectronCommand {
|
||||||
|
type: string;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectronQuery {
|
||||||
|
type: string;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectronApi {
|
||||||
|
linuxDisplayServer: string;
|
||||||
|
minimizeWindow: () => void;
|
||||||
|
maximizeWindow: () => void;
|
||||||
|
closeWindow: () => void;
|
||||||
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
|
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||||
|
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
|
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
|
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||||
|
startLinuxScreenShareMonitorCapture: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
|
||||||
|
stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise<boolean>;
|
||||||
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
|
getAppDataPath: () => Promise<string>;
|
||||||
|
consumePendingDeepLink: () => Promise<string | null>;
|
||||||
|
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
|
||||||
|
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
||||||
|
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||||
|
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
||||||
|
restartToApplyUpdate: () => Promise<boolean>;
|
||||||
|
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||||
|
setDesktopSettings: (patch: DesktopSettingsPatch) => Promise<DesktopSettingsSnapshot>;
|
||||||
|
relaunchApp: () => Promise<boolean>;
|
||||||
|
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||||
|
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||||
|
readFile: (filePath: string) => Promise<string>;
|
||||||
|
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
|
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||||
|
fileExists: (filePath: string) => Promise<boolean>;
|
||||||
|
deleteFile: (filePath: string) => Promise<boolean>;
|
||||||
|
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||||
|
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
|
||||||
|
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ElectronWindow = Window & {
|
||||||
|
electronAPI?: ElectronApi;
|
||||||
|
};
|
||||||
24
src/app/core/platform/electron/electron-bridge.service.ts
Normal file
24
src/app/core/platform/electron/electron-bridge.service.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import type { ElectronApi } from './electron-api.models';
|
||||||
|
import { getElectronApi } from './get-electron-api';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ElectronBridgeService {
|
||||||
|
get isAvailable(): boolean {
|
||||||
|
return this.getApi() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getApi(): ElectronApi | null {
|
||||||
|
return getElectronApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
requireApi(): ElectronApi {
|
||||||
|
const api = this.getApi();
|
||||||
|
|
||||||
|
if (!api) {
|
||||||
|
throw new Error('Electron API is not available in this runtime.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/app/core/platform/electron/get-electron-api.ts
Normal file
7
src/app/core/platform/electron/get-electron-api.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ElectronApi, ElectronWindow } from './electron-api.models';
|
||||||
|
|
||||||
|
export function getElectronApi(): ElectronApi | null {
|
||||||
|
return typeof window !== 'undefined'
|
||||||
|
? (window as ElectronWindow).electronAPI ?? null
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -1,13 +1,5 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { PlatformService } from './platform.service';
|
import { ElectronBridgeService } from './electron/electron-bridge.service';
|
||||||
|
|
||||||
interface ExternalLinkElectronApi {
|
|
||||||
openExternal?: (url: string) => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExternalLinkWindow = Window & {
|
|
||||||
electronAPI?: ExternalLinkElectronApi;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens URLs in the system default browser (Electron) or a new tab (browser).
|
* Opens URLs in the system default browser (Electron) or a new tab (browser).
|
||||||
@@ -17,18 +9,21 @@ type ExternalLinkWindow = Window & {
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ExternalLinkService {
|
export class ExternalLinkService {
|
||||||
private platform = inject(PlatformService);
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
/** Open a URL externally. Only http/https URLs are allowed. */
|
/** Open a URL externally. Only http/https URLs are allowed. */
|
||||||
open(url: string): void {
|
open(url: string): void {
|
||||||
if (!url || !(url.startsWith('http://') || url.startsWith('https://')))
|
if (!url || !(url.startsWith('http://') || url.startsWith('https://')))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (this.platform.isElectron) {
|
const electronApi = this.electronBridge.getApi();
|
||||||
(window as ExternalLinkWindow).electronAPI?.openExternal?.(url);
|
|
||||||
} else {
|
if (electronApi) {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
void electronApi.openExternal(url);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,22 +36,19 @@ export class ExternalLinkService {
|
|||||||
if (!target)
|
if (!target)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const href = target.href; // resolved full URL
|
const href = target.href;
|
||||||
|
|
||||||
if (!href)
|
if (!href)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Skip non-navigable URLs
|
|
||||||
if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:'))
|
if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:'))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Skip same-page anchors
|
|
||||||
const rawAttr = target.getAttribute('href');
|
const rawAttr = target.getAttribute('href');
|
||||||
|
|
||||||
if (rawAttr?.startsWith('#'))
|
if (rawAttr?.startsWith('#'))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Skip Angular router links
|
|
||||||
if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link'))
|
if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link'))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
2
src/app/core/platform/index.ts
Normal file
2
src/app/core/platform/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './platform.service';
|
||||||
|
export * from './external-link.service';
|
||||||
15
src/app/core/platform/platform.service.ts
Normal file
15
src/app/core/platform/platform.service.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { ElectronBridgeService } from './electron/electron-bridge.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PlatformService {
|
||||||
|
readonly isElectron: boolean;
|
||||||
|
readonly isBrowser: boolean;
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.isElectron = this.electronBridge.isAvailable;
|
||||||
|
|
||||||
|
this.isBrowser = !this.isElectron;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/app/core/realtime/index.ts
Normal file
8
src/app/core/realtime/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Transitional application-facing boundary over the shared realtime runtime.
|
||||||
|
* Keep business domains depending on this technical API rather than reaching
|
||||||
|
* into low-level infrastructure implementations directly.
|
||||||
|
*/
|
||||||
|
export { WebRTCService as RealtimeSessionFacade } from '../../infrastructure/realtime/realtime-session.service';
|
||||||
|
export * from '../../infrastructure/realtime/realtime.constants';
|
||||||
|
export * from '../../infrastructure/realtime/realtime.types';
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable complexity, padding-line-between-statements */
|
/* eslint-disable complexity, padding-line-between-statements */
|
||||||
import { getDebugNetworkMetricSnapshot } from '../debug-network-metrics.service';
|
import { getDebugNetworkMetricSnapshot } from '../../../infrastructure/realtime/logging/debug-network-metrics';
|
||||||
import type { Room, User } from '../../models/index';
|
import type { Room, User } from '../../models/index';
|
||||||
import {
|
import {
|
||||||
LOCAL_NETWORK_NODE_ID,
|
LOCAL_NETWORK_NODE_ID,
|
||||||
|
|||||||
@@ -218,7 +218,14 @@ export class DebuggingService {
|
|||||||
|
|
||||||
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
|
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
|
||||||
.trim() || '(empty console call)';
|
.trim() || '(empty console call)';
|
||||||
const consoleMetadata = this.extractConsoleMetadata(rawMessage);
|
// Use only string args for label/message extraction so that
|
||||||
|
// stringified object payloads don't pollute the parsed message.
|
||||||
|
// Object payloads are captured separately via extractConsolePayload.
|
||||||
|
const metadataSource = args
|
||||||
|
.filter((arg): arg is string => typeof arg === 'string')
|
||||||
|
.join(' ')
|
||||||
|
.trim() || rawMessage;
|
||||||
|
const consoleMetadata = this.extractConsoleMetadata(metadataSource);
|
||||||
const payload = this.extractConsolePayload(args);
|
const payload = this.extractConsolePayload(args);
|
||||||
const payloadText = payload === undefined
|
const payloadText = payload === undefined
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -5,65 +5,16 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PlatformService } from './platform.service';
|
import { PlatformService } from '../platform';
|
||||||
import { ServerDirectoryService, type ServerEndpoint } from './server-directory.service';
|
import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server-directory';
|
||||||
|
import {
|
||||||
type AutoUpdateMode = 'auto' | 'off' | 'version';
|
type AutoUpdateMode,
|
||||||
type DesktopUpdateStatus =
|
type DesktopUpdateServerContext,
|
||||||
| 'idle'
|
type DesktopUpdateServerVersionStatus,
|
||||||
| 'disabled'
|
type DesktopUpdateState,
|
||||||
| 'checking'
|
type ElectronApi
|
||||||
| 'downloading'
|
} from '../platform/electron/electron-api.models';
|
||||||
| 'up-to-date'
|
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||||
| 'restart-required'
|
|
||||||
| 'unsupported'
|
|
||||||
| 'no-manifest'
|
|
||||||
| 'target-unavailable'
|
|
||||||
| 'target-older-than-installed'
|
|
||||||
| 'error';
|
|
||||||
type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
|
|
||||||
|
|
||||||
interface DesktopUpdateState {
|
|
||||||
autoUpdateMode: AutoUpdateMode;
|
|
||||||
availableVersions: string[];
|
|
||||||
configuredManifestUrls: string[];
|
|
||||||
currentVersion: string;
|
|
||||||
defaultManifestUrls: string[];
|
|
||||||
isSupported: boolean;
|
|
||||||
lastCheckedAt: number | null;
|
|
||||||
latestVersion: string | null;
|
|
||||||
manifestUrl: string | null;
|
|
||||||
manifestUrls: string[];
|
|
||||||
minimumServerVersion: string | null;
|
|
||||||
preferredVersion: string | null;
|
|
||||||
restartRequired: boolean;
|
|
||||||
serverBlocked: boolean;
|
|
||||||
serverBlockMessage: string | null;
|
|
||||||
serverVersion: string | null;
|
|
||||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
|
||||||
status: DesktopUpdateStatus;
|
|
||||||
statusMessage: string | null;
|
|
||||||
targetVersion: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DesktopUpdateServerContext {
|
|
||||||
manifestUrls: string[];
|
|
||||||
serverVersion: string | null;
|
|
||||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DesktopUpdateElectronApi {
|
|
||||||
checkForAppUpdates?: () => Promise<DesktopUpdateState>;
|
|
||||||
configureAutoUpdateContext?: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
|
||||||
getAutoUpdateState?: () => Promise<DesktopUpdateState>;
|
|
||||||
onAutoUpdateStateChanged?: (listener: (state: DesktopUpdateState) => void) => () => void;
|
|
||||||
restartToApplyUpdate?: () => Promise<boolean>;
|
|
||||||
setDesktopSettings?: (patch: {
|
|
||||||
autoUpdateMode?: AutoUpdateMode;
|
|
||||||
manifestUrls?: string[];
|
|
||||||
preferredVersion?: string | null;
|
|
||||||
}) => Promise<unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerHealthResponse {
|
interface ServerHealthResponse {
|
||||||
releaseManifestUrl?: string;
|
releaseManifestUrl?: string;
|
||||||
@@ -77,10 +28,6 @@ interface ServerHealthSnapshot {
|
|||||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DesktopUpdateWindow = Window & {
|
|
||||||
electronAPI?: DesktopUpdateElectronApi;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
|
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
|
||||||
const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
|
const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
@@ -153,7 +100,8 @@ export class DesktopAppUpdateService {
|
|||||||
readonly state = signal<DesktopUpdateState>(createInitialState());
|
readonly state = signal<DesktopUpdateState>(createInitialState());
|
||||||
|
|
||||||
private injector = inject(Injector);
|
private injector = inject(Injector);
|
||||||
private servers = inject(ServerDirectoryService);
|
private servers = inject(ServerDirectoryFacade);
|
||||||
|
private electronBridge = inject(ElectronBridgeService);
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
private refreshTimerId: number | null = null;
|
private refreshTimerId: number | null = null;
|
||||||
private removeStateListener: (() => void) | null = null;
|
private removeStateListener: (() => void) | null = null;
|
||||||
@@ -393,9 +341,7 @@ export class DesktopAppUpdateService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getElectronApi(): DesktopUpdateElectronApi | null {
|
private getElectronApi(): ElectronApi | null {
|
||||||
return typeof window !== 'undefined'
|
return this.electronBridge.getApi();
|
||||||
? (window as DesktopUpdateWindow).electronAPI ?? null
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
export * from './notification-audio.service';
|
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 '../models/debugging.models';
|
||||||
export * from './debugging/debugging.service';
|
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';
|
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,13 +1,13 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
export type SettingsPage = 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
|
export type SettingsPage = 'general' | 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsModalService {
|
export class SettingsModalService {
|
||||||
readonly isOpen = signal(false);
|
readonly isOpen = signal(false);
|
||||||
readonly activePage = signal<SettingsPage>('network');
|
readonly activePage = signal<SettingsPage>('general');
|
||||||
readonly targetServerId = signal<string | null>(null);
|
readonly targetServerId = signal<string | null>(null);
|
||||||
|
|
||||||
open(page: SettingsPage = 'network', serverId?: string): void {
|
open(page: SettingsPage = 'general', serverId?: string): void {
|
||||||
this.activePage.set(page);
|
this.activePage.set(page);
|
||||||
this.targetServerId.set(serverId ?? null);
|
this.targetServerId.set(serverId ?? null);
|
||||||
this.isOpen.set(true);
|
this.isOpen.set(true);
|
||||||
|
|||||||
@@ -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
62
src/app/domains/README.md
Normal file
62
src/app/domains/README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Domains
|
||||||
|
|
||||||
|
Each folder below is a **bounded context** — a self-contained slice of
|
||||||
|
business logic with its own models, application services, and (optionally)
|
||||||
|
infrastructure adapters and UI.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Domain | Purpose | Public entry point |
|
||||||
|
|---|---|---|
|
||||||
|
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
|
||||||
|
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
|
||||||
|
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||||
|
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||||
|
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||||
|
| **voice-connection** | Voice activity detection, bitrate profiles | `VoiceConnectionFacade` |
|
||||||
|
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
|
||||||
|
|
||||||
|
## Folder convention
|
||||||
|
|
||||||
|
Every domain follows the same internal layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
domains/<name>/
|
||||||
|
├── index.ts # Barrel — the ONLY file outsiders import
|
||||||
|
├── domain/ # Pure types, interfaces, business rules
|
||||||
|
│ ├── <name>.models.ts
|
||||||
|
│ └── <name>.logic.ts # Pure functions (no Angular, no side effects)
|
||||||
|
├── application/ # Angular services that orchestrate domain logic
|
||||||
|
│ └── <name>.facade.ts # Public entry point for the domain
|
||||||
|
├── infrastructure/ # Technical adapters (HTTP, storage, WebSocket)
|
||||||
|
└── feature/ # Optional: domain-owned UI components / routes
|
||||||
|
└── settings/ # e.g. settings subpanel owned by this domain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Import from the barrel.** Outside a domain, always import from
|
||||||
|
`domains/<name>` (the `index.ts`), never from internal paths.
|
||||||
|
|
||||||
|
2. **No cross-domain imports.** Domain A must never import from Domain B's
|
||||||
|
internals. Shared types live in `shared-kernel/`.
|
||||||
|
|
||||||
|
3. **Features compose domains.** Top-level `features/` components inject
|
||||||
|
domain facades and compose their outputs — they never contain business
|
||||||
|
logic.
|
||||||
|
|
||||||
|
4. **Store slices are application-level.** `store/messages`, `store/rooms`,
|
||||||
|
`store/users` are global state managed by NgRx. They import from
|
||||||
|
`shared-kernel` for types and from domain facades for side-effects.
|
||||||
|
|
||||||
|
## Where do I put new code?
|
||||||
|
|
||||||
|
| I want to… | Put it in… |
|
||||||
|
|---|---|
|
||||||
|
| Add a new business concept | New folder under `domains/` following the convention above |
|
||||||
|
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
|
||||||
|
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
|
||||||
|
| Add a settings subpanel | `domains/<name>/feature/settings/` |
|
||||||
|
| Add a top-level page or shell component | `features/` |
|
||||||
|
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
|
||||||
|
| Add realtime/WebRTC logic | `infrastructure/realtime/` |
|
||||||
148
src/app/domains/attachment/README.md
Normal file
148
src/app/domains/attachment/README.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Attachment Domain
|
||||||
|
|
||||||
|
Handles file sharing between peers over WebRTC data channels. Files are announced, chunked into 64 KB pieces, streamed peer-to-peer as base64, and optionally persisted to disk (Electron) or kept in memory (browser).
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
```
|
||||||
|
attachment/
|
||||||
|
├── application/
|
||||||
|
│ ├── attachment.facade.ts Thin entry point, delegates to manager
|
||||||
|
│ ├── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners
|
||||||
|
│ ├── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel)
|
||||||
|
│ ├── attachment-transfer-transport.service.ts Base64 encode/decode, chunked streaming
|
||||||
|
│ ├── attachment-persistence.service.ts DB + filesystem persistence, migration from localStorage
|
||||||
|
│ └── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending)
|
||||||
|
│
|
||||||
|
├── domain/
|
||||||
|
│ ├── attachment.models.ts Attachment type extending AttachmentMeta with runtime state
|
||||||
|
│ ├── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment
|
||||||
|
│ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB
|
||||||
|
│ ├── attachment-transfer.models.ts Protocol event types (file-announce, file-chunk, file-request, ...)
|
||||||
|
│ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages
|
||||||
|
│
|
||||||
|
├── infrastructure/
|
||||||
|
│ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete)
|
||||||
|
│ └── attachment-storage.helpers.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
|
||||||
|
│
|
||||||
|
└── index.ts Barrel exports
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service composition
|
||||||
|
|
||||||
|
The facade is a thin pass-through. All real work happens inside the manager, which coordinates the transfer service (protocol), persistence service (DB/disk), and runtime store (signals).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Facade[AttachmentFacade]
|
||||||
|
Manager[AttachmentManagerService]
|
||||||
|
Transfer[AttachmentTransferService]
|
||||||
|
Transport[AttachmentTransferTransportService]
|
||||||
|
Persistence[AttachmentPersistenceService]
|
||||||
|
Store[AttachmentRuntimeStore]
|
||||||
|
Storage[AttachmentStorageService]
|
||||||
|
Logic[attachment.logic]
|
||||||
|
|
||||||
|
Facade --> Manager
|
||||||
|
Manager --> Transfer
|
||||||
|
Manager --> Persistence
|
||||||
|
Manager --> Store
|
||||||
|
Manager --> Logic
|
||||||
|
Transfer --> Transport
|
||||||
|
Transfer --> Store
|
||||||
|
Persistence --> Storage
|
||||||
|
Persistence --> Store
|
||||||
|
Storage --> Helpers[attachment-storage.helpers]
|
||||||
|
|
||||||
|
click Facade "application/attachment.facade.ts" "Thin entry point" _blank
|
||||||
|
click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle" _blank
|
||||||
|
click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol" _blank
|
||||||
|
click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank
|
||||||
|
click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence" _blank
|
||||||
|
click Store "application/attachment-runtime.store.ts" "In-memory signal-based state" _blank
|
||||||
|
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access" _blank
|
||||||
|
click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers" _blank
|
||||||
|
click Logic "domain/attachment.logic.ts" "Pure decision functions" _blank
|
||||||
|
```
|
||||||
|
|
||||||
|
## File transfer protocol
|
||||||
|
|
||||||
|
Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant S as Sender
|
||||||
|
participant R as Receiver
|
||||||
|
|
||||||
|
S->>R: file-announce (id, name, size, mimeType)
|
||||||
|
Note over R: Store metadata in runtime store
|
||||||
|
Note over R: shouldAutoRequestWhenWatched?
|
||||||
|
|
||||||
|
R->>S: file-request (attachmentId)
|
||||||
|
Note over S: Look up file in runtime store or on disk
|
||||||
|
|
||||||
|
loop Every 64 KB chunk
|
||||||
|
S->>R: file-chunk (attachmentId, index, data, progress, speed)
|
||||||
|
Note over R: Append to chunk buffer
|
||||||
|
Note over R: Update progress + EWMA speed
|
||||||
|
end
|
||||||
|
|
||||||
|
Note over R: All chunks received
|
||||||
|
Note over R: Reassemble blob
|
||||||
|
Note over R: shouldPersistDownloadedAttachment? Save to disk
|
||||||
|
```
|
||||||
|
|
||||||
|
### Failure handling
|
||||||
|
|
||||||
|
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant R as Receiver
|
||||||
|
participant P1 as Peer A
|
||||||
|
participant P2 as Peer B
|
||||||
|
|
||||||
|
R->>P1: file-request
|
||||||
|
P1->>R: file-not-found
|
||||||
|
Note over R: Try next peer
|
||||||
|
R->>P2: file-request
|
||||||
|
P2->>R: file-chunk (1/N)
|
||||||
|
P2->>R: file-chunk (2/N)
|
||||||
|
P2->>R: file-chunk (N/N)
|
||||||
|
Note over R: Transfer complete
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto-download rules
|
||||||
|
|
||||||
|
When the user navigates to a room, the manager watches the route and decides which attachments to request automatically based on domain logic:
|
||||||
|
|
||||||
|
| Condition | Auto-download? |
|
||||||
|
|---|---|
|
||||||
|
| Image or video, size <= 10 MB | Yes |
|
||||||
|
| Image or video, size > 10 MB | No |
|
||||||
|
| Non-media file | No |
|
||||||
|
|
||||||
|
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket:
|
||||||
|
|
||||||
|
```
|
||||||
|
{appDataPath}/{serverId}/{roomName}/{bucket}/{filename}
|
||||||
|
```
|
||||||
|
|
||||||
|
Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type.
|
||||||
|
|
||||||
|
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On browser builds, files stay in memory only.
|
||||||
|
|
||||||
|
## Runtime store
|
||||||
|
|
||||||
|
`AttachmentRuntimeStore` is a signal-based in-memory store using `Map` instances for:
|
||||||
|
|
||||||
|
- **attachments**: all known attachments keyed by ID
|
||||||
|
- **chunks**: incoming chunk buffers during active transfers
|
||||||
|
- **pendingRequests**: outbound requests waiting for a response
|
||||||
|
- **cancellations**: IDs of transfers the user cancelled
|
||||||
|
|
||||||
|
Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service.
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
effect,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
|
import { DatabaseService } from '../../../infrastructure/persistence';
|
||||||
|
import { ROOM_URL_PATTERN } from '../../../core/constants';
|
||||||
|
import { shouldAutoRequestWhenWatched } from '../domain/attachment.logic';
|
||||||
|
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
|
||||||
|
import type {
|
||||||
|
FileAnnouncePayload,
|
||||||
|
FileCancelPayload,
|
||||||
|
FileChunkPayload,
|
||||||
|
FileNotFoundPayload,
|
||||||
|
FileRequestPayload
|
||||||
|
} from '../domain/attachment-transfer.models';
|
||||||
|
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||||
|
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||||
|
import { AttachmentTransferService } from './attachment-transfer.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AttachmentManagerService {
|
||||||
|
get updated() {
|
||||||
|
return this.runtimeStore.updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly database = inject(DatabaseService);
|
||||||
|
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||||
|
private readonly persistence = inject(AttachmentPersistenceService);
|
||||||
|
private readonly transfer = inject(AttachmentTransferService);
|
||||||
|
|
||||||
|
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
|
||||||
|
private isDatabaseInitialised = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (this.database.isReady() && !this.isDatabaseInitialised) {
|
||||||
|
this.isDatabaseInitialised = true;
|
||||||
|
void this.persistence.initFromDatabase();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.router.events.subscribe((event) => {
|
||||||
|
if (!(event instanceof NavigationEnd)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
|
||||||
|
|
||||||
|
if (this.watchedRoomId) {
|
||||||
|
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.webrtc.onPeerConnected.subscribe(() => {
|
||||||
|
if (this.watchedRoomId) {
|
||||||
|
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getForMessage(messageId: string): Attachment[] {
|
||||||
|
return this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberMessageRoom(messageId: string, roomId: string): void {
|
||||||
|
if (!messageId || !roomId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.runtimeStore.rememberMessageRoom(messageId, roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
queueAutoDownloadsForMessage(messageId: string, attachmentId?: string): void {
|
||||||
|
void this.requestAutoDownloadsForMessage(messageId, attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
|
||||||
|
if (!roomId || !this.isRoomWatched(roomId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (this.database.isReady()) {
|
||||||
|
const messages = await this.database.getMessages(roomId, 500, 0);
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
|
||||||
|
await this.requestAutoDownloadsForMessage(message.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
|
||||||
|
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
|
||||||
|
|
||||||
|
if (attachmentRoomId === roomId) {
|
||||||
|
await this.requestAutoDownloadsForMessage(messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteForMessage(messageId: string): Promise<void> {
|
||||||
|
await this.persistence.deleteForMessage(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
|
||||||
|
return this.transfer.getAttachmentMetasForMessages(messageIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSyncedAttachments(
|
||||||
|
attachmentMap: Record<string, AttachmentMeta[]>,
|
||||||
|
messageRoomIds?: Record<string, string>
|
||||||
|
): void {
|
||||||
|
this.transfer.registerSyncedAttachments(attachmentMap, messageRoomIds);
|
||||||
|
|
||||||
|
for (const [messageId, attachments] of Object.entries(attachmentMap)) {
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
this.queueAutoDownloadsForMessage(messageId, attachment.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||||
|
this.transfer.requestFromAnyPeer(messageId, attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileNotFound(payload: FileNotFoundPayload): void {
|
||||||
|
this.transfer.handleFileNotFound(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||||
|
this.transfer.requestImageFromAnyPeer(messageId, attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestFile(messageId: string, attachment: Attachment): void {
|
||||||
|
this.transfer.requestFile(messageId, attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishAttachments(
|
||||||
|
messageId: string,
|
||||||
|
files: File[],
|
||||||
|
uploaderPeerId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
await this.transfer.publishAttachments(messageId, files, uploaderPeerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||||
|
this.transfer.handleFileAnnounce(payload);
|
||||||
|
|
||||||
|
if (payload.messageId && payload.file?.id) {
|
||||||
|
this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileChunk(payload: FileChunkPayload): void {
|
||||||
|
this.transfer.handleFileChunk(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||||
|
await this.transfer.handleFileRequest(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelRequest(messageId: string, attachment: Attachment): void {
|
||||||
|
this.transfer.cancelRequest(messageId, attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileCancel(payload: FileCancelPayload): void {
|
||||||
|
this.transfer.handleFileCancel(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fulfillRequestWithFile(
|
||||||
|
messageId: string,
|
||||||
|
fileId: string,
|
||||||
|
targetPeerId: string,
|
||||||
|
file: File
|
||||||
|
): Promise<void> {
|
||||||
|
await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
|
||||||
|
if (!messageId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const roomId = await this.persistence.resolveMessageRoomId(messageId);
|
||||||
|
|
||||||
|
if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
if (attachmentId && attachment.id !== attachmentId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!shouldAutoRequestWhenWatched(attachment))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (attachment.available)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if ((attachment.receivedBytes ?? 0) > 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (this.transfer.hasPendingRequest(messageId, attachment.id))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
this.transfer.requestFromAnyPeer(messageId, attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractWatchedRoomId(url: string): string | null {
|
||||||
|
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||||
|
|
||||||
|
return roomMatch ? roomMatch[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRoomWatched(roomId: string | null | undefined): boolean {
|
||||||
|
return !!roomId && roomId === this.watchedRoomId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { take } from 'rxjs';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { selectCurrentRoomName } from '../../../store/rooms/rooms.selectors';
|
||||||
|
import { DatabaseService } from '../../../infrastructure/persistence';
|
||||||
|
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
|
||||||
|
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
|
||||||
|
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants';
|
||||||
|
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../domain/attachment-transfer.constants';
|
||||||
|
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AttachmentPersistenceService {
|
||||||
|
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||||
|
private readonly ngrxStore = inject(Store);
|
||||||
|
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||||
|
private readonly database = inject(DatabaseService);
|
||||||
|
|
||||||
|
async deleteForMessage(messageId: string): Promise<void> {
|
||||||
|
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||||
|
const hadCachedAttachments = attachments.length > 0 || this.runtimeStore.hasAttachmentsForMessage(messageId);
|
||||||
|
const retainedSavedPaths = await this.getRetainedSavedPathsForOtherMessages(messageId);
|
||||||
|
const savedPathsToDelete = new Set<string>();
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
if (attachment.objectUrl) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(attachment.objectUrl);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment.savedPath && !retainedSavedPaths.has(attachment.savedPath)) {
|
||||||
|
savedPathsToDelete.add(attachment.savedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.runtimeStore.deleteAttachmentsForMessage(messageId);
|
||||||
|
this.runtimeStore.deleteMessageRoom(messageId);
|
||||||
|
this.runtimeStore.clearMessageScopedState(messageId);
|
||||||
|
|
||||||
|
if (hadCachedAttachments) {
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.database.isReady()) {
|
||||||
|
await this.database.deleteAttachmentsForMessage(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const diskPath of savedPathsToDelete) {
|
||||||
|
await this.attachmentStorage.deleteFile(diskPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async persistAttachmentMeta(attachment: Attachment): Promise<void> {
|
||||||
|
if (!this.database.isReady())
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.database.saveAttachment({
|
||||||
|
id: attachment.id,
|
||||||
|
messageId: attachment.messageId,
|
||||||
|
filename: attachment.filename,
|
||||||
|
size: attachment.size,
|
||||||
|
mime: attachment.mime,
|
||||||
|
isImage: attachment.isImage,
|
||||||
|
uploaderPeerId: attachment.uploaderPeerId,
|
||||||
|
filePath: attachment.filePath,
|
||||||
|
savedPath: attachment.savedPath
|
||||||
|
});
|
||||||
|
} catch { /* persistence is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> {
|
||||||
|
try {
|
||||||
|
const roomName = await this.resolveCurrentRoomName();
|
||||||
|
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, roomName);
|
||||||
|
|
||||||
|
if (!diskPath)
|
||||||
|
return;
|
||||||
|
|
||||||
|
attachment.savedPath = diskPath;
|
||||||
|
void this.persistAttachmentMeta(attachment);
|
||||||
|
} catch { /* disk save is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async initFromDatabase(): Promise<void> {
|
||||||
|
await this.loadFromDatabase();
|
||||||
|
await this.migrateFromLocalStorage();
|
||||||
|
await this.tryLoadSavedFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveMessageRoomId(messageId: string): Promise<string | null> {
|
||||||
|
const cachedRoomId = this.runtimeStore.getMessageRoomId(messageId);
|
||||||
|
|
||||||
|
if (cachedRoomId)
|
||||||
|
return cachedRoomId;
|
||||||
|
|
||||||
|
if (!this.database.isReady())
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = await this.database.getMessageById(messageId);
|
||||||
|
|
||||||
|
if (!message?.roomId)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
this.runtimeStore.rememberMessageRoom(messageId, message.roomId);
|
||||||
|
return message.roomId;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveCurrentRoomName(): Promise<string> {
|
||||||
|
return new Promise<string>((resolve) => {
|
||||||
|
this.ngrxStore
|
||||||
|
.select(selectCurrentRoomName)
|
||||||
|
.pipe(take(1))
|
||||||
|
.subscribe((name) => resolve(name || ''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFromDatabase(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
|
||||||
|
const grouped = new Map<string, Attachment[]>();
|
||||||
|
|
||||||
|
for (const record of allRecords) {
|
||||||
|
const attachment: Attachment = { ...record,
|
||||||
|
available: false };
|
||||||
|
const bucket = grouped.get(record.messageId) ?? [];
|
||||||
|
|
||||||
|
bucket.push(attachment);
|
||||||
|
grouped.set(record.messageId, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.runtimeStore.replaceAttachments(grouped);
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
} catch { /* load is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async migrateFromLocalStorage(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LEGACY_ATTACHMENTS_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!raw)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const legacyRecords: AttachmentMeta[] = JSON.parse(raw);
|
||||||
|
|
||||||
|
for (const meta of legacyRecords) {
|
||||||
|
const existing = [...this.runtimeStore.getAttachmentsForMessage(meta.messageId)];
|
||||||
|
|
||||||
|
if (!existing.find((entry) => entry.id === meta.id)) {
|
||||||
|
const attachment: Attachment = { ...meta,
|
||||||
|
available: false };
|
||||||
|
|
||||||
|
existing.push(attachment);
|
||||||
|
this.runtimeStore.setAttachmentsForMessage(meta.messageId, existing);
|
||||||
|
void this.persistAttachmentMeta(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.removeItem(LEGACY_ATTACHMENTS_STORAGE_KEY);
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
} catch { /* migration is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryLoadSavedFiles(): Promise<void> {
|
||||||
|
try {
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
if (attachment.available)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (attachment.savedPath) {
|
||||||
|
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
|
||||||
|
|
||||||
|
if (savedBase64) {
|
||||||
|
this.restoreAttachmentFromDisk(attachment, savedBase64);
|
||||||
|
hasChanges = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment.filePath) {
|
||||||
|
const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath);
|
||||||
|
|
||||||
|
if (originalBase64) {
|
||||||
|
this.restoreAttachmentFromDisk(attachment, originalBase64);
|
||||||
|
hasChanges = true;
|
||||||
|
|
||||||
|
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES && attachment.objectUrl) {
|
||||||
|
const response = await fetch(attachment.objectUrl);
|
||||||
|
|
||||||
|
void this.saveFileToDisk(attachment, await response.blob());
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges)
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
} catch { /* startup load is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void {
|
||||||
|
const bytes = this.base64ToUint8Array(base64);
|
||||||
|
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime });
|
||||||
|
|
||||||
|
attachment.objectUrl = URL.createObjectURL(blob);
|
||||||
|
attachment.available = true;
|
||||||
|
|
||||||
|
this.runtimeStore.setOriginalFile(
|
||||||
|
`${attachment.messageId}:${attachment.id}`,
|
||||||
|
new File([blob], attachment.filename, { type: attachment.mime })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> {
|
||||||
|
const retainedSavedPaths = new Set<string>();
|
||||||
|
|
||||||
|
for (const [existingMessageId, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
||||||
|
if (existingMessageId === messageId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
if (attachment.savedPath) {
|
||||||
|
retainedSavedPaths.add(attachment.savedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.database.isReady()) {
|
||||||
|
return retainedSavedPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedAttachments = await this.database.getAllAttachments();
|
||||||
|
|
||||||
|
for (const attachment of persistedAttachments) {
|
||||||
|
if (attachment.messageId !== messageId && attachment.savedPath) {
|
||||||
|
retainedSavedPaths.add(attachment.savedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return retainedSavedPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
private base64ToUint8Array(base64: string): Uint8Array {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < binary.length; index++) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
import type { Attachment } from '../domain/attachment.models';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AttachmentRuntimeStore {
|
||||||
|
readonly updated = signal<number>(0);
|
||||||
|
|
||||||
|
private attachmentsByMessage = new Map<string, Attachment[]>();
|
||||||
|
private messageRoomIds = new Map<string, string>();
|
||||||
|
private originalFiles = new Map<string, File>();
|
||||||
|
private cancelledTransfers = new Set<string>();
|
||||||
|
private pendingRequests = new Map<string, Set<string>>();
|
||||||
|
private chunkBuffers = new Map<string, ArrayBuffer[]>();
|
||||||
|
private chunkCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
touch(): void {
|
||||||
|
this.updated.set(this.updated() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttachmentsForMessage(messageId: string): Attachment[] {
|
||||||
|
return this.attachmentsByMessage.get(messageId) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttachmentsForMessage(messageId: string, attachments: Attachment[]): void {
|
||||||
|
if (attachments.length === 0) {
|
||||||
|
this.attachmentsByMessage.delete(messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.attachmentsByMessage.set(messageId, attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAttachmentsForMessage(messageId: string): boolean {
|
||||||
|
return this.attachmentsByMessage.has(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAttachmentsForMessage(messageId: string): void {
|
||||||
|
this.attachmentsByMessage.delete(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceAttachments(nextAttachments: Map<string, Attachment[]>): void {
|
||||||
|
this.attachmentsByMessage = nextAttachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttachmentEntries(): IterableIterator<[string, Attachment[]]> {
|
||||||
|
return this.attachmentsByMessage.entries();
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberMessageRoom(messageId: string, roomId: string): void {
|
||||||
|
this.messageRoomIds.set(messageId, roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessageRoomId(messageId: string): string | undefined {
|
||||||
|
return this.messageRoomIds.get(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteMessageRoom(messageId: string): void {
|
||||||
|
this.messageRoomIds.delete(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOriginalFile(key: string, file: File): void {
|
||||||
|
this.originalFiles.set(key, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
getOriginalFile(key: string): File | undefined {
|
||||||
|
return this.originalFiles.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
findOriginalFileByFileId(fileId: string): File | null {
|
||||||
|
for (const [key, file] of this.originalFiles) {
|
||||||
|
if (key.endsWith(`:${fileId}`)) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
addCancelledTransfer(key: string): void {
|
||||||
|
this.cancelledTransfers.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasCancelledTransfer(key: string): boolean {
|
||||||
|
return this.cancelledTransfers.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingRequestPeers(key: string, peers: Set<string>): void {
|
||||||
|
this.pendingRequests.set(key, peers);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingRequestPeers(key: string): Set<string> | undefined {
|
||||||
|
return this.pendingRequests.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPendingRequest(key: string): boolean {
|
||||||
|
return this.pendingRequests.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePendingRequest(key: string): void {
|
||||||
|
this.pendingRequests.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
setChunkBuffer(key: string, buffer: ArrayBuffer[]): void {
|
||||||
|
this.chunkBuffers.set(key, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
getChunkBuffer(key: string): ArrayBuffer[] | undefined {
|
||||||
|
return this.chunkBuffers.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteChunkBuffer(key: string): void {
|
||||||
|
this.chunkBuffers.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
setChunkCount(key: string, count: number): void {
|
||||||
|
this.chunkCounts.set(key, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
getChunkCount(key: string): number | undefined {
|
||||||
|
return this.chunkCounts.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteChunkCount(key: string): void {
|
||||||
|
this.chunkCounts.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMessageScopedState(messageId: string): void {
|
||||||
|
const scopedPrefix = `${messageId}:`;
|
||||||
|
|
||||||
|
for (const key of Array.from(this.originalFiles.keys())) {
|
||||||
|
if (key.startsWith(scopedPrefix)) {
|
||||||
|
this.originalFiles.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Array.from(this.pendingRequests.keys())) {
|
||||||
|
if (key.startsWith(scopedPrefix)) {
|
||||||
|
this.pendingRequests.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Array.from(this.chunkBuffers.keys())) {
|
||||||
|
if (key.startsWith(scopedPrefix)) {
|
||||||
|
this.chunkBuffers.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Array.from(this.chunkCounts.keys())) {
|
||||||
|
if (key.startsWith(scopedPrefix)) {
|
||||||
|
this.chunkCounts.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Array.from(this.cancelledTransfers)) {
|
||||||
|
if (key.startsWith(scopedPrefix)) {
|
||||||
|
this.cancelledTransfers.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
|
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
|
||||||
|
import { FILE_CHUNK_SIZE_BYTES } from '../domain/attachment-transfer.constants';
|
||||||
|
import { FileChunkEvent } from '../domain/attachment-transfer.models';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AttachmentTransferTransportService {
|
||||||
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||||
|
|
||||||
|
decodeBase64(base64: string): Uint8Array {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < binary.length; index++) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async streamFileToPeer(
|
||||||
|
targetPeerId: string,
|
||||||
|
messageId: string,
|
||||||
|
fileId: string,
|
||||||
|
file: File,
|
||||||
|
isCancelled: () => boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
let chunkIndex = 0;
|
||||||
|
|
||||||
|
while (offset < file.size) {
|
||||||
|
if (isCancelled())
|
||||||
|
break;
|
||||||
|
|
||||||
|
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
|
||||||
|
const arrayBuffer = await slice.arrayBuffer();
|
||||||
|
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
||||||
|
const fileChunkEvent: FileChunkEvent = {
|
||||||
|
type: 'file-chunk',
|
||||||
|
messageId,
|
||||||
|
fileId,
|
||||||
|
index: chunkIndex,
|
||||||
|
total: totalChunks,
|
||||||
|
data: base64
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||||
|
|
||||||
|
offset += FILE_CHUNK_SIZE_BYTES;
|
||||||
|
chunkIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async streamFileFromDiskToPeer(
|
||||||
|
targetPeerId: string,
|
||||||
|
messageId: string,
|
||||||
|
fileId: string,
|
||||||
|
diskPath: string,
|
||||||
|
isCancelled: () => boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const base64Full = await this.attachmentStorage.readFile(diskPath);
|
||||||
|
|
||||||
|
if (!base64Full)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const fileBytes = this.decodeBase64(base64Full);
|
||||||
|
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
|
||||||
|
|
||||||
|
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||||
|
if (isCancelled())
|
||||||
|
break;
|
||||||
|
|
||||||
|
const start = chunkIndex * FILE_CHUNK_SIZE_BYTES;
|
||||||
|
const end = Math.min(fileBytes.byteLength, start + FILE_CHUNK_SIZE_BYTES);
|
||||||
|
const slice = fileBytes.subarray(start, end);
|
||||||
|
const sliceBuffer = (slice.buffer as ArrayBuffer).slice(
|
||||||
|
slice.byteOffset,
|
||||||
|
slice.byteOffset + slice.byteLength
|
||||||
|
);
|
||||||
|
const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
|
||||||
|
const fileChunkEvent: FileChunkEvent = {
|
||||||
|
type: 'file-chunk',
|
||||||
|
messageId,
|
||||||
|
fileId,
|
||||||
|
index: chunkIndex,
|
||||||
|
total: totalChunks,
|
||||||
|
data: base64Chunk
|
||||||
|
};
|
||||||
|
|
||||||
|
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
let binary = '';
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
for (let index = 0; index < bytes.byteLength; index++) {
|
||||||
|
binary += String.fromCharCode(bytes[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { recordDebugNetworkFileChunk } from '../../../infrastructure/realtime/logging/debug-network-metrics';
|
||||||
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
|
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
|
||||||
|
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants';
|
||||||
|
import { shouldPersistDownloadedAttachment } from '../domain/attachment.logic';
|
||||||
|
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
|
||||||
|
import {
|
||||||
|
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
|
||||||
|
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
|
||||||
|
DEFAULT_ATTACHMENT_MIME_TYPE,
|
||||||
|
FILE_NOT_FOUND_REQUEST_ERROR,
|
||||||
|
NO_CONNECTED_PEERS_REQUEST_ERROR
|
||||||
|
} from '../domain/attachment-transfer.constants';
|
||||||
|
import {
|
||||||
|
type FileAnnounceEvent,
|
||||||
|
type FileAnnouncePayload,
|
||||||
|
type FileCancelEvent,
|
||||||
|
type FileCancelPayload,
|
||||||
|
type FileChunkPayload,
|
||||||
|
type FileNotFoundEvent,
|
||||||
|
type FileNotFoundPayload,
|
||||||
|
type FileRequestEvent,
|
||||||
|
type FileRequestPayload,
|
||||||
|
type LocalFileWithPath
|
||||||
|
} from '../domain/attachment-transfer.models';
|
||||||
|
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||||
|
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||||
|
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AttachmentTransferService {
|
||||||
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||||
|
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||||
|
private readonly persistence = inject(AttachmentPersistenceService);
|
||||||
|
private readonly transport = inject(AttachmentTransferTransportService);
|
||||||
|
|
||||||
|
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
|
||||||
|
const result: Record<string, AttachmentMeta[]> = {};
|
||||||
|
|
||||||
|
for (const messageId of messageIds) {
|
||||||
|
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||||
|
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
result[messageId] = attachments.map((attachment) => ({
|
||||||
|
id: attachment.id,
|
||||||
|
messageId: attachment.messageId,
|
||||||
|
filename: attachment.filename,
|
||||||
|
size: attachment.size,
|
||||||
|
mime: attachment.mime,
|
||||||
|
isImage: attachment.isImage,
|
||||||
|
uploaderPeerId: attachment.uploaderPeerId,
|
||||||
|
filePath: undefined,
|
||||||
|
savedPath: undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSyncedAttachments(
|
||||||
|
attachmentMap: Record<string, AttachmentMeta[]>,
|
||||||
|
messageRoomIds?: Record<string, string>
|
||||||
|
): void {
|
||||||
|
if (messageRoomIds) {
|
||||||
|
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
|
||||||
|
this.runtimeStore.rememberMessageRoom(messageId, roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAttachments: Attachment[] = [];
|
||||||
|
|
||||||
|
for (const [messageId, metas] of Object.entries(attachmentMap)) {
|
||||||
|
const existing = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
|
||||||
|
|
||||||
|
for (const meta of metas) {
|
||||||
|
const alreadyKnown = existing.find((entry) => entry.id === meta.id);
|
||||||
|
|
||||||
|
if (!alreadyKnown) {
|
||||||
|
const attachment: Attachment = { ...meta,
|
||||||
|
available: false,
|
||||||
|
receivedBytes: 0 };
|
||||||
|
|
||||||
|
existing.push(attachment);
|
||||||
|
newAttachments.push(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.runtimeStore.setAttachmentsForMessage(messageId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newAttachments.length > 0) {
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
|
||||||
|
for (const attachment of newAttachments) {
|
||||||
|
void this.persistence.persistAttachmentMeta(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||||
|
const clearedRequestError = this.clearAttachmentRequestError(attachment);
|
||||||
|
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||||
|
|
||||||
|
if (connectedPeers.length === 0) {
|
||||||
|
attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR;
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
console.warn('[Attachments] No connected peers to request file from');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearedRequestError)
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
|
||||||
|
this.runtimeStore.setPendingRequestPeers(
|
||||||
|
this.buildRequestKey(messageId, attachment.id),
|
||||||
|
new Set<string>()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileNotFound(payload: FileNotFoundPayload): void {
|
||||||
|
const { messageId, fileId } = payload;
|
||||||
|
|
||||||
|
if (!messageId || !fileId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||||
|
const attachment = attachments.find((entry) => entry.id === fileId);
|
||||||
|
const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
|
||||||
|
|
||||||
|
if (!didSendRequest && attachment) {
|
||||||
|
attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR;
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||||
|
this.requestFromAnyPeer(messageId, attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestFile(messageId: string, attachment: Attachment): void {
|
||||||
|
this.requestFromAnyPeer(messageId, attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPendingRequest(messageId: string, fileId: string): boolean {
|
||||||
|
return this.runtimeStore.hasPendingRequest(this.buildRequestKey(messageId, fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishAttachments(
|
||||||
|
messageId: string,
|
||||||
|
files: File[],
|
||||||
|
uploaderPeerId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const attachments: Attachment[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const fileId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
|
||||||
|
const attachment: Attachment = {
|
||||||
|
id: fileId,
|
||||||
|
messageId,
|
||||||
|
filename: file.name,
|
||||||
|
size: file.size,
|
||||||
|
mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE,
|
||||||
|
isImage: file.type.startsWith('image/'),
|
||||||
|
uploaderPeerId,
|
||||||
|
filePath: (file as LocalFileWithPath).path,
|
||||||
|
available: false
|
||||||
|
};
|
||||||
|
|
||||||
|
attachments.push(attachment);
|
||||||
|
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
attachment.objectUrl = URL.createObjectURL(file);
|
||||||
|
attachment.available = true;
|
||||||
|
} catch { /* non-critical */ }
|
||||||
|
|
||||||
|
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||||
|
void this.persistence.saveFileToDisk(attachment, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileAnnounceEvent: FileAnnounceEvent = {
|
||||||
|
type: 'file-announce',
|
||||||
|
messageId,
|
||||||
|
file: {
|
||||||
|
id: fileId,
|
||||||
|
filename: attachment.filename,
|
||||||
|
size: attachment.size,
|
||||||
|
mime: attachment.mime,
|
||||||
|
isImage: attachment.isImage,
|
||||||
|
uploaderPeerId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.webrtc.broadcastMessage(fileAnnounceEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingList = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||||
|
|
||||||
|
this.runtimeStore.setAttachmentsForMessage(messageId, [...existingList, ...attachments]);
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
void this.persistence.persistAttachmentMeta(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||||
|
const { messageId, file } = payload;
|
||||||
|
|
||||||
|
if (!messageId || !file)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
|
||||||
|
const alreadyKnown = list.find((entry) => entry.id === file.id);
|
||||||
|
|
||||||
|
if (alreadyKnown)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const attachment: Attachment = {
|
||||||
|
id: file.id,
|
||||||
|
messageId,
|
||||||
|
filename: file.filename,
|
||||||
|
size: file.size,
|
||||||
|
mime: file.mime,
|
||||||
|
isImage: !!file.isImage,
|
||||||
|
uploaderPeerId: file.uploaderPeerId,
|
||||||
|
available: false,
|
||||||
|
receivedBytes: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
list.push(attachment);
|
||||||
|
this.runtimeStore.setAttachmentsForMessage(messageId, list);
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
void this.persistence.persistAttachmentMeta(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileChunk(payload: FileChunkPayload): void {
|
||||||
|
const { messageId, fileId, fromPeerId, index, total, data } = payload;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!messageId || !fileId ||
|
||||||
|
typeof index !== 'number' ||
|
||||||
|
typeof total !== 'number' ||
|
||||||
|
typeof data !== 'string'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||||
|
const attachment = list.find((entry) => entry.id === fileId);
|
||||||
|
|
||||||
|
if (!attachment)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const decodedBytes = this.transport.decodeBase64(data);
|
||||||
|
const assemblyKey = `${messageId}:${fileId}`;
|
||||||
|
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||||
|
|
||||||
|
this.runtimeStore.deletePendingRequest(requestKey);
|
||||||
|
this.clearAttachmentRequestError(attachment);
|
||||||
|
|
||||||
|
const chunkBuffer = this.getOrCreateChunkBuffer(assemblyKey, total);
|
||||||
|
|
||||||
|
if (!chunkBuffer[index]) {
|
||||||
|
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
|
||||||
|
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
|
||||||
|
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
this.finalizeTransferIfComplete(attachment, assemblyKey, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||||
|
const { messageId, fileId, fromPeerId } = payload;
|
||||||
|
|
||||||
|
if (!messageId || !fileId || !fromPeerId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const exactKey = `${messageId}:${fileId}`;
|
||||||
|
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
|
||||||
|
?? this.runtimeStore.findOriginalFileByFileId(fileId);
|
||||||
|
|
||||||
|
if (originalFile) {
|
||||||
|
await this.transport.streamFileToPeer(
|
||||||
|
fromPeerId,
|
||||||
|
messageId,
|
||||||
|
fileId,
|
||||||
|
originalFile,
|
||||||
|
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||||
|
const attachment = list.find((entry) => entry.id === fileId);
|
||||||
|
const diskPath = attachment
|
||||||
|
? await this.attachmentStorage.resolveExistingPath(attachment)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (diskPath) {
|
||||||
|
await this.transport.streamFileFromDiskToPeer(
|
||||||
|
fromPeerId,
|
||||||
|
messageId,
|
||||||
|
fileId,
|
||||||
|
diskPath,
|
||||||
|
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment?.isImage) {
|
||||||
|
const roomName = await this.persistence.resolveCurrentRoomName();
|
||||||
|
const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath(
|
||||||
|
attachment.filename,
|
||||||
|
roomName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (legacyDiskPath) {
|
||||||
|
await this.transport.streamFileFromDiskToPeer(
|
||||||
|
fromPeerId,
|
||||||
|
messageId,
|
||||||
|
fileId,
|
||||||
|
legacyDiskPath,
|
||||||
|
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment?.available && attachment.objectUrl) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(attachment.objectUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], attachment.filename, { type: attachment.mime });
|
||||||
|
|
||||||
|
await this.transport.streamFileToPeer(
|
||||||
|
fromPeerId,
|
||||||
|
messageId,
|
||||||
|
fileId,
|
||||||
|
file,
|
||||||
|
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileNotFoundEvent: FileNotFoundEvent = {
|
||||||
|
type: 'file-not-found',
|
||||||
|
messageId,
|
||||||
|
fileId
|
||||||
|
};
|
||||||
|
|
||||||
|
this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelRequest(messageId: string, attachment: Attachment): void {
|
||||||
|
const targetPeerId = attachment.uploaderPeerId;
|
||||||
|
|
||||||
|
if (!targetPeerId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const assemblyKey = `${messageId}:${attachment.id}`;
|
||||||
|
|
||||||
|
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||||
|
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||||
|
|
||||||
|
attachment.receivedBytes = 0;
|
||||||
|
attachment.speedBps = 0;
|
||||||
|
attachment.startedAtMs = undefined;
|
||||||
|
attachment.lastUpdateMs = undefined;
|
||||||
|
|
||||||
|
if (attachment.objectUrl) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(attachment.objectUrl);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
attachment.objectUrl = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment.available = false;
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
|
||||||
|
const fileCancelEvent: FileCancelEvent = {
|
||||||
|
type: 'file-cancel',
|
||||||
|
messageId,
|
||||||
|
fileId: attachment.id
|
||||||
|
};
|
||||||
|
|
||||||
|
this.webrtc.sendToPeer(targetPeerId, fileCancelEvent);
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileCancel(payload: FileCancelPayload): void {
|
||||||
|
const { messageId, fileId, fromPeerId } = payload;
|
||||||
|
|
||||||
|
if (!messageId || !fileId || !fromPeerId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.runtimeStore.addCancelledTransfer(
|
||||||
|
this.buildTransferKey(messageId, fileId, fromPeerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fulfillRequestWithFile(
|
||||||
|
messageId: string,
|
||||||
|
fileId: string,
|
||||||
|
targetPeerId: string,
|
||||||
|
file: File
|
||||||
|
): Promise<void> {
|
||||||
|
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
|
||||||
|
await this.transport.streamFileToPeer(
|
||||||
|
targetPeerId,
|
||||||
|
messageId,
|
||||||
|
fileId,
|
||||||
|
file,
|
||||||
|
() => this.isTransferCancelled(targetPeerId, messageId, fileId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTransferKey(messageId: string, fileId: string, peerId: string): string {
|
||||||
|
return `${messageId}:${fileId}:${peerId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRequestKey(messageId: string, fileId: string): string {
|
||||||
|
return `${messageId}:${fileId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAttachmentRequestError(attachment: Attachment): boolean {
|
||||||
|
if (!attachment.requestError)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
attachment.requestError = undefined;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean {
|
||||||
|
return this.runtimeStore.hasCancelledTransfer(
|
||||||
|
this.buildTransferKey(messageId, fileId, targetPeerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendFileRequestToNextPeer(
|
||||||
|
messageId: string,
|
||||||
|
fileId: string,
|
||||||
|
preferredPeerId?: string
|
||||||
|
): boolean {
|
||||||
|
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||||
|
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||||
|
const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>();
|
||||||
|
|
||||||
|
let targetPeerId: string | undefined;
|
||||||
|
|
||||||
|
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) {
|
||||||
|
targetPeerId = preferredPeerId;
|
||||||
|
} else {
|
||||||
|
targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetPeerId) {
|
||||||
|
this.runtimeStore.deletePendingRequest(requestKey);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
triedPeers.add(targetPeerId);
|
||||||
|
this.runtimeStore.setPendingRequestPeers(requestKey, triedPeers);
|
||||||
|
|
||||||
|
const fileRequestEvent: FileRequestEvent = {
|
||||||
|
type: 'file-request',
|
||||||
|
messageId,
|
||||||
|
fileId
|
||||||
|
};
|
||||||
|
|
||||||
|
this.webrtc.sendToPeer(targetPeerId, fileRequestEvent);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrCreateChunkBuffer(assemblyKey: string, total: number): ArrayBuffer[] {
|
||||||
|
const existingChunkBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
|
||||||
|
|
||||||
|
if (existingChunkBuffer) {
|
||||||
|
return existingChunkBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdChunkBuffer = new Array(total);
|
||||||
|
|
||||||
|
this.runtimeStore.setChunkBuffer(assemblyKey, createdChunkBuffer);
|
||||||
|
this.runtimeStore.setChunkCount(assemblyKey, 0);
|
||||||
|
|
||||||
|
return createdChunkBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTransferProgress(
|
||||||
|
attachment: Attachment,
|
||||||
|
decodedBytes: Uint8Array,
|
||||||
|
fromPeerId?: string
|
||||||
|
): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const previousReceived = attachment.receivedBytes ?? 0;
|
||||||
|
|
||||||
|
attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
|
||||||
|
|
||||||
|
if (fromPeerId) {
|
||||||
|
recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attachment.startedAtMs)
|
||||||
|
attachment.startedAtMs = now;
|
||||||
|
|
||||||
|
if (!attachment.lastUpdateMs)
|
||||||
|
attachment.lastUpdateMs = now;
|
||||||
|
|
||||||
|
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
|
||||||
|
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000;
|
||||||
|
const previousSpeed = attachment.speedBps ?? instantaneousBps;
|
||||||
|
|
||||||
|
attachment.speedBps =
|
||||||
|
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT * previousSpeed +
|
||||||
|
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT * instantaneousBps;
|
||||||
|
|
||||||
|
attachment.lastUpdateMs = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private finalizeTransferIfComplete(
|
||||||
|
attachment: Attachment,
|
||||||
|
assemblyKey: string,
|
||||||
|
total: number
|
||||||
|
): void {
|
||||||
|
const receivedChunkCount = this.runtimeStore.getChunkCount(assemblyKey) ?? 0;
|
||||||
|
const completeBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!completeBuffer
|
||||||
|
|| (receivedChunkCount !== total && (attachment.receivedBytes ?? 0) < attachment.size)
|
||||||
|
|| !completeBuffer.every((part) => part instanceof ArrayBuffer)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob(completeBuffer, { type: attachment.mime });
|
||||||
|
|
||||||
|
attachment.available = true;
|
||||||
|
attachment.objectUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
if (shouldPersistDownloadedAttachment(attachment)) {
|
||||||
|
void this.persistence.saveFileToDisk(attachment, blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||||
|
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||||
|
this.runtimeStore.touch();
|
||||||
|
void this.persistence.persistAttachmentMeta(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/app/domains/attachment/application/attachment.facade.ts
Normal file
119
src/app/domains/attachment/application/attachment.facade.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { AttachmentManagerService } from './attachment-manager.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AttachmentFacade {
|
||||||
|
get updated() {
|
||||||
|
return this.manager.updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly manager = inject(AttachmentManagerService);
|
||||||
|
|
||||||
|
getForMessage(
|
||||||
|
...args: Parameters<AttachmentManagerService['getForMessage']>
|
||||||
|
): ReturnType<AttachmentManagerService['getForMessage']> {
|
||||||
|
return this.manager.getForMessage(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberMessageRoom(
|
||||||
|
...args: Parameters<AttachmentManagerService['rememberMessageRoom']>
|
||||||
|
): ReturnType<AttachmentManagerService['rememberMessageRoom']> {
|
||||||
|
return this.manager.rememberMessageRoom(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
queueAutoDownloadsForMessage(
|
||||||
|
...args: Parameters<AttachmentManagerService['queueAutoDownloadsForMessage']>
|
||||||
|
): ReturnType<AttachmentManagerService['queueAutoDownloadsForMessage']> {
|
||||||
|
return this.manager.queueAutoDownloadsForMessage(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAutoDownloadsForRoom(
|
||||||
|
...args: Parameters<AttachmentManagerService['requestAutoDownloadsForRoom']>
|
||||||
|
): ReturnType<AttachmentManagerService['requestAutoDownloadsForRoom']> {
|
||||||
|
return this.manager.requestAutoDownloadsForRoom(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteForMessage(
|
||||||
|
...args: Parameters<AttachmentManagerService['deleteForMessage']>
|
||||||
|
): ReturnType<AttachmentManagerService['deleteForMessage']> {
|
||||||
|
return this.manager.deleteForMessage(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttachmentMetasForMessages(
|
||||||
|
...args: Parameters<AttachmentManagerService['getAttachmentMetasForMessages']>
|
||||||
|
): ReturnType<AttachmentManagerService['getAttachmentMetasForMessages']> {
|
||||||
|
return this.manager.getAttachmentMetasForMessages(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSyncedAttachments(
|
||||||
|
...args: Parameters<AttachmentManagerService['registerSyncedAttachments']>
|
||||||
|
): ReturnType<AttachmentManagerService['registerSyncedAttachments']> {
|
||||||
|
return this.manager.registerSyncedAttachments(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestFromAnyPeer(
|
||||||
|
...args: Parameters<AttachmentManagerService['requestFromAnyPeer']>
|
||||||
|
): ReturnType<AttachmentManagerService['requestFromAnyPeer']> {
|
||||||
|
return this.manager.requestFromAnyPeer(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileNotFound(
|
||||||
|
...args: Parameters<AttachmentManagerService['handleFileNotFound']>
|
||||||
|
): ReturnType<AttachmentManagerService['handleFileNotFound']> {
|
||||||
|
return this.manager.handleFileNotFound(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestImageFromAnyPeer(
|
||||||
|
...args: Parameters<AttachmentManagerService['requestImageFromAnyPeer']>
|
||||||
|
): ReturnType<AttachmentManagerService['requestImageFromAnyPeer']> {
|
||||||
|
return this.manager.requestImageFromAnyPeer(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestFile(
|
||||||
|
...args: Parameters<AttachmentManagerService['requestFile']>
|
||||||
|
): ReturnType<AttachmentManagerService['requestFile']> {
|
||||||
|
return this.manager.requestFile(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
publishAttachments(
|
||||||
|
...args: Parameters<AttachmentManagerService['publishAttachments']>
|
||||||
|
): ReturnType<AttachmentManagerService['publishAttachments']> {
|
||||||
|
return this.manager.publishAttachments(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileAnnounce(
|
||||||
|
...args: Parameters<AttachmentManagerService['handleFileAnnounce']>
|
||||||
|
): ReturnType<AttachmentManagerService['handleFileAnnounce']> {
|
||||||
|
return this.manager.handleFileAnnounce(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileChunk(
|
||||||
|
...args: Parameters<AttachmentManagerService['handleFileChunk']>
|
||||||
|
): ReturnType<AttachmentManagerService['handleFileChunk']> {
|
||||||
|
return this.manager.handleFileChunk(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileRequest(
|
||||||
|
...args: Parameters<AttachmentManagerService['handleFileRequest']>
|
||||||
|
): ReturnType<AttachmentManagerService['handleFileRequest']> {
|
||||||
|
return this.manager.handleFileRequest(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelRequest(
|
||||||
|
...args: Parameters<AttachmentManagerService['cancelRequest']>
|
||||||
|
): ReturnType<AttachmentManagerService['cancelRequest']> {
|
||||||
|
return this.manager.cancelRequest(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileCancel(
|
||||||
|
...args: Parameters<AttachmentManagerService['handleFileCancel']>
|
||||||
|
): ReturnType<AttachmentManagerService['handleFileCancel']> {
|
||||||
|
return this.manager.handleFileCancel(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
fulfillRequestWithFile(
|
||||||
|
...args: Parameters<AttachmentManagerService['fulfillRequestWithFile']>
|
||||||
|
): ReturnType<AttachmentManagerService['fulfillRequestWithFile']> {
|
||||||
|
return this.manager.fulfillRequestWithFile(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
|
||||||
|
export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EWMA smoothing weight for the previous speed estimate.
|
||||||
|
* The complementary weight is applied to the latest sample.
|
||||||
|
*/
|
||||||
|
export const ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT = 0.7;
|
||||||
|
export const ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT = 1 - ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT;
|
||||||
|
|
||||||
|
/** Fallback MIME type when none is provided by the sender. */
|
||||||
|
export const DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream';
|
||||||
|
|
||||||
|
/** localStorage key used by the legacy attachment store during migration. */
|
||||||
|
export const LEGACY_ATTACHMENTS_STORAGE_KEY = 'metoyou_attachments';
|
||||||
|
|
||||||
|
/** User-facing error when no peers are available for a request. */
|
||||||
|
export const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are available to provide this file right now.';
|
||||||
|
|
||||||
|
/** User-facing error when connected peers cannot provide a requested file. */
|
||||||
|
export const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.';
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ChatEvent } from '../../../shared-kernel';
|
||||||
|
import type { ChatAttachmentAnnouncement } from '../../../shared-kernel';
|
||||||
|
|
||||||
|
export type FileAnnounceEvent = ChatEvent & {
|
||||||
|
type: 'file-announce';
|
||||||
|
messageId: string;
|
||||||
|
file: ChatAttachmentAnnouncement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileChunkEvent = ChatEvent & {
|
||||||
|
type: 'file-chunk';
|
||||||
|
messageId: string;
|
||||||
|
fileId: string;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
data: string;
|
||||||
|
fromPeerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileRequestEvent = ChatEvent & {
|
||||||
|
type: 'file-request';
|
||||||
|
messageId: string;
|
||||||
|
fileId: string;
|
||||||
|
fromPeerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileCancelEvent = ChatEvent & {
|
||||||
|
type: 'file-cancel';
|
||||||
|
messageId: string;
|
||||||
|
fileId: string;
|
||||||
|
fromPeerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileNotFoundEvent = ChatEvent & {
|
||||||
|
type: 'file-not-found';
|
||||||
|
messageId: string;
|
||||||
|
fileId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file'>;
|
||||||
|
|
||||||
|
export interface FileChunkPayload {
|
||||||
|
messageId?: string;
|
||||||
|
fileId?: string;
|
||||||
|
fromPeerId?: string;
|
||||||
|
index?: number;
|
||||||
|
total?: number;
|
||||||
|
data?: ChatEvent['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
|
||||||
|
export type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
|
||||||
|
export type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;
|
||||||
|
|
||||||
|
export type LocalFileWithPath = File & {
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */
|
||||||
|
export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||||
19
src/app/domains/attachment/domain/attachment.logic.ts
Normal file
19
src/app/domains/attachment/domain/attachment.logic.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { MAX_AUTO_SAVE_SIZE_BYTES } from './attachment.constants';
|
||||||
|
import type { Attachment } from './attachment.models';
|
||||||
|
|
||||||
|
export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean {
|
||||||
|
return attachment.mime.startsWith('image/') ||
|
||||||
|
attachment.mime.startsWith('video/') ||
|
||||||
|
attachment.mime.startsWith('audio/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAutoRequestWhenWatched(attachment: Attachment): boolean {
|
||||||
|
return attachment.isImage ||
|
||||||
|
(isAttachmentMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldPersistDownloadedAttachment(attachment: Pick<Attachment, 'size' | 'mime'>): boolean {
|
||||||
|
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
|
||||||
|
attachment.mime.startsWith('video/') ||
|
||||||
|
attachment.mime.startsWith('audio/');
|
||||||
|
}
|
||||||
13
src/app/domains/attachment/domain/attachment.models.ts
Normal file
13
src/app/domains/attachment/domain/attachment.models.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ChatAttachmentMeta } from '../../../shared-kernel';
|
||||||
|
|
||||||
|
export type AttachmentMeta = ChatAttachmentMeta;
|
||||||
|
|
||||||
|
export interface Attachment extends AttachmentMeta {
|
||||||
|
available: boolean;
|
||||||
|
objectUrl?: string;
|
||||||
|
receivedBytes?: number;
|
||||||
|
speedBps?: number;
|
||||||
|
startedAtMs?: number;
|
||||||
|
lastUpdateMs?: number;
|
||||||
|
requestError?: string;
|
||||||
|
}
|
||||||
3
src/app/domains/attachment/index.ts
Normal file
3
src/app/domains/attachment/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './application/attachment.facade';
|
||||||
|
export * from './domain/attachment.constants';
|
||||||
|
export * from './domain/attachment.models';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const ROOM_NAME_SANITIZER = /[^\w.-]+/g;
|
||||||
|
|
||||||
|
export function sanitizeAttachmentRoomName(roomName: string): string {
|
||||||
|
const sanitizedRoomName = roomName.trim().replace(ROOM_NAME_SANITIZER, '_');
|
||||||
|
|
||||||
|
return sanitizedRoomName || 'room';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAttachmentStorageBucket(mime: string): 'video' | 'audio' | 'image' | 'files' {
|
||||||
|
if (mime.startsWith('video/')) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mime.startsWith('audio/')) {
|
||||||
|
return 'audio';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mime.startsWith('image/')) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'files';
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||||
|
import type { Attachment } from '../domain/attachment.models';
|
||||||
|
import { resolveAttachmentStorageBucket, sanitizeAttachmentRoomName } from './attachment-storage.helpers';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AttachmentStorageService {
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
async resolveExistingPath(
|
||||||
|
attachment: Pick<Attachment, 'filePath' | 'savedPath'>
|
||||||
|
): Promise<string | null> {
|
||||||
|
return this.findExistingPath([attachment.filePath, attachment.savedPath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveLegacyImagePath(filename: string, roomName: string): Promise<string | null> {
|
||||||
|
const appDataPath = await this.resolveAppDataPath();
|
||||||
|
|
||||||
|
if (!appDataPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${filename}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(filePath: string): Promise<string | null> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!electronApi || !filePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await electronApi.readFile(filePath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBlob(
|
||||||
|
attachment: Pick<Attachment, 'filename' | 'mime'>,
|
||||||
|
blob: Blob,
|
||||||
|
roomName: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
const appDataPath = await this.resolveAppDataPath();
|
||||||
|
|
||||||
|
if (!electronApi || !appDataPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const directoryPath = `${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/${resolveAttachmentStorageBucket(attachment.mime)}`;
|
||||||
|
|
||||||
|
await electronApi.ensureDir(directoryPath);
|
||||||
|
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
const diskPath = `${directoryPath}/${attachment.filename}`;
|
||||||
|
|
||||||
|
await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer));
|
||||||
|
|
||||||
|
return diskPath;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(filePath: string): Promise<void> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!electronApi || !filePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await electronApi.deleteFile(filePath);
|
||||||
|
} catch { /* best-effort cleanup */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAppDataPath(): Promise<string | null> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!electronApi) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await electronApi.getAppDataPath();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!electronApi) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidatePath of candidates) {
|
||||||
|
if (!candidatePath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await electronApi.fileExists(candidatePath)) {
|
||||||
|
return candidatePath;
|
||||||
|
}
|
||||||
|
} catch { /* keep trying remaining candidates */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
let binary = '';
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
for (let index = 0; index < bytes.byteLength; index++) {
|
||||||
|
binary += String.fromCharCode(bytes[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/app/domains/auth/README.md
Normal file
74
src/app/domains/auth/README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Auth Domain
|
||||||
|
|
||||||
|
Handles user authentication (login and registration) against the configured server endpoint. Provides the login, register, and user-bar UI components.
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
```
|
||||||
|
auth/
|
||||||
|
├── application/
|
||||||
|
│ └── auth.service.ts HTTP login/register against the active server endpoint
|
||||||
|
│
|
||||||
|
├── feature/
|
||||||
|
│ ├── login/ Login form component
|
||||||
|
│ ├── register/ Registration form component
|
||||||
|
│ └── user-bar/ Displays current user or login/register links
|
||||||
|
│
|
||||||
|
└── index.ts Barrel exports
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service overview
|
||||||
|
|
||||||
|
`AuthService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component stores `currentUserId` in localStorage and dispatches `UsersActions.setCurrentUser` into the NgRx store.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Login[LoginComponent]
|
||||||
|
Register[RegisterComponent]
|
||||||
|
UserBar[UserBarComponent]
|
||||||
|
Auth[AuthService]
|
||||||
|
SD[ServerDirectoryFacade]
|
||||||
|
Store[NgRx Store]
|
||||||
|
|
||||||
|
Login --> Auth
|
||||||
|
Register --> Auth
|
||||||
|
UserBar --> Store
|
||||||
|
Auth --> SD
|
||||||
|
Login --> Store
|
||||||
|
|
||||||
|
click Auth "application/auth.service.ts" "HTTP login/register" _blank
|
||||||
|
click Login "feature/login/" "Login form" _blank
|
||||||
|
click Register "feature/register/" "Registration form" _blank
|
||||||
|
click UserBar "feature/user-bar/" "Current user display" _blank
|
||||||
|
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API URL" _blank
|
||||||
|
```
|
||||||
|
|
||||||
|
## Login flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Login as LoginComponent
|
||||||
|
participant Auth as AuthService
|
||||||
|
participant SD as ServerDirectoryFacade
|
||||||
|
participant API as Server API
|
||||||
|
participant Store as NgRx Store
|
||||||
|
|
||||||
|
User->>Login: Submit credentials
|
||||||
|
Login->>Auth: login(username, password)
|
||||||
|
Auth->>SD: getApiBaseUrl()
|
||||||
|
SD-->>Auth: https://server/api
|
||||||
|
Auth->>API: POST /api/auth/login
|
||||||
|
API-->>Auth: { userId, displayName }
|
||||||
|
Auth-->>Login: success
|
||||||
|
Login->>Store: UsersActions.setCurrentUser
|
||||||
|
Login->>Login: localStorage.setItem(currentUserId)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registration flow
|
||||||
|
|
||||||
|
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same store dispatch happens.
|
||||||
|
|
||||||
|
## User bar
|
||||||
|
|
||||||
|
`UserBarComponent` reads the current user from the NgRx store. When logged in it shows the user's display name; when not logged in it shows links to the login and register views.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { ServerDirectoryService, ServerEndpoint } from './server-directory.service';
|
import { type ServerEndpoint, ServerDirectoryFacade } from '../../server-directory';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response returned by the authentication endpoints (login / register).
|
* Response returned by the authentication endpoints (login / register).
|
||||||
@@ -20,14 +20,14 @@ export interface LoginResponse {
|
|||||||
* Handles user authentication (login and registration) against a
|
* Handles user authentication (login and registration) against a
|
||||||
* configurable back-end server.
|
* configurable back-end server.
|
||||||
*
|
*
|
||||||
* The target server is resolved via {@link ServerDirectoryService}: the
|
* The target server is resolved via {@link ServerDirectoryFacade}: the
|
||||||
* caller may pass an explicit `serverId`, otherwise the currently active
|
* caller may pass an explicit `serverId`, otherwise the currently active
|
||||||
* server endpoint is used.
|
* server endpoint is used.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly serverDirectory = inject(ServerDirectoryService);
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the API base URL for the given server.
|
* Resolve the API base URL for the given server.
|
||||||
@@ -6,16 +6,16 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideLogIn } from '@ng-icons/lucide';
|
import { lucideLogIn } from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../application/auth.service';
|
||||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { User } from '../../../core/models/index';
|
import { User } from '../../../../shared-kernel';
|
||||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
@@ -32,7 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
|||||||
* Login form allowing existing users to authenticate against a selected server.
|
* Login form allowing existing users to authenticate against a selected server.
|
||||||
*/
|
*/
|
||||||
export class LoginComponent {
|
export class LoginComponent {
|
||||||
serversSvc = inject(ServerDirectoryService);
|
serversSvc = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
servers = this.serversSvc.servers;
|
servers = this.serversSvc.servers;
|
||||||
username = '';
|
username = '';
|
||||||
@@ -42,6 +42,7 @@ export class LoginComponent {
|
|||||||
|
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
/** TrackBy function for server list rendering. */
|
/** TrackBy function for server list rendering. */
|
||||||
@@ -72,6 +73,14 @@ export class LoginComponent {
|
|||||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||||
|
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||||
|
|
||||||
|
if (returnUrl?.startsWith('/')) {
|
||||||
|
this.router.navigateByUrl(returnUrl);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
@@ -82,6 +91,10 @@ export class LoginComponent {
|
|||||||
|
|
||||||
/** Navigate to the registration page. */
|
/** Navigate to the registration page. */
|
||||||
goRegister() {
|
goRegister() {
|
||||||
this.router.navigate(['/register']);
|
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||||
|
|
||||||
|
this.router.navigate(['/register'], {
|
||||||
|
queryParams: returnUrl ? { returnUrl } : undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,16 +6,16 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideUserPlus } from '@ng-icons/lucide';
|
import { lucideUserPlus } from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../application/auth.service';
|
||||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { User } from '../../../core/models/index';
|
import { User } from '../../../../shared-kernel';
|
||||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-register',
|
selector: 'app-register',
|
||||||
@@ -32,7 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
|||||||
* Registration form allowing new users to create an account on a selected server.
|
* Registration form allowing new users to create an account on a selected server.
|
||||||
*/
|
*/
|
||||||
export class RegisterComponent {
|
export class RegisterComponent {
|
||||||
serversSvc = inject(ServerDirectoryService);
|
serversSvc = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
servers = this.serversSvc.servers;
|
servers = this.serversSvc.servers;
|
||||||
username = '';
|
username = '';
|
||||||
@@ -43,6 +43,7 @@ export class RegisterComponent {
|
|||||||
|
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
/** TrackBy function for server list rendering. */
|
/** TrackBy function for server list rendering. */
|
||||||
@@ -74,6 +75,14 @@ export class RegisterComponent {
|
|||||||
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||||
|
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||||
|
|
||||||
|
if (returnUrl?.startsWith('/')) {
|
||||||
|
this.router.navigateByUrl(returnUrl);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
@@ -84,6 +93,10 @@ export class RegisterComponent {
|
|||||||
|
|
||||||
/** Navigate to the login page. */
|
/** Navigate to the login page. */
|
||||||
goLogin() {
|
goLogin() {
|
||||||
this.router.navigate(['/login']);
|
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||||
|
|
||||||
|
this.router.navigate(['/login'], {
|
||||||
|
queryParams: returnUrl ? { returnUrl } : undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
lucideLogIn,
|
lucideLogIn,
|
||||||
lucideUserPlus
|
lucideUserPlus
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-bar',
|
selector: 'app-user-bar',
|
||||||
1
src/app/domains/auth/index.ts
Normal file
1
src/app/domains/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './application/auth.service';
|
||||||
143
src/app/domains/chat/README.md
Normal file
143
src/app/domains/chat/README.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Chat Domain
|
||||||
|
|
||||||
|
Text messaging, reactions, GIF search, typing indicators, and the user list. All UI is under `feature/`; application services handle GIF integration; domain rules govern message editing, deletion, and sync.
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
```
|
||||||
|
chat/
|
||||||
|
├── application/
|
||||||
|
│ └── klipy.service.ts GIF search via the KLIPY API (proxied through the server)
|
||||||
|
│
|
||||||
|
├── domain/
|
||||||
|
│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
|
||||||
|
│ └── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
|
||||||
|
│
|
||||||
|
├── feature/
|
||||||
|
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays)
|
||||||
|
│ │ ├── chat-messages.component.ts Root component: replies, GIF picker, reactions, drag-drop
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── message-composer/ Markdown toolbar, file drag-drop, send
|
||||||
|
│ │ │ ├── message-item/ Single message bubble with edit/delete/react
|
||||||
|
│ │ │ ├── message-list/ Paginated list (50 msgs/page), auto-scroll, Prism highlighting
|
||||||
|
│ │ │ └── message-overlays/ Context menus, reaction picker, reply preview
|
||||||
|
│ │ ├── models/ View models for messages
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ └── chat-markdown.service.ts Markdown-to-HTML rendering
|
||||||
|
│ │
|
||||||
|
│ ├── klipy-gif-picker/ GIF search/browse picker panel
|
||||||
|
│ ├── typing-indicator/ "X is typing..." display (3 s TTL, max 4 names)
|
||||||
|
│ └── user-list/ Online user sidebar
|
||||||
|
│
|
||||||
|
└── index.ts Barrel exports
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component composition
|
||||||
|
|
||||||
|
`ChatMessagesComponent` is the root of the chat view. It renders the message list, composer, and overlays as child components and coordinates cross-cutting interactions like replying to a message or inserting a GIF.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Chat[ChatMessagesComponent]
|
||||||
|
List[MessageListComponent]
|
||||||
|
Composer[MessageComposerComponent]
|
||||||
|
Overlays[MessageOverlays]
|
||||||
|
Item[MessageItemComponent]
|
||||||
|
GIF[KlipyGifPickerComponent]
|
||||||
|
Typing[TypingIndicatorComponent]
|
||||||
|
Users[UserListComponent]
|
||||||
|
|
||||||
|
Chat --> List
|
||||||
|
Chat --> Composer
|
||||||
|
Chat --> Overlays
|
||||||
|
Chat --> GIF
|
||||||
|
List --> Item
|
||||||
|
Item --> Overlays
|
||||||
|
|
||||||
|
click Chat "feature/chat-messages/chat-messages.component.ts" "Root chat view" _blank
|
||||||
|
click List "feature/chat-messages/components/message-list/" "Paginated message list" _blank
|
||||||
|
click Composer "feature/chat-messages/components/message-composer/" "Markdown toolbar + send" _blank
|
||||||
|
click Overlays "feature/chat-messages/components/message-overlays/" "Context menus, reaction picker" _blank
|
||||||
|
click Item "feature/chat-messages/components/message-item/" "Single message bubble" _blank
|
||||||
|
click GIF "feature/klipy-gif-picker/" "GIF search panel" _blank
|
||||||
|
click Typing "feature/typing-indicator/" "Typing indicator" _blank
|
||||||
|
click Users "feature/user-list/" "Online user sidebar" _blank
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message lifecycle
|
||||||
|
|
||||||
|
Messages are created in the composer, broadcast to peers over the data channel, and rendered in the list. Editing and deletion are sender-only operations.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Composer as MessageComposer
|
||||||
|
participant Store as NgRx Store
|
||||||
|
participant DC as Data Channel
|
||||||
|
participant Peer as Remote Peer
|
||||||
|
|
||||||
|
User->>Composer: Type + send
|
||||||
|
Composer->>Store: dispatch addMessage
|
||||||
|
Composer->>DC: broadcastMessage(chat-message)
|
||||||
|
DC->>Peer: chat-message event
|
||||||
|
|
||||||
|
Note over User: Edit
|
||||||
|
User->>Store: dispatch editMessage
|
||||||
|
User->>DC: broadcastMessage(edit-message)
|
||||||
|
|
||||||
|
Note over User: Delete
|
||||||
|
User->>Store: dispatch deleteMessage (normaliseDeletedMessage)
|
||||||
|
User->>DC: broadcastMessage(delete-message)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message sync
|
||||||
|
|
||||||
|
When a peer connects (or reconnects), both sides exchange an inventory of their recent messages so each can request anything it missed. The inventory is capped at 1 000 messages and sent in chunks of 200.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant A as Peer A
|
||||||
|
participant B as Peer B
|
||||||
|
|
||||||
|
A->>B: inventory (up to 1000 msg IDs + timestamps)
|
||||||
|
B->>B: findMissingIds(remote, local)
|
||||||
|
B->>A: request missing message IDs
|
||||||
|
A->>B: message payloads (chunked, 200/batch)
|
||||||
|
```
|
||||||
|
|
||||||
|
`findMissingIds` compares each remote item's timestamp and reaction/attachment counts against the local map. Any item that is missing, newer, or has different counts is requested.
|
||||||
|
|
||||||
|
## GIF integration
|
||||||
|
|
||||||
|
`KlipyService` checks availability on the active server, then proxies search requests through the server API. Images are rendered via an image proxy endpoint to avoid mixed-content issues.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Picker[KlipyGifPickerComponent]
|
||||||
|
Klipy[KlipyService]
|
||||||
|
SD[ServerDirectoryFacade]
|
||||||
|
API[Server API]
|
||||||
|
|
||||||
|
Picker --> Klipy
|
||||||
|
Klipy --> SD
|
||||||
|
Klipy --> API
|
||||||
|
|
||||||
|
click Picker "feature/klipy-gif-picker/" "GIF search panel" _blank
|
||||||
|
click Klipy "application/klipy.service.ts" "GIF search via KLIPY API" _blank
|
||||||
|
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL" _blank
|
||||||
|
```
|
||||||
|
|
||||||
|
## Domain rules
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `canEditMessage(msg, userId)` | Only the sender can edit their own message |
|
||||||
|
| `normaliseDeletedMessage(msg)` | Strips content and reactions from deleted messages |
|
||||||
|
| `getMessageTimestamp(msg)` | Returns `editedAt` if present, otherwise `timestamp` |
|
||||||
|
| `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering |
|
||||||
|
| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer |
|
||||||
|
| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request |
|
||||||
|
|
||||||
|
## Typing indicator
|
||||||
|
|
||||||
|
`TypingIndicatorComponent` listens for typing events from peers. Each event resets a 3-second TTL timer. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
throwError
|
throwError
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { catchError, map } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
import { ServerDirectoryService } from './server-directory.service';
|
import { ServerDirectoryFacade } from '../../server-directory';
|
||||||
|
|
||||||
export interface KlipyGif {
|
export interface KlipyGif {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,7 +41,7 @@ const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class KlipyService {
|
export class KlipyService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly serverDirectory = inject(ServerDirectoryService);
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
private readonly availabilityState = signal({
|
private readonly availabilityState = signal({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
loading: true
|
loading: true
|
||||||
59
src/app/domains/chat/domain/message-sync.rules.ts
Normal file
59
src/app/domains/chat/domain/message-sync.rules.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/** Maximum number of recent messages to include in sync inventories. */
|
||||||
|
export const INVENTORY_LIMIT = 1000;
|
||||||
|
|
||||||
|
/** Number of messages per chunk for inventory / batch transfers. */
|
||||||
|
export const CHUNK_SIZE = 200;
|
||||||
|
|
||||||
|
/** Aggressive sync poll interval (10 seconds). */
|
||||||
|
export const SYNC_POLL_FAST_MS = 10_000;
|
||||||
|
|
||||||
|
/** Idle sync poll interval after a clean (no-new-messages) cycle (15 minutes). */
|
||||||
|
export const SYNC_POLL_SLOW_MS = 900_000;
|
||||||
|
|
||||||
|
/** Sync timeout duration before auto-completing a cycle (5 seconds). */
|
||||||
|
export const SYNC_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
/** Large limit used for legacy full-sync operations. */
|
||||||
|
export const FULL_SYNC_LIMIT = 10_000;
|
||||||
|
|
||||||
|
/** Inventory item representing a message's sync state. */
|
||||||
|
export interface InventoryItem {
|
||||||
|
id: string;
|
||||||
|
ts: number;
|
||||||
|
rc: number;
|
||||||
|
ac?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits an array into chunks of the given size. */
|
||||||
|
export function chunkArray<T>(items: T[], size: number): T[][] {
|
||||||
|
const chunks: T[][] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < items.length; index += size) {
|
||||||
|
chunks.push(items.slice(index, index + size));
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
|
||||||
|
export function findMissingIds(
|
||||||
|
remoteItems: readonly { id: string; ts: number; rc?: number; ac?: number }[],
|
||||||
|
localMap: ReadonlyMap<string, { ts: number; rc: number; ac: number }>
|
||||||
|
): string[] {
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
for (const item of remoteItems) {
|
||||||
|
const local = localMap.get(item.id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!local ||
|
||||||
|
item.ts > local.ts ||
|
||||||
|
(item.rc !== undefined && item.rc !== local.rc) ||
|
||||||
|
(item.ac !== undefined && item.ac !== local.ac)
|
||||||
|
) {
|
||||||
|
missing.push(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
31
src/app/domains/chat/domain/message.rules.ts
Normal file
31
src/app/domains/chat/domain/message.rules.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { DELETED_MESSAGE_CONTENT, type Message } from '../../../shared-kernel';
|
||||||
|
|
||||||
|
/** Extracts the effective timestamp from a message (editedAt takes priority). */
|
||||||
|
export function getMessageTimestamp(msg: Message): number {
|
||||||
|
return msg.editedAt || msg.timestamp || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Computes the most recent timestamp across a batch of messages. */
|
||||||
|
export function getLatestTimestamp(messages: Message[]): number {
|
||||||
|
return messages.reduce(
|
||||||
|
(max, msg) => Math.max(max, getMessageTimestamp(msg)),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips sensitive content from a deleted message. */
|
||||||
|
export function normaliseDeletedMessage(message: Message): Message {
|
||||||
|
if (!message.isDeleted)
|
||||||
|
return message;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: DELETED_MESSAGE_CONTENT,
|
||||||
|
reactions: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the given user is allowed to edit this message. */
|
||||||
|
export function canEditMessage(message: Message, userId: string): boolean {
|
||||||
|
return message.senderId === userId;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user