Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3c2f01cc6 | |||
| dac5cb42a5 | |||
| 29032b5a36 | |||
| e75b4a38ed | |||
| 07e91a0d09 | |||
| cb59af6b6c | |||
| 6b9a39fe4a | |||
| a01abbb1bf | |||
| bdea95511d |
@@ -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 |
|
| 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 |
|
| 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
|
## Login / register response
|
||||||
|
|
||||||
```json
|
```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 |
|
| Create/join on foreign server | `RoomsEffects.createRoom$`, invite/join flows | `ensureCredentialForServerUrl` provisions (or reuses) the per-server session token first; REST/WebSocket calls use the **actor user id** for that signal URL, not the home registration id |
|
||||||
| Foreign auth failure | `signalServerAuthFailed` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth |
|
| Foreign auth failure | `signalServerAuthFailed` | 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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ npm run cap:assets:android # → tools/generate-android-app-icons.mjs (uses sh
|
|||||||
|
|
||||||
This produces, for every density (`mdpi … xxxhdpi`):
|
This produces, for every density (`mdpi … xxxhdpi`):
|
||||||
|
|
||||||
- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` — legacy launcher bitmaps (the brand disc).
|
- `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` — 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_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.
|
- `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:
|
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 |
|
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
||||||
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
|
| `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)
|
### iOS (APNs)
|
||||||
|
|
||||||
@@ -199,9 +201,10 @@ The service shows a low-importance ongoing notification while a call is active.
|
|||||||
|
|
||||||
## Safe area (Android)
|
## Safe area (Android)
|
||||||
|
|
||||||
- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads.
|
- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads. `MobileAppLifecycleService` calls `syncMobileSafeAreaInsets()` after Capacitor boot so Android SystemBars recomputes inset variables once the SPA is ready.
|
||||||
- `capacitor.config.ts` sets `plugins.SystemBars.insetsHandling: 'css'` so Android WebView versions that mis-report `env(safe-area-inset-*)` still receive correct insets.
|
- `capacitor.config.ts` sets `plugins.SystemBars.insetsHandling: 'css'` so Android WebView versions that mis-report `env(safe-area-inset-*)` still receive correct insets.
|
||||||
- Global `styles.scss` applies inset padding on `html` (with `env()` fallback) and sizes `app-root` to `height: 100%` so content stays below the status bar and above the navigation bar in edge-to-edge mode.
|
- Global `styles.scss` defines `metoyou-safe-area-shell` (mobile app shell padding), `metoyou-fixed-safe-viewport` (full-screen modals/backdrops), and `metoyou-fixed-safe-bottom-sheet` (bottom sheets and CDK profile-card panels). These read `--safe-area-inset-*` with `env()` fallback so routed pages, settings, context menus, and profile cards stay below the status bar and above the navigation bar.
|
||||||
|
- Android `styles.xml` uses transparent status/navigation bars and `windowLayoutInDisplayCutoutMode=shortEdges` so Capacitor can draw edge-to-edge and report accurate insets.
|
||||||
|
|
||||||
## Self-hosted HTTPS signal servers (Android)
|
## Self-hosted HTTPS signal servers (Android)
|
||||||
|
|
||||||
@@ -253,6 +256,7 @@ Network security configs:
|
|||||||
- `MobileCallSessionService` — CallKit + foreground service + in-call notifications.
|
- `MobileCallSessionService` — CallKit + foreground service + in-call notifications.
|
||||||
- `App` bootstrap — initializes mobile persistence, lifecycle, app-update polling, call-session, and push registration wiring.
|
- `App` bootstrap — initializes mobile persistence, lifecycle, app-update polling, call-session, and push registration wiring.
|
||||||
- `MobileAppUpdateService` — periodic Play Store / App Store checks (30 min) and settings UI actions; mirrors Electron `DesktopAppUpdateService` polling but uses native store APIs instead of release manifests.
|
- `MobileAppUpdateService` — periodic Play Store / App Store checks (30 min) and settings UI actions; mirrors Electron `DesktopAppUpdateService` polling but uses native store APIs instead of release manifests.
|
||||||
|
- Settings → **Data** on Capacitor shells shows the private app-data root and **Erase user data** (`LocalUserDataService` clears SQLite, Capacitor attachment files, auth tokens, and `metoyou_*` localStorage keys, then logs out).
|
||||||
|
|
||||||
## Phase 3 completion notes
|
## Phase 3 completion notes
|
||||||
|
|
||||||
|
|||||||
76
e2e/helpers/settings-modal.ts
Normal 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 };
|
||||||
72
e2e/helpers/signal-manager.ts
Normal 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);
|
||||||
|
}
|
||||||
49
e2e/helpers/voice-roster.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,10 +8,6 @@ interface ScreenShareMediaStream extends MediaStream {
|
|||||||
__isScreenShare?: boolean;
|
__isScreenShare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function webRtcHarnessWindow(scope: Window = window): WebRtcTestHarnessWindow {
|
|
||||||
return scope as unknown as WebRtcTestHarnessWindow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
||||||
* Tracks all created peer connections and their remote tracks so tests
|
* Tracks all created peer connections and their remote tracks so tests
|
||||||
@@ -32,7 +28,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
source?: AudioScheduledSourceNode;
|
source?: AudioScheduledSourceNode;
|
||||||
drawIntervalId?: number;
|
drawIntervalId?: number;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
const harness = webRtcHarnessWindow();
|
const harness = window as unknown as WebRtcTestHarnessWindow;
|
||||||
|
|
||||||
harness.__rtcConnections = connections;
|
harness.__rtcConnections = connections;
|
||||||
harness.__rtcDataChannels = dataChannels;
|
harness.__rtcDataChannels = dataChannels;
|
||||||
@@ -160,6 +156,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
|
|
||||||
return resultStream;
|
return resultStream;
|
||||||
};
|
};
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +178,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const OrigAudioContext = window.AudioContext;
|
const OrigAudioContext = window.AudioContext;
|
||||||
const audioHarness = webRtcHarnessWindow();
|
const audioHarness = window as unknown as WebRtcTestHarnessWindow;
|
||||||
|
|
||||||
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
|
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
|
||||||
const ctx: AudioContext = new OrigAudioContext(...args);
|
const ctx: AudioContext = new OrigAudioContext(...args);
|
||||||
@@ -211,7 +208,7 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
|||||||
|
|
||||||
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => webRtcHarnessWindow().__rtcConnections?.some(
|
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
) ?? false,
|
) ?? false,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -224,7 +221,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
|
|||||||
*/
|
*/
|
||||||
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => webRtcHarnessWindow().__rtcConnections?.some(
|
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
) ?? false
|
) ?? false
|
||||||
);
|
);
|
||||||
@@ -233,7 +230,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
|||||||
/** Returns the number of tracked peer connections in `connected` state. */
|
/** Returns the number of tracked peer connections in `connected` state. */
|
||||||
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||||
(pc) => pc.connectionState === 'connected'
|
(pc) => pc.connectionState === 'connected'
|
||||||
).length ?? 0
|
).length ?? 0
|
||||||
);
|
);
|
||||||
@@ -241,19 +238,36 @@ export async function getConnectedPeerCount(page: Page): Promise<number> {
|
|||||||
|
|
||||||
/** Wait until the expected number of peer connections are `connected`. */
|
/** Wait until the expected number of peer connections are `connected`. */
|
||||||
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
try {
|
||||||
(count) => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
await page.waitForFunction(
|
||||||
(pc) => pc.connectionState === 'connected'
|
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||||
).length === count,
|
(pc) => pc.connectionState === 'connected'
|
||||||
expectedCount,
|
).length === count,
|
||||||
{ timeout }
|
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. */
|
/** Returns the number of tracked RTCDataChannels in the open state. */
|
||||||
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||||
(channel) => channel.readyState === 'open'
|
(channel) => channel.readyState === 'open'
|
||||||
).length ?? 0
|
).length ?? 0
|
||||||
);
|
);
|
||||||
@@ -262,7 +276,7 @@ export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
|||||||
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
||||||
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(count) => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||||
(channel) => channel.readyState === 'open'
|
(channel) => channel.readyState === 'open'
|
||||||
).length === count,
|
).length === count,
|
||||||
expectedCount,
|
expectedCount,
|
||||||
@@ -273,7 +287,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num
|
|||||||
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
||||||
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||||
|
|
||||||
let closed = 0;
|
let closed = 0;
|
||||||
|
|
||||||
@@ -293,7 +307,7 @@ export async function closeOpenDataChannels(page: Page): Promise<number> {
|
|||||||
/** Dispatch a synthetic data-channel error event on each open channel. */
|
/** Dispatch a synthetic data-channel error event on each open channel. */
|
||||||
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||||
|
|
||||||
let dispatched = 0;
|
let dispatched = 0;
|
||||||
|
|
||||||
@@ -354,7 +368,7 @@ interface PerPeerAudioStat {
|
|||||||
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
||||||
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length) {
|
if (!connections?.length) {
|
||||||
return [];
|
return [];
|
||||||
@@ -472,7 +486,7 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||||
}> {
|
}> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return { outbound: null, inbound: null };
|
return { outbound: null, inbound: null };
|
||||||
@@ -486,8 +500,8 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
hasInbound: boolean;
|
hasInbound: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hwm: Record<number, HWMEntry> = webRtcHarnessWindow().__rtcStatsHWM =
|
const hwm: Record<number, HWMEntry> = (window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM =
|
||||||
(webRtcHarnessWindow().__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
((window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
||||||
|
|
||||||
for (let idx = 0; idx < connections.length; idx++) {
|
for (let idx = 0; idx < connections.length; idx++) {
|
||||||
let stats: RTCStatsReport;
|
let stats: RTCStatsReport;
|
||||||
@@ -596,7 +610,7 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis
|
|||||||
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
async () => {
|
async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return false;
|
return false;
|
||||||
@@ -705,7 +719,7 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||||
}> {
|
}> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return { outbound: null, inbound: null };
|
return { outbound: null, inbound: null };
|
||||||
@@ -719,8 +733,8 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
hasInbound: boolean;
|
hasInbound: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hwm: Record<number, VHWM> = webRtcHarnessWindow().__rtcVideoStatsHWM =
|
const hwm: Record<number, VHWM> = (window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM =
|
||||||
(webRtcHarnessWindow().__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
((window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
||||||
|
|
||||||
for (let idx = 0; idx < connections.length; idx++) {
|
for (let idx = 0; idx < connections.length; idx++) {
|
||||||
let stats: RTCStatsReport;
|
let stats: RTCStatsReport;
|
||||||
@@ -804,7 +818,7 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
async () => {
|
async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return false;
|
return false;
|
||||||
@@ -972,7 +986,7 @@ export async function waitForInboundVideoFlow(
|
|||||||
*/
|
*/
|
||||||
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const conns = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const conns = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!conns?.length)
|
if (!conns?.length)
|
||||||
return 'No connections tracked';
|
return 'No connections tracked';
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ test.describe('Multi-device session', () => {
|
|||||||
expect(instanceA).not.toEqual(instanceB);
|
expect(instanceA).not.toEqual(instanceB);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await test.step('shows one self identity in the members panel on each device', async () => {
|
||||||
|
for (const client of [scenario.clientA, scenario.clientB]) {
|
||||||
|
await expect(
|
||||||
|
membersSidePanel(client.page).getByText(scenario.credentials.displayName, { exact: true })
|
||||||
|
).toHaveCount(1, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await test.step('syncs chat from device A to device B', async () => {
|
await test.step('syncs chat from device A to device B', async () => {
|
||||||
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
||||||
});
|
});
|
||||||
|
|||||||
88
e2e/tests/auth/offline-signal-server-no-login-loop.spec.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,13 +6,15 @@ import sharp from 'sharp';
|
|||||||
|
|
||||||
import { test, expect } from '../../fixtures/base';
|
import { test, expect } from '../../fixtures/base';
|
||||||
import {
|
import {
|
||||||
|
ADAPTIVE_FOREGROUND_ICON_RATIO,
|
||||||
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
||||||
findMissingLauncherResources,
|
findMissingLauncherResources,
|
||||||
findStockCapacitorResources,
|
findStockCapacitorResources,
|
||||||
isBrandLauncherBackgroundColor,
|
isBrandLauncherBackgroundColor,
|
||||||
readAdaptiveIconBackgroundColor,
|
readAdaptiveIconBackgroundColor,
|
||||||
REQUIRED_LAUNCHER_ICON_FILES,
|
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';
|
} 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(corner, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||||
expect(colorDistance(center, WHITE)).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
25
e2e/tests/mobile/mobile-settings-logout.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
dumpRtcDiagnostics,
|
dumpRtcDiagnostics,
|
||||||
getConnectedPeerCount,
|
getConnectedPeerCount,
|
||||||
installWebRTCTracking,
|
installWebRTCTracking,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
waitForAllPeerAudioFlow,
|
waitForAllPeerAudioFlow,
|
||||||
waitForAudioStatsPresent,
|
waitForAudioStatsPresent,
|
||||||
waitForConnectedPeerCount,
|
|
||||||
waitForPeerConnected
|
waitForPeerConnected
|
||||||
} from '../../helpers/webrtc-helpers';
|
} from '../../helpers/webrtc-helpers';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||||
|
import { getMinimumConnectedPeerMeshCount, waitForConnectedRemotePeerMesh } from '../../helpers/signal-manager';
|
||||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
|
|
||||||
// ── Signal endpoint identifiers ──────────────────────────────────────
|
// ── Signal endpoint identifiers ──────────────────────────────────────
|
||||||
@@ -132,7 +134,8 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
await installTestServerEndpoints(client.context, groupEndpoints);
|
await installTestServerEndpoints(client.context, groupEndpoints);
|
||||||
await installDeterministicVoiceSettings(client.page);
|
await installDeterministicVoiceSettings(client.page);
|
||||||
await installWebRTCTracking(client.page);
|
await installWebRTCTracking(client.context);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
clients.push({ ...client, user });
|
clients.push({ ...client, user });
|
||||||
}
|
}
|
||||||
@@ -300,8 +303,11 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||||
|
await client.page.waitForTimeout(2_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clients[0].page.waitForTimeout(10_000);
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
}
|
}
|
||||||
@@ -310,11 +316,11 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
// ── Audio mesh ──────────────────────────────────────────────
|
// ── Audio mesh ──────────────────────────────────────────────
|
||||||
await test.step('All users discover peers and audio flows pairwise', async () => {
|
await test.step('All users discover peers and audio flows pairwise', async () => {
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForPeerConnected(client.page, 45_000)
|
waitForPeerConnected(client.page, 90_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
@@ -324,7 +330,7 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
await clients[0].page.waitForTimeout(5_000);
|
await clients[0].page.waitForTimeout(5_000);
|
||||||
|
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,7 +341,6 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
await openVoiceWorkspace(client.page);
|
await openVoiceWorkspace(client.page);
|
||||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -372,18 +377,28 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
for (const client of stayers) {
|
for (const client of stayers) {
|
||||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
await expect.poll(async () => {
|
||||||
|
const actual = await getConnectedPeerCount(client.page);
|
||||||
|
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
|
||||||
|
|
||||||
|
return actual >= minimum;
|
||||||
|
}, {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
intervals: [500, 1_000]
|
intervals: [500, 1_000]
|
||||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
}).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check chatters still have voice peers even while viewing another room
|
// Check chatters still have voice peers even while viewing another room
|
||||||
for (const chatter of chatters) {
|
for (const chatter of chatters) {
|
||||||
await expect.poll(async () => await getConnectedPeerCount(chatter.page), {
|
await expect.poll(async () => {
|
||||||
|
const actual = await getConnectedPeerCount(chatter.page);
|
||||||
|
const minimum = await getMinimumConnectedPeerMeshCount(chatter.page, EXPECTED_REMOTE_PEERS);
|
||||||
|
|
||||||
|
return actual >= minimum;
|
||||||
|
}, {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
intervals: [500, 1_000]
|
intervals: [500, 1_000]
|
||||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
}).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Date.now() < deadline) {
|
if (Date.now() < deadline) {
|
||||||
@@ -749,63 +764,6 @@ async function waitForLocalVoiceChannelConnection(page: Page, channelName: strin
|
|||||||
|
|
||||||
// ── Roster / state helpers ───────────────────────────────────────────
|
// ── Roster / state helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
(count) => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-voice-workspace');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
|
|
||||||
|
|
||||||
return connectedUsers.length === count;
|
|
||||||
},
|
|
||||||
expectedCount,
|
|
||||||
{ timeout: 45_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ expected, name }) => {
|
|
||||||
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
|
|
||||||
interface RoomShape { channels?: ChannelShape[] }
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
|
||||||
const channelId = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name)?.id;
|
|
||||||
|
|
||||||
if (!channelId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
|
|
||||||
|
|
||||||
return roster.length === expected;
|
|
||||||
},
|
|
||||||
{ expected: expectedCount, name: channelName },
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceStateAcrossPages(
|
async function waitForVoiceStateAcrossPages(
|
||||||
clients: readonly TestClient[],
|
clients: readonly TestClient[],
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
|||||||
@@ -6,14 +6,21 @@ import {
|
|||||||
dumpRtcDiagnostics,
|
dumpRtcDiagnostics,
|
||||||
getConnectedPeerCount,
|
getConnectedPeerCount,
|
||||||
installWebRTCTracking,
|
installWebRTCTracking,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
waitForAllPeerAudioFlow,
|
waitForAllPeerAudioFlow,
|
||||||
waitForAudioStatsPresent,
|
waitForAudioStatsPresent,
|
||||||
waitForConnectedPeerCount,
|
|
||||||
waitForPeerConnected
|
waitForPeerConnected
|
||||||
} from '../../helpers/webrtc-helpers';
|
} from '../../helpers/webrtc-helpers';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||||
|
import {
|
||||||
|
getConnectedSignalManagerCount,
|
||||||
|
getMinimumConnectedPeerMeshCount,
|
||||||
|
waitForConnectedRemotePeerMesh,
|
||||||
|
waitForConnectedSignalManagerCount
|
||||||
|
} from '../../helpers/signal-manager';
|
||||||
|
|
||||||
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
|
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
|
||||||
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
||||||
@@ -116,8 +123,11 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||||
|
await client.page.waitForTimeout(2_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clients[0].page.waitForTimeout(10_000);
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
}
|
}
|
||||||
@@ -126,12 +136,12 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
await test.step('All users discover all peers and audio flows pairwise', async () => {
|
await test.step('All users discover all peers and audio flows pairwise', async () => {
|
||||||
// Wait for all clients to have at least one connected peer (fast)
|
// Wait for all clients to have at least one connected peer (fast)
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForPeerConnected(client.page, 45_000)
|
waitForPeerConnected(client.page, 90_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Wait for all clients to have all 7 peers connected
|
// Wait for all clients to have all 7 peers connected
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Wait for audio stats to appear on all clients
|
// Wait for audio stats to appear on all clients
|
||||||
@@ -146,7 +156,7 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
// Check bidirectional audio flow on each client
|
// Check bidirectional audio flow on each client
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +166,6 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
await openVoiceWorkspace(client.page);
|
await openVoiceWorkspace(client.page);
|
||||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
await waitForConnectedSignalManagerCount(client.page, 2);
|
await waitForConnectedSignalManagerCount(client.page, 2);
|
||||||
}
|
}
|
||||||
@@ -167,10 +176,15 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
await expect.poll(async () => {
|
||||||
|
const actual = await getConnectedPeerCount(client.page);
|
||||||
|
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
|
||||||
|
|
||||||
|
return actual >= minimum;
|
||||||
|
}, {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
intervals: [500, 1_000]
|
intervals: [500, 1_000]
|
||||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
}).toBe(true);
|
||||||
|
|
||||||
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
@@ -292,7 +306,8 @@ async function createTrackedClients(
|
|||||||
|
|
||||||
await installTestServerEndpoints(client.context, endpoints);
|
await installTestServerEndpoints(client.context, endpoints);
|
||||||
await installDeterministicVoiceSettings(client.page);
|
await installDeterministicVoiceSettings(client.page);
|
||||||
await installWebRTCTracking(client.page);
|
await installWebRTCTracking(client.context);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
clients.push({
|
clients.push({
|
||||||
...client,
|
...client,
|
||||||
@@ -576,124 +591,6 @@ async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise
|
|||||||
}, channelName);
|
}, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForConnectedSignalManagerCount(page: Page, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
(count) => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const realtime = component['realtime'] as {
|
|
||||||
signalingTransportHandler?: {
|
|
||||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
|
||||||
};
|
|
||||||
} | undefined;
|
|
||||||
const countValue = realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
|
||||||
|
|
||||||
return countValue === count;
|
|
||||||
},
|
|
||||||
expectedCount,
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getConnectedSignalManagerCount(page: Page): Promise<number> {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const realtime = component['realtime'] as {
|
|
||||||
signalingTransportHandler?: {
|
|
||||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
|
||||||
};
|
|
||||||
} | undefined;
|
|
||||||
|
|
||||||
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
(count) => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-voice-workspace');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
|
|
||||||
|
|
||||||
return connectedUsers.length === count;
|
|
||||||
},
|
|
||||||
expectedCount,
|
|
||||||
{ timeout: 45_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ expected, name }) => {
|
|
||||||
interface ChannelShape {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: 'text' | 'voice';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoomShape {
|
|
||||||
channels?: ChannelShape[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
|
||||||
const channelId = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name)?.id;
|
|
||||||
|
|
||||||
if (!channelId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
|
|
||||||
|
|
||||||
return roster.length === expected;
|
|
||||||
},
|
|
||||||
{ expected: expectedCount, name: channelName },
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceStateAcrossPages(
|
async function waitForVoiceStateAcrossPages(
|
||||||
clients: readonly TestClient[],
|
clients: readonly TestClient[],
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
|||||||
127
e2e/tests/voice/voice-mute-state-reset.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,10 @@ export interface AppMetricsProcessSnapshot {
|
|||||||
pid: number;
|
pid: number;
|
||||||
type: string;
|
type: string;
|
||||||
workingSetKb: number | null;
|
workingSetKb: number | null;
|
||||||
|
peakWorkingSetKb: number | null;
|
||||||
|
privateBytesKb: number | null;
|
||||||
|
creationTime: number | null;
|
||||||
|
cpuPercent: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppMetricsSnapshot {
|
export interface AppMetricsSnapshot {
|
||||||
@@ -17,7 +21,17 @@ export function collectAppMetricsSnapshot(): AppMetricsSnapshot {
|
|||||||
processes: app.getAppMetrics().map((metric) => ({
|
processes: app.getAppMetrics().map((metric) => ({
|
||||||
pid: metric.pid,
|
pid: metric.pid,
|
||||||
type: metric.type,
|
type: metric.type,
|
||||||
workingSetKb: metric.memory?.workingSetSize ?? null
|
workingSetKb: metric.memory?.workingSetSize ?? null,
|
||||||
|
peakWorkingSetKb: readOptionalKilobytes(metric.memory?.peakWorkingSetSize),
|
||||||
|
privateBytesKb: readOptionalKilobytes(metric.memory?.privateBytes),
|
||||||
|
creationTime: metric.creationTime ?? null,
|
||||||
|
cpuPercent: typeof metric.cpu?.percentCPUUsage === 'number'
|
||||||
|
? Math.round(metric.cpu.percentCPUUsage * 10) / 10
|
||||||
|
: null
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalKilobytes(value: number | undefined): number | null {
|
||||||
|
return typeof value === 'number' && value >= 0 ? value : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { isPerfDiagEnabled } from './diagnostics.flags';
|
|||||||
describe('isPerfDiagEnabled', () => {
|
describe('isPerfDiagEnabled', () => {
|
||||||
it('returns false when the flag is unset', () => {
|
it('returns false when the flag is unset', () => {
|
||||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||||
expect(isPerfDiagEnabled({}, true)).toBe(false);
|
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
||||||
@@ -17,11 +17,12 @@ describe('isPerfDiagEnabled', () => {
|
|||||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false in packaged builds unless force is set', () => {
|
it('returns true in packaged Electron builds without env flags', () => {
|
||||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
|
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||||
expect(isPerfDiagEnabled({
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '0' }, true)).toBe(true);
|
||||||
METOYOU_PERF_DIAG: '1',
|
});
|
||||||
METOYOU_PERF_DIAG_FORCE: '1'
|
|
||||||
}, true)).toBe(true);
|
it('returns false in development when the flag is unset', () => {
|
||||||
|
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,13 +17,9 @@ export function isPerfDiagEnabled(
|
|||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
isPackaged: boolean
|
isPackaged: boolean
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
|
if (isPackaged) {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
|
return isTruthyFlag(env[PERF_DIAG_ENV]);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
ipcMain
|
ipcMain,
|
||||||
|
shell
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import { collectAppMetricsSnapshot } from '../app-metrics';
|
import { collectAppMetricsSnapshot, type AppMetricsSnapshot } from '../app-metrics';
|
||||||
|
import { getMainWindow } from '../window/create-window';
|
||||||
|
import { resolveReadablePath } from '../path-jail';
|
||||||
import { sumWorkingSetKb } from './process-metrics.rules';
|
import { sumWorkingSetKb } from './process-metrics.rules';
|
||||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||||
|
import { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
|
||||||
|
import { 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 type { PerfDiagEntry } from './diagnostics.models';
|
||||||
import { PerfDiagWriter } from './diagnostics.writer';
|
import { PerfDiagWriter } from './diagnostics.writer';
|
||||||
|
|
||||||
@@ -15,6 +27,8 @@ let activeWriter: PerfDiagWriter | null = null;
|
|||||||
let processPollTimer: NodeJS.Timeout | null = null;
|
let processPollTimer: NodeJS.Timeout | null = null;
|
||||||
let diagnosticsEnabled = false;
|
let diagnosticsEnabled = false;
|
||||||
let ipcRegistered = false;
|
let ipcRegistered = false;
|
||||||
|
let highMemoryAlertTriggeredThisSession = false;
|
||||||
|
let sessionStartedAt = 0;
|
||||||
|
|
||||||
export function isPerfDiagActive(): boolean {
|
export function isPerfDiagActive(): boolean {
|
||||||
return diagnosticsEnabled;
|
return diagnosticsEnabled;
|
||||||
@@ -43,6 +57,37 @@ export function ensurePerfDiagIpcRegistered(): void {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-pending-high-memory-alert', async () => {
|
||||||
|
return readHighMemoryAlert(app.getPath('userData'));
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('acknowledge-high-memory-alert', async () => {
|
||||||
|
await clearHighMemoryAlert(app.getPath('userData'));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('show-log-file-in-folder', async (_event, filePath: string) => {
|
||||||
|
if (typeof filePath !== 'string' || !filePath.trim()) {
|
||||||
|
return {
|
||||||
|
shown: false,
|
||||||
|
reason: 'missing-path'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedPath = await resolveReadablePath(filePath);
|
||||||
|
|
||||||
|
if (!scopedPath) {
|
||||||
|
return {
|
||||||
|
shown: false,
|
||||||
|
reason: 'outside-app-data'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.showItemInFolder(scopedPath);
|
||||||
|
|
||||||
|
return { shown: true };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
||||||
@@ -64,9 +109,13 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
|
|||||||
});
|
});
|
||||||
|
|
||||||
activeWriter = writer;
|
activeWriter = writer;
|
||||||
|
highMemoryAlertTriggeredThisSession = false;
|
||||||
|
sessionStartedAt = Date.now();
|
||||||
registerProcessCrashHandlers(writer);
|
registerProcessCrashHandlers(writer);
|
||||||
startProcessMetricsPolling(writer);
|
startProcessMetricsPolling(writer);
|
||||||
|
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
|
||||||
writer.append({
|
writer.append({
|
||||||
collectedAt: Date.now(),
|
collectedAt: Date.now(),
|
||||||
source: 'main',
|
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;
|
return writer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +256,8 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
|||||||
processes: metrics.processes
|
processes: metrics.processes
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void maybeTriggerHighMemoryAlert(writer, metrics, totalKb);
|
||||||
} catch {
|
} catch {
|
||||||
// Collector failures must never affect the app.
|
// Collector failures must never affect the app.
|
||||||
}
|
}
|
||||||
@@ -204,6 +267,64 @@ function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
|||||||
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
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 {
|
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
||||||
return {
|
return {
|
||||||
collectedAt: Number(entry.collectedAt) || Date.now(),
|
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
|
|||||||
|
|
||||||
export type PerfDiagEntryType =
|
export type PerfDiagEntryType =
|
||||||
| 'session'
|
| 'session'
|
||||||
|
| 'environment'
|
||||||
| 'process'
|
| 'process'
|
||||||
| 'store'
|
| 'store'
|
||||||
| 'components'
|
| 'components'
|
||||||
| 'heap'
|
| 'heap'
|
||||||
|
| 'high-memory'
|
||||||
| 'crash'
|
| 'crash'
|
||||||
| 'unresponsive';
|
| 'unresponsive';
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
resolveDiagnosticsFilePath
|
resolveDiagnosticsFilePath
|
||||||
} from './diagnostics.rules';
|
} from './diagnostics.rules';
|
||||||
|
|
||||||
const DEFAULT_RING_CAPACITY = 120;
|
const DEFAULT_RING_CAPACITY = 300;
|
||||||
const FLUSH_DEBOUNCE_MS = 250;
|
const FLUSH_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
export interface PerfDiagWriterOptions {
|
export interface PerfDiagWriterOptions {
|
||||||
@@ -18,6 +18,7 @@ export interface PerfDiagWriterOptions {
|
|||||||
|
|
||||||
export class PerfDiagWriter {
|
export class PerfDiagWriter {
|
||||||
private readonly filePath: string;
|
private readonly filePath: string;
|
||||||
|
private readonly sessionIdValue: string;
|
||||||
private readonly ringCapacity: number;
|
private readonly ringCapacity: number;
|
||||||
private readonly pendingLines: string[] = [];
|
private readonly pendingLines: string[] = [];
|
||||||
private ring: PerfDiagEntry[] = [];
|
private ring: PerfDiagEntry[] = [];
|
||||||
@@ -26,10 +27,15 @@ export class PerfDiagWriter {
|
|||||||
private disabled = false;
|
private disabled = false;
|
||||||
|
|
||||||
constructor(options: PerfDiagWriterOptions) {
|
constructor(options: PerfDiagWriterOptions) {
|
||||||
|
this.sessionIdValue = options.sessionId;
|
||||||
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
||||||
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get sessionId(): string {
|
||||||
|
return this.sessionIdValue;
|
||||||
|
}
|
||||||
|
|
||||||
get snapshotFilePath(): string {
|
get snapshotFilePath(): string {
|
||||||
return this.filePath;
|
return this.filePath;
|
||||||
}
|
}
|
||||||
|
|||||||
27
electron/diagnostics/high-memory-alert.rules.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
11
electron/diagnostics/high-memory-alert.rules.ts
Normal 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);
|
||||||
|
}
|
||||||
64
electron/diagnostics/high-memory-alert.store.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
57
electron/diagnostics/high-memory-alert.store.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
201
electron/diagnostics/high-memory-snapshot.rules.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
179
electron/diagnostics/high-memory-snapshot.rules.ts
Normal 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;
|
||||||
|
}
|
||||||
39
electron/diagnostics/immediate-renderer-samples.collector.ts
Normal 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 ?? {}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,16 @@
|
|||||||
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
||||||
|
export {
|
||||||
|
clearHighMemoryAlert,
|
||||||
|
readHighMemoryAlert,
|
||||||
|
resolveHighMemoryAlertPath,
|
||||||
|
writeHighMemoryAlert
|
||||||
|
} from './high-memory-alert.store';
|
||||||
|
export type { HighMemoryAlertRecord } from './high-memory-alert.store';
|
||||||
|
export {
|
||||||
|
exceedsHighMemoryThreshold,
|
||||||
|
formatWorkingSetGb,
|
||||||
|
HIGH_MEMORY_THRESHOLD_KB
|
||||||
|
} from './high-memory-alert.rules';
|
||||||
export {
|
export {
|
||||||
attachRendererDiagnosticsHooks,
|
attachRendererDiagnosticsHooks,
|
||||||
ensurePerfDiagIpcRegistered,
|
ensurePerfDiagIpcRegistered,
|
||||||
|
|||||||
91
electron/diagnostics/session-context.collector.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -259,6 +259,14 @@ export interface ElectronAPI {
|
|||||||
type: string;
|
type: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
|
getPendingHighMemoryAlert: () => Promise<{
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
} | null>;
|
||||||
|
acknowledgeHighMemoryAlert: () => Promise<boolean>;
|
||||||
|
showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openCurrentDataFolder: () => Promise<boolean>;
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
exportUserData: () => Promise<ExportUserDataResult>;
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
@@ -400,6 +408,9 @@ const electronAPI: ElectronAPI = {
|
|||||||
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
||||||
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
||||||
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
||||||
|
getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'),
|
||||||
|
acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-high-memory-alert'),
|
||||||
|
showLogFileInFolder: (filePath) => ipcRenderer.invoke('show-log-file-in-folder', filePath),
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||||
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
||||||
|
|||||||
105
server/src/websocket/handler-voice-disconnect.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
|
||||||
user.ws.send(JSON.stringify({
|
user.ws.send(JSON.stringify({
|
||||||
type: 'voice_state',
|
type: 'voice_state',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
getServerIdsForOderId,
|
getServerIdsForOderId,
|
||||||
isOderIdConnectedToServer
|
isOderIdConnectedToServer
|
||||||
} from './broadcast';
|
} from './broadcast';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage, finalizeVoiceDisconnectForConnection } from './handler';
|
||||||
|
|
||||||
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
|
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ function removeDeadConnection(connectionId: string): void {
|
|||||||
if (user) {
|
if (user) {
|
||||||
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||||
|
|
||||||
|
finalizeVoiceDisconnectForConnection(connectionId);
|
||||||
|
|
||||||
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||||
|
|
||||||
user.serverIds.forEach((sid) => {
|
user.serverIds.forEach((sid) => {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 12 KiB |
@@ -2,21 +2,31 @@
|
|||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
<item name="colorAccent">@color/colorAccent</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>
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</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>
|
||||||
|
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
<item name="android:background">@drawable/splash</item>
|
<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>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
||||||
"updateSettings": "Update settings",
|
"updateSettings": "Update settings",
|
||||||
"restartNow": "Restart now"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,12 +262,14 @@
|
|||||||
"localData": {
|
"localData": {
|
||||||
"title": "Local data",
|
"title": "Local data",
|
||||||
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
"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"
|
"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": {
|
"currentFolder": {
|
||||||
"title": "Current data folder",
|
"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",
|
"openFolder": "Open folder",
|
||||||
"opening": "Opening...",
|
"opening": "Opening...",
|
||||||
@@ -287,6 +289,7 @@
|
|||||||
"erase": {
|
"erase": {
|
||||||
"title": "Erase user data",
|
"title": "Erase user data",
|
||||||
"description": "Remove local app data from this device and recreate an empty database.",
|
"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",
|
"button": "Erase user data",
|
||||||
"erasing": "Erasing...",
|
"erasing": "Erasing...",
|
||||||
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
"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}}.",
|
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
||||||
"imported": "Imported data.",
|
"imported": "Imported data.",
|
||||||
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
"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."
|
"operationFailed": "Data operation failed."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
||||||
"updateSettings": "Update settings",
|
"updateSettings": "Update settings",
|
||||||
"restartNow": "Restart now"
|
"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": {
|
"attachment": {
|
||||||
@@ -1317,12 +1327,14 @@
|
|||||||
"localData": {
|
"localData": {
|
||||||
"title": "Local data",
|
"title": "Local data",
|
||||||
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
"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"
|
"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": {
|
"currentFolder": {
|
||||||
"title": "Current data folder",
|
"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",
|
"openFolder": "Open folder",
|
||||||
"opening": "Opening...",
|
"opening": "Opening...",
|
||||||
@@ -1342,6 +1354,7 @@
|
|||||||
"erase": {
|
"erase": {
|
||||||
"title": "Erase user data",
|
"title": "Erase user data",
|
||||||
"description": "Remove local app data from this device and recreate an empty database.",
|
"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",
|
"button": "Erase user data",
|
||||||
"erasing": "Erasing...",
|
"erasing": "Erasing...",
|
||||||
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
"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}}.",
|
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
||||||
"imported": "Imported data.",
|
"imported": "Imported data.",
|
||||||
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
"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."
|
"operationFailed": "Data operation failed."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<div
|
<div
|
||||||
appThemeNode="appRoot"
|
appThemeNode="appRoot"
|
||||||
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
|
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
|
||||||
|
[class.metoyou-safe-area-shell]="isMobile()"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="h-full min-h-0 min-w-0 overflow-hidden"
|
class="h-full min-h-0 min-w-0 overflow-hidden"
|
||||||
@@ -166,6 +167,7 @@
|
|||||||
<app-incoming-call-modal />
|
<app-incoming-call-modal />
|
||||||
<app-screen-share-source-picker />
|
<app-screen-share-source-picker />
|
||||||
<app-native-context-menu />
|
<app-native-context-menu />
|
||||||
|
<app-high-memory-alert-modal />
|
||||||
<app-debug-console [showLauncher]="false" />
|
<app-debug-console [showLauncher]="false" />
|
||||||
<app-theme-picker-overlay />
|
<app-theme-picker-overlay />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
loadLastViewedChatFromStorage
|
loadLastViewedChatFromStorage
|
||||||
} from './infrastructure/persistence';
|
} from './infrastructure/persistence';
|
||||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
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 { ServerDirectoryFacade } from './domains/server-directory';
|
||||||
import { NotificationsFacade } from './domains/notifications';
|
import { NotificationsFacade } from './domains/notifications';
|
||||||
import { TimeSyncService } from './core/services/time-sync.service';
|
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 { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||||
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.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 { UsersActions } from './store/users/users.actions';
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
@@ -81,6 +83,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from './core/i18n';
|
|||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareSourcePickerComponent,
|
ScreenShareSourcePickerComponent,
|
||||||
NativeContextMenuComponent,
|
NativeContextMenuComponent,
|
||||||
|
HighMemoryAlertModalComponent,
|
||||||
PrivateCallComponent,
|
PrivateCallComponent,
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
ThemePickerOverlayComponent,
|
ThemePickerOverlayComponent,
|
||||||
@@ -103,6 +106,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
desktopUpdates = inject(DesktopAppUpdateService);
|
desktopUpdates = inject(DesktopAppUpdateService);
|
||||||
desktopUpdateState = this.desktopUpdates.state;
|
desktopUpdateState = this.desktopUpdates.state;
|
||||||
|
desktopHighMemoryAlert = inject(DesktopHighMemoryAlertService);
|
||||||
readonly databaseService = inject(DatabaseService);
|
readonly databaseService = inject(DatabaseService);
|
||||||
readonly router = inject(Router);
|
readonly router = inject(Router);
|
||||||
readonly servers = inject(ServerDirectoryFacade);
|
readonly servers = inject(ServerDirectoryFacade);
|
||||||
@@ -288,6 +292,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
// - desktop deep-link bridge (only relevant after first paint)
|
// - desktop deep-link bridge (only relevant after first paint)
|
||||||
// - background presence + game activity loops
|
// - background presence + game activity loops
|
||||||
void this.desktopUpdates.initialize();
|
void this.desktopUpdates.initialize();
|
||||||
|
void this.desktopHighMemoryAlert.initialize();
|
||||||
void this.kickOffBackgroundBootstrap();
|
void this.kickOffBackgroundBootstrap();
|
||||||
|
|
||||||
// The only thing we genuinely must await before deciding which route
|
// The only thing we genuinely must await before deciding which route
|
||||||
|
|||||||
@@ -251,6 +251,13 @@ export interface ElectronPerfDiagEntry {
|
|||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ElectronHighMemoryAlertRecord {
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronApi {
|
export interface ElectronApi {
|
||||||
linuxDisplayServer: string;
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
@@ -272,6 +279,9 @@ export interface ElectronApi {
|
|||||||
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
||||||
isPerfDiagEnabled?: () => Promise<boolean>;
|
isPerfDiagEnabled?: () => Promise<boolean>;
|
||||||
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => 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>;
|
getAppDataPath: () => Promise<string>;
|
||||||
openCurrentDataFolder: () => Promise<boolean>;
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
exportUserData: () => Promise<ExportUserDataResult>;
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
formatAppRamLabel,
|
formatAppRamLabel,
|
||||||
|
formatKilobytesAsGigabytes,
|
||||||
formatKilobytesAsMegabytes,
|
formatKilobytesAsMegabytes,
|
||||||
sumWorkingSetKb
|
sumWorkingSetKb
|
||||||
} from './electron-app-metrics.rules';
|
} 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', () => {
|
describe('formatKilobytesAsMegabytes', () => {
|
||||||
it('rounds large values to whole megabytes', () => {
|
it('rounds large values to whole megabytes', () => {
|
||||||
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');
|
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export function formatKilobytesAsMegabytes(kilobytes: number): string {
|
|||||||
return `${megabytes.toFixed(2)} MB`;
|
return `${megabytes.toFixed(2)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatKilobytesAsGigabytes(kilobytes: number): string {
|
||||||
|
return (kilobytes / (1024 * 1024)).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
|
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
|
||||||
const totalKb = sumWorkingSetKb(snapshot.processes);
|
const totalKb = sumWorkingSetKb(snapshot.processes);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,4 +43,13 @@ describe('AuthTokenStoreService', () => {
|
|||||||
|
|
||||||
expect(service.getToken('http://localhost:3001')).toBeNull();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export class AuthTokenStoreService {
|
|||||||
this.writeStore(nextStore);
|
this.writeStore(nextStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearAllTokens(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
hasAnyValidToken(): boolean {
|
hasAnyValidToken(): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import { AUTH_MODE_AUTHORIZE, buildLoginReturnQueryParams } from '../../domain/logic/auth-navigation.rules';
|
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';
|
import { SignalServerAuthService } from './signal-server-auth.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -25,25 +27,43 @@ export class SignalServerAuthorizeService {
|
|||||||
return false;
|
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') {
|
if (result.kind === 'existing' || result.kind === 'provisioned') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.kind === 'collision') {
|
const endpointStatus = await this.resolveEndpointStatusForAuthorize(serverUrl);
|
||||||
await this.navigateToAuthorize(serverUrl, this.router.url);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.kind === 'skipped' && result.reason === 'no-provision-secret') {
|
if (shouldNavigateToAuthorizeSignalServer(endpointStatus, result)) {
|
||||||
await this.navigateToAuthorize(serverUrl, this.router.url);
|
await this.navigateToAuthorize(serverUrl, this.router.url);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
async navigateToAuthorize(serverUrl: string, returnUrl: string): Promise<void> {
|
||||||
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
||||||
name: this.buildEndpointName(serverUrl),
|
name: this.buildEndpointName(serverUrl),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './application/services/authentication.service';
|
export * from './application/services/authentication.service';
|
||||||
export * from './application/services/auth-token-store.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-auth.service';
|
||||||
export * from './application/services/signal-server-authorize.service';
|
export * from './application/services/signal-server-authorize.service';
|
||||||
export * from './application/services/signal-server-credential-store.service';
|
export * from './application/services/signal-server-credential-store.service';
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type RoomSignalSource,
|
type RoomSignalSource,
|
||||||
type RoomSignalSourceInput
|
type RoomSignalSourceInput
|
||||||
} from '../../domain/logic/room-signal-source.logic';
|
} 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 { ServerEndpointCompatibilityService } from '../../infrastructure/services/server-endpoint-compatibility.service';
|
||||||
import { ServerEndpointHealthService } from '../../infrastructure/services/server-endpoint-health.service';
|
import { ServerEndpointHealthService } from '../../infrastructure/services/server-endpoint-health.service';
|
||||||
import { ServerEndpointStateService } from './server-endpoint-state.service';
|
import { ServerEndpointStateService } from './server-endpoint-state.service';
|
||||||
@@ -110,7 +111,7 @@ export class ServerDirectoryService {
|
|||||||
|
|
||||||
const clientVersion = await this.endpointCompatibility.getClientVersion();
|
const clientVersion = await this.endpointCompatibility.getClientVersion();
|
||||||
|
|
||||||
if (!clientVersion) {
|
if (!clientVersion && isEndpointOnlineForConnection(endpoint.status)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ export class ServerDirectoryService {
|
|||||||
|
|
||||||
const refreshedEndpoint = this.servers().find((candidate) => candidate.id === endpoint.id);
|
const refreshedEndpoint = this.servers().find((candidate) => candidate.id === endpoint.id);
|
||||||
|
|
||||||
return !!refreshedEndpoint && refreshedEndpoint.status !== 'incompatible';
|
return isEndpointOnlineForConnection(refreshedEndpoint?.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveRoomEndpoint(
|
resolveRoomEndpoint(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { VoiceState } from '../../../../shared-kernel';
|
|||||||
import {
|
import {
|
||||||
isLocalVoiceOwner,
|
isLocalVoiceOwner,
|
||||||
isVoiceOnAnotherClient,
|
isVoiceOnAnotherClient,
|
||||||
|
shouldApplyRemoteVoiceStateToCurrentUser,
|
||||||
shouldTransmitVoice
|
shouldTransmitVoice
|
||||||
} from './client-voice-session.rules';
|
} from './client-voice-session.rules';
|
||||||
|
|
||||||
@@ -51,4 +52,54 @@ describe('client-voice-session.rules', () => {
|
|||||||
|
|
||||||
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,3 +32,29 @@ export function shouldTransmitVoice(
|
|||||||
|
|
||||||
return voiceState.clientInstanceId === clientInstanceId;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -10,7 +10,11 @@
|
|||||||
<h4 class="text-base font-semibold text-foreground">{{ 'settings.data.localData.title' | translate }}</h4>
|
<h4 class="text-base font-semibold text-foreground">{{ 'settings.data.localData.title' | translate }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,17 +29,17 @@
|
|||||||
name="lucideRefreshCw"
|
name="lucideRefreshCw"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
Restart app
|
{{ 'settings.data.localData.restartApp' | translate }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (!isElectron) {
|
@if (!supportsLocalDataManagement) {
|
||||||
<section class="rounded-lg border border-border bg-secondary/30 p-5">
|
<section class="rounded-lg border border-border bg-secondary/30 p-5">
|
||||||
<p class="text-sm text-muted-foreground">{{ 'settings.data.desktopOnly' | translate }}</p>
|
<p class="text-sm text-muted-foreground">{{ 'settings.data.desktopOnly' | translate }}</p>
|
||||||
</section>
|
</section>
|
||||||
} @else {
|
} @else if (supportsDesktopDataFolderActions) {
|
||||||
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||||
<div>
|
<div>
|
||||||
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.currentFolder.title' | translate }}</h5>
|
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.currentFolder.title' | translate }}</h5>
|
||||||
@@ -108,6 +112,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="data-settings-erase-button"
|
||||||
(click)="eraseData()"
|
(click)="eraseData()"
|
||||||
[disabled]="busyAction() !== null"
|
[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"
|
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) }}
|
{{ busyAction() === 'erase' ? ('settings.data.erase.erasing' | translate) : ('settings.data.erase.button' | translate) }}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</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="space-y-4 rounded-lg border border-destructive/30 bg-destructive/10 p-5">
|
||||||
<section class="rounded-lg border border-primary/30 bg-primary/10 p-4">
|
<div>
|
||||||
<p class="break-words text-sm text-foreground">{{ statusMessage() }}</p>
|
<h5 class="text-sm font-semibold text-foreground">{{ 'settings.data.erase.title' | translate }}</h5>
|
||||||
</section>
|
<p class="mt-1 text-sm text-muted-foreground">{{ 'settings.data.erase.descriptionMobile' | translate }}</p>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
@if (errorMessage()) {
|
<button
|
||||||
<section class="rounded-lg border border-destructive/30 bg-destructive/10 p-4">
|
type="button"
|
||||||
<p class="break-words text-sm text-foreground">{{ errorMessage() }}</p>
|
data-testid="data-settings-erase-button"
|
||||||
</section>
|
(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>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,16 @@ import {
|
|||||||
lucideUpload
|
lucideUpload
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
|
import { PlatformService } from '../../../../core/platform';
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
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 { 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';
|
type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
|
||||||
|
|
||||||
@@ -42,9 +50,25 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
|
|||||||
})
|
})
|
||||||
export class DataSettingsComponent {
|
export class DataSettingsComponent {
|
||||||
private readonly electron = inject(ElectronBridgeService);
|
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);
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
readonly isElectron = this.electron.isAvailable;
|
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 dataPath = signal<string | null>(null);
|
||||||
readonly busyAction = signal<DataAction | null>(null);
|
readonly busyAction = signal<DataAction | null>(null);
|
||||||
readonly statusMessage = signal<string | null>(null);
|
readonly statusMessage = signal<string | null>(null);
|
||||||
@@ -106,6 +130,14 @@ export class DataSettingsComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.runAction('erase', async () => {
|
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();
|
const result = await this.electron.requireApi().eraseUserData();
|
||||||
|
|
||||||
this.restartRequired.set(result.restartRequired);
|
this.restartRequired.set(result.restartRequired);
|
||||||
@@ -121,14 +153,18 @@ export class DataSettingsComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadDataPath(): Promise<void> {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (this.supportsMobileLocalDataErase) {
|
||||||
this.dataPath.set(await this.electron.requireApi().getAppDataPath());
|
this.dataPath.set(await this.capacitorAttachmentStore.getAppDataPath());
|
||||||
} catch {
|
|
||||||
this.dataPath.set(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@if (isOpen() && !isThemeStudioFullscreen()) {
|
@if (isOpen() && !isThemeStudioFullscreen()) {
|
||||||
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
||||||
<div
|
<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-100]="animating()"
|
||||||
[class.opacity-0]="!animating()"
|
[class.opacity-0]="!animating()"
|
||||||
(click)="onBackdropClick()"
|
(click)="onBackdropClick()"
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Modal: full-screen page on mobile, centered dialog on desktop -->
|
<!-- 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
|
<div
|
||||||
appThemeNode="settingsModalSurface"
|
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"
|
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>
|
||||||
|
|
||||||
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="openThirdPartyLicenses()"
|
(click)="openThirdPartyLicenses()"
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ import {
|
|||||||
lucideTerminal,
|
lucideTerminal,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideBan,
|
lucideBan,
|
||||||
lucideShield
|
lucideShield,
|
||||||
|
lucideLogOut
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||||
import { ViewportService } from '../../../core/platform';
|
import { ViewportService } from '../../../core/platform';
|
||||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
|
import { UserLogoutService } from '../../../domains/authentication/application/services/user-logout.service';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { Room, UserRole } from '../../../shared-kernel';
|
import { Room, UserRole } from '../../../shared-kernel';
|
||||||
@@ -97,7 +99,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
|
|||||||
lucideTerminal,
|
lucideTerminal,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideBan,
|
lucideBan,
|
||||||
lucideShield
|
lucideShield,
|
||||||
|
lucideLogOut
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
templateUrl: './settings-modal.component.html'
|
templateUrl: './settings-modal.component.html'
|
||||||
@@ -106,6 +109,7 @@ export class SettingsModalComponent {
|
|||||||
readonly modal = inject(SettingsModalService);
|
readonly modal = inject(SettingsModalService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private webrtc = inject(RealtimeSessionFacade);
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private userLogout = inject(UserLogoutService);
|
||||||
private theme = inject(ThemeService);
|
private theme = inject(ThemeService);
|
||||||
private themeLibrary = inject(ThemeLibraryService);
|
private themeLibrary = inject(ThemeLibraryService);
|
||||||
private viewport = inject(ViewportService);
|
private viewport = inject(ViewportService);
|
||||||
@@ -413,6 +417,11 @@ export class SettingsModalComponent {
|
|||||||
this.showThirdPartyLicenses.set(false);
|
this.showThirdPartyLicenses.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
this.closeForExternalNavigation();
|
||||||
|
this.userLogout.logout();
|
||||||
|
}
|
||||||
|
|
||||||
navigate(page: SettingsPage): void {
|
navigate(page: SettingsPage): void {
|
||||||
this.modal.navigate(page);
|
this.modal.navigate(page);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,15 +31,13 @@ import {
|
|||||||
selectIsSignalServerReconnecting,
|
selectIsSignalServerReconnecting,
|
||||||
selectSignalServerCompatibilityError
|
selectSignalServerCompatibilityError
|
||||||
} from '../../../store/rooms/rooms.selectors';
|
} from '../../../store/rooms/rooms.selectors';
|
||||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
|
||||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||||
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||||
import { PlatformService } from '../../../core/platform';
|
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 { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
|
||||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||||
import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
|
import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
|
||||||
@@ -94,6 +92,7 @@ export class TitleBarComponent {
|
|||||||
private pluginRegistry = inject(PluginRegistryService);
|
private pluginRegistry = inject(PluginRegistryService);
|
||||||
private pluginRequirements = inject(PluginRequirementStateService);
|
private pluginRequirements = inject(PluginRequirementStateService);
|
||||||
private pluginStore = inject(PluginStoreService);
|
private pluginStore = inject(PluginStoreService);
|
||||||
|
private userLogout = inject(UserLogoutService);
|
||||||
|
|
||||||
private getWindowControlsApi() {
|
private getWindowControlsApi() {
|
||||||
return this.electronBridge.getApi();
|
return this.electronBridge.getApi();
|
||||||
@@ -376,16 +375,7 @@ export class TitleBarComponent {
|
|||||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||||
logout() {
|
logout() {
|
||||||
this._showMenu.set(false);
|
this._showMenu.set(false);
|
||||||
// Disconnect from signaling server - this broadcasts "user_left" to all
|
this.userLogout.logout();
|
||||||
// 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']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import type { ElectronApi } from '../../core/platform/electron/electron-api.mode
|
|||||||
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
|
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
|
||||||
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
|
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;
|
const SAMPLE_INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
let started = false;
|
let started = false;
|
||||||
@@ -36,6 +41,22 @@ export async function bootstrapPerfDiagnostics(
|
|||||||
|
|
||||||
started = true;
|
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 = {
|
const reporter: PerfDiagReporter = {
|
||||||
report: (entry: PerfDiagEntry) => reportSample(entry)
|
report: (entry: PerfDiagEntry) => reportSample(entry)
|
||||||
};
|
};
|
||||||
@@ -92,5 +113,6 @@ function stopPerfDiagnosticsSampling(): void {
|
|||||||
sampleTimer = null;
|
sampleTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete globalThis.__collectPerfDiagSample;
|
||||||
started = false;
|
started = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
|
|||||||
|
|
||||||
export type PerfDiagEntryType =
|
export type PerfDiagEntryType =
|
||||||
| 'session'
|
| 'session'
|
||||||
|
| 'environment'
|
||||||
| 'process'
|
| 'process'
|
||||||
| 'store'
|
| 'store'
|
||||||
| 'components'
|
| 'components'
|
||||||
| 'heap'
|
| 'heap'
|
||||||
|
| 'high-memory'
|
||||||
| 'crash'
|
| 'crash'
|
||||||
| 'unresponsive';
|
| 'unresponsive';
|
||||||
|
|
||||||
|
|||||||
@@ -78,10 +78,12 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
|||||||
if (PushNotifications) {
|
if (PushNotifications) {
|
||||||
const permission = await PushNotifications.checkPermissions();
|
const permission = await PushNotifications.checkPermissions();
|
||||||
|
|
||||||
if (permission.receive === 'prompt') {
|
if (permission.receive !== 'granted') {
|
||||||
await PushNotifications.requestPermissions();
|
await PushNotifications.requestPermissions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.requestPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestPermission(): Promise<boolean> {
|
async requestPermission(): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
|
|
||||||
|
|
||||||
import type { MobileCapturePermissionResult } from '../../logic/mobile-media-permission.rules';
|
import type { MobileCapturePermissionResult } from '../../logic/mobile-media-permission.rules';
|
||||||
|
import { isCapacitorNativeRuntime } from '../../logic/platform-detection.rules';
|
||||||
|
|
||||||
export interface MetoyouMobilePlugin {
|
export interface MetoyouMobilePlugin {
|
||||||
requestVoiceCapturePermissions(): Promise<MobileCapturePermissionResult>;
|
requestVoiceCapturePermissions(): Promise<MobileCapturePermissionResult>;
|
||||||
@@ -25,7 +24,14 @@ export async function loadMetoyouMobilePlugin(): Promise<MetoyouMobilePlugin | n
|
|||||||
|
|
||||||
if (!metoyouMobilePluginPromise) {
|
if (!metoyouMobilePluginPromise) {
|
||||||
metoyouMobilePluginPromise = import('@capacitor/core')
|
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);
|
.catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './logic/platform-detection.rules';
|
export * from './logic/platform-detection.rules';
|
||||||
export * from './logic/call-notification.rules';
|
export * from './logic/call-notification.rules';
|
||||||
|
export * from './services/mobile-runtime-permissions.service';
|
||||||
export * from './services/mobile-platform.service';
|
export * from './services/mobile-platform.service';
|
||||||
export * from './services/mobile-notifications.service';
|
export * from './services/mobile-notifications.service';
|
||||||
export * from './services/mobile-media.service';
|
export * from './services/mobile-media.service';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,7 +30,7 @@ export async function ensureMobileVoiceCapturePermissions(): Promise<boolean> {
|
|||||||
|
|
||||||
return isVoiceCaptureAllowed(result);
|
return isVoiceCaptureAllowed(result);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +51,6 @@ export async function ensureMobileCameraCapturePermissions(): Promise<boolean> {
|
|||||||
|
|
||||||
return isCameraCaptureAllowed(result);
|
return isCameraCaptureAllowed(result);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import sharp from 'sharp';
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
@@ -9,13 +10,16 @@ import {
|
|||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ADAPTIVE_FOREGROUND_ICON_RATIO,
|
||||||
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
||||||
findMissingLauncherResources,
|
findMissingLauncherResources,
|
||||||
findStockCapacitorResources,
|
findStockCapacitorResources,
|
||||||
isBrandLauncherBackgroundColor,
|
isBrandLauncherBackgroundColor,
|
||||||
readAdaptiveIconBackgroundColor,
|
readAdaptiveIconBackgroundColor,
|
||||||
REQUIRED_LAUNCHER_ICON_FILES,
|
REQUIRED_LAUNCHER_ICON_FILES,
|
||||||
REQUIRED_SPLASH_FILES
|
REQUIRED_SPLASH_FILES,
|
||||||
|
resolveIconPixelSize,
|
||||||
|
SPLASH_ICON_RATIO
|
||||||
} from './mobile-android-launcher-icon.rules';
|
} from './mobile-android-launcher-icon.rules';
|
||||||
|
|
||||||
const RES_DIR = resolve(process.cwd(), 'android/app/src/main/res');
|
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 allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
|
||||||
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
|
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', () => {
|
it('ships a launcher icon and splash for every required density', () => {
|
||||||
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
|
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -49,4 +59,15 @@ describe('mobile-android-launcher-icon.rules', () => {
|
|||||||
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
|
expect(isBrandLauncherBackgroundColor(color)).toBe(true);
|
||||||
expect(color?.toLowerCase()).toBe(BRAND_LAUNCHER_BACKGROUND_COLOR.toLowerCase());
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,26 @@
|
|||||||
/** Brand purple sampled from `images/icon-new-rounded.png` (the circle behind the cat). */
|
/** Brand purple sampled from `images/icon-new-rounded.png` (the circle behind the cat). */
|
||||||
export const BRAND_LAUNCHER_BACKGROUND_COLOR = '#4A217A';
|
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. */
|
/** Density buckets Android resolves launcher icons from. */
|
||||||
export const ANDROID_ICON_DENSITIES = [
|
export const ANDROID_ICON_DENSITIES = [
|
||||||
'mdpi',
|
'mdpi',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||