27 Commits

Author SHA1 Message Date
Myx
64e34ad586 feat: basic selected server indicator 2026-03-30 04:54:02 +02:00
Myx
e3b23247a9 feat: Close to tray 2026-03-30 04:48:34 +02:00
Myx
42ac712571 feat: Add notifications 2026-03-30 04:41:58 +02:00
Myx
b7d4bf20e3 feat: Add webcam basic support 2026-03-30 03:10:44 +02:00
Myx
727059fb52 Add seperation of voice channels, creation of new ones, and move around users 2026-03-30 02:11:39 +02:00
Myx
83694570e3 feat: Allow admin to create new text channels 2026-03-30 01:25:56 +02:00
Myx
109402cdd6 perf: Health snapshot changes 2026-03-30 00:28:45 +02:00
Myx
eb23fd71ec perf: Optimizing the image loading
Does no longer load all klipy images through image proxy from signal server. Improves loading performance.
2026-03-30 00:26:28 +02:00
Myx
11917f3412 fix: Make attachments unique when downloaded
Fixes the issue with attachments replacing each other locally so files with same filename appears as the same file
2026-03-30 00:08:53 +02:00
Myx
8162e0444a Move toju-app into own its folder 2026-03-29 23:55:24 +02:00
Myx
0467a7b612 documentation improvement 2026-03-23 01:34:18 +01:00
Myx
971a5afb8b ddd test 2 2026-03-23 00:42:08 +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
361 changed files with 17359 additions and 5186 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

@@ -67,8 +67,10 @@ jobs:
- name: Build application - name: Build application
run: | run: |
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
cd toju-app
npx ng build --configuration production --base-href='./' npx ng build --configuration production --base-href='./'
cd ..
npx --package typescript tsc -p tsconfig.electron.json npx --package typescript tsc -p tsconfig.electron.json
cd server cd server
node ../tools/sync-server-build-version.js node ../tools/sync-server-build-version.js
@@ -120,8 +122,10 @@ jobs:
- name: Build application - name: Build application
run: | run: |
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
Push-Location "toju-app"
npx ng build --configuration production --base-href='./' npx ng build --configuration production --base-href='./'
Pop-Location
npx --package typescript tsc -p tsconfig.electron.json npx --package typescript tsc -p tsconfig.electron.json
Push-Location server Push-Location server
node ../tools/sync-server-build-version.js node ../tools/sync-server-build-version.js

7
.gitignore vendored
View File

