Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb0ac930ad | |||
| f0d79aa627 | |||
| 95259e8943 | |||
| 924d4bbb1d | |||
| baa350e90a | |||
| b2a2d9d770 | |||
| c3c2f01cc6 | |||
| dac5cb42a5 | |||
| 29032b5a36 | |||
| e75b4a38ed |
72
e2e/helpers/signal-manager.ts
Normal file
72
e2e/helpers/signal-manager.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/** Read how many signaling managers are currently connected for this page. */
|
||||||
|
export async function getConnectedSignalManagerCount(page: Page): Promise<number> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.querySelector('app-rooms-side-panel');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = debugApi.getComponent(host);
|
||||||
|
const realtime = component['realtime'] as {
|
||||||
|
signalingTransportHandler?: {
|
||||||
|
getConnectedSignalingManagers?: () => unknown[];
|
||||||
|
};
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual-signal setups create one RTCPeerConnection per remote peer per active
|
||||||
|
* signaling manager, so the harness tracks `remotePeerCount * signalCount`
|
||||||
|
* connected peer connections.
|
||||||
|
*/
|
||||||
|
export async function waitForConnectedRemotePeerMesh(
|
||||||
|
page: Page,
|
||||||
|
remotePeerCount: number,
|
||||||
|
timeout = 45_000
|
||||||
|
): Promise<void> {
|
||||||
|
const signalCount = Math.max(await getConnectedSignalManagerCount(page), 1);
|
||||||
|
const expectedCount = remotePeerCount * signalCount;
|
||||||
|
const minimumCount = Math.max(remotePeerCount, expectedCount - signalCount);
|
||||||
|
|
||||||
|
await page.waitForFunction(
|
||||||
|
(min) => ((window as unknown as {
|
||||||
|
__rtcConnections?: RTCPeerConnection[];
|
||||||
|
}).__rtcConnections ?? []).filter(
|
||||||
|
(pc) => pc.connectionState === 'connected'
|
||||||
|
).length >= min,
|
||||||
|
minimumCount,
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMinimumConnectedPeerMeshCount(
|
||||||
|
page: Page,
|
||||||
|
remotePeerCount: number
|
||||||
|
): Promise<number> {
|
||||||
|
const signalCount = Math.max(await getConnectedSignalManagerCount(page), 1);
|
||||||
|
const expectedCount = remotePeerCount * signalCount;
|
||||||
|
|
||||||
|
return Math.max(remotePeerCount, expectedCount - signalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForConnectedSignalManagerCount(
|
||||||
|
page: Page,
|
||||||
|
expectedCount: number,
|
||||||
|
timeout = 30_000
|
||||||
|
): Promise<void> {
|
||||||
|
await expect.poll(async () => await getConnectedSignalManagerCount(page), {
|
||||||
|
timeout,
|
||||||
|
intervals: [500, 1_000]
|
||||||
|
}).toBe(expectedCount);
|
||||||
|
}
|
||||||
49
e2e/helpers/voice-roster.ts
Normal file
49
e2e/helpers/voice-roster.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/** Wait until the side-panel roster under a voice channel lists the expected user count. */
|
||||||
|
export async function waitForVoiceRosterCount(
|
||||||
|
page: Page,
|
||||||
|
channelName: string,
|
||||||
|
expectedCount: number,
|
||||||
|
timeout = 45_000
|
||||||
|
): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ expected, name }) => {
|
||||||
|
const buttons = document.querySelectorAll(
|
||||||
|
`app-rooms-side-panel button[data-channel-type="voice"][data-channel-name="${name}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const button of buttons) {
|
||||||
|
const panel = button.closest('app-rooms-side-panel');
|
||||||
|
|
||||||
|
if (!panel || panel.getBoundingClientRect().width === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rosterDiv = button.nextElementSibling;
|
||||||
|
|
||||||
|
if (!rosterDiv) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayNames = new Set<string>();
|
||||||
|
|
||||||
|
rosterDiv.querySelectorAll('[appThemeNode="roomVoiceUserItem"] span.text-sm').forEach((element) => {
|
||||||
|
const label = element.textContent?.trim();
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
displayNames.add(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (displayNames.size === expected) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{ expected: expectedCount, name: channelName },
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,10 +8,6 @@ interface ScreenShareMediaStream extends MediaStream {
|
|||||||
__isScreenShare?: boolean;
|
__isScreenShare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function webRtcHarnessWindow(scope: Window = window): WebRtcTestHarnessWindow {
|
|
||||||
return scope as unknown as WebRtcTestHarnessWindow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
||||||
* Tracks all created peer connections and their remote tracks so tests
|
* Tracks all created peer connections and their remote tracks so tests
|
||||||
@@ -32,7 +28,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
source?: AudioScheduledSourceNode;
|
source?: AudioScheduledSourceNode;
|
||||||
drawIntervalId?: number;
|
drawIntervalId?: number;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
const harness = webRtcHarnessWindow();
|
const harness = window as unknown as WebRtcTestHarnessWindow;
|
||||||
|
|
||||||
harness.__rtcConnections = connections;
|
harness.__rtcConnections = connections;
|
||||||
harness.__rtcDataChannels = dataChannels;
|
harness.__rtcDataChannels = dataChannels;
|
||||||
@@ -160,6 +156,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
|
|
||||||
return resultStream;
|
return resultStream;
|
||||||
};
|
};
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +178,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const OrigAudioContext = window.AudioContext;
|
const OrigAudioContext = window.AudioContext;
|
||||||
const audioHarness = webRtcHarnessWindow();
|
const audioHarness = window as unknown as WebRtcTestHarnessWindow;
|
||||||
|
|
||||||
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
|
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
|
||||||
const ctx: AudioContext = new OrigAudioContext(...args);
|
const ctx: AudioContext = new OrigAudioContext(...args);
|
||||||
@@ -211,7 +208,7 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
|||||||
|
|
||||||
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => webRtcHarnessWindow().__rtcConnections?.some(
|
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
) ?? false,
|
) ?? false,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -224,7 +221,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
|
|||||||
*/
|
*/
|
||||||
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => webRtcHarnessWindow().__rtcConnections?.some(
|
() => (window as unknown as WebRtcTestHarnessWindow).__rtcConnections?.some(
|
||||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
) ?? false
|
) ?? false
|
||||||
);
|
);
|
||||||
@@ -233,7 +230,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
|||||||
/** Returns the number of tracked peer connections in `connected` state. */
|
/** Returns the number of tracked peer connections in `connected` state. */
|
||||||
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||||
(pc) => pc.connectionState === 'connected'
|
(pc) => pc.connectionState === 'connected'
|
||||||
).length ?? 0
|
).length ?? 0
|
||||||
);
|
);
|
||||||
@@ -241,19 +238,36 @@ export async function getConnectedPeerCount(page: Page): Promise<number> {
|
|||||||
|
|
||||||
/** Wait until the expected number of peer connections are `connected`. */
|
/** Wait until the expected number of peer connections are `connected`. */
|
||||||
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||||
|
try {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(count) => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||||
(pc) => pc.connectionState === 'connected'
|
(pc) => pc.connectionState === 'connected'
|
||||||
).length === count,
|
).length === count,
|
||||||
expectedCount,
|
expectedCount,
|
||||||
{ timeout }
|
{ timeout }
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const diagnostics = await page.evaluate(() => {
|
||||||
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: connections.filter((pc) => pc.connectionState === 'connected').length,
|
||||||
|
states: connections.map((pc) => pc.connectionState)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Expected ${expectedCount} connected peers within ${timeout}ms; `
|
||||||
|
+ `saw ${diagnostics.connected} connected (${diagnostics.states.join(', ') || 'none'})`,
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the number of tracked RTCDataChannels in the open state. */
|
/** Returns the number of tracked RTCDataChannels in the open state. */
|
||||||
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
() => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||||
(channel) => channel.readyState === 'open'
|
(channel) => channel.readyState === 'open'
|
||||||
).length ?? 0
|
).length ?? 0
|
||||||
);
|
);
|
||||||
@@ -262,7 +276,7 @@ export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
|||||||
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
||||||
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(count) => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
(count) => ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||||
(channel) => channel.readyState === 'open'
|
(channel) => channel.readyState === 'open'
|
||||||
).length === count,
|
).length === count,
|
||||||
expectedCount,
|
expectedCount,
|
||||||
@@ -273,7 +287,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num
|
|||||||
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
||||||
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||||
|
|
||||||
let closed = 0;
|
let closed = 0;
|
||||||
|
|
||||||
@@ -293,7 +307,7 @@ export async function closeOpenDataChannels(page: Page): Promise<number> {
|
|||||||
/** Dispatch a synthetic data-channel error event on each open channel. */
|
/** Dispatch a synthetic data-channel error event on each open channel. */
|
||||||
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
const channels = ((window as unknown as WebRtcTestHarnessWindow).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||||
|
|
||||||
let dispatched = 0;
|
let dispatched = 0;
|
||||||
|
|
||||||
@@ -354,7 +368,7 @@ interface PerPeerAudioStat {
|
|||||||
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
||||||
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length) {
|
if (!connections?.length) {
|
||||||
return [];
|
return [];
|
||||||
@@ -472,7 +486,7 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||||
}> {
|
}> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return { outbound: null, inbound: null };
|
return { outbound: null, inbound: null };
|
||||||
@@ -486,8 +500,8 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
hasInbound: boolean;
|
hasInbound: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hwm: Record<number, HWMEntry> = webRtcHarnessWindow().__rtcStatsHWM =
|
const hwm: Record<number, HWMEntry> = (window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM =
|
||||||
(webRtcHarnessWindow().__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
((window as unknown as WebRtcTestHarnessWindow).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
||||||
|
|
||||||
for (let idx = 0; idx < connections.length; idx++) {
|
for (let idx = 0; idx < connections.length; idx++) {
|
||||||
let stats: RTCStatsReport;
|
let stats: RTCStatsReport;
|
||||||
@@ -596,7 +610,7 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis
|
|||||||
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
async () => {
|
async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return false;
|
return false;
|
||||||
@@ -705,7 +719,7 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||||
}> {
|
}> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return { outbound: null, inbound: null };
|
return { outbound: null, inbound: null };
|
||||||
@@ -719,8 +733,8 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
hasInbound: boolean;
|
hasInbound: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hwm: Record<number, VHWM> = webRtcHarnessWindow().__rtcVideoStatsHWM =
|
const hwm: Record<number, VHWM> = (window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM =
|
||||||
(webRtcHarnessWindow().__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
((window as unknown as WebRtcTestHarnessWindow).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
||||||
|
|
||||||
for (let idx = 0; idx < connections.length; idx++) {
|
for (let idx = 0; idx < connections.length; idx++) {
|
||||||
let stats: RTCStatsReport;
|
let stats: RTCStatsReport;
|
||||||
@@ -804,7 +818,7 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
async () => {
|
async () => {
|
||||||
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return false;
|
return false;
|
||||||
@@ -972,7 +986,7 @@ export async function waitForInboundVideoFlow(
|
|||||||
*/
|
*/
|
||||||
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const conns = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
const conns = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!conns?.length)
|
if (!conns?.length)
|
||||||
return 'No connections tracked';
|
return 'No connections tracked';
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ test.describe('Multi-device session', () => {
|
|||||||
expect(instanceA).not.toEqual(instanceB);
|
expect(instanceA).not.toEqual(instanceB);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await test.step('shows one self identity in the members panel on each device', async () => {
|
||||||
|
for (const client of [scenario.clientA, scenario.clientB]) {
|
||||||
|
await expect(
|
||||||
|
membersSidePanel(client.page).getByText(scenario.credentials.displayName, { exact: true })
|
||||||
|
).toHaveCount(1, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await test.step('syncs chat from device A to device B', async () => {
|
await test.step('syncs chat from device A to device B', async () => {
|
||||||
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
dumpRtcDiagnostics,
|
dumpRtcDiagnostics,
|
||||||
getConnectedPeerCount,
|
getConnectedPeerCount,
|
||||||
installWebRTCTracking,
|
installWebRTCTracking,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
waitForAllPeerAudioFlow,
|
waitForAllPeerAudioFlow,
|
||||||
waitForAudioStatsPresent,
|
waitForAudioStatsPresent,
|
||||||
waitForConnectedPeerCount,
|
|
||||||
waitForPeerConnected
|
waitForPeerConnected
|
||||||
} from '../../helpers/webrtc-helpers';
|
} from '../../helpers/webrtc-helpers';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||||
|
import { getMinimumConnectedPeerMeshCount, waitForConnectedRemotePeerMesh } from '../../helpers/signal-manager';
|
||||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
|
|
||||||
// ── Signal endpoint identifiers ──────────────────────────────────────
|
// ── Signal endpoint identifiers ──────────────────────────────────────
|
||||||
@@ -132,7 +134,8 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
await installTestServerEndpoints(client.context, groupEndpoints);
|
await installTestServerEndpoints(client.context, groupEndpoints);
|
||||||
await installDeterministicVoiceSettings(client.page);
|
await installDeterministicVoiceSettings(client.page);
|
||||||
await installWebRTCTracking(client.page);
|
await installWebRTCTracking(client.context);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
clients.push({ ...client, user });
|
clients.push({ ...client, user });
|
||||||
}
|
}
|
||||||
@@ -300,8 +303,11 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||||
|
await client.page.waitForTimeout(2_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clients[0].page.waitForTimeout(10_000);
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
}
|
}
|
||||||
@@ -310,11 +316,11 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
// ── Audio mesh ──────────────────────────────────────────────
|
// ── Audio mesh ──────────────────────────────────────────────
|
||||||
await test.step('All users discover peers and audio flows pairwise', async () => {
|
await test.step('All users discover peers and audio flows pairwise', async () => {
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForPeerConnected(client.page, 45_000)
|
waitForPeerConnected(client.page, 90_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
@@ -324,7 +330,7 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
await clients[0].page.waitForTimeout(5_000);
|
await clients[0].page.waitForTimeout(5_000);
|
||||||
|
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,7 +341,6 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
await openVoiceWorkspace(client.page);
|
await openVoiceWorkspace(client.page);
|
||||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -372,18 +377,28 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
for (const client of stayers) {
|
for (const client of stayers) {
|
||||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
await expect.poll(async () => {
|
||||||
|
const actual = await getConnectedPeerCount(client.page);
|
||||||
|
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
|
||||||
|
|
||||||
|
return actual >= minimum;
|
||||||
|
}, {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
intervals: [500, 1_000]
|
intervals: [500, 1_000]
|
||||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
}).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check chatters still have voice peers even while viewing another room
|
// Check chatters still have voice peers even while viewing another room
|
||||||
for (const chatter of chatters) {
|
for (const chatter of chatters) {
|
||||||
await expect.poll(async () => await getConnectedPeerCount(chatter.page), {
|
await expect.poll(async () => {
|
||||||
|
const actual = await getConnectedPeerCount(chatter.page);
|
||||||
|
const minimum = await getMinimumConnectedPeerMeshCount(chatter.page, EXPECTED_REMOTE_PEERS);
|
||||||
|
|
||||||
|
return actual >= minimum;
|
||||||
|
}, {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
intervals: [500, 1_000]
|
intervals: [500, 1_000]
|
||||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
}).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Date.now() < deadline) {
|
if (Date.now() < deadline) {
|
||||||
@@ -749,63 +764,6 @@ async function waitForLocalVoiceChannelConnection(page: Page, channelName: strin
|
|||||||
|
|
||||||
// ── Roster / state helpers ───────────────────────────────────────────
|
// ── Roster / state helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
(count) => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-voice-workspace');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
|
|
||||||
|
|
||||||
return connectedUsers.length === count;
|
|
||||||
},
|
|
||||||
expectedCount,
|
|
||||||
{ timeout: 45_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ expected, name }) => {
|
|
||||||
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
|
|
||||||
interface RoomShape { channels?: ChannelShape[] }
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
|
||||||
const channelId = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name)?.id;
|
|
||||||
|
|
||||||
if (!channelId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
|
|
||||||
|
|
||||||
return roster.length === expected;
|
|
||||||
},
|
|
||||||
{ expected: expectedCount, name: channelName },
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceStateAcrossPages(
|
async function waitForVoiceStateAcrossPages(
|
||||||
clients: readonly TestClient[],
|
clients: readonly TestClient[],
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
|||||||
@@ -6,14 +6,21 @@ import {
|
|||||||
dumpRtcDiagnostics,
|
dumpRtcDiagnostics,
|
||||||
getConnectedPeerCount,
|
getConnectedPeerCount,
|
||||||
installWebRTCTracking,
|
installWebRTCTracking,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
waitForAllPeerAudioFlow,
|
waitForAllPeerAudioFlow,
|
||||||
waitForAudioStatsPresent,
|
waitForAudioStatsPresent,
|
||||||
waitForConnectedPeerCount,
|
|
||||||
waitForPeerConnected
|
waitForPeerConnected
|
||||||
} from '../../helpers/webrtc-helpers';
|
} from '../../helpers/webrtc-helpers';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { waitForVoiceRosterCount } from '../../helpers/voice-roster';
|
||||||
|
import {
|
||||||
|
getConnectedSignalManagerCount,
|
||||||
|
getMinimumConnectedPeerMeshCount,
|
||||||
|
waitForConnectedRemotePeerMesh,
|
||||||
|
waitForConnectedSignalManagerCount
|
||||||
|
} from '../../helpers/signal-manager';
|
||||||
|
|
||||||
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
|
const PRIMARY_SIGNAL_ID = 'e2e-test-server-a';
|
||||||
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
const SECONDARY_SIGNAL_ID = 'e2e-test-server-b';
|
||||||
@@ -116,8 +123,11 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
await joinVoiceChannelUntilConnected(client.page, VOICE_CHANNEL);
|
||||||
|
await client.page.waitForTimeout(2_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clients[0].page.waitForTimeout(10_000);
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
}
|
}
|
||||||
@@ -126,12 +136,12 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
await test.step('All users discover all peers and audio flows pairwise', async () => {
|
await test.step('All users discover all peers and audio flows pairwise', async () => {
|
||||||
// Wait for all clients to have at least one connected peer (fast)
|
// Wait for all clients to have at least one connected peer (fast)
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForPeerConnected(client.page, 45_000)
|
waitForPeerConnected(client.page, 90_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Wait for all clients to have all 7 peers connected
|
// Wait for all clients to have all 7 peers connected
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForConnectedPeerCount(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForConnectedRemotePeerMesh(client.page, EXPECTED_REMOTE_PEERS, 180_000)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Wait for audio stats to appear on all clients
|
// Wait for audio stats to appear on all clients
|
||||||
@@ -146,7 +156,7 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
// Check bidirectional audio flow on each client
|
// Check bidirectional audio flow on each client
|
||||||
await Promise.all(clients.map((client) =>
|
await Promise.all(clients.map((client) =>
|
||||||
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 90_000)
|
waitForAllPeerAudioFlow(client.page, EXPECTED_REMOTE_PEERS, 300_000)
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +166,6 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
await openVoiceWorkspace(client.page);
|
await openVoiceWorkspace(client.page);
|
||||||
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
await expect(room.voiceWorkspace).toBeVisible({ timeout: 10_000 });
|
||||||
await waitForVoiceWorkspaceUserCount(client.page, USER_COUNT);
|
|
||||||
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
await waitForVoiceRosterCount(client.page, VOICE_CHANNEL, USER_COUNT);
|
||||||
await waitForConnectedSignalManagerCount(client.page, 2);
|
await waitForConnectedSignalManagerCount(client.page, 2);
|
||||||
}
|
}
|
||||||
@@ -167,10 +176,15 @@ test.describe('Dual-signal multi-user voice', () => {
|
|||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
await expect.poll(async () => await getConnectedPeerCount(client.page), {
|
await expect.poll(async () => {
|
||||||
|
const actual = await getConnectedPeerCount(client.page);
|
||||||
|
const minimum = await getMinimumConnectedPeerMeshCount(client.page, EXPECTED_REMOTE_PEERS);
|
||||||
|
|
||||||
|
return actual >= minimum;
|
||||||
|
}, {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
intervals: [500, 1_000]
|
intervals: [500, 1_000]
|
||||||
}).toBe(EXPECTED_REMOTE_PEERS);
|
}).toBe(true);
|
||||||
|
|
||||||
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
await expect.poll(async () => await getConnectedSignalManagerCount(client.page), {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
@@ -292,7 +306,8 @@ async function createTrackedClients(
|
|||||||
|
|
||||||
await installTestServerEndpoints(client.context, endpoints);
|
await installTestServerEndpoints(client.context, endpoints);
|
||||||
await installDeterministicVoiceSettings(client.page);
|
await installDeterministicVoiceSettings(client.page);
|
||||||
await installWebRTCTracking(client.page);
|
await installWebRTCTracking(client.context);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
clients.push({
|
clients.push({
|
||||||
...client,
|
...client,
|
||||||
@@ -576,124 +591,6 @@ async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise
|
|||||||
}, channelName);
|
}, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForConnectedSignalManagerCount(page: Page, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
(count) => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const realtime = component['realtime'] as {
|
|
||||||
signalingTransportHandler?: {
|
|
||||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
|
||||||
};
|
|
||||||
} | undefined;
|
|
||||||
const countValue = realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
|
||||||
|
|
||||||
return countValue === count;
|
|
||||||
},
|
|
||||||
expectedCount,
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getConnectedSignalManagerCount(page: Page): Promise<number> {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const realtime = component['realtime'] as {
|
|
||||||
signalingTransportHandler?: {
|
|
||||||
getConnectedSignalingManagers?: () => { signalUrl: string }[];
|
|
||||||
};
|
|
||||||
} | undefined;
|
|
||||||
|
|
||||||
return realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
(count) => {
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-voice-workspace');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? [];
|
|
||||||
|
|
||||||
return connectedUsers.length === count;
|
|
||||||
},
|
|
||||||
expectedCount,
|
|
||||||
{ timeout: 45_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceRosterCount(page: Page, channelName: string, expectedCount: number): Promise<void> {
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ expected, name }) => {
|
|
||||||
interface ChannelShape {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: 'text' | 'voice';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoomShape {
|
|
||||||
channels?: ChannelShape[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AngularDebugApi {
|
|
||||||
getComponent: (element: Element) => Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = document.querySelector('app-rooms-side-panel');
|
|
||||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
||||||
|
|
||||||
if (!host || !debugApi?.getComponent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = debugApi.getComponent(host);
|
|
||||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
|
||||||
const channelId = currentRoom?.channels?.find((channel) => channel.type === 'voice' && channel.name === name)?.id;
|
|
||||||
|
|
||||||
if (!channelId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? [];
|
|
||||||
|
|
||||||
return roster.length === expected;
|
|
||||||
},
|
|
||||||
{ expected: expectedCount, name: channelName },
|
|
||||||
{ timeout: 30_000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForVoiceStateAcrossPages(
|
async function waitForVoiceStateAcrossPages(
|
||||||
clients: readonly TestClient[],
|
clients: readonly TestClient[],
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
|||||||
127
e2e/tests/voice/voice-mute-state-reset.spec.ts
Normal file
127
e2e/tests/voice/voice-mute-state-reset.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { test, expect } from '../../fixtures/multi-client';
|
||||||
|
import {
|
||||||
|
MULTI_DEVICE_PASSWORD,
|
||||||
|
MULTI_DEVICE_VOICE_CHANNEL,
|
||||||
|
closeClient,
|
||||||
|
loginSecondDeviceIntoServer,
|
||||||
|
uniqueMultiDeviceName
|
||||||
|
} from '../../helpers/multi-device-session';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
|
||||||
|
async function waitForVoiceMuteState(
|
||||||
|
page: import('@playwright/test').Page,
|
||||||
|
displayName: string,
|
||||||
|
expectedMuted: boolean,
|
||||||
|
timeout = 45_000
|
||||||
|
): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ expectedDisplayName, expectedMuted: muted }) => {
|
||||||
|
interface VoiceStateShape { isMuted?: boolean }
|
||||||
|
interface UserShape { displayName: string; voiceState?: VoiceStateShape }
|
||||||
|
interface ChannelShape { id: string; type: 'text' | 'voice' }
|
||||||
|
interface RoomShape { channels?: ChannelShape[] }
|
||||||
|
interface AngularDebugApi {
|
||||||
|
getComponent: (element: Element) => Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.querySelector('app-rooms-side-panel');
|
||||||
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||||
|
|
||||||
|
if (!host || !debugApi?.getComponent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = debugApi.getComponent(host);
|
||||||
|
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||||
|
const voiceChannel = currentRoom?.channels?.find((channel) => channel.type === 'voice');
|
||||||
|
|
||||||
|
if (!voiceChannel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? [];
|
||||||
|
const entry = roster.find((userEntry) => userEntry.displayName === expectedDisplayName);
|
||||||
|
|
||||||
|
return entry?.voiceState?.isMuted === muted;
|
||||||
|
},
|
||||||
|
{ expectedDisplayName: displayName, expectedMuted },
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Voice mute state reset', () => {
|
||||||
|
test.describe.configure({ timeout: 300_000, retries: 1 });
|
||||||
|
|
||||||
|
test('clears stale mute state after abrupt disconnect and voice rejoin', async ({ createClient }) => {
|
||||||
|
const suffix = uniqueMultiDeviceName('voice-mute-reset');
|
||||||
|
const hostCredentials = {
|
||||||
|
username: `host_${suffix}`,
|
||||||
|
displayName: 'Voice Host',
|
||||||
|
password: MULTI_DEVICE_PASSWORD
|
||||||
|
};
|
||||||
|
const guestCredentials = {
|
||||||
|
username: `guest_${suffix}`,
|
||||||
|
displayName: 'Voice Guest',
|
||||||
|
password: MULTI_DEVICE_PASSWORD
|
||||||
|
};
|
||||||
|
const serverName = `Voice Mute Reset ${suffix}`;
|
||||||
|
|
||||||
|
let hostClient = await createClient();
|
||||||
|
|
||||||
|
const guestClient = await createClient();
|
||||||
|
|
||||||
|
await test.step('host creates the shared server', async () => {
|
||||||
|
const registerPage = new RegisterPage(hostClient.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(hostCredentials.username, hostCredentials.displayName, hostCredentials.password);
|
||||||
|
await expect(hostClient.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const search = new ServerSearchPage(hostClient.page);
|
||||||
|
|
||||||
|
await search.createServer(serverName, { description: 'Voice mute reset coverage' });
|
||||||
|
await expect(hostClient.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostRoom = new ChatRoomPage(hostClient.page);
|
||||||
|
|
||||||
|
await hostRoom.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL);
|
||||||
|
|
||||||
|
await test.step('guest joins the server', async () => {
|
||||||
|
const registerPage = new RegisterPage(guestClient.page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(guestCredentials.username, guestCredentials.displayName, guestCredentials.password);
|
||||||
|
await expect(guestClient.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const search = new ServerSearchPage(guestClient.page);
|
||||||
|
|
||||||
|
await search.joinServerFromSearch(serverName);
|
||||||
|
await expect(guestClient.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('host joins voice muted and guest observes the muted state', async () => {
|
||||||
|
await hostRoom.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||||
|
await expect(hostRoom.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||||
|
await hostRoom.muteButton.click();
|
||||||
|
|
||||||
|
await waitForVoiceMuteState(guestClient.page, hostCredentials.displayName, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('abrupt host disconnect clears stale mute before rejoin', async () => {
|
||||||
|
await closeClient(hostClient);
|
||||||
|
|
||||||
|
hostClient = await createClient();
|
||||||
|
await loginSecondDeviceIntoServer(hostClient.page, hostCredentials, serverName);
|
||||||
|
|
||||||
|
const reopenedRoom = new ChatRoomPage(hostClient.page);
|
||||||
|
|
||||||
|
await reopenedRoom.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||||
|
await expect(reopenedRoom.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await waitForVoiceMuteState(guestClient.page, hostCredentials.displayName, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,10 @@ export interface AppMetricsProcessSnapshot {
|
|||||||
pid: number;
|
pid: number;
|
||||||
type: string;
|
type: string;
|
||||||
workingSetKb: number | null;
|
workingSetKb: number | null;
|
||||||
|
peakWorkingSetKb: number | null;
|
||||||
|
privateBytesKb: number | null;
|
||||||
|
creationTime: number | null;
|
||||||
|
cpuPercent: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppMetricsSnapshot {
|
export interface AppMetricsSnapshot {
|
||||||
@@ -17,7 +21,17 @@ export function collectAppMetricsSnapshot(): AppMetricsSnapshot {
|
|||||||
processes: app.getAppMetrics().map((metric) => ({
|
processes: app.getAppMetrics().map((metric) => ({
|
||||||
pid: metric.pid,
|
pid: metric.pid,
|
||||||
type: metric.type,
|
type: metric.type,
|
||||||
workingSetKb: metric.memory?.workingSetSize ?? null
|
workingSetKb: metric.memory?.workingSetSize ?? null,
|
||||||
|
peakWorkingSetKb: readOptionalKilobytes(metric.memory?.peakWorkingSetSize),
|
||||||
|
privateBytesKb: readOptionalKilobytes(metric.memory?.privateBytes),
|
||||||
|
creationTime: metric.creationTime ?? null,
|
||||||
|
cpuPercent: typeof metric.cpu?.percentCPUUsage === 'number'
|
||||||
|
? Math.round(metric.cpu.percentCPUUsage * 10) / 10
|
||||||
|
: null
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalKilobytes(value: number | undefined): number | null {
|
||||||
|
return typeof value === 'number' && value >= 0 ? value : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { isPerfDiagEnabled } from './diagnostics.flags';
|
|||||||
describe('isPerfDiagEnabled', () => {
|
describe('isPerfDiagEnabled', () => {
|
||||||
it('returns false when the flag is unset', () => {
|
it('returns false when the flag is unset', () => {
|
||||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||||
expect(isPerfDiagEnabled({}, true)).toBe(false);
|
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
||||||
@@ -17,11 +17,12 @@ describe('isPerfDiagEnabled', () => {
|
|||||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false in packaged builds unless force is set', () => {
|
it('returns true in packaged Electron builds without env flags', () => {
|
||||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
|
expect(isPerfDiagEnabled({}, true)).toBe(true);
|
||||||
expect(isPerfDiagEnabled({
|
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '0' }, true)).toBe(true);
|
||||||
METOYOU_PERF_DIAG: '1',
|
});
|
||||||
METOYOU_PERF_DIAG_FORCE: '1'
|
|
||||||
}, true)).toBe(true);
|
it('returns false in development when the flag is unset', () => {
|
||||||
|
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,13 +17,9 @@ export function isPerfDiagEnabled(
|
|||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
isPackaged: boolean
|
isPackaged: boolean
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
|
if (isPackaged) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return isTruthyFlag(env[PERF_DIAG_ENV]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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({
|
|
||||||
collectedAt: metrics.collectedAt,
|
|
||||||
source: 'main',
|
|
||||||
type: 'process',
|
|
||||||
payload: {
|
|
||||||
totalWorkingSetKb: totalKb,
|
|
||||||
processes: metrics.processes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
highMemoryAlertTriggeredThisSession = true;
|
||||||
|
|
||||||
|
const record = await captureHighMemoryDiagnostics({
|
||||||
|
userDataPath: app.getPath('userData'),
|
||||||
|
sessionStartedAt,
|
||||||
|
metrics,
|
||||||
|
totalWorkingSetKb: totalWorkingSetKb ?? 0,
|
||||||
|
writer: activeWriter,
|
||||||
|
mainWindow: getMainWindow(),
|
||||||
|
reason: 'threshold'
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
// Collector failures must never affect the app.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sample();
|
await persistAndNotifyHighMemoryAlert(record);
|
||||||
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ export type PerfDiagSource = 'main' | 'renderer';
|
|||||||
|
|
||||||
export type PerfDiagEntryType =
|
export type PerfDiagEntryType =
|
||||||
| 'session'
|
| 'session'
|
||||||
|
| 'environment'
|
||||||
| 'process'
|
| 'process'
|
||||||
| 'store'
|
| 'store'
|
||||||
| 'components'
|
| 'components'
|
||||||
| 'heap'
|
| 'heap'
|
||||||
|
| 'high-memory'
|
||||||
| 'crash'
|
| 'crash'
|
||||||
| 'unresponsive';
|
| 'unresponsive';
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
resolveDiagnosticsFilePath
|
resolveDiagnosticsFilePath
|
||||||
} from './diagnostics.rules';
|
} from './diagnostics.rules';
|
||||||
|
|
||||||
const DEFAULT_RING_CAPACITY = 120;
|
const DEFAULT_RING_CAPACITY = 300;
|
||||||
const FLUSH_DEBOUNCE_MS = 250;
|
const FLUSH_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
export interface PerfDiagWriterOptions {
|
export interface PerfDiagWriterOptions {
|
||||||
@@ -18,6 +18,7 @@ export interface PerfDiagWriterOptions {
|
|||||||
|
|
||||||
export class PerfDiagWriter {
|
export class PerfDiagWriter {
|
||||||
private readonly filePath: string;
|
private readonly filePath: string;
|
||||||
|
private readonly sessionIdValue: string;
|
||||||
private readonly ringCapacity: number;
|
private readonly ringCapacity: number;
|
||||||
private readonly pendingLines: string[] = [];
|
private readonly pendingLines: string[] = [];
|
||||||
private ring: PerfDiagEntry[] = [];
|
private ring: PerfDiagEntry[] = [];
|
||||||
@@ -26,10 +27,15 @@ export class PerfDiagWriter {
|
|||||||
private disabled = false;
|
private disabled = false;
|
||||||
|
|
||||||
constructor(options: PerfDiagWriterOptions) {
|
constructor(options: PerfDiagWriterOptions) {
|
||||||
|
this.sessionIdValue = options.sessionId;
|
||||||
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
||||||
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get sessionId(): string {
|
||||||
|
return this.sessionIdValue;
|
||||||
|
}
|
||||||
|
|
||||||
get snapshotFilePath(): string {
|
get snapshotFilePath(): string {
|
||||||
return this.filePath;
|
return this.filePath;
|
||||||
}
|
}
|
||||||
|
|||||||
27
electron/diagnostics/high-memory-alert.rules.spec.ts
Normal file
27
electron/diagnostics/high-memory-alert.rules.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
exceedsHighMemoryThreshold,
|
||||||
|
formatWorkingSetGb,
|
||||||
|
HIGH_MEMORY_THRESHOLD_KB
|
||||||
|
} from './high-memory-alert.rules';
|
||||||
|
|
||||||
|
describe('high-memory-alert.rules', () => {
|
||||||
|
it('uses a 2 GiB working-set threshold', () => {
|
||||||
|
expect(HIGH_MEMORY_THRESHOLD_KB).toBe(2 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects totals at or above the threshold', () => {
|
||||||
|
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB - 1)).toBe(false);
|
||||||
|
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB)).toBe(true);
|
||||||
|
expect(exceedsHighMemoryThreshold(HIGH_MEMORY_THRESHOLD_KB + 1024)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats working set totals in gigabytes', () => {
|
||||||
|
expect(formatWorkingSetGb(1536 * 1024)).toBe('1.50');
|
||||||
|
expect(formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB)).toBe('2.00');
|
||||||
|
});
|
||||||
|
});
|
||||||
11
electron/diagnostics/high-memory-alert.rules.ts
Normal file
11
electron/diagnostics/high-memory-alert.rules.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/** 2 GiB working-set threshold for writing a diagnostics snapshot. */
|
||||||
|
export const HIGH_MEMORY_THRESHOLD_KB = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function exceedsHighMemoryThreshold(totalWorkingSetKb: number | null | undefined): boolean {
|
||||||
|
return typeof totalWorkingSetKb === 'number'
|
||||||
|
&& totalWorkingSetKb >= HIGH_MEMORY_THRESHOLD_KB;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWorkingSetGb(totalWorkingSetKb: number): string {
|
||||||
|
return (totalWorkingSetKb / (1024 * 1024)).toFixed(2);
|
||||||
|
}
|
||||||
65
electron/diagnostics/high-memory-alert.store.spec.ts
Normal file
65
electron/diagnostics/high-memory-alert.store.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
clearHighMemoryAlert,
|
||||||
|
readHighMemoryAlert,
|
||||||
|
resolveHighMemoryAlertPath,
|
||||||
|
writeHighMemoryAlert
|
||||||
|
} from './high-memory-alert.store';
|
||||||
|
|
||||||
|
describe('high-memory-alert.store', () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, {
|
||||||
|
recursive: true,
|
||||||
|
force: true
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes and reads a pending startup alert record', async () => {
|
||||||
|
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
|
||||||
|
|
||||||
|
tempDirs.push(userDataPath);
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'),
|
||||||
|
detectedAt: 1_700_000_000_000,
|
||||||
|
peakWorkingSetKb: 2_200_000,
|
||||||
|
sessionId: 'session-1',
|
||||||
|
reason: 'threshold' as const
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeHighMemoryAlert(userDataPath, record);
|
||||||
|
|
||||||
|
expect(resolveHighMemoryAlertPath(userDataPath)).toBe(
|
||||||
|
path.join(userDataPath, 'diagnostics', 'high-memory-pending.json')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await readHighMemoryAlert(userDataPath)).toEqual(record);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the pending startup alert record', async () => {
|
||||||
|
const userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-'));
|
||||||
|
|
||||||
|
tempDirs.push(userDataPath);
|
||||||
|
|
||||||
|
await writeHighMemoryAlert(userDataPath, {
|
||||||
|
logFilePath: '/tmp/perf.jsonl',
|
||||||
|
detectedAt: Date.now(),
|
||||||
|
peakWorkingSetKb: 2_100_000,
|
||||||
|
sessionId: 'session-2'
|
||||||
|
});
|
||||||
|
|
||||||
|
await clearHighMemoryAlert(userDataPath);
|
||||||
|
|
||||||
|
expect(await readHighMemoryAlert(userDataPath)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
electron/diagnostics/high-memory-alert.store.ts
Normal file
63
electron/diagnostics/high-memory-alert.store.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export type HighMemoryAlertReason = 'manual' | 'threshold';
|
||||||
|
|
||||||
|
export interface HighMemoryAlertRecord {
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
reason?: HighMemoryAlertReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHighMemoryAlertPath(userDataPath: string): string {
|
||||||
|
return path.join(userDataPath, 'diagnostics', 'high-memory-pending.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readHighMemoryAlert(userDataPath: string): Promise<HighMemoryAlertRecord | null> {
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(resolveHighMemoryAlertPath(userDataPath), 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as Partial<HighMemoryAlertRecord>;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof parsed.logFilePath !== 'string'
|
||||||
|
|| !parsed.logFilePath.trim()
|
||||||
|
|| typeof parsed.detectedAt !== 'number'
|
||||||
|
|| typeof parsed.peakWorkingSetKb !== 'number'
|
||||||
|
|| typeof parsed.sessionId !== 'string'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
logFilePath: parsed.logFilePath,
|
||||||
|
detectedAt: parsed.detectedAt,
|
||||||
|
peakWorkingSetKb: parsed.peakWorkingSetKb,
|
||||||
|
sessionId: parsed.sessionId,
|
||||||
|
...(parsed.reason === 'manual' || parsed.reason === 'threshold'
|
||||||
|
? { reason: parsed.reason }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeHighMemoryAlert(
|
||||||
|
userDataPath: string,
|
||||||
|
record: HighMemoryAlertRecord
|
||||||
|
): Promise<void> {
|
||||||
|
const filePath = resolveHighMemoryAlertPath(userDataPath);
|
||||||
|
|
||||||
|
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fsp.writeFile(filePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearHighMemoryAlert(userDataPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(resolveHighMemoryAlertPath(userDataPath));
|
||||||
|
} catch {
|
||||||
|
// Missing pending alert is fine.
|
||||||
|
}
|
||||||
|
}
|
||||||
57
electron/diagnostics/high-memory-capture.spec.ts
Normal file
57
electron/diagnostics/high-memory-capture.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
|
||||||
|
import { captureHighMemoryDiagnostics } from './high-memory-capture';
|
||||||
|
|
||||||
|
vi.mock('./immediate-renderer-samples.collector', () => ({
|
||||||
|
collectImmediateRendererSamples: vi.fn(async () => [])
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./session-context.collector', () => ({
|
||||||
|
collectSessionContext: vi.fn(() => ({
|
||||||
|
platform: 'linux',
|
||||||
|
userDataPath: '/tmp/user-data'
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('captureHighMemoryDiagnostics', () => {
|
||||||
|
let userDataPath = '';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-capture-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a diagnostics snapshot and returns an alert record', async () => {
|
||||||
|
const record = await captureHighMemoryDiagnostics({
|
||||||
|
userDataPath,
|
||||||
|
sessionStartedAt: Date.now() - 60_000,
|
||||||
|
metrics: {
|
||||||
|
collectedAt: Date.now(),
|
||||||
|
processes: [
|
||||||
|
{
|
||||||
|
pid: 1,
|
||||||
|
type: 'Browser',
|
||||||
|
workingSetKb: 2_200_000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
totalWorkingSetKb: 2_200_000,
|
||||||
|
writer: null,
|
||||||
|
mainWindow: null,
|
||||||
|
reason: 'manual'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(record.peakWorkingSetKb).toBe(2_200_000);
|
||||||
|
expect(record.reason).toBe('manual');
|
||||||
|
expect(record.logFilePath).toContain(userDataPath);
|
||||||
|
await expect(fsp.stat(record.logFilePath)).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
80
electron/diagnostics/high-memory-capture.ts
Normal file
80
electron/diagnostics/high-memory-capture.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import type { AppMetricsSnapshot } from '../app-metrics';
|
||||||
|
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
|
||||||
|
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
|
||||||
|
import { collectSessionContext } from './session-context.collector';
|
||||||
|
import type { HighMemoryAlertRecord } from './high-memory-alert.store';
|
||||||
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
|
import { PerfDiagWriter } from './diagnostics.writer';
|
||||||
|
|
||||||
|
export type HighMemoryCaptureReason = 'manual' | 'threshold';
|
||||||
|
|
||||||
|
export interface CaptureHighMemoryDiagnosticsInput {
|
||||||
|
userDataPath: string;
|
||||||
|
sessionStartedAt: number;
|
||||||
|
metrics: AppMetricsSnapshot;
|
||||||
|
totalWorkingSetKb: number;
|
||||||
|
writer: PerfDiagWriter | null;
|
||||||
|
mainWindow: BrowserWindow | null;
|
||||||
|
reason: HighMemoryCaptureReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureHighMemoryDiagnostics(
|
||||||
|
input: CaptureHighMemoryDiagnosticsInput
|
||||||
|
): Promise<HighMemoryAlertRecord> {
|
||||||
|
const detectedAt = Date.now();
|
||||||
|
const writer = input.writer ?? new PerfDiagWriter({
|
||||||
|
userDataPath: input.userDataPath,
|
||||||
|
sessionId: `${input.reason}-${detectedAt.toString(36)}-${process.pid}`
|
||||||
|
});
|
||||||
|
const immediateRendererEntries = await collectImmediateRendererSamples(input.mainWindow);
|
||||||
|
const environment = collectSessionContext({
|
||||||
|
sessionStartedAt: input.sessionStartedAt,
|
||||||
|
userDataPath: input.userDataPath
|
||||||
|
});
|
||||||
|
|
||||||
|
appendEntries(writer, immediateRendererEntries);
|
||||||
|
appendEntries(writer, [
|
||||||
|
{
|
||||||
|
collectedAt: detectedAt,
|
||||||
|
source: 'main',
|
||||||
|
type: 'environment',
|
||||||
|
payload: {
|
||||||
|
...environment
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collectedAt: detectedAt,
|
||||||
|
source: 'main',
|
||||||
|
type: 'high-memory',
|
||||||
|
payload: buildHighMemoryDiagnosticPayload({
|
||||||
|
detectedAt,
|
||||||
|
totalWorkingSetKb: input.totalWorkingSetKb,
|
||||||
|
metrics: input.metrics,
|
||||||
|
environment,
|
||||||
|
mainProcessMemory: process.memoryUsage(),
|
||||||
|
ringEntries: writer.bufferedEntries,
|
||||||
|
immediateRendererEntries,
|
||||||
|
sessionId: writer.sessionId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await writer.flushSnapshot(
|
||||||
|
input.reason === 'manual' ? 'manual-export' : 'high-memory-threshold'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
logFilePath: writer.snapshotFilePath,
|
||||||
|
detectedAt,
|
||||||
|
peakWorkingSetKb: input.totalWorkingSetKb,
|
||||||
|
sessionId: writer.sessionId,
|
||||||
|
reason: input.reason
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendEntries(writer: PerfDiagWriter, entries: readonly PerfDiagEntry[]): void {
|
||||||
|
for (const entry of entries) {
|
||||||
|
writer.append(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
electron/diagnostics/high-memory-snapshot.rules.spec.ts
Normal file
201
electron/diagnostics/high-memory-snapshot.rules.spec.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
|
import {
|
||||||
|
buildHighMemoryDiagnosticPayload,
|
||||||
|
buildHighMemorySummary,
|
||||||
|
extractLatestRendererSamples,
|
||||||
|
extractProcessHistory,
|
||||||
|
formatMemoryUsageMb,
|
||||||
|
rankProcessesByWorkingSet,
|
||||||
|
summarizeRingBuffer
|
||||||
|
} from './high-memory-snapshot.rules';
|
||||||
|
|
||||||
|
function createProcess(overrides: Partial<{
|
||||||
|
pid: number;
|
||||||
|
type: string;
|
||||||
|
workingSetKb: number | null;
|
||||||
|
peakWorkingSetKb: number | null;
|
||||||
|
privateBytesKb: number | null;
|
||||||
|
creationTime: number | null;
|
||||||
|
cpuPercent: number | null;
|
||||||
|
}> = {}) {
|
||||||
|
return {
|
||||||
|
pid: 1,
|
||||||
|
type: 'Tab',
|
||||||
|
workingSetKb: 1024,
|
||||||
|
peakWorkingSetKb: null,
|
||||||
|
privateBytesKb: null,
|
||||||
|
creationTime: null,
|
||||||
|
cpuPercent: null,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('high-memory-snapshot.rules', () => {
|
||||||
|
it('ranks processes by working set and computes share percentages', () => {
|
||||||
|
const tabProcess = createProcess({ pid: 1, type: 'Tab', workingSetKb: 512_000 });
|
||||||
|
const gpuProcess = createProcess({ pid: 2, type: 'GPU', workingSetKb: 1_536_000 });
|
||||||
|
const ranked = rankProcessesByWorkingSet([tabProcess, gpuProcess], 2_048_000);
|
||||||
|
|
||||||
|
expect(ranked[0]?.type).toBe('GPU');
|
||||||
|
expect(ranked[0]?.sharePercent).toBe(75);
|
||||||
|
expect(ranked[1]?.sharePercent).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the latest renderer store, heap, and component samples', () => {
|
||||||
|
const entries: PerfDiagEntry[] = [
|
||||||
|
{
|
||||||
|
collectedAt: 1,
|
||||||
|
source: 'renderer',
|
||||||
|
type: 'store',
|
||||||
|
payload: { domains: { chat: 100 } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collectedAt: 2,
|
||||||
|
source: 'renderer',
|
||||||
|
type: 'heap',
|
||||||
|
payload: { usedJsHeapMb: 120 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collectedAt: 3,
|
||||||
|
source: 'renderer',
|
||||||
|
type: 'components',
|
||||||
|
payload: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collectedAt: 4,
|
||||||
|
source: 'renderer',
|
||||||
|
type: 'store',
|
||||||
|
payload: { domains: { chat: 500 } }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(extractLatestRendererSamples(entries)).toEqual({
|
||||||
|
store: { domains: { chat: 500 } },
|
||||||
|
heap: { usedJsHeapMb: 120 },
|
||||||
|
components: { suspectedLeaks: [{ name: 'ChatMessageItem', count: 40, expected: 20 }] }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts recent process history from the ring buffer', () => {
|
||||||
|
const entries: PerfDiagEntry[] = [
|
||||||
|
{
|
||||||
|
collectedAt: 1,
|
||||||
|
source: 'main',
|
||||||
|
type: 'process',
|
||||||
|
payload: { totalWorkingSetKb: 1000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collectedAt: 2,
|
||||||
|
source: 'main',
|
||||||
|
type: 'session',
|
||||||
|
payload: { event: 'noop' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
collectedAt: 3,
|
||||||
|
source: 'main',
|
||||||
|
type: 'process',
|
||||||
|
payload: { totalWorkingSetKb: 2000 }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(extractProcessHistory(entries)).toEqual([{ collectedAt: 1, totalWorkingSetKb: 1000 }, { collectedAt: 3, totalWorkingSetKb: 2000 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarizes ring buffer entry counts', () => {
|
||||||
|
expect(summarizeRingBuffer([
|
||||||
|
{ collectedAt: 1, source: 'main', type: 'process', payload: {} },
|
||||||
|
{ collectedAt: 2, source: 'renderer', type: 'heap', payload: {} },
|
||||||
|
{ collectedAt: 3, source: 'main', type: 'process', payload: {} }
|
||||||
|
])).toEqual({
|
||||||
|
'main:process': 2,
|
||||||
|
'renderer:heap': 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a high-memory summary with threshold context', () => {
|
||||||
|
const summary = buildHighMemorySummary(
|
||||||
|
2_200_000,
|
||||||
|
[createProcess({ workingSetKb: 2_200_000 })],
|
||||||
|
1_700_000_000_000
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summary.totalWorkingSetGb).toBe('2.10');
|
||||||
|
expect(summary.thresholdGb).toBe('2.00');
|
||||||
|
expect(summary.topProcesses).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a comprehensive high-memory diagnostic payload', () => {
|
||||||
|
const payload = buildHighMemoryDiagnosticPayload({
|
||||||
|
detectedAt: 1_700_000_000_000,
|
||||||
|
totalWorkingSetKb: 2_200_000,
|
||||||
|
metrics: {
|
||||||
|
collectedAt: 1_700_000_000_000,
|
||||||
|
processes: [
|
||||||
|
createProcess({
|
||||||
|
workingSetKb: 2_200_000,
|
||||||
|
peakWorkingSetKb: 2_300_000,
|
||||||
|
privateBytesKb: 1_800_000,
|
||||||
|
creationTime: 1,
|
||||||
|
cpuPercent: 12
|
||||||
|
})
|
||||||
|
]
|
||||||
|
},
|
||||||
|
environment: { appVersion: '1.0.0' },
|
||||||
|
mainProcessMemory: {
|
||||||
|
rss: 64 * 1024 * 1024,
|
||||||
|
heapTotal: 32 * 1024 * 1024,
|
||||||
|
heapUsed: 16 * 1024 * 1024,
|
||||||
|
external: 8 * 1024 * 1024,
|
||||||
|
arrayBuffers: 1024
|
||||||
|
},
|
||||||
|
ringEntries: [
|
||||||
|
{
|
||||||
|
collectedAt: 1,
|
||||||
|
source: 'main',
|
||||||
|
type: 'process',
|
||||||
|
payload: { totalWorkingSetKb: 2_000_000 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
immediateRendererEntries: [
|
||||||
|
{
|
||||||
|
collectedAt: 2,
|
||||||
|
source: 'renderer',
|
||||||
|
type: 'heap',
|
||||||
|
payload: { usedJsHeapMb: 300, route: '/room/abc' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sessionId: 'session-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.event).toBe('high-memory-threshold');
|
||||||
|
expect(payload.summary).toMatchObject({
|
||||||
|
totalWorkingSetKb: 2_200_000
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.processHistory).toHaveLength(1);
|
||||||
|
expect(payload.recentRendererSamples).toEqual({
|
||||||
|
store: null,
|
||||||
|
heap: { usedJsHeapMb: 300, route: '/room/abc' },
|
||||||
|
components: null
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(formatMemoryUsageMb({
|
||||||
|
rss: 64 * 1024 * 1024,
|
||||||
|
heapTotal: 32 * 1024 * 1024,
|
||||||
|
heapUsed: 16 * 1024 * 1024,
|
||||||
|
external: 8 * 1024 * 1024,
|
||||||
|
arrayBuffers: 1024
|
||||||
|
})).toEqual({
|
||||||
|
rssMb: 64,
|
||||||
|
heapTotalMb: 32,
|
||||||
|
heapUsedMb: 16,
|
||||||
|
externalMb: 8,
|
||||||
|
arrayBuffersMb: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
179
electron/diagnostics/high-memory-snapshot.rules.ts
Normal file
179
electron/diagnostics/high-memory-snapshot.rules.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import type { AppMetricsProcessSnapshot, AppMetricsSnapshot } from '../app-metrics';
|
||||||
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
|
import { formatWorkingSetGb, HIGH_MEMORY_THRESHOLD_KB } from './high-memory-alert.rules';
|
||||||
|
import type { SessionContextSnapshot } from './session-context.collector';
|
||||||
|
|
||||||
|
export interface RankedProcessSnapshot extends AppMetricsProcessSnapshot {
|
||||||
|
sharePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HighMemorySummary {
|
||||||
|
detectedAt: number;
|
||||||
|
thresholdKb: number;
|
||||||
|
thresholdGb: string;
|
||||||
|
totalWorkingSetKb: number;
|
||||||
|
totalWorkingSetGb: string;
|
||||||
|
topProcesses: RankedProcessSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LatestRendererSamples {
|
||||||
|
store: Record<string, unknown> | null;
|
||||||
|
heap: Record<string, unknown> | null;
|
||||||
|
components: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rankProcessesByWorkingSet(
|
||||||
|
processes: readonly AppMetricsProcessSnapshot[],
|
||||||
|
totalWorkingSetKb: number | null
|
||||||
|
): RankedProcessSnapshot[] {
|
||||||
|
const total = totalWorkingSetKb ?? 0;
|
||||||
|
|
||||||
|
return [...processes]
|
||||||
|
.filter((process) => process.workingSetKb != null && process.workingSetKb > 0)
|
||||||
|
.sort((left, right) => (right.workingSetKb ?? 0) - (left.workingSetKb ?? 0))
|
||||||
|
.map((process) => ({
|
||||||
|
...process,
|
||||||
|
sharePercent: total > 0
|
||||||
|
? Math.round(((process.workingSetKb ?? 0) / total) * 1000) / 10
|
||||||
|
: 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractLatestRendererSamples(entries: readonly PerfDiagEntry[]): LatestRendererSamples {
|
||||||
|
let store: Record<string, unknown> | null = null;
|
||||||
|
let heap: Record<string, unknown> | null = null;
|
||||||
|
let components: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||||
|
const entry = entries[index];
|
||||||
|
|
||||||
|
if (entry.source !== 'renderer') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store && entry.type === 'store') {
|
||||||
|
store = entry.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!heap && entry.type === 'heap') {
|
||||||
|
heap = entry.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!components && entry.type === 'components') {
|
||||||
|
components = entry.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store && heap && components) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
heap,
|
||||||
|
components
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractProcessHistory(
|
||||||
|
entries: readonly PerfDiagEntry[],
|
||||||
|
limit = 24
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
const history: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||||
|
const entry = entries[index];
|
||||||
|
|
||||||
|
if (entry.type !== 'process') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.unshift({
|
||||||
|
collectedAt: entry.collectedAt,
|
||||||
|
...entry.payload
|
||||||
|
});
|
||||||
|
|
||||||
|
if (history.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeRingBuffer(entries: readonly PerfDiagEntry[]): Record<string, number> {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const key = `${entry.source}:${entry.type}`;
|
||||||
|
|
||||||
|
counts[key] = (counts[key] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHighMemorySummary(
|
||||||
|
totalWorkingSetKb: number,
|
||||||
|
processes: readonly AppMetricsProcessSnapshot[],
|
||||||
|
detectedAt: number
|
||||||
|
): HighMemorySummary {
|
||||||
|
return {
|
||||||
|
detectedAt,
|
||||||
|
thresholdKb: HIGH_MEMORY_THRESHOLD_KB,
|
||||||
|
thresholdGb: formatWorkingSetGb(HIGH_MEMORY_THRESHOLD_KB),
|
||||||
|
totalWorkingSetKb,
|
||||||
|
totalWorkingSetGb: formatWorkingSetGb(totalWorkingSetKb),
|
||||||
|
topProcesses: rankProcessesByWorkingSet(processes, totalWorkingSetKb).slice(0, 12)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMemoryUsageMb(memoryUsage: NodeJS.MemoryUsage): Record<string, number> {
|
||||||
|
return {
|
||||||
|
rssMb: roundMb(memoryUsage.rss),
|
||||||
|
heapTotalMb: roundMb(memoryUsage.heapTotal),
|
||||||
|
heapUsedMb: roundMb(memoryUsage.heapUsed),
|
||||||
|
externalMb: roundMb(memoryUsage.external),
|
||||||
|
arrayBuffersMb: roundMb(memoryUsage.arrayBuffers ?? 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHighMemoryDiagnosticPayload(input: {
|
||||||
|
detectedAt: number;
|
||||||
|
totalWorkingSetKb: number;
|
||||||
|
metrics: AppMetricsSnapshot;
|
||||||
|
environment: SessionContextSnapshot;
|
||||||
|
mainProcessMemory: NodeJS.MemoryUsage;
|
||||||
|
ringEntries: readonly PerfDiagEntry[];
|
||||||
|
immediateRendererEntries: readonly PerfDiagEntry[];
|
||||||
|
sessionId: string;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
const mergedRingEntries = [...input.ringEntries, ...input.immediateRendererEntries];
|
||||||
|
const recentRendererSamples = extractLatestRendererSamples(mergedRingEntries);
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: 'high-memory-threshold',
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
summary: buildHighMemorySummary(
|
||||||
|
input.totalWorkingSetKb,
|
||||||
|
input.metrics.processes,
|
||||||
|
input.detectedAt
|
||||||
|
),
|
||||||
|
environment: input.environment,
|
||||||
|
metrics: input.metrics,
|
||||||
|
mainProcessMemory: input.mainProcessMemory,
|
||||||
|
mainProcessMemoryMb: formatMemoryUsageMb(input.mainProcessMemory),
|
||||||
|
processHistory: extractProcessHistory(mergedRingEntries),
|
||||||
|
ringSummary: summarizeRingBuffer(mergedRingEntries),
|
||||||
|
recentRendererSamples,
|
||||||
|
immediateRendererSamples: input.immediateRendererEntries.map((entry) => ({
|
||||||
|
collectedAt: entry.collectedAt,
|
||||||
|
type: entry.type,
|
||||||
|
payload: entry.payload
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundMb(bytes: number): number {
|
||||||
|
return Math.round((bytes / (1024 * 1024)) * 100) / 100;
|
||||||
|
}
|
||||||
39
electron/diagnostics/immediate-renderer-samples.collector.ts
Normal file
39
electron/diagnostics/immediate-renderer-samples.collector.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import type { PerfDiagEntry } from './diagnostics.models';
|
||||||
|
|
||||||
|
export async function collectImmediateRendererSamples(
|
||||||
|
window: BrowserWindow | null | undefined
|
||||||
|
): Promise<PerfDiagEntry[]> {
|
||||||
|
if (!window || window.isDestroyed()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.webContents.executeJavaScript(`
|
||||||
|
(function () {
|
||||||
|
const collect = globalThis.__collectPerfDiagSample;
|
||||||
|
|
||||||
|
return typeof collect === 'function' ? collect() : [];
|
||||||
|
})()
|
||||||
|
`, true);
|
||||||
|
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
.filter((entry) => entry && typeof entry === 'object')
|
||||||
|
.map((entry) => normalizeImmediateRendererEntry(entry as Partial<PerfDiagEntry>));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImmediateRendererEntry(entry: Partial<PerfDiagEntry>): PerfDiagEntry {
|
||||||
|
return {
|
||||||
|
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||||
|
source: 'renderer',
|
||||||
|
type: entry.type ?? 'session',
|
||||||
|
payload: entry.payload ?? {}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
export { 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';
|
||||||
|
|||||||
91
electron/diagnostics/session-context.collector.ts
Normal file
91
electron/diagnostics/session-context.collector.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
export interface SessionWindowSnapshot {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
url: string | null;
|
||||||
|
focused: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
destroyed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionContextSnapshot {
|
||||||
|
collectedAt: number;
|
||||||
|
sessionStartedAt: number;
|
||||||
|
uptimeMs: number;
|
||||||
|
appVersion: string;
|
||||||
|
electronVersion: string;
|
||||||
|
chromeVersion: string;
|
||||||
|
nodeVersion: string;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
arch: string;
|
||||||
|
osType: string;
|
||||||
|
osRelease: string;
|
||||||
|
osVersion: string | null;
|
||||||
|
totalMemKb: number;
|
||||||
|
freeMemKb: number;
|
||||||
|
userDataPath: string;
|
||||||
|
appPath: string;
|
||||||
|
isPackaged: boolean;
|
||||||
|
locale: string;
|
||||||
|
windowCount: number;
|
||||||
|
windows: SessionWindowSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectSessionContext(input: {
|
||||||
|
sessionStartedAt: number;
|
||||||
|
userDataPath: string;
|
||||||
|
}): SessionContextSnapshot {
|
||||||
|
const collectedAt = Date.now();
|
||||||
|
|
||||||
|
return {
|
||||||
|
collectedAt,
|
||||||
|
sessionStartedAt: input.sessionStartedAt,
|
||||||
|
uptimeMs: Math.max(0, collectedAt - input.sessionStartedAt),
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
electronVersion: process.versions.electron ?? 'unknown',
|
||||||
|
chromeVersion: process.versions.chrome ?? 'unknown',
|
||||||
|
nodeVersion: process.versions.node ?? 'unknown',
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
osType: os.type(),
|
||||||
|
osRelease: os.release(),
|
||||||
|
osVersion: readOsVersion(),
|
||||||
|
totalMemKb: Math.round(os.totalmem() / 1024),
|
||||||
|
freeMemKb: Math.round(os.freemem() / 1024),
|
||||||
|
userDataPath: input.userDataPath,
|
||||||
|
appPath: app.getAppPath(),
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
locale: app.getLocale(),
|
||||||
|
windowCount: BrowserWindow.getAllWindows().length,
|
||||||
|
windows: BrowserWindow.getAllWindows().map(collectWindowSnapshot)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectWindowSnapshot(window: BrowserWindow): SessionWindowSnapshot {
|
||||||
|
let url: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
url = window.webContents.getURL() || null;
|
||||||
|
} catch {
|
||||||
|
url = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: window.id,
|
||||||
|
title: window.getTitle(),
|
||||||
|
url,
|
||||||
|
focused: window.isFocused(),
|
||||||
|
visible: window.isVisible(),
|
||||||
|
destroyed: window.isDestroyed()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOsVersion(): string | null {
|
||||||
|
try {
|
||||||
|
return os.version?.() ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
electron/ipc/file-read.rules.spec.ts
Normal file
16
electron/ipc/file-read.rules.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { isReadableRegularFile } from './file-read.rules';
|
||||||
|
|
||||||
|
describe('file-read.rules', () => {
|
||||||
|
it('accepts regular files', () => {
|
||||||
|
expect(isReadableRegularFile({ isFile: () => true })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects directories and other non-file paths', () => {
|
||||||
|
expect(isReadableRegularFile({ isFile: () => false })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
6
electron/ipc/file-read.rules.ts
Normal file
6
electron/ipc/file-read.rules.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { Stats } from 'fs';
|
||||||
|
|
||||||
|
/** Only regular files can be read through the read-file IPC surface. */
|
||||||
|
export function isReadableRegularFile(stats: Pick<Stats, 'isFile'>): boolean {
|
||||||
|
return stats.isFile();
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
grantPluginReadRoot,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fsp.stat(scopedPath);
|
||||||
|
|
||||||
|
if (!isReadableRegularFile(stats)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await fsp.readFile(scopedPath);
|
const data = await fsp.readFile(scopedPath);
|
||||||
|
|
||||||
return data.toString('base64');
|
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,6 +677,13 @@ export function setupSystemHandlers(): void {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fsp.stat(scopedPath);
|
||||||
|
|
||||||
|
if (!isReadableRegularFile(stats)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const fileHandle = await fsp.open(scopedPath, 'r');
|
const fileHandle = await fsp.open(scopedPath, 'r');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -678,6 +696,9 @@ export function setupSystemHandlers(): void {
|
|||||||
} finally {
|
} finally {
|
||||||
await fileHandle.close();
|
await fileHandle.close();
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
|
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
105
server/src/websocket/handler-voice-disconnect.spec.ts
Normal file
105
server/src/websocket/handler-voice-disconnect.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import { connectedUsers } from './state';
|
||||||
|
import { ConnectedUser } from './types';
|
||||||
|
import { finalizeVoiceDisconnectForConnection } from './handler';
|
||||||
|
|
||||||
|
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const ws = {
|
||||||
|
readyState: WebSocket.OPEN,
|
||||||
|
send: (data: string) => { sent.push(data); },
|
||||||
|
close: () => {},
|
||||||
|
terminate: () => {},
|
||||||
|
sentMessages: sent
|
||||||
|
} as unknown as WebSocket & { sentMessages: string[] };
|
||||||
|
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConnectedUser(
|
||||||
|
connectionId: string,
|
||||||
|
overrides: Partial<ConnectedUser> = {}
|
||||||
|
): ConnectedUser {
|
||||||
|
const user: ConnectedUser = {
|
||||||
|
oderId: 'user-1',
|
||||||
|
ws: createMockWs(),
|
||||||
|
authenticated: true,
|
||||||
|
serverIds: new Set(['server-1']),
|
||||||
|
displayName: 'Alice',
|
||||||
|
lastPong: Date.now(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSentMessages(user: ConnectedUser): string[] {
|
||||||
|
return (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('finalizeVoiceDisconnectForConnection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcasts a cleared voice_state when a voice-active connection is removed', () => {
|
||||||
|
createConnectedUser('conn-voice', {
|
||||||
|
voiceActive: true,
|
||||||
|
voiceStateSnapshot: {
|
||||||
|
type: 'voice_state',
|
||||||
|
serverId: 'server-1',
|
||||||
|
voiceState: {
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: true,
|
||||||
|
isDeafened: false,
|
||||||
|
roomId: 'voice-1',
|
||||||
|
serverId: 'server-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = createConnectedUser('conn-observer', { oderId: 'user-2' });
|
||||||
|
|
||||||
|
getSentMessages(observer).length = 0;
|
||||||
|
|
||||||
|
finalizeVoiceDisconnectForConnection('conn-voice');
|
||||||
|
|
||||||
|
const messages = getSentMessages(observer).map((raw) => JSON.parse(raw) as {
|
||||||
|
type: string;
|
||||||
|
voiceState?: { isConnected?: boolean; isMuted?: boolean; isDeafened?: boolean };
|
||||||
|
});
|
||||||
|
const voiceState = messages.find((message) => message.type === 'voice_state');
|
||||||
|
|
||||||
|
expect(voiceState).toMatchObject({
|
||||||
|
type: 'voice_state',
|
||||||
|
voiceState: {
|
||||||
|
isConnected: false,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(connectedUsers.get('conn-voice')?.voiceActive).toBe(false);
|
||||||
|
expect(connectedUsers.get('conn-voice')?.voiceStateSnapshot).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the connection was not voice-active', () => {
|
||||||
|
const observer = createConnectedUser('conn-observer', { oderId: 'user-2' });
|
||||||
|
|
||||||
|
createConnectedUser('conn-idle');
|
||||||
|
|
||||||
|
getSentMessages(observer).length = 0;
|
||||||
|
|
||||||
|
finalizeVoiceDisconnectForConnection('conn-idle');
|
||||||
|
|
||||||
|
expect(getSentMessages(observer)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -134,6 +134,59 @@ function clearVoiceActiveForOderId(oderId: string, exceptConnectionId?: string):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readVoiceStateServerId(snapshot: Record<string, unknown> | undefined): string | undefined {
|
||||||
|
if (!snapshot) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedVoiceState = snapshot['voiceState'];
|
||||||
|
|
||||||
|
if (nestedVoiceState && typeof nestedVoiceState === 'object') {
|
||||||
|
const nestedServerId = readMessageId((nestedVoiceState as { serverId?: unknown }).serverId);
|
||||||
|
|
||||||
|
if (nestedServerId) {
|
||||||
|
return nestedServerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return readMessageId(snapshot['serverId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast a cleared voice_state when a voice-active socket disappears without a graceful leave. */
|
||||||
|
export function finalizeVoiceDisconnectForConnection(connectionId: string): void {
|
||||||
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
|
if (!user?.authenticated || (!user.voiceActive && !user.voiceStateSnapshot)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = readVoiceStateServerId(user.voiceStateSnapshot) ?? user.viewedServerId;
|
||||||
|
|
||||||
|
if (serverId && user.serverIds.has(serverId)) {
|
||||||
|
broadcastToServer(
|
||||||
|
serverId,
|
||||||
|
{
|
||||||
|
type: 'voice_state',
|
||||||
|
serverId,
|
||||||
|
oderId: user.oderId,
|
||||||
|
displayName: normalizeDisplayName(user.displayName),
|
||||||
|
voiceState: {
|
||||||
|
isConnected: false,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
isSpeaking: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ excludeConnectionId: connectionId }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.voiceActive = false;
|
||||||
|
user.voiceStateSnapshot = undefined;
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
clearVoiceActiveForOderId(user.oderId, connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
|
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
|
||||||
user.ws.send(JSON.stringify({
|
user.ws.send(JSON.stringify({
|
||||||
type: 'voice_state',
|
type: 'voice_state',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
getServerIdsForOderId,
|
getServerIdsForOderId,
|
||||||
isOderIdConnectedToServer
|
isOderIdConnectedToServer
|
||||||
} from './broadcast';
|
} from './broadcast';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage, finalizeVoiceDisconnectForConnection } from './handler';
|
||||||
|
|
||||||
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
|
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ function removeDeadConnection(connectionId: string): void {
|
|||||||
if (user) {
|
if (user) {
|
||||||
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
|
||||||
|
|
||||||
|
finalizeVoiceDisconnectForConnection(connectionId);
|
||||||
|
|
||||||
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
|
||||||
|
|
||||||
user.serverIds.forEach((sid) => {
|
user.serverIds.forEach((sid) => {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
loadLastViewedChatFromStorage
|
loadLastViewedChatFromStorage
|
||||||
} from './infrastructure/persistence';
|
} from './infrastructure/persistence';
|
||||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
||||||
|
import { DesktopHighMemoryAlertService } from './core/services/desktop-high-memory-alert.service';
|
||||||
import { ServerDirectoryFacade } from './domains/server-directory';
|
import { ServerDirectoryFacade } from './domains/server-directory';
|
||||||
import { NotificationsFacade } from './domains/notifications';
|
import { NotificationsFacade } from './domains/notifications';
|
||||||
import { TimeSyncService } from './core/services/time-sync.service';
|
import { TimeSyncService } from './core/services/time-sync.service';
|
||||||
@@ -53,6 +54,7 @@ import { SettingsModalComponent } from './features/settings/settings-modal/setti
|
|||||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||||
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
|
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
|
||||||
|
import { HighMemoryAlertModalComponent } from './features/shell/high-memory-alert-modal/high-memory-alert-modal.component';
|
||||||
import { UsersActions } from './store/users/users.actions';
|
import { UsersActions } from './store/users/users.actions';
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
@@ -81,6 +83,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from './core/i18n';
|
|||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareSourcePickerComponent,
|
ScreenShareSourcePickerComponent,
|
||||||
NativeContextMenuComponent,
|
NativeContextMenuComponent,
|
||||||
|
HighMemoryAlertModalComponent,
|
||||||
PrivateCallComponent,
|
PrivateCallComponent,
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
ThemePickerOverlayComponent,
|
ThemePickerOverlayComponent,
|
||||||
@@ -103,6 +106,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
desktopUpdates = inject(DesktopAppUpdateService);
|
desktopUpdates = inject(DesktopAppUpdateService);
|
||||||
desktopUpdateState = this.desktopUpdates.state;
|
desktopUpdateState = this.desktopUpdates.state;
|
||||||
|
desktopHighMemoryAlert = inject(DesktopHighMemoryAlertService);
|
||||||
readonly databaseService = inject(DatabaseService);
|
readonly databaseService = inject(DatabaseService);
|
||||||
readonly router = inject(Router);
|
readonly router = inject(Router);
|
||||||
readonly servers = inject(ServerDirectoryFacade);
|
readonly servers = inject(ServerDirectoryFacade);
|
||||||
@@ -288,6 +292,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
// - desktop deep-link bridge (only relevant after first paint)
|
// - desktop deep-link bridge (only relevant after first paint)
|
||||||
// - background presence + game activity loops
|
// - background presence + game activity loops
|
||||||
void this.desktopUpdates.initialize();
|
void this.desktopUpdates.initialize();
|
||||||
|
void this.desktopHighMemoryAlert.initialize();
|
||||||
void this.kickOffBackgroundBootstrap();
|
void this.kickOffBackgroundBootstrap();
|
||||||
|
|
||||||
// The only thing we genuinely must await before deciding which route
|
// The only thing we genuinely must await before deciding which route
|
||||||
|
|||||||
@@ -251,6 +251,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 }>;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
formatAppRamLabel,
|
formatAppRamLabel,
|
||||||
|
formatKilobytesAsGigabytes,
|
||||||
formatKilobytesAsMegabytes,
|
formatKilobytesAsMegabytes,
|
||||||
sumWorkingSetKb
|
sumWorkingSetKb
|
||||||
} from './electron-app-metrics.rules';
|
} from './electron-app-metrics.rules';
|
||||||
@@ -38,6 +39,13 @@ describe('sumWorkingSetKb', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatKilobytesAsGigabytes', () => {
|
||||||
|
it('formats totals in gigabytes with two decimals', () => {
|
||||||
|
expect(formatKilobytesAsGigabytes(1536 * 1024)).toBe('1.50');
|
||||||
|
expect(formatKilobytesAsGigabytes(2 * 1024 * 1024)).toBe('2.00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('formatKilobytesAsMegabytes', () => {
|
describe('formatKilobytesAsMegabytes', () => {
|
||||||
it('rounds large values to whole megabytes', () => {
|
it('rounds large values to whole megabytes', () => {
|
||||||
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');
|
expect(formatKilobytesAsMegabytes(412 * 1024)).toBe('412 MB');
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export function formatKilobytesAsMegabytes(kilobytes: number): string {
|
|||||||
return `${megabytes.toFixed(2)} MB`;
|
return `${megabytes.toFixed(2)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatKilobytesAsGigabytes(kilobytes: number): string {
|
||||||
|
return (kilobytes / (1024 * 1024)).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
|
export function formatAppRamLabel(snapshot: ElectronAppMetricsSnapshot): string | null {
|
||||||
const totalKb = sumWorkingSetKb(snapshot.processes);
|
const totalKb = sumWorkingSetKb(snapshot.processes);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { Injector, runInInjectionContext } from '@angular/core';
|
||||||
|
|
||||||
|
import { DesktopHighMemoryAlertService } from './desktop-high-memory-alert.service';
|
||||||
|
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||||
|
|
||||||
|
describe('DesktopHighMemoryAlertService', () => {
|
||||||
|
let electronBridge: {
|
||||||
|
isAvailable: boolean;
|
||||||
|
getApi: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let documentStub: Document;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
documentStub = {
|
||||||
|
body: null,
|
||||||
|
createElement: vi.fn(),
|
||||||
|
execCommand: vi.fn(() => true)
|
||||||
|
} as unknown as Document;
|
||||||
|
|
||||||
|
electronBridge = {
|
||||||
|
isAvailable: true,
|
||||||
|
getApi: vi.fn(() => ({
|
||||||
|
getPendingHighMemoryAlert: vi.fn(async () => ({
|
||||||
|
logFilePath: '/tmp/diagnostics/session.ndjson',
|
||||||
|
detectedAt: 1,
|
||||||
|
peakWorkingSetKb: 2_200_000,
|
||||||
|
sessionId: 'session-1'
|
||||||
|
})),
|
||||||
|
onHighMemoryAlertPending: vi.fn(() => () => undefined),
|
||||||
|
exportHighMemoryDiagnostics: vi.fn(async () => ({
|
||||||
|
logFilePath: '/tmp/diagnostics/manual.ndjson',
|
||||||
|
detectedAt: 2,
|
||||||
|
peakWorkingSetKb: 1_800_000,
|
||||||
|
sessionId: 'session-2',
|
||||||
|
reason: 'manual' as const
|
||||||
|
})),
|
||||||
|
acknowledgeHighMemoryAlert: vi.fn(async () => true)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function createService(): DesktopHighMemoryAlertService {
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
DesktopHighMemoryAlertService,
|
||||||
|
{ provide: ElectronBridgeService, useValue: electronBridge },
|
||||||
|
{ provide: DOCUMENT, useValue: documentStub }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return runInInjectionContext(injector, () => injector.get(DesktopHighMemoryAlertService));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('loads a pending alert from disk on initialize', async () => {
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/session.ndjson');
|
||||||
|
expect(service.peakUsageGb()).toBe('2.10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the modal when a live high-memory alert event arrives', async () => {
|
||||||
|
let listener: ((alert: {
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
}) => void) | undefined;
|
||||||
|
|
||||||
|
electronBridge.getApi = vi.fn(() => ({
|
||||||
|
getPendingHighMemoryAlert: vi.fn(async () => null),
|
||||||
|
onHighMemoryAlertPending: vi.fn((callback) => {
|
||||||
|
listener = callback;
|
||||||
|
return () => undefined;
|
||||||
|
}),
|
||||||
|
exportHighMemoryDiagnostics: vi.fn(async () => null),
|
||||||
|
acknowledgeHighMemoryAlert: vi.fn(async () => true)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
listener?.({
|
||||||
|
logFilePath: '/tmp/diagnostics/live.ndjson',
|
||||||
|
detectedAt: 3,
|
||||||
|
peakWorkingSetKb: 2_400_000,
|
||||||
|
sessionId: 'session-3'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/live.ndjson');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports diagnostics manually and opens the modal with manual copy', async () => {
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
await expect(service.exportDiagnostics()).resolves.toBe(true);
|
||||||
|
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/manual.ndjson');
|
||||||
|
expect(service.pendingAlert()?.reason).toBe('manual');
|
||||||
|
expect(service.titleKey()).toBe('app.highMemoryAlert.manualTitle');
|
||||||
|
expect(service.messageKey()).toBe('app.highMemoryAlert.manualMessage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses threshold copy for live high-memory alerts', async () => {
|
||||||
|
let listener: ((alert: {
|
||||||
|
logFilePath: string;
|
||||||
|
detectedAt: number;
|
||||||
|
peakWorkingSetKb: number;
|
||||||
|
sessionId: string;
|
||||||
|
reason?: 'manual' | 'threshold';
|
||||||
|
}) => void) | undefined;
|
||||||
|
|
||||||
|
electronBridge.getApi = vi.fn(() => ({
|
||||||
|
getPendingHighMemoryAlert: vi.fn(async () => null),
|
||||||
|
onHighMemoryAlertPending: vi.fn((callback) => {
|
||||||
|
listener = callback;
|
||||||
|
return () => undefined;
|
||||||
|
}),
|
||||||
|
exportHighMemoryDiagnostics: vi.fn(async () => null),
|
||||||
|
acknowledgeHighMemoryAlert: vi.fn(async () => true)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
listener?.({
|
||||||
|
logFilePath: '/tmp/diagnostics/live.ndjson',
|
||||||
|
detectedAt: 3,
|
||||||
|
peakWorkingSetKb: 2_400_000,
|
||||||
|
sessionId: 'session-3',
|
||||||
|
reason: 'threshold'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.titleKey()).toBe('app.highMemoryAlert.thresholdTitle');
|
||||||
|
expect(service.messageKey()).toBe('app.highMemoryAlert.thresholdMessage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the diagnostics log path to the clipboard', async () => {
|
||||||
|
const writeText = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText }
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
await expect(service.copyLogPath()).resolves.toBe(true);
|
||||||
|
expect(writeText).toHaveBeenCalledWith('/tmp/diagnostics/session.ndjson');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
|
||||||
|
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||||
|
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
|
||||||
|
import { formatKilobytesAsGigabytes } from '../platform/electron/electron-app-metrics.rules';
|
||||||
|
import {
|
||||||
|
resolveHighMemoryAlertCopyKind,
|
||||||
|
resolveHighMemoryAlertMessageKey,
|
||||||
|
resolveHighMemoryAlertTitleKey
|
||||||
|
} from './high-memory-alert-copy.rules';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class DesktopHighMemoryAlertService {
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
|
||||||
|
readonly pendingAlert = signal<ElectronHighMemoryAlertRecord | null>(null);
|
||||||
|
|
||||||
|
readonly peakUsageGb = computed(() => {
|
||||||
|
const alert = this.pendingAlert();
|
||||||
|
|
||||||
|
return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly titleKey = computed(() => resolveHighMemoryAlertTitleKey(
|
||||||
|
resolveHighMemoryAlertCopyKind(this.pendingAlert())
|
||||||
|
));
|
||||||
|
|
||||||
|
readonly messageKey = computed(() => resolveHighMemoryAlertMessageKey(
|
||||||
|
resolveHighMemoryAlertCopyKind(this.pendingAlert())
|
||||||
|
));
|
||||||
|
|
||||||
|
private initialized = false;
|
||||||
|
private removePendingListener: (() => void) | null = null;
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (!this.electronBridge.isAvailable || this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!api) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removePendingListener?.();
|
||||||
|
this.removePendingListener = api.onHighMemoryAlertPending?.((alert) => {
|
||||||
|
this.pendingAlert.set(alert);
|
||||||
|
}) ?? null;
|
||||||
|
|
||||||
|
const alert = await api.getPendingHighMemoryAlert?.();
|
||||||
|
|
||||||
|
if (alert) {
|
||||||
|
this.pendingAlert.set(alert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportDiagnostics(): Promise<boolean> {
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
const alert = await api?.exportHighMemoryDiagnostics?.();
|
||||||
|
|
||||||
|
if (!alert) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingAlert.set(alert);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async dismiss(): Promise<void> {
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
await api?.acknowledgeHighMemoryAlert?.();
|
||||||
|
this.pendingAlert.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async openLogFile(): Promise<void> {
|
||||||
|
const alert = this.pendingAlert();
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!alert?.logFilePath || !api?.openFilePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.openFilePath(alert.logFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showLogFileInFolder(): Promise<void> {
|
||||||
|
const alert = this.pendingAlert();
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!alert?.logFilePath || !api?.showLogFileInFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.showLogFileInFolder(alert.logFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyLogPath(): Promise<boolean> {
|
||||||
|
const alert = this.pendingAlert();
|
||||||
|
|
||||||
|
if (!alert?.logFilePath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.writeTextToClipboard(alert.logFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeTextToClipboard(value: string): Promise<boolean> {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
return true;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = this.document.body;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = this.document.createElement('textarea');
|
||||||
|
|
||||||
|
textarea.value = value;
|
||||||
|
textarea.setAttribute('readonly', 'true');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.pointerEvents = 'none';
|
||||||
|
body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
let copied = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
copied = this.document.execCommand('copy');
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
body.removeChild(textarea);
|
||||||
|
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveHighMemoryAlertCopyKind,
|
||||||
|
resolveHighMemoryAlertMessageKey,
|
||||||
|
resolveHighMemoryAlertTitleKey
|
||||||
|
} from './high-memory-alert-copy.rules';
|
||||||
|
|
||||||
|
describe('high-memory-alert-copy.rules', () => {
|
||||||
|
it('uses threshold copy for live alerts and legacy records without a reason', () => {
|
||||||
|
expect(resolveHighMemoryAlertCopyKind({
|
||||||
|
logFilePath: '/tmp/log.jsonl',
|
||||||
|
detectedAt: 1,
|
||||||
|
peakWorkingSetKb: 2_100_000,
|
||||||
|
sessionId: 'session-1'
|
||||||
|
})).toBe('threshold');
|
||||||
|
|
||||||
|
expect(resolveHighMemoryAlertCopyKind({
|
||||||
|
logFilePath: '/tmp/log.jsonl',
|
||||||
|
detectedAt: 1,
|
||||||
|
peakWorkingSetKb: 2_100_000,
|
||||||
|
sessionId: 'session-1',
|
||||||
|
reason: 'threshold'
|
||||||
|
})).toBe('threshold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses manual copy for exported diagnostics', () => {
|
||||||
|
expect(resolveHighMemoryAlertCopyKind({
|
||||||
|
logFilePath: '/tmp/log.jsonl',
|
||||||
|
detectedAt: 1,
|
||||||
|
peakWorkingSetKb: 1_800_000,
|
||||||
|
sessionId: 'session-2',
|
||||||
|
reason: 'manual'
|
||||||
|
})).toBe('manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps copy kinds to translation keys', () => {
|
||||||
|
expect(resolveHighMemoryAlertTitleKey('threshold')).toBe('app.highMemoryAlert.thresholdTitle');
|
||||||
|
expect(resolveHighMemoryAlertTitleKey('manual')).toBe('app.highMemoryAlert.manualTitle');
|
||||||
|
expect(resolveHighMemoryAlertMessageKey('threshold')).toBe('app.highMemoryAlert.thresholdMessage');
|
||||||
|
expect(resolveHighMemoryAlertMessageKey('manual')).toBe('app.highMemoryAlert.manualMessage');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
|
||||||
|
|
||||||
|
export type HighMemoryAlertCopyKind = 'threshold' | 'manual';
|
||||||
|
|
||||||
|
export function resolveHighMemoryAlertCopyKind(
|
||||||
|
alert: ElectronHighMemoryAlertRecord | null | undefined
|
||||||
|
): HighMemoryAlertCopyKind {
|
||||||
|
return alert?.reason === 'manual' ? 'manual' : 'threshold';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHighMemoryAlertTitleKey(kind: HighMemoryAlertCopyKind): string {
|
||||||
|
return kind === 'manual'
|
||||||
|
? 'app.highMemoryAlert.manualTitle'
|
||||||
|
: 'app.highMemoryAlert.thresholdTitle';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHighMemoryAlertMessageKey(kind: HighMemoryAlertCopyKind): string {
|
||||||
|
return kind === 'manual'
|
||||||
|
? 'app.highMemoryAlert.manualMessage'
|
||||||
|
: 'app.highMemoryAlert.thresholdMessage';
|
||||||
|
}
|
||||||
@@ -107,12 +107,15 @@ Concurrent triggers (file-announce, message sync, peer connect) can race to requ
|
|||||||
|
|
||||||
- **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window.
|
- **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`).
|
||||||
|
|||||||
@@ -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']> {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
|
||||||
|
|
||||||
|
describe('AttachmentChunkAckService', () => {
|
||||||
|
let service: AttachmentChunkAckService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new AttachmentChunkAckService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a waiter when the matching chunk ack arrives', async () => {
|
||||||
|
const waitPromise = service.waitForAck('msg-1', 'file-1', 0, 1_000);
|
||||||
|
|
||||||
|
service.resolveAck('msg-1', 'file-1', 0);
|
||||||
|
|
||||||
|
await expect(waitPromise).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('times out when no ack arrives', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const waitPromise = service.waitForAck('msg-1', 'file-1', 1, 50);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(51);
|
||||||
|
|
||||||
|
await expect(waitPromise).rejects.toThrow('attachment chunk ack timeout');
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { buildAttachmentChunkAckKey } from '../../domain/logic/attachment-chunk-ack.rules';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AttachmentChunkAckService {
|
||||||
|
private readonly waiters = new Map<string, () => void>();
|
||||||
|
|
||||||
|
waitForAck(
|
||||||
|
messageId: string,
|
||||||
|
fileId: string,
|
||||||
|
index: number,
|
||||||
|
timeoutMs = 60_000
|
||||||
|
): Promise<void> {
|
||||||
|
const key = buildAttachmentChunkAckKey(messageId, fileId, index);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.waiters.delete(key);
|
||||||
|
reject(new Error('attachment chunk ack timeout'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
this.waiters.set(key, () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.waiters.delete(key);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAck(messageId: string, fileId: string, index: number): void {
|
||||||
|
this.waiters.get(buildAttachmentChunkAckKey(messageId, fileId, index))?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPendingForFile(messageId: string, fileId: string): void {
|
||||||
|
const prefix = `${messageId}:${fileId}:`;
|
||||||
|
|
||||||
|
for (const [key, resolve] of this.waiters) {
|
||||||
|
if (!key.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
this.waiters.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import '@angular/compiler';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { Injector, runInInjectionContext } from '@angular/core';
|
||||||
|
|
||||||
|
import { AttachmentDownloadService } from './attachment-download.service';
|
||||||
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
|
import type { Attachment } from '../../domain/models/attachment.model';
|
||||||
|
|
||||||
|
describe('AttachmentDownloadService', () => {
|
||||||
|
let electronBridge: {
|
||||||
|
isAvailable: boolean;
|
||||||
|
getApi: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let documentStub: Document;
|
||||||
|
let saveExistingFileAs: ReturnType<typeof vi.fn>;
|
||||||
|
let saveFileAs: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
saveExistingFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
||||||
|
saveFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
||||||
|
|
||||||
|
electronBridge = {
|
||||||
|
isAvailable: true,
|
||||||
|
getApi: vi.fn(() => ({
|
||||||
|
saveExistingFileAs,
|
||||||
|
saveFileAs
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
documentStub = {
|
||||||
|
body: {
|
||||||
|
appendChild: vi.fn(),
|
||||||
|
removeChild: vi.fn()
|
||||||
|
},
|
||||||
|
createElement: vi.fn(() => ({
|
||||||
|
click: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
href: '',
|
||||||
|
download: ''
|
||||||
|
}))
|
||||||
|
} as unknown as Document;
|
||||||
|
});
|
||||||
|
|
||||||
|
function createService(): AttachmentDownloadService {
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
AttachmentDownloadService,
|
||||||
|
{ provide: ElectronBridgeService, useValue: electronBridge },
|
||||||
|
{ provide: DOCUMENT, useValue: documentStub }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return runInInjectionContext(injector, () => injector.get(AttachmentDownloadService));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('exports a completed disk-only attachment through Electron save dialog', async () => {
|
||||||
|
const service = createService();
|
||||||
|
const attachment: Attachment = {
|
||||||
|
id: 'file-1',
|
||||||
|
messageId: 'message-1',
|
||||||
|
filename: 'large.bin',
|
||||||
|
mime: 'application/octet-stream',
|
||||||
|
size: 5_000_000_000,
|
||||||
|
available: true,
|
||||||
|
savedPath: '/appdata/server/room/files/large.bin'
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(saveExistingFileAs).toHaveBeenCalledWith('/appdata/server/room/files/large.bin', 'large.bin');
|
||||||
|
expect(saveFileAs).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the attachment is not downloadable yet', async () => {
|
||||||
|
const service = createService();
|
||||||
|
const attachment: Attachment = {
|
||||||
|
id: 'file-2',
|
||||||
|
messageId: 'message-2',
|
||||||
|
filename: 'large.bin',
|
||||||
|
mime: 'application/octet-stream',
|
||||||
|
size: 5_000_000_000,
|
||||||
|
available: true
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(saveExistingFileAs).not.toHaveBeenCalled();
|
||||||
|
expect(saveFileAs).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
|
||||||
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
|
import { canDownloadAttachment, resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
|
||||||
|
import type { Attachment } from '../../domain/models/attachment.model';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AttachmentDownloadService {
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
|
||||||
|
async downloadToUserLocation(attachment: Attachment): Promise<boolean> {
|
||||||
|
if (!canDownloadAttachment(attachment)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
const diskPath = resolveAttachmentDiskPath(attachment);
|
||||||
|
|
||||||
|
if (electronApi) {
|
||||||
|
if (diskPath && electronApi.saveExistingFileAs) {
|
||||||
|
try {
|
||||||
|
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
|
||||||
|
|
||||||
|
if (result.saved || result.cancelled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall back to blob/browser download */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await this.getAttachmentBlob(attachment);
|
||||||
|
|
||||||
|
if (blob) {
|
||||||
|
try {
|
||||||
|
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
|
||||||
|
|
||||||
|
if (result.saved || result.cancelled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall back to browser download */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attachment.objectUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = this.document.createElement('a');
|
||||||
|
|
||||||
|
link.href = attachment.objectUrl;
|
||||||
|
link.download = attachment.filename;
|
||||||
|
this.document.body?.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
|
||||||
|
if (!attachment.objectUrl || attachment.objectUrl.startsWith('file:')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(attachment.objectUrl);
|
||||||
|
|
||||||
|
return await response.blob();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result !== 'string') {
|
||||||
|
reject(new Error('Failed to encode attachment'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, base64 = ''] = reader.result.split(',', 2);
|
||||||
|
|
||||||
|
resolve(base64);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,13 @@ import {
|
|||||||
inject
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildAttachmentDisplayPinKey,
|
||||||
|
canRevokeAttachmentDisplayBlob,
|
||||||
|
shouldRevokeDisplayBlobForAttachment
|
||||||
|
} from './attachment-blob-eviction.rules';
|
||||||
|
|
||||||
|
describe('attachment-blob-eviction rules', () => {
|
||||||
|
it('builds a stable pin key from message and attachment ids', () => {
|
||||||
|
expect(buildAttachmentDisplayPinKey('msg-1', 'att-1')).toBe('msg-1:att-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows revoking blob urls when a disk path can rehydrate the attachment', () => {
|
||||||
|
expect(canRevokeAttachmentDisplayBlob({
|
||||||
|
objectUrl: 'blob:http://localhost/abc',
|
||||||
|
savedPath: '/appdata/photo.png',
|
||||||
|
receivedBytes: 0,
|
||||||
|
available: true
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to revoke blobs that are the only local copy', () => {
|
||||||
|
expect(canRevokeAttachmentDisplayBlob({
|
||||||
|
objectUrl: 'blob:http://localhost/abc',
|
||||||
|
receivedBytes: 0,
|
||||||
|
available: true
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to revoke blobs while a download is still in progress', () => {
|
||||||
|
expect(canRevokeAttachmentDisplayBlob({
|
||||||
|
objectUrl: 'blob:http://localhost/abc',
|
||||||
|
savedPath: '/appdata/photo.png',
|
||||||
|
receivedBytes: 1024,
|
||||||
|
available: false
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips revocation for pinned attachments', () => {
|
||||||
|
const attachment = {
|
||||||
|
id: 'att-1',
|
||||||
|
objectUrl: 'blob:http://localhost/abc',
|
||||||
|
savedPath: '/appdata/photo.png',
|
||||||
|
receivedBytes: 0,
|
||||||
|
available: true
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(shouldRevokeDisplayBlobForAttachment(
|
||||||
|
'msg-1',
|
||||||
|
attachment,
|
||||||
|
new Set([buildAttachmentDisplayPinKey('msg-1', 'att-1')])
|
||||||
|
)).toBe(false);
|
||||||
|
|
||||||
|
expect(shouldRevokeDisplayBlobForAttachment('msg-1', attachment, new Set())).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { isBlobObjectUrl } from './attachment-display-url.rules';
|
||||||
|
|
||||||
|
/** Margin around the chat scrollport used to hydrate blobs before they enter view. */
|
||||||
|
export const ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN = '200px';
|
||||||
|
|
||||||
|
export interface AttachmentDisplayBlobCandidate {
|
||||||
|
available?: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
objectUrl?: string;
|
||||||
|
receivedBytes?: number;
|
||||||
|
savedPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAttachmentDisplayPinKey(messageId: string, attachmentId: string): string {
|
||||||
|
return `${messageId}:${attachmentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canRevokeAttachmentDisplayBlob(
|
||||||
|
attachment: AttachmentDisplayBlobCandidate
|
||||||
|
): boolean {
|
||||||
|
if (!attachment.objectUrl || !isBlobObjectUrl(attachment.objectUrl)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNonEmptyString(attachment.savedPath) && !hasNonEmptyString(attachment.filePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attachment.receivedBytes ?? 0) > 0 && attachment.available !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRevokeDisplayBlobForAttachment(
|
||||||
|
messageId: string,
|
||||||
|
attachment: AttachmentDisplayBlobCandidate & { id: string },
|
||||||
|
pinnedKeys: ReadonlySet<string>
|
||||||
|
): boolean {
|
||||||
|
if (pinnedKeys.has(buildAttachmentDisplayPinKey(messageId, attachment.id))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return canRevokeAttachmentDisplayBlob(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNonEmptyString(value: string | null | undefined): boolean {
|
||||||
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ import {
|
|||||||
it
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function buildAttachmentChunkAckKey(messageId: string, fileId: string, index: number): string {
|
||||||
|
return `${messageId}:${fileId}:${index}`;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'>;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
const previousEntry = findDirectCallParticipantEntryForUser(previousSession, {
|
||||||
|
id: participant.userId,
|
||||||
|
oderId: participant.profile.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
participant.userId,
|
participant.userId,
|
||||||
{
|
{
|
||||||
...participant,
|
...participant,
|
||||||
joined: previousSession.participants[participant.userId]?.joined ?? participant.joined
|
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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user