Compare commits

...

9 Commits

Author SHA1 Message Date
Myx
c3c2f01cc6 fix: Bug - User automatically leaves voice after short period of time
All checks were successful
Queue Release Build / prepare (push) Successful in 22s
Deploy Web Apps / deploy (push) Successful in 7m32s
Queue Release Build / build-windows (push) Successful in 27m41s
Queue Release Build / build-linux (push) Successful in 44m56s
Queue Release Build / build-android (push) Successful in 18m52s
Queue Release Build / finalize (push) Successful in 21s
Ignore stale P2P self-disconnect voice-state echoes while this client actively owns voice, refresh noise-reduction input on re-join, and repair dual-signal E2E harness expectations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 04:04:31 +02:00
Myx
dac5cb42a5 perf: diagnoistics improvements 2026-06-12 01:22:01 +02:00
Myx
29032b5a36 fix: Bug - Voice states doesn't get cleared for all users on leave
Broadcast a cleared voice_state when voice-active sockets drop and reset mute/deafen flags on disconnect or reconnect so stale session state cannot leak to other clients.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 01:00:01 +02:00
Myx
e75b4a38ed fix: Bug - Same user logged in on multiple clients acts like 2 different users
Collapse home and signal-server actor aliases into one canonical room member so multi-device sessions no longer duplicate the local user in the members panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 00:52:59 +02:00
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
124 changed files with 3560 additions and 386 deletions

View File

@@ -12,6 +12,12 @@ Session-token authentication for the signaling server and product client.
| 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
@@ -115,6 +121,8 @@ A per-install **provision secret** enables silent account creation on newly adde
| 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.

View File