@@ -6,7 +6,9 @@
/tmp /tmp
/out-tsc /out-tsc
/bazel-out /bazel-out
*.sqlite
*/architecture.md
/docs
# Node # Node
/node_modules /node_modules
npm-debug.log npm-debug.log
@@ -51,3 +53,6 @@ Thumbs.db
.certs/ .certs/
/server/data/variables.json /server/data/variables.json
dist-server/* dist-server/*
AGENTS.md
doc/**

View File

@@ -1,10 +1,14 @@
<img src="./images/icon.png" width="100" height="100">
# Toju / Zoracord # Toju / Zoracord
Desktop chat app with three parts: Desktop chat app with four parts:
- `src/` Angular client - `src/` Angular client
- `electron/` desktop shell, IPC, and local database - `electron/` desktop shell, IPC, and local database
- `server/` directory server, join request API, and websocket events - `server/` directory server, join request API, and websocket events
- `website/` Toju website served at toju.app
## Install ## Install
@@ -17,7 +21,7 @@ Desktop chat app with three parts:
Root `.env`: Root `.env`:
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode - `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
- `PORT=3001` changes the server port - `PORT=3001` changes the server port in local development and overrides the server app setting
If `SSL=true`, run `./generate-cert.sh` once. If `SSL=true`, run `./generate-cert.sh` once.
@@ -25,6 +29,10 @@ Server files:
- `server/data/variables.json` holds `klipyApiKey` - `server/data/variables.json` holds `klipyApiKey`
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates - `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to)
- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`)
- `server/data/variables.json` can now also hold `serverPort` (1-65535)
- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP
## Main commands ## Main commands
@@ -48,3 +56,64 @@ Inside `server/`:
- `npm run dev` starts the server with reload - `npm run dev` starts the server with reload
- `npm run build` compiles to `dist/` - `npm run build` compiles to `dist/`
- `npm run start` runs the compiled server - `npm run start` runs the compiled server
# Images
<img src="./website/src/images/screenshots/gif.png" width="700" height="400">
<img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
## Main Toju app Structure
| Path | Description |
|------|-------------|
| `src/app/` | Main application root |
| `src/app/core/` | Core utilities, services, models |
| `src/app/domains/` | Domain-driven modules |
| `src/app/features/` | UI feature modules |
| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) |
| `src/app/shared/` | Shared UI components |
| `src/app/shared-kernel/` | Shared domain contracts & models |
| `src/app/store/` | Global state management |
| `src/assets/` | Static assets |
| `src/environments/` | Environment configs |
---
### Domains
| Path | Link |
|------|------|
| Attachment | [app/domains/attachment/README.md](src/app/domains/attachment/README.md) |
| Auth | [app/domains/auth/README.md](src/app/domains/auth/README.md) |
| Chat | [app/domains/chat/README.md](src/app/domains/chat/README.md) |
| Screen Share | [app/domains/screen-share/README.md](src/app/domains/screen-share/README.md) |
| Server Directory | [app/domains/server-directory/README.md](src/app/domains/server-directory/README.md) |
| Voice Connection | [app/domains/voice-connection/README.md](src/app/domains/voice-connection/README.md) |
| Voice Session | [app/domains/voice-session/README.md](src/app/domains/voice-session/README.md) |
| Domains Root | [app/domains/README.md](src/app/domains/README.md) |
---
### Infrastructure
| Path | Link |
|------|------|
| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) |
| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) |
---
### Shared Kernel
| Path | Link |
|------|------|
| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) |
---
### Entry Points
| File | Link |
|------|------|
| Main | [main.ts](src/main.ts) |
| Index HTML | [index.html](src/index.html) |
| App Root | [app/app.ts](src/app/app.ts) |

4
dev.sh
View File

@@ -20,12 +20,12 @@ if [ "$SSL" = "true" ]; then
"$DIR/generate-cert.sh" "$DIR/generate-cert.sh"
fi fi
NG_SERVE="ng serve --host=0.0.0.0 --ssl --ssl-cert=.certs/localhost.crt --ssl-key=.certs/localhost.key" NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key"
WAIT_URL="https://localhost:4200" WAIT_URL="https://localhost:4200"
HEALTH_URL="https://localhost:3001/api/health" HEALTH_URL="https://localhost:3001/api/health"
export NODE_TLS_REJECT_UNAUTHORIZED=0 export NODE_TLS_REJECT_UNAUTHORIZED=0
else else
NG_SERVE="ng serve --host=0.0.0.0" NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
WAIT_URL="http://localhost:4200" WAIT_URL="http://localhost:4200"
HEALTH_URL="http://localhost:3001/api/health" HEALTH_URL="http://localhost:3001/api/health"
fi fi

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

@@ -1,12 +1,19 @@
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow } from 'electron';
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing'; import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater'; import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
import { synchronizeAutoStartSetting } from './auto-start';
import { import {
initializeDatabase, initializeDatabase,
destroyDatabase, destroyDatabase,
getDataSource getDataSource
} from '../db/database'; } from '../db/database';
import { createWindow, getDockIconPath } from '../window/create-window'; import {
createWindow,
getDockIconPath,
getMainWindow,
prepareWindowForAppQuit,
showMainWindow
} from '../window/create-window';
import { import {
setupCqrsHandlers, setupCqrsHandlers,
setupSystemHandlers, setupSystemHandlers,
@@ -24,12 +31,18 @@ export function registerAppLifecycle(): void {
setupCqrsHandlers(); setupCqrsHandlers();
setupWindowControlHandlers(); setupWindowControlHandlers();
setupSystemHandlers(); setupSystemHandlers();
await synchronizeAutoStartSetting();
initializeDesktopUpdater(); initializeDesktopUpdater();
await createWindow(); await createWindow();
app.on('activate', () => { app.on('activate', () => {
if (getMainWindow()) {
void showMainWindow();
return;
}
if (BrowserWindow.getAllWindows().length === 0) if (BrowserWindow.getAllWindows().length === 0)
createWindow(); void createWindow();
}); });
}); });
@@ -39,6 +52,8 @@ export function registerAppLifecycle(): void {
}); });
app.on('before-quit', async (event) => { app.on('before-quit', async (event) => {
prepareWindowForAppQuit();
if (getDataSource()?.isInitialized) { if (getDataSource()?.isInitialized) {
event.preventDefault(); event.preventDefault();
shutdownDesktopUpdater(); shutdownDesktopUpdater();

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

@@ -0,0 +1,18 @@
import { DataSource, MoreThan } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessagesSinceQuery } from '../../types';
import { rowToMessage } from '../../mappers';
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const { roomId, sinceTimestamp } = query.payload;
const rows = await repo.find({
where: {
roomId,
timestamp: MoreThan(sinceTimestamp)
},
order: { timestamp: 'ASC' }
});
return rows.map(rowToMessage);
}

View File

@@ -4,6 +4,7 @@ import {
QueryTypeKey, QueryTypeKey,
Query, Query,
GetMessagesQuery, GetMessagesQuery,
GetMessagesSinceQuery,
GetMessageByIdQuery, GetMessageByIdQuery,
GetReactionsForMessageQuery, GetReactionsForMessageQuery,
GetUserQuery, GetUserQuery,
@@ -13,6 +14,7 @@ import {
GetAttachmentsForMessageQuery GetAttachmentsForMessageQuery
} from '../types'; } from '../types';
import { handleGetMessages } from './handlers/getMessages'; import { handleGetMessages } from './handlers/getMessages';
import { handleGetMessagesSince } from './handlers/getMessagesSince';
import { handleGetMessageById } from './handlers/getMessageById'; import { handleGetMessageById } from './handlers/getMessageById';
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage'; import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
import { handleGetUser } from './handlers/getUser'; import { handleGetUser } from './handlers/getUser';
@@ -27,6 +29,7 @@ import { handleGetAllAttachments } from './handlers/getAllAttachments';
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource), [QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
[QueryType.GetMessagesSince]: (query) => handleGetMessagesSince(query as GetMessagesSinceQuery, dataSource),
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource), [QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource), [QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource), [QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),

View File

@@ -22,6 +22,7 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
export const QueryType = { export const QueryType = {
GetMessages: 'get-messages', GetMessages: 'get-messages',
GetMessagesSince: 'get-messages-since',
GetMessageById: 'get-message-by-id', GetMessageById: 'get-message-by-id',
GetReactionsForMessage: 'get-reactions-for-message', GetReactionsForMessage: 'get-reactions-for-message',
GetUser: 'get-user', GetUser: 'get-user',
@@ -84,6 +85,7 @@ export interface RoomPayload {
topic?: string; topic?: string;
hostId: string; hostId: string;
password?: string; password?: string;
hasPassword?: boolean;
isPrivate?: boolean; isPrivate?: boolean;
createdAt: number; createdAt: number;
userCount?: number; userCount?: number;
@@ -93,6 +95,9 @@ export interface RoomPayload {
permissions?: unknown; permissions?: unknown;
channels?: unknown[]; channels?: unknown[];
members?: unknown[]; members?: unknown[];
sourceId?: string;
sourceName?: string;
sourceUrl?: string;
} }
export interface BanPayload { export interface BanPayload {
@@ -156,6 +161,7 @@ export type Command =
| ClearAllDataCommand; | ClearAllDataCommand;
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } } export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } }
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } } export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } } export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } } export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
@@ -170,6 +176,7 @@ export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachmen
export type Query = export type Query =
| GetMessagesQuery | GetMessagesQuery
| GetMessagesSinceQuery
| GetMessageByIdQuery | GetMessageByIdQuery
| GetReactionsForMessageQuery | GetReactionsForMessageQuery
| GetUserQuery | GetUserQuery

View File

@@ -6,6 +6,8 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
export interface DesktopSettings { export interface DesktopSettings {
autoUpdateMode: AutoUpdateMode; autoUpdateMode: AutoUpdateMode;
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
@@ -19,6 +21,8 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
autoUpdateMode: 'auto', autoUpdateMode: 'auto',
autoStart: true,
closeToTray: true,
hardwareAcceleration: true, hardwareAcceleration: true,
manifestUrls: [], manifestUrls: [],
preferredVersion: null, preferredVersion: null,
@@ -81,6 +85,12 @@ export function readDesktopSettings(): DesktopSettings {
return { return {
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode), autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
autoStart: typeof parsed.autoStart === 'boolean'
? parsed.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart,
closeToTray: typeof parsed.closeToTray === 'boolean'
? parsed.closeToTray
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean' vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
? parsed.vaapiVideoEncode ? parsed.vaapiVideoEncode
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode, : DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
@@ -102,6 +112,12 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
}; };
const nextSettings: DesktopSettings = { const nextSettings: DesktopSettings = {
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode), autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
autoStart: typeof mergedSettings.autoStart === 'boolean'
? mergedSettings.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart,
closeToTray: typeof mergedSettings.closeToTray === 'boolean'
? mergedSettings.closeToTray
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean' hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
? mergedSettings.hardwareAcceleration ? mergedSettings.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration, : DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,

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

@@ -4,6 +4,7 @@ import {
desktopCapturer, desktopCapturer,
dialog, dialog,
ipcMain, ipcMain,
Notification,
shell shell
} from 'electron'; } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
@@ -28,8 +29,16 @@ import {
getDesktopUpdateState, getDesktopUpdateState,
handleDesktopSettingsChanged, handleDesktopSettingsChanged,
restartToApplyUpdate, restartToApplyUpdate,
readDesktopUpdateServerHealth,
type DesktopUpdateServerContext type DesktopUpdateServerContext
} from '../update/desktop-updater'; } from '../update/desktop-updater';
import { consumePendingDeepLink } from '../app/deep-links';
import { synchronizeAutoStartSetting } from '../app/auto-start';
import {
getMainWindow,
getWindowIconPath,
updateCloseToTraySetting
} from '../window/create-window';
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [ const FILE_CLIPBOARD_FORMATS = [
@@ -83,6 +92,12 @@ interface ClipboardFilePayload {
path?: string; path?: string;
} }
interface DesktopNotificationPayload {
body: string;
requestAttention?: boolean;
title: string;
}
function resolveLinuxDisplayServer(): string { function resolveLinuxDisplayServer(): string {
if (process.platform !== 'linux') { if (process.platform !== 'linux') {
return 'N/A'; return 'N/A';
@@ -258,6 +273,8 @@ export function setupSystemHandlers(): void {
return false; return false;
}); });
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
ipcMain.handle('get-sources', async () => { ipcMain.handle('get-sources', async () => {
try { try {
const thumbnailSize = { width: 240, height: 150 }; const thumbnailSize = { width: 240, height: 150 };
@@ -311,8 +328,75 @@ export function setupSystemHandlers(): void {
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot()); ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
ipcMain.handle('show-desktop-notification', async (_event, payload: DesktopNotificationPayload) => {
const title = typeof payload?.title === 'string' ? payload.title.trim() : '';
const body = typeof payload?.body === 'string' ? payload.body : '';
const mainWindow = getMainWindow();
if (!title) {
return false;
}
if (Notification.isSupported()) {
try {
const notification = new Notification({
title,
body,
icon: getWindowIconPath(),
silent: true
});
notification.on('click', () => {
if (!mainWindow) {
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
});
notification.show();
} catch {
// Ignore notification center failures and still attempt taskbar attention.
}
}
if (payload?.requestAttention && mainWindow && (mainWindow.isMinimized() || !mainWindow.isFocused())) {
mainWindow.flashFrame(true);
}
return true;
});
ipcMain.handle('request-window-attention', () => {
const mainWindow = getMainWindow();
if (!mainWindow || (!mainWindow.isMinimized() && mainWindow.isFocused())) {
return false;
}
mainWindow.flashFrame(true);
return true;
});
ipcMain.handle('clear-window-attention', () => {
getMainWindow()?.flashFrame(false);
return true;
});
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState()); ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
ipcMain.handle('get-auto-update-server-health', async (_event, serverUrl: string) => {
return await readDesktopUpdateServerHealth(serverUrl);
});
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => { ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
return await configureDesktopUpdaterContext(context); return await configureDesktopUpdaterContext(context);
}); });
@@ -326,6 +410,8 @@ export function setupSystemHandlers(): void {
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => { ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
const snapshot = updateDesktopSettings(patch); const snapshot = updateDesktopSettings(patch);
await synchronizeAutoStartSetting(snapshot.autoStart);
updateCloseToTraySetting(snapshot.closeToTray);
await handleDesktopSettingsChanged(); await handleDesktopSettingsChanged();
return snapshot; return snapshot;
}); });

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();
registerAppLifecycle();
if (initializeDeepLinkHandling()) {
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,8 @@ import { Command, Query } from './cqrs/types';
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk'; const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended'; const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed'; const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
export interface LinuxScreenShareAudioRoutingInfo { export interface LinuxScreenShareAudioRoutingInfo {
available: boolean; available: boolean;
@@ -49,6 +51,12 @@ export interface DesktopUpdateServerContext {
serverVersionStatus: DesktopUpdateServerVersionStatus; serverVersionStatus: DesktopUpdateServerVersionStatus;
} }
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState { export interface DesktopUpdateState {
autoUpdateMode: 'auto' | 'off' | 'version'; autoUpdateMode: 'auto' | 'off' | 'version';
availableVersions: string[]; availableVersions: string[];
@@ -83,6 +91,17 @@ export interface DesktopUpdateState {
targetVersion: string | null; targetVersion: string | null;
} }
export interface DesktopNotificationPayload {
body: string;
requestAttention: boolean;
title: string;
}
export interface WindowStateSnapshot {
isFocused: boolean;
isMinimized: boolean;
}
function readLinuxDisplayServer(): string { function readLinuxDisplayServer(): string {
if (process.platform !== 'linux') { if (process.platform !== 'linux') {
return 'N/A'; return 'N/A';
@@ -115,27 +134,39 @@ export interface ElectronAPI {
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void; onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void; onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
consumePendingDeepLink: () => Promise<string | null>;
getDesktopSettings: () => Promise<{ getDesktopSettings: () => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version'; autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
runtimeHardwareAcceleration: boolean; runtimeHardwareAcceleration: boolean;
restartRequired: boolean; restartRequired: boolean;
}>; }>;
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
requestWindowAttention: () => Promise<boolean>;
clearWindowAttention: () => Promise<boolean>;
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
getAutoUpdateState: () => Promise<DesktopUpdateState>; getAutoUpdateState: () => Promise<DesktopUpdateState>;
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>; configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
checkForAppUpdates: () => Promise<DesktopUpdateState>; checkForAppUpdates: () => Promise<DesktopUpdateState>;
restartToApplyUpdate: () => Promise<boolean>; restartToApplyUpdate: () => Promise<boolean>;
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void; onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
setDesktopSettings: (patch: { setDesktopSettings: (patch: {
autoUpdateMode?: 'auto' | 'off' | 'version'; autoUpdateMode?: 'auto' | 'off' | 'version';
autoStart?: boolean;
closeToTray?: boolean;
hardwareAcceleration?: boolean; hardwareAcceleration?: boolean;
manifestUrls?: string[]; manifestUrls?: string[];
preferredVersion?: string | null; preferredVersion?: string | null;
vaapiVideoEncode?: boolean; vaapiVideoEncode?: boolean;
}) => Promise<{ }) => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version'; autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
@@ -143,6 +174,7 @@ export interface ElectronAPI {
restartRequired: boolean; restartRequired: boolean;
}>; }>;
relaunchApp: () => Promise<boolean>; relaunchApp: () => Promise<boolean>;
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
readClipboardFiles: () => Promise<ClipboardFilePayload[]>; readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
readFile: (filePath: string) => Promise<string>; readFile: (filePath: string) => Promise<string>;
writeFile: (filePath: string, data: string) => Promise<boolean>; writeFile: (filePath: string, data: string) => Promise<boolean>;
@@ -198,8 +230,24 @@ const electronAPI: ElectronAPI = {
}; };
}, },
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'), getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'), getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
requestWindowAttention: () => ipcRenderer.invoke('request-window-attention'),
clearWindowAttention: () => ipcRenderer.invoke('clear-window-attention'),
onWindowStateChanged: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: WindowStateSnapshot) => {
listener(state);
};
ipcRenderer.on(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
};
},
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'), getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
getAutoUpdateServerHealth: (serverUrl) => ipcRenderer.invoke('get-auto-update-server-health', serverUrl),
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context), configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'), checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'), restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
@@ -216,6 +264,17 @@ const electronAPI: ElectronAPI = {
}, },
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch), setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
relaunchApp: () => ipcRenderer.invoke('relaunch-app'), relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
onDeepLinkReceived: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, url: string) => {
listener(url);
};
ipcRenderer.on(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
};
},
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'), readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath), readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data), writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),

View File

@@ -18,6 +18,11 @@ interface ReleaseManifestEntry {
version: string; version: string;
} }
interface ServerHealthResponse {
releaseManifestUrl?: string;
serverVersion?: string;
}
interface UpdateVersionInfo { interface UpdateVersionInfo {
version: string; version: string;
} }
@@ -53,6 +58,12 @@ export interface DesktopUpdateServerContext {
serverVersionStatus: DesktopUpdateServerVersionStatus; serverVersionStatus: DesktopUpdateServerVersionStatus;
} }
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState { export interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode; autoUpdateMode: AutoUpdateMode;
availableVersions: string[]; availableVersions: string[];
@@ -78,6 +89,8 @@ export interface DesktopUpdateState {
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed'; export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const SERVER_HEALTH_TIMEOUT_MS = 5_000;
let currentCheckPromise: Promise<void> | null = null; let currentCheckPromise: Promise<void> | null = null;
let currentContext: DesktopUpdateServerContext = { let currentContext: DesktopUpdateServerContext = {
manifestUrls: [], manifestUrls: [],
@@ -388,6 +401,47 @@ async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest
return parseReleaseManifest(payload); return parseReleaseManifest(payload);
} }
function createUnavailableServerHealthSnapshot(): DesktopUpdateServerHealthSnapshot {
return {
manifestUrl: null,
serverVersion: null,
serverVersionStatus: 'unavailable'
};
}
async function loadServerHealth(serverUrl: string): Promise<DesktopUpdateServerHealthSnapshot> {
const sanitizedServerUrl = sanitizeHttpUrl(serverUrl);
if (!sanitizedServerUrl) {
return createUnavailableServerHealthSnapshot();
}
try {
const response = await net.fetch(`${sanitizedServerUrl}/api/health`, {
method: 'GET',
headers: {
accept: 'application/json'
},
signal: AbortSignal.timeout(SERVER_HEALTH_TIMEOUT_MS)
});
if (!response.ok) {
return createUnavailableServerHealthSnapshot();
}
const payload = await response.json() as ServerHealthResponse;
const serverVersion = normalizeSemanticVersion(payload.serverVersion);
return {
manifestUrl: sanitizeHttpUrl(payload.releaseManifestUrl),
serverVersion,
serverVersionStatus: serverVersion ? 'reported' : 'missing'
};
} catch {
return createUnavailableServerHealthSnapshot();
}
}
function formatManifestLoadErrors(errors: string[]): string { function formatManifestLoadErrors(errors: string[]): string {
if (errors.length === 0) { if (errors.length === 0) {
return 'No valid release manifest could be loaded.'; return 'No valid release manifest could be loaded.';
@@ -724,6 +778,12 @@ export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
return desktopUpdateState; return desktopUpdateState;
} }
export async function readDesktopUpdateServerHealth(
serverUrl: string
): Promise<DesktopUpdateServerHealthSnapshot> {
return await loadServerHealth(serverUrl);
}
export function restartToApplyUpdate(): boolean { export function restartToApplyUpdate(): boolean {
if (!desktopUpdateState.restartRequired) { if (!desktopUpdateState.restartRequired) {
return false; return false;

View File

@@ -2,13 +2,21 @@ import {
app, app,
BrowserWindow, BrowserWindow,
desktopCapturer, desktopCapturer,
Menu,
session, session,
shell shell,
Tray
} from 'electron'; } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { readDesktopSettings } from '../desktop-settings';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let closeToTrayEnabled = true;
let appQuitting = false;
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
function getAssetPath(...segments: string[]): string { function getAssetPath(...segments: string[]): string {
const basePath = app.isPackaged const basePath = app.isPackaged
@@ -38,13 +46,124 @@ export function getDockIconPath(): string | undefined {
return getExistingAssetPath('macos', '1024x1024.png'); return getExistingAssetPath('macos', '1024x1024.png');
} }
function getTrayIconPath(): string | undefined {
if (process.platform === 'win32')
return getExistingAssetPath('windows', 'icon.ico');
return getExistingAssetPath('icon.png');
}
export { getWindowIconPath };
export function getMainWindow(): BrowserWindow | null { export function getMainWindow(): BrowserWindow | null {
return mainWindow; return mainWindow;
} }
function destroyTray(): void {
if (!tray) {
return;
}
tray.destroy();
tray = null;
}
function requestAppQuit(): void {
prepareWindowForAppQuit();
app.quit();
}
function ensureTray(): void {
if (tray) {
return;
}
const trayIconPath = getTrayIconPath();
if (!trayIconPath) {
return;
}
tray = new Tray(trayIconPath);
tray.setToolTip('MetoYou');
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: 'Open MetoYou',
click: () => {
void showMainWindow();
}
},
{
type: 'separator'
},
{
label: 'Close MetoYou',
click: () => {
requestAppQuit();
}
}
])
);
tray.on('click', () => {
void showMainWindow();
});
}
function hideWindowToTray(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.hide();
emitWindowState();
}
export function updateCloseToTraySetting(enabled: boolean): void {
closeToTrayEnabled = enabled;
}
export function prepareWindowForAppQuit(): void {
appQuitting = true;
destroyTray();
}
export async function showMainWindow(): Promise<void> {
if (!mainWindow || mainWindow.isDestroyed()) {
await createWindow();
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
emitWindowState();
}
function emitWindowState(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.webContents.send(WINDOW_STATE_CHANGED_CHANNEL, {
isFocused: mainWindow.isFocused(),
isMinimized: mainWindow.isMinimized()
});
}
export async function createWindow(): Promise<void> { export async function createWindow(): Promise<void> {
const windowIconPath = getWindowIconPath(); const windowIconPath = getWindowIconPath();
closeToTrayEnabled = readDesktopSettings().closeToTray;
ensureTray();
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1400, width: 1400,
height: 900, height: 900,
@@ -105,10 +224,46 @@ export async function createWindow(): Promise<void> {
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html')); await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
} }
mainWindow.on('close', (event) => {
if (appQuitting || !closeToTrayEnabled) {
return;
}
event.preventDefault();
hideWindowToTray();
});
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
mainWindow = null; mainWindow = null;
}); });
mainWindow.on('focus', () => {
mainWindow?.flashFrame(false);
emitWindowState();
});
mainWindow.on('blur', () => {
emitWindowState();
});
mainWindow.on('minimize', () => {
emitWindowState();
});
mainWindow.on('restore', () => {
emitWindowState();
});
mainWindow.on('show', () => {
emitWindowState();
});
mainWindow.on('hide', () => {
emitWindowState();
});
emitWindowState();
mainWindow.webContents.setWindowOpenHandler(({ url }) => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url); shell.openExternal(url);
return { action: 'deny' }; return { action: 'deny' };

View File

@@ -199,7 +199,7 @@ module.exports = tseslint.config(
}, },
// HTML template formatting rules (external Angular templates only) // HTML template formatting rules (external Angular templates only)
{ {
files: ['src/app/**/*.html'], files: ['toju-app/src/app/**/*.html'],
plugins: { 'no-dashes': noDashPlugin }, plugins: { 'no-dashes': noDashPlugin },
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: { rules: {

50
package-lock.json generated
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

@@ -7,22 +7,22 @@
"homepage": "https://git.azaaxin.com/myxelium/Toju", "homepage": "https://git.azaaxin.com/myxelium/Toju",
"main": "dist/electron/main.js", "main": "dist/electron/main.js",
"scripts": { "scripts": {
"ng": "ng", "ng": "cd \"toju-app\" && ng",
"prebuild": "npm run bundle:rnnoise", "prebuild": "npm run bundle:rnnoise",
"prestart": "npm run bundle:rnnoise", "prestart": "npm run bundle:rnnoise",
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js", "bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js",
"start": "ng serve", "start": "cd \"toju-app\" && ng serve",
"build": "ng build", "build": "cd \"toju-app\" && ng build",
"build:electron": "tsc -p tsconfig.electron.json", "build:electron": "tsc -p tsconfig.electron.json",
"build:all": "npm run build && npm run build:electron && cd server && npm run build", "build:all": "npm run build && npm run build:electron && cd server && npm run build",
"build:prod": "ng build --configuration production --base-href='./'", "build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
"watch": "ng build --watch --configuration development", "watch": "cd \"toju-app\" && ng build --watch --configuration development",
"test": "ng test", "test": "cd \"toju-app\" && ng test",
"server:build": "cd server && npm run build", "server:build": "cd server && npm run build",
"server:start": "cd server && npm start", "server:start": "cd server && npm start",
"server:dev": "cd server && npm run dev", "server:dev": "cd server && npm run dev",
"electron": "ng build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage", "electron": "npm run build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"", "electron:dev": "concurrently \"npm run start\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
"electron:full": "./dev.sh", "electron:full": "./dev.sh",
"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\"", "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",
@@ -40,8 +40,8 @@
"dev:app": "npm run electron:dev", "dev:app": "npm run electron:dev",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "npm run format && npm run sort:props && eslint . --fix", "lint:fix": "npm run format && npm run sort:props && eslint . --fix",
"format": "prettier --write \"src/app/**/*.html\"", "format": "prettier --write \"toju-app/src/app/**/*.html\"",
"format:check": "prettier --check \"src/app/**/*.html\"", "format:check": "prettier --check \"toju-app/src/app/**/*.html\"",
"release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux", "release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux",
"release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win", "release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win",
"release:manifest": "node tools/generate-release-manifest.js", "release:manifest": "node tools/generate-release-manifest.js",
@@ -70,6 +70,7 @@
"@spartan-ng/cli": "^0.0.1-alpha.589", "@spartan-ng/cli": "^0.0.1-alpha.589",
"@spartan-ng/ui-core": "^0.0.1-alpha.380", "@spartan-ng/ui-core": "^0.0.1-alpha.380",
"@timephy/rnnoise-wasm": "^1.0.0", "@timephy/rnnoise-wasm": "^1.0.0",
"auto-launch": "^5.0.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cytoscape": "^3.33.1", "cytoscape": "^3.33.1",
@@ -96,6 +97,7 @@
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin-js": "^4.4.1", "@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1", "@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5",
"@types/simple-peer": "^9.11.9", "@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"angular-eslint": "21.2.0", "angular-eslint": "21.2.0",
@@ -120,6 +122,14 @@
"build": { "build": {
"appId": "com.metoyou.app", "appId": "com.metoyou.app",
"productName": "MetoYou", "productName": "MetoYou",
"protocols": [
{
"name": "Toju Invite Links",
"schemes": [
"toju"
]
}
],
"directories": { "directories": {
"output": "dist-electron" "output": "dist-electron"
}, },

Binary file not shown.

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,10 +11,12 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
description: server.description ?? null, description: server.description ?? null,
ownerId: server.ownerId, ownerId: server.ownerId,
ownerPublicKey: server.ownerPublicKey, ownerPublicKey: server.ownerPublicKey,
passwordHash: server.passwordHash ?? null,
isPrivate: server.isPrivate ? 1 : 0, isPrivate: server.isPrivate ? 1 : 0,
maxUsers: server.maxUsers, maxUsers: server.maxUsers,
currentUsers: server.currentUsers, currentUsers: server.currentUsers,
tags: JSON.stringify(server.tags), tags: JSON.stringify(server.tags),
channels: JSON.stringify(server.channels ?? []),
createdAt: server.createdAt, createdAt: server.createdAt,
lastSeen: server.lastSeen lastSeen: server.lastSeen
}); });

View File

@@ -3,10 +3,67 @@ import { ServerEntity } from '../entities/ServerEntity';
import { JoinRequestEntity } from '../entities/JoinRequestEntity'; import { JoinRequestEntity } from '../entities/JoinRequestEntity';
import { import {
AuthUserPayload, AuthUserPayload,
ServerChannelPayload,
ServerPayload, ServerPayload,
JoinRequestPayload JoinRequestPayload
} from './types'; } from './types';
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${name.toLocaleLowerCase()}`;
}
function parseStringArray(raw: string | null | undefined): string[] {
try {
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed)
? parsed.filter((value): value is string => typeof value === 'string')
: [];
} catch {
return [];
}
}
function parseServerChannels(raw: string | null | undefined): ServerChannelPayload[] {
try {
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return parsed
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
.map((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return null;
}
seenIds.add(id);
seenNames.add(nameKey);
return {
id,
name,
type,
position
} satisfies ServerChannelPayload;
})
.filter((channel): channel is ServerChannelPayload => !!channel);
} catch {
return [];
}
}
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload { export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
return { return {
id: row.id, id: row.id,
@@ -24,10 +81,13 @@ export function rowToServer(row: ServerEntity): ServerPayload {
description: row.description ?? undefined, description: row.description ?? undefined,
ownerId: row.ownerId, ownerId: row.ownerId,
ownerPublicKey: row.ownerPublicKey, ownerPublicKey: row.ownerPublicKey,
hasPassword: !!row.passwordHash,
passwordHash: row.passwordHash ?? undefined,
isPrivate: !!row.isPrivate, isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers, maxUsers: row.maxUsers,
currentUsers: row.currentUsers, currentUsers: row.currentUsers,
tags: JSON.parse(row.tags || '[]'), tags: parseStringArray(row.tags),
channels: parseServerChannels(row.channels),
createdAt: row.createdAt, createdAt: row.createdAt,
lastSeen: row.lastSeen lastSeen: row.lastSeen
}; };

View File

@@ -28,16 +28,28 @@ export interface AuthUserPayload {
createdAt: number; createdAt: number;
} }
export type ServerChannelType = 'text' | 'voice';
export interface ServerChannelPayload {
id: string;
name: string;
type: ServerChannelType;
position: number;
}
export interface ServerPayload { export interface ServerPayload {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
ownerId: string; ownerId: string;
ownerPublicKey: string; ownerPublicKey: string;
hasPassword?: boolean;
passwordHash?: string | null;
isPrivate: boolean; isPrivate: boolean;
maxUsers: number; maxUsers: number;
currentUsers: number; currentUsers: number;
tags: string[]; tags: string[];
channels: ServerChannelPayload[];
createdAt: number; createdAt: number;
lastSeen: number; lastSeen: number;
} }

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;
@@ -33,6 +36,9 @@ export class ServerEntity {
@Column('text', { default: '[]' }) @Column('text', { default: '[]' })
tags!: string; tags!: string;
@Column('text', { default: '[]' })
channels!: string;
@Column('integer') @Column('integer')
createdAt!: number; createdAt!: number;

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

@@ -25,6 +25,7 @@ export class InitialSchema1000000000000 implements MigrationInterface {
"maxUsers" INTEGER NOT NULL DEFAULT 0, "maxUsers" INTEGER NOT NULL DEFAULT 0,
"currentUsers" INTEGER NOT NULL DEFAULT 0, "currentUsers" INTEGER NOT NULL DEFAULT 0,
"tags" TEXT NOT NULL DEFAULT '[]', "tags" TEXT NOT NULL DEFAULT '[]',
"channels" TEXT NOT NULL DEFAULT '[]',
"createdAt" INTEGER NOT NULL, "createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL "lastSeen" INTEGER NOT NULL
) )

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

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ServerChannels1000000000002 implements MigrationInterface {
name = 'ServerChannels1000000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "channels"`);
}
}

