Compare commits

..

31 Commits

Author SHA1 Message Date
Myx
6dff163b88 ddd test 2 2026-03-23 00:23:56 +01:00
Myx
fe9c1dd1c0 ddd test 2026-03-20 03:05:29 +01:00
Myx
429bb9d8ff Chat message placeholder adjustment 2026-03-19 21:39:47 +01:00
Myx
b5d676fb78 New attempt to fix windows screenshare 2026-03-19 21:39:20 +01:00
Myx
aa595c45d8 Fix autostart on linux 2026-03-19 21:38:47 +01:00
Myx
1c7e535057 Possibly screensharing fix for windows where they get deafened when screensharing with audio
All checks were successful
Queue Release Build / prepare (push) Successful in 14s
Deploy Web Apps / deploy (push) Successful in 9m24s
Queue Release Build / build-linux (push) Successful in 24m57s
Queue Release Build / build-windows (push) Successful in 25m21s
Queue Release Build / finalize (push) Successful in 1m43s
2026-03-19 04:15:59 +01:00
Myx
8f960be1e9 Resync username instead of using Anonymous 2026-03-19 03:57:51 +01:00
Myx
9a173792a4 Fix users list to only show server users 2026-03-19 03:48:41 +01:00
Myx
cb2c0495b9 hotfix handshake issue
All checks were successful
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 10m15s
Queue Release Build / build-linux (push) Successful in 26m14s
Queue Release Build / build-windows (push) Successful in 25m41s
Queue Release Build / finalize (push) Successful in 1m51s
2026-03-19 03:34:26 +01:00
Myx
c3ef8e8800 Allow multiple signal servers (might need rollback)
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 9m58s
Queue Release Build / build-linux (push) Successful in 26m26s
Queue Release Build / build-windows (push) Successful in 25m3s
Queue Release Build / finalize (push) Successful in 1m43s
2026-03-19 02:11:15 +01:00
Myx
c862c2fe03 Auto start with system
Some checks failed
Queue Release Build / prepare (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / finalize (push) Has been cancelled
Deploy Web Apps / deploy (push) Successful in 6m2s
2026-03-18 23:46:16 +01:00
Myx
4faa62864d Fix syncing issues
Some checks failed
Deploy Web Apps / deploy (push) Has been cancelled
Queue Release Build / prepare (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / finalize (push) Has been cancelled
2026-03-18 23:11:48 +01:00
Myx
1cdd1c5d2b fix typing indicator on wrong server
Some checks failed
Queue Release Build / build-linux (push) Blocked by required conditions
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 16m15s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
2026-03-18 22:10:11 +01:00
Myx
141de64767 Reconnection when signal server is not active and minor changes 2026-03-18 20:45:31 +01:00
Myx
eb987ac672 Private servers with password and invite links (Experimental) 2026-03-18 20:42:40 +01:00
Myx
f8fd78d21a Add server variables
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 15m14s
Queue Release Build / build-linux (push) Successful in 22m12s
Queue Release Build / build-windows (push) Successful in 23m20s
Queue Release Build / finalize (push) Successful in 2m12s
2026-03-15 16:12:21 +01:00
Myx
150c45c31a Use wayland if possible on linux
All checks were successful
Queue Release Build / prepare (push) Successful in 58s
Deploy Web Apps / deploy (push) Successful in 22m58s
Queue Release Build / build-linux (push) Successful in 1h10m11s
Queue Release Build / build-windows (push) Successful in 32m8s
Queue Release Build / finalize (push) Successful in 3m11s
2026-03-13 20:14:19 +01:00
Myx
00adf39121 Add translation support for website
All checks were successful
Queue Release Build / prepare (push) Successful in 1m20s
Deploy Web Apps / deploy (push) Successful in 17m37s
Queue Release Build / build-windows (push) Successful in 39m8s
Queue Release Build / build-linux (push) Successful in 1h3m53s
Queue Release Build / finalize (push) Successful in 5m43s
2026-03-13 03:45:29 +01:00
Myx
2b6e477c9a [Attempt 1] fix slow website
Some checks failed
Queue Release Build / prepare (push) Successful in 25s
Deploy Web Apps / deploy (push) Successful in 17m20s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-linux (push) Has started running
Queue Release Build / build-windows (push) Has been cancelled
2026-03-13 02:46:21 +01:00
Myx
22d355a522 [Experimental Screenshare audio fix] Seperate logic to own files (minor change can possibly revert) 2026-03-13 02:26:55 +01:00
Myx
15c5952e29 Improved logger 2026-03-13 00:48:33 +01:00
Myx
781c05294f feat!: [Experimental hotfix 2] Fix Audio stream handling on windows electron
Experimental fix for solving replacement of mic audio when enabling screenshare audio on windows electron

On Windows Electron, startWithElectronDesktopCapturer uses getUserMedia({ chromeMediaSource: 'desktop' }) for both video and audio. This getUserMedia desktop audio call can interfere with / replace the existing mic getUserMedia stream, killing voice audio.

BREAKING CHANGE: possibly streaming
2026-03-12 23:55:38 +01:00
Myx
778e75bef5 fix: [Experimental hotfix 1] Fix Signaling issues Toju App
1. Server: WebSocket ping/pong heartbeat (index.ts)
Added a 30-second ping interval that pings all connected clients
Connections without a pong response for 45 seconds are terminated and cleaned up
Extracted removeDeadConnection() to deduplicate the cleanup logic between close events and dead connection reaping
2. Server: Fixed sendServerUsers filter bug (handler.ts:13)
Removed && cu.displayName from the filter — users who joined a server before their identify message was processed were silently invisible to everyone. This was the direct cause of "can't see each other" in session 2.
3. Client: Typing message now includes serverId
Added serverId: this.webrtc.currentServerId to the typing payload
Added a currentServerId getter on WebRTCService
2026-03-12 23:53:10 +01:00
Myx
7bf37ba510 Website v3
All checks were successful
Queue Release Build / prepare (push) Successful in 39s
Deploy Web Apps / deploy (push) Successful in 16m30s
Queue Release Build / build-linux (push) Successful in 52m31s
Queue Release Build / build-windows (push) Successful in 36m48s
Queue Release Build / finalize (push) Successful in 3m20s
2026-03-12 14:32:48 +01:00
Myx
3c04b5db26 Add webapp release
Some checks failed
Queue Release Build / prepare (push) Successful in 50s
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
Deploy Web Apps / deploy (push) Has been cancelled
2026-03-12 13:37:46 +01:00
Myx
45e0b09af8 Toju Website V2 2026-03-12 13:21:33 +01:00
Myx
106212ef3d feat: Add website v1
All checks were successful
Queue Release Build / prepare (push) Successful in 13s
Queue Release Build / build-windows (push) Successful in 36m11s
Queue Release Build / build-linux (push) Successful in 41m21s
Queue Release Build / finalize (push) Successful in 3m44s
Not final:
Needs more file icons
clean up some text
2026-03-12 01:33:45 +01:00
Myx
be465fd297 Fix screenshare portals linux
All checks were successful
Queue Release Build / prepare (push) Successful in 14s
Queue Release Build / build-windows (push) Successful in 34m25s
Queue Release Build / build-linux (push) Successful in 40m26s
Queue Release Build / finalize (push) Successful in 3m44s
2026-03-11 17:54:04 +01:00
Myx
7b3caa0b61 Fix electron windows screen and window picker for screensharing
All checks were successful
Queue Release Build / prepare (push) Successful in 44s
Queue Release Build / build-windows (push) Successful in 32m25s
Queue Release Build / build-linux (push) Successful in 42m57s
Queue Release Build / finalize (push) Successful in 3m41s
2026-03-11 14:40:53 +01:00
Myx
22f583e6b3 Add missing environments file swap
All checks were successful
Queue Release Build / prepare (push) Successful in 32s
Queue Release Build / build-windows (push) Successful in 30m21s
Queue Release Build / build-linux (push) Successful in 40m47s
Queue Release Build / finalize (push) Successful in 4m8s
2026-03-11 11:38:34 +01:00
Myx
091c7b2601 Updates server url
All checks were successful
Queue Release Build / prepare (push) Successful in 31s
Queue Release Build / build-windows (push) Successful in 34m34s
Queue Release Build / build-linux (push) Successful in 42m26s
Queue Release Build / finalize (push) Successful in 3m38s
2026-03-11 10:49:18 +01:00
356 changed files with 36809 additions and 5979 deletions

View File

@@ -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

View 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

View File

@@ -15,10 +15,10 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4
- name: Setup Node.js # - name: Setup Node.js
uses: https://github.com/actions/setup-node@v4 # uses: https://github.com/actions/setup-node@v4
with: # with:
node-version: 20 # node-version: 20
- name: Publish draft release - name: Publish draft release
env: env:

View File

@@ -55,6 +55,11 @@ jobs:
npm ci npm ci
cd server && npm ci cd server && npm ci
- name: Install zip utility
run: |
apt-get update
apt-get install -y zip
- name: Set CI release version - name: Set CI release version
run: > run: >
node tools/set-release-version.js node tools/set-release-version.js
@@ -73,6 +78,10 @@ jobs:
run: | run: |
npx electron-builder --linux --publish never npx electron-builder --linux --publish never
node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64 node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64
rm -f dist-electron/metoyou-client-${{ needs.prepare.outputs.release_version }}.zip
rm -f dist-server/metoyou-server-linux-x64-${{ needs.prepare.outputs.release_version }}.zip
(cd dist && zip -rq ../dist-electron/metoyou-client-${{ needs.prepare.outputs.release_version }}.zip client)
(cd dist-server && zip -q metoyou-server-linux-x64-${{ needs.prepare.outputs.release_version }}.zip metoyou-server-linux-x64 sql-wasm.wasm)
- name: Upload Linux assets - name: Upload Linux assets
env: env:
@@ -121,8 +130,68 @@ jobs:
- name: Build Windows assets - name: Build Windows assets
run: | run: |
$projectRoot = $PWD.ProviderPath
$electronBuilderWorkspace = Join-Path $env:TEMP ([guid]::NewGuid().ToString('N'))
$electronBuilderCache = Join-Path $electronBuilderWorkspace 'electron-builder-cache'
$electronCache = Join-Path $electronBuilderWorkspace 'electron-cache'
$locationPushed = $false
function Invoke-RoboCopy {
param(
[string]$Source,
[string]$Destination
)
robocopy $Source $Destination /MIR /NFL /NDL /NJH /NJS /NP | Out-Null
if ($LASTEXITCODE -gt 7) {
throw "robocopy failed from $Source to $Destination with exit code $LASTEXITCODE"
}
}
# Stage the packaging inputs into a real short-path directory.
# electron-builder rejects junction-backed files during asar creation
# because their resolved path sits outside the package root.
New-Item -ItemType Directory -Path $electronBuilderWorkspace | Out-Null
New-Item -ItemType Directory -Path $electronBuilderCache | Out-Null
New-Item -ItemType Directory -Path $electronCache | Out-Null
$env:ELECTRON_BUILDER_CACHE = $electronBuilderCache
$env:ELECTRON_CACHE = $electronCache
try {
Copy-Item -Path (Join-Path $projectRoot 'package.json') -Destination (Join-Path $electronBuilderWorkspace 'package.json') -Force
Copy-Item -Path (Join-Path $projectRoot 'package-lock.json') -Destination (Join-Path $electronBuilderWorkspace 'package-lock.json') -Force
Invoke-RoboCopy (Join-Path $projectRoot 'dist') (Join-Path $electronBuilderWorkspace 'dist')
Invoke-RoboCopy (Join-Path $projectRoot 'images') (Join-Path $electronBuilderWorkspace 'images')
Invoke-RoboCopy (Join-Path $projectRoot 'node_modules') (Join-Path $electronBuilderWorkspace 'node_modules')
Push-Location $electronBuilderWorkspace
$locationPushed = $true
npx electron-builder --win --publish never npx electron-builder --win --publish never
if ($LASTEXITCODE -ne 0) {
throw "electron-builder failed with exit code $LASTEXITCODE"
}
Invoke-RoboCopy (Join-Path $electronBuilderWorkspace 'dist-electron') (Join-Path $projectRoot 'dist-electron')
} finally {
if ($locationPushed) {
Pop-Location
}
if (Test-Path $electronBuilderWorkspace) {
cmd /c rmdir /s /q "$electronBuilderWorkspace" | Out-Null
}
}
node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe
$serverArchivePath = Join-Path $PWD "dist-server/metoyou-server-win-x64-${{ needs.prepare.outputs.release_version }}.zip"
if (Test-Path $serverArchivePath) {
Remove-Item $serverArchivePath -Force
}
Push-Location dist-server
try {
Compress-Archive -Path metoyou-server-win-x64.exe, sql-wasm.wasm -DestinationPath $serverArchivePath -CompressionLevel Optimal
} finally {
Pop-Location
}
- name: Upload Windows assets - name: Upload Windows assets
env: env:

107
README.md
View File

@@ -6,6 +6,12 @@ Desktop chat app with three parts:
- `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
## Architecture
- Renderer architecture and refactor conventions live in `docs/architecture.md`
- Electron renderer integrations should go through `src/app/core/platform/electron/`
- Pure shared logic belongs in `src/app/core/helpers/` and reusable contracts belong in `src/app/core/models/`
## Install ## Install
1. Run `npm install` 1. Run `npm install`
@@ -17,7 +23,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 +31,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

View File

@@ -86,6 +86,12 @@
}, },
"configurations": { "configurations": {
"production": { "production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",

2
dev.sh
View File

@@ -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"

166
docs/architecture.md Normal file
View File

@@ -0,0 +1,166 @@
# Frontend Architecture
This document defines the target structure for the Angular renderer and the boundaries between web-safe code, Electron adapters, reusable logic, and feature UI.
## Goals
- Keep feature code easy to navigate by grouping it around user-facing domains.
- Push platform-specific behavior behind explicit adapters.
- Move pure logic into helpers and dedicated model files instead of embedding it in components and large services.
- Split large services into smaller units with one clear responsibility each.
## Target Structure
```text
src/app/
app.ts
domains/
<domain>/
application/ # facades and use-case orchestration
domain/ # models and pure domain logic
infrastructure/ # adapters, api clients, persistence
feature/ # smart domain UI containers
ui/ # dumb/presentational domain UI
infrastructure/
persistence/ # shared storage adapters and persistence facades
realtime/ # shared signaling, peer transport, media runtime state
shared-kernel/ # intentionally shared cross-domain contracts and enums
core/
constants.ts # cross-domain technical constants only
helpers/ # transitional pure helpers still being moved out
models/ # transitional shared contracts pending extraction
platform/
platform.service.ts # runtime/environment detection adapters
external-link.service.ts # browser/electron navigation adapters
electron/ # renderer-side adapters for preload / Electron APIs
realtime/ # compatibility/public import boundary for realtime
services/ # technical cross-domain services only
features/ # transitional feature area while slices move into domains/*/feature
shared/
ui/ # shared presentational primitives
utils/ # shared pure utilities
store/ # ngrx reducers, effects, selectors, actions
```
## Layering Rules
Dependency direction must stay one-way:
1. `domains/*/feature`, `domains/*/ui`, and transitional `features/` may depend on `domains/`, `infrastructure/`, `core/`, `shared/`, and `store/`.
2. `domains/*/application` may depend on its own `domain/`, `infrastructure/`, top-level `infrastructure/`, `core/`, and `store/`.
3. `domains/*/domain` should stay framework-light and hold domain models plus pure logic.
4. `domains/*/infrastructure` may depend on `core/platform/`, browser APIs, HTTP, persistence, and external adapters.
5. Top-level `infrastructure/` should hold shared technical runtime implementations; `core/` should hold cross-domain utilities, compatibility entry points, and platform adapters rather than full runtime subsystems.
6. `shared-kernel/` should hold the intentionally shared cross-domain contracts that more than one domain must agree on.
7. `core/models/` is transitional and should shrink over time. Domain-specific models belong inside their domain folder, while stable cross-domain contracts should move into `shared-kernel/`.
## Responsibility Split
Use these roles consistently when a domain starts to absorb too many concerns:
- `application`: Angular-facing facades and use-case orchestration.
- `domain`: contracts, policies, calculations, mapping rules, and other pure domain logic.
- `infrastructure`: platform adapters, storage, transport, HTTP, IPC, and persistence boundaries.
- `feature`: smart components that wire store, routing, and facades.
- `ui`: presentational components with minimal business knowledge.
## Platform Boundary
Renderer code should not access `window.electronAPI` directly except inside the Electron adapter layer.
Current convention:
- Use `core/platform/electron/electron-api.models.ts` for Angular-side Electron typings.
- Use `core/platform/electron/electron-bridge.service.ts` to reach preload APIs from Angular code.
- Keep runtime detection and browser/Electron wrappers such as `core/platform/platform.service.ts` and `core/platform/external-link.service.ts` inside `core/platform/` rather than `core/services/`.
- Keep Electron-only persistence and file-system logic inside dedicated adapter services such as `domains/attachment/infrastructure/attachment-storage.service.ts`.
This keeps feature and domain code platform-agnostic and makes the browser runtime easier to reason about.
## Models And Pure Logic
- Attachment runtime types now live in `domains/attachment/domain/attachment.models.ts`.
- Attachment download-policy rules now live in `domains/attachment/domain/attachment.logic.ts`.
- Attachment file-path sanitizing and storage-bucket selection live in `domains/attachment/infrastructure/attachment-storage.helpers.ts`.
- New domain types should be placed in a dedicated model file inside their domain folder when they are not shared across domains.
- Only transitional shared contracts should stay in `core/models/`.
- Stable cross-domain enums and contracts should be extracted into `shared-kernel/` once ownership is clear.
- Shared signaling and attachment transport contracts should live in `shared-kernel/` rather than inside `core/models/`.
## Incremental Refactor Path
The repo is large enough that refactoring must stay incremental. Preferred order:
1. Extract platform adapters from direct renderer global access.
2. Split persistence and cache behavior out of large orchestration services.
3. Move reusable types and helper logic into dedicated files.
4. Break remaining multi-responsibility facades into managers or coordinators.
## Current Baseline
The current refactor establishes these patterns:
- Shared Electron bridge for Angular services and root app wiring.
- Attachment now lives under `domains/attachment/` with `application/attachment.facade.ts` as the public Angular-facing boundary.
- Attachment application orchestration is now split across `domains/attachment/application/attachment-manager.service.ts`, `attachment-transfer.service.ts`, `attachment-persistence.service.ts`, and `attachment-runtime.store.ts`.
- Attachment runtime types and pure transfer-policy logic live in `domains/attachment/domain/`.
- Attachment disk persistence and storage helpers live in `domains/attachment/infrastructure/`.
- Shared browser/Electron persistence adapters now live in `infrastructure/persistence/`, with `DatabaseService` as the renderer-facing facade over IndexedDB and Electron CQRS-backed SQLite.
- Auth HTTP/login orchestration now lives under `domains/auth/application/auth.service.ts`.
- Chat GIF search and KLIPY integration now live under `domains/chat/application/klipy.service.ts`.
- Voice-session now lives under `domains/voice-session/` with `application/voice-session.facade.ts` as the public Angular-facing boundary.
- Voice-session models and pure route/room mapping logic live in `domains/voice-session/domain/`.
- Voice workspace UI state now lives in `domains/voice-session/application/voice-workspace.service.ts`, and persisted voice/screen-share preferences now live in `domains/voice-session/infrastructure/voice-settings.storage.ts`.
- Voice activity tracking now lives in `domains/voice-connection/application/voice-activity.service.ts`.
- Server-directory now lives under `domains/server-directory/` with `application/server-directory.facade.ts` as the public Angular-facing boundary.
- Server-directory endpoint state now lives in `domains/server-directory/application/server-endpoint-state.service.ts`.
- Server-directory contracts and user-facing compatibility messaging live in `domains/server-directory/domain/`.
- Endpoint default URL normalization and built-in endpoint templates live in `domains/server-directory/domain/server-endpoint-defaults.ts`.
- Endpoint localStorage persistence, HTTP fan-out, compatibility checks, and health probes live in `domains/server-directory/infrastructure/`.
- `infrastructure/realtime/realtime-session.service.ts` now holds the shared realtime runtime service.
- `core/realtime/index.ts` is the compatibility/public import surface and re-exports that runtime service as `RealtimeSessionFacade` for technical cross-domain consumers.
- `domains/voice-connection/` now exposes `application/voice-connection.facade.ts` as the voice-specific Angular-facing boundary.
- `domains/screen-share/` now exposes `application/screen-share.facade.ts` plus `application/screen-share-source-picker.service.ts`, and `domain/screen-share.config.ts` is the real source for screen-share presets/options plus `ELECTRON_ENTIRE_SCREEN_SOURCE_NAME`.
- Shared transport contracts and the low-level signaling and peer-connection stack now live under `infrastructure/realtime/`.
- Shared media preference contracts now live under `shared-kernel/media-preferences.ts`, so `voice-session` settings no longer import `voice-connection` or `screen-share` domain internals.
- Shared signaling and attachment contracts now live under `shared-kernel/signaling-contracts.ts` and `shared-kernel/attachment-contracts.ts`, while `core/models/` keeps compatibility re-exports during the migration.
- Realtime debug network metric collection now lives in `infrastructure/realtime/logging/debug-network-metrics.ts`; the debug console reads that infrastructure state rather than keeping the metric store inside `core/services/`.
- Shared media handling, voice orchestration helpers, noise reduction, and screen-share capture adapters now live in `infrastructure/realtime/media/`.
- The old `domains/webrtc/*` and `core/services/webrtc.service.ts` compatibility shims have been removed after all in-repo callers moved to `core/realtime`, `domains/voice-connection/`, and `domains/screen-share/`.
- Multi-server signaling topology and signaling-manager lifecycle extracted from `WebRTCService` into `ServerSignalingCoordinator`.
- Angular-facing signal and connection-state bookkeeping extracted from `WebRTCService` into `WebRtcStateController`.
- Peer/media event streams, peer messaging, remote stream access, and screen-share entry points extracted from `WebRTCService` into `PeerMediaFacade`.
- Lower-level signaling connect/send/identify helpers extracted from `WebRTCService` into `SignalingTransportHandler`.
- Incoming signaling message semantics extracted from `WebRTCService` into `IncomingSignalingMessageHandler`.
- Outbound server-membership signaling commands extracted from `WebRTCService` into `ServerMembershipSignalingHandler`.
- Remote screen-share request state and control-plane handling extracted from `WebRTCService` into `RemoteScreenShareRequestController`.
- Voice session and heartbeat orchestration extracted from `WebRTCService` into `VoiceSessionController`.
- Desktop client-version lookup and endpoint compatibility checks for server-directory live in `domains/server-directory/infrastructure/server-endpoint-compatibility.service.ts`.
- Endpoint health probing and fallback transport for server-directory live in `domains/server-directory/infrastructure/server-endpoint-health.service.ts`.
- Server-directory HTTP fan-out, endpoint resolution, and API response normalization live in `domains/server-directory/infrastructure/server-directory-api.service.ts`.
## Shared Kernel Organisation
`shared-kernel/` is organised by business concept so newcomers can find types
by meaning rather than by historical accident:
| File | Contents |
|---|---|
| `user.models.ts` | `User`, `UserStatus`, `UserRole`, `RoomMember` |
| `room.models.ts` | `Room`, `RoomSettings`, `RoomPermissions`, `Channel`, `ChannelType` |
| `message.models.ts` | `Message`, `Reaction`, `DELETED_MESSAGE_CONTENT` |
| `moderation.models.ts` | `BanEntry` |
| `voice-state.models.ts` | `VoiceState`, `ScreenShareState` |
| `chat-events.ts` | `ChatEventType`, `ChatEvent`, `ChatInventoryItem` |
| `media-preferences.ts` | `LatencyProfile`, `ScreenShareQuality`, quality presets |
| `signaling-contracts.ts` | `SignalingMessage`, `SignalingMessageType` |
| `attachment-contracts.ts` | `ChatAttachmentAnnouncement`, `ChatAttachmentMeta` |
`core/models/index.ts` is now a **pure re-export barrel** that re-exports all
of the above (plus domain re-exports like `ServerInfo`) for backward
compatibility. New code should import from `shared-kernel/` directly.
## Next Candidates
- Migrate remaining voice and room UI from transitional `features/` into domain-local `feature/` and `ui/` folders.
- Migrate remaining WebRTC-facing UI from transitional `features/` and `shared/` locations into domain-local `feature/` and `ui/` folders where it improves ownership.

129
electron/app/auto-start.ts Normal file
View 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
View 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;
}

View File

@@ -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');
// Collect all enabled features into a single switch to avoid later calls overwriting earlier ones
const enabledFeatures: string[] = [];
if (process.platform === 'linux') {
// PipeWire-based audio pipeline for screen share audio capture
enabledFeatures.push('AudioServiceOutOfProcess');
// PipeWire-based screen capture so the xdg-desktop-portal system picker works
enabledFeatures.push('WebRTCPipeWireCapturer');
} }
function audioFlags(): void { const desktopSettings = readDesktopSettings();
if (process.platform === 'linux') {
// Use the new PipeWire-based audio pipeline on Linux for better screen share audio capture support if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
app.commandLine.appendSwitch('enable-features', 'AudioServiceOutOfProcess'); enabledFeatures.push('VaapiVideoEncode');
}
if (enabledFeatures.length > 0) {
app.commandLine.appendSwitch('enable-features', enabledFeatures.join(','));
} }
} }
function linuxSpecificFlags(): void { function linuxSpecificFlags(): void {
if (process.platform !== 'linux') {
return;
}
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues // Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
if (process.platform === 'linux') {
app.commandLine.appendSwitch('no-sandbox'); app.commandLine.appendSwitch('no-sandbox');
app.commandLine.appendSwitch('disable-dev-shm-usage'); 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');

View File

@@ -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();

View File

@@ -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,35 +443,53 @@ 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);
if (sinkName === VOICE_SINK_NAME) { // App-owned streams must stay on the voice sink.
return; if (appOwned && sinkName !== VOICE_SINK_NAME) {
}
try { try {
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME); await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
} catch { } catch {
// Streams can disappear or be recreated while rerouting. // Streams can disappear or be recreated while rerouting.
} }
return;
}
// Non-app streams sitting on the voice sink should be moved to the
// screenshare sink for desktop-audio capture.
if (!appOwned && sinkName === VOICE_SINK_NAME) {
try {
await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME);
} catch {
// Streams can disappear or be recreated while rerouting.
}
}
}) })
); );
} }
@@ -515,7 +551,7 @@ function startSinkInputRerouteLoop(): void {
} }
routingState.rerouteIntervalId = setInterval(() => { 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 {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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
}; };
} }

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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;
} }

View File

@@ -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 () => {
try {
const thumbnailSize = { width: 240, height: 150 };
const [screenSources, windowSources] = await Promise.all([
desktopCapturer.getSources({
types: ['screen'],
thumbnailSize
}),
desktopCapturer.getSources({
types: ['window'],
thumbnailSize,
fetchWindowIcons: true
})
]);
const sources = [...screenSources, ...windowSources];
const uniqueSources = new Map(sources.map((source) => [source.id, source]));
return [...uniqueSources.values()].map((source) => ({
id: source.id, id: source.id,
name: source.name, name: source.name,
thumbnail: source.thumbnail.toDataURL() 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;
}); });

View File

@@ -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();
if (initializeDeepLinkHandling()) {
registerAppLifecycle(); registerAppLifecycle();
}

View File

@@ -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"`);
}
}

View File

@@ -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),

View File

@@ -729,7 +729,7 @@ export function restartToApplyUpdate(): boolean {
return false; return false;
} }
autoUpdater.quitAndInstall(false, true); autoUpdater.quitAndInstall(true, true);
return true; return true;
} }

View File

@@ -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
View File

@@ -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",

View File

@@ -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"
}, },
@@ -147,7 +157,7 @@
"publish": [ "publish": [
{ {
"provider": "generic", "provider": "generic",
"url": "https://updates.metoyou.invalid" "url": "https://tojusignal.azaaxin.com"
} }
], ],
"mac": { "mac": {

23
public/web.config Normal file
View 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.

View File

@@ -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());

View File

@@ -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';
}

View File

@@ -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);
} }

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View 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;
}

View File

@@ -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;

View 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;
}

View 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;
}

View File

@@ -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';

View File

@@ -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) => {

View 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"`);
}
}

View File

@@ -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
];

View File

@@ -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);
} }

View 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)}`;
}

View 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()
}));
});

View File

@@ -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;

View 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 });
}
}

View File

@@ -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':

View File

@@ -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() }));

View File

@@ -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;
} }

View File

@@ -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" />

View File

@@ -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
) )
}, },

View File

@@ -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;
}
}
} }

View File

@@ -1,313 +1,54 @@
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'; // ── shared-kernel re-exports ────────────────────────────────────────
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 { // ── domain re-exports ───────────────────────────────────────────────
id: string; export type { ServerInfo } from '../../domains/server-directory';
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;
}

View 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;
};

View 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;
}
}

View 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;
}

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
export * from './platform.service';
export * from './external-link.service';

View 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;
}
}

View 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

View File

@@ -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,

View File

@@ -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

View File

@@ -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;
} }
} }

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -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()));
}
}

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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
View 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/` |

View 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"
click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle"
click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol"
click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming"
click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence"
click Store "application/attachment-runtime.store.ts" "In-memory signal-based state"
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access"
click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers"
click Logic "domain/attachment.logic.ts" "Pure decision functions"
```
## 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.

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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.';

View File

@@ -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;
};

View File

@@ -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

View 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/');
}

View 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;
}

View File

@@ -0,0 +1,3 @@
export * from './application/attachment.facade';
export * from './domain/attachment.constants';
export * from './domain/attachment.models';

View File

@@ -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';
}

View File

@@ -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);
}
}

View 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"
click Login "feature/login/" "Login form"
click Register "feature/register/" "Registration form"
click UserBar "feature/user-bar/" "Current user display"
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API URL"
```
## 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.

View File

@@ -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.

View File

@@ -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
});
} }
} }

View File

@@ -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
});
} }
} }

View File

@@ -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',

View File

@@ -0,0 +1 @@
export * from './application/auth.service';

View 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"
click List "feature/chat-messages/components/message-list/" "Paginated message list"
click Composer "feature/chat-messages/components/message-composer/" "Markdown toolbar + send"
click Overlays "feature/chat-messages/components/message-overlays/" "Context menus, reaction picker"
click Item "feature/chat-messages/components/message-item/" "Single message bubble"
click GIF "feature/klipy-gif-picker/" "GIF search panel"
click Typing "feature/typing-indicator/" "Typing indicator"
click Users "feature/user-list/" "Online user sidebar"
```
## 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"
click Klipy "application/klipy.service.ts" "GIF search via KLIPY API"
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL"
```
## 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".

Some files were not shown because too many files have changed in this diff Show More