Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9429dacac5 | ||
|
|
1ce72e7ac3 | ||
|
|
718f4a99f0 | ||
|
|
e45e165a6f | ||
|
|
2a88d62ddf | ||
|
|
7e2cbcfe6c | ||
|
|
3266581d3c | ||
|
|
92c2f578e2 | ||
|
|
a83f5aa750 | ||
|
|
f9e8538c80 | ||
|
|
e49b3ec112 | ||
|
|
d71e3a98da | ||
|
|
d658ab0827 | ||
|
|
34c32ba64a | ||
|
|
cfe10907be | ||
|
|
cdc32db30f | ||
|
|
20d7f22fd2 | ||
|
|
41ebaf2407 | ||
|
|
d3d22846e7 | ||
|
|
59dfd2de85 | ||
|
|
edc4d935d8 | ||
|
|
3e090933fd | ||
|
|
590e487250 | ||
|
|
497033aff0 | ||
|
|
0078c320a5 | ||
|
|
b13f71d2d3 | ||
|
|
fa45052432 | ||
|
|
bb0ac930ad | ||
|
|
f0d79aa627 | ||
|
|
95259e8943 | ||
|
|
924d4bbb1d | ||
|
|
baa350e90a | ||
|
|
b2a2d9d770 | ||
|
|
c3c2f01cc6 | ||
|
|
dac5cb42a5 | ||
|
|
29032b5a36 | ||
|
|
e75b4a38ed |
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
description: Short-chat handoff to cut token burn; agents cannot open new chats
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Handoff (token control)
|
||||||
|
|
||||||
|
Agents **cannot** create a new Cursor chat. When a handoff is needed, **overwrite** `agents-docs/HANDOFF.md` and tell the user to start a **new chat** with `@agents-docs/HANDOFF.md`.
|
||||||
|
|
||||||
|
## File size rule (mandatory)
|
||||||
|
|
||||||
|
- **Never append** to `HANDOFF.md`. Always replace the entire file.
|
||||||
|
- One active handoff only — no history stack in this file.
|
||||||
|
- Keep sections short (bullets, paths, not log dumps).
|
||||||
|
|
||||||
|
## When to hand off
|
||||||
|
|
||||||
|
- User says "handoff", "new chat", or "wrap up this session"
|
||||||
|
- Thread is long (many tool rounds, large pastes, repeated failed approaches) and more major work remains
|
||||||
|
- Switching to a clearly separate objective
|
||||||
|
|
||||||
|
## What to do (new handoff)
|
||||||
|
|
||||||
|
1. **Overwrite** `agents-docs/HANDOFF.md` completely — `Status: active`; fill Goal, Completed, Changed files, Decisions, Failed approaches, Current issue, Next steps, Commands (brief).
|
||||||
|
2. Stop major new work in this chat after writing the handoff (unless the user says continue here).
|
||||||
|
3. Tell the user the one-liner for the new chat.
|
||||||
|
|
||||||
|
## When the task is finished
|
||||||
|
|
||||||
|
After the user-approved work is done (or they abandon the handoff objective):
|
||||||
|
|
||||||
|
1. **Clear** `agents-docs/HANDOFF.md`: set `Status: none` and empty all section bodies (restore the short template — do not leave old Completed/Next steps lying around).
|
||||||
|
2. Do this in the same turn you claim done, so the next chat does not reload stale handoff context.
|
||||||
|
|
||||||
|
## New-chat bootstrap
|
||||||
|
|
||||||
|
If `Status: active`, read the handoff first and continue Next steps. Do not reload the whole monorepo or redo Completed work. When that work is finished → **clear** the file as above.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
description: Interview the user before implementing fixes; no guessing
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Interview before implement
|
||||||
|
|
||||||
|
Before writing or changing product code for a bug/feature ask:
|
||||||
|
|
||||||
|
1. Read the ask (and the one Obsidian bug note if named). Do **not** start a large codebase rewrite yet.
|
||||||
|
2. Reply with a **short interview** (bullets only):
|
||||||
|
- What you think the bug/goal is (1–2 sentences)
|
||||||
|
- What’s missing / unclear
|
||||||
|
- Choices (A/B/C) with a **recommended** default
|
||||||
|
- Proposed scope (files/areas you will touch; what you will not)
|
||||||
|
- How you will prove done
|
||||||
|
3. **Stop and wait** for the user’s answers. Do not implement until they approve or choose.
|
||||||
|
4. Then implement exactly what they chose — no silent extra scope.
|
||||||
|
|
||||||
|
Skip the interview only when the user says e.g. “just fix it”, “no interview”, or the handoff already records approved Next steps/decisions and they said continue.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Reduce indexing / accidental bulk reads. Scope policy lives in AGENTS.md.
|
||||||
|
# Do NOT list electron/, server/, e2e/, website/, or docs-site/ here —
|
||||||
|
# those stay reachable when the user explicitly expands scope.
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
dist-server/
|
||||||
|
.angular/
|
||||||
|
coverage/
|
||||||
|
test-results/
|
||||||
|
e2e/playwright-report/
|
||||||
|
docs-site/.docusaurus/
|
||||||
|
docs-site/build/
|
||||||
|
*.sqlite
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Media / binary bulk
|
||||||
|
images/
|
||||||
|
**/*.png
|
||||||
|
**/*.jpg
|
||||||
|
**/*.jpeg
|
||||||
|
**/*.webp
|
||||||
|
**/*.gif
|
||||||
|
**/*.mp4
|
||||||
|
**/*.wasm
|
||||||
@@ -249,8 +249,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$projectRoot = $PWD.ProviderPath
|
$projectRoot = $PWD.ProviderPath
|
||||||
$electronBuilderWorkspace = Join-Path $env:TEMP ([guid]::NewGuid().ToString('N'))
|
$electronBuilderWorkspace = Join-Path $env:TEMP ([guid]::NewGuid().ToString('N'))
|
||||||
$electronBuilderCache = Join-Path $electronBuilderWorkspace 'electron-builder-cache'
|
$electronBuilderCache = Join-Path $env:LOCALAPPDATA 'electron-builder\Cache'
|
||||||
$electronCache = Join-Path $electronBuilderWorkspace 'electron-cache'
|
$electronCache = Join-Path $env:LOCALAPPDATA 'electron\Cache'
|
||||||
$locationPushed = $false
|
$locationPushed = $false
|
||||||
|
|
||||||
function Invoke-RoboCopy {
|
function Invoke-RoboCopy {
|
||||||
@@ -266,21 +266,68 @@ jobs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Initialize-WinCodeSignCache {
|
||||||
|
param(
|
||||||
|
[string]$CacheRoot,
|
||||||
|
[string]$SevenZip
|
||||||
|
)
|
||||||
|
|
||||||
|
# electron-builder downloads winCodeSign for rcedit (icon and version stamping)
|
||||||
|
# even when nothing is signed. Its two darwin symlinks cannot be recreated
|
||||||
|
# without SeCreateSymbolicLinkPrivilege, so seed the cache without them.
|
||||||
|
# The version must match the one app-builder resolves, otherwise it downloads
|
||||||
|
# its own copy and fails on the symlinks again.
|
||||||
|
$version = 'winCodeSign-2.6.0'
|
||||||
|
$target = Join-Path $CacheRoot "winCodeSign\$version"
|
||||||
|
|
||||||
|
if (Test-Path $target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$staging = "$target.incomplete"
|
||||||
|
$archive = Join-Path $env:TEMP "$version.7z"
|
||||||
|
$url = "https://github.com/electron-userland/electron-builder-binaries/releases/download/$version/$version.7z"
|
||||||
|
|
||||||
|
Remove-Item $staging -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile $archive -UseBasicParsing
|
||||||
|
& $SevenZip x -bd -y "-o$staging" '-x!darwin' $archive | Out-Null
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "failed to extract $version with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force | Out-Null
|
||||||
|
Move-Item $staging $target
|
||||||
|
} finally {
|
||||||
|
Remove-Item $archive -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item $staging -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Stage the packaging inputs into a real short-path directory.
|
# Stage the packaging inputs into a real short-path directory.
|
||||||
# electron-builder rejects junction-backed files during asar creation
|
# electron-builder rejects junction-backed files during asar creation
|
||||||
# because their resolved path sits outside the package root.
|
# because their resolved path sits outside the package root.
|
||||||
New-Item -ItemType Directory -Path $electronBuilderWorkspace | Out-Null
|
New-Item -ItemType Directory -Path $electronBuilderWorkspace | Out-Null
|
||||||
New-Item -ItemType Directory -Path $electronBuilderCache | Out-Null
|
New-Item -ItemType Directory -Path $electronBuilderCache -Force | Out-Null
|
||||||
New-Item -ItemType Directory -Path $electronCache | Out-Null
|
New-Item -ItemType Directory -Path $electronCache -Force | Out-Null
|
||||||
$env:ELECTRON_BUILDER_CACHE = $electronBuilderCache
|
$env:ELECTRON_BUILDER_CACHE = $electronBuilderCache
|
||||||
$env:ELECTRON_CACHE = $electronCache
|
$env:ELECTRON_CACHE = $electronCache
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Initialize-WinCodeSignCache `
|
||||||
|
-CacheRoot $electronBuilderCache `
|
||||||
|
-SevenZip (Join-Path $projectRoot 'node_modules\7zip-bin\win\x64\7za.exe')
|
||||||
|
|
||||||
Copy-Item -Path (Join-Path $projectRoot 'package.json') -Destination (Join-Path $electronBuilderWorkspace 'package.json') -Force
|
Copy-Item -Path (Join-Path $projectRoot 'package.json') -Destination (Join-Path $electronBuilderWorkspace 'package.json') -Force
|
||||||
Copy-Item -Path (Join-Path $projectRoot 'package-lock.json') -Destination (Join-Path $electronBuilderWorkspace 'package-lock.json') -Force
|
Copy-Item -Path (Join-Path $projectRoot 'package-lock.json') -Destination (Join-Path $electronBuilderWorkspace 'package-lock.json') -Force
|
||||||
Invoke-RoboCopy (Join-Path $projectRoot 'dist') (Join-Path $electronBuilderWorkspace 'dist')
|
Invoke-RoboCopy (Join-Path $projectRoot 'dist') (Join-Path $electronBuilderWorkspace 'dist')
|
||||||
Invoke-RoboCopy (Join-Path $projectRoot 'docs-site/build') (Join-Path $electronBuilderWorkspace 'docs-site/build')
|
Invoke-RoboCopy (Join-Path $projectRoot 'docs-site/build') (Join-Path $electronBuilderWorkspace 'docs-site/build')
|
||||||
Invoke-RoboCopy (Join-Path $projectRoot 'images') (Join-Path $electronBuilderWorkspace 'images')
|
Invoke-RoboCopy (Join-Path $projectRoot 'images') (Join-Path $electronBuilderWorkspace 'images')
|
||||||
|
Invoke-RoboCopy (Join-Path $projectRoot 'tools') (Join-Path $electronBuilderWorkspace 'tools')
|
||||||
Invoke-RoboCopy (Join-Path $projectRoot 'node_modules') (Join-Path $electronBuilderWorkspace 'node_modules')
|
Invoke-RoboCopy (Join-Path $projectRoot 'node_modules') (Join-Path $electronBuilderWorkspace 'node_modules')
|
||||||
|
|
||||||
Push-Location $electronBuilderWorkspace
|
Push-Location $electronBuilderWorkspace
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ Thumbs.db
|
|||||||
# Environment & certs
|
# Environment & certs
|
||||||
.env
|
.env
|
||||||
.certs/
|
.certs/
|
||||||
|
.dev-userdata/
|
||||||
/server/data/variables.json
|
/server/data/variables.json
|
||||||
/server/data/metoyou.sqlite
|
/server/data/metoyou.sqlite
|
||||||
dist-server/*
|
dist-server/*
|
||||||
|
|||||||
@@ -1,103 +1,118 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Read these files at the start of every session before doing any work:
|
Keep this file small. Detail lives in linked docs — load those **only when the task needs them**.
|
||||||
|
|
||||||
1. `agents-docs/AGENT_WORKFLOW.md` — workflow and operating rules
|
## Session handoff (new chats)
|
||||||
2. `agents-docs/LESSONS.md` — durable rules learned from past corrections; apply any that match this session's work
|
|
||||||
3. `agents-docs/AGENTS_FEATURES.md` — when and how to update feature docs
|
|
||||||
4. `agents-docs/FEATURES.md` — feature index
|
|
||||||
5. `agents-docs/ENGINEERING.md` — engineering standards
|
|
||||||
6. `agents-docs/CONTEXT-MAP.md` — index of bounded contexts in this repo
|
|
||||||
|
|
||||||
Reference on-demand (when the workflow triggers them — see `agents-docs/AGENT_WORKFLOW.md` §§ 4–5):
|
Agents **cannot** open a new Cursor chat. To reset context:
|
||||||
|
|
||||||
- `agents-docs/AGENTS_CONTEXT.md` — contract for updating `CONTEXT.md` / `CONTEXT-MAP.md`
|
1. **Overwrite** `agents-docs/HANDOFF.md` entirely (`Status: active`) — never append. See `.cursor/rules/handoff.mdc`.
|
||||||
- `agents-docs/AGENTS_ADRS.md` — contract for writing architecture decision records
|
2. Ask the user to start a **new chat** and attach `@agents-docs/HANDOFF.md`.
|
||||||
- `agents-docs/BUG_TRACKER.md` — Obsidian bug inbox location, allowed vault edits, and triage workflow
|
|
||||||
|
|
||||||
When working in a subdomain, also read its `CONTEXT.md` first:
|
**At session start:** if `Status: active`, read it first and continue Next steps. Do not redo Completed work.
|
||||||
|
|
||||||
- Product client (Angular 21): `toju-app/CONTEXT.md`
|
**When the task is finished:** clear the handoff — set `Status: none` and empty all sections (same short template). Never leave a growing archive in `HANDOFF.md`.
|
||||||
- Desktop shell (Electron main + preload): `electron/CONTEXT.md`
|
|
||||||
- Signaling server (Express + WebSocket): `server/CONTEXT.md`
|
## Named Obsidian bugs
|
||||||
- End-to-end tests (Playwright): `e2e/CONTEXT.md`
|
|
||||||
- Marketing site (Angular 19): `website/CONTEXT.md`
|
When the user says `fix bug "…"`, follow `agents-docs/BUG_TRACKER.md` § Named fix: open **that one** vault note, **interview before implementing**, then fix in default scope and set `status: Resolved`. Do not scan the whole bug inbox.
|
||||||
- Application documentation (Docusaurus): `docs-site/CONTEXT.md`
|
|
||||||
|
## Interview before implement (user in control)
|
||||||
|
|
||||||
|
Before changing product code for a bug or feature:
|
||||||
|
|
||||||
|
1. Read the ask / named bug note (cheap orientation only — not a monorepo dig).
|
||||||
|
2. Post a **short interview**: your understanding, gaps, A/B/C choices with a recommended default, proposed scope, how you’ll prove done.
|
||||||
|
3. **Wait** for the user’s choices. Do not guess past ambiguity.
|
||||||
|
4. Implement only what they approved.
|
||||||
|
|
||||||
|
Skip only if they say “just fix it” / “no interview”, or an active handoff already has approved decisions and they said continue. See `.cursor/rules/interview-before-fix.mdc`.
|
||||||
|
|
||||||
|
## Default work scope (token fence)
|
||||||
|
|
||||||
|
Unless the user **explicitly** expands scope, stay inside:
|
||||||
|
|
||||||
|
- `toju-app/` (primary)
|
||||||
|
- `electron/` — **targeted only** (desktop shell is coupled to the client; see below)
|
||||||
|
- CI: `.gitea/workflows/`
|
||||||
|
- Agent/docs as needed: this file, `agents-docs/` (index-first / handoff), `toju-app/CONTEXT.md`; `electron/CONTEXT.md` only when touching Electron
|
||||||
|
|
||||||
|
### Electron — relevant files only
|
||||||
|
|
||||||
|
`electron/` is in default scope because the renderer talks to it via preload/IPC/local DB. Do **not** browse the whole tree.
|
||||||
|
|
||||||
|
When a `toju-app` change needs the desktop bridge:
|
||||||
|
|
||||||
|
1. Start from the renderer call site (`window.api` / Electron bridge usage).
|
||||||
|
2. Open only the matching surface: usually `electron/preload.ts`, then the specific handler under `electron/ipc/`, `electron/cqrs*`, or the one module/entity/migration involved.
|
||||||
|
3. Prefer ripgrep with path `electron/` + a concrete symbol over listing directories.
|
||||||
|
4. Skip unrelated areas (`electron/api/` docs server, `game-detection/`, `update/`, other migrations, etc.) unless the bug points there.
|
||||||
|
|
||||||
|
**Still out of scope by default** (do not search/read/edit unless the user names them):
|
||||||
|
|
||||||
|
- `server/`, `e2e/`, `website/`, `docs-site/`
|
||||||
|
- Root noise: `dist*/`, `node_modules/`, `images/`, `project-files/`, `test-results/`
|
||||||
|
|
||||||
|
If the root cause looks like `server/` or e2e-only, **ask once** instead of exploring those trees.
|
||||||
|
|
||||||
|
Search with path filters. Prefer `toju-app/src/app/domains/<name>/` over repo-wide greps.
|
||||||
|
|
||||||
|
## Session start (cheap bootstrap)
|
||||||
|
|
||||||
|
1. Skim this file.
|
||||||
|
2. If handoff `Status: active` → read `agents-docs/HANDOFF.md`.
|
||||||
|
3. Open `agents-docs/LESSONS-INDEX.md` only — match tags; open matching bodies in `LESSONS.md`.
|
||||||
|
4. Read `toju-app/CONTEXT.md` for client work; `electron/CONTEXT.md` only if this task touches Electron.
|
||||||
|
5. Other docs **on demand** only.
|
||||||
|
|
||||||
|
**Models:** use the latest problem-solving model the user selected. Save tokens with **scope, handoffs, and short chats** — not by silently downgrading model quality.
|
||||||
|
|
||||||
|
**Do not auto-read:** `ENGINEERING.md`, `AGENTS_FEATURES.md`, `FEATURES.md`, `CONTEXT-MAP.md`, full `AGENT_WORKFLOW.md`, feature docs, ADRs — unless needed.
|
||||||
|
|
||||||
|
On-demand: `agents-docs/AGENT_WORKFLOW.md`, `AGENTS_FEATURES.md`, `FEATURES.md`, `ENGINEERING.md`, `AGENTS_CONTEXT.md`, `AGENTS_ADRS.md`, `BUG_TRACKER.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
MetoYou (also called Toju) is a desktop-first, P2P Discord-style chat application managed as an npm-workspaces monorepo. It bundles an Angular 21 product client, an Electron 39 desktop shell with TypeORM + sql.js for local persistence, a small Node/TypeScript Express signaling server with WebSocket-based realtime, a Playwright end-to-end suite, an Angular 19 marketing site, and a Docusaurus app/plugin documentation site that ships inside the Electron build. Voice and screen-share are WebRTC, with RNNoise denoising via a WASM audio worklet.
|
MetoYou / Toju: desktop-first P2P chat. Default surface: Angular client (`toju-app/`) + targeted Electron bridge + CI.
|
||||||
|
|
||||||
## CRITICAL — Non-negotiable rules for all agents
|
## CRITICAL — Done means the asked behavior works
|
||||||
|
|
||||||
### Test-Driven Development (MANDATORY)
|
**Unit/spec green is support, not done.**
|
||||||
**Write tests before implementation code.**
|
|
||||||
|
|
||||||
When creating or changing anything:
|
1. Restate acceptance in one sentence.
|
||||||
1. STOP — do not write implementation first
|
2. Prove the behavior (user-visible path, focused test at the right level, or explicit manual check).
|
||||||
2. Write failing tests (RED)
|
3. Prefer a regression that fails if the asked behavior regresses.
|
||||||
3. Run tests and confirm failure (`npm run test` for the product client; `npm run test:e2e` for end-to-end; place spec files colocated with source, suffix `.spec.ts`)
|
|
||||||
4. Write minimal code to pass tests (GREEN)
|
|
||||||
5. Refactor while keeping tests green
|
|
||||||
|
|
||||||
This applies to all code — Angular components and services, NgRx effects/reducers, Electron IPC handlers, server CQRS handlers, websocket message handlers, plugin runtime, and domain logic. If the code lives in a package without a configured test runner (server, website, docs-site), surface that gap before adding logic there.
|
### Test-backed development (balanced)
|
||||||
|
|
||||||
### Lint correctness (MANDATORY)
|
For domain/logic: failing behavior-level test → minimal fix → green.
|
||||||
Before completing any task:
|
Skip full red-green for docs/copy/agent text, formatting, trivial wiring already covered higher up.
|
||||||
1. Run `npm run lint` from the repo root (ESLint 9 flat config in `eslint.config.js` covers every package)
|
|
||||||
2. Fix all errors
|
|
||||||
3. Do not consider work complete until it exits with code 0
|
|
||||||
|
|
||||||
### Type / build correctness (MANDATORY)
|
**Do not:** ship implementation-shaped mocks as the feature; stop at unit-green for product asks; run full monorepo / full e2e on every tiny change — targeted specs first.
|
||||||
Type checks live in build scripts:
|
|
||||||
|
|
||||||
- Product client (`toju-app/`): `npm run build` (Angular CLI runs `tsc` with strict settings)
|
### Lint / type correctness (scoped)
|
||||||
- Electron (`electron/`): `npm run build:electron` (invokes `tsc -p tsconfig.electron.json`)
|
|
||||||
- Server (`server/`): `cd server && npm run build` (invokes `tsc`)
|
|
||||||
|
|
||||||
If your change touches one of these packages, run the corresponding build and ensure it exits 0 before marking work complete.
|
1. Targeted Vitest under `toju-app/` (and colocated Electron specs only if you changed those files).
|
||||||
|
2. Auto-fix style first: `npm run lint:fix` from repo root (`format` + `sort:props` + `eslint . --fix`). Do **not** hand-edit formatting/import-sort/eslint-fixable issues — re-run `lint:fix`. Then confirm clean with `npm run lint` only if you need a no-write check; prefer trusting `lint:fix` exit 0.
|
||||||
|
3. Do not paste entire lint logs into the chat — fix via `lint:fix` / minimal code changes for non-auto issues.
|
||||||
|
4. `npm run build` when client types/templates could break; `npm run build:electron` only if you changed Electron sources.
|
||||||
|
|
||||||
## Most important rule
|
Do **not** run `cd server && npm run build` or `npm run test:e2e` unless scope expanded or the bug is proven there.
|
||||||
|
|
||||||
After any change that affects API contracts, schemas, invariants, workflows, or major behavior: update the relevant `agents-docs/features/<slug>.md` as part of the same task — not as a follow-up. New feature area → create `agents-docs/features/<slug>.md` and add an entry to `agents-docs/FEATURES.md` (alphabetical).
|
### Feature docs
|
||||||
|
|
||||||
The product client already maintains per-domain READMEs under `toju-app/src/app/domains/<name>/README.md`. When the change is fully internal to one of those bounded contexts and its surface stays the same, the domain README is the right place to update; cross-context contracts (websocket envelopes, IPC channels, server routes, plugin manifests) belong in `agents-docs/features/`.
|
Internal domain changes → `toju-app/src/app/domains/<name>/README.md`.
|
||||||
|
IPC/preload/WS contract changes → `agents-docs/features/<slug>.md` when that contract actually changed.
|
||||||
## Structure of further instructions
|
|
||||||
|
|
||||||
- **Agent workflow & operating rules:** `agents-docs/AGENT_WORKFLOW.md`
|
|
||||||
- **Agent lessons (durable cross-session rules):** `agents-docs/LESSONS.md`
|
|
||||||
- **Engineering standards:** `agents-docs/ENGINEERING.md`
|
|
||||||
- **Feature documentation contract:** `agents-docs/AGENTS_FEATURES.md`
|
|
||||||
- **CONTEXT documentation contract:** `agents-docs/AGENTS_CONTEXT.md`
|
|
||||||
- **ADR contract:** `agents-docs/AGENTS_ADRS.md`
|
|
||||||
- **Feature index:** `agents-docs/FEATURES.md`
|
|
||||||
- **Feature docs:** `agents-docs/features/`
|
|
||||||
- **Architecture decisions:** `agents-docs/adr/`
|
|
||||||
- **Context map:** `agents-docs/CONTEXT-MAP.md`
|
|
||||||
- **Obsidian bug tracker:** `agents-docs/BUG_TRACKER.md`
|
|
||||||
- **Product-client domain:** `toju-app/CONTEXT.md`
|
|
||||||
- **Desktop-shell domain:** `electron/CONTEXT.md`
|
|
||||||
- **Server domain:** `server/CONTEXT.md`
|
|
||||||
- **E2E suite domain:** `e2e/CONTEXT.md`
|
|
||||||
- **Marketing-site domain:** `website/CONTEXT.md`
|
|
||||||
- **App-docs domain:** `docs-site/CONTEXT.md`
|
|
||||||
|
|
||||||
Keep this file minimal. Do not duplicate detailed rules here.
|
|
||||||
|
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
Before marking work complete:
|
- [ ] Interview completed (or user opted out); implemented only approved choices
|
||||||
|
- [ ] Asked behavior proven (not only unit tests green)
|
||||||
- [ ] Tests written before implementation
|
- [ ] Stayed in scope (`toju-app` + targeted `electron` + CI) unless user expanded it
|
||||||
- [ ] All tests passing (`npm run test`, plus `npm run test:e2e` if behavior is user-visible)
|
- [ ] Appropriate targeted tests for logic changes
|
||||||
- [ ] `npm run lint` passes
|
- [ ] Lint via `npm run lint:fix` (not hand-fixed style); build only touched packages
|
||||||
- [ ] Affected package builds: `npm run build` / `npm run build:electron` / `cd server && npm run build`
|
- [ ] Docs only if contracts changed
|
||||||
- [ ] Naming conventions followed (kebab-case files; domain `*.rules.ts` / `*.model.ts` / `*.component.ts` suffixes)
|
- [ ] Lesson + index entry if corrected this session
|
||||||
- [ ] Errors handled
|
- [ ] If the thread is long and work remains: overwrite `HANDOFF.md` and ask user for a new chat
|
||||||
- [ ] Feature docs updated if contract/schema/invariant changed (see `agents-docs/AGENTS_FEATURES.md`)
|
- [ ] If work from an active handoff is finished: clear `HANDOFF.md` to `Status: none` (empty sections)
|
||||||
- [ ] `CONTEXT.md` updated if a domain term was resolved or introduced (see `agents-docs/AGENTS_CONTEXT.md`)
|
- [ ] PR when requesting merge (`Fixes #<n>` / `Relates to #<n>`)
|
||||||
- [ ] ADR written if a hard-to-reverse decision was made (see `agents-docs/AGENTS_ADRS.md`)
|
|
||||||
- [ ] Lesson recorded in `agents-docs/LESSONS.md` if this session produced a correction, revert, or hidden constraint (see triggers in `agents-docs/AGENT_WORKFLOW.md`)
|
|
||||||
- [ ] PR opened with summary and linked issues (`Fixes #<n>` / `Relates to #<n>`)
|
|
||||||
- [ ] Gitea Workflows checks passing
|
|
||||||
|
|||||||
@@ -1,110 +1,83 @@
|
|||||||
# Agent Workflow & Operating Instructions
|
# Agent Workflow & Operating Instructions
|
||||||
|
|
||||||
These rules apply to **all AI agents** working on this project, regardless of platform or model.
|
These rules apply to **all AI agents** working on this project.
|
||||||
|
|
||||||
Read this file at the start of every session.
|
**Token budget (mandatory):**
|
||||||
|
|
||||||
|
- Default scope: **`toju-app/` + targeted `electron/` + `.gitea/workflows/`** — see `/AGENTS.md`. No `server/`, `e2e/`, `website/`, `docs-site/` unless the user expands scope.
|
||||||
|
- Electron: follow the renderer → preload → one handler path; never dump the whole `electron/` tree.
|
||||||
|
- Prefer path-scoped search; one focused agent; no Bugbot / security / best-of-N unless asked.
|
||||||
|
- **Handoff > fat chats:** agents cannot open new chats. Write `agents-docs/HANDOFF.md` and ask the user to start a new chat with that file attached.
|
||||||
|
- **Named bugs:** `fix bug "…"` → one Obsidian note (`BUG_TRACKER.md` § Named fix), then default repo scope — not the whole vault or monorepo.
|
||||||
|
- **Models:** keep the user’s latest problem-solving model. Cut cost with scope + handoffs, not weaker models.
|
||||||
|
|
||||||
|
Do **not** re-read this whole file every turn after the first skim.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workflow Orchestration
|
## Workflow Orchestration
|
||||||
|
|
||||||
### 1. Plan Mode Default
|
### 1. Interview before implement (default)
|
||||||
|
|
||||||
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
|
- For bugs/features: short interview first (understanding, gaps, choices + recommended default, scope, proof) — then wait. See `/AGENTS.md` and `.cursor/rules/interview-before-fix.mdc`.
|
||||||
- If something goes sideways, STOP and re-plan immediately — don't keep pushing
|
- Do not guess past ambiguity; the user controls the implementation choices.
|
||||||
- Use plan mode for verification steps, not just building
|
- Skip only when the user opts out (“just fix it”) or an approved handoff already decided.
|
||||||
- Write detailed specs upfront to reduce ambiguity
|
|
||||||
|
|
||||||
### 2. Subagent Strategy
|
### 2. Plan mode
|
||||||
|
|
||||||
- Use subagents liberally to keep the main context window clean
|
- Use plan mode when architecture is unclear or the user asks — the interview often replaces a heavy plan for normal bug fixes.
|
||||||
- Offload research, exploration, and parallel analysis to subagents
|
- Skip long planning essays; prefer bullet choices.
|
||||||
- For complex problems, throw more compute at it via subagents
|
|
||||||
- One task per subagent for focused execution
|
|
||||||
|
|
||||||
### 3. Self-Improvement Loop
|
### 3. Subagents sparingly
|
||||||
|
|
||||||
The goal is a small, sharp file of project-specific rules in `agents-docs/LESSONS.md` that future sessions read and apply. The format of a lesson is defined at the top of `agents-docs/LESSONS.md` — read it before writing one.
|
- Default: one agent.
|
||||||
|
- Subagents only for true parallel search inside allowed paths.
|
||||||
|
- Never spawn extra review agents unless the user asks.
|
||||||
|
|
||||||
**Read at session start.** Open `agents-docs/LESSONS.md` and apply any rules that match the work you're about to do. This is non-optional; the file exists so the same mistake isn't made twice.
|
### 4. Handoff / short sessions
|
||||||
|
|
||||||
**Triggers — record a lesson when any of these happen.** Don't wait for a formal request; these are the signals:
|
Triggers: user says handoff / new chat; thread is long with more major work left; switching objectives.
|
||||||
|
|
||||||
- User says "no", "actually", "don't", "stop", "that's wrong", or "instead do X"
|
Action: **overwrite** (never append) `agents-docs/HANDOFF.md` with `Status: active` and short sections. Then stop major work and ask the user to open a new chat.
|
||||||
- User reverts, rewrites, or asks you to redo your edit
|
|
||||||
- User re-prompts you with the same or similar instruction (signal that the first attempt missed something)
|
|
||||||
- User points out a hidden constraint, past incident, or convention you didn't know
|
|
||||||
- Code review (human or `/review`) surfaces an issue caused by your approach
|
|
||||||
- You catch yourself about to do the same thing the project has been corrected on before
|
|
||||||
|
|
||||||
If unsure whether it's worth recording: write it. Sharper is better than missing, and grooming the file is cheap.
|
New chat: if handoff is active, read it first; continue Next steps; do not redo Completed work. If Next steps still need choices, re-interview — don’t invent them.
|
||||||
|
|
||||||
**Write before reporting done.** A session that produced a correction must produce a lesson — record it in the same turn the work is completed, not "later". The `AGENTS.md` completion checklist has a line for this; don't tick the box without it.
|
**When finished:** clear `HANDOFF.md` to `Status: none` with empty sections so the file stays tiny for the next session.
|
||||||
|
|
||||||
**Groom periodically.** When `agents-docs/LESSONS.md` passes ~20 entries, propose consolidations to the user — merge duplicates, delete rules that no longer apply, shorten anything vague.
|
### 5. Self-Improvement Loop
|
||||||
|
|
||||||
### 4. CONTEXT.md upkeep
|
**At session start:** `LESSONS-INDEX.md` only; open matching lesson bodies by tag.
|
||||||
|
|
||||||
Read `CONTEXT.md` (or `agents-docs/CONTEXT-MAP.md` → per-subdomain `CONTEXT.md`) when working in a subdomain. Use its vocabulary verbatim **where defined** in code, tests, issues, and commits. If a needed term isn't in the glossary, treat it as a trigger (see below) rather than silently inventing a synonym; the full contract lives in `agents-docs/AGENTS_CONTEXT.md`.
|
Record a lesson + index line when corrected. Prefer fewer sharp rules (~20).
|
||||||
|
|
||||||
**Triggers — capture vocabulary in the moment:**
|
### 6. CONTEXT.md upkeep
|
||||||
|
|
||||||
- A previously-ambiguous domain term gets a clear resolution → add it (one-sentence definition, aliases to avoid).
|
Default: `toju-app/CONTEXT.md`. Read `electron/CONTEXT.md` only when touching Electron. Other packages only when in scope.
|
||||||
- User corrects your terminology → record the correct term; mark the wrong one as an alias to avoid.
|
|
||||||
- A new feature introduces a concept absent from the glossary → add it before claiming the feature done.
|
|
||||||
- You catch yourself inventing a synonym because the right term isn't there → flag the gap; don't silently coin a new term.
|
|
||||||
|
|
||||||
**Write before reporting done.** Update the relevant `CONTEXT.md` in the same turn the trigger fires. Append-only — add new entries, don't reshuffle existing ones. The format is documented at the top of each `CONTEXT.md`. See `agents-docs/AGENTS_CONTEXT.md` for the full contract.
|
### 7. ADR upkeep
|
||||||
|
|
||||||
### 5. ADR upkeep
|
Only when hard-to-reverse + surprising + real trade-offs. Contract: `agents-docs/AGENTS_ADRS.md`.
|
||||||
|
|
||||||
Read `agents-docs/adr/` when about to change anything that crosses an existing decision boundary. If your work would contradict an ADR, surface it explicitly — never silently override.
|
### 8. Verification Before Done (behavior first)
|
||||||
|
|
||||||
**Triggers — write an ADR only when all three apply:**
|
Done = asked functionality works **as the user confirmed in the interview**. Unit green ≠ done for product asks.
|
||||||
|
|
||||||
- **Hard to reverse** (schema migration, framework swap, integration redesign).
|
### 9. Demand Elegance (Balanced)
|
||||||
- **Surprising without context** (future engineers will question the approach).
|
|
||||||
- **Result of genuine trade-offs** (real alternatives existed and you chose deliberately).
|
|
||||||
|
|
||||||
If all three apply: write the ADR in the same turn as the decision. Next number (4-digit zero-padded), kebab-case slug, Nygard short form — see `agents-docs/adr/0001-record-architectural-decisions.md` for the canonical example and `agents-docs/AGENTS_ADRS.md` for the contract. If any of the three is missing: don't write one.
|
One pause for non-trivial design; skip for obvious fixes once the user has chosen a direction.
|
||||||
|
|
||||||
**Supersede, don't delete.** Overturned decisions get a new ADR; the old one stays with a `Superseded by ADR-NNNN` note.
|
### 10. Bug fixing (after interview)
|
||||||
|
|
||||||
### 6. Verification Before Done
|
Implement the approved plan with evidence in default scope. If root cause is clearly outside scope, say so and ask to expand — don’t silently crawl.
|
||||||
|
|
||||||
- Never mark a task complete without proving it works
|
|
||||||
- Diff behavior between main and your changes when relevant
|
|
||||||
- Ask yourself: "Would a staff engineer approve this?"
|
|
||||||
- Run tests, check logs, demonstrate correctness
|
|
||||||
|
|
||||||
### 7. Demand Elegance (Balanced)
|
|
||||||
|
|
||||||
- For non-trivial changes: pause and ask "is there a more elegant way?"
|
|
||||||
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
|
|
||||||
- Skip this for simple, obvious fixes — don't over-engineer
|
|
||||||
- Challenge your own work before presenting it
|
|
||||||
|
|
||||||
### 8. Autonomous Bug Fixing
|
|
||||||
|
|
||||||
- When given a bug report: just fix it. Don't ask for hand-holding
|
|
||||||
- Point at logs, errors, failing tests — then resolve them
|
|
||||||
- Zero context switching required from the user
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|
||||||
This project hosts at Gitea (`git.azaaxin.com/myxelium/Toju`). Gitea PRs and issues use GitHub-style syntax.
|
Gitea: `git.azaaxin.com/myxelium/Toju`. Branch `<type>/<short-description>`; PR with summary + test plan; `Fixes #<n>` / `Relates to #<n>`.
|
||||||
|
|
||||||
- Create a feature branch for every change: `<type>/<short-description>` (e.g. `feat/add-retry-logic`, `fix/null-pointer-webhook`) — `<type>` should match the Conventional Commits prefix (`feat`, `fix`, `chore`, `docs`, `perf`, `refactor`, `test`)
|
|
||||||
- Open the PR via the Gitea web UI (or `tea pulls create` if `tea` CLI is installed) — include a summary and a test plan
|
|
||||||
- Link issues in the PR body with `Fixes #<number>` for auto-close or `Relates to #<number>` for reference (Gitea honors the same keywords as GitHub)
|
|
||||||
- After merge, delete the feature branch
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core Principles
|
## Core Principles
|
||||||
|
|
||||||
- **Simplicity First:** Make every change as simple as possible. Impact minimal code.
|
- **Simplicity First** · **No Laziness** · **Minimal Impact** · **Cheap Context** · **Default Scope Fence** · **Handoff Beats Fat Context** · **Interview Before Guessing**
|
||||||
- **No Laziness:** Find root causes. No temporary fixes. Senior developer standards.
|
|
||||||
- **Minimal Impact:** Changes should only touch what's necessary. Avoid introducing bugs.
|
|
||||||
|
|||||||
+25
-55
@@ -1,49 +1,49 @@
|
|||||||
# Obsidian Bug Tracker — Agent Contract
|
# Obsidian Bug Tracker — Agent Contract
|
||||||
|
|
||||||
User-maintained bug reports live outside the repo. Read this file when asked to triage, investigate, or work from the bug backlog.
|
User-maintained bug reports live outside the repo. Use this when the user names a bug or asks to triage the backlog.
|
||||||
|
|
||||||
**Overrides** `agents-docs/AGENT_WORKFLOW.md` §8 (Autonomous Bug Fixing) unless the user explicitly asks you to fix a bug in code.
|
**Inbox:** `/home/ludde/Nextcloud/Obsidian Vault/Log/Bugs/`
|
||||||
|
**Attachments:** `…/Bugs/attachments/<Bug title>/`
|
||||||
|
**Dashboard / template:** `…/Log/Create bug.md`, `…/Log/Templates/Bug Report.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Location
|
## Named fix (default — cheap path)
|
||||||
|
|
||||||
| Item | Path |
|
When the user says e.g. `fix bug "Images and files in chat doesn't load"`:
|
||||||
|------|------|
|
|
||||||
| Bug inbox | `/home/ludde/Nextcloud/Obsidian Vault/Log/Bugs/` |
|
1. **Resolve one note** under `Log/Bugs/` whose title matches (usually `Bug - <title>.md`). Do not list or read the whole inbox.
|
||||||
| Attachments | `…/Bugs/attachments/<Bug title>/` |
|
2. **Read that note** (Description, Steps, Expected/Actual, Logs). Read attachments only under that bug’s attachment folder if referenced.
|
||||||
| Dashboard | `/home/ludde/Nextcloud/Obsidian Vault/Log/Create bug.md` |
|
3. **Interview before implement** (see `/AGENTS.md` and `.cursor/rules/interview-before-fix.mdc`): restate the bug, list gaps, present choices with a recommended default, propose scope and proof of done — then **wait** for the user. Do not start coding until they answer (unless they said “just fix it”).
|
||||||
| Template | `/home/ludde/Nextcloud/Obsidian Vault/Log/Templates/Bug Report.md` |
|
4. Acceptance criteria = the note’s Expected Result **plus** the user’s interview answers.
|
||||||
|
5. **Fix in default repo scope** (`toju-app/` + targeted `electron/` + CI) per `AGENTS.md`. Do not crawl `server/` / `e2e/` / other packages unless the note or user clearly requires it — then ask once.
|
||||||
|
6. Prove the asked behavior (not only unit-green). Prefer a regression that encodes the note’s failure mode.
|
||||||
|
7. When done: set that note’s frontmatter `status` to `Resolved` (or `Closed` if the user prefers). Do not rewrite Description / Investigation / Resolution unless asked.
|
||||||
|
8. Long thread + more work left → write `agents-docs/HANDOFF.md` and ask for a new chat.
|
||||||
|
|
||||||
|
Do **not** re-read `BUG_TRACKER.md` every turn after the first use. Do **not** load the stale “open bugs” snapshot as truth — the vault files are source of truth.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Allowed actions on vault files
|
## Backlog triage only
|
||||||
|
|
||||||
Unless the user explicitly asks for more:
|
If the user asks to list/triage open bugs (not a named fix): `ls` / glob `Log/Bugs/*.md`, filter `status: Open`, summarize titles — still don’t open every body until they pick one.
|
||||||
|
|
||||||
1. **Change `status`** in a bug note's YAML frontmatter (`Open` → `Resolved` or `Closed`).
|
|
||||||
2. **Move files** (e.g. reorganize notes or attachments when instructed).
|
|
||||||
|
|
||||||
Do **not** edit other vault fields or sections (`Investigation`, `Resolution`, description, etc.) unless the user asks.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Allowed reads (unrestricted)
|
## Vault edit policy
|
||||||
|
|
||||||
To understand and solve bugs you may read freely:
|
Unless the user asks for more:
|
||||||
|
|
||||||
- All bug notes and attachments under `Log/Bugs/`
|
- **Allowed write:** frontmatter `status` (`Open` → `Resolved` / `Closed`); move files if they specify a convention.
|
||||||
- The full MetoYou repo (code, tests, logs, docs)
|
- **Do not edit:** other frontmatter fields, Description, Steps, Investigation, Resolution, etc.
|
||||||
- Runtime output, test results, and debug artifacts
|
|
||||||
|
|
||||||
Investigation findings belong in chat or in repo changes — not in the vault — unless the user asks you to update the note.
|
Investigation findings go in chat or the repo — not the vault — unless asked.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bug note format
|
## Bug note format
|
||||||
|
|
||||||
Each note is Markdown with YAML frontmatter:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
title: Bug - …
|
title: Bug - …
|
||||||
@@ -57,34 +57,4 @@ tags: [bug]
|
|||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
Body sections: **Description**, **Steps to Reproduce**, **Expected Result**, **Actual Result**, **Logs / Screenshots**, **Investigation**, **Resolution**.
|
Body: **Description**, **Steps to Reproduce**, **Expected Result**, **Actual Result**, **Logs / Screenshots**, **Investigation**, **Resolution**.
|
||||||
|
|
||||||
The dashboard (`Create bug.md`) uses Dataview; keep `type: bug` and `status` accurate so counts stay correct.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. List open bugs: `Glob` or `ls` on `…/Log/Bugs/*.md`, filter `status: Open`.
|
|
||||||
2. Read the note and any linked attachments.
|
|
||||||
3. Investigate in the repo (read-only toward the vault).
|
|
||||||
4. Report findings to the user.
|
|
||||||
5. Only when told to fix: implement in repo (TDD, lint, build per `AGENTS.md`).
|
|
||||||
6. When a bug is done: update vault `status` to `Resolved` or `Closed` (and move files if the user specifies a convention).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open bugs (snapshot 2026-06-10)
|
|
||||||
|
|
||||||
| Title | Priority | Environment |
|
|
||||||
|-------|----------|-------------|
|
|
||||||
| Attachments gets syncronized corrupt | Critical | All major clients |
|
|
||||||
| Chats doesn't sync for multi client users | High | All |
|
|
||||||
| No android app icon | High | Android |
|
|
||||||
| No login screen mobile phone on startup | High | Android, Android Browser |
|
|
||||||
| Fresh users have the server list in dashboard completely empty until anything searched | High | — |
|
|
||||||
| Video attachment on android gets sent in the message bubble above with no preview image | High | Android |
|
|
||||||
| Local files should be remembered by client | High | — |
|
|
||||||
| Emojis should be user bound not client bound | Medium | All |
|
|
||||||
|
|
||||||
Re-scan the folder at session start; this table is not auto-updated.
|
|
||||||
|
|||||||
+15
-10
@@ -45,9 +45,14 @@ The server package does not currently have a test runner script — there is one
|
|||||||
|
|
||||||
E2E tests exercise the real Electron app against the real signaling server. The `.agents/skills/playwright-e2e/SKILL.md` describes the convention this repo uses for E2E test design — read it before adding new tests.
|
E2E tests exercise the real Electron app against the real signaling server. The `.agents/skills/playwright-e2e/SKILL.md` describes the convention this repo uses for E2E test design — read it before adding new tests.
|
||||||
|
|
||||||
### TDD discipline
|
### Test-backed development (balanced)
|
||||||
|
|
||||||
Write the failing test first. Run it, watch it fail, then write the smallest code that makes it pass. This rule is non-negotiable (see `/AGENTS.md` § CRITICAL).
|
See `/AGENTS.md` § CRITICAL for the full policy. Summary:
|
||||||
|
|
||||||
|
- **Done** = the asked behavior works, proven at the right level. Green unit tests alone are not done when the ask was product behavior.
|
||||||
|
- Prefer red-green for domain/logic and bug invariants; encode the **user-facing or cross-boundary** outcome, not only implementation details.
|
||||||
|
- Skip full red-green for docs/copy/agent-instruction text, pure formatting, and trivial wiring already covered by a higher-level test you will run.
|
||||||
|
- Run **targeted** specs while iterating (`cd toju-app && npx vitest run <path>`); broaden to full suite / e2e when risk is cross-cutting or before merge.
|
||||||
|
|
||||||
Integration / cross-package work that needs a real database can rely on Electron's TypeORM + sql.js setup (in-memory by default) — no Testcontainers required.
|
Integration / cross-package work that needs a real database can rely on Electron's TypeORM + sql.js setup (in-memory by default) — no Testcontainers required.
|
||||||
|
|
||||||
@@ -191,8 +196,8 @@ cd server && npm run build # server tsc
|
|||||||
npm run build:all # all of the above
|
npm run build:all # all of the above
|
||||||
|
|
||||||
# --- lint / format ---
|
# --- lint / format ---
|
||||||
npm run lint # eslint .
|
npm run lint # eslint . (check only)
|
||||||
npm run lint:fix # format + sort:props + eslint --fix
|
npm run lint:fix # preferred: format + sort:props + eslint --fix
|
||||||
npm run format # prettier on Angular HTML templates only
|
npm run format # prettier on Angular HTML templates only
|
||||||
npm run format:check # prettier --check on HTML templates
|
npm run format:check # prettier --check on HTML templates
|
||||||
|
|
||||||
@@ -209,16 +214,16 @@ npm run migration:revert # roll back last
|
|||||||
|
|
||||||
Before marking work complete:
|
Before marking work complete:
|
||||||
|
|
||||||
- [ ] Tests written before implementation
|
- [ ] Tests written before implementation (balanced — see `/AGENTS.md`)
|
||||||
- [ ] All tests passing (`npm run test`, plus `npm run test:e2e` if behavior is user-visible)
|
- [ ] Asked behavior proven; targeted tests pass (`npm run test:e2e` only if in scope / user-visible and needed)
|
||||||
- [ ] `npm run lint` passes
|
- [ ] `npm run lint:fix` passes (do not hand-fix auto-fixable lint/format)
|
||||||
- [ ] Affected package builds: `npm run build` / `npm run build:electron` / `cd server && npm run build`
|
- [ ] Affected package builds: `npm run build` / `npm run build:electron` when those packages were touched
|
||||||
- [ ] Naming conventions followed
|
- [ ] Naming conventions followed
|
||||||
- [ ] Errors handled
|
- [ ] Errors handled
|
||||||
- [ ] Security considered (no secrets in code, no plaintext token logging, no IPC handler accepting arbitrary file paths)
|
- [ ] Security considered (no secrets in code, no plaintext token logging, no IPC handler accepting arbitrary file paths)
|
||||||
- [ ] Feature docs updated if contract/schema/invariant changed (see `agents-docs/AGENTS_FEATURES.md`)
|
- [ ] Feature docs updated if contract/schema/invariant changed (see `agents-docs/AGENTS_FEATURES.md`)
|
||||||
- [ ] `CONTEXT.md` updated if a domain term was resolved or introduced (see `agents-docs/AGENTS_CONTEXT.md`)
|
- [ ] `CONTEXT.md` updated if a domain term was resolved or introduced (see `agents-docs/AGENTS_CONTEXT.md`)
|
||||||
- [ ] ADR written if a hard-to-reverse decision was made (see `agents-docs/AGENTS_ADRS.md`)
|
- [ ] ADR written if a hard-to-reverse decision was made (see `agents-docs/AGENTS_ADRS.md`)
|
||||||
- [ ] Lesson recorded in `agents-docs/LESSONS.md` if this session produced a correction, revert, or hidden constraint (see triggers in `agents-docs/AGENT_WORKFLOW.md`)
|
- [ ] Lesson recorded in `agents-docs/LESSONS.md` + `LESSONS-INDEX.md` if this session produced a correction
|
||||||
- [ ] PR opened with summary and linked issues
|
- [ ] PR opened with summary and linked issues when requesting merge
|
||||||
- [ ] Gitea Workflows checks passing
|
- [ ] Gitea Workflows checks passing
|
||||||
|
|||||||
+15
-2
@@ -9,14 +9,27 @@ It must stay accurate as new features are introduced, renamed, merged, or remove
|
|||||||
## Feature list (alphabetical)
|
## Feature list (alphabetical)
|
||||||
|
|
||||||
- [App i18n](features/app-i18n.md) — `@ngx-translate/core` localization for the product client; English-only catalog today, same stack as the marketing website.
|
- [App i18n](features/app-i18n.md) — `@ngx-translate/core` localization for the product client; English-only catalog today, same stack as the marketing website.
|
||||||
|
- [Attachments](features/attachments.md) — P2P chunked file transfer over WebRTC data channels with Electron/Capacitor disk persistence.
|
||||||
- [Authentication](features/authentication.md) — signaling-server session tokens, protected REST/WebSocket identity, and client bearer storage.
|
- [Authentication](features/authentication.md) — signaling-server session tokens, protected REST/WebSocket identity, and client bearer storage.
|
||||||
- [Custom Emoji](features/custom-emoji.md) — peer-synced user-created emoji assets, chat reaction shortcuts, and composer emoji insertion.
|
- [Custom Emoji](features/custom-emoji.md) — peer-synced user-created emoji assets, chat reaction shortcuts, and composer emoji insertion.
|
||||||
|
- [Desktop Local API](features/desktop-local-api.md) — Electron localhost HTTP read API, auth proxy, and offline Docusaurus docs.
|
||||||
|
- [Direct Messaging](features/direct-messaging.md) — index entry; full contract in [Messaging](features/messaging.md).
|
||||||
|
- [Game Activity](features/game-activity.md) — RAWG game matching, Electron process detection, and P2P now-playing sync.
|
||||||
|
- [Invites & Join Requests](features/invites-join-requests.md) — invite links, HTML landing pages, and moderated join approval.
|
||||||
|
- [Klipy GIFs](features/klipy-gifs.md) — server-proxied GIF search for chat and DM composers.
|
||||||
|
- [Link Preview & Media Proxy](features/link-preview-media-proxy.md) — SSRF-guarded link unfurling and image proxy on the signaling server.
|
||||||
- [Message Integrity](features/message-integrity.md) — signed P2P message revision chains, inventory `headHash` convergence, and Ed25519 signing-key registration on the signaling server.
|
- [Message Integrity](features/message-integrity.md) — signed P2P message revision chains, inventory `headHash` convergence, and Ed25519 signing-key registration on the signaling server.
|
||||||
|
- [Messaging](features/messaging.md) — server-channel chat, direct messages, inventory sync, and DM delivery state machine.
|
||||||
- [Mobile Capacitor](features/mobile-capacitor.md) — Capacitor native shell, mobile infrastructure facades, and phone-specific call/chat/media integrations.
|
- [Mobile Capacitor](features/mobile-capacitor.md) — Capacitor native shell, mobile infrastructure facades, and phone-specific call/chat/media integrations.
|
||||||
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints (server) consumed by the `/dashboard` and `/servers` client pages.
|
- [Plugins](features/plugins.md) — client plugin runtime, server metadata API, Electron plugin data, and P2P message bus.
|
||||||
|
- [Push Notifications](features/push-notifications.md) — FCM/APNs device tokens on the server and Capacitor registration.
|
||||||
|
- [Server Directory](features/server-directory.md) — multi-endpoint catalog, REST CRUD/join/moderation, and room signal affinity.
|
||||||
|
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints consumed by `/dashboard` and `/servers`.
|
||||||
|
- [Signaling](features/signaling.md) — canonical WebSocket envelope catalog, ordering invariants, and relay rules.
|
||||||
- [Signal Server Tag](features/signal-server-tag.md) — configurable signal-server display tag shown on profile cards for a user's registration server.
|
- [Signal Server Tag](features/signal-server-tag.md) — configurable signal-server display tag shown on profile cards for a user's registration server.
|
||||||
|
- [Voice & WebRTC](features/voice-webrtc.md) — voice/camera/screen-share WebRTC with signaling relay and multi-device ownership.
|
||||||
|
|
||||||
The product client already documents its bounded contexts at `toju-app/src/app/domains/<name>/README.md` (Access Control, Attachment, Authentication, Chat, Direct Call, Direct Message, Experimental Media, Game Activity, Notifications, Plugins, Profile Avatar, Screen Share, Server Directory, Theme, Voice Connection, Voice Session). Those domain READMEs cover internal product-client behavior.
|
The product client also documents its bounded contexts at `toju-app/src/app/domains/<name>/README.md` (Access Control, Attachment, Authentication, Chat, Custom Emoji, Direct Call, Direct Message, Experimental Media, Game Activity, Notifications, Plugins, Profile Avatar, Screen Share, Server Directory, Theme, Voice Connection, Voice Session). Those domain READMEs cover internal product-client behavior.
|
||||||
|
|
||||||
`agents-docs/features/<slug>.md` is for **cross-context** contracts and feature areas that span more than one subdomain — WebSocket envelopes, IPC channels, plugin manifests, end-to-end flows that touch client + server + Electron together. Add an entry here the first time you write one.
|
`agents-docs/features/<slug>.md` is for **cross-context** contracts and feature areas that span more than one subdomain — WebSocket envelopes, IPC channels, plugin manifests, end-to-end flows that touch client + server + Electron together. Add an entry here the first time you write one.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Session Handoff
|
||||||
|
|
||||||
|
> **New chat:** attach `@agents-docs/HANDOFF.md`. Say: continue from this handoff; do not redo completed work.
|
||||||
|
|
||||||
|
**Status:** none
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
## Changed files
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
## Failed approaches
|
||||||
|
|
||||||
|
## Current issue
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
## Commands
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Agent Lessons — Index
|
||||||
|
|
||||||
|
**Session start:** read this file only. Match tags to the task. Open the matching lesson body in `agents-docs/LESSONS.md` — do **not** load every lesson.
|
||||||
|
|
||||||
|
When adding a lesson: append the full entry near the top of `LESSONS.md` (under `## Lessons`) **and** add one bullet here.
|
||||||
|
|
||||||
|
Tags help grepping: `rg '\\[attachments\\]' agents-docs/LESSONS-INDEX.md`
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
- A hold-on-unknown rule needs every attach site behind it — `[voice] [webrtc] [realtime]`
|
||||||
|
- Swap a live device with `replaceTrack`; an empty device list is missing evidence — `[voice] [webrtc] [devices]`
|
||||||
|
- One owner for a toggle the UI mirrors — `[voice] [state] [ui]`
|
||||||
|
- A timed-out sync round is not a clean one — `[messages] [realtime] [verification]`
|
||||||
|
- Derive a conversation id from canonical humans, never from the ids on the wire — `[direct-message] [identity]`
|
||||||
|
- Report whether a call event was delivered before showing a live call — `[direct-call] [verification]`
|
||||||
|
- Never spend a retry budget on attempts the transport cannot deliver — `[realtime] [recovery]`
|
||||||
|
- Repair a dead data channel on the live connection before rebuilding the peer — `[realtime] [webrtc] [recovery]`
|
||||||
|
- Compare peer ids only within one signal server's identity space — `[realtime] [identity] [webrtc]`
|
||||||
|
- Reproduce initiator/glare bugs with a simultaneous reconnect, not staggered joins — `[testing] [realtime] [webrtc]`
|
||||||
|
- Keep `NgOptimizedImage` off runtime blob and data URLs — `[angular] [images]`
|
||||||
|
- Read the exact Obsidian bug note for `fix bug "…"` (one note only) — `[workflow] [bugs]`
|
||||||
|
- Run `npm run i18n:sync` after editing any `public/i18n/catalog/*.json` file — `[i18n] [testing]`
|
||||||
|
- Match direct-call recipients against every local identity alias, exactly like DMs already do — `[direct-call] [identity]`
|
||||||
|
- Resolve outbound direct-call recipient ids to the peer's connected signal identity — `[direct-call] [identity] [signaling]`
|
||||||
|
- Decide attachment receive admission once at request time; never re-gate size in the chunk handler — `[attachments]`
|
||||||
|
- Re-queue attachment auto-downloads on every message/room binding event; never trust one transport's ordering — `[attachments] [realtime]`
|
||||||
|
- Scope per-user UI state by user id, not by the client database — `[persistence] [multi-user] [custom-emoji]`
|
||||||
|
- Don't strand signed-out mobile users on a logged-out dashboard — `[auth] [mobile] [routing]`
|
||||||
|
- "Shared from your device" must gate on local bytes, not uploader user id — `[attachments] [multi-device]`
|
||||||
|
- Generate Android brand icons from the source mark; guard against stock Capacitor placeholders — `[mobile] [android] [assets]`
|
||||||
|
- Bind chat attachments to a pre-allocated message id, never by matching content — `[attachments] [chat] [mobile]`
|
||||||
|
- Attachment file persistence must be platform-agnostic, not Electron-only — `[attachments] [persistence] [mobile]`
|
||||||
|
- Never count duplicate chunks toward transfer progress, and never finalize on byte counters — `[attachments] [webrtc]`
|
||||||
|
- Don't bump E2E timeouts for sync flakes - gate on presence and read server logs — `[testing] [realtime]`
|
||||||
|
- When renaming an Angular route, sweep every navigate/url-match/doc reference — `[routing]`
|
||||||
|
- Server discovery must fan out across all endpoints and self-heal on 404 — never hardcode a host capability blocklist — `[server-directory]`
|
||||||
|
- Server registration needs `ownerPublicKey: oderId || id`, and must not be fire-and-forget — `[server-directory] [rooms]`
|
||||||
|
- Identify must fall back to the legacy session token, not only the new credential store — `[realtime] [authentication]`
|
||||||
|
- Keep the per-signal-URL identify credential resolvable from the store — `[realtime] [authentication]`
|
||||||
|
- Store clientInstanceId in sessionStorage not localStorage — `[realtime] [multi-device]`
|
||||||
|
- Revalidate IndexedDB scope without reinitializing on every read — `[persistence] [performance]`
|
||||||
|
- Restore local user scope before protected writes — `[authentication] [persistence]`
|
||||||
|
- Persisted local user state still requires a session token — `[authentication] [signaling]`
|
||||||
|
- Declare MODIFY_AUDIO_SETTINGS for Android WebRTC mic capture — `[mobile] [android]`
|
||||||
|
- Do not override Tailwind with box-sizing inherit — `[mobile] [css]`
|
||||||
|
- Use the app-shell servers rail for mobile discovery pages — `[mobile] [layout]`
|
||||||
|
- Defer attachment blob hydration on Electron startup — `[attachments] [electron]`
|
||||||
|
- Lazy-load Capacitor modules on Electron/desktop — `[mobile] [electron]`
|
||||||
|
- Use the upgrade transaction during IndexedDB schema migrations — `[persistence] [browser]`
|
||||||
|
- Wait for authenticateUser storage prep before post-login navigation — `[authentication] [browser]`
|
||||||
|
- Use dense arrays for chunked transfer buffers — `[custom-emoji] [webrtc]`
|
||||||
|
- Route custom emoji right-click through the native context menu — `[custom-emoji] [ux]`
|
||||||
|
- Separate known emoji assets from saved library — `[custom-emoji] [ux]`
|
||||||
|
- Chunk custom emoji assets over data channels — `[custom-emoji] [webrtc]`
|
||||||
|
- Re-clear visible notification channels after recompute — `[notifications] [startup]`
|
||||||
|
- Disambiguate nested chat cards — `[chat] [ui]`
|
||||||
|
- Use terminal Vitest when the test tool hangs — `[testing]`
|
||||||
|
- Do not add fake chrome around screenshots — `[website] [design]`
|
||||||
|
- Verify lint exits 0 before claiming done — `[verification]`
|
||||||
|
- Prefer `npm run lint:fix` over hand-fixing lint/format — `[verification] [lint] [tokens]`
|
||||||
|
- Use blob URLs for inline attachment previews — `[attachments] [electron]`
|
||||||
|
- Resolve Electron drag-and-drop file paths with webUtils — `[attachments] [electron]`
|
||||||
|
- Preserve uploader local attachment paths across sync — `[attachments] [persistence]`
|
||||||
|
- Interview before coding; don’t guess the fix — `[workflow] [bugs] [tokens]`
|
||||||
|
- Prove the asked behavior; unit-green is not done — `[verification] [testing] [workflow]`
|
||||||
|
- Default to `toju-app/` + targeted `electron/` + CI; do not crawl the monorepo — `[workflow] [tokens] [scope]`
|
||||||
|
- Write/overwrite HANDOFF.md for new chats; clear it when the task finishes — `[workflow] [tokens] [handoff]`
|
||||||
|
- An outage test that only re-checks the end state is not a guard — `[testing] [verification] [webrtc]`
|
||||||
|
- coturn hands out a relay candidate but refuses loopback peers by default — `[testing] [webrtc] [turn]`
|
||||||
|
- Pin a chosen media device with `deviceId: { exact }`, never a bare string — `[webrtc] [media] [electron]`
|
||||||
|
- A second dev Electron window needs its own `--user-data-dir` — `[electron] [dev-shell]`
|
||||||
|
- Never answer `second-instance` by relaunching the app — `[electron] [dev-shell]`
|
||||||
|
- `ERR_FAILED (-2)` on a dev `loadURL` usually means aborted, not unreachable — `[electron] [dev-shell]`
|
||||||
|
- Never gate a presence indicator on the observer's own participation — `[ui] [voice] [webrtc]`
|
||||||
|
- Never test suspend/resume against a live-reloading dev server — `[testing] [dev-shell] [verification]`
|
||||||
|
- Keep diagnostic history outside the page you are diagnosing — `[testing] [verification]`
|
||||||
|
- Record whether the user is in a call before calling zero RTP a failure — `[testing] [voice] [verification]`
|
||||||
|
- Assert continuity when the state you broke repairs itself — `[testing] [voice] [verification]`
|
||||||
|
- Missing gossip about a peer is not evidence it left voice — `[voice] [webrtc] [realtime]`
|
||||||
|
- `app.commandLine.appendSwitch` cannot disable the Chromium sandbox — `[electron] [packaging] [linux]`
|
||||||
|
|
||||||
+250
-5
@@ -1,12 +1,12 @@
|
|||||||
# Agent Lessons
|
# Agent Lessons
|
||||||
|
|
||||||
Durable rules for AI agents working on this project. Read this file at session start. Append to it when this session produces a correction worth remembering.
|
Durable rules for AI agents working on this project.
|
||||||
|
|
||||||
## How to use this file
|
## How to use this file
|
||||||
|
|
||||||
**At session start:** scan the rules below. If any match the work you're about to do, apply them.
|
**At session start:** read `agents-docs/LESSONS-INDEX.md` only. Open lesson bodies here **only** for tags that match the task. Do not load this entire file into context by default.
|
||||||
|
|
||||||
**During the session:** if the user corrects you, reverts your edit, or re-prompts with the same instruction — that is a signal to record a lesson before closing the task. See the trigger list in `agents-docs/AGENT_WORKFLOW.md`.
|
**During the session:** if the user corrects you, reverts your edit, or re-prompts with the same instruction — record a lesson here **and** add a one-line entry to `LESSONS-INDEX.md` before closing the task. See triggers in `agents-docs/AGENT_WORKFLOW.md`.
|
||||||
|
|
||||||
**Format of a lesson:** every entry uses the four-slot template below. Brevity matters — if you can't state the rule in one sentence, the lesson isn't sharp enough yet.
|
**Format of a lesson:** every entry uses the four-slot template below. Brevity matters — if you can't state the rule in one sentence, the lesson isn't sharp enough yet.
|
||||||
|
|
||||||
@@ -25,6 +25,244 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
|
|
||||||
## Lessons
|
## Lessons
|
||||||
|
|
||||||
|
### `app.commandLine.appendSwitch` cannot disable the Chromium sandbox [electron] [packaging] [linux]
|
||||||
|
|
||||||
|
- **Trigger:** a packaged Linux build shows only the window background and spams `Unable to access(W_OK|X_OK) /tmp` / `Creating shared memory in /tmp/... failed`, while the same build works when the user types `--no-sandbox`.
|
||||||
|
- **Rule:** sandbox and Ozone switches only count when they are on the real command line at process start. Never pair a runtime `appendSwitch('no-sandbox')` with `appendSwitch('disable-dev-shm-usage')` — the first is a no-op because the zygote has already forked, the second takes effect and redirects shared memory into `/tmp`, which the still-active sandbox denies forever.
|
||||||
|
- **Why:** electron-builder's AppImage `AppRun` is a bash script that execs `$APPDIR/<executableName> "$@"` and ignores the bundled desktop entry, so `linux.executableArgs` never reaches a double-click or terminal launch. Only an installed `.desktop` file passes those arguments.
|
||||||
|
- **Example:** `electron/app/linux-launcher.rules.ts` generates the launcher that `tools/after-pack.js` installs in place of the real binary (renamed `<name>-bin`); it enables `--no-sandbox` only where unprivileged user namespaces are denied (Ubuntu 24.04+ AppArmor, hardened kernels), since an AppImage payload is mounted `nosuid` and cannot fall back to the SUID helper.
|
||||||
|
|
||||||
|
### A hold-on-unknown rule needs every attach site behind it [voice] [webrtc] [realtime]
|
||||||
|
|
||||||
|
- **Trigger:** replacing a strict media gate with "hold an established path when nothing confirms the peer", while some fast path still attaches the track without asking the rule.
|
||||||
|
- **Rule:** route every attach site — including the pre-offer shortcut in `createPeerConnection` and every sibling media kind — through the same decision, and refresh all of them on each piece of new evidence.
|
||||||
|
- **Why:** an ungated attach becomes the guess the rule then protects: the track alone reads as an established path, so `hold` keeps sending indefinitely to a peer that never joined the channel. The strict gate used to erase that mistake on the next pass.
|
||||||
|
- **Example:** `MediaManager.mayOpenVoicePathToPeer()` gates the first offer in `create-peer-connection.ts`, `syncCameraRouting()` reuses `decideVoicePathRouting`, and `notePeerVoiceReport()` calls `refreshVoiceRouting()` so a departure report reaches the camera too, not just the mic.
|
||||||
|
|
||||||
|
### Missing gossip about a peer is not evidence it left voice [voice] [webrtc] [realtime]
|
||||||
|
|
||||||
|
- **Trigger:** gating an outgoing media track on the observer's store copy of the *remote* user's `voiceState`, and detaching whenever that copy is absent.
|
||||||
|
- **Rule:** close a negotiated media path only on positive evidence — we left voice, the peer itself reported leaving or another channel, or the connection is gone; treat absence as unknown and hold the path. Opening a path still requires confirmation, so a guess never starts sending the microphone.
|
||||||
|
- **Why:** the signal server broadcasts `user_left` for any socket it declares dead, so a suspend or a flaky hop wipes that copy while the peer is still in the channel. The observer then detached its mic permanently — a silent member with no way back through the UI, not even by toggling mute.
|
||||||
|
- **Example:** `decideVoicePathRouting()` in `toju-app/src/app/domains/voice-session/domain/logic/voice-path-routing.rules.ts`, used by `MediaManager.syncVoiceRouting()` for the mic and `mayHearPeerVoice()` for playback gain.
|
||||||
|
|
||||||
|
### Assert continuity when the state you broke repairs itself [testing] [voice] [verification]
|
||||||
|
|
||||||
|
- **Trigger:** proving a media cut by wiping a peer from the roster and then checking that audio still flows.
|
||||||
|
- **Rule:** when the broken state is refreshed by a periodic message, sample the victim second by second across the window instead of asserting an end state.
|
||||||
|
- **Why:** peers gossip their voice state every 5s (`VOICE_HEARTBEAT_INTERVAL_MS`), so the roster heals moments after the wipe and the mic re-attaches; the end-state check passed against the *unfixed* code and proved nothing. Only a per-second sample showed the peer losing audio.
|
||||||
|
- **Example:** `assertUninterruptedInboundAudio(peer, 10)` in `e2e/tests/voice/roster-loss-preserves-voice.spec.ts` fails on the first silent second; the earlier `assertTwoWayAudio` after the wipe did not.
|
||||||
|
|
||||||
|
### Never test suspend/resume against a live-reloading dev server [testing] [dev-shell] [verification]
|
||||||
|
|
||||||
|
- **Trigger:** suspending the machine to check whether a voice call survives sleep/wake, with the windows served by `ng serve`.
|
||||||
|
- **Rule:** disable the dev server's reload before any suspend/resume test (`LIVE_RELOAD=false npm run dev`), and treat a renderer reload in the results as an invalid run rather than a product finding.
|
||||||
|
- **Why:** `ng serve --ssl` runs Vite over HTTP/2; the suspend destroys that stream, so on resume Vite throws `The stream has been destroyed` from `viteTransformMiddleware` into the error overlay of every window, and its live-reload client reloads the page. The reload re-bootstraps the app out of the call, so the post-resume readings showed a "connected" peer with zero RTP — which looks exactly like a silently dead call but only meant the reloaded app was no longer in voice.
|
||||||
|
- **Example:** `dev.sh` appends `--live-reload=false` when `LIVE_RELOAD=false`; the first P7.4 attempt produced 20 `audio stalled` lines that proved nothing.
|
||||||
|
|
||||||
|
### Keep diagnostic history outside the page you are diagnosing [testing] [verification]
|
||||||
|
|
||||||
|
- **Trigger:** collecting samples into a `window.__probe` array in the DevTools console, then reading them back after the disruptive event.
|
||||||
|
- **Rule:** persist probe samples to `localStorage` (or outside the renderer entirely) and stamp each sample with a per-load id, so a reload keeps the history and becomes visible evidence instead of silent data loss.
|
||||||
|
- **Why:** the event under test is often the very thing that destroys in-heap state; a reload wiped every pre-suspend sample while leaving the old console lines on screen, so the probe looked loaded but `__voiceProbe` was undefined and the baseline was gone.
|
||||||
|
- **Example:** `tools/voice-probe.js` stores samples under `metoyou_voice_probe_v1` and reports `RENDERER RELOADED` when `performance.timeOrigin` changes between samples.
|
||||||
|
|
||||||
|
### Record whether the user is in a call before calling zero RTP a failure [testing] [voice] [verification]
|
||||||
|
|
||||||
|
- **Trigger:** asserting on inbound/outbound audio packets without also recording voice membership and local mic track state.
|
||||||
|
- **Rule:** capture `isVoiceConnected()` and the local audio tracks' `readyState` in the same sample as the RTP counters, and only call a stall a stall when the client is supposed to be in voice.
|
||||||
|
- **Why:** peer connections exist for chat data channels regardless of voice, so "connected with zero audio" is the normal reading outside a call; without the voice flag the two cases are indistinguishable and a healthy app looks broken.
|
||||||
|
- **Example:** `readLocalMedia()` in `tools/voice-probe.js` logs `in-voice mic=live`, and the stall check is gated on `current.voice === 'in-voice'`.
|
||||||
|
|
||||||
|
### Never answer `second-instance` by relaunching the app [electron] [dev-shell]
|
||||||
|
|
||||||
|
- **Trigger:** making a second dev launch reuse the open window by restarting the running instance (`app.relaunch(); app.exit(0)`).
|
||||||
|
- **Rule:** handle a second instance in place — focus and `webContents.reloadIgnoringCache()` — and never relaunch the process from the `second-instance` handler.
|
||||||
|
- **Why:** the relaunched successor inherits the same dev argument and asks for the single-instance lock while the dying parent still holds it, so it is refused as yet another second instance and the pair respawns forever; every generation also exits `0` instead of the launcher's handoff code, so `concurrently --kill-others` tears down `ng serve` and the API server, and an in-flight `loadURL` dies as `ERR_FAILED (-2)` that reads like an unreachable dev server.
|
||||||
|
- **Example:** `resolveSecondInstanceAction()` in `electron/app/second-instance.rules.ts` returns `'reload-existing'`, and `deep-links.ts` reloads instead of relaunching.
|
||||||
|
|
||||||
|
### Never gate a presence indicator on the observer's own participation [ui] [voice] [webrtc]
|
||||||
|
|
||||||
|
- **Trigger:** writing `if (!isUserInCurrentVoiceRoom(...)) return false` before reading a remote user's share/camera state.
|
||||||
|
- **Rule:** decide a remote indicator from the observed user's state alone; keep the observer's own session out of the input entirely.
|
||||||
|
- **Why:** a user sharing alone in a voice channel looked idle to everyone outside it, so nobody could tell there was anything to watch — while the peer plane had already delivered the announcement, because `screen-state` goes to every open data channel and not just voice participants.
|
||||||
|
- **Example:** `shouldShowStreamIndicator()` in `domains/voice-session/domain/logic/stream-indicator.rules.ts`; guarded by `e2e/tests/screen-share/outside-voice-live-indicator.spec.ts`, where the observer never joins voice.
|
||||||
|
|
||||||
|
### `ERR_FAILED (-2)` on a dev `loadURL` usually means aborted, not unreachable [electron] [dev-shell]
|
||||||
|
|
||||||
|
- **Trigger:** blaming the cert or `ng serve` when Electron logs `ERR_FAILED (-2) loading 'https://127.0.0.1:4200'`.
|
||||||
|
- **Rule:** read the rejection stack — `stopLoadingListener` means the navigation was stopped (window destroyed, app exiting), so look for whatever killed the process; `SSL=true` already appends `ignore-certificate-errors`.
|
||||||
|
- **Why:** the cert and the dev server were fine; the app was exiting underneath the load, and chasing TLS wasted the first pass at the bug.
|
||||||
|
- **Example:** `loadDevelopmentClientWithRetry()` in `electron/window/dev-client-load.rules.ts` retries and never throws, so the window still gets its listeners and shows a readable failure page.
|
||||||
|
|
||||||
|
### Pin a chosen media device with `deviceId: { exact }`, never a bare string [webrtc] [media] [electron]
|
||||||
|
|
||||||
|
- **Trigger:** the user picks a different microphone or camera and nothing changes — not mid-call, not after leaving and rejoining voice.
|
||||||
|
- **Rule:** build `getUserMedia` constraints as `deviceId: { exact: id }`, and handle `OverconstrainedError` / `NotFoundError` by retrying once with the system default.
|
||||||
|
- **Why:** a bare `deviceId: id` is an `ideal` constraint, so Chromium may satisfy it with the device it already had; the feature then looks broken while every unit test passes. `exact` makes the request fail loudly instead, which is why it needs the explicit fallback so an unplugged device degrades rather than killing the call.
|
||||||
|
- **Example:** `buildMicrophoneConstraints` in `audio-device-selection.rules.ts` plus the single retry with `SYSTEM_DEFAULT_AUDIO_DEVICE_ID` in `media.manager.ts` `captureMicrophone` and `direct-call.service.ts` `captureCallMicrophone`.
|
||||||
|
|
||||||
|
### A second dev Electron window needs its own `--user-data-dir` [electron] [dev-shell]
|
||||||
|
|
||||||
|
- **Trigger:** launching a second desktop instance for a two-user test; the existing window blinks and reloads and no second window appears.
|
||||||
|
- **Rule:** launch the peer with its own `--user-data-dir` (`npm run dev:peer`), and never launch the desktop shell from an agent shell.
|
||||||
|
- **Why:** Electron's single-instance lock is scoped to the `userData` directory, so a default-directory launch hands its argv to the running instance instead; `tools/launch-electron.js` always appends `--metoyou-dev-reload-existing`, and the `second-instance` handler in `electron/app/deep-links.ts` answers that with `app.relaunch(); app.exit(0)`. Separate data dirs are also what give the two windows separate identities.
|
||||||
|
- **Example:** `dev-peer.sh` — `--user-data-dir="$DIR/.dev-userdata/$PEER_NAME"`.
|
||||||
|
|
||||||
|
### An outage test that only re-checks the end state is not a guard [testing] [verification] [webrtc]
|
||||||
|
|
||||||
|
- **Trigger:** writing or trusting a test that breaks something (kills a server, closes a channel), then asserts the feature works again afterwards.
|
||||||
|
- **Rule:** also assert what must **not** have happened in between — for a call, that the `RTCPeerConnection` was never rebuilt (`countCreatedPeerConnections` unchanged) — and prove the assertion by temporarily injecting the regression.
|
||||||
|
- **Why:** re-checking only the end state passes for a client that tore the call down and rebuilt it, which the user hears as a dropped call. Injecting `peerManager.closeAllPeers()` on signaling reconnect kept every audio and peer-count assertion green; only the connection-count assertion failed.
|
||||||
|
- **Example:** `e2e/tests/voice/recovery-preserves-media.spec.ts` — "The call was never rebuilt behind the user back" compares counts captured before `testServer.kill()`.
|
||||||
|
|
||||||
|
### coturn hands out a relay candidate but refuses loopback peers by default [testing] [webrtc] [turn]
|
||||||
|
|
||||||
|
- **Trigger:** a relay-only test (`iceTransportPolicy: 'relay'`) where candidates gather fine but every peer connection ends up `closed`.
|
||||||
|
- **Rule:** run a local coturn with `--allow-loopback-peers` (plus `--log-file=stdout --verbose`, or `docker logs` stays empty and readiness cannot be observed).
|
||||||
|
- **Why:** without it coturn still allocates and Chrome still reports a `typ relay` candidate, so the failure looks like broken app code rather than a blocked relay; connectivity checks to the other 127.x browser are simply dropped.
|
||||||
|
- **Example:** `e2e/helpers/turn-server.ts` — `--allow-loopback-peers` next to `--relay-ip=127.0.0.1`.
|
||||||
|
|
||||||
|
### Swap a live device with `replaceTrack`; an empty device list is missing evidence [voice] [webrtc] [devices]
|
||||||
|
|
||||||
|
- **Trigger:** a settings picker changes a capture device (mic, camera) while a session is live, or code reacts to `devicechange` by re-reading `enumerateDevices()`.
|
||||||
|
- **Rule:** re-capture, then `replaceTrack` on the existing senders and stop the old track — never tear the session down and rejoin. Treat an empty (or id-less) device list as *no information*: only fall back to the system default when a populated list proves the saved id is gone. Ask for `deviceId` as a preference, not `exact`.
|
||||||
|
- **Why:** `voice-controls.component.ts` called `disconnect()` then `connect()` for a mic change, so every peer saw a leave/rejoin and the user lost the channel; the settings pickers wrote `localStorage` and applied nothing. `enumerateDevices()` returns `[]` before microphone permission is granted and Firefox never lists audio outputs, so "not in the list" would silently reset a valid choice on startup. A plain track swap on an already negotiated sender needs no SDP exchange, so the swap is invisible to peers.
|
||||||
|
- **Example:** `MediaManager.switchInputDevice()` + `resolveAudioDeviceSelection()` / `buildMicrophoneConstraints()` in `domains/voice-session/domain/logic/audio-device-selection.rules.ts`, owned by `VoiceAudioDeviceService`; proven by `e2e/tests/voice/live-input-device-change.spec.ts` (outbound audio keeps flowing, no rejoin broadcast).
|
||||||
|
|
||||||
|
### One owner for a toggle the UI mirrors [voice] [state] [ui]
|
||||||
|
|
||||||
|
- **Trigger:** two surfaces (in-channel controls and a settings modal, a tray and a window) each keep a local `signal` for the same boolean — mute, deafen, camera on.
|
||||||
|
- **Rule:** keep the state where the effect happens and let every surface read it back through a `computed`; never reset a mirror to a hardcoded value on teardown.
|
||||||
|
- **Why:** `MediaManager` owned `isMicMuted` / `isSelfDeafened`, but `voice-controls.component.ts` kept its own copies and reset them to `false` in `disconnect()`, so after leaving voice the button said unmuted while the track was still disabled — and playback was un-deafened behind the user's back.
|
||||||
|
- **Example:** `isMuted = computed(() => this.webrtcService.isMuted())` in `voice-controls.component.ts`; `disconnect()` passes the real state into `voicePlayback.updateDeafened()`.
|
||||||
|
|
||||||
|
### A timed-out sync round is not a clean one [messages] [realtime] [verification]
|
||||||
|
|
||||||
|
- **Trigger:** deciding a poll/backoff cadence (sync, presence, reconciliation) from a timeout firing with nothing received, or from a fire-and-forget send that "asked" every peer.
|
||||||
|
- **Rule:** model the round — who was actually reached, who replied, what they reported — and let only a fully answered round with nothing outstanding buy the slow cadence; re-arm the timer from the verdict of the round that just closed, never from the previous one.
|
||||||
|
- **Why:** `messages-sync.effects.ts` set `lastSyncClean = true` inside `syncTimeout$`, so a round nobody answered dropped the poll from 10s to 15min; `sendToPeer` also returned `void` and only logged when the channel was closed, so peers listed in `getConnectedPeers()` (filled at `connectionState === 'connected'`, before the data channel opens) counted as asked. On top of that, `repeat({ delay })` read the flag at emission time, so a round that discovered missing ids was already committed to a 15-minute wait.
|
||||||
|
- **Example:** `message-sync-round.rules.ts` (`createInventoryRound` / `recordInventoryReply` / `isInventoryRoundClean`) plus `messages-sync.effects.spec.ts`, which advances fake timers and asserts the fast cadence survives silence, a partial answer, an undelivered request, and a late reply reporting missing ids.
|
||||||
|
|
||||||
|
### Derive a conversation id from canonical humans, never from the ids on the wire [direct-message] [identity]
|
||||||
|
|
||||||
|
- **Trigger:** building or trusting a composite id (DM thread, call id, dedupe key) made of participant ids that arrived in a payload or came from a roster entry.
|
||||||
|
- **Rule:** resolve every id through an alias index first (`buildDirectParticipantAliasIndex` → `getCanonicalDirectConversationId` / `canonicalizeDirectConversationId`), and collapse already-stored alias copies on first touch instead of only fixing new ones.
|
||||||
|
- **Why:** `getDirectConversationId` sorted the raw pair, so a peer who addressed the local user by a provisioned foreign actor id produced a second thread; the recipient saw two conversations for one human and clicking the peer opened the empty one. Matching aliases for *admission* was already in place, which made the fork look like a delivery bug instead of an id bug.
|
||||||
|
- **Example:** `e2e/tests/chat/cross-signal-dm-identity.spec.ts` fails with `element(s) not found` for the peer's message the moment the self-alias group is dropped from `DirectMessageService.participantAliasIndex()`.
|
||||||
|
|
||||||
|
### Report whether a call event was delivered before showing a live call [direct-call] [verification]
|
||||||
|
|
||||||
|
- **Trigger:** calling a fire-and-forget send (`sendCallEvent`, broadcast, notify) and then moving the UI into the success state.
|
||||||
|
- **Rule:** return the transport result, ring before joining local media, and surface "reached nobody" through the same error signal the view already renders.
|
||||||
|
- **Why:** `startCall` joined voice first and dropped the boolean from `PeerDeliveryService.sendCallEvent`, so a call to an unreachable peer showed the caller in a live-looking session that would never connect.
|
||||||
|
- **Example:** `DirectCallService.ringParticipants` sets `deliveryError` (`call.errors.ringUndelivered`) and `private-call.component.ts` folds it into `callErrorMessage`; the e2e drives it with `window.simulateOffline()` on the caller.
|
||||||
|
|
||||||
|
### Never spend a retry budget on attempts the transport cannot deliver [realtime] [recovery]
|
||||||
|
|
||||||
|
- **Trigger:** writing or reviewing a bounded retry loop (peer reconnect, resync, delivery) that counts attempts before checking whether the channel it needs is even available.
|
||||||
|
- **Rule:** check the dependency first and defer without counting; spend an attempt only when it can actually reach the far side, and when the budget really does run out publish a state the UI can show and re-arm the loop when the dependency returns.
|
||||||
|
- **Why:** `peer-recovery.ts` incremented `reconnectAttempts` before `isSignalingConnected()`, so a ~60s signal outage burned all 12 attempts doing nothing, then cleared the timer and deleted the tracker entry with no user-visible state and no re-arm — the peer stayed dead until an unrelated roster event happened to heal it.
|
||||||
|
- **Example:** `schedulePeerReconnect` now defers while signaling is down, emits `peerRecoveryStatus$` `{ status: 'failed' }` at exhaustion, and `resumeStalledPeerRecovery()` re-arms from `handleSignalingConnectionStatus`.
|
||||||
|
|
||||||
|
### Repair a dead data channel on the live connection before rebuilding the peer [realtime] [webrtc] [recovery]
|
||||||
|
|
||||||
|
- **Trigger:** handling a closed/failed `RTCDataChannel` by tracking the peer as disconnected and rebuilding the whole `RTCPeerConnection`.
|
||||||
|
- **Rule:** while the connection is still `connected`, have the deterministically elected initiator create a replacement channel on that same connection (no renegotiation needed — the SCTP transport is already up) and let the other side adopt the incoming channel; rebuild only as the fallback when the replacement never opens.
|
||||||
|
- **Why:** the control channel dying took voice, camera, and screen share down with it, and `replaceDataChannel` was already implemented and wired but never called — the spec asserted `not.toHaveBeenCalled()` and the README described the soft replacement as if it shipped.
|
||||||
|
- **Example:** `e2e/tests/voice/recovery-preserves-media.spec.ts` asserts the created-`RTCPeerConnection` count stays at 1 per peer after `closeOpenDataChannels`; forcing the rebuild path makes it fail.
|
||||||
|
|
||||||
|
### Compare peer ids only within one signal server's identity space [realtime] [identity] [webrtc]
|
||||||
|
|
||||||
|
- **Trigger:** about to compare a remote `peerId` / roster `oderId` against a local id — deterministic initiator election, offer-collision politeness, reconnect election, self-filtering, or the `oderId` stamped into a voice/camera/screen payload.
|
||||||
|
- **Rule:** resolve the local id for that peer's signal server (`getLocalOderIdForSignalUrl` where the `signalUrl` is in hand, `getIdentifyCredentialsForPeer` inside the peer manager) and elect roles only through `peer-role.rules.ts`; never reach for the home credential.
|
||||||
|
- **Why:** one human has a different actor id per signal server, so a home-vs-foreign comparison is not antisymmetric — both peers offer (glare) or neither does until the 5s takeover, which is the "some users can't hear each other" report. It also makes your own foreign roster entry fail the self-check, so the client tries to peer with itself.
|
||||||
|
- **Example:** `realtime-session.service.ts` wired `getLocalOderId` to `getIdentifyCredentials()` (always home) while `shouldInitiatePeer` compared it against foreign roster ids.
|
||||||
|
|
||||||
|
### Reproduce initiator/glare bugs with a simultaneous reconnect, not staggered joins [testing] [realtime] [webrtc]
|
||||||
|
|
||||||
|
- **Trigger:** writing an e2e for peer election, glare, or "cannot hear each other" and joining clients one after another.
|
||||||
|
- **Rule:** get every client onto the roster, then reload/reconnect them with `Promise.all` so all pairs elect from the same snapshot, and assert real audio flow plus exactly one initiator per pair.
|
||||||
|
- **Why:** staggered joins let one side's 1s fallback-offer timer serialize negotiation, so a wrong comparison still converges and the test passes on broken code — three sequential-join runs passed against the known-bad wiring before the simultaneous reconnect made it fail on audio.
|
||||||
|
- **Example:** `e2e/tests/voice/cross-signal-initiator-election.spec.ts` — 4 users, 2 home signal servers, one shared voice channel, `Promise.all(reload)`.
|
||||||
|
|
||||||
|
### Interview before coding; don’t guess the fix [workflow] [bugs] [tokens]
|
||||||
|
|
||||||
|
- **Trigger:** about to edit product code for a bug/feature after reading the ask or Obsidian note, while acceptance, approach, or scope is still ambiguous or has real alternatives.
|
||||||
|
- **Rule:** send a short interview (understanding, gaps, A/B/C + recommended default, proposed scope, proof of done), wait for the user’s choices, then implement only that — skip only if they said “just fix it” / “no interview.”
|
||||||
|
- **Why:** unprompted guesses cause wrong fixes and expensive back-and-forth; one clarifying turn costs less than a wrong implementation thread.
|
||||||
|
- **Example:** `fix bug "Images and files in chat doesn't load"` → read the note → ask whether the failure is channel-switch blank vs cold reload vs both before touching attachment services.
|
||||||
|
|
||||||
|
### Default to `toju-app/` + targeted `electron/` + CI; do not crawl the monorepo [workflow] [tokens] [scope]
|
||||||
|
|
||||||
|
- **Trigger:** about to browse all of `electron/`, or to `grep`/`Read` under `server/`, `e2e/`, `website/`, or `docs-site/` on a normal product bug without the user naming those packages.
|
||||||
|
- **Rule:** stay in `toju-app/`, `.gitea/workflows/`, and **only the Electron files on the renderer→preload→handler path**; if the fix looks like `server/`/e2e, ask once instead of exploring those trees.
|
||||||
|
- **Why:** monorepo-wide (and whole-`electron/`) exploration multiplies context on expensive problem-solving models without fixing the asked client bug.
|
||||||
|
- **Example:** attachment disk restore → `toju-app` persistence service + `electron/preload.ts` + the one IPC/file helper involved — not every file under `electron/migrations/` or `electron/api/`.
|
||||||
|
|
||||||
|
### Write HANDOFF.md and ask the user for a new chat — agents cannot open chats [workflow] [tokens] [handoff]
|
||||||
|
|
||||||
|
- **Trigger:** the thread is long, the user says "handoff"/"new chat", or a new major objective starts while more work remains.
|
||||||
|
- **Rule:** **overwrite** (never append) `agents-docs/HANDOFF.md` with `Status: active` and short sections, ask the user to start a new chat with that file; when the handoff task is finished, **clear** the file to `Status: none` with empty sections.
|
||||||
|
- **Why:** fat chat history dominates token burn; an appending handoff file becomes a second fat archive that every new chat reloads.
|
||||||
|
- **Example:** user: "handoff" → replace HANDOFF → reply: "Start a new chat and attach `@agents-docs/HANDOFF.md`." Later when done → reset HANDOFF to empty `Status: none`.
|
||||||
|
|
||||||
|
### Prove the asked behavior; unit-green is not done [verification] [testing] [workflow]
|
||||||
|
|
||||||
|
- **Trigger:** about to report a task finished because colocated Vitest specs (or a narrow mocked unit) are green, while the user’s ask was a product behavior, UI flow, or bug they can still reproduce.
|
||||||
|
- **Rule:** treat acceptance as “the asked functionality works” — prove it with a user-visible path, focused e2e, or an explicit manual check; keep unit tests as support, never as the sole done signal.
|
||||||
|
- **Why:** agents optimized for TDD often stop at implementation-shaped tests that pass while the real feature/bug remains broken, which wastes follow-up turns and burns tokens on false completion.
|
||||||
|
- **Example:** for “DM reply doesn’t show for the caller,” a passing `DirectMessageService` mock test is insufficient until the cross-signal conversation identity path is exercised (e2e or a behavior-level regression that fails on the old fork-thread bug).
|
||||||
|
|
||||||
|
### Keep `NgOptimizedImage` off runtime blob and data URLs [angular] [images]
|
||||||
|
|
||||||
|
- **Trigger:** Angular template lint suggests replacing `[src]` with `ngSrc` for a user-uploaded image rendered from `blob:` or `data:`.
|
||||||
|
- **Rule:** Keep a plain `src` binding, document/disable `prefer-ngsrc`, and use native loading/decoding plus the app's own lifecycle controls; Angular throws `NG02952` for blob/data `ngSrc`.
|
||||||
|
- **Why:** `NgOptimizedImage` targets network/CDN images and cannot resize, preload, or safely manage renderer-created attachment blobs.
|
||||||
|
- **Example:** chat attachment thumbnails use `[src]="attachment.objectUrl" loading="lazy" decoding="async"`, never `[ngSrc]`.
|
||||||
|
|
||||||
|
### Read the exact Obsidian bug note before diagnosing a named ticket [workflow] [bugs]
|
||||||
|
|
||||||
|
- **Trigger:** The user says `fix bug "…"`, names a `Bug - …` ticket, or the worktree already contains plausible changes / a similarly named resolved ticket.
|
||||||
|
- **Rule:** Resolve and read **only** that note under `Log/Bugs/` (and its attachment folder if needed); use Expected Result as acceptance; fix in default scope; do not list the whole inbox or treat `BUG_TRACKER.md`'s snapshot table as live.
|
||||||
|
- **Why:** attachment reload-host changes looked related to “Images and files in chat doesn't load” but came from a separate resolved ticket and did not cover the reported channel-switch state regression; inbox-wide reads also burn tokens for no gain.
|
||||||
|
- **Example:** `fix bug "Images and files in chat doesn't load"` → read `/home/ludde/Nextcloud/Obsidian Vault/Log/Bugs/Bug - Images and files in chat doesn't load.md` only, then implement against its Steps/Expected.
|
||||||
|
|
||||||
|
### Run `npm run i18n:sync` after editing any `public/i18n/catalog/*.json` file [i18n] [testing]
|
||||||
|
|
||||||
|
- **Trigger:** Added new `call.errors.*` keys to `toju-app/public/i18n/catalog/call.json` and used them in code; the full test run failed in `app-i18n-catalog.rules.spec.ts` with "Missing i18n keys" even though the keys existed in the catalog file.
|
||||||
|
- **Rule:** The runtime and the catalog spec read the merged `toju-app/public/i18n/en.json`, not the per-area `catalog/*.json` files — after any catalog edit, run `npm run i18n:sync` (root script, `tools/sync-app-i18n-catalog.mjs`) and commit the regenerated `en.json` alongside the catalog change.
|
||||||
|
- **Why:** without the sync the new strings silently fall back to raw keys at runtime and the catalog spec fails, but only in the full suite — targeted spec runs of the feature under change pass, so the failure surfaces late.
|
||||||
|
- **Example:** `npm run i18n:sync && npm run test` after adding `call.errors.microphonePermissionDenied` to `catalog/call.json`.
|
||||||
|
|
||||||
|
### Match direct-call recipients against every local identity alias, exactly like DMs already do [direct-call] [identity]
|
||||||
|
|
||||||
|
- **Trigger:** "User receiving direct call doesn't get notified" — a caller who met the callee through a room on the caller's signal server addressed the ring by the callee's *provisioned actor id*; `handleIncomingCallEvent` admitted only `payload.participantIds.includes(oderId || id)`, so the ring was silently dropped, the caller sat "In Voice", and the callee saw nothing. DMs had the identical bug fixed earlier (`baa350e`), but the fix stopped at `DirectMessageService` and never reached `DirectCallService`.
|
||||||
|
- **Rule:** every self check on a cross-user event (admission, sender-echo filter, remote-participant filtering, DM-header peer lookup) must span all local aliases — home id, entity id, peer id, plus each `SignalServerCredentialStoreService.listValidCredentials()` actor id — and incoming aliases must be normalized onto the canonical local id before session state is keyed (`normalizeDirectCallPayloadSelfAliases`).
|
||||||
|
- **Why:** the failure only reproduces when caller and callee have different home signal servers, which no same-server e2e covers; and when one identity-alias bug is fixed in a domain, grep for the same `=== currentUserId` pattern in sibling domains that share the transport — the direct-call domain reused `PeerDeliveryService` but kept the naive check for another month.
|
||||||
|
- **Example:** `direct-call-participant-identity.rules.ts#directCallPayloadIncludesAnyId` / `normalizeDirectCallPayloadSelfAliases`; regression e2e `e2e/tests/voice/dm-header-call-ring.spec.ts` registers Bob on a secondary signal server, meets in a primary-signal room, and asserts the DM-header call rings Bob's incoming-call modal (fails on old code, passes after).
|
||||||
|
|
||||||
|
### Resolve outbound direct-call recipient ids to the peer's connected signal identity [direct-call] [identity] [signaling]
|
||||||
|
|
||||||
|
- **Trigger:** cross-signal direct calls still failed after the inbound alias fix — the caller joined voice and showed "In voice" while the callee never rang. `PeerDeliveryService.resolveSignalingPeerId` returned null when the stored peer id was a home id but presence/route was registered under the provisioned actor id, so `sendRawMessage` was never called; even when attempted, the server relays only when `targetUserId` exactly matches the callee's connected `oderId`.
|
||||||
|
- **Rule:** outbound DM/call delivery must collect every recipient alias (`peer-delivery-identity.rules.ts#collectRecipientDeliveryCandidateIds`), pick the routable id with `pickRoutableRecipientId`, always attempt signaling send (broadcast fallback when no single route works), and surface `call.errors.recipientUnreachable` to the caller when delivery cannot succeed — never leave the caller in a silent "In voice" state.
|
||||||
|
- **Why:** inbound and outbound identity bugs are independent; fixing admission on the callee does not help if the ring never leaves the caller or hits the wrong `targetUserId` on the wire.
|
||||||
|
- **Example:** `PeerDeliveryService.sendViaSignaling` + `DirectCallService.resolveRoutableRecipientId`; e2e `e2e/tests/voice/dm-header-call-ring.spec.ts` (callee-home room, people-search call).
|
||||||
|
|
||||||
|
### Decide attachment receive admission once at request time; never re-gate size in the chunk handler [attachments]
|
||||||
|
|
||||||
|
- **Trigger:** "Sending files between users doesn't really work" — a browser user clicked Request on a 10–50 MB generic file, the request gate (`canReceiveAttachment`) admitted it for in-memory receive, the sender streamed chunks, but `handleFileChunk` still had a leftover hard `size > MAX_AUTO_SAVE_SIZE_BYTES` rejection on the in-memory path, so every chunk was dropped, no ack was ever sent, the sender's `waitForAck` timed out, and the GUI never changed.
|
||||||
|
- **Rule:** `canReceiveAttachment` (request time) is the single admission decision; the chunk handler may only route between disk-streaming and in-memory assembly — any stricter size check there silently drops chunks the request gate already admitted.
|
||||||
|
- **Why:** the failure is invisible in logs-from-the-outside: the sender's per-chunk sends look like a working transfer ("packages with size 32kb") until the ack timeout, and the receiver sets `requestError` only into memory that a re-request immediately clears — the user just sees a dead Request button.
|
||||||
|
- **Example:** removed the `MAX_AUTO_SAVE_SIZE_BYTES` guard in `attachment-transfer.service.ts#handleFileChunk`; regression e2e `e2e/tests/chat/large-generic-file-transfer.spec.ts` sends an 11 MB `.bin` between two browser clients and asserts Request → progress → Download (fails on the old code, passes after).
|
||||||
|
|
||||||
|
### Re-queue attachment auto-downloads on every message/room binding event; never trust one transport's ordering [attachments] [realtime]
|
||||||
|
|
||||||
|
- **Trigger:** cross-user attachment sync e2e (`chat-message-features.spec.ts`) flaked ~50%: `file-announce` (WebRTC data channel) beat `chat-message` (signaling websocket) to the receiver, so the announce-time auto-download resolved `roomId=null`, silently gave up, and nothing ever retried — the receiver showed "Waiting for image source..." forever. A related bug: the stalled-download reset keyed only on "receivedBytes>0 && no pending request", but the pending-request marker is deleted on the *first* chunk, so any auto-download pass during an active transfer cancelled it mid-stream and the retry deadlocked against the sender's active-transfer dedupe.
|
||||||
|
- **Rule:** events that complete the `messageId -> roomId` binding (`chat-message` in `messages-incoming.handlers.ts`) must call `queueAutoDownloadsForMessage` again — never assume `file-announce` arrives after the message, they ride different transports; and stall detection must gate on chunk-progress staleness (`lastUpdateMs` older than `ATTACHMENT_STALLED_DOWNLOAD_THRESHOLD_MS`), never on the absence of a pending-request marker alone.
|
||||||
|
- **Why:** both halves fail silently (no error, no requestError set), so the UI just sits at 0 bytes; the flake is timing-dependent and invisible in single-client tests — only the two-client e2e with `--repeat-each` exposed it deterministically enough to fix.
|
||||||
|
- **Example:** `handleChatMessage` now calls `attachments.queueAutoDownloadsForMessage(message.id)` after `rememberMessageRoom`; `shouldResetStalledAttachmentDownload(attachment, hasPendingRequest, nowMs)` in `attachment-autodownload.rules.ts`. Verified with `npx playwright test -g "syncs image and file attachments|syncs multi-chunk" --repeat-each=4` (8/8 after, ~50% before).
|
||||||
|
|
||||||
### Scope per-user UI state by user id, not by the client database [persistence] [multi-user] [custom-emoji]
|
### Scope per-user UI state by user id, not by the client database [persistence] [multi-user] [custom-emoji]
|
||||||
|
|
||||||
- **Trigger:** custom emoji "saved library" membership was a single `savedByUser` flag on the shared emoji row plus a long-lived singleton (`CustomEmojiService`) that merged state across logins — so a second account on the same client (and the Electron shared SQLite DB) inherited the first user's picker.
|
- **Trigger:** custom emoji "saved library" membership was a single `savedByUser` flag on the shared emoji row plus a long-lived singleton (`CustomEmojiService`) that merged state across logins — so a second account on the same client (and the Electron shared SQLite DB) inherited the first user's picker.
|
||||||
@@ -249,12 +487,19 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
- **Why:** duplicated chrome makes CTA/product previews look broken, and bottom-aligned large headings can cover accompanying text on the marketing site.
|
- **Why:** duplicated chrome makes CTA/product previews look broken, and bottom-aligned large headings can cover accompanying text on the marketing site.
|
||||||
- **Example:** `website/src/app/pages/home/home.component.html` should render the screenshot directly; `host-section` should use top-aligned heading and `.host-section-copy` columns.
|
- **Example:** `website/src/app/pages/home/home.component.html` should render the screenshot directly; `host-section` should use top-aligned heading and `.host-section-copy` columns.
|
||||||
|
|
||||||
|
### Prefer `npm run lint:fix` over hand-fixing lint/format [verification] [lint] [tokens]
|
||||||
|
|
||||||
|
- **Trigger:** about to manually re-indent, reorder imports, or tweak Prettier/ESLint-fixable style after seeing lint failures.
|
||||||
|
- **Rule:** from repo root run `npm run lint:fix` (`format` + `sort:props` + `eslint . --fix`); only hand-edit remaining non-fixable errors.
|
||||||
|
- **Why:** manual style fixes burn turns and tokens and often miss what the project script already auto-corrects.
|
||||||
|
- **Example:** after code changes → `npm run lint:fix` → if exit 0, do not also rewrite imports by hand.
|
||||||
|
|
||||||
### Verify lint exits 0 before claiming done [verification]
|
### Verify lint exits 0 before claiming done [verification]
|
||||||
|
|
||||||
- **Trigger:** about to report a task as complete after running tests but skipping ESLint.
|
- **Trigger:** about to report a task as complete after running tests but skipping ESLint.
|
||||||
- **Rule:** run `npm run lint` from the repo root and confirm exit code 0 before any "done" claim.
|
- **Rule:** run `npm run lint:fix` from the repo root (or `npm run lint` after fixes) and confirm exit code 0 before any "done" claim.
|
||||||
- **Why:** `npm run test` only runs the toju-app Vitest suite — it doesn't cover the server, Electron, or website packages. ESLint (flat config in `eslint.config.js`) is the universal check across every package; type-style violations slip through tests and break Gitea Workflows for the next agent.
|
- **Why:** `npm run test` only runs the toju-app Vitest suite — it doesn't cover the server, Electron, or website packages. ESLint (flat config in `eslint.config.js`) is the universal check across every package; type-style violations slip through tests and break Gitea Workflows for the next agent.
|
||||||
- **Example:** `npm run lint && echo OK` — only claim done after seeing `OK`. For Electron type errors specifically, also confirm `npm run build:electron` succeeds (it invokes `tsc -p tsconfig.electron.json`).
|
- **Example:** `npm run lint:fix && echo OK` — only claim done after seeing `OK`. For Electron type errors specifically, also confirm `npm run build:electron` succeeds (it invokes `tsc -p tsconfig.electron.json`).
|
||||||
|
|
||||||
### Use blob URLs for inline attachment previews [attachments] [electron]
|
### Use blob URLs for inline attachment previews [attachments] [electron]
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
# App i18n
|
# App i18n
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
Client-side UI string localization for the product client (`toju-app`), using the same `@ngx-translate/core` stack as the marketing website.
|
Client-side UI string localization for the product client (`toju-app`), using the same `@ngx-translate/core` stack as the marketing website.
|
||||||
|
|
||||||
|
## Migration status
|
||||||
|
|
||||||
|
Only **English** ships today (`SUPPORTED_APP_LOCALES = ['en']`). The catalog workflow and `translate` pipe are in place, but many components still use hardcoded strings — new user-visible copy should use i18n keys; migrate adjacent strings when touching a component. There is no locale preference UI yet.
|
||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
- Bundle locale JSON under `toju-app/public/i18n/`.
|
- Bundle locale JSON under `toju-app/public/i18n/`.
|
||||||
@@ -60,3 +67,14 @@ The sync script also extracts `theme.registry.*` labels/descriptions from `theme
|
|||||||
- `toju-app/src/app/core/i18n/app-i18n.rules.spec.ts`
|
- `toju-app/src/app/core/i18n/app-i18n.rules.spec.ts`
|
||||||
- `toju-app/src/app/core/i18n/app-i18n.service.spec.ts`
|
- `toju-app/src/app/core/i18n/app-i18n.service.spec.ts`
|
||||||
- `toju-app/src/app/core/i18n/app-i18n.testing.ts` — `provideAppI18nForTests()` / `initializeAppI18nForTests()` for Vitest injectors
|
- `toju-app/src/app/core/i18n/app-i18n.testing.ts` — `provideAppI18nForTests()` / `initializeAppI18nForTests()` for Vitest injectors
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `toju-app/AGENTS.md` — i18n usage rules for agents
|
||||||
|
- Marketing site i18n is separate: `website/public/i18n/`
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Documented partial migration status and locale UI gap |
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Attachments
|
||||||
|
|
||||||
|
> **Area:** attachments
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Attachments move file bytes peer-to-peer over the WebRTC ordered data channel using a announce → request → chunk protocol. Chat and DMs attach metadata to messages; the signaling server does not store or relay file payloads. Sibling devices learn attachment **metadata** via `account_sync` `chat-sync-batch` but must still download bytes from a peer that has them.
|
||||||
|
|
||||||
|
Domain internals: [`toju-app/src/app/domains/attachment/README.md`](../../toju-app/src/app/domains/attachment/README.md).
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Chunked P2P transfer with flow control and cancel semantics.
|
||||||
|
- Auto-download when policy allows; disk streaming on Electron/Capacitor.
|
||||||
|
- Ownership vs "shared from your device" UI rules.
|
||||||
|
- Persist attachment rows + filesystem paths on desktop/mobile.
|
||||||
|
|
||||||
|
This area does **not** own:
|
||||||
|
|
||||||
|
- Message envelopes or delivery states → [messaging.md](messaging.md).
|
||||||
|
- WebRTC negotiation → [voice-webrtc.md](voice-webrtc.md).
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
|
||||||
|
- **Announce** — sender advertises `fileId`, name, size, mime without sending bytes.
|
||||||
|
- **Mirror host** — peer that holds a complete copy and can serve chunks.
|
||||||
|
- **Buffered send** — waits for data-channel back-pressure (4 MB high / 1 MB low water marks on chat channel).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2P protocol
|
||||||
|
|
||||||
|
| type | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `file-announce` | Metadata only |
|
||||||
|
| `file-request` | Receiver starts download |
|
||||||
|
| `file-chunk` | Base64 chunk (`index`, `total`, `data`) |
|
||||||
|
| `file-chunk-ack` | Per-chunk flow control |
|
||||||
|
| `file-cancel` | Abort in flight |
|
||||||
|
| `file-not-found` | Host lacks bytes |
|
||||||
|
|
||||||
|
**Chunk size:** `FILE_CHUNK_SIZE_BYTES` = **64 KB** (`attachment-transfer.constants.ts`).
|
||||||
|
|
||||||
|
**Electron send path:** reads one chunk at a time from disk via IPC (`append-file-bytes` / read chunk) to avoid loading whole files into renderer memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
| Runtime | Metadata | Bytes |
|
||||||
|
|---------|----------|-------|
|
||||||
|
| Browser | In-memory / optional save | Below **10 MB** auto-save cap (`MAX_AUTO_SAVE_SIZE_BYTES`) |
|
||||||
|
| Electron | SQLite `attachments` + CQRS | `user/<username>/…` via `AttachmentStorageService` / IPC |
|
||||||
|
| Capacitor | SQLite | App-private attachment directory — [mobile-capacitor.md](mobile-capacitor.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download / export to user location
|
||||||
|
|
||||||
|
`AttachmentDownloadService.downloadToUserLocation` picks the runtime-appropriate export path:
|
||||||
|
|
||||||
|
| Runtime | Behavior |
|
||||||
|
|---------|----------|
|
||||||
|
| Electron | `saveExistingFileAs` (disk-backed) or `saveFileAs` (blob) native save dialog |
|
||||||
|
| Browser | Anchor `download` click on the object URL |
|
||||||
|
| Capacitor | `CapacitorAttachmentExportService.exportToDevice`: copies the disk file from `Directory.Data` into `Directory.Documents` (or fetches the object URL and writes base64) using `buildAttachmentExportFileName` (timestamp suffix so exports never collide — Android 11+ rejects overwrites of files the app did not create). Anchor downloads do nothing in the Android WebView. |
|
||||||
|
|
||||||
|
## Multi-device
|
||||||
|
|
||||||
|
`chat-sync-batch` in `account_sync` carries an `attachments` map (local paths stripped). Sibling devices discover files exist; P2P `file-request` still required for bytes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business rules and invariants
|
||||||
|
|
||||||
|
- Transfers are between connected peers only (no server CDN).
|
||||||
|
- Receive strategy is decided once at request time by `canReceiveAttachment` (`attachment.logic.ts`): ≤ 10 MB assembles in memory everywhere; > 10 MB streams to disk on Electron/Capacitor, assembles in memory on the browser up to its 50 MB persist cap, and is rejected with a visible `fileTooLarge` error beyond that. `handleFileChunk` must accept whatever the request gate admitted — a stricter chunk-time size cap silently drops chunks and stalls the transfer.
|
||||||
|
- Visibility-based blob lifecycle on desktop: revoke `blob:` URLs when messages scroll off-screen if disk can rehydrate.
|
||||||
|
- Startup hydration is an availability boundary: file requests and host re-announcements wait for persisted metadata before inspecting local files. A host re-announces persisted files after reload and on peer connection even from non-chat routes, and can recover an original Electron source path by copying it into app data on demand before serving.
|
||||||
|
- Startup database hydration merges persisted metadata into the live runtime attachment map; it must preserve attachments announced during initialization and completed runtime state (`available`, progress, display URL) while filling missing local paths. Replacing the map can regress a completed download to Retry, spinner, or 100% after navigation.
|
||||||
|
- Starting a request updates the runtime version immediately so inline cards and galleries show pending/download state at zero bytes; exhausting all candidate peers surfaces `fileNotFound` instead of silently clearing the pending request. A repeat host announce re-queues guarded auto-download recovery for eligible media.
|
||||||
|
- Display-blob memory invariants (added 2026-07-14, RAM investigation):
|
||||||
|
- Inline hydration (`chat-message-item` effect) only runs for messages that are visible or within the `IntersectionObserver` root margin — gated by `attachment-hydration-visibility.rules.ts`. Off-screen rows never load blobs.
|
||||||
|
- Visibility observation uses the rendered message row (`componentHost.firstElementChild`), not the boxless Angular component host; otherwise returning to a channel leaves revoked attachments on permanent spinners.
|
||||||
|
- Disk-to-blob hydration is deduplicated per attachment and capped at two active tasks. Offscreen/destroy lifecycle cancellation is checked after every IPC read and before object-URL assignment, so rapid channel switches cannot accumulate stale full-file buffers or orphaned blobs; pinned fullscreen/gallery attachments are exempt.
|
||||||
|
- Disk-hydrated blobs are **not** duplicated into `AttachmentRuntimeStore.originalFiles`; peer requests are served from the disk path (`streamRequestedFile` prefers `resolveExistingPath`). `originalFiles` only holds uploads/downloads that have no disk copy yet.
|
||||||
|
- `revokeAttachmentDisplayBlob` also drops the `originalFiles` entry when `savedPath` exists, so revocation actually frees the bytes.
|
||||||
|
- Message rows always revoke their display blobs on destroy (pins are respected), not only when they were visible.
|
||||||
|
- Room switch sweeps display blobs of all other rooms (`releaseDisplayBlobsForInactiveRooms`, driven by `collectMessageIdsForInactiveRoomBlobRelease`). Messages with unknown room mapping are left alone.
|
||||||
|
- "Shared from your device" badge only when bytes are local to the viewing user.
|
||||||
|
- Blob-backed chat thumbnails use plain `src` with native lazy loading and async decoding. `NgOptimizedImage` is forbidden for these URLs because Angular rejects `blob:` inputs; fullscreen images remain eager.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical implementation
|
||||||
|
|
||||||
|
- Facade: `AttachmentFacade` → `AttachmentManagerService`
|
||||||
|
- Protocol: `AttachmentTransferService` + `AttachmentTransferTransportService`
|
||||||
|
- Electron IPC: `read-file-chunk`, `append-file-bytes`, `write-file`, `delete-file`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Domain logic specs under `attachment/`
|
||||||
|
- E2E: `e2e/tests/chat/chat-message-features.spec.ts`, `local-attachment-persistence.spec.ts`, `multi-device-attachment-sharing.spec.ts`, `large-generic-file-transfer.spec.ts` (browser receiver, generic file above the 10 MB auto-save cap)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security considerations
|
||||||
|
|
||||||
|
- No server-side virus scanning; peers trust senders they are connected to.
|
||||||
|
- Files stay in user data directories (Electron path jail).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related features
|
||||||
|
|
||||||
|
- [messaging.md](messaging.md) — message + attachment metadata coupling
|
||||||
|
- [authentication.md](authentication.md) — `account_sync` batches
|
||||||
|
- [mobile-capacitor.md](mobile-capacitor.md) — mobile storage
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-14 | Bounded display hydration to two deduplicated tasks, cancelled stale channel work before blob assignment, and added native lazy/async thumbnail hints |
|
||||||
|
| 2026-07-14 | Fixed channel-return hydration by observing the rendered message row instead of the boxless component host |
|
||||||
|
| 2026-07-14 | Made zero-byte requests visible, surfaced async peer-exhaustion failures, and retried eligible media when a host re-announces |
|
||||||
|
| 2026-07-14 | Preserved live attachment/download state when startup database hydration completes after realtime events |
|
||||||
|
| 2026-07-14 | Prevented reload-time `file-not-found` responses by waiting for metadata hydration, re-announcing hosts outside chat routes, and recovering persisted source paths on demand |
|
||||||
|
| 2026-07-14 | Blob-memory invariants: visibility-gated hydration, no `originalFiles` duplication for disk-backed blobs, revoke-on-destroy, inactive-room blob sweep |
|
||||||
|
| 2026-07-13 | Capacitor download/export to public `Documents` via `CapacitorAttachmentExportService` |
|
||||||
|
| 2026-07-05 | Expanded to full contract style |
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
# Authentication
|
# Authentication
|
||||||
|
|
||||||
Session-token authentication for the signaling server and product client.
|
> **Area:** authentication
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Session-token authentication binds REST mutations and WebSocket `identify` to a user identity on each signaling server. The product client may hold **multiple** server credentials (home + foreign auto-provisioned accounts) while keeping one local user profile. Multi-device tabs share one identity via separate `clientInstanceId` values and `account_sync` relay.
|
||||||
|
|
||||||
|
WebSocket details: [signaling.md](signaling.md). Local API tokens: [desktop-local-api.md](desktop-local-api.md).
|
||||||
|
|
||||||
## Trust boundaries
|
## Trust boundaries
|
||||||
|
|
||||||
@@ -8,8 +16,8 @@ Session-token authentication for the signaling server and product client.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Signaling server REST (mutations) | `Authorization: Bearer <token>` | Actor user IDs in request bodies are ignored; server derives `authUserId` from the token |
|
| Signaling server REST (mutations) | `Authorization: Bearer <token>` | Actor user IDs in request bodies are ignored; server derives `authUserId` from the token |
|
||||||
| Signaling server REST (discovery) | None | `GET /api/servers`, featured/trending/search remain public |
|
| Signaling server REST (discovery) | None | `GET /api/servers`, featured/trending/search remain public |
|
||||||
| Signaling server WebSocket | `identify.token` | Connections must identify before any other message type |
|
| Signaling server WebSocket | `identify.token` | Connections must identify before any other message type — see [signaling.md](signaling.md) |
|
||||||
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only |
|
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only — see [desktop-local-api.md](desktop-local-api.md) |
|
||||||
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
|
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
|
||||||
|
|
||||||
## Client logout
|
## Client logout
|
||||||
@@ -36,13 +44,81 @@ Session-token authentication for the signaling server and product client.
|
|||||||
|
|
||||||
## Protected REST routes
|
## Protected REST routes
|
||||||
|
|
||||||
Require `Authorization: Bearer`:
|
Require `Authorization: Bearer` (`requireAuth` middleware). Public routes are listed for contrast.
|
||||||
|
|
||||||
- `PUT/POST/DELETE` under `/api/servers/*` (except public `GET`)
|
### Users (`/api/users`)
|
||||||
- `PUT /api/requests/:id`
|
|
||||||
- Plugin-support mutations under `/api/servers/:serverId/plugins/*`
|
| Method | Path | Auth |
|
||||||
- `/api/users/device-tokens/*`
|
|--------|------|------|
|
||||||
- `POST /api/users/logout`
|
| POST | `/register` | Public |
|
||||||
|
| POST | `/login` | Public |
|
||||||
|
| GET | `/:id/signing-public-key` | Public |
|
||||||
|
| PUT | `/me/signing-key` | Bearer |
|
||||||
|
| POST | `/logout` | Bearer |
|
||||||
|
|
||||||
|
### Device tokens (`/api/users/device-tokens`)
|
||||||
|
|
||||||
|
All routes require bearer; `userId` in body or path must equal `authUserId` (`403` otherwise).
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
|--------|------|
|
||||||
|
| POST | `/` |
|
||||||
|
| GET | `/:userId` |
|
||||||
|
| POST | `/:userId/dispatch` |
|
||||||
|
|
||||||
|
### Servers (`/api/servers`)
|
||||||
|
|
||||||
|
| Method | Path | Auth |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/`, `/featured`, `/trending`, `/:id` | Public |
|
||||||
|
| POST | `/` | Bearer |
|
||||||
|
| PUT | `/:id` | Bearer |
|
||||||
|
| DELETE | `/:id` | Bearer |
|
||||||
|
| POST | `/:id/join` | Bearer |
|
||||||
|
| POST | `/:id/leave` | Bearer |
|
||||||
|
| POST | `/:id/heartbeat` | Bearer |
|
||||||
|
| POST | `/:id/invites` | Bearer |
|
||||||
|
| GET | `/:id/requests` | Bearer |
|
||||||
|
| POST | `/:id/moderation/kick` | Bearer |
|
||||||
|
| POST | `/:id/moderation/ban` | Bearer |
|
||||||
|
| POST | `/:id/moderation/unban` | Bearer |
|
||||||
|
|
||||||
|
### Join requests (`/api/requests`)
|
||||||
|
|
||||||
|
| Method | Path | Auth |
|
||||||
|
|--------|------|------|
|
||||||
|
| PUT | `/:id` | Bearer (approve/deny) |
|
||||||
|
|
||||||
|
### Plugin support (`/api/servers/:serverId/plugins`)
|
||||||
|
|
||||||
|
| Method | Path | Auth |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/` | Public (metadata read) |
|
||||||
|
| PUT | `/:pluginId/requirement` | Bearer |
|
||||||
|
| DELETE | `/:pluginId/requirement` | Bearer |
|
||||||
|
| PUT | `/:pluginId/events/:eventName` | Bearer |
|
||||||
|
| DELETE | `/:pluginId/events/:eventName` | Bearer |
|
||||||
|
| GET/PUT/DELETE | `/:pluginId/data/*` | **410 Gone** (server plugin data disabled) |
|
||||||
|
|
||||||
|
### Public (no bearer)
|
||||||
|
|
||||||
|
- `GET /api/health`, `/api/time`
|
||||||
|
- `GET /api/link-metadata`, `/api/image-proxy`
|
||||||
|
- `GET /api/klipy/config`, `/api/klipy/gifs`
|
||||||
|
- `POST /api/games/match`
|
||||||
|
- `GET /api/invites/:id`
|
||||||
|
- `GET /invite/:id` (HTML invite page)
|
||||||
|
- OpenAPI docs routes (`/api/openapi.json`, `/api/docs`, …) — gated by server config, not session auth
|
||||||
|
|
||||||
|
Full server-directory semantics: [server-directory.md](server-directory.md).
|
||||||
|
|
||||||
|
## Message signing key registration
|
||||||
|
|
||||||
|
Ed25519 signing keys for [message-integrity.md](message-integrity.md) register via `PUT /api/users/me/signing-key` with `{ publicKeyJwk }`.
|
||||||
|
|
||||||
|
- **When registered:** `AuthenticationService` calls `MessageSigningService.registerSigningPublicKeyIfNeeded()` after successful **home** `POST /login` and `POST /register` only (`authentication.service.ts`).
|
||||||
|
- **Scope:** registration uses the **active** signaling server's API base (`ServerDirectoryFacade.activeServer()`). Foreign-server auto-provision (`authorizeSignalServer` / `SignalServerProvisionerService`) does **not** currently call signing-key registration — message integrity on foreign servers depends on a later login path or manual registration when that server becomes active.
|
||||||
|
- **Storage:** private key in `localStorage` (`metoyou.messageSigningKeyPair`); public key directory on server SQLite only.
|
||||||
|
|
||||||
## WebSocket identify contract
|
## WebSocket identify contract
|
||||||
|
|
||||||
@@ -119,7 +195,7 @@ A per-install **provision secret** enables silent account creation on newly adde
|
|||||||
| Foreign login/register | `authorizeSignalServer` | Upserts credential for that URL only; home session unchanged |
|
| Foreign login/register | `authorizeSignalServer` | Upserts credential for that URL only; home session unchanged |
|
||||||
| Auto-provision | `SignalServerProvisionerService` | Registers or logs in on foreign server using provision secret; on username collision tries suffixed username (`alice-<homeUserIdPrefix>`) and prefixes the display name with `#<homeUserIdPrefix> #<signalServerTag>` so same-name accounts stay distinguishable |
|
| Auto-provision | `SignalServerProvisionerService` | Registers or logs in on foreign server using provision secret; on username collision tries suffixed username (`alice-<homeUserIdPrefix>`) and prefixes the display name with `#<homeUserIdPrefix> #<signalServerTag>` so same-name accounts stay distinguishable |
|
||||||
| Create/join on foreign server | `RoomsEffects.createRoom$`, invite/join flows | `ensureCredentialForServerUrl` provisions (or reuses) the per-server session token first; REST/WebSocket calls use the **actor user id** for that signal URL, not the home registration id |
|
| Create/join on foreign server | `RoomsEffects.createRoom$`, invite/join flows | `ensureCredentialForServerUrl` provisions (or reuses) the per-server session token first; REST/WebSocket calls use the **actor user id** for that signal URL, not the home registration id |
|
||||||
| Foreign auth failure | `signalServerAuthFailed` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth |
|
| Foreign auth failure | `signalServerAuthFailed` | `auth_required` (message raced ahead of identify) re-identifies or is ignored while a valid local credential exists; `auth_error` (token rejected) clears that URL's credential and re-provisions on foreign servers or expires the home session |
|
||||||
|
|
||||||
Unreachable or offline signal servers must **not** open `/login?mode=authorize`. `ensureEndpointVersionCompatibility()` treats only `online` endpoints as connectable, and `ensureCredentialForServerUrl()` skips authorize navigation when health checks report the server offline (or provisioning fails over the network).
|
Unreachable or offline signal servers must **not** open `/login?mode=authorize`. `ensureEndpointVersionCompatibility()` treats only `online` endpoints as connectable, and `ensureCredentialForServerUrl()` skips authorize navigation when health checks report the server offline (or provisioning fails over the network).
|
||||||
|
|
||||||
@@ -135,3 +211,17 @@ Startup routing for signed-out visitors is decided by `resolveUnauthenticatedSta
|
|||||||
- CORS allowlist: optional `corsAllowlist` in `server/data/variables.json` or `CORS_ALLOWLIST` env (comma-separated). Empty allowlist keeps permissive CORS for local development.
|
- CORS allowlist: optional `corsAllowlist` in `server/data/variables.json` or `CORS_ALLOWLIST` env (comma-separated). Empty allowlist keeps permissive CORS for local development.
|
||||||
- Push-token routes require bearer auth and user-id match.
|
- Push-token routes require bearer auth and user-id match.
|
||||||
- RTC relay: direct-message/direct-call types always relay; server-icon types require shared server membership; WebRTC offer/answer/ice remain open for cross-server DM WebRTC.
|
- RTC relay: direct-message/direct-call types always relay; server-icon types require shared server membership; WebRTC offer/answer/ice remain open for cross-server DM WebRTC.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [signaling.md](signaling.md) — WebSocket `identify`, `account_sync`, ordering invariants
|
||||||
|
- [desktop-local-api.md](desktop-local-api.md) — Electron Local API bearer tokens
|
||||||
|
- [message-integrity.md](message-integrity.md) — signing keys and revision chains
|
||||||
|
- [server-directory.md](server-directory.md) — protected server REST mutations
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-14 | Distinguish `auth_required` vs `auth_error` on `signalServerAuthFailed`; stop false home-session expiry; leave `/login` when in-memory user still authenticated |
|
||||||
|
| 2026-07-05 | Expanded protected-route inventory; clarified signing-key registration scope; cross-links |
|
||||||
|
|||||||
@@ -2,64 +2,125 @@
|
|||||||
|
|
||||||
> **Area:** custom-emoji
|
> **Area:** custom-emoji
|
||||||
> **Status:** Active
|
> **Status:** Active
|
||||||
> **Last updated:** 2026-06-05
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Custom emoji lets users upload small image emoji, use them in chat messages and reactions, and sync emoji assets needed for rendering to connected peers over the existing data-channel mesh.
|
Custom emoji lets users upload small image emoji, use them in chat messages and reactions, and sync the image bytes to connected peers over the WebRTC data channel (and to sibling devices via `account_sync`). The signaling server never stores emoji assets.
|
||||||
|
|
||||||
|
Internal UI and NgRx wiring: [`toju-app/src/app/domains/custom-emoji/README.md`](../../toju-app/src/app/domains/custom-emoji/README.md). Chat composer integration: [`toju-app/src/app/domains/chat/README.md`](../../toju-app/src/app/domains/chat/README.md).
|
||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
- Own custom emoji asset validation, local persistence, user-saved library membership, shortcut ranking, and peer-to-peer asset sync.
|
- Validate uploads (size, MIME), persist image assets locally, and track per-user **saved library** membership.
|
||||||
- Expose a shared picker consumed by chat message reactions and the chat composer.
|
- Rank shortcuts by local usage (not synced across devices).
|
||||||
- Keep usage ranking local to the current user; usage counts are not synced.
|
- Sync assets P2P (`custom-emoji-*` envelopes) and proactively push referenced emoji when sending messages.
|
||||||
- Does not store custom emoji on the signaling server.
|
- Relay the same envelopes on `account_sync` for multi-device library convergence.
|
||||||
|
- Expose `CustomEmojiPickerComponent` for composer and reactions.
|
||||||
|
|
||||||
## Key Concepts
|
This area does **not** own:
|
||||||
|
|
||||||
- **Custom emoji asset**: A user-created image stored as a data URL with id, name, mime, size, hash, creator, timestamps, and optional saved-library membership.
|
- Message send/edit transport → [messaging.md](messaging.md).
|
||||||
- **Known custom emoji**: A synced asset available for message rendering and forwarding, but not shown in the current user's picker unless saved.
|
- Profile avatar bytes → `toju-app/src/app/domains/profile-avatar/README.md`.
|
||||||
- **Saved custom emoji**: A known asset the current user added to their library; saved emoji appear in the picker and shortcut ranking. Library membership is **user-bound, not client-bound** — it is tracked per signed-in user (keyed by user id), so a second account on the same device never inherits the first account's library.
|
- Server-side storage (none).
|
||||||
- **Emoji shortcut row**: The seven most-used emoji entries for the current user plus an eighth control that opens the full selector.
|
|
||||||
- **Custom emoji token**: The stable message/reaction representation `:emoji[id](name)`, resolved locally to the synced image asset when rendering.
|
|
||||||
- **Composer emoji alias**: The readable inline draft representation `:name:`. The composer rewrites known aliases to stable custom emoji tokens only when sending.
|
|
||||||
|
|
||||||
## Peer Envelope Contract
|
## Key concepts
|
||||||
|
|
||||||
Custom emoji uses `ChatEvent` data-channel envelopes:
|
- **Custom emoji asset** — image with `id`, `name`, `mime`, `size`, `hash`, `creatorUserId`, `dataUrl` (or reconstructed from chunks).
|
||||||
|
- **Known emoji** — synced for rendering; not necessarily in the picker.
|
||||||
|
- **Saved emoji** — in the active user's library (`metoyou_custom_emoji_saved:<userId>`); shown in picker and shortcut row.
|
||||||
|
- **Token** — stable wire form `:emoji[id](name)` in message/reaction bodies.
|
||||||
|
- **Composer alias** — draft form `:name:` rewritten to a token on send when the name is known.
|
||||||
|
- **Shortcut row** — seven most-used saved entries plus opener for full picker.
|
||||||
|
|
||||||
- `custom-emoji-summary`: `{ customEmojiSummaries: [{ id, hash, updatedAt }] }`
|
---
|
||||||
- `custom-emoji-request`: `{ ids: string[] }`
|
|
||||||
- `custom-emoji-full`: `{ customEmojiTransfer: Omit<CustomEmoji, 'dataUrl'>, total: number }`
|
|
||||||
- `custom-emoji-chunk`: `{ customEmojiId, index, total, data }`
|
|
||||||
|
|
||||||
When a peer connects, each side sends a summary of known assets. The receiver requests missing or stale emoji by id, and the owner replies with a small manifest followed by bounded base64 chunks using buffered peer sends. Creating a new emoji also streams that manifest and chunk sequence to every currently connected peer. Outgoing room chat messages, edits, reactions, and direct messages proactively push every referenced custom emoji asset to connected peers in parallel with the message event, so receivers do not wait for a request round-trip. Small assets that fit under `CUSTOM_EMOJI_INLINE_MAX_JSON_BYTES` travel inline in one `custom-emoji-full` event; larger assets use manifest plus chunks. Incoming chat messages and chat-sync batches still scan for `:emoji[id](name)` tokens and request any missing assets from the sender as a repair path. Full inline `customEmoji` payloads remain accepted for backward compatibility.
|
## Peer envelope contract (P2P)
|
||||||
|
|
||||||
## Business Rules
|
| type | Payload |
|
||||||
|
|------|---------|
|
||||||
|
| `custom-emoji-summary` | `{ customEmojiSummaries: [{ id, hash, updatedAt }] }` |
|
||||||
|
| `custom-emoji-request` | `{ ids: string[] }` |
|
||||||
|
| `custom-emoji-full` | manifest (`customEmojiTransfer`) ± inline bytes |
|
||||||
|
| `custom-emoji-chunk` | `{ customEmojiId, index, total, data }` base64 |
|
||||||
|
|
||||||
- Uploads are capped at 1 MB.
|
**Handshake:** on peer connect both sides send summaries; receiver requests stale/missing ids; owner sends manifest then chunked payloads via buffered sends.
|
||||||
- Accepted image types match profile avatars: WebP, GIF, JPG, and JPEG.
|
|
||||||
- Local shortcut ranking is keyed by the active user and includes Unicode emoji plus saved custom emoji only.
|
|
||||||
- Saved-library membership is bound to the user, not the client: `CustomEmojiService` tracks the set of saved emoji ids per user id in `localStorage` (`metoyou_custom_emoji_saved:<userId>`, mirroring the per-user usage ranking). The picker shows only emoji in the active user's saved set, so signing in as a different account on the same client never exposes the previous account's library. On first load after this change the set is seeded from legacy `savedByUser` rows the user actually created (`creatorUserId === userId`), so creators keep their library while other local accounts stay empty.
|
|
||||||
- Message rendering reserves inline emoji space with a transparent placeholder image while a referenced custom emoji asset is not yet available; deferred markdown placeholders rewrite tokens to readable `:name:` aliases so raw `:emoji[id](name)` text never flashes in chat.
|
|
||||||
- Seen custom emoji are not added to the picker automatically; right-click a rendered custom emoji in chat or on a custom emoji reaction and choose **Add to emoji library** from the app context menu (`NativeContextMenuComponent`).
|
|
||||||
- Saved custom emoji can be removed from the picker library by right-clicking them inside the emoji picker and choosing **Remove from emoji library**; the asset stays available for rendering messages that already reference it.
|
|
||||||
- Emoji hosts are marked with `data-custom-emoji` / `data-custom-emoji-library` plus `data-custom-emoji-id` so the global context menu can distinguish them from regular images and suppress the default **Copy Image** action.
|
|
||||||
- The full emoji picker includes a search field that filters built-in Unicode emoji by common terms and saved custom emoji by name.
|
|
||||||
- Custom emoji data-channel chunks are capped below typical SCTP message limits; back-pressure alone is not enough because a single oversized send can fire `RTCDataChannel.onerror`.
|
|
||||||
- Completed transfers are persisted only when the reconstructed data URL matches the manifest size and hash; corrupt local rows are dropped before summaries are advertised.
|
|
||||||
|
|
||||||
## Data Access
|
**Proactive push:** outgoing chat/DM messages scan for tokens and push assets to connected peers in parallel with the message event.
|
||||||
|
|
||||||
- Browser runtime stores custom emoji image assets in IndexedDB store `customEmojis` (per-user database scope).
|
**Inline threshold:** assets ≤ `CUSTOM_EMOJI_INLINE_MAX_JSON_BYTES` (48 KiB) ship in one `custom-emoji-full`; larger assets use manifest + chunks.
|
||||||
- Electron runtime stores custom emoji image assets in SQLite table `custom_emojis`, created by migration `1000000000011-AddCustomEmojis` (a single shared desktop database).
|
|
||||||
- Renderer access goes through `DatabaseService` methods `saveCustomEmoji`, `getCustomEmojis`, and `deleteCustomEmoji`. These persist the image **assets** only; they are not scoped per user (the Electron table is shared across local accounts). Per-user **library membership** lives separately in `localStorage` (`metoyou_custom_emoji_saved:<userId>`), which is what keeps the picker user-bound even on a shared client database.
|
**Repair path:** incoming messages and `chat-sync-batch` scan for tokens and request missing assets from the sender.
|
||||||
|
|
||||||
|
### Multi-device (`account_sync`)
|
||||||
|
|
||||||
|
Relayable types (`account-sync.rules.ts`): `custom-emoji-summary`, `custom-emoji-request`, `custom-emoji-full`, `custom-emoji-chunk`. See [signaling.md](signaling.md) and [authentication.md](authentication.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business rules and invariants
|
||||||
|
|
||||||
|
- Max upload **1 MB**; MIME: WebP, GIF, JPEG/JPG (same set as profile avatars).
|
||||||
|
- Library membership is **per user id**, not per device — second account on same machine does not inherit another user's saved set.
|
||||||
|
- Seeing an emoji in chat does **not** add it to the library; user must **Add to emoji library** from context menu.
|
||||||
|
- Remove from library hides picker entry but keeps asset for messages that already reference it.
|
||||||
|
- Chunks stay below SCTP-safe sizes; oversized single sends can trigger `RTCDataChannel.onerror` even when back-pressure is idle.
|
||||||
|
- Persist only when reconstructed `dataUrl` matches manifest **size and hash**; corrupt rows are dropped before advertising summaries.
|
||||||
|
- Placeholder rendering avoids flashing raw tokens while assets are in flight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
| Runtime | Asset bytes | Library membership |
|
||||||
|
|---------|-------------|-------------------|
|
||||||
|
| Browser | IndexedDB `customEmojis` (per-user DB scope) | `localStorage` `metoyou_custom_emoji_saved:<userId>` |
|
||||||
|
| Electron | SQLite `custom_emojis` (shared desktop DB) | same localStorage key |
|
||||||
|
| Capacitor | SQLite `custom_emojis` in `metoyou__<userId>` | same localStorage key |
|
||||||
|
|
||||||
|
API: `DatabaseService.saveCustomEmoji` / `getCustomEmojis` / `deleteCustomEmoji`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical implementation
|
||||||
|
|
||||||
|
- Rules: `domains/custom-emoji/domain/custom-emoji.rules.ts`
|
||||||
|
- Service: `CustomEmojiService`; effects: `CustomEmojiSyncEffects`
|
||||||
|
- Picker: `feature/custom-emoji-picker/`
|
||||||
|
- Context menu: `data-custom-emoji` / `data-custom-emoji-library` attributes on rendered hosts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Unit tests cover upload size validation, shortcut selection, picker search filtering, custom emoji token generation, data-channel chunk splitting, readable composer alias rewriting, transfer integrity, saved-library membership, and add/remove library context-menu actions.
|
- `custom-emoji.rules.spec.ts`, `custom-emoji.service.spec.ts`, `custom-emoji-picker.component.spec.ts`
|
||||||
|
- `account-sync.rules.spec.ts` (relayable types)
|
||||||
|
- E2E: `e2e/tests/chat/custom-emoji-user-binding.spec.ts`
|
||||||
|
|
||||||
## Security Considerations
|
---
|
||||||
|
|
||||||
- Emoji payloads are image-only and size-limited before persistence or broadcast.
|
## Security considerations
|
||||||
- Assets sync only to already connected peers; the signaling server does not persist or proxy emoji images.
|
|
||||||
|
- Image-only, size-capped payloads before persist or broadcast.
|
||||||
|
- Assets reach only connected peers (or same-account devices via `account_sync`); server never proxies bytes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- Usage counts and shortcut ranking are **local only**.
|
||||||
|
- Electron asset table is **shared across OS users** on one desktop install; library keys remain per MetoYou user id.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related features
|
||||||
|
|
||||||
|
- [messaging.md](messaging.md) — tokens in message bodies, proactive push on send
|
||||||
|
- [signaling.md](signaling.md) — `account_sync`
|
||||||
|
- [mobile-capacitor.md](mobile-capacitor.md) — Capacitor SQLite path
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Restructured to match messaging doc style; fixed duplicate sections; Capacitor + account_sync |
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Desktop Local API
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Electron hosts an optional **localhost HTTP API** that exposes read-only access to the local SQLite database, proxies login to allowed signaling servers, and serves bundled Docusaurus documentation offline.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
|-------|------|
|
||||||
|
| Electron `api/router.ts` | HTTP routes, bearer token store, CQRS query dispatch |
|
||||||
|
| Desktop settings | Enable/disable Local API, port, allowed signal servers |
|
||||||
|
| `docs-site` build | Static bundle mounted at `/docusaurus/*` |
|
||||||
|
|
||||||
|
## Trust boundary
|
||||||
|
|
||||||
|
Separate **in-memory bearer tokens** from signaling-server session tokens. Login via Local API issues a local token; read routes require `Authorization: Bearer`. See [authentication.md](authentication.md).
|
||||||
|
|
||||||
|
## Routes (summary)
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| GET | `/api/health` | No | Local API liveness |
|
||||||
|
| GET | `/api/openapi.json`, `/docs`, `/scalar/api-reference.js` | No | API docs (Scalar) |
|
||||||
|
| GET | `/docusaurus/*` | No | In-app documentation site |
|
||||||
|
| POST | `/api/auth/login` | No | Proxy to configured signaling server; returns local bearer |
|
||||||
|
| POST | `/api/auth/logout` | Bearer | Revoke local token |
|
||||||
|
| GET | `/api/profile` | Bearer | Current user profile |
|
||||||
|
| GET | `/api/rooms`, `/api/rooms/{roomId}`, `.../users`, `.../messages`, `.../bans` | Bearer | Read-only room data |
|
||||||
|
| GET | `/api/messages/{messageId}`, `.../reactions`, `.../attachments` | Bearer | Message graph |
|
||||||
|
| GET | `/api/users/{userId}`, `/api/attachments`, `/api/plugin-data` | Bearer | User + plugin data reads |
|
||||||
|
| GET | `/api/meta/{key}` | Bearer | Meta key lookup |
|
||||||
|
|
||||||
|
Database routes return **503** when SQLite is not initialised.
|
||||||
|
|
||||||
|
## IPC
|
||||||
|
|
||||||
|
- `get-local-api-status`, `open-local-api-docs`, `open-docusaurus-docs`
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [authentication.md](authentication.md) — trust table
|
||||||
|
- `electron/CONTEXT.md` — Local API vocabulary
|
||||||
|
- `docs-site/CONTEXT.md` — documentation bundle
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial Local API route catalog |
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Direct Messaging
|
||||||
|
|
||||||
|
> **Area:** messaging
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Direct messaging (1:1 and group PMs) is documented in full in **[messaging.md](messaging.md)** — transports, delivery state machine, sync protocol, storage, and security. This file remains as an index entry in [FEATURES.md](../FEATURES.md).
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
- **Domain:** `toju-app/src/app/domains/direct-message/`
|
||||||
|
- **Entry points:** `DirectMessageService`, `PeerDeliveryService`, `FriendService`
|
||||||
|
- **Persistence:** `metoyou_direct_message_*` (per-user local storage)
|
||||||
|
- **P2P types:** `direct-message`, `direct-message-status`, `direct-message-mutation`, `direct-message-typing`, `direct-message-sync-request`, `direct-message-sync`
|
||||||
|
- **Calls:** `direct-call` shares `PeerDeliveryService` → [voice-webrtc.md](voice-webrtc.md)
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [messaging.md](messaging.md) — full cross-context contract
|
||||||
|
- [signaling.md](signaling.md) — WebSocket relay
|
||||||
|
- Domain README: [`toju-app/src/app/domains/direct-message/README.md`](../../toju-app/src/app/domains/direct-message/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Slimmed to index; comprehensive content moved to messaging.md |
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Game Activity
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
"Now playing" game detection: Electron foreground-window/process heuristics, RAWG metadata match via signaling server, and P2P `game-activity` broadcast to peers. Shown on profile cards and room sidebars.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
|-------|------|
|
||||||
|
| `game-activity` domain | Scan loop, confidence scoring, P2P broadcast, user store updates |
|
||||||
|
| Electron IPC | `get-running-process-names`, `get-active-game-candidate` |
|
||||||
|
| Signaling server | `POST /api/games/match` (RAWG proxy + miss cache) |
|
||||||
|
| P2P | `game-activity` data-channel event |
|
||||||
|
|
||||||
|
## Server API
|
||||||
|
|
||||||
|
### `POST /api/games/match`
|
||||||
|
|
||||||
|
- **Auth:** Public
|
||||||
|
- **Body:** `{ processNames: string[], candidates?: { processName, score }[] }` (bounded list sizes)
|
||||||
|
- **Response:** `{ game: MatchedGame | null }` — RAWG-backed title, cover art, store links
|
||||||
|
|
||||||
|
Misses cached in server SQLite (`GameMatchMiss`) to limit API calls.
|
||||||
|
|
||||||
|
## Client detection
|
||||||
|
|
||||||
|
- Periodic scan (default 10 s, configurable 5–60 s in localStorage `metoyou_game_scan_interval_ms`).
|
||||||
|
- Ignores launcher/helper processes via `IGNORED_PROCESS_NAMES` and regex patterns.
|
||||||
|
- **Electron:** suppresses scan when MetoYou window is focused; prefers foreground-window candidate from `get-active-game-candidate`.
|
||||||
|
- **Browser/Capacitor:** no process scan — activity only from P2P peers.
|
||||||
|
|
||||||
|
## P2P event
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "game-activity", "activity": { "game", "startedAt", "processName", ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Peers merge into `User.gameActivity` in NgRx store.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [signaling.md](signaling.md) — not WS-relayed
|
||||||
|
- [server-directory.md](server-directory.md) — API base URL for match endpoint
|
||||||
|
- Domain README: [`toju-app/src/app/domains/game-activity/README.md`](../../toju-app/src/app/domains/game-activity/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial cross-context game-activity contract |
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Invites & Join Requests
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Invite links and join-request approval let users join private or moderated chat-servers without public listing. Spans signaling **server** REST + HTML invite pages and the product **client** `server-directory` invite feature.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Server: create time-limited invites, resolve invite metadata, record join requests, notify requesters on moderation decisions.
|
||||||
|
- Client: create/copy invite links, render invite landing UX, call join API with invite codes/passwords.
|
||||||
|
- It does NOT own: WebSocket room membership (`join_server` after REST join succeeds).
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
|
||||||
|
- **Invite:** opaque id mapping to a server; may expire.
|
||||||
|
- **Join request:** pending membership when server requires approval.
|
||||||
|
- **request_update:** server-pushed WebSocket notification when a moderator approves/denies.
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
### Invites
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| POST | `/api/servers/:id/invites` | Bearer | Create invite (moderator) |
|
||||||
|
| GET | `/api/invites/:id` | Public | Resolve invite metadata + server card |
|
||||||
|
| GET | `/invite/:id` | Public | HTML invite landing page (browser) |
|
||||||
|
|
||||||
|
### Join
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| POST | `/api/servers/:id/join` | Bearer | Join with password, invite id, or public access; may create join request |
|
||||||
|
|
||||||
|
### Join requests (moderation)
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| GET | `/api/servers/:id/requests` | Bearer (`manageServer`) | List pending requests |
|
||||||
|
| PUT | `/api/requests/:id` | Bearer (`manageServer`) | Approve or deny; body `{ status }` |
|
||||||
|
|
||||||
|
`PUT /api/requests/:id` validates optional `ownerId` matches authenticated user, checks `manageServer` permission, updates status, and sends `notifyUser(request.userId, { type: 'request_update', request })`.
|
||||||
|
|
||||||
|
## Client flow
|
||||||
|
|
||||||
|
1. Moderator creates invite via `ServerDirectoryFacade.createInvite()`.
|
||||||
|
2. Recipient opens `/invite/:id` or deep link; client resolves `GET /api/invites/:id`.
|
||||||
|
3. Authenticated user calls `POST /api/servers/:id/join` with invite payload.
|
||||||
|
4. On approval-required servers, user waits for `request_update` or polls requests list (moderator UI).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [server-directory.md](server-directory.md) — join/leave REST
|
||||||
|
- [authentication.md](authentication.md) — bearer on mutations
|
||||||
|
- [signaling.md](signaling.md) — `join_server` after join
|
||||||
|
- Domain README: [`toju-app/src/app/domains/server-directory/README.md`](../../toju-app/src/app/domains/server-directory/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial cross-context invite/join-request contract |
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Klipy GIFs
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
GIF search in chat and DM composers via a **Klipy API proxy** on the signaling server. API keys stay server-side; clients call same-origin routes on the active signal server.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Server: proxy/search Klipy API (`variables.json` `klipyApiKey`).
|
||||||
|
- Client `chat` domain: `KlipyService`, composer picker; DMs reuse the same integration.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `GET /api/klipy/config`
|
||||||
|
|
||||||
|
- **Auth:** Public
|
||||||
|
- **Response:** `{ enabled: boolean }` — `enabled` when server has a configured API key
|
||||||
|
|
||||||
|
### `GET /api/klipy/gifs`
|
||||||
|
|
||||||
|
- **Auth:** Public
|
||||||
|
- **Query:** `q` (search), `page`, `per_page` (default 24, max 50)
|
||||||
|
- **Response:** Normalised `{ gifs: [{ id, slug, title, url, previewUrl, width, height }], hasNext }`
|
||||||
|
- **Upstream:** `https://api.klipy.com/api/v1` with 8 s timeout
|
||||||
|
|
||||||
|
## Client behavior
|
||||||
|
|
||||||
|
- GIF picker visibility is resolved against the **current chat-server's** signal server (not a global offline endpoint) so KLIPY availability matches the room's backend.
|
||||||
|
- Selected GIFs send as markdown image messages; rendering uses [link-preview-media-proxy.md](link-preview-media-proxy.md) image proxy when needed.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [server-directory.md](server-directory.md) — per-room signal server for config
|
||||||
|
- [direct-messaging.md](direct-messaging.md) — DM composer reuse
|
||||||
|
- Domain README: [`toju-app/src/app/domains/chat/README.md`](../../toju-app/src/app/domains/chat/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial Klipy proxy contract |
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Link Preview & Media Proxy
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The signaling server fetches untrusted URLs on behalf of clients for **link embed previews** and **image proxying**, with SSRF guards. Chat and DM composers render embeds using these endpoints.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Server: outbound fetch with host validation, caching, size limits.
|
||||||
|
- Client `chat` domain: request metadata when messages contain URLs; render cards in message list.
|
||||||
|
- It does NOT store embeds long-term on the server beyond in-memory cache.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `GET /api/link-metadata`
|
||||||
|
|
||||||
|
- **Auth:** Public
|
||||||
|
- **Query:** `url` (http/https)
|
||||||
|
- **Response:** `{ title?, description?, imageUrl?, siteName? }`
|
||||||
|
- **Guards:** `resolveAndValidateHost` + `safeFetch`; 8 s timeout; HTML capped at 512 KB; in-memory cache sized by `variables.json` link-preview config
|
||||||
|
|
||||||
|
### `GET /api/image-proxy`
|
||||||
|
|
||||||
|
- **Auth:** Public
|
||||||
|
- **Query:** `url` (http/https)
|
||||||
|
- **Response:** Raw image bytes (`Content-Type` from origin)
|
||||||
|
- **Limits:** image/* only; max 8 MB; 8 s timeout; SSRF validation
|
||||||
|
- **Cache:** `Cache-Control: public, max-age=3600`
|
||||||
|
|
||||||
|
## Client usage
|
||||||
|
|
||||||
|
Message markdown / link-embed pipeline calls link-metadata for unfurling; proxied images load through `/api/image-proxy` when direct fetch would fail (CORS, mixed content).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [server-directory.md](server-directory.md) — requests use active server's API base
|
||||||
|
- Domain README: [`toju-app/src/app/domains/chat/README.md`](../../toju-app/src/app/domains/chat/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial link preview / image proxy contract |
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
# Message Integrity
|
# Message Integrity
|
||||||
|
|
||||||
Signed, append-only **message revisions** give P2P chat a verifiable history without central message storage. The materialized `Message` row in local SQLite/IDB is a cache; peers converge via inventory snapshots and revision events.
|
> **Area:** messaging
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Signed, append-only **message revisions** give P2P chat a verifiable history without central message storage. The materialized `Message` row in local SQLite/IDB is a cache; peers converge via inventory snapshots (`revision`, `headHash`) and `message-revision` events.
|
||||||
|
|
||||||
|
Parent transport and sync context: [messaging.md](messaging.md).
|
||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
@@ -15,7 +23,7 @@ Signed, append-only **message revisions** give P2P chat a verifiable history wit
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Product client (`toju-app`) | Revision construction, merge, verification, P2P broadcast, local persistence |
|
| Product client (`toju-app`) | Revision construction, merge, verification, P2P broadcast, local persistence |
|
||||||
| Signaling server (`server`) | `PUT /api/users/me/signing-key`, `GET /api/users/:id/signing-public-key` — key directory only, no message storage |
|
| Signaling server (`server`) | `PUT /api/users/me/signing-key`, `GET /api/users/:id/signing-public-key` — key directory only, no message storage |
|
||||||
| Electron / mobile persistence | `revision` + `headHash` on message rows; revision audit log (IDB store / SQLite meta) |
|
| Electron / mobile persistence | `revision` + `headHash` on message rows; revision audit log in IDB store (browser), SQLite `meta` table (Electron **and Capacitor** — keys `message-revision:<messageId>:<revision>`) |
|
||||||
|
|
||||||
Plugin API messages may emit unsigned revisions (`plugin-edit` / `plugin-delete`) when the actor is a synthetic plugin user.
|
Plugin API messages may emit unsigned revisions (`plugin-edit` / `plugin-delete`) when the actor is a synthetic plugin user.
|
||||||
|
|
||||||
@@ -47,7 +55,23 @@ Plugin API messages may emit unsigned revisions (`plugin-edit` / `plugin-delete`
|
|||||||
| `PUT` | `/api/users/me/signing-key` | Bearer | `{ publicKeyJwk }` — stores Ed25519 public JWK on the user row |
|
| `PUT` | `/api/users/me/signing-key` | Bearer | `{ publicKeyJwk }` — stores Ed25519 public JWK on the user row |
|
||||||
| `GET` | `/api/users/:id/signing-public-key` | Public | `{ publicKeyJwk }` — used by peers to verify signatures |
|
| `GET` | `/api/users/:id/signing-public-key` | Public | `{ publicKeyJwk }` — used by peers to verify signatures |
|
||||||
|
|
||||||
Registration runs automatically after login/register via `AuthenticationService`.
|
Registration runs automatically after **home** login/register via `AuthenticationService` — see [authentication.md](authentication.md) for foreign-server scope.
|
||||||
|
|
||||||
|
## Multi-device relay (`account_sync`)
|
||||||
|
|
||||||
|
`message-revision` chat events are relayable to sibling connections via WebSocket `account_sync` (alongside legacy `chat-message` paths documented in [authentication.md](authentication.md)). Inventory convergence still prefers P2P data-channel sync when peers are connected.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [authentication.md](authentication.md) — signing-key registration, `account_sync` chat batches
|
||||||
|
- [signaling.md](signaling.md) — `account_sync` envelope
|
||||||
|
- [mobile-capacitor.md](mobile-capacitor.md) — Capacitor `meta` revision keys
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Capacitor meta persistence; account_sync cross-ref; signing registration scope |
|
||||||
|
|
||||||
## Degraded-mode behavior
|
## Degraded-mode behavior
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Messaging
|
||||||
|
|
||||||
|
> **Area:** messaging
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-13
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Messaging in MetoYou covers two transports that share inventory-sync concepts and (for DMs) a monotonic delivery state machine. **Server-channel chat** is broadcast by the signaling server over WebSocket (`chat_message`) as a narrow fallback when P2P data channels are down — the server does not persist message bodies. **Direct messages** (1:1 and group DMs) are primarily peer-to-peer over the WebRTC ordered data channel, with WebSocket signaling relay when no channel is open and an offline queue when neither path succeeds.
|
||||||
|
|
||||||
|
On both transports the client maintains local history (Electron SQLite / browser IndexedDB for server channels; user-scoped `localStorage` for DMs) and a **chunked inventory-sync protocol** so peers reconcile missing rows without flooding the link.
|
||||||
|
|
||||||
|
This document is the cross-context contract: envelope names, sync protocol, delivery states, edit/delete rules, and storage boundaries. Internal NgRx orchestration lives in [`toju-app/src/app/domains/chat/README.md`](../../toju-app/src/app/domains/chat/README.md) and [`toju-app/src/app/domains/direct-message/README.md`](../../toju-app/src/app/domains/direct-message/README.md). WebSocket relay rules: [signaling.md](signaling.md). Signed revision chains: [message-integrity.md](message-integrity.md).
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Send server-channel chat over WebSocket fallback (`chat_message`) and primarily over P2P (`chat-message`, `edit-message`, `delete-message`, `message-revision`).
|
||||||
|
- Send, edit, delete, and react in direct messages over the data channel with signaling fallback.
|
||||||
|
- Carry typing indicators: server channels (`typing` → `user_typing`) and DMs (`direct-message-typing`).
|
||||||
|
- Reconcile peer history via the inventory protocol (`chat-inventory` / `chat-sync-batch`; DM `direct-message-sync`).
|
||||||
|
- Drive a monotonic DM delivery state machine: `QUEUED → SENT → DELIVERED → ACKNOWLEDGED`.
|
||||||
|
- Relay multi-device chat via `account_sync` (`chat-message`, `message-revision`, `chat-sync-batch`).
|
||||||
|
|
||||||
|
This area does **not** own:
|
||||||
|
|
||||||
|
- Attachment payloads or chunked file transfer → [attachments.md](attachments.md).
|
||||||
|
- WebRTC session setup and data-channel lifecycle → [voice-webrtc.md](voice-webrtc.md).
|
||||||
|
- Write permission resolution (`writeMessages`, `manageMessages`, bans) → `toju-app/src/app/domains/access-control/README.md`.
|
||||||
|
- Full WebSocket envelope catalog (identity, voice, plugins) → [signaling.md](signaling.md).
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
|
||||||
|
- **Server-channel message** — room-scoped text in a saved chat-server. Primary path: P2P `chat-message` on the data channel. Fallback: server broadcasts `chat_message` to other connections in the room.
|
||||||
|
- **Direct message** — 1:1 or group PM. Persisted per user under `metoyou_direct_message_*` keys (domain-owned storage, not the global messages CQRS table).
|
||||||
|
- **Conversation** — DM thread (`direct` or `group`). Upgrading a 1:1 call to a group creates a **new** group conversation; the original 1:1 history is not copied.
|
||||||
|
- **Inventory event** — `chat-inventory` (P2P): sender announces message ids plus integrity fields (`ts`, `rc`, `ac`, `revision`, `headHash`); receiver requests missing or stale ids.
|
||||||
|
- **Sync batch** — `chat-sync-batch`: chunked response, **200 messages per envelope** (`CHUNK_SIZE` in `message-sync.rules.ts`).
|
||||||
|
- **Delivery state** — DM-only enum: `QUEUED (0) → SENT (1) → DELIVERED (2) → ACKNOWLEDGED (3)`. Advanced only via `advanceDirectMessageStatus` (never backwards).
|
||||||
|
- **Peer delivery** — `PeerDeliveryService` tries data channel, then signaling forward, then offline queue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transports
|
||||||
|
|
||||||
|
### Server-channel chat
|
||||||
|
|
||||||
|
**P2P (primary):** `chat-message`, `edit-message`, `delete-message`, `message-revision`, reactions, and inventory events on the ordered data channel. See [message-integrity.md](message-integrity.md) for dual-emit revision behavior.
|
||||||
|
|
||||||
|
**WebSocket (fallback):** Client sends `chat_message`; `handleChatMessage` (`server/src/websocket/handler.ts`) broadcasts to other connections in the room. The server does **not** handle `edit_message` or `delete_message` on the wire — edits and deletes are P2P (and `account_sync` for sibling devices).
|
||||||
|
|
||||||
|
**Typing:** Client sends `typing`; server broadcasts `user_typing` (transient, no persistence).
|
||||||
|
|
||||||
|
**Multi-device:** Sibling tabs receive live chat via `account_sync` payloads (`chat-message`, `message-revision`, `chat-sync-batch`). See [authentication.md](authentication.md).
|
||||||
|
|
||||||
|
### Direct messages
|
||||||
|
|
||||||
|
**P2P (primary):** Events on the shared ordered data channel (same peer connections as voice/chat).
|
||||||
|
|
||||||
|
**WebSocket (fallback):** `PeerDeliveryService.sendViaSignaling` forwards these types to `targetUserId` without requiring shared server membership:
|
||||||
|
|
||||||
|
| type | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `direct-message` | New message |
|
||||||
|
| `direct-message-status` | Delivery / ack |
|
||||||
|
| `direct-message-mutation` | Edit, delete, reactions |
|
||||||
|
| `direct-message-typing` | Typing indicator |
|
||||||
|
| `direct-message-sync-request` | Request snapshot |
|
||||||
|
| `direct-message-sync` | Bounded history merge |
|
||||||
|
|
||||||
|
**Offline queue:** When both paths fail, `OfflineMessageQueueService` retains message ids; replay runs on `peerConnected$` / `networkRestored$` (no scheduled retry timer).
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
| Data | Where |
|
||||||
|
|------|--------|
|
||||||
|
| Server-channel messages | `DatabaseService` → Electron SQLite or browser IndexedDB (`messages` store) |
|
||||||
|
| Direct messages | `metoyou_direct_message_*` via direct-message repositories |
|
||||||
|
| Signaling server | **No message bytes** — broadcast/relay only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inventory / sync protocol
|
||||||
|
|
||||||
|
Shared shapes in `toju-app/src/app/shared-kernel/chat-events.ts`:
|
||||||
|
|
||||||
|
| Event | Role |
|
||||||
|
|-------|------|
|
||||||
|
| `chat-inventory-request` | Ask peer for inventory |
|
||||||
|
| `chat-inventory` | Announce ids + integrity snapshots |
|
||||||
|
| `chat-sync-request` | Request specific missing ids |
|
||||||
|
| `chat-sync-batch` | Up to **200** messages per envelope |
|
||||||
|
| `direct-message-sync-request` / `direct-message-sync` | DM-scoped snapshot merge |
|
||||||
|
|
||||||
|
Rules (`message-sync.rules.ts`, `message-integrity.rules.ts`):
|
||||||
|
|
||||||
|
- Merges are **additive** — sparser peers never wipe richer local history.
|
||||||
|
- `findMissingIds` compares remote inventory to local `revision` / `headHash` (and legacy `ts` / `rc` / `ac`).
|
||||||
|
- `INVENTORY_LIMIT` = `FULL_SYNC_LIMIT` = **20_000** (2026-07-14, RAM investigation; previously 1_000_000). Building an inventory or full-sync batch loads full message rows into memory, so the ceiling must stay bounded. Only the most recent 20k messages per room are reconciled peer-to-peer; older messages stay local-only. `ACCOUNT_SYNC_MESSAGE_LIMIT` follows `FULL_SYNC_LIMIT`.
|
||||||
|
- Sync polling: 10 s when catching up, 15 min after a clean cycle (`SYNC_POLL_FAST_MS` / `SYNC_POLL_SLOW_MS`).
|
||||||
|
- NgRx store retention: on room switch, inactive rooms are pruned to the most recent `CACHED_INACTIVE_ROOM_MESSAGE_LIMIT` = **100** messages each (`messages.reducer.ts`), keeping return-visit rendering instant while bounding store growth across many rooms. The active room is never pruned; the local DB keeps full history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delivery state machine (DMs only)
|
||||||
|
|
||||||
|
| Value | Numeric | Meaning |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `QUEUED` | 0 | Composed locally; no successful send yet |
|
||||||
|
| `SENT` | 1 | Data channel or signaling forward accepted the payload |
|
||||||
|
| `DELIVERED` | 2 | At least one recipient acknowledged receipt |
|
||||||
|
| `ACKNOWLEDGED` | 3 | Full recipient set acknowledged (1:1: the peer; group: every participant) |
|
||||||
|
|
||||||
|
`advanceDirectMessageStatus` only moves forward (`direct-message.logic.ts`). Server-channel messages have no application-level delivery enum; the UI treats them as sent once the transport accepts the event.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edit and delete
|
||||||
|
|
||||||
|
**Server channels:** Outgoing edits check `canEditMessage(message, userId)` before broadcast. Incoming P2P `edit-message` / `delete-message` merge via NgRx handlers; signed paths prefer `message-revision` when integrity is enabled.
|
||||||
|
|
||||||
|
**DMs:** `direct-message-mutation` with types `edit`, `delete`, `reaction-add`, `reaction-remove`. `applyMutation` in `DirectMessageService` updates by `messageId` but **does not verify** the mutator is the original author — a non-cooperating peer could mutate another user's row. Server chat enforces authorship on **outgoing** edits only.
|
||||||
|
|
||||||
|
Deletes keep tombstone semantics (`isDeleted`, empty `content`) so inventory sync can converge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business rules and invariants
|
||||||
|
|
||||||
|
- The signaling server is **not authoritative** for message content — it relays `chat_message` and DM types opaquely.
|
||||||
|
- DM events are **ignored** unless the local user is in `recipients` / `participants` or already has the conversation locally.
|
||||||
|
- Recipient matching (DM **and** `direct-call`) must accept **every local identity alias** — home id, entity id, peer id, and each provisioned signal-server actor id from `SignalServerCredentialStoreService` — because senders who met the recipient on a foreign signal server address them by the provisioned actor id (`direct-message-identity.rules.ts`, `direct-call-participant-identity.rules.ts`).
|
||||||
|
- DM status transitions are **monotonic**.
|
||||||
|
- Inventory merges never downgrade a row with a newer `revision` / `headHash`.
|
||||||
|
- 1:1 → group upgrade **does not copy** private history into the new group thread.
|
||||||
|
- Unread counts are **idempotent by message id** — re-sync does not double-increment.
|
||||||
|
- Incoming DMs raise a system notification via `NotificationsFacade.handleIncomingDirectMessage` (title = sender name; `shouldDeliverDirectMessageNotification` suppresses only when the conversation is on screen in an active window, notifications are disabled, or the user is busy). System messages (e.g. call-started) and deletions never notify. On Capacitor this flows through the same `DesktopNotificationService` → LocalNotifications routing as server chat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical implementation
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
- `server/src/websocket/handler.ts` — `handleChatMessage`, `handleTyping`, DM forward via `forwardRtcMessage` / `DIRECT_SIGNALING_TYPES`.
|
||||||
|
- No message CQRS or entities on the server.
|
||||||
|
|
||||||
|
### Product client
|
||||||
|
|
||||||
|
| Area | Location |
|
||||||
|
|------|----------|
|
||||||
|
| Server chat effects / handlers | `store/messages/`, `domains/chat/` |
|
||||||
|
| DM service / queue | `domains/direct-message/application/services/` |
|
||||||
|
| Sync rules | `domains/chat/domain/rules/message-sync.rules.ts` |
|
||||||
|
| Wire types | `shared-kernel/chat-events.ts`, `direct-message-contracts.ts` |
|
||||||
|
| Account sync relay | `infrastructure/realtime/account-sync/` |
|
||||||
|
|
||||||
|
### Electron
|
||||||
|
|
||||||
|
- Server-channel rows: TypeORM `Message` entity + CQRS `save-message` / `delete-message`.
|
||||||
|
- DMs: renderer `localStorage` repositories (not the main message table).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit: `message-sync.rules.spec.ts`, `message-integrity.rules.spec.ts`, `message.rules.spec.ts`, `direct-message.service.spec.ts`, `direct-message.logic` specs, `messages-incoming.handlers.spec.ts`, `account-sync-chat.helper.spec.ts`.
|
||||||
|
- E2E: `e2e/tests/chat/chat-message-features.spec.ts`, `multi-client-chat-sync.spec.ts`, `dm-flow.spec.ts`, `multi-device-attachment-sharing.spec.ts`, `e2e/tests/voice/dm-header-call-ring.spec.ts` (DM-header call ring, incl. cross-signal actor-id addressing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance considerations
|
||||||
|
|
||||||
|
- Sync batches: **200 messages per `chat-sync-batch` envelope**.
|
||||||
|
- `chat_message` broadcast is O(connections in room) per send.
|
||||||
|
- Group DMs: O(recipients) transport attempts per message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security considerations
|
||||||
|
|
||||||
|
- **No end-to-end encryption** for message bodies. WebRTC data channels use DTLS; signaling fallback is TLS WebSocket; local DBs store plaintext.
|
||||||
|
- **DM `applyMutation` does not verify authorship** on incoming mutations.
|
||||||
|
- **No server-side rate limit** on `chat_message` volume.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known issues and limitations
|
||||||
|
|
||||||
|
- **No server-side chat log** — late joiners depend on peers with local history or `account_sync` from a sibling device.
|
||||||
|
- **DM mutation authorship** not verified on receive.
|
||||||
|
- **Offline queue** replays only on peer connect / network restore events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related features
|
||||||
|
|
||||||
|
- [signaling.md](signaling.md) — WebSocket relay and ordering invariants
|
||||||
|
- [message-integrity.md](message-integrity.md) — signed revision chains
|
||||||
|
- [attachments.md](attachments.md) — file payloads alongside chat events
|
||||||
|
- [voice-webrtc.md](voice-webrtc.md) — data channel transport
|
||||||
|
- [authentication.md](authentication.md) — `account_sync` multi-device relay
|
||||||
|
- [direct-messaging.md](direct-messaging.md) — short index (defers here)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-14 | RAM bounds: `INVENTORY_LIMIT`/`FULL_SYNC_LIMIT` lowered to 20k (most recent messages reconcile); NgRx prunes inactive rooms to 100 cached messages on room switch |
|
||||||
|
| 2026-07-13 | Incoming DMs raise system notifications through the notifications domain (previously unread-badge only) |
|
||||||
|
| 2026-07-13 | Recipient matching for DM and `direct-call` events must span all local identity aliases (provisioned actor ids included) |
|
||||||
|
| 2026-07-05 | Initial comprehensive messaging contract (replaces thin direct-messaging summary) |
|
||||||
@@ -31,10 +31,6 @@ npm run cap:sync
|
|||||||
npm run cap:open:android
|
npm run cap:open:android
|
||||||
npm run cap:open:ios
|
npm run cap:open:ios
|
||||||
|
|
||||||
### Linux: Android Studio path
|
|
||||||
|
|
||||||
Capacitor defaults to `/usr/local/android-studio/bin/studio.sh`. If Android Studio is installed elsewhere (common with **Flatpak** from Flathub), `npm run cap:open:android` uses `tools/resolve-android-studio-path.js` to locate `studio.sh` (Flatpak `active` symlink, Toolbox, snap, `/opt`, etc.). Override anytime with `CAPACITOR_ANDROID_STUDIO_PATH`.
|
|
||||||
|
|
||||||
# Convenience (build + sync + open)
|
# Convenience (build + sync + open)
|
||||||
npm run cap:build:android
|
npm run cap:build:android
|
||||||
npm run cap:build:ios
|
npm run cap:build:ios
|
||||||
@@ -44,6 +40,10 @@ npm run cap:apk:android
|
|||||||
# → toju-app/android/app/build/outputs/apk/debug/app-debug.apk
|
# → toju-app/android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Linux: Android Studio path
|
||||||
|
|
||||||
|
Capacitor defaults to `/usr/local/android-studio/bin/studio.sh`. If Android Studio is installed elsewhere (common with **Flatpak** from Flathub), `npm run cap:open:android` uses `tools/resolve-android-studio-path.js` to locate `studio.sh` (Flatpak `active` symlink, Toolbox, snap, `/opt`, etc.). Override anytime with `CAPACITOR_ANDROID_STUDIO_PATH`.
|
||||||
|
|
||||||
Config: `toju-app/capacitor.config.ts` (`webDir: ../dist/client/browser`).
|
Config: `toju-app/capacitor.config.ts` (`webDir: ../dist/client/browser`).
|
||||||
|
|
||||||
### CI (Gitea)
|
### CI (Gitea)
|
||||||
@@ -85,13 +85,15 @@ Re-run `npm run cap:assets:android` whenever `images/icon-new-rounded.png` chang
|
|||||||
| Feature | Status | Notes |
|
| Feature | Status | Notes |
|
||||||
|---------|--------|-------|
|
|---------|--------|-------|
|
||||||
| Push/local notifications | **Working (partial)** | Local notifications always available; remote push (FCM/APNs) registers only when Firebase/APNs is configured — app starts normally without `google-services.json` |
|
| Push/local notifications | **Working (partial)** | Local notifications always available; remote push (FCM/APNs) registers only when Firebase/APNs is configured — app starts normally without `google-services.json` |
|
||||||
|
| Chat message notifications | **Working** | `DesktopNotificationService` routes to `MobileNotificationsService.showMessage()` on Capacitor (LocalNotifications channel `toju-messages`); web `Notification` API is never used on native shells |
|
||||||
| Server push dispatch | **Working (configured)** | Tokens persist in server SQLite; outbound FCM/APNs via env credentials |
|
| Server push dispatch | **Working (configured)** | Tokens persist in server SQLite; outbound FCM/APNs via env credentials |
|
||||||
| In-call notifications | **Working (Capacitor)** | Persistent notification with answer/mute/hang-up actions |
|
| In-call notifications | **Working (Capacitor)** | Persistent notification with answer/mute/hang-up actions |
|
||||||
| Stream pop-out (PiP) | **Working (partial)** | Document PiP when WebView supports it; Android native PiP fallback via `MetoyouMobile` plugin |
|
| Stream pop-out (PiP) | **Working (partial)** | Document PiP when WebView supports it; Android native PiP fallback via `MetoyouMobile` plugin |
|
||||||
| Background voice | **Working (partial)** | Android foreground service; iOS `UIBackgroundModes` audio + CallKit active-call bridge |
|
| Background voice | **Working (partial)** | Android foreground service; iOS `UIBackgroundModes` audio + CallKit active-call bridge |
|
||||||
| iOS CallKit | **Working (partial)** | `MetoyouMobile.startCallKitSession` reports active calls; requires Xcode target wiring after `cap:sync` |
|
| iOS CallKit | **Working (partial)** | `MetoyouMobile.startCallKitSession` reports active calls; requires Xcode target wiring after `cap:sync` |
|
||||||
| Screensharing | **Limited** | Disabled on iOS WebView; Android `getDisplayMedia` may work |
|
| Screensharing | **Hidden on native mobile** | `getDisplayMedia` is unavailable in mobile WebViews; all screen-share buttons (private call, voice controls, floating controls, voice workspace) are gated behind `!viewport.isMobile() && !MobilePlatformService.isNativeMobile()` |
|
||||||
| Composer attachments | **Working** | Mobile attachment button + hidden file input |
|
| Composer attachments | **Working** | Mobile attachment button + hidden file input |
|
||||||
|
| Attachment download/export | **Working** | `AttachmentDownloadService` delegates to `CapacitorAttachmentExportService` on native shells: copies disk-backed files (or fetches the object URL) into the public `Documents` directory with a timestamped name; anchor `download` links do nothing in the Android WebView |
|
||||||
| Camera sharing | **Working** | Existing `getUserMedia` camera path in WebRTC stack |
|
| Camera sharing | **Working** | Existing `getUserMedia` camera path in WebRTC stack |
|
||||||
| Speakerphone | **Working (partial)** | Android `AudioManager` via `MetoyouMobile`; iOS `@capgo/capacitor-audio-session`; direct-call speaker toggle on native mobile |
|
| Speakerphone | **Working (partial)** | Android `AudioManager` via `MetoyouMobile`; iOS `@capgo/capacitor-audio-session`; direct-call speaker toggle on native mobile |
|
||||||
| Local DB (SQLite) | **Working** | `DatabaseService` routes Capacitor shells to `CapacitorDatabaseService` (native SQLite CRUD) |
|
| Local DB (SQLite) | **Working** | `DatabaseService` routes Capacitor shells to `CapacitorDatabaseService` (native SQLite CRUD) |
|
||||||
@@ -103,7 +105,7 @@ Re-run `npm run cap:assets:android` whenever `images/icon-new-rounded.png` chang
|
|||||||
- **iOS CallKit:** Plugin Swift source ships in `ios/App/App/MetoyouMobilePlugin.swift`; add it to the Xcode target if not auto-linked. Incoming-call UI is not fully bridged to WebRTC answer/hang-up yet.
|
- **iOS CallKit:** Plugin Swift source ships in `ios/App/App/MetoyouMobilePlugin.swift`; add it to the Xcode target if not auto-linked. Incoming-call UI is not fully bridged to WebRTC answer/hang-up yet.
|
||||||
- **iOS screenshare:** `getDisplayMedia` is not available in WKWebView.
|
- **iOS screenshare:** `getDisplayMedia` is not available in WKWebView.
|
||||||
- **Android PiP:** Native PiP enters activity-level PiP; WebView video may not always render inside PiP on all OEM WebViews.
|
- **Android PiP:** Native PiP enters activity-level PiP; WebView video may not always render inside PiP on all OEM WebViews.
|
||||||
- **Production discovery:** `signal.toju.app` may not expose `/api/servers/featured` or `/trending`; client skips those calls for known hosts.
|
- **Legacy discovery endpoints:** Older signal servers may not expose `/api/servers/featured` or `/trending` (they resolve as `/servers/:id` and return 404). The client still calls those routes on every online endpoint and **falls back per-endpoint to `GET /api/servers`** when 404 is returned — see [server-discovery.md](server-discovery.md).
|
||||||
- **Push delivery:** Requires FCM service account and APNs key configuration on the signaling server.
|
- **Push delivery:** Requires FCM service account and APNs key configuration on the signaling server.
|
||||||
|
|
||||||
## Push notification setup (FCM / APNs)
|
## Push notification setup (FCM / APNs)
|
||||||
@@ -133,8 +135,11 @@ Declared in `toju-app/android/app/src/main/AndroidManifest.xml`:
|
|||||||
| `BLUETOOTH_CONNECT` | Bluetooth headset routing during calls (Android 12+) |
|
| `BLUETOOTH_CONNECT` | Bluetooth headset routing during calls (Android 12+) |
|
||||||
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
||||||
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
|
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
|
||||||
|
| `READ_EXTERNAL_STORAGE` (maxSdk 32) / `WRITE_EXTERNAL_STORAGE` (maxSdk 29) | Attachment export to public `Documents` on Android 10 and below |
|
||||||
|
|
||||||
Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells. If the native plugin is unavailable or the bridge call fails, capture preflight defers to the WebView `getUserMedia` permission flow instead of aborting voice/camera joins.
|
Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells. If the native plugin is unavailable or the bridge call fails, capture preflight defers to the WebView `getUserMedia` permission flow instead of aborting voice/camera joins. Preflight only blocks capture on an explicit native `denied` state (`mobile-media-permission.rules.ts`); a `prompt` state is deferred to the WebView so the user still gets the permission dialog.
|
||||||
|
|
||||||
|
Join and capture failures are surfaced in the UI instead of failing silently: `DirectCallService.joinCall` sets a `joinError` signal (`call.errors.*` i18n keys for signaling, capture-unsupported, mic permission, and mic unavailable cases), and the private-call and voice-controls components surface camera errors the same way.
|
||||||
|
|
||||||
On Capacitor startup, `MobileRuntimePermissionsService` (via `MobileAppLifecycleService.initialize()`) proactively prompts for microphone, camera, local-notification, and push-notification runtime permissions so Android 13+ shells do not keep every permission in the "Not allowed" state until the user joins voice or receives a call.
|
On Capacitor startup, `MobileRuntimePermissionsService` (via `MobileAppLifecycleService.initialize()`) proactively prompts for microphone, camera, local-notification, and push-notification runtime permissions so Android 13+ shells do not keep every permission in the "Not allowed" state until the user joins voice or receives a call.
|
||||||
|
|
||||||
@@ -165,16 +170,19 @@ Tokens persist in server SQLite (`device_tokens` table). Outbound push uses repo
|
|||||||
| `APNS_BUNDLE_ID` | Defaults to `com.metoyou.app` |
|
| `APNS_BUNDLE_ID` | Defaults to `com.metoyou.app` |
|
||||||
| `APNS_USE_SANDBOX` | `true` for development builds |
|
| `APNS_USE_SANDBOX` | `true` for development builds |
|
||||||
|
|
||||||
Manual dispatch (ops/testing):
|
Manual dispatch (ops/testing). Requires `Authorization: Bearer`; `:userId` in the path **must match** the authenticated user (`403` otherwise):
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /api/users/device-tokens/:userId/dispatch
|
POST /api/users/device-tokens/:userId/dispatch
|
||||||
|
Authorization: Bearer <token>
|
||||||
{ "title": "Incoming call", "body": "Alice is calling" }
|
{ "title": "Incoming call", "body": "Alice is calling" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`POST /api/users/device-tokens` and `GET /api/users/device-tokens/:userId` apply the same rule: body/param `userId` must equal the bearer identity.
|
||||||
|
|
||||||
## Android foreground service
|
## Android foreground service
|
||||||
|
|
||||||
`VoiceCallForegroundService` starts when `MobileCallSessionService` begins an active call. Required manifest permissions:
|
`VoiceCallForegroundService` starts when `MobileCallSessionService` begins an active call. The voice-channel path also starts/stops it directly: `MediaManager.enableVoice()` / `disableVoice()` call `startMobileVoiceForegroundSession()` / `stopMobileVoiceForegroundSession()` (`infrastructure/mobile/logic/mobile-voice-foreground-session.ts`) so channel voice keeps the mic alive when the app backgrounds. Required manifest permissions:
|
||||||
|
|
||||||
- `FOREGROUND_SERVICE`
|
- `FOREGROUND_SERVICE`
|
||||||
- `FOREGROUND_SERVICE_MICROPHONE`
|
- `FOREGROUND_SERVICE_MICROPHONE`
|
||||||
@@ -193,10 +201,13 @@ The service shows a low-importance ongoing notification while a call is active.
|
|||||||
- Routing: `infrastructure/persistence/database-backend.rules.ts` — Capacitor uses SQLite, not IndexedDB.
|
- Routing: `infrastructure/persistence/database-backend.rules.ts` — Capacitor uses SQLite, not IndexedDB.
|
||||||
- Per-user database files: `metoyou__<userId>` via `mobile-sqlite-database-name.rules.ts`.
|
- Per-user database files: `metoyou__<userId>` via `mobile-sqlite-database-name.rules.ts`.
|
||||||
- First launch runs DDL migrations stored in the `meta` table. Schema init failures are cached per database file so the client does not retry in a loop.
|
- First launch runs DDL migrations stored in the `meta` table. Schema init failures are cached per database file so the client does not retry in a loop.
|
||||||
|
- **Custom emoji assets** persist in the `custom_emojis` table (`CapacitorDatabaseService.saveCustomEmoji` / `getCustomEmojis` / `deleteCustomEmoji`).
|
||||||
|
- **Message revisions** persist in `meta` under keys `message-revision:<messageId>:<revision>` (JSON payload). See [message-integrity.md](message-integrity.md) and [custom-emoji.md](custom-emoji.md).
|
||||||
|
|
||||||
## Capacitor plugin loading
|
## Capacitor plugin loading
|
||||||
|
|
||||||
- `infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts` uses **static** `@capacitor/*` imports and `Capacitor.isPluginAvailable()` before returning a plugin. Do not `import()` plugin modules dynamically or `await` plugin objects (Capacitor proxies expose a throwing `.then()` stub).
|
- `infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts` loads `@capacitor/*` modules via **dynamic `import()`** only when `isCapacitorNativeRuntime()` is true, and checks `Capacitor.isPluginAvailable()` before returning a plugin. Electron and browser shells never evaluate these imports at startup.
|
||||||
|
- Do not `await` a Capacitor plugin proxy object directly — Capacitor proxies expose a throwing `.then()` stub; always call methods on the resolved plugin instance.
|
||||||
- After adding or upgrading Capacitor plugins, run `npm run build:prod && npm run cap:sync` so Android/iOS native projects register `App`, `AppUpdate`, `LocalNotifications`, push, and SQLite.
|
- After adding or upgrading Capacitor plugins, run `npm run build:prod && npm run cap:sync` so Android/iOS native projects register `App`, `AppUpdate`, `LocalNotifications`, push, and SQLite.
|
||||||
|
|
||||||
## Safe area (Android)
|
## Safe area (Android)
|
||||||
@@ -267,10 +278,16 @@ Phase 3 delivered:
|
|||||||
3. iOS CallKit bridge (partial) via `MetoyouMobile` plugin and `MobileCallKitService`.
|
3. iOS CallKit bridge (partial) via `MetoyouMobile` plugin and `MobileCallKitService`.
|
||||||
4. Android Firebase Gradle wiring with `google-services.json.example` (real file gitignored).
|
4. Android Firebase Gradle wiring with `google-services.json.example` (real file gitignored).
|
||||||
5. Capacitor plugin availability checks to avoid hard failures when plugins are missing pre-sync.
|
5. Capacitor plugin availability checks to avoid hard failures when plugins are missing pre-sync.
|
||||||
6. Discovery endpoint skip for production signal hosts without featured/trending routes.
|
6. Discovery 404 fallback to public server listing on legacy signal hosts (see [server-discovery.md](server-discovery.md)).
|
||||||
|
|
||||||
Remaining work:
|
Remaining work:
|
||||||
|
|
||||||
- Wire CallKit answer/end actions back into `DirectCallService`.
|
- Wire CallKit answer/end actions back into `DirectCallService`.
|
||||||
- Migrate legacy IndexedDB mobile data into SQLite where needed.
|
- Migrate legacy IndexedDB mobile data into SQLite where needed.
|
||||||
- Deploy featured/trending routes to production signal servers or add capability negotiation in health checks.
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-13 | Chat notifications routed to LocalNotifications (`toju-messages` channel + `ic_stat_metoyou` status icon); capture preflight blocks only on native `denied`; call join/camera errors surfaced via `call.errors.*`; voice channels start the foreground service; screen share hidden on native mobile; attachment export to `Documents`; full-screen overlays use `metoyou-fixed-safe-viewport` |
|
||||||
|
| 2026-07-05 | Corrected discovery fallback (not host skip), plugin-loader dynamic imports, markdown fence; added Capacitor custom-emoji/revision persistence and dispatch auth rules |
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Client-only plugin runtime with server-stored **metadata** (install requirements, event definitions) and Electron-local **plugin data** persistence. Plugins extend chat slash commands, toolbar actions, DOM mounts, and a P2P message bus — they never execute on the signaling server.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
|-------|------|
|
||||||
|
| Product client (`plugins` domain) | Manifest validation, load order, `PluginHostService`, UI registry, store installs |
|
||||||
|
| Electron | Local manifest discovery (`plugins/`, `plugin-bundles/`), `plugin_data` CQRS table, path jail |
|
||||||
|
| Signaling server | Requirement/event metadata REST + `plugin_event` WebSocket broadcast; **no** plugin code execution |
|
||||||
|
| P2P data channel | `plugin-message-bus` events (ignored by chat reducers) |
|
||||||
|
|
||||||
|
Server plugin **data** HTTP routes return **410 Gone** (`PLUGIN_DATA_DISABLED`).
|
||||||
|
|
||||||
|
## Server REST (`/api/servers/:serverId/plugins`)
|
||||||
|
|
||||||
|
| Method | Path | Auth |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/` | Public (metadata snapshot) |
|
||||||
|
| PUT | `/:pluginId/requirement` | Bearer |
|
||||||
|
| DELETE | `/:pluginId/requirement` | Bearer |
|
||||||
|
| PUT | `/:pluginId/events/:eventName` | Bearer |
|
||||||
|
| DELETE | `/:pluginId/events/:eventName` | Bearer |
|
||||||
|
| GET/PUT/DELETE | `/:pluginId/data/*` | 410 (disabled) |
|
||||||
|
|
||||||
|
## WebSocket
|
||||||
|
|
||||||
|
| type | Direction | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| `plugin_requirements` | Server → client | Snapshot after `join_server` / `view_server` |
|
||||||
|
| `plugin_event` | Client → server → room | Validated broadcast of plugin events |
|
||||||
|
| `plugin_error` | Server → client | Validation failure |
|
||||||
|
|
||||||
|
See [signaling.md](signaling.md).
|
||||||
|
|
||||||
|
## Manifest scopes
|
||||||
|
|
||||||
|
- `scope: "client"` — global desktop/browser plugins (Settings → Client plugins).
|
||||||
|
- `scope: "server"` — per chat-server plugins; join may block until user consents to required plugins.
|
||||||
|
|
||||||
|
Store source manifests support HTTPS `bundle`/`bundleUrl` with optional SHA-256 `integrity` verification before `import()`.
|
||||||
|
|
||||||
|
## Electron IPC / storage
|
||||||
|
|
||||||
|
- `list-local-plugin-manifests`, `get-local-plugins-path`, `grant-plugin-read-root`
|
||||||
|
- Plugin preferences and `api.clientData` / `api.serverData` → `plugin_data` table (user-scoped)
|
||||||
|
- Cached bundles: `plugin-bundles/<plugin-id>/<version>/main.js`
|
||||||
|
|
||||||
|
## Client API surface (summary)
|
||||||
|
|
||||||
|
Plugins receive `TojuClientPluginApi`: `commands`, `ui.mountElement`, `ui.registerToolbarAction`, `messageBus`, `messages.setTyping`, `context.getCurrent()`, `clientData`/`serverData` async storage.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [signaling.md](signaling.md) — `plugin_event`, `plugin_requirements`
|
||||||
|
- [server-directory.md](server-directory.md) — server-scoped install on join
|
||||||
|
- [authentication.md](authentication.md) — bearer on metadata mutations
|
||||||
|
- Domain README: [`toju-app/src/app/domains/plugins/README.md`](../../toju-app/src/app/domains/plugins/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial cross-context plugin contract |
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Push Notifications
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Mobile remote push (FCM/APNs) and server-side device token storage. Desktop uses local/Electron notifications via the `notifications` domain.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
|-------|------|
|
||||||
|
| Signaling server | `device_tokens` SQLite table; FCM/APNs dispatch |
|
||||||
|
| Capacitor client | Token registration via `MobilePushRegistrationService` |
|
||||||
|
| Server env | FCM service account + APNs key configuration |
|
||||||
|
|
||||||
|
## REST API (`/api/users/device-tokens`)
|
||||||
|
|
||||||
|
All routes require bearer; `userId` must match authenticated identity.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| POST | `/` | Upsert `{ userId, platform: "android"\|"ios", token }` |
|
||||||
|
| GET | `/:userId` | List tokens for user |
|
||||||
|
| POST | `/:userId/dispatch` | Manual push `{ title, body, data? }` (ops/testing) |
|
||||||
|
|
||||||
|
## Server configuration
|
||||||
|
|
||||||
|
Repository-root `.env`:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `FCM_SERVICE_ACCOUNT_PATH` or `FCM_SERVICE_ACCOUNT_JSON` | Android FCM HTTP v1 |
|
||||||
|
| `APNS_KEY_PATH`, `APNS_KEY_ID`, `APNS_TEAM_ID` | iOS APNs HTTP/2 |
|
||||||
|
| `APNS_BUNDLE_ID` | Default `com.metoyou.app` |
|
||||||
|
| `APNS_USE_SANDBOX` | Development builds |
|
||||||
|
|
||||||
|
## Mobile client
|
||||||
|
|
||||||
|
- Optional Firebase: app starts without `google-services.json`; registration skipped when remote push not configured.
|
||||||
|
- See [mobile-capacitor.md](mobile-capacitor.md) for FCM/APNs setup, permissions, and in-call local notifications.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [authentication.md](authentication.md) — bearer + userId match rules
|
||||||
|
- [mobile-capacitor.md](mobile-capacitor.md) — client registration and foreground service
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial push notification contract |
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Server Directory
|
||||||
|
|
||||||
|
> **Area:** server-directory
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Server directory is the cross-context contract for **which signaling servers exist**, how clients health-check and route to them, and how public/private chat-servers are created, joined, updated, moderated, and discovered over REST. It spans the signaling **server** (`server/src/routes/servers.ts`, CQRS handlers) and the product **client** (`server-directory` domain + `ServerDirectoryFacade`).
|
||||||
|
|
||||||
|
Curated browse lists (featured/trending) are documented separately in [server-discovery.md](server-discovery.md). WebSocket membership (`join_server`, presence) is in [signaling.md](signaling.md).
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Server: persist public server records, memberships, channels, roles, bans, invites, join requests; expose REST CRUD and access checks.
|
||||||
|
- Client: maintain configured endpoint list, health/compatibility probes, canonical endpoint dedup by `serverInstanceId`, room `sourceId`/`sourceUrl` affinity, and HTTP orchestration for all server operations.
|
||||||
|
- It does NOT own: P2P chat transport, voice WebRTC, or local Electron room/message persistence (except mirroring server metadata into local DB after join).
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
|
||||||
|
- **ServerEndpoint:** configured signaling base URL with health status, latency, and version compatibility.
|
||||||
|
- **ServerInfo:** public server card shape returned by search/discovery/GET — includes `sourceId`, `sourceName`, `sourceUrl` filled by the client API layer.
|
||||||
|
- **Room signal affinity:** each saved room records which endpoint registered it; reconnect prefers that URL before fallback endpoints.
|
||||||
|
- **serverInstanceId:** stable id from `GET /api/health` used to collapse alias URLs to one canonical endpoint.
|
||||||
|
|
||||||
|
## Public REST (no bearer)
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/api/health` | Liveness, `serverVersion`, `serverInstanceId`, optional `serverTag` |
|
||||||
|
| GET | `/api/servers` | Free-text search / public listing (`q`, `limit`) |
|
||||||
|
| GET | `/api/servers/featured` | Curated popular list — [server-discovery.md](server-discovery.md) |
|
||||||
|
| GET | `/api/servers/trending` | Curated active list — [server-discovery.md](server-discovery.md) |
|
||||||
|
| GET | `/api/servers/:id` | Single server metadata |
|
||||||
|
|
||||||
|
## Protected REST (bearer required)
|
||||||
|
|
||||||
|
All mutations derive the actor from the session token; body user ids are not trusted.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| POST | `/api/servers` | Register a new public server |
|
||||||
|
| PUT | `/api/servers/:id` | Update name, description, channels, icon metadata, access settings |
|
||||||
|
| DELETE | `/api/servers/:id` | Unregister server (owner) |
|
||||||
|
| POST | `/api/servers/:id/join` | Join or request access (password, invite, public) |
|
||||||
|
| POST | `/api/servers/:id/leave` | Leave membership |
|
||||||
|
| POST | `/api/servers/:id/heartbeat` | Refresh `lastSeen` for trending ranking |
|
||||||
|
| POST | `/api/servers/:id/invites` | Create invite link — [invites-join-requests.md](invites-join-requests.md) |
|
||||||
|
| GET | `/api/servers/:id/requests` | List pending join requests (moderators) |
|
||||||
|
| POST | `/api/servers/:id/moderation/kick` | Remove member |
|
||||||
|
| POST | `/api/servers/:id/moderation/ban` | Ban member (optional expiry) |
|
||||||
|
| POST | `/api/servers/:id/moderation/unban` | Lift ban |
|
||||||
|
|
||||||
|
Join-request approval: `PUT /api/requests/:id` — [invites-join-requests.md](invites-join-requests.md).
|
||||||
|
|
||||||
|
Plugin metadata under `/api/servers/:serverId/plugins` — [plugins.md](plugins.md).
|
||||||
|
|
||||||
|
## Client endpoint lifecycle
|
||||||
|
|
||||||
|
1. Load endpoints from `localStorage` (`metoyou_server_endpoints`); reconcile with environment defaults.
|
||||||
|
2. `testAllServers()` probes `GET /api/health` (5 s timeout); on failure falls back to `GET /api/servers`.
|
||||||
|
3. Mark incompatible when `serverVersion` fails semantic compatibility check.
|
||||||
|
4. `resolveCanonicalEndpoint()` collapses aliases sharing the same `serverInstanceId`.
|
||||||
|
5. Cold-start room reconnect waits for the initial health sweep before opening WebSockets.
|
||||||
|
|
||||||
|
## Multi-endpoint behavior
|
||||||
|
|
||||||
|
| Operation | Fan-out |
|
||||||
|
|-----------|---------|
|
||||||
|
| Search (`searchServers` with `searchAllServers`) | All online endpoints, dedupe by server id |
|
||||||
|
| Discovery (featured/trending) | All online endpoints + 404→public list fallback |
|
||||||
|
| Room CRUD/join | Authoritative room `sourceUrl` first; temporary fallback to other compatible endpoints on outage |
|
||||||
|
|
||||||
|
Only `status === 'incompatible'` stops fallback with an update-required message. Network errors and Cloudflare 521/522 must continue to the next endpoint.
|
||||||
|
|
||||||
|
## Server-owned channel metadata
|
||||||
|
|
||||||
|
`PUT /api/servers/:id` persists the server's `channels` array (text + voice). The client round-trips channel create/rename/delete through this API — local-only channel state is not authoritative. Server-side normalisation deduplicates names within each channel type.
|
||||||
|
|
||||||
|
## WebSocket complement
|
||||||
|
|
||||||
|
After REST join, the client sends `join_server` on the room's signaling URL. Presence (`server_users`, `user_joined`, `user_left`) is room-scoped on the WebSocket — see [signaling.md](signaling.md).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [server-discovery.md](server-discovery.md) — featured/trending ranking and browse UI
|
||||||
|
- [authentication.md](authentication.md) — bearer tokens for mutations
|
||||||
|
- [invites-join-requests.md](invites-join-requests.md) — invite links and approval workflow
|
||||||
|
- [signal-server-tag.md](signal-server-tag.md) — `serverTag` on health + profile cards
|
||||||
|
- Product-client domain README: [`toju-app/src/app/domains/server-directory/README.md`](../../toju-app/src/app/domains/server-directory/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial cross-context server-directory REST contract |
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Area:** server-directory
|
> **Area:** server-directory
|
||||||
> **Status:** Active
|
> **Status:** Active
|
||||||
> **Last updated:** 2025-02-14
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -67,13 +67,24 @@ Both endpoints live in `server/src/routes/servers.ts` and **must be registered b
|
|||||||
## Client internals
|
## Client internals
|
||||||
|
|
||||||
- `ServerDirectoryApiService.getFeaturedServers()` / `getTrendingServers()` call the routes through a shared private `getDiscoveryServers(path)` helper and normalise into `ServerInfo[]`.
|
- `ServerDirectoryApiService.getFeaturedServers()` / `getTrendingServers()` call the routes through a shared private `getDiscoveryServers(path)` helper and normalise into `ServerInfo[]`.
|
||||||
|
- **Multi-endpoint fan-out:** discovery queries **every online endpoint** (`getSearchableEndpoints()` + `forkJoin`), deduplicated by server ID — mirroring free-text search. Querying only the active endpoint made the default `/servers` view appear empty when populated servers lived on other endpoints.
|
||||||
|
- **Legacy 404 fallback:** when `GET /api/servers/featured` or `/trending` returns **404** (older signal servers resolve those paths as `/servers/:id`), `fetchDiscoveryFromEndpoint` falls back per-endpoint to the public `GET /api/servers` listing (`fetchPublicServerListForDiscovery`) instead of returning `[]`. Verified in `server-directory-api.service.spec.ts` (including production hosts like `signal.toju.app`).
|
||||||
- `ServerDirectoryService` → `ServerDirectoryFacade` expose `getFeaturedServers()` / `getTrendingServers()` as the domain boundary.
|
- `ServerDirectoryService` → `ServerDirectoryFacade` expose `getFeaturedServers()` / `getTrendingServers()` as the domain boundary.
|
||||||
- `FindServersComponent` (`/servers`) composes **Recently active** (the user's saved rooms, capped at 6), **Featured**, and **Trending** sections, all rendered through `app-server-browser` with `[showMyServers]="true"`.
|
- `FindServersComponent` (`/servers`) composes **Recently active** (the user's saved rooms, capped at 6), **Featured**, and **Trending** sections, all rendered through `app-server-browser` with `[showMyServers]="true"`.
|
||||||
- `DashboardComponent` (`/dashboard`) is a single-column landing page (max-width centered, no in-page sidebars): a header greeting (no emoji), a global search with `Ctrl+K` focus and localStorage-backed **Recent Searches** chips shown beneath it, three primary action cards (Find People → `/people`, Find Servers → `/servers`, Create Server → `/create-server` — one link each), and discovery panels **People you might know**, **Popular Servers**, **Your Friends**, and **Recently Active Servers**. Each list is capped at 5 (`DISCOVERY_LIMIT`). It loads `popularServers` on init from `getFeaturedServers(5)`, falling back to `getTrendingServers(5)` when featured is empty; reuses `app-friend-button` for Add and `app-user-avatar` for people rows. `peopleYouMightKnow` excludes existing friends (via `FriendService.friendIds()`); `friends` lists discovered people who are friends. "See all" header links route to the matching `/people` or `/servers` page (no duplicated footer links). Recent searches are recorded on Enter (deduped, most-recent-first, capped at 8) and persisted under `metoyou_dashboard_recent_searches`.
|
- `DashboardComponent` (`/dashboard`) is a single-column landing page (max-width centered, no in-page sidebars): a header greeting (no emoji), a global search with `Ctrl+K` focus and localStorage-backed **Recent Searches** chips shown beneath it, three primary action cards (Find People → `/people`, Find Servers → `/servers`, Create Server → `/create-server` — one link each), and discovery panels **People you might know**, **Popular Servers**, **Your Friends**, and **Recently Active Servers**. Each list is capped at 5 (`DISCOVERY_LIMIT`). It loads `popularServers` on init from `getFeaturedServers(5)`, falling back to `getTrendingServers(5)` when featured is empty; reuses `app-friend-button` for Add and `app-user-avatar` for people rows. `peopleYouMightKnow` excludes existing friends (via `FriendService.friendIds()`); `friends` lists discovered people who are friends. "See all" header links route to the matching `/people` or `/servers` page (no duplicated footer links). Recent searches are recorded on Enter (deduped, most-recent-first, capped at 8) and persisted under `metoyou_dashboard_recent_searches`.
|
||||||
- The servers-rail top button (`servers-rail.component`) is the **Dashboard** button (`lucideLayoutDashboard`, `title="Dashboard"`); its `goToDashboard()` handler deselects any active voice server and navigates to `/dashboard`. A **Create a server** button (`lucidePlus`, `data-testid="server-rail-create"`) sits below the saved-server icons and opens `app-create-server-dialog` (a Toju modal on desktop / bottom sheet on mobile) which dispatches `RoomsActions.createRoom` directly; the dashboard / `/create-server` route remains as an alternative entry point. Rail icons (`h-12 w-12`, `md:h-11 w-11`) animate their corner radius on hover and `:active` for a Discord-style squircle effect.
|
- The servers-rail top button (`servers-rail.component`) is the **Dashboard** button (`lucideLayoutDashboard`, `title="Dashboard"`); its `goToDashboard()` handler deselects any active voice server and navigates to `/dashboard`. A **Create a server** button (`lucidePlus`, `data-testid="server-rail-create"`) sits below the saved-server icons and opens `app-create-server-dialog` (a Toju modal on desktop / bottom sheet on mobile) which dispatches `RoomsActions.createRoom` directly; the dashboard / `/create-server` route remains as an alternative entry point. Rail icons (`h-12 w-12`, `md:h-11 w-11`) animate their corner radius on hover and `:active` for a Discord-style squircle effect.
|
||||||
- On mobile (`ViewportService.isMobile()`), `DashboardComponent`, `FindPeopleComponent` (`/people`), and `FindServersComponent` (`/servers`) each mount their page body inside a single `<swiper-container>` slide next to `app-servers-rail` (rail `shrink-0`, content `flex-1` with a left border), mirroring the chat-room / DM-workspace mobile layout so the primary navigation rail stays reachable. The page body is shared between the desktop and mobile branches via an `<ng-template #pageContent>` + `[ngTemplateOutlet]`, and each component declares `schemas: [CUSTOM_ELEMENTS_SCHEMA]` for the Swiper custom elements.
|
- On mobile (`ViewportService.isMobile()`), discovery routes (`/dashboard`, `/people`, `/servers`) render their page body full-width via `<ng-template #pageContent>` + `[ngTemplateOutlet]`. The **servers rail is global** in `app.html` (`shouldShowMobileAppServersRail` in `core/platform/mobile-shell-layout.rules.ts`) — discovery pages must **not** embed a second `<app-servers-rail>` or Swiper stack. Chat-room and DM-workspace routes keep their own embedded rail inside Swiper and hide the global shell rail (see `toju-app/AGENTS.md`).
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- Product-client domain README: `toju-app/src/app/domains/server-directory/README.md`
|
- Product-client domain README: `toju-app/src/app/domains/server-directory/README.md`
|
||||||
|
- Full server-directory REST contract (CRUD, join, moderation): [server-directory.md](server-directory.md)
|
||||||
- People discovery (`/people`): `toju-app/src/app/domains/direct-message/README.md`
|
- People discovery (`/people`): `toju-app/src/app/domains/direct-message/README.md`
|
||||||
|
- Mobile shell: [mobile-capacitor.md](mobile-capacitor.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
| ---------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 2026-07-05 | Added multi-endpoint fan-out and 404 fallback; corrected mobile shell layout (global rail, no per-page Swiper) |
|
||||||
|
| 2025-02-14 | Initial documentation |
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
# Signal Server Tag
|
# Signal Server Tag
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
Users registered on a signal server can show that server's display tag on their profile card (opened by clicking their name or avatar).
|
Users registered on a signal server can show that server's display tag on their profile card (opened by clicking their name or avatar).
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Server: expose a human-readable tag per **endpoint** (not per user identity).
|
||||||
|
- Client: resolve tag for a user's **home** signaling server (`homeSignalServerUrl`) and render on profile cards.
|
||||||
|
|
||||||
## Server configuration
|
## Server configuration
|
||||||
|
|
||||||
`server/data/variables.json` accepts an optional `serverTag` string. When omitted, the server falls back to its public URL built from `serverProtocol`, `serverHost`, and `serverPort`.
|
`server/data/variables.json` accepts an optional `serverTag` string. When omitted, the server falls back to its public URL built from `serverProtocol`, `serverHost`, and `serverPort` (`server/src/config/variables.ts`).
|
||||||
|
|
||||||
## Health API
|
## Health API
|
||||||
|
|
||||||
@@ -12,11 +20,27 @@ Users registered on a signal server can show that server's display tag on their
|
|||||||
|
|
||||||
## WebSocket presence
|
## WebSocket presence
|
||||||
|
|
||||||
The client sends `homeSignalServerUrl` in `identify` messages. The signaling server echoes that value in `server_users` and `user_joined` payloads so other clients can resolve the correct tag.
|
The client sends `homeSignalServerUrl` in `identify` messages. The signaling server echoes that value in `server_users` and `user_joined` payloads so other clients can resolve the correct tag. See [signaling.md](signaling.md).
|
||||||
|
|
||||||
## Client behavior
|
## Client behavior
|
||||||
|
|
||||||
- Login and registration store `homeSignalServerUrl` on the current user.
|
- Login and registration store `homeSignalServerUrl` on the current user.
|
||||||
- Profile cards show the resolved tag beside the username in muted text.
|
- Profile cards show the resolved tag beside the username in muted text (`profile-signal-server-tag.component`).
|
||||||
- Configured labels render as `#tag`; URL fallbacks render as a globe icon with the URL in a tooltip.
|
- Configured labels render as `#tag`; URL fallbacks render as a globe icon with the URL in a tooltip.
|
||||||
- Tag resolution prefers the endpoint's cached `serverTag` from health checks, then falls back to the stored home URL.
|
- Tag resolution (`signal-server-tag.rules.ts`): match `homeSignalServerUrl` against configured endpoints and prefer cached health `serverTag`; otherwise show the raw URL fallback.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `toju-app/src/app/domains/server-directory/domain/logic/signal-server-tag.rules.spec.ts`
|
||||||
|
- `server/src/websocket/handler-status.spec.ts` (presence payload includes `homeSignalServerUrl`)
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [server-directory.md](server-directory.md) — endpoint health cache
|
||||||
|
- [authentication.md](authentication.md) — `homeSignalServerUrl` on identify
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Clarified per-endpoint tag vs per-user home URL; added test references |
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Signaling (WebSocket)
|
||||||
|
|
||||||
|
> **Area:** realtime
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The signaling server exposes a single WebSocket per origin that carries identity, room membership, presence, WebRTC SDP/ICE relay, selected server-relayed chat/DM/voice fallbacks, plugin events, and multi-device `account_sync`. The product client implements the consumer in `toju-app/src/app/infrastructure/realtime/signaling/`.
|
||||||
|
|
||||||
|
**Canonical contract:** this document and [`server/src/websocket/handler.ts`](../../server/src/websocket/handler.ts). Do **not** treat `toju-app/src/app/shared-kernel/signaling-contracts.ts` as authoritative — it lists legacy types (`join`, `leave`, `chat`, `ice-candidate`) that do not match the live server.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Authenticate connections via `identify` (session token).
|
||||||
|
- Track per-connection room membership and broadcast room-scoped presence.
|
||||||
|
- Relay WebRTC offers/answers/ICE between peers that share server membership (or DM/direct-call rules).
|
||||||
|
- Relay narrow server fallbacks when P2P data channels are unavailable (chat, DM, voice presence).
|
||||||
|
- Forward `account_sync` payloads to sibling connections for the same user identity.
|
||||||
|
- It does NOT own: P2P data-channel payloads (attachments, message inventory, custom emoji chunks, plugin message bus), local persistence, or REST server-directory APIs.
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
|
||||||
|
- **Envelope:** JSON object with required `type` string; additional fields vary by type.
|
||||||
|
- **oderId:** user identity on the wire (legacy spelling, matches server code).
|
||||||
|
- **clientInstanceId:** per-tab/device id stored in `sessionStorage`; multiple open connections per `oderId` are allowed.
|
||||||
|
- **connectionScope:** optional string grouping connections (e.g. browser profile).
|
||||||
|
- **voiceActive:** server marks the connection that owns outbound RTC relay for a user; updated from `voice_state` payloads.
|
||||||
|
|
||||||
|
## Ordering invariants
|
||||||
|
|
||||||
|
1. **`identify` before anything else** — unauthenticated connections receive `auth_required` for all types except `identify` and `keepalive`.
|
||||||
|
2. **Per-connection serialization** — `handleWebSocketMessage` chains handlers per `connectionId` so `join_server` cannot run while `identify` is still awaiting the token DB lookup.
|
||||||
|
3. **Client replay on reconnect** — `SignalingManager.reIdentifyAndRejoin` sends `identify` then re-joins rooms; see [`toju-app/src/app/infrastructure/realtime/README.md`](../../toju-app/src/app/infrastructure/realtime/README.md).
|
||||||
|
|
||||||
|
## Connection lifecycle (server → client)
|
||||||
|
|
||||||
|
On connect the server assigns a `connectionId` and may emit:
|
||||||
|
|
||||||
|
| type | When |
|
||||||
|
|------|------|
|
||||||
|
| `connected` | Immediately after WebSocket open (`server/src/websocket/index.ts`) |
|
||||||
|
|
||||||
|
On disconnect, if the connection was voice-active, the server broadcasts a cleared `voice_state` via `finalizeVoiceDisconnectForConnection`.
|
||||||
|
|
||||||
|
## Inbound types (client → server)
|
||||||
|
|
||||||
|
| type | Auth | Behavior |
|
||||||
|
|------|------|----------|
|
||||||
|
| `keepalive` | Optional | Responds `keepalive_ack` with `serverTime` |
|
||||||
|
| `identify` | N/A (establishes auth) | Validates session token; sets `oderId`, profile fields; evicts stale same `(oderId, connectionScope, clientInstanceId)` sockets; emits `account_sync_peer_online` to siblings |
|
||||||
|
| `join_server` | Required | Access check; adds `serverId` to connection; sends `server_users` + `plugin_requirements`; may broadcast `user_joined` (identity-aware) |
|
||||||
|
| `view_server` | Required | Switches `viewedServerId`; refreshes `server_users` + `plugin_requirements` |
|
||||||
|
| `leave_server` | Required | Removes membership; may broadcast `user_left` with remaining `serverIds` |
|
||||||
|
| `offer`, `answer`, `ice_candidate` | Required | Relay to `targetUserId` when peers share server membership |
|
||||||
|
| `direct-message`, `direct-message-status`, `direct-message-mutation`, `direct-message-typing`, `direct-message-sync-request`, `direct-message-sync`, `direct-call` | Required | Relay to `targetUserId` (DM rules — no shared-server requirement) |
|
||||||
|
| `server_icon_peer_request`, `server_icon_peer_data` | Required | Relay when both users share `serverId` membership |
|
||||||
|
| `chat_message` | Required | Broadcast to server members (excludes sender connection) |
|
||||||
|
| `voice_state` | Required | Updates `voiceActive` / snapshot; broadcast to server members |
|
||||||
|
| `voice_client_takeover` | Required | Notifies sibling connections via `notifyOtherConnectionsForOderId` |
|
||||||
|
| `account_sync` | Required | Forwards `payload` object to other connections for same `oderId` |
|
||||||
|
| `typing` | Required | Broadcast `user_typing` to server members |
|
||||||
|
| `status_update` | Required | Broadcast `status_update` (`online` \| `away` \| `busy` \| `offline`) to joined servers |
|
||||||
|
| `server_icon_available` | Required | Records local `iconUpdatedAt` per server on the connection |
|
||||||
|
| `server_icon_sync_request` | Required | Responds `server_icon_sync_peers` with peers having newer icons |
|
||||||
|
| `plugin_event` | Required | Validates against server plugin metadata; broadcast or `plugin_error` |
|
||||||
|
|
||||||
|
Unknown inbound types are logged and ignored.
|
||||||
|
|
||||||
|
### `identify` request fields
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| `token` | Yes | Session token from REST login/register |
|
||||||
|
| `oderId` | No | If present, must match token user id |
|
||||||
|
| `displayName` | No | Defaults to existing or `"User"` |
|
||||||
|
| `description`, `profileUpdatedAt`, `homeSignalServerUrl` | No | Profile card fields |
|
||||||
|
| `clientInstanceId` | No | Per-tab id for multi-device and voice ownership |
|
||||||
|
| `connectionScope` | No | Eviction scope for stale sockets |
|
||||||
|
|
||||||
|
Errors: `auth_error` with `MISSING_TOKEN`, `INVALID_TOKEN`, or `USER_ID_MISMATCH`.
|
||||||
|
|
||||||
|
## Server-emitted types (server → client)
|
||||||
|
|
||||||
|
| type | Trigger |
|
||||||
|
|------|---------|
|
||||||
|
| `keepalive_ack` | Response to `keepalive` |
|
||||||
|
| `auth_required` | Message before `identify` |
|
||||||
|
| `auth_error` | Failed `identify` |
|
||||||
|
| `access_denied` | `join_server` rejected (`serverId`, `reason`) |
|
||||||
|
| `server_users` | After join/view; lists unique users in room |
|
||||||
|
| `user_joined` | New identity in server (excludes same identity's connections) |
|
||||||
|
| `user_left` | Identity fully left server (`serverIds` = remaining rooms) |
|
||||||
|
| `plugin_requirements` | Plugin install snapshot after join/view |
|
||||||
|
| `plugin_error` | Invalid plugin event |
|
||||||
|
| `server_icon_sync_peers` | Response to `server_icon_sync_request` |
|
||||||
|
| `account_sync_peer_online` | Sibling connection came online |
|
||||||
|
| `account_sync` | Relayed multi-device payload |
|
||||||
|
| `chat_message` | Relayed room chat fallback |
|
||||||
|
| `user_typing` | Typing indicator |
|
||||||
|
| `status_update` | Presence status change |
|
||||||
|
| `voice_state` | Voice roster / disconnect cleanup |
|
||||||
|
| `voice_client_takeover` | Another tab took voice ownership |
|
||||||
|
| `plugin_event` | Broadcast plugin event |
|
||||||
|
| Forwarded RTC/DM | Copies of client messages with `fromUserId` set |
|
||||||
|
|
||||||
|
## Relay rules
|
||||||
|
|
||||||
|
- **RTC (`offer` / `answer` / `ice_candidate`):** forwarded when sender and target share any server membership.
|
||||||
|
- **Direct signaling types:** forwarded to `targetUserId` without shared-server check.
|
||||||
|
- **Server icon P2P:** both users must be members of `message.serverId`.
|
||||||
|
- **Broadcasts** (`chat_message`, `voice_state`, `typing`, etc.): exclude sender **connection id** (not whole identity) so multi-device sessions still receive updates.
|
||||||
|
- **`user_joined` / `user_left`:** exclude whole **identity** so other users do not see duplicate join/leave for multiple tabs.
|
||||||
|
|
||||||
|
## P2P vs signaling split
|
||||||
|
|
||||||
|
| Transport | Carries |
|
||||||
|
|-----------|---------|
|
||||||
|
| **WebRTC data channel** | Chat events, attachments, custom emoji, message revisions/inventory, profile avatar bytes, voice/screen control, plugin message bus, game activity |
|
||||||
|
| **WebSocket signaling** | Identity, membership, presence, RTC SDP/ICE, chat/DM/voice fallbacks, `account_sync`, plugin events |
|
||||||
|
|
||||||
|
Server-relayed chat (`chat_message`) and DM types exist so users see written chat and delivery state while data channels are down. Media, attachments, and inventory sync remain peer-plane responsibilities.
|
||||||
|
|
||||||
|
## Multi-device (`account_sync`)
|
||||||
|
|
||||||
|
The client wraps relayable local changes in `account_sync` envelopes. The server forwards the inner `payload` to other open connections for the same `oderId`. When a device identifies, siblings receive `account_sync_peer_online` and push snapshots (saved servers, friends, emoji library, chat history batches, etc.). See [authentication.md](authentication.md) and domain-specific feature docs.
|
||||||
|
|
||||||
|
## Client implementation map
|
||||||
|
|
||||||
|
| Concern | Location |
|
||||||
|
|---------|----------|
|
||||||
|
| One socket per signal URL | `signaling/signaling.manager.ts` |
|
||||||
|
| Route picker | `signaling/signaling-transport-handler.ts` |
|
||||||
|
| Room affinity | `signaling/server-signaling-coordinator.ts` |
|
||||||
|
| Inbound dispatch | `signaling/signaling-message-handler.ts` |
|
||||||
|
| Constants (intervals, types) | `realtime.constants.ts` |
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [authentication.md](authentication.md) — session tokens and `identify` trust boundary
|
||||||
|
- [direct-messaging.md](direct-messaging.md) — DM envelope relay types
|
||||||
|
- [voice-webrtc.md](voice-webrtc.md) — RTC relay and `voice_state`
|
||||||
|
- [plugins.md](plugins.md) — `plugin_event` / `plugin_requirements`
|
||||||
|
- [message-integrity.md](message-integrity.md) — signed revisions (P2P; `account_sync` relay)
|
||||||
|
- Product client deep dive: [`toju-app/src/app/infrastructure/realtime/README.md`](../../toju-app/src/app/infrastructure/realtime/README.md)
|
||||||
|
- Server handler: [`server/src/websocket/handler.ts`](../../server/src/websocket/handler.ts)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial canonical envelope catalog; deprecates `shared-kernel/signaling-contracts.ts` as wire source |
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Voice & WebRTC
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Voice channels, camera, and screen-share use direct **WebRTC** peer connections between clients. The signaling server relays SDP offers/answers/ICE and **voice presence** (`voice_state`); media never flows through the server.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
|-------|------|
|
||||||
|
| `infrastructure/realtime/` | WebRTC sessions, negotiation, data channels, RNNoise worklet |
|
||||||
|
| `voice-connection` / `voice-session` domains | Facades, workspace UI, settings, multi-device ownership |
|
||||||
|
| `screen-share` domain | Source picker, quality presets (Electron) |
|
||||||
|
| Signaling server | RTC relay + `voice_state` / `voice_client_takeover` broadcast |
|
||||||
|
|
||||||
|
## WebSocket signaling types
|
||||||
|
|
||||||
|
| type | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `offer`, `answer`, `ice_candidate` | WebRTC negotiation relay to `targetUserId` |
|
||||||
|
| `voice_state` | Voice roster (mute/deafen/speaking, channel id); sets `voiceActive` on one connection per user |
|
||||||
|
| `voice_client_takeover` | Notify sibling tabs to yield voice ownership |
|
||||||
|
|
||||||
|
Relay rules: RTC messages require shared server membership (except DM-specific types). See [signaling.md](signaling.md).
|
||||||
|
|
||||||
|
## Multi-device voice
|
||||||
|
|
||||||
|
- Only one connection per `oderId` may be `voiceActive`; RTC offers route to that connection (fallback: any open connection).
|
||||||
|
- Other tabs show passive UI and may send `voice_client_takeover`.
|
||||||
|
- `clientInstanceId` in `identify` and voice payloads distinguishes tabs.
|
||||||
|
|
||||||
|
## Media pipeline (client)
|
||||||
|
|
||||||
|
- **Voice:** `getUserMedia` → optional RNNoise AudioWorklet → gain → same-room peer routing only.
|
||||||
|
- **Camera:** separate video track; same-room filter.
|
||||||
|
- **Screen share:** on-demand via data-channel `SCREEN_SHARE_REQUEST`; platform-specific capture (browser `getDisplayMedia`, Electron picker, Linux PulseAudio routing).
|
||||||
|
|
||||||
|
## P2P data channel
|
||||||
|
|
||||||
|
Carries voice/screen control messages, chat, attachments, and state sync — not server-relayed. Data-channel failure triggers peer renegotiation or full rebuild (see realtime README). When a failed control channel is replaced (`replaceDataChannel`), the old channel is closed first so its SCTP resources are released.
|
||||||
|
|
||||||
|
## Media memory invariants (2026-07-14, RAM investigation)
|
||||||
|
|
||||||
|
- `removePeer` / `closeAllPeers` clear **all four** remote stream maps, including `remotePeerCameraStreams` (previously leaked per departed peer).
|
||||||
|
- Video tiles (`voice-workspace-stream-tile`) pause and null `srcObject` in `ngOnDestroy` (`voice-workspace-stream-video.rules.ts`) so Chromium releases decoder/frame buffers immediately.
|
||||||
|
- `debug-network-metrics` drops a peer's entry when the peer is fully removed and caps the store at `MAX_TRACKED_DEBUG_NETWORK_PEERS` = 200 (oldest evicted).
|
||||||
|
- Electron registers `setDisplayMediaRequestHandler` once per app run (guarded in `create-window.ts`), not on every window recreation.
|
||||||
|
|
||||||
|
## Mobile / Capacitor
|
||||||
|
|
||||||
|
Background voice uses Android foreground service + iOS audio/CallKit bridges — [mobile-capacitor.md](mobile-capacitor.md). Screen share is limited on mobile WebViews.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [signaling.md](signaling.md) — envelope catalog
|
||||||
|
- [direct-messaging.md](direct-messaging.md) — private calls share `PeerDeliveryService`
|
||||||
|
- [`toju-app/src/app/infrastructure/realtime/README.md`](../../toju-app/src/app/infrastructure/realtime/README.md) — negotiation, recovery, RNNoise
|
||||||
|
- Domain READMEs: `voice-connection`, `voice-session`, `screen-share`
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-14 | Media memory invariants: camera-stream map cleanup, tile `srcObject` release, debug-metrics cap, single display-media handler registration, replaced data channels closed |
|
||||||
|
| 2026-07-05 | Initial cross-context voice/WebRTC contract |
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# User Story: Silent cross–signal-server account auth
|
||||||
|
|
||||||
|
> **Status:** Open (research complete — not fixed)
|
||||||
|
> **Priority / Severity:** Critical
|
||||||
|
> **Area:** authentication, realtime, server-directory
|
||||||
|
> **Last researched:** 2026-08-12
|
||||||
|
> **Related docs:** [features/authentication.md](../features/authentication.md), `toju-app/src/app/domains/authentication/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User story
|
||||||
|
|
||||||
|
**As a** signed-in Toju user
|
||||||
|
**I want** the app to automatically create (or reuse) my account on any additional signal server as soon as I need that server
|
||||||
|
**So that** I never see a login / authorize prompt again after my initial home-server login, and chat / presence / joins keep working across the whole multi-server network.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem statement
|
||||||
|
|
||||||
|
The product supports multiple signaling servers. A user registers/logs in once on a **home** signal server. When they later interact with a **foreign** signal server (join/create a room hosted there, open an invite, activate another endpoint, etc.), the client is supposed to **silently provision** a linked account on that server using a local **provision secret**, store a per-server session credential, and continue — without interrupting the UI.
|
||||||
|
|
||||||
|
In practice, the **login / authorize screen keeps appearing** (`/login?mode=authorize&serverId=…`) even though the user is already authenticated locally. That breaks the “one login, whole app” contract and feels like the session is constantly dying.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desired behavior (acceptance criteria)
|
||||||
|
|
||||||
|
1. After a successful home login or register, the user is never prompted for credentials again solely because they touched another signal server.
|
||||||
|
2. The first time the user has business with signal server N (N ≠ home):
|
||||||
|
- The client ensures a valid per-URL credential exists (register-or-login with the provision secret).
|
||||||
|
- WebSocket `identify` and protected REST calls use that credential’s actor user id + token.
|
||||||
|
- The home NgRx / local profile stays unchanged.
|
||||||
|
3. If the preferred username is already taken on the foreign server, the client silently uses the designed suffix strategy (`alice-<homeUserIdPrefix>`) and optional display-name disambiguation — still **without** opening `/login`.
|
||||||
|
4. Transient `auth_required` (message raced ahead of identify) never opens login and never tears down the home session while a valid local credential exists.
|
||||||
|
5. Rejected foreign tokens trigger **re-provision** (or credential refresh), not a home logout and not a blocking authorize form when silent provision is possible.
|
||||||
|
6. Offline / unreachable / incompatible endpoints never open `/login?mode=authorize`.
|
||||||
|
7. Session restore after app restart still silently provisions foreign servers (provision secret and credentials survive restart on desktop).
|
||||||
|
8. Settings → Network may show `Authorized` / `Needs sign-in` for diagnostics, but “Needs sign-in” must not become the default path for a normal logged-in user who simply joined a room on another host.
|
||||||
|
|
||||||
|
### Explicit non-goals (for this story)
|
||||||
|
|
||||||
|
- Changing the home-server password / register UX for first-time users.
|
||||||
|
- Merging foreign actor ids into a single global server-side identity (home id ≠ foreign provisioned id is expected).
|
||||||
|
- Removing the authorize UI entirely — it may remain as a **last resort** (e.g. true username collision exhaustion, or user-initiated “Sign in” from Network settings).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current intended architecture (as designed)
|
||||||
|
|
||||||
|
| Concept | Role |
|
||||||
|
|--------|------|
|
||||||
|
| Home session | Local profile + credential for `homeSignalServerUrl` |
|
||||||
|
| Provision secret | Per-install secret generated on home login/register; used as the password when auto-registering/logging into foreign servers |
|
||||||
|
| Per-signal credential store | `metoyou.signalServerCredentials` — token + actor userId per normalized server URL |
|
||||||
|
| Legacy token store | `metoyou.authTokens` — still used for REST interceptor / session restore fallback |
|
||||||
|
| `ensureProvisioned` | Register-or-login on a foreign URL using the provision secret |
|
||||||
|
| `ensureCredentialForServerUrl` | Gate before foreign room connect / invite / join — provision first; only then optionally navigate to authorize |
|
||||||
|
| `authorize` login mode | Manual login that only upserts a foreign credential (`authorizeSignalServer`) without resetting home state |
|
||||||
|
|
||||||
|
Primary call sites that demand a foreign credential:
|
||||||
|
|
||||||
|
- Room signaling connect (`room-signaling-connection.ts`)
|
||||||
|
- Invite / server-browser join flows
|
||||||
|
- Active endpoint health → opportunistic `ensureProvisioned`
|
||||||
|
- `provisionActiveSignalServers$` after `loadCurrentUserSuccess`
|
||||||
|
|
||||||
|
Authorize navigation is gated by `shouldNavigateToAuthorizeSignalServer`:
|
||||||
|
|
||||||
|
- Endpoint must look **online**
|
||||||
|
- Provision result is `collision` **or** `skipped` with reason `no-provision-secret`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Research findings — likely causes
|
||||||
|
|
||||||
|
These are **code-backed hypotheses** ranked by how directly they produce a login prompt while the user still has a home session.
|
||||||
|
|
||||||
|
### Cause A — Missing provision secret → authorize login (primary)
|
||||||
|
|
||||||
|
**Mechanism**
|
||||||
|
|
||||||
|
1. `SignalServerAuthService.ensureProvisioned` returns `{ kind: 'skipped', reason: 'no-provision-secret' }` when `ProvisionSecretStoreService.getSecret(homeUser.id)` is null.
|
||||||
|
2. `SignalServerAuthorizeService.ensureCredentialForServerUrl` then calls `navigateToAuthorize` → `/login?mode=authorize`.
|
||||||
|
3. Login’s authorize mode **does not** auto-redirect away when `currentUser` is set (the leave-login effect explicitly returns early in authorize mode), so the prompt stays on screen.
|
||||||
|
|
||||||
|
**Why the secret is often missing**
|
||||||
|
|
||||||
|
- Secret is created only in `prepareAuthenticatedUserStorage` via `ensureHomeProvisionSecret`, and **only when both** `user.homeSignalServerUrl` **and** `loginResponse` are present.
|
||||||
|
- Session restore (`loadCurrentUserSuccess` → `provisionActiveSignalServers$`) calls `ensureProvisioned` but **never** calls `ensureHomeProvisionSecret` to create a missing secret.
|
||||||
|
- Web / non-Electron fallback stores the secret in **sessionStorage** (`metoyou.provisionSecret.<userId>`), which dies when the tab/session ends.
|
||||||
|
- Accounts created before this feature, wiped Electron `userData/provision-secrets/`, or logins that never received a `loginResponse` + home URL pair never get a secret.
|
||||||
|
|
||||||
|
**Evidence in code**
|
||||||
|
|
||||||
|
- `signal-server-authorize.rules.ts` — `no-provision-secret` ⇒ navigate to authorize
|
||||||
|
- `signal-server-authorize.service.spec.ts` — “still provisions foreign servers and navigates to authorize when the secret is missing”
|
||||||
|
- `users.effects.ts` — `ensureHomeProvisionSecret` only inside `prepareAuthenticatedUserStorage` with `loginResponse`
|
||||||
|
|
||||||
|
### Cause B — Username collision exhaustion → authorize login
|
||||||
|
|
||||||
|
**Mechanism**
|
||||||
|
|
||||||
|
`SignalServerProvisionerService` tries preferred username, then suffixed candidates. If every register returns 409 and every login with the provision secret returns 401, it throws `ProvisionUsernameCollisionError` → `kind: 'collision'` → authorize UI.
|
||||||
|
|
||||||
|
**When it shows up**
|
||||||
|
|
||||||
|
Another user already owns those usernames on the foreign server with different passwords (not our provisioned accounts). Silent recovery is impossible without a different identity strategy or manual credentials.
|
||||||
|
|
||||||
|
### Cause C — Home session false expiry → full `/login` (not just authorize)
|
||||||
|
|
||||||
|
**Mechanism**
|
||||||
|
|
||||||
|
`signalServerAuthFailed$` clears the credential for the failing URL, then:
|
||||||
|
|
||||||
|
- `expire-home-session` if the failure is classified as the **home** server → `clearStoredCurrentUserId` + `SESSION_EXPIRED` → `redirectOnSessionExpired$` → `/login`
|
||||||
|
- `provision-foreign` otherwise → silent `ensureProvisioned` (no login UI by itself)
|
||||||
|
|
||||||
|
**False home classification risks**
|
||||||
|
|
||||||
|
- Missing / stale `homeSignalServerUrl` on the restored user → foreign failures compared with empty home URL → `isSameSignalServerUrl` is false, so this path usually prefers foreign provision; but home failures with no resolvable credential after retries still expire the session.
|
||||||
|
- Exhausted re-identify retry budget on home while credential lookup fails (empty credential store + broken legacy fallback) → `auth_required` / `auth_error` treated as unrecoverable home expiry.
|
||||||
|
- Past regressions (see lessons): identifying only from the new credential store, or treating `auth_required` as logout — partially mitigated, but restore edge cases still matter.
|
||||||
|
|
||||||
|
### Cause D — Credential present locally but identify never runs / races
|
||||||
|
|
||||||
|
**Mechanism**
|
||||||
|
|
||||||
|
Without a resolvable token for the foreign URL, the socket sends non-identify traffic → server `auth_required`. If the client then cannot re-identify or re-provision (Cause A), user-facing flows that gate on `ensureCredentialForServerUrl` open authorize login. Presence/chat then look “broken” even though the home profile still shows logged in.
|
||||||
|
|
||||||
|
Related lesson: identify must fall back to legacy `AuthTokenStoreService` for **home**; foreign servers **cannot** be reconstructed from the legacy store (actor id differs) — so foreign URLs **must** be provisioned, not guessed.
|
||||||
|
|
||||||
|
### Cause E — Opportunistic provision fails quietly; later gate opens login
|
||||||
|
|
||||||
|
**Mechanism**
|
||||||
|
|
||||||
|
`provisionActiveSignalServers$` and server health `ensureProvisioned(...).catch(() => undefined)` swallow errors. A later user action (join room) hits `ensureCredentialForServerUrl` with the same missing secret / collision and **then** navigates to authorize — so login appears mid-flow rather than at startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User-visible scenarios
|
||||||
|
|
||||||
|
### Happy path (required)
|
||||||
|
|
||||||
|
1. Alice registers on Signal Server 1.
|
||||||
|
2. Alice browses/joins a community hosted on Signal Server 2.
|
||||||
|
3. Client silently registers `alice` (or `alice-<prefix>`) on Server 2 with the provision secret.
|
||||||
|
4. Alice lands in the room; no login modal/page; peers see her presence under the Server 2 actor id.
|
||||||
|
|
||||||
|
### Failure path today (bug)
|
||||||
|
|
||||||
|
1. Alice is logged in (user bar / local profile show her).
|
||||||
|
2. Alice opens an invite or room whose `sourceUrl` is Signal Server 2.
|
||||||
|
3. Client cannot provision (no secret / collision).
|
||||||
|
4. App navigates to `/login?mode=authorize&serverId=…&returnUrl=…`.
|
||||||
|
5. Alice believes she was logged out; re-entering home credentials may even bind the wrong server if she is not careful with the server picker.
|
||||||
|
|
||||||
|
### Restart path (required)
|
||||||
|
|
||||||
|
1. Alice fully quits the desktop app and reopens.
|
||||||
|
2. Home session restores from local DB + token stores.
|
||||||
|
3. Touching Server 2 again still silent-provisions or reuses the stored foreign credential — no authorize prompt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proof of done (when implementing)
|
||||||
|
|
||||||
|
Prefer behavior-level proof over mocks shaped like the provisioner:
|
||||||
|
|
||||||
|
1. **Integration / focused effect+service tests**
|
||||||
|
- Missing secret on restore → secret is ensured, then foreign provision succeeds, **and** `Router.navigate(['/login'])` is never called.
|
||||||
|
- Foreign `auth_error` with home session intact → re-provision + re-identify; no `SESSION_EXPIRED`.
|
||||||
|
- Online foreign endpoint + successful provision → `ensureCredentialForServerUrl` returns `true`.
|
||||||
|
2. **Manual / E2E**
|
||||||
|
- Two live signal servers; register on #1; join room on #2 without typing a password again; reload app; rejoin still silent.
|
||||||
|
3. **Negative**
|
||||||
|
- Offline foreign endpoint must not open authorize login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Likely fix directions (for a later interview — not approved yet)
|
||||||
|
|
||||||
|
| Option | Idea | Tradeoff |
|
||||||
|
|--------|------|----------|
|
||||||
|
| **A (recommended)** | On session restore / before any foreign `ensureProvisioned`, call `ensureHomeProvisionSecret` so a missing secret is generated once and persisted; keep authorize UI only for true collision / user-initiated sign-in | New secret cannot unlock accounts previously provisioned with an old lost secret — may need re-register with suffix or collision path |
|
||||||
|
| **B** | Stop navigating to authorize on `no-provision-secret`; surface a non-blocking Network badge / toast and retry when secret becomes available | User may join without credential and hit silent presence failures |
|
||||||
|
| **C** | Derive a stable provision secret from a durable local key (not sessionStorage) on web so restarts keep the same secret | Crypto/key-storage design; still need migration for existing installs |
|
||||||
|
| **D** | For collisions, auto-pick a stronger unique username (e.g. always include fuller home user id) before opening authorize | Reduces but does not eliminate collision UX |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.ts`
|
||||||
|
- `toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts`
|
||||||
|
- `toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.ts`
|
||||||
|
- `toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.ts`
|
||||||
|
- `toju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.ts`
|
||||||
|
- `toju-app/src/app/domains/authentication/domain/logic/signal-server-auth-failure.rules.ts`
|
||||||
|
- `toju-app/src/app/store/users/users.effects.ts` (`signalServerAuthFailed$`, `provisionActiveSignalServers$`, `redirectOnSessionExpired$`, `prepareAuthenticatedUserStorage`)
|
||||||
|
- `toju-app/src/app/store/rooms/room-signaling-connection.ts`
|
||||||
|
- `electron/api/provision-secret-store.ts`
|
||||||
|
- `agents-docs/features/authentication.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons already adjacent
|
||||||
|
|
||||||
|
- Identify must fall back to the legacy session token (home restore).
|
||||||
|
- Keep per-signal-URL identify credentials resolvable from the store.
|
||||||
|
- Persisted local user state still requires a session token.
|
||||||
|
- Do not open authorize login for offline endpoints.
|
||||||
|
- Distinguish `auth_required` vs `auth_error` so home session is not falsely expired.
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Launch one more Electron window against an already-running dev stack.
|
||||||
|
#
|
||||||
|
# Electron's single-instance lock is scoped to the userData directory, so a peer
|
||||||
|
# window only gets its own lock — and its own identity — when it gets its own
|
||||||
|
# --user-data-dir. Without that, tools/launch-electron.js hands its argv to the
|
||||||
|
# running instance and the dev-reload path just reloads window A instead.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PEER_NAME="${1:-peer}"
|
||||||
|
PEER_DATA_DIR="$DIR/.dev-userdata/$PEER_NAME"
|
||||||
|
|
||||||
|
if [ -f "$DIR/.env" ]; then
|
||||||
|
set -a
|
||||||
|
source "$DIR/.env"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
SSL="${SSL:-false}"
|
||||||
|
|
||||||
|
if [ "$SSL" = "true" ]; then
|
||||||
|
CLIENT_URL="https://127.0.0.1:4200"
|
||||||
|
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
else
|
||||||
|
CLIENT_URL="http://127.0.0.1:4200"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! npx wait-on --timeout 5000 "$CLIENT_URL" >/dev/null 2>&1; then
|
||||||
|
echo "No dev client at $CLIENT_URL — start the stack with 'npm run dev' first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$PEER_DATA_DIR"
|
||||||
|
|
||||||
|
echo "Launching peer window '$PEER_NAME' (data dir: $PEER_DATA_DIR)"
|
||||||
|
echo "Reminder: a fresh peer needs https://localhost:3001 added under Settings -> signal servers."
|
||||||
|
|
||||||
|
exec npx cross-env NODE_ENV=development SSL="$SSL" node tools/launch-electron.js . \
|
||||||
|
--no-sandbox \
|
||||||
|
--disable-dev-shm-usage \
|
||||||
|
--user-data-dir="$PEER_DATA_DIR"
|
||||||
@@ -12,6 +12,18 @@ if [ -f "$DIR/.env" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
SSL="${SSL:-false}"
|
SSL="${SSL:-false}"
|
||||||
|
LIVE_RELOAD="${LIVE_RELOAD:-true}"
|
||||||
|
|
||||||
|
# Suspending the machine tears down the dev-server connection. The live-reload client
|
||||||
|
# answers the reconnect by reloading the page, which destroys the very session under
|
||||||
|
# test (voice call, peer connections, console state). Set LIVE_RELOAD=false to keep the
|
||||||
|
# renderer alive across a suspend. Editing client files then has no effect until restart.
|
||||||
|
NG_RELOAD_FLAG=""
|
||||||
|
|
||||||
|
if [ "$LIVE_RELOAD" != "true" ]; then
|
||||||
|
NG_RELOAD_FLAG=" --live-reload=false"
|
||||||
|
echo "Live reload disabled: client edits will NOT reach the running window."
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$SSL" = "true" ]; then
|
if [ "$SSL" = "true" ]; then
|
||||||
# Ensure certs exist
|
# Ensure certs exist
|
||||||
@@ -20,13 +32,13 @@ if [ "$SSL" = "true" ]; then
|
|||||||
"$DIR/generate-cert.sh"
|
"$DIR/generate-cert.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
NG_SERVE="cd toju-app && npx 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$NG_RELOAD_FLAG"
|
||||||
# Use 127.0.0.1 so wait-on does not hit a stale HTTP listener on localhost (::1).
|
# Use 127.0.0.1 so wait-on does not hit a stale HTTP listener on localhost (::1).
|
||||||
WAIT_URL="https://127.0.0.1:4200"
|
WAIT_URL="https://127.0.0.1:4200"
|
||||||
HEALTH_URL="https://127.0.0.1:3001/api/health"
|
HEALTH_URL="https://127.0.0.1:3001/api/health"
|
||||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
else
|
else
|
||||||
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
|
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0$NG_RELOAD_FLAG"
|
||||||
WAIT_URL="http://127.0.0.1:4200"
|
WAIT_URL="http://127.0.0.1:4200"
|
||||||
HEALTH_URL="http://127.0.0.1:3001/api/health"
|
HEALTH_URL="http://127.0.0.1:3001/api/health"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export interface PeerRoleEdge {
|
||||||
|
/** Remote peer id, in the identity space of the signal server routing that peer. */
|
||||||
|
peerId: string;
|
||||||
|
/** Our own actor id in that same identity space. */
|
||||||
|
localActorId: string | null;
|
||||||
|
isInitiator: boolean;
|
||||||
|
connectionState: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read who elected themselves initiator for every active peer, together with the local
|
||||||
|
* actor id in that peer's identity space. Both halves of a pair must describe the same
|
||||||
|
* two ids, which is what makes the election comparable in the first place.
|
||||||
|
*/
|
||||||
|
export async function readPeerRoleEdges(page: Page): Promise<PeerRoleEdge[]> {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
interface PeerDataShape {
|
||||||
|
connection?: { connectionState?: string };
|
||||||
|
isInitiator?: boolean;
|
||||||
|
}
|
||||||
|
interface RealtimeShape {
|
||||||
|
peerManager?: { activePeerConnections?: Map<string, PeerDataShape> };
|
||||||
|
signalingTransportHandler?: {
|
||||||
|
getIdentifyCredentialsForPeer?: (peerId: string) => { oderId?: string } | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.querySelector('app-rooms-side-panel');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const realtime = debugApi.getComponent(host)['realtime'] as RealtimeShape | undefined;
|
||||||
|
const peers = realtime?.peerManager?.activePeerConnections;
|
||||||
|
|
||||||
|
if (!peers) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const edges: PeerRoleEdge[] = [];
|
||||||
|
|
||||||
|
peers.forEach((peerData, peerId) => {
|
||||||
|
const credentials = realtime?.signalingTransportHandler?.getIdentifyCredentialsForPeer?.(peerId);
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
connectionState: peerData.connection?.connectionState ?? 'unknown',
|
||||||
|
isInitiator: peerData.isInitiator === true,
|
||||||
|
localActorId: credentials?.oderId ?? null,
|
||||||
|
peerId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return edges;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many RTCPeerConnections this page has created since load. A clean session creates
|
||||||
|
* exactly one per remote peer; a rebuilt peer - for example a non-initiator that gave up
|
||||||
|
* waiting for an offer that was never elected to be sent - adds another.
|
||||||
|
*/
|
||||||
|
export async function countCreatedPeerConnections(page: Page): Promise<number> {
|
||||||
|
return await page.evaluate(() =>
|
||||||
|
((window as unknown as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? []).length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every connected pair must have exactly one initiator. Comparing ids from two different
|
||||||
|
* identity spaces breaks the antisymmetry of the election, so both peers offer (glare) or
|
||||||
|
* neither does until a takeover timer fires.
|
||||||
|
*/
|
||||||
|
export function expectExactlyOneInitiatorPerPair(edgesByClient: Record<string, PeerRoleEdge[]>): void {
|
||||||
|
const directed = new Map<string, boolean>();
|
||||||
|
const pairs = new Set<string>();
|
||||||
|
|
||||||
|
for (const [clientName, edges] of Object.entries(edgesByClient)) {
|
||||||
|
for (const edge of edges) {
|
||||||
|
expect(
|
||||||
|
edge.localActorId,
|
||||||
|
`${clientName} has no local actor id in the identity space of peer ${edge.peerId}`
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
const localActorId = edge.localActorId as string;
|
||||||
|
|
||||||
|
directed.set(`${localActorId}->${edge.peerId}`, edge.isInitiator);
|
||||||
|
pairs.add([localActorId, edge.peerId].sort().join('<->'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(pairs.size, 'expected at least one peer pair').toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const pair of pairs) {
|
||||||
|
const [first, second] = pair.split('<->');
|
||||||
|
const forward = directed.get(`${first}->${second}`);
|
||||||
|
const backward = directed.get(`${second}->${first}`);
|
||||||
|
|
||||||
|
expect(forward, `missing peer connection ${first} -> ${second}`).not.toBeUndefined();
|
||||||
|
expect(backward, `missing peer connection ${second} -> ${first}`).not.toBeUndefined();
|
||||||
|
expect(
|
||||||
|
[forward, backward].filter(Boolean),
|
||||||
|
`expected exactly one initiator for pair ${pair}`
|
||||||
|
).toHaveLength(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/** Read how many signaling managers are currently connected for this page. */
|
||||||
|
export async function getConnectedSignalManagerCount(page: Page): Promise<number> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.querySelector('app-rooms-side-panel');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = debugApi.getComponent(host);
|
||||||
|
const realtime = component['realtime'] as {
|
||||||
|
signalingTransportHandler?: {
|
||||||
|
getConnectedSignalingManagers?: () => unknown[];
|
||||||
|
};
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual-signal setups create one RTCPeerConnection per remote peer per active
|
||||||
|
* signaling manager, so the harness tracks `remotePeerCount * signalCount`
|
||||||
|
* connected peer connections.
|
||||||
|
*/
|
||||||
|
export async function waitForConnectedRemotePeerMesh(
|
||||||
|
page: Page,
|
||||||
|
remotePeerCount: number,
|
||||||
|
timeout = 45_000
|
||||||
|
): Promise<void> {
|
||||||
|
const signalCount = Math.max(await getConnectedSignalManagerCount(page), 1);
|
||||||
|
const expectedCount = remotePeerCount * signalCount;
|
||||||
|
const minimumCount = Math.max(remotePeerCount, expectedCount - signalCount);
|
||||||
|
|
||||||
|
await page.waitForFunction(
|
||||||
|
(min) => ((window as unknown as {
|
||||||
|
__rtcConnections?: RTCPeerConnection[];
|
||||||
|
}).__rtcConnections ?? []).filter(
|
||||||
|
(pc) => pc.connectionState === 'connected'
|
||||||
|
).length >= min,
|
||||||
|
minimumCount,
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMinimumConnectedPeerMeshCount(
|
||||||
|
page: Page,
|
||||||
|
remotePeerCount: number
|
||||||
|
): Promise<number> {
|
||||||
|
const signalCount = Math.max(await getConnectedSignalManagerCount(page), 1);
|
||||||
|
const expectedCount = remotePeerCount * signalCount;
|
||||||
|
|
||||||
|
return Math.max(remotePeerCount, expectedCount - signalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForConnectedSignalManagerCount(
|
||||||
|
page: Page,
|
||||||
|
expectedCount: number,
|
||||||
|
timeout = 30_000
|
||||||
|
): Promise<void> {
|
||||||
|
await expect.poll(async () => await getConnectedSignalManagerCount(page), {
|
||||||
|
timeout,
|
||||||
|
intervals: [500, 1_000]
|
||||||
|
}).toBe(expectedCount);
|
||||||
|
}
|
||||||
@@ -22,7 +22,11 @@ const SERVER_ENTRY = existsSync(SERVER_DIST_ENTRY) ? SERVER_DIST_ENTRY : SERVER_
|
|||||||
const USE_COMPILED_SERVER = SERVER_ENTRY === SERVER_DIST_ENTRY;
|
const USE_COMPILED_SERVER = SERVER_ENTRY === SERVER_DIST_ENTRY;
|
||||||
|
|
||||||
// ── Create isolated temp data directory ──────────────────────────────
|
// ── Create isolated temp data directory ──────────────────────────────
|
||||||
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
// The Playwright helper supplies a durable directory when a test needs to
|
||||||
|
// restart the signaling process on the same port without losing its database.
|
||||||
|
const suppliedTmpDir = process.env.TEST_SERVER_DATA_DIR;
|
||||||
|
const ownsTmpDir = !suppliedTmpDir;
|
||||||
|
const tmpDir = suppliedTmpDir || mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
|
||||||
const dataDir = join(tmpDir, 'data');
|
const dataDir = join(tmpDir, 'data');
|
||||||
mkdirSync(dataDir, { recursive: true });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
@@ -81,6 +85,10 @@ child.on('exit', (code) => {
|
|||||||
|
|
||||||
// ── Cleanup on signals ───────────────────────────────────────────────
|
// ── Cleanup on signals ───────────────────────────────────────────────
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
|
if (!ownsTmpDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`);
|
console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`);
|
||||||
|
|||||||
+83
-16
@@ -1,11 +1,18 @@
|
|||||||
import { spawn, type ChildProcess } from 'node:child_process';
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
import { once } from 'node:events';
|
import { once } from 'node:events';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
import { createServer } from 'node:net';
|
import { createServer } from 'node:net';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
export interface TestServerHandle {
|
export interface TestServerHandle {
|
||||||
port: number;
|
port: number;
|
||||||
url: string;
|
url: string;
|
||||||
|
restart: () => Promise<void>;
|
||||||
|
/** Kill the process but keep the port and data dir, so `start()` can bring it back. */
|
||||||
|
kill: () => Promise<void>;
|
||||||
|
/** Start the server again on the same port and data dir after `kill()`. */
|
||||||
|
start: () => Promise<void>;
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,10 +22,85 @@ const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
|
|||||||
export async function startTestServer(retries = 3): Promise<TestServerHandle> {
|
export async function startTestServer(retries = 3): Promise<TestServerHandle> {
|
||||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
const port = await allocatePort();
|
const port = await allocatePort();
|
||||||
|
const dataDir = await mkdtemp(join(tmpdir(), 'metoyou-e2e-handle-'));
|
||||||
|
|
||||||
|
let child: ChildProcess | null = null;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
child = await spawnTestServer(port, dataDir);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
if (attempt < retries) {
|
||||||
|
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
port,
|
||||||
|
url: `http://localhost:${port}`,
|
||||||
|
restart: async () => {
|
||||||
|
if (stopped) {
|
||||||
|
throw new Error('Cannot restart a stopped test server');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child) {
|
||||||
|
await stopServer(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
child = await spawnTestServer(port, dataDir);
|
||||||
|
},
|
||||||
|
kill: async () => {
|
||||||
|
if (stopped) {
|
||||||
|
throw new Error('Cannot kill a stopped test server');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child) {
|
||||||
|
await stopServer(child);
|
||||||
|
child = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
start: async () => {
|
||||||
|
if (stopped) {
|
||||||
|
throw new Error('Cannot start a stopped test server');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
child = await spawnTestServer(port, dataDir);
|
||||||
|
},
|
||||||
|
stop: async () => {
|
||||||
|
if (stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopped = true;
|
||||||
|
|
||||||
|
if (child) {
|
||||||
|
await stopServer(child);
|
||||||
|
child = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('startTestServer: unreachable');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnTestServer(port: number, dataDir: string): Promise<ChildProcess> {
|
||||||
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
|
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
|
||||||
cwd: E2E_DIR,
|
cwd: E2E_DIR,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
TEST_SERVER_DATA_DIR: dataDir,
|
||||||
TEST_SERVER_PORT: String(port)
|
TEST_SERVER_PORT: String(port)
|
||||||
},
|
},
|
||||||
stdio: 'pipe'
|
stdio: 'pipe'
|
||||||
@@ -36,25 +118,10 @@ export async function startTestServer(retries = 3): Promise<TestServerHandle> {
|
|||||||
await waitForServerReady(port, child);
|
await waitForServerReady(port, child);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await stopServer(child);
|
await stopServer(child);
|
||||||
|
|
||||||
if (attempt < retries) {
|
|
||||||
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return child;
|
||||||
port,
|
|
||||||
url: `http://localhost:${port}`,
|
|
||||||
stop: async () => {
|
|
||||||
await stopServer(child);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('startTestServer: unreachable');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function allocatePort(): Promise<number> {
|
async function allocatePort(): Promise<number> {
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { type BrowserContext, type Page } from '@playwright/test';
|
||||||
|
import type { WebRtcTestHarnessWindow } from './webrtc-test-window.types';
|
||||||
|
|
||||||
|
/** Same shape `IceServerSettingsService` persists under `metoyou_ice_servers`. */
|
||||||
|
interface StoredIceServerEntry {
|
||||||
|
id: string;
|
||||||
|
type: 'stun' | 'turn';
|
||||||
|
urls: string;
|
||||||
|
username?: string;
|
||||||
|
credential?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnCredentials {
|
||||||
|
urls: string;
|
||||||
|
username: string;
|
||||||
|
credential: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICE_SERVERS_STORAGE_KEY = 'metoyou_ice_servers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the app with a single TURN server, the way a user would in
|
||||||
|
* Settings -> ICE servers. Nothing test-specific reads this back: the app loads
|
||||||
|
* it through `IceServerSettingsService`, so the call really is configured the
|
||||||
|
* product way.
|
||||||
|
*
|
||||||
|
* Call BEFORE any `goto()`.
|
||||||
|
*/
|
||||||
|
export async function seedTurnOnlyIceServers(
|
||||||
|
target: BrowserContext | Page,
|
||||||
|
turn: TurnCredentials
|
||||||
|
): Promise<void> {
|
||||||
|
const entries: StoredIceServerEntry[] = [
|
||||||
|
{
|
||||||
|
credential: turn.credential,
|
||||||
|
id: 'e2e-turn',
|
||||||
|
type: 'turn',
|
||||||
|
urls: turn.urls,
|
||||||
|
username: turn.username
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await target.addInitScript(
|
||||||
|
([key, value]) => {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
[ICE_SERVERS_STORAGE_KEY, JSON.stringify(entries)] as const
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take away the direct path. Every `RTCPeerConnection` is built with
|
||||||
|
* `iceTransportPolicy: 'relay'`, so host and server-reflexive candidates are
|
||||||
|
* discarded and the call can only succeed by relaying through the configured
|
||||||
|
* TURN server - which is what a user behind symmetric NAT is forced to do.
|
||||||
|
*
|
||||||
|
* Install AFTER `installWebRTCTracking` (it wraps whatever constructor is
|
||||||
|
* current) and BEFORE any `goto()`.
|
||||||
|
*/
|
||||||
|
export async function forceRelayOnlyIce(target: BrowserContext | Page): Promise<void> {
|
||||||
|
await target.addInitScript(() => {
|
||||||
|
const harness = window as unknown as WebRtcTestHarnessWindow & {
|
||||||
|
__relayIceConfigs?: RTCConfiguration[];
|
||||||
|
};
|
||||||
|
const Wrapped = harness.RTCPeerConnection;
|
||||||
|
|
||||||
|
harness.__relayIceConfigs = [];
|
||||||
|
|
||||||
|
const RelayOnly = function(this: RTCPeerConnection, config?: RTCConfiguration) {
|
||||||
|
const relayConfig: RTCConfiguration = { ...config, iceTransportPolicy: 'relay' };
|
||||||
|
|
||||||
|
harness.__relayIceConfigs?.push(relayConfig);
|
||||||
|
return new Wrapped(relayConfig);
|
||||||
|
} as unknown as typeof RTCPeerConnection;
|
||||||
|
|
||||||
|
RelayOnly.prototype = Wrapped.prototype;
|
||||||
|
Object.setPrototypeOf(RelayOnly, Wrapped);
|
||||||
|
harness.RTCPeerConnection = RelayOnly;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configuration each peer connection was actually built with. A relay-only
|
||||||
|
* run that connects nothing usually means the app handed over no TURN server at
|
||||||
|
* all, which looks identical to a broken relay from the outside.
|
||||||
|
*/
|
||||||
|
export async function getRelayIceConfigs(page: Page): Promise<RTCConfiguration[]> {
|
||||||
|
return await page.evaluate(() =>
|
||||||
|
(window as unknown as { __relayIceConfigs?: RTCConfiguration[] }).__relayIceConfigs ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedCandidatePair {
|
||||||
|
localCandidateType: string;
|
||||||
|
remoteCandidateType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The candidate pair each connection actually settled on. `relay` on the local
|
||||||
|
* side means our packets left through the TURN server rather than going direct.
|
||||||
|
*/
|
||||||
|
export async function getSelectedCandidatePairs(page: Page): Promise<SelectedCandidatePair[]> {
|
||||||
|
return await page.evaluate(async () => {
|
||||||
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? [];
|
||||||
|
const pairs: SelectedCandidatePair[] = [];
|
||||||
|
|
||||||
|
for (const pc of connections) {
|
||||||
|
let stats: RTCStatsReport;
|
||||||
|
|
||||||
|
try {
|
||||||
|
stats = await pc.getStats();
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = new Map<string, string>();
|
||||||
|
|
||||||
|
let selected: { localCandidateId?: string; remoteCandidateId?: string } | null = null;
|
||||||
|
|
||||||
|
stats.forEach((report) => {
|
||||||
|
if (report.type === 'local-candidate' || report.type === 'remote-candidate') {
|
||||||
|
candidates.set(report.id as string, (report as { candidateType?: string }).candidateType ?? 'unknown');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stats.forEach((report) => {
|
||||||
|
if (report.type !== 'candidate-pair') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair = report as unknown as {
|
||||||
|
state?: string;
|
||||||
|
nominated?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
localCandidateId?: string;
|
||||||
|
remoteCandidateId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pair.state === 'succeeded' && (pair.nominated || pair.selected)) {
|
||||||
|
selected = pair;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair = selected as { localCandidateId?: string; remoteCandidateId?: string };
|
||||||
|
|
||||||
|
pairs.push({
|
||||||
|
localCandidateType: candidates.get(pair.localCandidateId ?? '') ?? 'unknown',
|
||||||
|
remoteCandidateType: candidates.get(pair.remoteCandidateId ?? '') ?? 'unknown'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until `expectedPairs` connections report a settled candidate pair whose
|
||||||
|
* local candidate is a TURN relay.
|
||||||
|
*/
|
||||||
|
export async function waitForRelayedCandidatePairs(
|
||||||
|
page: Page,
|
||||||
|
expectedPairs: number,
|
||||||
|
timeoutMs = 60_000
|
||||||
|
): Promise<SelectedCandidatePair[]> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
let latest: SelectedCandidatePair[] = [];
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
latest = await getSelectedCandidatePairs(page);
|
||||||
|
|
||||||
|
const relayed = latest.filter((pair) => pair.localCandidateType === 'relay');
|
||||||
|
|
||||||
|
if (relayed.length >= expectedPairs) {
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for ${expectedPairs} relayed candidate pairs. Last seen: ${JSON.stringify(latest)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
|
||||||
|
export interface TurnServerHandle {
|
||||||
|
urls: string;
|
||||||
|
username: string;
|
||||||
|
credential: string;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE = 'coturn/coturn:latest';
|
||||||
|
const CONTAINER_NAME = 'metoyou-e2e-turn';
|
||||||
|
const USERNAME = 'e2e';
|
||||||
|
const CREDENTIAL = 'e2epass';
|
||||||
|
const RELAY_MIN_PORT = 49_160;
|
||||||
|
const RELAY_MAX_PORT = 49_200;
|
||||||
|
|
||||||
|
/** Whether a working Docker daemon is reachable, so a spec can skip instead of failing. */
|
||||||
|
export async function isDockerAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await run('docker', ['info'], { timeout: 15_000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a throwaway coturn on the loopback interface. Relay-only tests need a real
|
||||||
|
* TURN server: `iceTransportPolicy: 'relay'` discards every other candidate, so
|
||||||
|
* without one there is no path at all and the test would prove nothing.
|
||||||
|
*/
|
||||||
|
export async function startTurnServer(): Promise<TurnServerHandle> {
|
||||||
|
await removeContainer();
|
||||||
|
|
||||||
|
const port = await allocatePort();
|
||||||
|
|
||||||
|
await run('docker', [
|
||||||
|
'run',
|
||||||
|
'--detach',
|
||||||
|
'--name',
|
||||||
|
CONTAINER_NAME,
|
||||||
|
'--network',
|
||||||
|
'host',
|
||||||
|
IMAGE,
|
||||||
|
'-n',
|
||||||
|
`--listening-port=${port}`,
|
||||||
|
'--listening-ip=127.0.0.1',
|
||||||
|
'--relay-ip=127.0.0.1',
|
||||||
|
`--min-port=${RELAY_MIN_PORT}`,
|
||||||
|
`--max-port=${RELAY_MAX_PORT}`,
|
||||||
|
'--lt-cred-mech',
|
||||||
|
`--user=${USERNAME}:${CREDENTIAL}`,
|
||||||
|
// Both browsers are on this machine. Without this coturn still hands out a
|
||||||
|
// relay candidate but refuses to forward to a 127.x peer, so ICE fails in a
|
||||||
|
// way that looks like a broken app rather than a blocked relay.
|
||||||
|
'--allow-loopback-peers',
|
||||||
|
'--realm=metoyou.test',
|
||||||
|
'--fingerprint',
|
||||||
|
'--no-tls',
|
||||||
|
'--no-dtls',
|
||||||
|
// Readiness is read back off `docker logs`: coturn logs to a file inside the
|
||||||
|
// container unless pointed at stdout, and the per-listener lines only appear
|
||||||
|
// at verbose level.
|
||||||
|
'--log-file=stdout',
|
||||||
|
'--verbose'
|
||||||
|
], { timeout: 120_000 });
|
||||||
|
|
||||||
|
await waitForTurnPort(port);
|
||||||
|
|
||||||
|
return {
|
||||||
|
credential: CREDENTIAL,
|
||||||
|
stop: removeContainer,
|
||||||
|
urls: `turn:127.0.0.1:${port}?transport=udp`,
|
||||||
|
username: USERNAME
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeContainer(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await run('docker', [
|
||||||
|
'rm',
|
||||||
|
'--force',
|
||||||
|
CONTAINER_NAME
|
||||||
|
], { timeout: 30_000 });
|
||||||
|
} catch {
|
||||||
|
// No such container - nothing to clean up.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTurnPort(port: number, timeoutMs = 20_000): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const { stdout, stderr } = await run('docker', ['logs', CONTAINER_NAME], { timeout: 10_000 })
|
||||||
|
.catch(() => ({ stderr: '', stdout: '' }));
|
||||||
|
|
||||||
|
if (`${stdout}${stderr}`.includes(`UDP listener opened on: 127.0.0.1:${port}`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`coturn did not open a UDP listener on 127.0.0.1:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** coturn binds this itself, so only probe for a free port and hand it over. */
|
||||||
|
async function allocatePort(): Promise<number> {
|
||||||
|
return await new Promise<number>((resolve, reject) => {
|
||||||
|
const probe = createServer();
|
||||||
|
|
||||||
|
probe.once('error', reject);
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = probe.address();
|
||||||
|
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
probe.close();
|
||||||
|
reject(new Error('Failed to resolve an ephemeral TURN port'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { port } = address;
|
||||||
|
|
||||||
|
probe.close((error) => (error ? reject(error) : resolve(port)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(durationMs: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, durationMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/** Wait until the side-panel roster under a voice channel lists the expected user count. */
|
||||||
|
export async function waitForVoiceRosterCount(
|
||||||
|
page: Page,
|
||||||
|
channelName: string,
|
||||||
|
expectedCount: number,
|
||||||
|
timeout = 45_000
|
||||||
|
): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ expected, name }) => {
|
||||||
|
const buttons = document.querySelectorAll(
|
||||||
|
`app-rooms-side-panel button[data-channel-type="voice"][data-channel-name="${name}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const button of buttons) {
|
||||||
|
const panel = button.closest('app-rooms-side-panel');
|
||||||
|
|
||||||
|
if (!panel || panel.getBoundingClientRect().width === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rosterDiv = button.nextElementSibling;
|
||||||
|
|
||||||
|
if (!rosterDiv) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayNames = new Set<string>();
|
||||||
|
|
||||||
|
rosterDiv.querySelectorAll('[appThemeNode="roomVoiceUserItem"] span.text-sm').forEach((element) => {
|
||||||
|
const label = element.textContent?.trim();
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
displayNames.add(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (displayNames.size === expected) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{ expected: expectedCount, name: channelName },
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import type { Client } from '../fixtures/multi-client';
|
||||||
|
import { ChatRoomPage } from '../pages/chat-room.page';
|
||||||
|
import { RegisterPage } from '../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../pages/server-search.page';
|
||||||
|
import {
|
||||||
|
installAutoResumeAudioContext,
|
||||||
|
installWebRTCTracking,
|
||||||
|
waitForAudioStatsPresent,
|
||||||
|
waitForConnectedPeerCount,
|
||||||
|
waitForOpenDataChannelCount
|
||||||
|
} from './webrtc-helpers';
|
||||||
|
|
||||||
|
const PAIR_PASSWORD = 'TestPass123!';
|
||||||
|
|
||||||
|
export interface VoicePairClient extends Client {
|
||||||
|
displayName: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register two fresh users, put them in a new server, and connect both to one voice
|
||||||
|
* channel with the WebRTC tracking harness installed. Returns once both sides report a
|
||||||
|
* connected peer, an open data channel, and live audio stats.
|
||||||
|
*/
|
||||||
|
export async function createVoicePairInNewServer(
|
||||||
|
createClient: () => Promise<Client>,
|
||||||
|
serverName: string,
|
||||||
|
options: { channelName?: string; namePrefix?: string } = {}
|
||||||
|
): Promise<VoicePairClient[]> {
|
||||||
|
const channelName = options.channelName ?? 'General';
|
||||||
|
const namePrefix = options.namePrefix ?? 'Voice Pair';
|
||||||
|
const uniqueSuffix = Date.now();
|
||||||
|
const clients: VoicePairClient[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < 2; index++) {
|
||||||
|
const client = await createClient();
|
||||||
|
|
||||||
|
await installDeterministicVoiceSettings(client.page);
|
||||||
|
await installWebRTCTracking(client.page);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
|
clients.push({
|
||||||
|
...client,
|
||||||
|
displayName: `${namePrefix} ${index + 1}`,
|
||||||
|
username: `voice_pair_${uniqueSuffix}_${index + 1}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
const registerPage = new RegisterPage(client.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(client.username, client.displayName, PAIR_PASSWORD);
|
||||||
|
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await new ServerSearchPage(clients[0].page).createServer(serverName, { description: `${namePrefix} voice session` });
|
||||||
|
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
|
||||||
|
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
|
||||||
|
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
|
||||||
|
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(channelName);
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
const room = new ChatRoomPage(client.page);
|
||||||
|
|
||||||
|
await room.joinVoiceChannel(channelName);
|
||||||
|
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await waitForConnectedPeerCount(client.page, 1, 90_000);
|
||||||
|
await waitForOpenDataChannelCount(client.page, 1, 90_000);
|
||||||
|
await waitForAudioStatsPresent(client.page, 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin voice settings so audio levels and codecs do not vary between runs. */
|
||||||
|
export async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
|
||||||
|
inputVolume: 100,
|
||||||
|
outputVolume: 100,
|
||||||
|
audioBitrate: 96,
|
||||||
|
latencyProfile: 'balanced',
|
||||||
|
includeSystemAudio: false,
|
||||||
|
noiseReduction: false,
|
||||||
|
screenShareQuality: 'balanced',
|
||||||
|
askScreenShareQuality: false
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||||||
|
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||||
|
const searchInput = page.getByPlaceholder('Search servers...');
|
||||||
|
|
||||||
|
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||||
|
await searchInput.fill(roomName);
|
||||||
|
|
||||||
|
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
|
||||||
|
|
||||||
|
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
await roomCard.dblclick();
|
||||||
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await waitForCurrentRoomName(page, roomName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
|
||||||
|
const roomButton = page.locator(`button[title="${roomName}"]`);
|
||||||
|
|
||||||
|
await expect(roomButton).toBeVisible({ timeout: 20_000 });
|
||||||
|
await roomButton.click();
|
||||||
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await waitForCurrentRoomName(page, roomName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expectedRoomName) => {
|
||||||
|
interface RoomShape { name?: string }
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.querySelector('app-rooms-side-panel');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = debugApi.getComponent(host);
|
||||||
|
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||||
|
|
||||||
|
return currentRoom?.name === expectedRoomName;
|
||||||
|
},
|
||||||
|
roomName,
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinVoiceChannelUntilConnected(
|
||||||
|
page: Page,
|
||||||
|
channelName: string,
|
||||||
|
attempts = 3
|
||||||
|
): Promise<void> {
|
||||||
|
const room = new ChatRoomPage(page);
|
||||||
|
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||||
|
await room.joinVoiceChannel(channelName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForLocalVoiceChannelConnection(page, channelName, 20_000);
|
||||||
|
await expect(room.muteButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
await page.waitForTimeout(1_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastErrorMessage = lastError instanceof Error
|
||||||
|
? `Last error: ${lastError.message}`
|
||||||
|
: 'Last error: unavailable';
|
||||||
|
|
||||||
|
throw new Error(`Failed to connect ${page.url()} to voice channel ${channelName}.\n${lastErrorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForLocalVoiceChannelConnection(
|
||||||
|
page: Page,
|
||||||
|
channelName: string,
|
||||||
|
timeout = 20_000
|
||||||
|
): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(name) => {
|
||||||
|
interface VoiceStateShape { isConnected?: boolean; roomId?: string; serverId?: string }
|
||||||
|
interface UserShape { voiceState?: VoiceStateShape }
|
||||||
|
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
|
||||||
|
interface RoomShape { id: string; channels?: ChannelShape[] }
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.querySelector('app-rooms-side-panel');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = debugApi.getComponent(host);
|
||||||
|
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||||
|
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
|
||||||
|
const voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name);
|
||||||
|
const voiceState = currentUser?.voiceState;
|
||||||
|
|
||||||
|
return !!voiceChannel
|
||||||
|
&& voiceState?.isConnected === true
|
||||||
|
&& voiceState.roomId === voiceChannel.id
|
||||||
|
&& voiceState.serverId === currentRoom.id;
|
||||||
|
},
|
||||||
|
channelName,
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,10 +8,6 @@ interface ScreenShareMediaStream extends MediaStream {
|
|||||||
__isScreenShare?: boolean;
|
__isScreenShare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function webRtcHarnessWindow(scope: Window = window): WebRtcTestHarnessWindow {
|
|
||||||
return scope as unknown as WebRtcTestHarnessWindow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
||||||
* Tracks all created peer connections and their remote tracks so tests
|
* Tracks all created peer connections and their remote tracks so tests
|
||||||
@@ -32,7 +28,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
source?: AudioScheduledSourceNode;
|
source?: AudioScheduledSourceNode;
|
||||||
drawIntervalId?: number;
|
drawIntervalId?: number;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
const harness = webRtcHarnessWindow();
|
const harness = window as unknown as WebRtcTestHarnessWindow;
|
||||||
|
|
||||||
harness.__rtcConnections = connections;
|
harness.__rtcConnections = connections;
|
||||||
harness.__rtcDataChannels = dataChannels;
|
harness.__rtcDataChannels = dataChannels;
|
||||||
@@ -160,6 +156,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
|
|
||||||
return resultStream;
|
return resultStream;
|
||||||
};
|
};
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +178,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const OrigAudioContext = window.AudioContext;
|
const OrigAudioContext = window.AudioContext;
|
||||||
const audioHarness = webRtcHarnessWindow();
|
const audioHarness = window as unknown as WebRtcTestHarnessWindow;
|
||||||
|
|
||||||
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
|
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
|
||||||
const ctx: AudioContext = new OrigAudioContext(...args);
|
const ctx: AudioContext = new OrigAudioContext(...args);
|
||||||
@@ -211,7 +208,7 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
|||||||
|
|
||||||
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => webRtcHarnessWindow().__rtcConnections?.some(
|
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
) ?? false,
|
) ?? false,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -224,7 +221,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
|
|||||||
*/
|
*/
|
||||||
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => webRtcHarnessWindow().__rtcConnections?.some(
|
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
) ?? false
|
) ?? false
|
||||||
);
|
);
|
||||||
@@ -233,7 +230,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
|||||||
/** Returns the number of tracked peer connections in `connected` state. */
|
/** Returns the number of tracked peer connections in `connected` state. */
|
||||||
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||||
(pc) => pc.connectionState === 'connected'
|
(pc) => pc.connectionState === 'connected'
|
||||||
).length ?? 0
|
).length ?? 0
|
||||||
);
|
);
|
||||||
@@ -241,19 +238,36 @@ export async function getConnectedPeerCount(page: Page): Promise<number> {
|
|||||||
|
|
||||||
/** Wait until the expected number of peer connections are `connected`. */
|
/** Wait until the expected number of peer connections are `connected`. */
|
||||||
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||||
|
try {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(count) => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||||
(pc) => pc.connectionState === 'connected'
|
(pc) => pc.connectionState === 'connected'
|
||||||
).length === count,
|
).length === count,
|
||||||
expectedCount,
|
expectedCount,
|
||||||
{ timeout }
|
{ timeout }
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const diagnostics = await page.evaluate(() => {
|
||||||
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: connections.filter((pc) => pc.connectionState === 'connected').length,
|
||||||
|
states: connections.map((pc) => pc.connectionState)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Expected ${expectedCount} connected peers within ${timeout}ms; `
|
||||||
|
+ `saw ${diagnostics.connected} connected (${diagnostics.states.join(', ') || 'none'})`,
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the number of tracked RTCDataChannels in the open state. */
|
/** Returns the number of tracked RTCDataChannels in the open state. */
|
||||||
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||||
(channel) => channel.readyState === 'open'
|
(channel) => channel.readyState === 'open'
|
||||||
).length ?? 0
|
).length ?? 0
|
||||||
);
|
);
|
||||||
@@ -262,7 +276,7 @@ export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
|||||||
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
||||||
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(count) => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||||
(channel) => channel.readyState === 'open'
|
(channel) => channel.readyState === 'open'
|
||||||
).length === count,
|
).length === count,
|
||||||
expectedCount,
|
expectedCount,
|
||||||
@@ -273,7 +287,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num
|
|||||||
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
||||||
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||||
|
|
||||||
let closed = 0;
|
let closed = 0;
|
||||||
|
|
||||||
@@ -293,7 +307,7 @@ export async function closeOpenDataChannels(page: Page): Promise<number> {
|
|||||||
/** Dispatch a synthetic data-channel error event on each open channel. */
|
/** Dispatch a synthetic data-channel error event on each open channel. */
|
||||||
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||||
|
|
||||||
let dispatched = 0;
|
let dispatched = 0;
|
||||||
|
|
||||||
@@ -354,7 +368,7 @@ interface PerPeerAudioStat {
|
|||||||
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
||||||
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length) {
|
if (!connections?.length) {
|
||||||
return [];
|
return [];
|
||||||
@@ -472,7 +486,7 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||||
}> {
|
}> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return { outbound: null, inbound: null };
|
return { outbound: null, inbound: null };
|
||||||
@@ -486,8 +500,8 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
hasInbound: boolean;
|
hasInbound: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hwm: Record<number, HWMEntry> = webRtcHarnessWindow().__rtcStatsHWM =
|
const hwm: Record<number, HWMEntry> = (window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM =
|
||||||
(webRtcHarnessWindow().__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
((window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
||||||
|
|
||||||
for (let idx = 0; idx < connections.length; idx++) {
|
for (let idx = 0; idx < connections.length; idx++) {
|
||||||
let stats: RTCStatsReport;
|
let stats: RTCStatsReport;
|
||||||
@@ -596,7 +610,7 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis
|
|||||||
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
async () => {
|
async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return false;
|
return false;
|
||||||
@@ -705,7 +719,7 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||||
}> {
|
}> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return { outbound: null, inbound: null };
|
return { outbound: null, inbound: null };
|
||||||
@@ -719,8 +733,8 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
hasInbound: boolean;
|
hasInbound: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hwm: Record<number, VHWM> = webRtcHarnessWindow().__rtcVideoStatsHWM =
|
const hwm: Record<number, VHWM> = (window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM =
|
||||||
(webRtcHarnessWindow().__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
((window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
||||||
|
|
||||||
for (let idx = 0; idx < connections.length; idx++) {
|
for (let idx = 0; idx < connections.length; idx++) {
|
||||||
let stats: RTCStatsReport;
|
let stats: RTCStatsReport;
|
||||||
@@ -804,7 +818,7 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
async () => {
|
async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return false;
|
return false;
|
||||||
@@ -972,7 +986,7 @@ export async function waitForInboundVideoFlow(
|
|||||||
*/
|
*/
|
||||||
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const conns = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const conns = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!conns?.length)
|
if (!conns?.length)
|
||||||
return 'No connections tracked';
|
return 'No connections tracked';
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ test.describe('Multi-device session', () => {
|
|||||||
expect(instanceA).not.toEqual(instanceB);
|
expect(instanceA).not.toEqual(instanceB);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await test.step('shows one self identity in the members panel on each device', async () => {
|
||||||
|
for (const client of [scenario.clientA, scenario.clientB]) {
|
||||||
|
await expect(
|
||||||
|
membersSidePanel(client.page).getByText(scenario.credentials.displayName, { exact: true })
|
||||||
|
).toHaveCount(1, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await test.step('syncs chat from device A to device B', async () => {
|
await test.step('syncs chat from device A to device B', async () => {
|
||||||
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { expect } from '@playwright/test';
|
import { expect, type Page } from '@playwright/test';
|
||||||
import { test } from '../../fixtures/multi-client';
|
import { test } from '../../fixtures/multi-client';
|
||||||
import { openSettingsFromMenu } from '../../helpers/app-menu';
|
import { openSettingsFromMenu } from '../../helpers/app-menu';
|
||||||
import { expectDashboardReady } from '../../helpers/dashboard';
|
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||||
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
||||||
import { startTestServer } from '../../helpers/test-server';
|
import { startTestServer } from '../../helpers/test-server';
|
||||||
import {
|
import {
|
||||||
|
authHeaders,
|
||||||
readAuthTokenFromPage,
|
readAuthTokenFromPage,
|
||||||
readSignalServerCredentialFromPage,
|
readSignalServerCredentialFromPage,
|
||||||
registerTestUser
|
registerTestUser
|
||||||
} from '../../helpers/auth-api';
|
} from '../../helpers/auth-api';
|
||||||
|
import { expectServerPeerVisible } from '../../helpers/multi-device-session';
|
||||||
|
import { LoginPage } from '../../pages/login.page';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
|
||||||
const PRIMARY_ENDPOINT_ID = 'e2e-multi-auth-primary';
|
const PRIMARY_ENDPOINT_ID = 'e2e-multi-auth-primary';
|
||||||
const USER_PASSWORD = 'TestPass123!';
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
@@ -108,4 +112,421 @@ test.describe('Multi-signal-server authentication', () => {
|
|||||||
await secondaryServer.stop();
|
await secondaryServer.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('restored session recreates a missing secret, provisions silently, and joins foreign presence', async ({ createClient }) => {
|
||||||
|
const primaryServer = await startTestServer();
|
||||||
|
const secondaryServer = await startTestServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
const suffix = `restore_auth_${Date.now()}`;
|
||||||
|
const aliceUsername = `alice_${suffix}`;
|
||||||
|
const bobUsername = `bob_${suffix}`;
|
||||||
|
const serverName = `Foreign Restore ${suffix}`;
|
||||||
|
|
||||||
|
await installTestServerEndpoints(alice.context, [
|
||||||
|
{
|
||||||
|
id: PRIMARY_ENDPOINT_ID,
|
||||||
|
name: 'E2E Primary Signal',
|
||||||
|
url: primaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await installTestServerEndpoints(bob.context, [
|
||||||
|
{
|
||||||
|
id: 'e2e-multi-auth-secondary',
|
||||||
|
name: 'E2E Secondary Signal',
|
||||||
|
url: secondaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await test.step('Bob creates the foreign-hosted server', async () => {
|
||||||
|
const register = new RegisterPage(bob.page);
|
||||||
|
|
||||||
|
await register.goto();
|
||||||
|
await register.register(bobUsername, 'Bob Restore', USER_PASSWORD);
|
||||||
|
await expectDashboardReady(bob.page);
|
||||||
|
|
||||||
|
await new ServerSearchPage(bob.page).createServer(serverName, {
|
||||||
|
description: 'Restore-safe foreign authentication coverage'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice registers only on her home signal server', async () => {
|
||||||
|
const register = new RegisterPage(alice.page);
|
||||||
|
|
||||||
|
await register.goto();
|
||||||
|
await register.register(aliceUsername, 'Alice Restore', USER_PASSWORD);
|
||||||
|
await expectDashboardReady(alice.page);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('A restored tab has no provision secret when the foreign endpoint appears', async () => {
|
||||||
|
await alice.page.evaluate(() => {
|
||||||
|
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
|
||||||
|
const key = sessionStorage.key(index);
|
||||||
|
|
||||||
|
if (key?.startsWith('metoyou.provisionSecret.')) {
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await installTestServerEndpoints(alice.context, [
|
||||||
|
{
|
||||||
|
id: PRIMARY_ENDPOINT_ID,
|
||||||
|
name: 'E2E Primary Signal',
|
||||||
|
url: primaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'e2e-multi-auth-secondary',
|
||||||
|
name: 'E2E Secondary Signal',
|
||||||
|
url: secondaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await alice.page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await expectDashboardReady(alice.page);
|
||||||
|
await expect(alice.page).not.toHaveURL(/\/login/);
|
||||||
|
await expect.poll(async () =>
|
||||||
|
await readSignalServerCredentialFromPage(alice.page, secondaryServer.url),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice joins the foreign server and both users see mutual presence', async () => {
|
||||||
|
await new ServerSearchPage(alice.page).joinServerFromSearch(serverName);
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
|
||||||
|
await expectServerPeerVisible(alice.page, 'Bob Restore');
|
||||||
|
await expectServerPeerVisible(bob.page, 'Alice Restore');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Both users restore mutual presence after the foreign signal server restarts', async () => {
|
||||||
|
await Promise.all([installRestartSignalingTrace(alice.page), installRestartSignalingTrace(bob.page)]);
|
||||||
|
|
||||||
|
await secondaryServer.restart();
|
||||||
|
|
||||||
|
await expect.poll(async () =>
|
||||||
|
await hasRestartPresenceRecovery(alice.page, 'Bob Restore'),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
await expect.poll(async () =>
|
||||||
|
await hasRestartPresenceRecovery(bob.page, 'Alice Restore'),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
await expectServerPeerVisible(alice.page, 'Bob Restore');
|
||||||
|
await expectServerPeerVisible(bob.page, 'Alice Restore');
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await primaryServer.stop();
|
||||||
|
await secondaryServer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two devices of the same human share one account on a foreign signal server', async ({ createClient }) => {
|
||||||
|
const primaryServer = await startTestServer();
|
||||||
|
const secondaryServer = await startTestServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const suffix = `one_identity_${Date.now()}`;
|
||||||
|
const username = `alice_${suffix}`;
|
||||||
|
const endpoints = [
|
||||||
|
{
|
||||||
|
id: PRIMARY_ENDPOINT_ID,
|
||||||
|
name: 'E2E Primary Signal',
|
||||||
|
url: primaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'e2e-multi-auth-secondary',
|
||||||
|
name: 'E2E Secondary Signal',
|
||||||
|
url: secondaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online' as const
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const laptop = await createClient();
|
||||||
|
|
||||||
|
await installTestServerEndpoints(laptop.context, endpoints);
|
||||||
|
|
||||||
|
await test.step('Alice signs in on her laptop and provisions the foreign server', async () => {
|
||||||
|
const register = new RegisterPage(laptop.page);
|
||||||
|
|
||||||
|
await register.goto();
|
||||||
|
await register.register(username, 'Alice One Identity', USER_PASSWORD);
|
||||||
|
await expectDashboardReady(laptop.page);
|
||||||
|
await restartApp(laptop.page);
|
||||||
|
});
|
||||||
|
|
||||||
|
const laptopCredential = await waitForForeignCredential(laptop.page, secondaryServer.url);
|
||||||
|
const phone = await createClient();
|
||||||
|
|
||||||
|
await installTestServerEndpoints(phone.context, endpoints);
|
||||||
|
|
||||||
|
await test.step('Alice signs in on a second device with no shared local storage', async () => {
|
||||||
|
const login = new LoginPage(phone.page);
|
||||||
|
|
||||||
|
await login.goto();
|
||||||
|
await login.login(username, USER_PASSWORD);
|
||||||
|
await expectDashboardReady(phone.page);
|
||||||
|
await restartApp(phone.page);
|
||||||
|
});
|
||||||
|
|
||||||
|
const phoneCredential = await waitForForeignCredential(phone.page, secondaryServer.url);
|
||||||
|
|
||||||
|
// One human must be one actor on the foreign server. A per-device secret
|
||||||
|
// made the second device register `alice-<shortHomeId>` instead, which is
|
||||||
|
// what showed the same person twice to everybody else.
|
||||||
|
expect(phoneCredential?.userId).toBe(laptopCredential?.userId);
|
||||||
|
expect(phoneCredential?.username).toBe(username);
|
||||||
|
expect(laptopCredential?.username).toBe(username);
|
||||||
|
} finally {
|
||||||
|
await primaryServer.stop();
|
||||||
|
await secondaryServer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lost foreign secret shows contextual retry without logging out the home session', async ({ createClient, request }) => {
|
||||||
|
const primaryServer = await startTestServer();
|
||||||
|
const secondaryServer = await startTestServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const alice = await createClient();
|
||||||
|
const suffix = `lost_secret_${Date.now()}`;
|
||||||
|
const username = `alice_${suffix}`;
|
||||||
|
|
||||||
|
await installTestServerEndpoints(alice.context, [
|
||||||
|
{
|
||||||
|
id: PRIMARY_ENDPOINT_ID,
|
||||||
|
name: 'E2E Primary Signal',
|
||||||
|
url: primaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const register = new RegisterPage(alice.page);
|
||||||
|
|
||||||
|
await register.goto();
|
||||||
|
await register.register(username, 'Alice Lost Secret', USER_PASSWORD);
|
||||||
|
await expectDashboardReady(alice.page);
|
||||||
|
|
||||||
|
const homeUserId = await alice.page.evaluate(() =>
|
||||||
|
localStorage.getItem('metoyou_currentUserId')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!homeUserId) {
|
||||||
|
throw new Error('Expected restored home user id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortHomeId = homeUserId.replace(/-/g, '').slice(0, 6)
|
||||||
|
.toLowerCase();
|
||||||
|
const oldForeignAccount = await registerTestUser(
|
||||||
|
request,
|
||||||
|
secondaryServer.url,
|
||||||
|
username,
|
||||||
|
'OldForeignSecret123!',
|
||||||
|
'Alice Lost Secret'
|
||||||
|
);
|
||||||
|
|
||||||
|
await registerTestUser(
|
||||||
|
request,
|
||||||
|
secondaryServer.url,
|
||||||
|
`${username}-${shortHomeId}`,
|
||||||
|
'OldForeignSecret123!',
|
||||||
|
'Alice Lost Secret'
|
||||||
|
);
|
||||||
|
|
||||||
|
const serverName = `Lost Secret Recovery ${suffix}`;
|
||||||
|
const createResponse = await request.post(`${secondaryServer.url}/api/servers`, {
|
||||||
|
headers: authHeaders(oldForeignAccount.token),
|
||||||
|
data: {
|
||||||
|
name: serverName,
|
||||||
|
description: 'Contextual auth recovery coverage',
|
||||||
|
ownerId: oldForeignAccount.id,
|
||||||
|
ownerPublicKey: oldForeignAccount.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createResponse.ok(), await createResponse.text()).toBe(true);
|
||||||
|
|
||||||
|
await alice.page.evaluate(() => {
|
||||||
|
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
|
||||||
|
const key = sessionStorage.key(index);
|
||||||
|
|
||||||
|
if (key?.startsWith('metoyou.provisionSecret.')) {
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await installTestServerEndpoints(alice.context, [
|
||||||
|
{
|
||||||
|
id: PRIMARY_ENDPOINT_ID,
|
||||||
|
name: 'E2E Primary Signal',
|
||||||
|
url: primaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'e2e-multi-auth-secondary',
|
||||||
|
name: 'E2E Secondary Signal',
|
||||||
|
url: secondaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await alice.page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await expectDashboardReady(alice.page);
|
||||||
|
await new ServerSearchPage(alice.page).joinServerFromSearch(serverName);
|
||||||
|
|
||||||
|
const recovery = alice.page.getByTestId('signal-server-auth-recovery');
|
||||||
|
|
||||||
|
await expect(recovery).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(recovery).toContainText('Reconnect to');
|
||||||
|
await expect(alice.page).not.toHaveURL(/\/login/);
|
||||||
|
|
||||||
|
await recovery.getByTestId('signal-server-auth-retry').click();
|
||||||
|
await expect(recovery).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(alice.page).not.toHaveURL(/\/login/);
|
||||||
|
} finally {
|
||||||
|
await primaryServer.stop();
|
||||||
|
await secondaryServer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Foreign endpoints are provisioned on the bootstrap path, so reload to reach it. */
|
||||||
|
async function restartApp(page: Page): Promise<void> {
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await expectDashboardReady(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForForeignCredential(page: Page, serverUrl: string) {
|
||||||
|
await expect.poll(async () =>
|
||||||
|
await readSignalServerCredentialFromPage(page, serverUrl),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
return await readSignalServerCredentialFromPage(page, serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestartSignalingTraceEvent {
|
||||||
|
displayName?: string;
|
||||||
|
direction: 'inbound' | 'outbound';
|
||||||
|
type: string;
|
||||||
|
users?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installRestartSignalingTrace(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const tracedWindow = window as typeof window & {
|
||||||
|
__restartSignalingTrace?: RestartSignalingTraceEvent[];
|
||||||
|
};
|
||||||
|
const OriginalWebSocket = window.WebSocket;
|
||||||
|
const trace: RestartSignalingTraceEvent[] = [];
|
||||||
|
const TrackedWebSocket = function(
|
||||||
|
this: WebSocket,
|
||||||
|
url: string | URL,
|
||||||
|
protocols?: string | string[]
|
||||||
|
): WebSocket {
|
||||||
|
const socket = protocols === undefined
|
||||||
|
? new OriginalWebSocket(url)
|
||||||
|
: new OriginalWebSocket(url, protocols);
|
||||||
|
const originalSend = socket.send.bind(socket);
|
||||||
|
|
||||||
|
socket.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView): void => {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data) as { type?: unknown };
|
||||||
|
|
||||||
|
if (typeof message.type === 'string') {
|
||||||
|
trace.push({ direction: 'outbound', type: message.type });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON websocket traffic.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
originalSend(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.addEventListener('message', (event) => {
|
||||||
|
if (typeof event.data !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data) as {
|
||||||
|
displayName?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
users?: { displayName?: unknown }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof message.type === 'string') {
|
||||||
|
trace.push({
|
||||||
|
displayName: typeof message.displayName === 'string'
|
||||||
|
? message.displayName
|
||||||
|
: undefined,
|
||||||
|
direction: 'inbound',
|
||||||
|
type: message.type,
|
||||||
|
users: Array.isArray(message.users)
|
||||||
|
? message.users
|
||||||
|
.map((user) => user.displayName)
|
||||||
|
.filter((displayName): displayName is string => typeof displayName === 'string')
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON websocket traffic.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.setPrototypeOf(TrackedWebSocket, OriginalWebSocket);
|
||||||
|
TrackedWebSocket.prototype = OriginalWebSocket.prototype;
|
||||||
|
tracedWindow.__restartSignalingTrace = trace;
|
||||||
|
tracedWindow.WebSocket = TrackedWebSocket as unknown as typeof WebSocket;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasRestartPresenceRecovery(page: Page, expectedPeerName: string): Promise<boolean> {
|
||||||
|
return await page.evaluate((peerName) => {
|
||||||
|
const trace = (window as typeof window & {
|
||||||
|
__restartSignalingTrace?: RestartSignalingTraceEvent[];
|
||||||
|
}).__restartSignalingTrace ?? [];
|
||||||
|
const identifyIndex = trace.findIndex((event) =>
|
||||||
|
event.direction === 'outbound' && event.type === 'identify'
|
||||||
|
);
|
||||||
|
const joinIndex = trace.findIndex((event) =>
|
||||||
|
event.direction === 'outbound' && event.type === 'join_server'
|
||||||
|
);
|
||||||
|
const receivedPeerPresence = trace.some((event) =>
|
||||||
|
event.direction === 'inbound' && (
|
||||||
|
(event.type === 'server_users' && event.users?.includes(peerName))
|
||||||
|
|| (event.type === 'user_joined' && event.displayName === peerName)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return identifyIndex >= 0
|
||||||
|
&& joinIndex > identifyIndex
|
||||||
|
&& receivedPeerPresence;
|
||||||
|
}, expectedPeerName);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../../fixtures/multi-client';
|
||||||
|
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
||||||
|
import { startTestServer } from '../../helpers/test-server';
|
||||||
|
import { readSignalServerCredentialFromPage } from '../../helpers/auth-api';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P4 coverage: one human must own one DM thread, and a call that reached
|
||||||
|
* nobody must not look live.
|
||||||
|
*
|
||||||
|
* The fork this guards against: a peer on another signal server addresses the
|
||||||
|
* local user by their provisioned actor id, so the inbound conversation id does
|
||||||
|
* not match the id the local user builds from their home identity. Before the
|
||||||
|
* canonicalization the recipient ended up with two threads for the same human -
|
||||||
|
* one holding the peer's messages, one empty - and clicking the peer opened the
|
||||||
|
* empty one.
|
||||||
|
*/
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
const PRIMARY_SIGNAL_ID = 'e2e-dm-identity-primary';
|
||||||
|
const SECONDARY_SIGNAL_ID = 'e2e-dm-identity-secondary';
|
||||||
|
|
||||||
|
test.describe('Cross-signal direct message identity', () => {
|
||||||
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
|
test('keeps one DM thread when the peer addresses the local user by a provisioned actor id', async ({
|
||||||
|
createClient,
|
||||||
|
testServer
|
||||||
|
}) => {
|
||||||
|
const secondaryServer = await startTestServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const suffix = uniqueName('xsig-dm');
|
||||||
|
const serverName = `Cross Signal DM ${suffix}`;
|
||||||
|
const message = `cross signal hello ${suffix}`;
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
const endpoints = [
|
||||||
|
{
|
||||||
|
id: PRIMARY_SIGNAL_ID,
|
||||||
|
name: 'E2E DM Signal A',
|
||||||
|
url: testServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SECONDARY_SIGNAL_ID,
|
||||||
|
name: 'E2E DM Signal B',
|
||||||
|
url: secondaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await installTestServerEndpoints(alice.context, endpoints);
|
||||||
|
await installTestServerEndpoints(bob.context, endpoints);
|
||||||
|
|
||||||
|
await test.step('Alice is home on signal A, Bob on signal B', async () => {
|
||||||
|
await registerOn(alice.page, PRIMARY_SIGNAL_ID, `alice_${suffix}`, 'Alice');
|
||||||
|
await registerOn(bob.page, SECONDARY_SIGNAL_ID, `bob_${suffix}`, 'Bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('They meet in a room on signal A, so Bob acts through a provisioned identity', async () => {
|
||||||
|
await new ServerSearchPage(alice.page).createServer(serverName, {
|
||||||
|
description: 'Cross-signal DM identity coverage',
|
||||||
|
sourceId: PRIMARY_SIGNAL_ID
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(alice.page).waitForReady();
|
||||||
|
|
||||||
|
await new ServerSearchPage(bob.page).joinServerFromSearch(serverName);
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(bob.page).waitForReady();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await readSignalServerCredentialFromPage(bob.page, testServer.url), { timeout: 30_000 })
|
||||||
|
.not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice sends Bob a DM addressed to his provisioned actor id', async () => {
|
||||||
|
await openDmFromRoomUserCard(alice.page, 'Bob');
|
||||||
|
await alice.page.getByTestId('dm-input').fill(message);
|
||||||
|
await alice.page.getByTestId('dm-input').press('Enter');
|
||||||
|
|
||||||
|
// Bob stores the thread before he ever opens the DM view, so the
|
||||||
|
// inbound conversation id is the only id his device knows.
|
||||||
|
await expect
|
||||||
|
.poll(async () => await countStoredConversations(bob.page), { timeout: 30_000 })
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob opens Alice and finds one thread holding her message', async () => {
|
||||||
|
await openDmFromRoomUserCard(bob.page, 'Alice');
|
||||||
|
|
||||||
|
await expect(bob.page.locator('app-dm-chat').getByText(message)).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(bob.page.locator('app-dm-conversation-item')).toHaveCount(1, { timeout: 20_000 });
|
||||||
|
expect(await countStoredConversations(bob.page)).toBe(1);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await secondaryServer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('surfaces an undelivered ring instead of a call that looks live', async ({ createClient }) => {
|
||||||
|
const suffix = uniqueName('undelivered-ring');
|
||||||
|
const serverName = `Undelivered Ring ${suffix}`;
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
|
||||||
|
await test.step('Alice and Bob meet in a room', async () => {
|
||||||
|
await registerOn(alice.page, null, `alice_${suffix}`, 'Alice');
|
||||||
|
await registerOn(bob.page, null, `bob_${suffix}`, 'Bob');
|
||||||
|
|
||||||
|
await new ServerSearchPage(alice.page).createServer(serverName, {
|
||||||
|
description: 'Undelivered call ring coverage'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(alice.page).waitForReady();
|
||||||
|
|
||||||
|
await new ServerSearchPage(bob.page).joinServerFromSearch(serverName);
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(bob.page).waitForReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice calls Bob with no transport that can carry the ring', async () => {
|
||||||
|
await openDmFromRoomUserCard(alice.page, 'Bob');
|
||||||
|
await alice.page.evaluate(() => window.simulateOffline?.());
|
||||||
|
|
||||||
|
const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' });
|
||||||
|
|
||||||
|
await expect(callButton).toBeEnabled({ timeout: 20_000 });
|
||||||
|
await callButton.click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('The call view says the ring reached nobody', async () => {
|
||||||
|
await expect(alice.page.getByTestId('private-call-error')).toContainText('Could not reach anyone', {
|
||||||
|
timeout: 20_000
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function registerOn(
|
||||||
|
page: Page,
|
||||||
|
signalServerId: string | null,
|
||||||
|
username: string,
|
||||||
|
displayName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const registerPage = new RegisterPage(page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
|
||||||
|
if (signalServerId) {
|
||||||
|
await registerPage.serverSelect.selectOption(signalServerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await registerPage.register(username, displayName, USER_PASSWORD);
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDmFromRoomUserCard(page: Page, displayName: string): Promise<void> {
|
||||||
|
const userCard = page.locator('[data-testid^="room-user-card-"]', { hasText: displayName }).first();
|
||||||
|
|
||||||
|
await expect(userCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
await userCard.getByRole('button', { name: `Message ${displayName}` }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dm\//, { timeout: 20_000 });
|
||||||
|
await expect(page.getByRole('heading', { name: displayName })).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count stored DM threads for whichever user owns this browser profile. */
|
||||||
|
async function countStoredConversations(page: Page): Promise<number> {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const prefix = 'metoyou_direct_message_conversations:';
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < localStorage.length; index++) {
|
||||||
|
const key = localStorage.key(index);
|
||||||
|
|
||||||
|
if (!key?.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(key) ?? '[]') as unknown[];
|
||||||
|
|
||||||
|
total += Array.isArray(parsed) ? parsed.length : 0;
|
||||||
|
} catch {
|
||||||
|
// A half-written entry is not a thread.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for "Sending files between users doesn't really work":
|
||||||
|
* a generic (non-media) file above the 10 MB auto-save cap sent to a browser
|
||||||
|
* receiver. The receiver clicks Request; previously the chunk handler dropped
|
||||||
|
* every incoming chunk with a silent file-too-large error, the sender's ack
|
||||||
|
* wait timed out, and the GUI never changed.
|
||||||
|
*/
|
||||||
|
const LARGE_FILE_SIZE_BYTES = 11 * 1024 * 1024;
|
||||||
|
|
||||||
|
test.describe('Large generic file transfer', () => {
|
||||||
|
test.describe.configure({ timeout: 420_000, retries: 1 });
|
||||||
|
|
||||||
|
test('browser receiver can request and download a generic file above the auto-save cap', async ({ createClient }) => {
|
||||||
|
const suffix = uniqueName('largefile');
|
||||||
|
const serverName = `Large File Server ${suffix}`;
|
||||||
|
const fileName = `${suffix}-dataset.bin`;
|
||||||
|
const caption = `Large file upload ${suffix}`;
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
const aliceMessages = new ChatMessagesPage(alice.page);
|
||||||
|
const bobMessages = new ChatMessagesPage(bob.page);
|
||||||
|
|
||||||
|
await test.step('Alice and Bob register and meet in a server', async () => {
|
||||||
|
const aliceRegister = new RegisterPage(alice.page);
|
||||||
|
|
||||||
|
await aliceRegister.goto();
|
||||||
|
await aliceRegister.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||||
|
await expect(alice.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const bobRegister = new RegisterPage(bob.page);
|
||||||
|
|
||||||
|
await bobRegister.goto();
|
||||||
|
await bobRegister.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||||
|
await expect(bob.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const aliceSearch = new ServerSearchPage(alice.page);
|
||||||
|
|
||||||
|
await aliceSearch.createServer(serverName, { description: 'Large generic file transfer coverage' });
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
|
await bobSearch.joinServerFromSearch(serverName);
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
await aliceMessages.waitForReady();
|
||||||
|
await bobMessages.waitForReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice sends an 11 MB generic file', async () => {
|
||||||
|
await attachGeneratedBinaryFile(aliceMessages, fileName, LARGE_FILE_SIZE_BYTES);
|
||||||
|
await aliceMessages.sendMessage(caption);
|
||||||
|
await expect(aliceMessages.getMessageItemByText(caption)).toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const bobBubble = bobMessages.getMessageItemByText(caption);
|
||||||
|
|
||||||
|
await test.step('Bob sees the attachment card with a Request button', async () => {
|
||||||
|
await expect(bobBubble).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(bobBubble.getByText(fileName, { exact: false })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(bobBubble.getByRole('button', { name: /request/i })).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob requests the file and it downloads to completion', async () => {
|
||||||
|
await bobBubble.getByRole('button', { name: /request/i }).click();
|
||||||
|
|
||||||
|
// The transfer must visibly progress (Cancel replaces Request) instead of
|
||||||
|
// silently stalling at 0 bytes like the original bug.
|
||||||
|
await expect(bobBubble.getByRole('button', { name: /cancel/i })).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await expect(bobBubble.getByRole('button', { name: /download/i })).toBeVisible({ timeout: 300_000 });
|
||||||
|
await expect(bobBubble.getByText(/too large/i)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the file inside the page so the multi-megabyte payload never crosses
|
||||||
|
* the CDP protocol as a base64 string.
|
||||||
|
*/
|
||||||
|
async function attachGeneratedBinaryFile(
|
||||||
|
messages: ChatMessagesPage,
|
||||||
|
fileName: string,
|
||||||
|
sizeBytes: number
|
||||||
|
): Promise<void> {
|
||||||
|
await messages.waitForReady();
|
||||||
|
|
||||||
|
await messages.composerInput.evaluate((element, { name, size }) => {
|
||||||
|
const bytes = new Uint8Array(size);
|
||||||
|
|
||||||
|
for (let index = 0; index < size; index++) {
|
||||||
|
bytes[index] = (index * 31 + 7) & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
|
||||||
|
dataTransfer.items.add(new File([bytes], name, { type: 'application/octet-stream' }));
|
||||||
|
element.dispatchEvent(new DragEvent('drop', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
dataTransfer
|
||||||
|
}));
|
||||||
|
}, { name: fileName, size: sizeBytes });
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
}
|
||||||
@@ -85,6 +85,61 @@ test.describe('Multi-device attachment sharing', () => {
|
|||||||
await expect(getButton.first()).toBeVisible({ timeout: 20_000 });
|
await expect(getButton.first()).toBeVisible({ timeout: 20_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('relays file-announce metadata to a sibling device that is already online during upload', async ({
|
||||||
|
createClient
|
||||||
|
}) => {
|
||||||
|
const suffix = uniqueMultiDeviceName('attach-online');
|
||||||
|
const credentials = {
|
||||||
|
username: `online_${suffix}`,
|
||||||
|
displayName: 'Multi Device User',
|
||||||
|
password: MULTI_DEVICE_PASSWORD
|
||||||
|
};
|
||||||
|
const serverName = `Attachment Online Relay ${suffix}`;
|
||||||
|
const fileName = `${suffix}-relay.bin`;
|
||||||
|
const caption = `Uploaded while device B was online ${suffix}`;
|
||||||
|
const fileAttachment = createBinaryFilePayload(fileName, 'application/octet-stream', `relay-body-${suffix}`);
|
||||||
|
const clientA = await createClient();
|
||||||
|
const clientB = await createClient();
|
||||||
|
const messagesA = new ChatMessagesPage(clientA.page);
|
||||||
|
const messagesB = new ChatMessagesPage(clientB.page);
|
||||||
|
|
||||||
|
await test.step('device A registers and creates a server', async () => {
|
||||||
|
const registerPage = new RegisterPage(clientA.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
|
||||||
|
await expect(clientA.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const search = new ServerSearchPage(clientA.page);
|
||||||
|
|
||||||
|
await search.createServer(serverName, { description: 'Sibling online file-announce relay coverage' });
|
||||||
|
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
await messagesA.waitForReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('device B logs into the same server before the upload starts', async () => {
|
||||||
|
await loginSecondDeviceIntoServer(clientB.page, credentials, serverName);
|
||||||
|
await clientA.page.bringToFront();
|
||||||
|
await messagesA.waitForReady();
|
||||||
|
await clientB.page.bringToFront();
|
||||||
|
await messagesB.waitForReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('device A uploads while device B is already in the room', async () => {
|
||||||
|
await clientA.page.bringToFront();
|
||||||
|
await messagesA.attachFiles([fileAttachment]);
|
||||||
|
await messagesA.sendMessage(caption);
|
||||||
|
await expect(messagesA.getMessageItemByText(caption)).toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('device B learns attachment metadata without a server-rail click dance', async () => {
|
||||||
|
await clientB.page.bringToFront();
|
||||||
|
await expect(messagesB.getMessageItemByText(caption)).toBeVisible({ timeout: 90_000 });
|
||||||
|
await expect(messagesB.getMessageItemByText(caption).getByText(fileName, { exact: false }))
|
||||||
|
.toBeVisible({ timeout: 90_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function createBinaryFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
|
function createBinaryFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
|
||||||
|
|
||||||
|
test.describe('Multi-image gallery grouping', () => {
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test('groups three images in one message bubble with a visible grid', async ({ createClient }) => {
|
||||||
|
const suffix = uniqueName('gallery');
|
||||||
|
const client = await createClient();
|
||||||
|
const registerPage = new RegisterPage(client.page);
|
||||||
|
const search = new ServerSearchPage(client.page);
|
||||||
|
const messages = new ChatMessagesPage(client.page);
|
||||||
|
const serverName = `Gallery Group ${suffix}`;
|
||||||
|
const imageNames = [
|
||||||
|
`${suffix}-one.svg`,
|
||||||
|
`${suffix}-two.svg`,
|
||||||
|
`${suffix}-three.svg`
|
||||||
|
];
|
||||||
|
const images = imageNames.map((name) => createSvgFilePayload(name));
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(`gallery_${suffix}`, 'Gallery User', 'TestPass123!');
|
||||||
|
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
await search.createServer(serverName, { description: 'Multi-image gallery regression server' });
|
||||||
|
await expect(client.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
await messages.waitForReady();
|
||||||
|
|
||||||
|
await messages.attachFiles(images);
|
||||||
|
await messages.sendPendingAttachments();
|
||||||
|
|
||||||
|
for (const imageName of imageNames) {
|
||||||
|
await messages.expectMessageImageLoaded(imageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = await messages.getMessageIdContainingImage(imageNames[0]);
|
||||||
|
|
||||||
|
expect(messageId).toBeTruthy();
|
||||||
|
|
||||||
|
const bubble = client.page.locator(`[data-message-id="${messageId}"]`);
|
||||||
|
|
||||||
|
await expect(bubble.locator('img[alt$=".svg"]')).toHaveCount(3, { timeout: 20_000 });
|
||||||
|
await expect(bubble.locator('.chat-image-grid')).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function uniqueName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10_000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSvgFilePayload(name: string): ChatDropFilePayload {
|
||||||
|
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="#4A217A"/></svg>';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
mimeType: 'image/svg+xml',
|
||||||
|
base64: Buffer.from(svg, 'utf8').toString('base64')
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test, type Client } from '../../fixtures/multi-client';
|
||||||
|
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||||
|
import {
|
||||||
|
dumpRtcDiagnostics,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
|
installWebRTCTracking,
|
||||||
|
waitForAudioStatsPresent,
|
||||||
|
waitForConnectedPeerCount,
|
||||||
|
waitForInboundVideoFlow,
|
||||||
|
waitForOutboundVideoFlow
|
||||||
|
} from '../../helpers/webrtc-helpers';
|
||||||
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
const VOICE_CHANNEL = 'General';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen share is pull-based: the sharer only attaches tracks to peers that asked
|
||||||
|
* for them. This covers the case the request model has to get right - a viewer who
|
||||||
|
* arrives after the share already started.
|
||||||
|
*/
|
||||||
|
test.describe('Late joiner screen share', () => {
|
||||||
|
test('a user who joins voice mid-share still receives the screen', async ({ createClient }) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
|
||||||
|
const serverName = `Late Share ${Date.now()}`;
|
||||||
|
const sharer = await createVoiceClient(createClient, 'sharer');
|
||||||
|
const viewer = await createVoiceClient(createClient, 'viewer');
|
||||||
|
|
||||||
|
await test.step('Both users register and join the server', async () => {
|
||||||
|
await new ServerSearchPage(sharer.page).createServer(serverName, {
|
||||||
|
description: 'Late joiner screen share test'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sharer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
|
||||||
|
await new ServerSearchPage(viewer.page).joinServerFromSearch(serverName);
|
||||||
|
await expect(viewer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('The sharer starts sharing while alone in voice', async () => {
|
||||||
|
const room = new ChatRoomPage(sharer.page);
|
||||||
|
|
||||||
|
await room.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||||
|
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
|
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await openVoiceWorkspace(sharer.page);
|
||||||
|
await room.startScreenShare();
|
||||||
|
await expect(room.isScreenShareActive).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('The viewer joins voice after the share is already running', async () => {
|
||||||
|
const room = new ChatRoomPage(viewer.page);
|
||||||
|
|
||||||
|
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
|
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await waitForConnectedPeerCount(viewer.page, 1, 90_000);
|
||||||
|
await waitForConnectedPeerCount(sharer.page, 1, 90_000);
|
||||||
|
await waitForAudioStatsPresent(viewer.page, 30_000);
|
||||||
|
|
||||||
|
await openVoiceWorkspace(viewer.page);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('The in-progress screen reaches the late joiner', async () => {
|
||||||
|
try {
|
||||||
|
const outbound = await waitForOutboundVideoFlow(sharer.page, 60_000);
|
||||||
|
const inbound = await waitForInboundVideoFlow(viewer.page, 60_000);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
outbound.outboundBytesDelta > 0 || outbound.outboundPacketsDelta > 0,
|
||||||
|
'The sharer never sent screen video to the late joiner'
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
inbound.inboundBytesDelta > 0 || inbound.inboundPacketsDelta > 0,
|
||||||
|
'The late joiner never received the in-progress screen share'
|
||||||
|
).toBe(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[sharer RTC]\n${await dumpRtcDiagnostics(sharer.page)}`);
|
||||||
|
console.log(`[viewer RTC]\n${await dumpRtcDiagnostics(viewer.page)}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('The late joiner renders a remote screen tile', async () => {
|
||||||
|
await expect(viewer.page.locator('app-voice-workspace-stream-tile').first())
|
||||||
|
.toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Expand the voice workspace, which is what turns on remote screen-share requests. */
|
||||||
|
async function openVoiceWorkspace(page: Page): Promise<void> {
|
||||||
|
const viewButton = page.locator('app-rooms-side-panel')
|
||||||
|
.getByRole('button', { name: /view/i })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
await expect(viewButton).toBeVisible({ timeout: 20_000 });
|
||||||
|
await viewButton.click();
|
||||||
|
await expect(page.locator('app-voice-workspace')).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createVoiceClient(
|
||||||
|
createClient: () => Promise<Client>,
|
||||||
|
role: string
|
||||||
|
): Promise<Client> {
|
||||||
|
const client = await createClient();
|
||||||
|
|
||||||
|
await installDeterministicVoiceSettings(client.page);
|
||||||
|
await installWebRTCTracking(client.page);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
|
const registerPage = new RegisterPage(client.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(
|
||||||
|
`late_share_${role}_${Date.now()}`,
|
||||||
|
`Late Share ${role}`,
|
||||||
|
USER_PASSWORD
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { test, type Client } from '../../fixtures/multi-client';
|
||||||
|
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||||
|
import { installAutoResumeAudioContext, installWebRTCTracking } from '../../helpers/webrtc-helpers';
|
||||||
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
const VOICE_CHANNEL = 'General';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user sharing alone in a voice channel used to look idle to everyone else,
|
||||||
|
* because the LIVE badge was gated on the observer's own voice connection. The
|
||||||
|
* observer here never joins voice, so the badge can only appear if sharing
|
||||||
|
* presence reaches a non-participant.
|
||||||
|
*/
|
||||||
|
test.describe('Screen share visibility from outside the channel', () => {
|
||||||
|
test('a user who is not in voice sees the LIVE badge of someone sharing alone', async ({ createClient }) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
|
||||||
|
const serverName = `Outside Share ${Date.now()}`;
|
||||||
|
const sharer = await createVoiceClient(createClient, 'sharer');
|
||||||
|
const observer = await createVoiceClient(createClient, 'observer');
|
||||||
|
const sharerRoom = new ChatRoomPage(sharer.page);
|
||||||
|
const observerRoom = new ChatRoomPage(observer.page);
|
||||||
|
|
||||||
|
await test.step('Both users register and join the server', async () => {
|
||||||
|
await new ServerSearchPage(sharer.page).createServer(serverName, {
|
||||||
|
description: 'Live badge visibility test'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sharer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
|
||||||
|
await new ServerSearchPage(observer.page).joinServerFromSearch(serverName);
|
||||||
|
await expect(observer.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('The sharer shares while alone in the voice channel', async () => {
|
||||||
|
await sharerRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||||
|
await sharerRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
|
await expect(sharerRoom.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await sharerRoom.startScreenShare();
|
||||||
|
await expect(sharerRoom.isScreenShareActive).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('The observer stays out of voice and still sees the badge', async () => {
|
||||||
|
await expect(observerRoom.channelsSidePanel).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
// Proves the observer never joined: the disconnect control only shows in voice.
|
||||||
|
await expect(observerRoom.disconnectButton).toBeHidden();
|
||||||
|
|
||||||
|
await expect(liveBadge(observerRoom))
|
||||||
|
.toBeVisible({ timeout: 60_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('The badge disappears when the share stops', async () => {
|
||||||
|
await sharerRoom.stopScreenShare();
|
||||||
|
|
||||||
|
await expect(liveBadge(observerRoom))
|
||||||
|
.toBeHidden({ timeout: 60_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function liveBadge(room: ChatRoomPage) {
|
||||||
|
return room.channelsSidePanel
|
||||||
|
.locator('[data-testid="voice-user-live"]')
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createVoiceClient(
|
||||||
|
createClient: () => Promise<Client>,
|
||||||
|
role: string
|
||||||
|
): Promise<Client> {
|
||||||
|
const client = await createClient();
|
||||||
|
|
||||||
|
await installDeterministicVoiceSettings(client.page);
|
||||||
|
await installWebRTCTracking(client.page);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
|
const registerPage = new RegisterPage(client.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(
|
||||||
|
`outside_share_${role}_${Date.now()}`,
|
||||||
|
`Outside Share ${role}`,
|
||||||
|
USER_PASSWORD
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { test, type Client } from '../../fixtures/multi-client';
|
||||||
|
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||||
|
import {
|
||||||
|
countCreatedPeerConnections,
|
||||||
|
expectExactlyOneInitiatorPerPair,
|
||||||
|
readPeerRoleEdges,
|
||||||
|
type PeerRoleEdge
|
||||||
|
} from '../../helpers/peer-role';
|
||||||
|
import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
|
||||||
|
import { startTestServer } from '../../helpers/test-server';
|
||||||
|
import {
|
||||||
|
installDeterministicVoiceSettings,
|
||||||
|
joinRoomFromSearch,
|
||||||
|
joinVoiceChannelUntilConnected,
|
||||||
|
openSavedRoomByName
|
||||||
|
} from '../../helpers/voice-session';
|
||||||
|
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||||
|
import {
|
||||||
|
dumpRtcDiagnostics,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
|
installWebRTCTracking,
|
||||||
|
waitForAllPeerAudioFlow,
|
||||||
|
waitForAudioStatsPresent,
|
||||||
|
waitForPeerConnected
|
||||||
|
} from '../../helpers/webrtc-helpers';
|
||||||
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
|
||||||
|
const SIGNAL_A_ID = 'e2e-cross-signal-a';
|
||||||
|
const SIGNAL_B_ID = 'e2e-cross-signal-b';
|
||||||
|
const VOICE_CHANNEL = 'General';
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
const USER_COUNT = 4;
|
||||||
|
const EXPECTED_REMOTE_PEERS = USER_COUNT - 1;
|
||||||
|
|
||||||
|
interface TestUser {
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
/** Signal server this human registered on - their home identity space. */
|
||||||
|
homeSignalId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestClient = Client & { user: TestUser };
|
||||||
|
|
||||||
|
test.describe('Cross-signal WebRTC identity', () => {
|
||||||
|
test.describe.configure({ timeout: 600_000 });
|
||||||
|
|
||||||
|
test('elects exactly one initiator per pair when peers have different home signal servers', async ({
|
||||||
|
createClient,
|
||||||
|
testServer
|
||||||
|
}) => {
|
||||||
|
const signalB = await startTestServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const suffix = `cross_signal_${Date.now()}`;
|
||||||
|
const roomName = `Cross Signal Voice ${suffix}`;
|
||||||
|
const endpoints: SeededEndpointInput[] = [
|
||||||
|
{
|
||||||
|
id: SIGNAL_A_ID,
|
||||||
|
name: 'E2E Signal A',
|
||||||
|
url: testServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SIGNAL_B_ID,
|
||||||
|
name: 'E2E Signal B',
|
||||||
|
url: signalB.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
// The room is hosted on signal B. Two humans are at home there and two are
|
||||||
|
// foreign guests, so most pairs must compare a foreign actor id against a
|
||||||
|
// foreign actor id - never a home id against one.
|
||||||
|
const users: TestUser[] = [
|
||||||
|
{ username: `host_${suffix}`, displayName: 'Cross Host', homeSignalId: SIGNAL_B_ID },
|
||||||
|
{ username: `native_${suffix}`, displayName: 'Cross Native', homeSignalId: SIGNAL_B_ID },
|
||||||
|
{ username: `guest_a_${suffix}`, displayName: 'Cross Guest A', homeSignalId: SIGNAL_A_ID },
|
||||||
|
{ username: `guest_b_${suffix}`, displayName: 'Cross Guest B', homeSignalId: SIGNAL_A_ID }
|
||||||
|
];
|
||||||
|
const clients: TestClient[] = [];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const client = await createClient();
|
||||||
|
|
||||||
|
await installTestServerEndpoints(client.context, endpoints);
|
||||||
|
await installDeterministicVoiceSettings(client.page);
|
||||||
|
await installWebRTCTracking(client.context);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
|
clients.push({ ...client, user });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [host] = clients;
|
||||||
|
|
||||||
|
await test.step('Each human registers on their own home signal server', async () => {
|
||||||
|
for (const client of clients) {
|
||||||
|
const register = new RegisterPage(client.page);
|
||||||
|
|
||||||
|
await register.goto();
|
||||||
|
await register.serverSelect.selectOption(client.user.homeSignalId);
|
||||||
|
await register.register(client.user.username, client.user.displayName, USER_PASSWORD);
|
||||||
|
await expectDashboardReady(client.page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('The host creates the voice room on signal B', async () => {
|
||||||
|
await new ServerSearchPage(host.page).createServer(roomName, {
|
||||||
|
description: 'Cross-signal initiator election coverage',
|
||||||
|
sourceId: SIGNAL_B_ID
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(host.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatRoomPage(host.page).ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Everyone else joins the room, provisioning a foreign account when needed', async () => {
|
||||||
|
for (const client of clients.slice(1)) {
|
||||||
|
await joinRoomFromSearch(client.page, roomName);
|
||||||
|
}
|
||||||
|
|
||||||
|
await openSavedRoomByName(host.page, roomName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Everyone reconnects at once, so every pair elects its initiator from the same
|
||||||
|
// roster snapshot. Staggered arrivals let one side's 1s fallback-offer timer
|
||||||
|
// serialize negotiation, which hides a wrong comparison; a reconnect storm - a
|
||||||
|
// signal blip, or a channel everyone piles into - does not.
|
||||||
|
await test.step('All four reconnect simultaneously', async () => {
|
||||||
|
await Promise.all(clients.map(async (client) => {
|
||||||
|
await client.page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await openSavedRoomByName(client.page, roomName);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('All four join the same voice channel simultaneously', async () => {
|
||||||
|
await Promise.all(clients.map((client) =>
|
||||||
|
joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL)
|
||||||
|
));
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Every pair carries bidirectional audio', async () => {
|
||||||
|
await Promise.all(clients.map((client) => waitForPeerConnected(client.page, 90_000)));
|
||||||
|
await Promise.all(clients.map((client) => waitForAudioStatsPresent(client.page, 30_000)));
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
|
await waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 120_000);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[${client.user.displayName} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Exactly one side of every pair elected itself initiator', async () => {
|
||||||
|
const edgesByClient: Record<string, PeerRoleEdge[]> = {};
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
edgesByClient[client.user.displayName] = (await readPeerRoleEdges(client.page))
|
||||||
|
.filter((edge) => edge.connectionState === 'connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparing a home id against a foreign actor id is not antisymmetric, so both
|
||||||
|
// peers could offer (glare) or neither could until a takeover timer fired.
|
||||||
|
expectExactlyOneInitiatorPerPair(edgesByClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('No peer had to be rebuilt to reach that state', async () => {
|
||||||
|
for (const client of clients) {
|
||||||
|
expect(
|
||||||
|
await countCreatedPeerConnections(client.page),
|
||||||
|
`${client.user.displayName} rebuilt a peer connection instead of connecting on the first offer`
|
||||||
|
).toBe(EXPECTED_REMOTE_PEERS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await signalB.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../../fixtures/multi-client';
|
||||||
|
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
||||||
|
import { startTestServer } from '../../helpers/test-server';
|
||||||
|
import { readSignalServerCredentialFromPage } from '../../helpers/auth-api';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for "User receiving direct call doesn't get notified":
|
||||||
|
* starting a call from the DM chat header (steps: open DM of a user, click
|
||||||
|
* call) must ring the recipient - incoming-call modal, ring audio, and a
|
||||||
|
* server-rail call entry. Includes the cross-signal topology where the callee
|
||||||
|
* is addressed by a provisioned actor id instead of their home identity.
|
||||||
|
*/
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
const PRIMARY_SIGNAL_ID = 'e2e-dm-ring-primary';
|
||||||
|
const SECONDARY_SIGNAL_ID = 'e2e-dm-ring-secondary';
|
||||||
|
|
||||||
|
test.describe('DM header call ring', () => {
|
||||||
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
|
test('callee is notified when the caller starts the call from the DM chat header', async ({ createClient }) => {
|
||||||
|
const suffix = uniqueName('dm-ring');
|
||||||
|
const serverName = `DM Ring Server ${suffix}`;
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
|
||||||
|
await installRingInstrumentation(bob.page);
|
||||||
|
|
||||||
|
await test.step('Alice and Bob register and meet in a server', async () => {
|
||||||
|
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
|
||||||
|
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
|
||||||
|
|
||||||
|
const aliceSearch = new ServerSearchPage(alice.page);
|
||||||
|
|
||||||
|
await aliceSearch.createServer(serverName, { description: 'DM header call ring regression coverage' });
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(alice.page).waitForReady();
|
||||||
|
|
||||||
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
|
await bobSearch.joinServerFromSearch(serverName);
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(bob.page).waitForReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Both users open the DM view; live DM delivery confirms the transport works', async () => {
|
||||||
|
const bobUserCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
||||||
|
|
||||||
|
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const aliceUserCard = bob.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Alice' }).first();
|
||||||
|
|
||||||
|
await expect(aliceUserCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
await aliceUserCard.getByRole('button', { name: 'Message Alice' }).click();
|
||||||
|
await expect(bob.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Mirrors the bug report: the users are in the DM view (not a server
|
||||||
|
// room) when the call starts. The message must arrive live so a broken
|
||||||
|
// ring cannot be blamed on a dead transport.
|
||||||
|
await alice.page.getByTestId('dm-input').fill(`hello before call ${suffix}`);
|
||||||
|
await alice.page.getByTestId('dm-input').press('Enter');
|
||||||
|
await expect(bob.page.locator('app-dm-chat').getByText(`hello before call ${suffix}`)).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice starts the call from the DM chat header', async () => {
|
||||||
|
const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' });
|
||||||
|
|
||||||
|
await expect(callButton).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(callButton).toBeEnabled({ timeout: 20_000 });
|
||||||
|
await callButton.click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob gets the incoming-call modal, ring audio, and rail entry', async () => {
|
||||||
|
await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await getCallAudioPlayCount(bob.page), {
|
||||||
|
timeout: 20_000,
|
||||||
|
intervals: [500, 1_000]
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callee homed on another signal server is notified when called via their provisioned actor id', async ({ createClient, testServer }) => {
|
||||||
|
const secondaryServer = await startTestServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const suffix = uniqueName('xsig-ring');
|
||||||
|
const serverName = `Cross Signal Ring ${suffix}`;
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
const endpoints = [
|
||||||
|
{
|
||||||
|
id: PRIMARY_SIGNAL_ID,
|
||||||
|
name: 'E2E Ring Signal A',
|
||||||
|
url: testServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SECONDARY_SIGNAL_ID,
|
||||||
|
name: 'E2E Ring Signal B',
|
||||||
|
url: secondaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await installTestServerEndpoints(alice.context, endpoints);
|
||||||
|
await installTestServerEndpoints(bob.context, endpoints);
|
||||||
|
await installRingInstrumentation(bob.page);
|
||||||
|
|
||||||
|
await test.step('Alice registers on the primary signal, Bob on the secondary', async () => {
|
||||||
|
const aliceRegister = new RegisterPage(alice.page);
|
||||||
|
|
||||||
|
await aliceRegister.goto();
|
||||||
|
await aliceRegister.serverSelect.selectOption(PRIMARY_SIGNAL_ID);
|
||||||
|
await aliceRegister.register(`alice_${suffix}`, 'Alice', USER_PASSWORD);
|
||||||
|
await expect(alice.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
|
||||||
|
const bobRegister = new RegisterPage(bob.page);
|
||||||
|
|
||||||
|
await bobRegister.goto();
|
||||||
|
await bobRegister.serverSelect.selectOption(SECONDARY_SIGNAL_ID);
|
||||||
|
await bobRegister.register(`bob_${suffix}`, 'Bob', USER_PASSWORD);
|
||||||
|
await expect(bob.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('They meet in a room on the primary signal; Bob gets a provisioned actor identity', async () => {
|
||||||
|
const aliceSearch = new ServerSearchPage(alice.page);
|
||||||
|
|
||||||
|
await aliceSearch.createServer(serverName, {
|
||||||
|
description: 'Cross-signal DM call ring coverage',
|
||||||
|
sourceId: PRIMARY_SIGNAL_ID
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(alice.page).waitForReady();
|
||||||
|
|
||||||
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
|
await bobSearch.joinServerFromSearch(serverName);
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(bob.page).waitForReady();
|
||||||
|
|
||||||
|
await expect.poll(async () =>
|
||||||
|
await readSignalServerCredentialFromPage(bob.page, testServer.url),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice opens the DM with Bob and calls from the DM chat header', async () => {
|
||||||
|
const bobUserCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
||||||
|
|
||||||
|
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' });
|
||||||
|
|
||||||
|
await expect(callButton).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(callButton).toBeEnabled({ timeout: 20_000 });
|
||||||
|
await callButton.click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob gets the incoming-call modal and ring audio', async () => {
|
||||||
|
await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await getCallAudioPlayCount(bob.page), {
|
||||||
|
timeout: 20_000,
|
||||||
|
intervals: [500, 1_000]
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await secondaryServer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
|
||||||
|
const registerPage = new RegisterPage(page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(username, displayName, USER_PASSWORD);
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installRingInstrumentation(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const OriginalAudio = window.Audio;
|
||||||
|
const callAudioState = { playCount: 0 };
|
||||||
|
|
||||||
|
(window as Window & { __callAudioState?: typeof callAudioState }).__callAudioState = callAudioState;
|
||||||
|
|
||||||
|
function isCallAudio(audio: HTMLAudioElement): boolean {
|
||||||
|
return audio.src.includes('/assets/audio/call.wav') || audio.src.endsWith('assets/audio/call.wav');
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as unknown as { Audio: typeof Audio }).Audio = function(this: HTMLAudioElement, src?: string) {
|
||||||
|
const audio = new OriginalAudio(src);
|
||||||
|
const originalPlay = audio.play.bind(audio);
|
||||||
|
|
||||||
|
audio.play = () => {
|
||||||
|
if (isCallAudio(audio)) {
|
||||||
|
callAudioState.playCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalPlay();
|
||||||
|
};
|
||||||
|
|
||||||
|
return audio;
|
||||||
|
} as typeof Audio;
|
||||||
|
|
||||||
|
window.Audio.prototype = OriginalAudio.prototype;
|
||||||
|
Object.setPrototypeOf(window.Audio, OriginalAudio);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCallAudioPlayCount(page: Page): Promise<number> {
|
||||||
|
return await page.evaluate(() => (window as Window & { __callAudioState?: { playCount: number } }).__callAudioState?.playCount ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test, type Client } from '../../fixtures/multi-client';
|
||||||
|
import { countCreatedPeerConnections } from '../../helpers/peer-role';
|
||||||
|
import { openSettingsDetailPage } from '../../helpers/settings-modal';
|
||||||
|
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||||
|
import {
|
||||||
|
dumpRtcDiagnostics,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
|
installWebRTCTracking,
|
||||||
|
waitForAllPeerAudioFlow,
|
||||||
|
waitForAudioStatsPresent,
|
||||||
|
waitForConnectedPeerCount,
|
||||||
|
waitForOpenDataChannelCount
|
||||||
|
} from '../../helpers/webrtc-helpers';
|
||||||
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
|
||||||
|
interface VoiceClient extends Client {
|
||||||
|
displayName: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
const VOICE_CHANNEL = 'General';
|
||||||
|
|
||||||
|
test.describe('Live audio device change', () => {
|
||||||
|
test('switching the microphone mid-call keeps both directions of audio alive', async ({ createClient }) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
|
||||||
|
const clients = await createVoicePair(createClient, `Mic Swap ${Date.now()}`);
|
||||||
|
const [alice, bob] = clients;
|
||||||
|
|
||||||
|
await assertMeshAudio(clients, 'initial two-user voice');
|
||||||
|
|
||||||
|
const connectionsBefore = {
|
||||||
|
alice: await countCreatedPeerConnections(alice.page),
|
||||||
|
bob: await countCreatedPeerConnections(bob.page)
|
||||||
|
};
|
||||||
|
const sentTracksBefore = await readOutboundAudioTrackIds(alice.page);
|
||||||
|
|
||||||
|
expect(sentTracksBefore, 'Alice should be sending audio before the switch').toHaveLength(1);
|
||||||
|
|
||||||
|
await test.step('Alice picks a different microphone from voice settings', async () => {
|
||||||
|
await openVoiceSettings(alice.page);
|
||||||
|
|
||||||
|
const alternateDeviceId = await readAlternateInputDeviceId(alice.page);
|
||||||
|
|
||||||
|
await startVoiceStateWatch(alice.page);
|
||||||
|
await alice.page.getByTestId('voice-settings-input-device').selectOption(alternateDeviceId);
|
||||||
|
|
||||||
|
// The swap re-captures the microphone; give it a moment before reading senders.
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readOutboundAudioTrackIds(alice.page))[0], { timeout: 20_000 })
|
||||||
|
.not.toBe(sentTracksBefore[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('The session was never interrupted', async () => {
|
||||||
|
const drops = await stopVoiceStateWatch(alice.page);
|
||||||
|
|
||||||
|
expect(drops, 'Alice left and rejoined voice instead of swapping the track').toBe(0);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await countCreatedPeerConnections(alice.page),
|
||||||
|
'Alice rebuilt her peer connection to change microphone'
|
||||||
|
).toBe(connectionsBefore.alice);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await countCreatedPeerConnections(bob.page),
|
||||||
|
'Bob rebuilt his peer connection because Alice changed microphone'
|
||||||
|
).toBe(connectionsBefore.bob);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Audio still flows both ways on the new microphone', async () => {
|
||||||
|
await waitForConnectedPeerCount(alice.page, 1, 30_000);
|
||||||
|
await waitForConnectedPeerCount(bob.page, 1, 30_000);
|
||||||
|
await assertMeshAudio(clients, 'after microphone switch');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching the speaker mid-call keeps remote audio playing', async ({ createClient }) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
|
||||||
|
const clients = await createVoicePair(createClient, `Speaker Swap ${Date.now()}`);
|
||||||
|
const [alice] = clients;
|
||||||
|
|
||||||
|
await assertMeshAudio(clients, 'initial two-user voice');
|
||||||
|
|
||||||
|
await openVoiceSettings(alice.page);
|
||||||
|
|
||||||
|
const alternateDeviceId = await readAlternateOutputDeviceId(alice.page);
|
||||||
|
|
||||||
|
test.skip(alternateDeviceId === null, 'This browser exposes no audio output devices');
|
||||||
|
|
||||||
|
await startVoiceStateWatch(alice.page);
|
||||||
|
await alice.page.getByTestId('voice-settings-output-device').selectOption(alternateDeviceId as string);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => readPreferredOutputDeviceId(alice.page), { timeout: 20_000 })
|
||||||
|
.toBe(alternateDeviceId === '' ? 'default' : alternateDeviceId);
|
||||||
|
|
||||||
|
expect(await stopVoiceStateWatch(alice.page), 'Changing the speaker dropped Alice out of voice').toBe(0);
|
||||||
|
|
||||||
|
await assertMeshAudio(clients, 'after speaker switch');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openVoiceSettings(page: Page): Promise<void> {
|
||||||
|
await openSettingsDetailPage(page, 'voice');
|
||||||
|
await expect(page.getByTestId('voice-settings-input-device')).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picker value to switch to: any real device, else the system-default entry. */
|
||||||
|
async function readAlternateInputDeviceId(page: Page): Promise<string> {
|
||||||
|
const select = page.getByTestId('voice-settings-input-device');
|
||||||
|
const currentValue = await select.inputValue();
|
||||||
|
const values = await select.locator('option').evaluateAll(
|
||||||
|
(options) => options.map((option) => (option as HTMLOptionElement).value)
|
||||||
|
);
|
||||||
|
const alternate = values.find((value) => value !== currentValue);
|
||||||
|
|
||||||
|
if (alternate === undefined) {
|
||||||
|
throw new Error(`The microphone picker only offers "${currentValue}", so no switch can be made`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAlternateOutputDeviceId(page: Page): Promise<string | null> {
|
||||||
|
const select = page.getByTestId('voice-settings-output-device');
|
||||||
|
const currentValue = await select.inputValue();
|
||||||
|
const values = await select.locator('option').evaluateAll(
|
||||||
|
(options) => options.map((option) => (option as HTMLOptionElement).value)
|
||||||
|
);
|
||||||
|
|
||||||
|
return values.find((value) => value !== currentValue) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The audio track ids this page is currently sending, one per peer connection. */
|
||||||
|
async function readOutboundAudioTrackIds(page: Page): Promise<(string | null)[]> {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const connections = (window as unknown as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? [];
|
||||||
|
|
||||||
|
return connections
|
||||||
|
.filter((connection) => connection.connectionState === 'connected')
|
||||||
|
.map((connection) => {
|
||||||
|
const audioSender = connection
|
||||||
|
.getSenders()
|
||||||
|
.find((sender) => sender.track?.kind === 'audio');
|
||||||
|
|
||||||
|
return audioSender?.track?.id ?? null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPreferredOutputDeviceId(page: Page): Promise<string | null> {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
interface PlaybackShape { preferredOutputDeviceId?: string }
|
||||||
|
|
||||||
|
const host = document.querySelector('app-voice-settings');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playback = debugApi.getComponent(host)['voicePlayback'] as PlaybackShape | undefined;
|
||||||
|
|
||||||
|
return playback?.preferredOutputDeviceId ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start counting moments where this client considered itself out of voice.
|
||||||
|
* A device change that tears the session down and rebuilds it registers here,
|
||||||
|
* even when the end state looks healthy again.
|
||||||
|
*/
|
||||||
|
async function startVoiceStateWatch(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
interface VoiceStateShape { isConnected?: boolean }
|
||||||
|
interface UserShape { voiceState?: VoiceStateShape }
|
||||||
|
|
||||||
|
const watchWindow = window as unknown as { __voiceDrops?: number; __voiceWatch?: number };
|
||||||
|
|
||||||
|
watchWindow.__voiceDrops = 0;
|
||||||
|
watchWindow.__voiceWatch = window.setInterval(() => {
|
||||||
|
const host = document.querySelector('app-rooms-side-panel');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = debugApi.getComponent(host);
|
||||||
|
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
|
||||||
|
|
||||||
|
if (currentUser?.voiceState?.isConnected === false) {
|
||||||
|
watchWindow.__voiceDrops = (watchWindow.__voiceDrops ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopVoiceStateWatch(page: Page): Promise<number> {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const watchWindow = window as unknown as { __voiceDrops?: number; __voiceWatch?: number };
|
||||||
|
|
||||||
|
if (watchWindow.__voiceWatch !== undefined) {
|
||||||
|
window.clearInterval(watchWindow.__voiceWatch);
|
||||||
|
watchWindow.__voiceWatch = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return watchWindow.__voiceDrops ?? 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createVoicePair(
|
||||||
|
createClient: () => Promise<Client>,
|
||||||
|
serverName: string
|
||||||
|
): Promise<VoiceClient[]> {
|
||||||
|
const clients: VoiceClient[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < 2; index++) {
|
||||||
|
const client = await createClient();
|
||||||
|
|
||||||
|
await installDeterministicVoiceSettings(client.page);
|
||||||
|
await installWebRTCTracking(client.page);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
|
clients.push({
|
||||||
|
...client,
|
||||||
|
displayName: `Device Voice ${index + 1}`,
|
||||||
|
username: `device_voice_${Date.now()}_${index + 1}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await test.step('Register both clients', async () => {
|
||||||
|
for (const client of clients) {
|
||||||
|
const registerPage = new RegisterPage(client.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(client.username, client.displayName, USER_PASSWORD);
|
||||||
|
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Create and join the server', async () => {
|
||||||
|
await new ServerSearchPage(clients[0].page).createServer(serverName, {
|
||||||
|
description: 'Live audio device change test'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
|
||||||
|
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
|
||||||
|
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Join both clients to voice', async () => {
|
||||||
|
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
const room = new ChatRoomPage(client.page);
|
||||||
|
|
||||||
|
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
|
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await waitForConnectedPeerCount(client.page, 1, 90_000);
|
||||||
|
await waitForOpenDataChannelCount(client.page, 1, 90_000);
|
||||||
|
await waitForAudioStatsPresent(client.page, 30_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertMeshAudio(clients: readonly VoiceClient[], label: string): Promise<void> {
|
||||||
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
|
await waitForAllPeerAudioFlow(client.page, 1, 60_000);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[${client.displayName} ${label} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../../fixtures/multi-client';
|
||||||
|
import { countCreatedPeerConnections } from '../../helpers/peer-role';
|
||||||
|
import { createVoicePairInNewServer, type VoicePairClient } from '../../helpers/voice-session';
|
||||||
|
import {
|
||||||
|
dumpRtcDiagnostics,
|
||||||
|
getOpenDataChannelCount,
|
||||||
|
getPerPeerAudioStats
|
||||||
|
} from '../../helpers/webrtc-helpers';
|
||||||
|
|
||||||
|
type PeerAudioStats = Awaited<ReturnType<typeof getPerPeerAudioStats>>;
|
||||||
|
|
||||||
|
/** Override for a quick check or a longer leak hunt: `SOAK_MINUTES=2 npx playwright test ...`. */
|
||||||
|
const SOAK_MINUTES = Number(process.env['SOAK_MINUTES'] ?? 30);
|
||||||
|
const SAMPLE_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
interface ResourceSnapshot {
|
||||||
|
audioElements: number;
|
||||||
|
heapMb: number;
|
||||||
|
remoteTracks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A long call must not accumulate anything. Structural counters are the honest leak
|
||||||
|
* signal here - a churning recovery loop shows up as extra remote tracks or audio
|
||||||
|
* elements long before heap bytes say anything conclusive.
|
||||||
|
*/
|
||||||
|
async function readResources(page: Page): Promise<ResourceSnapshot> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
interface HeapCapablePerformance extends Performance {
|
||||||
|
memory?: { usedJSHeapSize: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedHeap = (performance as HeapCapablePerformance).memory?.usedJSHeapSize ?? 0;
|
||||||
|
const remoteTracks = (window as unknown as { __rtcRemoteTracks?: unknown[] }).__rtcRemoteTracks ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioElements: document.querySelectorAll('audio').length,
|
||||||
|
heapMb: Math.round(usedHeap / (1_024 * 1_024)),
|
||||||
|
remoteTracks: remoteTracks.length
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeStats(stats: PeerAudioStats): string {
|
||||||
|
return stats
|
||||||
|
.map((stat) => `${stat.connectionState} in=${stat.inboundPackets} out=${stat.outboundPackets}`)
|
||||||
|
.join(' | ') || 'no peers';
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Long voice session', () => {
|
||||||
|
test(`carries audio for ${SOAK_MINUTES} minutes without stalling, rebuilding, or accumulating`, async ({
|
||||||
|
createClient
|
||||||
|
}) => {
|
||||||
|
const soakMs = SOAK_MINUTES * 60_000;
|
||||||
|
|
||||||
|
test.setTimeout(soakMs + 300_000);
|
||||||
|
|
||||||
|
const clients = await createVoicePairInNewServer(createClient, `Voice Soak ${Date.now()}`, {
|
||||||
|
namePrefix: 'Soak Voice'
|
||||||
|
});
|
||||||
|
const baselineConnections = await Promise.all(clients.map((client) => countCreatedPeerConnections(client.page)));
|
||||||
|
|
||||||
|
expect(baselineConnections, 'each client should start with exactly one peer connection').toEqual([1, 1]);
|
||||||
|
|
||||||
|
const baselineResources = await Promise.all(clients.map((client) => readResources(client.page)));
|
||||||
|
const previousStats: PeerAudioStats[] = await Promise.all(
|
||||||
|
clients.map((client) => getPerPeerAudioStats(client.page))
|
||||||
|
);
|
||||||
|
const deadline = Date.now() + soakMs;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
let sampleIndex = 0;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await clients[0].page.waitForTimeout(SAMPLE_INTERVAL_MS);
|
||||||
|
sampleIndex++;
|
||||||
|
|
||||||
|
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1_000);
|
||||||
|
|
||||||
|
for (let index = 0; index < clients.length; index++) {
|
||||||
|
const client = clients[index];
|
||||||
|
|
||||||
|
await assertClientStillHealthy(client, previousStats[index], elapsedSeconds);
|
||||||
|
previousStats[index] = await getPerPeerAudioStats(client.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = await Promise.all(clients.map((current) => readResources(current.page)));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[soak] sample ${sampleIndex} at +${elapsedSeconds}s: `
|
||||||
|
+ clients
|
||||||
|
.map((client, index) => `${client.displayName} heap=${resources[index].heapMb}MB`
|
||||||
|
+ ` audio=${resources[index].audioElements} tracks=${resources[index].remoteTracks}`)
|
||||||
|
.join(', ')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await test.step('Nothing accumulated over the session', async () => {
|
||||||
|
const finalResources = await Promise.all(clients.map((client) => readResources(client.page)));
|
||||||
|
|
||||||
|
for (let index = 0; index < clients.length; index++) {
|
||||||
|
const baseline = baselineResources[index];
|
||||||
|
const final = finalResources[index];
|
||||||
|
const label = clients[index].displayName;
|
||||||
|
|
||||||
|
// A stable call fires `track` once per remote track; repeats mean the media path
|
||||||
|
// was torn down and rebuilt behind the assertions above.
|
||||||
|
expect(final.remoteTracks, `${label} gained remote tracks during the soak`).toBe(baseline.remoteTracks);
|
||||||
|
expect(final.audioElements, `${label} accumulated audio elements`).toBeLessThanOrEqual(baseline.audioElements + 1);
|
||||||
|
expect(
|
||||||
|
final.heapMb,
|
||||||
|
`${label} heap grew from ${baseline.heapMb}MB to ${final.heapMb}MB`
|
||||||
|
).toBeLessThan(baseline.heapMb * 3 + 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function assertClientStillHealthy(
|
||||||
|
client: VoicePairClient,
|
||||||
|
previous: PeerAudioStats,
|
||||||
|
elapsedSeconds: number
|
||||||
|
): Promise<void> {
|
||||||
|
const label = `${client.displayName} at +${elapsedSeconds}s`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const current = await getPerPeerAudioStats(client.page);
|
||||||
|
const connected = current.filter((stat) => stat.connectionState === 'connected');
|
||||||
|
|
||||||
|
expect(connected, `${label}: expected exactly one connected peer, saw ${describeStats(current)}`).toHaveLength(1);
|
||||||
|
|
||||||
|
const before = previous[0];
|
||||||
|
const now = current[0];
|
||||||
|
|
||||||
|
expect(now.inboundPackets, `${label}: inbound audio stalled`).toBeGreaterThan(before.inboundPackets);
|
||||||
|
expect(now.outboundPackets, `${label}: outbound audio stalled`).toBeGreaterThan(before.outboundPackets);
|
||||||
|
|
||||||
|
// A rebuild would restore audio within a sample or two, so the flow assertions above
|
||||||
|
// cannot see it. Only the creation count can.
|
||||||
|
expect(
|
||||||
|
await countCreatedPeerConnections(client.page),
|
||||||
|
`${label}: the peer connection was rebuilt mid-call`
|
||||||
|
).toBe(1);
|
||||||
|
|
||||||
|
expect(await getOpenDataChannelCount(client.page), `${label}: the control channel is not open`).toBe(1);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[soak] ${label} diagnostics:\n${await dumpRtcDiagnostics(client.page)}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
dumpRtcDiagnostics,
|
dumpRtcDiagnostics,
|
||||||
getConnectedPeerCount,
|
getConnectedPeerCount,
|
||||||
installWebRTCTracking,
|
installWebRTCTracking,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
waitForAllPeerAudioFlow,
|
waitForAllPeerAudioFlow,
|
||||||
waitForAudioStatsPresent,
|
waitForAudioStatsPresent,
|
||||||
waitForConnectedPeerCount,
|
|
||||||
waitForPeerConnected
|
waitForPeerConnected
|
||||||
} from '../../helpers/webrtc-helpers';
|
} from '../../helpers/webrtc-helpers';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||||
|
import { getMinimumConnectedPeerMeshCount, waitForConnectedRemotePeerMesh } from '../../helpers/signal-manager';
|
||||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
|
|
||||||
// ── Signal endpoint identifiers ──────────────────────────────────────
|
// ── Signal endpoint identifiers ──────────────────────────────────────
|
||||||
@@ -132,7 +134,8 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
await installTestServerEndpoints(client.context, groupEndpoints);
|
await installTestServerEndpoints(client.context, groupEndpoints);
|
||||||
await installDeterministicVoiceSettings(client.page);
|
await installDeterministicVoiceSettings(client.page);
|
||||||
await installWebRTCTracking(client.page);
|
await installWebRTCTracking(client.context);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
clients.push({ ...client, user });
|
clients.push({ ...client, user });
|
||||||
}
|
}
|
||||||
@@ -300,8 +303,11 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||||
|
await client.page.waitForTimeout(2_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clients[0].page.waitForTimeout(10_000);
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
}
|
}
|
||||||
@@ -310,11 +316,11 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
// ── Audio mesh ──────────────────────────────────────────────
|
// ── Audio mesh ──────────────────────────────────────────────
|
||||||
await test.step('All users discover peers and audio flows pairwise', async () => {
|
await test.step('All users discover peers and audio flows pairwise', async () => {
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForPeerConnected(client.page, 45_000)
|
waitForPeerConnected(client.page, 90_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
@@ -324,7 +330,7 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
await clients[0].page.waitForTimeout(5_000);
|
await clients[0].page.waitForTimeout(5_000);
|
||||||
|
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,7 +341,6 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
await openVoiceWorkspace(client.page);
|
await openVoiceWorkspace(client.page);
|
||||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -372,18 +377,28 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
for (const client of stayers) {
|
for (const client of stayers) {
|
||||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
await expect.poll(async () => {
|
||||||
|
const actual = await getConnectedPeerCount(client.page);
|
||||||
|
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
|
||||||
|
|
||||||
|
return actual >= minimum;
|
||||||
|
}, {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
intervals: [500, 1_000]
|
intervals: [500, 1_000]
|
||||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
}).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check chatters still have voice peers even while viewing another room
|
// Check chatters still have voice peers even while viewing another room
|
||||||
for (const chatter of chatters) {
|
for (const chatter of chatters) {
|
||||||
await expect.poll(async () => await getConnectedPeerCount(chatter.page), {
|
await expect.poll(async () => {
|
||||||
|
const actual = await getConnectedPeerCount(chatter.page);
|
||||||
|
const minimum = await getMinimumConnectedPeerMeshCount(chatter.page, EXPECTED_REMOTE_PEERS);
|
||||||
|
|
||||||
|
return actual >= minimum;
|
||||||
|
}, {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
intervals: [500, 1_000]
|
intervals: [500, 1_000]
|
||||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
}).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Date.now() < deadline) {
|
if (Date.now() < deadline) {
|
||||||
@@ -749,63 +764,6 @@ async function waitForLocalVoiceChannelConnection(page: Page, channelName: strin
|
|||||||
|
|
||||||
// ── Roster / state helpers ───────────────────────────────────────────
|
// ── Roster / state helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
(count) => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-voice-workspace');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
|
|
||||||
|
|
||||||
return connectedUsers.length === count;
|
|
||||||
},
|
|
||||||
expectedCount,
|
|
||||||
{ timeout: 45_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ expected, name }) => {
|
|
||||||
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
|
|
||||||
interface RoomShape { channels?: ChannelShape[] }
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
|
||||||
const channelId = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name)?.id;
|
|
||||||
|
|
||||||
if (!channelId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
|
|
||||||
|
|
||||||
return roster.length === expected;
|
|
||||||
},
|
|
||||||
{ expected: expectedCount, name: channelName },
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceStateAcrossPages(
|
async function waitForVoiceStateAcrossPages(
|
||||||
clients: readonly TestClient[],
|
clients: readonly TestClient[],
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
|||||||
@@ -6,14 +6,21 @@ import {
|
|||||||
dumpRtcDiagnostics,
|
dumpRtcDiagnostics,
|
||||||
getConnectedPeerCount,
|
getConnectedPeerCount,
|
||||||
installWebRTCTracking,
|
installWebRTCTracking,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
waitForAllPeerAudioFlow,
|
waitForAllPeerAudioFlow,
|
||||||
waitForAudioStatsPresent,
|
waitForAudioStatsPresent,
|
||||||
waitForConnectedPeerCount,
|
|
||||||
waitForPeerConnected
|
waitForPeerConnected
|
||||||
} from '../../helpers/webrtc-helpers';
|
} from '../../helpers/webrtc-helpers';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||||
|
import {
|
||||||
|
getConnectedSignalManagerCount,
|
||||||
|
getMinimumConnectedPeerMeshCount,
|
||||||
|
waitForConnectedRemotePeerMesh,
|
||||||
|
waitForConnectedSignalManagerCount
|
||||||
|
} from '../../helpers/signal-manager';
|
||||||
|
|
||||||
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
|
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
|
||||||
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
||||||
@@ -116,8 +123,11 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||||
|
await client.page.waitForTimeout(2_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clients[0].page.waitForTimeout(10_000);
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
}
|
}
|
||||||
@@ -126,12 +136,12 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
await test.step('All users discover all peers and audio flows pairwise', async () => {
|
await test.step('All users discover all peers and audio flows pairwise', async () => {
|
||||||
// Wait for all clients to have at least one connected peer (fast)
|
// Wait for all clients to have at least one connected peer (fast)
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForPeerConnected(client.page, 45_000)
|
waitForPeerConnected(client.page, 90_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Wait for all clients to have all 7 peers connected
|
// Wait for all clients to have all 7 peers connected
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Wait for audio stats to appear on all clients
|
// Wait for audio stats to appear on all clients
|
||||||
@@ -146,7 +156,7 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
// Check bidirectional audio flow on each client
|
// Check bidirectional audio flow on each client
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +166,6 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
await openVoiceWorkspace(client.page);
|
await openVoiceWorkspace(client.page);
|
||||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
await waitForConnectedSignalManagerCount(client.page, 2);
|
await waitForConnectedSignalManagerCount(client.page, 2);
|
||||||
}
|
}
|
||||||
@@ -167,10 +176,15 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
await expect.poll(async () => {
|
||||||
|
const actual = await getConnectedPeerCount(client.page);
|
||||||
|
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
|
||||||
|
|
||||||
|
return actual >= minimum;
|
||||||
|
}, {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
intervals: [500, 1_000]
|
intervals: [500, 1_000]
|
||||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
}).toBe(true);
|
||||||
|
|
||||||
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
@@ -292,7 +306,8 @@ async function createTrackedClients(
|
|||||||
|
|
||||||
await installTestServerEndpoints(client.context, endpoints);
|
await installTestServerEndpoints(client.context, endpoints);
|
||||||
await installDeterministicVoiceSettings(client.page);
|
await installDeterministicVoiceSettings(client.page);
|
||||||
await installWebRTCTracking(client.page);
|
await installWebRTCTracking(client.context);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
clients.push({
|
clients.push({
|
||||||
...client,
|
...client,
|
||||||
@@ -576,124 +591,6 @@ async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise
|
|||||||
}, channelName);
|
}, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForConnectedSignalManagerCount(page: Page, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
(count) => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const realtime = component['realtime'] as {
|
|
||||||
signalingTransportHandler?: {
|
|
||||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
|
||||||
};
|
|
||||||
} | undefined;
|
|
||||||
const countValue = realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
|
||||||
|
|
||||||
return countValue === count;
|
|
||||||
},
|
|
||||||
expectedCount,
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getConnectedSignalManagerCount(page: Page): Promise<number> {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const realtime = component['realtime'] as {
|
|
||||||
signalingTransportHandler?: {
|
|
||||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
|
||||||
};
|
|
||||||
} | undefined;
|
|
||||||
|
|
||||||
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
(count) => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-voice-workspace');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
|
|
||||||
|
|
||||||
return connectedUsers.length === count;
|
|
||||||
},
|
|
||||||
expectedCount,
|
|
||||||
{ timeout: 45_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ expected, name }) => {
|
|
||||||
interface ChannelShape {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: 'text' | 'voice';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoomShape {
|
|
||||||
channels?: ChannelShape[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
|
||||||
const channelId = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name)?.id;
|
|
||||||
|
|
||||||
if (!channelId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
|
|
||||||
|
|
||||||
return roster.length === expected;
|
|
||||||
},
|
|
||||||
{ expected: expectedCount, name: channelName },
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceStateAcrossPages(
|
async function waitForVoiceStateAcrossPages(
|
||||||
clients: readonly TestClient[],
|
clients: readonly TestClient[],
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test, type Client } from '../../fixtures/multi-client';
|
||||||
|
import { countCreatedPeerConnections } from '../../helpers/peer-role';
|
||||||
|
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||||
|
import {
|
||||||
|
closeOpenDataChannels,
|
||||||
|
dumpRtcDiagnostics,
|
||||||
|
getOpenDataChannelCount,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
|
installWebRTCTracking,
|
||||||
|
waitForAllPeerAudioFlow,
|
||||||
|
waitForAudioStatsPresent,
|
||||||
|
waitForConnectedPeerCount,
|
||||||
|
waitForOpenDataChannelCount
|
||||||
|
} from '../../helpers/webrtc-helpers';
|
||||||
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
|
||||||
|
interface VoiceClient extends Client {
|
||||||
|
displayName: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
const VOICE_CHANNEL = 'General';
|
||||||
|
/** 12 reconnect attempts at 5s - the whole budget fits inside this outage. */
|
||||||
|
const OUTAGE_HOLD_MS = 70_000;
|
||||||
|
|
||||||
|
test.describe('Recovery preserves live media', () => {
|
||||||
|
test('replaces a dead control channel without rebuilding the peer connection', async ({ createClient }) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
|
||||||
|
const clients = await createVoicePair(createClient, `DC Soft Replace ${Date.now()}`);
|
||||||
|
const [alice, bob] = clients;
|
||||||
|
|
||||||
|
await assertMeshAudio(clients, 'initial two-user voice');
|
||||||
|
|
||||||
|
const connectionsBefore = {
|
||||||
|
alice: await countCreatedPeerConnections(alice.page),
|
||||||
|
bob: await countCreatedPeerConnections(bob.page)
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(connectionsBefore.alice).toBe(1);
|
||||||
|
expect(connectionsBefore.bob).toBe(1);
|
||||||
|
|
||||||
|
await test.step('The control channel is replaced on the same connection', async () => {
|
||||||
|
const closed = await closeOpenDataChannels(alice.page);
|
||||||
|
|
||||||
|
expect(closed).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await waitForOpenDataChannelCount(alice.page, 1, 60_000);
|
||||||
|
await waitForOpenDataChannelCount(bob.page, 1, 60_000);
|
||||||
|
|
||||||
|
// A rebuild would construct a second RTCPeerConnection on both sides, taking voice,
|
||||||
|
// camera, and screen share down with the control channel.
|
||||||
|
expect(
|
||||||
|
await countCreatedPeerConnections(alice.page),
|
||||||
|
'Alice rebuilt her peer connection instead of replacing the control channel'
|
||||||
|
).toBe(connectionsBefore.alice);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await countCreatedPeerConnections(bob.page),
|
||||||
|
'Bob rebuilt his peer connection instead of adopting the replacement control channel'
|
||||||
|
).toBe(connectionsBefore.bob);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Audio never had to be renegotiated', async () => {
|
||||||
|
await waitForConnectedPeerCount(alice.page, 1, 30_000);
|
||||||
|
await waitForConnectedPeerCount(bob.page, 1, 30_000);
|
||||||
|
await assertMeshAudio(clients, 'after control-channel replacement');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// This covers the user-visible half: an outage that outlives the 12-attempt reconnect
|
||||||
|
// budget must not end the call. It cannot isolate the attempt accounting, because the
|
||||||
|
// roster resync on signaling reconnect re-peers anyway - `peer-recovery.spec.ts` owns
|
||||||
|
// the deterministic proof that a deferred attempt costs nothing.
|
||||||
|
test('keeps voice alive across a signal outage longer than the reconnect budget', async ({
|
||||||
|
createClient,
|
||||||
|
testServer
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(480_000);
|
||||||
|
|
||||||
|
const clients = await createVoicePair(createClient, `Signal Outage Voice ${Date.now()}`);
|
||||||
|
|
||||||
|
await assertMeshAudio(clients, 'initial two-user voice');
|
||||||
|
|
||||||
|
const connectionsBefore = await Promise.all(clients.map((client) => countCreatedPeerConnections(client.page)));
|
||||||
|
|
||||||
|
expect(connectionsBefore).toEqual([1, 1]);
|
||||||
|
|
||||||
|
await test.step('The signal server goes away for longer than the reconnect budget', async () => {
|
||||||
|
await testServer.kill();
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await waitForSignalingConnected(client.page, false, 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await clients[0].page.waitForTimeout(OUTAGE_HOLD_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Peer media is unaffected by the signaling outage', async () => {
|
||||||
|
await assertMeshAudio(clients, 'during signal outage');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Voice is still healthy once signaling returns', async () => {
|
||||||
|
await testServer.start();
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await waitForSignalingConnected(client.page, true, 120_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await waitForConnectedPeerCount(client.page, 1, 90_000);
|
||||||
|
await waitForOpenDataChannelCount(client.page, 1, 90_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertMeshAudio(clients, 'after signaling recovery');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('The call was never rebuilt behind the user back', async () => {
|
||||||
|
// Media never depended on the signal server, so the roster resync must adopt the
|
||||||
|
// living peer connection. Rebuilding it would drop audio for a beat and reset
|
||||||
|
// screen share - invisible to the assertions above, which only re-check the end state.
|
||||||
|
const connectionsAfter = await Promise.all(clients.map((client) => countCreatedPeerConnections(client.page)));
|
||||||
|
|
||||||
|
expect(connectionsAfter, 'a client rebuilt its peer connection when signaling came back').toEqual(
|
||||||
|
connectionsBefore
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createVoicePair(
|
||||||
|
createClient: () => Promise<Client>,
|
||||||
|
serverName: string
|
||||||
|
): Promise<VoiceClient[]> {
|
||||||
|
const clients: VoiceClient[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < 2; index++) {
|
||||||
|
const client = await createClient();
|
||||||
|
|
||||||
|
await installDeterministicVoiceSettings(client.page);
|
||||||
|
await installWebRTCTracking(client.page);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
|
clients.push({
|
||||||
|
...client,
|
||||||
|
displayName: `Recovery Voice ${index + 1}`,
|
||||||
|
username: `recovery_voice_${Date.now()}_${index + 1}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await test.step('Register both clients', async () => {
|
||||||
|
for (const client of clients) {
|
||||||
|
const registerPage = new RegisterPage(client.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(client.username, client.displayName, USER_PASSWORD);
|
||||||
|
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Create and join the server', async () => {
|
||||||
|
await new ServerSearchPage(clients[0].page).createServer(serverName, {
|
||||||
|
description: 'Recovery keeps live media test'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
|
||||||
|
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
|
||||||
|
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Join both clients to voice', async () => {
|
||||||
|
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
const room = new ChatRoomPage(client.page);
|
||||||
|
|
||||||
|
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
|
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await waitForConnectedPeerCount(client.page, 1, 90_000);
|
||||||
|
await waitForOpenDataChannelCount(client.page, 1, 90_000);
|
||||||
|
await waitForAudioStatsPresent(client.page, 30_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertMeshAudio(clients: readonly VoiceClient[], label: string): Promise<void> {
|
||||||
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
|
await waitForAllPeerAudioFlow(client.page, 1, 60_000);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[${client.displayName} ${label} data channels] ${await getOpenDataChannelCount(client.page)}`);
|
||||||
|
console.log(`[${client.displayName} ${label} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait until the client's own view of its signaling connection matches `connected`. */
|
||||||
|
async function waitForSignalingConnected(page: Page, connected: boolean, timeout: number): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expected) => {
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.querySelector('app-rooms-side-panel');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realtime = debugApi.getComponent(host)['realtime'] as { isConnected?: () => boolean } | undefined;
|
||||||
|
|
||||||
|
return realtime?.isConnected?.() === expected;
|
||||||
|
},
|
||||||
|
connected,
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { type Page } from '@playwright/test';
|
||||||
|
import { test } from '../../fixtures/multi-client';
|
||||||
|
import { createVoicePairInNewServer, type VoicePairClient } from '../../helpers/voice-session';
|
||||||
|
import {
|
||||||
|
dumpRtcDiagnostics,
|
||||||
|
getAudioStatsDelta,
|
||||||
|
waitForConnectedPeerCount,
|
||||||
|
waitForOpenDataChannelCount
|
||||||
|
} from '../../helpers/webrtc-helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signal server pings every 30s and gives up on a socket 45s after the last pong,
|
||||||
|
* so it needs up to 75s to declare a client dead and broadcast `user_left`.
|
||||||
|
*/
|
||||||
|
const DEAD_SOCKET_HOLD_MS = 95_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outgoing voice used to be gated on the observer's roster copy of the remote user's
|
||||||
|
* voice state, which is signaling gossip. The signal server broadcasts `user_left` for
|
||||||
|
* any socket it declares dead, so a sleeping laptop, a flaky wifi hop, or a dropped
|
||||||
|
* socket wiped that copy - and the observer cut its microphone to a peer that never
|
||||||
|
* left the channel.
|
||||||
|
*/
|
||||||
|
test.describe('Losing a peer from the roster must not silence the call', () => {
|
||||||
|
// The roster wipe is injected directly, because reproducing it through a real outage
|
||||||
|
// depends on whether the observer notices the dead transport before `user_left`
|
||||||
|
// arrives - the reducer keeps the voice state while a live peer transport exists.
|
||||||
|
test('keeps sending to a peer the roster forgot', async ({ createClient }) => {
|
||||||
|
test.setTimeout(300_000);
|
||||||
|
|
||||||
|
const clients = await createVoicePairInNewServer(
|
||||||
|
createClient,
|
||||||
|
`Roster Wipe Voice ${Date.now()}`,
|
||||||
|
{ namePrefix: 'Roster Wipe' }
|
||||||
|
);
|
||||||
|
const [peer, observer] = clients;
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await assertTwoWayAudio(client, 'before the roster wipe');
|
||||||
|
}
|
||||||
|
|
||||||
|
await test.step('The observer is told the peer left the server', async () => {
|
||||||
|
const wipedUserId = await wipeRemoteVoiceMembersFromRoster(observer.page);
|
||||||
|
|
||||||
|
test.info().annotations.push({ type: 'wiped user', description: wipedUserId });
|
||||||
|
await waitForNoRemoteVoiceMembersInRoster(observer.page, 15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing about the media plane changed, so the peer must not lose a single second of
|
||||||
|
// audio. Checking only the end state would hide the cut: the peer keeps sending voice
|
||||||
|
// heartbeats, so the roster heals itself moments later.
|
||||||
|
await test.step('The peer never stops receiving the observer microphone', async () => {
|
||||||
|
await assertUninterruptedInboundAudio(peer, 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sleep/wake shape without a suspend: one client loses its signal socket long
|
||||||
|
* enough for the server to declare it dead, and its peer connections die with it. When
|
||||||
|
* everything returns the peer re-identifies with no voice state attached, so asking the
|
||||||
|
* peer over the rebuilt data channel is the only thing that can confirm it is still in
|
||||||
|
* our channel.
|
||||||
|
*
|
||||||
|
* `recovery-preserves-media.spec.ts` cannot reach this: killing the server leaves
|
||||||
|
* nobody to broadcast `user_left`.
|
||||||
|
*/
|
||||||
|
test('restores two-way voice after the server declares one client dead', async ({ createClient }) => {
|
||||||
|
test.setTimeout(600_000);
|
||||||
|
|
||||||
|
const clients = await createVoicePairInNewServer(
|
||||||
|
createClient,
|
||||||
|
`Roster Loss Voice ${Date.now()}`,
|
||||||
|
{ namePrefix: 'Roster Loss' }
|
||||||
|
);
|
||||||
|
const [droppedClient, observer] = clients;
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await assertTwoWayAudio(client, 'before the outage');
|
||||||
|
}
|
||||||
|
|
||||||
|
await test.step('One client loses its signal socket and its peer connections', async () => {
|
||||||
|
await droppedClient.context.setOffline(true);
|
||||||
|
await closeTrackedPeerConnections(droppedClient.page);
|
||||||
|
await observer.page.waitForTimeout(DEAD_SOCKET_HOLD_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Both clients are two-way again once the socket returns', async () => {
|
||||||
|
await droppedClient.context.setOffline(false);
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await waitForConnectedPeerCount(client.page, 1, 180_000);
|
||||||
|
await waitForOpenDataChannelCount(client.page, 1, 180_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await assertTwoWayAudio(client, 'after the socket returned', 90_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Fail unless the client both sends and receives voice packets within the timeout. */
|
||||||
|
async function assertTwoWayAudio(
|
||||||
|
client: VoicePairClient,
|
||||||
|
label: string,
|
||||||
|
timeoutMs = 60_000
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
let outboundPacketsDelta = 0;
|
||||||
|
let inboundPacketsDelta = 0;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
({ outboundPacketsDelta, inboundPacketsDelta } = await getAudioStatsDelta(client.page, 3_000));
|
||||||
|
|
||||||
|
if (outboundPacketsDelta > 0 && inboundPacketsDelta > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`${client.displayName} is not two-way ${label}: sent ${outboundPacketsDelta}, `
|
||||||
|
+ `received ${inboundPacketsDelta} packets in the last sample.\n`
|
||||||
|
+ await dumpRtcDiagnostics(client.page)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail if the client goes even one second without receiving voice packets. Peers gossip
|
||||||
|
* their voice state every 5s, so a torn-down microphone comes back on its own - only a
|
||||||
|
* continuous sample can tell that the audio never stopped.
|
||||||
|
*/
|
||||||
|
async function assertUninterruptedInboundAudio(
|
||||||
|
client: VoicePairClient,
|
||||||
|
seconds: number
|
||||||
|
): Promise<void> {
|
||||||
|
for (let sample = 1; sample <= seconds; sample++) {
|
||||||
|
const { inboundPacketsDelta } = await getAudioStatsDelta(client.page, 1_000);
|
||||||
|
|
||||||
|
if (inboundPacketsDelta === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${client.displayName} stopped receiving voice ${sample}s after the roster wipe.\n`
|
||||||
|
+ await dumpRtcDiagnostics(client.page)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kill the media plane the way a suspend does, leaving the peer to notice on its own. */
|
||||||
|
async function closeTrackedPeerConnections(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const connections = (window as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? [];
|
||||||
|
|
||||||
|
for (const connection of connections) {
|
||||||
|
connection.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay what the signal server does when it declares a socket dead: tell this client the
|
||||||
|
* remote user left the server, with no live transport recorded. Returns the wiped user id.
|
||||||
|
*/
|
||||||
|
async function wipeRemoteVoiceMembersFromRoster(page: Page): Promise<string> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
interface RosterUser {
|
||||||
|
id?: string;
|
||||||
|
oderId?: string;
|
||||||
|
peerId?: string;
|
||||||
|
voiceState?: { isConnected?: boolean };
|
||||||
|
}
|
||||||
|
interface StoreLike {
|
||||||
|
dispatch: (action: { type: string } & Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.querySelector('app-rooms-side-panel');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
throw new Error('Angular debug API is unavailable, cannot reach the store');
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = debugApi.getComponent(host);
|
||||||
|
const store = component['store'] as StoreLike | undefined;
|
||||||
|
const users = (component['onlineUsers'] as (() => RosterUser[]) | undefined)?.() ?? [];
|
||||||
|
const currentUser = (component['currentUser'] as (() => RosterUser | null) | undefined)?.() ?? null;
|
||||||
|
const currentRoom = (component['currentRoom'] as (() => { id?: string } | null) | undefined)?.() ?? null;
|
||||||
|
const remoteVoiceUser = users.find((user) =>
|
||||||
|
user.voiceState?.isConnected === true
|
||||||
|
&& user.id !== currentUser?.id
|
||||||
|
&& user.oderId !== currentUser?.oderId);
|
||||||
|
|
||||||
|
if (!store || !remoteVoiceUser?.id || !currentRoom?.id) {
|
||||||
|
throw new Error('No remote voice member to wipe from the roster');
|
||||||
|
}
|
||||||
|
|
||||||
|
store.dispatch({
|
||||||
|
type: '[Users] User Left',
|
||||||
|
userId: remoteVoiceUser.id,
|
||||||
|
serverId: currentRoom.id,
|
||||||
|
connectedPeerIds: []
|
||||||
|
});
|
||||||
|
|
||||||
|
return remoteVoiceUser.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait until no remote user in the client's roster claims to be in voice. */
|
||||||
|
async function waitForNoRemoteVoiceMembersInRoster(page: Page, timeout: number): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
interface RosterUser {
|
||||||
|
id?: string;
|
||||||
|
oderId?: string;
|
||||||
|
peerId?: string;
|
||||||
|
voiceState?: { isConnected?: boolean };
|
||||||
|
}
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.querySelector('app-rooms-side-panel');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = debugApi.getComponent(host);
|
||||||
|
const users = (component['onlineUsers'] as (() => RosterUser[]) | undefined)?.() ?? [];
|
||||||
|
const currentUser = (component['currentUser'] as (() => RosterUser | null) | undefined)?.() ?? null;
|
||||||
|
const selfIds = new Set([
|
||||||
|
currentUser?.id,
|
||||||
|
currentUser?.oderId,
|
||||||
|
currentUser?.peerId
|
||||||
|
].filter(Boolean));
|
||||||
|
|
||||||
|
return users
|
||||||
|
.filter((user) => ![
|
||||||
|
user.id,
|
||||||
|
user.oderId,
|
||||||
|
user.peerId
|
||||||
|
].some((id) => !!id && selfIds.has(id)))
|
||||||
|
.every((user) => user.voiceState?.isConnected !== true);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { test, type Client } from '../../fixtures/multi-client';
|
||||||
|
import {
|
||||||
|
forceRelayOnlyIce,
|
||||||
|
getRelayIceConfigs,
|
||||||
|
seedTurnOnlyIceServers,
|
||||||
|
waitForRelayedCandidatePairs,
|
||||||
|
type TurnCredentials
|
||||||
|
} from '../../helpers/turn-relay';
|
||||||
|
import {
|
||||||
|
isDockerAvailable,
|
||||||
|
startTurnServer,
|
||||||
|
type TurnServerHandle
|
||||||
|
} from '../../helpers/turn-server';
|
||||||
|
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||||
|
import {
|
||||||
|
installAutoResumeAudioContext,
|
||||||
|
installWebRTCTracking,
|
||||||
|
waitForAllPeerAudioFlow,
|
||||||
|
waitForAudioStatsPresent,
|
||||||
|
waitForConnectedPeerCount,
|
||||||
|
waitForOpenDataChannelCount
|
||||||
|
} from '../../helpers/webrtc-helpers';
|
||||||
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
const VOICE_CHANNEL = 'General';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symmetric NAT gives a browser no usable direct path, so the whole call has to
|
||||||
|
* ride a TURN relay. `iceTransportPolicy: 'relay'` reproduces that without any
|
||||||
|
* network trickery: host and server-reflexive candidates are thrown away, and
|
||||||
|
* only the TURN server the app was configured with is left.
|
||||||
|
*/
|
||||||
|
test.describe('Relay-only voice', () => {
|
||||||
|
let turnServer: TurnServerHandle | null = null;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
if (!await isDockerAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
turnServer = await startTurnServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await turnServer?.stop();
|
||||||
|
turnServer = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two users hear each other with every direct path removed', async ({ createClient }) => {
|
||||||
|
test.skip(!turnServer, 'Relay-only voice needs Docker to run a local coturn.');
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
|
||||||
|
const turn = turnServer as TurnServerHandle;
|
||||||
|
const clients = await createRelayOnlyVoicePair(createClient, turn, `Relay Only Voice ${Date.now()}`);
|
||||||
|
|
||||||
|
await test.step('Both ends settled on a TURN relay, not a direct path', async () => {
|
||||||
|
for (const client of clients) {
|
||||||
|
const pairs = await waitForRelayedCandidatePairs(client.page, 1, 60_000);
|
||||||
|
|
||||||
|
// A direct pair here would mean the policy leaked and the test proved nothing.
|
||||||
|
for (const pair of pairs) {
|
||||||
|
expect(pair.localCandidateType, 'a peer connection escaped the relay-only policy').toBe('relay');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Audio flows both ways through the relay', async () => {
|
||||||
|
for (const client of clients) {
|
||||||
|
await waitForAllPeerAudioFlow(client.page, 1, 60_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createRelayOnlyVoicePair(
|
||||||
|
createClient: () => Promise<Client>,
|
||||||
|
turn: TurnCredentials,
|
||||||
|
serverName: string
|
||||||
|
): Promise<Client[]> {
|
||||||
|
const clients: Client[] = [];
|
||||||
|
const credentials: { username: string; displayName: string }[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < 2; index++) {
|
||||||
|
const client = await createClient();
|
||||||
|
|
||||||
|
await installDeterministicVoiceSettings(client.page);
|
||||||
|
await installWebRTCTracking(client.page);
|
||||||
|
await forceRelayOnlyIce(client.page);
|
||||||
|
await seedTurnOnlyIceServers(client.page, turn);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
|
clients.push(client);
|
||||||
|
credentials.push({
|
||||||
|
displayName: `Relay Voice ${index + 1}`,
|
||||||
|
username: `relay_voice_${Date.now()}_${index + 1}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await test.step('Register both clients', async () => {
|
||||||
|
for (const [index, client] of clients.entries()) {
|
||||||
|
const registerPage = new RegisterPage(client.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(
|
||||||
|
credentials[index].username,
|
||||||
|
credentials[index].displayName,
|
||||||
|
USER_PASSWORD
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Create and join the server', async () => {
|
||||||
|
await new ServerSearchPage(clients[0].page).createServer(serverName, {
|
||||||
|
description: 'Relay-only voice test'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
|
||||||
|
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
|
||||||
|
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Join both clients to voice', async () => {
|
||||||
|
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
const room = new ChatRoomPage(client.page);
|
||||||
|
|
||||||
|
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
|
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, client] of clients.entries()) {
|
||||||
|
try {
|
||||||
|
await waitForConnectedPeerCount(client.page, 1, 90_000);
|
||||||
|
await waitForOpenDataChannelCount(client.page, 1, 90_000);
|
||||||
|
await waitForAudioStatsPresent(client.page, 30_000);
|
||||||
|
} catch (error) {
|
||||||
|
// No TURN server in the config looks exactly like a failed relay from the
|
||||||
|
// outside, so show what the app actually handed to WebRTC.
|
||||||
|
const configs = await getRelayIceConfigs(client.page);
|
||||||
|
|
||||||
|
console.log(`[relay client ${index + 1} ice configs] ${JSON.stringify(configs.slice(0, 3))}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return clients;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
|
import {
|
||||||
|
MULTI_DEVICE_PASSWORD,
|
||||||
|
MULTI_DEVICE_VOICE_CHANNEL,
|
||||||
|
closeClient,
|
||||||
|
loginSecondDeviceIntoServer,
|
||||||
|
uniqueMultiDeviceName
|
||||||
|
} from '../../helpers/multi-device-session';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
|
||||||
|
async function waitForVoiceMuteState(
|
||||||
|
page: import('@playwright/test').Page,
|
||||||
|
displayName: string,
|
||||||
|
expectedMuted: boolean,
|
||||||
|
timeout = 45_000
|
||||||
|
): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ expectedDisplayName, expectedMuted: muted }) => {
|
||||||
|
interface VoiceStateShape { isMuted?: boolean }
|
||||||
|
interface UserShape { displayName: string; voiceState?: VoiceStateShape }
|
||||||
|
interface ChannelShape { id: string; type: 'text' | 'voice' }
|
||||||
|
interface RoomShape { channels?: ChannelShape[] }
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.querySelector('app-rooms-side-panel');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = debugApi.getComponent(host);
|
||||||
|
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||||
|
const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice');
|
||||||
|
|
||||||
|
if (!voiceChannel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
|
||||||
|
const entry = roster.find((userEntry) => userEntry.displayName === expectedDisplayName);
|
||||||
|
|
||||||
|
return entry?.voiceState?.isMuted === muted;
|
||||||
|
},
|
||||||
|
{ expectedDisplayName: displayName, expectedMuted },
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Voice mute state reset', () => {
|
||||||
|
test.describe.configure({ timeout: 300_000, retries: 1 });
|
||||||
|
|
||||||
|
test('clears stale mute state after abrupt disconnect and voice rejoin', async ({ createClient }) => {
|
||||||
|
const suffix = uniqueMultiDeviceName('voice-mute-reset');
|
||||||
|
const hostCredentials = {
|
||||||
|
username: `host_${suffix}`,
|
||||||
|
displayName: 'Voice Host',
|
||||||
|
password: MULTI_DEVICE_PASSWORD
|
||||||
|
};
|
||||||
|
const guestCredentials = {
|
||||||
|
username: `guest_${suffix}`,
|
||||||
|
displayName: 'Voice Guest',
|
||||||
|
password: MULTI_DEVICE_PASSWORD
|
||||||
|
};
|
||||||
|
const serverName = `Voice Mute Reset ${suffix}`;
|
||||||
|
|
||||||
|
let hostClient = await createClient();
|
||||||
|
|
||||||
|
const guestClient = await createClient();
|
||||||
|
|
||||||
|
await test.step('host creates the shared server', async () => {
|
||||||
|
const registerPage = new RegisterPage(hostClient.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(hostCredentials.username, hostCredentials.displayName, hostCredentials.password);
|
||||||
|
await expect(hostClient.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const search = new ServerSearchPage(hostClient.page);
|
||||||
|
|
||||||
|
await search.createServer(serverName, { description: 'Voice mute reset coverage' });
|
||||||
|
await expect(hostClient.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostRoom = new ChatRoomPage(hostClient.page);
|
||||||
|
|
||||||
|
await hostRoom.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL);
|
||||||
|
|
||||||
|
await test.step('guest joins the server', async () => {
|
||||||
|
const registerPage = new RegisterPage(guestClient.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(guestCredentials.username, guestCredentials.displayName, guestCredentials.password);
|
||||||
|
await expect(guestClient.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const search = new ServerSearchPage(guestClient.page);
|
||||||
|
|
||||||
|
await search.joinServerFromSearch(serverName);
|
||||||
|
await expect(guestClient.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('host joins voice muted and guest observes the muted state', async () => {
|
||||||
|
await hostRoom.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||||
|
await expect(hostRoom.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||||
|
await hostRoom.muteButton.click();
|
||||||
|
|
||||||
|
await waitForVoiceMuteState(guestClient.page, hostCredentials.displayName, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('abrupt host disconnect clears stale mute before rejoin', async () => {
|
||||||
|
await closeClient(hostClient);
|
||||||
|
|
||||||
|
hostClient = await createClient();
|
||||||
|
await loginSecondDeviceIntoServer(hostClient.page, hostCredentials, serverName);
|
||||||
|
|
||||||
|
const reopenedRoom = new ChatRoomPage(hostClient.page);
|
||||||
|
|
||||||
|
await reopenedRoom.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||||
|
await expect(reopenedRoom.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await waitForVoiceMuteState(guestClient.page, hostCredentials.displayName, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ Owns the desktop runtime: the Electron main process, the preload bridge that exp
|
|||||||
| **Local API server** | An in-process HTTP server (`electron/api/local-api-server.ts`) that serves the prebuilt Docusaurus docs and OpenAPI views to the renderer over `http://localhost:<port>/`. | "internal API" |
|
| **Local API server** | An in-process HTTP server (`electron/api/local-api-server.ts`) that serves the prebuilt Docusaurus docs and OpenAPI views to the renderer over `http://localhost:<port>/`. | "internal API" |
|
||||||
| **Plugin library** | The plugin loader (`electron/plugin-library.ts`) — resolves manifests, validates entry points, and prepares the sandbox the renderer mounts plugins into. | "plugin manager" |
|
| **Plugin library** | The plugin loader (`electron/plugin-library.ts`) — resolves manifests, validates entry points, and prepares the sandbox the renderer mounts plugins into. | "plugin manager" |
|
||||||
| **Data archive** | The export/import format implemented in `electron/data-archive.ts` for moving a user's local database between installs. | "backup" |
|
| **Data archive** | The export/import format implemented in `electron/data-archive.ts` for moving a user's local database between installs. | "backup" |
|
||||||
|
| **Linux launcher** | The shell script installed as the packaged Linux executable by `tools/after-pack.js`; built by `electron/app/linux-launcher.rules.ts`, it picks the sandbox switches and hands over to the renamed real binary `<executableName>-bin`. | "wrapper", "AppRun" |
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ Owns the desktop runtime: the Electron main process, the preload bridge that exp
|
|||||||
- Every schema change is accompanied by a **TypeORM migration**; the database is never mutated outside the migration system.
|
- Every schema change is accompanied by a **TypeORM migration**; the database is never mutated outside the migration system.
|
||||||
- IPC handler errors are translated to typed error envelopes before crossing back into the renderer — the renderer never sees a raw `Error` from main.
|
- IPC handler errors are translated to typed error envelopes before crossing back into the renderer — the renderer never sees a raw `Error` from main.
|
||||||
- The **Preload bridge** exposes a frozen, allow-listed set of methods; adding a method requires touching both `preload.ts` and the matching handler.
|
- The **Preload bridge** exposes a frozen, allow-listed set of methods; adding a method requires touching both `preload.ts` and the matching handler.
|
||||||
|
- Chromium sandbox and Ozone switches are only ever set on the real command line — the **Linux launcher** for packaged builds, the launch scripts in development. `app.commandLine.appendSwitch` runs too late for them and must not be used to fake it.
|
||||||
|
|
||||||
## Flagged ambiguities
|
## Flagged ambiguities
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -4,6 +4,10 @@ export interface AppMetricsProcessSnapshot {
|
|||||||
pid: number;
|
pid: number;
|
||||||
type: string;
|
type: string;
|
||||||
workingSetKb: number | null;
|
workingSetKb: number | null;
|
||||||
|
peakWorkingSetKb: number | null;
|
||||||
|
privateBytesKb: number | null;
|
||||||
|
creationTime: number | null;
|
||||||
|
cpuPercent: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppMetricsSnapshot {
|
export interface AppMetricsSnapshot {
|
||||||
@@ -17,7 +21,17 @@ export function collectAppMetricsSnapshot(): AppMetricsSnapshot {
|
|||||||
processes: app.getAppMetrics().map((metric) => ({
|
processes: app.getAppMetrics().map((metric) => ({
|
||||||
pid: metric.pid,
|
pid: metric.pid,
|
||||||
type: metric.type,
|
type: metric.type,
|
||||||
workingSetKb: metric.memory?.workingSetSize ?? null
|
workingSetKb: metric.memory?.workingSetSize ?? null,
|
||||||
|
peakWorkingSetKb: readOptionalKilobytes(metric.memory?.peakWorkingSetSize),
|
||||||
|
privateBytesKb: readOptionalKilobytes(metric.memory?.privateBytes),
|
||||||
|
creationTime: metric.creationTime ?? null,
|
||||||
|
cpuPercent: typeof metric.cpu?.percentCPUUsage === 'number'
|
||||||
|
? Math.round(metric.cpu.percentCPUUsage * 10) / 10
|
||||||
|
: null
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalKilobytes(value: number | undefined): number | null {
|
||||||
|
return typeof value === 'number' && value >= 0 ? value : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { createWindow, getMainWindow } from '../window/create-window';
|
import { createWindow, getMainWindow } from '../window/create-window';
|
||||||
|
import { resolveSecondInstanceAction } from './second-instance.rules';
|
||||||
|
|
||||||
const CUSTOM_PROTOCOL = 'toju';
|
const CUSTOM_PROTOCOL = 'toju';
|
||||||
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
||||||
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
||||||
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
|
|
||||||
|
|
||||||
let pendingDeepLink: string | null = null;
|
let pendingDeepLink: string | null = null;
|
||||||
|
|
||||||
@@ -42,6 +42,24 @@ function focusMainWindow(): void {
|
|||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reloadMainWindow(): void {
|
||||||
|
const mainWindow = getMainWindow();
|
||||||
|
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
void createWindow();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
focusMainWindow();
|
||||||
|
|
||||||
|
if (mainWindow.webContents.isLoadingMainFrame()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.webContents.reloadIgnoringCache();
|
||||||
|
}
|
||||||
|
|
||||||
function forwardDeepLink(url: string): void {
|
function forwardDeepLink(url: string): void {
|
||||||
const mainWindow = getMainWindow();
|
const mainWindow = getMainWindow();
|
||||||
|
|
||||||
@@ -96,13 +114,16 @@ export function initializeDeepLinkHandling(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.on('second-instance', (_event, argv) => {
|
app.on('second-instance', (_event, argv) => {
|
||||||
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) {
|
const action = resolveSecondInstanceAction({
|
||||||
app.relaunch();
|
argv,
|
||||||
app.exit(0);
|
devSingleInstanceExitCode: resolveDevSingleInstanceExitCode()
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
|
if (action === 'reload-existing') {
|
||||||
|
reloadMainWindow();
|
||||||
|
} else {
|
||||||
focusMainWindow();
|
focusMainWindow();
|
||||||
|
}
|
||||||
|
|
||||||
const deepLink = extractDeepLink(argv);
|
const deepLink = extractDeepLink(argv);
|
||||||
|
|
||||||
|
|||||||
+4
-15
@@ -4,7 +4,6 @@ import { readDesktopSettings } from '../desktop-settings';
|
|||||||
|
|
||||||
export function configureAppFlags(): void {
|
export function configureAppFlags(): void {
|
||||||
configureDesktopBranding();
|
configureDesktopBranding();
|
||||||
linuxSpecificFlags();
|
|
||||||
networkFlags();
|
networkFlags();
|
||||||
setupGpuEncodingFlags();
|
setupGpuEncodingFlags();
|
||||||
chromiumFlags();
|
chromiumFlags();
|
||||||
@@ -21,6 +20,10 @@ function chromiumFlags(): void {
|
|||||||
const enabledFeatures: string[] = [];
|
const enabledFeatures: string[] = [];
|
||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
|
// Sandbox and Ozone platform selection happen before this file runs. The
|
||||||
|
// packaged launcher script and the dev launch scripts pass those switches
|
||||||
|
// on the real command line instead.
|
||||||
|
|
||||||
// PipeWire-based audio pipeline for screen share audio capture
|
// PipeWire-based audio pipeline for screen share audio capture
|
||||||
enabledFeatures.push('AudioServiceOutOfProcess');
|
enabledFeatures.push('AudioServiceOutOfProcess');
|
||||||
// PipeWire-based screen capture so the xdg-desktop-portal system picker works
|
// PipeWire-based screen capture so the xdg-desktop-portal system picker works
|
||||||
@@ -38,20 +41,6 @@ function chromiumFlags(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function linuxSpecificFlags(): void {
|
|
||||||
if (process.platform !== 'linux') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
|
||||||
app.commandLine.appendSwitch('no-sandbox');
|
|
||||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
|
||||||
|
|
||||||
// Chromium chooses the Linux Ozone platform before Electron runs this file.
|
|
||||||
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland
|
|
||||||
// sessions so the browser process selects the correct backend early enough.
|
|
||||||
}
|
|
||||||
|
|
||||||
function networkFlags(): void {
|
function networkFlags(): void {
|
||||||
// Accept self-signed certificates in development (for --ssl dev server)
|
// Accept self-signed certificates in development (for --ssl dev server)
|
||||||
if (process.env['SSL'] === 'true') {
|
if (process.env['SSL'] === 'true') {
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
|||||||
import {
|
import {
|
||||||
attachRendererDiagnosticsHooks,
|
attachRendererDiagnosticsHooks,
|
||||||
ensurePerfDiagIpcRegistered,
|
ensurePerfDiagIpcRegistered,
|
||||||
|
shutdownHighMemoryMonitoring,
|
||||||
shutdownPerfDiagnostics,
|
shutdownPerfDiagnostics,
|
||||||
|
startHighMemoryMonitoring,
|
||||||
startPerfDiagnostics
|
startPerfDiagnostics
|
||||||
} from '../diagnostics';
|
} from '../diagnostics';
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ function startLocalApiAfterWindowReady(): void {
|
|||||||
|
|
||||||
export function registerAppLifecycle(): void {
|
export function registerAppLifecycle(): void {
|
||||||
ensurePerfDiagIpcRegistered();
|
ensurePerfDiagIpcRegistered();
|
||||||
|
startHighMemoryMonitoring();
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
const dockIconPath = getDockIconPath();
|
const dockIconPath = getDockIconPath();
|
||||||
@@ -83,6 +86,7 @@ export function registerAppLifecycle(): void {
|
|||||||
|
|
||||||
app.on('before-quit', async (event) => {
|
app.on('before-quit', async (event) => {
|
||||||
prepareWindowForAppQuit();
|
prepareWindowForAppQuit();
|
||||||
|
shutdownHighMemoryMonitoring();
|
||||||
await shutdownPerfDiagnostics();
|
await shutdownPerfDiagnostics();
|
||||||
|
|
||||||
if (getDataSource()?.isInitialized) {
|
if (getDataSource()?.isInitialized) {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
mkdtempSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { buildLinuxLauncherScript, resolveLinuxLauncherNames } from './linux-launcher.rules';
|
||||||
|
|
||||||
|
interface KernelFlags {
|
||||||
|
apparmorRestriction: string;
|
||||||
|
unprivilegedUsernsClone: string;
|
||||||
|
maxUserNamespaces: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERMISSIVE_KERNEL: KernelFlags = {
|
||||||
|
apparmorRestriction: '0',
|
||||||
|
unprivilegedUsernsClone: '1',
|
||||||
|
maxUserNamespaces: '15000'
|
||||||
|
};
|
||||||
|
|
||||||
|
let workspace = '';
|
||||||
|
|
||||||
|
function writeKernelFlags(flags: KernelFlags): Record<keyof KernelFlags, string> {
|
||||||
|
const paths = {
|
||||||
|
apparmorRestriction: join(workspace, 'apparmor_restrict_unprivileged_userns'),
|
||||||
|
unprivilegedUsernsClone: join(workspace, 'unprivileged_userns_clone'),
|
||||||
|
maxUserNamespaces: join(workspace, 'max_user_namespaces')
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of Object.keys(paths) as (keyof KernelFlags)[]) {
|
||||||
|
writeFileSync(paths[key], `${flags[key]}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runLauncher(flags: KernelFlags, args: string[] = []): string {
|
||||||
|
const paths = writeKernelFlags(flags);
|
||||||
|
const { launcherFileName, binaryFileName } = resolveLinuxLauncherNames('toju');
|
||||||
|
const launcherPath = join(workspace, launcherFileName);
|
||||||
|
const binaryPath = join(workspace, binaryFileName);
|
||||||
|
|
||||||
|
writeFileSync(binaryPath, '#!/bin/sh\nprintf \'%s\\n\' "$@"\n', { encoding: 'utf8', mode: 0o755 });
|
||||||
|
writeFileSync(
|
||||||
|
launcherPath,
|
||||||
|
buildLinuxLauncherScript({
|
||||||
|
binaryFileName,
|
||||||
|
apparmorRestrictionPath: paths.apparmorRestriction,
|
||||||
|
unprivilegedUsernsClonePath: paths.unprivilegedUsernsClone,
|
||||||
|
maxUserNamespacesPath: paths.maxUserNamespaces
|
||||||
|
}),
|
||||||
|
{ encoding: 'utf8', mode: 0o755 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return execFileSync(launcherPath, args, { encoding: 'utf8' }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildLinuxLauncherScript', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
workspace = mkdtempSync(join(tmpdir(), 'toju-launcher-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(workspace, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the sandbox on when the kernel allows unprivileged user namespaces', () => {
|
||||||
|
expect(runLauncher(PERMISSIVE_KERNEL)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the sandbox when AppArmor confines unprivileged user namespaces', () => {
|
||||||
|
const output = runLauncher({ ...PERMISSIVE_KERNEL, apparmorRestriction: '1' });
|
||||||
|
|
||||||
|
expect(output).toBe('--no-sandbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the sandbox when the kernel forbids unprivileged namespace cloning', () => {
|
||||||
|
const output = runLauncher({ ...PERMISSIVE_KERNEL, unprivilegedUsernsClone: '0' });
|
||||||
|
|
||||||
|
expect(output).toBe('--no-sandbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the sandbox when no user namespaces are available at all', () => {
|
||||||
|
const output = runLauncher({ ...PERMISSIVE_KERNEL, maxUserNamespaces: '0' });
|
||||||
|
|
||||||
|
expect(output).toBe('--no-sandbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards launch arguments to the real binary', () => {
|
||||||
|
const output = runLauncher(PERMISSIVE_KERNEL, ['toju://invite/abc', '--ozone-platform=wayland']);
|
||||||
|
|
||||||
|
expect(output.split('\n')).toEqual(['toju://invite/abc', '--ozone-platform=wayland']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never repeats a sandbox switch the caller already supplied', () => {
|
||||||
|
const output = runLauncher(
|
||||||
|
{ ...PERMISSIVE_KERNEL, apparmorRestriction: '1' },
|
||||||
|
['--no-sandbox', '%U']
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(output.split('\n')).toEqual(['--no-sandbox', '%U']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assumes a blocked sandbox is fine when the kernel switches are unreadable', () => {
|
||||||
|
const { launcherFileName, binaryFileName } = resolveLinuxLauncherNames('toju');
|
||||||
|
const launcherPath = join(workspace, launcherFileName);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(workspace, binaryFileName),
|
||||||
|
'#!/bin/sh\nprintf \'%s\\n\' "$@"\n',
|
||||||
|
{ encoding: 'utf8', mode: 0o755 }
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
launcherPath,
|
||||||
|
buildLinuxLauncherScript({
|
||||||
|
binaryFileName,
|
||||||
|
apparmorRestrictionPath: join(workspace, 'missing-apparmor'),
|
||||||
|
unprivilegedUsernsClonePath: join(workspace, 'missing-clone'),
|
||||||
|
maxUserNamespacesPath: join(workspace, 'missing-max')
|
||||||
|
}),
|
||||||
|
{ encoding: 'utf8', mode: 0o755 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(execFileSync(launcherPath, { encoding: 'utf8' }).trim()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveLinuxLauncherNames', () => {
|
||||||
|
it('keeps the published executable name for the launcher and renames the binary', () => {
|
||||||
|
expect(resolveLinuxLauncherNames('toju')).toEqual({
|
||||||
|
launcherFileName: 'toju',
|
||||||
|
binaryFileName: 'toju-bin'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
export const LINUX_LAUNCHER_BINARY_SUFFIX = '-bin';
|
||||||
|
|
||||||
|
export const APPARMOR_USERNS_RESTRICTION_PATH = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns';
|
||||||
|
export const UNPRIVILEGED_USERNS_CLONE_PATH = '/proc/sys/kernel/unprivileged_userns_clone';
|
||||||
|
export const MAX_USER_NAMESPACES_PATH = '/proc/sys/user/max_user_namespaces';
|
||||||
|
|
||||||
|
export interface LinuxLauncherNames {
|
||||||
|
launcherFileName: string;
|
||||||
|
binaryFileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinuxLauncherScriptOptions {
|
||||||
|
binaryFileName: string;
|
||||||
|
apparmorRestrictionPath?: string;
|
||||||
|
unprivilegedUsernsClonePath?: string;
|
||||||
|
maxUserNamespacesPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLinuxLauncherNames(executableName: string): LinuxLauncherNames {
|
||||||
|
return {
|
||||||
|
launcherFileName: executableName,
|
||||||
|
binaryFileName: `${executableName}${LINUX_LAUNCHER_BINARY_SUFFIX}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chromium reads `--no-sandbox` while the browser process boots, long before
|
||||||
|
* the main script runs, so `app.commandLine.appendSwitch` cannot influence it.
|
||||||
|
* The packaged executable is therefore this script, which decides before
|
||||||
|
* handing over to the real binary.
|
||||||
|
*
|
||||||
|
* The sandbox stays on wherever the kernel can host it. It is dropped only
|
||||||
|
* where unprivileged user namespaces are denied - Ubuntu 24.04+ confines
|
||||||
|
* unconfined binaries through AppArmor, and hardened kernels disable the
|
||||||
|
* namespaces outright. An AppImage cannot fall back to the SUID helper because
|
||||||
|
* its payload is mounted `nosuid`, so without this the app aborts at startup.
|
||||||
|
*/
|
||||||
|
export function buildLinuxLauncherScript(options: LinuxLauncherScriptOptions): string {
|
||||||
|
const apparmorRestrictionPath = options.apparmorRestrictionPath ?? APPARMOR_USERNS_RESTRICTION_PATH;
|
||||||
|
const unprivilegedUsernsClonePath = options.unprivilegedUsernsClonePath ?? UNPRIVILEGED_USERNS_CLONE_PATH;
|
||||||
|
const maxUserNamespacesPath = options.maxUserNamespacesPath ?? MAX_USER_NAMESPACES_PATH;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'#!/bin/sh',
|
||||||
|
'# Generated during packaging. Chromium only honours --no-sandbox when it is',
|
||||||
|
'# present on the real command line, so the decision happens here.',
|
||||||
|
'set -eu',
|
||||||
|
'',
|
||||||
|
'launcher_path="$0"',
|
||||||
|
'',
|
||||||
|
'case "$launcher_path" in',
|
||||||
|
' */*) ;;',
|
||||||
|
' *) launcher_path="$(command -v -- "$launcher_path" 2>/dev/null || printf \'%s\' "$launcher_path")" ;;',
|
||||||
|
'esac',
|
||||||
|
'',
|
||||||
|
'launcher_path="$(readlink -f -- "$launcher_path" 2>/dev/null || printf \'%s\' "$launcher_path")"',
|
||||||
|
`binary_path="$(dirname -- "$launcher_path")/${options.binaryFileName}"`,
|
||||||
|
'',
|
||||||
|
'read_kernel_flag() {',
|
||||||
|
' if [ ! -r "$1" ]; then',
|
||||||
|
' printf \'%s\' "$2"',
|
||||||
|
' return 0',
|
||||||
|
' fi',
|
||||||
|
'',
|
||||||
|
' cat -- "$1" 2>/dev/null || printf \'%s\' "$2"',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
'sandbox_is_blocked() {',
|
||||||
|
` if [ "$(read_kernel_flag ${apparmorRestrictionPath} 0)" = "1" ]; then`,
|
||||||
|
' return 0',
|
||||||
|
' fi',
|
||||||
|
'',
|
||||||
|
` if [ "$(read_kernel_flag ${unprivilegedUsernsClonePath} 1)" = "0" ]; then`,
|
||||||
|
' return 0',
|
||||||
|
' fi',
|
||||||
|
'',
|
||||||
|
` if [ "$(read_kernel_flag ${maxUserNamespacesPath} 1)" = "0" ]; then`,
|
||||||
|
' return 0',
|
||||||
|
' fi',
|
||||||
|
'',
|
||||||
|
' return 1',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
'for launcher_arg in "$@"; do',
|
||||||
|
' case "$launcher_arg" in',
|
||||||
|
' --no-sandbox) exec "$binary_path" "$@" ;;',
|
||||||
|
' esac',
|
||||||
|
'done',
|
||||||
|
'',
|
||||||
|
'if sandbox_is_blocked; then',
|
||||||
|
' exec "$binary_path" --no-sandbox "$@"',
|
||||||
|
'fi',
|
||||||
|
'',
|
||||||
|
'exec "$binary_path" "$@"',
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { DEV_RELOAD_EXISTING_ARG, resolveSecondInstanceAction } from './second-instance.rules';
|
||||||
|
|
||||||
|
describe('resolveSecondInstanceAction', () => {
|
||||||
|
it('reloads the open window when a dev launch asks to reuse it', () => {
|
||||||
|
const action = resolveSecondInstanceAction({
|
||||||
|
argv: [
|
||||||
|
'electron',
|
||||||
|
'.',
|
||||||
|
DEV_RELOAD_EXISTING_ARG
|
||||||
|
],
|
||||||
|
devSingleInstanceExitCode: 23
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(action).toBe('reload-existing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never asks a packaged instance to reload, even with the dev argument', () => {
|
||||||
|
const action = resolveSecondInstanceAction({
|
||||||
|
argv: ['metoyou', DEV_RELOAD_EXISTING_ARG],
|
||||||
|
devSingleInstanceExitCode: null
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(action).toBe('focus');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses the open window for an ordinary second launch', () => {
|
||||||
|
const action = resolveSecondInstanceAction({
|
||||||
|
argv: [
|
||||||
|
'electron',
|
||||||
|
'.',
|
||||||
|
'toju://invite/abc'
|
||||||
|
],
|
||||||
|
devSingleInstanceExitCode: 23
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(action).toBe('focus');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
|
||||||
|
|
||||||
|
export type SecondInstanceAction = 'reload-existing' | 'focus';
|
||||||
|
|
||||||
|
export interface SecondInstanceInput {
|
||||||
|
argv: string[];
|
||||||
|
devSingleInstanceExitCode: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dev launch always carries `--metoyou-dev-reload-existing`, so the running
|
||||||
|
* instance reloads in place. It must never answer by relaunching itself: the
|
||||||
|
* successor inherits the same argument and asks for the single-instance lock
|
||||||
|
* while the dying parent still holds it, so the refused successor fires
|
||||||
|
* `second-instance` again and the pair respawns forever.
|
||||||
|
*/
|
||||||
|
export function resolveSecondInstanceAction(input: SecondInstanceInput): SecondInstanceAction {
|
||||||
|
const isDevelopmentLaunch = input.devSingleInstanceExitCode != null;
|
||||||
|
|
||||||
|
return isDevelopmentLaunch && input.argv.includes(DEV_RELOAD_EXISTING_ARG)
|
||||||
|
? 'reload-existing'
|
||||||
|
: 'focus';
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { isPerfDiagEnabled } from './diagnostics.flags';
|
|||||||
describe('isPerfDiagEnabled', () => {
|
describe('isPerfDiagEnabled', () => {
|
||||||
it('returns false when the flag is unset', () => {
|
it('returns false when the flag is unset', () => {
|
||||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||||
expect(isPerfDiagEnabled({}, true)).toBe(false);
|
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
||||||
@@ -17,11 +17,12 @@ describe('isPerfDiagEnabled', () => {
|
|||||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false in packaged builds unless force is set', () => {
|
it('returns true in packaged Electron builds without env flags', () => {
|
||||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
|
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||||
expect(isPerfDiagEnabled({
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '0' }, true)).toBe(true);
|
||||||
METOYOU_PERF_DIAG: '1',
|
});
|
||||||
METOYOU_PERF_DIAG_FORCE: '1'
|
|
||||||
}, true)).toBe(true);
|
it('returns false in development when the flag is unset', () => {
|
||||||
|
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,13 +17,9 @@ export function isPerfDiagEnabled(
|
|||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
isPackaged: boolean
|
isPackaged: boolean
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
|
if (isPackaged) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return isTruthyFlag(env[PERF_DIAG_ENV]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
ipcMain
|
ipcMain,
|
||||||
|
shell
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import { collectAppMetricsSnapshot } from '../app-metrics';
|
import { collectAppMetricsSnapshot, type AppMetricsSnapshot } from '../app-metrics';
|
||||||
|
import { getMainWindow } from '../window/create-window';
|
||||||
|
import { resolveReadablePath } from '../path-jail';
|
||||||
import { sumWorkingSetKb } from './process-metrics.rules';
|
import { sumWorkingSetKb } from './process-metrics.rules';
|
||||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||||
|
import { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
|
||||||
|
import { captureHighMemoryDiagnostics } from './high-memory-capture';
|
||||||
|
import { collectSessionContext } from './session-context.collector';
|
||||||
|
import {
|
||||||
|
clearHighMemoryAlert,
|
||||||
|
readHighMemoryAlert,
|
||||||
|
writeHighMemoryAlert,
|
||||||
|
type HighMemoryAlertRecord
|
||||||
|
} from './high-memory-alert.store';
|
||||||
import type { PerfDiagEntry } from './diagnostics.models';
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
import { PerfDiagWriter } from './diagnostics.writer';
|
import { PerfDiagWriter } from './diagnostics.writer';
|
||||||
|
|
||||||
const PROCESS_POLL_INTERVAL_MS = 5_000;
|
const PROCESS_POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
export const HIGH_MEMORY_ALERT_PENDING_CHANNEL = 'high-memory-alert-pending';
|
||||||
|
|
||||||
let activeWriter: PerfDiagWriter | null = null;
|
let activeWriter: PerfDiagWriter | null = null;
|
||||||
let processPollTimer: NodeJS.Timeout | null = null;
|
let processPollTimer: NodeJS.Timeout | null = null;
|
||||||
let diagnosticsEnabled = false;
|
let diagnosticsEnabled = false;
|
||||||
let ipcRegistered = false;
|
let ipcRegistered = false;
|
||||||
|
let highMemoryAlertTriggeredThisSession = false;
|
||||||
|
let sessionStartedAt = 0;
|
||||||
|
|
||||||
export function isPerfDiagActive(): boolean {
|
export function isPerfDiagActive(): boolean {
|
||||||
return diagnosticsEnabled;
|
return diagnosticsEnabled;
|
||||||
@@ -43,14 +59,103 @@ export function ensurePerfDiagIpcRegistered(): void {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-pending-high-memory-alert', async () => {
|
||||||
|
return readHighMemoryAlert(app.getPath('userData'));
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('acknowledge-high-memory-alert', async () => {
|
||||||
|
await clearHighMemoryAlert(app.getPath('userData'));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('export-high-memory-diagnostics', async () => {
|
||||||
|
const metrics = collectAppMetricsSnapshot();
|
||||||
|
const totalKb = sumWorkingSetKb(metrics.processes) ?? 0;
|
||||||
|
const record = await captureHighMemoryDiagnostics({
|
||||||
|
userDataPath: app.getPath('userData'),
|
||||||
|
sessionStartedAt,
|
||||||
|
metrics,
|
||||||
|
totalWorkingSetKb: totalKb,
|
||||||
|
writer: activeWriter,
|
||||||
|
mainWindow: getMainWindow(),
|
||||||
|
reason: 'manual'
|
||||||
|
});
|
||||||
|
|
||||||
|
await persistAndNotifyHighMemoryAlert(record);
|
||||||
|
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('show-log-file-in-folder', async (_event, filePath: string) => {
|
||||||
|
if (typeof filePath !== 'string' || !filePath.trim()) {
|
||||||
|
return {
|
||||||
|
shown: false,
|
||||||
|
reason: 'missing-path'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedPath = await resolveReadablePath(filePath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
|
return {
|
||||||
|
shown: false,
|
||||||
|
reason: 'outside-app-data'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.showItemInFolder(scopedPath);
|
||||||
|
|
||||||
|
return { shown: true };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
||||||
return activeWriter;
|
return activeWriter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startHighMemoryMonitoring(): void {
|
||||||
|
ensurePerfDiagIpcRegistered();
|
||||||
|
|
||||||
|
if (!sessionStartedAt) {
|
||||||
|
sessionStartedAt = Date.now();
|
||||||
|
highMemoryAlertTriggeredThisSession = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processPollTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sample = (): void => {
|
||||||
|
try {
|
||||||
|
const metrics = collectAppMetricsSnapshot();
|
||||||
|
const totalKb = sumWorkingSetKb(metrics.processes);
|
||||||
|
|
||||||
|
if (activeWriter && diagnosticsEnabled) {
|
||||||
|
activeWriter.append({
|
||||||
|
collectedAt: metrics.collectedAt,
|
||||||
|
source: 'main',
|
||||||
|
type: 'process',
|
||||||
|
payload: {
|
||||||
|
totalWorkingSetKb: totalKb,
|
||||||
|
processes: metrics.processes
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void maybeTriggerHighMemoryAlert(metrics, totalKb);
|
||||||
|
} catch {
|
||||||
|
// Collector failures must never affect the app.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sample();
|
||||||
|
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
export function startPerfDiagnostics(): PerfDiagWriter | null {
|
export function startPerfDiagnostics(): PerfDiagWriter | null {
|
||||||
ensurePerfDiagIpcRegistered();
|
ensurePerfDiagIpcRegistered();
|
||||||
|
startHighMemoryMonitoring();
|
||||||
diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged);
|
diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged);
|
||||||
|
|
||||||
if (!diagnosticsEnabled) {
|
if (!diagnosticsEnabled) {
|
||||||
@@ -65,7 +170,8 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
|
|||||||
|
|
||||||
activeWriter = writer;
|
activeWriter = writer;
|
||||||
registerProcessCrashHandlers(writer);
|
registerProcessCrashHandlers(writer);
|
||||||
startProcessMetricsPolling(writer);
|
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
|
||||||
writer.append({
|
writer.append({
|
||||||
collectedAt: Date.now(),
|
collectedAt: Date.now(),
|
||||||
@@ -78,6 +184,18 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
writer.append({
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
source: 'main',
|
||||||
|
type: 'environment',
|
||||||
|
payload: {
|
||||||
|
...collectSessionContext({
|
||||||
|
sessionStartedAt,
|
||||||
|
userDataPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return writer;
|
return writer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,14 +245,15 @@ export async function shutdownPerfDiagnostics(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await activeWriter.flushSnapshot('shutdown');
|
await activeWriter.flushSnapshot('shutdown');
|
||||||
|
activeWriter = null;
|
||||||
|
diagnosticsEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shutdownHighMemoryMonitoring(): void {
|
||||||
if (processPollTimer) {
|
if (processPollTimer) {
|
||||||
clearInterval(processPollTimer);
|
clearInterval(processPollTimer);
|
||||||
processPollTimer = null;
|
processPollTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeWriter = null;
|
|
||||||
diagnosticsEnabled = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
|
function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
|
||||||
@@ -180,28 +299,36 @@ function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
async function maybeTriggerHighMemoryAlert(
|
||||||
const sample = (): void => {
|
metrics: AppMetricsSnapshot,
|
||||||
try {
|
totalWorkingSetKb: number | null
|
||||||
const metrics = collectAppMetricsSnapshot();
|
): Promise<void> {
|
||||||
const totalKb = sumWorkingSetKb(metrics.processes);
|
if (highMemoryAlertTriggeredThisSession || !exceedsHighMemoryThreshold(totalWorkingSetKb)) {
|
||||||
|
return;
|
||||||
writer.append({
|
|
||||||
collectedAt: metrics.collectedAt,
|
|
||||||
source: 'main',
|
|
||||||
type: 'process',
|
|
||||||
payload: {
|
|
||||||
totalWorkingSetKb: totalKb,
|
|
||||||
processes: metrics.processes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
highMemoryAlertTriggeredThisSession = true;
|
||||||
|
|
||||||
|
const record = await captureHighMemoryDiagnostics({
|
||||||
|
userDataPath: app.getPath('userData'),
|
||||||
|
sessionStartedAt,
|
||||||
|
metrics,
|
||||||
|
totalWorkingSetKb: totalWorkingSetKb ?? 0,
|
||||||
|
writer: activeWriter,
|
||||||
|
mainWindow: getMainWindow(),
|
||||||
|
reason: 'threshold'
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
// Collector failures must never affect the app.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sample();
|
await persistAndNotifyHighMemoryAlert(record);
|
||||||
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
}
|
||||||
|
|
||||||
|
async function persistAndNotifyHighMemoryAlert(record: HighMemoryAlertRecord): Promise<void> {
|
||||||
|
await writeHighMemoryAlert(app.getPath('userData'), record);
|
||||||
|
notifyHighMemoryAlert(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyHighMemoryAlert(record: HighMemoryAlertRecord): void {
|
||||||
|
getMainWindow()?.webContents.send(HIGH_MEMORY_ALERT_PENDING_CHANNEL, record);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
|
|||||||
|
|
||||||
export type PerfDiagEntryType =
|
export type PerfDiagEntryType =
|
||||||
| 'session'
|
| 'session'
|
||||||
|
| 'environment'
|
||||||
| 'process'
|
| 'process'
|
||||||
| 'store'
|
| 'store'
|
||||||
| 'components'
|
| 'components'
|
||||||
| 'heap'
|
| 'heap'
|
||||||
|
| 'high-memory'
|
||||||
| 'crash'
|
| 'crash'
|
||||||
| 'unresponsive';
|
| 'unresponsive';
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
resolveDiagnosticsFilePath
|
resolveDiagnosticsFilePath
|
||||||
} from './diagnostics.rules';
|
} from './diagnostics.rules';
|
||||||
|
|
||||||
const DEFAULT_RING_CAPACITY = 120;
|
const DEFAULT_RING_CAPACITY = 300;
|
||||||
const FLUSH_DEBOUNCE_MS = 250;
|
const FLUSH_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
export interface PerfDiagWriterOptions {
|
export interface PerfDiagWriterOptions {
|
||||||
@@ -18,6 +18,7 @@ export interface PerfDiagWriterOptions {
|
|||||||
|
|
||||||
export class PerfDiagWriter {
|
export class PerfDiagWriter {
|
||||||
private readonly filePath: string;
|
private readonly filePath: string;
|
||||||
|
private readonly sessionIdValue: string;
|
||||||
private readonly ringCapacity: number;
|
private readonly ringCapacity: number;
|
||||||
private readonly pendingLines: string[] = [];
|
private readonly pendingLines: string[] = [];
|
||||||
private ring: PerfDiagEntry[] = [];
|
private ring: PerfDiagEntry[] = [];
|
||||||
@@ -26,10 +27,15 @@ export class PerfDiagWriter {
|
|||||||
private disabled = false;
|
private disabled = false;
|
||||||
|
|
||||||
constructor(options: PerfDiagWriterOptions) {
|
constructor(options: PerfDiagWriterOptions) {
|
||||||
|
this.sessionIdValue = options.sessionId;
|
||||||
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
||||||
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get sessionId(): string {
|
||||||
|
return this.sessionIdValue;
|
||||||
|
}
|
||||||
|
|
||||||
get snapshotFilePath(): string {
|
get snapshotFilePath(): string {
|
||||||
return this.filePath;
|
return this.filePath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
exceedsHighMemoryThreshold,
|
||||||
|
formatWorkingSetGb,
|
||||||
|
HIGH_MEMORY_THRESHOLD_KB
|
||||||
|
} from './high-memory-alert.rules';
|
||||||
|
|
||||||
|
describe('high-memory-alert.rules', () => {
|
||||||
|
it('uses a 2 GiB working-set threshold', () => {
|
||||||
|
expect(HIGH_MEMORY_THRESHOLD_KB).toBe(2 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects totals at or above the threshold', () => {
|
||||||
|
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB - 1)).toBe(false);
|
||||||
|
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB)).toBe(true);
|
||||||
|
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB + 1024)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats working set totals in gigabytes', () => {
|
||||||
|
expect(formatWorkingSetGb(1536 * 1024)).toBe('1.50');
|
||||||
|
expect(formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB)).toBe('2.00');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** 2 GiB working-set threshold for writing a diagnostics snapshot. */
|
||||||
|
export const HIGH_MEMORY_THRESHOLD_KB = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function exceedsHighMemoryThreshold(totalWorkingSetKb: number | null | undefined): boolean {
|
||||||
|
return typeof totalWorkingSetKb === 'number'
|
||||||
|
&& totalWorkingSetKb >= HIGH_MEMORY_THRESHOLD_KB;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWorkingSetGb(totalWorkingSetKb: number): string {
|
||||||
|
return (totalWorkingSetKb / (1024 * 1024)).toFixed(2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
clearHighMemoryAlert,
|
||||||
|
readHighMemoryAlert,
|
||||||
|
resolveHighMemoryAlertPath,
|
||||||
|
writeHighMemoryAlert
|
||||||
|
} from './high-memory-alert.store';
|
||||||
|
|
||||||
|
describe('high-memory-alert.store', () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, {
|
||||||
|
recursive: true,
|
||||||
|
force: true
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes and reads a pending startup alert record', async () => {
|
||||||
|
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
|
||||||
|
|
||||||
|
tempDirs.push(userDataPath);
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'),
|
||||||
|
detectedAt: 1_700_000_000_000,
|
||||||
|
peakWorkingSetKb: 2_200_000,
|
||||||
|
sessionId: 'session-1',
|
||||||
|
reason: 'threshold' as const
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeHighMemoryAlert(userDataPath, record);
|
||||||
|
|
||||||
|
expect(resolveHighMemoryAlertPath(userDataPath)).toBe(
|
||||||
|
path.join(userDataPath, 'diagnostics', 'high-memory-pending.json')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await readHighMemoryAlert(userDataPath)).toEqual(record);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the pending startup alert record', async () => {
|
||||||
|
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
|
||||||
|
|
||||||
|
tempDirs.push(userDataPath);
|
||||||
|
|
||||||
|
await writeHighMemoryAlert(userDataPath, {
|
||||||
|
logFilePath: '/tmp/perf.jsonl',
|
||||||
|
detectedAt: Date.now(),
|
||||||
|
peakWorkingSetKb: 2_100_000,
|
||||||
|
sessionId: 'session-2'
|
||||||
|
});
|
||||||
|
|
||||||
|
await clearHighMemoryAlert(userDataPath);
|
||||||
|
|
||||||
|
expect(await readHighMemoryAlert(userDataPath)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export type HighMemoryAlertReason = 'manual' | 'threshold';
|
||||||
|
|
||||||
|
export interface HighMemoryAlertRecord {
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
reason?: HighMemoryAlertReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHighMemoryAlertPath(userDataPath: string): string {
|
||||||
|
return path.join(userDataPath, 'diagnostics', 'high-memory-pending.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readHighMemoryAlert(userDataPath: string): Promise<HighMemoryAlertRecord | null> {
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(resolveHighMemoryAlertPath(userDataPath), 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as Partial<HighMemoryAlertRecord>;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof parsed.logFilePath !== 'string'
|
||||||
|
|| !parsed.logFilePath.trim()
|
||||||
|
|| typeof parsed.detectedAt !== 'number'
|
||||||
|
|| typeof parsed.peakWorkingSetKb !== 'number'
|
||||||
|
|| typeof parsed.sessionId !== 'string'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
logFilePath: parsed.logFilePath,
|
||||||
|
detectedAt: parsed.detectedAt,
|
||||||
|
peakWorkingSetKb: parsed.peakWorkingSetKb,
|
||||||
|
sessionId: parsed.sessionId,
|
||||||
|
...(parsed.reason === 'manual' || parsed.reason === 'threshold'
|
||||||
|
? { reason: parsed.reason }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeHighMemoryAlert(
|
||||||
|
userDataPath: string,
|
||||||
|
record: HighMemoryAlertRecord
|
||||||
|
): Promise<void> {
|
||||||
|
const filePath = resolveHighMemoryAlertPath(userDataPath);
|
||||||
|
|
||||||
|
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fsp.writeFile(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearHighMemoryAlert(userDataPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(resolveHighMemoryAlertPath(userDataPath));
|
||||||
|
} catch {
|
||||||
|
// Missing pending alert is fine.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
|
||||||
|
import { captureHighMemoryDiagnostics } from './high-memory-capture';
|
||||||
|
|
||||||
|
vi.mock('./immediate-renderer-samples.collector', () => ({
|
||||||
|
collectImmediateRendererSamples: vi.fn(async () => [])
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./session-context.collector', () => ({
|
||||||
|
collectSessionContext: vi.fn(() => ({
|
||||||
|
platform: 'linux',
|
||||||
|
userDataPath: '/tmp/user-data'
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('captureHighMemoryDiagnostics', () => {
|
||||||
|
let userDataPath = '';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-capture-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a diagnostics snapshot and returns an alert record', async () => {
|
||||||
|
const record = await captureHighMemoryDiagnostics({
|
||||||
|
userDataPath,
|
||||||
|
sessionStartedAt: Date.now() - 60_000,
|
||||||
|
metrics: {
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
processes: [
|
||||||
|
{
|
||||||
|
pid: 1,
|
||||||
|
type: 'Browser',
|
||||||
|
workingSetKb: 2_200_000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
totalWorkingSetKb: 2_200_000,
|
||||||
|
writer: null,
|
||||||
|
mainWindow: null,
|
||||||
|
reason: 'manual'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(record.peakWorkingSetKb).toBe(2_200_000);
|
||||||
|
expect(record.reason).toBe('manual');
|
||||||
|
expect(record.logFilePath).toContain(userDataPath);
|
||||||
|
await expect(fsp.stat(record.logFilePath)).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import type { AppMetricsSnapshot } from '../app-metrics';
|
||||||
|
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
|
||||||
|
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
|
||||||
|
import { collectSessionContext } from './session-context.collector';
|
||||||
|
import type { HighMemoryAlertRecord } from './high-memory-alert.store';
|
||||||
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
|
import { PerfDiagWriter } from './diagnostics.writer';
|
||||||
|
|
||||||
|
export type HighMemoryCaptureReason = 'manual' | 'threshold';
|
||||||
|
|
||||||
|
export interface CaptureHighMemoryDiagnosticsInput {
|
||||||
|
userDataPath: string;
|
||||||
|
sessionStartedAt: number;
|
||||||
|
metrics: AppMetricsSnapshot;
|
||||||
|
totalWorkingSetKb: number;
|
||||||
|
writer: PerfDiagWriter | null;
|
||||||
|
mainWindow: BrowserWindow | null;
|
||||||
|
reason: HighMemoryCaptureReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureHighMemoryDiagnostics(
|
||||||
|
input: CaptureHighMemoryDiagnosticsInput
|
||||||
|
): Promise<HighMemoryAlertRecord> {
|
||||||
|
const detectedAt = Date.now();
|
||||||
|
const writer = input.writer ?? new PerfDiagWriter({
|
||||||
|
userDataPath: input.userDataPath,
|
||||||
|
sessionId: `${input.reason}-${detectedAt.toString(36)}-${process.pid}`
|
||||||
|
});
|
||||||
|
const immediateRendererEntries = await collectImmediateRendererSamples(input.mainWindow);
|
||||||
|
const environment = collectSessionContext({
|
||||||
|
sessionStartedAt: input.sessionStartedAt,
|
||||||
|
userDataPath: input.userDataPath
|
||||||
|
});
|
||||||
|
|
||||||
|
appendEntries(writer, immediateRendererEntries);
|
||||||
|
appendEntries(writer, [
|
||||||
|
{
|
||||||
|
collectedAt: detectedAt,
|
||||||
|
source: 'main',
|
||||||
|
type: 'environment',
|
||||||
|
payload: {
|
||||||
|
...environment
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collectedAt: detectedAt,
|
||||||
|
source: 'main',
|
||||||
|
type: 'high-memory',
|
||||||
|
payload: buildHighMemoryDiagnosticPayload({
|
||||||
|
detectedAt,
|
||||||
|
totalWorkingSetKb: input.totalWorkingSetKb,
|
||||||
|
metrics: input.metrics,
|
||||||
|
environment,
|
||||||
|
mainProcessMemory: process.memoryUsage(),
|
||||||
|
ringEntries: writer.bufferedEntries,
|
||||||
|
immediateRendererEntries,
|
||||||
|
sessionId: writer.sessionId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await writer.flushSnapshot(
|
||||||
|
input.reason === 'manual' ? 'manual-export' : 'high-memory-threshold'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
logFilePath: writer.snapshotFilePath,
|
||||||
|
detectedAt,
|
||||||
|
peakWorkingSetKb: input.totalWorkingSetKb,
|
||||||
|
sessionId: writer.sessionId,
|
||||||
|
reason: input.reason
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendEntries(writer: PerfDiagWriter, entries: readonly PerfDiagEntry[]): void {
|
||||||
|
for (const entry of entries) {
|
||||||
|
writer.append(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
|
import {
|
||||||
|
buildHighMemoryDiagnosticPayload,
|
||||||
|
buildHighMemorySummary,
|
||||||
|
extractLatestRendererSamples,
|
||||||
|
extractProcessHistory,
|
||||||
|
formatMemoryUsageMb,
|
||||||
|
rankProcessesByWorkingSet,
|
||||||
|
summarizeRingBuffer
|
||||||
|
} from './high-memory-snapshot.rules';
|
||||||
|
|
||||||
|
function createProcess(overrides: Partial<{
|
||||||
|
pid: number;
|
||||||
|
type: string;
|
||||||
|
workingSetKb: number | null;
|
||||||
|
peakWorkingSetKb: number | null;
|
||||||
|
privateBytesKb: number | null;
|
||||||
|
creationTime: number | null;
|
||||||
|
cpuPercent: number | null;
|
||||||
|
}> = {}) {
|
||||||
|
return {
|
||||||
|
pid: 1,
|
||||||
|
type: 'Tab',
|
||||||
|
workingSetKb: 1024,
|
||||||
|
peakWorkingSetKb: null,
|
||||||
|
privateBytesKb: null,
|
||||||
|
creationTime: null,
|
||||||
|
cpuPercent: null,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('high-memory-snapshot.rules', () => {
|
||||||
|
it('ranks processes by working set and computes share percentages', () => {
|
||||||
|
const tabProcess = createProcess({ pid: 1, type: 'Tab', workingSetKb: 512_000 });
|
||||||
|
const gpuProcess = createProcess({ pid: 2, type: 'GPU', workingSetKb: 1_536_000 });
|
||||||
|
const ranked = rankProcessesByWorkingSet([tabProcess, gpuProcess], 2_048_000);
|
||||||
|
|
||||||
|
expect(ranked[0]?.type).toBe('GPU');
|
||||||
|
expect(ranked[0]?.sharePercent).toBe(75);
|
||||||
|
expect(ranked[1]?.sharePercent).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the latest renderer store, heap, and component samples', () => {
|
||||||
|
const entries: PerfDiagEntry[] = [
|
||||||
|
{
|
||||||
|
collectedAt: 1,
|
||||||
|
source: 'renderer',
|
||||||
|
type: 'store',
|
||||||
|
payload: { domains: { chat: 100 } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collectedAt: 2,
|
||||||
|
source: 'renderer',
|
||||||
|
type: 'heap',
|
||||||
|
payload: { usedJsHeapMb: 120 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collectedAt: 3,
|
||||||
|
source: 'renderer',
|
||||||
|
type: 'components',
|
||||||
|
payload: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collectedAt: 4,
|
||||||
|
source: 'renderer',
|
||||||
|
type: 'store',
|
||||||
|
payload: { domains: { chat: 500 } }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(extractLatestRendererSamples(entries)).toEqual({
|
||||||
|
store: { domains: { chat: 500 } },
|
||||||
|
heap: { usedJsHeapMb: 120 },
|
||||||
|
components: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts recent process history from the ring buffer', () => {
|
||||||
|
const entries: PerfDiagEntry[] = [
|
||||||
|
{
|
||||||
|
collectedAt: 1,
|
||||||
|
source: 'main',
|
||||||
|
type: 'process',
|
||||||
|
payload: { totalWorkingSetKb: 1000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collectedAt: 2,
|
||||||
|
source: 'main',
|
||||||
|
type: 'session',
|
||||||
|
payload: { event: 'noop' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collectedAt: 3,
|
||||||
|
source: 'main',
|
||||||
|
type: 'process',
|
||||||
|
payload: { totalWorkingSetKb: 2000 }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(extractProcessHistory(entries)).toEqual([{ collectedAt: 1, totalWorkingSetKb: 1000 }, { collectedAt: 3, totalWorkingSetKb: 2000 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarizes ring buffer entry counts', () => {
|
||||||
|
expect(summarizeRingBuffer([
|
||||||
|
{ collectedAt: 1, source: 'main', type: 'process', payload: {} },
|
||||||
|
{ collectedAt: 2, source: 'renderer', type: 'heap', payload: {} },
|
||||||
|
{ collectedAt: 3, source: 'main', type: 'process', payload: {} }
|
||||||
|
])).toEqual({
|
||||||
|
'main:process': 2,
|
||||||
|
'renderer:heap': 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a high-memory summary with threshold context', () => {
|
||||||
|
const summary = buildHighMemorySummary(
|
||||||
|
2_200_000,
|
||||||
|
[createProcess({ workingSetKb: 2_200_000 })],
|
||||||
|
1_700_000_000_000
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summary.totalWorkingSetGb).toBe('2.10');
|
||||||
|
expect(summary.thresholdGb).toBe('2.00');
|
||||||
|
expect(summary.topProcesses).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a comprehensive high-memory diagnostic payload', () => {
|
||||||
|
const payload = buildHighMemoryDiagnosticPayload({
|
||||||
|
detectedAt: 1_700_000_000_000,
|
||||||
|
totalWorkingSetKb: 2_200_000,
|
||||||
|
metrics: {
|
||||||
|
collectedAt: 1_700_000_000_000,
|
||||||
|
processes: [
|
||||||
|
createProcess({
|
||||||
|
workingSetKb: 2_200_000,
|
||||||
|
peakWorkingSetKb: 2_300_000,
|
||||||
|
privateBytesKb: 1_800_000,
|
||||||
|
creationTime: 1,
|
||||||
|
cpuPercent: 12
|
||||||
|
})
|
||||||
|
]
|
||||||
|
},
|
||||||
|
environment: { appVersion: '1.0.0' },
|
||||||
|
mainProcessMemory: {
|
||||||
|
rss: 64 * 1024 * 1024,
|
||||||
|
heapTotal: 32 * 1024 * 1024,
|
||||||
|
heapUsed: 16 * 1024 * 1024,
|
||||||
|
external: 8 * 1024 * 1024,
|
||||||
|
arrayBuffers: 1024
|
||||||
|
},
|
||||||
|
ringEntries: [
|
||||||
|
{
|
||||||
|
collectedAt: 1,
|
||||||
|
source: 'main',
|
||||||
|
type: 'process',
|
||||||
|
payload: { totalWorkingSetKb: 2_000_000 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
immediateRendererEntries: [
|
||||||
|
{
|
||||||
|
collectedAt: 2,
|
||||||
|
source: 'renderer',
|
||||||
|
type: 'heap',
|
||||||
|
payload: { usedJsHeapMb: 300, route: '/room/abc' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sessionId: 'session-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.event).toBe('high-memory-threshold');
|
||||||
|
expect(payload.summary).toMatchObject({
|
||||||
|
totalWorkingSetKb: 2_200_000
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.processHistory).toHaveLength(1);
|
||||||
|
expect(payload.recentRendererSamples).toEqual({
|
||||||
|
store: null,
|
||||||
|
heap: { usedJsHeapMb: 300, route: '/room/abc' },
|
||||||
|
components: null
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(formatMemoryUsageMb({
|
||||||
|
rss: 64 * 1024 * 1024,
|
||||||
|
heapTotal: 32 * 1024 * 1024,
|
||||||
|
heapUsed: 16 * 1024 * 1024,
|
||||||
|
external: 8 * 1024 * 1024,
|
||||||
|
arrayBuffers: 1024
|
||||||
|
})).toEqual({
|
||||||
|
rssMb: 64,
|
||||||
|
heapTotalMb: 32,
|
||||||
|
heapUsedMb: 16,
|
||||||
|
externalMb: 8,
|
||||||
|
arrayBuffersMb: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import type { AppMetricsProcessSnapshot, AppMetricsSnapshot } from '../app-metrics';
|
||||||
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
|
import { formatWorkingSetGb, HIGH_MEMORY_THRESHOLD_KB } from './high-memory-alert.rules';
|
||||||
|
import type { SessionContextSnapshot } from './session-context.collector';
|
||||||
|
|
||||||
|
export interface RankedProcessSnapshot extends AppMetricsProcessSnapshot {
|
||||||
|
sharePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HighMemorySummary {
|
||||||
|
detectedAt: number;
|
||||||
|
thresholdKb: number;
|
||||||
|
thresholdGb: string;
|
||||||
|
totalWorkingSetKb: number;
|
||||||
|
totalWorkingSetGb: string;
|
||||||
|
topProcesses: RankedProcessSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LatestRendererSamples {
|
||||||
|
store: Record<string, unknown> | null;
|
||||||
|
heap: Record<string, unknown> | null;
|
||||||
|
components: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rankProcessesByWorkingSet(
|
||||||
|
processes: readonly AppMetricsProcessSnapshot[],
|
||||||
|
totalWorkingSetKb: number | null
|
||||||
|
): RankedProcessSnapshot[] {
|
||||||
|
const total = totalWorkingSetKb ?? 0;
|
||||||
|
|
||||||
|
return [...processes]
|
||||||
|
.filter((process) => process.workingSetKb != null && process.workingSetKb > 0)
|
||||||
|
.sort((left, right) => (right.workingSetKb ?? 0) - (left.workingSetKb ?? 0))
|
||||||
|
.map((process) => ({
|
||||||
|
...process,
|
||||||
|
sharePercent: total > 0
|
||||||
|
? Math.round(((process.workingSetKb ?? 0) / total) * 1000) / 10
|
||||||
|
: 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractLatestRendererSamples(entries: readonly PerfDiagEntry[]): LatestRendererSamples {
|
||||||
|
let store: Record<string, unknown> | null = null;
|
||||||
|
let heap: Record<string, unknown> | null = null;
|
||||||
|
let components: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||||
|
const entry = entries[index];
|
||||||
|
|
||||||
|
if (entry.source !== 'renderer') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store && entry.type === 'store') {
|
||||||
|
store = entry.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!heap && entry.type === 'heap') {
|
||||||
|
heap = entry.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!components && entry.type === 'components') {
|
||||||
|
components = entry.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store && heap && components) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
heap,
|
||||||
|
components
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractProcessHistory(
|
||||||
|
entries: readonly PerfDiagEntry[],
|
||||||
|
limit = 24
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
const history: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||||
|
const entry = entries[index];
|
||||||
|
|
||||||
|
if (entry.type !== 'process') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.unshift({
|
||||||
|
collectedAt: entry.collectedAt,
|
||||||
|
...entry.payload
|
||||||
|
});
|
||||||
|
|
||||||
|
if (history.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeRingBuffer(entries: readonly PerfDiagEntry[]): Record<string, number> {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const key = `${entry.source}:${entry.type}`;
|
||||||
|
|
||||||
|
counts[key] = (counts[key] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHighMemorySummary(
|
||||||
|
totalWorkingSetKb: number,
|
||||||
|
processes: readonly AppMetricsProcessSnapshot[],
|
||||||
|
detectedAt: number
|
||||||
|
): HighMemorySummary {
|
||||||
|
return {
|
||||||
|
detectedAt,
|
||||||
|
thresholdKb: HIGH_MEMORY_THRESHOLD_KB,
|
||||||
|
thresholdGb: formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB),
|
||||||
|
totalWorkingSetKb,
|
||||||
|
totalWorkingSetGb: formatWorkingSetGb(totalWorkingSetKb),
|
||||||
|
topProcesses: rankProcessesByWorkingSet(processes, totalWorkingSetKb).slice(0, 12)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMemoryUsageMb(memoryUsage: NodeJS.MemoryUsage): Record<string, number> {
|
||||||
|
return {
|
||||||
|
rssMb: roundMb(memoryUsage.rss),
|
||||||
|
heapTotalMb: roundMb(memoryUsage.heapTotal),
|
||||||
|
heapUsedMb: roundMb(memoryUsage.heapUsed),
|
||||||
|
externalMb: roundMb(memoryUsage.external),
|
||||||
|
arrayBuffersMb: roundMb(memoryUsage.arrayBuffers ?? 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHighMemoryDiagnosticPayload(input: {
|
||||||
|
detectedAt: number;
|
||||||
|
totalWorkingSetKb: number;
|
||||||
|
metrics: AppMetricsSnapshot;
|
||||||
|
environment: SessionContextSnapshot;
|
||||||
|
mainProcessMemory: NodeJS.MemoryUsage;
|
||||||
|
ringEntries: readonly PerfDiagEntry[];
|
||||||
|
immediateRendererEntries: readonly PerfDiagEntry[];
|
||||||
|
sessionId: string;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
const mergedRingEntries = [...input.ringEntries, ...input.immediateRendererEntries];
|
||||||
|
const recentRendererSamples = extractLatestRendererSamples(mergedRingEntries);
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: 'high-memory-threshold',
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
summary: buildHighMemorySummary(
|
||||||
|
input.totalWorkingSetKb,
|
||||||
|
input.metrics.processes,
|
||||||
|
input.detectedAt
|
||||||
|
),
|
||||||
|
environment: input.environment,
|
||||||
|
metrics: input.metrics,
|
||||||
|
mainProcessMemory: input.mainProcessMemory,
|
||||||
|
mainProcessMemoryMb: formatMemoryUsageMb(input.mainProcessMemory),
|
||||||
|
processHistory: extractProcessHistory(mergedRingEntries),
|
||||||
|
ringSummary: summarizeRingBuffer(mergedRingEntries),
|
||||||
|
recentRendererSamples,
|
||||||
|
immediateRendererSamples: input.immediateRendererEntries.map((entry) => ({
|
||||||
|
collectedAt: entry.collectedAt,
|
||||||
|
type: entry.type,
|
||||||
|
payload: entry.payload
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundMb(bytes: number): number {
|
||||||
|
return Math.round((bytes / (1024 * 1024)) * 100) / 100;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
|
|
||||||
|
export async function collectImmediateRendererSamples(
|
||||||
|
window: BrowserWindow | null | undefined
|
||||||
|
): Promise<PerfDiagEntry[]> {
|
||||||
|
if (!window || window.isDestroyed()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.webContents.executeJavaScript(`
|
||||||
|
(function () {
|
||||||
|
const collect = globalThis.__collectPerfDiagSample;
|
||||||
|
|
||||||
|
return typeof collect === 'function' ? collect() : [];
|
||||||
|
})()
|
||||||
|
`, true);
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
.filter((entry) => entry && typeof entry === 'object')
|
||||||
|
.map((entry) => normalizeImmediateRendererEntry(entry as Partial<PerfDiagEntry>));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImmediateRendererEntry(entry: Partial<PerfDiagEntry>): PerfDiagEntry {
|
||||||
|
return {
|
||||||
|
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||||
|
source: 'renderer',
|
||||||
|
type: entry.type ?? 'session',
|
||||||
|
payload: entry.payload ?? {}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
||||||
|
export {
|
||||||
|
clearHighMemoryAlert,
|
||||||
|
readHighMemoryAlert,
|
||||||
|
resolveHighMemoryAlertPath,
|
||||||
|
writeHighMemoryAlert
|
||||||
|
} from './high-memory-alert.store';
|
||||||
|
export type { HighMemoryAlertRecord } from './high-memory-alert.store';
|
||||||
|
export {
|
||||||
|
exceedsHighMemoryThreshold,
|
||||||
|
formatWorkingSetGb,
|
||||||
|
HIGH_MEMORY_THRESHOLD_KB
|
||||||
|
} from './high-memory-alert.rules';
|
||||||
export {
|
export {
|
||||||
attachRendererDiagnosticsHooks,
|
attachRendererDiagnosticsHooks,
|
||||||
ensurePerfDiagIpcRegistered,
|
ensurePerfDiagIpcRegistered,
|
||||||
getActivePerfDiagWriter,
|
getActivePerfDiagWriter,
|
||||||
|
HIGH_MEMORY_ALERT_PENDING_CHANNEL,
|
||||||
isPerfDiagActive,
|
isPerfDiagActive,
|
||||||
|
shutdownHighMemoryMonitoring,
|
||||||
shutdownPerfDiagnostics,
|
shutdownPerfDiagnostics,
|
||||||
|
startHighMemoryMonitoring,
|
||||||
startPerfDiagnostics
|
startPerfDiagnostics
|
||||||
} from './diagnostics.lifecycle';
|
} from './diagnostics.lifecycle';
|
||||||
export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models';
|
export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models';
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
export interface SessionWindowSnapshot {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
url: string | null;
|
||||||
|
focused: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
destroyed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionContextSnapshot {
|
||||||
|
collectedAt: number;
|
||||||
|
sessionStartedAt: number;
|
||||||
|
uptimeMs: number;
|
||||||
|
appVersion: string;
|
||||||
|
electronVersion: string;
|
||||||
|
chromeVersion: string;
|
||||||
|
nodeVersion: string;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
arch: string;
|
||||||
|
osType: string;
|
||||||
|
osRelease: string;
|
||||||
|
osVersion: string | null;
|
||||||
|
totalMemKb: number;
|
||||||
|
freeMemKb: number;
|
||||||
|
userDataPath: string;
|
||||||
|
appPath: string;
|
||||||
|
isPackaged: boolean;
|
||||||
|
locale: string;
|
||||||
|
windowCount: number;
|
||||||
|
windows: SessionWindowSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectSessionContext(input: {
|
||||||
|
sessionStartedAt: number;
|
||||||
|
userDataPath: string;
|
||||||
|
}): SessionContextSnapshot {
|
||||||
|
const collectedAt = Date.now();
|
||||||
|
|
||||||
|
return {
|
||||||
|
collectedAt,
|
||||||
|
sessionStartedAt: input.sessionStartedAt,
|
||||||
|
uptimeMs: Math.max(0, collectedAt - input.sessionStartedAt),
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
electronVersion: process.versions.electron ?? 'unknown',
|
||||||
|
chromeVersion: process.versions.chrome ?? 'unknown',
|
||||||
|
nodeVersion: process.versions.node ?? 'unknown',
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
osType: os.type(),
|
||||||
|
osRelease: os.release(),
|
||||||
|
osVersion: readOsVersion(),
|
||||||
|
totalMemKb: Math.round(os.totalmem() / 1024),
|
||||||
|
freeMemKb: Math.round(os.freemem() / 1024),
|
||||||
|
userDataPath: input.userDataPath,
|
||||||
|
appPath: app.getAppPath(),
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
locale: app.getLocale(),
|
||||||
|
windowCount: BrowserWindow.getAllWindows().length,
|
||||||
|
windows: BrowserWindow.getAllWindows().map(collectWindowSnapshot)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectWindowSnapshot(window: BrowserWindow): SessionWindowSnapshot {
|
||||||
|
let url: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
url = window.webContents.getURL() || null;
|
||||||
|
} catch {
|
||||||
|
url = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: window.id,
|
||||||
|
title: window.getTitle(),
|
||||||
|
url,
|
||||||
|
focused: window.isFocused(),
|
||||||
|
visible: window.isVisible(),
|
||||||
|
destroyed: window.isDestroyed()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOsVersion(): string | null {
|
||||||
|
try {
|
||||||
|
return os.version?.() ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { isReadableRegularFile } from './file-read.rules';
|
||||||
|
|
||||||
|
describe('file-read.rules', () => {
|
||||||
|
it('accepts regular files', () => {
|
||||||
|
expect(isReadableRegularFile({ isFile: () => true })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects directories and other non-file paths', () => {
|
||||||
|
expect(isReadableRegularFile({ isFile: () => false })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { Stats } from 'fs';
|
||||||
|
|
||||||
|
/** Only regular files can be read through the read-file IPC surface. */
|
||||||
|
export function isReadableRegularFile(stats: Pick<Stats, 'isFile'>): boolean {
|
||||||
|
return stats.isFile();
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
grantPluginReadRoot,
|
grantPluginReadRoot,
|
||||||
resolveReadablePath
|
resolveReadablePath
|
||||||
} from '../path-jail';
|
} from '../path-jail';
|
||||||
|
import { isReadableRegularFile } from './file-read.rules';
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
|
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
|
||||||
@@ -654,9 +655,19 @@ export function setupSystemHandlers(): void {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fsp.stat(scopedPath);
|
||||||
|
|
||||||
|
if (!isReadableRegularFile(stats)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await fsp.readFile(scopedPath);
|
const data = await fsp.readFile(scopedPath);
|
||||||
|
|
||||||
return data.toString('base64');
|
return data.toString('base64');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
|
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
|
||||||
@@ -666,6 +677,13 @@ export function setupSystemHandlers(): void {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fsp.stat(scopedPath);
|
||||||
|
|
||||||
|
if (!isReadableRegularFile(stats)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const fileHandle = await fsp.open(scopedPath, 'r');
|
const fileHandle = await fsp.open(scopedPath, 'r');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -678,6 +696,9 @@ export function setupSystemHandlers(): void {
|
|||||||
} finally {
|
} finally {
|
||||||
await fileHandle.close();
|
await fileHandle.close();
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
|
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
|
||||||
@@ -728,6 +749,17 @@ export function setupSystemHandlers(): void {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('append-file-bytes', async (_event, filePath: string, bytes: Uint8Array) => {
|
||||||
|
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.appendFile(scopedPath, Buffer.from(bytes));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('delete-file', async (_event, filePath: string) => {
|
ipcMain.handle('delete-file', async (_event, filePath: string) => {
|
||||||
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ describe('path-jail', () => {
|
|||||||
await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath);
|
await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts diagnostics log paths under diagnostics', async () => {
|
||||||
|
const diagnosticsDir = path.join(tempRoot, 'diagnostics');
|
||||||
|
|
||||||
|
fs.mkdirSync(diagnosticsDir, { recursive: true });
|
||||||
|
const logPath = path.join(diagnosticsDir, 'perf-session.jsonl');
|
||||||
|
|
||||||
|
fs.writeFileSync(logPath, '{}');
|
||||||
|
|
||||||
|
await expect(assertPathUnderRoot(tempRoot, logPath)).resolves.toBe(logPath);
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts cached plugin bundle paths under plugin-bundles', async () => {
|
it('accepts cached plugin bundle paths under plugin-bundles', async () => {
|
||||||
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0');
|
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0');
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export const DEFAULT_USER_DATA_SUBDIRS = [
|
|||||||
'plugin-bundles',
|
'plugin-bundles',
|
||||||
'plugin-cache',
|
'plugin-cache',
|
||||||
'themes',
|
'themes',
|
||||||
'metoyou'
|
'metoyou',
|
||||||
|
'diagnostics'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function isPathInside(parentPath: string, candidatePath: string): boolean {
|
export function isPathInside(parentPath: string, candidatePath: string): boolean {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
|||||||
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||||
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
||||||
|
const HIGH_MEMORY_ALERT_PENDING_CHANNEL = 'high-memory-alert-pending';
|
||||||
|
|
||||||
export interface LinuxScreenShareAudioRoutingInfo {
|
export interface LinuxScreenShareAudioRoutingInfo {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -259,6 +260,29 @@ export interface ElectronAPI {
|
|||||||
type: string;
|
type: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
|
getPendingHighMemoryAlert: () => Promise<{
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
reason?: 'manual' | 'threshold';
|
||||||
|
} | null>;
|
||||||
|
acknowledgeHighMemoryAlert: () => Promise<boolean>;
|
||||||
|
exportHighMemoryDiagnostics: () => Promise<{
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
reason?: 'manual' | 'threshold';
|
||||||
|
}>;
|
||||||
|
onHighMemoryAlertPending: (listener: (alert: {
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
reason?: 'manual' | 'threshold';
|
||||||
|
}) => void) => () => void;
|
||||||
|
showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openCurrentDataFolder: () => Promise<boolean>;
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
exportUserData: () => Promise<ExportUserDataResult>;
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
@@ -327,6 +351,7 @@ export interface ElectronAPI {
|
|||||||
grantPluginReadRoot: (rootPath: string) => Promise<boolean>;
|
grantPluginReadRoot: (rootPath: string) => Promise<boolean>;
|
||||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
|
appendFileBytes: (filePath: string, data: Uint8Array) => Promise<boolean>;
|
||||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||||
saveExistingFileAs: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
saveExistingFileAs: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||||
openFilePath: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
|
openFilePath: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
|
||||||
@@ -400,6 +425,26 @@ const electronAPI: ElectronAPI = {
|
|||||||
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
||||||
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
||||||
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
||||||
|
getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'),
|
||||||
|
acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-high-memory-alert'),
|
||||||
|
exportHighMemoryDiagnostics: () => ipcRenderer.invoke('export-high-memory-diagnostics'),
|
||||||
|
onHighMemoryAlertPending: (listener) => {
|
||||||
|
const wrappedListener = (_event: Electron.IpcRendererEvent, alert: {
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
}) => {
|
||||||
|
listener(alert);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcRenderer.on(HIGH_MEMORY_ALERT_PENDING_CHANNEL, wrappedListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener(HIGH_MEMORY_ALERT_PENDING_CHANNEL, wrappedListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
showLogFileInFolder: (filePath) => ipcRenderer.invoke('show-log-file-in-folder', filePath),
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||||
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
||||||
@@ -467,6 +512,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath),
|
grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath),
|
||||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||||
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
|
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
|
||||||
|
appendFileBytes: (filePath, data) => ipcRenderer.invoke('append-file-bytes', filePath, data),
|
||||||
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
||||||
saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName),
|
saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName),
|
||||||
openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath),
|
openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath),
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules';
|
import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules';
|
||||||
import { readDesktopSettings } from '../desktop-settings';
|
import { readDesktopSettings } from '../desktop-settings';
|
||||||
|
import { DEV_CLIENT_LOAD_ATTEMPTS, loadDevelopmentClientWithRetry } from './dev-client-load.rules';
|
||||||
import { resolveDevelopmentClientUrl } from './dev-client-url.rules';
|
import { resolveDevelopmentClientUrl } from './dev-client-url.rules';
|
||||||
|
import { shouldRegisterDisplayMediaHandler } from './display-media-handler.rules';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let closeToTrayEnabled = true;
|
let closeToTrayEnabled = true;
|
||||||
let appQuitting = false;
|
let appQuitting = false;
|
||||||
let youtubeRequestHeadersConfigured = false;
|
let youtubeRequestHeadersConfigured = false;
|
||||||
|
let displayMediaHandlerConfigured = false;
|
||||||
|
|
||||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||||
const YOUTUBE_EMBED_REFERRER = 'https://toju.app/';
|
const YOUTUBE_EMBED_REFERRER = 'https://toju.app/';
|
||||||
@@ -189,31 +192,12 @@ function emitWindowState(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWindow(): Promise<void> {
|
function ensureDisplayMediaRequestHandler(): void {
|
||||||
const windowIconPath = getWindowIconPath();
|
if (!shouldRegisterDisplayMediaHandler(process.platform, displayMediaHandlerConfigured)) {
|
||||||
|
return;
|
||||||
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
|
||||||
ensureTray();
|
|
||||||
ensureYoutubeEmbedRequestHeaders();
|
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
|
||||||
width: 1400,
|
|
||||||
height: 900,
|
|
||||||
minWidth: 800,
|
|
||||||
minHeight: 600,
|
|
||||||
frame: false,
|
|
||||||
title: DESKTOP_APP_DISPLAY_NAME,
|
|
||||||
titleBarStyle: 'hidden',
|
|
||||||
backgroundColor: '#0a0a0f',
|
|
||||||
...(windowIconPath ? { icon: windowIconPath } : {}),
|
|
||||||
webPreferences: {
|
|
||||||
backgroundThrottling: false,
|
|
||||||
nodeIntegration: false,
|
|
||||||
contextIsolation: true,
|
|
||||||
preload: path.join(__dirname, '..', 'preload.js'),
|
|
||||||
webSecurity: true
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
displayMediaHandlerConfigured = true;
|
||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
session.defaultSession.setDisplayMediaRequestHandler(
|
session.defaultSession.setDisplayMediaRequestHandler(
|
||||||
@@ -241,9 +225,10 @@ export async function createWindow(): Promise<void> {
|
|||||||
},
|
},
|
||||||
{ useSystemPicker: true }
|
{ useSystemPicker: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
session.defaultSession.setDisplayMediaRequestHandler(
|
session.defaultSession.setDisplayMediaRequestHandler(
|
||||||
async (request, respond) => {
|
async (request, respond) => {
|
||||||
// On Windows the system picker (useSystemPicker: true) is preferred.
|
// On Windows the system picker (useSystemPicker: true) is preferred.
|
||||||
@@ -277,8 +262,82 @@ export async function createWindow(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describeError(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDevClientFailurePage(url: string, reason: string): string {
|
||||||
|
const escapedReason = reason.replace(/&/g, '&').replace(/</g, '<');
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html><body style="background:#0a0a0f;color:#e5e7eb;font:14px system-ui;padding:48px">
|
||||||
|
<h1 style="font-size:18px">The dev client did not load</h1>
|
||||||
|
<p>Could not load <code>${url}</code> after ${DEV_CLIENT_LOAD_ATTEMPTS} attempts.</p>
|
||||||
|
<p style="color:#f87171"><code>${escapedReason}</code></p>
|
||||||
|
<p>Check that <code>npm run dev</code> is still running, then reload with Ctrl+R.</p>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevelopmentClient(window: BrowserWindow, url: string): Promise<void> {
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
const outcome = await loadDevelopmentClientWithRetry({
|
||||||
|
isAborted: () => window.isDestroyed(),
|
||||||
|
load: () => window.loadURL(url),
|
||||||
|
onGiveUp: (attempts, error) => {
|
||||||
|
lastError = error;
|
||||||
|
console.error(`[Window] Dev client at ${url} failed after ${attempts} attempts: ${describeError(error)}`);
|
||||||
|
},
|
||||||
|
onRetry: (attempt, error) => {
|
||||||
|
lastError = error;
|
||||||
|
console.warn(`[Window] Dev client at ${url} not ready (attempt ${attempt}): ${describeError(error)}. Retrying.`);
|
||||||
|
},
|
||||||
|
wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (outcome !== 'failed' || window.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failurePage = buildDevClientFailurePage(url, describeError(lastError));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(failurePage)}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Window] Could not show the dev client failure page: ${describeError(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWindow(): Promise<void> {
|
||||||
|
const windowIconPath = getWindowIconPath();
|
||||||
|
|
||||||
|
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
||||||
|
ensureTray();
|
||||||
|
ensureYoutubeEmbedRequestHeaders();
|
||||||
|
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1400,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
frame: false,
|
||||||
|
title: DESKTOP_APP_DISPLAY_NAME,
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
|
backgroundColor: '#0a0a0f',
|
||||||
|
...(windowIconPath ? { icon: windowIconPath } : {}),
|
||||||
|
webPreferences: {
|
||||||
|
backgroundThrottling: false,
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
preload: path.join(__dirname, '..', 'preload.js'),
|
||||||
|
webSecurity: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureDisplayMediaRequestHandler();
|
||||||
|
|
||||||
if (process.env['NODE_ENV'] === 'development') {
|
if (process.env['NODE_ENV'] === 'development') {
|
||||||
await mainWindow.loadURL(resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
|
await loadDevelopmentClient(mainWindow, resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
|
||||||
|
|
||||||
if (process.env['DEBUG_DEVTOOLS'] === '1') {
|
if (process.env['DEBUG_DEVTOOLS'] === '1') {
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEV_CLIENT_LOAD_ATTEMPTS,
|
||||||
|
DevClientLoadDeps,
|
||||||
|
loadDevelopmentClientWithRetry
|
||||||
|
} from './dev-client-load.rules';
|
||||||
|
|
||||||
|
function createDeps(overrides: Partial<DevClientLoadDeps> = {}): DevClientLoadDeps {
|
||||||
|
return {
|
||||||
|
isAborted: () => false,
|
||||||
|
load: vi.fn().mockResolvedValue(undefined),
|
||||||
|
onGiveUp: vi.fn(),
|
||||||
|
onRetry: vi.fn(),
|
||||||
|
wait: vi.fn().mockResolvedValue(undefined),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loadDevelopmentClientWithRetry', () => {
|
||||||
|
it('loads once when the dev server answers', async () => {
|
||||||
|
const deps = createDeps();
|
||||||
|
|
||||||
|
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('loaded');
|
||||||
|
expect(deps.load).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.onRetry).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a rebuild gap and reports the recovered load', async () => {
|
||||||
|
const load = vi.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('ERR_CONNECTION_REFUSED (-102)'))
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
const deps = createDeps({ load });
|
||||||
|
|
||||||
|
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('loaded');
|
||||||
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
|
expect(deps.onRetry).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.wait).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports failure instead of throwing so the caller still wires the window', async () => {
|
||||||
|
const error = new Error('ERR_FAILED (-2)');
|
||||||
|
const deps = createDeps({ load: vi.fn().mockRejectedValue(error) });
|
||||||
|
|
||||||
|
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('failed');
|
||||||
|
expect(deps.load).toHaveBeenCalledTimes(DEV_CLIENT_LOAD_ATTEMPTS);
|
||||||
|
expect(deps.onGiveUp).toHaveBeenCalledWith(DEV_CLIENT_LOAD_ATTEMPTS, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops retrying once the window is gone', async () => {
|
||||||
|
let windowAlive = true;
|
||||||
|
|
||||||
|
const load = vi.fn().mockImplementation(() => {
|
||||||
|
windowAlive = false;
|
||||||
|
|
||||||
|
return Promise.reject(new Error('ERR_FAILED (-2)'));
|
||||||
|
});
|
||||||
|
const deps = createDeps({
|
||||||
|
isAborted: () => !windowAlive,
|
||||||
|
load
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('aborted');
|
||||||
|
expect(load).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.onGiveUp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export const DEV_CLIENT_LOAD_ATTEMPTS = 10;
|
||||||
|
export const DEV_CLIENT_RETRY_DELAY_MS = 500;
|
||||||
|
|
||||||
|
export type DevClientLoadOutcome = 'loaded' | 'aborted' | 'failed';
|
||||||
|
|
||||||
|
export interface DevClientLoadDeps {
|
||||||
|
load: () => Promise<void>;
|
||||||
|
isAborted: () => boolean;
|
||||||
|
wait: (delayMs: number) => Promise<void>;
|
||||||
|
onRetry: (attempt: number, error: unknown) => void;
|
||||||
|
onGiveUp: (attempts: number, error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dev client is served by a watch-mode build, so a load can fail for
|
||||||
|
* reasons that resolve on their own: a rebuild in flight, or a shutdown that
|
||||||
|
* aborted the navigation. Failing hard skipped every window listener
|
||||||
|
* registered after the load and left a blank window with no message, so this
|
||||||
|
* retries and always reports instead of throwing.
|
||||||
|
*/
|
||||||
|
export async function loadDevelopmentClientWithRetry(deps: DevClientLoadDeps): Promise<DevClientLoadOutcome> {
|
||||||
|
for (let attempt = 1; attempt <= DEV_CLIENT_LOAD_ATTEMPTS; attempt += 1) {
|
||||||
|
if (deps.isAborted()) {
|
||||||
|
return 'aborted';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deps.load();
|
||||||
|
|
||||||
|
return 'loaded';
|
||||||
|
} catch (error) {
|
||||||
|
if (deps.isAborted()) {
|
||||||
|
return 'aborted';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === DEV_CLIENT_LOAD_ATTEMPTS) {
|
||||||
|
deps.onGiveUp(attempt, error);
|
||||||
|
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.onRetry(attempt, error);
|
||||||
|
|
||||||
|
await deps.wait(DEV_CLIENT_RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { shouldRegisterDisplayMediaHandler } from './display-media-handler.rules';
|
||||||
|
|
||||||
|
describe('shouldRegisterDisplayMediaHandler', () => {
|
||||||
|
it('registers once for platforms that need a fallback picker', () => {
|
||||||
|
expect(shouldRegisterDisplayMediaHandler('linux', false)).toBe(true);
|
||||||
|
expect(shouldRegisterDisplayMediaHandler('win32', false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-register on window recreation', () => {
|
||||||
|
expect(shouldRegisterDisplayMediaHandler('linux', true)).toBe(false);
|
||||||
|
expect(shouldRegisterDisplayMediaHandler('win32', true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never registers on platforms with a native picker', () => {
|
||||||
|
expect(shouldRegisterDisplayMediaHandler('darwin', false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* The display-media request handler is a session-level singleton. Registering
|
||||||
|
* it inside `createWindow()` without a guard re-installed a fresh handler
|
||||||
|
* (holding fresh closures) every time the window was recreated from the tray
|
||||||
|
* or a deep link. Registration happens at most once per app run.
|
||||||
|
*/
|
||||||
|
export function shouldRegisterDisplayMediaHandler(
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
alreadyConfigured: boolean
|
||||||
|
): boolean {
|
||||||
|
if (alreadyConfigured) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return platform === 'linux' || platform === 'win32';
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
"build:prod:win": "npm run build:prod:all && electron-builder --win",
|
"build:prod:win": "npm run build:prod:all && electron-builder --win",
|
||||||
"dev": "npm run build:electron && npm run electron:full",
|
"dev": "npm run build:electron && npm run electron:full",
|
||||||
"dev:app": "npm run electron:dev",
|
"dev:app": "npm run electron:dev",
|
||||||
|
"dev:peer": "./dev-peer.sh",
|
||||||
"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 \"toju-app/src/app/**/*.html\"",
|
"format": "prettier --write \"toju-app/src/app/**/*.html\"",
|
||||||
@@ -180,6 +181,7 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron"
|
"output": "dist-electron"
|
||||||
},
|
},
|
||||||
|
"afterPack": "tools/after-pack.js",
|
||||||
"files": [
|
"files": [
|
||||||
"!node_modules",
|
"!node_modules",
|
||||||
"dist/client/**/*",
|
"dist/client/**/*",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user