View File

@@ -0,0 +1,119 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
interface LegacyServerRow {
id: string;
channels: string | null;
}
interface LegacyServerChannel {
id: string;
name: string;
type: 'text' | 'voice';
position: number;
}
function normalizeLegacyChannels(raw: string | null): LegacyServerChannel[] {
try {
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return parsed
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
.map((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = type ? `${type}:${name.toLocaleLowerCase()}` : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return null;
}
seenIds.add(id);
seenNames.add(nameKey);
return {
id,
name,
type,
position
} satisfies LegacyServerChannel;
})
.filter((channel): channel is LegacyServerChannel => !!channel);
} catch {
return [];
}
}
function shouldRestoreLegacyVoiceGeneral(channels: LegacyServerChannel[]): boolean {
const hasTextGeneral = channels.some(
(channel) => channel.type === 'text' && (channel.id === 'general' || channel.name.toLocaleLowerCase() === 'general')
);
const hasVoiceAfk = channels.some(
(channel) => channel.type === 'voice' && (channel.id === 'vc-afk' || channel.name.toLocaleLowerCase() === 'afk')
);
const hasVoiceGeneral = channels.some(
(channel) => channel.type === 'voice' && (channel.id === 'vc-general' || channel.name.toLocaleLowerCase() === 'general')
);
return hasTextGeneral && hasVoiceAfk && !hasVoiceGeneral;
}
function repairLegacyVoiceChannels(channels: LegacyServerChannel[]): LegacyServerChannel[] {
if (!shouldRestoreLegacyVoiceGeneral(channels)) {
return channels;
}
const textChannels = channels.filter((channel) => channel.type === 'text');
const voiceChannels = channels.filter((channel) => channel.type === 'voice');
const repairedVoiceChannels = [
{
id: 'vc-general',
name: 'General',
type: 'voice' as const,
position: 0
},
...voiceChannels
].map((channel, index) => ({
...channel,
position: index
}));
return [
...textChannels,
...repairedVoiceChannels
];
}
export class RepairLegacyVoiceChannels1000000000003 implements MigrationInterface {
name = 'RepairLegacyVoiceChannels1000000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
const rows = await queryRunner.query(`SELECT "id", "channels" FROM "servers"`) as LegacyServerRow[];
for (const row of rows) {
const channels = normalizeLegacyChannels(row.channels);
const repaired = repairLegacyVoiceChannels(channels);
if (JSON.stringify(repaired) === JSON.stringify(channels)) {
continue;
}
await queryRunner.query(
`UPDATE "servers" SET "channels" = ? WHERE "id" = ?`,
[JSON.stringify(repaired), row.id]
);
}
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Forward-only data repair migration.
}
}

View File

