Compare commits

..

29 Commits

Author SHA1 Message Date
Myx
07e91a0d09 fix: Bug - Add logout in mobile version of settings, allow clearing data on android
All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 7m55s
Queue Release Build / build-windows (push) Successful in 28m37s
Queue Release Build / build-linux (push) Successful in 47m3s
Queue Release Build / build-android (push) Successful in 20m33s
Queue Release Build / finalize (push) Successful in 3m48s
Expose settings logout on mobile where the title bar is hidden, and enable
Capacitor data settings with storage visibility and local erase/sign-out.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 22:31:40 +02:00
Myx
cb59af6b6c fix: Bug - Login screen shows up on unreachable signal servers
Skip authorize login navigation when a signal server endpoint is offline or
unreachable; gate connection and credential flows on online status only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 22:08:53 +02:00
Myx
6b9a39fe4a fix: Bug - Android app view is below titlebar and action bar on android
Respect Android system bar insets with safe-area shell padding, inset-aware modal and bottom-sheet layouts, transparent edge-to-edge themes, and a Capacitor SystemBars refresh on mobile startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 21:24:48 +02:00
Myx
a01abbb1bf fix: Bug - Android app is zoomed in
Scale launcher and splash brand assets to the adaptive-icon safe zone and a smaller splash ratio so circular launcher masks and the launch splash no longer crop the cat face.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 21:20:01 +02:00
Myx
bdea95511d fix: Bug - Android app doesn't ask for permissions
Prompt for microphone, camera, and notification runtime permissions during Capacitor startup, and fall back to WebView getUserMedia when the native preflight bridge fails so voice joins still surface Android permission dialogs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 21:06:51 +02:00
Myx
9981aee602 chore: Update dev run
All checks were successful
Queue Release Build / prepare (push) Successful in 20s
Deploy Web Apps / deploy (push) Successful in 9m11s
Queue Release Build / build-windows (push) Successful in 28m8s
Queue Release Build / build-linux (push) Successful in 46m49s
Queue Release Build / build-android (push) Successful in 20m54s
Queue Release Build / finalize (push) Successful in 2m15s
2026-06-11 12:32:15 +02:00
Myx
31962aeb1a fix: restore build and stabilize E2E cross-signal behavior
Revert the automated member-ordering pass that broke Angular field init
(TS2729) and disable that rule until a safe reorder strategy exists.
Fix modal/confirm dialog i18n defaults via template fallbacks, search all
active endpoints (including offline), register foreign rooms with actor
owner IDs, sync profile display names from avatar summaries, and guard
dm-chat when a private call converts to a group conversation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 12:16:40 +02:00
Myx
79c6f91cd6 chore: enforce lint across codebase and ban "maybe" in identifiers
Remove member-ordering and complexity eslint-disable comments by reordering
class members and applying targeted fixes. Add metoyou/no-maybe-in-naming,
type-safe WebRTC e2e harness helpers, and resolve remaining lint errors so
npm run lint exits cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 11:08:26 +02:00
Myx
b630bacdc6 test: fix most e2e tests 2026-06-11 10:21:28 +02:00
Myx
1671a04f03 fix: Bug - Emojis should be user bound not client bound
Bind custom emoji library membership to the signed-in user instead of the
client. CustomEmojiService now tracks saved emoji ids per user id in
localStorage (metoyou_custom_emoji_saved:<userId>) and the picker only shows
the active user's set, seeded on first load from legacy savedByUser rows the
user created. This stops a second account on the same client (or Electron's
shared SQLite database) from inheriting another user's emoji picker, while
keeping synced assets available for message rendering.

