Compare commits

...

10 Commits

Author SHA1 Message Date
Myx
bb0ac930ad Improve attachment memory safety, downloads, and high-memory alert UX.
All checks were successful
Queue Release Build / prepare (push) Successful in 20s
Deploy Web Apps / deploy (push) Successful in 9m2s
Queue Release Build / build-windows (push) Successful in 28m8s
Queue Release Build / build-linux (push) Successful in 47m26s
Queue Release Build / build-android (push) Successful in 19m52s
Queue Release Build / finalize (push) Successful in 4m42s
Stream large receives to disk with chunk acks to cap renderer RAM, evict
off-screen display blobs, and route exports through a disk-aware download
service. Fix the high-memory dialog (backdrop dismiss, copy, log actions),
allow diagnostics paths in the path jail, and restore persisted image
hydration after reload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 00:25:22 +02:00
Myx
f0d79aa627 fix: Bug - Files lose host on reload
Persist large uploads under app data on publish and restore, and re-announce hosted attachments after reload so peers can download again.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 22:04:41 +02:00
Myx
95259e8943 fix: Bug - Sending files between users doesn't really work
Stream oversized generic attachments to disk instead of silently dropping chunks, avoid loading completed file downloads into renderer memory, and surface a clear error when the browser client cannot receive a file.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 21:50:21 +02:00
Myx
924d4bbb1d fix: Bug - In direct voice call the status is displayed as offline
Resolve direct-call participant join state and DM peer status across user identity aliases so call UI no longer shows participants as disconnected when they are in the call.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 21:31:03 +02:00
Myx
baa350e90a fix: Bug - Users doesn't receive dm messages
Match direct messages against every local identity alias (home id and provisioned signal-server actor ids) so recipients accept traffic addressed to their per-server presence id instead of silently dropping it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 20:59:57 +02:00
Myx
b2a2d9d770 fix: Bug - Users appear as both online and offline
Align chat message sender ids with per-server presence identities so profile cards opened from message authors resolve the same live user state as the members panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 20:55:13 +02:00
Myx
c3c2f01cc6 fix: Bug - User automatically leaves voice after short period of time
All checks were successful
Queue Release Build / prepare (push) Successful in 22s
Deploy Web Apps / deploy (push) Successful in 7m32s
Queue Release Build / build-windows (push) Successful in 27m41s
Queue Release Build / build-linux (push) Successful in 44m56s
Queue Release Build / build-android (push) Successful in 18m52s
Queue Release Build / finalize (push) Successful in 21s
Ignore stale P2P self-disconnect voice-state echoes while this client actively owns voice, refresh noise-reduction input on re-join, and repair dual-signal E2E harness expectations.

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 00:52:59 +02:00
130 changed files with 5465 additions and 690 deletions

View File

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

View File

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

View File

@@ -8,10 +8,6 @@ interface ScreenShareMediaStream extends MediaStream {
__isScreenShare?: boolean;
}
function webRtcHarnessWindow(scope: Window = window): WebRtcTestHarnessWindow {
return scope as unknown as WebRtcTestHarnessWindow;
}
/**
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
* Tracks all created peer connections and their remote tracks so tests
@@ -32,7 +28,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
source?: AudioScheduledSourceNode;
drawIntervalId?: number;
}[] = [];
const harness = webRtcHarnessWindow();
const harness = window as unknown as WebRtcTestHarnessWindow;
harness.__rtcConnections = connections;
harness.__rtcDataChannels = dataChannels;
@@ -160,6 +156,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
return resultStream;
};
});
}
@@ -181,7 +178,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
await page.addInitScript(() => {
const OrigAudioContext = window.AudioContext;
const audioHarness = webRtcHarnessWindow();
const audioHarness = window as unknown as WebRtcTestHarnessWindow;
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
const ctx: AudioContext = new OrigAudioContext(...args);
@@ -211,7 +208,7 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
await page.waitForFunction(
() => webRtcHarnessWindow().__rtcConnections?.some(
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false,
undefined,
@@ -224,7 +221,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
*/
export async function isPeerStillConnected(page: Page): Promise<boolean> {
return page.evaluate(
() => webRtcHarnessWindow().__rtcConnections?.some(
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false
);
@@ -233,7 +230,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
/** Returns the number of tracked peer connections in `connected` state. */
export async function getConnectedPeerCount(page: Page): Promise<number> {
return page.evaluate(
() => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected'
).length ?? 0
);
@@ -241,19 +238,36 @@ export async function getConnectedPeerCount(page: Page): Promise<number> {
/** Wait until the expected number of peer connections are `connected`. */
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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();
});
});

View 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.
}
}

View 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();
});
});

View 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);
}
}

View File

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

View File

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

View File

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

View File

@@ -1,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';

View File

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

View File

@@ -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);
});
});

View 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();
}

View File

@@ -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);

View File

@@ -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');

View File

@@ -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 {

View File

@@ -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),

View File

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

View File

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

View File

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

View File

@@ -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"
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -441,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",

View File

@@ -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": {
@@ -1496,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",

View File

@@ -167,6 +167,7 @@
<app-incoming-call-modal />
<app-screen-share-source-picker />
<app-native-context-menu />
<app-high-memory-alert-modal />
<app-debug-console [showLauncher]="false" />
<app-theme-picker-overlay />
</div>

View File

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

View File

@@ -251,6 +251,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 }>;

View File

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

View File

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

View File

@@ -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;
}
}

View File

@@ -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');
});
});

View File

@@ -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;
}
}

View File

@@ -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');
});
});

View File

@@ -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';
}

View File