@@ -1,3 +1,11 @@
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema'; import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
export const serverMigrations = [InitialSchema1000000000000]; export const serverMigrations = [
InitialSchema1000000000000,
ServerAccessControl1000000000001,
ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003
];

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,134 @@
import { Router } from 'express'; import { Response, Router } from 'express';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { ServerPayload, JoinRequestPayload } from '../cqrs/types'; import {
ServerChannelPayload,
ServerPayload
} from '../cqrs/types';
import { import {
getAllPublicServers, getAllPublicServers,
getServerById, getServerById,
getUserById, getUserById,
upsertServer, upsertServer,
deleteServer, deleteServer,
createJoinRequest,
getPendingRequestsForServer getPendingRequestsForServer
} from '../cqrs'; } from '../cqrs';
import { notifyServerOwner } from '../websocket/broadcast'; import {
banServerUser,
buildSignalingUrl,
createServerInvite,
joinServerWithAccess,
leaveServerUser,
passwordHashForInput,
ServerAccessError,
kickServerUser,
ensureServerMembership,
unbanServerUser
} from '../services/server-access.service';
import {
buildAppInviteUrl,
buildBrowserInviteUrl,
buildInviteUrl,
getRequestOrigin
} from './invite-utils';
const router = Router(); const router = Router();
async function enrichServer(server: ServerPayload) { function normalizeRole(role: unknown): string | null {
return typeof role === 'string' ? role.trim().toLowerCase() : null;
}
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${name.toLocaleLowerCase()}`;
}
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
return !!role && allowedRoles.includes(role);
}
function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const seenNames = new Set<string>();
const channels: ServerChannelPayload[] = [];
for (const [index, channel] of value.entries()) {
if (!channel || typeof channel !== 'object') {
continue;
}
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seen.has(id) || seenNames.has(nameKey)) {
continue;
}
seen.add(id);
seenNames.add(nameKey);
channels.push({
id,
name,
type,
position
});
}
return channels;
}
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
const owner = await getUserById(server.ownerId); const owner = await getUserById(server.ownerId);
const { passwordHash, ...publicServer } = server;
return { return {
...server, ...publicServer,
hasPassword: server.hasPassword ?? !!passwordHash,
ownerName: owner?.displayName, ownerName: owner?.displayName,
sourceUrl,
userCount: server.currentUsers userCount: server.currentUsers
}; };
} }
function sendAccessError(error: unknown, res: Response) {
if (error instanceof ServerAccessError) {
res.status(error.status).json({ error: error.message, errorCode: error.code });
return;
}
console.error('Unhandled server access error:', error);
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
}
async function buildInviteResponse(invite: {
id: string;
createdAt: number;
expiresAt: number;
createdBy: string;
createdByDisplayName: string | null;
serverId: string;
}, server: ServerPayload, signalOrigin: string) {
return {
id: invite.id,
serverId: invite.serverId,
createdAt: invite.createdAt,
expiresAt: invite.expiresAt,
inviteUrl: buildInviteUrl(signalOrigin, invite.id),
browserUrl: buildBrowserInviteUrl(signalOrigin, invite.id),
appUrl: buildAppInviteUrl(signalOrigin, invite.id),
sourceUrl: signalOrigin,
createdBy: invite.createdBy,
createdByDisplayName: invite.createdByDisplayName ?? undefined,
isExpired: invite.expiresAt <= Date.now(),
server: await enrichServer(server, signalOrigin)
};
}
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const { q, tags, limit = 20, offset = 0 } = req.query; const { q, tags, limit = 20, offset = 0 } = req.query;
@@ -54,45 +159,254 @@ router.get('/', async (req, res) => {
}); });
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body; const {
id: clientId,
name,
description,
ownerId,
ownerPublicKey,
isPrivate,
maxUsers,
password,
tags,
channels
} = req.body;
if (!name || !ownerId || !ownerPublicKey) if (!name || !ownerId || !ownerPublicKey)
return res.status(400).json({ error: 'Missing required fields' }); return res.status(400).json({ error: 'Missing required fields' });
const passwordHash = passwordHashForInput(password);
const server: ServerPayload = { const server: ServerPayload = {
id: clientId || uuidv4(), id: clientId || uuidv4(),
name, name,
description, description,
ownerId, ownerId,
ownerPublicKey, ownerPublicKey,
hasPassword: !!passwordHash,
passwordHash,
isPrivate: isPrivate ?? false, isPrivate: isPrivate ?? false,
maxUsers: maxUsers ?? 0, maxUsers: maxUsers ?? 0,
currentUsers: 0, currentUsers: 0,
tags: tags ?? [], tags: tags ?? [],
channels: normalizeServerChannels(channels),
createdAt: Date.now(), createdAt: Date.now(),
lastSeen: Date.now() lastSeen: Date.now()
}; };
await upsertServer(server); await upsertServer(server);
res.status(201).json(server); await ensureServerMembership(server.id, ownerId);
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
}); });
router.put('/:id', async (req, res) => { router.put('/:id', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { currentOwnerId, ...updates } = req.body; const {
currentOwnerId,
actingRole,
password,
hasPassword: _ignoredHasPassword,
passwordHash: _ignoredPasswordHash,
channels,
...updates
} = req.body;
const existing = await getServerById(id); const existing = await getServerById(id);
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId; const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
const normalizedRole = normalizeRole(actingRole);
if (!existing) if (!existing)
return res.status(404).json({ error: 'Server not found' }); return res.status(404).json({ error: 'Server not found' });
if (existing.ownerId !== authenticatedOwnerId) if (
existing.ownerId !== authenticatedOwnerId &&
!isAllowedRole(normalizedRole, ['host', 'admin'])
) {
return res.status(403).json({ error: 'Not authorized' }); return res.status(403).json({ error: 'Not authorized' });
}
const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() }; const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
const hasChannelsUpdate = Object.prototype.hasOwnProperty.call(req.body, 'channels');
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
const server: ServerPayload = {
...existing,
...updates,
channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels,
hasPassword: !!nextPasswordHash,
passwordHash: nextPasswordHash,
lastSeen: Date.now()
};
await upsertServer(server); await upsertServer(server);
res.json(server); res.json(await enrichServer(server, getRequestOrigin(req)));
});
router.post('/:id/join', async (req, res) => {
const { id: serverId } = req.params;
const { userId, password, inviteId } = req.body;
if (!userId) {
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
}
try {
const result = await joinServerWithAccess({
serverId,
userId: String(userId),
password: typeof password === 'string' ? password : undefined,
inviteId: typeof inviteId === 'string' ? inviteId : undefined
});
const origin = getRequestOrigin(req);
res.json({
success: true,
signalingUrl: buildSignalingUrl(origin),
joinedBefore: result.joinedBefore,
via: result.via,
server: await enrichServer(result.server, origin)
});
} catch (error) {
sendAccessError(error, res);
}
});
router.post('/:id/invites', async (req, res) => {
const { id: serverId } = req.params;
const { requesterUserId, requesterDisplayName } = req.body;
if (!requesterUserId) {
return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' });
}
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
try {
const invite = await createServerInvite(
serverId,
String(requesterUserId),
typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined
);
res.status(201).json(await buildInviteResponse(invite, server, getRequestOrigin(req)));
} catch (error) {
sendAccessError(error, res);
}
});
router.post('/:id/moderation/kick', async (req, res) => {
const { id: serverId } = req.params;
const { actorUserId, actorRole, targetUserId } = req.body;
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
if (!targetUserId) {
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
}
if (
server.ownerId !== actorUserId &&
!isAllowedRole(normalizeRole(actorRole), [
'host',
'admin',
'moderator'
])
) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
await kickServerUser(serverId, String(targetUserId));
res.json({ ok: true });
});
router.post('/:id/moderation/ban', async (req, res) => {
const { id: serverId } = req.params;
const { actorUserId, actorRole, targetUserId, banId, displayName, reason, expiresAt } = req.body;
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
if (!targetUserId) {
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
}
if (
server.ownerId !== actorUserId &&
!isAllowedRole(normalizeRole(actorRole), [
'host',
'admin',
'moderator'
])
) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
await banServerUser({
serverId,
userId: String(targetUserId),
banId: typeof banId === 'string' ? banId : undefined,
bannedBy: String(actorUserId || ''),
displayName: typeof displayName === 'string' ? displayName : undefined,
reason: typeof reason === 'string' ? reason : undefined,
expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined
});
res.json({ ok: true });
});
router.post('/:id/moderation/unban', async (req, res) => {
const { id: serverId } = req.params;
const { actorUserId, actorRole, banId, targetUserId } = req.body;
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
if (
server.ownerId !== actorUserId &&
!isAllowedRole(normalizeRole(actorRole), [
'host',
'admin',
'moderator'
])
) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
await unbanServerUser({
serverId,
banId: typeof banId === 'string' ? banId : undefined,
userId: typeof targetUserId === 'string' ? targetUserId : undefined
});
res.json({ ok: true });
});
router.post('/:id/leave', async (req, res) => {
const { id: serverId } = req.params;
const { userId } = req.body;
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
if (!userId) {
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
}
await leaveServerUser(serverId, String(userId));
res.json({ ok: true });
}); });
router.post('/:id/heartbeat', async (req, res) => { router.post('/:id/heartbeat', async (req, res) => {
@@ -128,32 +442,6 @@ router.delete('/:id', async (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
router.post('/:id/join', async (req, res) => {
const { id: serverId } = req.params;
const { userId, userPublicKey, displayName } = req.body;
const server = await getServerById(serverId);
if (!server)
return res.status(404).json({ error: 'Server not found' });
const request: JoinRequestPayload = {
id: uuidv4(),
serverId,
userId,
userPublicKey,
displayName,
status: server.isPrivate ? 'pending' : 'approved',
createdAt: Date.now()
};
await createJoinRequest(request);
if (server.isPrivate)
notifyServerOwner(server.ownerId, { type: 'join_request', request });
res.status(201).json(request);
});
router.get('/:id/requests', async (req, res) => { router.get('/:id/requests', async (req, res) => {
const { id: serverId } = req.params; const { id: serverId } = req.params;
const { ownerId } = req.query; const { ownerId } = req.query;
@@ -170,4 +458,15 @@ router.get('/:id/requests', async (req, res) => {
res.json({ requests }); res.json({ requests });
}); });
router.get('/:id', async (req, res) => {
const { id } = req.params;
const server = await getServerById(id);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
res.json(await enrichServer(server, getRequestOrigin(req)));
});
export default router; export default router;

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) .filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
.map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' })); .map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users })); user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
} }
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
user.oderId = String(message['oderId'] || connectionId); user.oderId = String(message['oderId'] || connectionId);
user.displayName = String(message['displayName'] || 'Anonymous'); user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`); console.log(`User identified: ${user.displayName} (${user.oderId})`);
} }
function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
const sid = String(message['serverId']); const sid = String(message['serverId']);
if (!sid)
return;
const authorization = await authorizeWebSocketJoin(sid, user.oderId);
if (!authorization.allowed) {
user.ws.send(JSON.stringify({
type: 'access_denied',
serverId: sid,
reason: authorization.reason
}));
return;
}
const isNew = !user.serverIds.has(sid); const isNew = !user.serverIds.has(sid);
user.serverIds.add(sid); user.serverIds.add(sid);
user.viewedServerId = sid; user.viewedServerId = sid;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`); console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} (new=${isNew})`);
sendServerUsers(user, sid); sendServerUsers(user, sid);
@@ -38,7 +61,7 @@ function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId:
broadcastToServer(sid, { broadcastToServer(sid, {
type: 'user_joined', type: 'user_joined',
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName ?? 'Anonymous', displayName: normalizeDisplayName(user.displayName),
serverId: sid serverId: sid
}, user.oderId); }, user.oderId);
} }
@@ -49,7 +72,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
user.viewedServerId = viewSid; user.viewedServerId = viewSid;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`); console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
sendServerUsers(user, viewSid); sendServerUsers(user, viewSid);
} }
@@ -70,8 +93,9 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
broadcastToServer(leaveSid, { broadcastToServer(leaveSid, {
type: 'user_left', type: 'user_left',
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName ?? 'Anonymous', displayName: normalizeDisplayName(user.displayName),
serverId: leaveSid serverId: leaveSid,
serverIds: Array.from(user.serverIds)
}, user.oderId); }, user.oderId);
} }
@@ -110,18 +134,22 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
function handleTyping(user: ConnectedUser, message: WsMessage): void { function handleTyping(user: ConnectedUser, message: WsMessage): void {
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim()
? message['channelId'].trim()
: 'general';
if (typingSid && user.serverIds.has(typingSid)) { if (typingSid && user.serverIds.has(typingSid)) {
broadcastToServer(typingSid, { broadcastToServer(typingSid, {
type: 'user_typing', type: 'user_typing',
serverId: typingSid, serverId: typingSid,
channelId,
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName displayName: user.displayName
}, user.oderId); }, user.oderId);
} }
} }
export function handleWebSocketMessage(connectionId: string, message: WsMessage): void { export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
const user = connectedUsers.get(connectionId); const user = connectedUsers.get(connectionId);
if (!user) if (!user)
@@ -133,7 +161,7 @@ export function handleWebSocketMessage(connectionId: string, message: WsMessage)
break; break;
case 'join_server': case 'join_server':
handleJoinServer(user, message, connectionId); await handleJoinServer(user, message, connectionId);
break; break;
case 'view_server': case 'view_server':

View File

@@ -25,7 +25,8 @@ function removeDeadConnection(connectionId: string): void {
type: 'user_left', type: 'user_left',
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName, displayName: user.displayName,
serverId: sid serverId: sid,
serverIds: []
}, user.oderId); }, user.oderId);
}); });
@@ -77,11 +78,11 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
} }
}); });
ws.on('message', (data) => { 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);
} }

View File

@@ -1,313 +0,0 @@
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
export type ChannelType = 'text' | 'voice';
export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
export interface User {
id: string;
oderId: string;
username: string;
displayName: string;
avatarUrl?: string;
status: UserStatus;
role: UserRole;
joinedAt: number;
peerId?: string;
isOnline?: boolean;
isAdmin?: boolean;
isRoomOwner?: boolean;
voiceState?: VoiceState;
screenShareState?: ScreenShareState;
}
export interface RoomMember {
id: string;
oderId?: string;
username: string;
displayName: string;
avatarUrl?: string;
role: UserRole;
joinedAt: number;
lastSeenAt: number;
}
export interface Channel {
id: string;
name: string;
type: ChannelType;
position: number;
}
export interface Message {
id: string;
roomId: string;
channelId?: string;
senderId: string;
senderName: string;
content: string;
timestamp: number;
editedAt?: number;
reactions: Reaction[];
isDeleted: boolean;
replyToId?: string;
}
export interface Reaction {
id: string;
messageId: string;
oderId: string;
userId: string;
emoji: string;
timestamp: number;
}
export interface Room {
id: string;
name: string;
description?: string;
topic?: string;
hostId: string;
password?: string;
isPrivate: boolean;
createdAt: number;
userCount: number;
maxUsers?: number;
icon?: string;
iconUpdatedAt?: number;
permissions?: RoomPermissions;
channels?: Channel[];
members?: RoomMember[];
}
export interface RoomSettings {
name: string;
description?: string;
topic?: string;
isPrivate: boolean;
password?: string;
maxUsers?: number;
rules?: string[];
}
export interface RoomPermissions {
adminsManageRooms?: boolean;
moderatorsManageRooms?: boolean;
adminsManageIcon?: boolean;
moderatorsManageIcon?: boolean;
allowVoice?: boolean;
allowScreenShare?: boolean;
allowFileUploads?: boolean;
slowModeInterval?: number;
}
export interface BanEntry {
oderId: string;
userId: string;
roomId: string;
bannedBy: string;
displayName?: string;
reason?: string;
expiresAt?: number;
timestamp: number;
}
export interface PeerConnection {
peerId: string;
userId: string;
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
dataChannel?: RTCDataChannel;
connection?: RTCPeerConnection;
}
export interface VoiceState {
isConnected: boolean;
isMuted: boolean;
isDeafened: boolean;
isSpeaking: boolean;
isMutedByAdmin?: boolean;
volume?: number;
roomId?: string;
serverId?: string;
}
export interface ScreenShareState {
isSharing: boolean;
streamId?: string;
sourceId?: string;
sourceName?: string;
}
export type SignalingMessageType =
| 'offer'
| 'answer'
| 'ice-candidate'
| 'join'
| 'leave'
| 'chat'
| 'state-sync'
| 'kick'
| 'ban'
| 'host-change'
| 'room-update';
export interface SignalingMessage {
type: SignalingMessageType;
from: string;
to?: string;
payload: unknown;
timestamp: number;
}
export type ChatEventType =
| 'message'
| 'chat-message'
| 'edit'
| 'message-edited'
| 'delete'
| 'message-deleted'
| 'reaction'
| 'reaction-added'
| 'reaction-removed'
| 'kick'
| 'ban'
| 'room-deleted'
| 'host-change'
| 'room-settings-update'
| 'voice-state'
| 'chat-inventory-request'
| 'chat-inventory'
| 'chat-sync-request-ids'
| 'chat-sync-batch'
| 'chat-sync-summary'
| 'chat-sync-request'
| 'chat-sync-full'
| 'file-announce'
| 'file-chunk'
| 'file-request'
| 'file-cancel'
| 'file-not-found'
| 'member-roster-request'
| 'member-roster'
| 'member-leave'
| 'voice-state-request'
| 'state-request'
| 'screen-state'
| 'screen-share-request'
| 'screen-share-stop'
| 'role-change'
| 'room-permissions-update'
| 'server-icon-summary'
| 'server-icon-request'
| 'server-icon-full'
| 'server-icon-update'
| 'server-state-request'
| 'server-state-full'
| 'unban'
| 'channels-update';
export interface ChatInventoryItem {
id: string;
ts: number;
rc: number;
ac?: number;
}
export interface ChatAttachmentAnnouncement {
id: string;
filename: string;
size: number;
mime: string;
isImage: boolean;
uploaderPeerId?: string;
}
export interface ChatAttachmentMeta extends ChatAttachmentAnnouncement {
messageId: string;
filePath?: string;
savedPath?: string;
}
/** Optional fields depend on `type`. */
export interface ChatEvent {
type: ChatEventType;
fromPeerId?: string;
messageId?: string;
message?: Message;
reaction?: Reaction;
data?: string | Partial<Message>;
timestamp?: number;
targetUserId?: string;
roomId?: string;
items?: ChatInventoryItem[];
ids?: string[];
messages?: Message[];
attachments?: Record<string, ChatAttachmentMeta[]>;
total?: number;
index?: number;
count?: number;
lastUpdated?: number;
file?: ChatAttachmentAnnouncement;
fileId?: string;
hostId?: string;
hostOderId?: string;
previousHostId?: string;
previousHostOderId?: string;
kickedBy?: string;
bannedBy?: string;
content?: string;
editedAt?: number;
deletedAt?: number;
deletedBy?: string;
oderId?: string;
displayName?: string;
emoji?: string;
reason?: string;
settings?: RoomSettings;
permissions?: Partial<RoomPermissions>;
voiceState?: Partial<VoiceState>;
isScreenSharing?: boolean;
icon?: string;
iconUpdatedAt?: number;
role?: UserRole;
room?: Room;
channels?: Channel[];
members?: RoomMember[];
ban?: BanEntry;
bans?: BanEntry[];
banOderId?: string;
expiresAt?: number;
}
export interface ServerInfo {
id: string;
name: string;
description?: string;
topic?: string;
hostName: string;
ownerId?: string;
ownerName?: string;
ownerPublicKey?: string;
userCount: number;
maxUsers: number;
isPrivate: boolean;
tags?: string[];
createdAt: number;
sourceId?: string;
sourceName?: string;
}
export interface JoinRequest {
roomId: string;
userId: string;
username: string;
}
export interface AppState {
currentUser: User | null;
currentRoom: Room | null;
isConnecting: boolean;
error: string | null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +0,0 @@
export * from './notification-audio.service';
export * from './platform.service';
export * from './browser-database.service';
export * from './electron-database.service';
export * from './database.service';
export * from '../models/debugging.models';
export * from './debugging/debugging.service';
export * from './webrtc.service';
export * from './server-directory.service';
export * from './klipy.service';
export * from './voice-session.service';
export * from './voice-activity.service';
export * from './external-link.service';
export * from './settings-modal.service';

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: 'Default 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,993 +0,0 @@
/**
* WebRTCService - thin Angular service that composes specialised managers.
*
* Each concern lives in its own file under `./webrtc/`:
* • SignalingManager - WebSocket lifecycle & reconnection
* • PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels
* • MediaManager - mic voice, mute, deafen, bitrate
* • ScreenShareManager - screen capture & mixed audio
* • WebRTCLogger - debug / diagnostic logging
*
* This file wires them together and exposes a public API that is
* identical to the old monolithic service so consumers don't change.
*/
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars */
import {
Injectable,
signal,
computed,
inject,
OnDestroy
} from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { SignalingMessage, ChatEvent } from '../models/index';
import { TimeSyncService } from './time-sync.service';
import { DebuggingService } from './debugging.service';
import { ScreenShareSourcePickerService } from './screen-share-source-picker.service';
import {
SignalingManager,
PeerConnectionManager,
MediaManager,
ScreenShareManager,
WebRTCLogger,
IdentifyCredentials,
JoinedServerInfo,
VoiceStateSnapshot,
LatencyProfile,
ScreenShareStartOptions,
SIGNALING_TYPE_IDENTIFY,
SIGNALING_TYPE_JOIN_SERVER,
SIGNALING_TYPE_VIEW_SERVER,
SIGNALING_TYPE_LEAVE_SERVER,
SIGNALING_TYPE_OFFER,
SIGNALING_TYPE_ANSWER,
SIGNALING_TYPE_ICE_CANDIDATE,
SIGNALING_TYPE_CONNECTED,
SIGNALING_TYPE_SERVER_USERS,
SIGNALING_TYPE_USER_JOINED,
SIGNALING_TYPE_USER_LEFT,
DEFAULT_DISPLAY_NAME,
P2P_TYPE_SCREEN_SHARE_REQUEST,
P2P_TYPE_SCREEN_SHARE_STOP,
P2P_TYPE_VOICE_STATE,
P2P_TYPE_SCREEN_STATE
} from './webrtc';
interface SignalingUserSummary {
oderId: string;
displayName: string;
}
interface IncomingSignalingPayload {
sdp?: RTCSessionDescriptionInit;
candidate?: RTCIceCandidateInit;
}
type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payload'> & {
type: string;
payload?: IncomingSignalingPayload;
oderId?: string;
serverTime?: number;
serverId?: string;
users?: SignalingUserSummary[];
displayName?: string;
fromUserId?: string;
};
@Injectable({
providedIn: 'root'
})
export class WebRTCService implements OnDestroy {
private readonly timeSync = inject(TimeSyncService);
private readonly debugging = inject(DebuggingService);
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
private lastIdentifyCredentials: IdentifyCredentials | null = null;
private lastJoinedServer: JoinedServerInfo | null = null;
private readonly memberServerIds = new Set<string>();
private activeServerId: string | null = null;
/** The server ID where voice is currently active, or `null` when not in voice. */
private voiceServerId: string | null = null;
/** Maps each remote peer ID to the server they were discovered from. */
private readonly peerServerMap = new Map<string, string>();
private readonly serviceDestroyed$ = new Subject<void>();
private remoteScreenShareRequestsEnabled = false;
private readonly desiredRemoteScreenSharePeers = new Set<string>();
private readonly activeRemoteScreenSharePeers = new Set<string>();
private readonly _localPeerId = signal<string>(uuidv4());
private readonly _isSignalingConnected = signal(false);
private readonly _isVoiceConnected = signal(false);
private readonly _connectedPeers = signal<string[]>([]);
private readonly _isMuted = signal(false);
private readonly _isDeafened = signal(false);
private readonly _isScreenSharing = signal(false);
private readonly _isNoiseReductionEnabled = signal(false);
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
private readonly _isScreenShareRemotePlaybackSuppressed = signal(false);
private readonly _forceDefaultRemotePlaybackOutput = signal(false);
private readonly _hasConnectionError = signal(false);
private readonly _connectionErrorMessage = signal<string | null>(null);
private readonly _hasEverConnected = signal(false);
/**
* Reactive snapshot of per-peer latencies (ms).
* Updated whenever a ping/pong round-trip completes.
* Keyed by remote peer (oderId).
*/
private readonly _peerLatencies = signal<ReadonlyMap<string, number>>(new Map());
// Public computed signals (unchanged external API)
readonly peerId = computed(() => this._localPeerId());
readonly isConnected = computed(() => this._isSignalingConnected());
readonly hasEverConnected = computed(() => this._hasEverConnected());
readonly isVoiceConnected = computed(() => this._isVoiceConnected());
readonly connectedPeers = computed(() => this._connectedPeers());
readonly isMuted = computed(() => this._isMuted());
readonly isDeafened = computed(() => this._isDeafened());
readonly isScreenSharing = computed(() => this._isScreenSharing());
readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled());
readonly screenStream = computed(() => this._screenStreamSignal());
readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed());
readonly forceDefaultRemotePlaybackOutput = computed(() => this._forceDefaultRemotePlaybackOutput());
readonly hasConnectionError = computed(() => this._hasConnectionError());
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
readonly shouldShowConnectionError = computed(() => {
if (!this._hasConnectionError())
return false;
if (this._isVoiceConnected() && this._connectedPeers().length > 0)
return false;
return true;
});
/** Per-peer latency map (ms). Read via `peerLatencies()`. */
readonly peerLatencies = computed(() => this._peerLatencies());
private readonly signalingMessage$ = new Subject<IncomingSignalingMessage>();
readonly onSignalingMessage = this.signalingMessage$.asObservable();
// Delegates to managers
get onMessageReceived(): Observable<ChatEvent> {
return this.peerManager.messageReceived$.asObservable();
}
get onPeerConnected(): Observable<string> {
return this.peerManager.peerConnected$.asObservable();
}
get onPeerDisconnected(): Observable<string> {
return this.peerManager.peerDisconnected$.asObservable();
}
get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> {
return this.peerManager.remoteStream$.asObservable();
}
get onVoiceConnected(): Observable<void> {
return this.mediaManager.voiceConnected$.asObservable();
}
private readonly signalingManager: SignalingManager;
private readonly peerManager: PeerConnectionManager;
private readonly mediaManager: MediaManager;
private readonly screenShareManager: ScreenShareManager;
constructor() {
// Create managers with null callbacks first to break circular initialization
this.signalingManager = new SignalingManager(
this.logger,
() => this.lastIdentifyCredentials,
() => this.lastJoinedServer,
() => this.memberServerIds
);
this.peerManager = new PeerConnectionManager(this.logger, null!);
this.mediaManager = new MediaManager(this.logger, null!);
this.screenShareManager = new ScreenShareManager(this.logger, null!);
// Now wire up cross-references (all managers are instantiated)
this.peerManager.setCallbacks({
sendRawMessage: (msg: Record<string, unknown>) => this.signalingManager.sendRawMessage(msg),
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
isSignalingConnected: (): boolean => this._isSignalingConnected(),
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
getIdentifyCredentials: (): IdentifyCredentials | null => this.lastIdentifyCredentials,
getLocalPeerId: (): string => this._localPeerId(),
isScreenSharingActive: (): boolean => this._isScreenSharing()
});
this.mediaManager.setCallbacks({
getActivePeers: (): Map<string, import('./webrtc').PeerData> =>
this.peerManager.activePeerConnections,
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
broadcastMessage: (event: ChatEvent): void => this.peerManager.broadcastMessage(event),
getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(),
getIdentifyDisplayName: (): string =>
this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME
});
this.screenShareManager.setCallbacks({
getActivePeers: (): Map<string, import('./webrtc').PeerData> =>
this.peerManager.activePeerConnections,
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates(),
selectDesktopSource: async (sources, options) => await this.screenShareSourcePicker.open(
sources,
options.includeSystemAudio
),
updateLocalScreenShareState: (state): void => {
this._isScreenSharing.set(state.active);
this._screenStreamSignal.set(state.stream);
this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback);
this._forceDefaultRemotePlaybackOutput.set(state.forceDefaultRemotePlaybackOutput);
}
});
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;
}
/** The server ID currently being viewed / active, or `null`. */
get currentServerId(): string | null {
return this.activeServerId;
}
/**
* Send an identify message to the signaling server.
*
* The credentials are cached so they can be replayed after a reconnect.
*
* @param oderId - The user's unique order/peer ID.
* @param displayName - The user's display name.
*/
identify(oderId: string, displayName: string): void {
this.lastIdentifyCredentials = { oderId,
displayName };
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
oderId,
displayName });
}
/**
* Join a server (room) on the signaling server.
*
* @param roomId - The server / room ID to join.
* @param userId - The local user ID.
*/
joinRoom(roomId: string, userId: string): void {
this.lastJoinedServer = { serverId: roomId,
userId };
this.memberServerIds.add(roomId);
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
serverId: roomId });
}
/**
* Switch to a different server. If already a member, sends a view event;
* otherwise joins the server.
*
* @param serverId - The target server ID.
* @param userId - The local user ID.
*/
switchServer(serverId: string, userId: string): void {
this.lastJoinedServer = { serverId,
userId };
if (this.memberServerIds.has(serverId)) {
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
serverId });
this.logger.info('Viewed server (already joined)', {
serverId,
userId,
voiceConnected: this._isVoiceConnected()
});
} else {
this.memberServerIds.add(serverId);
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
serverId });
this.logger.info('Joined new server via switch', {
serverId,
userId,
voiceConnected: this._isVoiceConnected()
});
}
}
/**
* Leave one or all servers.
*
* If `serverId` is provided, leaves only that server.
* Otherwise leaves every joined server and performs a full cleanup.
*
* @param serverId - Optional server to leave; omit to leave all.
*/
leaveRoom(serverId?: string): void {
if (serverId) {
this.memberServerIds.delete(serverId);
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
serverId });
this.logger.info('Left server', { serverId });
if (this.memberServerIds.size === 0) {
this.fullCleanup();
}
return;
}
this.memberServerIds.forEach((sid) => {
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
serverId: sid });
});
this.memberServerIds.clear();
this.fullCleanup();
}
/**
* Check whether the local client has joined a given server.
*
* @param serverId - The server to check.
*/
hasJoinedServer(serverId: string): boolean {
return this.memberServerIds.has(serverId);
}
/** Returns a read-only set of all currently-joined server IDs. */
getJoinedServerIds(): ReadonlySet<string> {
return this.memberServerIds;
}
/**
* Broadcast a {@link ChatEvent} to every connected peer.
*
* @param event - The chat event to send.
*/
broadcastMessage(event: ChatEvent): void {
this.peerManager.broadcastMessage(event);
}
/**
* Send a {@link ChatEvent} to a specific peer.
*
* @param peerId - The target peer ID.
* @param event - The chat event to send.
*/
sendToPeer(peerId: string, event: ChatEvent): void {
this.peerManager.sendToPeer(peerId, event);
}
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
const nextDesiredPeers = new Set(
peerIds.filter((peerId): peerId is string => !!peerId)
);
if (!enabled) {
this.remoteScreenShareRequestsEnabled = false;
this.desiredRemoteScreenSharePeers.clear();
this.stopRemoteScreenShares([...this.activeRemoteScreenSharePeers]);
return;
}
this.remoteScreenShareRequestsEnabled = true;
for (const activePeerId of [...this.activeRemoteScreenSharePeers]) {
if (!nextDesiredPeers.has(activePeerId)) {
this.stopRemoteScreenShares([activePeerId]);
}
}
this.desiredRemoteScreenSharePeers.clear();
nextDesiredPeers.forEach((peerId) => this.desiredRemoteScreenSharePeers.add(peerId));
this.requestRemoteScreenShares([...nextDesiredPeers]);
}
/**
* Send a {@link ChatEvent} to a peer with back-pressure awareness.
*
* @param peerId - The target peer ID.
* @param event - The chat event to send.
*/
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
return this.peerManager.sendToPeerBuffered(peerId, event);
}
/** Returns an array of currently-connected peer IDs. */
getConnectedPeers(): string[] {
return this.peerManager.getConnectedPeerIds();
}
/**
* Get the composite remote {@link MediaStream} for a connected peer.
*
* @param peerId - The remote peer whose stream to retrieve.
* @returns The stream, or `null` if the peer has no active stream.
*/
getRemoteStream(peerId: string): MediaStream | null {
return this.peerManager.remotePeerStreams.get(peerId) ?? null;
}
/**
* Get the remote voice-only stream for a connected peer.
*
* @param peerId - The remote peer whose voice stream to retrieve.
* @returns The stream, or `null` if the peer has no active voice audio.
*/
getRemoteVoiceStream(peerId: string): MediaStream | null {
return this.peerManager.remotePeerVoiceStreams.get(peerId) ?? null;
}
/**
* Get the remote screen-share stream for a connected peer.
*
* This contains the screen video track and any audio track that belongs to
* the screen share itself, not the peer's normal voice-chat audio.
*
* @param peerId - The remote peer whose screen-share stream to retrieve.
* @returns The stream, or `null` if the peer has no active screen share.
*/
getRemoteScreenShareStream(peerId: string): MediaStream | null {
return this.peerManager.remotePeerScreenShareStreams.get(peerId) ?? null;
}
/**
* Get the current local media stream (microphone audio).
*
* @returns The local {@link MediaStream}, or `null` if voice is not active.
*/
getLocalStream(): MediaStream | null {
return this.mediaManager.getLocalStream();
}
/**
* Get the raw local microphone stream before gain / RNNoise processing.
*
* @returns The raw microphone {@link MediaStream}, or `null` if voice is not active.
*/
getRawMicStream(): MediaStream | null {
return this.mediaManager.getRawMicStream();
}
/**
* Request microphone access and start sending audio to all peers.
*
* @returns The captured local {@link MediaStream}.
*/
async enableVoice(): Promise<MediaStream> {
const stream = await this.mediaManager.enableVoice();
this.syncMediaSignals();
return stream;
}
/** Stop local voice capture and remove audio senders from peers. */
disableVoice(): void {
this.voiceServerId = null;
this.mediaManager.disableVoice();
this._isVoiceConnected.set(false);
}
/**
* Inject an externally-obtained media stream as the local voice source.
*
* @param stream - The media stream to use.
*/
async setLocalStream(stream: MediaStream): Promise<void> {
await this.mediaManager.setLocalStream(stream);
this.syncMediaSignals();
}
/**
* Toggle the local microphone mute state.
*
* @param muted - Explicit state; if omitted, the current state is toggled.
*/
toggleMute(muted?: boolean): void {
this.mediaManager.toggleMute(muted);
this._isMuted.set(this.mediaManager.getIsMicMuted());
}
/**
* Toggle self-deafen (suppress incoming audio playback).
*
* @param deafened - Explicit state; if omitted, the current state is toggled.
*/
toggleDeafen(deafened?: boolean): void {
this.mediaManager.toggleDeafen(deafened);
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
}
/**
* Toggle RNNoise noise reduction on the local microphone.
*
* When enabled, the raw mic audio is routed through an AudioWorklet
* that applies neural-network noise suppression before being sent
* to peers.
*
* @param enabled - Explicit state; if omitted, the current state is toggled.
*/
async toggleNoiseReduction(enabled?: boolean): Promise<void> {
await this.mediaManager.toggleNoiseReduction(enabled);
this._isNoiseReductionEnabled.set(this.mediaManager.getIsNoiseReductionEnabled());
}
/**
* Set the output volume for remote audio playback.
*
* @param volume - Normalised volume (0-1).
*/
setOutputVolume(volume: number): void {
this.mediaManager.setOutputVolume(volume);
}
/**
* Set the input (microphone) volume.
*
* Adjusts a Web Audio GainNode on the local mic stream so the level
* sent to peers changes in real time without renegotiation.
*
* @param volume - Normalised volume (0-1).
*/
setInputVolume(volume: number): void {
this.mediaManager.setInputVolume(volume);
}
/**
* Set the maximum audio bitrate for all peer connections.
*
* @param kbps - Target bitrate in kilobits per second.
*/
async setAudioBitrate(kbps: number): Promise<void> {
return this.mediaManager.setAudioBitrate(kbps);
}
/**
* Apply a predefined latency profile that maps to a specific bitrate.
*
* @param profile - One of `'low'`, `'balanced'`, or `'high'`.
*/
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
return this.mediaManager.setLatencyProfile(profile);
}
/**
* Start broadcasting voice-presence heartbeats to all peers.
*
* Also marks the given server as the active voice server and closes
* any peer connections that belong to other servers so that audio
* is isolated to the correct voice channel.
*
* @param roomId - The voice channel room ID.
* @param serverId - The voice channel server ID.
*/
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
if (serverId) {
this.voiceServerId = serverId;
}
this.mediaManager.startVoiceHeartbeat(roomId, serverId);
}
/** Stop the voice-presence heartbeat. */
stopVoiceHeartbeat(): void {
this.mediaManager.stopVoiceHeartbeat();
}
/**
* Start sharing the screen (or a window) with all connected peers.
*
* @param options - Screen-share capture options.
* @returns The screen-capture {@link MediaStream}.
*/
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
return await this.screenShareManager.startScreenShare(options);
}
/** Stop screen sharing and restore microphone audio on all peers. */
stopScreenShare(): void {
this.screenShareManager.stopScreenShare();
}
/** Disconnect from the signaling server and clean up all state. */
disconnect(): void {
this.voiceServerId = null;
this.peerServerMap.clear();
this.leaveRoom();
this.mediaManager.stopVoiceHeartbeat();
this.signalingManager.close();
this._isSignalingConnected.set(false);
this._hasEverConnected.set(false);
this._hasConnectionError.set(false);
this._connectionErrorMessage.set(null);
this.serviceDestroyed$.next();
}
/** Alias for {@link disconnect}. */
disconnectAll(): void {
this.disconnect();
}
private fullCleanup(): void {
this.voiceServerId = null;
this.peerServerMap.clear();
this.remoteScreenShareRequestsEnabled = false;
this.desiredRemoteScreenSharePeers.clear();
this.activeRemoteScreenSharePeers.clear();
this.peerManager.closeAllPeers();
this._connectedPeers.set([]);
this.mediaManager.disableVoice();
this._isVoiceConnected.set(false);
this.screenShareManager.stopScreenShare();
this._isScreenSharing.set(false);
this._screenStreamSignal.set(null);
this._isScreenShareRemotePlaybackSuppressed.set(false);
this._forceDefaultRemotePlaybackOutput.set(false);
}
/** Synchronise Angular signals from the MediaManager's internal state. */
private syncMediaSignals(): void {
this._isVoiceConnected.set(this.mediaManager.getIsVoiceActive());
this._isMuted.set(this.mediaManager.getIsMicMuted());
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
}
/** Returns true if a peer connection exists and its data channel is open. */
private isPeerHealthy(peer: import('./webrtc').PeerData | undefined): boolean {
if (!peer)
return false;
const connState = peer.connection?.connectionState;
const dcState = peer.dataChannel?.readyState;
return connState === 'connected' && dcState === 'open';
}
private handlePeerControlMessage(event: ChatEvent): void {
if (!event.fromPeerId) {
return;
}
if (event.type === P2P_TYPE_SCREEN_STATE && event.isScreenSharing === false) {
this.peerManager.clearRemoteScreenShareStream(event.fromPeerId);
return;
}
if (event.type === P2P_TYPE_SCREEN_SHARE_REQUEST) {
this.screenShareManager.requestScreenShareForPeer(event.fromPeerId);
return;
}
if (event.type === P2P_TYPE_SCREEN_SHARE_STOP) {
this.screenShareManager.stopScreenShareForPeer(event.fromPeerId);
}
}
private requestRemoteScreenShares(peerIds: string[]): void {
const connectedPeerIds = new Set(this.peerManager.getConnectedPeerIds());
for (const peerId of peerIds) {
if (!connectedPeerIds.has(peerId) || this.activeRemoteScreenSharePeers.has(peerId)) {
continue;
}
this.peerManager.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_REQUEST });
this.activeRemoteScreenSharePeers.add(peerId);
}
}
private stopRemoteScreenShares(peerIds: string[]): void {
const connectedPeerIds = new Set(this.peerManager.getConnectedPeerIds());
for (const peerId of peerIds) {
if (this.activeRemoteScreenSharePeers.has(peerId) && connectedPeerIds.has(peerId)) {
this.peerManager.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_STOP });
}
this.activeRemoteScreenSharePeers.delete(peerId);
this.peerManager.clearRemoteScreenShareStream(peerId);
}
}
ngOnDestroy(): void {
this.disconnect();
this.serviceDestroyed$.complete();
this.signalingManager.destroy();
this.peerManager.destroy();
this.mediaManager.destroy();
this.screenShareManager.destroy();
}
}

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

View File

@@ -1,80 +0,0 @@
export interface DesktopSource {
id: string;
name: string;
thumbnail: string;
}
export interface ElectronDesktopSourceSelection {
includeSystemAudio: boolean;
source: DesktopSource;
}
export interface ElectronDesktopCaptureResult {
includeSystemAudio: boolean;
stream: MediaStream;
}
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 ScreenShareElectronApi {
getSources?: () => Promise<DesktopSource[]>;
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;
}
export type ElectronDesktopVideoConstraint = MediaTrackConstraints & {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
maxWidth: number;
maxHeight: number;
maxFrameRate: number;
};
};
export type ElectronDesktopAudioConstraint = MediaTrackConstraints & {
mandatory: {
chromeMediaSource: 'desktop';
chromeMediaSourceId: string;
};
};
export interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints {
video: ElectronDesktopVideoConstraint;
audio?: false | ElectronDesktopAudioConstraint;
}
export type ScreenShareWindow = Window & {
electronAPI?: ScreenShareElectronApi;
};

View File

@@ -1,29 +0,0 @@
.chat-textarea {
--textarea-bg: hsl(40deg 3.7% 15.9% / 87%);
background: var(--textarea-bg);
height: 62px;
min-height: 62px;
max-height: 520px;
overflow-y: hidden;
resize: none;
transition: height 0.12s ease;
&.ctrl-resize {
resize: vertical;
}
}
.send-btn {
opacity: 0;
pointer-events: none;
transform: scale(0.85);
transition:
opacity 0.2s ease,
transform 0.2s ease;
&.visible {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
}

View File

@@ -1,80 +0,0 @@
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
<!-- Create button -->
<button
type="button"
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
title="Create Server"
(click)="createServer()"
>
<ng-icon
name="lucidePlus"
class="w-5 h-5"
/>
</button>
<!-- Saved servers icons -->
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
@for (room of visibleSavedRooms(); track room.id) {
<button
type="button"
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
[title]="room.name"
(click)="joinSavedRoom(room)"
(contextmenu)="openContextMenu($event, room)"
>
@if (room.icon) {
<img
[ngSrc]="room.icon"
[alt]="room.name"
class="w-full h-full object-cover"
/>
} @else {
<div class="w-full h-full flex items-center justify-center bg-secondary">
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
</div>
}
</button>
}
</div>
</nav>
<!-- Context menu -->
@if (showMenu()) {
<app-context-menu
[x]="menuX()"
[y]="menuY()"
(closed)="closeMenu()"
[width]="'w-44'"
>
<button
type="button"
(click)="openLeaveConfirm()"
class="context-menu-item"
>
Leave Server
</button>
</app-context-menu>
}
@if (showBannedDialog()) {
<app-confirm-dialog
title="Banned"
confirmLabel="OK"
cancelLabel="Close"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="closeBannedDialog()"
(cancelled)="closeBannedDialog()"
>
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
</app-confirm-dialog>
}
@if (showLeaveConfirm() && contextRoom()) {
<app-leave-server-dialog
[room]="contextRoom()!"
[currentUser]="currentUser() ?? null"
(confirmed)="confirmLeave($event)"
(cancelled)="cancelLeave()"
/>
}

View File

@@ -1,9 +0,0 @@
import { User } from '../../../core/models';
export interface ScreenShareWorkspaceStreamItem {
id: string;
peerKey: string;
user: User;
stream: MediaStream;
isLocal: boolean;
}

View File

@@ -1,4 +0,0 @@
export const environment = {
production: true,
defaultServerUrl: 'https://signal.toju.app'
};

View File

@@ -1,4 +0,0 @@
export const environment = {
production: false,
defaultServerUrl: 'https://46.59.68.77:3001'
};

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "$schema": "../node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "npm", "packageManager": "npm",
@@ -62,27 +62,28 @@
], ],
"styles": [ "styles": [
"src/styles.scss", "src/styles.scss",
"node_modules/prismjs/themes/prism-okaidia.css" "../node_modules/prismjs/themes/prism-okaidia.css"
], ],
"scripts": [ "scripts": [
"node_modules/prismjs/prism.js", "../node_modules/prismjs/prism.js",
"node_modules/prismjs/components/prism-markup.min.js", "../node_modules/prismjs/components/prism-markup.min.js",
"node_modules/prismjs/components/prism-clike.min.js", "../node_modules/prismjs/components/prism-clike.min.js",
"node_modules/prismjs/components/prism-javascript.min.js", "../node_modules/prismjs/components/prism-javascript.min.js",
"node_modules/prismjs/components/prism-typescript.min.js", "../node_modules/prismjs/components/prism-typescript.min.js",
"node_modules/prismjs/components/prism-css.min.js", "../node_modules/prismjs/components/prism-css.min.js",
"node_modules/prismjs/components/prism-scss.min.js", "../node_modules/prismjs/components/prism-scss.min.js",
"node_modules/prismjs/components/prism-json.min.js", "../node_modules/prismjs/components/prism-json.min.js",
"node_modules/prismjs/components/prism-bash.min.js", "../node_modules/prismjs/components/prism-bash.min.js",
"node_modules/prismjs/components/prism-markdown.min.js", "../node_modules/prismjs/components/prism-markdown.min.js",
"node_modules/prismjs/components/prism-yaml.min.js", "../node_modules/prismjs/components/prism-yaml.min.js",
"node_modules/prismjs/components/prism-python.min.js", "../node_modules/prismjs/components/prism-python.min.js",
"node_modules/prismjs/components/prism-csharp.min.js" "../node_modules/prismjs/components/prism-csharp.min.js"
], ],
"allowedCommonJsDependencies": [ "allowedCommonJsDependencies": [
"simple-peer", "simple-peer",
"uuid" "uuid"
] ],
"outputPath": "../dist/client"
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -96,7 +97,7 @@
{ {
"type": "initial", "type": "initial",
"maximumWarning": "1MB", "maximumWarning": "1MB",
"maximumError": "2MB" "maximumError": "2.1MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -13,6 +13,7 @@ import { routes } from './app.routes';
import { messagesReducer } from './store/messages/messages.reducer'; import { messagesReducer } from './store/messages/messages.reducer';
import { usersReducer } from './store/users/users.reducer'; import { usersReducer } from './store/users/users.reducer';
import { roomsReducer } from './store/rooms/rooms.reducer'; import { roomsReducer } from './store/rooms/rooms.reducer';
import { NotificationsEffects } from './domains/notifications';
import { MessagesEffects } from './store/messages/messages.effects'; import { MessagesEffects } from './store/messages/messages.effects';
import { MessagesSyncEffects } from './store/messages/messages-sync.effects'; import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
import { UsersEffects } from './store/users/users.effects'; import { UsersEffects } from './store/users/users.effects';
@@ -32,6 +33,7 @@ export const appConfig: ApplicationConfig = {
rooms: roomsReducer rooms: roomsReducer
}), }),
provideEffects([ provideEffects([
NotificationsEffects,
MessagesEffects, MessagesEffects,
MessagesSyncEffects, MessagesSyncEffects,
UsersEffects, UsersEffects,

View File

@@ -50,47 +50,6 @@
<app-floating-voice-controls /> <app-floating-voice-controls />
</div> </div>
@if (desktopUpdateState().serverBlocked) {
<div class="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 px-6 py-10 backdrop-blur-sm">
<div class="w-full max-w-xl rounded-2xl border border-red-500/30 bg-card p-6 shadow-2xl">
<h2 class="text-xl font-semibold text-foreground">Server update required</h2>
<p class="mt-3 text-sm text-muted-foreground">
{{ desktopUpdateState().serverBlockMessage || 'The connected server must be updated before this desktop app can continue.' }}
</p>
<div class="mt-5 grid gap-4 rounded-xl border border-border bg-secondary/20 p-4 text-sm text-muted-foreground sm:grid-cols-2">
<div>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Connected server</p>
<p class="mt-2 text-foreground">{{ desktopUpdateState().serverVersion || 'Not reported' }}</p>
</div>
<div>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Required minimum</p>
<p class="mt-2 text-foreground">{{ desktopUpdateState().minimumServerVersion || 'Unknown' }}</p>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<button
type="button"
(click)="refreshDesktopUpdateContext()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Retry
</button>
<button
type="button"
(click)="openNetworkSettings()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Open network settings
</button>
</div>
</div>
</div>
}
<!-- Unified Settings Modal --> <!-- Unified Settings Modal -->
<app-settings-modal /> <app-settings-modal />

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,16 +14,18 @@ import {
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { DatabaseService } from './core/services/database.service'; import { DatabaseService } from './infrastructure/persistence';
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service'; import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
import { ServerDirectoryService } from './core/services/server-directory.service'; import { ServerDirectoryFacade } from './domains/server-directory';
import { NotificationsFacade } from './domains/notifications';
import { TimeSyncService } from './core/services/time-sync.service'; import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionService } from './core/services/voice-session.service'; import { VoiceSessionFacade } from './domains/voice-session';
import { ExternalLinkService } from './core/services/external-link.service'; import { ExternalLinkService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.service'; import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { ServersRailComponent } from './features/servers/servers-rail.component'; import { ServersRailComponent } from './features/servers/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar.component'; import { TitleBarComponent } from './features/shell/title-bar.component';
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component'; import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component'; import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component'; import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component'; import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
@@ -50,7 +53,7 @@ import {
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrl: './app.scss'
}) })
export class App implements OnInit { export class App implements OnInit, OnDestroy {
store = inject(Store); store = inject(Store);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
desktopUpdates = inject(DesktopAppUpdateService); desktopUpdates = inject(DesktopAppUpdateService);
@@ -58,11 +61,14 @@ export class App implements OnInit {
private databaseService = inject(DatabaseService); private databaseService = inject(DatabaseService);
private router = inject(Router); private router = inject(Router);
private servers = inject(ServerDirectoryService); private servers = inject(ServerDirectoryFacade);
private notifications = inject(NotificationsFacade);
private settingsModal = inject(SettingsModalService); private settingsModal = inject(SettingsModalService);
private timeSync = inject(TimeSyncService); private timeSync = inject(TimeSyncService);
private voiceSession = inject(VoiceSessionService); private voiceSession = inject(VoiceSessionFacade);
private externalLinks = inject(ExternalLinkService); private externalLinks = inject(ExternalLinkService);
private electronBridge = inject(ElectronBridgeService);
private deepLinkCleanup: (() => void) | null = null;
@HostListener('document:click', ['$event']) @HostListener('document:click', ['$event'])
onGlobalLinkClick(evt: MouseEvent): void { onGlobalLinkClick(evt: MouseEvent): void {
@@ -80,6 +86,10 @@ export class App implements OnInit {
await this.timeSync.syncWithEndpoint(apiBase); await this.timeSync.syncWithEndpoint(apiBase);
} catch {} } catch {}
await this.notifications.initialize();
await this.setupDesktopDeepLinks();
this.store.dispatch(UsersActions.loadCurrentUser()); this.store.dispatch(UsersActions.loadCurrentUser());
this.store.dispatch(RoomsActions.loadRooms()); this.store.dispatch(RoomsActions.loadRooms());
@@ -87,8 +97,12 @@ export class App implements OnInit {
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID); const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
if (!currentUserId) { if (!currentUserId) {
if (this.router.url !== '/login' && this.router.url !== '/register') { if (!this.isPublicRoute(this.router.url)) {
this.router.navigate(['/login']).catch(() => {}); this.router.navigate(['/login'], {
queryParams: {
returnUrl: this.router.url
}
}).catch(() => {});
} }
} else { } else {
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE); const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
@@ -116,6 +130,11 @@ export class App implements OnInit {
}); });
} }
ngOnDestroy(): void {
this.deepLinkCleanup?.();
this.deepLinkCleanup = null;
}
openNetworkSettings(): void { openNetworkSettings(): void {
this.settingsModal.open('network'); this.settingsModal.open('network');
} }
@@ -131,4 +150,72 @@ export class App implements OnInit {
async restartToApplyUpdate(): Promise<void> { async restartToApplyUpdate(): Promise<void> {
await this.desktopUpdates.restartToApplyUpdate(); await this.desktopUpdates.restartToApplyUpdate();
} }
private async setupDesktopDeepLinks(): Promise<void> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return;
}
this.deepLinkCleanup = electronApi.onDeepLinkReceived?.((url) => {
void this.handleDesktopDeepLink(url);
}) || null;
const pendingDeepLink = await electronApi.consumePendingDeepLink?.();
if (pendingDeepLink) {
await this.handleDesktopDeepLink(pendingDeepLink);
}
}
private async handleDesktopDeepLink(url: string): Promise<void> {
const invite = this.parseDesktopInviteUrl(url);
if (!invite) {
return;
}
await this.router.navigate(['/invite', invite.inviteId], {
queryParams: {
server: invite.sourceUrl
}
});
}
private isPublicRoute(url: string): boolean {
return url === '/login' ||
url === '/register' ||
url.startsWith('/invite/');
}
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {
try {
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== 'toju:') {
return null;
}
const pathSegments = [parsedUrl.hostname, ...parsedUrl.pathname.split('/').filter(Boolean)]
.map((segment) => decodeURIComponent(segment));
if (pathSegments[0] !== 'invite' || !pathSegments[1]) {
return null;
}
const sourceUrl = parsedUrl.searchParams.get('server')?.trim();
if (!sourceUrl) {
return null;
}
return {
inviteId: pathSegments[1],
sourceUrl
};
} catch {
return null;
}
}
} }

View File

@@ -1,6 +1,7 @@
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId'; export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute'; export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings'; export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings'; export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings'; export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes'; export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';

View File

@@ -0,0 +1,52 @@
/**
* Transitional compatibility barrel.
*
* All business types now live in `src/app/shared-kernel/` (organised by concept)
* or in their owning domain. This file re-exports everything so existing
* `import { X } from 'core/models'` lines keep working while the codebase
* migrates to direct shared-kernel imports.
*
* NEW CODE should import from `@shared-kernel` or the owning domain barrel
* instead of this file.
*/
export type {
User,
UserStatus,
UserRole,
RoomMember
} from '../../shared-kernel';
export type {
Room,
RoomSettings,
RoomPermissions,
Channel,
ChannelType
} from '../../shared-kernel';
export type { Message, Reaction } from '../../shared-kernel';
export { DELETED_MESSAGE_CONTENT } from '../../shared-kernel';
export type { BanEntry } from '../../shared-kernel';
export type { VoiceState, ScreenShareState } from '../../shared-kernel';
export type {
ChatEventBase,
ChatEventType,
ChatEvent,
ChatInventoryItem
} from '../../shared-kernel';
export type {
SignalingMessage,
SignalingMessageType
} from '../../shared-kernel';
export type {
ChatAttachmentAnnouncement,
ChatAttachmentMeta
} from '../../shared-kernel';
export type { ServerInfo } from '../../domains/server-directory';

View File

@@ -0,0 +1,174 @@
export interface LinuxScreenShareAudioRoutingInfo {
available: boolean;
active: boolean;
monitorCaptureSupported: boolean;
screenShareSinkName: string;
screenShareMonitorSourceName: string;
voiceSinkName: string;
reason?: string;
}
export interface LinuxScreenShareMonitorCaptureInfo {
bitsPerSample: number;
captureId: string;
channelCount: number;
sampleRate: number;
sourceName: string;
}
export interface LinuxScreenShareMonitorAudioChunkPayload {
captureId: string;
chunk: Uint8Array;
}
export interface LinuxScreenShareMonitorAudioEndedPayload {
captureId: string;
reason?: string;
}
export interface ClipboardFilePayload {
data: string;
lastModified: number;
mime: string;
name: string;
path?: string;
}
export type AutoUpdateMode = 'auto' | 'off' | 'version';
export type DesktopUpdateStatus =
| 'idle'
| 'disabled'
| 'checking'
| 'downloading'
| 'up-to-date'
| 'restart-required'
| 'unsupported'
| 'no-manifest'
| 'target-unavailable'
| 'target-older-than-installed'
| 'error';
export type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
export interface DesktopUpdateServerContext {
manifestUrls: string[];
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode;
availableVersions: string[];
configuredManifestUrls: string[];
currentVersion: string;
defaultManifestUrls: string[];
isSupported: boolean;
lastCheckedAt: number | null;
latestVersion: string | null;
manifestUrl: string | null;
manifestUrls: string[];
minimumServerVersion: string | null;
preferredVersion: string | null;
restartRequired: boolean;
serverBlocked: boolean;
serverBlockMessage: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
status: DesktopUpdateStatus;
statusMessage: string | null;
targetVersion: string | null;
}
export interface DesktopSettingsSnapshot {
autoUpdateMode: AutoUpdateMode;
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
runtimeHardwareAcceleration: boolean;
restartRequired: boolean;
}
export interface DesktopSettingsPatch {
autoUpdateMode?: AutoUpdateMode;
autoStart?: boolean;
closeToTray?: boolean;
hardwareAcceleration?: boolean;
manifestUrls?: string[];
preferredVersion?: string | null;
vaapiVideoEncode?: boolean;
}
export interface DesktopNotificationPayload {
body: string;
requestAttention: boolean;
title: string;
}
export interface WindowStateSnapshot {
isFocused: boolean;
isMinimized: boolean;
}
export interface ElectronCommand {
type: string;
payload: unknown;
}
export interface ElectronQuery {
type: string;
payload: unknown;
}
export interface ElectronApi {
linuxDisplayServer: string;
minimizeWindow: () => void;
maximizeWindow: () => void;
closeWindow: () => void;
openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
startLinuxScreenShareMonitorCapture: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise<boolean>;
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>;
consumePendingDeepLink: () => Promise<string | null>;
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
requestWindowAttention: () => Promise<boolean>;
clearWindowAttention: () => Promise<boolean>;
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
getAutoUpdateState: () => Promise<DesktopUpdateState>;
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
checkForAppUpdates: () => Promise<DesktopUpdateState>;
restartToApplyUpdate: () => Promise<boolean>;
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
setDesktopSettings: (patch: DesktopSettingsPatch) => Promise<DesktopSettingsSnapshot>;
relaunchApp: () => Promise<boolean>;
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
readFile: (filePath: string) => Promise<string>;
writeFile: (filePath: string, data: string) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
fileExists: (filePath: string) => Promise<boolean>;
deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>;
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
}
export type ElectronWindow = Window & {
electronAPI?: ElectronApi;
};

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

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,
@@ -433,7 +433,7 @@ class DebugNetworkSnapshotBuilder {
} }
} }
if (type === 'screen-state') { if (type === 'screen-state' || type === 'camera-state') {
const subjectNode = direction === 'outbound' const subjectNode = direction === 'outbound'
? this.ensureLocalNetworkNode( ? this.ensureLocalNetworkNode(
state, state,
@@ -442,12 +442,14 @@ class DebugNetworkSnapshotBuilder {
this.getPayloadString(payload, 'displayName') this.getPayloadString(payload, 'displayName')
) )
: peerNode; : peerNode;
const isScreenSharing = this.getPayloadBoolean(payload, 'isScreenSharing'); const isStreaming = type === 'screen-state'
? this.getPayloadBoolean(payload, 'isScreenSharing')
: this.getPayloadBoolean(payload, 'isCameraEnabled');
if (isScreenSharing !== null) { if (isStreaming !== null) {
subjectNode.isStreaming = isScreenSharing; subjectNode.isStreaming = isStreaming;
if (!isScreenSharing) if (!isStreaming)
subjectNode.streams.video = 0; subjectNode.streams.video = 0;
} }
} }

View File

@@ -218,7 +218,6 @@ 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)';
// Use only string args for label/message extraction so that // Use only string args for label/message extraction so that
// stringified object payloads don't pollute the parsed message. // stringified object payloads don't pollute the parsed message.
// Object payloads are captured separately via extractConsolePayload. // Object payloads are captured separately via extractConsolePayload.
@@ -226,7 +225,6 @@ export class DebuggingService {
.filter((arg): arg is string => typeof arg === 'string') .filter((arg): arg is string => typeof arg === 'string')
.join(' ') .join(' ')
.trim() || rawMessage; .trim() || rawMessage;
const consoleMetadata = this.extractConsoleMetadata(metadataSource); const consoleMetadata = this.extractConsoleMetadata(metadataSource);
const payload = this.extractConsolePayload(args); const payload = this.extractConsolePayload(args);
const payloadText = payload === undefined const payloadText = payload === undefined

View File

@@ -5,70 +5,17 @@ import {
inject, inject,
signal signal
} from '@angular/core'; } from '@angular/core';
import { PlatformService } from './platform.service'; import { PlatformService } from '../platform';
import { ServerDirectoryService, type ServerEndpoint } from './server-directory.service'; import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server-directory';
import {
type AutoUpdateMode = 'auto' | 'off' | 'version'; type AutoUpdateMode,
type DesktopUpdateStatus = type DesktopUpdateServerContext,
| 'idle' type DesktopUpdateServerHealthSnapshot,
| 'disabled' type DesktopUpdateServerVersionStatus,
| 'checking' type DesktopUpdateState,
| 'downloading' type ElectronApi
| 'up-to-date' } from '../platform/electron/electron-api.models';
| 'restart-required' import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
| 'unsupported'
| 'no-manifest'
| 'target-unavailable'
| 'target-older-than-installed'
| 'error';
type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode;
availableVersions: string[];
configuredManifestUrls: string[];
currentVersion: string;
defaultManifestUrls: string[];
isSupported: boolean;
lastCheckedAt: number | null;
latestVersion: string | null;
manifestUrl: string | null;
manifestUrls: string[];
minimumServerVersion: string | null;
preferredVersion: string | null;
restartRequired: boolean;
serverBlocked: boolean;
serverBlockMessage: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
status: DesktopUpdateStatus;
statusMessage: string | null;
targetVersion: string | null;
}
interface DesktopUpdateServerContext {
manifestUrls: string[];
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
interface DesktopUpdateElectronApi {
checkForAppUpdates?: () => Promise<DesktopUpdateState>;
configureAutoUpdateContext?: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
getAutoUpdateState?: () => Promise<DesktopUpdateState>;
onAutoUpdateStateChanged?: (listener: (state: DesktopUpdateState) => void) => () => void;
restartToApplyUpdate?: () => Promise<boolean>;
setDesktopSettings?: (patch: {
autoUpdateMode?: AutoUpdateMode;
manifestUrls?: string[];
preferredVersion?: string | null;
}) => Promise<unknown>;
}
interface ServerHealthResponse {
releaseManifestUrl?: string;
serverVersion?: string;
}
interface ServerHealthSnapshot { interface ServerHealthSnapshot {
endpointId: string; endpointId: string;
@@ -77,12 +24,7 @@ interface ServerHealthSnapshot {
serverVersionStatus: DesktopUpdateServerVersionStatus; serverVersionStatus: DesktopUpdateServerVersionStatus;
} }
type DesktopUpdateWindow = Window & {
electronAPI?: DesktopUpdateElectronApi;
};
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000; const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
function createInitialState(): DesktopUpdateState { function createInitialState(): DesktopUpdateState {
return { return {
@@ -153,7 +95,8 @@ export class DesktopAppUpdateService {
readonly state = signal<DesktopUpdateState>(createInitialState()); readonly state = signal<DesktopUpdateState>(createInitialState());
private injector = inject(Injector); private injector = inject(Injector);
private servers = inject(ServerDirectoryService); private servers = inject(ServerDirectoryFacade);
private electronBridge = inject(ElectronBridgeService);
private initialized = false; private initialized = false;
private refreshTimerId: number | null = null; private refreshTimerId: number | null = null;
private removeStateListener: (() => void) | null = null; private removeStateListener: (() => void) | null = null;
@@ -344,14 +287,9 @@ export class DesktopAppUpdateService {
private async readServerHealth(endpoint: ServerEndpoint): Promise<ServerHealthSnapshot> { private async readServerHealth(endpoint: ServerEndpoint): Promise<ServerHealthSnapshot> {
const sanitizedServerUrl = endpoint.url.replace(/\/+$/, ''); const sanitizedServerUrl = endpoint.url.replace(/\/+$/, '');
const api = this.getElectronApi();
try { if (!api?.getAutoUpdateServerHealth) {
const response = await fetch(`${sanitizedServerUrl}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(SERVER_CONTEXT_TIMEOUT_MS)
});
if (!response.ok) {
return { return {
endpointId: endpoint.id, endpointId: endpoint.id,
manifestUrl: null, manifestUrl: null,
@@ -360,14 +298,12 @@ export class DesktopAppUpdateService {
}; };
} }
const payload = await response.json() as ServerHealthResponse; try {
const serverVersion = normalizeOptionalString(payload.serverVersion); const payload = await api.getAutoUpdateServerHealth(sanitizedServerUrl);
return { return {
endpointId: endpoint.id, endpointId: endpoint.id,
manifestUrl: normalizeOptionalHttpUrl(payload.releaseManifestUrl), ...this.normalizeHealthSnapshot(payload)
serverVersion,
serverVersionStatus: serverVersion ? 'reported' : 'missing'
}; };
} catch { } catch {
return { return {
@@ -379,6 +315,22 @@ export class DesktopAppUpdateService {
} }
} }
private normalizeHealthSnapshot(
snapshot: DesktopUpdateServerHealthSnapshot
): Omit<ServerHealthSnapshot, 'endpointId'> {
const serverVersion = normalizeOptionalString(snapshot.serverVersion);
return {
manifestUrl: normalizeOptionalHttpUrl(snapshot.manifestUrl),
serverVersion,
serverVersionStatus: serverVersion
? snapshot.serverVersionStatus
: snapshot.serverVersionStatus === 'reported'
? 'missing'
: snapshot.serverVersionStatus
};
}
private async pushContext(context: Partial<DesktopUpdateServerContext>): Promise<void> { private async pushContext(context: Partial<DesktopUpdateServerContext>): Promise<void> {
const api = this.getElectronApi(); const api = this.getElectronApi();
@@ -393,9 +345,7 @@ export class DesktopAppUpdateService {
} catch {} } catch {}
} }
private getElectronApi(): DesktopUpdateElectronApi | null { private getElectronApi(): ElectronApi | null {
return typeof window !== 'undefined' return this.electronBridge.getApi();
? (window as DesktopUpdateWindow).electronAPI ?? null
: null;
} }
} }