Adds unit coverage for per-user scoping and a single-page-load Playwright e2e
that switches users client-side (second user joins the first user's server)
and asserts no library leak.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 04:09:06 +02:00
Myx
cb386394d0 fix: Bug - No login screen mobile phone on startup
Signed-out mobile visitors landing on / or /dashboard were intentionally
kept on a logged-out /dashboard, so they were never greeted with a login
screen on startup. Replace the imperative startup-redirect logic in
App.ngOnInit with a platform-agnostic pure rule
resolveUnauthenticatedStartupRedirect: non-public routes redirect to
/login (with a safe returnUrl), public routes (/login, /register,
/invite/...) are left alone. Mobile is no longer special-cased.

- Unit: auth-navigation.rules.spec.ts
- E2E: e2e/tests/mobile/mobile-login-on-startup.spec.ts (mobile viewport
  set before navigation; /dashboard and / both land on /login)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 03:38:16 +02:00
Myx
182828bb1e fix: Bug - Two devices sharing same user says "Shared from your device"
Gate the "Shared from your device" label and the hidden download
affordance on whether this device actually holds the file bytes, not on
whether the current user uploaded it. uploaderPeerId is the user id, so
the old check claimed ownership on every device of the uploader,
blocking view/download on second devices that only synced metadata.

Also include attachment metadata in the account_sync chat-sync-batch so
sibling devices learn about synced attachments at all.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 03:29:47 +02:00
Myx
49b602dbda fix: Bug - No android app icon 2026-06-11 03:03:10 +02:00
Myx
d72a027c9a fix: Bug - Video attachment on android gets sent in the message bubble above with no preview image 2026-06-11 02:44:22 +02:00
Myx
b1b3d93851 fix: Bug - Fresh users have the server list in dashboard completely empty until anything searched 2026-06-11 02:11:31 +02:00
Myx
494a05e606 fix: Bug - Local files should be remembered by client 2026-06-11 01:54:00 +02:00
Myx
5bf4f698df fix: Bug - Attachments gets syncronized corrupt 2026-06-11 00:37:06 +02:00
Myx
d174536272 fix: Chats doesn't sync for multi client users 2026-06-11 00:04:49 +02:00
Myx
d0aff6319d fix: should now sync with other devices
All checks were successful
Queue Release Build / prepare (push) Successful in 25s
Deploy Web Apps / deploy (push) Successful in 7m8s
Queue Release Build / build-windows (push) Successful in 28m10s
Queue Release Build / build-linux (push) Successful in 44m38s
Queue Release Build / build-android (push) Successful in 18m36s
Queue Release Build / finalize (push) Successful in 1m40s
2026-06-09 22:00:39 +02:00
Myx
1274ad9b46 fix: search 2026-06-09 22:00:06 +02:00
Myx
eb51f043ac fix: Major bug cleanup pass 1
All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 8m12s
Queue Release Build / build-windows (push) Successful in 27m44s
Queue Release Build / build-linux (push) Successful in 48m1s
Queue Release Build / build-android (push) Successful in 22m7s
Queue Release Build / finalize (push) Successful in 2m42s
2026-06-09 17:59:54 +02:00
Myx
80d7728e66 fix: No longer displays edited on all messages and fix Disconnected from signaling server on multiple clients
All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 8m19s
Queue Release Build / build-windows (push) Successful in 27m48s
Queue Release Build / build-linux (push) Successful in 47m35s
Queue Release Build / build-android (push) Successful in 21m15s
Queue Release Build / finalize (push) Successful in 2m30s
2026-06-07 15:05:12 +02:00
Myx
83456c018c fix: Fix multiple bugs with new authentication flow 2026-06-07 15:04:21 +02:00
Myx
9fc26b1ccf ci: fix apk build
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 6m35s
Queue Release Build / build-windows (push) Successful in 27m49s
Queue Release Build / build-linux (push) Successful in 44m3s
Queue Release Build / build-android (push) Successful in 20m59s
Queue Release Build / finalize (push) Successful in 2m40s
2026-06-05 18:37:17 +02:00
Myx
45675192a5 feat: Security 2026-06-05 18:34:01 +02:00
Myx
ee293d7daf feat: Rename to Toju and add translation
Some checks failed
Deploy Web Apps / deploy (push) Successful in 5m52s
Build Android APK / build-android-apk (push) Failing after 23m15s
Queue Release Build / prepare (push) Successful in 1m42s
Queue Release Build / build-linux (push) Failing after 9m33s
Queue Release Build / build-windows (push) Successful in 26m5s
Queue Release Build / finalize (push) Has been skipped
2026-06-05 17:17:29 +02:00
Myx
8ecfc9a1fe feat: Add slashcommand api 2026-06-05 17:12:26 +02:00
Myx
4070ef6caf perf: Add ram metric 2026-06-05 15:27:33 +02:00
Myx
a675f12e61 feat: Add deafen to pc, fix mobiel view, fix freeze on startup 2026-06-05 15:27:06 +02:00
738 changed files with 34476 additions and 4319 deletions

View File

@@ -195,7 +195,7 @@ export class LoginPage {
| ------------------- | ------------------ | ----------------------- | | ------------------- | ------------------ | ----------------------- |
| `/login` | `LoginPage` | `LoginComponent` | | `/login` | `LoginPage` | `LoginComponent` |
| `/register` | `RegisterPage` | `RegisterComponent` | | `/register` | `RegisterPage` | `RegisterComponent` |
| `/search` | `ServerSearchPage` | `ServerSearchComponent` | | `/servers` | `FindServersPage` | `FindServersComponent` |
| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` | | `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` |
| `/settings` | `SettingsPage` | `SettingsComponent` | | `/settings` | `SettingsPage` | `SettingsComponent` |
| `/invite/:inviteId` | `InvitePage` | `InviteComponent` | | `/invite/:inviteId` | `InvitePage` | `InviteComponent` |

View File

@@ -1,14 +1,6 @@
name: Build Android APK name: Build Android APK
on: on:
push:
branches: [main, master]
paths:
- 'toju-app/**'
- 'package.json'
- 'package-lock.json'
- 'tools/build-android-apk.sh'
- '.gitea/workflows/build-android-apk.yml'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -39,7 +31,14 @@ jobs:
- name: Install Android build toolchain - name: Install Android build toolchain
run: | run: |
apt-get update apt-get update
apt-get install -y --no-install-recommends openjdk-21-jdk wget unzip apt-get install -y --no-install-recommends wget unzip ca-certificates gnupg
# node:22 is Debian Bookworm — openjdk-21-jdk is not in default repos.
install -d /etc/apt/keyrings
wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor -o /etc/apt/keyrings/adoptium.gpg
echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list
apt-get update
apt-get install -y --no-install-recommends temurin-21-jdk
export ANDROID_SDK_ROOT=/opt/android-sdk export ANDROID_SDK_ROOT=/opt/android-sdk
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
@@ -54,7 +53,7 @@ jobs:
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV" echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV" echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
echo "JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64" >> "$GITHUB_ENV" echo "JAVA_HOME=/usr/lib/jvm/temurin-21-jdk-amd64" >> "$GITHUB_ENV"
echo "PATH=$PATH:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_ENV" echo "PATH=$PATH:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_ENV"
- name: Install dependencies - name: Install dependencies
@@ -62,12 +61,39 @@ jobs:
NODE_ENV: development NODE_ENV: development
run: npm ci run: npm ci
- name: Resolve release version
id: version
run: node tools/resolve-release-version.js --write-output
- name: Ensure draft release exists
id: release
env:
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: >
node tools/gitea-release.js ensure-draft
--server-url "${{ github.server_url }}"
--repository "${{ github.repository }}"
--tag "${{ steps.version.outputs.release_tag }}"
--target "${{ github.sha }}"
--name "${{ steps.version.outputs.release_name }}"
--body "Automated draft release from ${{ github.ref_name }} @ ${{ github.sha }}"
--write-output
- name: Build debug APK - name: Build debug APK
run: bash tools/build-android-apk.sh run: bash tools/build-android-apk.sh
- name: Upload APK artifact - name: Stage Android APK
uses: https://github.com/actions/upload-artifact@v4 run: |
with: mkdir -p dist-android
name: metoyou-android-debug-apk cp toju-app/android/app/build/outputs/apk/debug/app-debug.apk \
path: toju-app/android/app/build/outputs/apk/debug/app-debug.apk "dist-android/Toju-${{ steps.version.outputs.release_version }}-android-debug.apk"
if-no-files-found: error
- name: Upload Android APK to draft release
env:
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: >
node tools/gitea-release.js upload-built-assets
--server-url "${{ github.server_url }}"
--repository "${{ github.repository }}"
--release-id "${{ steps.release.outputs.release_id }}"
--dist-android dist-android

View File

@@ -110,6 +110,87 @@ jobs:
--dist-electron dist-electron --dist-electron dist-electron
--dist-server dist-server --dist-server dist-server
build-android:
needs: prepare
runs-on: ubuntu-latest
container: node:22
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Restore npm cache
uses: https://github.com/actions/cache@v4
with:
path: /root/.npm
key: npm-android-${{ hashFiles('package-lock.json') }}
restore-keys: npm-android-
- name: Restore Gradle cache
uses: https://github.com/actions/cache@v4
with:
path: |
/root/.gradle/caches
/root/.gradle/wrapper
key: gradle-android-${{ hashFiles('toju-app/android/**/*.gradle*', 'toju-app/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-android-
- name: Install Android build toolchain
run: |
apt-get update
apt-get install -y --no-install-recommends wget unzip ca-certificates gnupg
install -d /etc/apt/keyrings
wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor -o /etc/apt/keyrings/adoptium.gpg
echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list
apt-get update
apt-get install -y --no-install-recommends temurin-21-jdk
export ANDROID_SDK_ROOT=/opt/android-sdk
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
cd /tmp
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q commandlinetools-linux-11076708_latest.zip
mv cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest"
export PATH="$PATH:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools"
yes | sdkmanager --licenses >/dev/null
sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
echo "JAVA_HOME=/usr/lib/jvm/temurin-21-jdk-amd64" >> "$GITHUB_ENV"
echo "PATH=$PATH:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_ENV"
- name: Install dependencies
env:
NODE_ENV: development
run: npm ci
- name: Set CI release version
run: >
node tools/set-release-version.js
--version "${{ needs.prepare.outputs.release_version }}"
- name: Build debug APK
run: bash tools/build-android-apk.sh
- name: Stage Android APK
run: |
mkdir -p dist-android
cp toju-app/android/app/build/outputs/apk/debug/app-debug.apk \
"dist-android/Toju-${{ needs.prepare.outputs.release_version }}-android-debug.apk"
- name: Upload Android APK
env:
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: >
node tools/gitea-release.js upload-built-assets
--server-url "${{ github.server_url }}"
--repository "${{ github.repository }}"
--release-id "${{ needs.prepare.outputs.release_id }}"
--dist-android dist-android
build-windows: build-windows:
needs: prepare needs: prepare
runs-on: windows runs-on: windows

1
.gitignore vendored
View File

@@ -59,6 +59,7 @@ Thumbs.db
.env .env
.certs/ .certs/
/server/data/variables.json /server/data/variables.json
/server/data/metoyou.sqlite
dist-server/* dist-server/*
doc/** doc/**

View File

@@ -13,6 +13,7 @@ Reference on-demand (when the workflow triggers them — see `agents-docs/AGENT_
- `agents-docs/AGENTS_CONTEXT.md` — contract for updating `CONTEXT.md` / `CONTEXT-MAP.md` - `agents-docs/AGENTS_CONTEXT.md` — contract for updating `CONTEXT.md` / `CONTEXT-MAP.md`
- `agents-docs/AGENTS_ADRS.md` — contract for writing architecture decision records - `agents-docs/AGENTS_ADRS.md` — contract for writing architecture decision records
- `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: When working in a subdomain, also read its `CONTEXT.md` first:
@@ -74,6 +75,7 @@ The product client already maintains per-domain READMEs under `toju-app/src/app/
- **Feature docs:** `agents-docs/features/` - **Feature docs:** `agents-docs/features/`
- **Architecture decisions:** `agents-docs/adr/` - **Architecture decisions:** `agents-docs/adr/`
- **Context map:** `agents-docs/CONTEXT-MAP.md` - **Context map:** `agents-docs/CONTEXT-MAP.md`
- **Obsidian bug tracker:** `agents-docs/BUG_TRACKER.md`
- **Product-client domain:** `toju-app/CONTEXT.md` - **Product-client domain:** `toju-app/CONTEXT.md`
- **Desktop-shell domain:** `electron/CONTEXT.md` - **Desktop-shell domain:** `electron/CONTEXT.md`
- **Server domain:** `server/CONTEXT.md` - **Server domain:** `server/CONTEXT.md`

View File

@@ -0,0 +1,90 @@
# 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.
**Overrides** `agents-docs/AGENT_WORKFLOW.md` §8 (Autonomous Bug Fixing) unless the user explicitly asks you to fix a bug in code.
---
## Location
| Item | Path |
|------|------|
| Bug inbox | `/home/ludde/Nextcloud/Obsidian Vault/Log/Bugs/` |
| Attachments | `…/Bugs/attachments/<Bug title>/` |
| Dashboard | `/home/ludde/Nextcloud/Obsidian Vault/Log/Create bug.md` |
| Template | `/home/ludde/Nextcloud/Obsidian Vault/Log/Templates/Bug Report.md` |
---
## Allowed actions on vault files
Unless the user explicitly asks for more:
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)
To understand and solve bugs you may read freely:
- All bug notes and attachments under `Log/Bugs/`
- The full MetoYou repo (code, tests, logs, docs)
- 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.
---
## Bug note format
Each note is Markdown with YAML frontmatter:
```yaml
---
title: Bug - …
type: bug
status: Open # Open | Resolved | Closed
priority: Low | Medium | High | Critical
severity: Low | Medium | High | Critical
environment:
created: YYYY-MM-DD HH:mm
tags: [bug]
---
```
Body sections: **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.

View File

@@ -124,7 +124,8 @@ Behavioral changes to any of these qualify as a feature-doc update under the rul
- `release-draft.yml` — queues release builds on push to `main` / `master` - `release-draft.yml` — queues release builds on push to `main` / `master`
- `publish-draft-release.yml` — publishes draft releases - `publish-draft-release.yml` — publishes draft releases
- `deploy-web-apps.yml` — deploys the marketing site and Docusaurus docs - `deploy-web-apps.yml` — deploys the marketing site and Docusaurus docs
- `build-android-apk.yml`builds a debug Capacitor Android APK on push (mobile-related paths) or manual dispatch; uploads `app-debug.apk` as a workflow artifact - `build-android-apk.yml`manual **workflow_dispatch** debug Capacitor Android APK build; uploads `Toju-<version>-android-debug.apk` to the draft release (same path as desktop assets)
- `release-draft.yml` job `build-android` — builds and uploads the debug APK to each queued draft release alongside desktop/server archives
- All checks must pass before merging a PR - All checks must pass before merging a PR
- Workflow status is visible in the Gitea PR view; use the web UI or `tea` CLI to inspect runs - Workflow status is visible in the Gitea PR view; use the web UI or `tea` CLI to inspect runs

View File

@@ -8,7 +8,10 @@ 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.
- [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.
- [Message Integrity](features/message-integrity.md) — signed P2P message revision chains, inventory `headHash` convergence, and Ed25519 signing-key registration on the signaling server.
- [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. - [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints (server) consumed by the `/dashboard` and `/servers` client pages.
- [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.

View File

@@ -25,6 +25,160 @@ Durable rules for AI agents working on this project. Read this file at session s
## Lessons ## Lessons
### 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.
- **Rule:** when state is "per signed-in user" but the asset/row store is shared (Electron `custom_emojis`, or a renderer singleton that survives logout), key the membership by user id in its own store (`localStorage` `metoyou_custom_emoji_saved:<userId>`, mirroring the existing per-user usage ranking) and rebuild it in `loadForUser`; never rely on a global row flag or assume the singleton was reset on logout.
- **Why:** the browser already isolates rows per-user database, so the leak only reproduces in-session (no reload) and on Electron's shared DB — both invisible if you only test reloads; a row-level flag also can't represent two local users saving the same asset.
- **Example:** `CustomEmojiService.resolveSavedIds(userId, emojis)` reads/seeds a per-user id set; e2e `e2e/tests/chat/custom-emoji-user-binding.spec.ts` runs the whole user switch in ONE page load (client-side router nav only) so the singleton-retention leak is actually exercised, and the second user *joins* the first user's server instead of creating one (in-session "create a second server" leaves `sourceId` empty and the submit disabled).
### Don't strand signed-out mobile users on a logged-out dashboard [auth] [mobile] [routing]
- **Trigger:** `App.ngOnInit` special-cased mobile — signed-out visitors landing on `/` or `/dashboard` were kept on `/dashboard` (the "login form has no mobile chrome" rationale), so mobile users got a logged-out dashboard and never saw a login screen on startup.
- **Rule:** decide startup routing for signed-out users with the platform-agnostic pure rule `resolveUnauthenticatedStartupRedirect(currentUrl)` (`auth-navigation.rules.ts`) — non-public routes → `/login` (with safe `returnUrl`), public routes (`/login`, `/register`, `/invite/...`) → stay; do not branch on `isMobile()` here.
- **Why:** the mobile exception directly contradicted the product expectation ("greet signed-out users with the login screen"); the login form already links to register, so there is no dead-end to avoid.
- **Example:** unit `auth-navigation.rules.spec.ts` (`resolveUnauthenticatedStartupRedirect('/dashboard') === { path:'/login', queryParams:{} }`); e2e `e2e/tests/mobile/mobile-login-on-startup.spec.ts` sets a 390×844 viewport **before** navigating (so `ViewportService.isMobile` is true at bootstrap) and asserts `/dashboard` and `/` both land on `/login`.
### "Shared from your device" must gate on local bytes, not uploader user id [attachments] [multi-device]
- **Trigger:** a second device of the same user showed "Shared from your device" and hid the download affordance for a file uploaded from another device — `isUploader(attachment)` returned `uploaderPeerId === currentUserId`, but `uploaderPeerId` is the **user** id (set to `currentUser.id` in `publishAttachments`), so it is true on every device of the uploader, including ones that only synced metadata.
- **Rule:** key the sharing/ownership UI off whether *this device* holds the bytes, not who uploaded it — use `isSharingFromThisDevice(attachment, currentUserId)` (= `isUploaderUser && deviceHasLocalCopy`) from `attachment-sharing.rules.ts`; `deviceHasLocalCopy` = `available` + blob `objectUrl`, or a non-empty `savedPath`/`filePath` (synced metadata strips local paths, so it correctly reads as "no copy").
- **Why:** same-user devices do **not** P2P with each other and sync only via `account_sync` (which strips `filePath`/`savedPath`), so the second device legitimately has no bytes; claiming ownership blocked the only path to view/download. For the regression to even be reachable in e2e, `account_sync`'s `chat-sync-batch` had to start carrying the `attachments` map (it previously dropped attachment metadata entirely) via `pushSavedRoomMessagesViaAccountSync(..., loadAttachmentMetas)`.
- **Example:** unit `attachment-sharing.rules.spec.ts` (`isSharingFromThisDevice({uploaderPeerId:'u1', available:false}, 'u1') === false`); e2e `e2e/tests/chat/multi-device-attachment-sharing.spec.ts` uploads on device A then logs device B in afterward so the `account_sync_peer_online` full-state push delivers the attachment, then asserts device B shows a Request button and **no** "Shared from your device".
### Generate Android brand icons from the source mark; guard against stock Capacitor placeholders [mobile] [android] [assets]
- **Trigger:** the Android app shipped the default Ionic/Capacitor launcher icon (and a white adaptive background) because no brand icon was ever generated into `toju-app/android/app/src/main/res/`.
- **Rule:** regenerate launcher + splash from `images/icon-new-rounded.png` with `npm run cap:assets:android` (`tools/generate-android-app-icons.mjs`, uses `sharp`), set the adaptive background to brand purple `#4A217A` (never `#FFFFFF`), and have the adaptive icon reference `@mipmap/ic_launcher_foreground` PNGs (delete the stock `drawable-v24/ic_launcher_foreground.xml` vector). `cap:sync` is not needed — these live in the native project, not `webDir`.
- **Why:** a native launcher icon can't be asserted through a browser, so the regression proof is a hash guard: `mobile-android-launcher-icon.rules.ts` records the SHA-256 of every stock placeholder and the tests fail if any density still matches one. Pixel checks (purple ring + white-cat centre) confirm the brand mark actually rendered.
- **Example:** `findStockCapacitorResources(hashByFile)` must return `[]`; unit `mobile-android-launcher-icon.rules.spec.ts` + e2e `e2e/tests/mobile/android-app-icon.spec.ts` (deterministic fs/pixel checks, no emulator).
### Bind chat attachments to a pre-allocated message id, never by matching content [attachments] [chat] [mobile]
- **Trigger:** caption-less media (videos/images sent with no text) grouped onto the message bubble above and left an empty message below on Android — `ChatMessagesComponent` dispatched `sendMessage` without an id, then a `setTimeout` re-discovered the message by `entry.content === content` (always `''` for attachment-only sends) and called `publishAttachments` on it.
- **Rule:** pre-allocate the message id in the component (`planChatMessageSend` in `chat-message-send.rules.ts`), dispatch it via `MessagesActions.sendMessage({ id, ... })` (effect uses `id ?? uuidv4()`), and bind attachments to that exact id with `publishAttachments(id, files)` — never re-find the message by content/timing.
- **Why:** empty content is shared by every attachment-only message, so content matching picks the newest match and races the async create-effect; on Android the create latency exceeds the old 100 ms timer, so the file binds to a stale sibling. The race is invisible on fast desktop browsers, so the deterministic regression proof is the unit test that asserts the dispatched action id equals the attachment-binding id, not an e2e timing game (see the "don't bump E2E timeouts for sync flakes" lesson).
- **Example:** `planChatMessageSend(...).attachmentBinding.messageId === plan.action.id` enforced in `chat-message-send.rules.spec.ts`; behavioral guard in `e2e/tests/chat/attachment-only-message-grouping.spec.ts` (proves the id flows component→effect→attachment by requiring each caption-less attachment to render in its own bubble).
### Attachment file persistence must be platform-agnostic, not Electron-only [attachments] [persistence] [mobile]
- **Trigger:** `AttachmentStorageService` talked only to `window.electronAPI`, so `canWriteFiles()` returned `false` on Android (Capacitor) and in the browser — no bytes were ever persisted there, and after restart/logout-login the uploader hit "Your original upload could not be found on this device" / "no peer with this file".
- **Rule:** keep the path/bucket layout in `AttachmentStorageService` but delegate raw IO to a pluggable `AttachmentFileStore` selected by `PlatformService` — Electron disk, Capacitor `Directory.Data` (lazy-loaded, inline media via `convertFileSrc`), and a per-user IndexedDB vfs for the browser with a finite `maxPersistableBytes` cap; gate transfer persistence on `canStreamToDisk()` / `canPersistSize()` so the cap degrades gracefully.
- **Why:** the browser e2e harness can't test native disk, but the browser IndexedDB store is real persistence, so a single-client send → `page.reload()` → reopen-room test proves the whole persist/restore orchestration with no peer connected.
- **Example:** `attachment-file-store.ts` + `{electron,browser,capacitor}-attachment-file-store.ts`; `e2e/tests/chat/local-attachment-persistence.spec.ts` waits for both byte records (vfs) **and** `attachments` records with `savedPath` (summed across all `metoyou`/`metoyou::<user>` DBs, since an empty anonymous-scope DB exists) before reloading.
### Never count duplicate chunks toward transfer progress, and never finalize on byte counters [attachments] [webrtc]
- **Trigger:** P2P attachments arrived corrupt everywhere ("only the first bytes") because concurrent auto-download triggers double-requested a file, the sender streamed it twice, and the receiver counted duplicate chunk deliveries toward `receivedBytes` — inflating it past `size`, which both dropped the remaining chunks (post-Security guard) and passed the `receivedBytes >= size` finalize shortcut over a sparse buffer.
- **Rule:** in chunked transfer receivers, ignore an already-buffered chunk index entirely (no progress update), use dense buffers, and finalize only when every chunk index is present — never use byte totals as an alternative completion signal; dedupe streams on the sender per `(messageId, fileId, peerId)`.
- **Why:** byte counters lie as soon as any duplicate, retry, or concurrent stream exists, and sparse-array `every`/`some` skip holes, so "looks complete" checks silently pass on partial data (same trap as the custom-emoji sparse-array lesson).
- **Example:** `handleFileChunk` / `finalizeTransferIfComplete` in `attachment-transfer.service.ts`; multi-chunk e2e coverage via `expectMessageImageContentSha256` in `e2e/tests/chat/chat-message-features.spec.ts` (single-chunk files cannot catch assembly bugs — test with >64 KiB payloads).
### Don't bump E2E timeouts for sync flakes - gate on presence and read server logs [testing] [realtime]
- **Trigger:** a multi-client chat-sync E2E flaked on "message not visible" and the first instinct was to raise `toBeVisible` timeouts or add waits; the user correctly rejected this ("it's not a timeout issue").
- **Rule:** when a cross-user E2E assertion flakes, first gate the assertion on an observable precondition (peer visible in the members panel), then diff the signaling-server logs of a passing vs failing run (`joined server`, `user_joined`, `user_left`, `Removing dead connection`) before touching any timeout.
- **Why:** the flake was a server race — `identify` + `join_server` arriving in one TCP segment were processed concurrently, the join was dropped as unauthenticated, and room membership silently vanished; no timeout can fix a message that is never broadcast. Fixed by serializing per-connection message handling in `server/src/websocket/handler.ts`.
- **Example:** failing run showed one `joined server` for Ludde then `user_left` on sibling-client close; passing run showed two. `expectServerPeerVisible(page, displayName)` in `e2e/helpers/multi-device-session.ts` is the presence gate.
### When renaming an Angular route, sweep every navigate/url-match/doc reference [routing]
- **Trigger:** the find-servers route was renamed `/search``/servers` in `app.routes.ts`, but `servers-rail.component.ts` still called `router.navigate(['/search'])` (leave-server) and matched `startsWith('/search')` for the user-bar visibility signal, throwing `NG04002: 'search'` on leave and never showing the user-bar on the discovery page.
- **Rule:** after changing a `path:` in `app.routes.ts`, grep the whole repo for the old literal (`/search`) across `*.ts`/`*.html` (router calls, `startsWith`/url-match signals) and docs (`docs-site`, `.agents/skills/playwright-e2e/SKILL.md` route tables, domain READMEs) and update them all in the same change.
- **Why:** `router.navigate` to a non-existent path raises `NG04002` and aborts navigation, and stale `startsWith` matches silently break route-derived UI state — neither is caught by the build (string literals) and there was no `servers-rail` spec to catch it.
- **Example:** fixed `isOnServers`/`router.navigate(['/servers'])` in `servers-rail.component.{ts,html}`; canonical post-leave/discovery route is `/servers` (`FindServersComponent`), matching `DashboardComponent`'s `router.navigate(['/servers'])`.
### Server discovery must fan out across all endpoints and self-heal on 404 — never hardcode a host capability blocklist [server-directory]
- **Trigger:** the dashboard "Popular Servers" and `/servers` discovery view were empty for fresh users until they typed a search. The first fix added a static `DISCOVERY_UNSUPPORTED_HOSTS` blocklist (`signal.toju.app` / `signal-sweden.toju.app`) that short-circuited discovery to `[]`; the production hosts later shipped the `/featured` + `/trending` routes (verified `curl` → 200 with servers), so the stale blocklist kept blocking exactly the default endpoints a fresh account has while ungated search still surfaced them.
- **Rule:** discovery (`getFeaturedServers`/`getTrendingServers`) must fan out across `getSearchableEndpoints()` with `forkJoin` + `deduplicateById` (mirroring all-endpoint search), and detect capability *at runtime* — on a `404` from `/api/servers/{featured,trending}`, fall back per-endpoint to the public `GET /api/servers` listing (`fetchPublicServerListForDiscovery`) instead of returning `[]`. Do not maintain a hardcoded list of hosts that "don't support" a route; it goes stale silently and the build can't catch it.
- **Why:** legacy servers resolve `/featured` as `/servers/:id` and answer 404, so a 404→fallback keeps the default view populated everywhere without a blocklist; the empty-query view renders discovery sections (not search results), so any divergence between discovery and search makes it look broken while search works.
- **Example:** `fetchDiscoveryFromEndpoint` + `fetchPublicServerListForDiscovery` in `server-directory-api.service.ts`; `e2e/tests/servers/server-discovery-default.spec.ts` proves a fresh account sees Popular Servers without searching AND that route-intercepting `/featured`+`/trending` to 404 still populates it via the fallback.
### Server registration needs `ownerPublicKey: oderId || id`, and must not be fire-and-forget [server-directory] [rooms]
- **Trigger:** creating a server appeared to work (the creator landed in the room view) but the server didn't exist on the backend — invite-link creation and search both 404'd. `createRoom$` sent `ownerPublicKey: currentUser.oderId` with no fallback; on restored sessions `oderId` can be falsy (identify still works because it falls back to `id`), so `POST /api/servers` returned `400 Missing required fields`, and the `.subscribe()` swallowed the error while `createRoomSuccess` fired regardless.
- **Rule:** resolve owner identity as `oderId || id` everywhere it's required (the server rejects an empty `ownerPublicKey`), and give `registerServer().subscribe()` an `error` handler so a failed registration is never silent.
- **Why:** verified against the live server — authed POST with a truthy `ownerPublicKey` → 201; authed POST with an empty one → 400; the swallowed 400 is exactly what produces a "ghost" room the creator can enter but no one can find.
- **Example:** `buildServerRegistrationPayload(room, currentUser, normalizedPassword)` in `toju-app/src/app/store/rooms/server-registration.rules.ts`, used by `RoomsEffects.createRoom$`.
### Identify must fall back to the legacy session token, not only the new credential store [realtime] [authentication]
- **Trigger:** the multi-signal-server auth refactor changed `resolveCredentialForSignalUrl` to read *only* `SignalServerCredentialStoreService`; sessions restored from disk (and logins where `user.homeSignalServerUrl` is unset) have an empty credential store, so `identify` was skipped on every signal server ("Skipping identify because no session token is available") and users appeared alone — no presence, no peers, sent messages visible only to themselves. E2E never caught it because every e2e flow does a *fresh* register/login that writes the credential store directly.
- **Rule:** when resolving the identify credential for a signal URL, prefer the per-signal credential but fall back to the legacy `AuthTokenStoreService` token reconstructed with the current home user's `id`/`displayName`; never gate `identify` solely on the new credential store.
- **Why:** `persistSessionToken` always writes the legacy `metoyou.authTokens` store on login, but the per-signal credential store is only populated on fresh login (with a `loginResponse`) or successful migration/provisioning — so on reload it can be empty while a valid session still exists.
- **Example:** `resolveSignalIdentity(credential, legacyTokenEntry, homeUser)` in `signal-server-credential-resolution.rules.ts`, wired through `SignalServerAuthService.resolveCredentialForSignalUrl` (which now passes `this.authTokenStore.getTokenEntry(httpUrl)` and a `homeUser` carrying `id`). Test cross-user behavior via a *session-restore* path, not just fresh login.
### Keep the per-signal-URL identify credential resolvable from the store [realtime] [authentication]
- **Trigger:** after the multi-signal-server auth refactor, `SignalingManager.getLastIdentify` was switched to `getIdentifyCredentialsForSignalUrl`, which only read an in-memory cache populated *after* `identify()` ran; a freshly (re)connected socket then emitted `join_server` before any identify and users silently never appeared in the presence roster (almost all multi-user e2e tests timed out waiting for the peer's `room-user-card`).
- **Rule:** `getIdentifyCredentialsForSignalUrl` must fall back to resolving the credential from the credential store so a new socket's `onopen` re-identifies before it re-joins; never restrict it to only the in-memory identify cache.
- **Why:** the server drops `join_server`/`view_server` on any unauthenticated connection, so an identify-less join is lost with no error and recovery only happens on a later reconnect (often beyond the 20s test timeout).
- **Example:** server log showed `join_server authed=false ... display=User` dropped, then `User identified: Alice` on a different connection but no `Alice joined server`; fixed in `signaling-transport-handler.ts` by resolving via `dependencies.resolveCredential(signalUrl)` when the cache is empty.
### Store clientInstanceId in sessionStorage not localStorage [realtime] [multi-device]
- **Trigger:** same user logged in on two tabs, browsers, or synced profiles sees alternating "Disconnected from signaling server" and no cross-device chat/voice sync.
- **Rule:** persist `metoyou.clientInstanceId` in `sessionStorage` (one id per tab/window) and clear any legacy `localStorage` copy on first read.
- **Why:** server identify evicts stale sockets with the same `(oderId, connectionScope, clientInstanceId)` tuple; a shared localStorage id makes each client kick the other in a reconnect loop.
- **Example:** `ClientInstanceService.getClientInstanceId()` writes to `sessionStorage`; two tabs get different ids and stay connected simultaneously.
### Revalidate IndexedDB scope without reinitializing on every read [persistence] [performance]
- **Trigger:** `DatabaseService.ensureReady()` called `initialize()` before every delegated read/write to fix user-scope races.
- **Rule:** cache the last validated `metoyou_currentUserId` and only re-run backend initialization when that scope changes or an in-flight initialize completes with a different scope.
- **Why:** per-operation revalidation fans out across ban lookups, room loads, and message reads, causing channel/chat UI to stay blank until repeated server clicks eventually win the race.
- **Example:** `ensureReady()` returns immediately when `isReady()` and `validatedUserScope` still match `getStoredCurrentUserId()`.
### Restore local user scope before protected writes [authentication] [persistence]
- **Trigger:** a logged-in in-memory user can create rooms or messages after `metoyou_currentUserId` was cleared by a late session-expired path.
- **Rule:** before protected local persistence or server-directory actions, restore `metoyou_currentUserId` from the current user and avoid treating a live current user as unauthenticated.
- **Why:** otherwise rooms/messages fall into the anonymous IndexedDB scope, and route checks redirect to login even though NgRx still has the authenticated user.
- **Example:** `MessagesEffects.sendMessage$`, `RoomsEffects.createRoom$`, and server-directory create/join components call `setStoredCurrentUserId(currentUser.id)` before writing or joining.
### Persisted local user state still requires a session token [authentication] [signaling]
- **Trigger:** Users appear logged in from local storage but cannot see peers online or send chat after session-token auth shipped.
- **Rule:** before connecting signaling or loading rooms for a persisted user, require a non-expired token in `metoyou.authTokens`; redirect to `/login` on `SESSION_EXPIRED`, `auth_required`, or `auth_error`.
- **Why:** WebSocket `identify` is skipped without a token, so `join_server`, RTC relay, and presence never establish even though the profile exists locally.
- **Example:** `hasValidPersistedSession()` in `auth-session.rules.ts` from `loadCurrentUser$`.
### Declare MODIFY_AUDIO_SETTINGS for Android WebRTC mic capture [mobile] [android]
- **Trigger:** Android users accept the microphone prompt but voice calls and channels still fail to join.
- **Rule:** include `android.permission.MODIFY_AUDIO_SETTINGS` in `toju-app/android/app/src/main/AndroidManifest.xml` and preflight Capacitor capture through `MobileMediaService.ensureVoiceCapturePermissions()` before `getUserMedia`.
- **Why:** Capacitor's `BridgeWebChromeClient.onPermissionRequest` requests `RECORD_AUDIO` and `MODIFY_AUDIO_SETTINGS` together; if the latter is undeclared, the combined grant is treated as denied even after the user taps Allow.
- **Example:** `ANDROID_REQUIRED_MANIFEST_PERMISSIONS` in `mobile-android-manifest-permissions.rules.ts`.
### Do not override Tailwind with box-sizing inherit [mobile] [css]
- **Trigger:** mobile pages still overflow horizontally until devtools disables `*, *::before, *::after { box-sizing: inherit }` in global styles.
- **Rule:** in `src/styles.scss` keep `box-sizing: border-box` on the universal selector (matching Tailwind preflight); never replace it with `inherit` from `html`.
- **Why:** `inherit` overrides preflight and some nested component hosts resolve to `content-box`, so `w-full` plus padding becomes wider than the parent — especially visible on the mobile dashboard beside the servers rail.
- **Example:** `src/styles.scss` `@layer base` universal rule uses `border-box`, not `inherit`.
### Use the app-shell servers rail for mobile discovery pages [mobile] [layout]
- **Trigger:** patching `min-w-0` / `overflow-x-hidden` on the dashboard (or find-people/find-servers) while the page still renders wider than the phone beside an embedded servers rail.
- **Rule:** on mobile discovery routes (`/dashboard`, `/people`, `/servers`, …) show the global `app.html` servers rail and render the page full-width in `appWorkspace`; keep embedded swiper+rail stacks only for chat/DM/call routes (`shouldShowMobileAppServersRail` in `mobile-shell-layout.rules.ts`).
- **Why:** nesting a second rail+Swiper stack inside `router-outlet` fights the shell flex width and content keeps sizing to intrinsic width, clipping cards and inputs on every viewport.
- **Example:** `hideAppServersRail()` in `app.html` + dashboard `pageContent` only (no local `<app-servers-rail>`).
### Defer attachment blob hydration on Electron startup [attachments] [electron]
- **Trigger:** fixing inline attachment display by eagerly calling `tryRestoreAttachmentFromLocal()` for every persisted attachment during `initFromDatabase()`.
- **Rule:** load attachment metadata at startup, but hydrate blob URLs only for the watched room on demand; read disk files through chunked IPC (`readFileChunk`) and yield between chunks/attachments so large images never block the renderer.
- **Why:** restoring every saved attachment as a single base64 round-trip plus synchronous `atob()` can freeze Electron for seconds even after the shell paints.
- **Example:** `runInitFromDatabase()` stops at `loadFromDatabase()`; `restoreLocalAttachmentsForRoom()` hydrates lazily via `restoreAttachmentBlobFromDiskPath()`.
### Lazy-load Capacitor modules on Electron/desktop [mobile] [electron]
- **Trigger:** adding mobile facades that statically import Capacitor adapters or `@capacitor/*` plugins into shared Angular services used by the desktop app.
- **Rule:** keep web/electron shells on web adapters synchronously and load Capacitor adapters/plugins only through dynamic `import()` after `runtime === 'capacitor'` — never top-level `import '@capacitor/...'` in code reachable from `app.ts` / `DirectCallService`.
- **Why:** bundlers evaluate static Capacitor imports during Electron startup, which can freeze the renderer before first paint even when runtime detection would have chosen the web adapter.
- **Example:** `resolveMobileAdapter()` in `mobile-capacitor-adapter.rules.ts` plus async `capacitor-plugin-loader.ts` / `loadMetoyouMobilePlugin()`.
### Use the upgrade transaction during IndexedDB schema migrations [persistence] [browser] ### Use the upgrade transaction during IndexedDB schema migrations [persistence] [browser]
- **Trigger:** bumping `BROWSER_DATABASE_VERSION` and opening existing stores via `database.transaction(...)` inside `onupgradeneeded`. - **Trigger:** bumping `BROWSER_DATABASE_VERSION` and opening existing stores via `database.transaction(...)` inside `onupgradeneeded`.

View File

@@ -0,0 +1,13 @@
# ADR-0002: Session-Token Authentication on the Signaling Server
## Status
Accepted
## Context
The signaling server trusted client-supplied user IDs on REST mutations and WebSocket `identify`, allowing impersonation for kicks, bans, joins, plugin administration, and push dispatch. The product client already used bearer tokens for the Electron Local API, but the shared signaling server had no equivalent binding between HTTP/WebSocket actions and a logged-in user.
## Decision
Issue opaque session tokens on login/register, persist them in server SQLite, require `Authorization: Bearer` on all mutating REST routes, and require `identify.token` on WebSocket connections before any other client message is accepted. Actor fields (`currentOwnerId`, `actorUserId`, `requesterUserId`) are derived from the token instead of request bodies.
## Rationale
This closes identity spoofing without changing the P2P product model: discovery stays public, chat/media still relay over WebSocket, and DM WebRTC signaling remains available across servers. Bcrypt password hashing with transparent SHA-256 upgrade preserves existing accounts. A deprecation window for body-only auth was intentionally omitted so all clients must authenticate in one release, avoiding prolonged dual-trust behavior.

View File

@@ -0,0 +1,16 @@
# ADR-0003: Multi-Client Sessions with Connection-Scoped Routing
## Status
Accepted
## Context
Users expect to stay logged in on multiple devices simultaneously (Discord-style). The signaling server already issued multiple session tokens per user, but WebSocket broadcasts deduplicated by `oderId`, which prevented a user's second device from receiving chat, typing, or voice-state updates from their first device. Voice had no per-device identity, so two clients could both attempt to transmit audio.
## Decision
Introduce a stable per-install `clientInstanceId` on the product client. Route server broadcasts by **connection id** (exclude only the sender socket) while keeping presence `user_joined` / `user_left` identity-scoped. Track `voiceActive` per connection; relay RTC to the voice-active socket. Enforce single voice owner per user via `VoiceState.clientInstanceId` and `voice_client_takeover` handoff between connections.
## Consequences
- **Positive:** Chat and presence sync across a user's devices; voice behaves like Discord (one transmitting client, passive viewers, explicit takeover).
- **Positive:** Stale-tab hygiene uses `(oderId, connectionScope, clientInstanceId)` eviction without kicking other devices.
- **Negative:** `findUserByOderId` semantics change — RTC now prefers voice-active connections; callers must not assume one socket per user.
- **Negative:** Clients must include `clientInstanceId` on identify and voice payloads; older builds without it still work but cannot participate in multi-device voice exclusivity reliably.

View File

@@ -0,0 +1,23 @@
# ADR-0003: Signed Message Revision Chains for P2P Chat Integrity
## Status
Accepted
## Context
P2P chat sync compared timestamps, reaction counts, and attachment counts only. A peer could rewrite history or apply edits out of order with no cryptographic check. The product has no central message store, so integrity must travel with sync traffic and local audit logs.
## Decision
Adopt an append-only **revision chain** per message:
- Each mutation emits a `MessageRevision` (create, edit, delete, moderation, plugin) with `revision`, `prevRevisionHash`, and `headHash` (SHA-256 over canonical head state).
- Inventories advertise `{ revision, headHash }` so peers detect gaps and hash mismatches.
- Human-authored revisions are signed with per-user Ed25519 keys; public keys are registered on the signaling server for verification.
- Legacy `chat-message` / `message-edited` / `message-deleted` events continue to broadcast alongside `message-revision` for one-release backward compatibility.
## Rationale
Revision chains give deterministic merge (higher valid revision wins) without requiring a trusted relay. Signing binds edits to registered users while keeping chat payloads off the server. Dual emit avoids breaking peers that have not upgraded inventory or revision handlers yet.
## Consequences
- New persistence columns and revision audit stores on browser IDB, Electron SQLite, and Capacitor schemas.
- Plugin synthetic users may emit unsigned revisions until a plugin signing model exists.
- Attachment byte integrity (SHA-256 on `file-announce`) remains a separate follow-up.

View File

@@ -0,0 +1,62 @@
# App i18n
Client-side UI string localization for the product client (`toju-app`), using the same `@ngx-translate/core` stack as the marketing website.
## Responsibilities
- Bundle locale JSON under `toju-app/public/i18n/`.
- Bootstrap translations at app startup via `AppI18nService` (root `App` constructor).
- Expose `APP_TRANSLATE_IMPORTS` for standalone components that use the `translate` pipe in templates.
- Resolve the active locale through `resolveAppLocale()` in `app-i18n.rules.ts`.
## Boundaries
- **In scope:** user-visible UI copy in the Angular product client.
- **Out of scope:** server error messages, plugin-authored strings, Electron IPC payloads, and marketing-site copy (`website/public/i18n/`).
## Key files
| Path | Role |
|------|------|
| `toju-app/public/i18n/en.json` | English translation catalog (only locale shipped today). |
| `toju-app/src/app/core/i18n/app-i18n.rules.ts` | Supported locales and locale resolution. |
| `toju-app/src/app/core/i18n/app-i18n.service.ts` | Loads bundled JSON into `TranslateService`. |
| `toju-app/src/app/core/i18n/app-translate.imports.ts` | `TranslateModule` import bundle for standalone components. |
| `toju-app/src/app/app.config.ts` | `provideTranslateService()` registration. |
## Usage
**Templates** — import `APP_TRANSLATE_IMPORTS` in the standalone component and use the pipe:
```html
{{ 'common.brand' | translate }}
```
**TypeScript** — inject `AppI18nService` (or `TranslateService`) and call `instant()`:
```ts
this.appI18n.instant('common.brand');
```
## Catalog workflow
User-visible strings live in fragment files under `toju-app/public/i18n/catalog/*.json`, merged into `toju-app/public/i18n/en.json` by:
```bash
npm run i18n:sync
```
The sync script also extracts `theme.registry.*` labels/descriptions from `theme-registry.logic.ts` and `permissions.*` from `access-control.constants.ts` so those large registries stay DRY. Extracted prefixes use dotted paths and are merged as nested JSON (e.g. `theme.registry.appShell.label`, not a flat `"theme.registry"` root key).
## Adding a locale later
1. Add `toju-app/public/i18n/catalog/*.json` fragments for the new locale (or mirror `en.json` structure).
2. Register the locale in `SUPPORTED_APP_LOCALES`.
3. Import and `setTranslation()` in `AppI18nService`.
4. Wire user preference (e.g. general settings) to `AppI18nService.initialize(preferredLocale)`.
## Tests
- `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.testing.ts``provideAppI18nForTests()` / `initializeAppI18nForTests()` for Vitest injectors

View File

@@ -0,0 +1,137 @@
# Authentication
Session-token authentication for the signaling server and product client.
## Trust boundaries
| Surface | Identity proof | Notes |
|---|---|---|
| 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 WebSocket | `identify.token` | Connections must identify before any other message type |
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only |
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
## Client logout
- Desktop: title-bar menu **Logout** (`UserLogoutService`).
- Mobile / all platforms: settings modal footer **Logout** (`data-testid="settings-logout-button"`) — required because the title bar is hidden on mobile breakpoints.
- Logout disconnects realtime sessions, clears the persisted current-user id, resets NgRx room/user/message state, and navigates to `/login`.
## Login / register response
```json
{
"id": "<uuid>",
"username": "alice",
"displayName": "Alice",
"token": "<opaque-hex>",
"expiresAt": 1710000000000
}
```
- Tokens are opaque 64-character hex strings stored in server SQLite (`session_tokens`).
- Default TTL: 10 years (`SESSION_TOKEN_TTL_MS` env override supported on the signaling server).
- Passwords are stored with bcrypt; legacy SHA-256 hashes are upgraded transparently on successful login.
## Protected REST routes
Require `Authorization: Bearer`:
- `PUT/POST/DELETE` under `/api/servers/*` (except public `GET`)
- `PUT /api/requests/:id`
- Plugin-support mutations under `/api/servers/:serverId/plugins/*`
- `/api/users/device-tokens/*`
- `POST /api/users/logout`
## WebSocket identify contract
```json
{
"type": "identify",
"token": "<session-token>",
"oderId": "<user-id>",
"displayName": "Alice",
"connectionScope": "ws://host:3001",
"clientInstanceId": "<per-install-uuid>"
}
```
- `oderId` must match the token's user id when provided.
- `clientInstanceId` is a stable per-tab UUID generated by the product client (`metoyou.clientInstanceId` in `sessionStorage`). The signaling server uses it to distinguish multiple WebSocket connections for the same user and to route voice ownership.
- Server responds with `auth_error` or `auth_required` when authentication fails.
- **Per-connection message ordering (invariant):** the server processes WebSocket messages for one connection strictly in arrival order (`handleWebSocketMessage` chains them per connection id). `identify` awaits a DB token lookup, and clients send `identify` + `join_server` back-to-back (often one TCP segment); concurrent handling let the join run mid-identify, get rejected as unauthenticated, and silently drop room membership — that connection then missed all `user_joined` / `chat_message` broadcasts (root cause of "chats don't sync for multi-client users").
## Multi-device sessions
- Each login/register issues a **new** session token; prior tokens remain valid until they expire or the client calls `POST /api/users/logout` with that token.
- The same user may keep multiple WebSocket connections open (different devices or browser profiles). Server broadcasts (chat, typing, voice state, status) exclude only the **sending connection**, so other connections for that identity still receive updates.
- Voice/WebRTC is exclusive per user: only one `clientInstanceId` may own active voice at a time. Other connections show passive UI and can send `voice_client_takeover` to move voice to the local device.
- Stale reconnect hygiene: when a client re-identifies with the same `(oderId, connectionScope, clientInstanceId)` tuple, the server closes the older socket for that tuple.
### Account-owned state sync (`account_sync`)
When the same account is logged in on multiple devices, account-owned data is kept in sync through the signaling server:
| Data | Mechanism |
|---|---|
| Server chat messages (live) | `chat_message` signaling relay (connection-scoped broadcast) **plus** `account_sync` `chat-message` / `message-revision` to sibling devices |
| Server chat messages (catch-up) | `account_sync` `chat-sync-batch` pushed when a sibling device comes online (`account_sync_peer_online`); each batch carries its messages' **attachment metadata** (`attachments` map, local paths stripped) so sibling devices learn about synced attachments — they are then requestable/downloadable but never marked "Shared from your device" unless the bytes are local |
| Voice / typing | Existing `voice_state` / `user_typing` relays |
| Saved servers (join/leave) | `account_sync` payload `saved-room-sync` / `saved-room-remove` |
| Profile avatar + card text | `account_sync` `user-avatar-full` + `user-avatar-chunk` |
| Custom emoji library | `account_sync` `custom-emoji-full` + `custom-emoji-chunk` |
| Friends list | `account_sync` `friend-added` / `friend-removed` |
| Server icons, edits, reactions | `account_sync` relay of existing P2P broadcast event types |
Client rules:
- `broadcastMessage()` still fans out over peer data channels; relayable events are **also** wrapped in `account_sync` and sent on the WebSocket.
- The server forwards `account_sync` to every other open connection for the same `oderId` via `notifyOtherConnectionsForOderId`.
- Receivers ignore payloads whose `clientInstanceId` matches the local tab id.
- When a new device identifies, the server notifies existing connections with `account_sync_peer_online`; those devices push a full snapshot (saved rooms, **room message history**, friends, profile, emoji library).
WebSocket envelope:
```json
{
"type": "account_sync",
"clientInstanceId": "<per-tab-uuid>",
"payload": { "type": "saved-room-sync", "room": { "...": "..." } }
}
```
Server response to other connections includes `fromUserId` set to the sender's `oderId`.
## Client storage
The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server.
Per-server credentials (`metoyou.signalServerCredentials`) map each normalized signal-server URL to the authenticated user id, username, display name, session token, expiry, and whether the account was auto-provisioned. The home user profile in SQLite/NgRx remains the device-local identity (`homeSignalServerUrl`); foreign-server credentials are a side map used for REST and WebSocket identify on that URL.
A per-install **provision secret** enables silent account creation on newly added or encountered signal servers. It is generated on home register/login, stored in Electron `safeStorage` when available (sessionStorage fallback on web), and never persisted as the user's visible login password.
### Multi-signal-server auth flows
| Flow | Action | Effect |
|---|---|---|
| Home login/register | `authenticateUser` | Resets local state, stores home credential + provision secret |
| 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 |
| 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 |
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).
Authorize UI: `/login?mode=authorize&serverId=…&returnUrl=…` (also supported on `/register`). Settings → Network shows per-endpoint `Authorized` / `Needs sign-in` badges.
Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home signaling server (or any stored token as a fallback). Missing or rejected **home** tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. Foreign-server `auth_required` / `auth_error` responses clear only that server's credential and attempt re-provision.
Startup routing for signed-out visitors is decided by `resolveUnauthenticatedStartupRedirect(currentUrl)` (`auth-navigation.rules.ts`), called from `App.ngOnInit`: any non-public route is redirected to `/login` (carrying a safe `returnUrl`), while public routes (`/login`, `/register`, `/invite/...`) are left alone. This is **platform-agnostic** — mobile is intentionally not special-cased, so a signed-out mobile user is greeted with the login screen on startup rather than a logged-out `/dashboard`.
## Security considerations
- Rate limits: login/register (100 / 15 min), server join (30 / min).
- 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.
- 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.

View File

@@ -19,7 +19,7 @@ Custom emoji lets users upload small image emoji, use them in chat messages and
- **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. - **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.
- **Known custom emoji**: A synced asset available for message rendering and forwarding, but not shown in the current user's picker unless saved. - **Known custom emoji**: A synced asset available for message rendering and forwarding, but not shown in the current user's picker unless saved.
- **Saved custom emoji**: A known asset with `savedByUser` enabled; saved emoji appear in the picker and shortcut ranking. - **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.
- **Emoji shortcut row**: The seven most-used emoji entries for the current user plus an eighth control that opens the full selector. - **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. - **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. - **Composer emoji alias**: The readable inline draft representation `:name:`. The composer rewrites known aliases to stable custom emoji tokens only when sending.
@@ -40,6 +40,7 @@ When a peer connects, each side sends a summary of known assets. The receiver re
- Uploads are capped at 1 MB. - Uploads are capped at 1 MB.
- Accepted image types match profile avatars: WebP, GIF, JPG, and JPEG. - 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. - 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. - 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`). - 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. - 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.
@@ -50,9 +51,9 @@ When a peer connects, each side sends a summary of known assets. The receiver re
## Data Access ## Data Access
- Browser runtime stores custom emoji in IndexedDB store `customEmojis`. - Browser runtime stores custom emoji image assets in IndexedDB store `customEmojis` (per-user database scope).
- Electron runtime stores custom emoji in SQLite table `custom_emojis`, created by migration `1000000000011-AddCustomEmojis`. - 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`. - 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.
## Testing ## Testing

View File

@@ -0,0 +1,60 @@
# 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.
## Responsibilities
- **Revision chain** — Every create, edit, delete, moderation, or plugin mutation appends a `MessageRevision` with monotonically increasing `revision`, `prevRevisionHash`, and `headHash`.
- **Dual emit** — Outgoing mutations broadcast the legacy event (`chat-message`, `message-edited`, `message-deleted`) **and** `message-revision` so older peers keep working while integrity-aware peers prefer revisions.
- **Inventory** — Sync inventories include `{ id, ts, rc, ac, revision, headHash }`. Peers re-fetch when remote revision is newer or the same revision has a different hash (tamper detection).
- **Signing** — Human authors sign revisions with per-user Ed25519 keys. Public keys are registered on the signaling server; private keys stay in browser `localStorage`.
## Boundaries
| Layer | Owns |
| --- | --- |
| 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 |
| Electron / mobile persistence | `revision` + `headHash` on message rows; revision audit log (IDB store / SQLite meta) |
Plugin API messages may emit unsigned revisions (`plugin-edit` / `plugin-delete`) when the actor is a synthetic plugin user.
## Key types
- `Message.revision`, `Message.headHash` — materialized cache fields on the shared `Message` model.
- `MessageRevision` — wire + persistence audit record (`message-revision.models.ts`).
- `MessageRevisionType``create`, `author-edit`, `author-delete`, `moderate-edit`, `moderate-delete`, `plugin-edit`, `plugin-delete`.
- `ChatEvent.type: 'message-revision'` — P2P envelope carrying a full `MessageRevision`.
## Merge rules
1. Valid signed revision with higher `revision` wins over legacy timestamp edits.
2. Same `revision`, different `headHash` → treat as stale/tampered and re-fetch.
3. Unsigned revisions (no `signature`) are accepted for backward compatibility when verification is skipped.
4. Legacy peers without `revision`/`headHash` in inventory fall back to `ts` / `rc` / `ac` comparison.
## Client touchpoints
- Domain rules: `message-integrity.rules.ts`, `message-revision.builder.rules.ts`, `message-sync.rules.ts`
- Services: `MessageRevisionService`, `MessageSigningService`
- Store: `messages.effects.ts` (outgoing dual-emit), `messages-incoming.handlers.ts` (`handleMessageRevision`), `messages.helpers.ts` (inventory + merge)
- Plugins: `plugin-client-api.service.ts` emits revisions for send/edit/delete
## Server API
| Method | Path | Auth | Body / response |
| --- | --- | --- | --- |
| `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 |
Registration runs automatically after login/register via `AuthenticationService`.
## Degraded-mode behavior
- Outgoing revision signing is **best-effort**: if `Ed25519` signing fails, the client still broadcasts the legacy `chat-message` envelope (unsigned revision).
- Incoming signed revisions are accepted without cryptographic verification when the sender's public key is not yet registered on the server, so chat is not blocked during key-registration races.
## Testing
- Unit: `message-integrity.rules.spec.ts`, `message-revision.builder.rules.spec.ts`, `message-revision-signing.rules.spec.ts`, `message-sync.rules.spec.ts`, `messages-incoming.handlers.spec.ts`
- Outgoing revision wiring is covered indirectly through existing message effect tests; add focused specs when changing merge or signing behavior.

View File

@@ -4,7 +4,7 @@ Cross-context mobile shell for the Angular product client (`toju-app/`). Wraps t
## Responsibilities ## Responsibilities
- Detect runtime shell (`browser`, `capacitor`, `electron`) without importing native plugins in domain code. - Detect runtime shell (`browser`, `capacitor`, `electron`) without importing native plugins in domain code. Capacitor packages and adapters load only on `capacitor` shells via dynamic `import()` so Electron/desktop startup never evaluates `@capacitor/*` modules.
- Expose facades for notifications, in-call controls, media/attachments, stream pop-out, background audio session, CallKit, and native persistence. - Expose facades for notifications, in-call controls, media/attachments, stream pop-out, background audio session, CallKit, and native persistence.
- Integrate with direct-call, voice-workspace, and chat composer flows. - Integrate with direct-call, voice-workspace, and chat composer flows.
@@ -48,13 +48,37 @@ Config: `toju-app/capacitor.config.ts` (`webDir: ../dist/client/browser`).
### CI (Gitea) ### CI (Gitea)
Workflow `.gitea/workflows/build-android-apk.yml` runs automatically on push to `main` / `master` when mobile client paths change (`toju-app/**`, root lockfile, or the workflow itself). Use **workflow_dispatch** to build on demand from any branch. Release workflow `.gitea/workflows/release-draft.yml` builds a debug Android APK on every push to `main` / `master` (job `build-android`), stages it as `Toju-<version>-android-debug.apk`, and uploads it to the same draft Gitea release as the desktop `.exe` / `.deb` assets via `tools/gitea-release.js`.
The job installs JDK 21 and Android SDK platform 36 inside the `node:22` container, runs `tools/build-android-apk.sh`, and uploads `metoyou-android-debug-apk` (`app-debug.apk`) as a workflow artifact. No signing keystore is configured — output is a **debug** APK suitable for sideloading and QA. Manual-only workflow `.gitea/workflows/build-android-apk.yml` (**workflow_dispatch**) repeats the same build and release upload on demand from any branch.
Both jobs install JDK 21 and Android SDK platform 36 inside the `node:22` container and run `tools/build-android-apk.sh`. No signing keystore is configured — output is a **debug** APK suitable for sideloading and QA.
Optional `google-services.json` is not injected in CI; push registration in artifact builds follows the same optional-Firebase behavior as local unsigned debug builds. Optional `google-services.json` is not injected in CI; push registration in artifact builds follows the same optional-Firebase behavior as local unsigned debug builds.
After dependency or plugin changes, run `npm run build:prod && npm run cap:sync` so native projects register `@capacitor/app`, `@capacitor-community/sqlite`, push plugins, and `MetoyouMobile`. After dependency or plugin changes, run `npm run build:prod && npm run cap:sync` so native projects register `@capacitor/app`, `@capacitor-community/sqlite`, `@capawesome/capacitor-app-update`, push plugins, and `MetoyouMobile`.
## App icon & splash (Android brand assets)
The Capacitor shell must ship the Toju brand mark, not the stock Ionic/Capacitor placeholder. Brand resources are generated from `images/icon-new-rounded.png` (circular cat-on-purple disc) into `toju-app/android/app/src/main/res/`:
```bash
npm run cap:assets:android # → tools/generate-android-app-icons.mjs (uses sharp)
```
This produces, for every density (`mdpi … xxxhdpi`):
- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` — legacy launcher bitmaps (the brand disc inset to the adaptive-icon safe zone so circular masks do not clip the cat face).
- `mipmap-*/ic_launcher_foreground.png` — adaptive foreground centred at **66/108** of the 108dp canvas (Android safe zone); the adaptive layers in `mipmap-anydpi-v26/ic_launcher*.xml` reference `@mipmap/ic_launcher_foreground` with `@color/ic_launcher_background` brand purple behind it.
- `values/ic_launcher_background.xml` — adaptive background colour set to the **brand purple `#4A217A`**, not stock white.
- `drawable*/splash.png` (port + land per density, plus the base) — brand mark centred at **32%** of the shorter splash edge on a purple field (down from 40% so the cat face is not cropped on launch).
Invariants are encoded in `toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts` (required file set, brand background colour, and the SHA-256 of every stock Capacitor placeholder that must never reappear). Coverage:
- Unit: `mobile-android-launcher-icon.rules.spec.ts` — asserts every density is present, no resource matches a stock placeholder hash, and the adaptive background is the brand purple.
- E2E: `e2e/tests/mobile/android-app-icon.spec.ts` — same contract plus pixel checks (launcher ring is purple, centre is the white cat; splash corner is purple, centre is the cat). Deterministic; no emulator.
Re-run `npm run cap:assets:android` whenever `images/icon-new-rounded.png` changes; `npm run cap:sync` is **not** needed (resources live in the native project, not `webDir`).
## Feature status ## Feature status
@@ -71,6 +95,7 @@ After dependency or plugin changes, run `npm run build:prod && npm run cap:sync`
| 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) |
| Store app updates | **Working (partial)** | `@capawesome/capacitor-app-update` via `MobileAppUpdateService`; Android in-app updates when Play allows, iOS opens App Store |
## Platform limitations ## Platform limitations
@@ -91,9 +116,28 @@ The app starts without Firebase. `MobilePushRegistrationService` probes `Metoyou
2. Copy `toju-app/android/app/google-services.json.example` to `google-services.json` (gitignored) and fill in your Firebase values. 2. Copy `toju-app/android/app/google-services.json.example` to `google-services.json` (gitignored) and fill in your Firebase values.
3. Run `npm run cap:sync` so the Google Services Gradle plugin applies when the file is present (`build.gradle` applies it only when the JSON exists). 3. Run `npm run cap:sync` so the Google Services Gradle plugin applies when the file is present (`build.gradle` applies it only when the JSON exists).
4. Rebuild with `npm run cap:build:android`. 4. Rebuild with `npm run cap:build:android`.
5. Ensure `POST_NOTIFICATIONS`, `RECORD_AUDIO`, and foreground-service permissions are granted on Android 13+. 5. Ensure `POST_NOTIFICATIONS`, `RECORD_AUDIO`, `MODIFY_AUDIO_SETTINGS`, `CAMERA`, and foreground-service permissions are granted on Android 13+.
6. Verify `MobilePushRegistrationService` logs a registration token after login. 6. Verify `MobilePushRegistrationService` logs a registration token after login.
### Android runtime permissions (voice / camera)
Capacitor's WebView requests `RECORD_AUDIO` **and** `MODIFY_AUDIO_SETTINGS` together for microphone capture. If `MODIFY_AUDIO_SETTINGS` is missing from `AndroidManifest.xml`, users can accept the prompt and `getUserMedia` still fails.
Declared in `toju-app/android/app/src/main/AndroidManifest.xml`:
| Permission | Purpose |
|------------|---------|
| `RECORD_AUDIO` | Microphone capture for voice calls and channels |
| `MODIFY_AUDIO_SETTINGS` | Required by Capacitor WebChromeClient alongside `RECORD_AUDIO` |
| `CAMERA` | WebRTC camera sharing and WebView file capture |
| `BLUETOOTH_CONNECT` | Bluetooth headset routing during calls (Android 12+) |
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
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.
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.
### iOS (APNs) ### iOS (APNs)
1. Enable Push Notifications capability in Xcode for the `App` target. 1. Enable Push Notifications capability in Xcode for the `App` target.
@@ -135,6 +179,7 @@ POST /api/users/device-tokens/:userId/dispatch
- `FOREGROUND_SERVICE` - `FOREGROUND_SERVICE`
- `FOREGROUND_SERVICE_MICROPHONE` - `FOREGROUND_SERVICE_MICROPHONE`
- `RECORD_AUDIO` - `RECORD_AUDIO`
- `MODIFY_AUDIO_SETTINGS`
- `POST_NOTIFICATIONS` - `POST_NOTIFICATIONS`
The service shows a low-importance ongoing notification while a call is active. The service shows a low-importance ongoing notification while a call is active.
@@ -152,13 +197,14 @@ The service shows a low-importance ongoing notification while a call is active.
## 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` 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).
- After adding or upgrading Capacitor plugins, run `npm run build:prod && npm run cap:sync` so Android/iOS native projects register `App`, `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)
- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads. - Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads. `MobileAppLifecycleService` calls `syncMobileSafeAreaInsets()` after Capacitor boot so Android SystemBars recomputes inset variables once the SPA is ready.
- `capacitor.config.ts` sets `plugins.SystemBars.insetsHandling: 'css'` so Android WebView versions that mis-report `env(safe-area-inset-*)` still receive correct insets. - `capacitor.config.ts` sets `plugins.SystemBars.insetsHandling: 'css'` so Android WebView versions that mis-report `env(safe-area-inset-*)` still receive correct insets.
- Global `styles.scss` applies inset padding on `html` (with `env()` fallback) and sizes `app-root` to `height: 100%` so content stays below the status bar and above the navigation bar in edge-to-edge mode. - Global `styles.scss` defines `metoyou-safe-area-shell` (mobile app shell padding), `metoyou-fixed-safe-viewport` (full-screen modals/backdrops), and `metoyou-fixed-safe-bottom-sheet` (bottom sheets and CDK profile-card panels). These read `--safe-area-inset-*` with `env()` fallback so routed pages, settings, context menus, and profile cards stay below the status bar and above the navigation bar.
- Android `styles.xml` uses transparent status/navigation bars and `windowLayoutInDisplayCutoutMode=shortEdges` so Capacitor can draw edge-to-edge and report accurate insets.
## Self-hosted HTTPS signal servers (Android) ## Self-hosted HTTPS signal servers (Android)
@@ -208,7 +254,9 @@ Network security configs:
- `ChatMessageComposerComponent` — `shouldShowAttachmentButton` + `pickAttachmentsFromDevice()`. - `ChatMessageComposerComponent` — `shouldShowAttachmentButton` + `pickAttachmentsFromDevice()`.
- `VoiceWorkspaceStreamTileComponent` — PiP when focused stream tile backgrounds. - `VoiceWorkspaceStreamTileComponent` — PiP when focused stream tile backgrounds.
- `MobileCallSessionService` — CallKit + foreground service + in-call notifications. - `MobileCallSessionService` — CallKit + foreground service + in-call notifications.
- `App` bootstrap — initializes mobile persistence, lifecycle, call-session, and push registration wiring. - `App` bootstrap — initializes mobile persistence, lifecycle, app-update polling, call-session, and push registration wiring.
- `MobileAppUpdateService` — periodic Play Store / App Store checks (30 min) and settings UI actions; mirrors Electron `DesktopAppUpdateService` polling but uses native store APIs instead of release manifests.
- Settings → **Data** on Capacitor shells shows the private app-data root and **Erase user data** (`LocalUserDataService` clears SQLite, Capacitor attachment files, auth tokens, and `metoyou_*` localStorage keys, then logs out).
## Phase 3 completion notes ## Phase 3 completion notes

9
dev.sh
View File

@@ -21,13 +21,14 @@ if [ "$SSL" = "true" ]; then
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"
WAIT_URL="https://localhost:4200" # Use 127.0.0.1 so wait-on does not hit a stale HTTP listener on localhost (::1).
HEALTH_URL="https://localhost:3001/api/health" WAIT_URL="https://127.0.0.1:4200"
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"
WAIT_URL="http://localhost:4200" WAIT_URL="http://127.0.0.1:4200"
HEALTH_URL="http://localhost:3001/api/health" HEALTH_URL="http://127.0.0.1:3001/api/health"
fi fi
exec npx concurrently --kill-others \ exec npx concurrently --kill-others \

View File

@@ -15,7 +15,7 @@ Owns the Docusaurus-based application and plugin-author documentation. The build
| Term | Definition | Aliases to avoid | | Term | Definition | Aliases to avoid |
|------|------------|------------------| |------|------------|------------------|
| **App docs** | End-user-facing documentation for the MetoYou desktop client. | "manual" | | **App docs** | End-user-facing documentation for the Toju desktop client. | "manual" |
| **Plugin docs** | Developer-facing reference for the plugin runtime — manifest format, lifecycle hooks, host APIs. Authoritative source for the plugin contract surface. | "API docs" | | **Plugin docs** | Developer-facing reference for the plugin runtime — manifest format, lifecycle hooks, host APIs. Authoritative source for the plugin contract surface. | "API docs" |
| **Local API server** | The Electron in-process HTTP server that mounts `docs-site/build/` so the renderer can browse docs offline. Defined under `electron/api/`. | "embedded server" | | **Local API server** | The Electron in-process HTTP server that mounts `docs-site/build/` so the renderer can browse docs offline. Defined under `electron/api/`. | "embedded server" |

View File

@@ -48,7 +48,7 @@ This avoids:
1. Add trusted signaling server URLs in desktop settings. 1. Add trusted signaling server URLs in desktop settings.
2. Start the Local API server. 2. Start the Local API server.
3. Call `POST /api/auth/login` with `username`, `password`, and `serverUrl`. 3. Call `POST /api/auth/login` with `username`, `password`, and `serverUrl`.
4. MetoYou validates credentials through the signaling server. 4. Toju validates credentials through the signaling server.
5. The desktop app issues an opaque local bearer token. 5. The desktop app issues an opaque local bearer token.
6. Use `Authorization: Bearer <token>` for protected routes. 6. Use `Authorization: Bearer <token>` for protected routes.

View File

@@ -4,7 +4,7 @@ sidebar_position: 1
# Contributing # Contributing
MetoYou is an npm-managed monorepo. Toju is an npm-managed monorepo.
## Packages ## Packages

View File

@@ -10,14 +10,20 @@ This page maps the app routes and important DOM areas. It is useful for plugin a
| Route | Component | Purpose | | Route | Component | Purpose |
| ---------------------------- | ------------------------- | --------------------------------------------------------------------- | | ---------------------------- | ------------------------- | --------------------------------------------------------------------- |
| `/` | Redirect | Redirects to `/search`. | | `/` | Redirect | Redirects to `/dashboard`. |
| `/login` | `LoginComponent` | User login. | | `/login` | `LoginComponent` | User login. |
| `/register` | `RegisterComponent` | User registration. | | `/register` | `RegisterComponent` | User registration. |
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. | | `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
| `/search` | `ServerSearchComponent` | Search and join servers. | | `/dashboard` | `DashboardComponent` | Landing dashboard after sign-in. |
| `/people` | `FindPeopleComponent` | Discover and start direct messages with people. |
| `/servers` | `FindServersComponent` | Search, discover, and join servers. |
| `/create-server` | `CreateServerComponent` | Create a new server. |
| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. | | `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. |
| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. | | `/dm` | `DmWorkspaceComponent` | Direct-message workspace. |
| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. | | `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. |
| `/pm` | `DmWorkspaceComponent` | Private-message workspace (alias of the DM workspace). |
| `/pm/:conversationId` | `DmWorkspaceComponent` | A selected private-message conversation. |
| `/call/:callId` | `PrivateCallComponent` | Active private (1:1) call. |
| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. | | `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. |
| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. | | `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. |
| `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. | | `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. |

View File

@@ -4,11 +4,11 @@ sidebar_position: 5
# LLM Plugin Builder Guide # LLM Plugin Builder Guide
Copy this page into an LLM prompt when you want it to build a MetoYou plugin. It is intentionally explicit about the app, communication model, visual structure, manifest format, runtime rules, API types, and examples so the model has fewer gaps to invent around. Copy this page into an LLM prompt when you want it to build a Toju plugin. It is intentionally explicit about the app, communication model, visual structure, manifest format, runtime rules, API types, and examples so the model has fewer gaps to invent around.
## Task For The LLM ## Task For The LLM
Build a MetoYou client plugin: a browser-safe JavaScript ES module with a `toju-plugin.json` manifest, loaded by the Angular renderer, running inside the user's local MetoYou app, using only browser APIs and the provided `TojuClientPluginApi`. Build a Toju client plugin: a browser-safe JavaScript ES module with a `toju-plugin.json` manifest, loaded by the Angular renderer, running inside the user's local Toju app, using only browser APIs and the provided `TojuClientPluginApi`.
Return a plugin folder like this: Return a plugin folder like this:
@@ -22,7 +22,7 @@ my-plugin/
## Hard Rules ## Hard Rules
- Do not modify MetoYou core unless the user explicitly asks for a core code change. - Do not modify Toju core unless the user explicitly asks for a core code change.
- Use plain browser ESM in `main.js`. Do not use Node APIs, `require`, `fs`, `path`, `child_process`, or build tooling unless explicitly requested. - Use plain browser ESM in `main.js`. Do not use Node APIs, `require`, `fs`, `path`, `child_process`, or build tooling unless explicitly requested.
- Use `toju-plugin.json` as the manifest name. - Use `toju-plugin.json` as the manifest name.
- Put every disposable returned by plugin APIs in `context.subscriptions`. - Put every disposable returned by plugin APIs in `context.subscriptions`.
@@ -35,9 +35,9 @@ my-plugin/
- Server-installed plugins are requirement metadata plus local client downloads. The signaling server never executes plugin entrypoints. - Server-installed plugins are requirement metadata plus local client downloads. The signaling server never executes plugin entrypoints.
- Every event used with `api.events.*` must be declared in the manifest `events` array. - Every event used with `api.events.*` must be declared in the manifest `events` array.
## What MetoYou Is ## What Toju Is
MetoYou is a Discord-like chat and voice app: Toju is a Discord-like chat and voice app:
- `toju-app/`: Angular renderer and plugin runtime. - `toju-app/`: Angular renderer and plugin runtime.
- `electron/`: Electron desktop shell, preload bridge, local database, local REST API, local docs host. - `electron/`: Electron desktop shell, preload bridge, local database, local REST API, local docs host.
@@ -124,7 +124,7 @@ Important routes:
| Route | Purpose | | Route | Purpose |
| ------------------------------- | ------------------------------------------------------------------- | | ------------------------------- | ------------------------------------------------------------------- |
| `/search` | Search and join servers. | | `/servers` | Search, discover, and join servers. |
| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. | | `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. |
| `/dm` and `/dm/:conversationId` | Direct-message workspace. | | `/dm` and `/dm/:conversationId` | Direct-message workspace. |
| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. | | `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. |
@@ -178,7 +178,7 @@ Minimal manifest:
"schemaVersion": 1, "schemaVersion": 1,
"id": "example.my-plugin", "id": "example.my-plugin",
"title": "My Plugin", "title": "My Plugin",
"description": "Adds a focused MetoYou feature.", "description": "Adds a focused Toju feature.",
"version": "1.0.0", "version": "1.0.0",
"kind": "client", "kind": "client",
"scope": "client", "scope": "client",
@@ -621,7 +621,7 @@ interface PluginApiCustomStreamRequest {
label?: string; label?: string;
} }
type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual'; type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'slashCommand' | 'manual';
interface PluginApiActionContext { interface PluginApiActionContext {
source: PluginApiActionSource; source: PluginApiActionSource;
user: User | null; user: User | null;
@@ -821,6 +821,10 @@ interface TojuClientPluginApi {
registerEmbedRenderer: (id: string, contribution: PluginApiEmbedRendererContribution) => TojuPluginDisposable; registerEmbedRenderer: (id: string, contribution: PluginApiEmbedRendererContribution) => TojuPluginDisposable;
mountElement: (id: string, request: PluginApiDomMountRequest) => TojuPluginDisposable; mountElement: (id: string, request: PluginApiDomMountRequest) => TojuPluginDisposable;
}; };
readonly commands: {
register: (id: string, contribution: PluginApiSlashCommandContribution) => TojuPluginDisposable;
list: () => PluginApiSlashCommandContribution[];
};
} }
``` ```
@@ -851,7 +855,7 @@ const currentUser = api.profile.getCurrent();
api.profile.update({ api.profile.update({
displayName: 'Ludde the Builder', displayName: 'Ludde the Builder',
description: 'Building plugins for MetoYou.' description: 'Building plugins for Toju.'
}); });
api.profile.updateAvatar({ api.profile.updateAvatar({
@@ -1178,6 +1182,8 @@ Capabilities:
| `registerEmbedRenderer` | `ui.embeds` | | `registerEmbedRenderer` | `ui.embeds` |
| `mountElement` | `ui.dom` | | `mountElement` | `ui.dom` |
For `/` slash commands, use `api.commands.register` (capability `ui.commands`). See the Slash Commands subsection below.
Register side panel: Register side panel:
```js ```js
@@ -1310,6 +1316,36 @@ context.subscriptions.push(
`mountElement` tags the element with plugin ownership metadata, replaces duplicate mounts for the same plugin/id, and removes it on disposal/unload. `mountElement` tags the element with plugin ownership metadata, replaces duplicate mounts for the same plugin/id, and removes it on disposal/unload.
### Slash Commands
Capability: `commands.register` and `commands.list` both require `ui.commands`.
Register `/` slash commands that appear in the chat composer's autocomplete menu. Set `scope: 'global'` (default) for commands available in chat servers and direct messages, or `scope: 'server'` for commands only offered while a chat server is active. Declare `options` to parse arguments into `context.args` (use `type: 'rest'` to capture all trailing text). The `run` callback receives a context with `source: 'slashCommand'`, the parsed `args`, the invoked `command` name, the raw `rawArgs`, and the current user/server/channel.
```js
context.subscriptions.push(
api.commands.register('announce', {
name: 'announce',
description: 'Post an announcement to the current channel',
icon: 'megaphone',
scope: 'server',
options: [{ name: 'message', type: 'rest', required: true }],
run: (slash) => api.messages.send(`Announcement: ${slash.args.message}`, slash.textChannel?.id)
})
);
context.subscriptions.push(
api.commands.register('shrug', {
name: 'shrug',
description: 'Append the shrug emoticon',
scope: 'global',
run: () => api.messages.send('shrug')
})
);
```
A command with no `options` runs immediately when picked; a command with options fills `/name ` so the user can type arguments before sending. Slash input is never posted as a chat message; unmatched `/text` falls through as a normal message.
## Capability Cheat Sheet ## Capability Cheat Sheet
| API call group | Capabilities | | API call group | Capabilities |
@@ -1351,6 +1387,7 @@ context.subscriptions.push(
| `ui.registerChannelSection` | `ui.channelsSection` | | `ui.registerChannelSection` | `ui.channelsSection` |
| `ui.registerEmbedRenderer` | `ui.embeds` | | `ui.registerEmbedRenderer` | `ui.embeds` |
| `ui.mountElement` | `ui.dom` | | `ui.mountElement` | `ui.dom` |
| `commands.register`, `commands.list` | `ui.commands` |
## Complete Example Plugin ## Complete Example Plugin

View File

@@ -4,7 +4,7 @@ sidebar_position: 4
# Local REST API # Local REST API
The MetoYou desktop app exposes an optional local HTTP API for scripts and tools. It is implemented in Electron and reads local desktop data. The Toju desktop app exposes an optional local HTTP API for scripts and tools. It is implemented in Electron and reads local desktop data.
## Enable the API ## Enable the API

View File

@@ -3,9 +3,9 @@ slug: /
sidebar_position: 1 sidebar_position: 1
--- ---
# MetoYou Documentation # Toju Documentation
MetoYou is a desktop-first chat app with text channels, voice channels, direct messages, plugins, local desktop storage, a local REST API, and a Docusaurus documentation site bundled into the app. Toju is a desktop-first chat app with text channels, voice channels, direct messages, plugins, local desktop storage, a local REST API, and a Docusaurus documentation site bundled into the app.
This site is split into three paths: This site is split into three paths:
@@ -26,7 +26,7 @@ The Electron app can host this documentation locally. The docs endpoint is not a
## Runtime Boundaries ## Runtime Boundaries
MetoYou keeps responsibilities split by package: Toju keeps responsibilities split by package:
- `toju-app/` is the Angular product client and plugin runtime. - `toju-app/` is the Angular product client and plugin runtime.
- `electron/` is the main process, preload bridge, IPC, local persistence, and local HTTP host. - `electron/` is the main process, preload bridge, IPC, local persistence, and local HTTP host.

View File

@@ -318,6 +318,55 @@ interface PluginApiDomMountRequest {
| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` | Adds an embed renderer. | | `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` | Adds an embed renderer. |
| `ui.mountElement(id, request)` | `ui.dom` | Mounts plugin-owned DOM into a target element or selector. | | `ui.mountElement(id, request)` | `ui.dom` | Mounts plugin-owned DOM into a target element or selector. |
## Slash Commands
Slash commands appear in a Discord-style autocomplete menu when a user types `/` in the chat composer. A command with `scope: 'global'` (the default) is offered in every chat surface, including direct messages; a command with `scope: 'server'` only appears while a chat server is active. The user picks a command from the menu (or types it and presses Enter) and the `run` callback executes with the parsed arguments and the current interaction context.
```ts
type PluginApiSlashCommandScope = 'global' | 'server';
interface PluginApiSlashCommandOption {
description?: string;
name: string;
required?: boolean;
// 'rest' captures all remaining text; otherwise a single whitespace-delimited token
type?: 'string' | 'number' | 'boolean' | 'rest';
}
interface PluginApiSlashCommandContext extends PluginApiActionContext {
args: Record<string, string>; // parsed values keyed by option name
command: string; // invoked name without the leading slash
rawArgs: string; // raw text typed after the command name
}
interface PluginApiSlashCommandContribution {
description?: string;
icon?: string;
name: string;
options?: PluginApiSlashCommandOption[];
run: (context: PluginApiSlashCommandContext) => Promise<void> | void;
scope?: PluginApiSlashCommandScope;
}
```
| Method | Capability | Description |
| ----------------------------------- | -------------- | ------------------------------------------------------------------- |
| `commands.register(id, command)` | `ui.commands` | Registers a `/` slash command for the chat composer. |
| `commands.list()` | `ui.commands` | Lists every slash command currently registered across all plugins. |
```ts
context.subscriptions.push(
api.commands.register('shout', {
description: 'Shout a message in uppercase',
icon: '📢',
name: 'shout',
options: [{ name: 'message', required: true, type: 'rest' }],
run: (slash) => api.messages.send(slash.args.message.toUpperCase()),
scope: 'server'
})
);
```
## Context and Logger ## Context and Logger
| Method | Capability | Description | | Method | Capability | Description |

View File

@@ -0,0 +1,114 @@
---
sidebar_position: 12
---
# Slash Commands API
The Commands API lets plugins register `/` slash commands. When a user types `/` in the chat composer, Toju shows a Discord-style autocomplete menu of available commands. Selecting a command (click, `Enter`, or `Tab`) runs it — either immediately when it declares no options, or after the user types the requested arguments.
## Required Capabilities
| Method | Capability |
| --------------------------------- | ------------- |
| `commands.register(id, command)` | `ui.commands` |
| `commands.list()` | `ui.commands` |
Every registration returns a disposable. Push it into `context.subscriptions` so the command is removed when the plugin unloads.
## Command Scope
A command's `scope` controls where it appears:
| Scope | Available in |
| ------------------- | --------------------------------------------- |
| `global` (default) | Chat servers **and** direct messages |
| `server` | Only while a chat server is the active surface |
Use `global` for commands that work without a server context (e.g. `/help`, `/shrug`). Use `server` for commands that act on the current server, channel, or members.
## Options and Argument Parsing
Declare `options` to describe the arguments a command accepts. Toju parses what the user typed after the command name and passes the result to `run` as `context.args`, keyed by option name.
```ts
interface PluginApiSlashCommandOption {
description?: string;
name: string;
required?: boolean;
// 'rest' captures all remaining text; otherwise a single whitespace-delimited token
type?: 'string' | 'number' | 'boolean' | 'rest';
}
```
- Positional options are filled left-to-right from whitespace-delimited tokens.
- A `rest` option captures all remaining text verbatim (use it last, for free-form text).
- Missing positional values are passed as empty strings.
- The autocomplete menu shows required options as `<name>` and optional ones as `[name]`.
Values arrive as strings; convert `number`/`boolean` types yourself inside `run`.
## Command Context
`run` receives a context that extends the standard action context (`source: 'slashCommand'`) with the invocation details:
```ts
interface PluginApiSlashCommandContext extends PluginApiActionContext {
args: Record<string, string>; // parsed values keyed by option name
command: string; // invoked name without the leading slash
rawArgs: string; // raw text typed after the command name
// inherited: server, textChannel, voiceChannel, user, source
}
```
## Register a Command
```js
export function activate(context) {
const api = context.api;
// Server-scoped command with a free-form message argument.
context.subscriptions.push(
api.commands.register('announce', {
name: 'announce',
description: 'Post an announcement to the current channel',
icon: '📢',
scope: 'server',
options: [{ name: 'message', type: 'rest', required: true }],
run: (slash) => {
api.messages.send(`📢 ${slash.args.message}`, slash.textChannel?.id);
}
})
);
// Global command that works in servers and DMs.
context.subscriptions.push(
api.commands.register('shrug', {
name: 'shrug',
description: 'Append the shrug emoticon',
scope: 'global',
run: () => api.messages.send('¯\\_(ツ)_/¯')
})
);
}
```
`api.messages.send` requires the `messages.send` capability, so the example above declares both `ui.commands` and `messages.send` in its manifest.
## List Registered Commands
```js
const allCommands = context.api.commands.list();
```
Returns every slash command currently registered across all active plugins, including their scope and options.
## Built-in Commands
Toju ships first-party commands that are always available without any plugin, such as `/lenny` (posts `( ͡° ͜ʖ ͡°)`). They appear in the same autocomplete menu tagged as **Built-in**. Plugin commands are listed alongside them; if a plugin registers a command with the same name as a built-in, both appear and the user can pick either.
## How Input Is Handled
- Typing `/` opens the menu; typing more characters filters by command name (prefix matches rank first).
- Picking a command **without options** runs it immediately and clears the composer.
- Picking a command **with options** fills `/name ` so the user can type arguments, then `Enter` runs it.
- Slash input is intercepted and never posted as a chat message. Text that starts with `/` but matches no registered command falls through and is sent as a normal message.

View File

@@ -6,6 +6,8 @@ sidebar_position: 11
The UI API lets plugins add pages, settings pages, side panels, channel sections, actions, embed renderers, and controlled DOM mounts. The UI API lets plugins add pages, settings pages, side panels, channel sections, actions, embed renderers, and controlled DOM mounts.
For `/` slash commands in the chat composer, see the [Slash Commands API](./commands.md) (`api.commands`).
Prefer registered UI contributions over direct DOM mounting. Contribution APIs let Angular render the plugin UI when the matching app surface exists. Direct DOM mounting runs immediately and throws if the target selector is not present. Prefer registered UI contributions over direct DOM mounting. Contribution APIs let Angular render the plugin UI when the matching app surface exists. Direct DOM mounting runs immediately and throws if the target selector is not present.
## Required Capabilities ## Required Capabilities
@@ -151,7 +153,7 @@ export function activate(context) {
Toolbar actions are command-style plugin entries shown in the server side panel's View plugins menu. Use them for small actions that should be easy to launch from a server, such as opening a plugin page, sending a status message, starting a timer, or toggling a plugin feature. Toolbar actions are command-style plugin entries shown in the server side panel's View plugins menu. Use them for small actions that should be easy to launch from a server, such as opening a plugin page, sending a status message, starting a timer, or toggling a plugin feature.
The View plugins link appears in `[data-testid="plugin-room-side-panel"]` when the plugin side-panel area is rendered. Opening it shows an overlay menu, positioned like profile-card overlays, with registered actions laid out as plugin icon tiles. The `icon` field can be short text such as `RH`, an emoji, or an image URL; when omitted, MetoYou falls back to initials from the plugin/action labels. The View plugins link appears in `[data-testid="plugin-room-side-panel"]` when the plugin side-panel area is rendered. Opening it shows an overlay menu, positioned like profile-card overlays, with registered actions laid out as plugin icon tiles. The `icon` field can be short text such as `RH`, an emoji, or an image URL; when omitted, Toju falls back to initials from the plugin/action labels.
Toolbar action callbacks receive an action context with `source: 'toolbarAction'`, the current user, current server, active text channel, and current voice channel when available. Toolbar action callbacks receive an action context with `source: 'toolbarAction'`, the current user, current server, active text channel, and current voice channel when available.

View File

@@ -37,6 +37,7 @@ Capabilities protect privileged app surfaces. A plugin must declare a capability
| `ui.channelsSection` | `ui.registerChannelSection()` | Adds channel sections. | | `ui.channelsSection` | `ui.registerChannelSection()` | Adds channel sections. |
| `ui.embeds` | `ui.registerEmbedRenderer()` | Renders custom embeds. | | `ui.embeds` | `ui.registerEmbedRenderer()` | Renders custom embeds. |
| `ui.dom` | `ui.mountElement()` | Mounts plugin-owned DOM into app targets. | | `ui.dom` | `ui.mountElement()` | Mounts plugin-owned DOM into app targets. |
| `ui.commands` | `commands.register()`, `commands.list()` | Registers `/` slash commands (global or server scope) and lists registered commands. |
| `storage.local` | `storage.*`, `clientData.*` | Reads and writes plugin-local data. | | `storage.local` | `storage.*`, `clientData.*` | Reads and writes plugin-local data. |
| `storage.serverData.read` | `serverData.read()` | Reads local per-user/per-server plugin data. | | `storage.serverData.read` | `serverData.read()` | Reads local per-user/per-server plugin data. |
| `storage.serverData.write` | `serverData.write()`, `serverData.remove()` | Writes or removes local per-user/per-server plugin data. | | `storage.serverData.write` | `serverData.write()`, `serverData.remove()` | Writes or removes local per-user/per-server plugin data. |

View File

@@ -4,7 +4,7 @@ sidebar_position: 1
# Create a Plugin # Create a Plugin
MetoYou plugins are browser-safe ES modules loaded by the Angular renderer. A plugin receives a frozen `TojuClientPluginApi`, declares every privileged capability in its manifest, and registers cleanup work through disposables. Toju plugins are browser-safe ES modules loaded by the Angular renderer. A plugin receives a frozen `TojuClientPluginApi`, declares every privileged capability in its manifest, and registers cleanup work through disposables.
## Folder Layout ## Folder Layout

View File

@@ -45,6 +45,61 @@ export function activate(context) {
The action appears as a tile in the server side panel's View plugins menu and runs with `source: 'toolbarAction'`. The action appears as a tile in the server side panel's View plugins menu and runs with `source: 'toolbarAction'`.
## Slash Command Plugin
`toju-plugin.json`
```json
{
"schemaVersion": 1,
"id": "example.slash-commands",
"title": "Slash Commands",
"description": "Registers / commands available from the chat composer.",
"version": "1.0.0",
"kind": "client",
"scope": "client",
"apiVersion": "1.0.0",
"compatibility": {
"minimumTojuVersion": "1.0.0",
"verifiedTojuVersion": "1.0.0"
},
"entrypoint": "./main.js",
"capabilities": ["messages.send", "ui.commands"]
}
```
`main.js`
```js
export function activate(context) {
const { api } = context;
// Global: works in chat servers and direct messages.
context.subscriptions.push(
api.commands.register('shrug', {
name: 'shrug',
description: 'Append the shrug emoticon',
scope: 'global',
run: () => api.messages.send('¯\\_(ツ)_/¯')
})
);
// Server-scoped: only offered while a chat server is active.
context.subscriptions.push(
api.commands.register('announce', {
name: 'announce',
description: 'Post an announcement to the current channel',
icon: '📢',
scope: 'server',
options: [{ name: 'message', type: 'rest', required: true }],
run: (slash) => api.messages.send(`📢 ${slash.args.message}`, slash.textChannel?.id)
})
);
}
```
Typing `/` in the composer opens the autocomplete menu. `/shrug` runs immediately; `/announce <message>` fills the composer so the user can type the announcement before sending. See the [Slash Commands API](./api/commands.md) for option parsing and the command context.
## Settings Page Plugin ## Settings Page Plugin
```json ```json

View File

@@ -41,6 +41,7 @@ type PluginCapabilityId =
| 'ui.channelsSection' | 'ui.channelsSection'
| 'ui.embeds' | 'ui.embeds'
| 'ui.dom' | 'ui.dom'
| 'ui.commands'
| 'storage.local' | 'storage.local'
| 'storage.serverData.read' | 'storage.serverData.read'
| 'storage.serverData.write' | 'storage.serverData.write'
@@ -131,7 +132,7 @@ interface TojuPluginManifest {
`scope: "server"` marks a plugin as server-scoped. Server-scoped store entries can be installed to a chat server as requirements. Required server plugins are auto-installed for members when that server opens; optional requirements stay listed but do not auto-install. `scope: "server"` marks a plugin as server-scoped. Server-scoped store entries can be installed to a chat server as requirements. Required server plugins are auto-installed for members when that server opens; optional requirements stay listed but do not auto-install.
When a user installs a server-scoped plugin into the server they are currently viewing, MetoYou enables that plugin id locally and activates the plugin immediately after the local manifest is registered. Installing a server-scoped plugin for another server records the activation preference so it activates when that server is opened. When a user installs a server-scoped plugin into the server they are currently viewing, Toju enables that plugin id locally and activates the plugin immediately after the local manifest is registered. Installing a server-scoped plugin for another server records the activation preference so it activates when that server is opened.
## Entrypoint and Bundle ## Entrypoint and Bundle

View File

@@ -4,7 +4,7 @@ sidebar_position: 1
# First Steps # First Steps
MetoYou is a chat app for servers, text conversations, direct messages, and live voice. You do not need to understand the technical parts to use it. Toju is a chat app for servers, text conversations, direct messages, and live voice. You do not need to understand the technical parts to use it.
## Main Words ## Main Words
@@ -18,11 +18,11 @@ MetoYou is a chat app for servers, text conversations, direct messages, and live
## Sign In ## Sign In
1. Open MetoYou. 1. Open Toju.
2. Sign in with your username and password. 2. Sign in with your username and password.
3. If you use more than one signaling server, choose the server endpoint that owns your account. 3. If you use more than one signaling server, choose the server endpoint that owns your account.
A signaling server handles accounts, server discovery, membership, and connection setup. In normal use you can think of it as the place MetoYou checks when you log in and join servers. A signaling server handles accounts, server discovery, membership, and connection setup. In normal use you can think of it as the place Toju checks when you log in and join servers.
## Find a Server ## Find a Server

View File

@@ -4,7 +4,7 @@ sidebar_position: 5
# Plugins for Users # Plugins for Users
Plugins add features to MetoYou. They can add pages, buttons, panels, settings, sounds, message tools, custom embeds, or server-specific behavior. Plugins add features to Toju. They can add pages, buttons, panels, settings, sounds, message tools, custom embeds, or server-specific behavior.
## Types of Plugins ## Types of Plugins
@@ -30,6 +30,8 @@ Server-scoped plugins installed to the server you are currently viewing are enab
When plugins add quick actions to a server, the server side panel shows a View plugins link in the plugin area. Open it to see a grid of plugin action tiles. Selecting a tile runs that plugin's action in the current server and channel context. When plugins add quick actions to a server, the server side panel shows a View plugins link in the plugin area. Open it to see a grid of plugin action tiles. Selecting a tile runs that plugin's action in the current server and channel context.
Plugins can also add `/` slash commands. Type `/` in the message box to open the command menu; plugin commands appear there tagged with the plugin name, alongside built-in commands like `/lenny`. See [Text and Direct Messages](./text-and-direct-messages.md#slash-commands) for how to use the menu.
## Install a Local Plugin ## Install a Local Plugin
Desktop builds can discover local plugin folders from the app data plugins directory. Desktop builds can discover local plugin folders from the app data plugins directory.
@@ -42,7 +44,7 @@ Desktop builds can discover local plugin folders from the app data plugins direc
## Server Plugin Prompts ## Server Plugin Prompts
When a server uses plugins, MetoYou may show a prompt. When a server uses plugins, Toju may show a prompt.
| Status | Meaning | | Status | Meaning |
| ------------ | --------------------------------------------------------------------------------- | | ------------ | --------------------------------------------------------------------------------- |
@@ -65,7 +67,7 @@ Examples:
| Messages | Send messages, read current messages, moderate messages, or render embeds. | | Messages | Send messages, read current messages, moderate messages, or render embeds. |
| Users and roles | Read member lists, create plugin users, or manage users. | | Users and roles | Read member lists, create plugin users, or manage users. |
| Voice and media | Play audio, add an audio stream, add a video stream, or adjust volume. | | Voice and media | Play audio, add an audio stream, add a video stream, or adjust volume. |
| UI | Add pages, settings pages, side panels, toolbar buttons, or DOM elements. | | UI | Add pages, settings pages, side panels, toolbar buttons, slash commands, or DOM elements. |
| Storage | Save plugin preferences locally or per server. | | Storage | Save plugin preferences locally or per server. |
Only grant capabilities to plugins you trust. Only grant capabilities to plugins you trust.
@@ -83,4 +85,4 @@ The Plugin Manager lets you:
## Plugin Safety Notes ## Plugin Safety Notes
Plugins are browser-safe JavaScript modules loaded by the client. They do not run on the signaling server. A plugin can only call privileged MetoYou APIs when its manifest declares the capability and you grant it. Plugins are browser-safe JavaScript modules loaded by the client. They do not run on the signaling server. A plugin can only call privileged Toju APIs when its manifest declares the capability and you grant it.

View File

@@ -4,7 +4,7 @@ sidebar_position: 2
# Servers and Channels # Servers and Channels
A server is the main shared space in MetoYou. Servers contain members, channels, permissions, optional plugins, and server settings. A server is the main shared space in Toju. Servers contain members, channels, permissions, optional plugins, and server settings.
## Server Rail ## Server Rail

View File

@@ -20,7 +20,7 @@ Settings control the app, voice, plugins, servers, themes, updates, local APIs,
## Local Data ## Local Data
Desktop MetoYou stores local app data on your device. That can include rooms, messages, users, plugin data, settings, and metadata. The desktop settings include data import/export tools. Desktop Toju stores local app data on your device. That can include rooms, messages, users, plugin data, settings, and metadata. The desktop settings include data import/export tools.
## Local API and Documentation Hosting ## Local API and Documentation Hosting

View File

@@ -13,6 +13,7 @@ Text channels belong to a server. Everyone with access to that server and channe
You can use text channels to: You can use text channels to:
- send normal messages; - send normal messages;
- run slash commands by typing `/`;
- edit or delete your own messages when allowed; - edit or delete your own messages when allowed;
- react to messages; - react to messages;
- send attachments; - send attachments;
@@ -24,14 +25,25 @@ You can use text channels to:
Direct messages are private conversations outside a server channel. Use them when a message is meant for one person instead of the server. Direct messages are private conversations outside a server channel. Use them when a message is meant for one person instead of the server.
## Slash Commands
Type `/` at the start of the message box to open the slash command menu. It lists the commands you can run, with a short description for each.
- Keep typing to filter the list (for example `/le`).
- Use the up and down arrow keys to move through the list, then press `Enter` or `Tab` to pick a command. You can also click one.
- Press `Escape` to close the menu.
- A command that needs extra text fills the box with `/name ` so you can type the rest, then send it.
Toju includes built-in commands such as `/lenny`, which posts `( ͡° ͜ʖ ͡°)`. Plugins can add their own commands, which appear in the same menu (tagged with the plugin name). Slash commands are available in both text channels and direct messages; some plugin commands only appear inside a server. Text that starts with `/` but matches no command is sent as a normal message.
## Attachments and Media ## Attachments and Media
Attachments can appear as files, images, audio, or video depending on the file type and what the app can preview. If an image or link cannot load directly, the app can use fallback paths where available. Attachments can appear as files, images, audio, or video depending on the file type and what the app can preview. If an image or link cannot load directly, the app can use fallback paths where available.
## Message Sync ## Message Sync
MetoYou stores messages locally and syncs recent messages with peers when connections are available. If you were offline, messages may appear after peers reconnect and exchange their recent message lists. Toju stores messages locally and syncs recent messages with peers when connections are available. If you were offline, messages may appear after peers reconnect and exchange their recent message lists.
## Plugin Messages ## Plugin Messages
Some plugins can send messages, create bot-style plugin users, render custom embeds, or add composer buttons. MetoYou asks for plugin capability grants before plugins can use privileged message features. Some plugins can send messages, create bot-style plugin users, render custom embeds, or add composer buttons. Toju asks for plugin capability grants before plugins can use privileged message features.

View File

@@ -45,7 +45,7 @@ When someone shares camera or screen, the voice workspace can expand into a larg
## Floating Voice Controls ## Floating Voice Controls
If you navigate away from the server while still connected to voice, MetoYou can show floating voice controls. Use them to return to the voice server or leave the call. If you navigate away from the server while still connected to voice, Toju can show floating voice controls. Use them to return to the voice server or leave the call.
## Voice Settings ## Voice Settings

View File

@@ -2,11 +2,11 @@
sidebar_position: 2 sidebar_position: 2
--- ---
# Using MetoYou # Using Toju
## Sign In ## Sign In
MetoYou signs in through a signaling server. The signaling server validates the user account, coordinates server membership, relays selected realtime messages, and helps peers establish WebRTC connections. Toju signs in through a signaling server. The signaling server validates the user account, coordinates server membership, relays selected realtime messages, and helps peers establish WebRTC connections.
For the desktop Local API, the same signaling server allow-list is used before local bearer tokens can be issued. This keeps local automation tied to servers you explicitly trust. For the desktop Local API, the same signaling server allow-list is used before local bearer tokens can be issued. This keeps local automation tied to servers you explicitly trust.
@@ -39,7 +39,7 @@ Desktop builds include platform integrations such as Linux display-server detect
Open the Plugin Store from the title bar package button or menu. The plugin manager separates global client plugins from server-scoped plugins. Installed plugins can be activated, reloaded, unloaded, disabled, inspected for logs, and granted capabilities. Open the Plugin Store from the title bar package button or menu. The plugin manager separates global client plugins from server-scoped plugins. Installed plugins can be activated, reloaded, unloaded, disabled, inspected for logs, and granted capabilities.
Plugins are explicit runtime modules. MetoYou loads browser-safe ES modules, passes a frozen API object, and cleans up registered disposables when a plugin unloads. Plugins are explicit runtime modules. Toju loads browser-safe ES modules, passes a frozen API object, and cleans up registered disposables when a plugin unloads.
## Desktop Settings ## Desktop Settings

View File

@@ -2,7 +2,7 @@ import type { Config } from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic'; import type * as Preset from '@docusaurus/preset-classic';
const config: Config = { const config: Config = {
title: 'MetoYou Docs', title: 'Toju Docs',
tagline: 'Desktop chat, local APIs, and plugin development', tagline: 'Desktop chat, local APIs, and plugin development',
url: 'http://127.0.0.1', url: 'http://127.0.0.1',
baseUrl: '/docusaurus/', baseUrl: '/docusaurus/',
@@ -31,7 +31,7 @@ const config: Config = {
], ],
themeConfig: { themeConfig: {
navbar: { navbar: {
title: 'MetoYou Docs', title: 'Toju Docs',
items: [ items: [
{ type: 'docSidebar', sidebarId: 'mainSidebar', position: 'left', label: 'Guides' }, { type: 'docSidebar', sidebarId: 'mainSidebar', position: 'left', label: 'Guides' },
{ to: '/user-guide/first-steps', label: 'User Guide', position: 'left' }, { to: '/user-guide/first-steps', label: 'User Guide', position: 'left' },
@@ -56,7 +56,7 @@ const config: Config = {
] ]
} }
], ],
copyright: 'MetoYou local documentation. Built with Docusaurus.' copyright: 'Toju local documentation. Built with Docusaurus.'
}, },
prism: { prism: {
additionalLanguages: [ additionalLanguages: [

View File

@@ -13,7 +13,7 @@ const sidebars: SidebarsConfig = {
'user-guide/voice-channels', 'user-guide/voice-channels',
'user-guide/plugins', 'user-guide/plugins',
'user-guide/settings', 'user-guide/settings',
'using-metoyou' 'using-toju'
] ]
}, },
{ {
@@ -50,7 +50,8 @@ const sidebars: SidebarsConfig = {
'plugin-development/api/message-bus', 'plugin-development/api/message-bus',
'plugin-development/api/p2p-and-media', 'plugin-development/api/p2p-and-media',
'plugin-development/api/storage', 'plugin-development/api/storage',
'plugin-development/api/ui' 'plugin-development/api/ui',
'plugin-development/api/commands'
] ]
}, },
'plugin-development/examples' 'plugin-development/examples'

View File

@@ -48,7 +48,8 @@ export const test = base.extend<MultiClientFixture>({
const context = await browser.newContext({ const context = await browser.newContext({
permissions: ['microphone', 'camera'], permissions: ['microphone', 'camera'],
baseURL: 'http://localhost:4200' baseURL: 'http://localhost:4200',
viewport: { width: 1440, height: 900 }
}); });
await installTestServerEndpoint(context, testServer.port); await installTestServerEndpoint(context, testServer.port);

20
e2e/helpers/app-menu.ts Normal file
View File

@@ -0,0 +1,20 @@
import { expect, type Page } from '@playwright/test';
export async function openTitleBarMenu(page: Page): Promise<void> {
const menuButton = page.getByRole('button', { name: 'Menu' });
await expect(menuButton).toBeVisible({ timeout: 15_000 });
await menuButton.click();
await expect(page.locator('app-title-bar .absolute.right-0.top-full').first()).toBeVisible({ timeout: 10_000 });
}
export async function openPluginStore(page: Page): Promise<void> {
await openTitleBarMenu(page);
await page.getByRole('button', { name: 'Plugin Store' }).click();
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
}
export async function openSettingsFromMenu(page: Page): Promise<void> {
await openTitleBarMenu(page);
await page.getByRole('button', { name: 'Settings' }).click();
}

106
e2e/helpers/auth-api.ts Normal file
View File

@@ -0,0 +1,106 @@
import { type APIRequestContext, type Page } from '@playwright/test';
export const AUTH_TOKENS_STORAGE_KEY = 'metoyou.authTokens';
export const SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY = 'metoyou.signalServerCredentials';
export interface AuthSession {
id: string;
username: string;
displayName: string;
token: string;
expiresAt: number;
}
export function authHeaders(token: string): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
};
}
export async function registerTestUser(
request: APIRequestContext,
baseUrl: string,
username: string,
password: string,
displayName?: string
): Promise<AuthSession> {
const response = await request.post(`${baseUrl}/api/users/register`, {
data: {
username,
password,
displayName: displayName ?? username
}
});
if (!response.ok()) {
throw new Error(`Failed to register test user ${username}: ${response.status()} ${await response.text()}`);
}
return await response.json() as AuthSession;
}
export async function loginTestUser(
request: APIRequestContext,
baseUrl: string,
username: string,
password: string
): Promise<AuthSession> {
const response = await request.post(`${baseUrl}/api/users/login`, {
data: { username, password }
});
if (!response.ok()) {
throw new Error(`Failed to login test user ${username}: ${response.status()} ${await response.text()}`);
}
return await response.json() as AuthSession;
}
export async function readSignalServerCredentialFromPage(
page: Page,
serverUrl: string
): Promise<{ userId: string; token: string; username: string } | null> {
return await page.evaluate(({ storageKey, url }) => {
try {
const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, {
userId: string;
token: string;
username: string;
expiresAt: number;
}>;
const normalizedUrl = url.trim().replace(/\/+$/, '');
const entry = store[normalizedUrl];
if (!entry || entry.expiresAt <= Date.now()) {
return null;
}
return {
userId: entry.userId,
token: entry.token,
username: entry.username
};
} catch {
return null;
}
}, { storageKey: SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY, url: serverUrl });
}
export async function readAuthTokenFromPage(page: Page, serverUrl: string): Promise<string | null> {
return await page.evaluate(({ storageKey, url }) => {
try {
const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, { token: string; expiresAt: number }>;
const normalizedUrl = url.trim().replace(/\/+$/, '');
const entry = store[normalizedUrl];
if (!entry || entry.expiresAt <= Date.now()) {
return null;
}
return entry.token;
} catch {
return null;
}
}, { storageKey: AUTH_TOKENS_STORAGE_KEY, url: serverUrl });
}

11
e2e/helpers/dashboard.ts Normal file
View File

@@ -0,0 +1,11 @@
import { expect, type Page } from '@playwright/test';
/** Dashboard omnibox (desktop placeholder copy changed with i18n refresh). */
export function dashboardSearchInput(page: Page) {
return page.getByRole('textbox', { name: 'Search people, servers, and invites' });
}
export async function expectDashboardReady(page: Page, timeout = 30_000): Promise<void> {
await expect(page).toHaveURL(/\/dashboard/, { timeout });
await expect(dashboardSearchInput(page)).toBeVisible({ timeout });
}

View File

@@ -0,0 +1,312 @@
import { expect, type Page } from '@playwright/test';
import { type Client } from '../fixtures/multi-client';
import { LoginPage } from '../pages/login.page';
import { RegisterPage } from '../pages/register.page';
import { ServerSearchPage } from '../pages/server-search.page';
import { ChatRoomPage } from '../pages/chat-room.page';
import { ChatMessagesPage } from '../pages/chat-messages.page';
export const MULTI_DEVICE_PASSWORD = 'TestPass123!';
export const MULTI_DEVICE_VOICE_CHANNEL = 'General';
export interface MultiDeviceCredentials {
username: string;
displayName: string;
password: string;
}
export interface MultiDeviceScenario {
clientA: Client;
clientB: Client;
credentials: MultiDeviceCredentials;
serverName: string;
messagesA: ChatMessagesPage;
messagesB: ChatMessagesPage;
roomA: ChatRoomPage;
roomB: ChatRoomPage;
}
export function uniqueMultiDeviceName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10_000)}`;
}
export async function createMultiDeviceScenario(
createClient: () => Promise<Client>,
options: { suffix?: string; serverDescription?: string } = {}
): Promise<MultiDeviceScenario> {
const suffix = options.suffix ?? uniqueMultiDeviceName('multi-device');
const credentials: MultiDeviceCredentials = {
username: `multi_${suffix}`,
displayName: 'Multi Device User',
password: MULTI_DEVICE_PASSWORD
};
const serverName = `Multi Device Server ${suffix}`;
const clientA = await createClient();
const clientB = await createClient();
await warmClientPage(clientA.page);
await warmClientPage(clientB.page);
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 searchA = new ServerSearchPage(clientA.page);
await searchA.createServer(serverName, {
description: options.serverDescription ?? 'Multi-device session coverage'
});
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await waitForCurrentRoomName(clientA.page, serverName);
const roomA = new ChatRoomPage(clientA.page);
await roomA.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL);
await loginSecondDeviceIntoServer(clientB.page, credentials, serverName);
await waitForCurrentRoomName(clientB.page, serverName);
const messagesA = new ChatMessagesPage(clientA.page);
const messagesB = new ChatMessagesPage(clientB.page);
const roomB = new ChatRoomPage(clientB.page);
await messagesA.waitForReady();
await messagesB.waitForReady();
return {
clientA,
clientB,
credentials,
serverName,
messagesA,
messagesB,
roomA,
roomB
};
}
export async function loginSecondDeviceIntoServer(
page: Page,
credentials: MultiDeviceCredentials,
serverName: string
): Promise<void> {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(credentials.username, credentials.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
const search = new ServerSearchPage(page);
await search.joinServerFromSearch(serverName);
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
}
export async function expectCrossDeviceMessage(
sender: ChatMessagesPage,
receiver: ChatMessagesPage,
message: string,
timeout = 60_000
): Promise<void> {
await sender.sendMessage(message);
await expectSyncedMessage(receiver, message, timeout);
}
/** Waits until a message sent elsewhere appears in the local chat history. */
export async function expectSyncedMessage(
receiver: ChatMessagesPage,
message: string,
timeout = 90_000
): Promise<void> {
await receiver.waitForReady();
await expect(receiver.getMessageItemByText(message)).toBeVisible({ timeout });
}
export async function expectSyncedMessageWithResync(
page: Page,
receiver: ChatMessagesPage,
message: string,
timeout = 60_000
): Promise<void> {
await receiver.waitForReady();
const alreadyVisible = await receiver.getMessageItemByText(message)
.isVisible()
.catch(() => false);
if (!alreadyVisible) {
await resyncChannelMessages(page);
}
await expect(receiver.getMessageItemByText(message)).toBeVisible({ timeout });
}
export async function resyncChannelMessages(page: Page, channelName = 'general'): Promise<void> {
const channel = page.locator(`button[data-channel-type="text"][data-channel-name="${channelName}"]`).first();
await expect(channel).toBeVisible({ timeout: 10_000 });
await channel.click({ button: 'right' });
await page.getByRole('button', { name: 'Resync Messages' }).click();
}
export async function closeClient(client: Client): Promise<void> {
await client.context.close();
}
export async function registerGuestAndJoinServer(
page: Page,
credentials: MultiDeviceCredentials,
serverName: string
): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
const search = new ServerSearchPage(page);
await search.joinServerFromSearch(serverName);
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
}
export async function reopenClientInServer(
createClient: () => Promise<Client>,
credentials: MultiDeviceCredentials,
serverName: string
): Promise<{ client: Client; messages: ChatMessagesPage }> {
const client = await createClient();
await warmClientPage(client.page);
await loginSecondDeviceIntoServer(client.page, credentials, serverName);
const messages = new ChatMessagesPage(client.page);
await messages.waitForReady();
return { client, messages };
}
async function warmClientPage(page: Page): Promise<void> {
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle').catch(() => undefined);
}
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 readClientInstanceId(page: Page): Promise<string | null> {
return page.evaluate(() => {
const sessionId = sessionStorage.getItem('metoyou.clientInstanceId')?.trim();
if (sessionId) {
return sessionId;
}
return localStorage.getItem('metoyou.clientInstanceId')?.trim() ?? null;
});
}
export async function logoutFromMenu(page: Page): Promise<void> {
const menuButton = page.getByRole('button', { name: 'Menu' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(menuButton).toBeVisible({ timeout: 10_000 });
await menuButton.click();
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
await logoutButton.click();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
}
export function channelsSidePanel(page: Page) {
return page.locator('app-rooms-side-panel').first();
}
export function membersSidePanel(page: Page) {
return page.locator('app-rooms-side-panel').last();
}
export function serverMemberRow(page: Page, displayName: string) {
return membersSidePanel(page)
.locator('[role="button"], button')
.filter({ has: page.getByText(displayName, { exact: true }) })
.first();
}
/**
* Gates cross-user assertions on real presence: the peer must show up in the
* members panel before chat delivery between the two users can be expected.
*/
export async function expectServerPeerVisible(
page: Page,
displayName: string,
timeout = 45_000
): Promise<void> {
await expect(serverMemberRow(page, displayName)).toBeVisible({ timeout });
}
export function passiveVoiceChannelJoinBadge(page: Page, channelName = MULTI_DEVICE_VOICE_CHANNEL) {
return page
.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`)
.getByText('Join', { exact: true });
}
export async function expectPassiveVoiceOnDevice(
page: Page,
options: { timeout?: number; displayName?: string; channelName?: string } = {}
): Promise<void> {
const timeout = options.timeout ?? 45_000;
const channelName = options.channelName ?? MULTI_DEVICE_VOICE_CHANNEL;
const displayName = options.displayName;
await expect.poll(async () => {
const membersLabel = await membersSidePanel(page)
.getByText('In voice on another device', { exact: false })
.isVisible()
.catch(() => false);
const joinBadge = await passiveVoiceChannelJoinBadge(page, channelName).isVisible()
.catch(() => false);
const grayedVoiceUser = displayName
? await channelsSidePanel(page).locator('.opacity-50')
.filter({ hasText: displayName })
.first()
.isVisible()
.catch(() => false)
: false;
return membersLabel || joinBadge || grayedVoiceUser;
}, { timeout }).toBe(true);
}
export async function expectActiveVoiceOnDevice(page: Page, timeout = 20_000): Promise<void> {
await expect(page.locator('app-voice-controls, app-voice-workspace').first()).toBeVisible({ timeout });
}

View File

@@ -0,0 +1,19 @@
import { expect, type Page } from '@playwright/test';
export const E2E_PLUGIN_SOURCE_URL = 'http://localhost:4200/plugins/e2e-plugin-source.json';
export const E2E_PLUGIN_TITLE = 'E2E All API Plugin';
export async function addPluginSource(page: Page, sourceUrl = E2E_PLUGIN_SOURCE_URL): Promise<void> {
const sourceInput = page.getByLabel('Plugin source manifest URL');
await expect(sourceInput).toBeVisible({ timeout: 15_000 });
await sourceInput.click();
await sourceInput.fill(sourceUrl);
await expect(sourceInput).toHaveValue(sourceUrl, { timeout: 5_000 });
const addSourceButton = page.getByRole('button', { name: 'Add Source' });
await expect(addSourceButton).toBeEnabled({ timeout: 10_000 });
await addSourceButton.click();
await expect(page.getByRole('heading', { name: E2E_PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
}

View File

@@ -0,0 +1,76 @@
import { expect, type Page } from '@playwright/test';
const MOBILE_VIEWPORT = { width: 390, height: 844 };
export async function openSettingsModal(page: Page, settingsPage = 'general'): Promise<void> {
await page.evaluate((targetPage) => {
interface SettingsModalServiceHandle {
open: (page: string) => void;
}
interface SettingsModalComponentHandle {
mobilePage?: { set: (page: 'menu' | 'detail') => void };
animating?: { set: (value: boolean) => void };
navigate?: (page: string) => void;
}
interface AppComponentHandle {
settingsModal?: SettingsModalServiceHandle;
}
interface AngularDebugApi {
getComponent: (element: Element) => AppComponentHandle & SettingsModalComponentHandle;
applyChanges?: (component: unknown) => void;
}
const debugApi = (window as Window & { ng?: AngularDebugApi }).ng;
const appRoot = document.querySelector('app-root');
const settingsHost = document.querySelector('app-settings-modal');
const appComponent = appRoot && debugApi?.getComponent(appRoot);
const settingsComponent = settingsHost && debugApi?.getComponent(settingsHost);
if (!appComponent?.settingsModal?.open) {
throw new Error('Angular debug API could not open settings modal');
}
appComponent.settingsModal.open(targetPage);
settingsComponent?.mobilePage?.set('menu');
settingsComponent?.animating?.set(true);
debugApi?.applyChanges?.(appComponent);
debugApi?.applyChanges?.(settingsComponent);
}, settingsPage);
await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId('settings-logout-button')).toBeVisible({ timeout: 10_000 });
}
export async function openSettingsDetailPage(page: Page, settingsPage: string): Promise<void> {
await openSettingsModal(page, settingsPage);
await page.evaluate((targetPage) => {
interface SettingsModalComponentHandle {
navigate?: (page: string) => void;
animating?: { set: (value: boolean) => void };
}
interface AngularDebugApi {
getComponent: (element: Element) => SettingsModalComponentHandle;
applyChanges?: (component: SettingsModalComponentHandle) => void;
}
const host = document.querySelector('app-settings-modal');
const debugApi = (window as Window & { ng?: AngularDebugApi }).ng;
const component = host && debugApi?.getComponent(host);
if (!component?.navigate) {
throw new Error('Angular debug API could not navigate settings modal');
}
component.navigate(targetPage);
component.animating?.set(true);
debugApi?.applyChanges?.(component);
}, settingsPage);
}
export async function openSettingsDataPage(page: Page): Promise<void> {
await openSettingsDetailPage(page, 'data');
await expect(page.locator('app-data-settings')).toBeVisible({ timeout: 10_000 });
}
export { MOBILE_VIEWPORT };

View File

@@ -7,16 +7,19 @@
* *
* Cleanup: the temp directory is removed when the process exits. * Cleanup: the temp directory is removed when the process exits.
*/ */
const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs'); const { existsSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs');
const { join } = require('path'); const { join } = require('path');
const { tmpdir } = require('os'); const { tmpdir } = require('os');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const TEST_PORT = process.env.TEST_SERVER_PORT || '3099'; const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
const SERVER_DIR = join(__dirname, '..', '..', 'server'); const SERVER_DIR = join(__dirname, '..', '..', 'server');
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts'); const SERVER_DIST_ENTRY = join(SERVER_DIR, 'dist', 'index.js');
const SERVER_SRC_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json'); const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js'); const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js');
const SERVER_ENTRY = existsSync(SERVER_DIST_ENTRY) ? SERVER_DIST_ENTRY : SERVER_SRC_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-')); const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
@@ -45,7 +48,7 @@ console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
// and node_modules are found from the real server/ directory. // and node_modules are found from the real server/ directory.
const child = spawn( const child = spawn(
process.execPath, process.execPath,
[TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY], USE_COMPILED_SERVER ? [SERVER_ENTRY] : [TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
{ {
cwd: tmpDir, cwd: tmpDir,
env: { env: {

View File

@@ -1,15 +1,30 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ import { type BrowserContext, type Page } from '@playwright/test';
import { type Page } from '@playwright/test'; import type { WebRtcTestHarnessWindow } from './webrtc-test-window.types';
type RtcPeerConnectionArgs = ConstructorParameters<typeof RTCPeerConnection>;
type AudioContextArgs = ConstructorParameters<typeof AudioContext>;
interface ScreenShareMediaStream extends MediaStream {
__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
* can inspect WebRTC state via `page.evaluate()`. * can inspect WebRTC state via `page.evaluate()`.
* *
* Call immediately after page creation, before any `goto()`. * Call on the browser context (preferred) or page before any `goto()`.
*/ */
export async function installWebRTCTracking(page: Page): Promise<void> { export async function installWebRTCTracking(target: BrowserContext | Page): Promise<void> {
await page.addInitScript(() => { const addInitScript = 'addInitScript' in target && typeof target.addInitScript === 'function'
? target.addInitScript.bind(target)
: (target as Page).addInitScript.bind(target);
await addInitScript(() => {
const connections: RTCPeerConnection[] = []; const connections: RTCPeerConnection[] = [];
const dataChannels: RTCDataChannel[] = []; const dataChannels: RTCDataChannel[] = [];
const syntheticMediaResources: { const syntheticMediaResources: {
@@ -17,11 +32,12 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
source?: AudioScheduledSourceNode; source?: AudioScheduledSourceNode;
drawIntervalId?: number; drawIntervalId?: number;
}[] = []; }[] = [];
const harness = webRtcHarnessWindow();
(window as any).__rtcConnections = connections; harness.__rtcConnections = connections;
(window as any).__rtcDataChannels = dataChannels; harness.__rtcDataChannels = dataChannels;
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[]; harness.__rtcRemoteTracks = [];
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources; harness.__rtcSyntheticMediaResources = syntheticMediaResources;
const OriginalRTCPeerConnection = window.RTCPeerConnection; const OriginalRTCPeerConnection = window.RTCPeerConnection;
const trackDataChannel = (channel: RTCDataChannel) => { const trackDataChannel = (channel: RTCDataChannel) => {
@@ -32,7 +48,7 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
dataChannels.push(channel); dataChannels.push(channel);
}; };
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) { harness.RTCPeerConnection = function(this: RTCPeerConnection, ...args: RtcPeerConnectionArgs) {
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args); const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
const originalCreateDataChannel = pc.createDataChannel.bind(pc); const originalCreateDataChannel = pc.createDataChannel.bind(pc);
@@ -46,7 +62,7 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
}) as RTCPeerConnection['createDataChannel']; }) as RTCPeerConnection['createDataChannel'];
pc.addEventListener('connectionstatechange', () => { pc.addEventListener('connectionstatechange', () => {
(window as any).__lastRtcState = pc.connectionState; harness.__lastRtcState = pc.connectionState;
}); });
pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => { pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => {
@@ -54,7 +70,7 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
}); });
pc.addEventListener('track', (event: RTCTrackEvent) => { pc.addEventListener('track', (event: RTCTrackEvent) => {
(window as any).__rtcRemoteTracks.push({ harness.__rtcRemoteTracks.push({
kind: event.track.kind, kind: event.track.kind,
id: event.track.id, id: event.track.id,
readyState: event.track.readyState readyState: event.track.readyState
@@ -62,10 +78,10 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
}); });
return pc; return pc;
} as any; } as typeof RTCPeerConnection;
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype; harness.RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection); Object.setPrototypeOf(harness.RTCPeerConnection, OriginalRTCPeerConnection);
// Patch getDisplayMedia to return a synthetic screen share stream // Patch getDisplayMedia to return a synthetic screen share stream
// (canvas-based video + 880Hz oscillator audio) so the browser // (canvas-based video + 880Hz oscillator audio) so the browser
@@ -140,7 +156,7 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
}, { once: true }); }, { once: true });
// Tag the stream so tests can identify it // Tag the stream so tests can identify it
(resultStream as any).__isScreenShare = true; (resultStream as ScreenShareMediaStream).__isScreenShare = true;
return resultStream; return resultStream;
}; };
@@ -165,11 +181,12 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
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();
(window as any).AudioContext = function(this: AudioContext, ...args: any[]) { audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
const ctx: AudioContext = new OrigAudioContext(...args); const ctx: AudioContext = new OrigAudioContext(...args);
// Track all created AudioContexts for test diagnostics // Track all created AudioContexts for test diagnostics
const tracked = ((window as any).__trackedAudioContexts ??= []) as AudioContext[]; const tracked = audioHarness.__trackedAudioContexts ??= [];
tracked.push(ctx); tracked.push(ctx);
@@ -185,18 +202,19 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
}); });
return ctx; return ctx;
} as any; } as typeof AudioContext;
(window as any).AudioContext.prototype = OrigAudioContext.prototype; audioHarness.AudioContext.prototype = OrigAudioContext.prototype;
Object.setPrototypeOf((window as any).AudioContext, OrigAudioContext); Object.setPrototypeOf(audioHarness.AudioContext, OrigAudioContext);
}); });
} }
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(
() => (window as any).__rtcConnections?.some( () => webRtcHarnessWindow().__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected' (pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false, ) ?? false,
undefined,
{ timeout } { timeout }
); );
} }
@@ -206,7 +224,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(
() => (window as any).__rtcConnections?.some( () => webRtcHarnessWindow().__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected' (pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false ) ?? false
); );
@@ -215,7 +233,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(
() => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter( () => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected' (pc) => pc.connectionState === 'connected'
).length ?? 0 ).length ?? 0
); );
@@ -224,7 +242,7 @@ 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> {
await page.waitForFunction( await page.waitForFunction(
(count) => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter( (count) => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected' (pc) => pc.connectionState === 'connected'
).length === count, ).length === count,
expectedCount, expectedCount,
@@ -235,7 +253,7 @@ export async function waitForConnectedPeerCount(page: Page, expectedCount: numbe
/** 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(
() => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( () => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
(channel) => channel.readyState === 'open' (channel) => channel.readyState === 'open'
).length ?? 0 ).length ?? 0
); );
@@ -244,7 +262,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) => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( (count) => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
(channel) => channel.readyState === 'open' (channel) => channel.readyState === 'open'
).length === count, ).length === count,
expectedCount, expectedCount,
@@ -255,7 +273,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 = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
let closed = 0; let closed = 0;
@@ -275,7 +293,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 = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
let dispatched = 0; let dispatched = 0;
@@ -336,7 +354,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 = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) { if (!connections?.length) {
return []; return [];
@@ -353,7 +371,7 @@ export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat
try { try {
const stats = await pc.getStats(); const stats = await pc.getStats();
stats.forEach((report: any) => { stats.forEach((report: RTCStats) => {
const kind = report.kind ?? report.mediaType; const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'audio') { if (report.type === 'outbound-rtp' && kind === 'audio') {
@@ -454,7 +472,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 = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) if (!connections?.length)
return { outbound: null, inbound: null }; return { outbound: null, inbound: null };
@@ -468,8 +486,8 @@ export async function getAudioStats(page: Page): Promise<{
hasInbound: boolean; hasInbound: boolean;
}; };
const hwm: Record<number, HWMEntry> = (window as any).__rtcStatsHWM = const hwm: Record<number, HWMEntry> = webRtcHarnessWindow().__rtcStatsHWM =
((window as any).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {}; (webRtcHarnessWindow().__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;
@@ -487,7 +505,7 @@ export async function getAudioStats(page: Page): Promise<{
let hasOut = false; let hasOut = false;
let hasIn = false; let hasIn = false;
stats.forEach((report: any) => { stats.forEach((report: RTCStats) => {
const kind = report.kind ?? report.mediaType; const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'audio') { if (report.type === 'outbound-rtp' && kind === 'audio') {
@@ -578,7 +596,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 = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) if (!connections?.length)
return false; return false;
@@ -595,7 +613,7 @@ export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Pr
let hasOut = false; let hasOut = false;
let hasIn = false; let hasIn = false;
stats.forEach((report: any) => { stats.forEach((report: RTCStats) => {
const kind = report.kind ?? report.mediaType; const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'audio') if (report.type === 'outbound-rtp' && kind === 'audio')
@@ -611,6 +629,7 @@ export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Pr
return false; return false;
}, },
undefined,
{ timeout } { timeout }
); );
} }
@@ -686,7 +705,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 = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) if (!connections?.length)
return { outbound: null, inbound: null }; return { outbound: null, inbound: null };
@@ -700,8 +719,8 @@ export async function getVideoStats(page: Page): Promise<{
hasInbound: boolean; hasInbound: boolean;
} }
const hwm: Record<number, VHWM> = (window as any).__rtcVideoStatsHWM = const hwm: Record<number, VHWM> = webRtcHarnessWindow().__rtcVideoStatsHWM =
((window as any).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {}; (webRtcHarnessWindow().__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;
@@ -719,7 +738,7 @@ export async function getVideoStats(page: Page): Promise<{
let hasOut = false; let hasOut = false;
let hasIn = false; let hasIn = false;
stats.forEach((report: any) => { stats.forEach((report: RTCStats) => {
const kind = report.kind ?? report.mediaType; const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'video') { if (report.type === 'outbound-rtp' && kind === 'video') {
@@ -785,7 +804,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 = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) if (!connections?.length)
return false; return false;
@@ -802,7 +821,7 @@ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Pr
let hasOut = false; let hasOut = false;
let hasIn = false; let hasIn = false;
stats.forEach((report: any) => { stats.forEach((report: RTCStats) => {
const kind = report.kind ?? report.mediaType; const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'video') if (report.type === 'outbound-rtp' && kind === 'video')
@@ -818,6 +837,7 @@ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Pr
return false; return false;
}, },
undefined,
{ timeout } { timeout }
); );
} }
@@ -952,7 +972,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 = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; const conns = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
if (!conns?.length) if (!conns?.length)
return 'No connections tracked'; return 'No connections tracked';
@@ -977,7 +997,7 @@ export async function dumpRtcDiagnostics(page: Page): Promise<string> {
try { try {
const stats = await pc.getStats(); const stats = await pc.getStats();
stats.forEach((report: any) => { stats.forEach((report: RTCStats) => {
if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp') if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp')
return; return;
@@ -987,7 +1007,7 @@ export async function dumpRtcDiagnostics(page: Page): Promise<string> {
lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`); lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`);
}); });
} catch (err: any) { } catch (err: unknown) {
lines.push(` getStats() failed: ${err?.message ?? err}`); lines.push(` getStats() failed: ${err?.message ?? err}`);
} }
} }

View File

@@ -0,0 +1,28 @@
export interface RtcRemoteTrackSnapshot {
kind: string;
id: string;
readyState: string;
}
export interface RtcSyntheticMediaResource {
audioCtx: AudioContext;
source?: AudioScheduledSourceNode;
drawIntervalId?: number;
}
export interface WebRtcTestHarnessWindow extends Window {
__rtcConnections: RTCPeerConnection[];
__rtcDataChannels: RTCDataChannel[];
__rtcRemoteTracks: RtcRemoteTrackSnapshot[];
__rtcSyntheticMediaResources: RtcSyntheticMediaResource[];
__trackedAudioContexts?: AudioContext[];
__rtcStatsHWM?: Record<number, Record<string, number | boolean>>;
__rtcVideoStatsHWM?: Record<number, Record<string, number | boolean>>;
__lastRtcState?: RTCPeerConnectionState;
RTCPeerConnection: typeof RTCPeerConnection;
AudioContext: typeof AudioContext;
}
export function getWebRtcTestHarnessWindow(): WebRtcTestHarnessWindow {
return window as unknown as WebRtcTestHarnessWindow;
}

View File

@@ -34,9 +34,22 @@ export class ChatMessagesPage {
} }
async sendMessage(content: string): Promise<void> { async sendMessage(content: string): Promise<void> {
await this.waitForReady(); let lastError: unknown;
await this.composerInput.fill(content);
await this.sendButton.click(); for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await this.waitForReady();
await this.composerInput.fill(content);
await expect(this.composerInput).toHaveValue(content, { timeout: 5_000 });
await expect(this.sendButton).toBeEnabled({ timeout: 5_000 });
await this.sendButton.click();
return;
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error ? lastError : new Error('Failed to send chat message');
} }
async typeDraft(content: string): Promise<void> { async typeDraft(content: string): Promise<void> {
@@ -44,6 +57,13 @@ export class ChatMessagesPage {
await this.composerInput.fill(content); await this.composerInput.fill(content);
} }
/** Types into the composer in a way that emits input/typing events (not just fill). */
async typeDraftWithTypingEvents(content: string): Promise<void> {
await this.waitForReady();
await this.composerInput.click();
await this.composerInput.pressSequentially(content, { delay: 40 });
}
async clearDraft(): Promise<void> { async clearDraft(): Promise<void> {
await this.waitForReady(); await this.waitForReady();
await this.composerInput.fill(''); await this.composerInput.fill('');
@@ -74,6 +94,25 @@ export class ChatMessagesPage {
}, files); }, files);
} }
/** Sends the currently-attached files with no text caption (attachment-only message). */
async sendPendingAttachments(): Promise<void> {
await this.waitForReady();
await expect(this.sendButton).toBeEnabled({ timeout: 10_000 });
await this.sendButton.click();
}
/** The message bubble that contains the rendered image with the given alt text. */
getMessageItemContainingImage(altText: string): Locator {
return this.messageItems.filter({
has: this.page.locator(`img[alt="${altText}"]`)
}).last();
}
/** Resolves the stable data-message-id of the bubble holding the given image. */
async getMessageIdContainingImage(altText: string): Promise<string | null> {
return this.getMessageItemContainingImage(altText).getAttribute('data-message-id');
}
async openGifPicker(): Promise<void> { async openGifPicker(): Promise<void> {
await this.waitForReady(); await this.waitForReady();
await this.gifButton.click(); await this.gifButton.click();
@@ -112,6 +151,31 @@ export class ChatMessagesPage {
}).toBe(true); }).toBe(true);
} }
/** SHA-256 of the bytes currently served by the rendered chat image. */
async getMessageImageSha256(altText: string): Promise<string> {
const image = this.getMessageImageByAlt(altText);
return image.evaluate(async (element) => {
const img = element as HTMLImageElement;
const response = await fetch(img.src);
const buffer = await response.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buffer);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
});
}
/** Asserts the rendered chat image is byte-identical to the sent file. */
async expectMessageImageContentSha256(altText: string, expectedSha256: string): Promise<void> {
await this.expectMessageImageLoaded(altText);
await expect.poll(() => this.getMessageImageSha256(altText), {
timeout: 30_000,
message: `Image ${altText} should be received byte-identical (no truncated/corrupt transfer)`
}).toBe(expectedSha256);
}
getEmbedCardByTitle(title: string): Locator { getEmbedCardByTitle(title: string): Locator {
return this.page.locator('app-chat-link-embed').filter({ return this.page.locator('app-chat-link-embed').filter({
has: this.page.getByText(title, { exact: true }) has: this.page.getByText(title, { exact: true })

View File

@@ -317,13 +317,22 @@ export class ChatRoomPage {
throw new Error('Missing room, user, or endpoint when persisting channels'); throw new Error('Missing room, user, or endpoint when persisting channels');
} }
const authTokens = JSON.parse(localStorage.getItem('metoyou.authTokens') || '{}') as Record<string, { token: string; expiresAt: number }>;
const normalizedApiUrl = apiBaseUrl.trim().replace(/\/+$/, '');
const authEntry = authTokens[normalizedApiUrl];
const authToken = authEntry && authEntry.expiresAt > Date.now() ? authEntry.token : null;
if (!authToken) {
throw new Error('Missing session token for channel persistence');
}
const response = await fetch(`${apiBaseUrl}/api/servers/${room.id}`, { const response = await fetch(`${apiBaseUrl}/api/servers/${room.id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`
}, },
body: JSON.stringify({ body: JSON.stringify({
currentOwnerId: currentUser.id,
channels: nextChannels channels: nextChannels
}) })
}); });

View File

@@ -10,15 +10,14 @@ export class LoginPage {
readonly registerLink: Locator; readonly registerLink: Locator;
constructor(private page: Page) { constructor(private page: Page) {
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]') this.form = page.locator('form').filter({ has: page.locator('#login-username') });
.first();
this.usernameInput = page.locator('#login-username'); this.usernameInput = page.locator('#login-username');
this.passwordInput = page.locator('#login-password'); this.passwordInput = page.locator('#login-password');
this.serverSelect = page.locator('#login-server'); this.serverSelect = page.locator('#login-server');
this.submitButton = this.form.getByRole('button', { name: 'Login' }); this.submitButton = this.form.getByRole('button', { name: 'Login' });
this.errorText = page.locator('.text-destructive'); this.errorText = page.locator('.text-destructive');
this.registerLink = this.form.getByRole('button', { name: 'Register' }); this.registerLink = page.getByRole('button', { name: 'Register' });
} }
async goto() { async goto() {

27
e2e/run-playwright.mjs Normal file
View File

@@ -0,0 +1,27 @@
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const e2eDirectory = fileURLToPath(new URL('.', import.meta.url));
const env = { ...process.env };
const browsersPath = env.PLAYWRIGHT_BROWSERS_PATH;
if (browsersPath?.includes('/cursor-sandbox-cache/')) {
delete env.PLAYWRIGHT_BROWSERS_PATH;
}
const [command = 'test', ...args] = process.argv.slice(2);
const executable = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const child = spawn(executable, ['playwright', command, ...args], {
cwd: e2eDirectory,
env,
stdio: 'inherit'
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});

View File

@@ -0,0 +1,153 @@
import { test, expect } from '../../fixtures/multi-client';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
interface TestUser {
username: string;
displayName: string;
password: string;
}
test.describe('Login returnUrl handling', () => {
test.describe.configure({ timeout: 120_000 });
test('unwraps nested login returnUrl chains after successful login', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = uniqueName('nested-return');
const user: TestUser = {
username: `user_${suffix}`,
displayName: 'Return Url User',
password: 'TestPass123!'
};
await test.step('Create an account', async () => {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
});
await test.step('Log out and open a deeply nested login returnUrl', async () => {
await logout(page);
const nestedReturnUrl = '/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers';
await page.goto(`/login?returnUrl=${encodeURIComponent(nestedReturnUrl)}`, {
waitUntil: 'domcontentloaded'
});
await expect(page.locator('#login-username')).toBeVisible({ timeout: 15_000 });
});
await test.step('Login lands on the original destination instead of looping on /login', async () => {
const loginPage = new LoginPage(page);
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
await expect(page).not.toHaveURL(/returnUrl=.*login/);
});
});
test('redirects unauthenticated /servers visits to login and returns there after login', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = uniqueName('servers-return');
const user: TestUser = {
username: `user_${suffix}`,
displayName: 'Servers Return User',
password: 'TestPass123!'
};
await test.step('Create an account and log out', async () => {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
await logout(page);
});
await test.step('Visiting /servers sends the user to a single-level login returnUrl', async () => {
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
await expect(page).not.toHaveURL(/returnUrl=.*login/);
});
await test.step('Logging in returns to /servers', async () => {
const loginPage = new LoginPage(page);
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
});
});
test('lets a returning user log back in after an expired session redirect', async ({ createClient }) => {
const client = await createClient();
const { page } = client;
const suffix = uniqueName('expired-session');
const user: TestUser = {
username: `user_${suffix}`,
displayName: 'Expired Session User',
password: 'TestPass123!'
};
await test.step('Create an account', async () => {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
});
await test.step('Simulate an expired session while keeping the persisted user id', async () => {
await page.evaluate(() => {
const storageKey = 'metoyou.authTokens';
const raw = localStorage.getItem(storageKey);
if (!raw) {
return;
}
const parsed = JSON.parse(raw) as Record<string, { token: string; expiresAt: number }>;
const expiredStore = Object.fromEntries(
Object.entries(parsed).map(([url, entry]) => [url, { ...entry, expiresAt: 0 }])
);
localStorage.setItem(storageKey, JSON.stringify(expiredStore));
});
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
await expect(page).not.toHaveURL(/returnUrl=.*login/);
});
await test.step('The user can authenticate again and reach /servers', async () => {
const loginPage = new LoginPage(page);
await loginPage.login(user.username, user.password);
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
});
});
});
async function logout(page: import('@playwright/test').Page): Promise<void> {
const menuButton = page.getByRole('button', { name: 'Menu' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(menuButton).toBeVisible({ timeout: 10_000 });
await menuButton.click();
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
await logoutButton.click();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -0,0 +1,94 @@
import { test, expect } from '../../fixtures/multi-client';
import {
MULTI_DEVICE_VOICE_CHANNEL,
channelsSidePanel,
createMultiDeviceScenario,
expectCrossDeviceMessage,
expectActiveVoiceOnDevice,
expectPassiveVoiceOnDevice,
logoutFromMenu,
membersSidePanel,
passiveVoiceChannelJoinBadge,
readClientInstanceId,
uniqueMultiDeviceName
} from '../../helpers/multi-device-session';
test.describe('Multi-device session', () => {
test.describe.configure({ timeout: 300_000, retries: 1 });
test('covers identity, chat sync, typing exclusion, and voice exclusivity', async ({ createClient }) => {
const scenario = await createMultiDeviceScenario(createClient);
const messageAtoB = `Cross-device A to B ${uniqueMultiDeviceName('msg')}`;
const messageBtoA = `Cross-device B to A ${uniqueMultiDeviceName('msg')}`;
const typingDraft = `Typing draft ${uniqueMultiDeviceName('draft')}`;
await test.step('assigns distinct clientInstanceId per browser context', async () => {
const instanceA = await readClientInstanceId(scenario.clientA.page);
const instanceB = await readClientInstanceId(scenario.clientB.page);
expect(instanceA).toBeTruthy();
expect(instanceB).toBeTruthy();
expect(instanceA).not.toEqual(instanceB);
});
await test.step('syncs chat from device A to device B', async () => {
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
});
await test.step('syncs chat from device B to device A', async () => {
await expectCrossDeviceMessage(scenario.messagesB, scenario.messagesA, messageBtoA);
});
await test.step('does not show own typing indicator on the other device for the same user', async () => {
await scenario.messagesA.typeDraftWithTypingEvents(typingDraft);
await expect(
scenario.clientB.page.getByText(`${scenario.credentials.displayName} is typing`, { exact: false })
).toHaveCount(0, { timeout: 5_000 });
});
await test.step('shows passive in-voice UI on the second device when the first joins voice', async () => {
await scenario.roomA.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
await expectActiveVoiceOnDevice(scenario.clientA.page);
await expectPassiveVoiceOnDevice(scenario.clientB.page, {
displayName: scenario.credentials.displayName
});
await expect(
membersSidePanel(scenario.clientB.page).getByText('In voice on another device', { exact: false })
).toBeVisible({ timeout: 20_000 });
await expect(
channelsSidePanel(scenario.clientB.page).locator('.opacity-50')
.filter({
hasText: scenario.credentials.displayName
})
.first()
).toBeVisible({ timeout: 20_000 });
});
await test.step('shows Join takeover affordance on passive device voice channel', async () => {
await expect(passiveVoiceChannelJoinBadge(scenario.clientB.page)).toBeVisible({ timeout: 20_000 });
});
await test.step('transfers voice ownership when the passive device takes over', async () => {
await scenario.roomB.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
await expectActiveVoiceOnDevice(scenario.clientB.page);
await expectPassiveVoiceOnDevice(scenario.clientA.page, {
displayName: scenario.credentials.displayName
});
});
await test.step('keeps the second device logged in when the first device logs out', async () => {
const message = `Still logged in ${uniqueMultiDeviceName('logout')}`;
await logoutFromMenu(scenario.clientA.page);
await scenario.messagesB.sendMessage(message);
await expect(scenario.messagesB.getMessageItemByText(message)).toBeVisible({ timeout: 20_000 });
await expect(scenario.clientB.page).toHaveURL(/\/room\//, { timeout: 10_000 });
});
});
});

View File

@@ -0,0 +1,111 @@
import { expect } from '@playwright/test';
import { test } from '../../fixtures/multi-client';
import { openSettingsFromMenu } from '../../helpers/app-menu';
import { expectDashboardReady } from '../../helpers/dashboard';
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server';
import {
readAuthTokenFromPage,
readSignalServerCredentialFromPage,
registerTestUser
} from '../../helpers/auth-api';
import { RegisterPage } from '../../pages/register.page';
const PRIMARY_ENDPOINT_ID = 'e2e-multi-auth-primary';
const USER_PASSWORD = 'TestPass123!';
test.describe('Multi-signal-server authentication', () => {
test.describe.configure({ timeout: 180_000 });
test('auto-provisions a foreign signal server when a new endpoint is added', async ({ createClient, request }) => {
const primaryServer = await startTestServer();
const secondaryServer = await startTestServer();
try {
const client = await createClient();
const suffix = `multi_auth_${Date.now()}`;
const username = `user_${suffix}`;
await installTestServerEndpoints(client.context, [
{
id: PRIMARY_ENDPOINT_ID,
name: 'E2E Primary Signal',
url: primaryServer.url,
isActive: true,
status: 'online'
}
]);
await test.step('Register on the home signal server', async () => {
const register = new RegisterPage(client.page);
await register.goto();
await register.register(username, 'Multi Auth User', USER_PASSWORD);
await expectDashboardReady(client.page);
});
await test.step('Add a second signal server in network settings', async () => {
await openSettingsFromMenu(client.page);
await client.page.getByRole('button', { name: 'Network' }).click();
await client.page.getByPlaceholder('Server name').fill('E2E Secondary Signal');
await client.page.getByPlaceholder('Server URL (e.g., http://localhost:3001)').fill(secondaryServer.url);
await client.page.getByTestId('add-signal-server-button').click();
await expect(client.page.getByText(secondaryServer.url)).toBeVisible({ timeout: 15_000 });
});
await test.step('Wait for auto-provisioned credentials on the secondary server', async () => {
await expect.poll(async () =>
await readSignalServerCredentialFromPage(client.page, secondaryServer.url),
{ timeout: 30_000 }
).not.toBeNull();
const homeToken = await readAuthTokenFromPage(client.page, primaryServer.url);
const secondaryCredential = await readSignalServerCredentialFromPage(client.page, secondaryServer.url);
expect(homeToken).toBeTruthy();
expect(secondaryCredential?.username).toBe(username);
expect(secondaryCredential?.token).toBeTruthy();
});
await test.step('Secondary credential can call authenticated APIs', async () => {
const secondaryCredential = await readSignalServerCredentialFromPage(client.page, secondaryServer.url);
if (!secondaryCredential) {
throw new Error('Expected secondary signal-server credential to be provisioned');
}
const response = await request.post(`${secondaryServer.url}/api/servers`, {
headers: {
Authorization: `Bearer ${secondaryCredential.token}`,
'Content-Type': 'application/json'
},
data: {
name: `Secondary Provisioned Server ${suffix}`,
description: 'Created with auto-provisioned credentials',
ownerId: secondaryCredential.userId,
ownerPublicKey: 'e2e-secondary-owner-key'
}
});
expect(response.ok(), `POST /api/servers failed: ${response.status()} ${await response.text()}`).toBe(true);
});
await test.step('Home registration still works independently on the secondary server', async () => {
const otherUser = await registerTestUser(
request,
secondaryServer.url,
`other_${suffix}`,
USER_PASSWORD,
'Other User'
);
expect(otherUser.username).toBe(`other_${suffix}`);
});
} finally {
await primaryServer.stop();
await secondaryServer.stop();
}
});
});

View File

@@ -0,0 +1,88 @@
import { expect } from '@playwright/test';
import { test } from '../../fixtures/multi-client';
import { openSettingsFromMenu } from '../../helpers/app-menu';
import { expectDashboardReady } from '../../helpers/dashboard';
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server';
import { readSignalServerCredentialFromPage, SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY } from '../../helpers/auth-api';
import { RegisterPage } from '../../pages/register.page';
const PRIMARY_ENDPOINT_ID = 'e2e-offline-login-primary';
const USER_PASSWORD = 'TestPass123!';
test.describe('Offline signal server navigation', () => {
test('does not redirect to authorize login after a foreign server goes offline', async ({ createClient }) => {
const primaryServer = await startTestServer();
const secondaryServer = await startTestServer();
const suffix = `offline_login_${Date.now()}`;
const username = `user_${suffix}`;
try {
const client = await createClient();
await installTestServerEndpoints(client.context, [
{
id: PRIMARY_ENDPOINT_ID,
name: 'E2E Primary Signal',
url: primaryServer.url,
isActive: true,
status: 'online'
}
]);
await test.step('Register and provision a secondary signal server', async () => {
const register = new RegisterPage(client.page);
await register.goto();
await register.register(username, 'Offline Login User', USER_PASSWORD);
await expectDashboardReady(client.page);
await openSettingsFromMenu(client.page);
await client.page.getByRole('button', { name: 'Network' }).click();
await client.page.getByPlaceholder('Server name').fill('E2E Secondary Signal');
await client.page.getByPlaceholder('Server URL (e.g., http://localhost:3001)').fill(secondaryServer.url);
await client.page.getByTestId('add-signal-server-button').click();
await expect(client.page.getByText(secondaryServer.url)).toBeVisible({ timeout: 15_000 });
await expect.poll(async () =>
await readSignalServerCredentialFromPage(client.page, secondaryServer.url),
{ timeout: 30_000 }
).not.toBeNull();
await client.page.keyboard.press('Escape');
});
await test.step('Offline secondary endpoints do not trigger authorize login', async () => {
await secondaryServer.stop();
await client.page.evaluate(({ storageKey, url }) => {
const normalizedUrl = url.trim().replace(/\/+$/, '');
const credentialStore = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, unknown>;
const nextCredentialStore = Object.fromEntries(
Object.entries(credentialStore).filter(([key]) => key !== normalizedUrl)
);
localStorage.setItem(storageKey, JSON.stringify(nextCredentialStore));
const endpoints = JSON.parse(localStorage.getItem('metoyou_server_endpoints') || '[]') as {
url: string;
status: string;
}[];
localStorage.setItem('metoyou_server_endpoints', JSON.stringify(endpoints.map((endpoint) =>
endpoint.url.trim().replace(/\/+$/, '') === normalizedUrl
? { ...endpoint, status: 'offline' }
: endpoint
)));
}, { storageKey: SIGNAL_SERVER_CREDENTIALS_STORAGE_KEY, url: secondaryServer.url });
await client.page.goto('/dashboard', { waitUntil: 'commit', timeout: 10_000 });
await expect(client.page).not.toHaveURL(/\/login/);
await expect(client.page.url()).not.toMatch(/mode=authorize/);
});
} finally {
await primaryServer.stop();
await secondaryServer.stop();
}
});
});

View File

@@ -48,14 +48,13 @@ test.describe('User session data isolation', () => {
await test.step('Alice registers and creates local chat history', async () => { await test.step('Alice registers and creates local chat history', async () => {
await registerUser(client.page, alice); await registerUser(client.page, alice);
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage); await createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
}); });
await test.step('Alice sees the same saved room and message after a full restart', async () => { await test.step('Alice sees the same saved room and message after a full restart', async () => {
await restartPersistentClient(client, testServer.port); await restartPersistentClient(client, testServer.port);
await openApp(client.page); await openApp(client.page);
await expect(client.page).not.toHaveURL(/\/login/, { timeout: 15_000 }); await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
}); });
} finally { } finally {
await closePersistentClient(client); await closePersistentClient(client);
@@ -88,11 +87,11 @@ test.describe('User session data isolation', () => {
await test.step('Alice creates persisted local data and verifies it survives a restart', async () => { await test.step('Alice creates persisted local data and verifies it survives a restart', async () => {
await registerUser(client.page, alice); await registerUser(client.page, alice);
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage); await createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
await restartPersistentClient(client, testServer.port); await restartPersistentClient(client, testServer.port);
await openApp(client.page); await openApp(client.page);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage); await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
}); });
await test.step('Bob starts from a blank slate in the same browser profile', async () => { await test.step('Bob starts from a blank slate in the same browser profile', async () => {
@@ -102,11 +101,11 @@ test.describe('User session data isolation', () => {
}); });
await test.step('Bob gets only his own saved room and history after a restart', async () => { await test.step('Bob gets only his own saved room and history after a restart', async () => {
await createServerAndSendMessage(client.page, bobServerName, bobMessage); await createServerAndSendMessage(client.page, bob, bobServerName, bobMessage);
await restartPersistentClient(client, testServer.port); await restartPersistentClient(client, testServer.port);
await openApp(client.page); await openApp(client.page);
await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage); await expectSavedRoomAndHistory(client.page, bob, bobServerName, bobMessage);
await expectSavedRoomHidden(client.page, aliceServerName); await expectSavedRoomHidden(client.page, aliceServerName);
}); });
@@ -117,7 +116,7 @@ test.describe('User session data isolation', () => {
await expectSavedRoomVisible(client.page, aliceServerName); await expectSavedRoomVisible(client.page, aliceServerName);
await expectSavedRoomHidden(client.page, bobServerName); await expectSavedRoomHidden(client.page, bobServerName);
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage); await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
}); });
} finally { } finally {
await closePersistentClient(client); await closePersistentClient(client);
@@ -194,32 +193,58 @@ async function logoutUser(page: Page): Promise<void> {
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 }); await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
} }
async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise<void> { async function createServerAndSendMessage(page: Page, user: TestUser, serverName: string, messageText: string): Promise<void> {
const searchPage = new ServerSearchPage(page); const searchPage = new ServerSearchPage(page);
const messagesPage = new ChatMessagesPage(page); const messagesPage = new ChatMessagesPage(page);
await searchPage.createServer(serverName, { await loginIfNeeded(page, user);
description: `User session isolation coverage for ${serverName}` await ensureCurrentUserScope(page, user);
}); await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
if (await waitForLoginForm(page, 5_000)) {
await loginUser(page, user);
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
}
await expect(searchPage.serverNameInput).toBeVisible({ timeout: 10_000 });
await searchPage.serverNameInput.fill(serverName);
await searchPage.serverDescriptionInput.fill(`User session isolation coverage for ${serverName}`);
await searchPage.createSubmitButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 }); await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await messagesPage.sendMessage(messageText); await messagesPage.sendMessage(messageText);
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 }); await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
await expectMessagePersistedInIndexedDb(page, messageText);
} }
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> { async function expectSavedRoomAndHistory(page: Page, user: TestUser, roomName: string, messageText: string): Promise<void> {
const railRoomButton = getRailSavedRoomButton(page, roomName); if (await waitForVisibleText(page, messageText, 5_000)) {
const messagesPage = new ChatMessagesPage(page); return;
}
await expect(railRoomButton).toBeVisible({ timeout: 20_000 }); if (await new LoginPage(page).usernameInput.isVisible().catch(() => false)) {
await page.goto('/servers', { waitUntil: 'domcontentloaded' }); await loginUser(page, user);
const searchRoomButton = getSearchSavedRoomButton(page, roomName); }
await expect(searchRoomButton).toBeVisible({ timeout: 20_000 }); await expectMessagePersistedInIndexedDb(page, messageText);
await searchRoomButton.click();
const persistedRoomId = await getPersistedRoomIdForMessage(page, messageText);
if (persistedRoomId) {
await openPersistedRoomById(page, user, persistedRoomId);
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
return;
}
if (await openSavedRoomFromRail(page, roomName)) {
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
return;
}
await joinServerFromSearchAfterLogin(page, user, roomName);
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 }); await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
} }
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> { async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
@@ -232,14 +257,17 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<
} }
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> { async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 }); if (await page.getByText(roomName, { exact: false }).first()
.isVisible()
.catch(() => false)) {
return;
}
await page.goto('/servers', { waitUntil: 'domcontentloaded' }); await page.goto('/servers', { waitUntil: 'domcontentloaded' });
await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 }); await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
} }
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> { async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
await expect(getRailSavedRoomButton(page, roomName)).toHaveCount(0);
if (!page.url().includes('/servers')) { if (!page.url().includes('/servers')) {
await page.goto('/servers', { waitUntil: 'domcontentloaded' }); await page.goto('/servers', { waitUntil: 'domcontentloaded' });
} }
@@ -247,14 +275,227 @@ async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void
await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0); await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0);
} }
function getRailSavedRoomButton(page: Page, roomName: string) {
return page.locator(`button[title="${roomName}"]`).first();
}
function getSearchSavedRoomButton(page: Page, roomName: string) { function getSearchSavedRoomButton(page: Page, roomName: string) {
return page.locator('app-server-browser').getByRole('button', { name: roomName, exact: true }); return page.locator('app-server-browser').getByRole('button', { name: roomName, exact: true });
} }
async function openSavedRoomFromRail(page: Page, roomName: string): Promise<boolean> {
try {
await expect(page.locator('app-servers-rail')).toBeVisible({ timeout: 10_000 });
const clicked = await page.locator('app-servers-rail button').evaluateAll((buttons, expectedName) => {
const expectedPrefix = expectedName.slice(0, 24);
const button = buttons.find((candidate) => {
const title = (candidate as HTMLButtonElement).title;
return title === expectedName || title.startsWith(expectedPrefix);
}) as HTMLButtonElement | undefined;
button?.click();
return !!button;
}, roomName);
if (!clicked) {
return await openSavedRoomFromDashboard(page, roomName);
}
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
return true;
} catch {
return await openSavedRoomFromDashboard(page, roomName);
}
}
async function openSavedRoomFromDashboard(page: Page, roomName: string): Promise<boolean> {
const roomNamePattern = new RegExp(escapeRegExp(roomName.slice(0, 24)));
const roomButton = page.getByRole('button', { name: roomNamePattern }).first();
try {
await expect(roomButton).toBeVisible({ timeout: 10_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
return true;
} catch {
return await joinVisibleServerFromDashboard(page, roomNamePattern);
}
}
async function joinVisibleServerFromDashboard(page: Page, roomNamePattern: RegExp): Promise<boolean> {
const serverRow = page.locator('div', { hasText: roomNamePattern }).filter({
has: page.getByRole('button', { name: 'Join' })
})
.last();
const joinButton = serverRow.getByRole('button', { name: 'Join' });
try {
await expect(joinButton).toBeVisible({ timeout: 10_000 });
await joinButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
return true;
} catch {
return false;
}
}
async function joinServerFromSearchAfterLogin(page: Page, user: TestUser, roomName: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
await loginIfNeeded(page, user);
await searchPage.goto();
if (!await waitForServerSearch(page, 5_000)) {
await loginUser(page, user);
await searchPage.goto();
}
await expect(searchPage.searchInput).toBeVisible({ timeout: 15_000 });
await searchPage.searchInput.fill(roomName);
const serverCard = page.locator('div[title]', { hasText: roomName }).first();
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.dblclick();
}
async function loginIfNeeded(page: Page, user: TestUser): Promise<void> {
const loginPage = new LoginPage(page);
if (page.url().includes('/login')) {
await expect(loginPage.usernameInput).toBeVisible({ timeout: 15_000 });
await loginUser(page, user);
return;
}
if (await loginPage.usernameInput.isVisible().catch(() => false)) {
await loginUser(page, user);
}
}
async function ensureCurrentUserScope(page: Page, user: TestUser): Promise<void> {
if (await hasCurrentUserScope(page)) {
return;
}
await loginUser(page, user);
await expect.poll(() => hasCurrentUserScope(page), { timeout: 10_000 }).toBe(true);
}
async function hasCurrentUserScope(page: Page): Promise<boolean> {
return page.evaluate(() => !!localStorage.getItem('metoyou_currentUserId')?.trim());
}
async function openPersistedRoomById(page: Page, user: TestUser, roomId: string): Promise<void> {
for (let attempt = 1; attempt <= 3; attempt += 1) {
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
if (await waitForLoginForm(page, 5_000)) {
await loginUser(page, user);
continue;
}
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
if (!await waitForLoginForm(page, 2_000)) {
return;
}
await loginUser(page, user);
}
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
}
async function waitForLoginForm(page: Page, timeout: number): Promise<boolean> {
try {
await expect(new LoginPage(page).usernameInput).toBeVisible({ timeout });
return true;
} catch {
return false;
}
}
async function waitForServerSearch(page: Page, timeout: number): Promise<boolean> {
try {
await expect(new ServerSearchPage(page).searchInput).toBeVisible({ timeout });
return true;
} catch {
return false;
}
}
async function waitForVisibleText(page: Page, text: string, timeout: number): Promise<boolean> {
try {
await expect(page.getByText(text, { exact: false })).toBeVisible({ timeout });
return true;
} catch {
return false;
}
}
async function expectMessagePersistedInIndexedDb(page: Page, messageText: string): Promise<void> {
await expect.poll(
() => getPersistedRoomIdForMessage(page, messageText).then((roomId) => !!roomId),
{ timeout: 10_000 }
).toBe(true);
}
async function getPersistedRoomIdForMessage(page: Page, messageText: string): Promise<string | null> {
return page.evaluate(async (expectedContent) => {
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim();
const preferredDatabaseName = `metoyou::${encodeURIComponent(currentUserId || 'anonymous')}`;
const discoveredDatabaseNames = typeof indexedDB.databases === 'function'
? (await indexedDB.databases())
.map((database) => database.name)
.filter((name): name is string => !!name && (name === 'metoyou' || name.startsWith('metoyou::')))
: null;
const databaseNames = discoveredDatabaseNames ?? [preferredDatabaseName];
const remainingDatabaseNames = databaseNames.filter((name) => name !== preferredDatabaseName);
const orderedDatabaseNames = databaseNames.includes(preferredDatabaseName)
? [preferredDatabaseName].concat(remainingDatabaseNames)
: remainingDatabaseNames;
for (const databaseName of orderedDatabaseNames) {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(databaseName);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
try {
if (!database.objectStoreNames.contains('messages')) {
continue;
}
const transaction = database.transaction('messages', 'readonly');
const request = transaction.objectStore('messages').getAll();
const roomId = await new Promise<string | null>((resolve, reject) => {
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const match = ((request.result as { content?: string; roomId?: string }[]) ?? [])
.find((message) => message.content === expectedContent);
resolve(match?.roomId ?? null);
};
});
if (roomId) {
return roomId;
}
} finally {
database.close();
}
}
return null;
}, messageText);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> { async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
let lastError: unknown; let lastError: unknown;

View File

@@ -0,0 +1,125 @@
import {
test,
expect,
type Client
} 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';
/**
* Regression coverage for: "Video attachment on android gets sent in the
* message bubble above with no preview image."
*
* Root cause was platform-agnostic: caption-less media was bound to a message
* re-discovered by matching `content` (always '' for attachment-only sends),
* which raced the async create-effect and grouped a second attachment onto the
* previous bubble - leaving an empty message behind. The fix pre-allocates the
* message id, dispatches it, and binds attachments to that exact id. This test
* proves each caption-less attachment lands in its own bubble and renders.
*/
test.describe('Attachment-only message grouping', () => {
test.describe.configure({ timeout: 180_000 });
test('each caption-less attachment keeps its own message bubble and preview', async ({ createClient }) => {
const scenario = await createSingleClientChatScenario(createClient);
const serverName = `Attachment Group ${uniqueName('srv')}`;
const introText = `Intro line ${uniqueName('intro')}`;
const firstImageName = `${uniqueName('first')}.svg`;
const secondImageName = `${uniqueName('second')}.svg`;
const firstImage = createSvgFilePayload(firstImageName);
const secondImage = createSvgFilePayload(secondImageName);
await test.step('Create a server and open its room', async () => {
await scenario.search.createServer(serverName, { description: 'Attachment grouping regression server' });
await expect(scenario.client.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await scenario.messages.waitForReady();
});
await test.step('Send a normal text message first', async () => {
await scenario.messages.sendMessage(introText);
await expect(scenario.messages.getMessageItemByText(introText)).toBeVisible({ timeout: 20_000 });
});
await test.step('Send two caption-less attachments back-to-back', async () => {
// Fire them rapidly (no render wait between) to mirror the reported
// rapid-upload repro and stress the message-create vs. attach ordering.
await scenario.messages.attachFiles([firstImage]);
await scenario.messages.sendPendingAttachments();
await scenario.messages.attachFiles([secondImage]);
await scenario.messages.sendPendingAttachments();
await scenario.messages.expectMessageImageLoaded(firstImageName);
await scenario.messages.expectMessageImageLoaded(secondImageName);
});
await test.step('Each attachment lives in its own bubble (no grouping, no blank message)', async () => {
const firstMessageId = await scenario.messages.getMessageIdContainingImage(firstImageName);
const secondMessageId = await scenario.messages.getMessageIdContainingImage(secondImageName);
expect(firstMessageId).toBeTruthy();
expect(secondMessageId).toBeTruthy();
// The bug grouped both onto one bubble; distinct ids prove they did not.
expect(firstMessageId).not.toBe(secondMessageId);
// Exactly two bubbles carry an image, and neither carries both.
await expect(scenario.messages.messageItems.filter({ has: scenario.client.page.locator('img[alt$=".svg"]') }))
.toHaveCount(2, { timeout: 20_000 });
await expect(scenario.messages.getMessageItemContainingImage(firstImageName).locator('img[alt$=".svg"]'))
.toHaveCount(1);
await expect(scenario.messages.getMessageItemContainingImage(secondImageName).locator('img[alt$=".svg"]'))
.toHaveCount(1);
});
});
});
interface SingleClientChatScenario {
client: Client;
messages: ChatMessagesPage;
search: ServerSearchPage;
}
async function createSingleClientChatScenario(createClient: () => Promise<Client>): Promise<SingleClientChatScenario> {
const suffix = uniqueName('solo');
const client = await createClient();
const credentials = {
username: `solo_${suffix}`,
displayName: 'Solo',
password: 'TestPass123!'
};
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
return {
client,
messages: new ChatMessagesPage(client.page),
search: new ServerSearchPage(client.page)
};
}
function createSvgFilePayload(name: string): ChatDropFilePayload {
const markup = [
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
'<rect width="160" height="120" rx="18" fill="#0f172a" />',
'<circle cx="38" cy="36" r="18" fill="#38bdf8" />',
`<text x="24" y="104" fill="#e2e8f0" font-size="12" font-family="Arial, sans-serif">${name}</text>`,
'</svg>'
].join('');
return {
name,
mimeType: 'image/svg+xml',
base64: Buffer.from(markup, 'utf8').toString('base64')
};
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -1,3 +1,4 @@
import { createHash, randomBytes } from 'node:crypto';
import { type Page } from '@playwright/test'; import { type Page } from '@playwright/test';
import { import {
test, test,
@@ -182,6 +183,28 @@ test.describe('Chat messaging features', () => {
}); });
}); });
test('syncs multi-chunk image attachments byte-identical between users', async ({ createClient }) => {
const scenario = await createChatScenario(createClient);
const imageName = `${uniqueName('photo')}.svg`;
const imageCaption = `Large image upload ${uniqueName('caption')}`;
// Several P2P file chunks (64 KiB each) - regression coverage for transfers
// that previously finalized with only the first chunks received.
const { payload, sha256 } = createMultiChunkImagePayload(imageName);
await test.step('Alice sends a multi-chunk image attachment', async () => {
await scenario.aliceMessages.attachFiles([payload]);
await scenario.aliceMessages.sendMessage(imageCaption);
await scenario.aliceMessages.expectMessageImageLoaded(imageName);
await scenario.aliceMessages.expectMessageImageContentSha256(imageName, sha256);
});
await test.step('Bob receives the image fully and byte-identical', async () => {
await expect(scenario.bobMessages.getMessageItemByText(imageCaption)).toBeVisible({ timeout: 20_000 });
await scenario.bobMessages.expectMessageImageContentSha256(imageName, sha256);
});
});
test('renders link embeds for shared links', async ({ createClient }) => { test('renders link embeds for shared links', async ({ createClient }) => {
const scenario = await createChatScenario(createClient); const scenario = await createChatScenario(createClient);
const messageText = `Useful docs ${MOCK_EMBED_URL}`; const messageText = `Useful docs ${MOCK_EMBED_URL}`;
@@ -442,6 +465,24 @@ function createTextFilePayload(name: string, mimeType: string, content: string):
}; };
} }
function createMultiChunkImagePayload(name: string): { payload: ChatDropFilePayload; sha256: string } {
// ~300 KB of XML-safe noise inside an SVG comment so the file spans
// multiple 64 KiB P2P transfer chunks while remaining a renderable image.
const noise = randomBytes(225_000).toString('base64');
const markup = buildMockSvgMarkup(name).replace('</svg>', `<!-- ${noise} --></svg>`);
const contentBuffer = Buffer.from(markup, 'utf8');
return {
payload: {
name,
mimeType: 'image/svg+xml',
base64: contentBuffer.toString('base64')
},
sha256: createHash('sha256').update(contentBuffer)
.digest('hex')
};
}
function buildMockSvgMarkup(label: string): string { function buildMockSvgMarkup(label: string): string {
return [ return [
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">', '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',

View File

@@ -0,0 +1,204 @@
import {
test,
expect,
type Page
} from '@playwright/test';
import { test as multiClientTest } from '../../fixtures/multi-client';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
interface TestUser {
username: string;
displayName: string;
password: string;
}
/**
* Regression coverage for: "Emojis should be user bound not client bound".
*
* A custom emoji belongs to the user who saved it, not to the client. A second
* account signing in on the same client must NOT inherit the first user's emoji
* library/picker.
*
* The whole scenario runs in a SINGLE page load (only the very first navigation
* reloads). All user switching is client-side via the router, because the leak
* lived in the long-lived singleton CustomEmojiService that used to keep the
* previous user's library after a logout + login without a reload. To avoid the
* (separate) in-session "create a second server" limitation, the second user
* joins the first user's server rather than creating their own.
*/
// Minimal valid 1x1 transparent GIF; the emoji pipeline validates mime + size only.
const TINY_GIF = Buffer.from(
'47494638396101000100800000000000ffffff21f90401000000002c00000000010001000002024401003b',
'hex'
);
multiClientTest.describe('Custom emoji are user bound, not client bound', () => {
multiClientTest.describe.configure({ timeout: 180_000 });
multiClientTest('a second user on the same client does not inherit the first user library', async ({ createClient }) => {
const { page } = await createClient();
const suffix = uniqueName('emoji-bound');
const alice: TestUser = { username: `alice_${suffix}`, displayName: 'Alice', password: 'TestPass123!' };
const bob: TestUser = { username: `bob_${suffix}`, displayName: 'Bob', password: 'TestPass123!' };
const serverName = `Shared Emoji Server ${suffix}`;
const libraryEmoji = page.locator('app-custom-emoji-picker [data-custom-emoji-library]');
await test.step('Alice registers, creates a server and uploads a custom emoji', async () => {
await new RegisterPage(page).goto();
await submitRegistration(page, alice);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
await createServer(page, serverName);
await openComposerEmojiModal(page);
await page.locator('app-custom-emoji-picker input[type="file"]').setInputFiles({
name: `partyblob_${suffix}.gif`,
mimeType: 'image/gif',
buffer: TINY_GIF
});
});
await test.step('Alice sees her own uploaded emoji in her library', async () => {
await openComposerEmojiModal(page);
await expect(libraryEmoji).toHaveCount(1, { timeout: 15_000 });
await page.keyboard.press('Escape');
});
await test.step('Bob signs in on the same client (no reload) and joins the same server', async () => {
await logoutClientSide(page);
await registerClientSide(page, bob);
await joinServerClientSide(page, serverName);
});
await test.step('Bob does not inherit Alice custom emoji library', async () => {
await openComposerEmojiModal(page);
// The modal is open (the file input is asserted inside the helper), so an
// empty grid is a genuine assertion rather than a timing artifact.
await expect(libraryEmoji).toHaveCount(0);
});
});
});
async function createServer(page: Page, serverName: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
await expect(searchPage.createServerButton).toBeVisible({ timeout: 15_000 });
await searchPage.createServerButton.click();
await expect(searchPage.serverNameInput).toBeVisible({ timeout: 15_000 });
// Client-side nav can render the form before its `(ngModelChange)` handler is
// wired, so an early fill never reaches the backing signal. Clear + refill
// until the submit button actually enables.
await expect.poll(async () => {
await searchPage.serverNameInput.fill('');
await searchPage.serverNameInput.fill(serverName);
return searchPage.createSubmitButton.isEnabled();
}, { timeout: 15_000 }).toBe(true);
await searchPage.createSubmitButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(page).waitForReady();
}
async function joinServerClientSide(page: Page, serverName: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
await page.locator('a[href="/servers"]').first()
.click();
await expect(searchPage.searchInput).toBeVisible({ timeout: 15_000 });
await searchPage.searchInput.fill(serverName);
const serverCard = page.locator('div[title]', { hasText: serverName }).first();
await expect(serverCard).toBeVisible({ timeout: 20_000 });
await serverCard.dblclick();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(page).waitForReady();
}
async function openComposerEmojiModal(page: Page): Promise<void> {
const picker = page.locator('app-custom-emoji-picker');
const fileInput = picker.locator('input[type="file"]');
// Reset to a known state: dismiss any open picker, then open it fresh.
await page.keyboard.press('Escape').catch(() => {});
await expect(picker).toHaveCount(0, { timeout: 5_000 })
.catch(() => {});
await page.locator('app-chat-message-composer')
.getByRole('button', { name: 'Open emoji selector' })
.first()
.click();
await expect(picker).toBeVisible({ timeout: 10_000 });
// The compact picker exposes a button that opens the full panel (with the
// upload field and the custom-emoji grid).
await picker.getByRole('button', { name: 'Open emoji selector' }).click();
await expect(fileInput).toBeAttached({ timeout: 10_000 });
}
async function registerClientSide(page: Page, user: TestUser): Promise<void> {
const loginPage = new LoginPage(page);
const registerPage = new RegisterPage(page);
await expect(loginPage.registerLink).toBeVisible({ timeout: 15_000 });
await loginPage.registerLink.click();
await expect(registerPage.usernameInput).toBeVisible({ timeout: 15_000 });
await submitRegistration(page, user);
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
}
/**
* Fills the registration form resiliently. On client-side navigation the
* template-driven `ngModel` can attach a tick after the input is visible, so an
* early `fill` is overwritten back to empty. Re-fill until every value sticks.
*/
async function submitRegistration(page: Page, user: TestUser): Promise<void> {
const username = page.locator('#register-username');
const displayName = page.locator('#register-display-name');
const password = page.locator('#register-password');
await expect.poll(async () => {
await username.fill(user.username);
await displayName.fill(user.displayName);
await password.fill(user.password);
return [
await username.inputValue(),
await displayName.inputValue(),
await password.inputValue()
].join('|');
}, { timeout: 15_000 }).toBe([
user.username,
user.displayName,
user.password
].join('|'));
await page.getByRole('button', { name: 'Create Account' }).click();
}
async function logoutClientSide(page: Page): Promise<void> {
const menuButton = page.getByRole('button', { name: 'Menu' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(menuButton).toBeVisible({ timeout: 10_000 });
await menuButton.click();
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
await logoutButton.click();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(new LoginPage(page).usernameInput).toBeVisible({ timeout: 10_000 });
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -0,0 +1,244 @@
import { type Page } from '@playwright/test';
import {
test,
expect,
type Client
} from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
const UPLOADER_LOCAL_MISSING_TEXT = 'Your original upload could not be found on this device';
test.describe('Local attachment persistence', () => {
test.describe.configure({ timeout: 180_000 });
test('remembers sent image and file across a page reload with no peer connected', async ({ createClient }) => {
const scenario = await createSingleClientChatScenario(createClient);
const serverName = `Persist Server ${uniqueName('persist')}`;
const imageName = `${uniqueName('diagram')}.svg`;
const fileName = `${uniqueName('notes')}.txt`;
const imageCaption = `Persisted image ${uniqueName('caption')}`;
const fileCaption = `Persisted file ${uniqueName('caption')}`;
const imageAttachment = createTextFilePayload(imageName, 'image/svg+xml', buildMockSvgMarkup(imageName));
const fileAttachment = createTextFilePayload(fileName, 'text/plain', `Attachment body for ${fileName}`);
await test.step('Create a server and open its room', async () => {
await createServerAndOpenRoom(scenario.search, scenario.client.page, serverName, 'Local attachment persistence server');
});
await test.step('Send an image and a generic file attachment', async () => {
await scenario.messages.attachFiles([imageAttachment]);
await scenario.messages.sendMessage(imageCaption);
await scenario.messages.expectMessageImageLoaded(imageName);
await scenario.messages.attachFiles([fileAttachment]);
await scenario.messages.sendMessage(fileCaption);
await expect(scenario.client.page.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 });
});
await test.step('Wait for both attachments to be persisted locally', async () => {
await waitForPersistedAttachmentBytes(scenario.client.page, 2);
await waitForPersistedAttachmentRecords(scenario.client.page, 2);
});
await test.step('Reload the page to simulate an application restart', async () => {
await scenario.client.page.reload();
await expect(scenario.client.page).toHaveURL(/\/(room|dashboard)/, { timeout: 30_000 });
await openSavedRoomByName(scenario.client.page, serverName);
await expect(scenario.messages.getMessageItemByText(imageCaption)).toBeVisible({ timeout: 20_000 });
});
await test.step('The image still renders from local storage with no peer', async () => {
await scenario.messages.expectMessageImageLoaded(imageName);
await expect(scenario.client.page.getByText(UPLOADER_LOCAL_MISSING_TEXT, { exact: false })).toHaveCount(0);
});
await test.step('The generic file is still remembered with no missing-upload error', async () => {
await expect(scenario.messages.getMessageItemByText(fileCaption)).toBeVisible({ timeout: 20_000 });
await expect(scenario.client.page.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 });
await expect(scenario.client.page.getByText(UPLOADER_LOCAL_MISSING_TEXT, { exact: false })).toHaveCount(0);
});
});
});
interface SingleClientChatScenario {
client: Client;
messages: ChatMessagesPage;
room: ChatRoomPage;
search: ServerSearchPage;
}
async function createSingleClientChatScenario(createClient: () => Promise<Client>): Promise<SingleClientChatScenario> {
const suffix = uniqueName('solo');
const client = await createClient();
const credentials = {
username: `solo_${suffix}`,
displayName: 'Solo',
password: 'TestPass123!'
};
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(
credentials.username,
credentials.displayName,
credentials.password
);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
return {
client,
messages: new ChatMessagesPage(client.page),
room: new ChatRoomPage(client.page),
search: new ServerSearchPage(client.page)
};
}
async function createServerAndOpenRoom(
searchPage: ServerSearchPage,
page: Page,
serverName: string,
description: string
): Promise<void> {
await searchPage.createServer(serverName, { description });
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
await waitForCurrentRoomName(page, serverName);
}
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);
}
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 }
);
}
interface CountOptions {
databaseRole: 'attachment-files' | 'app';
storeName: string;
requireSavedPath: boolean;
}
/** Counts records in the first matching IndexedDB store, optionally requiring a savedPath. */
async function countIndexedDbRecords(page: Page, options: CountOptions): Promise<number> {
return page.evaluate(async (countOptions: CountOptions) => {
if (typeof indexedDB.databases !== 'function') {
return 0;
}
const databases = await indexedDB.databases();
const matchingNames = databases
.map((entry) => entry.name ?? '')
.filter((name) => (countOptions.databaseRole === 'attachment-files'
? name.startsWith('metoyou-attachment-files')
: name === 'metoyou' || name.startsWith('metoyou::')));
const countInDatabase = (databaseName: string): Promise<number> => new Promise<number>((resolve) => {
const request = indexedDB.open(databaseName);
request.onerror = () => resolve(0);
request.onsuccess = () => {
const database = request.result;
if (!database.objectStoreNames.contains(countOptions.storeName)) {
database.close();
resolve(0);
return;
}
const getAll = database.transaction(countOptions.storeName, 'readonly')
.objectStore(countOptions.storeName)
.getAll();
getAll.onsuccess = () => {
const records = (getAll.result as { savedPath?: string }[]) ?? [];
const matching = countOptions.requireSavedPath
? records.filter((record) => !!record.savedPath)
: records;
resolve(matching.length);
database.close();
};
getAll.onerror = () => {
resolve(0);
database.close();
};
};
});
const counts = await Promise.all(matchingNames.map(countInDatabase));
return counts.reduce((total, count) => total + count, 0);
}, options);
}
/** Polls until at least `minCount` attachment byte records exist in the browser file store. */
async function waitForPersistedAttachmentBytes(page: Page, minCount: number): Promise<void> {
await expect.poll(
async () => countIndexedDbRecords(page, { databaseRole: 'attachment-files', storeName: 'files', requireSavedPath: false }),
{ timeout: 20_000, message: 'attachment bytes should persist to IndexedDB before reload' }
).toBeGreaterThanOrEqual(minCount);
}
/** Polls until at least `minCount` attachment metadata records with a savedPath exist in the app database. */
async function waitForPersistedAttachmentRecords(page: Page, minCount: number): Promise<void> {
await expect.poll(
async () => countIndexedDbRecords(page, { databaseRole: 'app', storeName: 'attachments', requireSavedPath: true }),
{ timeout: 20_000, message: 'attachment metadata with savedPath should persist before reload' }
).toBeGreaterThanOrEqual(minCount);
}
function createTextFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
return {
name,
mimeType,
base64: Buffer.from(content, 'utf8').toString('base64')
};
}
function buildMockSvgMarkup(label: string): string {
return [
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
'<rect width="160" height="120" rx="18" fill="#0f172a" />',
'<circle cx="38" cy="36" r="18" fill="#38bdf8" />',
'<rect x="66" y="28" width="64" height="16" rx="8" fill="#f8fafc" />',
'<rect x="24" y="74" width="112" height="12" rx="6" fill="#22c55e" />',
`<text x="24" y="104" fill="#e2e8f0" font-size="12" font-family="Arial, sans-serif">${label}</text>`,
'</svg>'
].join('');
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -0,0 +1,176 @@
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';
import {
MULTI_DEVICE_PASSWORD,
closeClient,
expectCrossDeviceMessage,
expectSyncedMessage,
expectSyncedMessageWithResync,
expectServerPeerVisible,
loginSecondDeviceIntoServer,
reopenClientInServer,
uniqueMultiDeviceName
} from '../../helpers/multi-device-session';
test.describe('Multi-client chat sync', () => {
test.describe.configure({ timeout: 360_000, retries: 1 });
test('syncs messages between same-user devices and late-joining users after offline gaps', async ({ createClient }) => {
const suffix = uniqueMultiDeviceName('multi-chat-sync');
const hostCredentials = {
username: `ludde_${suffix}`,
displayName: 'Ludde',
password: MULTI_DEVICE_PASSWORD
};
const guestCredentials = {
username: `azaaxin_${suffix}`,
displayName: 'Azaaxin',
password: MULTI_DEVICE_PASSWORD
};
const serverName = `Multi Client Chat Sync ${suffix}`;
const sharedBaselineMessage = `Shared baseline ${suffix}`;
const soloHostMessage = `Solo host message ${suffix}`;
const liveGuestProbeMessage = `Live guest probe ${suffix}`;
const offlineGapMessage = `Offline gap message ${suffix}`;
const client1 = await createClient();
const client2 = await createClient();
const client3 = await createClient();
await test.step('client 1: host registers and creates the shared server', async () => {
const registerPage = new RegisterPage(client1.page);
await registerPage.goto();
await registerPage.register(
hostCredentials.username,
hostCredentials.displayName,
hostCredentials.password
);
await expect(client1.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
const search = new ServerSearchPage(client1.page);
await search.createServer(serverName, {
description: 'Multi-client chat sync regression coverage'
});
await expect(client1.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
const messages1 = new ChatMessagesPage(client1.page);
await messages1.waitForReady();
await test.step('client 2: second host device joins the same server', async () => {
await loginSecondDeviceIntoServer(client2.page, hostCredentials, serverName);
});
const messages2 = new ChatMessagesPage(client2.page);
await messages2.waitForReady();
await test.step('both host devices exchange chat while online together', async () => {
await expectCrossDeviceMessage(messages1, messages2, sharedBaselineMessage);
});
await test.step('close the second host browser (client 2)', async () => {
await closeClient(client2);
});
await test.step('client 1 sends chat while the second host device is offline', async () => {
await client1.page.bringToFront();
await messages1.sendMessage(soloHostMessage);
await expect(messages1.getMessageItemByText(soloHostMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('guest account registers ahead of joining the server', async () => {
const registerPage = new RegisterPage(client3.page);
await registerPage.goto();
await registerPage.register(
guestCredentials.username,
guestCredentials.displayName,
guestCredentials.password
);
await expect(client3.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
});
let messages3 = new ChatMessagesPage(client3.page);
await test.step('client 3: guest joins and receives existing chat history', async () => {
// Keep the host tab active so its websocket + peer negotiation stay alive.
await client1.page.bringToFront();
await messages1.waitForReady();
const search = new ServerSearchPage(client3.page);
await search.joinServerFromSearch(serverName);
await expect(client3.page).toHaveURL(/\/room\//, { timeout: 20_000 });
messages3 = new ChatMessagesPage(client3.page);
await messages3.waitForReady();
// Presence gate: both users must see each other in the members panel
// before cross-user chat delivery can be expected.
await client1.page.bringToFront();
await expectServerPeerVisible(client1.page, guestCredentials.displayName);
await client3.page.bringToFront();
await expectServerPeerVisible(client3.page, hostCredentials.displayName);
// Live delivery first - proves host <-> guest transport is actually up.
await expectCrossDeviceMessage(messages1, messages3, liveGuestProbeMessage);
// History only replicates over P2P inventory once the peer link exists.
await client1.page.bringToFront();
await expectSyncedMessageWithResync(client3.page, messages3, sharedBaselineMessage);
await expectSyncedMessageWithResync(client3.page, messages3, soloHostMessage);
});
await test.step('close the guest browser (client 3)', async () => {
await closeClient(client3);
});
await test.step('reopen client 2 and send a message while client 1 stays online', async () => {
await client1.page.bringToFront();
const reopened = await reopenClientInServer(createClient, hostCredentials, serverName);
// Same-user catch-up uses account_sync, not P2P between own devices.
await expectSyncedMessageWithResync(
reopened.client.page,
reopened.messages,
soloHostMessage
);
await reopened.messages.sendMessage(offlineGapMessage);
await expect(reopened.messages.getMessageItemByText(offlineGapMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('reopened guest client receives the offline-gap message from host device 2', async () => {
await client1.page.bringToFront();
await messages1.waitForReady();
const reopenedGuest = await reopenClientInServer(createClient, guestCredentials, serverName);
// Presence gate before relying on cross-user delivery again.
await client1.page.bringToFront();
await expectServerPeerVisible(client1.page, guestCredentials.displayName);
await reopenedGuest.client.page.bringToFront();
await expectServerPeerVisible(reopenedGuest.client.page, hostCredentials.displayName);
await expectCrossDeviceMessage(messages1, reopenedGuest.messages, `Guest wake ${suffix}`);
await expectSyncedMessageWithResync(
reopenedGuest.client.page,
reopenedGuest.messages,
offlineGapMessage
);
});
await test.step('primary host device still receives the message from its second device', async () => {
await expectSyncedMessage(messages1, offlineGapMessage);
});
});
});

View File

@@ -0,0 +1,96 @@
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';
import {
MULTI_DEVICE_PASSWORD,
loginSecondDeviceIntoServer,
uniqueMultiDeviceName
} from '../../helpers/multi-device-session';
const SHARED_FROM_DEVICE_TEXT = 'Shared from your device';
test.describe('Multi-device attachment sharing', () => {
test.describe.configure({ timeout: 300_000, retries: 1 });
test('only the uploading device claims "Shared from your device"; the second same-user device can request it', async ({
createClient
}) => {
const suffix = uniqueMultiDeviceName('attach-share');
const credentials = {
username: `share_${suffix}`,
displayName: 'Multi Device User',
password: MULTI_DEVICE_PASSWORD
};
const serverName = `Attachment Sharing ${suffix}`;
const fileName = `${suffix}-handoff.bin`;
const caption = `Uploaded from device A ${suffix}`;
const fileAttachment = createBinaryFilePayload(fileName, 'application/octet-stream', `binary-body-${suffix}`);
const clientA = await createClient();
const messagesA = new ChatMessagesPage(clientA.page);
await test.step('device A registers, creates a server, and uploads a generic file', 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: 'Multi-device attachment sharing regression coverage' });
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await messagesA.waitForReady();
await messagesA.attachFiles([fileAttachment]);
await messagesA.sendMessage(caption);
await expect(messagesA.getMessageItemByText(caption)).toBeVisible({ timeout: 30_000 });
});
await test.step('device A (the uploader) shows "Shared from your device"', async () => {
const bubbleA = messagesA.getMessageItemByText(caption);
await expect(bubbleA.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 });
await expect(bubbleA.getByText(SHARED_FROM_DEVICE_TEXT, { exact: false })).toBeVisible({ timeout: 20_000 });
});
const clientB = await createClient();
const messagesB = new ChatMessagesPage(clientB.page);
await test.step('device B (same user) logs into the same server after the upload', async () => {
await loginSecondDeviceIntoServer(clientB.page, credentials, serverName);
// Keep device A active so it answers device B's account_sync_peer_online push.
await clientA.page.bringToFront();
await messagesA.waitForReady();
await clientB.page.bringToFront();
await messagesB.waitForReady();
});
await test.step('device B receives the message and its attachment via same-user account sync', async () => {
await expect(messagesB.getMessageItemByText(caption)).toBeVisible({ timeout: 90_000 });
await expect(messagesB.getMessageItemByText(caption).getByText(fileName, { exact: false }))
.toBeVisible({ timeout: 90_000 });
});
await test.step('device B does NOT claim to share it and can request/download the file', async () => {
const bubbleB = messagesB.getMessageItemByText(caption);
// The regression: device B used to render "Shared from your device" and hide the
// download affordance because the synced metadata carried the uploader's user id.
await expect(bubbleB.getByText(SHARED_FROM_DEVICE_TEXT, { exact: false })).toHaveCount(0);
// Device B must instead be able to fetch the file as any recipient would.
const getButton = bubbleB.getByRole('button', { name: /request|download/i });
await expect(getButton.first()).toBeVisible({ timeout: 20_000 });
});
});
});
function createBinaryFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
return {
name,
mimeType,
base64: Buffer.from(content, 'utf8').toString('base64')
};
}

View File

@@ -1,5 +1,6 @@
import { import {
expect, expect,
type BrowserContext,
type Locator, type Locator,
type Page type Page
} from '@playwright/test'; } from '@playwright/test';
@@ -35,6 +36,7 @@ test.describe('Chat notifications', () => {
await clearDesktopNotifications(scenario.alice.page); await clearDesktopNotifications(scenario.alice.page);
await scenario.bobRoom.joinTextChannel(scenario.channelName); await scenario.bobRoom.joinTextChannel(scenario.channelName);
await scenario.bobMessages.sendMessage(message); await scenario.bobMessages.sendMessage(message);
await expectUnreadCounts(scenario.alice.page, scenario.serverName, scenario.channelName);
}); });
await test.step('Alice receives a desktop notification with the channel preview', async () => { await test.step('Alice receives a desktop notification with the channel preview', async () => {
@@ -67,8 +69,7 @@ test.describe('Chat notifications', () => {
}); });
await test.step('Alice still sees unread badges for the room and channel', async () => { await test.step('Alice still sees unread badges for the room and channel', async () => {
await expect(getUnreadBadge(getSavedRoomButton(scenario.alice.page, scenario.serverName))).toHaveText('1', { timeout: 20_000 }); await expectUnreadCounts(scenario.alice.page, scenario.serverName, scenario.channelName);
await expect(getUnreadBadge(getTextChannelButton(scenario.alice.page, scenario.channelName))).toHaveText('1', { timeout: 20_000 });
}); });
await test.step('Alice does not get a muted desktop popup', async () => { await test.step('Alice does not get a muted desktop popup', async () => {
@@ -96,7 +97,7 @@ async function createNotificationScenario(createClient: () => Promise<Client>):
const alice = await createClient(); const alice = await createClient();
const bob = await createClient(); const bob = await createClient();
await installDesktopNotificationSpy(alice.page); await installDesktopNotificationSpy(alice.context);
await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password); await registerUser(alice.page, aliceCredentials.username, aliceCredentials.displayName, aliceCredentials.password);
await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password); await registerUser(bob.page, bobCredentials.username, bobCredentials.displayName, bobCredentials.password);
@@ -143,8 +144,8 @@ async function registerUser(page: Page, username: string, displayName: string, p
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
} }
async function installDesktopNotificationSpy(page: Page): Promise<void> { async function installDesktopNotificationSpy(context: BrowserContext): Promise<void> {
await page.addInitScript(() => { await context.addInitScript(() => {
const notifications: DesktopNotificationRecord[] = []; const notifications: DesktopNotificationRecord[] = [];
class MockNotification { class MockNotification {
@@ -250,6 +251,11 @@ function getUnreadBadge(container: Locator): Locator {
return container.locator('span.rounded-full').first(); return container.locator('span.rounded-full').first();
} }
async function expectUnreadCounts(page: Page, serverName: string, channelName: string): Promise<void> {
await expect(getUnreadBadge(getSavedRoomButton(page, serverName))).toHaveText('1', { timeout: 45_000 });
await expect(getUnreadBadge(getTextChannelButton(page, channelName))).toHaveText('1', { timeout: 45_000 });
}
function uniqueName(prefix: string): string { function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36) return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`; .slice(2, 8)}`;

View File

@@ -367,11 +367,10 @@ async function launchPersistentSession(
}); });
await installTestServerEndpoint(context, testServerPort); await installTestServerEndpoint(context, testServerPort);
await installWebRTCTracking(context);
const page = context.pages()[0] ?? await context.newPage(); const page = context.pages()[0] ?? await context.newPage();
await installWebRTCTracking(page);
return { context, page }; return { context, page };
} }

View File

@@ -196,11 +196,10 @@ async function launchPersistentSession(userDataDir: string, testServerPort: numb
}); });
await installTestServerEndpoint(context, testServerPort); await installTestServerEndpoint(context, testServerPort);
await installWebRTCTracking(context);
const page = context.pages()[0] ?? (await context.newPage()); const page = context.pages()[0] ?? (await context.newPage());
await installWebRTCTracking(page);
return { context, page }; return { context, page };
} }

View File

@@ -0,0 +1,121 @@
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import sharp from 'sharp';
import { test, expect } from '../../fixtures/base';
import {
ADAPTIVE_FOREGROUND_ICON_RATIO,
BRAND_LAUNCHER_BACKGROUND_COLOR,
findMissingLauncherResources,
findStockCapacitorResources,
isBrandLauncherBackgroundColor,
readAdaptiveIconBackgroundColor,
REQUIRED_LAUNCHER_ICON_FILES,
REQUIRED_SPLASH_FILES,
SPLASH_ICON_RATIO
} from '../../../toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules';
/**
* Regression coverage for: "No android app icon" - the Capacitor shell shipped
* the stock Ionic placeholder launcher icon instead of the Toju brand mark.
*
* A native launcher icon cannot be asserted through a running browser, so this
* spec verifies the committed Android resources directly: every density is
* present, none still match a stock Capacitor placeholder, the adaptive-icon
* background is the brand purple, and the generated bitmaps actually contain the
* brand mark (white cat on a purple disc). This is deterministic - no emulator,
* no timing - so it stays reliable in CI.
*/
const REPO_ROOT = join(__dirname, '..', '..', '..');
const RES_DIR = join(REPO_ROOT, 'toju-app', 'android', 'app', 'src', 'main', 'res');
const BRAND_PURPLE = { r: 0x4a, g: 0x21, b: 0x7a };
const WHITE = { r: 255, g: 255, b: 255 };
const COLOR_TOLERANCE = 24;
function sha256(resRelativePath: string): string {
return createHash('sha256').update(readFileSync(join(RES_DIR, resRelativePath)))
.digest('hex');
}
function colorDistance(
left: { r: number; g: number; b: number },
right: { r: number; g: number; b: number }
): number {
return Math.max(Math.abs(left.r - right.r), Math.abs(left.g - right.g), Math.abs(left.b - right.b));
}
async function samplePixel(
resRelativePath: string,
xRatio: number,
yRatio: number
): Promise<{ r: number; g: number; b: number }> {
const { data, info } = await sharp(join(RES_DIR, resRelativePath)).raw()
.toBuffer({ resolveWithObject: true });
const x = Math.min(info.width - 1, Math.floor(info.width * xRatio));
const y = Math.min(info.height - 1, Math.floor(info.height * yRatio));
const offset = (y * info.width + x) * info.channels;
return { r: data[offset], g: data[offset + 1], b: data[offset + 2] };
}
test.describe('Android brand app icon', () => {
test('ships a launcher icon and splash for every required density', () => {
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
const present = allRequired.filter((file) => existsSync(join(RES_DIR, file)));
expect(findMissingLauncherResources(present)).toEqual([]);
});
test('replaces every stock Capacitor placeholder with the brand asset', () => {
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
const hashByFile = Object.fromEntries(
allRequired.filter((file) => existsSync(join(RES_DIR, file))).map((file) => [file, sha256(file)])
);
expect(findStockCapacitorResources(hashByFile)).toEqual([]);
});
test('uses the brand purple as the adaptive-icon background', () => {
const valuesXml = readFileSync(join(RES_DIR, 'values', 'ic_launcher_background.xml'), 'utf8');
const color = readAdaptiveIconBackgroundColor(valuesXml);
expect(color).not.toBe('#FFFFFF');
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase());
});
test('renders the brand mark (white cat on a purple disc) in the launcher bitmap', async () => {
const launcher = 'mipmap-xxxhdpi/ic_launcher.png';
const ringTop = await samplePixel(launcher, 0.5, 0.12);
const ringLeft = await samplePixel(launcher, 0.12, 0.5);
const faceCenter = await samplePixel(launcher, 0.5, 0.5);
expect(colorDistance(ringTop, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
expect(colorDistance(ringLeft, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
expect(colorDistance(faceCenter, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
});
test('renders the splash art as the brand mark centred on a purple field', async () => {
const splash = 'drawable-port-xhdpi/splash.png';
const corner = await samplePixel(splash, 0.04, 0.04);
const center = await samplePixel(splash, 0.5, 0.5);
expect(colorDistance(corner, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
expect(colorDistance(center, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
});
test('insets the adaptive foreground so launcher masks do not clip the cat face', async () => {
const foreground = 'mipmap-xxxhdpi/ic_launcher_foreground.png';
const { data, info } = await sharp(join(RES_DIR, foreground)).ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const topCenterOffset = (0 * info.width + Math.floor(info.width / 2)) * info.channels;
expect(data[topCenterOffset + 3]).toBeLessThan(32);
expect(ADAPTIVE_FOREGROUND_ICON_RATIO).toBeCloseTo(66 / 108, 5);
expect(SPLASH_ICON_RATIO).toBeLessThan(0.4);
});
});

View File

@@ -0,0 +1,39 @@
import { test, expect } from '../../fixtures/multi-client';
/**
* Regression coverage for: "No login screen mobile phone on startup".
*
* Signed-out mobile users used to be left on a logged-out /dashboard (the
* startup redirect special-cased mobile + root/dashboard and kept them there),
* so they were never greeted with the login screen. The fix removes that mobile
* exception: signed-out visitors are sent to /login on every platform.
*
* The mobile viewport must be set BEFORE navigation so ViewportService reports
* `isMobile === true` at app bootstrap, which is exactly when the redirect ran.
*/
const MOBILE_VIEWPORT = { width: 390, height: 844 };
test.describe('Mobile login screen on startup', () => {
test.describe.configure({ timeout: 120_000 });
test('greets a signed-out mobile visitor on /dashboard with the login screen', async ({ createClient }) => {
const { page } = await createClient();
await page.setViewportSize(MOBILE_VIEWPORT);
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(page.locator('#login-username')).toBeVisible({ timeout: 15_000 });
});
test('greets a signed-out mobile visitor on the app root with the login screen', async ({ createClient }) => {
const { page } = await createClient();
await page.setViewportSize(MOBILE_VIEWPORT);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(page.locator('#login-username')).toBeVisible({ timeout: 15_000 });
});
});

View File

@@ -0,0 +1,25 @@
import { expect, test } from '../../fixtures/multi-client';
import { expectDashboardReady } from '../../helpers/dashboard';
import { MOBILE_VIEWPORT, openSettingsModal } from '../../helpers/settings-modal';
import { RegisterPage } from '../../pages/register.page';
test.describe('Mobile settings logout', () => {
test('exposes logout in the settings menu on mobile viewports', async ({ createClient }) => {
const { page } = await createClient();
const suffix = `mobile_logout_${Date.now()}`;
await page.setViewportSize(MOBILE_VIEWPORT);
const register = new RegisterPage(page);
await register.goto();
await register.register(`user_${suffix}`, 'Mobile Logout User', 'TestPass123!');
await expectDashboardReady(page);
await openSettingsModal(page);
await page.getByTestId('settings-logout-button').click();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(page.locator('#login-username')).toBeVisible({ timeout: 10_000 });
});
});

View File

@@ -4,13 +4,20 @@ import {
test, test,
type Client type Client
} from '../../fixtures/multi-client'; } from '../../fixtures/multi-client';
import { openPluginStore } from '../../helpers/app-menu';
import {
addPluginSource,
E2E_PLUGIN_SOURCE_URL,
E2E_PLUGIN_TITLE
} from '../../helpers/plugin-store';
import { installWebRTCTracking } from '../../helpers/webrtc-helpers';
import { ChatMessagesPage } from '../../pages/chat-messages.page'; import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { ChatRoomPage } from '../../pages/chat-room.page'; import { ChatRoomPage } from '../../pages/chat-room.page';
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';
const PLUGIN_SOURCE_URL = 'http://localhost:4200/plugins/e2e-plugin-source.json'; const PLUGIN_SOURCE_URL = E2E_PLUGIN_SOURCE_URL;
const PLUGIN_TITLE = 'E2E All API Plugin'; const PLUGIN_TITLE = E2E_PLUGIN_TITLE;
const EDITED_MESSAGE = 'Plugin API edited message'; const EDITED_MESSAGE = 'Plugin API edited message';
const ORIGINAL_MESSAGE = 'Plugin API original message'; const ORIGINAL_MESSAGE = 'Plugin API original message';
const DELETED_MESSAGE = 'Plugin API deleted message'; const DELETED_MESSAGE = 'Plugin API deleted message';
@@ -35,8 +42,7 @@ test.describe('Plugin API multi-user runtime', () => {
}); });
await test.step('Activate the server plugin for Bob as the embed/soundboard receiver', async () => { await test.step('Activate the server plugin for Bob as the embed/soundboard receiver', async () => {
await installGrantAndActivatePlugin(scenario.bob.page, false); await installRequiredServerPluginsViaModal(scenario.bob.page);
await closeSettingsModal(scenario.bob.page);
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 }); await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 }); await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
}); });
@@ -87,6 +93,9 @@ async function createPluginApiScenario(createClient: () => Promise<Client>): Pro
const alice = await createClient(); const alice = await createClient();
const bob = await createClient(); const bob = await createClient();
await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page);
await registerUser(alice.page, `alice_${suffix}`, 'Alice'); await registerUser(alice.page, `alice_${suffix}`, 'Alice');
await registerUser(bob.page, `bob_${suffix}`, 'Bob'); await registerUser(bob.page, `bob_${suffix}`, 'Bob');
@@ -98,13 +107,10 @@ async function createPluginApiScenario(createClient: () => Promise<Client>): Pro
const aliceRoom = new ChatRoomPage(alice.page); const aliceRoom = new ChatRoomPage(alice.page);
await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL); await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
await installGrantAndActivatePlugin(alice.page, true);
await closeSettingsModal(alice.page);
await expect(soundboardComposerButton(alice.page)).toBeVisible({ timeout: 20_000 });
const bobSearch = new ServerSearchPage(bob.page); const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName, { acceptPluginDownloads: true }); await bobSearch.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 });
const bobRoom = new ChatRoomPage(bob.page); const bobRoom = new ChatRoomPage(bob.page);
@@ -113,6 +119,9 @@ async function createPluginApiScenario(createClient: () => Promise<Client>): Pro
await bobRoom.joinVoiceChannel(VOICE_CHANNEL); await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(aliceRoom.voiceControls).toBeVisible({ timeout: 30_000 }); await expect(aliceRoom.voiceControls).toBeVisible({ timeout: 30_000 });
await expect(bobRoom.voiceControls).toBeVisible({ timeout: 30_000 }); await expect(bobRoom.voiceControls).toBeVisible({ timeout: 30_000 });
await installGrantAndActivatePlugin(alice.page, true);
await closeSettingsModal(alice.page);
await expect(soundboardComposerButton(alice.page)).toBeVisible({ timeout: 20_000 });
const aliceMessages = new ChatMessagesPage(alice.page); const aliceMessages = new ChatMessagesPage(alice.page);
const bobMessages = new ChatMessagesPage(bob.page); const bobMessages = new ChatMessagesPage(bob.page);
@@ -141,14 +150,11 @@ async function registerUser(page: Page, username: string, displayName: string):
} }
async function installGrantAndActivatePlugin(page: Page, installFromStore: boolean): Promise<void> { async function installGrantAndActivatePlugin(page: Page, installFromStore: boolean): Promise<void> {
await page.getByRole('button', { name: 'Plugin Store' }).click(); await openPluginStore(page);
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 }); await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 });
if (installFromStore) { if (installFromStore) {
await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL); await addPluginSource(page, PLUGIN_SOURCE_URL);
await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
await page.locator('article', { hasText: PLUGIN_TITLE }).getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }) await page.locator('article', { hasText: PLUGIN_TITLE }).getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ })
.click(); .click();
@@ -171,6 +177,14 @@ async function installGrantAndActivatePlugin(page: Page, installFromStore: boole
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 30_000 });
} }
async function installRequiredServerPluginsViaModal(page: Page): Promise<void> {
const installButton = page.getByRole('button', { name: 'Install plugins' });
await expect(installButton).toBeVisible({ timeout: 30_000 });
await installButton.click();
await expect(installButton).toHaveCount(0, { timeout: 30_000 });
}
async function closeSettingsModal(page: Page): Promise<void> { async function closeSettingsModal(page: Page): Promise<void> {
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await expect(page.getByTestId('plugin-manager')).toHaveCount(0); await expect(page.getByTestId('plugin-manager')).toHaveCount(0);

View File

@@ -1,4 +1,7 @@
import { expect, test } from '../../fixtures/multi-client'; import { expect, test } from '../../fixtures/multi-client';
import { openPluginStore } from '../../helpers/app-menu';
import { expectDashboardReady } from '../../helpers/dashboard';
import { addPluginSource } from '../../helpers/plugin-store';
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';
@@ -15,7 +18,7 @@ test.describe('Plugin manager UI', () => {
await test.step('Register user and create server context', async () => { await test.step('Register user and create server context', async () => {
await register.goto(); await register.goto();
await register.register(`plugin_${suffix}`, 'Plugin Tester', 'TestPass123!'); await register.register(`plugin_${suffix}`, 'Plugin Tester', 'TestPass123!');
await expect(page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); await expectDashboardReady(page);
await search.createServer(`Plugin API Server ${suffix}`, { await search.createServer(`Plugin API Server ${suffix}`, {
description: 'Plugin manager UI E2E coverage' description: 'Plugin manager UI E2E coverage'
}); });
@@ -23,16 +26,13 @@ test.describe('Plugin manager UI', () => {
await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 }); await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 });
}); });
await test.step('Open visible Plugin Store button', async () => { await test.step('Open Plugin Store from the title-bar menu', async () => {
await page.getByRole('button', { name: 'Plugin Store' }).click(); await openPluginStore(page);
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 10_000 });
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 10_000 });
}); });
await test.step('Install fixture plugin from source manifest', async () => { await test.step('Install fixture plugin from source manifest', async () => {
await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json'); await addPluginSource(page);
await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
const pluginCard = page.locator('article', { hasText: 'E2E All API Plugin' }); const pluginCard = page.locator('article', { hasText: 'E2E All API Plugin' });
await pluginCard.getByRole('button', { name: 'Readme' }).click(); await pluginCard.getByRole('button', { name: 'Readme' }).click();

View File

@@ -1,6 +1,11 @@
import type { APIRequestContext, APIResponse } from '@playwright/test'; import type { APIRequestContext, APIResponse } from '@playwright/test';
import WebSocket from 'ws'; import WebSocket from 'ws';
import { expect, test } from '../../fixtures/multi-client'; import { expect, test } from '../../fixtures/multi-client';
import {
authHeaders,
registerTestUser,
type AuthSession
} from '../../helpers/auth-api';
import { import {
getPluginApiTestEvent, getPluginApiTestEvent,
readPluginApiTestManifest, readPluginApiTestManifest,
@@ -9,8 +14,6 @@ import {
TEST_PLUGIN_RELAY_EVENT TEST_PLUGIN_RELAY_EVENT
} from '../../helpers/plugin-api-test-fixture'; } from '../../helpers/plugin-api-test-fixture';
const OWNER_USER_ID = 'plugin-api-owner';
interface CreatedServerResponse { interface CreatedServerResponse {
id: string; id: string;
} }
@@ -54,10 +57,25 @@ interface TestSocket {
test.describe('Plugin support API', () => { test.describe('Plugin support API', () => {
test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => { test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => {
const manifest = await readPluginApiTestManifest(); const manifest = await readPluginApiTestManifest();
const server = await createServer(request, testServer.url, `Plugin API ${Date.now()}`); const owner = await registerTestUser(
request,
testServer.url,
`plugin-owner-${Date.now()}`,
'TestPass123!',
'Plugin Owner'
);
const peer = await registerTestUser(
request,
testServer.url,
`plugin-peer-${Date.now()}`,
'TestPass123!',
'Plugin Peer'
);
const server = await createServer(request, testServer.url, owner, `Plugin API ${Date.now()}`);
const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT); const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT);
const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT); const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT);
const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`; const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`;
const ownerHeaders = authHeaders(owner.token);
await test.step('Initial snapshot is empty', async () => { await test.step('Initial snapshot is empty', async () => {
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi)); const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
@@ -71,8 +89,8 @@ test.describe('Plugin support API', () => {
await test.step('Requirement API enforces server management permission', async () => { await test.step('Requirement API enforces server management permission', async () => {
const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, { const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
headers: authHeaders(peer.token),
data: { data: {
actorUserId: 'not-the-owner',
status: 'required' status: 'required'
} }
}); });
@@ -83,8 +101,8 @@ test.describe('Plugin support API', () => {
await test.step('Requirement and event definition APIs persist the test plugin contract', async () => { await test.step('Requirement and event definition APIs persist the test plugin contract', async () => {
const requirement = await expectJson<PluginRequirementResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, { const requirement = await expectJson<PluginRequirementResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
headers: ownerHeaders,
data: { data: {
actorUserId: OWNER_USER_ID,
reason: manifest.description, reason: manifest.description,
status: 'required', status: 'required',
versionRange: `^${manifest.version}` versionRange: `^${manifest.version}`
@@ -98,8 +116,8 @@ test.describe('Plugin support API', () => {
versionRange: `^${manifest.version}` versionRange: `^${manifest.version}`
})); }));
const relayDefinition = await upsertEventDefinition(request, pluginsApi, relayEvent); const relayDefinition = await upsertEventDefinition(request, pluginsApi, ownerHeaders, relayEvent);
const p2pDefinition = await upsertEventDefinition(request, pluginsApi, p2pEvent); const p2pDefinition = await upsertEventDefinition(request, pluginsApi, ownerHeaders, p2pEvent);
expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({ expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({
direction: 'serverRelay', direction: 'serverRelay',
@@ -123,8 +141,8 @@ test.describe('Plugin support API', () => {
await test.step('Plugin data API refuses arbitrary server persistence', async () => { await test.step('Plugin data API refuses arbitrary server persistence', async () => {
const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, { const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
headers: ownerHeaders,
data: { data: {
actorUserId: OWNER_USER_ID,
schemaVersion: 1, schemaVersion: 1,
scope: 'server', scope: 'server',
value: { value: {
@@ -140,15 +158,15 @@ test.describe('Plugin support API', () => {
params: { params: {
key: 'settings', key: 'settings',
scope: 'server', scope: 'server',
userId: OWNER_USER_ID userId: owner.id
} }
}), 410); }), 410);
expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED'); expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED');
const afterDelete = await expectJson<{ errorCode: string }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, { const afterDelete = await expectJson<{ errorCode: string }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
headers: ownerHeaders,
data: { data: {
actorUserId: OWNER_USER_ID,
scope: 'server' scope: 'server'
} }
}), 410); }), 410);
@@ -161,8 +179,8 @@ test.describe('Plugin support API', () => {
const bob = await openTestSocket(testServer.url); const bob = await openTestSocket(testServer.url);
try { try {
alice.send({ type: 'identify', oderId: OWNER_USER_ID, displayName: 'Plugin Owner' }); await identifySocket(alice, owner.token, 'Plugin Owner');
bob.send({ type: 'identify', oderId: 'plugin-api-peer', displayName: 'Plugin Peer' }); await identifySocket(bob, peer.token, 'Plugin Peer');
alice.send({ type: 'join_server', serverId: server.id }); alice.send({ type: 'join_server', serverId: server.id });
bob.send({ type: 'join_server', serverId: server.id }); bob.send({ type: 'join_server', serverId: server.id });
@@ -193,7 +211,7 @@ test.describe('Plugin support API', () => {
pluginId: TEST_PLUGIN_ID, pluginId: TEST_PLUGIN_ID,
serverId: server.id, serverId: server.id,
sourcePluginUserId: 'fixture-plugin-user', sourcePluginUserId: 'fixture-plugin-user',
sourceUserId: OWNER_USER_ID sourceUserId: owner.id
})); }));
expect(relayedEvent['payload']).toEqual({ message: 'hello from fixture plugin' }); expect(relayedEvent['payload']).toEqual({ message: 'hello from fixture plugin' });
@@ -237,15 +255,15 @@ test.describe('Plugin support API', () => {
await test.step('Delete APIs remove event definitions and requirements', async () => { await test.step('Delete APIs remove event definitions and requirements', async () => {
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_RELAY_EVENT}`, { await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_RELAY_EVENT}`, {
data: { actorUserId: OWNER_USER_ID } headers: ownerHeaders
})); }));
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_P2P_EVENT}`, { await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_P2P_EVENT}`, {
data: { actorUserId: OWNER_USER_ID } headers: ownerHeaders
})); }));
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, { await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
data: { actorUserId: OWNER_USER_ID } headers: ownerHeaders
})); }));
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi)); const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
@@ -259,9 +277,11 @@ test.describe('Plugin support API', () => {
async function createServer( async function createServer(
request: APIRequestContext, request: APIRequestContext,
baseUrl: string, baseUrl: string,
owner: AuthSession,
serverName: string serverName: string
): Promise<CreatedServerResponse> { ): Promise<CreatedServerResponse> {
const response = await request.post(`${baseUrl}/api/servers`, { const response = await request.post(`${baseUrl}/api/servers`, {
headers: authHeaders(owner.token),
data: { data: {
channels: [ channels: [
{ {
@@ -275,7 +295,7 @@ async function createServer(
id: `plugin-api-${Date.now()}`, id: `plugin-api-${Date.now()}`,
isPrivate: false, isPrivate: false,
name: serverName, name: serverName,
ownerId: OWNER_USER_ID, ownerId: owner.id,
ownerPublicKey: 'plugin-api-owner-public-key', ownerPublicKey: 'plugin-api-owner-public-key',
tags: ['plugins'] tags: ['plugins']
} }
@@ -287,13 +307,14 @@ async function createServer(
async function upsertEventDefinition( async function upsertEventDefinition(
request: APIRequestContext, request: APIRequestContext,
pluginsApi: string, pluginsApi: string,
headers: Record<string, string>,
eventDefinition: ReturnType<typeof getPluginApiTestEvent> eventDefinition: ReturnType<typeof getPluginApiTestEvent>
): Promise<PluginEventDefinitionResponse> { ): Promise<PluginEventDefinitionResponse> {
return await expectJson<PluginEventDefinitionResponse>(await request.put( return await expectJson<PluginEventDefinitionResponse>(await request.put(
`${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`, `${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`,
{ {
headers,
data: { data: {
actorUserId: OWNER_USER_ID,
direction: eventDefinition.direction, direction: eventDefinition.direction,
maxPayloadBytes: eventDefinition.maxPayloadBytes, maxPayloadBytes: eventDefinition.maxPayloadBytes,
schemaJson: '{"type":"object"}', schemaJson: '{"type":"object"}',
@@ -309,6 +330,20 @@ async function expectJson<T>(response: APIResponse, status = 200): Promise<T> {
return await response.json() as T; return await response.json() as T;
} }
async function identifySocket(socket: TestSocket, token: string, displayName: string): Promise<void> {
socket.send({ type: 'identify', token, displayName });
await new Promise((resolve) => {
setTimeout(resolve, 300);
});
const authError = socket.messages.find((message) => message.type === 'auth_error');
if (authError) {
throw new Error(`WebSocket identify failed: ${JSON.stringify(authError)}`);
}
}
async function openTestSocket(baseUrl: string): Promise<TestSocket> { async function openTestSocket(baseUrl: string): Promise<TestSocket> {
const socketUrl = baseUrl.replace(/^http/, 'ws'); const socketUrl = baseUrl.replace(/^http/, 'ws');
const socket = new WebSocket(socketUrl); const socket = new WebSocket(socketUrl);

View File

@@ -0,0 +1,98 @@
import { test, expect } from '../../fixtures/multi-client';
import { type Client } from '../../fixtures/multi-client';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { dashboardSearchInput, expectDashboardReady } from '../../helpers/dashboard';
import { MULTI_DEVICE_PASSWORD, uniqueMultiDeviceName } from '../../helpers/multi-device-session';
/**
* Regression coverage for: "Fresh users have the server list in dashboard
* completely empty until anything searched."
*
* The directory exposes a curated discovery view (featured/trending) that must
* populate the dashboard "Popular Servers" panel and the /servers page without
* the user typing a search query. A stale client-side host blocklist used to
* short-circuit discovery to [] for the default production endpoints, so servers
* only appeared once a search ran. These tests prove the default view is
* populated, and that discovery self-heals when an endpoint lacks the
* featured/trending routes (older signal servers answer them with 404).
*/
async function createPublicServer(client: Client, username: string, serverName: string): Promise<void> {
const register = new RegisterPage(client.page);
await register.goto();
await register.register(username, 'Discovery Host', MULTI_DEVICE_PASSWORD);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
const search = new ServerSearchPage(client.page);
await search.createServer(serverName, { description: 'Public discovery server' });
await expect(client.page).toHaveURL(/\/room\//, { timeout: 15_000 });
}
function popularServersPanel(client: Client) {
return client.page.locator('div.rounded-xl', { hasText: 'Popular Servers' });
}
test.describe('Server discovery default view', () => {
test.describe.configure({ timeout: 120_000, retries: 1 });
test('a fresh account sees public servers in Popular Servers without searching', async ({ createClient }) => {
const suffix = uniqueMultiDeviceName('discovery-default');
const serverName = `Discovery Default ${suffix}`;
const host = await createClient();
const visitor = await createClient();
await test.step('host registers and publishes a public server', async () => {
await createPublicServer(host, `host_${suffix}`, serverName);
});
await test.step('a brand-new account registers', async () => {
const register = new RegisterPage(visitor.page);
await register.goto();
await register.register(`visitor_${suffix}`, 'Discovery Visitor', MULTI_DEVICE_PASSWORD);
await expectDashboardReady(visitor.page);
});
await test.step('Popular Servers lists the public server with no search query entered', async () => {
await expect(dashboardSearchInput(visitor.page)).toHaveValue('');
await expect(popularServersPanel(visitor).getByText(serverName)).toBeVisible({ timeout: 30_000 });
});
});
test('discovery falls back to the public listing when featured/trending routes 404', async ({ createClient }) => {
const suffix = uniqueMultiDeviceName('discovery-fallback');
const serverName = `Discovery Fallback ${suffix}`;
const host = await createClient();
const visitor = await createClient();
await test.step('host registers and publishes a public server', async () => {
await createPublicServer(host, `host_${suffix}`, serverName);
});
await test.step('simulate a legacy signal server without featured/trending routes', async () => {
const notFound = {
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' })
};
await visitor.page.route('**/api/servers/featured**', (route) => route.fulfill(notFound));
await visitor.page.route('**/api/servers/trending**', (route) => route.fulfill(notFound));
});
await test.step('a brand-new account registers against the legacy-style endpoint', async () => {
const register = new RegisterPage(visitor.page);
await register.goto();
await register.register(`visitor_${suffix}`, 'Discovery Visitor', MULTI_DEVICE_PASSWORD);
await expectDashboardReady(visitor.page);
});
await test.step('Popular Servers still lists the server via the public-listing fallback', async () => {
await expect(dashboardSearchInput(visitor.page)).toHaveValue('');
await expect(popularServersPanel(visitor).getByText(serverName)).toBeVisible({ timeout: 30_000 });
});
});
});

View File

@@ -1,4 +1,5 @@
import { test, expect } from '../../fixtures/multi-client'; import { test, expect } from '../../fixtures/multi-client';
import { expectDashboardReady } from '../../helpers/dashboard';
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';
@@ -88,7 +89,7 @@ test.describe('Connectivity warning', () => {
await register.goto(); await register.goto();
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!'); await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
await expect(alice.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); await expectDashboardReady(alice.page);
}); });
await test.step('Register Bob', async () => { await test.step('Register Bob', async () => {
@@ -96,7 +97,7 @@ test.describe('Connectivity warning', () => {
await register.goto(); await register.goto();
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!'); await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
await expect(bob.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); await expectDashboardReady(bob.page);
}); });
await test.step('Register Charlie', async () => { await test.step('Register Charlie', async () => {
@@ -104,7 +105,7 @@ test.describe('Connectivity warning', () => {
await register.goto(); await register.goto();
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!'); await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
await expect(charlie.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); await expectDashboardReady(charlie.page);
}); });
// ── Create server and have everyone join ── // ── Create server and have everyone join ──

View File

@@ -1,4 +1,6 @@
import { test, expect } from '../../fixtures/multi-client'; import { test, expect } from '../../fixtures/multi-client';
import { openSettingsFromMenu } from '../../helpers/app-menu';
import { expectDashboardReady } from '../../helpers/dashboard';
import { RegisterPage } from '../../pages/register.page'; import { RegisterPage } from '../../pages/register.page';
test.describe('ICE server settings', () => { test.describe('ICE server settings', () => {
@@ -9,8 +11,8 @@ test.describe('ICE server settings', () => {
await register.goto(); await register.goto();
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!'); await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
await expect(page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); await expectDashboardReady(page);
await page.getByTitle('Settings').click(); await openSettingsFromMenu(page);
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click(); await page.getByRole('button', { name: 'Network' }).click();
await expect(page.getByTestId('ice-server-settings')).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId('ice-server-settings')).toBeVisible({ timeout: 10_000 });
@@ -101,7 +103,7 @@ test.describe('ICE server settings', () => {
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 5_000 }); await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 5_000 });
await page.reload({ waitUntil: 'domcontentloaded' }); await page.reload({ waitUntil: 'domcontentloaded' });
await page.getByTitle('Settings').click(); await openSettingsFromMenu(page);
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Network' }).click(); await page.getByRole('button', { name: 'Network' }).click();
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });

View File

@@ -1,4 +1,5 @@
import { test, expect } from '../../fixtures/multi-client'; import { test, expect } from '../../fixtures/multi-client';
import { expectDashboardReady } from '../../helpers/dashboard';
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';
@@ -89,7 +90,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
await register.goto(); await register.goto();
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!'); await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
await expect(alice.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); await expectDashboardReady(alice.page);
}); });
await test.step('Register Bob', async () => { await test.step('Register Bob', async () => {
@@ -97,7 +98,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
await register.goto(); await register.goto();
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!'); await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
await expect(bob.page.getByPlaceholder('Search people, servers, or paste an invite...')).toBeVisible({ timeout: 30_000 }); await expectDashboardReady(bob.page);
}); });
await test.step('Alice creates a server', async () => { await test.step('Alice creates a server', async () => {

View File

@@ -1,4 +1,8 @@
import { expect, type Page } from '@playwright/test'; import {
expect,
type APIRequestContext,
type Page
} from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client'; import { test, type Client } from '../../fixtures/multi-client';
import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint'; import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server'; import { startTestServer } from '../../helpers/test-server';
@@ -11,6 +15,12 @@ import {
waitForConnectedPeerCount, waitForConnectedPeerCount,
waitForPeerConnected waitForPeerConnected
} from '../../helpers/webrtc-helpers'; } from '../../helpers/webrtc-helpers';
import {
authHeaders,
readAuthTokenFromPage,
registerTestUser,
type AuthSession
} from '../../helpers/auth-api';
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';
@@ -104,6 +114,7 @@ function endpointsForGroup(
test.describe('Mixed signal-config voice', () => { test.describe('Mixed signal-config voice', () => {
test('8 users with different signal configs can voice, mute, deafen, and chat concurrently', async ({ test('8 users with different signal configs can voice, mute, deafen, and chat concurrently', async ({
createClient, createClient,
request,
testServer testServer
}) => { }) => {
test.setTimeout(720_000); test.setTimeout(720_000);
@@ -140,10 +151,38 @@ test.describe('Mixed signal-config voice', () => {
} }
}); });
let secondaryRoomId = '';
// Identity that owns the secondary room. The invite must be created with
// this same API session: client 0 also auto-provisions a *separate*
// identity on the secondary signal endpoint, which overwrites the page's
// stored token, so reading the token back from the page would yield a
// non-owner identity and the invite request would be rejected (NOT_MEMBER).
let secondaryRoomOwner: AuthSession;
// ── Create rooms ──────────────────────────────────────────── // ── Create rooms ────────────────────────────────────────────
await test.step('Create voice room on primary and chat room on secondary', async () => { await test.step('Create voice room on primary and chat room on secondary', async () => {
// Use a "both" user (client 0) to create both rooms // Use a "both" user (client 0) to create both rooms
const searchPage = new ServerSearchPage(clients[0].page); const searchPage = new ServerSearchPage(clients[0].page);
const secondarySession = await registerTestUser(
request,
secondaryServer.url,
clients[0].user.username,
clients[0].user.password,
clients[0].user.displayName
);
await clients[0].page.evaluate(({ serverUrl, token, expiresAt }) => {
const storageKey = 'metoyou.authTokens';
const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, { token: string; expiresAt: number }>;
const normalizedUrl = serverUrl.trim().replace(/\/+$/, '');
store[normalizedUrl] = { token, expiresAt };
localStorage.setItem(storageKey, JSON.stringify(store));
}, {
serverUrl: secondaryServer.url,
token: secondarySession.token,
expiresAt: secondarySession.expiresAt
});
await searchPage.createServer(VOICE_ROOM_NAME, { await searchPage.createServer(VOICE_ROOM_NAME, {
description: 'Voice room on primary signal', description: 'Voice room on primary signal',
@@ -152,12 +191,15 @@ test.describe('Mixed signal-config voice', () => {
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await searchPage.createServer(SECONDARY_ROOM_NAME, { const secondaryRoom = await createServerViaApi(
description: 'Chat room on secondary signal', request,
sourceId: SECONDARY_SIGNAL_ID secondaryServer.url,
}); secondarySession,
SECONDARY_ROOM_NAME
);
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); secondaryRoomId = secondaryRoom.id;
secondaryRoomOwner = secondarySession;
}); });
// ── Create invite links ───────────────────────────────────── // ── Create invite links ─────────────────────────────────────
@@ -171,26 +213,30 @@ test.describe('Mixed signal-config voice', () => {
// Navigate to voice room to get its ID // Navigate to voice room to get its ID
await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME); await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
const primaryRoomId = await getCurrentRoomId(clients[0].page); const primaryRoomId = await getCurrentRoomId(clients[0].page);
const userId = await getCurrentUserId(clients[0].page);
// Navigate to secondary room to get its ID
await openSavedRoomByName(clients[0].page, SECONDARY_ROOM_NAME);
const secondaryRoomId = await getCurrentRoomId(clients[0].page);
// Create invite for primary room (voice) via API // Create invite for primary room (voice) via API
const primaryToken = await readAuthTokenFromPage(clients[0].page, testServer.url);
if (!primaryToken) {
throw new Error('Missing session token for primary signal invite creation');
}
const primaryInvite = await createInviteViaApi( const primaryInvite = await createInviteViaApi(
testServer.url, testServer.url,
primaryRoomId, primaryRoomId,
userId, primaryToken,
clients[0].user.displayName clients[0].user.displayName
); );
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`; primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
// Create invite for secondary room (chat) via API // Create invite for secondary room (chat) via API using the API session
// that owns the room. The page-stored token for the secondary endpoint
// belongs to client 0's auto-provisioned identity, which is not the
// room owner and would be rejected with NOT_MEMBER.
const secondaryInvite = await createInviteViaApi( const secondaryInvite = await createInviteViaApi(
secondaryServer.url, secondaryServer.url,
secondaryRoomId, secondaryRoomId,
userId, secondaryRoomOwner.token,
clients[0].user.displayName clients[0].user.displayName
); );
@@ -463,17 +509,55 @@ function buildUsers(): TestUser[] {
// ── API helpers ────────────────────────────────────────────────────── // ── API helpers ──────────────────────────────────────────────────────
async function createServerViaApi(
request: APIRequestContext,
serverBaseUrl: string,
owner: { id: string; token: string },
serverName: string
): Promise<{ id: string }> {
const response = await request.post(`${serverBaseUrl}/api/servers`, {
headers: authHeaders(owner.token),
data: {
channels: [
{
id: 'general-text',
name: 'general',
position: 0,
type: 'text'
}
],
description: `E2E room on ${serverBaseUrl}`,
id: `mixed-signal-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`,
isPrivate: false,
name: serverName,
ownerId: owner.id,
ownerPublicKey: 'mixed-signal-owner-public-key',
tags: ['e2e']
}
});
if (!response.ok()) {
throw new Error(`Failed to create server via API: ${response.status()} ${await response.text()}`);
}
return await response.json() as { id: string };
}
async function createInviteViaApi( async function createInviteViaApi(
serverBaseUrl: string, serverBaseUrl: string,
roomId: string, roomId: string,
userId: string, authToken: string,
displayName: string displayName: string
): Promise<{ id: string }> { ): Promise<{ id: string }> {
const response = await fetch(`${serverBaseUrl}/api/servers/${roomId}/invites`, { const response = await fetch(`${serverBaseUrl}/api/servers/${roomId}/invites`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`
},
body: JSON.stringify({ body: JSON.stringify({
requesterUserId: userId,
requesterDisplayName: displayName requesterDisplayName: displayName
}) })
}); });
@@ -510,34 +594,6 @@ async function getCurrentRoomId(page: Page): Promise<string> {
}); });
} }
async function getCurrentUserId(page: Page): Promise<string> {
return await page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
interface UserShape {
id: string;
}
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 unavailable');
}
const component = debugApi.getComponent(host);
const user = (component['currentUser'] as (() => UserShape | null) | undefined)?.();
if (!user?.id) {
throw new Error('Current user not found');
}
return user.id;
});
}
// ── Navigation helpers ─────────────────────────────────────────────── // ── Navigation helpers ───────────────────────────────────────────────
async function installDeterministicVoiceSettings(page: Page): Promise<void> { async function installDeterministicVoiceSettings(page: Page): Promise<void> {

View File

@@ -0,0 +1,14 @@
import {
describe,
expect,
it
} from 'vitest';
import { getLocalApiTokenTtlMs } from './auth-store';
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
describe('auth-store', () => {
it('defaults local API tokens to a very long lifetime', () => {
expect(getLocalApiTokenTtlMs()).toBe(TEN_YEARS_MS);
});
});

View File

@@ -10,9 +10,13 @@ export interface IssuedToken {
expiresAt: number; expiresAt: number;
} }
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
const tokens = new Map<string, IssuedToken>(); const tokens = new Map<string, IssuedToken>();
export function getLocalApiTokenTtlMs(): number {
return DEFAULT_TOKEN_TTL_MS;
}
export function issueToken(params: { export function issueToken(params: {
userId: string; userId: string;
username: string; username: string;
@@ -24,7 +28,7 @@ export function issueToken(params: {
const issued: IssuedToken = { const issued: IssuedToken = {
token, token,
issuedAt, issuedAt,
expiresAt: issuedAt + TOKEN_TTL_MS, expiresAt: issuedAt + getLocalApiTokenTtlMs(),
userId: params.userId, userId: params.userId,
username: params.username, username: params.username,
displayName: params.displayName, displayName: params.displayName,

View File

@@ -80,7 +80,7 @@ export function getDocsHtml(specUrl: string): string {
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="${contentSecurityPolicy}" content="${contentSecurityPolicy}"
/> />
<title>MetoYou Local API</title> <title>Toju Local API</title>
<style> <style>
:root { color-scheme: light dark; } :root { color-scheme: light dark; }
body { body {

View File

@@ -18,10 +18,10 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
return { return {
openapi: '3.1.0', openapi: '3.1.0',
info: { info: {
title: 'MetoYou Local Desktop API', title: 'Toju Local Desktop API',
version: appVersion, version: appVersion,
description: description:
'Authenticated local HTTP API exposed by the MetoYou desktop app. ' 'Authenticated local HTTP API exposed by the Toju desktop app. '
+ 'Authentication is performed against a configured signaling server. ' + 'Authentication is performed against a configured signaling server. '
+ 'Bearer tokens issued here are scoped to this device only.' + 'Bearer tokens issued here are scoped to this device only.'
}, },

View File

@@ -0,0 +1,60 @@
import { safeStorage } from 'electron';
import {
mkdir,
readFile,
writeFile
} from 'fs/promises';
import path from 'path';
import { app } from 'electron';
const STORAGE_DIR_NAME = 'provision-secrets';
function getStorageDir(): string {
return path.join(app.getPath('userData'), STORAGE_DIR_NAME);
}
function getSecretFilePath(homeUserId: string): string {
return path.join(getStorageDir(), `${homeUserId}.bin`);
}
async function ensureStorageDir(): Promise<void> {
await mkdir(getStorageDir(), { recursive: true });
}
export async function storeProvisionSecret(homeUserId: string, secret: string): Promise<boolean> {
if (!homeUserId.trim() || !secret) {
return false;
}
await ensureStorageDir();
if (!safeStorage.isEncryptionAvailable()) {
await writeFile(getSecretFilePath(homeUserId), secret, 'utf8');
return true;
}
const encrypted = safeStorage.encryptString(secret);
await writeFile(getSecretFilePath(homeUserId), encrypted);
return true;
}
export async function getProvisionSecret(homeUserId: string): Promise<string | null> {
if (!homeUserId.trim()) {
return null;
}
try {
const filePath = getSecretFilePath(homeUserId);
const payload = await readFile(filePath);
if (!safeStorage.isEncryptionAvailable()) {
return payload.toString('utf8');
}
return safeStorage.decryptString(payload);
} catch {
return null;
}
}

23
electron/app-metrics.ts Normal file
View File

@@ -0,0 +1,23 @@
import { app } from 'electron';
export interface AppMetricsProcessSnapshot {
pid: number;
type: string;
workingSetKb: number | null;
}
export interface AppMetricsSnapshot {
collectedAt: number;
processes: AppMetricsProcessSnapshot[];
}
export function collectAppMetricsSnapshot(): AppMetricsSnapshot {
return {
collectedAt: Date.now(),
processes: app.getAppMetrics().map((metric) => ({
pid: metric.pid,
type: metric.type,
workingSetKb: metric.memory?.workingSetSize ?? null
}))
};
}

View File

@@ -3,21 +3,14 @@ import AutoLaunch from 'auto-launch';
import * as fsp from 'fs/promises'; import * as fsp from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { readDesktopSettings } from '../desktop-settings'; import { readDesktopSettings } from '../desktop-settings';
import { DESKTOP_APP_DISPLAY_NAME, patchLinuxAutostartDesktopEntryNameField } from './desktop-branding.rules';
import { resolveLaunchPath } from './launch-path';
let autoLauncher: AutoLaunch | null = null; let autoLauncher: AutoLaunch | null = null;
let autoLaunchPath = ''; let autoLaunchPath = '';
const LINUX_AUTO_START_ARGUMENTS = ['--no-sandbox', '%U']; const LINUX_AUTO_START_ARGUMENTS = ['--no-sandbox', '%U'];
function resolveLaunchPath(): string {
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
const appImagePath = process.platform === 'linux'
? String(process.env['APPIMAGE'] || '').trim()
: '';
return appImagePath || process.execPath;
}
function escapeDesktopEntryExecArgument(argument: string): string { function escapeDesktopEntryExecArgument(argument: string): string {
const escapedArgument = argument.replace(/(["\\$`])/g, '\\$1'); const escapedArgument = argument.replace(/(["\\$`])/g, '\\$1');
@@ -35,14 +28,12 @@ function buildLinuxAutoStartExecLine(launchPath: string): string {
} }
function buildLinuxAutoStartDesktopEntry(launchPath: string): string { function buildLinuxAutoStartDesktopEntry(launchPath: string): string {
const appName = path.basename(launchPath);
return [ return [
'[Desktop Entry]', '[Desktop Entry]',
'Type=Application', 'Type=Application',
'Version=1.0', 'Version=1.0',
`Name=${appName}`, `Name=${DESKTOP_APP_DISPLAY_NAME}`,
`Comment=${appName}startup script`, `Comment=${DESKTOP_APP_DISPLAY_NAME} startup script`,
buildLinuxAutoStartExecLine(launchPath), buildLinuxAutoStartExecLine(launchPath),
'StartupNotify=false', 'StartupNotify=false',
'Terminal=false' 'Terminal=false'
@@ -65,11 +56,13 @@ async function synchronizeLinuxAutoStartDesktopEntry(launchPath: string): Promis
// Create the desktop entry if auto-launch did not leave one behind. // Create the desktop entry if auto-launch did not leave one behind.
} }
const nextDesktopEntry = currentDesktopEntry const nextDesktopEntry = patchLinuxAutostartDesktopEntryNameField(
? /^Exec=.*$/m.test(currentDesktopEntry) currentDesktopEntry
? currentDesktopEntry.replace(/^Exec=.*$/m, execLine) ? /^Exec=.*$/m.test(currentDesktopEntry)
: `${currentDesktopEntry.trimEnd()}\n${execLine}\n` ? currentDesktopEntry.replace(/^Exec=.*$/m, execLine)
: buildLinuxAutoStartDesktopEntry(launchPath); : `${currentDesktopEntry.trimEnd()}\n${execLine}\n`
: buildLinuxAutoStartDesktopEntry(launchPath)
);
if (nextDesktopEntry === currentDesktopEntry) { if (nextDesktopEntry === currentDesktopEntry) {
return; return;
@@ -87,7 +80,7 @@ function getAutoLauncher(): AutoLaunch | null {
if (!autoLauncher) { if (!autoLauncher) {
autoLaunchPath = resolveLaunchPath(); autoLaunchPath = resolveLaunchPath();
autoLauncher = new AutoLaunch({ autoLauncher = new AutoLaunch({
name: app.getName(), name: DESKTOP_APP_DISPLAY_NAME,
path: autoLaunchPath path: autoLaunchPath
}); });
} }

View File

@@ -0,0 +1,88 @@
import { app } from 'electron';
import AutoLaunch from 'auto-launch';
import * as fsp from 'fs/promises';
import * as path from 'path';
import {
DESKTOP_APP_DISPLAY_NAME,
isLegacyLinuxAutostartEntry,
LEGACY_APP_REGISTRY_NAMES
} from './desktop-branding.rules';
import { resolveLaunchPath } from './launch-path';
function getLinuxAutoStartDirectory(): string {
return path.join(app.getPath('home'), '.config', 'autostart');
}
export function configureDesktopBranding(): void {
if (!app.isPackaged) {
return;
}
app.setName(DESKTOP_APP_DISPLAY_NAME);
if (process.platform !== 'darwin') {
process.title = DESKTOP_APP_DISPLAY_NAME;
}
}
async function removeLegacyLinuxAutostartEntries(launchPath: string): Promise<void> {
if (process.platform !== 'linux') {
return;
}
const autostartDirectory = getLinuxAutoStartDirectory();
const currentLaunchBaseName = path.basename(launchPath);
let fileNames: string[] = [];
try {
fileNames = await fsp.readdir(autostartDirectory);
} catch {
return;
}
await Promise.all(fileNames.map(async (fileName) => {
if (!fileName.endsWith('.desktop')) {
return;
}
if (!isLegacyLinuxAutostartEntry(fileName, currentLaunchBaseName)) {
return;
}
await fsp.unlink(path.join(autostartDirectory, fileName)).catch(() => {});
}));
}
async function disableLegacyWindowsAutoLaunchEntries(launchPath: string): Promise<void> {
if (process.platform !== 'win32') {
return;
}
await Promise.all(LEGACY_APP_REGISTRY_NAMES.map(async (legacyName) => {
const launcher = new AutoLaunch({
name: legacyName,
path: launchPath
});
try {
if (await launcher.isEnabled()) {
await launcher.disable();
}
} catch {
// Best-effort cleanup for renamed desktop binaries.
}
}));
}
export async function migrateLegacyDesktopBranding(): Promise<void> {
if (!app.isPackaged) {
return;
}
const launchPath = resolveLaunchPath();
await removeLegacyLinuxAutostartEntries(launchPath);
await disableLegacyWindowsAutoLaunchEntries(launchPath);
}

View File

@@ -0,0 +1,45 @@
import {
describe,
expect,
it
} from 'vitest';
import {
DESKTOP_APP_DISPLAY_NAME,
DESKTOP_EXECUTABLE_NAME,
LEGACY_APP_REGISTRY_NAMES,
isLegacyLinuxAutostartEntry,
patchLinuxAutostartDesktopEntryNameField
} from './desktop-branding.rules';
describe('desktop-branding.rules', () => {
it('exposes the Toju desktop branding constants', () => {
expect(DESKTOP_APP_DISPLAY_NAME).toBe('Toju');
expect(DESKTOP_EXECUTABLE_NAME).toBe('toju');
expect(LEGACY_APP_REGISTRY_NAMES).toContain('MetoYou');
expect(LEGACY_APP_REGISTRY_NAMES).toContain('metoyou');
});
it('treats legacy linux autostart entries as removable', () => {
expect(isLegacyLinuxAutostartEntry('metoyou.desktop', 'toju')).toBe(true);
expect(isLegacyLinuxAutostartEntry('MetoYou.desktop', 'toju')).toBe(true);
expect(isLegacyLinuxAutostartEntry('MetoYou-1.0.0-x86_64.AppImage.desktop', 'Toju-1.1.0-x86_64.AppImage')).toBe(true);
});
it('keeps the current launch entry when it already uses the Toju binary', () => {
expect(isLegacyLinuxAutostartEntry('toju.desktop', 'toju')).toBe(false);
expect(isLegacyLinuxAutostartEntry('Toju-1.1.0-x86_64.AppImage.desktop', 'Toju-1.1.0-x86_64.AppImage')).toBe(false);
});
it('rewrites the desktop entry display name to Toju', () => {
const patched = patchLinuxAutostartDesktopEntryNameField([
'[Desktop Entry]',
'Type=Application',
'Name=metoyou',
'Exec=/opt/Toju/toju --no-sandbox %U'
].join('\n'));
expect(patched).toContain('Name=Toju');
expect(patched).not.toContain('Name=metoyou');
});
});

View File

@@ -0,0 +1,43 @@
export const DESKTOP_APP_DISPLAY_NAME = 'Toju';
export const DESKTOP_EXECUTABLE_NAME = 'toju';
export const LEGACY_APP_REGISTRY_NAMES = [
'MetoYou',
'MeToYou',
'metoyou'
] as const;
function normalizeAutostartBaseName(fileName: string): string {
return fileName.replace(/\.desktop$/iu, '').replace(/\.exe$/iu, '');
}
export function isLegacyLinuxAutostartEntry(
fileName: string,
currentLaunchBaseName: string
): boolean {
const entryBaseName = normalizeAutostartBaseName(fileName);
const currentBaseName = normalizeAutostartBaseName(currentLaunchBaseName);
if (entryBaseName === currentBaseName) {
return false;
}
const normalizedEntry = entryBaseName.toLowerCase();
if (LEGACY_APP_REGISTRY_NAMES.some((legacyName) => normalizedEntry === legacyName.toLowerCase())) {
return true;
}
return /^metoyou[-.]/iu.test(entryBaseName);
}
export function patchLinuxAutostartDesktopEntryNameField(
desktopEntry: string,
displayName: string = DESKTOP_APP_DISPLAY_NAME
): string {
if (/^Name=.*$/m.test(desktopEntry)) {
return desktopEntry.replace(/^Name=.*$/m, `Name=${displayName}`);
}
return desktopEntry;
}

View File

@@ -1,7 +1,9 @@
import { app } from 'electron'; import { app } from 'electron';
import { configureDesktopBranding } from './desktop-branding-migration';
import { readDesktopSettings } from '../desktop-settings'; import { readDesktopSettings } from '../desktop-settings';
export function configureAppFlags(): void { export function configureAppFlags(): void {
configureDesktopBranding();
linuxSpecificFlags(); linuxSpecificFlags();
networkFlags(); networkFlags();
setupGpuEncodingFlags(); setupGpuEncodingFlags();

View File

@@ -0,0 +1,8 @@
/** Resolves the packaged binary path used for auto-start and updater migration. */
export function resolveLaunchPath(): string {
const appImagePath = process.platform === 'linux'
? String(process.env['APPIMAGE'] || '').trim()
: '';
return appImagePath || process.execPath;
}

View File

@@ -2,6 +2,7 @@ import { app, BrowserWindow } from 'electron';
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing'; import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater'; import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
import { synchronizeAutoStartSetting } from './auto-start'; import { synchronizeAutoStartSetting } from './auto-start';
import { migrateLegacyDesktopBranding } from './desktop-branding-migration';
import { applyLocalApiSettings, stopLocalApiServer } from '../api'; import { applyLocalApiSettings, stopLocalApiServer } from '../api';
import { import {
initializeDatabase, initializeDatabase,
@@ -21,6 +22,12 @@ import {
setupWindowControlHandlers setupWindowControlHandlers
} from '../ipc'; } from '../ipc';
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor'; import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
import {
attachRendererDiagnosticsHooks,
ensurePerfDiagIpcRegistered,
shutdownPerfDiagnostics,
startPerfDiagnostics
} from '../diagnostics';
function startLocalApiAfterWindowReady(): void { function startLocalApiAfterWindowReady(): void {
setImmediate(() => { setImmediate(() => {
@@ -31,6 +38,8 @@ function startLocalApiAfterWindowReady(): void {
} }
export function registerAppLifecycle(): void { export function registerAppLifecycle(): void {
ensurePerfDiagIpcRegistered();
app.whenReady().then(async () => { app.whenReady().then(async () => {
const dockIconPath = getDockIconPath(); const dockIconPath = getDockIconPath();
@@ -41,9 +50,18 @@ export function registerAppLifecycle(): void {
setupCqrsHandlers(); setupCqrsHandlers();
setupWindowControlHandlers(); setupWindowControlHandlers();
setupSystemHandlers(); setupSystemHandlers();
await migrateLegacyDesktopBranding();
await synchronizeAutoStartSetting(); await synchronizeAutoStartSetting();
initializeDesktopUpdater(); initializeDesktopUpdater();
startPerfDiagnostics();
await createWindow(); await createWindow();
const mainWindow = getMainWindow();
if (mainWindow) {
attachRendererDiagnosticsHooks(mainWindow);
}
startLocalApiAfterWindowReady(); startLocalApiAfterWindowReady();
startIdleMonitor(); startIdleMonitor();
@@ -65,6 +83,7 @@ export function registerAppLifecycle(): void {
app.on('before-quit', async (event) => { app.on('before-quit', async (event) => {
prepareWindowForAppQuit(); prepareWindowForAppQuit();
await shutdownPerfDiagnostics();
if (getDataSource()?.isInitialized) { if (getDataSource()?.isInitialized) {
event.preventDefault(); event.preventDefault();

View File

@@ -20,6 +20,8 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource:
content: message.content, content: message.content,
timestamp: message.timestamp, timestamp: message.timestamp,
editedAt: message.editedAt ?? null, editedAt: message.editedAt ?? null,
revision: message.revision ?? 0,
headHash: message.headHash ?? null,
isDeleted: message.isDeleted ? 1 : 0, isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null, replyToId: message.replyToId ?? null,
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null

View File

@@ -36,7 +36,8 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
const nullableFields = [ const nullableFields = [
'channelId', 'channelId',
'editedAt', 'editedAt',
'replyToId' 'replyToId',
'headHash'
] as const; ] as const;
for (const field of nullableFields) { for (const field of nullableFields) {
@@ -44,8 +45,13 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
entity[field] = updates[field] ?? null; entity[field] = updates[field] ?? null;
} }
if (updates.isDeleted !== undefined) if (updates.revision !== undefined) {
existing.revision = updates.revision;
}
if (updates.isDeleted !== undefined) {
existing.isDeleted = updates.isDeleted ? 1 : 0; existing.isDeleted = updates.isDeleted ? 1 : 0;
}
if (updates.linkMetadata !== undefined) if (updates.linkMetadata !== undefined)
existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null; existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;

View File

@@ -34,6 +34,8 @@ export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] =
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content, content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
timestamp: row.timestamp, timestamp: row.timestamp,
editedAt: row.editedAt ?? undefined, editedAt: row.editedAt ?? undefined,
revision: row.revision ?? 0,
headHash: row.headHash ?? undefined,
reactions: isDeleted ? [] : reactions, reactions: isDeleted ? [] : reactions,
isDeleted, isDeleted,
replyToId: row.replyToId ?? undefined, replyToId: row.replyToId ?? undefined,

View File

@@ -57,6 +57,8 @@ export interface MessagePayload {
content: string; content: string;
timestamp: number; timestamp: number;
editedAt?: number; editedAt?: number;
revision?: number;
headHash?: string;
reactions?: ReactionPayload[]; reactions?: ReactionPayload[];
isDeleted?: boolean; isDeleted?: boolean;
replyToId?: string; replyToId?: string;

View File

@@ -47,8 +47,8 @@ export async function exportUserData(): Promise<ExportUserDataResult> {
.slice(0, 10)}.dat`; .slice(0, 10)}.dat`;
const { canceled, filePath } = await dialog.showSaveDialog({ const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: path.join(app.getPath('documents'), defaultFileName), defaultPath: path.join(app.getPath('documents'), defaultFileName),
filters: [{ extensions: ['dat'], name: 'MetoYou data archive' }], filters: [{ extensions: ['dat'], name: 'Toju data archive' }],
title: 'Export MetoYou data' title: 'Export Toju data'
}); });
if (canceled || !filePath) { if (canceled || !filePath) {
@@ -87,9 +87,9 @@ export async function exportUserData(): Promise<ExportUserDataResult> {
export async function importUserData(): Promise<ImportUserDataResult> { export async function importUserData(): Promise<ImportUserDataResult> {
const { canceled, filePaths } = await dialog.showOpenDialog({ const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }], filters: [{ extensions: ['dat', 'zip'], name: 'Toju data archive' }],
properties: ['openFile'], properties: ['openFile'],
title: 'Import MetoYou data' title: 'Import Toju data'
}); });
if (canceled || filePaths.length === 0) { if (canceled || filePaths.length === 0) {
@@ -235,7 +235,7 @@ function validateArchiveManifest(entries: ZipArchiveEntry[]): void {
const manifest = entries.find((entry) => entry.path === ARCHIVE_MANIFEST_PATH); const manifest = entries.find((entry) => entry.path === ARCHIVE_MANIFEST_PATH);
if (!manifest) { if (!manifest) {
throw new Error('The selected file is missing a MetoYou data manifest.'); throw new Error('The selected file is missing a Toju data manifest.');
} }
const parsed = JSON.parse(manifest.data.toString('utf8')) as { format?: string; version?: number }; const parsed = JSON.parse(manifest.data.toString('utf8')) as { format?: string; version?: number };

View File

@@ -0,0 +1,27 @@
import {
describe,
it,
expect
} from 'vitest';
import { isPerfDiagEnabled } from './diagnostics.flags';
describe('isPerfDiagEnabled', () => {
it('returns false when the flag is unset', () => {
expect(isPerfDiagEnabled({}, false)).toBe(false);
expect(isPerfDiagEnabled({}, true)).toBe(false);
});
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, false)).toBe(true);
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'true' }, false)).toBe(true);
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
});
it('returns false in packaged builds unless force is set', () => {
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
expect(isPerfDiagEnabled({
METOYOU_PERF_DIAG: '1',
METOYOU_PERF_DIAG_FORCE: '1'
}, true)).toBe(true);
});
});

View File

@@ -0,0 +1,29 @@
export const PERF_DIAG_ENV = 'METOYOU_PERF_DIAG';
export const PERF_DIAG_FORCE_ENV = 'METOYOU_PERF_DIAG_FORCE';
const TRUTHY = new Set([
'1',
'true',
'yes',
'on'
]);
function isTruthyFlag(value: string | undefined): boolean {
return TRUTHY.has(String(value ?? '').trim()
.toLowerCase());
}
export function isPerfDiagEnabled(
env: NodeJS.ProcessEnv,
isPackaged: boolean
): boolean {
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
return false;
}
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
return false;
}
return true;
}

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