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

View File

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

View File

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

View File

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

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

View File

@@ -25,7 +25,9 @@ import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
import { import {
attachRendererDiagnosticsHooks, attachRendererDiagnosticsHooks,
ensurePerfDiagIpcRegistered, ensurePerfDiagIpcRegistered,
shutdownHighMemoryMonitoring,
shutdownPerfDiagnostics, shutdownPerfDiagnostics,
startHighMemoryMonitoring,
startPerfDiagnostics startPerfDiagnostics
} from '../diagnostics'; } from '../diagnostics';
@@ -39,6 +41,7 @@ function startLocalApiAfterWindowReady(): void {
export function registerAppLifecycle(): void { export function registerAppLifecycle(): void {
ensurePerfDiagIpcRegistered(); ensurePerfDiagIpcRegistered();
startHighMemoryMonitoring();
app.whenReady().then(async () => { app.whenReady().then(async () => {
const dockIconPath = getDockIconPath(); const dockIconPath = getDockIconPath();
@@ -83,6 +86,7 @@ export function registerAppLifecycle(): void {
app.on('before-quit', async (event) => { app.on('before-quit', async (event) => {
prepareWindowForAppQuit(); prepareWindowForAppQuit();
shutdownHighMemoryMonitoring();
await shutdownPerfDiagnostics(); await shutdownPerfDiagnostics();
if (getDataSource()?.isInitialized) { if (getDataSource()?.isInitialized) {

View File

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

View File

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

View File

@@ -1,20 +1,36 @@
import { import {
app, app,
BrowserWindow, BrowserWindow,
ipcMain ipcMain,
shell
} from 'electron'; } from 'electron';
import { collectAppMetricsSnapshot } from '../app-metrics'; import { collectAppMetricsSnapshot, type AppMetricsSnapshot } from '../app-metrics';
import { getMainWindow } from '../window/create-window';
import { resolveReadablePath } from '../path-jail';
import { sumWorkingSetKb } from './process-metrics.rules'; import { sumWorkingSetKb } from './process-metrics.rules';
import { isPerfDiagEnabled } from './diagnostics.flags'; import { isPerfDiagEnabled } from './diagnostics.flags';
import { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
import { 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 type { PerfDiagEntry } from './diagnostics.models';
import { PerfDiagWriter } from './diagnostics.writer'; import { PerfDiagWriter } from './diagnostics.writer';
const PROCESS_POLL_INTERVAL_MS = 5_000; const PROCESS_POLL_INTERVAL_MS = 5_000;
export const HIGH_MEMORY_ALERT_PENDING_CHANNEL = 'high-memory-alert-pending';
let activeWriter: PerfDiagWriter | null = null; let activeWriter: PerfDiagWriter | null = null;
let processPollTimer: NodeJS.Timeout | null = null; let processPollTimer: NodeJS.Timeout | null = null;
let diagnosticsEnabled = false; let diagnosticsEnabled = false;
let ipcRegistered = false; let ipcRegistered = false;
let highMemoryAlertTriggeredThisSession = false;
let sessionStartedAt = 0;
export function isPerfDiagActive(): boolean { export function isPerfDiagActive(): boolean {
return diagnosticsEnabled; return diagnosticsEnabled;
@@ -43,14 +59,103 @@ export function ensurePerfDiagIpcRegistered(): void {
return false; return false;
} }
}); });
ipcMain.handle('get-pending-high-memory-alert', async () => {
return readHighMemoryAlert(app.getPath('userData'));
});
ipcMain.handle('acknowledge-high-memory-alert', async () => {
await clearHighMemoryAlert(app.getPath('userData'));
return true;
});
ipcMain.handle('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 { export function getActivePerfDiagWriter(): PerfDiagWriter | null {
return activeWriter; 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 { export function startPerfDiagnostics(): PerfDiagWriter | null {
ensurePerfDiagIpcRegistered(); ensurePerfDiagIpcRegistered();
startHighMemoryMonitoring();
diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged); diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged);
if (!diagnosticsEnabled) { if (!diagnosticsEnabled) {
@@ -65,7 +170,8 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
activeWriter = writer; activeWriter = writer;
registerProcessCrashHandlers(writer); registerProcessCrashHandlers(writer);
startProcessMetricsPolling(writer);
const userDataPath = app.getPath('userData');
writer.append({ writer.append({
collectedAt: Date.now(), 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; return writer;
} }
@@ -127,14 +245,15 @@ export async function shutdownPerfDiagnostics(): Promise<void> {
} }
await activeWriter.flushSnapshot('shutdown'); await activeWriter.flushSnapshot('shutdown');
activeWriter = null;
diagnosticsEnabled = false;
}
export function shutdownHighMemoryMonitoring(): void {
if (processPollTimer) { if (processPollTimer) {
clearInterval(processPollTimer); clearInterval(processPollTimer);
processPollTimer = null; processPollTimer = null;
} }
activeWriter = null;
diagnosticsEnabled = false;
} }
function registerProcessCrashHandlers(writer: PerfDiagWriter): void { function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
@@ -180,28 +299,36 @@ function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
}); });
} }
function startProcessMetricsPolling(writer: PerfDiagWriter): void { async function maybeTriggerHighMemoryAlert(
const sample = (): void => { metrics: AppMetricsSnapshot,
try { totalWorkingSetKb: number | null
const metrics = collectAppMetricsSnapshot(); ): Promise<void> {
const totalKb = sumWorkingSetKb(metrics.processes); if (highMemoryAlertTriggeredThisSession || !exceedsHighMemoryThreshold(totalWorkingSetKb)) {
return;
}
writer.append({ highMemoryAlertTriggeredThisSession = true;
collectedAt: metrics.collectedAt,
source: 'main',
type: 'process',
payload: {
totalWorkingSetKb: totalKb,
processes: metrics.processes
}
});
} catch {
// Collector failures must never affect the app.
}
};
sample(); const record = await captureHighMemoryDiagnostics({
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS); userDataPath: app.getPath('userData'),
sessionStartedAt,
metrics,
totalWorkingSetKb: totalWorkingSetKb ?? 0,
writer: activeWriter,
mainWindow: getMainWindow(),
reason: 'threshold'
});
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 { function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {

View File

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

View File

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

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 { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
export {
clearHighMemoryAlert,
readHighMemoryAlert,
resolveHighMemoryAlertPath,
writeHighMemoryAlert
} from './high-memory-alert.store';
export type { HighMemoryAlertRecord } from './high-memory-alert.store';
export {
exceedsHighMemoryThreshold,
formatWorkingSetGb,
HIGH_MEMORY_THRESHOLD_KB
} from './high-memory-alert.rules';
export { export {
attachRendererDiagnosticsHooks, attachRendererDiagnosticsHooks,
ensurePerfDiagIpcRegistered, ensurePerfDiagIpcRegistered,
getActivePerfDiagWriter, getActivePerfDiagWriter,
HIGH_MEMORY_ALERT_PENDING_CHANNEL,
isPerfDiagActive, isPerfDiagActive,
shutdownHighMemoryMonitoring,
shutdownPerfDiagnostics, shutdownPerfDiagnostics,
startHighMemoryMonitoring,
startPerfDiagnostics startPerfDiagnostics
} from './diagnostics.lifecycle'; } from './diagnostics.lifecycle';
export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models'; 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, grantPluginReadRoot,
resolveReadablePath resolveReadablePath
} from '../path-jail'; } from '../path-jail';
import { isReadableRegularFile } from './file-read.rules';
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20; const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
@@ -654,9 +655,19 @@ export function setupSystemHandlers(): void {
return null; return null;
} }
const data = await fsp.readFile(scopedPath); try {
const stats = await fsp.stat(scopedPath);
return data.toString('base64'); 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) => { ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
@@ -666,17 +677,27 @@ export function setupSystemHandlers(): void {
return null; return null;
} }
const fileHandle = await fsp.open(scopedPath, 'r');
try { try {
const safeStart = Math.max(0, Math.trunc(start)); const stats = await fsp.stat(scopedPath);
const safeEnd = Math.max(safeStart, Math.trunc(end));
const buffer = Buffer.alloc(safeEnd - safeStart);
const result = await fileHandle.read(buffer, 0, buffer.length, safeStart);
return buffer.subarray(0, result.bytesRead).toString('base64'); if (!isReadableRegularFile(stats)) {
} finally { return null;
await fileHandle.close(); }
const fileHandle = await fsp.open(scopedPath, 'r');
try {
const safeStart = Math.max(0, Math.trunc(start));
const safeEnd = Math.max(safeStart, Math.trunc(end));
const buffer = Buffer.alloc(safeEnd - safeStart);
const result = await fileHandle.read(buffer, 0, buffer.length, safeStart);
return buffer.subarray(0, result.bytesRead).toString('base64');
} finally {
await fileHandle.close();
}
} catch {
return null;
} }
}); });
@@ -728,6 +749,17 @@ export function setupSystemHandlers(): void {
return true; 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) => { ipcMain.handle('delete-file', async (_event, filePath: string) => {
const scopedPath = await resolveWritableUserDataFilePath(filePath); const scopedPath = await resolveWritableUserDataFilePath(filePath);

View File

@@ -35,6 +35,17 @@ describe('path-jail', () => {
await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath); 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 () => { it('accepts cached plugin bundle paths under plugin-bundles', async () => {
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0'); 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-bundles',
'plugin-cache', 'plugin-cache',
'themes', 'themes',
'metoyou' 'metoyou',
'diagnostics'
] as const; ] as const;
export function isPathInside(parentPath: string, candidatePath: string): boolean { 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 DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed'; const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed'; const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
const HIGH_MEMORY_ALERT_PENDING_CHANNEL = 'high-memory-alert-pending';
export interface LinuxScreenShareAudioRoutingInfo { export interface LinuxScreenShareAudioRoutingInfo {
available: boolean; available: boolean;
@@ -259,6 +260,29 @@ export interface ElectronAPI {
type: string; type: string;
payload: Record<string, unknown>; payload: Record<string, unknown>;
}) => Promise<boolean>; }) => Promise<boolean>;
getPendingHighMemoryAlert: () => Promise<{
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
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>; getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>; openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>; exportUserData: () => Promise<ExportUserDataResult>;
@@ -327,6 +351,7 @@ export interface ElectronAPI {
grantPluginReadRoot: (rootPath: string) => Promise<boolean>; grantPluginReadRoot: (rootPath: string) => Promise<boolean>;
writeFile: (filePath: string, data: string) => Promise<boolean>; writeFile: (filePath: string, data: string) => Promise<boolean>;
appendFile: (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 }>; saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
saveExistingFileAs: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>; saveExistingFileAs: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
openFilePath: (filePath: string) => Promise<{ opened: boolean; reason?: string }>; openFilePath: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
@@ -400,6 +425,26 @@ const electronAPI: ElectronAPI = {
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'), getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'), isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry), reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'),
acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-high-memory-alert'),
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'), getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'), openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
exportUserData: () => ipcRenderer.invoke('export-user-data'), exportUserData: () => ipcRenderer.invoke('export-user-data'),
@@ -467,6 +512,7 @@ const electronAPI: ElectronAPI = {
grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath), grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data), writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
appendFile: (filePath, data) => ipcRenderer.invoke('append-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), saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName), saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName),
openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath), 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 { function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
user.ws.send(JSON.stringify({ user.ws.send(JSON.stringify({
type: 'voice_state', type: 'voice_state',

View File

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

View File

@@ -15,6 +15,18 @@
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.", "downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
"updateSettings": "Update settings", "updateSettings": "Update settings",
"restartNow": "Restart now" "restartNow": "Restart now"
},
"highMemoryAlert": {
"badge": "High memory usage",
"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.", "chunksOutOfOrder": "Received media chunks out of order. Retry the download.",
"writeDownloadFailed": "Could not write media download to disk.", "writeDownloadFailed": "Could not write media download to disk.",
"openDownloadFailed": "Could not open completed media download from 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", "title": "App-wide debugging",
"description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.", "description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.",
"processRam": "Process RAM", "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", "capturedEvents": "Captured events",
"lastUpdate": "Last update: {{label}}", "lastUpdate": "Last update: {{label}}",
"noLogsYet": "No logs yet", "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.", "downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
"updateSettings": "Update settings", "updateSettings": "Update settings",
"restartNow": "Restart now" "restartNow": "Restart now"
},
"highMemoryAlert": {
"badge": "High memory usage",
"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": { "attachment": {
@@ -26,7 +38,8 @@
"chunksOutOfOrder": "Received media chunks out of order. Retry the download.", "chunksOutOfOrder": "Received media chunks out of order. Retry the download.",
"writeDownloadFailed": "Could not write media download to disk.", "writeDownloadFailed": "Could not write media download to disk.",
"openDownloadFailed": "Could not open completed media download from 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": { "auth": {
@@ -1496,7 +1509,9 @@
"title": "App-wide debugging", "title": "App-wide debugging",
"description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.", "description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.",
"processRam": "Process RAM", "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", "capturedEvents": "Captured events",
"lastUpdate": "Last update: {{label}}", "lastUpdate": "Last update: {{label}}",
"noLogsYet": "No logs yet", "noLogsYet": "No logs yet",

View File

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

View File

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

View File

@@ -251,6 +251,14 @@ export interface ElectronPerfDiagEntry {
payload: Record<string, unknown>; payload: Record<string, unknown>;
} }
export interface ElectronHighMemoryAlertRecord {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
reason?: 'manual' | 'threshold';
}
export interface ElectronApi { export interface ElectronApi {
linuxDisplayServer: string; linuxDisplayServer: string;
minimizeWindow: () => void; minimizeWindow: () => void;
@@ -272,6 +280,11 @@ export interface ElectronApi {
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>; getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
isPerfDiagEnabled?: () => Promise<boolean>; isPerfDiagEnabled?: () => Promise<boolean>;
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>; reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>;
acknowledgeHighMemoryAlert?: () => Promise<boolean>;
exportHighMemoryDiagnostics?: () => Promise<ElectronHighMemoryAlertRecord>;
onHighMemoryAlertPending?: (listener: (alert: ElectronHighMemoryAlertRecord) => void) => () => void;
showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>; openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>; exportUserData: () => Promise<ExportUserDataResult>;
@@ -309,6 +322,7 @@ export interface ElectronApi {
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>; grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
writeFile: (filePath: string, data: string) => Promise<boolean>; writeFile: (filePath: string, data: string) => Promise<boolean>;
appendFile: (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 }>; saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>; saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>; openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;

View File

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

View File

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

View File

@@ -4,20 +4,22 @@ import { ElectronBridgeService } from './electron/electron-bridge.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PlatformService { export class PlatformService {
readonly isElectron: boolean;
readonly isCapacitor: boolean; readonly isCapacitor: boolean;
readonly isBrowser: boolean; readonly isBrowser: boolean;
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
constructor() { constructor() {
this.isElectron = this.electronBridge.isAvailable; const isElectron = this.electronBridge.isAvailable;
const runtime = detectRuntimePlatform({ const runtime = detectRuntimePlatform({
hasElectronApi: this.isElectron, hasElectronApi: isElectron,
capacitorIsNative: isCapacitorNativeRuntime() capacitorIsNative: isCapacitorNativeRuntime()
}); });
this.isCapacitor = runtime === 'capacitor'; this.isCapacitor = runtime === 'capacitor';
this.isBrowser = runtime === 'browser'; 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. - **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. - **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 ### 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. 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 ```mermaid
sequenceDiagram sequenceDiagram
participant R as Receiver 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). - `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. - `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. - `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). 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 - **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. 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); 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( requestFile(
...args: Parameters<AttachmentManagerService['requestFile']> ...args: Parameters<AttachmentManagerService['requestFile']>
): ReturnType<AttachmentManagerService['requestFile']> { ): ReturnType<AttachmentManagerService['requestFile']> {
@@ -99,6 +117,12 @@ export class AttachmentFacade {
return this.manager.handleFileChunk(...args); return this.manager.handleFileChunk(...args);
} }
handleFileChunkAck(
...args: Parameters<AttachmentManagerService['handleFileChunkAck']>
): ReturnType<AttachmentManagerService['handleFileChunkAck']> {
return this.manager.handleFileChunkAck(...args);
}
handleFileRequest( handleFileRequest(
...args: Parameters<AttachmentManagerService['handleFileRequest']> ...args: Parameters<AttachmentManagerService['handleFileRequest']>
): ReturnType<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 inject
} from '@angular/core'; } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { take } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
import { DatabaseService } from '../../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules'; import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules';
import { buildAttachmentDisplayPinKey, shouldRevokeDisplayBlobForAttachment } from '../../domain/logic/attachment-blob-eviction.rules';
import { import {
getWatchedAttachmentRoomIdFromUrl, getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId, isDirectMessageAttachmentRoomId,
@@ -17,6 +21,7 @@ import type {
FileAnnouncePayload, FileAnnouncePayload,
FileCancelPayload, FileCancelPayload,
FileChunkPayload, FileChunkPayload,
FileChunkAckPayload,
FileNotFoundPayload, FileNotFoundPayload,
FileRequestPayload FileRequestPayload
} from '../../domain/models/attachment-transfer.model'; } from '../../domain/models/attachment-transfer.model';
@@ -32,6 +37,7 @@ export class AttachmentManagerService {
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly database = inject(DatabaseService); private readonly database = inject(DatabaseService);
private readonly runtimeStore = inject(AttachmentRuntimeStore); private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly persistence = inject(AttachmentPersistenceService); private readonly persistence = inject(AttachmentPersistenceService);
@@ -40,14 +46,16 @@ export class AttachmentManagerService {
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url); private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
private isDatabaseInitialised = false; private isDatabaseInitialised = false;
private autoDownloadRequestsByRoom = new Map<string, Promise<void>>(); private autoDownloadRequestsByRoom = new Map<string, Promise<void>>();
private pinnedDisplayBlobKeys = new Set<string>();
constructor() { constructor() {
effect(() => { effect(() => {
if (this.database.isReady() && !this.isDatabaseInitialised) { if (this.database.isReady() && !this.isDatabaseInitialised) {
this.isDatabaseInitialised = true; this.isDatabaseInitialised = true;
void this.persistence.initFromDatabase().then(() => { void this.persistence.initFromDatabase().then(async () => {
if (this.watchedRoomId) { 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(() => { this.webrtc.onPeerConnected.subscribe(() => {
if (this.watchedRoomId) { if (this.watchedRoomId) {
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId); void this.restoreLocalAttachmentsForRoom(this.watchedRoomId).then(async () => {
await this.announceHostedAttachments();
});
void this.requestAutoDownloadsForRoom(this.watchedRoomId); void this.requestAutoDownloadsForRoom(this.watchedRoomId);
} }
}); });
@@ -152,6 +163,48 @@ export class AttachmentManagerService {
return restored; 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> { requestFile(messageId: string, attachment: Attachment): Promise<void> {
return this.transfer.requestFile(messageId, attachment); return this.transfer.requestFile(messageId, attachment);
} }
@@ -165,9 +218,9 @@ export class AttachmentManagerService {
} }
handleFileAnnounce(payload: FileAnnouncePayload): void { 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); this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id);
} }
} }
@@ -176,6 +229,10 @@ export class AttachmentManagerService {
this.transfer.handleFileChunk(payload); this.transfer.handleFileChunk(payload);
} }
handleFileChunkAck(payload: FileChunkAckPayload): void {
this.transfer.handleFileChunkAck(payload);
}
async handleFileRequest(payload: FileRequestPayload): Promise<void> { async handleFileRequest(payload: FileRequestPayload): Promise<void> {
await this.transfer.handleFileRequest(payload); await this.transfer.handleFileRequest(payload);
} }
@@ -210,7 +267,7 @@ export class AttachmentManagerService {
for (const messageId of messageIds) { for (const messageId of messageIds) {
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) { for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
if (await this.persistence.tryRestoreAttachmentFromLocal(attachment)) { if (await this.persistence.tryRestoreAttachmentHostOnly(attachment)) {
hasChanges = true; hasChanges = true;
await yieldToAttachmentHydrationLoop(); await yieldToAttachmentHydrationLoop();
} }
@@ -324,6 +381,15 @@ export class AttachmentManagerService {
return getWatchedAttachmentRoomIdFromUrl(url); 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 { private isRoomWatched(roomId: string | null | undefined): boolean {
return !!roomId && roomId === this.watchedRoomId; return !!roomId && roomId === this.watchedRoomId;
} }

View File

@@ -12,6 +12,7 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { DatabaseService } from '../../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
@@ -51,6 +52,7 @@ describe('AttachmentPersistenceService', () => {
savedPath: '/appdata/photo.png' savedPath: '/appdata/photo.png'
} }
])), ])),
getAttachmentsForMessage: vi.fn(() => Promise.resolve([])),
getMessageById: vi.fn(() => Promise.resolve(null)), getMessageById: vi.fn(() => Promise.resolve(null)),
saveAttachment: vi.fn(() => Promise.resolve()), saveAttachment: vi.fn(() => Promise.resolve()),
deleteAttachmentsForMessage: vi.fn(() => Promise.resolve()) deleteAttachmentsForMessage: vi.fn(() => Promise.resolve())
@@ -64,6 +66,9 @@ describe('AttachmentPersistenceService', () => {
getFileSize: vi.fn(() => Promise.resolve(3)), getFileSize: vi.fn(() => Promise.resolve(3)),
getFileUrl: vi.fn(() => Promise.resolve(null)), getFileUrl: vi.fn(() => Promise.resolve(null)),
canReadFileChunks: vi.fn(() => true), 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) providesInlineObjectUrl: vi.fn(() => false)
}; };
}); });
@@ -75,7 +80,7 @@ describe('AttachmentPersistenceService', () => {
AttachmentRuntimeStore, AttachmentRuntimeStore,
{ provide: DatabaseService, useValue: database }, { provide: DatabaseService, useValue: database },
{ provide: AttachmentStorageService, useValue: attachmentStorage }, { 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 () => { 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(); await service.initFromDatabase();
@@ -108,10 +123,12 @@ describe('AttachmentPersistenceService', () => {
savedPath: '/appdata/photo.png', savedPath: '/appdata/photo.png',
available: false available: false
}; };
const versionBefore = runtimeStore.updated();
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true); await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
expect(attachment.available).toBe(true); expect(attachment.available).toBe(true);
expect(attachment.objectUrl).toMatch(/^blob:/); expect(attachment.objectUrl).toMatch(/^blob:/);
expect(runtimeStore.updated()).toBeGreaterThan(versionBefore);
expect(attachmentStorage.getFileSize).toHaveBeenCalledWith('/appdata/photo.png'); expect(attachmentStorage.getFileSize).toHaveBeenCalledWith('/appdata/photo.png');
expect(attachmentStorage.readFileChunk).toHaveBeenCalled(); expect(attachmentStorage.readFileChunk).toHaveBeenCalled();
expect(attachmentStorage.readFile).not.toHaveBeenCalled(); expect(attachmentStorage.readFile).not.toHaveBeenCalled();
@@ -169,4 +186,81 @@ describe('AttachmentPersistenceService', () => {
expect(attachmentStorage.readFile).not.toHaveBeenCalled(); expect(attachmentStorage.readFile).not.toHaveBeenCalled();
expect(attachmentStorage.readFileChunk).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, decodeBase64ToUint8Array,
yieldToAttachmentHydrationLoop yieldToAttachmentHydrationLoop
} from '../../domain/logic/attachment-blob.rules'; } 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 { isBlobObjectUrl, needsBlobObjectUrlForInlineDisplay } from '../../domain/logic/attachment-display-url.rules';
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules'; import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
import { isAttachmentMedia } from '../../domain/logic/attachment.logic';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -118,7 +120,7 @@ export class AttachmentPersistenceService {
} }
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> { async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
const restored = await this.ensureInlineDisplayObjectUrl(attachment); const restored = await this.ensurePersistedUploadHost(attachment, { hydrateMediaForDisplay: true });
if (restored) { if (restored) {
attachment.requestError = undefined; attachment.requestError = undefined;
@@ -127,6 +129,69 @@ export class AttachmentPersistenceService {
return restored; 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> { async ensureInlineDisplayObjectUrl(attachment: Attachment): Promise<boolean> {
if (!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl)) { if (!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl)) {
return true; return true;
@@ -156,6 +221,7 @@ export class AttachmentPersistenceService {
this.revokeAttachmentObjectUrl(attachment); this.revokeAttachmentObjectUrl(attachment);
attachment.objectUrl = nativeUrl; attachment.objectUrl = nativeUrl;
attachment.available = true; attachment.available = true;
this.runtimeStore.touch();
return true; return true;
} }
} }
@@ -330,6 +396,8 @@ export class AttachmentPersistenceService {
`${attachment.messageId}:${attachment.id}`, `${attachment.messageId}:${attachment.id}`,
new File([blob], attachment.filename, { type: attachment.mime }) new File([blob], attachment.filename, { type: attachment.mime })
); );
this.runtimeStore.touch();
} }
private revokeAttachmentObjectUrl(attachment: Attachment): void { private revokeAttachmentObjectUrl(attachment: Attachment): void {

View File

@@ -12,6 +12,7 @@ export class AttachmentRuntimeStore {
private pendingRequests = new Map<string, Set<string>>(); private pendingRequests = new Map<string, Set<string>>();
private chunkBuffers = new Map<string, (ArrayBuffer | undefined)[]>(); private chunkBuffers = new Map<string, (ArrayBuffer | undefined)[]>();
private chunkCounts = new Map<string, number>(); private chunkCounts = new Map<string, number>();
private announcedHostsByAttachment = new Map<string, Set<string>>();
touch(): void { touch(): void {
this.updated.set(this.updated() + 1); this.updated.set(this.updated() + 1);
@@ -66,6 +67,25 @@ export class AttachmentRuntimeStore {
return this.originalFiles.get(key); 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 { findOriginalFileByFileId(fileId: string): File | null {
for (const [key, file] of this.originalFiles) { for (const [key, file] of this.originalFiles) {
if (key.endsWith(`:${fileId}`)) { if (key.endsWith(`:${fileId}`)) {
@@ -160,5 +180,11 @@ export class AttachmentRuntimeStore {
this.cancelledTransfers.delete(key); 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, decodeBase64,
iterateBlobChunks iterateBlobChunks
} from '../../../../shared-kernel'; } from '../../../../shared-kernel';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentTransferTransportService { export class AttachmentTransferTransportService {
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentStorage = inject(AttachmentStorageService); private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly chunkAcks = inject(AttachmentChunkAckService);
decodeBase64(base64: string): Uint8Array { decodeBase64(base64: string): Uint8Array {
return decodeBase64(base64); return decodeBase64(base64);
@@ -39,6 +41,7 @@ export class AttachmentTransferTransportService {
}; };
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); 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.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.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 { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferService } from './attachment-transfer.service'; import { AttachmentTransferService } from './attachment-transfer.service';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service'; import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
const MESSAGE_ID = 'msg-1'; const MESSAGE_ID = 'msg-1';
const FILE_ID = 'file-1'; const FILE_ID = 'file-1';
@@ -52,6 +53,7 @@ describe('AttachmentTransferService', () => {
resolveExistingPath: ReturnType<typeof vi.fn>; resolveExistingPath: ReturnType<typeof vi.fn>;
resolveLegacyImagePath: ReturnType<typeof vi.fn>; resolveLegacyImagePath: ReturnType<typeof vi.fn>;
appendBase64: ReturnType<typeof vi.fn>; appendBase64: ReturnType<typeof vi.fn>;
appendBytes: ReturnType<typeof vi.fn>;
createWritableFile: ReturnType<typeof vi.fn>; createWritableFile: ReturnType<typeof vi.fn>;
deleteFile: ReturnType<typeof vi.fn>; deleteFile: ReturnType<typeof vi.fn>;
}; };
@@ -60,6 +62,11 @@ describe('AttachmentTransferService', () => {
streamFileToPeer: ReturnType<typeof vi.fn>; streamFileToPeer: ReturnType<typeof vi.fn>;
streamFileFromDiskToPeer: 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: { let webrtc: {
getConnectedPeers: ReturnType<typeof vi.fn>; getConnectedPeers: ReturnType<typeof vi.fn>;
broadcastMessage: ReturnType<typeof vi.fn>; broadcastMessage: ReturnType<typeof vi.fn>;
@@ -88,6 +95,7 @@ describe('AttachmentTransferService', () => {
resolveExistingPath: vi.fn(async () => null), resolveExistingPath: vi.fn(async () => null),
resolveLegacyImagePath: vi.fn(async () => null), resolveLegacyImagePath: vi.fn(async () => null),
appendBase64: vi.fn(async () => true), appendBase64: vi.fn(async () => true),
appendBytes: vi.fn(async () => true),
createWritableFile: vi.fn(async () => '/appdata/server/room/files/file-1'), createWritableFile: vi.fn(async () => '/appdata/server/room/files/file-1'),
deleteFile: vi.fn(async () => true) deleteFile: vi.fn(async () => true)
}; };
@@ -98,6 +106,12 @@ describe('AttachmentTransferService', () => {
streamFileFromDiskToPeer: vi.fn(async () => undefined) streamFileFromDiskToPeer: vi.fn(async () => undefined)
}; };
chunkAcks = {
resolveAck: vi.fn(),
waitForAck: vi.fn(async () => undefined),
cancelPendingForFile: vi.fn()
};
webrtc = { webrtc = {
getConnectedPeers: vi.fn(() => [PEER_ID]), getConnectedPeers: vi.fn(() => [PEER_ID]),
broadcastMessage: vi.fn(), broadcastMessage: vi.fn(),
@@ -115,7 +129,8 @@ describe('AttachmentTransferService', () => {
{ provide: AppI18nService, useValue: { instant: (key: string) => key } }, { provide: AppI18nService, useValue: { instant: (key: string) => key } },
{ provide: AttachmentStorageService, useValue: attachmentStorage }, { provide: AttachmentStorageService, useValue: attachmentStorage },
{ provide: AttachmentPersistenceService, useValue: persistence }, { provide: AttachmentPersistenceService, useValue: persistence },
{ provide: AttachmentTransferTransportService, useValue: transport } { provide: AttachmentTransferTransportService, useValue: transport },
{ provide: AttachmentChunkAckService, useValue: chunkAcks }
] ]
}); });
const service = runInInjectionContext(injector, () => injector.get(AttachmentTransferService)); 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 () => { it('streams a requested file only once while the same request is already in flight', async () => {
attachmentStorage.resolveExistingPath.mockResolvedValue(null);
const service = createService(); const service = createService();
registerIncomingAttachment(9); registerIncomingAttachment(9);
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File([new Uint8Array(9)], 'photo.png', { type: 'image/png' })); 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({ const firstRequest = service.handleFileRequest({
messageId: MESSAGE_ID, messageId: MESSAGE_ID,
fileId: FILE_ID, fileId: FILE_ID,
@@ -316,7 +327,6 @@ describe('AttachmentTransferService', () => {
fromPeerId: PEER_ID fromPeerId: PEER_ID
}); });
releaseStream();
await Promise.all([firstRequest, duplicateRequest]); await Promise.all([firstRequest, duplicateRequest]);
expect(transport.streamFileToPeer).toHaveBeenCalledTimes(1); expect(transport.streamFileToPeer).toHaveBeenCalledTimes(1);
@@ -364,6 +374,23 @@ describe('AttachmentTransferService', () => {
return attachment; 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 () => { it('streams playable media to disk when the store supports streaming', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(true); attachmentStorage.canStreamToDisk.mockReturnValue(true);
@@ -379,7 +406,14 @@ describe('AttachmentTransferService', () => {
await vi.waitFor(() => expect(attachment.available).toBe(true)); await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.createWritableFile).toHaveBeenCalled(); 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(); expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
}); });
@@ -401,6 +435,18 @@ describe('AttachmentTransferService', () => {
expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1); 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', () => { it('marks a request as pending synchronously so concurrent auto-download triggers cannot double-request', () => {
const service = createService(); const service = createService();
const attachment = registerIncomingAttachment(9); const attachment = registerIncomingAttachment(9);
@@ -409,4 +455,276 @@ describe('AttachmentTransferService', () => {
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(true); 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 { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants'; import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { isImageAttachment, resolvePublishAttachmentIsImage } from '../../domain/logic/attachment-image.rules'; import { isImageAttachment, resolvePublishAttachmentIsImage } from '../../domain/logic/attachment-image.rules';
import { isSharingFromThisDevice } from '../../domain/logic/attachment-sharing.rules'; import { base64DecodedByteLength, decodeBase64ToUint8Array } from '../../domain/logic/attachment-blob.rules';
import { shouldCopyUploaderMediaToAppData, shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic'; 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 type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import { import {
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT, ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT, ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
DEFAULT_ATTACHMENT_MIME_TYPE, DEFAULT_ATTACHMENT_MIME_TYPE,
ATTACHMENT_DOWNLOAD_FAILED_KEY, ATTACHMENT_DOWNLOAD_FAILED_KEY,
ATTACHMENT_FILE_TOO_LARGE_KEY,
ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY, ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY,
ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY,
ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY, ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY,
ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY, ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY,
FILE_NOT_FOUND_REQUEST_ERROR_KEY, FILE_NOT_FOUND_REQUEST_ERROR_KEY,
@@ -30,6 +37,8 @@ import {
type FileCancelEvent, type FileCancelEvent,
type FileCancelPayload, type FileCancelPayload,
type FileChunkPayload, type FileChunkPayload,
type FileChunkAckPayload,
type FileChunkAckEvent,
type FileNotFoundEvent, type FileNotFoundEvent,
type FileNotFoundPayload, type FileNotFoundPayload,
type FileRequestEvent, type FileRequestEvent,
@@ -39,6 +48,7 @@ import {
import { AttachmentPersistenceService } from './attachment-persistence.service'; import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service'; import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
interface DiskReceiveAssembly { interface DiskReceiveAssembly {
path: string; path: string;
@@ -79,9 +89,10 @@ export class AttachmentTransferService {
private readonly attachmentStorage = inject(AttachmentStorageService); private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly persistence = inject(AttachmentPersistenceService); private readonly persistence = inject(AttachmentPersistenceService);
private readonly transport = inject(AttachmentTransferTransportService); private readonly transport = inject(AttachmentTransferTransportService);
private readonly chunkAcks = inject(AttachmentChunkAckService);
private readonly diskReceiveAssemblies = new Map<string, DiskReceiveAssembly>(); 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>(); private readonly activeOutboundTransfers = new Set<string>();
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> { getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
@@ -188,6 +199,13 @@ export class AttachmentTransferService {
return; 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) if (clearedRequestError)
this.runtimeStore.touch(); this.runtimeStore.touch();
@@ -261,6 +279,7 @@ export class AttachmentTransferService {
} }
await this.persistPublishedAttachment(attachment, file); await this.persistPublishedAttachment(attachment, file);
this.releaseInMemoryUploadCopyIfPersisted(`${messageId}:${fileId}`, attachment);
const fileAnnounceEvent: FileAnnounceEvent = { const fileAnnounceEvent: FileAnnounceEvent = {
type: 'file-announce', type: 'file-announce',
@@ -288,17 +307,23 @@ export class AttachmentTransferService {
} }
} }
handleFileAnnounce(payload: FileAnnouncePayload): void { handleFileAnnounce(payload: FileAnnouncePayload): boolean {
const { messageId, file } = payload; const { messageId, file } = payload;
if (!messageId || !file) if (!messageId || !file) {
return; return false;
}
if (payload.fromPeerId) {
this.runtimeStore.addAnnouncedHost(this.buildRequestKey(messageId, file.id), payload.fromPeerId);
}
const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)]; const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
const alreadyKnown = list.find((entry) => entry.id === file.id); const alreadyKnown = list.find((entry) => entry.id === file.id);
if (alreadyKnown) if (alreadyKnown) {
return; return false;
}
const attachment: Attachment = { const attachment: Attachment = {
id: file.id, id: file.id,
@@ -320,6 +345,8 @@ export class AttachmentTransferService {
this.runtimeStore.setAttachmentsForMessage(messageId, list); this.runtimeStore.setAttachmentsForMessage(messageId, list);
this.runtimeStore.touch(); this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment); void this.persistence.persistAttachmentMeta(attachment);
return true;
} }
handleFileChunk(payload: FileChunkPayload): void { handleFileChunk(payload: FileChunkPayload): void {
@@ -344,12 +371,14 @@ export class AttachmentTransferService {
return; 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; return;
} }
if (this.shouldReceiveToDisk(attachment)) { if (this.shouldReceiveToDisk(attachment)) {
this.enqueueDiskFileChunk(attachment, { void this.receiveDiskChunk(attachment, {
data, data,
fileId, fileId,
fromPeerId, fromPeerId,
@@ -361,6 +390,12 @@ export class AttachmentTransferService {
return; 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 decodedBytes = this.transport.decodeBase64(data);
const assemblyKey = `${messageId}:${fileId}`; const assemblyKey = `${messageId}:${fileId}`;
const requestKey = this.buildRequestKey(messageId, fileId); const requestKey = this.buildRequestKey(messageId, fileId);
@@ -378,10 +413,21 @@ export class AttachmentTransferService {
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer; chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1); this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
this.updateTransferProgress(attachment, decodedBytes, fromPeerId); this.updateTransferProgress(attachment, decodedBytes.byteLength, fromPeerId);
this.runtimeStore.touch(); this.runtimeStore.touch();
void this.finalizeTransferIfComplete(attachment, assemblyKey, total); 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> { async handleFileRequest(payload: FileRequestPayload): Promise<void> {
@@ -495,21 +541,6 @@ export class AttachmentTransferService {
fromPeerId: string fromPeerId: string
): Promise<void> { ): Promise<void> {
const exactKey = `${messageId}:${fileId}`; 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 list = this.runtimeStore.getAttachmentsForMessage(messageId);
const attachment = list.find((entry) => entry.id === fileId); const attachment = list.find((entry) => entry.id === fileId);
const diskPath = attachment const diskPath = attachment
@@ -528,6 +559,21 @@ export class AttachmentTransferService {
return; 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) { if (attachment?.isImage) {
const roomName = await this.persistence.resolveCurrentRoomName(); const roomName = await this.persistence.resolveCurrentRoomName();
const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath( const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath(
@@ -614,14 +660,13 @@ export class AttachmentTransferService {
const connectedPeers = this.webrtc.getConnectedPeers(); const connectedPeers = this.webrtc.getConnectedPeers();
const requestKey = this.buildRequestKey(messageId, fileId); const requestKey = this.buildRequestKey(messageId, fileId);
const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>(); const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>();
const announcedHosts = this.runtimeStore.getAnnouncedHosts(requestKey);
let targetPeerId: string | undefined; const targetPeerId = selectFileRequestPeer({
connectedPeers,
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) { triedPeers,
targetPeerId = preferredPeerId; announcedHosts,
} else { uploaderPeerId: preferredPeerId
targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId)); });
}
if (!targetPeerId) { if (!targetPeerId) {
this.runtimeStore.deletePendingRequest(requestKey); this.runtimeStore.deletePendingRequest(requestKey);
@@ -661,16 +706,16 @@ export class AttachmentTransferService {
private updateTransferProgress( private updateTransferProgress(
attachment: Attachment, attachment: Attachment,
decodedBytes: Uint8Array, chunkByteLength: number,
fromPeerId?: string fromPeerId?: string
): void { ): void {
const now = Date.now(); const now = Date.now();
const previousReceived = attachment.receivedBytes ?? 0; const previousReceived = attachment.receivedBytes ?? 0;
attachment.receivedBytes = previousReceived + decodedBytes.byteLength; attachment.receivedBytes = previousReceived + chunkByteLength;
if (fromPeerId) { if (fromPeerId) {
recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now); recordDebugNetworkFileChunk(fromPeerId, chunkByteLength, now);
} }
if (!attachment.startedAtMs) if (!attachment.startedAtMs)
@@ -680,7 +725,7 @@ export class AttachmentTransferService {
attachment.lastUpdateMs = now; attachment.lastUpdateMs = now;
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs); const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000; const instantaneousBps = (chunkByteLength / elapsedMs) * 1000;
const previousSpeed = attachment.speedBps ?? instantaneousBps; const previousSpeed = attachment.speedBps ?? instantaneousBps;
attachment.speedBps = attachment.speedBps =
@@ -729,6 +774,7 @@ export class AttachmentTransferService {
this.runtimeStore.touch(); this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment); void this.persistence.persistAttachmentMeta(attachment);
void this.announceLocalHost(attachment);
} }
/** /**
@@ -748,7 +794,7 @@ export class AttachmentTransferService {
return; return;
} }
if (shouldCopyUploaderMediaToAppData( if (shouldCopyLargeUploaderFileToAppData(
attachment, attachment,
attachment.filePath, attachment.filePath,
this.attachmentStorage.canCopyFiles() 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> { private async applySavedPathObjectUrl(attachment: Attachment, savedPath: string | null): Promise<void> {
if (!savedPath) { if (!savedPath) {
return; return;
@@ -784,37 +905,57 @@ export class AttachmentTransferService {
} }
private shouldReceiveToDisk(attachment: Attachment): boolean { private shouldReceiveToDisk(attachment: Attachment): boolean {
return this.isPlayableMedia(attachment) && return shouldStreamAttachmentReceiveToDisk(attachment, this.receiveCapabilities());
!attachment.filePath &&
this.attachmentStorage.canStreamToDisk() &&
this.attachmentStorage.canPersistSize(attachment.size);
} }
private enqueueDiskFileChunk( private receiveCapabilities() {
attachment: Attachment, return {
payload: ValidFileChunkPayload canStreamToDisk: this.attachmentStorage.canStreamToDisk(),
): void { canPersistSize: (bytes: number) => this.attachmentStorage.canPersistSize(bytes)
};
}
private receiveDiskChunk(attachment: Attachment, payload: ValidFileChunkPayload): void {
const assemblyKey = `${payload.messageId}:${payload.fileId}`; 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 const next = previous
.catch(() => undefined) .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)); .catch((error: unknown) => this.handleDiskReceiveFailure(attachment, assemblyKey, error));
this.diskReceiveChains.set(assemblyKey, next); this.diskReceiveLocks.set(assemblyKey, next);
void next.finally(() => { void next.finally(() => {
if (this.diskReceiveChains.get(assemblyKey) === next) { if (this.diskReceiveLocks.get(assemblyKey) === next) {
this.diskReceiveChains.delete(assemblyKey); 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( private async handleDiskFileChunk(
attachment: Attachment, attachment: Attachment,
assemblyKey: string, assemblyKey: string,
payload: ValidFileChunkPayload payload: ValidFileChunkPayload
): Promise<void> { ): 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); const requestKey = this.buildRequestKey(payload.messageId, payload.fileId);
this.runtimeStore.deletePendingRequest(requestKey); this.runtimeStore.deletePendingRequest(requestKey);
@@ -834,7 +975,7 @@ export class AttachmentTransferService {
throw new Error(this.appI18n.instant(ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY)); 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) { if (!didAppend) {
throw new Error(this.appI18n.instant(ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY)); throw new Error(this.appI18n.instant(ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY));
@@ -842,7 +983,7 @@ export class AttachmentTransferService {
assembly.receivedIndexes.add(payload.index); assembly.receivedIndexes.add(payload.index);
assembly.receivedCount += 1; assembly.receivedCount += 1;
this.updateTransferProgress(attachment, decodedBytes, payload.fromPeerId); this.updateTransferProgress(attachment, chunkByteLength, payload.fromPeerId);
this.runtimeStore.touch(); this.runtimeStore.touch();
if (assembly.receivedCount < assembly.total) { if (assembly.receivedCount < assembly.total) {
@@ -850,17 +991,12 @@ export class AttachmentTransferService {
} }
attachment.savedPath = assembly.path; 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.available = true;
attachment.objectUrl = undefined;
this.diskReceiveAssemblies.delete(assemblyKey); this.diskReceiveAssemblies.delete(assemblyKey);
this.runtimeStore.touch(); this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment); void this.persistence.persistAttachmentMeta(attachment);
void this.announceLocalHost(attachment);
} }
private async getOrCreateDiskReceiveAssembly( 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_WRITE_DOWNLOAD_FAILED_KEY = 'attachment.errors.writeDownloadFailed';
export const ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY = 'attachment.errors.openDownloadFailed'; export const ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY = 'attachment.errors.openDownloadFailed';
export const ATTACHMENT_DOWNLOAD_FAILED_KEY = 'attachment.errors.downloadFailed'; 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 it
} from 'vitest'; } from 'vitest';
import { decodeBase64ToUint8Array } from './attachment-blob.rules'; import {
base64DecodedByteLength,
decodeBase64ToUint8Array
} from './attachment-blob.rules';
describe('attachment blob rules', () => { describe('attachment blob rules', () => {
it('decodes base64 payloads into byte arrays', () => { it('decodes base64 payloads into byte arrays', () => {
@@ -16,4 +19,9 @@ describe('attachment blob rules', () => {
67 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); 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. */ /** Yield control back to the browser so long attachment hydration cannot freeze Electron. */
export function yieldToAttachmentHydrationLoop(): Promise<void> { export function yieldToAttachmentHydrationLoop(): Promise<void> {
return new Promise((resolve) => { 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 { import {
dedupeImageAttachmentsForDisplay, dedupeImageAttachmentsForDisplay,
hasImageFilename, hasImageFilename,
isAttachmentPendingInlineHydration,
isImageAttachment, isImageAttachment,
isInlineDisplayableImage, isInlineDisplayableImage,
resolvePublishAttachmentIsImage resolvePublishAttachmentIsImage
@@ -38,6 +39,27 @@ describe('attachment-image rules', () => {
})).toBe(true); })).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', () => { it('dedupes image attachments by filename and prefers displayable copies', () => {
const deduped = dedupeImageAttachmentsForDisplay([ const deduped = dedupeImageAttachmentsForDisplay([
{ {

View File

@@ -22,6 +22,7 @@ export interface ImageAttachmentCandidate {
isImage: boolean; isImage: boolean;
mime: string; mime: string;
objectUrl?: string; objectUrl?: string;
receivedBytes?: number;
savedPath?: string; savedPath?: string;
} }
@@ -50,6 +51,27 @@ export function isInlineDisplayableImage(
!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl); !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( export function imageAttachmentDisplayRank(
attachment: Pick<ImageAttachmentCandidate, 'available' | 'filePath' | 'isImage' | 'objectUrl' | 'savedPath'> attachment: Pick<ImageAttachmentCandidate, 'available' | 'filePath' | 'isImage' | 'objectUrl' | 'savedPath'>
): number { ): 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 { import {
canHostAttachment,
deviceHasLocalCopy, deviceHasLocalCopy,
isSharingFromThisDevice, isSharingFromThisDevice,
isUploaderUser isUploaderUser
@@ -66,4 +67,10 @@ describe('attachment sharing rules', () => {
).toBe(false); ).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); 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 { function hasNonEmptyString(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0; return typeof value === 'string' && value.trim().length > 0;
} }

View File

@@ -1,7 +1,10 @@
import { import {
getWatchedAttachmentRoomIdFromUrl, getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId, isDirectMessageAttachmentRoomId,
shouldCopyUploaderMediaToAppData shouldCopyUploaderMediaToAppData,
shouldCopyLargeUploaderFileToAppData,
shouldStreamAttachmentReceiveToDisk,
canReceiveAttachment
} from './attachment.logic'; } from './attachment.logic';
describe('attachment logic', () => { describe('attachment logic', () => {
@@ -33,6 +36,16 @@ describe('attachment logic', () => {
}, '/home/ludde/video.mp4', true)).toBe(true); }, '/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', () => { it('skips app-data copy for small uploads and missing source paths', () => {
expect(shouldCopyUploaderMediaToAppData({ expect(shouldCopyUploaderMediaToAppData({
size: 1024, size: 1024,
@@ -44,4 +57,48 @@ describe('attachment logic', () => {
mime: 'video/mp4' mime: 'video/mp4'
}, undefined, true)).toBe(false); }, 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'>, attachment: Pick<Attachment, 'size' | 'mime'>,
sourcePath?: string | null, sourcePath?: string | null,
canCopyFiles = false 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 { ): boolean {
return canCopyFiles && return canCopyFiles &&
!!sourcePath && !!sourcePath?.trim() &&
(attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/')) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES; 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); 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 { function decodeUrlSegment(value: string): string {
try { try {
return decodeURIComponent(value); return decodeURIComponent(value);

View File

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

View File

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

View File

@@ -187,6 +187,18 @@ export class AttachmentStorageService {
return this.store.appendFile(filePath, base64Data); 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> { async deleteFile(filePath: string): Promise<void> {
if (!filePath) { if (!filePath) {
return; 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> { async readFile(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi(); 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 { switchMap } from 'rxjs/operators';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent } from '../../../../shared'; import { BottomSheetComponent } from '../../../../shared';
import { RealtimeSessionFacade } from '../../../../core/realtime'; 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 { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions'; import { MessagesActions } from '../../../../store/messages/messages.actions';
import { import {
@@ -69,10 +72,10 @@ export class ChatMessagesComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
@ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent; @ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
private readonly electronBridge = inject(ElectronBridgeService);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly attachmentDownload = inject(AttachmentDownloadService);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService); private readonly viewport = inject(ViewportService);
@@ -300,6 +303,7 @@ export class ChatMessagesComponent {
return; return;
} }
this.attachmentsSvc.pinDisplayBlobs(attachments);
this.lightboxState.set({ this.lightboxState.set({
attachments, attachments,
index index
@@ -307,6 +311,12 @@ export class ChatMessagesComponent {
} }
closeLightbox(): void { closeLightbox(): void {
const state = this.lightboxState();
if (state) {
this.attachmentsSvc.unpinDisplayBlobs(state.attachments);
}
this.lightboxState.set(null); this.lightboxState.set(null);
} }
@@ -336,10 +346,17 @@ export class ChatMessagesComponent {
return; return;
} }
this.attachmentsSvc.pinDisplayBlobs(availableImages);
this.galleryAttachments.set(availableImages); this.galleryAttachments.set(availableImages);
} }
closeImageGallery(): void { closeImageGallery(): void {
const gallery = this.galleryAttachments();
if (gallery) {
this.attachmentsSvc.unpinDisplayBlobs(gallery);
}
this.galleryAttachments.set(null); this.galleryAttachments.set(null);
} }
@@ -352,46 +369,7 @@ export class ChatMessagesComponent {
} }
async downloadAttachment(attachment: Attachment): Promise<void> { async downloadAttachment(attachment: Attachment): Promise<void> {
if (!attachment.available || !attachment.objectUrl) await this.attachmentDownload.downloadToUserLocation(attachment);
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);
} }
async copyImageToClipboard(attachment: Attachment): Promise<void> { async copyImageToClipboard(attachment: Attachment): Promise<void> {
@@ -415,46 +393,6 @@ export class ChatMessagesComponent {
return message.senderId === this.currentUser()?.id; 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> { private convertToPng(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (blob.type === 'image/png') { 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 class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
</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) { } @else if ((gridImage.receivedBytes || 0) > 0) {
<div class="chat-image-grid-cell chat-image-grid-loading"> <div class="chat-image-grid-cell chat-image-grid-loading">
<ng-icon <ng-icon
@@ -234,7 +238,7 @@
@for (att of attachmentsList; track att.id) { @for (att of attachmentsList; track att.id) {
@if (shouldShowAttachmentInList(att)) { @if (shouldShowAttachmentInList(att)) {
@if (isImageLikeAttachment(att) && !imageGridLayout().useGrid) { @if (isImageLikeAttachment(att) && !imageGridLayout().useGrid) {
@if (att.available && att.objectUrl) { @if (isDisplayableImage(att)) {
<div <div
class="group/img relative inline-block" class="group/img relative inline-block"
(contextmenu)="openImageContextMenu($event, att)" (contextmenu)="openImageContextMenu($event, att)"
@@ -269,6 +273,13 @@
</button> </button>
</div> </div>
</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) { } @else if ((att.receivedBytes || 0) > 0) {
<div <div
appThemeNode="chatAttachmentCard" appThemeNode="chatAttachmentCard"

View File

@@ -5,11 +5,12 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, computed,
ElementRef,
effect, effect,
ElementRef,
inject, inject,
input, input,
OnDestroy, OnDestroy,
AfterViewInit,
output, output,
signal, signal,
TemplateRef, TemplateRef,
@@ -44,9 +45,11 @@ import {
} from '../../../../../attachment'; } from '../../../../../attachment';
import { import {
dedupeImageAttachmentsForDisplay, dedupeImageAttachmentsForDisplay,
isAttachmentPendingInlineHydration,
isImageAttachment, isImageAttachment,
isInlineDisplayableImage isInlineDisplayableImage
} from '../../../../../attachment/domain/logic/attachment-image.rules'; } 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 { PlatformService, ViewportService } from '../../../../../../core/platform';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { ExperimentalMediaSettingsService } from '../../../../../experimental-media'; 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 { shouldShowMessageEditedLabel } from '../../../../domain/rules/message.rules';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { Message, User } from '../../../../../../shared-kernel'; import { Message, User } from '../../../../../../shared-kernel';
import { resolveUserByIdentity } from '../../../../../../store/users/user-identity-lookup.rules';
import { ThemeNodeDirective } from '../../../../../theme'; import { ThemeNodeDirective } from '../../../../../theme';
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component'; import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins'; import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
@@ -167,10 +171,11 @@ interface MissingPluginEmbedFallback {
style: 'display: contents;' style: 'display: contents;'
} }
}) })
export class ChatMessageItemComponent implements OnDestroy { export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>; @ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>; @ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly pluginRequirements = inject(PluginRequirementStateService); private readonly pluginRequirements = inject(PluginRequirementStateService);
@@ -187,6 +192,8 @@ export class ChatMessageItemComponent implements OnDestroy {
private readonly appI18n = inject(AppI18nService); private readonly appI18n = inject(AppI18nService);
private mobileSheetOverlayRef: OverlayRef | null = null; private mobileSheetOverlayRef: OverlayRef | null = null;
private longPressTimer: number | null = null; private longPressTimer: number | null = null;
private visibilityObserver: IntersectionObserver | null = null;
private readonly isMessageVisible = signal(false);
readonly isMobile = this.viewport.isMobile; readonly isMobile = this.viewport.isMobile;
readonly mobileSheetOpen = signal(false); readonly mobileSheetOpen = signal(false);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
@@ -221,7 +228,7 @@ export class ChatMessageItemComponent implements OnDestroy {
readonly showEmojiPicker = signal(false); readonly showEmojiPicker = signal(false);
readonly senderUser = computed<User>(() => { readonly senderUser = computed<User>(() => {
const msg = this.message(); const msg = this.message();
const found = this.userLookup().get(msg.senderId); const found = resolveUserByIdentity(this.userLookup(), msg.senderId);
return ( return (
found ?? { found ?? {
@@ -263,12 +270,17 @@ export class ChatMessageItemComponent implements OnDestroy {
const images = this.imageAttachments(); const images = this.imageAttachments();
void this.attachmentVersion(); void this.attachmentVersion();
const isVisible = this.isMessageVisible();
for (const image of images) { for (const image of images) {
if (isInlineDisplayableImage(image)) { if (isInlineDisplayableImage(image)) {
continue; continue;
} }
if (!isAttachmentPendingInlineHydration(image)) {
continue;
}
const liveAttachment = this.getLiveAttachment(image.id); const liveAttachment = this.getLiveAttachment(image.id);
if (!liveAttachment) { if (!liveAttachment) {
@@ -278,7 +290,11 @@ export class ChatMessageItemComponent implements OnDestroy {
void this.attachmentsSvc.tryRestoreAttachmentFromLocal(liveAttachment); 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); 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 { ngOnDestroy(): void {
this.visibilityObserver?.disconnect();
this.visibilityObserver = null;
if (this.isMessageVisible()) {
this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(this.message().id);
}
this.clearLongPressTimer(); this.clearLongPressTimer();
this.detachMobileSheet(); this.detachMobileSheet();
} }
@@ -771,6 +847,10 @@ export class ChatMessageItemComponent implements OnDestroy {
return isInlineDisplayableImage(attachment); return isInlineDisplayableImage(attachment);
} }
isImagePendingHydration(attachment: ChatMessageAttachmentViewModel): boolean {
return isAttachmentPendingInlineHydration(attachment);
}
isImageLikeAttachment(attachment: ChatMessageAttachmentViewModel): boolean { isImageLikeAttachment(attachment: ChatMessageAttachmentViewModel): boolean {
return isImageAttachment(attachment); 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 { private getLiveAttachment(attachmentId: string): Attachment | undefined {
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId); return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
} }

View File

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

View File

@@ -16,6 +16,7 @@ import {
} from '../../../../infrastructure/mobile'; } from '../../../../infrastructure/mobile';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing'; import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { import {
VoiceActivityService, VoiceActivityService,
VoiceConnectionFacade, VoiceConnectionFacade,
@@ -109,6 +110,47 @@ describe('DirectCallService', () => {
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled(); 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 () => { it('answers an incoming call from the modal action', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] }); const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
@@ -573,9 +615,17 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
{ {
provide: MobileMediaService, provide: MobileMediaService,
useValue: { useValue: {
ensureVoiceCapturePermissions: vi.fn(async () => true),
setSpeakerphoneEnabled: vi.fn(async () => undefined) setSpeakerphoneEnabled: vi.fn(async () => undefined)
} }
}, },
{
provide: RealtimeSessionFacade,
useValue: {
getClientInstanceId: vi.fn(() => 'test-client'),
requestVoiceClientTakeover: vi.fn()
}
},
...provideAppI18nForTests() ...provideAppI18nForTests()
] ]
}); });

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Injectable, Injectable,
computed, computed,
@@ -33,6 +33,11 @@ import {
User User
} from '../../../../shared-kernel'; } from '../../../../shared-kernel';
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model'; 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'; import { toDirectMessageParticipant } from '../../../direct-message';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -772,13 +777,20 @@ export class DirectCallService {
private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession { private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession {
return { return {
...nextSession, ...nextSession,
participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => [ participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => {
participant.userId, const previousEntry = findDirectCallParticipantEntryForUser(previousSession, {
{ id: participant.userId,
...participant, oderId: participant.profile.userId
joined: previousSession.participants[participant.userId]?.joined ?? participant.joined });
}
])) return [
participant.userId,
{
...participant,
joined: previousEntry?.participant.joined ?? participant.joined
}
];
}))
}; };
} }
@@ -865,7 +877,14 @@ export class DirectCallService {
joined: boolean, joined: boolean,
status: DirectCallSession['status'] status: DirectCallSession['status']
): DirectCallSession { ): 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 { return {
...session, ...session,
@@ -874,7 +893,7 @@ export class DirectCallService {
...session.participants, ...session.participants,
...(participant ...(participant
? { ? {
[participantId]: { [key]: {
...participant, ...participant,
joined joined
} }
@@ -916,9 +935,9 @@ export class DirectCallService {
} }
private isCurrentUserJoined(session: DirectCallSession): boolean { 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 { private stopLocalMedia(session: DirectCallSession): void {
@@ -955,8 +974,13 @@ export class DirectCallService {
} }
private markRemoteVoiceState(userId: string, session: DirectCallSession, connected: boolean): void { 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({ this.store.dispatch(UsersActions.updateVoiceState({
userId, userId: resolvedUserId,
voiceState: { voiceState: {
isConnected: connected, isConnected: connected,
isMuted: false, 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, createGroupConversation,
directMessageEventIncludesUser, directMessageEventIncludesUser,
directMessageSyncIncludesUser, directMessageSyncIncludesUser,
directMessageConversationIncludesUser,
createDirectCallStartedMessage, createDirectCallStartedMessage,
getDirectConversationId, getDirectConversationId,
isGroupDirectConversation, isGroupDirectConversation,
@@ -137,6 +138,32 @@ describe('DirectMessageService domain flow', () => {
expect(directMessageEventIncludesUser(payload, 'charlie')).toBe(false); 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', () => { it('recognises only declared sync participants', () => {
const payload = { const payload = {
conversationId: 'dm-group-test', conversationId: 'dm-group-test',

View File

@@ -14,6 +14,7 @@ import { OfflineMessageQueueService } from './offline-message-queue.service';
import { PeerDeliveryService } from './peer-delivery.service'; import { PeerDeliveryService } from './peer-delivery.service';
import { AttachmentFacade } from '../../../attachment'; import { AttachmentFacade } from '../../../attachment';
import { CustomEmojiService } from '../../../custom-emoji'; import { CustomEmojiService } from '../../../custom-emoji';
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
import { import {
advanceDirectMessageStatus, advanceDirectMessageStatus,
createDirectConversation, createDirectConversation,
@@ -27,6 +28,7 @@ import {
updateMessageStatusInConversation, updateMessageStatusInConversation,
upsertDirectMessage upsertDirectMessage
} from '../../domain/logic/direct-message.logic'; } from '../../domain/logic/direct-message.logic';
import { collectDirectMessageSelfUserIds, isSelfDirectMessageSender } from '../../domain/logic/direct-message-identity.rules';
import { import {
DirectMessage, DirectMessage,
DirectMessageConversation, DirectMessageConversation,
@@ -67,6 +69,7 @@ export class DirectMessageService {
private readonly delivery = inject(PeerDeliveryService); private readonly delivery = inject(PeerDeliveryService);
private readonly attachments = inject(AttachmentFacade); private readonly attachments = inject(AttachmentFacade);
private readonly customEmoji = inject(CustomEmojiService); private readonly customEmoji = inject(CustomEmojiService);
private readonly credentialStore = inject(SignalServerCredentialStoreService);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly currentUser = this.store.selectSignal(selectCurrentUser); private readonly currentUser = this.store.selectSignal(selectCurrentUser);
@@ -501,8 +504,9 @@ export class DirectMessageService {
private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> { private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser(); 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; return;
} }
@@ -571,8 +575,9 @@ export class DirectMessageService {
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> { private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.findConversation(ownerId, payload.conversationId); const conversation = await this.findConversation(ownerId, payload.conversationId);
const selfUserIds = this.getSelfUserIds();
if (!conversation || !directMessageConversationIncludesUser(conversation, ownerId)) { if (!conversation || !directMessageConversationIncludesUser(conversation, selfUserIds)) {
return; return;
} }
@@ -580,16 +585,16 @@ export class DirectMessageService {
} }
private handleIncomingTyping(payload: DirectMessageTypingEventPayload): void { 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; return;
} }
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId); const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId);
if (!conversation if (!conversation
|| !directMessageConversationIncludesUser(conversation, currentUserId) || !directMessageConversationIncludesUser(conversation, selfUserIds)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) { || !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return; return;
} }
@@ -621,10 +626,11 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser(); const currentUser = this.requireCurrentUser();
const conversation = await this.findConversation(ownerId, payload.conversationId); const conversation = await this.findConversation(ownerId, payload.conversationId);
const selfUserIds = this.getSelfUserIds();
if (!conversation if (!conversation
|| payload.sender.userId === ownerId || selfUserIds.has(payload.sender.userId)
|| !directMessageConversationIncludesUser(conversation, ownerId) || !directMessageConversationIncludesUser(conversation, selfUserIds)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) { || !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return; return;
} }
@@ -647,12 +653,13 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser(); const currentUser = this.requireCurrentUser();
const currentParticipant = toDirectMessageParticipant(currentUser); const currentParticipant = toDirectMessageParticipant(currentUser);
const selfUserIds = this.getSelfUserIds();
if (payload.sender.userId === ownerId) { if (selfUserIds.has(payload.sender.userId)) {
return; return;
} }
if (!directMessageSyncIncludesUser(payload, ownerId) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) { if (!directMessageSyncIncludesUser(payload, selfUserIds) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) {
return; return;
} }
@@ -929,7 +936,9 @@ export class DirectMessageService {
return []; 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' { private conversationKind(conversation: DirectMessageConversation): 'direct' | 'group' {
@@ -991,4 +1000,16 @@ export class DirectMessageService {
return ownerId; 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'; } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectAllUsers } from '../../../../store/users/users.selectors'; 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' }) @Injectable({ providedIn: 'root' })
export class PeerDeliveryService { export class PeerDeliveryService {
@@ -87,13 +88,13 @@ export class PeerDeliveryService {
return recipientId; return recipientId;
} }
const user = this.users().find((candidate: User) => const lookup = buildUserIdentityLookup(this.users());
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId const user = resolveUserByIdentity(lookup, recipientId);
);
const candidates = [ const candidates = [
user?.oderId, user?.oderId,
user?.peerId, user?.peerId,
user?.id user?.id,
recipientId
].filter((candidate): candidate is string => !!candidate); ].filter((candidate): candidate is string => !!candidate);
return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null; return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null;
@@ -135,9 +136,8 @@ export class PeerDeliveryService {
} }
private resolveCandidateIds(recipientId: string): string[] { private resolveCandidateIds(recipientId: string): string[] {
const user = this.users().find((candidate: User) => const lookup = buildUserIdentityLookup(this.users());
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId const user = resolveUserByIdentity(lookup, recipientId);
);
return [ return [
recipientId, 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( export function directMessageConversationIncludesUser(
conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>, conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>,
userId: string userId: string | ReadonlySet<string>
): boolean { ): 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( export function directMessageEventIncludesUser(
payload: DirectMessageEventPayload, payload: DirectMessageEventPayload,
userId: string userId: string | ReadonlySet<string>
): boolean { ): 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( export function directMessageSyncIncludesUser(
payload: DirectMessageSyncEventPayload, payload: DirectMessageSyncEventPayload,
userId: string userId: string | ReadonlySet<string>
): boolean { ): 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( export function upsertDirectMessage(

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -15,7 +15,6 @@ import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs'; import { map } from 'rxjs';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { import {
BottomSheetComponent, BottomSheetComponent,
@@ -23,11 +22,16 @@ import {
UserAvatarComponent UserAvatarComponent
} from '../../../../shared'; } from '../../../../shared';
import { DirectCallService } from '../../../direct-call'; import { DirectCallService } from '../../../direct-call';
import { Attachment, AttachmentFacade } from '../../../attachment'; import {
Attachment,
AttachmentDownloadService,
AttachmentFacade
} from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme'; import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service'; import { DirectMessageService } from '../../application/services/direct-message.service';
import { isConversationBound } from './dm-chat.rules'; import { isConversationBound } from './dm-chat.rules';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; 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 { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide'; import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide';
import { import {
@@ -87,7 +91,7 @@ export class DmChatComponent {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly electronBridge = inject(ElectronBridgeService); private readonly attachmentDownload = inject(AttachmentDownloadService);
private readonly attachments = inject(AttachmentFacade); private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService); private readonly linkMetadata = inject(LinkMetadataService);
@@ -137,13 +141,14 @@ export class DmChatComponent {
readonly participantUsers = computed<User[]>(() => { readonly participantUsers = computed<User[]>(() => {
const conversation = this.conversation(); const conversation = this.conversation();
const knownUsers = this.allUsers(); const knownUsers = this.allUsers();
const userLookup = buildUserIdentityLookup(knownUsers);
if (!conversation) { if (!conversation) {
return []; return [];
} }
return conversation.participants.map((participantId) => { 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]; const participant = conversation.participantProfiles[participantId];
return ( return (
@@ -483,49 +488,7 @@ export class DmChatComponent {
} }
async downloadAttachment(attachment: Attachment): Promise<void> { async downloadAttachment(attachment: Attachment): Promise<void> {
if (!attachment.available || !attachment.objectUrl) { await this.attachmentDownload.downloadToUserLocation(attachment);
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);
} }
async copyImageToClipboard(attachment: Attachment): Promise<void> { async copyImageToClipboard(attachment: Attachment): Promise<void> {
@@ -597,48 +560,6 @@ export class DmChatComponent {
return `${messageId}:${url}`; 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 { private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
if (conversation.kind === 'group' || conversation.participants.length > 2) { if (conversation.kind === 'group' || conversation.participants.length > 2) {
return null; return null;
@@ -651,7 +572,9 @@ export class DmChatComponent {
return null; 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 { private groupConversationTitle(conversation: NonNullable<ReturnType<typeof this.conversation>>): string {

View File

@@ -136,7 +136,7 @@ describe('CreateServerDialogComponent', () => {
component.create(); 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); 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