View File

@@ -0,0 +1,4 @@
export * from './notification-audio.service';
export * from '../models/debugging.models';
export * from './debugging/debugging.service';
export * from './settings-modal.service';

View File

@@ -13,7 +13,7 @@ export enum AppSound {
} }
/** Path prefix for audio assets (served from the `assets/audio/` folder). */ /** Path prefix for audio assets (served from the `assets/audio/` folder). */
const AUDIO_BASE = '/assets/audio'; const AUDIO_BASE = 'assets/audio';
/** File extension used for all sound-effect assets. */ /** File extension used for all sound-effect assets. */
const AUDIO_EXT = 'wav'; const AUDIO_EXT = 'wav';
/** localStorage key for persisting notification volume. */ /** localStorage key for persisting notification volume. */
@@ -36,6 +36,8 @@ export class NotificationAudioService {
/** Pre-loaded audio buffers keyed by {@link AppSound}. */ /** Pre-loaded audio buffers keyed by {@link AppSound}. */
private readonly cache = new Map<AppSound, HTMLAudioElement>(); private readonly cache = new Map<AppSound, HTMLAudioElement>();
private readonly sources = new Map<AppSound, string>();
/** Reactive notification volume (0 - 1), persisted to localStorage. */ /** Reactive notification volume (0 - 1), persisted to localStorage. */
readonly notificationVolume = signal(this.loadVolume()); readonly notificationVolume = signal(this.loadVolume());
@@ -46,13 +48,22 @@ export class NotificationAudioService {
/** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */ /** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */
private preload(): void { private preload(): void {
for (const sound of Object.values(AppSound)) { for (const sound of Object.values(AppSound)) {
const audio = new Audio(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`); const src = this.resolveAudioUrl(sound);
const audio = new Audio();
audio.preload = 'auto'; audio.preload = 'auto';
audio.src = src;
audio.load();
this.sources.set(sound, src);
this.cache.set(sound, audio); this.cache.set(sound, audio);
} }
} }
private resolveAudioUrl(sound: AppSound): string {
return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString();
}
/** Read persisted volume from localStorage, falling back to the default. */ /** Read persisted volume from localStorage, falling back to the default. */
private loadVolume(): number { private loadVolume(): number {
try { try {
@@ -96,8 +107,9 @@ export class NotificationAudioService {
*/ */
play(sound: AppSound, volumeOverride?: number): void { play(sound: AppSound, volumeOverride?: number): void {
const cached = this.cache.get(sound); const cached = this.cache.get(sound);
const src = this.sources.get(sound);
if (!cached) if (!cached || !src)
return; return;
const vol = volumeOverride ?? this.notificationVolume(); const vol = volumeOverride ?? this.notificationVolume();
@@ -105,12 +117,23 @@ export class NotificationAudioService {
if (vol === 0) if (vol === 0)
return; // skip playback when muted return; // skip playback when muted
if (cached.readyState === HTMLMediaElement.HAVE_NOTHING) {
cached.load();
}
// Clone so overlapping plays don't cut each other off. // Clone so overlapping plays don't cut each other off.
const clone = cached.cloneNode(true) as HTMLAudioElement; const clone = cached.cloneNode(true) as HTMLAudioElement;
clone.preload = 'auto';
clone.volume = Math.max(0, Math.min(1, vol)); clone.volume = Math.max(0, Math.min(1, vol));
clone.play().catch(() => { clone.play().catch(() => {
const fallback = new Audio(src);
fallback.preload = 'auto';
fallback.volume = clone.volume;
fallback.play().catch(() => {
/* swallow autoplay errors */ /* swallow autoplay errors */
}); });
});
} }
} }

View File

@@ -1,13 +1,24 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
export type SettingsPage = 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
export type SettingsPage =
| 'general'
| 'network'
| 'notifications'
| 'voice'
| 'updates'
| 'debugging'
| 'server'
| 'members'
| 'bans'
| 'permissions';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SettingsModalService { export class SettingsModalService {
readonly isOpen = signal(false); readonly isOpen = signal(false);
readonly activePage = signal<SettingsPage>('network'); readonly activePage = signal<SettingsPage>('general');
readonly targetServerId = signal<string | null>(null); readonly targetServerId = signal<string | null>(null);
open(page: SettingsPage = 'network', serverId?: string): void { open(page: SettingsPage = 'general', serverId?: string): void {
this.activePage.set(page); this.activePage.set(page);
this.targetServerId.set(serverId ?? null); this.targetServerId.set(serverId ?? null);
this.isOpen.set(true); this.isOpen.set(true);

View File

@@ -0,0 +1,76 @@
# 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` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **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, in-channel camera transport | `VoiceConnectionFacade` |
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
## Detailed docs
The larger domains also keep longer design notes in their own folders:
- [attachment/README.md](attachment/README.md)
- [auth/README.md](auth/README.md)
- [chat/README.md](chat/README.md)
- [notifications/README.md](notifications/README.md)
- [screen-share/README.md](screen-share/README.md)
- [server-directory/README.md](server-directory/README.md)
- [voice-connection/README.md](voice-connection/README.md)
- [voice-session/README.md](voice-session/README.md)
## 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" _blank
click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle" _blank
click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol" _blank
click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank
click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence" _blank
click Store "application/attachment-runtime.store.ts" "In-memory signal-based state" _blank
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access" _blank
click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers" _blank
click Logic "domain/attachment.logic.ts" "Pure decision functions" _blank
```
## File transfer protocol
Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one.
```mermaid
sequenceDiagram
participant S as Sender
participant R as Receiver
S->>R: file-announce (id, name, size, mimeType)
Note over R: Store metadata in runtime store
Note over R: shouldAutoRequestWhenWatched?
R->>S: file-request (attachmentId)
Note over S: Look up file in runtime store or on disk
loop Every 64 KB chunk
S->>R: file-chunk (attachmentId, index, data, progress, speed)
Note over R: Append to chunk buffer
Note over R: Update progress + EWMA speed
end
Note over R: All chunks received
Note over R: Reassemble blob
Note over R: shouldPersistDownloadedAttachment? Save to disk
```
### Failure handling
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
```mermaid
sequenceDiagram
participant R as Receiver
participant P1 as Peer A
participant P2 as Peer B
R->>P1: file-request
P1->>R: file-not-found
Note over R: Try next peer
R->>P2: file-request
P2->>R: file-chunk (1/N)
P2->>R: file-chunk (2/N)
P2->>R: file-chunk (N/N)
Note over R: Transfer complete
```
## Auto-download rules
When the user navigates to a room, the manager watches the route and decides which attachments to request automatically based on domain logic:
| Condition | Auto-download? |
|---|---|
| Image or video, size <= 10 MB | Yes |
| Image or video, size > 10 MB | No |
| Non-media file | No |
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
## Persistence
On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket:
```
{appDataPath}/{serverId}/{roomName}/{bucket}/{attachmentId}.{ext?}
```
Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
`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;
}
}

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