@@ -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`).

View File

@@ -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']> {

View File

@@ -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();
});
});

View File

@@ -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);
}
}
}

View File

@@ -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();
});
});

View File

@@ -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);
});
}
}

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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 {

View File

@@ -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);
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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');
});
});

View File

@@ -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(

View File

@@ -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';

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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) => {

View File

@@ -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');
});
});

View File

@@ -0,0 +1,3 @@
export function buildAttachmentChunkAckKey(messageId: string, fileId: string, index: number): string {
return `${messageId}:${fileId}:${index}`;
}

View File

@@ -0,0 +1,44 @@
import {
describe,
expect,
it
} from 'vitest';
import {
canDownloadAttachment,
resolveAttachmentDiskPath
} from './attachment-download.rules';
describe('attachment-download.rules', () => {
it('allows download when a completed disk-only attachment has no object URL', () => {
expect(canDownloadAttachment({
available: true,
savedPath: '/appdata/server/room/files/large.bin'
})).toBe(true);
});
it('allows download when a blob object URL is available', () => {
expect(canDownloadAttachment({
available: true,
objectUrl: 'blob:http://localhost/abc'
})).toBe(true);
});
it('rejects incomplete or empty local copies', () => {
expect(canDownloadAttachment({
available: false,
savedPath: '/appdata/server/room/files/large.bin'
})).toBe(false);
expect(canDownloadAttachment({
available: true
})).toBe(false);
});
it('prefers savedPath over filePath for disk export', () => {
expect(resolveAttachmentDiskPath({
savedPath: '/appdata/copy.bin',
filePath: '/home/me/original.bin'
})).toBe('/appdata/copy.bin');
});
});

View File

@@ -0,0 +1,25 @@
import type { Attachment } from '../models/attachment.model';
export function canDownloadAttachment(
attachment: Pick<Attachment, 'available' | 'objectUrl' | 'savedPath' | 'filePath'>
): boolean {
if (attachment.available !== true) {
return false;
}
return hasNonEmptyString(attachment.objectUrl) ||
hasNonEmptyString(attachment.savedPath) ||
hasNonEmptyString(attachment.filePath);
}
export function resolveAttachmentDiskPath(
attachment: Pick<Attachment, 'savedPath' | 'filePath'>
): string | null {
const diskPath = attachment.savedPath?.trim() || attachment.filePath?.trim();
return diskPath || null;
}
function hasNonEmptyString(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}

View File

@@ -7,6 +7,7 @@ import {
import {
dedupeImageAttachmentsForDisplay,
hasImageFilename,
isAttachmentPendingInlineHydration,
isImageAttachment,
isInlineDisplayableImage,
resolvePublishAttachmentIsImage
@@ -38,6 +39,27 @@ describe('attachment-image rules', () => {
})).toBe(true);
});
it('detects images waiting for on-demand blob hydration', () => {
expect(isAttachmentPendingInlineHydration({
id: '1',
filename: 'photo.png',
mime: 'image/png',
isImage: true,
available: false,
savedPath: '/appdata/photo.png'
})).toBe(true);
expect(isAttachmentPendingInlineHydration({
id: '2',
filename: 'photo.png',
mime: 'image/png',
isImage: true,
available: true,
objectUrl: 'blob:http://localhost/photo',
savedPath: '/appdata/photo.png'
})).toBe(false);
});
it('dedupes image attachments by filename and prefers displayable copies', () => {
const deduped = dedupeImageAttachmentsForDisplay([
{

View File

@@ -22,6 +22,7 @@ export interface ImageAttachmentCandidate {
isImage: boolean;
mime: string;
objectUrl?: string;
receivedBytes?: number;
savedPath?: string;
}
@@ -50,6 +51,27 @@ export function isInlineDisplayableImage(
!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl);
}
export function isAttachmentPendingInlineHydration(
attachment: Pick<
ImageAttachmentCandidate,
'available' | 'filePath' | 'filename' | 'isImage' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath'
>
): boolean {
if (isInlineDisplayableImage(attachment)) {
return false;
}
if (!isImageAttachment(attachment)) {
return false;
}
if ((attachment.receivedBytes ?? 0) > 0 && attachment.available !== true) {
return false;
}
return !!(attachment.savedPath?.trim() || attachment.filePath?.trim());
}
export function imageAttachmentDisplayRank(
attachment: Pick<ImageAttachmentCandidate, 'available' | 'filePath' | 'isImage' | 'objectUrl' | 'savedPath'>
): number {

View File

@@ -0,0 +1,47 @@
import { selectFileRequestPeer } from './attachment-request.rules';
describe('selectFileRequestPeer', () => {
const uploader = 'uploader-peer';
const mirror = 'mirror-peer';
const other = 'other-peer';
it('prefers a mirror host over the original uploader when both are available', () => {
expect(selectFileRequestPeer({
connectedPeers: [
uploader,
mirror,
other
],
triedPeers: new Set(),
announcedHosts: new Set([uploader, mirror]),
uploaderPeerId: uploader
})).toBe(mirror);
});
it('falls back to the uploader when no mirror hosts are announced', () => {
expect(selectFileRequestPeer({
connectedPeers: [uploader, other],
triedPeers: new Set(),
announcedHosts: new Set([uploader]),
uploaderPeerId: uploader
})).toBe(uploader);
});
it('skips peers that were already tried', () => {
expect(selectFileRequestPeer({
connectedPeers: [mirror, uploader],
triedPeers: new Set([mirror]),
announcedHosts: new Set([mirror, uploader]),
uploaderPeerId: uploader
})).toBe(uploader);
});
it('returns undefined when every connected peer was already tried', () => {
expect(selectFileRequestPeer({
connectedPeers: [mirror, uploader],
triedPeers: new Set([mirror, uploader]),
announcedHosts: new Set([mirror, uploader]),
uploaderPeerId: uploader
})).toBeUndefined();
});
});

View File

@@ -0,0 +1,39 @@
export interface FileRequestPeerSelectionInput {
connectedPeers: string[];
triedPeers: ReadonlySet<string>;
announcedHosts: ReadonlySet<string>;
uploaderPeerId?: string;
}
/**
* Pick the next peer to request a file from. Mirror hosts (peers that announced
* they hold the bytes) are preferred over the original uploader so the sharer's
* device is not the only upload source.
*/
export function selectFileRequestPeer(input: FileRequestPeerSelectionInput): string | undefined {
const candidates = input.connectedPeers.filter((peerId) => !input.triedPeers.has(peerId));
if (candidates.length === 0) {
return undefined;
}
const mirrorHosts = candidates.filter(
(peerId) => input.announcedHosts.has(peerId) && peerId !== input.uploaderPeerId
);
if (mirrorHosts.length > 0) {
return mirrorHosts[0];
}
if (input.uploaderPeerId && candidates.includes(input.uploaderPeerId)) {
return input.uploaderPeerId;
}
const announcedCandidate = candidates.find((peerId) => input.announcedHosts.has(peerId));
if (announcedCandidate) {
return announcedCandidate;
}
return candidates[0];
}

View File

@@ -1,4 +1,5 @@
import {
canHostAttachment,
deviceHasLocalCopy,
isSharingFromThisDevice,
isUploaderUser
@@ -66,4 +67,10 @@ describe('attachment sharing rules', () => {
).toBe(false);
});
});
describe('canHostAttachment', () => {
it('is true for any device that holds the bytes locally', () => {
expect(canHostAttachment({ available: false, savedPath: '/appdata/file.bin' })).toBe(true);
});
});
});

View File

@@ -35,6 +35,13 @@ export function isSharingFromThisDevice(
return isUploaderUser(attachment, currentUserId) && deviceHasLocalCopy(attachment);
}
/** True when this device can serve the attachment bytes to other peers. */
export function canHostAttachment(
attachment: Pick<Attachment, 'available' | 'objectUrl' | 'savedPath' | 'filePath'>
): boolean {
return deviceHasLocalCopy(attachment);
}
function hasNonEmptyString(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}

View File

@@ -1,7 +1,10 @@
import {
getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId,
shouldCopyUploaderMediaToAppData
shouldCopyUploaderMediaToAppData,
shouldCopyLargeUploaderFileToAppData,
shouldStreamAttachmentReceiveToDisk,
canReceiveAttachment
} from './attachment.logic';
describe('attachment logic', () => {
@@ -33,6 +36,16 @@ describe('attachment logic', () => {
}, '/home/ludde/video.mp4', true)).toBe(true);
});
it('copies any oversized upload with a source path into app data', () => {
expect(shouldCopyLargeUploaderFileToAppData({
size: 628 * 1024 * 1024
}, '/home/ludde/setup.exe', true)).toBe(true);
expect(shouldCopyLargeUploaderFileToAppData({
size: 1024
}, '/home/ludde/setup.exe', true)).toBe(false);
});
it('skips app-data copy for small uploads and missing source paths', () => {
expect(shouldCopyUploaderMediaToAppData({
size: 1024,
@@ -44,4 +57,48 @@ describe('attachment logic', () => {
mime: 'video/mp4'
}, undefined, true)).toBe(false);
});
it('streams any persistable download to disk when the store supports streaming', () => {
const capabilities = {
canStreamToDisk: true,
canPersistSize: (bytes: number) => bytes <= 256 * 1024 * 1024
};
expect(shouldStreamAttachmentReceiveToDisk({
size: 200 * 1024 * 1024,
mime: 'application/zip',
filePath: undefined
}, capabilities)).toBe(true);
expect(shouldStreamAttachmentReceiveToDisk({
size: 3,
mime: 'application/zip',
filePath: undefined
}, capabilities)).toBe(true);
expect(shouldStreamAttachmentReceiveToDisk({
size: 200 * 1024 * 1024,
mime: 'application/zip',
filePath: '/home/ludde/archive.zip'
}, capabilities)).toBe(true);
});
it('receives browser-sized files in memory when disk streaming is unavailable', () => {
const browserCapabilities = {
canStreamToDisk: false,
canPersistSize: (bytes: number) => bytes <= 50 * 1024 * 1024
};
expect(canReceiveAttachment({
size: 20 * 1024 * 1024,
mime: 'application/zip',
filePath: undefined
}, browserCapabilities)).toBe(true);
expect(canReceiveAttachment({
size: 200 * 1024 * 1024,
mime: 'application/zip',
filePath: undefined
}, browserCapabilities)).toBe(false);
});
});

View File

@@ -26,10 +26,18 @@ export function shouldCopyUploaderMediaToAppData(
attachment: Pick<Attachment, 'size' | 'mime'>,
sourcePath?: string | null,
canCopyFiles = false
): boolean {
return shouldCopyLargeUploaderFileToAppData(attachment, sourcePath, canCopyFiles) &&
(attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/'));
}
export function shouldCopyLargeUploaderFileToAppData(
attachment: Pick<Attachment, 'size'>,
sourcePath?: string | null,
canCopyFiles = false
): boolean {
return canCopyFiles &&
!!sourcePath &&
(attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/')) &&
!!sourcePath?.trim() &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
}
@@ -50,6 +58,41 @@ export function isDirectMessageAttachmentRoomId(roomId: string | null | undefine
return !!roomId && roomId.startsWith(DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX);
}
export interface AttachmentReceiveCapabilities {
canStreamToDisk: boolean;
canPersistSize: (bytes: number) => boolean;
}
export function shouldStreamAttachmentReceiveToDisk(
attachment: Pick<Attachment, 'size' | 'mime' | 'filePath'>,
capabilities: AttachmentReceiveCapabilities
): boolean {
if (!capabilities.canStreamToDisk || !capabilities.canPersistSize(attachment.size)) {
return false;
}
return true;
}
export function canReceiveAttachmentInMemory(
attachment: Pick<Attachment, 'size'>,
capabilities: AttachmentReceiveCapabilities
): boolean {
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
return true;
}
return !capabilities.canStreamToDisk && capabilities.canPersistSize(attachment.size);
}
export function canReceiveAttachment(
attachment: Pick<Attachment, 'size' | 'mime' | 'filePath'>,
capabilities: AttachmentReceiveCapabilities
): boolean {
return shouldStreamAttachmentReceiveToDisk(attachment, capabilities)
|| canReceiveAttachmentInMemory(attachment, capabilities);
}
function decodeUrlSegment(value: string): string {
try {
return decodeURIComponent(value);

View File

@@ -17,6 +17,14 @@ export type FileChunkEvent = ChatEvent & {
fromPeerId?: string;
};
export type FileChunkAckEvent = ChatEvent & {
type: 'file-chunk-ack';
messageId: string;
fileId: string;
index: number;
fromPeerId?: string;
};
export type FileRequestEvent = ChatEvent & {
type: 'file-request';
messageId: string;
@@ -37,7 +45,7 @@ export type FileNotFoundEvent = ChatEvent & {
fileId: string;
};
export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file'>;
export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file' | 'fromPeerId'>;
export interface FileChunkPayload {
messageId?: string;
@@ -48,6 +56,13 @@ export interface FileChunkPayload {
data?: ChatEvent['data'];
}
export interface FileChunkAckPayload {
messageId?: string;
fileId?: string;
fromPeerId?: string;
index?: number;
}
export type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
export type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
export type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;

View File

@@ -1,5 +1,7 @@
export * from './application/facades/attachment.facade';
export * from './application/services/attachment-download.service';
export * from './domain/constants/attachment.constants';
export * from './domain/logic/attachment-download.rules';
export * from './domain/logic/attachment-sharing.rules';
export * from './domain/logic/local-file-path.rules';
export * from './domain/models/attachment.model';

View File

@@ -187,6 +187,18 @@ export class AttachmentStorageService {
return this.store.appendFile(filePath, base64Data);
}
async appendBytes(filePath: string, bytes: Uint8Array): Promise<boolean> {
if (!filePath) {
return false;
}
if (this.platform.isElectron) {
return this.electronStore.appendFileBytes(filePath, bytes);
}
return this.appendBase64(filePath, encodeUint8ArrayToBase64(bytes));
}
async deleteFile(filePath: string): Promise<void> {
if (!filePath) {
return;

View File

@@ -74,6 +74,20 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore {
}
}
async appendFileBytes(filePath: string, bytes: Uint8Array): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.appendFileBytes || !filePath) {
return false;
}
try {
return await electronApi.appendFileBytes(filePath, bytes);
} catch {
return false;
}
}
async readFile(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi();

View File

@@ -0,0 +1,49 @@
import type { Message } from '../../../../shared-kernel';
import { resolveIncomingChatMessageSenderId, resolveRoomMessageSenderId } from './message-sender-identity.rules';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 'message-1',
roomId: 'room-1',
senderId: 'home-user-1',
senderName: 'Alice',
content: 'hello',
timestamp: 1,
reactions: [],
isDeleted: false,
...overrides
};
}
describe('message-sender-identity.rules', () => {
it('resolveRoomMessageSenderId uses the per-server actor id', () => {
const senderId = resolveRoomMessageSenderId(
{ id: 'home-user-1', oderId: 'home-oder-1' },
'https://signal.example.com',
(_serverUrl, fallback) => fallback === 'home-oder-1' ? 'server-user-1' : fallback
);
expect(senderId).toBe('server-user-1');
});
it('resolveIncomingChatMessageSenderId prefers relay sender identity over message senderId', () => {
const senderId = resolveIncomingChatMessageSenderId(
createMessage({ senderId: 'home-user-1' }),
{
senderId: 'server-user-1',
fromPeerId: 'peer-1'
}
);
expect(senderId).toBe('server-user-1');
});
it('resolveIncomingChatMessageSenderId falls back to fromPeerId for P2P chat', () => {
const senderId = resolveIncomingChatMessageSenderId(
createMessage({ senderId: 'home-user-1' }),
{ fromPeerId: 'server-user-1' }
);
expect(senderId).toBe('server-user-1');
});
});

View File

@@ -0,0 +1,37 @@
import type { Message } from '../../../../shared-kernel';
/** Resolve the sender id that should be stored for a room chat message. */
export function resolveRoomMessageSenderId(
currentUser: Pick<{ id: string; oderId: string }, 'id' | 'oderId'>,
roomSourceUrl: string | undefined,
resolveActorUserId: (serverUrl: string | undefined, fallbackUserId: string) => string
): string {
const homeUserKey = currentUser.oderId || currentUser.id;
return resolveActorUserId(roomSourceUrl, homeUserKey);
}
interface IncomingChatMessageEnvelope {
senderId?: string;
fromPeerId?: string;
fromUserId?: string;
}
/** Normalize incoming chat sender ids to the per-server identity used by presence. */
export function resolveIncomingChatMessageSenderId(
message: Pick<Message, 'senderId'>,
envelope: IncomingChatMessageEnvelope
): string {
const relayIdentity = [envelope.senderId, envelope.fromUserId]
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
if (relayIdentity) {
return relayIdentity.trim();
}
if (envelope.fromPeerId?.trim()) {
return envelope.fromPeerId.trim();
}
return message.senderId;
}

View File

@@ -12,11 +12,14 @@ import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { v4 as uuidv4 } from 'uuid';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent } from '../../../../shared';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../attachment';
import {
Attachment,
AttachmentDownloadService,
AttachmentFacade
} from '../../../attachment';
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import {
@@ -69,10 +72,10 @@ export class ChatMessagesComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
@ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
private readonly electronBridge = inject(ElectronBridgeService);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly attachmentDownload = inject(AttachmentDownloadService);
private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
@@ -300,6 +303,7 @@ export class ChatMessagesComponent {
return;
}
this.attachmentsSvc.pinDisplayBlobs(attachments);
this.lightboxState.set({
attachments,
index
@@ -307,6 +311,12 @@ export class ChatMessagesComponent {
}
closeLightbox(): void {
const state = this.lightboxState();
if (state) {
this.attachmentsSvc.unpinDisplayBlobs(state.attachments);
}
this.lightboxState.set(null);
}
@@ -336,10 +346,17 @@ export class ChatMessagesComponent {
return;
}
this.attachmentsSvc.pinDisplayBlobs(availableImages);
this.galleryAttachments.set(availableImages);
}
closeImageGallery(): void {
const gallery = this.galleryAttachments();
if (gallery) {
this.attachmentsSvc.unpinDisplayBlobs(gallery);
}
this.galleryAttachments.set(null);
}
@@ -352,46 +369,7 @@ export class ChatMessagesComponent {
}
async downloadAttachment(attachment: Attachment): Promise<void> {
if (!attachment.available || !attachment.objectUrl)
return;
const electronApi = this.electronBridge.getApi();
if (electronApi) {
const diskPath = this.getAttachmentDiskPath(attachment);
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled)
return;
} 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;
} catch {
/* fall back to browser download */
}
}
}
const link = document.createElement('a');
link.href = attachment.objectUrl;
link.download = attachment.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
await this.attachmentDownload.downloadToUserLocation(attachment);
}
async copyImageToClipboard(attachment: Attachment): Promise<void> {
@@ -415,46 +393,6 @@ export class ChatMessagesComponent {
return message.senderId === this.currentUser()?.id;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl)
return null;
if (attachment.objectUrl.startsWith('file:'))
return null;
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || 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);
});
}
private convertToPng(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
if (blob.type === 'image/png') {

View File

@@ -192,6 +192,10 @@
/>
<div class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
</div>
} @else if (isImagePendingHydration(gridImage)) {
<div class="chat-image-grid-cell chat-image-grid-loading">
<div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if ((gridImage.receivedBytes || 0) > 0) {
<div class="chat-image-grid-cell chat-image-grid-loading">
<ng-icon
@@ -234,7 +238,7 @@
@for (att of attachmentsList; track att.id) {
@if (shouldShowAttachmentInList(att)) {
@if (isImageLikeAttachment(att) && !imageGridLayout().useGrid) {
@if (att.available && att.objectUrl) {
@if (isDisplayableImage(att)) {
<div
class="group/img relative inline-block"
(contextmenu)="openImageContextMenu($event, att)"
@@ -269,6 +273,13 @@
</button>
</div>
</div>
} @else if (isImagePendingHydration(att)) {
<div
appThemeNode="chatAttachmentCard"
class="flex max-h-80 min-h-32 min-w-48 items-center justify-center rounded-md border border-border bg-secondary/40 p-6"
>
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<div
appThemeNode="chatAttachmentCard"

View File

@@ -5,11 +5,12 @@ import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
effect,
ElementRef,
inject,
input,
OnDestroy,
AfterViewInit,
output,
signal,
TemplateRef,
@@ -44,9 +45,11 @@ import {
} from '../../../../../attachment';
import {
dedupeImageAttachmentsForDisplay,
isAttachmentPendingInlineHydration,
isImageAttachment,
isInlineDisplayableImage
} from '../../../../../attachment/domain/logic/attachment-image.rules';
import { ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN } from '../../../../../attachment/domain/logic/attachment-blob-eviction.rules';
import { PlatformService, ViewportService } from '../../../../../../core/platform';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { ExperimentalMediaSettingsService } from '../../../../../experimental-media';
@@ -56,6 +59,7 @@ import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules
import { shouldShowMessageEditedLabel } from '../../../../domain/rules/message.rules';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { Message, User } from '../../../../../../shared-kernel';
import { resolveUserByIdentity } from '../../../../../../store/users/user-identity-lookup.rules';
import { ThemeNodeDirective } from '../../../../../theme';
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
@@ -167,10 +171,11 @@ interface MissingPluginEmbedFallback {
style: 'display: contents;'
}
})
export class ChatMessageItemComponent implements OnDestroy {
export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly pluginRequirements = inject(PluginRequirementStateService);
@@ -187,6 +192,8 @@ export class ChatMessageItemComponent implements OnDestroy {
private readonly appI18n = inject(AppI18nService);
private mobileSheetOverlayRef: OverlayRef | null = null;
private longPressTimer: number | null = null;
private visibilityObserver: IntersectionObserver | null = null;
private readonly isMessageVisible = signal(false);
readonly isMobile = this.viewport.isMobile;
readonly mobileSheetOpen = signal(false);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
@@ -221,7 +228,7 @@ export class ChatMessageItemComponent implements OnDestroy {
readonly showEmojiPicker = signal(false);
readonly senderUser = computed<User>(() => {
const msg = this.message();
const found = this.userLookup().get(msg.senderId);
const found = resolveUserByIdentity(this.userLookup(), msg.senderId);
return (
found ?? {
@@ -263,12 +270,17 @@ export class ChatMessageItemComponent implements OnDestroy {
const images = this.imageAttachments();
void this.attachmentVersion();
const isVisible = this.isMessageVisible();
for (const image of images) {
if (isInlineDisplayableImage(image)) {
continue;
}
if (!isAttachmentPendingInlineHydration(image)) {
continue;
}
const liveAttachment = this.getLiveAttachment(image.id);
if (!liveAttachment) {
@@ -278,7 +290,11 @@ export class ChatMessageItemComponent implements OnDestroy {
void this.attachmentsSvc.tryRestoreAttachmentFromLocal(liveAttachment);
}
if (images.some((image) => !isInlineDisplayableImage(image))) {
if (!isVisible) {
return;
}
if (images.some((image) => !isInlineDisplayableImage(image) && !isAttachmentPendingInlineHydration(image))) {
void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId);
}
});
@@ -500,7 +516,67 @@ export class ChatMessageItemComponent implements OnDestroy {
}
}
ngAfterViewInit(): void {
if (typeof IntersectionObserver === 'undefined') {
return;
}
const host = this.elementRef.nativeElement;
const scrollRoot = host.closest('[appThemeNode="chatMessageList"]');
this.visibilityObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry) {
return;
}
this.handleMessageVisibilityChange(entry.isIntersecting);
},
{
root: scrollRoot,
rootMargin: ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN,
threshold: 0
}
);
this.visibilityObserver.observe(host);
this.syncInitialMessageVisibility(host, scrollRoot as HTMLElement | null);
}
private syncInitialMessageVisibility(host: HTMLElement, scrollRoot: HTMLElement | null): void {
if (this.isElementIntersectingScrollRoot(host, scrollRoot)) {
this.isMessageVisible.set(true);
}
}
private isElementIntersectingScrollRoot(host: HTMLElement, scrollRoot: HTMLElement | null): boolean {
const hostRect = host.getBoundingClientRect();
if (!scrollRoot) {
return hostRect.bottom > 0 &&
hostRect.top < window.innerHeight &&
hostRect.right > 0 &&
hostRect.left < window.innerWidth;
}
const rootRect = scrollRoot.getBoundingClientRect();
return hostRect.bottom > rootRect.top &&
hostRect.top < rootRect.bottom &&
hostRect.right > rootRect.left &&
hostRect.left < rootRect.right;
}
ngOnDestroy(): void {
this.visibilityObserver?.disconnect();
this.visibilityObserver = null;
if (this.isMessageVisible()) {
this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(this.message().id);
}
this.clearLongPressTimer();
this.detachMobileSheet();
}
@@ -771,6 +847,10 @@ export class ChatMessageItemComponent implements OnDestroy {
return isInlineDisplayableImage(attachment);
}
isImagePendingHydration(attachment: ChatMessageAttachmentViewModel): boolean {
return isAttachmentPendingInlineHydration(attachment);
}
isImageLikeAttachment(attachment: ChatMessageAttachmentViewModel): boolean {
return isImageAttachment(attachment);
}
@@ -881,6 +961,21 @@ export class ChatMessageItemComponent implements OnDestroy {
};
}
private handleMessageVisibilityChange(isVisible: boolean): void {
if (isVisible === this.isMessageVisible()) {
return;
}
this.isMessageVisible.set(isVisible);
const messageId = this.message().id;
if (isVisible) {
return;
}
this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(messageId);
}
private getLiveAttachment(attachmentId: string): Attachment | undefined {
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
}

View File

@@ -34,6 +34,7 @@ import {
ChatMessageReplyEvent
} from '../../models/chat-messages.model';
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
import { buildUserIdentityLookup } from '../../../../../../store/users/user-identity-lookup.rules';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { ThemeNodeDirective } from '../../../../../theme';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
@@ -146,21 +147,11 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
});
readonly userLookup = computed<ReadonlyMap<string, User>>(() => {
const lookup = new Map<string, User>();
for (const user of this.allUsers()) {
lookup.set(user.id, user);
if (user.oderId && user.oderId !== user.id) {
lookup.set(user.oderId, user);
}
}
const lookup = new Map(buildUserIdentityLookup(this.allUsers()));
for (const user of this.userLookupOverrides()) {
lookup.set(user.id, user);
if (user.oderId && user.oderId !== user.id) {
lookup.set(user.oderId, user);
for (const [key, value] of buildUserIdentityLookup([user])) {
lookup.set(key, value);
}
}

View File

@@ -16,6 +16,7 @@ import {
} from '../../../../infrastructure/mobile';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { ViewportService } from '../../../../core/platform';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import {
VoiceActivityService,
VoiceConnectionFacade,
@@ -109,6 +110,47 @@ describe('DirectCallService', () => {
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
});
it('marks a remote join against the session participant alias stored locally', async () => {
const aliceForeign = createUser('alice-foreign', 'Alice');
const bobForeign = createUser('bob-foreign', 'Bob');
const bobHome = { ...bobForeign, id: 'bob-home', oderId: 'bob-home' };
const context = createServiceContext({ currentUser: aliceForeign, allUsers: [aliceForeign, bobForeign] });
context.directCallEvents.next({
type: 'direct-call',
directCall: {
action: 'ring',
callId: 'dm-alice-foreign--bob-foreign',
conversationId: 'dm-alice-foreign--bob-foreign',
createdAt: 10,
sender: toParticipant(bobForeign),
participantIds: ['alice-foreign', 'bob-foreign'],
participants: [toParticipant(aliceForeign), toParticipant(bobForeign)]
}
});
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-foreign--bob-foreign')).not.toBeNull());
context.directCallEvents.next({
type: 'direct-call',
directCall: {
action: 'join',
callId: 'dm-alice-foreign--bob-foreign',
conversationId: 'dm-alice-foreign--bob-foreign',
createdAt: 10,
sender: toParticipant(bobHome),
participantIds: ['alice-foreign', 'bob-foreign'],
participants: [toParticipant(aliceForeign), toParticipant(bobForeign)]
}
});
await vi.waitFor(() => {
const session = context.service.sessionById('dm-alice-foreign--bob-foreign');
expect(session?.participants['bob-foreign']?.joined).toBe(true);
});
});
it('answers an incoming call from the modal action', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
@@ -573,9 +615,17 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
{
provide: MobileMediaService,
useValue: {
ensureVoiceCapturePermissions: vi.fn(async () => true),
setSpeakerphoneEnabled: vi.fn(async () => undefined)
}
},
{
provide: RealtimeSessionFacade,
useValue: {
getClientInstanceId: vi.fn(() => 'test-client'),
requestVoiceClientTakeover: vi.fn()
}
},
...provideAppI18nForTests()
]
});

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
@@ -33,6 +33,11 @@ import {
User
} from '../../../../shared-kernel';
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
import {
findDirectCallParticipantEntry,
findDirectCallParticipantEntryForUser,
isDirectCallParticipantJoined
} from '../../domain/logic/direct-call-participant-identity.rules';
import { toDirectMessageParticipant } from '../../../direct-message';
@Injectable({ providedIn: 'root' })
@@ -772,13 +777,20 @@ export class DirectCallService {
private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession {
return {
...nextSession,
participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => [
participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => {
const previousEntry = findDirectCallParticipantEntryForUser(previousSession, {
id: participant.userId,
oderId: participant.profile.userId
});
return [
participant.userId,
{
...participant,
joined: previousSession.participants[participant.userId]?.joined ?? participant.joined
joined: previousEntry?.participant.joined ?? participant.joined
}
]))
];
}))
};
}
@@ -865,7 +877,14 @@ export class DirectCallService {
joined: boolean,
status: DirectCallSession['status']
): DirectCallSession {
const participant = session.participants[participantId];
const knownUser = this.users().find((user) =>
user.id === participantId || user.oderId === participantId || user.peerId === participantId
);
const entry = knownUser
? findDirectCallParticipantEntryForUser(session, knownUser, [participantId])
: findDirectCallParticipantEntry(session, participantId);
const key = entry?.key ?? participantId;
const participant = entry?.participant ?? session.participants[participantId];
return {
...session,
@@ -874,7 +893,7 @@ export class DirectCallService {
...session.participants,
...(participant
? {
[participantId]: {
[key]: {
...participant,
joined
}
@@ -916,9 +935,9 @@ export class DirectCallService {
}
private isCurrentUserJoined(session: DirectCallSession): boolean {
const meId = this.currentUserId();
const user = this.currentUser();
return !!meId && !!session.participants[meId]?.joined;
return !!user && isDirectCallParticipantJoined(session, user);
}
private stopLocalMedia(session: DirectCallSession): void {
@@ -955,8 +974,13 @@ export class DirectCallService {
}
private markRemoteVoiceState(userId: string, session: DirectCallSession, connected: boolean): void {
const knownUser = this.users().find((user) =>
user.id === userId || user.oderId === userId || user.peerId === userId
);
const resolvedUserId = knownUser?.id ?? userId;
this.store.dispatch(UsersActions.updateVoiceState({
userId,
userId: resolvedUserId,
voiceState: {
isConnected: connected,
isMuted: false,

View File

@@ -0,0 +1,80 @@
import type { DirectCallSession } from '../models/direct-call.model';
import {
findDirectCallParticipantEntry,
findDirectCallParticipantEntryForUser,
isDirectCallParticipantJoined
} from './direct-call-participant-identity.rules';
function createSession(participants: DirectCallSession['participants']): DirectCallSession {
return {
callId: 'dm-alice-home--bob-foreign',
conversationId: 'dm-alice-home--bob-foreign',
createdAt: 1,
initiatorId: 'alice-home',
participantIds: Object.keys(participants),
participants,
status: 'connected'
};
}
describe('direct-call-participant-identity.rules', () => {
it('findDirectCallParticipantEntryForUser resolves join state across provisioned participant aliases', () => {
const session = createSession({
'bob-foreign': {
userId: 'bob-foreign',
profile: {
userId: 'bob-foreign',
username: 'bob',
displayName: 'Bob'
},
joined: true
}
});
expect(isDirectCallParticipantJoined(session, {
id: 'bob-entity',
oderId: 'bob-home'
}, ['bob-foreign'])).toBe(true);
expect(findDirectCallParticipantEntry(session, 'bob-foreign')?.participant.joined).toBe(true);
});
it('findDirectCallParticipantEntryForUser matches store users keyed by a different entity id', () => {
const session = createSession({
'bob-foreign': {
userId: 'bob-foreign',
profile: {
userId: 'bob-foreign',
username: 'bob',
displayName: 'Bob'
},
joined: true
}
});
expect(findDirectCallParticipantEntryForUser(session, {
id: 'bob-entity',
oderId: 'bob-home',
peerId: 'bob-peer'
}, ['bob-foreign'])?.participant.joined).toBe(true);
});
it('isDirectCallParticipantJoined returns false when no alias matches a joined participant', () => {
const session = createSession({
'bob-home': {
userId: 'bob-home',
profile: {
userId: 'bob-home',
username: 'bob',
displayName: 'Bob'
},
joined: false
}
});
expect(isDirectCallParticipantJoined(session, {
id: 'bob-entity',
oderId: 'bob-foreign'
}, ['bob-foreign'])).toBe(false);
});
});

View File

@@ -0,0 +1,88 @@
import type { User } from '../../../../shared-kernel';
import type { DirectCallParticipant, DirectCallSession } from '../models/direct-call.model';
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
/** Collect every id that can represent a user in direct-call participant state. */
export function collectDirectCallUserIdentityKeys(
user: UserIdentityFields,
additionalIds: readonly string[] = []
): string[] {
const keys: string[] = [];
for (const candidate of [
user.id,
user.oderId,
user.peerId,
...additionalIds
]) {
const normalized = candidate?.trim();
if (normalized && !keys.includes(normalized)) {
keys.push(normalized);
}
}
return keys;
}
export function findDirectCallParticipantEntry(
session: Pick<DirectCallSession, 'participants'>,
identity: string | undefined
): { key: string; participant: DirectCallParticipant } | undefined {
if (!identity?.trim()) {
return undefined;
}
const trimmed = identity.trim();
const direct = session.participants[trimmed];
if (direct) {
return { key: trimmed, participant: direct };
}
for (const [key, participant] of Object.entries(session.participants)) {
if (participant.userId === trimmed || participant.profile.userId === trimmed) {
return { key, participant };
}
}
return undefined;
}
export function findDirectCallParticipantEntryForUser(
session: Pick<DirectCallSession, 'participants'>,
user: UserIdentityFields,
additionalIds: readonly string[] = []
): { key: string; participant: DirectCallParticipant } | undefined {
for (const identity of collectDirectCallUserIdentityKeys(user, additionalIds)) {
const entry = findDirectCallParticipantEntry(session, identity);
if (entry) {
return entry;
}
}
const userKeys = new Set(collectDirectCallUserIdentityKeys(user, additionalIds));
for (const [key, participant] of Object.entries(session.participants)) {
const participantKeys = collectDirectCallUserIdentityKeys({
id: participant.userId,
oderId: participant.profile.userId
});
if (participantKeys.some((participantKey) => userKeys.has(participantKey))) {
return { key, participant };
}
}
return undefined;
}
export function isDirectCallParticipantJoined(
session: Pick<DirectCallSession, 'participants'>,
user: UserIdentityFields,
additionalIds: readonly string[] = []
): boolean {
return !!findDirectCallParticipantEntryForUser(session, user, additionalIds)?.participant.joined;
}

View File

@@ -4,6 +4,7 @@ import {
createGroupConversation,
directMessageEventIncludesUser,
directMessageSyncIncludesUser,
directMessageConversationIncludesUser,
createDirectCallStartedMessage,
getDirectConversationId,
isGroupDirectConversation,
@@ -137,6 +138,32 @@ describe('DirectMessageService domain flow', () => {
expect(directMessageEventIncludesUser(payload, 'charlie')).toBe(false);
});
it('recognises direct-message recipients across identity aliases', () => {
const payload = {
message: createMessage('message-1', 'SENT', getDirectConversationId('alice', 'bob-foreign'), ['bob-foreign']),
participants: [alice, { ...bob, userId: 'bob-foreign' }],
sender: alice
};
const bobIds = new Set(['bob', 'bob-foreign']);
expect(directMessageEventIncludesUser(payload, bobIds)).toBe(true);
expect(directMessageEventIncludesUser(payload, 'bob')).toBe(false);
});
it('recognises conversation participants across identity aliases', () => {
const conversation = {
...createDirectConversation(alice, bob, 10),
participants: ['alice', 'bob-foreign'],
participantProfiles: {
alice,
'bob-foreign': { ...bob, userId: 'bob-foreign' }
}
};
expect(directMessageConversationIncludesUser(conversation, new Set(['bob', 'bob-foreign']))).toBe(true);
expect(directMessageConversationIncludesUser(conversation, 'bob')).toBe(false);
});
it('recognises only declared sync participants', () => {
const payload = {
conversationId: 'dm-group-test',

View File

@@ -14,6 +14,7 @@ import { OfflineMessageQueueService } from './offline-message-queue.service';
import { PeerDeliveryService } from './peer-delivery.service';
import { AttachmentFacade } from '../../../attachment';
import { CustomEmojiService } from '../../../custom-emoji';
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
import {
advanceDirectMessageStatus,
createDirectConversation,
@@ -27,6 +28,7 @@ import {
updateMessageStatusInConversation,
upsertDirectMessage
} from '../../domain/logic/direct-message.logic';
import { collectDirectMessageSelfUserIds, isSelfDirectMessageSender } from '../../domain/logic/direct-message-identity.rules';
import {
DirectMessage,
DirectMessageConversation,
@@ -67,6 +69,7 @@ export class DirectMessageService {
private readonly delivery = inject(PeerDeliveryService);
private readonly attachments = inject(AttachmentFacade);
private readonly customEmoji = inject(CustomEmojiService);
private readonly credentialStore = inject(SignalServerCredentialStoreService);
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
@@ -501,8 +504,9 @@ export class DirectMessageService {
private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
const selfUserIds = this.getSelfUserIds();
if (!directMessageEventIncludesUser(payload, ownerId) || payload.sender.userId === ownerId || payload.message.senderId === ownerId) {
if (!directMessageEventIncludesUser(payload, selfUserIds) || isSelfDirectMessageSender(payload, selfUserIds)) {
return;
}
@@ -571,8 +575,9 @@ export class DirectMessageService {
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.findConversation(ownerId, payload.conversationId);
const selfUserIds = this.getSelfUserIds();
if (!conversation || !directMessageConversationIncludesUser(conversation, ownerId)) {
if (!conversation || !directMessageConversationIncludesUser(conversation, selfUserIds)) {
return;
}
@@ -580,16 +585,16 @@ export class DirectMessageService {
}
private handleIncomingTyping(payload: DirectMessageTypingEventPayload): void {
const currentUserId = this.getCurrentUserId();
const selfUserIds = this.getSelfUserIds();
if (!currentUserId || payload.sender.userId === currentUserId) {
if (selfUserIds.size === 0 || selfUserIds.has(payload.sender.userId)) {
return;
}
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId);
if (!conversation
|| !directMessageConversationIncludesUser(conversation, currentUserId)
|| !directMessageConversationIncludesUser(conversation, selfUserIds)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return;
}
@@ -621,10 +626,11 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
const conversation = await this.findConversation(ownerId, payload.conversationId);
const selfUserIds = this.getSelfUserIds();
if (!conversation
|| payload.sender.userId === ownerId
|| !directMessageConversationIncludesUser(conversation, ownerId)
|| selfUserIds.has(payload.sender.userId)
|| !directMessageConversationIncludesUser(conversation, selfUserIds)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return;
}
@@ -647,12 +653,13 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
const currentParticipant = toDirectMessageParticipant(currentUser);
const selfUserIds = this.getSelfUserIds();
if (payload.sender.userId === ownerId) {
if (selfUserIds.has(payload.sender.userId)) {
return;
}
if (!directMessageSyncIncludesUser(payload, ownerId) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) {
if (!directMessageSyncIncludesUser(payload, selfUserIds) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) {
return;
}
@@ -929,7 +936,9 @@ export class DirectMessageService {
return [];
}
return conversation.participants.filter((participantId) => participantId !== currentUserId);
const selfUserIds = this.getSelfUserIds();
return conversation.participants.filter((participantId) => !selfUserIds.has(participantId));
}
private conversationKind(conversation: DirectMessageConversation): 'direct' | 'group' {
@@ -991,4 +1000,16 @@ export class DirectMessageService {
return ownerId;
}
private getSelfUserIds(): ReadonlySet<string> {
const currentUser = this.currentUser();
if (!currentUser) {
return new Set();
}
const actorUserIds = this.credentialStore.listValidCredentials().map((credential) => credential.userId);
return collectDirectMessageSelfUserIds(currentUser, actorUserIds);
}
}

View File

@@ -9,7 +9,8 @@ import {
} from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import type { ChatEvent, User } from '../../../../shared-kernel';
import { buildUserIdentityLookup, resolveUserByIdentity } from '../../../../store/users/user-identity-lookup.rules';
import type { ChatEvent } from '../../../../shared-kernel';
@Injectable({ providedIn: 'root' })
export class PeerDeliveryService {
@@ -87,13 +88,13 @@ export class PeerDeliveryService {
return recipientId;
}
const user = this.users().find((candidate: User) =>
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId
);
const lookup = buildUserIdentityLookup(this.users());
const user = resolveUserByIdentity(lookup, recipientId);
const candidates = [
user?.oderId,
user?.peerId,
user?.id
user?.id,
recipientId
].filter((candidate): candidate is string => !!candidate);
return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null;
@@ -135,9 +136,8 @@ export class PeerDeliveryService {
}
private resolveCandidateIds(recipientId: string): string[] {
const user = this.users().find((candidate: User) =>
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId
);
const lookup = buildUserIdentityLookup(this.users());
const user = resolveUserByIdentity(lookup, recipientId);
return [
recipientId,

View File

@@ -0,0 +1,114 @@
import {
collectDirectMessageSelfUserIds,
directMessageConversationIncludesAnyUser,
directMessageEventIncludesAnyUser,
isSelfDirectMessageSender
} from './direct-message-identity.rules';
import type { DirectMessageConversation, DirectMessageParticipant } from '../models/direct-message.model';
const aliceHome: DirectMessageParticipant = {
userId: 'alice-home',
username: 'alice',
displayName: 'Alice'
};
const bobHome: DirectMessageParticipant = {
userId: 'bob-home',
username: 'bob',
displayName: 'Bob'
};
describe('direct-message-identity.rules', () => {
it('collects home and provisioned actor ids for the local user', () => {
const ids = collectDirectMessageSelfUserIds(
{ id: 'alice-home', oderId: 'alice-home' },
['alice-foreign']
);
expect(Array.from(ids).sort()).toEqual(['alice-foreign', 'alice-home']);
});
it('accepts incoming direct messages addressed to a provisioned actor id', () => {
const payload = {
message: {
id: 'message-1',
conversationId: 'dm-alice-home--bob-foreign',
senderId: 'alice-home',
recipientId: 'bob-foreign',
recipientIds: ['bob-foreign'],
content: 'hello',
timestamp: 1,
status: 'SENT' as const
},
sender: aliceHome,
participants: [aliceHome, { ...bobHome, userId: 'bob-foreign' }]
};
expect(directMessageEventIncludesAnyUser(payload, collectDirectMessageSelfUserIds(
{ id: 'bob-home', oderId: 'bob-home' },
['bob-foreign']
))).toBe(true);
});
it('rejects incoming direct messages that do not target any local identity', () => {
const payload = {
message: {
id: 'message-1',
conversationId: 'dm-alice-home--charlie',
senderId: 'alice-home',
recipientId: 'charlie',
recipientIds: ['charlie'],
content: 'hello',
timestamp: 1,
status: 'SENT' as const
},
sender: aliceHome,
participants: [aliceHome]
};
expect(directMessageEventIncludesAnyUser(payload, collectDirectMessageSelfUserIds(
{ id: 'bob-home', oderId: 'bob-home' },
['bob-foreign']
))).toBe(false);
});
it('treats any local identity as the sender for echo suppression', () => {
const payload = {
message: {
id: 'message-1',
conversationId: 'dm-alice-home--bob-foreign',
senderId: 'alice-foreign',
recipientId: 'bob-foreign',
recipientIds: ['bob-foreign'],
content: 'hello',
timestamp: 1,
status: 'SENT' as const
},
sender: { ...aliceHome, userId: 'alice-foreign' }
};
const selfIds = collectDirectMessageSelfUserIds(
{ id: 'alice-home', oderId: 'alice-home' },
['alice-foreign']
);
expect(isSelfDirectMessageSender(payload, selfIds)).toBe(true);
});
it('matches conversations stored under a different participant alias', () => {
const conversation: DirectMessageConversation = {
id: 'dm-alice-home--bob-foreign',
participants: ['alice-home', 'bob-foreign'],
participantProfiles: {
'alice-home': aliceHome,
'bob-foreign': { ...bobHome, userId: 'bob-foreign' }
},
messages: [],
lastMessageAt: 1,
unreadCount: 0
};
expect(directMessageConversationIncludesAnyUser(
conversation,
collectDirectMessageSelfUserIds({ id: 'bob-home', oderId: 'bob-home' }, ['bob-foreign'])
)).toBe(true);
});
});

View File

@@ -0,0 +1,49 @@
import type { User } from '../../../../shared-kernel';
import { directMessageConversationIncludesUser, directMessageEventIncludesUser } from './direct-message.logic';
import type { DirectMessageConversation, DirectMessageEventPayload } from '../models/direct-message.model';
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
/** Collect every id that can represent the local user in direct-message traffic. */
export function collectDirectMessageSelfUserIds(
user: UserIdentityFields,
additionalUserIds: readonly string[] = []
): ReadonlySet<string> {
const ids = new Set<string>();
for (const candidate of [
user.id,
user.oderId,
user.peerId,
...additionalUserIds
]) {
const normalized = candidate?.trim();
if (normalized) {
ids.add(normalized);
}
}
return ids;
}
export function directMessageEventIncludesAnyUser(
payload: DirectMessageEventPayload,
userIds: ReadonlySet<string>
): boolean {
return directMessageEventIncludesUser(payload, userIds);
}
export function isSelfDirectMessageSender(
payload: DirectMessageEventPayload,
userIds: ReadonlySet<string>
): boolean {
return userIds.has(payload.sender.userId) || userIds.has(payload.message.senderId);
}
export function directMessageConversationIncludesAnyUser(
conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>,
userIds: ReadonlySet<string>
): boolean {
return directMessageConversationIncludesUser(conversation, userIds);
}

View File

@@ -100,23 +100,46 @@ export function isGroupDirectConversation(conversation: DirectMessageConversatio
export function directMessageConversationIncludesUser(
conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>,
userId: string
userId: string | ReadonlySet<string>
): boolean {
return conversation.participants.includes(userId) || !!conversation.participantProfiles[userId];
const userIds = typeof userId === 'string' ? new Set([userId]) : userId;
if (conversation.participants.some((participantId) => userIds.has(participantId))) {
return true;
}
if (Object.keys(conversation.participantProfiles).some((participantId) => userIds.has(participantId))) {
return true;
}
return Object.values(conversation.participantProfiles).some((participant) =>
userIds.has(participant.userId)
);
}
export function directMessageEventIncludesUser(
payload: DirectMessageEventPayload,
userId: string
userId: string | ReadonlySet<string>
): boolean {
return collectDirectMessageEventParticipantIds(payload).has(userId);
const userIds = typeof userId === 'string' ? new Set([userId]) : userId;
const participantIds = collectDirectMessageEventParticipantIds(payload);
for (const candidate of userIds) {
if (participantIds.has(candidate)) {
return true;
}
}
return false;
}
export function directMessageSyncIncludesUser(
payload: DirectMessageSyncEventPayload,
userId: string
userId: string | ReadonlySet<string>
): boolean {
return payload.participants.some((participant) => participant.userId === userId);
const userIds = typeof userId === 'string' ? new Set([userId]) : userId;
return payload.participants.some((participant) => userIds.has(participant.userId));
}
export function upsertDirectMessage(

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
@@ -15,7 +15,6 @@ import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform';
import {
BottomSheetComponent,
@@ -23,11 +22,16 @@ import {
UserAvatarComponent
} from '../../../../shared';
import { DirectCallService } from '../../../direct-call';
import { Attachment, AttachmentFacade } from '../../../attachment';
import {
Attachment,
AttachmentDownloadService,
AttachmentFacade
} from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { isConversationBound } from './dm-chat.rules';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { buildUserIdentityLookup, resolveUserByIdentity } from '../../../../store/users/user-identity-lookup.rules';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide';
import {
@@ -87,7 +91,7 @@ export class DmChatComponent {
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly attachmentDownload = inject(AttachmentDownloadService);
private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService);
@@ -137,13 +141,14 @@ export class DmChatComponent {
readonly participantUsers = computed<User[]>(() => {
const conversation = this.conversation();
const knownUsers = this.allUsers();
const userLookup = buildUserIdentityLookup(knownUsers);
if (!conversation) {
return [];
}
return conversation.participants.map((participantId) => {
const knownUser = knownUsers.find((user) => user.id === participantId || user.oderId === participantId);
const knownUser = resolveUserByIdentity(userLookup, participantId);
const participant = conversation.participantProfiles[participantId];
return (
@@ -483,49 +488,7 @@ export class DmChatComponent {
}
async downloadAttachment(attachment: Attachment): Promise<void> {
if (!attachment.available || !attachment.objectUrl) {
return;
}
const electronApi = this.electronBridge.getApi();
if (electronApi) {
const diskPath = this.getAttachmentDiskPath(attachment);
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled) {
return;
}
} 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;
}
} catch {
/* fall back to browser download */
}
}
}
const link = document.createElement('a');
link.href = attachment.objectUrl;
link.download = attachment.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
await this.attachmentDownload.downloadToUserLocation(attachment);
}
async copyImageToClipboard(attachment: Attachment): Promise<void> {
@@ -597,48 +560,6 @@ export class DmChatComponent {
return `${messageId}:${url}`;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl) {
return null;
}
if (attachment.objectUrl.startsWith('file:')) {
return null;
}
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || 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);
});
}
private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
if (conversation.kind === 'group' || conversation.participants.length > 2) {
return null;
@@ -651,7 +572,9 @@ export class DmChatComponent {
return null;
}
return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
return this.participantUsers().find((user) =>
user.id === peerId || user.oderId === peerId || user.peerId === peerId
) ?? resolveUserByIdentity(buildUserIdentityLookup(this.allUsers()), peerId) ?? null;
}
private groupConversationTitle(conversation: NonNullable<ReturnType<typeof this.conversation>>): string {

View File

@@ -136,7 +136,7 @@ describe('CreateServerDialogComponent', () => {
component.create();
expect(router.navigate).toHaveBeenCalledWith(['/login']);
expect(router.navigate).toHaveBeenCalledWith(['/login'], { queryParams: {} });
expect(dispatch.mock.calls.some(([entry]) => entry.type === RoomsActions.createRoom.type)).toBe(false);
});
});

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