Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb0ac930ad | |||
| f0d79aa627 | |||
| 95259e8943 | |||
| 924d4bbb1d | |||
| baa350e90a | |||
| b2a2d9d770 | |||
| 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 |
|
||||
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
|
||||
|
||||
## Client logout
|
||||
|
||||
- Desktop: title-bar menu **Logout** (`UserLogoutService`).
|
||||
- Mobile / all platforms: settings modal footer **Logout** (`data-testid="settings-logout-button"`) — required because the title bar is hidden on mobile breakpoints.
|
||||
- Logout disconnects realtime sessions, clears the persisted current-user id, resets NgRx room/user/message state, and navigates to `/login`.
|
||||
|
||||
## Login / register response
|
||||
|
||||
```json
|
||||
@@ -115,6 +121,8 @@ A per-install **provision secret** enables silent account creation on newly adde
|
||||
| Create/join on foreign server | `RoomsEffects.createRoom$`, invite/join flows | `ensureCredentialForServerUrl` provisions (or reuses) the per-server session token first; REST/WebSocket calls use the **actor user id** for that signal URL, not the home registration id |
|
||||
| Foreign auth failure | `signalServerAuthFailed` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth |
|
||||
|
||||
Unreachable or offline signal servers must **not** open `/login?mode=authorize`. `ensureEndpointVersionCompatibility()` treats only `online` endpoints as connectable, and `ensureCredentialForServerUrl()` skips authorize navigation when health checks report the server offline (or provisioning fails over the network).
|
||||
|
||||
Authorize UI: `/login?mode=authorize&serverId=…&returnUrl=…` (also supported on `/register`). Settings → Network shows per-endpoint `Authorized` / `Needs sign-in` badges.
|
||||
|
||||
Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home signaling server (or any stored token as a fallback). Missing or rejected **home** tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. Foreign-server `auth_required` / `auth_error` responses clear only that server's credential and attempt re-provision.
|
||||
|
||||
@@ -68,10 +68,10 @@ npm run cap:assets:android # → tools/generate-android-app-icons.mjs (uses sh
|
||||
|
||||
This produces, for every density (`mdpi … xxxhdpi`):
|
||||
|
||||
- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` — legacy launcher bitmaps (the brand disc).
|
||||
- `mipmap-*/ic_launcher_foreground.png` — full-bleed adaptive foreground; the adaptive layers in `mipmap-anydpi-v26/ic_launcher*.xml` reference `@mipmap/ic_launcher_foreground` (the stock `drawable-v24/ic_launcher_foreground.xml` vector is removed).
|
||||
- `mipmap-*/ic_launcher.png` + `ic_launcher_round.png` — legacy launcher bitmaps (the brand disc inset to the adaptive-icon safe zone so circular masks do not clip the cat face).
|
||||
- `mipmap-*/ic_launcher_foreground.png` — adaptive foreground centred at **66/108** of the 108dp canvas (Android safe zone); the adaptive layers in `mipmap-anydpi-v26/ic_launcher*.xml` reference `@mipmap/ic_launcher_foreground` with `@color/ic_launcher_background` brand purple behind it.
|
||||
- `values/ic_launcher_background.xml` — adaptive background colour set to the **brand purple `#4A217A`**, not stock white.
|
||||
- `drawable*/splash.png` (port + land per density, plus the base) — brand mark centred on a purple field for the launch splash.
|
||||
- `drawable*/splash.png` (port + land per density, plus the base) — brand mark centred at **32%** of the shorter splash edge on a purple field (down from 40% so the cat face is not cropped on launch).
|
||||
|
||||
Invariants are encoded in `toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules.ts` (required file set, brand background colour, and the SHA-256 of every stock Capacitor placeholder that must never reappear). Coverage:
|
||||
|
||||
@@ -134,7 +134,9 @@ Declared in `toju-app/android/app/src/main/AndroidManifest.xml`:
|
||||
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
||||
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
|
||||
|
||||
Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells.
|
||||
Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells. If the native plugin is unavailable or the bridge call fails, capture preflight defers to the WebView `getUserMedia` permission flow instead of aborting voice/camera joins.
|
||||
|
||||
On Capacitor startup, `MobileRuntimePermissionsService` (via `MobileAppLifecycleService.initialize()`) proactively prompts for microphone, camera, local-notification, and push-notification runtime permissions so Android 13+ shells do not keep every permission in the "Not allowed" state until the user joins voice or receives a call.
|
||||
|
||||
### iOS (APNs)
|
||||
|
||||
@@ -199,9 +201,10 @@ The service shows a low-importance ongoing notification while a call is active.
|
||||
|
||||
## Safe area (Android)
|
||||
|
||||
- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads.
|
||||
- Capacitor `SystemBars` injects `--safe-area-inset-*` CSS variables into `document.documentElement`. `index.html` sets `viewport-fit=cover` and default inset values; `main.ts` calls `applyMobileSafeAreaDefaults()` so injection never hits a missing root element after the WebView loads. `MobileAppLifecycleService` calls `syncMobileSafeAreaInsets()` after Capacitor boot so Android SystemBars recomputes inset variables once the SPA is ready.
|
||||
- `capacitor.config.ts` sets `plugins.SystemBars.insetsHandling: 'css'` so Android WebView versions that mis-report `env(safe-area-inset-*)` still receive correct insets.
|
||||
- Global `styles.scss` applies inset padding on `html` (with `env()` fallback) and sizes `app-root` to `height: 100%` so content stays below the status bar and above the navigation bar in edge-to-edge mode.
|
||||
- Global `styles.scss` defines `metoyou-safe-area-shell` (mobile app shell padding), `metoyou-fixed-safe-viewport` (full-screen modals/backdrops), and `metoyou-fixed-safe-bottom-sheet` (bottom sheets and CDK profile-card panels). These read `--safe-area-inset-*` with `env()` fallback so routed pages, settings, context menus, and profile cards stay below the status bar and above the navigation bar.
|
||||
- Android `styles.xml` uses transparent status/navigation bars and `windowLayoutInDisplayCutoutMode=shortEdges` so Capacitor can draw edge-to-edge and report accurate insets.
|
||||
|
||||
## Self-hosted HTTPS signal servers (Android)
|
||||
|
||||
@@ -253,6 +256,7 @@ Network security configs:
|
||||
- `MobileCallSessionService` — CallKit + foreground service + in-call notifications.
|
||||
- `App` bootstrap — initializes mobile persistence, lifecycle, app-update polling, call-session, and push registration wiring.
|
||||
- `MobileAppUpdateService` — periodic Play Store / App Store checks (30 min) and settings UI actions; mirrors Electron `DesktopAppUpdateService` polling but uses native store APIs instead of release manifests.
|
||||
- Settings → **Data** on Capacitor shells shows the private app-data root and **Erase user data** (`LocalUserDataService` clears SQLite, Capacitor attachment files, auth tokens, and `metoyou_*` localStorage keys, then logs out).
|
||||
|
||||
## Phase 3 completion notes
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function webRtcHarnessWindow(scope: Window = window): WebRtcTestHarnessWindow {
|
||||
return scope as unknown as WebRtcTestHarnessWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
||||
* Tracks all created peer connections and their remote tracks so tests
|
||||
@@ -32,7 +28,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
||||
source?: AudioScheduledSourceNode;
|
||||
drawIntervalId?: number;
|
||||
}[] = [];
|
||||
const harness = webRtcHarnessWindow();
|
||||
const harness = window as unknown as WebRtcTestHarnessWindow;
|
||||
|
||||
harness.__rtcConnections = connections;
|
||||
harness.__rtcDataChannels = dataChannels;
|
||||
@@ -160,6 +156,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
||||
|
||||
return resultStream;
|
||||
};
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,7 +178,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
||||
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const OrigAudioContext = window.AudioContext;
|
||||
const audioHarness = webRtcHarnessWindow();
|
||||
const audioHarness = window as unknown as WebRtcTestHarnessWindow;
|
||||
|
||||
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
|
||||
const ctx: AudioContext = new OrigAudioContext(...args);
|
||||
@@ -211,7 +208,7 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
||||
|
||||
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => webRtcHarnessWindow().__rtcConnections?.some(
|
||||
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||
) ?? false,
|
||||
undefined,
|
||||
@@ -224,7 +221,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
|
||||
*/
|
||||
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
() => webRtcHarnessWindow().__rtcConnections?.some(
|
||||
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||
) ?? false
|
||||
);
|
||||
@@ -233,7 +230,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||
/** Returns the number of tracked peer connections in `connected` state. */
|
||||
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
||||
return page.evaluate(
|
||||
() => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||
(pc) => pc.connectionState === 'connected'
|
||||
).length ?? 0
|
||||
);
|
||||
@@ -241,19 +238,36 @@ export async function getConnectedPeerCount(page: Page): Promise<number> {
|
||||
|
||||
/** Wait until the expected number of peer connections are `connected`. */
|
||||
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(count) => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||
(pc) => pc.connectionState === 'connected'
|
||||
).length === count,
|
||||
expectedCount,
|
||||
{ timeout }
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => {
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? [];
|
||||
|
||||
return {
|
||||
connected: connections.filter((pc) => pc.connectionState === 'connected').length,
|
||||
states: connections.map((pc) => pc.connectionState)
|
||||
};
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
`Expected ${expectedCount} connected peers within ${timeout}ms; `
|
||||
+ `saw ${diagnostics.connected} connected (${diagnostics.states.join(', ') || 'none'})`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the number of tracked RTCDataChannels in the open state. */
|
||||
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
||||
return page.evaluate(
|
||||
() => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||
(channel) => channel.readyState === 'open'
|
||||
).length ?? 0
|
||||
);
|
||||
@@ -262,7 +276,7 @@ export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
||||
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
||||
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||
(channel) => channel.readyState === 'open'
|
||||
).length === count,
|
||||
expectedCount,
|
||||
@@ -273,7 +287,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num
|
||||
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
||||
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||
|
||||
let closed = 0;
|
||||
|
||||
@@ -293,7 +307,7 @@ export async function closeOpenDataChannels(page: Page): Promise<number> {
|
||||
/** Dispatch a synthetic data-channel error event on each open channel. */
|
||||
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||
|
||||
let dispatched = 0;
|
||||
|
||||
@@ -354,7 +368,7 @@ interface PerPeerAudioStat {
|
||||
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
||||
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
||||
return page.evaluate(async () => {
|
||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length) {
|
||||
return [];
|
||||
@@ -472,7 +486,7 @@ export async function getAudioStats(page: Page): Promise<{
|
||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||
}> {
|
||||
return page.evaluate(async () => {
|
||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return { outbound: null, inbound: null };
|
||||
@@ -486,8 +500,8 @@ export async function getAudioStats(page: Page): Promise<{
|
||||
hasInbound: boolean;
|
||||
};
|
||||
|
||||
const hwm: Record<number, HWMEntry> = webRtcHarnessWindow().__rtcStatsHWM =
|
||||
(webRtcHarnessWindow().__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
||||
const hwm: Record<number, HWMEntry> = (window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM =
|
||||
((window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
||||
|
||||
for (let idx = 0; idx < connections.length; idx++) {
|
||||
let stats: RTCStatsReport;
|
||||
@@ -596,7 +610,7 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis
|
||||
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
async () => {
|
||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return false;
|
||||
@@ -705,7 +719,7 @@ export async function getVideoStats(page: Page): Promise<{
|
||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||
}> {
|
||||
return page.evaluate(async () => {
|
||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return { outbound: null, inbound: null };
|
||||
@@ -719,8 +733,8 @@ export async function getVideoStats(page: Page): Promise<{
|
||||
hasInbound: boolean;
|
||||
}
|
||||
|
||||
const hwm: Record<number, VHWM> = webRtcHarnessWindow().__rtcVideoStatsHWM =
|
||||
(webRtcHarnessWindow().__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
||||
const hwm: Record<number, VHWM> = (window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM =
|
||||
((window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
||||
|
||||
for (let idx = 0; idx < connections.length; idx++) {
|
||||
let stats: RTCStatsReport;
|
||||
@@ -804,7 +818,7 @@ export async function getVideoStats(page: Page): Promise<{
|
||||
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
async () => {
|
||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!connections?.length)
|
||||
return false;
|
||||
@@ -972,7 +986,7 @@ export async function waitForInboundVideoFlow(
|
||||
*/
|
||||
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
||||
return page.evaluate(async () => {
|
||||
const conns = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
const conns = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||
|
||||
if (!conns?.length)
|
||||
return 'No connections tracked';
|
||||
|
||||
@@ -31,6 +31,14 @@ test.describe('Multi-device session', () => {
|
||||
expect(instanceA).not.toEqual(instanceB);
|
||||
});
|
||||
|
||||
await test.step('shows one self identity in the members panel on each device', async () => {
|
||||
for (const client of [scenario.clientA, scenario.clientB]) {
|
||||
await expect(
|
||||
membersSidePanel(client.page).getByText(scenario.credentials.displayName, { exact: true })
|
||||
).toHaveCount(1, { timeout: 20_000 });
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('syncs chat from device A to device B', async () => {
|
||||
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
||||
});
|
||||
|
||||
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 {
|
||||
ADAPTIVE_FOREGROUND_ICON_RATIO,
|
||||
BRAND_LAUNCHER_BACKGROUND_COLOR,
|
||||
findMissingLauncherResources,
|
||||
findStockCapacitorResources,
|
||||
isBrandLauncherBackgroundColor,
|
||||
readAdaptiveIconBackgroundColor,
|
||||
REQUIRED_LAUNCHER_ICON_FILES,
|
||||
REQUIRED_SPLASH_FILES
|
||||
REQUIRED_SPLASH_FILES,
|
||||
SPLASH_ICON_RATIO
|
||||
} from '../../../toju-app/src/app/infrastructure/mobile/logic/mobile-android-launcher-icon.rules';
|
||||
|
||||
/**
|
||||
@@ -104,4 +106,16 @@ test.describe('Android brand app icon', () => {
|
||||
expect(colorDistance(corner, BRAND_PURPLE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||
expect(colorDistance(center, WHITE)).toBeLessThanOrEqual(COLOR_TOLERANCE);
|
||||
});
|
||||
|
||||
test('insets the adaptive foreground so launcher masks do not clip the cat face', async () => {
|
||||
const foreground = 'mipmap-xxxhdpi/ic_launcher_foreground.png';
|
||||
const { data, info } = await sharp(join(RES_DIR, foreground)).ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const topCenterOffset = (0 * info.width + Math.floor(info.width / 2)) * info.channels;
|
||||
|
||||
expect(data[topCenterOffset + 3]).toBeLessThan(32);
|
||||
expect(ADAPTIVE_FOREGROUND_ICON_RATIO).toBeCloseTo(66 / 108, 5);
|
||||
expect(SPLASH_ICON_RATIO).toBeLessThan(0.4);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
getConnectedPeerCount,
|
||||
installWebRTCTracking,
|
||||
installAutoResumeAudioContext,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForPeerConnected
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import {
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||
import { getMinimumConnectedPeerMeshCount, waitForConnectedRemotePeerMesh } from '../../helpers/signal-manager';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
|
||||
// ── Signal endpoint identifiers ──────────────────────────────────────
|
||||
@@ -132,7 +134,8 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
await installTestServerEndpoints(client.context, groupEndpoints);
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installWebRTCTracking(client.context);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
clients.push({ ...client, user });
|
||||
}
|
||||
@@ -300,8 +303,11 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
for (const client of clients) {
|
||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||
await client.page.waitForTimeout(2_000);
|
||||
}
|
||||
|
||||
await clients[0].page.waitForTimeout(10_000);
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
}
|
||||
@@ -310,11 +316,11 @@ test.describe('Mixed signal-config voice', () => {
|
||||
// ── Audio mesh ──────────────────────────────────────────────
|
||||
await test.step('All users discover peers and audio flows pairwise', async () => {
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForPeerConnected(client.page, 45_000)
|
||||
waitForPeerConnected(client.page, 90_000)
|
||||
));
|
||||
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
|
||||
));
|
||||
|
||||
await Promise.all(clients.map((client) =>
|
||||
@@ -324,7 +330,7 @@ test.describe('Mixed signal-config voice', () => {
|
||||
await clients[0].page.waitForTimeout(5_000);
|
||||
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||
));
|
||||
});
|
||||
|
||||
@@ -335,7 +341,6 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
await openVoiceWorkspace(client.page);
|
||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
}
|
||||
});
|
||||
@@ -372,18 +377,28 @@ test.describe('Mixed signal-config voice', () => {
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const client of stayers) {
|
||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
||||
await expect.poll(async () => {
|
||||
const actual = await getConnectedPeerCount(client.page);
|
||||
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
|
||||
|
||||
return actual >= minimum;
|
||||
}, {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||||
}).toBe(true);
|
||||
}
|
||||
|
||||
// Check chatters still have voice peers even while viewing another room
|
||||
for (const chatter of chatters) {
|
||||
await expect.poll(async () => await getConnectedPeerCount(chatter.page), {
|
||||
await expect.poll(async () => {
|
||||
const actual = await getConnectedPeerCount(chatter.page);
|
||||
const minimum = await getMinimumConnectedPeerMeshCount(chatter.page, EXPECTED_REMOTE_PEERS);
|
||||
|
||||
return actual >= minimum;
|
||||
}, {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||||
}).toBe(true);
|
||||
}
|
||||
|
||||
if (Date.now() < deadline) {
|
||||
@@ -749,63 +764,6 @@ async function waitForLocalVoiceChannelConnection(page: Page, channelName: strin
|
||||
|
||||
// ── Roster / state helpers ───────────────────────────────────────────
|
||||
|
||||
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-voice-workspace');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
|
||||
|
||||
return connectedUsers.length === count;
|
||||
},
|
||||
expectedCount,
|
||||
{ timeout: 45_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ expected, name }) => {
|
||||
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
|
||||
interface RoomShape { channels?: ChannelShape[] }
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||
const channelId = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name)?.id;
|
||||
|
||||
if (!channelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
|
||||
|
||||
return roster.length === expected;
|
||||
},
|
||||
{ expected: expectedCount, name: channelName },
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVoiceStateAcrossPages(
|
||||
clients: readonly TestClient[],
|
||||
displayName: string,
|
||||
|
||||
@@ -6,14 +6,21 @@ import {
|
||||
dumpRtcDiagnostics,
|
||||
getConnectedPeerCount,
|
||||
installWebRTCTracking,
|
||||
installAutoResumeAudioContext,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForPeerConnected
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||
import {
|
||||
getConnectedSignalManagerCount,
|
||||
getMinimumConnectedPeerMeshCount,
|
||||
waitForConnectedRemotePeerMesh,
|
||||
waitForConnectedSignalManagerCount
|
||||
} from '../../helpers/signal-manager';
|
||||
|
||||
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
|
||||
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
||||
@@ -116,8 +123,11 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
for (const client of clients) {
|
||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||
await client.page.waitForTimeout(2_000);
|
||||
}
|
||||
|
||||
await clients[0].page.waitForTimeout(10_000);
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
}
|
||||
@@ -126,12 +136,12 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
await test.step('All users discover all peers and audio flows pairwise', async () => {
|
||||
// Wait for all clients to have at least one connected peer (fast)
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForPeerConnected(client.page, 45_000)
|
||||
waitForPeerConnected(client.page, 90_000)
|
||||
));
|
||||
|
||||
// Wait for all clients to have all 7 peers connected
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
|
||||
));
|
||||
|
||||
// Wait for audio stats to appear on all clients
|
||||
@@ -146,7 +156,7 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
// Check bidirectional audio flow on each client
|
||||
await Promise.all(clients.map((client) =>
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||
));
|
||||
});
|
||||
|
||||
@@ -156,7 +166,6 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
await openVoiceWorkspace(client.page);
|
||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||
await waitForConnectedSignalManagerCount(client.page, 2);
|
||||
}
|
||||
@@ -167,10 +176,15 @@ test.describe('Dual-signal multi-user voice', () => {
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const client of clients) {
|
||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
||||
await expect.poll(async () => {
|
||||
const actual = await getConnectedPeerCount(client.page);
|
||||
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
|
||||
|
||||
return actual >= minimum;
|
||||
}, {
|
||||
timeout: 10_000,
|
||||
intervals: [500, 1_000]
|
||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
||||
}).toBe(true);
|
||||
|
||||
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
||||
timeout: 10_000,
|
||||
@@ -292,7 +306,8 @@ async function createTrackedClients(
|
||||
|
||||
await installTestServerEndpoints(client.context, endpoints);
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installWebRTCTracking(client.context);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
clients.push({
|
||||
...client,
|
||||
@@ -576,124 +591,6 @@ async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise
|
||||
}, channelName);
|
||||
}
|
||||
|
||||
async function waitForConnectedSignalManagerCount(page: Page, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const realtime = component['realtime'] as {
|
||||
signalingTransportHandler?: {
|
||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
||||
};
|
||||
} | undefined;
|
||||
const countValue = realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
||||
|
||||
return countValue === count;
|
||||
},
|
||||
expectedCount,
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function getConnectedSignalManagerCount(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const realtime = component['realtime'] as {
|
||||
signalingTransportHandler?: {
|
||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
||||
};
|
||||
} | undefined;
|
||||
|
||||
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(count) => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-voice-workspace');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
|
||||
|
||||
return connectedUsers.length === count;
|
||||
},
|
||||
expectedCount,
|
||||
{ timeout: 45_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ expected, name }) => {
|
||||
interface ChannelShape {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'voice';
|
||||
}
|
||||
|
||||
interface RoomShape {
|
||||
channels?: ChannelShape[];
|
||||
}
|
||||
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||
const channelId = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name)?.id;
|
||||
|
||||
if (!channelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
|
||||
|
||||
return roster.length === expected;
|
||||
},
|
||||
{ expected: expectedCount, name: channelName },
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVoiceStateAcrossPages(
|
||||
clients: readonly TestClient[],
|
||||
displayName: string,
|
||||
|
||||
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;
|
||||
type: string;
|
||||
workingSetKb: number | null;
|
||||
peakWorkingSetKb: number | null;
|
||||
privateBytesKb: number | null;
|
||||
creationTime: number | null;
|
||||
cpuPercent: number | null;
|
||||
}
|
||||
|
||||
export interface AppMetricsSnapshot {
|
||||
@@ -17,7 +21,17 @@ export function collectAppMetricsSnapshot(): AppMetricsSnapshot {
|
||||
processes: app.getAppMetrics().map((metric) => ({
|
||||
pid: metric.pid,
|
||||
type: metric.type,
|
||||
workingSetKb: metric.memory?.workingSetSize ?? null
|
||||
workingSetKb: metric.memory?.workingSetSize ?? null,
|
||||
peakWorkingSetKb: readOptionalKilobytes(metric.memory?.peakWorkingSetSize),
|
||||
privateBytesKb: readOptionalKilobytes(metric.memory?.privateBytes),
|
||||
creationTime: metric.creationTime ?? null,
|
||||
cpuPercent: typeof metric.cpu?.percentCPUUsage === 'number'
|
||||
? Math.round(metric.cpu.percentCPUUsage * 10) / 10
|
||||
: null
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function readOptionalKilobytes(value: number | undefined): number | null {
|
||||
return typeof value === 'number' && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
||||
import {
|
||||
attachRendererDiagnosticsHooks,
|
||||
ensurePerfDiagIpcRegistered,
|
||||
shutdownHighMemoryMonitoring,
|
||||
shutdownPerfDiagnostics,
|
||||
startHighMemoryMonitoring,
|
||||
startPerfDiagnostics
|
||||
} from '../diagnostics';
|
||||
|
||||
@@ -39,6 +41,7 @@ function startLocalApiAfterWindowReady(): void {
|
||||
|
||||
export function registerAppLifecycle(): void {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
startHighMemoryMonitoring();
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const dockIconPath = getDockIconPath();
|
||||
@@ -83,6 +86,7 @@ export function registerAppLifecycle(): void {
|
||||
|
||||
app.on('before-quit', async (event) => {
|
||||
prepareWindowForAppQuit();
|
||||
shutdownHighMemoryMonitoring();
|
||||
await shutdownPerfDiagnostics();
|
||||
|
||||
if (getDataSource()?.isInitialized) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||
describe('isPerfDiagEnabled', () => {
|
||||
it('returns false when the flag is unset', () => {
|
||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||
expect(isPerfDiagEnabled({}, true)).toBe(false);
|
||||
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
||||
@@ -17,11 +17,12 @@ describe('isPerfDiagEnabled', () => {
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false in packaged builds unless force is set', () => {
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
|
||||
expect(isPerfDiagEnabled({
|
||||
METOYOU_PERF_DIAG: '1',
|
||||
METOYOU_PERF_DIAG_FORCE: '1'
|
||||
}, true)).toBe(true);
|
||||
it('returns true in packaged Electron builds without env flags', () => {
|
||||
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '0' }, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false in development when the flag is unset', () => {
|
||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,13 +17,9 @@ export function isPerfDiagEnabled(
|
||||
env: NodeJS.ProcessEnv,
|
||||
isPackaged: boolean
|
||||
): boolean {
|
||||
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isTruthyFlag(env[PERF_DIAG_ENV]);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
ipcMain
|
||||
ipcMain,
|
||||
shell
|
||||
} from 'electron';
|
||||
import { collectAppMetricsSnapshot } from '../app-metrics';
|
||||
import { collectAppMetricsSnapshot, type AppMetricsSnapshot } from '../app-metrics';
|
||||
import { getMainWindow } from '../window/create-window';
|
||||
import { resolveReadablePath } from '../path-jail';
|
||||
import { sumWorkingSetKb } from './process-metrics.rules';
|
||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||
import { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
|
||||
import { captureHighMemoryDiagnostics } from './high-memory-capture';
|
||||
import { collectSessionContext } from './session-context.collector';
|
||||
import {
|
||||
clearHighMemoryAlert,
|
||||
readHighMemoryAlert,
|
||||
writeHighMemoryAlert,
|
||||
type HighMemoryAlertRecord
|
||||
} from './high-memory-alert.store';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import { PerfDiagWriter } from './diagnostics.writer';
|
||||
|
||||
const PROCESS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
export const HIGH_MEMORY_ALERT_PENDING_CHANNEL = 'high-memory-alert-pending';
|
||||
|
||||
let activeWriter: PerfDiagWriter | null = null;
|
||||
let processPollTimer: NodeJS.Timeout | null = null;
|
||||
let diagnosticsEnabled = false;
|
||||
let ipcRegistered = false;
|
||||
let highMemoryAlertTriggeredThisSession = false;
|
||||
let sessionStartedAt = 0;
|
||||
|
||||
export function isPerfDiagActive(): boolean {
|
||||
return diagnosticsEnabled;
|
||||
@@ -43,14 +59,103 @@ export function ensurePerfDiagIpcRegistered(): void {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-pending-high-memory-alert', async () => {
|
||||
return readHighMemoryAlert(app.getPath('userData'));
|
||||
});
|
||||
|
||||
ipcMain.handle('acknowledge-high-memory-alert', async () => {
|
||||
await clearHighMemoryAlert(app.getPath('userData'));
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('export-high-memory-diagnostics', async () => {
|
||||
const metrics = collectAppMetricsSnapshot();
|
||||
const totalKb = sumWorkingSetKb(metrics.processes) ?? 0;
|
||||
const record = await captureHighMemoryDiagnostics({
|
||||
userDataPath: app.getPath('userData'),
|
||||
sessionStartedAt,
|
||||
metrics,
|
||||
totalWorkingSetKb: totalKb,
|
||||
writer: activeWriter,
|
||||
mainWindow: getMainWindow(),
|
||||
reason: 'manual'
|
||||
});
|
||||
|
||||
await persistAndNotifyHighMemoryAlert(record);
|
||||
|
||||
return record;
|
||||
});
|
||||
|
||||
ipcMain.handle('show-log-file-in-folder', async (_event, filePath: string) => {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) {
|
||||
return {
|
||||
shown: false,
|
||||
reason: 'missing-path'
|
||||
};
|
||||
}
|
||||
|
||||
const scopedPath = await resolveReadablePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return {
|
||||
shown: false,
|
||||
reason: 'outside-app-data'
|
||||
};
|
||||
}
|
||||
|
||||
shell.showItemInFolder(scopedPath);
|
||||
|
||||
return { shown: true };
|
||||
});
|
||||
}
|
||||
|
||||
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
||||
return activeWriter;
|
||||
}
|
||||
|
||||
export function startHighMemoryMonitoring(): void {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
|
||||
if (!sessionStartedAt) {
|
||||
sessionStartedAt = Date.now();
|
||||
highMemoryAlertTriggeredThisSession = false;
|
||||
}
|
||||
|
||||
if (processPollTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sample = (): void => {
|
||||
try {
|
||||
const metrics = collectAppMetricsSnapshot();
|
||||
const totalKb = sumWorkingSetKb(metrics.processes);
|
||||
|
||||
if (activeWriter && diagnosticsEnabled) {
|
||||
activeWriter.append({
|
||||
collectedAt: metrics.collectedAt,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: {
|
||||
totalWorkingSetKb: totalKb,
|
||||
processes: metrics.processes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void maybeTriggerHighMemoryAlert(metrics, totalKb);
|
||||
} catch {
|
||||
// Collector failures must never affect the app.
|
||||
}
|
||||
};
|
||||
|
||||
sample();
|
||||
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function startPerfDiagnostics(): PerfDiagWriter | null {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
startHighMemoryMonitoring();
|
||||
diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged);
|
||||
|
||||
if (!diagnosticsEnabled) {
|
||||
@@ -65,7 +170,8 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
|
||||
|
||||
activeWriter = writer;
|
||||
registerProcessCrashHandlers(writer);
|
||||
startProcessMetricsPolling(writer);
|
||||
|
||||
const userDataPath = app.getPath('userData');
|
||||
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
@@ -78,6 +184,18 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
|
||||
}
|
||||
});
|
||||
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'environment',
|
||||
payload: {
|
||||
...collectSessionContext({
|
||||
sessionStartedAt,
|
||||
userDataPath
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
return writer;
|
||||
}
|
||||
|
||||
@@ -127,14 +245,15 @@ export async function shutdownPerfDiagnostics(): Promise<void> {
|
||||
}
|
||||
|
||||
await activeWriter.flushSnapshot('shutdown');
|
||||
activeWriter = null;
|
||||
diagnosticsEnabled = false;
|
||||
}
|
||||
|
||||
export function shutdownHighMemoryMonitoring(): void {
|
||||
if (processPollTimer) {
|
||||
clearInterval(processPollTimer);
|
||||
processPollTimer = null;
|
||||
}
|
||||
|
||||
activeWriter = null;
|
||||
diagnosticsEnabled = false;
|
||||
}
|
||||
|
||||
function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
|
||||
@@ -180,28 +299,36 @@ function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
|
||||
});
|
||||
}
|
||||
|
||||
function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
||||
const sample = (): void => {
|
||||
try {
|
||||
const metrics = collectAppMetricsSnapshot();
|
||||
const totalKb = sumWorkingSetKb(metrics.processes);
|
||||
|
||||
writer.append({
|
||||
collectedAt: metrics.collectedAt,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: {
|
||||
totalWorkingSetKb: totalKb,
|
||||
processes: metrics.processes
|
||||
async function maybeTriggerHighMemoryAlert(
|
||||
metrics: AppMetricsSnapshot,
|
||||
totalWorkingSetKb: number | null
|
||||
): Promise<void> {
|
||||
if (highMemoryAlertTriggeredThisSession || !exceedsHighMemoryThreshold(totalWorkingSetKb)) {
|
||||
return;
|
||||
}
|
||||
|
||||
highMemoryAlertTriggeredThisSession = true;
|
||||
|
||||
const record = await captureHighMemoryDiagnostics({
|
||||
userDataPath: app.getPath('userData'),
|
||||
sessionStartedAt,
|
||||
metrics,
|
||||
totalWorkingSetKb: totalWorkingSetKb ?? 0,
|
||||
writer: activeWriter,
|
||||
mainWindow: getMainWindow(),
|
||||
reason: 'threshold'
|
||||
});
|
||||
} catch {
|
||||
// Collector failures must never affect the app.
|
||||
}
|
||||
};
|
||||
|
||||
sample();
|
||||
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
||||
await persistAndNotifyHighMemoryAlert(record);
|
||||
}
|
||||
|
||||
async function persistAndNotifyHighMemoryAlert(record: HighMemoryAlertRecord): Promise<void> {
|
||||
await writeHighMemoryAlert(app.getPath('userData'), record);
|
||||
notifyHighMemoryAlert(record);
|
||||
}
|
||||
|
||||
function notifyHighMemoryAlert(record: HighMemoryAlertRecord): void {
|
||||
getMainWindow()?.webContents.send(HIGH_MEMORY_ALERT_PENDING_CHANNEL, record);
|
||||
}
|
||||
|
||||
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
||||
|
||||
@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
|
||||
|
||||
export type PerfDiagEntryType =
|
||||
| 'session'
|
||||
| 'environment'
|
||||
| 'process'
|
||||
| 'store'
|
||||
| 'components'
|
||||
| 'heap'
|
||||
| 'high-memory'
|
||||
| 'crash'
|
||||
| 'unresponsive';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
resolveDiagnosticsFilePath
|
||||
} from './diagnostics.rules';
|
||||
|
||||
const DEFAULT_RING_CAPACITY = 120;
|
||||
const DEFAULT_RING_CAPACITY = 300;
|
||||
const FLUSH_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface PerfDiagWriterOptions {
|
||||
@@ -18,6 +18,7 @@ export interface PerfDiagWriterOptions {
|
||||
|
||||
export class PerfDiagWriter {
|
||||
private readonly filePath: string;
|
||||
private readonly sessionIdValue: string;
|
||||
private readonly ringCapacity: number;
|
||||
private readonly pendingLines: string[] = [];
|
||||
private ring: PerfDiagEntry[] = [];
|
||||
@@ -26,10 +27,15 @@ export class PerfDiagWriter {
|
||||
private disabled = false;
|
||||
|
||||
constructor(options: PerfDiagWriterOptions) {
|
||||
this.sessionIdValue = options.sessionId;
|
||||
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
||||
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
||||
}
|
||||
|
||||
get sessionId(): string {
|
||||
return this.sessionIdValue;
|
||||
}
|
||||
|
||||
get snapshotFilePath(): string {
|
||||
return this.filePath;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
65
electron/diagnostics/high-memory-alert.store.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import {
|
||||
clearHighMemoryAlert,
|
||||
readHighMemoryAlert,
|
||||
resolveHighMemoryAlertPath,
|
||||
writeHighMemoryAlert
|
||||
} from './high-memory-alert.store';
|
||||
|
||||
describe('high-memory-alert.store', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})));
|
||||
});
|
||||
|
||||
it('writes and reads a pending startup alert record', async () => {
|
||||
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
|
||||
|
||||
tempDirs.push(userDataPath);
|
||||
|
||||
const record = {
|
||||
logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'),
|
||||
detectedAt: 1_700_000_000_000,
|
||||
peakWorkingSetKb: 2_200_000,
|
||||
sessionId: 'session-1',
|
||||
reason: 'threshold' as const
|
||||
};
|
||||
|
||||
await writeHighMemoryAlert(userDataPath, record);
|
||||
|
||||
expect(resolveHighMemoryAlertPath(userDataPath)).toBe(
|
||||
path.join(userDataPath, 'diagnostics', 'high-memory-pending.json')
|
||||
);
|
||||
|
||||
expect(await readHighMemoryAlert(userDataPath)).toEqual(record);
|
||||
});
|
||||
|
||||
it('clears the pending startup alert record', async () => {
|
||||
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
|
||||
|
||||
tempDirs.push(userDataPath);
|
||||
|
||||
await writeHighMemoryAlert(userDataPath, {
|
||||
logFilePath: '/tmp/perf.jsonl',
|
||||
detectedAt: Date.now(),
|
||||
peakWorkingSetKb: 2_100_000,
|
||||
sessionId: 'session-2'
|
||||
});
|
||||
|
||||
await clearHighMemoryAlert(userDataPath);
|
||||
|
||||
expect(await readHighMemoryAlert(userDataPath)).toBeNull();
|
||||
});
|
||||
});
|
||||
63
electron/diagnostics/high-memory-alert.store.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export type HighMemoryAlertReason = 'manual' | 'threshold';
|
||||
|
||||
export interface HighMemoryAlertRecord {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: HighMemoryAlertReason;
|
||||
}
|
||||
|
||||
export function resolveHighMemoryAlertPath(userDataPath: string): string {
|
||||
return path.join(userDataPath, 'diagnostics', 'high-memory-pending.json');
|
||||
}
|
||||
|
||||
export async function readHighMemoryAlert(userDataPath: string): Promise<HighMemoryAlertRecord | null> {
|
||||
try {
|
||||
const raw = await fsp.readFile(resolveHighMemoryAlertPath(userDataPath), 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<HighMemoryAlertRecord>;
|
||||
|
||||
if (
|
||||
typeof parsed.logFilePath !== 'string'
|
||||
|| !parsed.logFilePath.trim()
|
||||
|| typeof parsed.detectedAt !== 'number'
|
||||
|| typeof parsed.peakWorkingSetKb !== 'number'
|
||||
|| typeof parsed.sessionId !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
logFilePath: parsed.logFilePath,
|
||||
detectedAt: parsed.detectedAt,
|
||||
peakWorkingSetKb: parsed.peakWorkingSetKb,
|
||||
sessionId: parsed.sessionId,
|
||||
...(parsed.reason === 'manual' || parsed.reason === 'threshold'
|
||||
? { reason: parsed.reason }
|
||||
: {})
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeHighMemoryAlert(
|
||||
userDataPath: string,
|
||||
record: HighMemoryAlertRecord
|
||||
): Promise<void> {
|
||||
const filePath = resolveHighMemoryAlertPath(userDataPath);
|
||||
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fsp.writeFile(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
export async function clearHighMemoryAlert(userDataPath: string): Promise<void> {
|
||||
try {
|
||||
await fsp.unlink(resolveHighMemoryAlertPath(userDataPath));
|
||||
} catch {
|
||||
// Missing pending alert is fine.
|
||||
}
|
||||
}
|
||||
57
electron/diagnostics/high-memory-capture.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as fsp from 'fs/promises';
|
||||
|
||||
import { captureHighMemoryDiagnostics } from './high-memory-capture';
|
||||
|
||||
vi.mock('./immediate-renderer-samples.collector', () => ({
|
||||
collectImmediateRendererSamples: vi.fn(async () => [])
|
||||
}));
|
||||
|
||||
vi.mock('./session-context.collector', () => ({
|
||||
collectSessionContext: vi.fn(() => ({
|
||||
platform: 'linux',
|
||||
userDataPath: '/tmp/user-data'
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('captureHighMemoryDiagnostics', () => {
|
||||
let userDataPath = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-capture-'));
|
||||
});
|
||||
|
||||
it('writes a diagnostics snapshot and returns an alert record', async () => {
|
||||
const record = await captureHighMemoryDiagnostics({
|
||||
userDataPath,
|
||||
sessionStartedAt: Date.now() - 60_000,
|
||||
metrics: {
|
||||
collectedAt: Date.now(),
|
||||
processes: [
|
||||
{
|
||||
pid: 1,
|
||||
type: 'Browser',
|
||||
workingSetKb: 2_200_000
|
||||
}
|
||||
]
|
||||
},
|
||||
totalWorkingSetKb: 2_200_000,
|
||||
writer: null,
|
||||
mainWindow: null,
|
||||
reason: 'manual'
|
||||
});
|
||||
|
||||
expect(record.peakWorkingSetKb).toBe(2_200_000);
|
||||
expect(record.reason).toBe('manual');
|
||||
expect(record.logFilePath).toContain(userDataPath);
|
||||
await expect(fsp.stat(record.logFilePath)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
80
electron/diagnostics/high-memory-capture.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { AppMetricsSnapshot } from '../app-metrics';
|
||||
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
|
||||
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
|
||||
import { collectSessionContext } from './session-context.collector';
|
||||
import type { HighMemoryAlertRecord } from './high-memory-alert.store';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import { PerfDiagWriter } from './diagnostics.writer';
|
||||
|
||||
export type HighMemoryCaptureReason = 'manual' | 'threshold';
|
||||
|
||||
export interface CaptureHighMemoryDiagnosticsInput {
|
||||
userDataPath: string;
|
||||
sessionStartedAt: number;
|
||||
metrics: AppMetricsSnapshot;
|
||||
totalWorkingSetKb: number;
|
||||
writer: PerfDiagWriter | null;
|
||||
mainWindow: BrowserWindow | null;
|
||||
reason: HighMemoryCaptureReason;
|
||||
}
|
||||
|
||||
export async function captureHighMemoryDiagnostics(
|
||||
input: CaptureHighMemoryDiagnosticsInput
|
||||
): Promise<HighMemoryAlertRecord> {
|
||||
const detectedAt = Date.now();
|
||||
const writer = input.writer ?? new PerfDiagWriter({
|
||||
userDataPath: input.userDataPath,
|
||||
sessionId: `${input.reason}-${detectedAt.toString(36)}-${process.pid}`
|
||||
});
|
||||
const immediateRendererEntries = await collectImmediateRendererSamples(input.mainWindow);
|
||||
const environment = collectSessionContext({
|
||||
sessionStartedAt: input.sessionStartedAt,
|
||||
userDataPath: input.userDataPath
|
||||
});
|
||||
|
||||
appendEntries(writer, immediateRendererEntries);
|
||||
appendEntries(writer, [
|
||||
{
|
||||
collectedAt: detectedAt,
|
||||
source: 'main',
|
||||
type: 'environment',
|
||||
payload: {
|
||||
...environment
|
||||
}
|
||||
},
|
||||
{
|
||||
collectedAt: detectedAt,
|
||||
source: 'main',
|
||||
type: 'high-memory',
|
||||
payload: buildHighMemoryDiagnosticPayload({
|
||||
detectedAt,
|
||||
totalWorkingSetKb: input.totalWorkingSetKb,
|
||||
metrics: input.metrics,
|
||||
environment,
|
||||
mainProcessMemory: process.memoryUsage(),
|
||||
ringEntries: writer.bufferedEntries,
|
||||
immediateRendererEntries,
|
||||
sessionId: writer.sessionId
|
||||
})
|
||||
}
|
||||
]);
|
||||
|
||||
await writer.flushSnapshot(
|
||||
input.reason === 'manual' ? 'manual-export' : 'high-memory-threshold'
|
||||
);
|
||||
|
||||
return {
|
||||
logFilePath: writer.snapshotFilePath,
|
||||
detectedAt,
|
||||
peakWorkingSetKb: input.totalWorkingSetKb,
|
||||
sessionId: writer.sessionId,
|
||||
reason: input.reason
|
||||
};
|
||||
}
|
||||
|
||||
function appendEntries(writer: PerfDiagWriter, entries: readonly PerfDiagEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
writer.append(entry);
|
||||
}
|
||||
}
|
||||
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,10 +1,25 @@
|
||||
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
||||
export {
|
||||
clearHighMemoryAlert,
|
||||
readHighMemoryAlert,
|
||||
resolveHighMemoryAlertPath,
|
||||
writeHighMemoryAlert
|
||||
} from './high-memory-alert.store';
|
||||
export type { HighMemoryAlertRecord } from './high-memory-alert.store';
|
||||
export {
|
||||
exceedsHighMemoryThreshold,
|
||||
formatWorkingSetGb,
|
||||
HIGH_MEMORY_THRESHOLD_KB
|
||||
} from './high-memory-alert.rules';
|
||||
export {
|
||||
attachRendererDiagnosticsHooks,
|
||||
ensurePerfDiagIpcRegistered,
|
||||
getActivePerfDiagWriter,
|
||||
HIGH_MEMORY_ALERT_PENDING_CHANNEL,
|
||||
isPerfDiagActive,
|
||||
shutdownHighMemoryMonitoring,
|
||||
shutdownPerfDiagnostics,
|
||||
startHighMemoryMonitoring,
|
||||
startPerfDiagnostics
|
||||
} from './diagnostics.lifecycle';
|
||||
export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
16
electron/ipc/file-read.rules.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { isReadableRegularFile } from './file-read.rules';
|
||||
|
||||
describe('file-read.rules', () => {
|
||||
it('accepts regular files', () => {
|
||||
expect(isReadableRegularFile({ isFile: () => true })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects directories and other non-file paths', () => {
|
||||
expect(isReadableRegularFile({ isFile: () => false })).toBe(false);
|
||||
});
|
||||
});
|
||||
6
electron/ipc/file-read.rules.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Stats } from 'fs';
|
||||
|
||||
/** Only regular files can be read through the read-file IPC surface. */
|
||||
export function isReadableRegularFile(stats: Pick<Stats, 'isFile'>): boolean {
|
||||
return stats.isFile();
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
grantPluginReadRoot,
|
||||
resolveReadablePath
|
||||
} from '../path-jail';
|
||||
import { isReadableRegularFile } from './file-read.rules';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
|
||||
@@ -654,9 +655,19 @@ export function setupSystemHandlers(): void {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fsp.stat(scopedPath);
|
||||
|
||||
if (!isReadableRegularFile(stats)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await fsp.readFile(scopedPath);
|
||||
|
||||
return data.toString('base64');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
|
||||
@@ -666,6 +677,13 @@ export function setupSystemHandlers(): void {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fsp.stat(scopedPath);
|
||||
|
||||
if (!isReadableRegularFile(stats)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileHandle = await fsp.open(scopedPath, 'r');
|
||||
|
||||
try {
|
||||
@@ -678,6 +696,9 @@ export function setupSystemHandlers(): void {
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
|
||||
@@ -728,6 +749,17 @@ export function setupSystemHandlers(): void {
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('append-file-bytes', async (_event, filePath: string, bytes: Uint8Array) => {
|
||||
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await fsp.appendFile(scopedPath, Buffer.from(bytes));
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-file', async (_event, filePath: string) => {
|
||||
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||
|
||||
|
||||
@@ -35,6 +35,17 @@ describe('path-jail', () => {
|
||||
await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath);
|
||||
});
|
||||
|
||||
it('accepts diagnostics log paths under diagnostics', async () => {
|
||||
const diagnosticsDir = path.join(tempRoot, 'diagnostics');
|
||||
|
||||
fs.mkdirSync(diagnosticsDir, { recursive: true });
|
||||
const logPath = path.join(diagnosticsDir, 'perf-session.jsonl');
|
||||
|
||||
fs.writeFileSync(logPath, '{}');
|
||||
|
||||
await expect(assertPathUnderRoot(tempRoot, logPath)).resolves.toBe(logPath);
|
||||
});
|
||||
|
||||
it('accepts cached plugin bundle paths under plugin-bundles', async () => {
|
||||
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0');
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ export const DEFAULT_USER_DATA_SUBDIRS = [
|
||||
'plugin-bundles',
|
||||
'plugin-cache',
|
||||
'themes',
|
||||
'metoyou'
|
||||
'metoyou',
|
||||
'diagnostics'
|
||||
] as const;
|
||||
|
||||
export function isPathInside(parentPath: string, candidatePath: string): boolean {
|
||||
|
||||
@@ -11,6 +11,7 @@ const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
||||
const HIGH_MEMORY_ALERT_PENDING_CHANNEL = 'high-memory-alert-pending';
|
||||
|
||||
export interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
@@ -259,6 +260,29 @@ export interface ElectronAPI {
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) => Promise<boolean>;
|
||||
getPendingHighMemoryAlert: () => Promise<{
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: 'manual' | 'threshold';
|
||||
} | null>;
|
||||
acknowledgeHighMemoryAlert: () => Promise<boolean>;
|
||||
exportHighMemoryDiagnostics: () => Promise<{
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: 'manual' | 'threshold';
|
||||
}>;
|
||||
onHighMemoryAlertPending: (listener: (alert: {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: 'manual' | 'threshold';
|
||||
}) => void) => () => void;
|
||||
showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
@@ -327,6 +351,7 @@ export interface ElectronAPI {
|
||||
grantPluginReadRoot: (rootPath: string) => Promise<boolean>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFileBytes: (filePath: string, data: Uint8Array) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
saveExistingFileAs: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
openFilePath: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
|
||||
@@ -400,6 +425,26 @@ const electronAPI: ElectronAPI = {
|
||||
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
||||
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
||||
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
||||
getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'),
|
||||
acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-high-memory-alert'),
|
||||
exportHighMemoryDiagnostics: () => ipcRenderer.invoke('export-high-memory-diagnostics'),
|
||||
onHighMemoryAlertPending: (listener) => {
|
||||
const wrappedListener = (_event: Electron.IpcRendererEvent, alert: {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
}) => {
|
||||
listener(alert);
|
||||
};
|
||||
|
||||
ipcRenderer.on(HIGH_MEMORY_ALERT_PENDING_CHANNEL, wrappedListener);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(HIGH_MEMORY_ALERT_PENDING_CHANNEL, wrappedListener);
|
||||
};
|
||||
},
|
||||
showLogFileInFolder: (filePath) => ipcRenderer.invoke('show-log-file-in-folder', filePath),
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
||||
@@ -467,6 +512,7 @@ const electronAPI: ElectronAPI = {
|
||||
grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath),
|
||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
|
||||
appendFileBytes: (filePath, data) => ipcRenderer.invoke('append-file-bytes', filePath, data),
|
||||
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
||||
saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName),
|
||||
openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath),
|
||||
|
||||
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 {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'voice_state',
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getServerIdsForOderId,
|
||||
isOderIdConnectedToServer
|
||||
} from './broadcast';
|
||||
import { handleWebSocketMessage } from './handler';
|
||||
import { handleWebSocketMessage, finalizeVoiceDisconnectForConnection } from './handler';
|
||||
|
||||
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
|
||||
|
||||
@@ -26,6 +26,8 @@ function removeDeadConnection(connectionId: string): void {
|
||||
if (user) {
|
||||
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||
|
||||
finalizeVoiceDisconnectForConnection(connectionId);
|
||||
|
||||
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||
|
||||
user.serverIds.forEach((sid) => {
|
||||
|
||||
|
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>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -15,6 +15,18 @@
|
||||
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
||||
"updateSettings": "Update settings",
|
||||
"restartNow": "Restart now"
|
||||
},
|
||||
"highMemoryAlert": {
|
||||
"badge": "High memory usage",
|
||||
"thresholdTitle": "The app is using {{usageGb}} GB of RAM",
|
||||
"thresholdMessage": "MetoYou crossed the 2 GB memory threshold. A diagnostics log was saved so you can inspect what was using memory or share it with support.",
|
||||
"manualTitle": "RAM diagnostics exported ({{usageGb}} GB in use)",
|
||||
"manualMessage": "A snapshot of current memory usage was saved. Open the log, reveal it in your file manager, or copy the path to share with support.",
|
||||
"openLog": "Open log file",
|
||||
"showInFolder": "Show in folder",
|
||||
"copyPath": "Copy path",
|
||||
"dismiss": "Dismiss",
|
||||
"dismissAriaLabel": "Dismiss high memory alert"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"chunksOutOfOrder": "Received media chunks out of order. Retry the download.",
|
||||
"writeDownloadFailed": "Could not write media download to disk.",
|
||||
"openDownloadFailed": "Could not open completed media download from disk.",
|
||||
"downloadFailed": "Media download failed. Retry the download."
|
||||
"downloadFailed": "Media download failed. Retry the download.",
|
||||
"fileTooLarge": "This file is too large to download in this client. Use the desktop app or ask the sender to share a smaller file."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,12 +262,14 @@
|
||||
"localData": {
|
||||
"title": "Local data",
|
||||
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
||||
"descriptionMobile": "Review and erase the private app storage that holds local messages, rooms, attachments, and saved settings on this device.",
|
||||
"restartApp": "Restart app"
|
||||
},
|
||||
"desktopOnly": "Data management is only available in the packaged Electron desktop app.",
|
||||
"desktopOnly": "Data management is only available in the desktop app or native mobile app.",
|
||||
"currentFolder": {
|
||||
"title": "Current data folder",
|
||||
"resolving": "Resolving data folder..."
|
||||
"resolving": "Resolving data folder...",
|
||||
"descriptionMobile": "Files are stored in the app's private data directory on this device."
|
||||
},
|
||||
"openFolder": "Open folder",
|
||||
"opening": "Opening...",
|
||||
@@ -287,6 +289,7 @@
|
||||
"erase": {
|
||||
"title": "Erase user data",
|
||||
"description": "Remove local app data from this device and recreate an empty database.",
|
||||
"descriptionMobile": "Remove local messages, rooms, attachments, and saved app data from this device.",
|
||||
"button": "Erase user data",
|
||||
"erasing": "Erasing...",
|
||||
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
||||
@@ -301,6 +304,7 @@
|
||||
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
||||
"imported": "Imported data.",
|
||||
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
||||
"erasedMobile": "Local data erased. You have been signed out.",
|
||||
"operationFailed": "Data operation failed."
|
||||
}
|
||||
},
|
||||
@@ -437,7 +441,9 @@
|
||||
"title": "App-wide debugging",
|
||||
"description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.",
|
||||
"processRam": "Process RAM",
|
||||
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds.",
|
||||
"exportRamDiagnostics": "Export RAM diagnostics",
|
||||
"exportRamDiagnosticsWorking": "Exporting...",
|
||||
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds. Export saves a diagnostics log and opens the high-memory alert dialog.",
|
||||
"capturedEvents": "Captured events",
|
||||
"lastUpdate": "Last update: {{label}}",
|
||||
"noLogsYet": "No logs yet",
|
||||
|
||||
@@ -15,6 +15,18 @@
|
||||
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
||||
"updateSettings": "Update settings",
|
||||
"restartNow": "Restart now"
|
||||
},
|
||||
"highMemoryAlert": {
|
||||
"badge": "High memory usage",
|
||||
"thresholdTitle": "The app is using {{usageGb}} GB of RAM",
|
||||
"thresholdMessage": "MetoYou crossed the 2 GB memory threshold. A diagnostics log was saved so you can inspect what was using memory or share it with support.",
|
||||
"manualTitle": "RAM diagnostics exported ({{usageGb}} GB in use)",
|
||||
"manualMessage": "A snapshot of current memory usage was saved. Open the log, reveal it in your file manager, or copy the path to share with support.",
|
||||
"openLog": "Open log file",
|
||||
"showInFolder": "Show in folder",
|
||||
"copyPath": "Copy path",
|
||||
"dismiss": "Dismiss",
|
||||
"dismissAriaLabel": "Dismiss high memory alert"
|
||||
}
|
||||
},
|
||||
"attachment": {
|
||||
@@ -26,7 +38,8 @@
|
||||
"chunksOutOfOrder": "Received media chunks out of order. Retry the download.",
|
||||
"writeDownloadFailed": "Could not write media download to disk.",
|
||||
"openDownloadFailed": "Could not open completed media download from disk.",
|
||||
"downloadFailed": "Media download failed. Retry the download."
|
||||
"downloadFailed": "Media download failed. Retry the download.",
|
||||
"fileTooLarge": "This file is too large to download in this client. Use the desktop app or ask the sender to share a smaller file."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -1317,12 +1330,14 @@
|
||||
"localData": {
|
||||
"title": "Local data",
|
||||
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
||||
"descriptionMobile": "Review and erase the private app storage that holds local messages, rooms, attachments, and saved settings on this device.",
|
||||
"restartApp": "Restart app"
|
||||
},
|
||||
"desktopOnly": "Data management is only available in the packaged Electron desktop app.",
|
||||
"desktopOnly": "Data management is only available in the desktop app or native mobile app.",
|
||||
"currentFolder": {
|
||||
"title": "Current data folder",
|
||||
"resolving": "Resolving data folder..."
|
||||
"resolving": "Resolving data folder...",
|
||||
"descriptionMobile": "Files are stored in the app's private data directory on this device."
|
||||
},
|
||||
"openFolder": "Open folder",
|
||||
"opening": "Opening...",
|
||||
@@ -1342,6 +1357,7 @@
|
||||
"erase": {
|
||||
"title": "Erase user data",
|
||||
"description": "Remove local app data from this device and recreate an empty database.",
|
||||
"descriptionMobile": "Remove local messages, rooms, attachments, and saved app data from this device.",
|
||||
"button": "Erase user data",
|
||||
"erasing": "Erasing...",
|
||||
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
||||
@@ -1356,6 +1372,7 @@
|
||||
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
||||
"imported": "Imported data.",
|
||||
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
||||
"erasedMobile": "Local data erased. You have been signed out.",
|
||||
"operationFailed": "Data operation failed."
|
||||
}
|
||||
},
|
||||
@@ -1492,7 +1509,9 @@
|
||||
"title": "App-wide debugging",
|
||||
"description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.",
|
||||
"processRam": "Process RAM",
|
||||
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds.",
|
||||
"exportRamDiagnostics": "Export RAM diagnostics",
|
||||
"exportRamDiagnosticsWorking": "Exporting...",
|
||||
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds. Export saves a diagnostics log and opens the high-memory alert dialog.",
|
||||
"capturedEvents": "Captured events",
|
||||
"lastUpdate": "Last update: {{label}}",
|
||||
"noLogsYet": "No logs yet",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<div
|
||||
appThemeNode="appRoot"
|
||||
class="workspace-bright-theme relative h-full overflow-hidden bg-background text-foreground"
|
||||
[class.metoyou-safe-area-shell]="isMobile()"
|
||||
>
|
||||
<div
|
||||
class="h-full min-h-0 min-w-0 overflow-hidden"
|
||||
@@ -166,6 +167,7 @@
|
||||
<app-incoming-call-modal />
|
||||
<app-screen-share-source-picker />
|
||||
<app-native-context-menu />
|
||||
<app-high-memory-alert-modal />
|
||||
<app-debug-console [showLauncher]="false" />
|
||||
<app-theme-picker-overlay />
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
loadLastViewedChatFromStorage
|
||||
} from './infrastructure/persistence';
|
||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
||||
import { DesktopHighMemoryAlertService } from './core/services/desktop-high-memory-alert.service';
|
||||
import { ServerDirectoryFacade } from './domains/server-directory';
|
||||
import { NotificationsFacade } from './domains/notifications';
|
||||
import { TimeSyncService } from './core/services/time-sync.service';
|
||||
@@ -53,6 +54,7 @@ import { SettingsModalComponent } from './features/settings/settings-modal/setti
|
||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
|
||||
import { HighMemoryAlertModalComponent } from './features/shell/high-memory-alert-modal/high-memory-alert-modal.component';
|
||||
import { UsersActions } from './store/users/users.actions';
|
||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
@@ -81,6 +83,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from './core/i18n';
|
||||
DebugConsoleComponent,
|
||||
ScreenShareSourcePickerComponent,
|
||||
NativeContextMenuComponent,
|
||||
HighMemoryAlertModalComponent,
|
||||
PrivateCallComponent,
|
||||
ThemeNodeDirective,
|
||||
ThemePickerOverlayComponent,
|
||||
@@ -103,6 +106,7 @@ export class App implements OnInit, OnDestroy {
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
desktopUpdates = inject(DesktopAppUpdateService);
|
||||
desktopUpdateState = this.desktopUpdates.state;
|
||||
desktopHighMemoryAlert = inject(DesktopHighMemoryAlertService);
|
||||
readonly databaseService = inject(DatabaseService);
|
||||
readonly router = inject(Router);
|
||||
readonly servers = inject(ServerDirectoryFacade);
|
||||
@@ -288,6 +292,7 @@ export class App implements OnInit, OnDestroy {
|
||||
// - desktop deep-link bridge (only relevant after first paint)
|
||||
// - background presence + game activity loops
|
||||
void this.desktopUpdates.initialize();
|
||||
void this.desktopHighMemoryAlert.initialize();
|
||||
void this.kickOffBackgroundBootstrap();
|
||||
|
||||
// The only thing we genuinely must await before deciding which route
|
||||
|
||||
@@ -251,6 +251,14 @@ export interface ElectronPerfDiagEntry {
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ElectronHighMemoryAlertRecord {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: 'manual' | 'threshold';
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
@@ -272,6 +280,11 @@ export interface ElectronApi {
|
||||
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
||||
isPerfDiagEnabled?: () => Promise<boolean>;
|
||||
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
|
||||
getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>;
|
||||
acknowledgeHighMemoryAlert?: () => Promise<boolean>;
|
||||
exportHighMemoryDiagnostics?: () => Promise<ElectronHighMemoryAlertRecord>;
|
||||
onHighMemoryAlertPending?: (listener: (alert: ElectronHighMemoryAlertRecord) => void) => () => void;
|
||||
showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
@@ -309,6 +322,7 @@ export interface ElectronApi {
|
||||
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFileBytes: (filePath: string, data: Uint8Array) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
|
||||
import {
|
||||
formatAppRamLabel,
|
||||
formatKilobytesAsGigabytes,
|
||||
formatKilobytesAsMegabytes,
|
||||
sumWorkingSetKb
|
||||
} from './electron-app-metrics.rules';
|
||||
@@ -38,6 +39,13 @@ describe('sumWorkingSetKb', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatKilobytesAsGigabytes', () => {
|
||||
it('formats totals in gigabytes with two decimals', () => {
|
||||
expect(formatKilobytesAsGigabytes(1536 * 1024)).toBe('1.50');
|
||||
expect(formatKilobytesAsGigabytes(2 * 1024 * 1024)).toBe('2.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatKilobytesAsMegabytes', () => {
|
||||
it('rounds large values to whole megabytes', () => {
|
||||
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');
|
||||
|
||||
@@ -36,6 +36,10 @@ export function formatKilobytesAsMegabytes(kilobytes: number): string {
|
||||
return `${megabytes.toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function formatKilobytesAsGigabytes(kilobytes: number): string {
|
||||
return (kilobytes / (1024 * 1024)).toFixed(2);
|
||||
}
|
||||
|
||||
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
|
||||
const totalKb = sumWorkingSetKb(snapshot.processes);
|
||||
|
||||
|
||||
@@ -4,20 +4,22 @@ import { ElectronBridgeService } from './electron/electron-bridge.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlatformService {
|
||||
readonly isElectron: boolean;
|
||||
readonly isCapacitor: boolean;
|
||||
readonly isBrowser: boolean;
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
constructor() {
|
||||
this.isElectron = this.electronBridge.isAvailable;
|
||||
|
||||
const isElectron = this.electronBridge.isAvailable;
|
||||
const runtime = detectRuntimePlatform({
|
||||
hasElectronApi: this.isElectron,
|
||||
hasElectronApi: isElectron,
|
||||
capacitorIsNative: isCapacitorNativeRuntime()
|
||||
});
|
||||
|
||||
this.isCapacitor = runtime === 'capacitor';
|
||||
this.isBrowser = runtime === 'browser';
|
||||
}
|
||||
|
||||
get isElectron(): boolean {
|
||||
return this.electronBridge.isAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import '@angular/compiler';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
|
||||
import { DesktopHighMemoryAlertService } from './desktop-high-memory-alert.service';
|
||||
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||
|
||||
describe('DesktopHighMemoryAlertService', () => {
|
||||
let electronBridge: {
|
||||
isAvailable: boolean;
|
||||
getApi: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let documentStub: Document;
|
||||
|
||||
beforeEach(() => {
|
||||
documentStub = {
|
||||
body: null,
|
||||
createElement: vi.fn(),
|
||||
execCommand: vi.fn(() => true)
|
||||
} as unknown as Document;
|
||||
|
||||
electronBridge = {
|
||||
isAvailable: true,
|
||||
getApi: vi.fn(() => ({
|
||||
getPendingHighMemoryAlert: vi.fn(async () => ({
|
||||
logFilePath: '/tmp/diagnostics/session.ndjson',
|
||||
detectedAt: 1,
|
||||
peakWorkingSetKb: 2_200_000,
|
||||
sessionId: 'session-1'
|
||||
})),
|
||||
onHighMemoryAlertPending: vi.fn(() => () => undefined),
|
||||
exportHighMemoryDiagnostics: vi.fn(async () => ({
|
||||
logFilePath: '/tmp/diagnostics/manual.ndjson',
|
||||
detectedAt: 2,
|
||||
peakWorkingSetKb: 1_800_000,
|
||||
sessionId: 'session-2',
|
||||
reason: 'manual' as const
|
||||
})),
|
||||
acknowledgeHighMemoryAlert: vi.fn(async () => true)
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
function createService(): DesktopHighMemoryAlertService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
DesktopHighMemoryAlertService,
|
||||
{ provide: ElectronBridgeService, useValue: electronBridge },
|
||||
{ provide: DOCUMENT, useValue: documentStub }
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(DesktopHighMemoryAlertService));
|
||||
}
|
||||
|
||||
it('loads a pending alert from disk on initialize', async () => {
|
||||
const service = createService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/session.ndjson');
|
||||
expect(service.peakUsageGb()).toBe('2.10');
|
||||
});
|
||||
|
||||
it('shows the modal when a live high-memory alert event arrives', async () => {
|
||||
let listener: ((alert: {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
}) => void) | undefined;
|
||||
|
||||
electronBridge.getApi = vi.fn(() => ({
|
||||
getPendingHighMemoryAlert: vi.fn(async () => null),
|
||||
onHighMemoryAlertPending: vi.fn((callback) => {
|
||||
listener = callback;
|
||||
return () => undefined;
|
||||
}),
|
||||
exportHighMemoryDiagnostics: vi.fn(async () => null),
|
||||
acknowledgeHighMemoryAlert: vi.fn(async () => true)
|
||||
}));
|
||||
|
||||
const service = createService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
listener?.({
|
||||
logFilePath: '/tmp/diagnostics/live.ndjson',
|
||||
detectedAt: 3,
|
||||
peakWorkingSetKb: 2_400_000,
|
||||
sessionId: 'session-3'
|
||||
});
|
||||
|
||||
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/live.ndjson');
|
||||
});
|
||||
|
||||
it('exports diagnostics manually and opens the modal with manual copy', async () => {
|
||||
const service = createService();
|
||||
|
||||
await expect(service.exportDiagnostics()).resolves.toBe(true);
|
||||
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/manual.ndjson');
|
||||
expect(service.pendingAlert()?.reason).toBe('manual');
|
||||
expect(service.titleKey()).toBe('app.highMemoryAlert.manualTitle');
|
||||
expect(service.messageKey()).toBe('app.highMemoryAlert.manualMessage');
|
||||
});
|
||||
|
||||
it('uses threshold copy for live high-memory alerts', async () => {
|
||||
let listener: ((alert: {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: 'manual' | 'threshold';
|
||||
}) => void) | undefined;
|
||||
|
||||
electronBridge.getApi = vi.fn(() => ({
|
||||
getPendingHighMemoryAlert: vi.fn(async () => null),
|
||||
onHighMemoryAlertPending: vi.fn((callback) => {
|
||||
listener = callback;
|
||||
return () => undefined;
|
||||
}),
|
||||
exportHighMemoryDiagnostics: vi.fn(async () => null),
|
||||
acknowledgeHighMemoryAlert: vi.fn(async () => true)
|
||||
}));
|
||||
|
||||
const service = createService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
listener?.({
|
||||
logFilePath: '/tmp/diagnostics/live.ndjson',
|
||||
detectedAt: 3,
|
||||
peakWorkingSetKb: 2_400_000,
|
||||
sessionId: 'session-3',
|
||||
reason: 'threshold'
|
||||
});
|
||||
|
||||
expect(service.titleKey()).toBe('app.highMemoryAlert.thresholdTitle');
|
||||
expect(service.messageKey()).toBe('app.highMemoryAlert.thresholdMessage');
|
||||
});
|
||||
|
||||
it('copies the diagnostics log path to the clipboard', async () => {
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText }
|
||||
});
|
||||
|
||||
const service = createService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
await expect(service.copyLogPath()).resolves.toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith('/tmp/diagnostics/session.ndjson');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
|
||||
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
|
||||
import { formatKilobytesAsGigabytes } from '../platform/electron/electron-app-metrics.rules';
|
||||
import {
|
||||
resolveHighMemoryAlertCopyKind,
|
||||
resolveHighMemoryAlertMessageKey,
|
||||
resolveHighMemoryAlertTitleKey
|
||||
} from './high-memory-alert-copy.rules';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DesktopHighMemoryAlertService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
readonly pendingAlert = signal<ElectronHighMemoryAlertRecord | null>(null);
|
||||
|
||||
readonly peakUsageGb = computed(() => {
|
||||
const alert = this.pendingAlert();
|
||||
|
||||
return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null;
|
||||
});
|
||||
|
||||
readonly titleKey = computed(() => resolveHighMemoryAlertTitleKey(
|
||||
resolveHighMemoryAlertCopyKind(this.pendingAlert())
|
||||
));
|
||||
|
||||
readonly messageKey = computed(() => resolveHighMemoryAlertMessageKey(
|
||||
resolveHighMemoryAlertCopyKind(this.pendingAlert())
|
||||
));
|
||||
|
||||
private initialized = false;
|
||||
private removePendingListener: (() => void) | null = null;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.electronBridge.isAvailable || this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.removePendingListener?.();
|
||||
this.removePendingListener = api.onHighMemoryAlertPending?.((alert) => {
|
||||
this.pendingAlert.set(alert);
|
||||
}) ?? null;
|
||||
|
||||
const alert = await api.getPendingHighMemoryAlert?.();
|
||||
|
||||
if (alert) {
|
||||
this.pendingAlert.set(alert);
|
||||
}
|
||||
}
|
||||
|
||||
async exportDiagnostics(): Promise<boolean> {
|
||||
const api = this.electronBridge.getApi();
|
||||
const alert = await api?.exportHighMemoryDiagnostics?.();
|
||||
|
||||
if (!alert) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.pendingAlert.set(alert);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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<boolean> {
|
||||
const alert = this.pendingAlert();
|
||||
|
||||
if (!alert?.logFilePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.writeTextToClipboard(alert.logFilePath);
|
||||
}
|
||||
|
||||
private async writeTextToClipboard(value: string): Promise<boolean> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const body = this.document.body;
|
||||
|
||||
if (!body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textarea = this.document.createElement('textarea');
|
||||
|
||||
textarea.value = value;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
let copied = false;
|
||||
|
||||
try {
|
||||
copied = this.document.execCommand('copy');
|
||||
} catch {}
|
||||
|
||||
body.removeChild(textarea);
|
||||
|
||||
return copied;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
resolveHighMemoryAlertCopyKind,
|
||||
resolveHighMemoryAlertMessageKey,
|
||||
resolveHighMemoryAlertTitleKey
|
||||
} from './high-memory-alert-copy.rules';
|
||||
|
||||
describe('high-memory-alert-copy.rules', () => {
|
||||
it('uses threshold copy for live alerts and legacy records without a reason', () => {
|
||||
expect(resolveHighMemoryAlertCopyKind({
|
||||
logFilePath: '/tmp/log.jsonl',
|
||||
detectedAt: 1,
|
||||
peakWorkingSetKb: 2_100_000,
|
||||
sessionId: 'session-1'
|
||||
})).toBe('threshold');
|
||||
|
||||
expect(resolveHighMemoryAlertCopyKind({
|
||||
logFilePath: '/tmp/log.jsonl',
|
||||
detectedAt: 1,
|
||||
peakWorkingSetKb: 2_100_000,
|
||||
sessionId: 'session-1',
|
||||
reason: 'threshold'
|
||||
})).toBe('threshold');
|
||||
});
|
||||
|
||||
it('uses manual copy for exported diagnostics', () => {
|
||||
expect(resolveHighMemoryAlertCopyKind({
|
||||
logFilePath: '/tmp/log.jsonl',
|
||||
detectedAt: 1,
|
||||
peakWorkingSetKb: 1_800_000,
|
||||
sessionId: 'session-2',
|
||||
reason: 'manual'
|
||||
})).toBe('manual');
|
||||
});
|
||||
|
||||
it('maps copy kinds to translation keys', () => {
|
||||
expect(resolveHighMemoryAlertTitleKey('threshold')).toBe('app.highMemoryAlert.thresholdTitle');
|
||||
expect(resolveHighMemoryAlertTitleKey('manual')).toBe('app.highMemoryAlert.manualTitle');
|
||||
expect(resolveHighMemoryAlertMessageKey('threshold')).toBe('app.highMemoryAlert.thresholdMessage');
|
||||
expect(resolveHighMemoryAlertMessageKey('manual')).toBe('app.highMemoryAlert.manualMessage');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
|
||||
|
||||
export type HighMemoryAlertCopyKind = 'threshold' | 'manual';
|
||||
|
||||
export function resolveHighMemoryAlertCopyKind(
|
||||
alert: ElectronHighMemoryAlertRecord | null | undefined
|
||||
): HighMemoryAlertCopyKind {
|
||||
return alert?.reason === 'manual' ? 'manual' : 'threshold';
|
||||
}
|
||||
|
||||
export function resolveHighMemoryAlertTitleKey(kind: HighMemoryAlertCopyKind): string {
|
||||
return kind === 'manual'
|
||||
? 'app.highMemoryAlert.manualTitle'
|
||||
: 'app.highMemoryAlert.thresholdTitle';
|
||||
}
|
||||
|
||||
export function resolveHighMemoryAlertMessageKey(kind: HighMemoryAlertCopyKind): string {
|
||||
return kind === 'manual'
|
||||
? 'app.highMemoryAlert.manualMessage'
|
||||
: 'app.highMemoryAlert.thresholdMessage';
|
||||
}
|
||||
@@ -107,12 +107,15 @@ Concurrent triggers (file-announce, message sync, peer connect) can race to requ
|
||||
|
||||
- **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window.
|
||||
- **Sender:** `handleFileRequest` / `fulfillRequestWithFile` track active outbound streams per `(messageId, fileId, peerId)` and ignore duplicate requests while a stream is in flight. A fresh `file-request` clears any earlier `file-cancel` marker from that peer.
|
||||
- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped.
|
||||
- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped. When the active store supports streaming (`canStreamToDisk`), **all** persistable downloads append directly to disk — metadata `filePath` does not force an in-memory assembly fallback. Disk-streamed receives decode each chunk once, append bytes through Electron IPC (`append-file-bytes`), and acknowledge the sender with `file-chunk-ack` so only one chunk is in flight at a time (preventing unbounded base64 retention in the renderer). Completed media stays on `savedPath` until inline display hydration runs on demand.
|
||||
- **Sender:** after each `file-chunk` the transport awaits the matching `file-chunk-ack` before sending the next chunk, in addition to data-channel bufferedAmount back-pressure.
|
||||
|
||||
### Failure handling
|
||||
|
||||
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
|
||||
|
||||
Peers that finish downloading a file re-announce it and register themselves as mirror hosts. New download requests prefer mirror hosts over the original uploader so the sharer's device is not the only upload source. Repeat `file-announce` events for already-known attachments update the host list but do not re-trigger auto-download.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant R as Receiver
|
||||
@@ -155,6 +158,7 @@ An optional experimental VLC.js adapter can be enabled from General settings. Wh
|
||||
|
||||
- `isUploaderUser(attachment, currentUserId)` — the current user is the uploader (same user, any device).
|
||||
- `deviceHasLocalCopy(attachment)` — this device physically holds the bytes (`available` + a blob `objectUrl`, or a non-empty `savedPath`/`filePath`). Synced metadata alone does not count, because P2P/account sync strips local paths.
|
||||
- `canHostAttachment(attachment)` — alias of `deviceHasLocalCopy`; any peer with local bytes can serve downloads.
|
||||
- `isSharingFromThisDevice(attachment, currentUserId)` — `isUploaderUser && deviceHasLocalCopy`. Only this returns the "Shared from your device" state.
|
||||
|
||||
The chat message item renders "Shared from your device" (and hides the request/download affordance) **only** when `isSharingFromThisDevice` is true. A second device of the same user that merely synced the message metadata is the uploader-user but holds no local copy, so it falls back to the normal recipient flow (request/download) instead of falsely claiming ownership and blocking the file (regression: the old check used `uploaderPeerId === currentUserId` and so claimed ownership on every device of the uploader). The transfer service uses the same rule to decide whether a no-peers failure should read "your original upload is missing" (sharing device) or "no connected peers" (any other device).
|
||||
@@ -195,3 +199,14 @@ Room and conversation names are sanitised to remove filesystem-unsafe characters
|
||||
- **cancellations**: IDs of transfers the user cancelled
|
||||
|
||||
Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service.
|
||||
|
||||
### Display blob lifecycle (memory)
|
||||
|
||||
Image inline previews on Electron/desktop use renderer `blob:` URLs rebuilt from disk. To cap RAM in media-heavy channels:
|
||||
|
||||
- **Room restore** (`restoreLocalAttachmentsForRoom`) resolves `savedPath` for hosting only — it does not hydrate every image blob up front.
|
||||
- **Visibility** (`ChatMessageItemComponent` + `IntersectionObserver` on the chat scrollport) hydrates blobs when a message enters view (with `ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN`) and revokes them when it leaves, as long as a disk path can rehydrate later (`canRevokeAttachmentDisplayBlob`).
|
||||
- **Pinned overlays** (lightbox / image gallery) call `pinDisplayBlobs` so an open full-screen view is not revoked while its message scrolls off-screen.
|
||||
- **Serving** is unaffected: peers still download from `savedPath` / `filePath`; blob URLs are display-only.
|
||||
|
||||
While a revoked image waits to rehydrate, chat renders the existing image-grid spinner skeleton (`isAttachmentPendingInlineHydration`).
|
||||
|
||||
@@ -75,6 +75,24 @@ export class AttachmentFacade {
|
||||
return this.manager.tryRestoreAttachmentFromLocal(...args);
|
||||
}
|
||||
|
||||
pinDisplayBlobs(
|
||||
...args: Parameters<AttachmentManagerService['pinDisplayBlobs']>
|
||||
): ReturnType<AttachmentManagerService['pinDisplayBlobs']> {
|
||||
return this.manager.pinDisplayBlobs(...args);
|
||||
}
|
||||
|
||||
unpinDisplayBlobs(
|
||||
...args: Parameters<AttachmentManagerService['unpinDisplayBlobs']>
|
||||
): ReturnType<AttachmentManagerService['unpinDisplayBlobs']> {
|
||||
return this.manager.unpinDisplayBlobs(...args);
|
||||
}
|
||||
|
||||
revokeOffscreenDisplayBlobsForMessage(
|
||||
...args: Parameters<AttachmentManagerService['revokeOffscreenDisplayBlobsForMessage']>
|
||||
): ReturnType<AttachmentManagerService['revokeOffscreenDisplayBlobsForMessage']> {
|
||||
return this.manager.revokeOffscreenDisplayBlobsForMessage(...args);
|
||||
}
|
||||
|
||||
requestFile(
|
||||
...args: Parameters<AttachmentManagerService['requestFile']>
|
||||
): ReturnType<AttachmentManagerService['requestFile']> {
|
||||
@@ -99,6 +117,12 @@ export class AttachmentFacade {
|
||||
return this.manager.handleFileChunk(...args);
|
||||
}
|
||||
|
||||
handleFileChunkAck(
|
||||
...args: Parameters<AttachmentManagerService['handleFileChunkAck']>
|
||||
): ReturnType<AttachmentManagerService['handleFileChunkAck']> {
|
||||
return this.manager.handleFileChunkAck(...args);
|
||||
}
|
||||
|
||||
handleFileRequest(
|
||||
...args: Parameters<AttachmentManagerService['handleFileRequest']>
|
||||
): ReturnType<AttachmentManagerService['handleFileRequest']> {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
|
||||
|
||||
describe('AttachmentChunkAckService', () => {
|
||||
let service: AttachmentChunkAckService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new AttachmentChunkAckService();
|
||||
});
|
||||
|
||||
it('resolves a waiter when the matching chunk ack arrives', async () => {
|
||||
const waitPromise = service.waitForAck('msg-1', 'file-1', 0, 1_000);
|
||||
|
||||
service.resolveAck('msg-1', 'file-1', 0);
|
||||
|
||||
await expect(waitPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('times out when no ack arrives', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const waitPromise = service.waitForAck('msg-1', 'file-1', 1, 50);
|
||||
|
||||
vi.advanceTimersByTime(51);
|
||||
|
||||
await expect(waitPromise).rejects.toThrow('attachment chunk ack timeout');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { buildAttachmentChunkAckKey } from '../../domain/logic/attachment-chunk-ack.rules';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentChunkAckService {
|
||||
private readonly waiters = new Map<string, () => void>();
|
||||
|
||||
waitForAck(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
index: number,
|
||||
timeoutMs = 60_000
|
||||
): Promise<void> {
|
||||
const key = buildAttachmentChunkAckKey(messageId, fileId, index);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.waiters.delete(key);
|
||||
reject(new Error('attachment chunk ack timeout'));
|
||||
}, timeoutMs);
|
||||
|
||||
this.waiters.set(key, () => {
|
||||
clearTimeout(timer);
|
||||
this.waiters.delete(key);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
resolveAck(messageId: string, fileId: string, index: number): void {
|
||||
this.waiters.get(buildAttachmentChunkAckKey(messageId, fileId, index))?.();
|
||||
}
|
||||
|
||||
cancelPendingForFile(messageId: string, fileId: string): void {
|
||||
const prefix = `${messageId}:${fileId}:`;
|
||||
|
||||
for (const [key, resolve] of this.waiters) {
|
||||
if (!key.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolve();
|
||||
this.waiters.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import '@angular/compiler';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
|
||||
import { AttachmentDownloadService } from './attachment-download.service';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
|
||||
describe('AttachmentDownloadService', () => {
|
||||
let electronBridge: {
|
||||
isAvailable: boolean;
|
||||
getApi: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let documentStub: Document;
|
||||
let saveExistingFileAs: ReturnType<typeof vi.fn>;
|
||||
let saveFileAs: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
saveExistingFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
||||
saveFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
||||
|
||||
electronBridge = {
|
||||
isAvailable: true,
|
||||
getApi: vi.fn(() => ({
|
||||
saveExistingFileAs,
|
||||
saveFileAs
|
||||
}))
|
||||
};
|
||||
|
||||
documentStub = {
|
||||
body: {
|
||||
appendChild: vi.fn(),
|
||||
removeChild: vi.fn()
|
||||
},
|
||||
createElement: vi.fn(() => ({
|
||||
click: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
href: '',
|
||||
download: ''
|
||||
}))
|
||||
} as unknown as Document;
|
||||
});
|
||||
|
||||
function createService(): AttachmentDownloadService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
AttachmentDownloadService,
|
||||
{ provide: ElectronBridgeService, useValue: electronBridge },
|
||||
{ provide: DOCUMENT, useValue: documentStub }
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(AttachmentDownloadService));
|
||||
}
|
||||
|
||||
it('exports a completed disk-only attachment through Electron save dialog', async () => {
|
||||
const service = createService();
|
||||
const attachment: Attachment = {
|
||||
id: 'file-1',
|
||||
messageId: 'message-1',
|
||||
filename: 'large.bin',
|
||||
mime: 'application/octet-stream',
|
||||
size: 5_000_000_000,
|
||||
available: true,
|
||||
savedPath: '/appdata/server/room/files/large.bin'
|
||||
};
|
||||
|
||||
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(saveExistingFileAs).toHaveBeenCalledWith('/appdata/server/room/files/large.bin', 'large.bin');
|
||||
expect(saveFileAs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the attachment is not downloadable yet', async () => {
|
||||
const service = createService();
|
||||
const attachment: Attachment = {
|
||||
id: 'file-2',
|
||||
messageId: 'message-2',
|
||||
filename: 'large.bin',
|
||||
mime: 'application/octet-stream',
|
||||
size: 5_000_000_000,
|
||||
available: true
|
||||
};
|
||||
|
||||
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(false);
|
||||
|
||||
expect(saveExistingFileAs).not.toHaveBeenCalled();
|
||||
expect(saveFileAs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { canDownloadAttachment, resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentDownloadService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
async downloadToUserLocation(attachment: Attachment): Promise<boolean> {
|
||||
if (!canDownloadAttachment(attachment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
const diskPath = resolveAttachmentDiskPath(attachment);
|
||||
|
||||
if (electronApi) {
|
||||
if (diskPath && electronApi.saveExistingFileAs) {
|
||||
try {
|
||||
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
|
||||
|
||||
if (result.saved || result.cancelled) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* fall back to blob/browser download */
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await this.getAttachmentBlob(attachment);
|
||||
|
||||
if (blob) {
|
||||
try {
|
||||
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
|
||||
|
||||
if (result.saved || result.cancelled) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* fall back to browser download */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!attachment.objectUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const link = this.document.createElement('a');
|
||||
|
||||
link.href = attachment.objectUrl;
|
||||
link.download = attachment.filename;
|
||||
this.document.body?.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
|
||||
if (!attachment.objectUrl || attachment.objectUrl.startsWith('file:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
|
||||
return await response.blob();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('Failed to encode attachment'));
|
||||
return;
|
||||
}
|
||||
|
||||
const [, base64 = ''] = reader.result.split(',', 2);
|
||||
|
||||
resolve(base64);
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,13 @@ import {
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { take } from 'rxjs';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules';
|
||||
import { buildAttachmentDisplayPinKey, shouldRevokeDisplayBlobForAttachment } from '../../domain/logic/attachment-blob-eviction.rules';
|
||||
import {
|
||||
getWatchedAttachmentRoomIdFromUrl,
|
||||
isDirectMessageAttachmentRoomId,
|
||||
@@ -17,6 +21,7 @@ import type {
|
||||
FileAnnouncePayload,
|
||||
FileCancelPayload,
|
||||
FileChunkPayload,
|
||||
FileChunkAckPayload,
|
||||
FileNotFoundPayload,
|
||||
FileRequestPayload
|
||||
} from '../../domain/models/attachment-transfer.model';
|
||||
@@ -32,6 +37,7 @@ export class AttachmentManagerService {
|
||||
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly database = inject(DatabaseService);
|
||||
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||
private readonly persistence = inject(AttachmentPersistenceService);
|
||||
@@ -40,14 +46,16 @@ export class AttachmentManagerService {
|
||||
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
|
||||
private isDatabaseInitialised = false;
|
||||
private autoDownloadRequestsByRoom = new Map<string, Promise<void>>();
|
||||
private pinnedDisplayBlobKeys = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.database.isReady() && !this.isDatabaseInitialised) {
|
||||
this.isDatabaseInitialised = true;
|
||||
void this.persistence.initFromDatabase().then(() => {
|
||||
void this.persistence.initFromDatabase().then(async () => {
|
||||
if (this.watchedRoomId) {
|
||||
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
|
||||
await this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
|
||||
await this.announceHostedAttachments();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -68,7 +76,10 @@ export class AttachmentManagerService {
|
||||
|
||||
this.webrtc.onPeerConnected.subscribe(() => {
|
||||
if (this.watchedRoomId) {
|
||||
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
|
||||
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId).then(async () => {
|
||||
await this.announceHostedAttachments();
|
||||
});
|
||||
|
||||
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
||||
}
|
||||
});
|
||||
@@ -152,6 +163,48 @@ export class AttachmentManagerService {
|
||||
return restored;
|
||||
}
|
||||
|
||||
pinDisplayBlobs(attachments: readonly Pick<Attachment, 'id' | 'messageId'>[]): void {
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.messageId || !attachment.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.pinnedDisplayBlobKeys.add(buildAttachmentDisplayPinKey(attachment.messageId, attachment.id));
|
||||
}
|
||||
}
|
||||
|
||||
unpinDisplayBlobs(attachments: readonly Pick<Attachment, 'id' | 'messageId'>[]): void {
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.messageId || !attachment.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.pinnedDisplayBlobKeys.delete(buildAttachmentDisplayPinKey(attachment.messageId, attachment.id));
|
||||
}
|
||||
}
|
||||
|
||||
revokeOffscreenDisplayBlobsForMessage(messageId: string): void {
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
|
||||
if (!shouldRevokeDisplayBlobForAttachment(messageId, attachment, this.pinnedDisplayBlobKeys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.persistence.revokeAttachmentDisplayBlob(attachment)) {
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
this.runtimeStore.touch();
|
||||
}
|
||||
}
|
||||
|
||||
requestFile(messageId: string, attachment: Attachment): Promise<void> {
|
||||
return this.transfer.requestFile(messageId, attachment);
|
||||
}
|
||||
@@ -165,9 +218,9 @@ export class AttachmentManagerService {
|
||||
}
|
||||
|
||||
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||
this.transfer.handleFileAnnounce(payload);
|
||||
const isNew = this.transfer.handleFileAnnounce(payload);
|
||||
|
||||
if (payload.messageId && payload.file?.id) {
|
||||
if (isNew && payload.messageId && payload.file?.id) {
|
||||
this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id);
|
||||
}
|
||||
}
|
||||
@@ -176,6 +229,10 @@ export class AttachmentManagerService {
|
||||
this.transfer.handleFileChunk(payload);
|
||||
}
|
||||
|
||||
handleFileChunkAck(payload: FileChunkAckPayload): void {
|
||||
this.transfer.handleFileChunkAck(payload);
|
||||
}
|
||||
|
||||
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||
await this.transfer.handleFileRequest(payload);
|
||||
}
|
||||
@@ -210,7 +267,7 @@ export class AttachmentManagerService {
|
||||
|
||||
for (const messageId of messageIds) {
|
||||
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
|
||||
if (await this.persistence.tryRestoreAttachmentFromLocal(attachment)) {
|
||||
if (await this.persistence.tryRestoreAttachmentHostOnly(attachment)) {
|
||||
hasChanges = true;
|
||||
await yieldToAttachmentHydrationLoop();
|
||||
}
|
||||
@@ -324,6 +381,15 @@ export class AttachmentManagerService {
|
||||
return getWatchedAttachmentRoomIdFromUrl(url);
|
||||
}
|
||||
|
||||
private async announceHostedAttachments(): Promise<void> {
|
||||
const currentUserId = await new Promise<string | null>((resolve) => {
|
||||
this.store.select(selectCurrentUserId).pipe(take(1))
|
||||
.subscribe((userId) => resolve(userId));
|
||||
});
|
||||
|
||||
await this.transfer.reannounceHostedAttachments(currentUserId);
|
||||
}
|
||||
|
||||
private isRoomWatched(roomId: string | null | undefined): boolean {
|
||||
return !!roomId && roomId === this.watchedRoomId;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||
@@ -51,6 +52,7 @@ describe('AttachmentPersistenceService', () => {
|
||||
savedPath: '/appdata/photo.png'
|
||||
}
|
||||
])),
|
||||
getAttachmentsForMessage: vi.fn(() => Promise.resolve([])),
|
||||
getMessageById: vi.fn(() => Promise.resolve(null)),
|
||||
saveAttachment: vi.fn(() => Promise.resolve()),
|
||||
deleteAttachmentsForMessage: vi.fn(() => Promise.resolve())
|
||||
@@ -64,6 +66,9 @@ describe('AttachmentPersistenceService', () => {
|
||||
getFileSize: vi.fn(() => Promise.resolve(3)),
|
||||
getFileUrl: vi.fn(() => Promise.resolve(null)),
|
||||
canReadFileChunks: vi.fn(() => true),
|
||||
canCopyFiles: vi.fn(() => true),
|
||||
createWritableFile: vi.fn(async () => '/appdata/server/room/files/setup.exe'),
|
||||
copyFile: vi.fn(async () => true),
|
||||
providesInlineObjectUrl: vi.fn(() => false)
|
||||
};
|
||||
});
|
||||
@@ -75,7 +80,7 @@ describe('AttachmentPersistenceService', () => {
|
||||
AttachmentRuntimeStore,
|
||||
{ provide: DatabaseService, useValue: database },
|
||||
{ provide: AttachmentStorageService, useValue: attachmentStorage },
|
||||
{ provide: Store, useValue: { select: () => ({ pipe: () => ({ subscribe: () => {} }) }) } }
|
||||
{ provide: Store, useValue: { select: () => of('room-1') } }
|
||||
]
|
||||
});
|
||||
|
||||
@@ -94,7 +99,17 @@ describe('AttachmentPersistenceService', () => {
|
||||
});
|
||||
|
||||
it('hydrates blob URLs on demand for a single attachment', async () => {
|
||||
const service = createService();
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
AttachmentPersistenceService,
|
||||
AttachmentRuntimeStore,
|
||||
{ provide: DatabaseService, useValue: database },
|
||||
{ provide: AttachmentStorageService, useValue: attachmentStorage },
|
||||
{ provide: Store, useValue: { select: () => of('room-1') } }
|
||||
]
|
||||
});
|
||||
const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService));
|
||||
const runtimeStore = injector.get(AttachmentRuntimeStore);
|
||||
|
||||
await service.initFromDatabase();
|
||||
|
||||
@@ -108,10 +123,12 @@ describe('AttachmentPersistenceService', () => {
|
||||
savedPath: '/appdata/photo.png',
|
||||
available: false
|
||||
};
|
||||
const versionBefore = runtimeStore.updated();
|
||||
|
||||
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
|
||||
expect(attachment.available).toBe(true);
|
||||
expect(attachment.objectUrl).toMatch(/^blob:/);
|
||||
expect(runtimeStore.updated()).toBeGreaterThan(versionBefore);
|
||||
expect(attachmentStorage.getFileSize).toHaveBeenCalledWith('/appdata/photo.png');
|
||||
expect(attachmentStorage.readFileChunk).toHaveBeenCalled();
|
||||
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
||||
@@ -169,4 +186,81 @@ describe('AttachmentPersistenceService', () => {
|
||||
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
||||
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('copies an external upload path into app data and hydrates generic files without loading a blob', async () => {
|
||||
attachmentStorage.resolveExistingPath
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue('/appdata/server/room/files/setup.exe');
|
||||
|
||||
const service = createService();
|
||||
const attachment = {
|
||||
id: 'att-setup',
|
||||
messageId: 'msg-1',
|
||||
filename: 'setup.exe',
|
||||
size: 628 * 1024 * 1024,
|
||||
mime: 'application/octet-stream',
|
||||
isImage: false,
|
||||
filePath: '/home/ludde/Downloads/setup.exe',
|
||||
available: false
|
||||
};
|
||||
|
||||
await expect(service.ensurePersistedUploadHost(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(attachment.savedPath).toBe('/appdata/server/room/files/setup.exe');
|
||||
expect(attachment.available).toBe(true);
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
expect(attachmentStorage.copyFile).toHaveBeenCalledWith(
|
||||
'/home/ludde/Downloads/setup.exe',
|
||||
'/appdata/server/room/files/setup.exe'
|
||||
);
|
||||
|
||||
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
||||
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
|
||||
expect(database.saveAttachment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores host metadata without hydrating media blobs when display hydration is disabled', async () => {
|
||||
const service = createService();
|
||||
const attachment = {
|
||||
id: 'att-1',
|
||||
messageId: 'msg-1',
|
||||
filename: 'photo.png',
|
||||
size: 3,
|
||||
mime: 'image/png',
|
||||
isImage: true,
|
||||
savedPath: '/appdata/photo.png',
|
||||
available: false
|
||||
};
|
||||
|
||||
await expect(service.tryRestoreAttachmentHostOnly(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(attachment.savedPath).toBe('/appdata/photo.png');
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
expect(attachment.available).toBe(false);
|
||||
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
|
||||
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('revokes display blobs while keeping disk paths for later rehydration', () => {
|
||||
const service = createService();
|
||||
const attachment = {
|
||||
id: 'att-1',
|
||||
messageId: 'msg-1',
|
||||
filename: 'photo.png',
|
||||
size: 3,
|
||||
mime: 'image/png',
|
||||
isImage: true,
|
||||
savedPath: '/appdata/photo.png',
|
||||
available: true,
|
||||
objectUrl: 'blob:http://localhost/abc'
|
||||
};
|
||||
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||
|
||||
expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true);
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
expect(attachment.savedPath).toBe('/appdata/photo.png');
|
||||
expect(revokeSpy).toHaveBeenCalledWith('blob:http://localhost/abc');
|
||||
|
||||
revokeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
decodeBase64ToUint8Array,
|
||||
yieldToAttachmentHydrationLoop
|
||||
} from '../../domain/logic/attachment-blob.rules';
|
||||
import { canRevokeAttachmentDisplayBlob } from '../../domain/logic/attachment-blob-eviction.rules';
|
||||
import { isBlobObjectUrl, needsBlobObjectUrlForInlineDisplay } from '../../domain/logic/attachment-display-url.rules';
|
||||
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
|
||||
import { isAttachmentMedia } from '../../domain/logic/attachment.logic';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -118,7 +120,7 @@ export class AttachmentPersistenceService {
|
||||
}
|
||||
|
||||
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
|
||||
const restored = await this.ensureInlineDisplayObjectUrl(attachment);
|
||||
const restored = await this.ensurePersistedUploadHost(attachment, { hydrateMediaForDisplay: true });
|
||||
|
||||
if (restored) {
|
||||
attachment.requestError = undefined;
|
||||
@@ -127,6 +129,69 @@ export class AttachmentPersistenceService {
|
||||
return restored;
|
||||
}
|
||||
|
||||
async tryRestoreAttachmentHostOnly(attachment: Attachment): Promise<boolean> {
|
||||
return this.ensurePersistedUploadHost(attachment, { hydrateMediaForDisplay: false });
|
||||
}
|
||||
|
||||
revokeAttachmentDisplayBlob(attachment: Attachment): boolean {
|
||||
if (!canRevokeAttachmentDisplayBlob(attachment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.revokeAttachmentObjectUrl(attachment);
|
||||
attachment.objectUrl = undefined;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async ensurePersistedUploadHost(
|
||||
attachment: Attachment,
|
||||
options: { hydrateMediaForDisplay?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
const hydrateMediaForDisplay = options.hydrateMediaForDisplay !== false;
|
||||
const existingPath = await this.attachmentStorage.resolveExistingPath(attachment);
|
||||
|
||||
if (existingPath) {
|
||||
return this.hydrateAttachmentFromStoredPath(attachment, existingPath, hydrateMediaForDisplay);
|
||||
}
|
||||
|
||||
if (!attachment.filePath?.trim() || !this.attachmentStorage.canCopyFiles()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const savedPath = await this.persistUploadCopyFromSourcePath(attachment, attachment.filePath);
|
||||
|
||||
if (!savedPath) {
|
||||
attachment.filePath = undefined;
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.hydrateAttachmentFromStoredPath(attachment, savedPath, hydrateMediaForDisplay);
|
||||
}
|
||||
|
||||
private async hydrateAttachmentFromStoredPath(
|
||||
attachment: Attachment,
|
||||
diskPath: string,
|
||||
hydrateMediaForDisplay = true
|
||||
): Promise<boolean> {
|
||||
attachment.savedPath = diskPath;
|
||||
|
||||
if (isAttachmentMedia(attachment)) {
|
||||
if (!hydrateMediaForDisplay) {
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.ensureInlineDisplayObjectUrl(attachment);
|
||||
}
|
||||
|
||||
attachment.available = true;
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async ensureInlineDisplayObjectUrl(attachment: Attachment): Promise<boolean> {
|
||||
if (!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl)) {
|
||||
return true;
|
||||
@@ -156,6 +221,7 @@ export class AttachmentPersistenceService {
|
||||
this.revokeAttachmentObjectUrl(attachment);
|
||||
attachment.objectUrl = nativeUrl;
|
||||
attachment.available = true;
|
||||
this.runtimeStore.touch();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -330,6 +396,8 @@ export class AttachmentPersistenceService {
|
||||
`${attachment.messageId}:${attachment.id}`,
|
||||
new File([blob], attachment.filename, { type: attachment.mime })
|
||||
);
|
||||
|
||||
this.runtimeStore.touch();
|
||||
}
|
||||
|
||||
private revokeAttachmentObjectUrl(attachment: Attachment): void {
|
||||
|
||||
@@ -12,6 +12,7 @@ export class AttachmentRuntimeStore {
|
||||
private pendingRequests = new Map<string, Set<string>>();
|
||||
private chunkBuffers = new Map<string, (ArrayBuffer | undefined)[]>();
|
||||
private chunkCounts = new Map<string, number>();
|
||||
private announcedHostsByAttachment = new Map<string, Set<string>>();
|
||||
|
||||
touch(): void {
|
||||
this.updated.set(this.updated() + 1);
|
||||
@@ -66,6 +67,25 @@ export class AttachmentRuntimeStore {
|
||||
return this.originalFiles.get(key);
|
||||
}
|
||||
|
||||
deleteOriginalFile(key: string): void {
|
||||
this.originalFiles.delete(key);
|
||||
}
|
||||
|
||||
addAnnouncedHost(requestKey: string, peerId: string): void {
|
||||
const hosts = this.announcedHostsByAttachment.get(requestKey) ?? new Set<string>();
|
||||
|
||||
hosts.add(peerId);
|
||||
this.announcedHostsByAttachment.set(requestKey, hosts);
|
||||
}
|
||||
|
||||
getAnnouncedHosts(requestKey: string): Set<string> {
|
||||
return this.announcedHostsByAttachment.get(requestKey) ?? new Set();
|
||||
}
|
||||
|
||||
deleteAnnouncedHosts(requestKey: string): void {
|
||||
this.announcedHostsByAttachment.delete(requestKey);
|
||||
}
|
||||
|
||||
findOriginalFileByFileId(fileId: string): File | null {
|
||||
for (const [key, file] of this.originalFiles) {
|
||||
if (key.endsWith(`:${fileId}`)) {
|
||||
@@ -160,5 +180,11 @@ export class AttachmentRuntimeStore {
|
||||
this.cancelledTransfers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Array.from(this.announcedHostsByAttachment.keys())) {
|
||||
if (key.startsWith(scopedPrefix)) {
|
||||
this.announcedHostsByAttachment.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
decodeBase64,
|
||||
iterateBlobChunks
|
||||
} from '../../../../shared-kernel';
|
||||
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentTransferTransportService {
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
private readonly chunkAcks = inject(AttachmentChunkAckService);
|
||||
|
||||
decodeBase64(base64: string): Uint8Array {
|
||||
return decodeBase64(base64);
|
||||
@@ -39,6 +41,7 @@ export class AttachmentTransferTransportService {
|
||||
};
|
||||
|
||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||
await this.chunkAcks.waitForAck(messageId, fileId, chunk.index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +87,7 @@ export class AttachmentTransferTransportService {
|
||||
};
|
||||
|
||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||
await this.chunkAcks.waitForAck(messageId, fileId, chunkIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +126,7 @@ export class AttachmentTransferTransportService {
|
||||
};
|
||||
|
||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||
await this.chunkAcks.waitForAck(messageId, fileId, chunkIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
import { AttachmentTransferService } from './attachment-transfer.service';
|
||||
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
|
||||
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
|
||||
|
||||
const MESSAGE_ID = 'msg-1';
|
||||
const FILE_ID = 'file-1';
|
||||
@@ -52,6 +53,7 @@ describe('AttachmentTransferService', () => {
|
||||
resolveExistingPath: ReturnType<typeof vi.fn>;
|
||||
resolveLegacyImagePath: ReturnType<typeof vi.fn>;
|
||||
appendBase64: ReturnType<typeof vi.fn>;
|
||||
appendBytes: ReturnType<typeof vi.fn>;
|
||||
createWritableFile: ReturnType<typeof vi.fn>;
|
||||
deleteFile: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
@@ -60,6 +62,11 @@ describe('AttachmentTransferService', () => {
|
||||
streamFileToPeer: ReturnType<typeof vi.fn>;
|
||||
streamFileFromDiskToPeer: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let chunkAcks: {
|
||||
resolveAck: ReturnType<typeof vi.fn>;
|
||||
waitForAck: ReturnType<typeof vi.fn>;
|
||||
cancelPendingForFile: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let webrtc: {
|
||||
getConnectedPeers: ReturnType<typeof vi.fn>;
|
||||
broadcastMessage: ReturnType<typeof vi.fn>;
|
||||
@@ -88,6 +95,7 @@ describe('AttachmentTransferService', () => {
|
||||
resolveExistingPath: vi.fn(async () => null),
|
||||
resolveLegacyImagePath: vi.fn(async () => null),
|
||||
appendBase64: vi.fn(async () => true),
|
||||
appendBytes: vi.fn(async () => true),
|
||||
createWritableFile: vi.fn(async () => '/appdata/server/room/files/file-1'),
|
||||
deleteFile: vi.fn(async () => true)
|
||||
};
|
||||
@@ -98,6 +106,12 @@ describe('AttachmentTransferService', () => {
|
||||
streamFileFromDiskToPeer: vi.fn(async () => undefined)
|
||||
};
|
||||
|
||||
chunkAcks = {
|
||||
resolveAck: vi.fn(),
|
||||
waitForAck: vi.fn(async () => undefined),
|
||||
cancelPendingForFile: vi.fn()
|
||||
};
|
||||
|
||||
webrtc = {
|
||||
getConnectedPeers: vi.fn(() => [PEER_ID]),
|
||||
broadcastMessage: vi.fn(),
|
||||
@@ -115,7 +129,8 @@ describe('AttachmentTransferService', () => {
|
||||
{ provide: AppI18nService, useValue: { instant: (key: string) => key } },
|
||||
{ provide: AttachmentStorageService, useValue: attachmentStorage },
|
||||
{ provide: AttachmentPersistenceService, useValue: persistence },
|
||||
{ provide: AttachmentTransferTransportService, useValue: transport }
|
||||
{ provide: AttachmentTransferTransportService, useValue: transport },
|
||||
{ provide: AttachmentChunkAckService, useValue: chunkAcks }
|
||||
]
|
||||
});
|
||||
const service = runInInjectionContext(injector, () => injector.get(AttachmentTransferService));
|
||||
@@ -294,17 +309,13 @@ describe('AttachmentTransferService', () => {
|
||||
});
|
||||
|
||||
it('streams a requested file only once while the same request is already in flight', async () => {
|
||||
attachmentStorage.resolveExistingPath.mockResolvedValue(null);
|
||||
|
||||
const service = createService();
|
||||
|
||||
registerIncomingAttachment(9);
|
||||
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File([new Uint8Array(9)], 'photo.png', { type: 'image/png' }));
|
||||
|
||||
let releaseStream: () => void = () => undefined;
|
||||
|
||||
transport.streamFileToPeer.mockImplementation(() => new Promise<void>((resolve) => {
|
||||
releaseStream = resolve;
|
||||
}));
|
||||
|
||||
const firstRequest = service.handleFileRequest({
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
@@ -316,7 +327,6 @@ describe('AttachmentTransferService', () => {
|
||||
fromPeerId: PEER_ID
|
||||
});
|
||||
|
||||
releaseStream();
|
||||
await Promise.all([firstRequest, duplicateRequest]);
|
||||
|
||||
expect(transport.streamFileToPeer).toHaveBeenCalledTimes(1);
|
||||
@@ -364,6 +374,23 @@ describe('AttachmentTransferService', () => {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
function registerIncomingGenericFile(size: number): Attachment {
|
||||
const attachment: Attachment = {
|
||||
id: FILE_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
filename: 'archive.zip',
|
||||
size,
|
||||
mime: 'application/zip',
|
||||
isImage: false,
|
||||
uploaderPeerId: PEER_ID,
|
||||
available: false,
|
||||
receivedBytes: 0
|
||||
};
|
||||
|
||||
runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]);
|
||||
return attachment;
|
||||
}
|
||||
|
||||
it('streams playable media to disk when the store supports streaming', async () => {
|
||||
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
||||
|
||||
@@ -379,7 +406,14 @@ describe('AttachmentTransferService', () => {
|
||||
await vi.waitFor(() => expect(attachment.available).toBe(true));
|
||||
|
||||
expect(attachmentStorage.createWritableFile).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBase64).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBytes).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
|
||||
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, {
|
||||
type: 'file-chunk-ack',
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
index: 0
|
||||
});
|
||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -401,6 +435,18 @@ describe('AttachmentTransferService', () => {
|
||||
expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves chunk ack waiters from inbound ack events', () => {
|
||||
const service = createService();
|
||||
|
||||
service.handleFileChunkAck({
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
index: 2
|
||||
});
|
||||
|
||||
expect(chunkAcks.resolveAck).toHaveBeenCalledWith(MESSAGE_ID, FILE_ID, 2);
|
||||
});
|
||||
|
||||
it('marks a request as pending synchronously so concurrent auto-download triggers cannot double-request', () => {
|
||||
const service = createService();
|
||||
const attachment = registerIncomingAttachment(9);
|
||||
@@ -409,4 +455,276 @@ describe('AttachmentTransferService', () => {
|
||||
|
||||
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(true);
|
||||
});
|
||||
|
||||
it('streams oversized generic files to disk when the store supports streaming', async () => {
|
||||
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
||||
attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 256 * 1024 * 1024);
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
|
||||
|
||||
service.handleFileChunk(chunkPayload(0, 1, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
await vi.waitFor(() => expect(attachment.available).toBe(true));
|
||||
|
||||
expect(attachmentStorage.createWritableFile).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBytes).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
|
||||
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, {
|
||||
type: 'file-chunk-ack',
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
index: 0
|
||||
});
|
||||
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
|
||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('streams large downloads to disk even when attachment metadata still carries a source filePath', async () => {
|
||||
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
||||
attachmentStorage.canPersistSize.mockReturnValue(true);
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
|
||||
|
||||
attachment.filePath = '/home/ludde/archive.zip';
|
||||
|
||||
service.handleFileChunk(chunkPayload(0, 1, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
await vi.waitFor(() => expect(attachment.available).toBe(true));
|
||||
|
||||
expect(attachmentStorage.appendBytes).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
|
||||
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, {
|
||||
type: 'file-chunk-ack',
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
index: 0
|
||||
});
|
||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||
expect(runtimeStore.getChunkBuffer(`${MESSAGE_ID}:${FILE_ID}`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not hydrate media blobs after a disk-streamed download completes', async () => {
|
||||
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingVideo(3);
|
||||
|
||||
service.handleFileChunk(chunkPayload(0, 1, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
await vi.waitFor(() => expect(attachment.available).toBe(true));
|
||||
|
||||
expect(attachment.savedPath).toBeTruthy();
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects oversized browser downloads before requesting peers', async () => {
|
||||
attachmentStorage.canStreamToDisk.mockReturnValue(false);
|
||||
attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 50 * 1024 * 1024);
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingGenericFile(200 * 1024 * 1024);
|
||||
|
||||
await service.requestFromAnyPeer(MESSAGE_ID, attachment);
|
||||
|
||||
expect(attachment.requestError).toBe('attachment.errors.fileTooLarge');
|
||||
expect(webrtc.sendToPeer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('assembles browser-sized generic files in memory when streaming is unavailable', async () => {
|
||||
attachmentStorage.canStreamToDisk.mockReturnValue(false);
|
||||
attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 50 * 1024 * 1024);
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingGenericFile(3);
|
||||
|
||||
service.handleFileChunk(chunkPayload(0, 1, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
await vi.waitFor(() => expect(attachment.available).toBe(true));
|
||||
|
||||
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
|
||||
expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('copies oversized generic uploads with a source path into app data when publishing', async () => {
|
||||
attachmentStorage.canCopyFiles.mockReturnValue(true);
|
||||
attachmentStorage.canPersistSize.mockReturnValue(true);
|
||||
persistence.persistUploadCopyFromSourcePath.mockImplementation(async (attachment) => {
|
||||
attachment.savedPath = '/appdata/server/room/files/setup.exe';
|
||||
return attachment.savedPath;
|
||||
});
|
||||
|
||||
const service = createService();
|
||||
const file = new File([new Uint8Array(11 * 1024 * 1024)], 'setup.exe', { type: 'application/octet-stream' });
|
||||
|
||||
Object.defineProperty(file, 'path', { value: '/home/ludde/setup.exe' });
|
||||
|
||||
await service.publishAttachments(MESSAGE_ID, [file], PEER_ID);
|
||||
|
||||
expect(persistence.persistUploadCopyFromSourcePath).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('streams a restored oversized generic file from app data when the in-memory upload is gone', async () => {
|
||||
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
|
||||
|
||||
attachment.savedPath = '/appdata/server/room/files/setup.exe';
|
||||
|
||||
await service.handleFileRequest({
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
fromPeerId: 'peer-2'
|
||||
});
|
||||
|
||||
expect(transport.streamFileFromDiskToPeer).toHaveBeenCalledWith(
|
||||
'peer-2',
|
||||
MESSAGE_ID,
|
||||
FILE_ID,
|
||||
'/appdata/server/room/files/setup.exe',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('re-announces hosted attachments that can still be served from disk', async () => {
|
||||
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
|
||||
|
||||
attachment.uploaderPeerId = PEER_ID;
|
||||
attachment.savedPath = '/appdata/server/room/files/setup.exe';
|
||||
attachment.available = true;
|
||||
|
||||
await service.reannounceHostedAttachments(PEER_ID);
|
||||
|
||||
expect(webrtc.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'file-announce',
|
||||
messageId: MESSAGE_ID,
|
||||
file: expect.objectContaining({ id: FILE_ID })
|
||||
}));
|
||||
});
|
||||
|
||||
it('requests a mirror host before the original uploader when both announced the file', async () => {
|
||||
const uploaderPeer = 'uploader-peer';
|
||||
const mirrorPeer = 'mirror-peer';
|
||||
|
||||
webrtc.getConnectedPeers.mockReturnValue([uploaderPeer, mirrorPeer]);
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingAttachment(3_000);
|
||||
|
||||
attachment.uploaderPeerId = uploaderPeer;
|
||||
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, uploaderPeer);
|
||||
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, mirrorPeer);
|
||||
|
||||
await service.requestFromAnyPeer(MESSAGE_ID, attachment);
|
||||
|
||||
expect(webrtc.sendToPeer).toHaveBeenCalledWith(mirrorPeer, expect.objectContaining({
|
||||
type: 'file-request',
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID
|
||||
}));
|
||||
});
|
||||
|
||||
it('records announced hosts from incoming file-announce payloads', () => {
|
||||
const service = createService();
|
||||
|
||||
service.handleFileAnnounce({
|
||||
messageId: MESSAGE_ID,
|
||||
fromPeerId: 'mirror-peer',
|
||||
file: {
|
||||
id: FILE_ID,
|
||||
filename: 'photo.png',
|
||||
size: 3,
|
||||
mime: 'image/png',
|
||||
isImage: true,
|
||||
uploaderPeerId: 'uploader-peer'
|
||||
}
|
||||
});
|
||||
|
||||
expect(runtimeStore.getAnnouncedHosts(`${MESSAGE_ID}:${FILE_ID}`).has('mirror-peer')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not register duplicate attachment metadata on repeat file-announce', () => {
|
||||
const service = createService();
|
||||
const announce = {
|
||||
messageId: MESSAGE_ID,
|
||||
fromPeerId: 'uploader-peer',
|
||||
file: {
|
||||
id: FILE_ID,
|
||||
filename: 'photo.png',
|
||||
size: 3,
|
||||
mime: 'image/png',
|
||||
isImage: true,
|
||||
uploaderPeerId: 'uploader-peer'
|
||||
}
|
||||
};
|
||||
|
||||
expect(service.handleFileAnnounce(announce)).toBe(true);
|
||||
expect(service.handleFileAnnounce(announce)).toBe(false);
|
||||
expect(runtimeStore.getAttachmentsForMessage(MESSAGE_ID)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prefers streaming from disk over an in-memory original file when both exist', async () => {
|
||||
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
|
||||
|
||||
attachment.savedPath = '/appdata/server/room/files/setup.exe';
|
||||
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File(['x'], 'setup.exe'));
|
||||
|
||||
await service.handleFileRequest({
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
fromPeerId: 'peer-2'
|
||||
});
|
||||
|
||||
expect(transport.streamFileFromDiskToPeer).toHaveBeenCalled();
|
||||
expect(transport.streamFileToPeer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases the in-memory upload copy after persisting a large generic file to disk', async () => {
|
||||
attachmentStorage.canCopyFiles.mockReturnValue(true);
|
||||
attachmentStorage.canPersistSize.mockReturnValue(true);
|
||||
persistence.persistUploadCopyFromSourcePath.mockImplementation(async (attachment) => {
|
||||
attachment.savedPath = '/appdata/server/room/files/setup.exe';
|
||||
return attachment.savedPath;
|
||||
});
|
||||
|
||||
const service = createService();
|
||||
const file = new File([new Uint8Array(11 * 1024 * 1024)], 'setup.exe', { type: 'application/octet-stream' });
|
||||
|
||||
Object.defineProperty(file, 'path', { value: '/home/ludde/setup.exe' });
|
||||
|
||||
await service.publishAttachments(MESSAGE_ID, [file], PEER_ID);
|
||||
|
||||
const attachment = runtimeStore.getAttachmentsForMessage(MESSAGE_ID)[0];
|
||||
|
||||
expect(runtimeStore.getOriginalFile(`${MESSAGE_ID}:${attachment.id}`)).toBeUndefined();
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
expect(attachment.available).toBe(true);
|
||||
expect(attachment.savedPath).toBe('/appdata/server/room/files/setup.exe');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,16 +8,23 @@ import { selectCurrentUserId } from '../../../../store/users/users.selectors';
|
||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
|
||||
import { isImageAttachment, resolvePublishAttachmentIsImage } from '../../domain/logic/attachment-image.rules';
|
||||
import { isSharingFromThisDevice } from '../../domain/logic/attachment-sharing.rules';
|
||||
import { shouldCopyUploaderMediaToAppData, shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
|
||||
import { base64DecodedByteLength, decodeBase64ToUint8Array } from '../../domain/logic/attachment-blob.rules';
|
||||
import { isSharingFromThisDevice, canHostAttachment } from '../../domain/logic/attachment-sharing.rules';
|
||||
import { selectFileRequestPeer } from '../../domain/logic/attachment-request.rules';
|
||||
import {
|
||||
canReceiveAttachment,
|
||||
shouldCopyLargeUploaderFileToAppData,
|
||||
shouldPersistDownloadedAttachment,
|
||||
shouldStreamAttachmentReceiveToDisk
|
||||
} from '../../domain/logic/attachment.logic';
|
||||
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
||||
import {
|
||||
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
|
||||
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
|
||||
DEFAULT_ATTACHMENT_MIME_TYPE,
|
||||
ATTACHMENT_DOWNLOAD_FAILED_KEY,
|
||||
ATTACHMENT_FILE_TOO_LARGE_KEY,
|
||||
ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY,
|
||||
ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY,
|
||||
ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY,
|
||||
ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY,
|
||||
FILE_NOT_FOUND_REQUEST_ERROR_KEY,
|
||||
@@ -30,6 +37,8 @@ import {
|
||||
type FileCancelEvent,
|
||||
type FileCancelPayload,
|
||||
type FileChunkPayload,
|
||||
type FileChunkAckPayload,
|
||||
type FileChunkAckEvent,
|
||||
type FileNotFoundEvent,
|
||||
type FileNotFoundPayload,
|
||||
type FileRequestEvent,
|
||||
@@ -39,6 +48,7 @@ import {
|
||||
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
|
||||
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
|
||||
|
||||
interface DiskReceiveAssembly {
|
||||
path: string;
|
||||
@@ -79,9 +89,10 @@ export class AttachmentTransferService {
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
private readonly persistence = inject(AttachmentPersistenceService);
|
||||
private readonly transport = inject(AttachmentTransferTransportService);
|
||||
private readonly chunkAcks = inject(AttachmentChunkAckService);
|
||||
|
||||
private readonly diskReceiveAssemblies = new Map<string, DiskReceiveAssembly>();
|
||||
private readonly diskReceiveChains = new Map<string, Promise<void>>();
|
||||
private readonly diskReceiveLocks = new Map<string, Promise<void>>();
|
||||
private readonly activeOutboundTransfers = new Set<string>();
|
||||
|
||||
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
|
||||
@@ -188,6 +199,13 @@ export class AttachmentTransferService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canReceiveAttachment(attachment, this.receiveCapabilities())) {
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY);
|
||||
this.runtimeStore.touch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (clearedRequestError)
|
||||
this.runtimeStore.touch();
|
||||
|
||||
@@ -261,6 +279,7 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
await this.persistPublishedAttachment(attachment, file);
|
||||
this.releaseInMemoryUploadCopyIfPersisted(`${messageId}:${fileId}`, attachment);
|
||||
|
||||
const fileAnnounceEvent: FileAnnounceEvent = {
|
||||
type: 'file-announce',
|
||||
@@ -288,17 +307,23 @@ export class AttachmentTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||
handleFileAnnounce(payload: FileAnnouncePayload): boolean {
|
||||
const { messageId, file } = payload;
|
||||
|
||||
if (!messageId || !file)
|
||||
return;
|
||||
if (!messageId || !file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.fromPeerId) {
|
||||
this.runtimeStore.addAnnouncedHost(this.buildRequestKey(messageId, file.id), payload.fromPeerId);
|
||||
}
|
||||
|
||||
const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
|
||||
const alreadyKnown = list.find((entry) => entry.id === file.id);
|
||||
|
||||
if (alreadyKnown)
|
||||
return;
|
||||
if (alreadyKnown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: file.id,
|
||||
@@ -320,6 +345,8 @@ export class AttachmentTransferService {
|
||||
this.runtimeStore.setAttachmentsForMessage(messageId, list);
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handleFileChunk(payload: FileChunkPayload): void {
|
||||
@@ -344,12 +371,14 @@ export class AttachmentTransferService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.shouldReceiveToDisk(attachment) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
if (!canReceiveAttachment(attachment, this.receiveCapabilities())) {
|
||||
attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY);
|
||||
this.runtimeStore.touch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldReceiveToDisk(attachment)) {
|
||||
this.enqueueDiskFileChunk(attachment, {
|
||||
void this.receiveDiskChunk(attachment, {
|
||||
data,
|
||||
fileId,
|
||||
fromPeerId,
|
||||
@@ -361,6 +390,12 @@ export class AttachmentTransferService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY);
|
||||
this.runtimeStore.touch();
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedBytes = this.transport.decodeBase64(data);
|
||||
const assemblyKey = `${messageId}:${fileId}`;
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
@@ -378,10 +413,21 @@ export class AttachmentTransferService {
|
||||
|
||||
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
|
||||
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
|
||||
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
|
||||
this.updateTransferProgress(attachment, decodedBytes.byteLength, fromPeerId);
|
||||
|
||||
this.runtimeStore.touch();
|
||||
void this.finalizeTransferIfComplete(attachment, assemblyKey, total);
|
||||
this.emitChunkAck({ fileId, fromPeerId, index, messageId });
|
||||
}
|
||||
|
||||
handleFileChunkAck(payload: FileChunkAckPayload): void {
|
||||
const { messageId, fileId, index } = payload;
|
||||
|
||||
if (!messageId || !fileId || typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chunkAcks.resolveAck(messageId, fileId, index);
|
||||
}
|
||||
|
||||
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||
@@ -495,21 +541,6 @@ export class AttachmentTransferService {
|
||||
fromPeerId: string
|
||||
): Promise<void> {
|
||||
const exactKey = `${messageId}:${fileId}`;
|
||||
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
|
||||
?? this.runtimeStore.findOriginalFileByFileId(fileId);
|
||||
|
||||
if (originalFile) {
|
||||
await this.transport.streamFileToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
originalFile,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
const attachment = list.find((entry) => entry.id === fileId);
|
||||
const diskPath = attachment
|
||||
@@ -528,6 +559,21 @@ export class AttachmentTransferService {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
|
||||
?? this.runtimeStore.findOriginalFileByFileId(fileId);
|
||||
|
||||
if (originalFile) {
|
||||
await this.transport.streamFileToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
originalFile,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment?.isImage) {
|
||||
const roomName = await this.persistence.resolveCurrentRoomName();
|
||||
const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath(
|
||||
@@ -614,14 +660,13 @@ export class AttachmentTransferService {
|
||||
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>();
|
||||
|
||||
let targetPeerId: string | undefined;
|
||||
|
||||
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) {
|
||||
targetPeerId = preferredPeerId;
|
||||
} else {
|
||||
targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId));
|
||||
}
|
||||
const announcedHosts = this.runtimeStore.getAnnouncedHosts(requestKey);
|
||||
const targetPeerId = selectFileRequestPeer({
|
||||
connectedPeers,
|
||||
triedPeers,
|
||||
announcedHosts,
|
||||
uploaderPeerId: preferredPeerId
|
||||
});
|
||||
|
||||
if (!targetPeerId) {
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
@@ -661,16 +706,16 @@ export class AttachmentTransferService {
|
||||
|
||||
private updateTransferProgress(
|
||||
attachment: Attachment,
|
||||
decodedBytes: Uint8Array,
|
||||
chunkByteLength: number,
|
||||
fromPeerId?: string
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const previousReceived = attachment.receivedBytes ?? 0;
|
||||
|
||||
attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
|
||||
attachment.receivedBytes = previousReceived + chunkByteLength;
|
||||
|
||||
if (fromPeerId) {
|
||||
recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now);
|
||||
recordDebugNetworkFileChunk(fromPeerId, chunkByteLength, now);
|
||||
}
|
||||
|
||||
if (!attachment.startedAtMs)
|
||||
@@ -680,7 +725,7 @@ export class AttachmentTransferService {
|
||||
attachment.lastUpdateMs = now;
|
||||
|
||||
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
|
||||
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000;
|
||||
const instantaneousBps = (chunkByteLength / elapsedMs) * 1000;
|
||||
const previousSpeed = attachment.speedBps ?? instantaneousBps;
|
||||
|
||||
attachment.speedBps =
|
||||
@@ -729,6 +774,7 @@ export class AttachmentTransferService {
|
||||
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
void this.announceLocalHost(attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -748,7 +794,7 @@ export class AttachmentTransferService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldCopyUploaderMediaToAppData(
|
||||
if (shouldCopyLargeUploaderFileToAppData(
|
||||
attachment,
|
||||
attachment.filePath,
|
||||
this.attachmentStorage.canCopyFiles()
|
||||
@@ -766,6 +812,81 @@ export class AttachmentTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
async reannounceHostedAttachments(currentUserId: string | null | undefined): Promise<void> {
|
||||
if (!currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
||||
for (const attachment of attachments) {
|
||||
if (!canHostAttachment(attachment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canServe = await this.attachmentStorage.resolveExistingPath(attachment);
|
||||
|
||||
if (!canServe) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.announceLocalHost(attachment, currentUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private releaseInMemoryUploadCopyIfPersisted(exactKey: string, attachment: Attachment): void {
|
||||
if (!attachment.savedPath?.trim() || attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runtimeStore.deleteOriginalFile(exactKey);
|
||||
|
||||
if (!attachment.objectUrl?.startsWith('blob:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
URL.revokeObjectURL(attachment.objectUrl);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!this.isPlayableMedia(attachment)) {
|
||||
attachment.objectUrl = undefined;
|
||||
attachment.available = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async announceLocalHost(attachment: Attachment, hostPeerId?: string | null): Promise<void> {
|
||||
if (!canHostAttachment(attachment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const announcingPeerId = hostPeerId ?? await this.resolveCurrentUserId();
|
||||
|
||||
if (!announcingPeerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runtimeStore.addAnnouncedHost(
|
||||
this.buildRequestKey(attachment.messageId, attachment.id),
|
||||
announcingPeerId
|
||||
);
|
||||
|
||||
const fileAnnounceEvent: FileAnnounceEvent = {
|
||||
type: 'file-announce',
|
||||
messageId: attachment.messageId,
|
||||
file: {
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
mime: attachment.mime,
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId: attachment.uploaderPeerId
|
||||
}
|
||||
};
|
||||
|
||||
this.webrtc.broadcastMessage(fileAnnounceEvent);
|
||||
}
|
||||
|
||||
private async applySavedPathObjectUrl(attachment: Attachment, savedPath: string | null): Promise<void> {
|
||||
if (!savedPath) {
|
||||
return;
|
||||
@@ -784,37 +905,57 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
private shouldReceiveToDisk(attachment: Attachment): boolean {
|
||||
return this.isPlayableMedia(attachment) &&
|
||||
!attachment.filePath &&
|
||||
this.attachmentStorage.canStreamToDisk() &&
|
||||
this.attachmentStorage.canPersistSize(attachment.size);
|
||||
return shouldStreamAttachmentReceiveToDisk(attachment, this.receiveCapabilities());
|
||||
}
|
||||
|
||||
private enqueueDiskFileChunk(
|
||||
attachment: Attachment,
|
||||
payload: ValidFileChunkPayload
|
||||
): void {
|
||||
private receiveCapabilities() {
|
||||
return {
|
||||
canStreamToDisk: this.attachmentStorage.canStreamToDisk(),
|
||||
canPersistSize: (bytes: number) => this.attachmentStorage.canPersistSize(bytes)
|
||||
};
|
||||
}
|
||||
|
||||
private receiveDiskChunk(attachment: Attachment, payload: ValidFileChunkPayload): void {
|
||||
const assemblyKey = `${payload.messageId}:${payload.fileId}`;
|
||||
const previous = this.diskReceiveChains.get(assemblyKey) ?? Promise.resolve();
|
||||
const previous = this.diskReceiveLocks.get(assemblyKey) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.handleDiskFileChunk(attachment, assemblyKey, payload))
|
||||
.then(async () => {
|
||||
await this.handleDiskFileChunk(attachment, assemblyKey, payload);
|
||||
this.emitChunkAck(payload);
|
||||
})
|
||||
.catch((error: unknown) => this.handleDiskReceiveFailure(attachment, assemblyKey, error));
|
||||
|
||||
this.diskReceiveChains.set(assemblyKey, next);
|
||||
this.diskReceiveLocks.set(assemblyKey, next);
|
||||
void next.finally(() => {
|
||||
if (this.diskReceiveChains.get(assemblyKey) === next) {
|
||||
this.diskReceiveChains.delete(assemblyKey);
|
||||
if (this.diskReceiveLocks.get(assemblyKey) === next) {
|
||||
this.diskReceiveLocks.delete(assemblyKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private emitChunkAck(payload: Pick<ValidFileChunkPayload, 'fileId' | 'fromPeerId' | 'index' | 'messageId'>): void {
|
||||
if (!payload.fromPeerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ack: FileChunkAckEvent = {
|
||||
type: 'file-chunk-ack',
|
||||
messageId: payload.messageId,
|
||||
fileId: payload.fileId,
|
||||
index: payload.index
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(payload.fromPeerId, ack);
|
||||
}
|
||||
|
||||
private async handleDiskFileChunk(
|
||||
attachment: Attachment,
|
||||
assemblyKey: string,
|
||||
payload: ValidFileChunkPayload
|
||||
): Promise<void> {
|
||||
const decodedBytes = this.transport.decodeBase64(payload.data);
|
||||
const chunkByteLength = base64DecodedByteLength(payload.data);
|
||||
const chunkBytes = decodeBase64ToUint8Array(payload.data);
|
||||
const requestKey = this.buildRequestKey(payload.messageId, payload.fileId);
|
||||
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
@@ -834,7 +975,7 @@ export class AttachmentTransferService {
|
||||
throw new Error(this.appI18n.instant(ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY));
|
||||
}
|
||||
|
||||
const didAppend = await this.attachmentStorage.appendBase64(assembly.path, payload.data);
|
||||
const didAppend = await this.attachmentStorage.appendBytes(assembly.path, chunkBytes);
|
||||
|
||||
if (!didAppend) {
|
||||
throw new Error(this.appI18n.instant(ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY));
|
||||
@@ -842,7 +983,7 @@ export class AttachmentTransferService {
|
||||
|
||||
assembly.receivedIndexes.add(payload.index);
|
||||
assembly.receivedCount += 1;
|
||||
this.updateTransferProgress(attachment, decodedBytes, payload.fromPeerId);
|
||||
this.updateTransferProgress(attachment, chunkByteLength, payload.fromPeerId);
|
||||
this.runtimeStore.touch();
|
||||
|
||||
if (assembly.receivedCount < assembly.total) {
|
||||
@@ -850,17 +991,12 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
attachment.savedPath = assembly.path;
|
||||
|
||||
const restoredForDisplay = await this.persistence.ensureInlineDisplayObjectUrl(attachment);
|
||||
|
||||
if (!restoredForDisplay) {
|
||||
throw new Error(this.appI18n.instant(ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY));
|
||||
}
|
||||
|
||||
attachment.available = true;
|
||||
attachment.objectUrl = undefined;
|
||||
this.diskReceiveAssemblies.delete(assemblyKey);
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
void this.announceLocalHost(attachment);
|
||||
}
|
||||
|
||||
private async getOrCreateDiskReceiveAssembly(
|
||||
|
||||
@@ -22,3 +22,4 @@ export const ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY = 'attachment.errors.chunksOutOf
|
||||
export const ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY = 'attachment.errors.writeDownloadFailed';
|
||||
export const ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY = 'attachment.errors.openDownloadFailed';
|
||||
export const ATTACHMENT_DOWNLOAD_FAILED_KEY = 'attachment.errors.downloadFailed';
|
||||
export const ATTACHMENT_FILE_TOO_LARGE_KEY = 'attachment.errors.fileTooLarge';
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
buildAttachmentDisplayPinKey,
|
||||
canRevokeAttachmentDisplayBlob,
|
||||
shouldRevokeDisplayBlobForAttachment
|
||||
} from './attachment-blob-eviction.rules';
|
||||
|
||||
describe('attachment-blob-eviction rules', () => {
|
||||
it('builds a stable pin key from message and attachment ids', () => {
|
||||
expect(buildAttachmentDisplayPinKey('msg-1', 'att-1')).toBe('msg-1:att-1');
|
||||
});
|
||||
|
||||
it('allows revoking blob urls when a disk path can rehydrate the attachment', () => {
|
||||
expect(canRevokeAttachmentDisplayBlob({
|
||||
objectUrl: 'blob:http://localhost/abc',
|
||||
savedPath: '/appdata/photo.png',
|
||||
receivedBytes: 0,
|
||||
available: true
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to revoke blobs that are the only local copy', () => {
|
||||
expect(canRevokeAttachmentDisplayBlob({
|
||||
objectUrl: 'blob:http://localhost/abc',
|
||||
receivedBytes: 0,
|
||||
available: true
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to revoke blobs while a download is still in progress', () => {
|
||||
expect(canRevokeAttachmentDisplayBlob({
|
||||
objectUrl: 'blob:http://localhost/abc',
|
||||
savedPath: '/appdata/photo.png',
|
||||
receivedBytes: 1024,
|
||||
available: false
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('skips revocation for pinned attachments', () => {
|
||||
const attachment = {
|
||||
id: 'att-1',
|
||||
objectUrl: 'blob:http://localhost/abc',
|
||||
savedPath: '/appdata/photo.png',
|
||||
receivedBytes: 0,
|
||||
available: true
|
||||
};
|
||||
|
||||
expect(shouldRevokeDisplayBlobForAttachment(
|
||||
'msg-1',
|
||||
attachment,
|
||||
new Set([buildAttachmentDisplayPinKey('msg-1', 'att-1')])
|
||||
)).toBe(false);
|
||||
|
||||
expect(shouldRevokeDisplayBlobForAttachment('msg-1', attachment, new Set())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { isBlobObjectUrl } from './attachment-display-url.rules';
|
||||
|
||||
/** Margin around the chat scrollport used to hydrate blobs before they enter view. */
|
||||
export const ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN = '200px';
|
||||
|
||||
export interface AttachmentDisplayBlobCandidate {
|
||||
available?: boolean;
|
||||
filePath?: string;
|
||||
objectUrl?: string;
|
||||
receivedBytes?: number;
|
||||
savedPath?: string;
|
||||
}
|
||||
|
||||
export function buildAttachmentDisplayPinKey(messageId: string, attachmentId: string): string {
|
||||
return `${messageId}:${attachmentId}`;
|
||||
}
|
||||
|
||||
export function canRevokeAttachmentDisplayBlob(
|
||||
attachment: AttachmentDisplayBlobCandidate
|
||||
): boolean {
|
||||
if (!attachment.objectUrl || !isBlobObjectUrl(attachment.objectUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasNonEmptyString(attachment.savedPath) && !hasNonEmptyString(attachment.filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((attachment.receivedBytes ?? 0) > 0 && attachment.available !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldRevokeDisplayBlobForAttachment(
|
||||
messageId: string,
|
||||
attachment: AttachmentDisplayBlobCandidate & { id: string },
|
||||
pinnedKeys: ReadonlySet<string>
|
||||
): boolean {
|
||||
if (pinnedKeys.has(buildAttachmentDisplayPinKey(messageId, attachment.id))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return canRevokeAttachmentDisplayBlob(attachment);
|
||||
}
|
||||
|
||||
function hasNonEmptyString(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { decodeBase64ToUint8Array } from './attachment-blob.rules';
|
||||
import {
|
||||
base64DecodedByteLength,
|
||||
decodeBase64ToUint8Array
|
||||
} from './attachment-blob.rules';
|
||||
|
||||
describe('attachment blob rules', () => {
|
||||
it('decodes base64 payloads into byte arrays', () => {
|
||||
@@ -16,4 +19,9 @@ describe('attachment blob rules', () => {
|
||||
67
|
||||
]);
|
||||
});
|
||||
|
||||
it('estimates decoded base64 byte length without allocating bytes', () => {
|
||||
expect(base64DecodedByteLength('QUJD')).toBe(3);
|
||||
expect(base64DecodedByteLength('YQ==')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,13 @@ export function encodeUint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Returns the decoded byte length of a base64 payload without allocating the bytes. */
|
||||
export function base64DecodedByteLength(base64: string): number {
|
||||
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
||||
|
||||
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
|
||||
}
|
||||
|
||||
/** Yield control back to the browser so long attachment hydration cannot freeze Electron. */
|
||||
export function yieldToAttachmentHydrationLoop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { buildAttachmentChunkAckKey } from './attachment-chunk-ack.rules';
|
||||
|
||||
describe('attachment-chunk-ack rules', () => {
|
||||
it('builds a stable ack key from message, file, and chunk index', () => {
|
||||
expect(buildAttachmentChunkAckKey('msg-1', 'file-1', 42)).toBe('msg-1:file-1:42');
|
||||
});
|
||||
});
|
||||