@@ -68,10 +68,10 @@ npm run cap:assets:android # → tools/generate-android-app-icons.mjs (uses sh
This produces, for every density (`mdpi … xxxhdpi`):
- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` — legacy launcher bitmaps (the brand disc).
- `mipmap-*/ic_launcher_foreground.png` full-bleed adaptive foreground; the adaptive layers in `mipmap-anydpi-v26/ic_launcher*.xml` reference `@mipmap/ic_launcher_foreground` (the stock `drawable-v24/ic_launcher_foreground.xml` vector is removed).
- `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 on a purple field for the launch splash.
- `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:
@@ -134,7 +134,9 @@ Declared in `toju-app/android/app/src/main/AndroidManifest.xml`:
| `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.
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)
@@ -199,9 +201,10 @@ The service shows a low-importance ongoing notification while a call is active.
## 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.
- 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)
@@ -253,6 +256,7 @@ Network security configs:
- `MobileCallSessionService` — CallKit + foreground service + in-call notifications.
- `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

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

@@ -0,0 +1,72 @@
import { expect, type Page } from '@playwright/test';
/** Read how many signaling managers are currently connected for this page. */
export async function getConnectedSignalManagerCount(page: Page): Promise<number> {
return page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return 0;
}
const component = debugApi.getComponent(host);
const realtime = component['realtime'] as {
signalingTransportHandler?: {
getConnectedSignalingManagers?: () => unknown[];
};
} | undefined;
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
});
}
/**
* Dual-signal setups create one RTCPeerConnection per remote peer per active
* signaling manager, so the harness tracks `remotePeerCount * signalCount`
* connected peer connections.
*/
export async function waitForConnectedRemotePeerMesh(
page: Page,
remotePeerCount: number,
timeout = 45_000
): Promise<void> {
const signalCount = Math.max(await getConnectedSignalManagerCount(page), 1);
const expectedCount = remotePeerCount * signalCount;
const minimumCount = Math.max(remotePeerCount, expectedCount - signalCount);
await page.waitForFunction(
(min) => ((window as unknown as {
__rtcConnections?: RTCPeerConnection[];
}).__rtcConnections ?? []).filter(
(pc) => pc.connectionState === 'connected'
).length >= min,
minimumCount,
{ timeout }
);
}
export async function getMinimumConnectedPeerMeshCount(
page: Page,
remotePeerCount: number
): Promise<number> {
const signalCount = Math.max(await getConnectedSignalManagerCount(page), 1);
const expectedCount = remotePeerCount * signalCount;
return Math.max(remotePeerCount, expectedCount - signalCount);
}
export async function waitForConnectedSignalManagerCount(
page: Page,
expectedCount: number,
timeout = 30_000
): Promise<void> {
await expect.poll(async () => await getConnectedSignalManagerCount(page), {
timeout,
intervals: [500, 1_000]
}).toBe(expectedCount);
}

View File

@@ -0,0 +1,49 @@
import { type Page } from '@playwright/test';
/** Wait until the side-panel roster under a voice channel lists the expected user count. */
export async function waitForVoiceRosterCount(
page: Page,
channelName: string,
expectedCount: number,
timeout = 45_000
): Promise<void> {
await page.waitForFunction(
({ expected, name }) => {
const buttons = document.querySelectorAll(
`app-rooms-side-panel button[data-channel-type="voice"][data-channel-name="${name}"]`
);
for (const button of buttons) {
const panel = button.closest('app-rooms-side-panel');
if (!panel || panel.getBoundingClientRect().width === 0) {
continue;
}
const rosterDiv = button.nextElementSibling;
if (!rosterDiv) {
continue;
}
const displayNames = new Set<string>();
rosterDiv.querySelectorAll('[appThemeNode="roomVoiceUserItem"] span.text-sm').forEach((element) => {
const label = element.textContent?.trim();
if (label) {
displayNames.add(label);
}
});
if (displayNames.size === expected) {
return true;
}
}
return false;
},
{ expected: expectedCount, name: channelName },
{ timeout }
);
}

View File

@@ -8,10 +8,6 @@ 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.
* Tracks all created peer connections and their remote tracks so tests
@@ -32,7 +28,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
source?: AudioScheduledSourceNode;
drawIntervalId?: number;
}[] = [];
const harness = webRtcHarnessWindow();
const harness = window as unknown as WebRtcTestHarnessWindow;
harness.__rtcConnections = connections;
harness.__rtcDataChannels = dataChannels;
@@ -160,6 +156,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
return resultStream;
};
});
}
@@ -181,7 +178,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
await page.addInitScript(() => {
const OrigAudioContext = window.AudioContext;
const audioHarness = webRtcHarnessWindow();
const audioHarness = window as unknown as WebRtcTestHarnessWindow;
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
const ctx: AudioContext = new OrigAudioContext(...args);
@@ -211,7 +208,7 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
await page.waitForFunction(
() => webRtcHarnessWindow().__rtcConnections?.some(
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false,
undefined,
@@ -224,7 +221,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
*/
export async function isPeerStillConnected(page: Page): Promise<boolean> {
return page.evaluate(
() => webRtcHarnessWindow().__rtcConnections?.some(
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false
);
@@ -233,7 +230,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
/** Returns the number of tracked peer connections in `connected` state. */
export async function getConnectedPeerCount(page: Page): Promise<number> {
return page.evaluate(
() => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected'
).length ?? 0
);
@@ -241,19 +238,36 @@ export async function getConnectedPeerCount(page: Page): Promise<number> {
/** Wait until the expected number of peer connections are `connected`. */
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
await page.waitForFunction(
(count) => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected'
).length === count,
expectedCount,
{ timeout }
);
try {
await page.waitForFunction(
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected'
).length === count,
expectedCount,
{ timeout }
);
} catch (error) {
const diagnostics = await page.evaluate(() => {
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? [];
return {
connected: connections.filter((pc) => pc.connectionState === 'connected').length,
states: connections.map((pc) => pc.connectionState)
};
});
throw new Error(
`Expected ${expectedCount} connected peers within ${timeout}ms; `
+ `saw ${diagnostics.connected} connected (${diagnostics.states.join(', ') || 'none'})`,
{ cause: error }
);
}
}
/** Returns the number of tracked RTCDataChannels in the open state. */
export async function getOpenDataChannelCount(page: Page): Promise<number> {
return page.evaluate(
() => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
(channel) => channel.readyState === 'open'
).length ?? 0
);
@@ -262,7 +276,7 @@ export async function getOpenDataChannelCount(page: Page): Promise<number> {
/** Wait until the expected number of tracked RTCDataChannels are open. */
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
await page.waitForFunction(
(count) => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
(channel) => channel.readyState === 'open'
).length === count,
expectedCount,
@@ -273,7 +287,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num
/** Close every currently-open RTCDataChannel and return how many were closed. */
export async function closeOpenDataChannels(page: Page): Promise<number> {
return page.evaluate(() => {
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
let closed = 0;
@@ -293,7 +307,7 @@ export async function closeOpenDataChannels(page: Page): Promise<number> {
/** Dispatch a synthetic data-channel error event on each open channel. */
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
return page.evaluate(() => {
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
let dispatched = 0;
@@ -354,7 +368,7 @@ interface PerPeerAudioStat {
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
return page.evaluate(async () => {
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) {
return [];
@@ -472,7 +486,7 @@ export async function getAudioStats(page: Page): Promise<{
inbound: { bytesReceived: number; packetsReceived: number } | null;
}> {
return page.evaluate(async () => {
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length)
return { outbound: null, inbound: null };
@@ -486,8 +500,8 @@ export async function getAudioStats(page: Page): Promise<{
hasInbound: boolean;
};
const hwm: Record<number, HWMEntry> = webRtcHarnessWindow().__rtcStatsHWM =
(webRtcHarnessWindow().__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
const hwm: Record<number, HWMEntry> = (window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM =
((window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
for (let idx = 0; idx < connections.length; idx++) {
let stats: RTCStatsReport;
@@ -596,7 +610,7 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
await page.waitForFunction(
async () => {
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length)
return false;
@@ -705,7 +719,7 @@ export async function getVideoStats(page: Page): Promise<{
inbound: { bytesReceived: number; packetsReceived: number } | null;
}> {
return page.evaluate(async () => {
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length)
return { outbound: null, inbound: null };
@@ -719,8 +733,8 @@ export async function getVideoStats(page: Page): Promise<{
hasInbound: boolean;
}
const hwm: Record<number, VHWM> = webRtcHarnessWindow().__rtcVideoStatsHWM =
(webRtcHarnessWindow().__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
const hwm: Record<number, VHWM> = (window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM =
((window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
for (let idx = 0; idx < connections.length; idx++) {
let stats: RTCStatsReport;
@@ -804,7 +818,7 @@ export async function getVideoStats(page: Page): Promise<{
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
await page.waitForFunction(
async () => {
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length)
return false;
@@ -972,7 +986,7 @@ export async function waitForInboundVideoFlow(
*/
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
return page.evaluate(async () => {
const conns = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
const conns = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
if (!conns?.length)
return 'No connections tracked';

View File

@@ -31,6 +31,14 @@ test.describe('Multi-device session', () => {
expect(instanceA).not.toEqual(instanceB);
});
await test.step('shows one self identity in the members panel on each device', async () => {
for (const client of [scenario.clientA, scenario.clientB]) {
await expect(
membersSidePanel(client.page).getByText(scenario.credentials.displayName, { exact: true })
).toHaveCount(1, { timeout: 20_000 });
}
});
await test.step('syncs chat from device A to device B', async () => {
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
});

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

@@ -6,13 +6,15 @@ 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
REQUIRED_SPLASH_FILES,
SPLASH_ICON_RATIO
} from '../../../toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules';
/**
@@ -104,4 +106,16 @@ test.describe('Android brand app icon', () => {
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,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

@@ -10,9 +10,9 @@ import {
dumpRtcDiagnostics,
getConnectedPeerCount,
installWebRTCTracking,
installAutoResumeAudioContext,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import {
@@ -24,6 +24,8 @@ import {
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
import { getMinimumConnectedPeerMeshCount, waitForConnectedRemotePeerMesh } from '../../helpers/signal-manager';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
// ── Signal endpoint identifiers ──────────────────────────────────────
@@ -132,7 +134,8 @@ test.describe('Mixed signal-config voice', () => {
await installTestServerEndpoints(client.context, groupEndpoints);
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
await installWebRTCTracking(client.context);
await installAutoResumeAudioContext(client.page);
clients.push({ ...client, user });
}
@@ -300,8 +303,11 @@ test.describe('Mixed signal-config voice', () => {
for (const client of clients) {
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
await client.page.waitForTimeout(2_000);
}
await clients[0].page.waitForTimeout(10_000);
for (const client of clients) {
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
}
@@ -310,11 +316,11 @@ test.describe('Mixed signal-config voice', () => {
// ── Audio mesh ──────────────────────────────────────────────
await test.step('All users discover peers and audio flows pairwise', async () => {
await Promise.all(clients.map((client) =>
waitForPeerConnected(client.page, 45_000)
waitForPeerConnected(client.page, 90_000)
));
await Promise.all(clients.map((client) =>
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
));
await Promise.all(clients.map((client) =>
@@ -324,7 +330,7 @@ test.describe('Mixed signal-config voice', () => {
await clients[0].page.waitForTimeout(5_000);
await Promise.all(clients.map((client) =>
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
));
});
@@ -335,7 +341,6 @@ test.describe('Mixed signal-config voice', () => {
await openVoiceWorkspace(client.page);
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
}
});
@@ -372,18 +377,28 @@ test.describe('Mixed signal-config voice', () => {
while (Date.now() < deadline) {
for (const client of stayers) {
await expect.poll(async () => await getConnectedPeerCount(client.page), {
await expect.poll(async () => {
const actual = await getConnectedPeerCount(client.page);
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
return actual >= minimum;
}, {
timeout: 10_000,
intervals: [500, 1_000]
}).toBe(EXPECTED_REMOTE_PEERS);
}).toBe(true);
}
// Check chatters still have voice peers even while viewing another room
for (const chatter of chatters) {
await expect.poll(async () => await getConnectedPeerCount(chatter.page), {
await expect.poll(async () => {
const actual = await getConnectedPeerCount(chatter.page);
const minimum = await getMinimumConnectedPeerMeshCount(chatter.page, EXPECTED_REMOTE_PEERS);
return actual >= minimum;
}, {
timeout: 10_000,
intervals: [500, 1_000]
}).toBe(EXPECTED_REMOTE_PEERS);
}).toBe(true);
}
if (Date.now() < deadline) {
@@ -749,63 +764,6 @@ async function waitForLocalVoiceChannelConnection(page: Page, channelName: strin
// ── Roster / state helpers ───────────────────────────────────────────
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
await page.waitForFunction(
(count) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-voice-workspace');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
return connectedUsers.length === count;
},
expectedCount,
{ timeout: 45_000 }
);
}
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
await page.waitForFunction(
({ expected, name }) => {
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
interface RoomShape { channels?: ChannelShape[] }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const channelId = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name)?.id;
if (!channelId) {
return false;
}
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
return roster.length === expected;
},
{ expected: expectedCount, name: channelName },
{ timeout: 30_000 }
);
}
async function waitForVoiceStateAcrossPages(
clients: readonly TestClient[],
displayName: string,

View File

@@ -6,14 +6,21 @@ import {
dumpRtcDiagnostics,
getConnectedPeerCount,
installWebRTCTracking,
installAutoResumeAudioContext,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
import {
getConnectedSignalManagerCount,
getMinimumConnectedPeerMeshCount,
waitForConnectedRemotePeerMesh,
waitForConnectedSignalManagerCount
} from '../../helpers/signal-manager';
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
@@ -116,8 +123,11 @@ test.describe('Dual-signal multi-user voice', () => {
for (const client of clients) {
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
await client.page.waitForTimeout(2_000);
}
await clients[0].page.waitForTimeout(10_000);
for (const client of clients) {
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
}
@@ -126,12 +136,12 @@ test.describe('Dual-signal multi-user voice', () => {
await test.step('All users discover all peers and audio flows pairwise', async () => {
// Wait for all clients to have at least one connected peer (fast)
await Promise.all(clients.map((client) =>
waitForPeerConnected(client.page, 45_000)
waitForPeerConnected(client.page, 90_000)
));
// Wait for all clients to have all 7 peers connected
await Promise.all(clients.map((client) =>
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
));
// Wait for audio stats to appear on all clients
@@ -146,7 +156,7 @@ test.describe('Dual-signal multi-user voice', () => {
// Check bidirectional audio flow on each client
await Promise.all(clients.map((client) =>
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
));
});
@@ -156,7 +166,6 @@ test.describe('Dual-signal multi-user voice', () => {
await openVoiceWorkspace(client.page);
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
await waitForConnectedSignalManagerCount(client.page, 2);
}
@@ -167,10 +176,15 @@ test.describe('Dual-signal multi-user voice', () => {
while (Date.now() < deadline) {
for (const client of clients) {
await expect.poll(async () => await getConnectedPeerCount(client.page), {
await expect.poll(async () => {
const actual = await getConnectedPeerCount(client.page);
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
return actual >= minimum;
}, {
timeout: 10_000,
intervals: [500, 1_000]
}).toBe(EXPECTED_REMOTE_PEERS);
}).toBe(true);
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
timeout: 10_000,
@@ -292,7 +306,8 @@ async function createTrackedClients(
await installTestServerEndpoints(client.context, endpoints);
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
await installWebRTCTracking(client.context);
await installAutoResumeAudioContext(client.page);
clients.push({
...client,
@@ -576,124 +591,6 @@ async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise
}, channelName);
}
async function waitForConnectedSignalManagerCount(page: Page, expectedCount: number): Promise<void> {
await page.waitForFunction(
(count) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const realtime = component['realtime'] as {
signalingTransportHandler?: {
getConnectedSignalingManagers?: () => { signalUrl: string }[];
};
} | undefined;
const countValue = realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
return countValue === count;
},
expectedCount,
{ timeout: 30_000 }
);
}
async function getConnectedSignalManagerCount(page: Page): Promise<number> {
return await page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return 0;
}
const component = debugApi.getComponent(host);
const realtime = component['realtime'] as {
signalingTransportHandler?: {
getConnectedSignalingManagers?: () => { signalUrl: string }[];
};
} | undefined;
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
});
}
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
await page.waitForFunction(
(count) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-voice-workspace');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
return connectedUsers.length === count;
},
expectedCount,
{ timeout: 45_000 }
);
}
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
await page.waitForFunction(
({ expected, name }) => {
interface ChannelShape {
id: string;
name: string;
type: 'text' | 'voice';
}
interface RoomShape {
channels?: ChannelShape[];
}
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const channelId = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name)?.id;
if (!channelId) {
return false;
}
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
return roster.length === expected;
},
{ expected: expectedCount, name: channelName },
{ timeout: 30_000 }
);
}
async function waitForVoiceStateAcrossPages(
clients: readonly TestClient[],
displayName: string,

View File

@@ -0,0 +1,127 @@
import { test, expect } from '../../fixtures/multi-client';
import {
MULTI_DEVICE_PASSWORD,
MULTI_DEVICE_VOICE_CHANNEL,
closeClient,
loginSecondDeviceIntoServer,
uniqueMultiDeviceName
} from '../../helpers/multi-device-session';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
async function waitForVoiceMuteState(
page: import('@playwright/test').Page,
displayName: string,
expectedMuted: boolean,
timeout = 45_000
): Promise<void> {
await page.waitForFunction(
({ expectedDisplayName, expectedMuted: muted }) => {
interface VoiceStateShape { isMuted?: boolean }
interface UserShape { displayName: string; voiceState?: VoiceStateShape }
interface ChannelShape { id: string; type: 'text' | 'voice' }
interface RoomShape { channels?: ChannelShape[] }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice');
if (!voiceChannel) {
return false;
}
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
const entry = roster.find((userEntry) => userEntry.displayName === expectedDisplayName);
return entry?.voiceState?.isMuted === muted;
},
{ expectedDisplayName: displayName, expectedMuted },
{ timeout }
);
}
test.describe('Voice mute state reset', () => {
test.describe.configure({ timeout: 300_000, retries: 1 });
test('clears stale mute state after abrupt disconnect and voice rejoin', async ({ createClient }) => {
const suffix = uniqueMultiDeviceName('voice-mute-reset');
const hostCredentials = {
username: `host_${suffix}`,
displayName: 'Voice Host',
password: MULTI_DEVICE_PASSWORD
};
const guestCredentials = {
username: `guest_${suffix}`,
displayName: 'Voice Guest',
password: MULTI_DEVICE_PASSWORD
};
const serverName = `Voice Mute Reset ${suffix}`;
let hostClient = await createClient();
const guestClient = await createClient();
await test.step('host creates the shared server', async () => {
const registerPage = new RegisterPage(hostClient.page);
await registerPage.goto();
await registerPage.register(hostCredentials.username, hostCredentials.displayName, hostCredentials.password);
await expect(hostClient.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
const search = new ServerSearchPage(hostClient.page);
await search.createServer(serverName, { description: 'Voice mute reset coverage' });
await expect(hostClient.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
const hostRoom = new ChatRoomPage(hostClient.page);
await hostRoom.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL);
await test.step('guest joins the server', async () => {
const registerPage = new RegisterPage(guestClient.page);
await registerPage.goto();
await registerPage.register(guestCredentials.username, guestCredentials.displayName, guestCredentials.password);
await expect(guestClient.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
const search = new ServerSearchPage(guestClient.page);
await search.joinServerFromSearch(serverName);
await expect(guestClient.page).toHaveURL(/\/room\//, { timeout: 20_000 });
});
await test.step('host joins voice muted and guest observes the muted state', async () => {
await hostRoom.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
await expect(hostRoom.voiceControls).toBeVisible({ timeout: 20_000 });
await hostRoom.muteButton.click();
await waitForVoiceMuteState(guestClient.page, hostCredentials.displayName, true);
});
await test.step('abrupt host disconnect clears stale mute before rejoin', async () => {
await closeClient(hostClient);
hostClient = await createClient();
await loginSecondDeviceIntoServer(hostClient.page, hostCredentials, serverName);
const reopenedRoom = new ChatRoomPage(hostClient.page);
await reopenedRoom.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
await expect(reopenedRoom.voiceControls).toBeVisible({ timeout: 20_000 });
await waitForVoiceMuteState(guestClient.page, hostCredentials.displayName, false);
});
});
});

View File

@@ -4,6 +4,10 @@ export interface AppMetricsProcessSnapshot {
pid: number;
type: string;
workingSetKb: number | null;
peakWorkingSetKb: number | null;
privateBytesKb: number | null;
creationTime: number | null;
cpuPercent: number | null;
}
export interface AppMetricsSnapshot {
@@ -17,7 +21,17 @@ export function collectAppMetricsSnapshot(): AppMetricsSnapshot {
processes: app.getAppMetrics().map((metric) => ({
pid: metric.pid,
type: metric.type,
workingSetKb: metric.memory?.workingSetSize ?? null
workingSetKb: metric.memory?.workingSetSize ?? null,
peakWorkingSetKb: readOptionalKilobytes(metric.memory?.peakWorkingSetSize),
privateBytesKb: readOptionalKilobytes(metric.memory?.privateBytes),
creationTime: metric.creationTime ?? null,
cpuPercent: typeof metric.cpu?.percentCPUUsage === 'number'
? Math.round(metric.cpu.percentCPUUsage * 10) / 10
: null
}))
};
}
function readOptionalKilobytes(value: number | undefined): number | null {
return typeof value === 'number' && value >= 0 ? value : null;
}

View File

@@ -8,7 +8,7 @@ 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);
expect(isPerfDiagEnabled({}, true)).toBe(true);
});
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
@@ -17,11 +17,12 @@ describe('isPerfDiagEnabled', () => {
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
});
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);
it('returns true in packaged Electron builds without env flags', () => {
expect(isPerfDiagEnabled({}, true)).toBe(true);
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '0' }, true)).toBe(true);
});
it('returns false in development when the flag is unset', () => {
expect(isPerfDiagEnabled({}, false)).toBe(false);
});
});

View File

@@ -17,13 +17,9 @@ export function isPerfDiagEnabled(
env: NodeJS.ProcessEnv,
isPackaged: boolean
): boolean {
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
return false;
if (isPackaged) {
return true;
}
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
return false;
}
return true;
return isTruthyFlag(env[PERF_DIAG_ENV]);
}

View File

@@ -1,11 +1,23 @@
import {
app,
BrowserWindow,
ipcMain
ipcMain,
shell
} from 'electron';
import { collectAppMetricsSnapshot } from '../app-metrics';
import { collectAppMetricsSnapshot, type AppMetricsSnapshot } from '../app-metrics';
import { getMainWindow } from '../window/create-window';
import { resolveReadablePath } from '../path-jail';
import { sumWorkingSetKb } from './process-metrics.rules';
import { isPerfDiagEnabled } from './diagnostics.flags';
import { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
import { collectSessionContext } from './session-context.collector';
import {
clearHighMemoryAlert,
readHighMemoryAlert,
writeHighMemoryAlert
} from './high-memory-alert.store';
import type { PerfDiagEntry } from './diagnostics.models';
import { PerfDiagWriter } from './diagnostics.writer';
@@ -15,6 +27,8 @@ let activeWriter: PerfDiagWriter | null = null;
let processPollTimer: NodeJS.Timeout | null = null;
let diagnosticsEnabled = false;
let ipcRegistered = false;
let highMemoryAlertTriggeredThisSession = false;
let sessionStartedAt = 0;
export function isPerfDiagActive(): boolean {
return diagnosticsEnabled;
@@ -43,6 +57,37 @@ export function ensurePerfDiagIpcRegistered(): void {
return false;
}
});
ipcMain.handle('get-pending-high-memory-alert', async () => {
return readHighMemoryAlert(app.getPath('userData'));
});
ipcMain.handle('acknowledge-high-memory-alert', async () => {
await clearHighMemoryAlert(app.getPath('userData'));
return true;
});
ipcMain.handle('show-log-file-in-folder', async (_event, filePath: string) => {
if (typeof filePath !== 'string' || !filePath.trim()) {
return {
shown: false,
reason: 'missing-path'
};
}
const scopedPath = await resolveReadablePath(filePath);
if (!scopedPath) {
return {
shown: false,
reason: 'outside-app-data'
};
}
shell.showItemInFolder(scopedPath);
return { shown: true };
});
}
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
@@ -64,9 +109,13 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
});
activeWriter = writer;
highMemoryAlertTriggeredThisSession = false;
sessionStartedAt = Date.now();
registerProcessCrashHandlers(writer);
startProcessMetricsPolling(writer);
const userDataPath = app.getPath('userData');
writer.append({
collectedAt: Date.now(),
source: 'main',
@@ -78,6 +127,18 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
}
});
writer.append({
collectedAt: Date.now(),
source: 'main',
type: 'environment',
payload: {
...collectSessionContext({
sessionStartedAt,
userDataPath
})
}
});
return writer;
}
@@ -195,6 +256,8 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void {
processes: metrics.processes
}
});
void maybeTriggerHighMemoryAlert(writer, metrics, totalKb);
} catch {
// Collector failures must never affect the app.
}
@@ -204,6 +267,64 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void {
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
}
async function maybeTriggerHighMemoryAlert(
writer: PerfDiagWriter,
metrics: AppMetricsSnapshot,
totalWorkingSetKb: number | null
): Promise<void> {
if (highMemoryAlertTriggeredThisSession || !exceedsHighMemoryThreshold(totalWorkingSetKb)) {
return;
}
highMemoryAlertTriggeredThisSession = true;
const detectedAt = Date.now();
const userDataPath = app.getPath('userData');
const immediateRendererEntries = await collectImmediateRendererSamples(getMainWindow());
const environment = collectSessionContext({
sessionStartedAt,
userDataPath
});
for (const entry of immediateRendererEntries) {
writer.append(entry);
}
writer.append({
collectedAt: detectedAt,
source: 'main',
type: 'environment',
payload: {
...environment
}
});
writer.append({
collectedAt: detectedAt,
source: 'main',
type: 'high-memory',
payload: buildHighMemoryDiagnosticPayload({
detectedAt,
totalWorkingSetKb: totalWorkingSetKb ?? 0,
metrics,
environment,
mainProcessMemory: process.memoryUsage(),
ringEntries: writer.bufferedEntries,
immediateRendererEntries,
sessionId: writer.sessionId
})
});
await writer.flushSnapshot('high-memory-threshold');
await writeHighMemoryAlert(userDataPath, {
logFilePath: writer.snapshotFilePath,
detectedAt,
peakWorkingSetKb: totalWorkingSetKb ?? 0,
sessionId: writer.sessionId
});
}
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
return {
collectedAt: Number(entry.collectedAt) || Date.now(),

View File

@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
export type PerfDiagEntryType =
| 'session'
| 'environment'
| 'process'
| 'store'
| 'components'
| 'heap'
| 'high-memory'
| 'crash'
| 'unresponsive';

View File

@@ -7,7 +7,7 @@ import {
resolveDiagnosticsFilePath
} from './diagnostics.rules';
const DEFAULT_RING_CAPACITY = 120;
const DEFAULT_RING_CAPACITY = 300;
const FLUSH_DEBOUNCE_MS = 250;
export interface PerfDiagWriterOptions {
@@ -18,6 +18,7 @@ export interface PerfDiagWriterOptions {
export class PerfDiagWriter {
private readonly filePath: string;
private readonly sessionIdValue: string;
private readonly ringCapacity: number;
private readonly pendingLines: string[] = [];
private ring: PerfDiagEntry[] = [];
@@ -26,10 +27,15 @@ export class PerfDiagWriter {
private disabled = false;
constructor(options: PerfDiagWriterOptions) {
this.sessionIdValue = options.sessionId;
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
}
get sessionId(): string {
return this.sessionIdValue;
}
get snapshotFilePath(): string {
return this.filePath;
}

View File

@@ -0,0 +1,27 @@
import {
describe,
expect,
it
} from 'vitest';
import {
exceedsHighMemoryThreshold,
formatWorkingSetGb,
HIGH_MEMORY_THRESHOLD_KB
} from './high-memory-alert.rules';
describe('high-memory-alert.rules', () => {
it('uses a 2 GiB working-set threshold', () => {
expect(HIGH_MEMORY_THRESHOLD_KB).toBe(2 * 1024 * 1024);
});
it('detects totals at or above the threshold', () => {
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB - 1)).toBe(false);
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB)).toBe(true);
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB + 1024)).toBe(true);
});
it('formats working set totals in gigabytes', () => {
expect(formatWorkingSetGb(1536 * 1024)).toBe('1.50');
expect(formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB)).toBe('2.00');
});
});

View File

@@ -0,0 +1,11 @@
/** 2 GiB working-set threshold for writing a diagnostics snapshot. */
export const HIGH_MEMORY_THRESHOLD_KB = 2 * 1024 * 1024;
export function exceedsHighMemoryThreshold(totalWorkingSetKb: number | null | undefined): boolean {
return typeof totalWorkingSetKb === 'number'
&& totalWorkingSetKb >= HIGH_MEMORY_THRESHOLD_KB;
}
export function formatWorkingSetGb(totalWorkingSetKb: number): string {
return (totalWorkingSetKb / (1024 * 1024)).toFixed(2);
}

View File

@@ -0,0 +1,64 @@
import * as fsp from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import {
afterEach,
describe,
expect,
it
} from 'vitest';
import {
clearHighMemoryAlert,
readHighMemoryAlert,
resolveHighMemoryAlertPath,
writeHighMemoryAlert
} from './high-memory-alert.store';
describe('high-memory-alert.store', () => {
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, {
recursive: true,
force: true
})));
});
it('writes and reads a pending startup alert record', async () => {
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
tempDirs.push(userDataPath);
const record = {
logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'),
detectedAt: 1_700_000_000_000,
peakWorkingSetKb: 2_200_000,
sessionId: 'session-1'
};
await writeHighMemoryAlert(userDataPath, record);
expect(resolveHighMemoryAlertPath(userDataPath)).toBe(
path.join(userDataPath, 'diagnostics', 'high-memory-pending.json')
);
expect(await readHighMemoryAlert(userDataPath)).toEqual(record);
});
it('clears the pending startup alert record', async () => {
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
tempDirs.push(userDataPath);
await writeHighMemoryAlert(userDataPath, {
logFilePath: '/tmp/perf.jsonl',
detectedAt: Date.now(),
peakWorkingSetKb: 2_100_000,
sessionId: 'session-2'
});
await clearHighMemoryAlert(userDataPath);
expect(await readHighMemoryAlert(userDataPath)).toBeNull();
});
});

View File

@@ -0,0 +1,57 @@
import * as fsp from 'fs/promises';
import * as path from 'path';
export interface HighMemoryAlertRecord {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
}
export function resolveHighMemoryAlertPath(userDataPath: string): string {
return path.join(userDataPath, 'diagnostics', 'high-memory-pending.json');
}
export async function readHighMemoryAlert(userDataPath: string): Promise<HighMemoryAlertRecord | null> {
try {
const raw = await fsp.readFile(resolveHighMemoryAlertPath(userDataPath), 'utf8');
const parsed = JSON.parse(raw) as Partial<HighMemoryAlertRecord>;
if (
typeof parsed.logFilePath !== 'string'
|| !parsed.logFilePath.trim()
|| typeof parsed.detectedAt !== 'number'
|| typeof parsed.peakWorkingSetKb !== 'number'
|| typeof parsed.sessionId !== 'string'
) {
return null;
}
return {
logFilePath: parsed.logFilePath,
detectedAt: parsed.detectedAt,
peakWorkingSetKb: parsed.peakWorkingSetKb,
sessionId: parsed.sessionId
};
} catch {
return null;
}
}
export async function writeHighMemoryAlert(
userDataPath: string,
record: HighMemoryAlertRecord
): Promise<void> {
const filePath = resolveHighMemoryAlertPath(userDataPath);
await fsp.mkdir(path.dirname(filePath), { recursive: true });
await fsp.writeFile(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
}
export async function clearHighMemoryAlert(userDataPath: string): Promise<void> {
try {
await fsp.unlink(resolveHighMemoryAlertPath(userDataPath));
} catch {
// Missing pending alert is fine.
}
}

View File

@@ -0,0 +1,201 @@
import {
describe,
expect,
it
} from 'vitest';
import type { PerfDiagEntry } from './diagnostics.models';
import {
buildHighMemoryDiagnosticPayload,
buildHighMemorySummary,
extractLatestRendererSamples,
extractProcessHistory,
formatMemoryUsageMb,
rankProcessesByWorkingSet,
summarizeRingBuffer
} from './high-memory-snapshot.rules';
function createProcess(overrides: Partial<{
pid: number;
type: string;
workingSetKb: number | null;
peakWorkingSetKb: number | null;
privateBytesKb: number | null;
creationTime: number | null;
cpuPercent: number | null;
}> = {}) {
return {
pid: 1,
type: 'Tab',
workingSetKb: 1024,
peakWorkingSetKb: null,
privateBytesKb: null,
creationTime: null,
cpuPercent: null,
...overrides
};
}
describe('high-memory-snapshot.rules', () => {
it('ranks processes by working set and computes share percentages', () => {
const tabProcess = createProcess({ pid: 1, type: 'Tab', workingSetKb: 512_000 });
const gpuProcess = createProcess({ pid: 2, type: 'GPU', workingSetKb: 1_536_000 });
const ranked = rankProcessesByWorkingSet([tabProcess, gpuProcess], 2_048_000);
expect(ranked[0]?.type).toBe('GPU');
expect(ranked[0]?.sharePercent).toBe(75);
expect(ranked[1]?.sharePercent).toBe(25);
});
it('extracts the latest renderer store, heap, and component samples', () => {
const entries: PerfDiagEntry[] = [
{
collectedAt: 1,
source: 'renderer',
type: 'store',
payload: { domains: { chat: 100 } }
},
{
collectedAt: 2,
source: 'renderer',
type: 'heap',
payload: { usedJsHeapMb: 120 }
},
{
collectedAt: 3,
source: 'renderer',
type: 'components',
payload: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] }
},
{
collectedAt: 4,
source: 'renderer',
type: 'store',
payload: { domains: { chat: 500 } }
}
];
expect(extractLatestRendererSamples(entries)).toEqual({
store: { domains: { chat: 500 } },
heap: { usedJsHeapMb: 120 },
components: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] }
});
});
it('extracts recent process history from the ring buffer', () => {
const entries: PerfDiagEntry[] = [
{
collectedAt: 1,
source: 'main',
type: 'process',
payload: { totalWorkingSetKb: 1000 }
},
{
collectedAt: 2,
source: 'main',
type: 'session',
payload: { event: 'noop' }
},
{
collectedAt: 3,
source: 'main',
type: 'process',
payload: { totalWorkingSetKb: 2000 }
}
];
expect(extractProcessHistory(entries)).toEqual([{ collectedAt: 1, totalWorkingSetKb: 1000 }, { collectedAt: 3, totalWorkingSetKb: 2000 }]);
});
it('summarizes ring buffer entry counts', () => {
expect(summarizeRingBuffer([
{ collectedAt: 1, source: 'main', type: 'process', payload: {} },
{ collectedAt: 2, source: 'renderer', type: 'heap', payload: {} },
{ collectedAt: 3, source: 'main', type: 'process', payload: {} }
])).toEqual({
'main:process': 2,
'renderer:heap': 1
});
});
it('builds a high-memory summary with threshold context', () => {
const summary = buildHighMemorySummary(
2_200_000,
[createProcess({ workingSetKb: 2_200_000 })],
1_700_000_000_000
);
expect(summary.totalWorkingSetGb).toBe('2.10');
expect(summary.thresholdGb).toBe('2.00');
expect(summary.topProcesses).toHaveLength(1);
});
it('builds a comprehensive high-memory diagnostic payload', () => {
const payload = buildHighMemoryDiagnosticPayload({
detectedAt: 1_700_000_000_000,
totalWorkingSetKb: 2_200_000,
metrics: {
collectedAt: 1_700_000_000_000,
processes: [
createProcess({
workingSetKb: 2_200_000,
peakWorkingSetKb: 2_300_000,
privateBytesKb: 1_800_000,
creationTime: 1,
cpuPercent: 12
})
]
},
environment: { appVersion: '1.0.0' },
mainProcessMemory: {
rss: 64 * 1024 * 1024,
heapTotal: 32 * 1024 * 1024,
heapUsed: 16 * 1024 * 1024,
external: 8 * 1024 * 1024,
arrayBuffers: 1024
},
ringEntries: [
{
collectedAt: 1,
source: 'main',
type: 'process',
payload: { totalWorkingSetKb: 2_000_000 }
}
],
immediateRendererEntries: [
{
collectedAt: 2,
source: 'renderer',
type: 'heap',
payload: { usedJsHeapMb: 300, route: '/room/abc' }
}
],
sessionId: 'session-1'
});
expect(payload.event).toBe('high-memory-threshold');
expect(payload.summary).toMatchObject({
totalWorkingSetKb: 2_200_000
});
expect(payload.processHistory).toHaveLength(1);
expect(payload.recentRendererSamples).toEqual({
store: null,
heap: { usedJsHeapMb: 300, route: '/room/abc' },
components: null
});
expect(formatMemoryUsageMb({
rss: 64 * 1024 * 1024,
heapTotal: 32 * 1024 * 1024,
heapUsed: 16 * 1024 * 1024,
external: 8 * 1024 * 1024,
arrayBuffers: 1024
})).toEqual({
rssMb: 64,
heapTotalMb: 32,
heapUsedMb: 16,
externalMb: 8,
arrayBuffersMb: 0
});
});
});

View File

@@ -0,0 +1,179 @@
import type { AppMetricsProcessSnapshot, AppMetricsSnapshot } from '../app-metrics';
import type { PerfDiagEntry } from './diagnostics.models';
import { formatWorkingSetGb, HIGH_MEMORY_THRESHOLD_KB } from './high-memory-alert.rules';
import type { SessionContextSnapshot } from './session-context.collector';
export interface RankedProcessSnapshot extends AppMetricsProcessSnapshot {
sharePercent: number;
}
export interface HighMemorySummary {
detectedAt: number;
thresholdKb: number;
thresholdGb: string;
totalWorkingSetKb: number;
totalWorkingSetGb: string;
topProcesses: RankedProcessSnapshot[];
}
export interface LatestRendererSamples {
store: Record<string, unknown> | null;
heap: Record<string, unknown> | null;
components: Record<string, unknown> | null;
}
export function rankProcessesByWorkingSet(
processes: readonly AppMetricsProcessSnapshot[],
totalWorkingSetKb: number | null
): RankedProcessSnapshot[] {
const total = totalWorkingSetKb ?? 0;
return [...processes]
.filter((process) => process.workingSetKb != null && process.workingSetKb > 0)
.sort((left, right) => (right.workingSetKb ?? 0) - (left.workingSetKb ?? 0))
.map((process) => ({
...process,
sharePercent: total > 0
? Math.round(((process.workingSetKb ?? 0) / total) * 1000) / 10
: 0
}));
}
export function extractLatestRendererSamples(entries: readonly PerfDiagEntry[]): LatestRendererSamples {
let store: Record<string, unknown> | null = null;
let heap: Record<string, unknown> | null = null;
let components: Record<string, unknown> | null = null;
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.source !== 'renderer') {
continue;
}
if (!store && entry.type === 'store') {
store = entry.payload;
}
if (!heap && entry.type === 'heap') {
heap = entry.payload;
}
if (!components && entry.type === 'components') {
components = entry.payload;
}
if (store && heap && components) {
break;
}
}
return {
store,
heap,
components
};
}
export function extractProcessHistory(
entries: readonly PerfDiagEntry[],
limit = 24
): Record<string, unknown>[] {
const history: Record<string, unknown>[] = [];
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.type !== 'process') {
continue;
}
history.unshift({
collectedAt: entry.collectedAt,
...entry.payload
});
if (history.length >= limit) {
break;
}
}
return history;
}
export function summarizeRingBuffer(entries: readonly PerfDiagEntry[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const entry of entries) {
const key = `${entry.source}:${entry.type}`;
counts[key] = (counts[key] ?? 0) + 1;
}
return counts;
}
export function buildHighMemorySummary(
totalWorkingSetKb: number,
processes: readonly AppMetricsProcessSnapshot[],
detectedAt: number
): HighMemorySummary {
return {
detectedAt,
thresholdKb: HIGH_MEMORY_THRESHOLD_KB,
thresholdGb: formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB),
totalWorkingSetKb,
totalWorkingSetGb: formatWorkingSetGb(totalWorkingSetKb),
topProcesses: rankProcessesByWorkingSet(processes, totalWorkingSetKb).slice(0, 12)
};
}
export function formatMemoryUsageMb(memoryUsage: NodeJS.MemoryUsage): Record<string, number> {
return {
rssMb: roundMb(memoryUsage.rss),
heapTotalMb: roundMb(memoryUsage.heapTotal),
heapUsedMb: roundMb(memoryUsage.heapUsed),
externalMb: roundMb(memoryUsage.external),
arrayBuffersMb: roundMb(memoryUsage.arrayBuffers ?? 0)
};
}
export function buildHighMemoryDiagnosticPayload(input: {
detectedAt: number;
totalWorkingSetKb: number;
metrics: AppMetricsSnapshot;
environment: SessionContextSnapshot;
mainProcessMemory: NodeJS.MemoryUsage;
ringEntries: readonly PerfDiagEntry[];
immediateRendererEntries: readonly PerfDiagEntry[];
sessionId: string;
}): Record<string, unknown> {
const mergedRingEntries = [...input.ringEntries, ...input.immediateRendererEntries];
const recentRendererSamples = extractLatestRendererSamples(mergedRingEntries);
return {
event: 'high-memory-threshold',
sessionId: input.sessionId,
summary: buildHighMemorySummary(
input.totalWorkingSetKb,
input.metrics.processes,
input.detectedAt
),
environment: input.environment,
metrics: input.metrics,
mainProcessMemory: input.mainProcessMemory,
mainProcessMemoryMb: formatMemoryUsageMb(input.mainProcessMemory),
processHistory: extractProcessHistory(mergedRingEntries),
ringSummary: summarizeRingBuffer(mergedRingEntries),
recentRendererSamples,
immediateRendererSamples: input.immediateRendererEntries.map((entry) => ({
collectedAt: entry.collectedAt,
type: entry.type,
payload: entry.payload
}))
};
}
function roundMb(bytes: number): number {
return Math.round((bytes / (1024 * 1024)) * 100) / 100;
}

View File

@@ -0,0 +1,39 @@
import type { BrowserWindow } from 'electron';
import type { PerfDiagEntry } from './diagnostics.models';
export async function collectImmediateRendererSamples(
window: BrowserWindow | null | undefined
): Promise<PerfDiagEntry[]> {
if (!window || window.isDestroyed()) {
return [];
}
try {
const result = await window.webContents.executeJavaScript(`
(function () {
const collect = globalThis.__collectPerfDiagSample;
return typeof collect === 'function' ? collect() : [];
})()
`, true);
if (!Array.isArray(result)) {
return [];
}
return result
.filter((entry) => entry && typeof entry === 'object')
.map((entry) => normalizeImmediateRendererEntry(entry as Partial<PerfDiagEntry>));
} catch {
return [];
}
}
function normalizeImmediateRendererEntry(entry: Partial<PerfDiagEntry>): PerfDiagEntry {
return {
collectedAt: Number(entry.collectedAt) || Date.now(),
source: 'renderer',
type: entry.type ?? 'session',
payload: entry.payload ?? {}
};
}

View File

@@ -1,4 +1,16 @@
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
export {
clearHighMemoryAlert,
readHighMemoryAlert,
resolveHighMemoryAlertPath,
writeHighMemoryAlert
} from './high-memory-alert.store';
export type { HighMemoryAlertRecord } from './high-memory-alert.store';
export {
exceedsHighMemoryThreshold,
formatWorkingSetGb,
HIGH_MEMORY_THRESHOLD_KB
} from './high-memory-alert.rules';
export {
attachRendererDiagnosticsHooks,
ensurePerfDiagIpcRegistered,

View File

@@ -0,0 +1,91 @@
import { app, BrowserWindow } from 'electron';
import * as os from 'os';
export interface SessionWindowSnapshot {
id: number;
title: string;
url: string | null;
focused: boolean;
visible: boolean;
destroyed: boolean;
}
export interface SessionContextSnapshot {
collectedAt: number;
sessionStartedAt: number;
uptimeMs: number;
appVersion: string;
electronVersion: string;
chromeVersion: string;
nodeVersion: string;
platform: NodeJS.Platform;
arch: string;
osType: string;
osRelease: string;
osVersion: string | null;
totalMemKb: number;
freeMemKb: number;
userDataPath: string;
appPath: string;
isPackaged: boolean;
locale: string;
windowCount: number;
windows: SessionWindowSnapshot[];
}
export function collectSessionContext(input: {
sessionStartedAt: number;
userDataPath: string;
}): SessionContextSnapshot {
const collectedAt = Date.now();
return {
collectedAt,
sessionStartedAt: input.sessionStartedAt,
uptimeMs: Math.max(0, collectedAt - input.sessionStartedAt),
appVersion: app.getVersion(),
electronVersion: process.versions.electron ?? 'unknown',
chromeVersion: process.versions.chrome ?? 'unknown',
nodeVersion: process.versions.node ?? 'unknown',
platform: process.platform,
arch: process.arch,
osType: os.type(),
osRelease: os.release(),
osVersion: readOsVersion(),
totalMemKb: Math.round(os.totalmem() / 1024),
freeMemKb: Math.round(os.freemem() / 1024),
userDataPath: input.userDataPath,
appPath: app.getAppPath(),
isPackaged: app.isPackaged,
locale: app.getLocale(),
windowCount: BrowserWindow.getAllWindows().length,
windows: BrowserWindow.getAllWindows().map(collectWindowSnapshot)
};
}
function collectWindowSnapshot(window: BrowserWindow): SessionWindowSnapshot {
let url: string | null = null;
try {
url = window.webContents.getURL() || null;
} catch {
url = null;
}
return {
id: window.id,
title: window.getTitle(),
url,
focused: window.isFocused(),
visible: window.isVisible(),
destroyed: window.isDestroyed()
};
}
function readOsVersion(): string | null {
try {
return os.version?.() ?? null;
} catch {
return null;
}
}

View File

@@ -259,6 +259,14 @@ export interface ElectronAPI {
type: string;
payload: Record<string, unknown>;
}) => Promise<boolean>;
getPendingHighMemoryAlert: () => Promise<{
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
} | null>;
acknowledgeHighMemoryAlert: () => Promise<boolean>;
showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>;
@@ -400,6 +408,9 @@ const electronAPI: ElectronAPI = {
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'),
acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-high-memory-alert'),
showLogFileInFolder: (filePath) => ipcRenderer.invoke('show-log-file-in-folder', filePath),
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
exportUserData: () => ipcRenderer.invoke('export-user-data'),

View File

@@ -0,0 +1,105 @@
import {
beforeEach,
describe,
expect,
it
} from 'vitest';
import { WebSocket } from 'ws';
import { connectedUsers } from './state';
import { ConnectedUser } from './types';
import { finalizeVoiceDisconnectForConnection } from './handler';
function createMockWs(): WebSocket & { sentMessages: string[] } {
const sent: string[] = [];
const ws = {
readyState: WebSocket.OPEN,
send: (data: string) => { sent.push(data); },
close: () => {},
terminate: () => {},
sentMessages: sent
} as unknown as WebSocket & { sentMessages: string[] };
return ws;
}
function createConnectedUser(
connectionId: string,
overrides: Partial<ConnectedUser> = {}
): ConnectedUser {
const user: ConnectedUser = {
oderId: 'user-1',
ws: createMockWs(),
authenticated: true,
serverIds: new Set(['server-1']),
displayName: 'Alice',
lastPong: Date.now(),
...overrides
};
connectedUsers.set(connectionId, user);
return user;
}
function getSentMessages(user: ConnectedUser): string[] {
return (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
}
describe('finalizeVoiceDisconnectForConnection', () => {
beforeEach(() => {
connectedUsers.clear();
});
it('broadcasts a cleared voice_state when a voice-active connection is removed', () => {
createConnectedUser('conn-voice', {
voiceActive: true,
voiceStateSnapshot: {
type: 'voice_state',
serverId: 'server-1',
voiceState: {
isConnected: true,
isMuted: true,
isDeafened: false,
roomId: 'voice-1',
serverId: 'server-1'
}
}
});
const observer = createConnectedUser('conn-observer', { oderId: 'user-2' });
getSentMessages(observer).length = 0;
finalizeVoiceDisconnectForConnection('conn-voice');
const messages = getSentMessages(observer).map((raw) => JSON.parse(raw) as {
type: string;
voiceState?: { isConnected?: boolean; isMuted?: boolean; isDeafened?: boolean };
});
const voiceState = messages.find((message) => message.type === 'voice_state');
expect(voiceState).toMatchObject({
type: 'voice_state',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false
}
});
expect(connectedUsers.get('conn-voice')?.voiceActive).toBe(false);
expect(connectedUsers.get('conn-voice')?.voiceStateSnapshot).toBeUndefined();
});
it('does nothing when the connection was not voice-active', () => {
const observer = createConnectedUser('conn-observer', { oderId: 'user-2' });
createConnectedUser('conn-idle');
getSentMessages(observer).length = 0;
finalizeVoiceDisconnectForConnection('conn-idle');
expect(getSentMessages(observer)).toHaveLength(0);
});
});

View File

@@ -134,6 +134,59 @@ function clearVoiceActiveForOderId(oderId: string, exceptConnectionId?: string):
});
}
function readVoiceStateServerId(snapshot: Record<string, unknown> | undefined): string | undefined {
if (!snapshot) {
return undefined;
}
const nestedVoiceState = snapshot['voiceState'];
if (nestedVoiceState && typeof nestedVoiceState === 'object') {
const nestedServerId = readMessageId((nestedVoiceState as { serverId?: unknown }).serverId);
if (nestedServerId) {
return nestedServerId;
}
}
return readMessageId(snapshot['serverId']);
}
/** Broadcast a cleared voice_state when a voice-active socket disappears without a graceful leave. */
export function finalizeVoiceDisconnectForConnection(connectionId: string): void {
const user = connectedUsers.get(connectionId);
if (!user?.authenticated || (!user.voiceActive && !user.voiceStateSnapshot)) {
return;
}
const serverId = readVoiceStateServerId(user.voiceStateSnapshot) ?? user.viewedServerId;
if (serverId && user.serverIds.has(serverId)) {
broadcastToServer(
serverId,
{
type: 'voice_state',
serverId,
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName),
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
isSpeaking: false
}
},
{ excludeConnectionId: connectionId }
);
}
user.voiceActive = false;
user.voiceStateSnapshot = undefined;
connectedUsers.set(connectionId, user);
clearVoiceActiveForOderId(user.oderId, connectionId);
}
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
user.ws.send(JSON.stringify({
type: 'voice_state',

View File

@@ -11,7 +11,7 @@ import {
getServerIdsForOderId,
isOderIdConnectedToServer
} from './broadcast';
import { handleWebSocketMessage } from './handler';
import { handleWebSocketMessage, finalizeVoiceDisconnectForConnection } from './handler';
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
@@ -26,6 +26,8 @@ function removeDeadConnection(connectionId: string): void {
if (user) {
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
finalizeVoiceDisconnectForConnection(connectionId);
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
user.serverIds.forEach((sid) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -2,21 +2,31 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>
</resources>

View File

@@ -15,6 +15,16 @@
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
"updateSettings": "Update settings",
"restartNow": "Restart now"
},
"highMemoryAlert": {
"badge": "High memory usage",
"title": "The app used {{usageGb}} GB of RAM last session",
"message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.",
"openLog": "Open log file",
"showInFolder": "Show in folder",
"copyPath": "Copy path",
"dismiss": "Dismiss",
"dismissAriaLabel": "Dismiss high memory alert"
}
}
}

View File

@@ -262,12 +262,14 @@
"localData": {
"title": "Local data",
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
"descriptionMobile": "Review and erase the private app storage that holds local messages, rooms, attachments, and saved settings on this device.",
"restartApp": "Restart app"
},
"desktopOnly": "Data management is only available in the packaged Electron desktop app.",
"desktopOnly": "Data management is only available in the desktop app or native mobile app.",
"currentFolder": {
"title": "Current data folder",
"resolving": "Resolving data folder..."
"resolving": "Resolving data folder...",
"descriptionMobile": "Files are stored in the app's private data directory on this device."
},
"openFolder": "Open folder",
"opening": "Opening...",
@@ -287,6 +289,7 @@
"erase": {
"title": "Erase user data",
"description": "Remove local app data from this device and recreate an empty database.",
"descriptionMobile": "Remove local messages, rooms, attachments, and saved app data from this device.",
"button": "Erase user data",
"erasing": "Erasing...",
"confirm": "Erase all local Toju data on this device? This cannot be undone."
@@ -301,6 +304,7 @@
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
"imported": "Imported data.",
"erased": "Local data erased. Restart the app to finish resetting the session.",
"erasedMobile": "Local data erased. You have been signed out.",
"operationFailed": "Data operation failed."
}
},

View File

@@ -15,6 +15,16 @@
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
"updateSettings": "Update settings",
"restartNow": "Restart now"
},
"highMemoryAlert": {
"badge": "High memory usage",
"title": "The app used {{usageGb}} GB of RAM last session",
"message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.",
"openLog": "Open log file",
"showInFolder": "Show in folder",
"copyPath": "Copy path",
"dismiss": "Dismiss",
"dismissAriaLabel": "Dismiss high memory alert"
}
},
"attachment": {
@@ -1317,12 +1327,14 @@
"localData": {
"title": "Local data",
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
"descriptionMobile": "Review and erase the private app storage that holds local messages, rooms, attachments, and saved settings on this device.",
"restartApp": "Restart app"
},
"desktopOnly": "Data management is only available in the packaged Electron desktop app.",
"desktopOnly": "Data management is only available in the desktop app or native mobile app.",
"currentFolder": {
"title": "Current data folder",
"resolving": "Resolving data folder..."
"resolving": "Resolving data folder...",
"descriptionMobile": "Files are stored in the app's private data directory on this device."
},
"openFolder": "Open folder",
"opening": "Opening...",
@@ -1342,6 +1354,7 @@
"erase": {
"title": "Erase user data",
"description": "Remove local app data from this device and recreate an empty database.",
"descriptionMobile": "Remove local messages, rooms, attachments, and saved app data from this device.",
"button": "Erase user data",
"erasing": "Erasing...",
"confirm": "Erase all local Toju data on this device? This cannot be undone."
@@ -1356,6 +1369,7 @@
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
"imported": "Imported data.",
"erased": "Local data erased. Restart the app to finish resetting the session.",
"erasedMobile": "Local data erased. You have been signed out.",
"operationFailed": "Data operation failed."
}
},

View File

@@ -1,6 +1,7 @@
<div
appThemeNode="appRoot"
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
[class.metoyou-safe-area-shell]="isMobile()"
>
<div
class="h-full min-h-0 min-w-0 overflow-hidden"
@@ -166,6 +167,7 @@
<app-incoming-call-modal />
<app-screen-share-source-picker />
<app-native-context-menu />
<app-high-memory-alert-modal />
<app-debug-console [showLauncher]="false" />
<app-theme-picker-overlay />
</div>

View File

@@ -26,6 +26,7 @@ import {
loadLastViewedChatFromStorage
} from './infrastructure/persistence';
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
import { DesktopHighMemoryAlertService } from './core/services/desktop-high-memory-alert.service';
import { ServerDirectoryFacade } from './domains/server-directory';
import { NotificationsFacade } from './domains/notifications';
import { TimeSyncService } from './core/services/time-sync.service';
@@ -53,6 +54,7 @@ import { SettingsModalComponent } from './features/settings/settings-modal/setti
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
import { HighMemoryAlertModalComponent } from './features/shell/high-memory-alert-modal/high-memory-alert-modal.component';
import { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
@@ -81,6 +83,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from './core/i18n';
DebugConsoleComponent,
ScreenShareSourcePickerComponent,
NativeContextMenuComponent,
HighMemoryAlertModalComponent,
PrivateCallComponent,
ThemeNodeDirective,
ThemePickerOverlayComponent,
@@ -103,6 +106,7 @@ export class App implements OnInit, OnDestroy {
currentRoom = this.store.selectSignal(selectCurrentRoom);
desktopUpdates = inject(DesktopAppUpdateService);
desktopUpdateState = this.desktopUpdates.state;
desktopHighMemoryAlert = inject(DesktopHighMemoryAlertService);
readonly databaseService = inject(DatabaseService);
readonly router = inject(Router);
readonly servers = inject(ServerDirectoryFacade);
@@ -288,6 +292,7 @@ export class App implements OnInit, OnDestroy {
// - desktop deep-link bridge (only relevant after first paint)
// - background presence + game activity loops
void this.desktopUpdates.initialize();
void this.desktopHighMemoryAlert.initialize();
void this.kickOffBackgroundBootstrap();
// The only thing we genuinely must await before deciding which route

View File

@@ -251,6 +251,13 @@ export interface ElectronPerfDiagEntry {
payload: Record<string, unknown>;
}
export interface ElectronHighMemoryAlertRecord {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
}
export interface ElectronApi {
linuxDisplayServer: string;
minimizeWindow: () => void;
@@ -272,6 +279,9 @@ export interface ElectronApi {
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
isPerfDiagEnabled?: () => Promise<boolean>;
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>;
acknowledgeHighMemoryAlert?: () => Promise<boolean>;
showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>;

View File

@@ -6,6 +6,7 @@ import {
import {
formatAppRamLabel,
formatKilobytesAsGigabytes,
formatKilobytesAsMegabytes,
sumWorkingSetKb
} from './electron-app-metrics.rules';
@@ -38,6 +39,13 @@ describe('sumWorkingSetKb', () => {
});
});
describe('formatKilobytesAsGigabytes', () => {
it('formats totals in gigabytes with two decimals', () => {
expect(formatKilobytesAsGigabytes(1536 * 1024)).toBe('1.50');
expect(formatKilobytesAsGigabytes(2 * 1024 * 1024)).toBe('2.00');
});
});
describe('formatKilobytesAsMegabytes', () => {
it('rounds large values to whole megabytes', () => {
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');

View File

@@ -36,6 +36,10 @@ export function formatKilobytesAsMegabytes(kilobytes: number): string {
return `${megabytes.toFixed(2)} MB`;
}
export function formatKilobytesAsGigabytes(kilobytes: number): string {
return (kilobytes / (1024 * 1024)).toFixed(2);
}
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
const totalKb = sumWorkingSetKb(snapshot.processes);

View File

@@ -0,0 +1,82 @@
import {
Injectable,
computed,
inject,
signal
} from '@angular/core';
import { PlatformService } from '../platform';
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
import { formatKilobytesAsGigabytes } from '../platform/electron/electron-app-metrics.rules';
@Injectable({ providedIn: 'root' })
export class DesktopHighMemoryAlertService {
private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService);
readonly pendingAlert = signal<ElectronHighMemoryAlertRecord | null>(null);
readonly peakUsageGb = computed(() => {
const alert = this.pendingAlert();
return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null;
});
async initialize(): Promise<void> {
if (!this.platform.isElectron) {
return;
}
const api = this.electronBridge.getApi();
if (!api?.getPendingHighMemoryAlert) {
return;
}
const alert = await api.getPendingHighMemoryAlert();
if (alert) {
this.pendingAlert.set(alert);
}
}
async dismiss(): Promise<void> {
const api = this.electronBridge.getApi();
await api?.acknowledgeHighMemoryAlert?.();
this.pendingAlert.set(null);
}
async openLogFile(): Promise<void> {
const alert = this.pendingAlert();
const api = this.electronBridge.getApi();
if (!alert?.logFilePath || !api?.openFilePath) {
return;
}
await api.openFilePath(alert.logFilePath);
}
async showLogFileInFolder(): Promise<void> {
const alert = this.pendingAlert();
const api = this.electronBridge.getApi();
if (!alert?.logFilePath || !api?.showLogFileInFolder) {
return;
}
await api.showLogFileInFolder(alert.logFilePath);
}
async copyLogPath(): Promise<void> {
const alert = this.pendingAlert();
if (!alert?.logFilePath) {
return;
}
await navigator.clipboard.writeText(alert.logFilePath);
}
}

View File

@@ -43,4 +43,13 @@ describe('AuthTokenStoreService', () => {
expect(service.getToken('http://localhost:3001')).toBeNull();
});
it('clears every stored token', () => {
service.setToken('http://localhost:3001', 'token-abc', Date.now() + 60_000);
service.setToken('http://localhost:3002', 'token-def', Date.now() + 60_000);
service.clearAllTokens();
expect(service.hasAnyValidToken()).toBe(false);
});
});

View File

@@ -49,6 +49,12 @@ export class AuthTokenStoreService {
this.writeStore(nextStore);
}
clearAllTokens(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
}
hasAnyValidToken(): boolean {
const now = Date.now();

View File

@@ -5,6 +5,8 @@ import { firstValueFrom } from 'rxjs';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ServerDirectoryFacade } from '../../../server-directory';
import { AUTH_MODE_AUTHORIZE, buildLoginReturnQueryParams } from '../../domain/logic/auth-navigation.rules';
import { isEndpointOnlineForConnection } from '../../../server-directory/domain/logic/server-endpoint-connectivity.rules';
import { shouldNavigateToAuthorizeSignalServer } from '../../domain/logic/signal-server-authorize.rules';
import { SignalServerAuthService } from './signal-server-auth.service';
@Injectable({ providedIn: 'root' })
@@ -25,25 +27,43 @@ export class SignalServerAuthorizeService {
return false;
}
const result = await this.signalServerAuth.ensureProvisioned(serverUrl, currentUser);
let result;
try {
result = await this.signalServerAuth.ensureProvisioned(serverUrl, currentUser);
} catch {
return false;
}
if (result.kind === 'existing' || result.kind === 'provisioned') {
return true;
}
if (result.kind === 'collision') {
await this.navigateToAuthorize(serverUrl, this.router.url);
return false;
}
const endpointStatus = await this.resolveEndpointStatusForAuthorize(serverUrl);
if (result.kind === 'skipped' && result.reason === 'no-provision-secret') {
if (shouldNavigateToAuthorizeSignalServer(endpointStatus, result)) {
await this.navigateToAuthorize(serverUrl, this.router.url);
return false;
}
return false;
}
private async resolveEndpointStatusForAuthorize(serverUrl: string) {
const endpoint = this.serverDirectory.findServerByUrl(serverUrl);
if (!endpoint) {
return null;
}
if (isEndpointOnlineForConnection(endpoint.status) || endpoint.status === 'offline' || endpoint.status === 'incompatible') {
return endpoint.status;
}
await this.serverDirectory.testServer(endpoint.id);
return this.serverDirectory.servers().find((candidate) => candidate.id === endpoint.id)?.status ?? endpoint.status;
}
async navigateToAuthorize(serverUrl: string, returnUrl: string): Promise<void> {
const endpoint = this.serverDirectory.ensureServerEndpoint({
name: this.buildEndpointName(serverUrl),

View File

@@ -0,0 +1,66 @@
import '@angular/compiler';
import { Injector, runInInjectionContext } from '@angular/core';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions';
import { UserLogoutService } from './user-logout.service';
describe('UserLogoutService', () => {
let webrtc: { disconnect: ReturnType<typeof vi.fn> };
let store: { dispatch: ReturnType<typeof vi.fn> };
let router: { navigate: ReturnType<typeof vi.fn> };
let service: UserLogoutService;
beforeEach(() => {
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: vi.fn(() => null),
length: 0
});
webrtc = { disconnect: vi.fn() };
store = { dispatch: vi.fn() };
router = { navigate: vi.fn(() => Promise.resolve(true)) };
const injector = Injector.create({
providers: [
UserLogoutService,
{ provide: RealtimeSessionFacade, useValue: webrtc },
{ provide: Store, useValue: store },
{ provide: Router, useValue: router }
]
});
service = runInInjectionContext(injector, () => injector.get(UserLogoutService));
});
it('disconnects, clears persisted user scope, resets store slices, and navigates to login', () => {
service.logout();
expect(webrtc.disconnect).toHaveBeenCalledOnce();
expect(store.dispatch).toHaveBeenCalledWith(MessagesActions.clearMessages());
expect(store.dispatch).toHaveBeenCalledWith(RoomsActions.resetRoomsState());
expect(store.dispatch).toHaveBeenCalledWith(UsersActions.resetUsersState());
expect(router.navigate).toHaveBeenCalledWith(['/login']);
});
it('can reset client state without navigating away', () => {
service.logout({ navigate: false });
expect(router.navigate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,28 @@
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { clearStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions';
@Injectable({ providedIn: 'root' })
export class UserLogoutService {
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly store = inject(Store);
private readonly router = inject(Router);
logout(options?: { navigate?: boolean }): void {
this.webrtc.disconnect();
clearStoredCurrentUserId();
this.store.dispatch(MessagesActions.clearMessages());
this.store.dispatch(RoomsActions.resetRoomsState());
this.store.dispatch(UsersActions.resetUsersState());
if (options?.navigate !== false) {
void this.router.navigate(['/login']);
}
}
}

View File

@@ -0,0 +1,44 @@
import {
describe,
expect,
it
} from 'vitest';
import { shouldNavigateToAuthorizeSignalServer } from './signal-server-authorize.rules';
describe('signal-server-authorize rules', () => {
it('does not navigate to authorize when the signal server is offline', () => {
expect(shouldNavigateToAuthorizeSignalServer('offline', {
kind: 'skipped',
reason: 'no-provision-secret'
})).toBe(false);
expect(shouldNavigateToAuthorizeSignalServer('offline', {
kind: 'collision',
error: new Error('collision') as never
})).toBe(false);
});
it('navigates to authorize on online servers that need manual sign-in', () => {
expect(shouldNavigateToAuthorizeSignalServer('online', {
kind: 'skipped',
reason: 'no-provision-secret'
})).toBe(true);
expect(shouldNavigateToAuthorizeSignalServer('online', {
kind: 'collision',
error: new Error('collision') as never
})).toBe(true);
});
it('does not navigate for unknown endpoint status or non-authorize provision outcomes', () => {
expect(shouldNavigateToAuthorizeSignalServer('unknown', {
kind: 'skipped',
reason: 'no-provision-secret'
})).toBe(false);
expect(shouldNavigateToAuthorizeSignalServer('online', {
kind: 'skipped',
reason: 'no-home-user'
})).toBe(false);
});
});

View File

@@ -0,0 +1,18 @@
import type { EnsureProvisionedResult } from '../../application/services/signal-server-auth.service';
import type { ServerEndpointStatus } from '../../../server-directory/domain/models/server-directory.model';
import { isEndpointOnlineForConnection } from '../../../server-directory/domain/logic/server-endpoint-connectivity.rules';
export function shouldNavigateToAuthorizeSignalServer(
endpointStatus: ServerEndpointStatus | undefined | null,
provisionResult: EnsureProvisionedResult
): boolean {
if (!isEndpointOnlineForConnection(endpointStatus)) {
return false;
}
if (provisionResult.kind === 'collision') {
return true;
}
return provisionResult.kind === 'skipped' && provisionResult.reason === 'no-provision-secret';
}

View File

@@ -1,5 +1,6 @@
export * from './application/services/authentication.service';
export * from './application/services/auth-token-store.service';
export * from './application/services/user-logout.service';
export * from './application/services/signal-server-auth.service';
export * from './application/services/signal-server-authorize.service';
export * from './application/services/signal-server-credential-store.service';

View File

@@ -26,6 +26,7 @@ import {
type RoomSignalSource,
type RoomSignalSourceInput
} from '../../domain/logic/room-signal-source.logic';
import { isEndpointOnlineForConnection } from '../../domain/logic/server-endpoint-connectivity.rules';
import { ServerEndpointCompatibilityService } from '../../infrastructure/services/server-endpoint-compatibility.service';
import { ServerEndpointHealthService } from '../../infrastructure/services/server-endpoint-health.service';
import { ServerEndpointStateService } from './server-endpoint-state.service';
@@ -110,7 +111,7 @@ export class ServerDirectoryService {
const clientVersion = await this.endpointCompatibility.getClientVersion();
if (!clientVersion) {
if (!clientVersion && isEndpointOnlineForConnection(endpoint.status)) {
return true;
}
@@ -118,7 +119,7 @@ export class ServerDirectoryService {
const refreshedEndpoint = this.servers().find((candidate) => candidate.id === endpoint.id);
return !!refreshedEndpoint && refreshedEndpoint.status !== 'incompatible';
return isEndpointOnlineForConnection(refreshedEndpoint?.status);
}
resolveRoomEndpoint(

View File

@@ -0,0 +1,17 @@
import {
describe,
expect,
it
} from 'vitest';
import { isEndpointOnlineForConnection } from './server-endpoint-connectivity.rules';
describe('server-endpoint-connectivity rules', () => {
it('treats only online endpoints as reachable for connection', () => {
expect(isEndpointOnlineForConnection('online')).toBe(true);
expect(isEndpointOnlineForConnection('offline')).toBe(false);
expect(isEndpointOnlineForConnection('checking')).toBe(false);
expect(isEndpointOnlineForConnection('unknown')).toBe(false);
expect(isEndpointOnlineForConnection('incompatible')).toBe(false);
expect(isEndpointOnlineForConnection(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,8 @@
import type { ServerEndpointStatus } from '../models/server-directory.model';
/** True only when health checks report the endpoint is reachable. */
export function isEndpointOnlineForConnection(
status: ServerEndpointStatus | undefined | null
): boolean {
return status === 'online';
}

View File

@@ -7,6 +7,7 @@ import type { VoiceState } from '../../../../shared-kernel';
import {
isLocalVoiceOwner,
isVoiceOnAnotherClient,
shouldApplyRemoteVoiceStateToCurrentUser,
shouldTransmitVoice
} from './client-voice-session.rules';
@@ -51,4 +52,54 @@ describe('client-voice-session.rules', () => {
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true);
});
it('ignores stale self disconnect updates while this client actively owns voice', () => {
const voiceState: VoiceState = {
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: false,
roomId: 'vc-general',
serverId: 'server-1',
clientInstanceId: localClientInstanceId
};
expect(shouldApplyRemoteVoiceStateToCurrentUser(
voiceState,
{ isConnected: false },
localClientInstanceId,
true
)).toBe(false);
});
it('applies self disconnect updates when this client is not actively transmitting', () => {
expect(shouldApplyRemoteVoiceStateToCurrentUser(
{
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: false,
clientInstanceId: localClientInstanceId
},
{ isConnected: false },
localClientInstanceId,
false
)).toBe(true);
});
it('ignores self disconnect echoes during the join race before clientInstanceId is stored', () => {
expect(shouldApplyRemoteVoiceStateToCurrentUser(
{
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: false,
roomId: 'vc-general',
serverId: 'server-1'
},
{ isConnected: false },
localClientInstanceId,
true
)).toBe(false);
});
});

View File

@@ -32,3 +32,29 @@ export function shouldTransmitVoice(
return voiceState.clientInstanceId === clientInstanceId;
}
/** Ignore stale P2P disconnect echoes for the current user while this client actively owns voice. */
export function shouldApplyRemoteVoiceStateToCurrentUser(
currentVoiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
incoming: Partial<VoiceState>,
localClientInstanceId: string,
isLocallyVoiceActive: boolean
): boolean {
if (incoming.isConnected !== false) {
return true;
}
if (!isLocallyVoiceActive) {
return true;
}
if (isLocalVoiceOwner(currentVoiceState, localClientInstanceId)) {
return false;
}
if (!currentVoiceState?.clientInstanceId) {
return false;
}
return true;
}

View File

@@ -0,0 +1,29 @@
import {
describe,
expect,
it
} from 'vitest';
import {
supportsDesktopDataFolderActions,
supportsLocalDataManagement,
supportsMobileLocalDataErase
} from './data-settings-capability.rules';
describe('data settings capability rules', () => {
it('enables local data management on Electron and Capacitor only', () => {
expect(supportsLocalDataManagement({ isElectron: true, isCapacitor: false })).toBe(true);
expect(supportsLocalDataManagement({ isElectron: false, isCapacitor: true })).toBe(true);
expect(supportsLocalDataManagement({ isElectron: false, isCapacitor: false })).toBe(false);
});
it('limits folder, export, and import actions to Electron', () => {
expect(supportsDesktopDataFolderActions({ isElectron: true, isCapacitor: false })).toBe(true);
expect(supportsDesktopDataFolderActions({ isElectron: false, isCapacitor: true })).toBe(false);
});
it('allows erase on Capacitor shells', () => {
expect(supportsMobileLocalDataErase({ isElectron: false, isCapacitor: true })).toBe(true);
expect(supportsMobileLocalDataErase({ isElectron: true, isCapacitor: false })).toBe(false);
});
});

View File

@@ -0,0 +1,16 @@
export interface DataSettingsPlatform {
isElectron: boolean;
isCapacitor: boolean;
}
export function supportsLocalDataManagement(platform: DataSettingsPlatform): boolean {
return platform.isElectron || platform.isCapacitor;
}
export function supportsDesktopDataFolderActions(platform: DataSettingsPlatform): boolean {
return platform.isElectron;
}
export function supportsMobileLocalDataErase(platform: DataSettingsPlatform): boolean {
return platform.isCapacitor;
}

View File

@@ -10,7 +10,11 @@
<h4 class="text-base font-semibold text-foreground">{{ 'settings.data.localData.title' | translate }}</h4>
</div>
<p class="mt-2 text-sm text-muted-foreground">
{{ 'settings.data.localData.description' | translate }}
@if (supportsMobileLocalDataErase) {
{{ 'settings.data.localData.descriptionMobile' | translate }}
} @else {
{{ 'settings.data.localData.description' | translate }}
}
</p>
</div>
@@ -25,17 +29,17 @@
name="lucideRefreshCw"
class="h-4 w-4"
/>
Restart app
{{ 'settings.data.localData.restartApp' | translate }}
</button>
}
</div>
</section>
@if (!isElectron) {
@if (!supportsLocalDataManagement) {
<section class="rounded-lg border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">{{ 'settings.data.desktopOnly' | translate }}</p>
</section>
} @else {
} @else if (supportsDesktopDataFolderActions) {
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.currentFolder.title' | translate }}</h5>
@@ -108,6 +112,7 @@
<button
type="button"
data-testid="data-settings-erase-button"
(click)="eraseData()"
[disabled]="busyAction() !== null"
class="inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-60"
@@ -119,17 +124,48 @@
{{ busyAction() === 'erase' ? ('settings.data.erase.erasing' | translate) : ('settings.data.erase.button' | translate) }}
</button>
</section>
} @else if (supportsMobileLocalDataErase) {
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.currentFolder.title' | translate }}</h5>
<p class="mt-2 break-all rounded-lg border border-border bg-secondary/20 px-3 py-2 text-sm text-muted-foreground">
{{ dataPath() || ('settings.data.currentFolder.resolving' | translate) }}
</p>
<p class="mt-2 text-sm text-muted-foreground">{{ 'settings.data.currentFolder.descriptionMobile' | translate }}</p>
</div>
</section>
@if (statusMessage()) {
<section class="rounded-lg border border-primary/30 bg-primary/10 p-4">
<p class="break-words text-sm text-foreground">{{ statusMessage() }}</p>
</section>
}
<section class="space-y-4 rounded-lg border border-destructive/30 bg-destructive/10 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.erase.title' | translate }}</h5>
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.data.erase.descriptionMobile' | translate }}</p>
</div>
@if (errorMessage()) {
<section class="rounded-lg border border-destructive/30 bg-destructive/10 p-4">
<p class="break-words text-sm text-foreground">{{ errorMessage() }}</p>
</section>
}
<button
type="button"
data-testid="data-settings-erase-button"
(click)="eraseData()"
[disabled]="busyAction() !== null"
class="inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<ng-icon
name="lucideTrash2"
class="h-4 w-4"
/>
{{ busyAction() === 'erase' ? ('settings.data.erase.erasing' | translate) : ('settings.data.erase.button' | translate) }}
</button>
</section>
}
@if (statusMessage()) {
<section class="rounded-lg border border-primary/30 bg-primary/10 p-4">
<p class="break-words text-sm text-foreground">{{ statusMessage() }}</p>
</section>
}
@if (errorMessage()) {
<section class="rounded-lg border border-destructive/30 bg-destructive/10 p-4">
<p class="break-words text-sm text-foreground">{{ errorMessage() }}</p>
</section>
}
</div>

View File

@@ -15,8 +15,16 @@ import {
lucideUpload
} from '@ng-icons/lucide';
import { PlatformService } from '../../../../core/platform';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { CapacitorAttachmentFileStore } from '../../../../domains/attachment/infrastructure/services/capacitor-attachment-file-store';
import { LocalUserDataService } from '../../../../infrastructure/persistence/local-user-data.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
supportsDesktopDataFolderActions,
supportsLocalDataManagement,
supportsMobileLocalDataErase
} from '../../domain/logic/data-settings-capability.rules';
type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
@@ -42,9 +50,25 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
})
export class DataSettingsComponent {
private readonly electron = inject(ElectronBridgeService);
private readonly platform = inject(PlatformService);
private readonly localUserData = inject(LocalUserDataService);
private readonly capacitorAttachmentStore = inject(CapacitorAttachmentFileStore);
private readonly appI18n = inject(AppI18nService);
readonly isElectron = this.electron.isAvailable;
readonly isCapacitor = this.platform.isCapacitor;
readonly supportsLocalDataManagement = supportsLocalDataManagement({
isElectron: this.isElectron,
isCapacitor: this.isCapacitor
});
readonly supportsDesktopDataFolderActions = supportsDesktopDataFolderActions({
isElectron: this.isElectron,
isCapacitor: this.isCapacitor
});
readonly supportsMobileLocalDataErase = supportsMobileLocalDataErase({
isElectron: this.isElectron,
isCapacitor: this.isCapacitor
});
readonly dataPath = signal<string | null>(null);
readonly busyAction = signal<DataAction | null>(null);
readonly statusMessage = signal<string | null>(null);
@@ -106,6 +130,14 @@ export class DataSettingsComponent {
}
await this.runAction('erase', async () => {
if (this.supportsMobileLocalDataErase) {
const result = await this.localUserData.eraseLocalUserData();
this.restartRequired.set(result.restartRequired);
this.statusMessage.set(this.appI18n.instant('settings.data.messages.erasedMobile'));
return;
}
const result = await this.electron.requireApi().eraseUserData();
this.restartRequired.set(result.restartRequired);
@@ -121,14 +153,18 @@ export class DataSettingsComponent {
}
private async loadDataPath(): Promise<void> {
if (!this.isElectron) {
if (this.supportsDesktopDataFolderActions) {
try {
this.dataPath.set(await this.electron.requireApi().getAppDataPath());
} catch {
this.dataPath.set(null);
}
return;
}
try {
this.dataPath.set(await this.electron.requireApi().getAppDataPath());
} catch {
this.dataPath.set(null);
if (this.supportsMobileLocalDataErase) {
this.dataPath.set(await this.capacitorAttachmentStore.getAppDataPath());
}
}

View File

@@ -2,7 +2,7 @@
@if (isOpen() && !isThemeStudioFullscreen()) {
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
<div
class="fixed inset-0 z-[90] hidden bg-black/60 backdrop-blur-sm transition-opacity duration-200 md:block"
class="fixed metoyou-fixed-safe-viewport z-[90] hidden bg-black/60 backdrop-blur-sm transition-opacity duration-200 md:block"
[class.opacity-100]="animating()"
[class.opacity-0]="!animating()"
(click)="onBackdropClick()"
@@ -14,7 +14,7 @@
></div>
<!-- Modal: full-screen page on mobile, centered dialog on desktop -->
<div class="fixed inset-0 z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
<div class="fixed metoyou-fixed-safe-viewport z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
<div
appThemeNode="settingsModalSurface"
class="pointer-events-auto relative flex h-full w-full overflow-hidden bg-card transition-all duration-200 md:h-[min(720px,88vh)] md:max-w-5xl md:rounded-lg md:border md:border-border md:shadow-lg"
@@ -129,7 +129,21 @@
}
</div>
<div class="mt-auto border-t border-border px-3 py-3">
<div class="mt-auto border-t border-border px-3 py-3 space-y-2">
@if (currentUser()) {
<button
type="button"
data-testid="settings-logout-button"
(click)="logout()"
class="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2.5 text-sm text-destructive transition-colors hover:bg-destructive/10"
>
<ng-icon
name="lucideLogOut"
class="h-4 w-4"
/>
{{ 'common.logout' | translate }}
</button>
}
<button
type="button"
(click)="openThirdPartyLicenses()"

View File

@@ -27,12 +27,14 @@ import {
lucideTerminal,
lucideUsers,
lucideBan,
lucideShield
lucideShield,
lucideLogOut
} from '@ng-icons/lucide';
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
import { ViewportService } from '../../../core/platform';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { UserLogoutService } from '../../../domains/authentication/application/services/user-logout.service';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { Room, UserRole } from '../../../shared-kernel';
@@ -97,7 +99,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
lucideTerminal,
lucideUsers,
lucideBan,
lucideShield
lucideShield,
lucideLogOut
})
],
templateUrl: './settings-modal.component.html'
@@ -106,6 +109,7 @@ export class SettingsModalComponent {
readonly modal = inject(SettingsModalService);
private store = inject(Store);
private webrtc = inject(RealtimeSessionFacade);
private userLogout = inject(UserLogoutService);
private theme = inject(ThemeService);
private themeLibrary = inject(ThemeLibraryService);
private viewport = inject(ViewportService);
@@ -413,6 +417,11 @@ export class SettingsModalComponent {
this.showThirdPartyLicenses.set(false);
}
logout(): void {
this.closeForExternalNavigation();
this.userLogout.logout();
}
navigate(page: SettingsPage): void {
this.modal.navigate(page);

View File

@@ -0,0 +1,85 @@
@if (alertService.pendingAlert(); as alert) {
<app-modal-backdrop
[zIndex]="120"
[ariaLabel]="'app.highMemoryAlert.dismissAriaLabel' | translate"
(dismissed)="dismiss()"
/>
<div
appThemeNode="highMemoryAlertDialog"
class="fixed inset-0 z-[121] flex items-center justify-center px-4"
role="alertdialog"
[attr.aria-labelledby]="'high-memory-alert-title'"
[attr.aria-describedby]="'high-memory-alert-description'"
>
<div class="relative w-full max-w-lg rounded-xl border border-border bg-card p-4 shadow-lg">
<button
type="button"
(click)="dismiss()"
class="absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
[attr.aria-label]="'app.highMemoryAlert.dismissAriaLabel' | translate"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-destructive">
{{ 'app.highMemoryAlert.badge' | translate }}
</p>
<h2
id="high-memory-alert-title"
class="mt-1 pr-10 text-base font-semibold text-foreground"
>
{{ 'app.highMemoryAlert.title' | translate:{ usageGb: alertService.peakUsageGb() } }}
</h2>
<p
id="high-memory-alert-description"
class="mt-2 pr-2 text-sm leading-6 text-muted-foreground"
>
{{ 'app.highMemoryAlert.message' | translate }}
</p>
<p class="mt-3 break-all rounded-lg border border-border/70 bg-secondary/40 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground">
{{ alert.logFilePath }}
</p>
<div class="mt-4 flex flex-wrap gap-2">
<button
type="button"
(click)="openLogFile()"
class="inline-flex items-center rounded-lg bg-primary px-3 py-2 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
{{ 'app.highMemoryAlert.openLog' | translate }}
</button>
<button
type="button"
(click)="showLogFileInFolder()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
>
{{ 'app.highMemoryAlert.showInFolder' | translate }}
</button>
<button
type="button"
(click)="copyLogPath()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
>
{{ 'app.highMemoryAlert.copyPath' | translate }}
</button>
<button
type="button"
(click)="dismiss()"
class="inline-flex items-center rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
{{ 'app.highMemoryAlert.dismiss' | translate }}
</button>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,47 @@
import { Component, inject } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideX } from '@ng-icons/lucide';
import { DesktopHighMemoryAlertService } from '../../../core/services/desktop-high-memory-alert.service';
import { ThemeNodeDirective } from '../../../domains/theme';
import { APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
import { ModalBackdropComponent } from '../../../shared/components/modal-backdrop/modal-backdrop.component';
@Component({
selector: 'app-high-memory-alert-modal',
standalone: true,
imports: [
NgIcon,
ThemeNodeDirective,
ModalBackdropComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
lucideX
})
],
templateUrl: './high-memory-alert-modal.component.html',
host: {
style: 'display: contents;'
}
})
export class HighMemoryAlertModalComponent {
readonly alertService = inject(DesktopHighMemoryAlertService);
async dismiss(): Promise<void> {
await this.alertService.dismiss();
}
openLogFile(): void {
void this.alertService.openLogFile();
}
showLogFileInFolder(): void {
void this.alertService.showLogFileInFolder();
}
copyLogPath(): void {
void this.alertService.copyLogPath();
}
}

View File

@@ -31,15 +31,13 @@ import {
selectIsSignalServerReconnecting,
selectSignalServerCompatibilityError
} from '../../../store/rooms/rooms.selectors';
import { MessagesActions } from '../../../store/messages/messages.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ServerDirectoryFacade } from '../../../domains/server-directory';
import { PlatformService } from '../../../core/platform';
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
import { UserLogoutService } from '../../../domains/authentication/application/services/user-logout.service';
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
@@ -94,6 +92,7 @@ export class TitleBarComponent {
private pluginRegistry = inject(PluginRegistryService);
private pluginRequirements = inject(PluginRequirementStateService);
private pluginStore = inject(PluginStoreService);
private userLogout = inject(UserLogoutService);
private getWindowControlsApi() {
return this.electronBridge.getApi();
@@ -376,16 +375,7 @@ export class TitleBarComponent {
/** Log out the current user, disconnect from signaling, and navigate to login. */
logout() {
this._showMenu.set(false);
// Disconnect from signaling server - this broadcasts "user_left" to all
// servers the user was a member of, so other users see them go offline.
this.webrtc.disconnect();
clearStoredCurrentUserId();
this.store.dispatch(MessagesActions.clearMessages());
this.store.dispatch(RoomsActions.resetRoomsState());
this.store.dispatch(UsersActions.resetUsersState());
this.router.navigate(['/login']);
this.userLogout.logout();
}
private async copyInviteLink(inviteUrl: string): Promise<void> {

View File

@@ -7,6 +7,11 @@ import type { ElectronApi } from '../../core/platform/electron/electron-api.mode
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
declare global {
// Registered for synchronous main-process sampling at high-memory threshold.
var __collectPerfDiagSample: (() => PerfDiagEntry[]) | undefined;
}
const SAMPLE_INTERVAL_MS = 10_000;
let started = false;
@@ -36,6 +41,22 @@ export async function bootstrapPerfDiagnostics(
started = true;
let immediateSampleCollector: PerfDiagnosticsCollector | null = null;
runInInjectionContext(injector, () => {
immediateSampleCollector = inject(PerfDiagnosticsCollector);
});
globalThis.__collectPerfDiagSample = () => {
if (!immediateSampleCollector) {
return [];
}
const sample = immediateSampleCollector.collectSample();
return sample ? immediateSampleCollector.buildEntries(sample) : [];
};
const reporter: PerfDiagReporter = {
report: (entry: PerfDiagEntry) => reportSample(entry)
};
@@ -92,5 +113,6 @@ function stopPerfDiagnosticsSampling(): void {
sampleTimer = null;
}
delete globalThis.__collectPerfDiagSample;
started = false;
}

View File

@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
export type PerfDiagEntryType =
| 'session'
| 'environment'
| 'process'
| 'store'
| 'components'
| 'heap'
| 'high-memory'
| 'crash'
| 'unresponsive';

View File

@@ -78,10 +78,12 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
if (PushNotifications) {
const permission = await PushNotifications.checkPermissions();
if (permission.receive === 'prompt') {
if (permission.receive !== 'granted') {
await PushNotifications.requestPermissions();
}
}
await this.requestPermission();
}
async requestPermission(): Promise<boolean> {

View File

@@ -1,6 +1,5 @@
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
import type { MobileCapturePermissionResult } from '../../logic/mobile-media-permission.rules';
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
export interface MetoyouMobilePlugin {
requestVoiceCapturePermissions(): Promise<MobileCapturePermissionResult>;
@@ -25,7 +24,14 @@ export async function loadMetoyouMobilePlugin(): Promise<MetoyouMobilePlugin | n
if (!metoyouMobilePluginPromise) {
metoyouMobilePluginPromise = import('@capacitor/core')
.then(({ registerPlugin }) => registerPlugin<MetoyouMobilePlugin>('MetoyouMobile'))
.then(async ({ Capacitor, registerPlugin }) => {
if (!Capacitor.isPluginAvailable('MetoyouMobile')) {
console.warn('[mobile] MetoyouMobile plugin is not implemented on this shell.');
return null;
}
return registerPlugin<MetoyouMobilePlugin>('MetoyouMobile');
})
.catch(() => null);
}

View File

@@ -1,5 +1,6 @@
export * from './logic/platform-detection.rules';
export * from './logic/call-notification.rules';
export * from './services/mobile-runtime-permissions.service';
export * from './services/mobile-platform.service';
export * from './services/mobile-notifications.service';
export * from './services/mobile-media.service';

View File

@@ -0,0 +1,73 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
const pluginState = vi.hoisted(() => ({
plugin: null as null | {
requestVoiceCapturePermissions: () => Promise<{ microphone?: string }>;
requestCameraCapturePermissions: () => Promise<{ camera?: string }>;
},
isNative: true
}));
vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({
loadMetoyouMobilePlugin: vi.fn(() => Promise.resolve(pluginState.plugin))
}));
vi.mock('./platform-detection.rules', () => ({
detectRuntimePlatform: vi.fn(({ capacitorIsNative }: { capacitorIsNative: boolean }) =>
capacitorIsNative ? 'capacitor' : 'browser'),
isCapacitorNativeRuntime: vi.fn(() => pluginState.isNative)
}));
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
import { ensureMobileCameraCapturePermissions, ensureMobileVoiceCapturePermissions } from './ensure-mobile-capture-permissions';
describe('ensure-mobile-capture-permissions', () => {
beforeEach(() => {
pluginState.isNative = true;
pluginState.plugin = {
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'granted' })),
requestCameraCapturePermissions: vi.fn(() => Promise.resolve({ camera: 'granted' }))
};
vi.mocked(loadMetoyouMobilePlugin).mockClear();
});
it('skips native preflight on browser shells', async () => {
pluginState.isNative = false;
await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(true);
expect(loadMetoyouMobilePlugin).not.toHaveBeenCalled();
});
it('defers to WebView capture when the native plugin is unavailable', async () => {
pluginState.plugin = null;
await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(true);
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
});
it('defers to WebView capture when the native plugin call fails', async () => {
pluginState.plugin = {
requestVoiceCapturePermissions: vi.fn(() => Promise.reject(new Error('UNIMPLEMENTED'))),
requestCameraCapturePermissions: vi.fn(() => Promise.reject(new Error('UNIMPLEMENTED')))
};
await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(true);
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
});
it('blocks capture when the native shell explicitly denies microphone access', async () => {
pluginState.plugin = {
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'denied' })),
requestCameraCapturePermissions: vi.fn(() => Promise.resolve({ camera: 'granted' }))
};
await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(false);
});
});

View File

@@ -30,7 +30,7 @@ export async function ensureMobileVoiceCapturePermissions(): Promise<boolean> {
return isVoiceCaptureAllowed(result);
} catch {
return false;
return true;
}
}
@@ -51,6 +51,6 @@ export async function ensureMobileCameraCapturePermissions(): Promise<boolean> {
return isCameraCaptureAllowed(result);
} catch {
return false;
return true;
}
}

View File

@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import sharp from 'sharp';
import {
describe,
expect,
@@ -9,13 +10,16 @@ import {
} from 'vitest';
import {
ADAPTIVE_FOREGROUND_ICON_RATIO,
BRAND_LAUNCHER_BACKGROUND_COLOR,
findMissingLauncherResources,
findStockCapacitorResources,
isBrandLauncherBackgroundColor,
readAdaptiveIconBackgroundColor,
REQUIRED_LAUNCHER_ICON_FILES,
REQUIRED_SPLASH_FILES
REQUIRED_SPLASH_FILES,
resolveIconPixelSize,
SPLASH_ICON_RATIO
} from './mobile-android-launcher-icon.rules';
const RES_DIR = resolve(process.cwd(), 'android/app/src/main/res');
@@ -31,6 +35,12 @@ describe('mobile-android-launcher-icon.rules', () => {
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
it('keeps the brand mark inside the adaptive-icon safe zone', () => {
expect(ADAPTIVE_FOREGROUND_ICON_RATIO).toBeCloseTo(66 / 108, 5);
expect(resolveIconPixelSize(432)).toBe(264);
expect(SPLASH_ICON_RATIO).toBeLessThan(0.4);
});
it('ships a launcher icon and splash for every required density', () => {
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
});
@@ -49,4 +59,15 @@ describe('mobile-android-launcher-icon.rules', () => {
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase());
});
it('insets the adaptive foreground so launcher masks do not clip the cat face', async () => {
const foregroundPath = resolve(RES_DIR, 'mipmap-xxxhdpi/ic_launcher_foreground.png');
const { data, info } = await sharp(foregroundPath).ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const topCenterOffset = (0 * info.width + Math.floor(info.width / 2)) * info.channels;
const topCenterAlpha = data[topCenterOffset + 3];
expect(topCenterAlpha).toBeLessThan(32);
});
});

View File

@@ -8,6 +8,26 @@
/** Brand purple sampled from `images/icon-new-rounded.png` (the circle behind the cat). */
export const BRAND_LAUNCHER_BACKGROUND_COLOR = '#4A217A';
/** Adaptive-icon foreground canvas size in dp (Android spec). */
export const ADAPTIVE_ICON_CANVAS_DP = 108;
/** Visible safe-zone diameter inside the adaptive-icon foreground layer (Android spec). */
export const ADAPTIVE_ICON_SAFE_ZONE_DP = 66;
/** Scale the brand mark to fit inside the adaptive-icon safe zone instead of full-bleed. */
export const ADAPTIVE_FOREGROUND_ICON_RATIO = ADAPTIVE_ICON_SAFE_ZONE_DP / ADAPTIVE_ICON_CANVAS_DP;
/** Legacy square/round launcher bitmaps use the same inset so circular masks do not clip the cat. */
export const LEGACY_LAUNCHER_ICON_RATIO = ADAPTIVE_FOREGROUND_ICON_RATIO;
/** Splash art keeps the brand mark smaller than the screen so ears and cheeks stay visible. */
export const SPLASH_ICON_RATIO = 0.32;
/** Return the brand icon pixel size for a square canvas at the given scale ratio. */
export function resolveIconPixelSize(canvasPx: number, ratio: number = ADAPTIVE_FOREGROUND_ICON_RATIO): number {
return Math.round(canvasPx * ratio);
}
/** Density buckets Android resolves launcher icons from. */
export const ANDROID_ICON_DENSITIES = [
'mdpi',

View File

@@ -0,0 +1,41 @@
import {
describe,
expect,
it
} from 'vitest';
import {
areMobileRuntimePermissionsGranted,
shouldPromptForNotificationPermission,
shouldRequestMobileRuntimePermissions
} from './mobile-runtime-permissions.rules';
describe('mobile-runtime-permissions.rules', () => {
it('only requests runtime permissions on Capacitor shells', () => {
expect(shouldRequestMobileRuntimePermissions('capacitor')).toBe(true);
expect(shouldRequestMobileRuntimePermissions('browser')).toBe(false);
expect(shouldRequestMobileRuntimePermissions('electron')).toBe(false);
});
it('prompts for notification permissions until granted', () => {
expect(shouldPromptForNotificationPermission('granted')).toBe(false);
expect(shouldPromptForNotificationPermission('prompt')).toBe(true);
expect(shouldPromptForNotificationPermission('denied')).toBe(true);
});
it('requires every runtime permission alias to be granted', () => {
expect(areMobileRuntimePermissionsGranted({
voiceGranted: true,
cameraGranted: true,
localNotificationsGranted: true,
pushNotificationsGranted: true
})).toBe(true);
expect(areMobileRuntimePermissionsGranted({
voiceGranted: false,
cameraGranted: true,
localNotificationsGranted: true,
pushNotificationsGranted: true
})).toBe(false);
});
});

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