Improve attachment memory safety, downloads, and high-memory alert UX.
All checks were successful
Queue Release Build / prepare (push) Successful in 20s
Deploy Web Apps / deploy (push) Successful in 9m2s
Queue Release Build / build-windows (push) Successful in 28m8s
Queue Release Build / build-linux (push) Successful in 47m26s
Queue Release Build / build-android (push) Successful in 19m52s
Queue Release Build / finalize (push) Successful in 4m42s

Stream large receives to disk with chunk acks to cap renderer RAM, evict
off-screen display blobs, and route exports through a disk-aware download
service. Fix the high-memory dialog (backdrop dismiss, copy, log actions),
allow diagnostics paths in the path jail, and restore persisted image
hydration after reload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-14 00:25:22 +02:00
parent f0d79aa627
commit bb0ac930ad
69 changed files with 2306 additions and 430 deletions

View File

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

View File

@@ -10,19 +10,21 @@ 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 { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules'; import { captureHighMemoryDiagnostics } from './high-memory-capture';
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
import { collectSessionContext } from './session-context.collector'; import { collectSessionContext } from './session-context.collector';
import { import {
clearHighMemoryAlert, clearHighMemoryAlert,
readHighMemoryAlert, readHighMemoryAlert,
writeHighMemoryAlert writeHighMemoryAlert,
type HighMemoryAlertRecord
} from './high-memory-alert.store'; } 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;
@@ -67,6 +69,24 @@ export function ensurePerfDiagIpcRegistered(): void {
return true; 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) => { ipcMain.handle('show-log-file-in-folder', async (_event, filePath: string) => {
if (typeof filePath !== 'string' || !filePath.trim()) { if (typeof filePath !== 'string' || !filePath.trim()) {
return { return {
@@ -94,8 +114,48 @@ 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) {
@@ -109,10 +169,7 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
}); });
activeWriter = writer; activeWriter = writer;
highMemoryAlertTriggeredThisSession = false;
sessionStartedAt = Date.now();
registerProcessCrashHandlers(writer); registerProcessCrashHandlers(writer);
startProcessMetricsPolling(writer);
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
@@ -188,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 {
@@ -241,34 +299,7 @@ function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
}); });
} }
function startProcessMetricsPolling(writer: PerfDiagWriter): void {
const sample = (): void => {
try {
const metrics = collectAppMetricsSnapshot();
const totalKb = sumWorkingSetKb(metrics.processes);
writer.append({
collectedAt: metrics.collectedAt,
source: 'main',
type: 'process',
payload: {
totalWorkingSetKb: totalKb,
processes: metrics.processes
}
});
void maybeTriggerHighMemoryAlert(writer, metrics, totalKb);
} catch {
// Collector failures must never affect the app.
}
};
sample();
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
}
async function maybeTriggerHighMemoryAlert( async function maybeTriggerHighMemoryAlert(
writer: PerfDiagWriter,
metrics: AppMetricsSnapshot, metrics: AppMetricsSnapshot,
totalWorkingSetKb: number | null totalWorkingSetKb: number | null
): Promise<void> { ): Promise<void> {
@@ -278,51 +309,26 @@ async function maybeTriggerHighMemoryAlert(
highMemoryAlertTriggeredThisSession = true; highMemoryAlertTriggeredThisSession = true;
const detectedAt = Date.now(); const record = await captureHighMemoryDiagnostics({
const userDataPath = app.getPath('userData'); userDataPath: app.getPath('userData'),
const immediateRendererEntries = await collectImmediateRendererSamples(getMainWindow());
const environment = collectSessionContext({
sessionStartedAt, sessionStartedAt,
userDataPath
});
for (const entry of immediateRendererEntries) {
writer.append(entry);
}
writer.append({
collectedAt: detectedAt,
source: 'main',
type: 'environment',
payload: {
...environment
}
});
writer.append({
collectedAt: detectedAt,
source: 'main',
type: 'high-memory',
payload: buildHighMemoryDiagnosticPayload({
detectedAt,
totalWorkingSetKb: totalWorkingSetKb ?? 0,
metrics, metrics,
environment, totalWorkingSetKb: totalWorkingSetKb ?? 0,
mainProcessMemory: process.memoryUsage(), writer: activeWriter,
ringEntries: writer.bufferedEntries, mainWindow: getMainWindow(),
immediateRendererEntries, reason: 'threshold'
sessionId: writer.sessionId
})
}); });
await writer.flushSnapshot('high-memory-threshold'); await persistAndNotifyHighMemoryAlert(record);
}
await writeHighMemoryAlert(userDataPath, { async function persistAndNotifyHighMemoryAlert(record: HighMemoryAlertRecord): Promise<void> {
logFilePath: writer.snapshotFilePath, await writeHighMemoryAlert(app.getPath('userData'), record);
detectedAt, notifyHighMemoryAlert(record);
peakWorkingSetKb: totalWorkingSetKb ?? 0, }
sessionId: writer.sessionId
}); function notifyHighMemoryAlert(record: HighMemoryAlertRecord): void {
getMainWindow()?.webContents.send(HIGH_MEMORY_ALERT_PENDING_CHANNEL, record);
} }
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry { function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {

View File

@@ -33,7 +33,8 @@ describe('high-memory-alert.store', () => {
logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'), logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'),
detectedAt: 1_700_000_000_000, detectedAt: 1_700_000_000_000,
peakWorkingSetKb: 2_200_000, peakWorkingSetKb: 2_200_000,
sessionId: 'session-1' sessionId: 'session-1',
reason: 'threshold' as const
}; };
await writeHighMemoryAlert(userDataPath, record); await writeHighMemoryAlert(userDataPath, record);

View File

@@ -1,11 +1,14 @@
import * as fsp from 'fs/promises'; import * as fsp from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
export type HighMemoryAlertReason = 'manual' | 'threshold';
export interface HighMemoryAlertRecord { export interface HighMemoryAlertRecord {
logFilePath: string; logFilePath: string;
detectedAt: number; detectedAt: number;
peakWorkingSetKb: number; peakWorkingSetKb: number;
sessionId: string; sessionId: string;
reason?: HighMemoryAlertReason;
} }
export function resolveHighMemoryAlertPath(userDataPath: string): string { export function resolveHighMemoryAlertPath(userDataPath: string): string {
@@ -31,7 +34,10 @@ export async function readHighMemoryAlert(userDataPath: string): Promise<HighMem
logFilePath: parsed.logFilePath, logFilePath: parsed.logFilePath,
detectedAt: parsed.detectedAt, detectedAt: parsed.detectedAt,
peakWorkingSetKb: parsed.peakWorkingSetKb, peakWorkingSetKb: parsed.peakWorkingSetKb,
sessionId: parsed.sessionId sessionId: parsed.sessionId,
...(parsed.reason === 'manual' || parsed.reason === 'threshold'
? { reason: parsed.reason }
: {})
}; };
} catch { } catch {
return null; return null;

View File

@@ -0,0 +1,57 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import * as os from 'os';
import * as path from 'path';
import * as fsp from 'fs/promises';
import { captureHighMemoryDiagnostics } from './high-memory-capture';
vi.mock('./immediate-renderer-samples.collector', () => ({
collectImmediateRendererSamples: vi.fn(async () => [])
}));
vi.mock('./session-context.collector', () => ({
collectSessionContext: vi.fn(() => ({
platform: 'linux',
userDataPath: '/tmp/user-data'
}))
}));
describe('captureHighMemoryDiagnostics', () => {
let userDataPath = '';
beforeEach(async () => {
userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-capture-'));
});
it('writes a diagnostics snapshot and returns an alert record', async () => {
const record = await captureHighMemoryDiagnostics({
userDataPath,
sessionStartedAt: Date.now() - 60_000,
metrics: {
collectedAt: Date.now(),
processes: [
{
pid: 1,
type: 'Browser',
workingSetKb: 2_200_000
}
]
},
totalWorkingSetKb: 2_200_000,
writer: null,
mainWindow: null,
reason: 'manual'
});
expect(record.peakWorkingSetKb).toBe(2_200_000);
expect(record.reason).toBe('manual');
expect(record.logFilePath).toContain(userDataPath);
await expect(fsp.stat(record.logFilePath)).resolves.toBeDefined();
});
});

View File

@@ -0,0 +1,80 @@
import type { BrowserWindow } from 'electron';
import type { AppMetricsSnapshot } from '../app-metrics';
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
import { collectSessionContext } from './session-context.collector';
import type { HighMemoryAlertRecord } from './high-memory-alert.store';
import type { PerfDiagEntry } from './diagnostics.models';
import { PerfDiagWriter } from './diagnostics.writer';
export type HighMemoryCaptureReason = 'manual' | 'threshold';
export interface CaptureHighMemoryDiagnosticsInput {
userDataPath: string;
sessionStartedAt: number;
metrics: AppMetricsSnapshot;
totalWorkingSetKb: number;
writer: PerfDiagWriter | null;
mainWindow: BrowserWindow | null;
reason: HighMemoryCaptureReason;
}
export async function captureHighMemoryDiagnostics(
input: CaptureHighMemoryDiagnosticsInput
): Promise<HighMemoryAlertRecord> {
const detectedAt = Date.now();
const writer = input.writer ?? new PerfDiagWriter({
userDataPath: input.userDataPath,
sessionId: `${input.reason}-${detectedAt.toString(36)}-${process.pid}`
});
const immediateRendererEntries = await collectImmediateRendererSamples(input.mainWindow);
const environment = collectSessionContext({
sessionStartedAt: input.sessionStartedAt,
userDataPath: input.userDataPath
});
appendEntries(writer, immediateRendererEntries);
appendEntries(writer, [
{
collectedAt: detectedAt,
source: 'main',
type: 'environment',
payload: {
...environment
}
},
{
collectedAt: detectedAt,
source: 'main',
type: 'high-memory',
payload: buildHighMemoryDiagnosticPayload({
detectedAt,
totalWorkingSetKb: input.totalWorkingSetKb,
metrics: input.metrics,
environment,
mainProcessMemory: process.memoryUsage(),
ringEntries: writer.bufferedEntries,
immediateRendererEntries,
sessionId: writer.sessionId
})
}
]);
await writer.flushSnapshot(
input.reason === 'manual' ? 'manual-export' : 'high-memory-threshold'
);
return {
logFilePath: writer.snapshotFilePath,
detectedAt,
peakWorkingSetKb: input.totalWorkingSetKb,
sessionId: writer.sessionId,
reason: input.reason
};
}
function appendEntries(writer: PerfDiagWriter, entries: readonly PerfDiagEntry[]): void {
for (const entry of entries) {
writer.append(entry);
}
}

View File

@@ -15,8 +15,11 @@ export {
attachRendererDiagnosticsHooks, attachRendererDiagnosticsHooks,
ensurePerfDiagIpcRegistered, ensurePerfDiagIpcRegistered,
getActivePerfDiagWriter, getActivePerfDiagWriter,
HIGH_MEMORY_ALERT_PENDING_CHANNEL,
isPerfDiagActive, isPerfDiagActive,
shutdownHighMemoryMonitoring,
shutdownPerfDiagnostics, shutdownPerfDiagnostics,
startHighMemoryMonitoring,
startPerfDiagnostics startPerfDiagnostics
} from './diagnostics.lifecycle'; } from './diagnostics.lifecycle';
export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models'; export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models';

View File

@@ -0,0 +1,16 @@
import {
describe,
expect,
it
} from 'vitest';
import { isReadableRegularFile } from './file-read.rules';
describe('file-read.rules', () => {
it('accepts regular files', () => {
expect(isReadableRegularFile({ isFile: () => true })).toBe(true);
});
it('rejects directories and other non-file paths', () => {
expect(isReadableRegularFile({ isFile: () => false })).toBe(false);
});
});

View File

@@ -0,0 +1,6 @@
import type { Stats } from 'fs';
/** Only regular files can be read through the read-file IPC surface. */
export function isReadableRegularFile(stats: Pick<Stats, 'isFile'>): boolean {
return stats.isFile();
}

View File

@@ -68,6 +68,7 @@ import {
grantPluginReadRoot, grantPluginReadRoot,
resolveReadablePath resolveReadablePath
} from '../path-jail'; } from '../path-jail';
import { isReadableRegularFile } from './file-read.rules';
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20; const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
@@ -654,9 +655,19 @@ export function setupSystemHandlers(): void {
return null; return null;
} }
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);

View File

@@ -35,6 +35,17 @@ describe('path-jail', () => {
await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath); await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath);
}); });
it('accepts diagnostics log paths under diagnostics', async () => {
const diagnosticsDir = path.join(tempRoot, 'diagnostics');
fs.mkdirSync(diagnosticsDir, { recursive: true });
const logPath = path.join(diagnosticsDir, 'perf-session.jsonl');
fs.writeFileSync(logPath, '{}');
await expect(assertPathUnderRoot(tempRoot, logPath)).resolves.toBe(logPath);
});
it('accepts cached plugin bundle paths under plugin-bundles', async () => { it('accepts cached plugin bundle paths under plugin-bundles', async () => {
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0'); const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0');

View File

@@ -9,7 +9,8 @@ export const DEFAULT_USER_DATA_SUBDIRS = [
'plugin-bundles', 'plugin-bundles',
'plugin-cache', 'plugin-cache',
'themes', 'themes',
'metoyou' 'metoyou',
'diagnostics'
] as const; ] as const;
export function isPathInside(parentPath: string, candidatePath: string): boolean { export function isPathInside(parentPath: string, candidatePath: string): boolean {

View File

@@ -11,6 +11,7 @@ const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received'; const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed'; const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed'; const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
const HIGH_MEMORY_ALERT_PENDING_CHANNEL = 'high-memory-alert-pending';
export interface LinuxScreenShareAudioRoutingInfo { export interface LinuxScreenShareAudioRoutingInfo {
available: boolean; available: boolean;
@@ -264,8 +265,23 @@ export interface ElectronAPI {
detectedAt: number; detectedAt: number;
peakWorkingSetKb: number; peakWorkingSetKb: number;
sessionId: string; sessionId: string;
reason?: 'manual' | 'threshold';
} | null>; } | null>;
acknowledgeHighMemoryAlert: () => Promise<boolean>; 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 }>; showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>; openCurrentDataFolder: () => Promise<boolean>;
@@ -335,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 }>;
@@ -410,6 +427,23 @@ const electronAPI: ElectronAPI = {
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry), reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'), getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'),
acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-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), 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'),
@@ -478,6 +512,7 @@ const electronAPI: ElectronAPI = {
grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath), grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data), writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data), appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
appendFileBytes: (filePath, data) => ipcRenderer.invoke('append-file-bytes', filePath, data),
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data), saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName), saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName),
openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath), openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath),

View File

@@ -18,8 +18,10 @@
}, },
"highMemoryAlert": { "highMemoryAlert": {
"badge": "High memory usage", "badge": "High memory usage",
"title": "The app used {{usageGb}} GB of RAM last session", "thresholdTitle": "The app is using {{usageGb}} GB of RAM",
"message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.", "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", "openLog": "Open log file",
"showInFolder": "Show in folder", "showInFolder": "Show in folder",
"copyPath": "Copy path", "copyPath": "Copy path",

View File

@@ -441,7 +441,9 @@
"title": "App-wide debugging", "title": "App-wide debugging",
"description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.", "description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.",
"processRam": "Process RAM", "processRam": "Process RAM",
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds.", "exportRamDiagnostics": "Export RAM diagnostics",
"exportRamDiagnosticsWorking": "Exporting...",
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds. Export saves a diagnostics log and opens the high-memory alert dialog.",
"capturedEvents": "Captured events", "capturedEvents": "Captured events",
"lastUpdate": "Last update: {{label}}", "lastUpdate": "Last update: {{label}}",
"noLogsYet": "No logs yet", "noLogsYet": "No logs yet",

View File

@@ -18,8 +18,10 @@
}, },
"highMemoryAlert": { "highMemoryAlert": {
"badge": "High memory usage", "badge": "High memory usage",
"title": "The app used {{usageGb}} GB of RAM last session", "thresholdTitle": "The app is using {{usageGb}} GB of RAM",
"message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.", "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", "openLog": "Open log file",
"showInFolder": "Show in folder", "showInFolder": "Show in folder",
"copyPath": "Copy path", "copyPath": "Copy path",
@@ -1507,7 +1509,9 @@
"title": "App-wide debugging", "title": "App-wide debugging",
"description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.", "description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.",
"processRam": "Process RAM", "processRam": "Process RAM",
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds.", "exportRamDiagnostics": "Export RAM diagnostics",
"exportRamDiagnosticsWorking": "Exporting...",
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds. Export saves a diagnostics log and opens the high-memory alert dialog.",
"capturedEvents": "Captured events", "capturedEvents": "Captured events",
"lastUpdate": "Last update: {{label}}", "lastUpdate": "Last update: {{label}}",
"noLogsYet": "No logs yet", "noLogsYet": "No logs yet",

View File

@@ -256,6 +256,7 @@ export interface ElectronHighMemoryAlertRecord {
detectedAt: number; detectedAt: number;
peakWorkingSetKb: number; peakWorkingSetKb: number;
sessionId: string; sessionId: string;
reason?: 'manual' | 'threshold';
} }
export interface ElectronApi { export interface ElectronApi {
@@ -281,6 +282,8 @@ export interface ElectronApi {
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>; reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>; getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>;
acknowledgeHighMemoryAlert?: () => Promise<boolean>; acknowledgeHighMemoryAlert?: () => Promise<boolean>;
exportHighMemoryDiagnostics?: () => Promise<ElectronHighMemoryAlertRecord>;
onHighMemoryAlertPending?: (listener: (alert: ElectronHighMemoryAlertRecord) => void) => () => void;
showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>; showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>; openCurrentDataFolder: () => Promise<boolean>;
@@ -319,6 +322,7 @@ export interface ElectronApi {
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>; grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
writeFile: (filePath: string, data: string) => Promise<boolean>; writeFile: (filePath: string, data: string) => Promise<boolean>;
appendFile: (filePath: string, data: string) => Promise<boolean>; appendFile: (filePath: string, data: string) => Promise<boolean>;
appendFileBytes: (filePath: string, data: Uint8Array) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>; saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>; saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>; openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;

View File

@@ -4,20 +4,22 @@ import { ElectronBridgeService } from './electron/electron-bridge.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PlatformService { export class PlatformService {
readonly isElectron: boolean;
readonly isCapacitor: boolean; readonly isCapacitor: boolean;
readonly isBrowser: boolean; readonly isBrowser: boolean;
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
constructor() { constructor() {
this.isElectron = this.electronBridge.isAvailable; const isElectron = this.electronBridge.isAvailable;
const runtime = detectRuntimePlatform({ const runtime = detectRuntimePlatform({
hasElectronApi: this.isElectron, hasElectronApi: isElectron,
capacitorIsNative: isCapacitorNativeRuntime() capacitorIsNative: isCapacitorNativeRuntime()
}); });
this.isCapacitor = runtime === 'capacitor'; this.isCapacitor = runtime === 'capacitor';
this.isBrowser = runtime === 'browser'; this.isBrowser = runtime === 'browser';
} }
get isElectron(): boolean {
return this.electronBridge.isAvailable;
}
} }

View File

@@ -0,0 +1,164 @@
import '@angular/compiler';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DOCUMENT } from '@angular/common';
import { Injector, runInInjectionContext } from '@angular/core';
import { DesktopHighMemoryAlertService } from './desktop-high-memory-alert.service';
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
describe('DesktopHighMemoryAlertService', () => {
let electronBridge: {
isAvailable: boolean;
getApi: ReturnType<typeof vi.fn>;
};
let documentStub: Document;
beforeEach(() => {
documentStub = {
body: null,
createElement: vi.fn(),
execCommand: vi.fn(() => true)
} as unknown as Document;
electronBridge = {
isAvailable: true,
getApi: vi.fn(() => ({
getPendingHighMemoryAlert: vi.fn(async () => ({
logFilePath: '/tmp/diagnostics/session.ndjson',
detectedAt: 1,
peakWorkingSetKb: 2_200_000,
sessionId: 'session-1'
})),
onHighMemoryAlertPending: vi.fn(() => () => undefined),
exportHighMemoryDiagnostics: vi.fn(async () => ({
logFilePath: '/tmp/diagnostics/manual.ndjson',
detectedAt: 2,
peakWorkingSetKb: 1_800_000,
sessionId: 'session-2',
reason: 'manual' as const
})),
acknowledgeHighMemoryAlert: vi.fn(async () => true)
}))
};
});
function createService(): DesktopHighMemoryAlertService {
const injector = Injector.create({
providers: [
DesktopHighMemoryAlertService,
{ provide: ElectronBridgeService, useValue: electronBridge },
{ provide: DOCUMENT, useValue: documentStub }
]
});
return runInInjectionContext(injector, () => injector.get(DesktopHighMemoryAlertService));
}
it('loads a pending alert from disk on initialize', async () => {
const service = createService();
await service.initialize();
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/session.ndjson');
expect(service.peakUsageGb()).toBe('2.10');
});
it('shows the modal when a live high-memory alert event arrives', async () => {
let listener: ((alert: {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
}) => void) | undefined;
electronBridge.getApi = vi.fn(() => ({
getPendingHighMemoryAlert: vi.fn(async () => null),
onHighMemoryAlertPending: vi.fn((callback) => {
listener = callback;
return () => undefined;
}),
exportHighMemoryDiagnostics: vi.fn(async () => null),
acknowledgeHighMemoryAlert: vi.fn(async () => true)
}));
const service = createService();
await service.initialize();
listener?.({
logFilePath: '/tmp/diagnostics/live.ndjson',
detectedAt: 3,
peakWorkingSetKb: 2_400_000,
sessionId: 'session-3'
});
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/live.ndjson');
});
it('exports diagnostics manually and opens the modal with manual copy', async () => {
const service = createService();
await expect(service.exportDiagnostics()).resolves.toBe(true);
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/manual.ndjson');
expect(service.pendingAlert()?.reason).toBe('manual');
expect(service.titleKey()).toBe('app.highMemoryAlert.manualTitle');
expect(service.messageKey()).toBe('app.highMemoryAlert.manualMessage');
});
it('uses threshold copy for live high-memory alerts', async () => {
let listener: ((alert: {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
reason?: 'manual' | 'threshold';
}) => void) | undefined;
electronBridge.getApi = vi.fn(() => ({
getPendingHighMemoryAlert: vi.fn(async () => null),
onHighMemoryAlertPending: vi.fn((callback) => {
listener = callback;
return () => undefined;
}),
exportHighMemoryDiagnostics: vi.fn(async () => null),
acknowledgeHighMemoryAlert: vi.fn(async () => true)
}));
const service = createService();
await service.initialize();
listener?.({
logFilePath: '/tmp/diagnostics/live.ndjson',
detectedAt: 3,
peakWorkingSetKb: 2_400_000,
sessionId: 'session-3',
reason: 'threshold'
});
expect(service.titleKey()).toBe('app.highMemoryAlert.thresholdTitle');
expect(service.messageKey()).toBe('app.highMemoryAlert.thresholdMessage');
});
it('copies the diagnostics log path to the clipboard', async () => {
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText }
});
const service = createService();
await service.initialize();
await expect(service.copyLogPath()).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith('/tmp/diagnostics/session.ndjson');
});
});

View File

@@ -4,16 +4,21 @@ import {
inject, inject,
signal signal
} from '@angular/core'; } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { PlatformService } from '../platform';
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service'; 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 { formatKilobytesAsGigabytes } from '../platform/electron/electron-app-metrics.rules';
import {
resolveHighMemoryAlertCopyKind,
resolveHighMemoryAlertMessageKey,
resolveHighMemoryAlertTitleKey
} from './high-memory-alert-copy.rules';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DesktopHighMemoryAlertService { export class DesktopHighMemoryAlertService {
private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
private readonly document = inject(DOCUMENT);
readonly pendingAlert = signal<ElectronHighMemoryAlertRecord | null>(null); readonly pendingAlert = signal<ElectronHighMemoryAlertRecord | null>(null);
@@ -23,24 +28,55 @@ export class DesktopHighMemoryAlertService {
return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null; 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> { async initialize(): Promise<void> {
if (!this.platform.isElectron) { if (!this.electronBridge.isAvailable || this.initialized) {
return; return;
} }
this.initialized = true;
const api = this.electronBridge.getApi(); const api = this.electronBridge.getApi();
if (!api?.getPendingHighMemoryAlert) { if (!api) {
return; return;
} }
const alert = await api.getPendingHighMemoryAlert(); this.removePendingListener?.();
this.removePendingListener = api.onHighMemoryAlertPending?.((alert) => {
this.pendingAlert.set(alert);
}) ?? null;
const alert = await api.getPendingHighMemoryAlert?.();
if (alert) { if (alert) {
this.pendingAlert.set(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> { async dismiss(): Promise<void> {
const api = this.electronBridge.getApi(); const api = this.electronBridge.getApi();
@@ -70,13 +106,49 @@ export class DesktopHighMemoryAlertService {
await api.showLogFileInFolder(alert.logFilePath); await api.showLogFileInFolder(alert.logFilePath);
} }
async copyLogPath(): Promise<void> { async copyLogPath(): Promise<boolean> {
const alert = this.pendingAlert(); const alert = this.pendingAlert();
if (!alert?.logFilePath) { if (!alert?.logFilePath) {
return; return false;
} }
await navigator.clipboard.writeText(alert.logFilePath); return await this.writeTextToClipboard(alert.logFilePath);
}
private async writeTextToClipboard(value: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {}
}
const body = this.document.body;
if (!body) {
return false;
}
const textarea = this.document.createElement('textarea');
textarea.value = value;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
body.appendChild(textarea);
textarea.focus();
textarea.select();
let copied = false;
try {
copied = this.document.execCommand('copy');
} catch {}
body.removeChild(textarea);
return copied;
} }
} }

View File

@@ -0,0 +1,47 @@
import {
describe,
expect,
it
} from 'vitest';
import {
resolveHighMemoryAlertCopyKind,
resolveHighMemoryAlertMessageKey,
resolveHighMemoryAlertTitleKey
} from './high-memory-alert-copy.rules';
describe('high-memory-alert-copy.rules', () => {
it('uses threshold copy for live alerts and legacy records without a reason', () => {
expect(resolveHighMemoryAlertCopyKind({
logFilePath: '/tmp/log.jsonl',
detectedAt: 1,
peakWorkingSetKb: 2_100_000,
sessionId: 'session-1'
})).toBe('threshold');
expect(resolveHighMemoryAlertCopyKind({
logFilePath: '/tmp/log.jsonl',
detectedAt: 1,
peakWorkingSetKb: 2_100_000,
sessionId: 'session-1',
reason: 'threshold'
})).toBe('threshold');
});
it('uses manual copy for exported diagnostics', () => {
expect(resolveHighMemoryAlertCopyKind({
logFilePath: '/tmp/log.jsonl',
detectedAt: 1,
peakWorkingSetKb: 1_800_000,
sessionId: 'session-2',
reason: 'manual'
})).toBe('manual');
});
it('maps copy kinds to translation keys', () => {
expect(resolveHighMemoryAlertTitleKey('threshold')).toBe('app.highMemoryAlert.thresholdTitle');
expect(resolveHighMemoryAlertTitleKey('manual')).toBe('app.highMemoryAlert.manualTitle');
expect(resolveHighMemoryAlertMessageKey('threshold')).toBe('app.highMemoryAlert.thresholdMessage');
expect(resolveHighMemoryAlertMessageKey('manual')).toBe('app.highMemoryAlert.manualMessage');
});
});

View File

@@ -0,0 +1,21 @@
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
export type HighMemoryAlertCopyKind = 'threshold' | 'manual';
export function resolveHighMemoryAlertCopyKind(
alert: ElectronHighMemoryAlertRecord | null | undefined
): HighMemoryAlertCopyKind {
return alert?.reason === 'manual' ? 'manual' : 'threshold';
}
export function resolveHighMemoryAlertTitleKey(kind: HighMemoryAlertCopyKind): string {
return kind === 'manual'
? 'app.highMemoryAlert.manualTitle'
: 'app.highMemoryAlert.thresholdTitle';
}
export function resolveHighMemoryAlertMessageKey(kind: HighMemoryAlertCopyKind): string {
return kind === 'manual'
? 'app.highMemoryAlert.manualMessage'
: 'app.highMemoryAlert.thresholdMessage';
}

View File

@@ -107,12 +107,15 @@ Concurrent triggers (file-announce, message sync, peer connect) can race to requ
- **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window. - **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window.
- **Sender:** `handleFileRequest` / `fulfillRequestWithFile` track active outbound streams per `(messageId, fileId, peerId)` and ignore duplicate requests while a stream is in flight. A fresh `file-request` clears any earlier `file-cancel` marker from that peer. - **Sender:** `handleFileRequest` / `fulfillRequestWithFile` track active outbound streams per `(messageId, fileId, peerId)` and ignore duplicate requests while a stream is in flight. A fresh `file-request` clears any earlier `file-cancel` marker from that peer.
- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped. - **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped. When the active store supports streaming (`canStreamToDisk`), **all** persistable downloads append directly to disk — metadata `filePath` does not force an in-memory assembly fallback. Disk-streamed receives decode each chunk once, append bytes through Electron IPC (`append-file-bytes`), and acknowledge the sender with `file-chunk-ack` so only one chunk is in flight at a time (preventing unbounded base64 retention in the renderer). Completed media stays on `savedPath` until inline display hydration runs on demand.
- **Sender:** after each `file-chunk` the transport awaits the matching `file-chunk-ack` before sending the next chunk, in addition to data-channel bufferedAmount back-pressure.
### Failure handling ### Failure handling
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress. If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
Peers that finish downloading a file re-announce it and register themselves as mirror hosts. New download requests prefer mirror hosts over the original uploader so the sharer's device is not the only upload source. Repeat `file-announce` events for already-known attachments update the host list but do not re-trigger auto-download.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant R as Receiver participant R as Receiver
@@ -155,6 +158,7 @@ An optional experimental VLC.js adapter can be enabled from General settings. Wh
- `isUploaderUser(attachment, currentUserId)` — the current user is the uploader (same user, any device). - `isUploaderUser(attachment, currentUserId)` — the current user is the uploader (same user, any device).
- `deviceHasLocalCopy(attachment)` — this device physically holds the bytes (`available` + a blob `objectUrl`, or a non-empty `savedPath`/`filePath`). Synced metadata alone does not count, because P2P/account sync strips local paths. - `deviceHasLocalCopy(attachment)` — this device physically holds the bytes (`available` + a blob `objectUrl`, or a non-empty `savedPath`/`filePath`). Synced metadata alone does not count, because P2P/account sync strips local paths.
- `canHostAttachment(attachment)` — alias of `deviceHasLocalCopy`; any peer with local bytes can serve downloads.
- `isSharingFromThisDevice(attachment, currentUserId)``isUploaderUser && deviceHasLocalCopy`. Only this returns the "Shared from your device" state. - `isSharingFromThisDevice(attachment, currentUserId)``isUploaderUser && deviceHasLocalCopy`. Only this returns the "Shared from your device" state.
The chat message item renders "Shared from your device" (and hides the request/download affordance) **only** when `isSharingFromThisDevice` is true. A second device of the same user that merely synced the message metadata is the uploader-user but holds no local copy, so it falls back to the normal recipient flow (request/download) instead of falsely claiming ownership and blocking the file (regression: the old check used `uploaderPeerId === currentUserId` and so claimed ownership on every device of the uploader). The transfer service uses the same rule to decide whether a no-peers failure should read "your original upload is missing" (sharing device) or "no connected peers" (any other device). The chat message item renders "Shared from your device" (and hides the request/download affordance) **only** when `isSharingFromThisDevice` is true. A second device of the same user that merely synced the message metadata is the uploader-user but holds no local copy, so it falls back to the normal recipient flow (request/download) instead of falsely claiming ownership and blocking the file (regression: the old check used `uploaderPeerId === currentUserId` and so claimed ownership on every device of the uploader). The transfer service uses the same rule to decide whether a no-peers failure should read "your original upload is missing" (sharing device) or "no connected peers" (any other device).
@@ -195,3 +199,14 @@ Room and conversation names are sanitised to remove filesystem-unsafe characters
- **cancellations**: IDs of transfers the user cancelled - **cancellations**: IDs of transfers the user cancelled
Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service. Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service.
### Display blob lifecycle (memory)
Image inline previews on Electron/desktop use renderer `blob:` URLs rebuilt from disk. To cap RAM in media-heavy channels:
- **Room restore** (`restoreLocalAttachmentsForRoom`) resolves `savedPath` for hosting only — it does not hydrate every image blob up front.
- **Visibility** (`ChatMessageItemComponent` + `IntersectionObserver` on the chat scrollport) hydrates blobs when a message enters view (with `ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN`) and revokes them when it leaves, as long as a disk path can rehydrate later (`canRevokeAttachmentDisplayBlob`).
- **Pinned overlays** (lightbox / image gallery) call `pinDisplayBlobs` so an open full-screen view is not revoked while its message scrolls off-screen.
- **Serving** is unaffected: peers still download from `savedPath` / `filePath`; blob URLs are display-only.
While a revoked image waits to rehydrate, chat renders the existing image-grid spinner skeleton (`isAttachmentPendingInlineHydration`).

View File

@@ -75,6 +75,24 @@ export class AttachmentFacade {
return this.manager.tryRestoreAttachmentFromLocal(...args); return this.manager.tryRestoreAttachmentFromLocal(...args);
} }
pinDisplayBlobs(
...args: Parameters<AttachmentManagerService['pinDisplayBlobs']>
): ReturnType<AttachmentManagerService['pinDisplayBlobs']> {
return this.manager.pinDisplayBlobs(...args);
}
unpinDisplayBlobs(
...args: Parameters<AttachmentManagerService['unpinDisplayBlobs']>
): ReturnType<AttachmentManagerService['unpinDisplayBlobs']> {
return this.manager.unpinDisplayBlobs(...args);
}
revokeOffscreenDisplayBlobsForMessage(
...args: Parameters<AttachmentManagerService['revokeOffscreenDisplayBlobsForMessage']>
): ReturnType<AttachmentManagerService['revokeOffscreenDisplayBlobsForMessage']> {
return this.manager.revokeOffscreenDisplayBlobsForMessage(...args);
}
requestFile( requestFile(
...args: Parameters<AttachmentManagerService['requestFile']> ...args: Parameters<AttachmentManagerService['requestFile']>
): ReturnType<AttachmentManagerService['requestFile']> { ): ReturnType<AttachmentManagerService['requestFile']> {
@@ -99,6 +117,12 @@ export class AttachmentFacade {
return this.manager.handleFileChunk(...args); return this.manager.handleFileChunk(...args);
} }
handleFileChunkAck(
...args: Parameters<AttachmentManagerService['handleFileChunkAck']>
): ReturnType<AttachmentManagerService['handleFileChunkAck']> {
return this.manager.handleFileChunkAck(...args);
}
handleFileRequest( handleFileRequest(
...args: Parameters<AttachmentManagerService['handleFileRequest']> ...args: Parameters<AttachmentManagerService['handleFileRequest']>
): ReturnType<AttachmentManagerService['handleFileRequest']> { ): ReturnType<AttachmentManagerService['handleFileRequest']> {

View File

@@ -0,0 +1,37 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
describe('AttachmentChunkAckService', () => {
let service: AttachmentChunkAckService;
beforeEach(() => {
service = new AttachmentChunkAckService();
});
it('resolves a waiter when the matching chunk ack arrives', async () => {
const waitPromise = service.waitForAck('msg-1', 'file-1', 0, 1_000);
service.resolveAck('msg-1', 'file-1', 0);
await expect(waitPromise).resolves.toBeUndefined();
});
it('times out when no ack arrives', async () => {
vi.useFakeTimers();
const waitPromise = service.waitForAck('msg-1', 'file-1', 1, 50);
vi.advanceTimersByTime(51);
await expect(waitPromise).rejects.toThrow('attachment chunk ack timeout');
vi.useRealTimers();
});
});

View File

@@ -0,0 +1,47 @@
import { Injectable } from '@angular/core';
import { buildAttachmentChunkAckKey } from '../../domain/logic/attachment-chunk-ack.rules';
@Injectable({ providedIn: 'root' })
export class AttachmentChunkAckService {
private readonly waiters = new Map<string, () => void>();
waitForAck(
messageId: string,
fileId: string,
index: number,
timeoutMs = 60_000
): Promise<void> {
const key = buildAttachmentChunkAckKey(messageId, fileId, index);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.waiters.delete(key);
reject(new Error('attachment chunk ack timeout'));
}, timeoutMs);
this.waiters.set(key, () => {
clearTimeout(timer);
this.waiters.delete(key);
resolve();
});
});
}
resolveAck(messageId: string, fileId: string, index: number): void {
this.waiters.get(buildAttachmentChunkAckKey(messageId, fileId, index))?.();
}
cancelPendingForFile(messageId: string, fileId: string): void {
const prefix = `${messageId}:${fileId}:`;
for (const [key, resolve] of this.waiters) {
if (!key.startsWith(prefix)) {
continue;
}
resolve();
this.waiters.delete(key);
}
}
}

View File

@@ -0,0 +1,97 @@
import '@angular/compiler';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DOCUMENT } from '@angular/common';
import { Injector, runInInjectionContext } from '@angular/core';
import { AttachmentDownloadService } from './attachment-download.service';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { Attachment } from '../../domain/models/attachment.model';
describe('AttachmentDownloadService', () => {
let electronBridge: {
isAvailable: boolean;
getApi: ReturnType<typeof vi.fn>;
};
let documentStub: Document;
let saveExistingFileAs: ReturnType<typeof vi.fn>;
let saveFileAs: ReturnType<typeof vi.fn>;
beforeEach(() => {
saveExistingFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
saveFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
electronBridge = {
isAvailable: true,
getApi: vi.fn(() => ({
saveExistingFileAs,
saveFileAs
}))
};
documentStub = {
body: {
appendChild: vi.fn(),
removeChild: vi.fn()
},
createElement: vi.fn(() => ({
click: vi.fn(),
remove: vi.fn(),
href: '',
download: ''
}))
} as unknown as Document;
});
function createService(): AttachmentDownloadService {
const injector = Injector.create({
providers: [
AttachmentDownloadService,
{ provide: ElectronBridgeService, useValue: electronBridge },
{ provide: DOCUMENT, useValue: documentStub }
]
});
return runInInjectionContext(injector, () => injector.get(AttachmentDownloadService));
}
it('exports a completed disk-only attachment through Electron save dialog', async () => {
const service = createService();
const attachment: Attachment = {
id: 'file-1',
messageId: 'message-1',
filename: 'large.bin',
mime: 'application/octet-stream',
size: 5_000_000_000,
available: true,
savedPath: '/appdata/server/room/files/large.bin'
};
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(true);
expect(saveExistingFileAs).toHaveBeenCalledWith('/appdata/server/room/files/large.bin', 'large.bin');
expect(saveFileAs).not.toHaveBeenCalled();
});
it('does nothing when the attachment is not downloadable yet', async () => {
const service = createService();
const attachment: Attachment = {
id: 'file-2',
messageId: 'message-2',
filename: 'large.bin',
mime: 'application/octet-stream',
size: 5_000_000_000,
available: true
};
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(false);
expect(saveExistingFileAs).not.toHaveBeenCalled();
expect(saveFileAs).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,97 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { canDownloadAttachment, resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
import type { Attachment } from '../../domain/models/attachment.model';
@Injectable({ providedIn: 'root' })
export class AttachmentDownloadService {
private readonly electronBridge = inject(ElectronBridgeService);
private readonly document = inject(DOCUMENT);
async downloadToUserLocation(attachment: Attachment): Promise<boolean> {
if (!canDownloadAttachment(attachment)) {
return false;
}
const electronApi = this.electronBridge.getApi();
const diskPath = resolveAttachmentDiskPath(attachment);
if (electronApi) {
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled) {
return true;
}
} catch {
/* fall back to blob/browser download */
}
}
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
try {
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
if (result.saved || result.cancelled) {
return true;
}
} catch {
/* fall back to browser download */
}
}
}
if (!attachment.objectUrl) {
return false;
}
const link = this.document.createElement('a');
link.href = attachment.objectUrl;
link.download = attachment.filename;
this.document.body?.appendChild(link);
link.click();
link.remove();
return true;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl || attachment.objectUrl.startsWith('file:')) {
return null;
}
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode attachment'));
return;
}
const [, base64 = ''] = reader.result.split(',', 2);
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
reader.readAsDataURL(blob);
});
}
}

View File

@@ -10,6 +10,7 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentUserId } from '../../../../store/users/users.selectors'; 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,
@@ -20,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';
@@ -44,6 +46,7 @@ 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(() => {
@@ -160,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);
} }
@@ -173,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);
} }
} }
@@ -184,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);
} }
@@ -218,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();
} }

View File

@@ -99,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();
@@ -113,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();
@@ -206,4 +218,49 @@ describe('AttachmentPersistenceService', () => {
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled(); expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
expect(database.saveAttachment).toHaveBeenCalled(); expect(database.saveAttachment).toHaveBeenCalled();
}); });
it('restores host metadata without hydrating media blobs when display hydration is disabled', async () => {
const service = createService();
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png',
available: false
};
await expect(service.tryRestoreAttachmentHostOnly(attachment)).resolves.toBe(true);
expect(attachment.savedPath).toBe('/appdata/photo.png');
expect(attachment.objectUrl).toBeUndefined();
expect(attachment.available).toBe(false);
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
});
it('revokes display blobs while keeping disk paths for later rehydration', () => {
const service = createService();
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png',
available: true,
objectUrl: 'blob:http://localhost/abc'
};
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true);
expect(attachment.objectUrl).toBeUndefined();
expect(attachment.savedPath).toBe('/appdata/photo.png');
expect(revokeSpy).toHaveBeenCalledWith('blob:http://localhost/abc');
revokeSpy.mockRestore();
});
}); });

View File

@@ -11,6 +11,7 @@ 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 { isAttachmentMedia } from '../../domain/logic/attachment.logic';
@@ -119,7 +120,7 @@ export class AttachmentPersistenceService {
} }
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> { async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
const restored = await this.ensurePersistedUploadHost(attachment); const restored = await this.ensurePersistedUploadHost(attachment, { hydrateMediaForDisplay: true });
if (restored) { if (restored) {
attachment.requestError = undefined; attachment.requestError = undefined;
@@ -128,11 +129,30 @@ export class AttachmentPersistenceService {
return restored; return restored;
} }
async ensurePersistedUploadHost(attachment: Attachment): Promise<boolean> { 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); const existingPath = await this.attachmentStorage.resolveExistingPath(attachment);
if (existingPath) { if (existingPath) {
return this.hydrateAttachmentFromStoredPath(attachment, existingPath); return this.hydrateAttachmentFromStoredPath(attachment, existingPath, hydrateMediaForDisplay);
} }
if (!attachment.filePath?.trim() || !this.attachmentStorage.canCopyFiles()) { if (!attachment.filePath?.trim() || !this.attachmentStorage.canCopyFiles()) {
@@ -147,13 +167,22 @@ export class AttachmentPersistenceService {
return false; return false;
} }
return this.hydrateAttachmentFromStoredPath(attachment, savedPath); return this.hydrateAttachmentFromStoredPath(attachment, savedPath, hydrateMediaForDisplay);
} }
private async hydrateAttachmentFromStoredPath(attachment: Attachment, diskPath: string): Promise<boolean> { private async hydrateAttachmentFromStoredPath(
attachment: Attachment,
diskPath: string,
hydrateMediaForDisplay = true
): Promise<boolean> {
attachment.savedPath = diskPath; attachment.savedPath = diskPath;
if (isAttachmentMedia(attachment)) { if (isAttachmentMedia(attachment)) {
if (!hydrateMediaForDisplay) {
void this.persistAttachmentMeta(attachment);
return true;
}
return this.ensureInlineDisplayObjectUrl(attachment); return this.ensureInlineDisplayObjectUrl(attachment);
} }
@@ -192,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;
} }
} }
@@ -366,6 +396,8 @@ export class AttachmentPersistenceService {
`${attachment.messageId}:${attachment.id}`, `${attachment.messageId}:${attachment.id}`,
new File([blob], attachment.filename, { type: attachment.mime }) new File([blob], attachment.filename, { type: attachment.mime })
); );
this.runtimeStore.touch();
} }
private revokeAttachmentObjectUrl(attachment: Attachment): void { private revokeAttachmentObjectUrl(attachment: Attachment): void {

View File

@@ -12,6 +12,7 @@ export class AttachmentRuntimeStore {
private pendingRequests = new Map<string, Set<string>>(); private pendingRequests = new Map<string, Set<string>>();
private chunkBuffers = new Map<string, (ArrayBuffer | undefined)[]>(); private chunkBuffers = new Map<string, (ArrayBuffer | undefined)[]>();
private chunkCounts = new Map<string, number>(); private chunkCounts = new Map<string, number>();
private announcedHostsByAttachment = new Map<string, Set<string>>();
touch(): void { touch(): void {
this.updated.set(this.updated() + 1); this.updated.set(this.updated() + 1);
@@ -66,6 +67,25 @@ export class AttachmentRuntimeStore {
return this.originalFiles.get(key); return this.originalFiles.get(key);
} }
deleteOriginalFile(key: string): void {
this.originalFiles.delete(key);
}
addAnnouncedHost(requestKey: string, peerId: string): void {
const hosts = this.announcedHostsByAttachment.get(requestKey) ?? new Set<string>();
hosts.add(peerId);
this.announcedHostsByAttachment.set(requestKey, hosts);
}
getAnnouncedHosts(requestKey: string): Set<string> {
return this.announcedHostsByAttachment.get(requestKey) ?? new Set();
}
deleteAnnouncedHosts(requestKey: string): void {
this.announcedHostsByAttachment.delete(requestKey);
}
findOriginalFileByFileId(fileId: string): File | null { findOriginalFileByFileId(fileId: string): File | null {
for (const [key, file] of this.originalFiles) { for (const [key, file] of this.originalFiles) {
if (key.endsWith(`:${fileId}`)) { if (key.endsWith(`:${fileId}`)) {
@@ -160,5 +180,11 @@ export class AttachmentRuntimeStore {
this.cancelledTransfers.delete(key); this.cancelledTransfers.delete(key);
} }
} }
for (const key of Array.from(this.announcedHostsByAttachment.keys())) {
if (key.startsWith(scopedPrefix)) {
this.announcedHostsByAttachment.delete(key);
}
}
} }
} }

View File

@@ -8,11 +8,13 @@ import {
decodeBase64, decodeBase64,
iterateBlobChunks iterateBlobChunks
} from '../../../../shared-kernel'; } from '../../../../shared-kernel';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentTransferTransportService { export class AttachmentTransferTransportService {
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentStorage = inject(AttachmentStorageService); private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly chunkAcks = inject(AttachmentChunkAckService);
decodeBase64(base64: string): Uint8Array { decodeBase64(base64: string): Uint8Array {
return decodeBase64(base64); return decodeBase64(base64);
@@ -39,6 +41,7 @@ export class AttachmentTransferTransportService {
}; };
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
await this.chunkAcks.waitForAck(messageId, fileId, chunk.index);
} }
} }
@@ -84,6 +87,7 @@ export class AttachmentTransferTransportService {
}; };
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
await this.chunkAcks.waitForAck(messageId, fileId, chunkIndex);
} }
} }
@@ -122,6 +126,7 @@ export class AttachmentTransferTransportService {
}; };
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
await this.chunkAcks.waitForAck(messageId, fileId, chunkIndex);
} }
} }
} }

View File

@@ -21,6 +21,7 @@ import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferService } from './attachment-transfer.service'; import { AttachmentTransferService } from './attachment-transfer.service';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service'; import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
const MESSAGE_ID = 'msg-1'; const MESSAGE_ID = 'msg-1';
const FILE_ID = 'file-1'; const FILE_ID = 'file-1';
@@ -52,6 +53,7 @@ describe('AttachmentTransferService', () => {
resolveExistingPath: ReturnType<typeof vi.fn>; resolveExistingPath: ReturnType<typeof vi.fn>;
resolveLegacyImagePath: ReturnType<typeof vi.fn>; resolveLegacyImagePath: ReturnType<typeof vi.fn>;
appendBase64: ReturnType<typeof vi.fn>; appendBase64: ReturnType<typeof vi.fn>;
appendBytes: ReturnType<typeof vi.fn>;
createWritableFile: ReturnType<typeof vi.fn>; createWritableFile: ReturnType<typeof vi.fn>;
deleteFile: ReturnType<typeof vi.fn>; deleteFile: ReturnType<typeof vi.fn>;
}; };
@@ -60,6 +62,11 @@ describe('AttachmentTransferService', () => {
streamFileToPeer: ReturnType<typeof vi.fn>; streamFileToPeer: ReturnType<typeof vi.fn>;
streamFileFromDiskToPeer: ReturnType<typeof vi.fn>; streamFileFromDiskToPeer: ReturnType<typeof vi.fn>;
}; };
let chunkAcks: {
resolveAck: ReturnType<typeof vi.fn>;
waitForAck: ReturnType<typeof vi.fn>;
cancelPendingForFile: ReturnType<typeof vi.fn>;
};
let webrtc: { let webrtc: {
getConnectedPeers: ReturnType<typeof vi.fn>; getConnectedPeers: ReturnType<typeof vi.fn>;
broadcastMessage: ReturnType<typeof vi.fn>; broadcastMessage: ReturnType<typeof vi.fn>;
@@ -88,6 +95,7 @@ describe('AttachmentTransferService', () => {
resolveExistingPath: vi.fn(async () => null), resolveExistingPath: vi.fn(async () => null),
resolveLegacyImagePath: vi.fn(async () => null), resolveLegacyImagePath: vi.fn(async () => null),
appendBase64: vi.fn(async () => true), appendBase64: vi.fn(async () => true),
appendBytes: vi.fn(async () => true),
createWritableFile: vi.fn(async () => '/appdata/server/room/files/file-1'), createWritableFile: vi.fn(async () => '/appdata/server/room/files/file-1'),
deleteFile: vi.fn(async () => true) deleteFile: vi.fn(async () => true)
}; };
@@ -98,6 +106,12 @@ describe('AttachmentTransferService', () => {
streamFileFromDiskToPeer: vi.fn(async () => undefined) streamFileFromDiskToPeer: vi.fn(async () => undefined)
}; };
chunkAcks = {
resolveAck: vi.fn(),
waitForAck: vi.fn(async () => undefined),
cancelPendingForFile: vi.fn()
};
webrtc = { webrtc = {
getConnectedPeers: vi.fn(() => [PEER_ID]), getConnectedPeers: vi.fn(() => [PEER_ID]),
broadcastMessage: vi.fn(), broadcastMessage: vi.fn(),
@@ -115,7 +129,8 @@ describe('AttachmentTransferService', () => {
{ provide: AppI18nService, useValue: { instant: (key: string) => key } }, { provide: AppI18nService, useValue: { instant: (key: string) => key } },
{ provide: AttachmentStorageService, useValue: attachmentStorage }, { provide: AttachmentStorageService, useValue: attachmentStorage },
{ provide: AttachmentPersistenceService, useValue: persistence }, { provide: AttachmentPersistenceService, useValue: persistence },
{ provide: AttachmentTransferTransportService, useValue: transport } { provide: AttachmentTransferTransportService, useValue: transport },
{ provide: AttachmentChunkAckService, useValue: chunkAcks }
] ]
}); });
const service = runInInjectionContext(injector, () => injector.get(AttachmentTransferService)); const service = runInInjectionContext(injector, () => injector.get(AttachmentTransferService));
@@ -294,17 +309,13 @@ describe('AttachmentTransferService', () => {
}); });
it('streams a requested file only once while the same request is already in flight', async () => { it('streams a requested file only once while the same request is already in flight', async () => {
attachmentStorage.resolveExistingPath.mockResolvedValue(null);
const service = createService(); const service = createService();
registerIncomingAttachment(9); registerIncomingAttachment(9);
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File([new Uint8Array(9)], 'photo.png', { type: 'image/png' })); runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File([new Uint8Array(9)], 'photo.png', { type: 'image/png' }));
let releaseStream: () => void = () => undefined;
transport.streamFileToPeer.mockImplementation(() => new Promise<void>((resolve) => {
releaseStream = resolve;
}));
const firstRequest = service.handleFileRequest({ const firstRequest = service.handleFileRequest({
messageId: MESSAGE_ID, messageId: MESSAGE_ID,
fileId: FILE_ID, fileId: FILE_ID,
@@ -316,7 +327,6 @@ describe('AttachmentTransferService', () => {
fromPeerId: PEER_ID fromPeerId: PEER_ID
}); });
releaseStream();
await Promise.all([firstRequest, duplicateRequest]); await Promise.all([firstRequest, duplicateRequest]);
expect(transport.streamFileToPeer).toHaveBeenCalledTimes(1); expect(transport.streamFileToPeer).toHaveBeenCalledTimes(1);
@@ -396,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();
}); });
@@ -418,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);
@@ -443,9 +472,65 @@ 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.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled(); expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
expect(persistence.saveFileToDisk).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 () => { it('rejects oversized browser downloads before requesting peers', async () => {
@@ -483,7 +568,10 @@ describe('AttachmentTransferService', () => {
it('copies oversized generic uploads with a source path into app data when publishing', async () => { it('copies oversized generic uploads with a source path into app data when publishing', async () => {
attachmentStorage.canCopyFiles.mockReturnValue(true); attachmentStorage.canCopyFiles.mockReturnValue(true);
attachmentStorage.canPersistSize.mockReturnValue(true); attachmentStorage.canPersistSize.mockReturnValue(true);
persistence.persistUploadCopyFromSourcePath.mockResolvedValue('/appdata/server/room/files/setup.exe'); persistence.persistUploadCopyFromSourcePath.mockImplementation(async (attachment) => {
attachment.savedPath = '/appdata/server/room/files/setup.exe';
return attachment.savedPath;
});
const service = createService(); const service = createService();
const file = new File([new Uint8Array(11 * 1024 * 1024)], 'setup.exe', { type: 'application/octet-stream' }); const file = new File([new Uint8Array(11 * 1024 * 1024)], 'setup.exe', { type: 'application/octet-stream' });
@@ -536,4 +624,107 @@ describe('AttachmentTransferService', () => {
file: expect.objectContaining({ id: FILE_ID }) file: expect.objectContaining({ id: FILE_ID })
})); }));
}); });
it('requests a mirror host before the original uploader when both announced the file', async () => {
const uploaderPeer = 'uploader-peer';
const mirrorPeer = 'mirror-peer';
webrtc.getConnectedPeers.mockReturnValue([uploaderPeer, mirrorPeer]);
const service = createService();
const attachment = registerIncomingAttachment(3_000);
attachment.uploaderPeerId = uploaderPeer;
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, uploaderPeer);
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, mirrorPeer);
await service.requestFromAnyPeer(MESSAGE_ID, attachment);
expect(webrtc.sendToPeer).toHaveBeenCalledWith(mirrorPeer, expect.objectContaining({
type: 'file-request',
messageId: MESSAGE_ID,
fileId: FILE_ID
}));
});
it('records announced hosts from incoming file-announce payloads', () => {
const service = createService();
service.handleFileAnnounce({
messageId: MESSAGE_ID,
fromPeerId: 'mirror-peer',
file: {
id: FILE_ID,
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
uploaderPeerId: 'uploader-peer'
}
});
expect(runtimeStore.getAnnouncedHosts(`${MESSAGE_ID}:${FILE_ID}`).has('mirror-peer')).toBe(true);
});
it('does not register duplicate attachment metadata on repeat file-announce', () => {
const service = createService();
const announce = {
messageId: MESSAGE_ID,
fromPeerId: 'uploader-peer',
file: {
id: FILE_ID,
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
uploaderPeerId: 'uploader-peer'
}
};
expect(service.handleFileAnnounce(announce)).toBe(true);
expect(service.handleFileAnnounce(announce)).toBe(false);
expect(runtimeStore.getAttachmentsForMessage(MESSAGE_ID)).toHaveLength(1);
});
it('prefers streaming from disk over an in-memory original file when both exist', async () => {
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
const service = createService();
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
attachment.savedPath = '/appdata/server/room/files/setup.exe';
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File(['x'], 'setup.exe'));
await service.handleFileRequest({
messageId: MESSAGE_ID,
fileId: FILE_ID,
fromPeerId: 'peer-2'
});
expect(transport.streamFileFromDiskToPeer).toHaveBeenCalled();
expect(transport.streamFileToPeer).not.toHaveBeenCalled();
});
it('releases the in-memory upload copy after persisting a large generic file to disk', async () => {
attachmentStorage.canCopyFiles.mockReturnValue(true);
attachmentStorage.canPersistSize.mockReturnValue(true);
persistence.persistUploadCopyFromSourcePath.mockImplementation(async (attachment) => {
attachment.savedPath = '/appdata/server/room/files/setup.exe';
return attachment.savedPath;
});
const service = createService();
const file = new File([new Uint8Array(11 * 1024 * 1024)], 'setup.exe', { type: 'application/octet-stream' });
Object.defineProperty(file, 'path', { value: '/home/ludde/setup.exe' });
await service.publishAttachments(MESSAGE_ID, [file], PEER_ID);
const attachment = runtimeStore.getAttachmentsForMessage(MESSAGE_ID)[0];
expect(runtimeStore.getOriginalFile(`${MESSAGE_ID}:${attachment.id}`)).toBeUndefined();
expect(attachment.objectUrl).toBeUndefined();
expect(attachment.available).toBe(true);
expect(attachment.savedPath).toBe('/appdata/server/room/files/setup.exe');
});
}); });

View File

@@ -8,10 +8,11 @@ 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 { isSharingFromThisDevice, canHostAttachment } from '../../domain/logic/attachment-sharing.rules';
import { selectFileRequestPeer } from '../../domain/logic/attachment-request.rules';
import { import {
canReceiveAttachment, canReceiveAttachment,
isAttachmentMedia,
shouldCopyLargeUploaderFileToAppData, shouldCopyLargeUploaderFileToAppData,
shouldPersistDownloadedAttachment, shouldPersistDownloadedAttachment,
shouldStreamAttachmentReceiveToDisk shouldStreamAttachmentReceiveToDisk
@@ -24,7 +25,6 @@ import {
ATTACHMENT_DOWNLOAD_FAILED_KEY, ATTACHMENT_DOWNLOAD_FAILED_KEY,
ATTACHMENT_FILE_TOO_LARGE_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,
@@ -37,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,
@@ -46,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;
@@ -86,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[]> {
@@ -275,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',
@@ -302,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,
@@ -334,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 {
@@ -365,7 +378,7 @@ export class AttachmentTransferService {
} }
if (this.shouldReceiveToDisk(attachment)) { if (this.shouldReceiveToDisk(attachment)) {
this.enqueueDiskFileChunk(attachment, { void this.receiveDiskChunk(attachment, {
data, data,
fileId, fileId,
fromPeerId, fromPeerId,
@@ -377,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);
@@ -394,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> {
@@ -511,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
@@ -544,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(
@@ -630,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);
@@ -677,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)
@@ -696,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 =
@@ -745,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);
} }
/** /**
@@ -789,7 +819,7 @@ export class AttachmentTransferService {
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) { for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
for (const attachment of attachments) { for (const attachment of attachments) {
if (!isSharingFromThisDevice(attachment, currentUserId)) { if (!canHostAttachment(attachment)) {
continue; continue;
} }
@@ -799,6 +829,48 @@ export class AttachmentTransferService {
continue; 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 = { const fileAnnounceEvent: FileAnnounceEvent = {
type: 'file-announce', type: 'file-announce',
messageId: attachment.messageId, messageId: attachment.messageId,
@@ -814,8 +886,6 @@ export class AttachmentTransferService {
this.webrtc.broadcastMessage(fileAnnounceEvent); 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) {
@@ -845,31 +915,47 @@ export class AttachmentTransferService {
}; };
} }
private enqueueDiskFileChunk( private receiveDiskChunk(attachment: Attachment, payload: ValidFileChunkPayload): void {
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);
@@ -889,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));
@@ -897,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) {
@@ -905,25 +991,12 @@ export class AttachmentTransferService {
} }
attachment.savedPath = assembly.path; attachment.savedPath = assembly.path;
if (!isAttachmentMedia(attachment)) {
attachment.available = true;
this.diskReceiveAssemblies.delete(assemblyKey);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
return;
}
const restoredForDisplay = await this.persistence.ensureInlineDisplayObjectUrl(attachment);
if (!restoredForDisplay) {
throw new Error(this.appI18n.instant(ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY));
}
attachment.available = true; attachment.available = true;
attachment.objectUrl = undefined;
this.diskReceiveAssemblies.delete(assemblyKey); this.diskReceiveAssemblies.delete(assemblyKey);
this.runtimeStore.touch(); this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment); void this.persistence.persistAttachmentMeta(attachment);
void this.announceLocalHost(attachment);
} }
private async getOrCreateDiskReceiveAssembly( private async getOrCreateDiskReceiveAssembly(

View File

@@ -0,0 +1,61 @@
import {
describe,
expect,
it
} from 'vitest';
import {
buildAttachmentDisplayPinKey,
canRevokeAttachmentDisplayBlob,
shouldRevokeDisplayBlobForAttachment
} from './attachment-blob-eviction.rules';
describe('attachment-blob-eviction rules', () => {
it('builds a stable pin key from message and attachment ids', () => {
expect(buildAttachmentDisplayPinKey('msg-1', 'att-1')).toBe('msg-1:att-1');
});
it('allows revoking blob urls when a disk path can rehydrate the attachment', () => {
expect(canRevokeAttachmentDisplayBlob({
objectUrl: 'blob:http://localhost/abc',
savedPath: '/appdata/photo.png',
receivedBytes: 0,
available: true
})).toBe(true);
});
it('refuses to revoke blobs that are the only local copy', () => {
expect(canRevokeAttachmentDisplayBlob({
objectUrl: 'blob:http://localhost/abc',
receivedBytes: 0,
available: true
})).toBe(false);
});
it('refuses to revoke blobs while a download is still in progress', () => {
expect(canRevokeAttachmentDisplayBlob({
objectUrl: 'blob:http://localhost/abc',
savedPath: '/appdata/photo.png',
receivedBytes: 1024,
available: false
})).toBe(false);
});
it('skips revocation for pinned attachments', () => {
const attachment = {
id: 'att-1',
objectUrl: 'blob:http://localhost/abc',
savedPath: '/appdata/photo.png',
receivedBytes: 0,
available: true
};
expect(shouldRevokeDisplayBlobForAttachment(
'msg-1',
attachment,
new Set([buildAttachmentDisplayPinKey('msg-1', 'att-1')])
)).toBe(false);
expect(shouldRevokeDisplayBlobForAttachment('msg-1', attachment, new Set())).toBe(true);
});
});

View File

@@ -0,0 +1,50 @@
import { isBlobObjectUrl } from './attachment-display-url.rules';
/** Margin around the chat scrollport used to hydrate blobs before they enter view. */
export const ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN = '200px';
export interface AttachmentDisplayBlobCandidate {
available?: boolean;
filePath?: string;
objectUrl?: string;
receivedBytes?: number;
savedPath?: string;
}
export function buildAttachmentDisplayPinKey(messageId: string, attachmentId: string): string {
return `${messageId}:${attachmentId}`;
}
export function canRevokeAttachmentDisplayBlob(
attachment: AttachmentDisplayBlobCandidate
): boolean {
if (!attachment.objectUrl || !isBlobObjectUrl(attachment.objectUrl)) {
return false;
}
if (!hasNonEmptyString(attachment.savedPath) && !hasNonEmptyString(attachment.filePath)) {
return false;
}
if ((attachment.receivedBytes ?? 0) > 0 && attachment.available !== true) {
return false;
}
return true;
}
export function shouldRevokeDisplayBlobForAttachment(
messageId: string,
attachment: AttachmentDisplayBlobCandidate & { id: string },
pinnedKeys: ReadonlySet<string>
): boolean {
if (pinnedKeys.has(buildAttachmentDisplayPinKey(messageId, attachment.id))) {
return false;
}
return canRevokeAttachmentDisplayBlob(attachment);
}
function hasNonEmptyString(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}

View File

@@ -4,7 +4,10 @@ import {
it it
} from 'vitest'; } from 'vitest';
import { decodeBase64ToUint8Array } from './attachment-blob.rules'; import {
base64DecodedByteLength,
decodeBase64ToUint8Array
} from './attachment-blob.rules';
describe('attachment blob rules', () => { describe('attachment blob rules', () => {
it('decodes base64 payloads into byte arrays', () => { it('decodes base64 payloads into byte arrays', () => {
@@ -16,4 +19,9 @@ describe('attachment blob rules', () => {
67 67
]); ]);
}); });
it('estimates decoded base64 byte length without allocating bytes', () => {
expect(base64DecodedByteLength('QUJD')).toBe(3);
expect(base64DecodedByteLength('YQ==')).toBe(1);
});
}); });

View File

@@ -29,6 +29,13 @@ export function encodeUint8ArrayToBase64(bytes: Uint8Array): string {
return btoa(binary); return btoa(binary);
} }
/** Returns the decoded byte length of a base64 payload without allocating the bytes. */
export function base64DecodedByteLength(base64: string): number {
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
}
/** Yield control back to the browser so long attachment hydration cannot freeze Electron. */ /** Yield control back to the browser so long attachment hydration cannot freeze Electron. */
export function yieldToAttachmentHydrationLoop(): Promise<void> { export function yieldToAttachmentHydrationLoop(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {

View File

@@ -0,0 +1,13 @@
import {
describe,
expect,
it
} from 'vitest';
import { buildAttachmentChunkAckKey } from './attachment-chunk-ack.rules';
describe('attachment-chunk-ack rules', () => {
it('builds a stable ack key from message, file, and chunk index', () => {
expect(buildAttachmentChunkAckKey('msg-1', 'file-1', 42)).toBe('msg-1:file-1:42');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ describe('attachment logic', () => {
}, undefined, true)).toBe(false); }, undefined, true)).toBe(false);
}); });
it('streams oversized generic files to disk when the store supports it', () => { it('streams any persistable download to disk when the store supports streaming', () => {
const capabilities = { const capabilities = {
canStreamToDisk: true, canStreamToDisk: true,
canPersistSize: (bytes: number) => bytes <= 256 * 1024 * 1024 canPersistSize: (bytes: number) => bytes <= 256 * 1024 * 1024
@@ -69,6 +69,18 @@ describe('attachment logic', () => {
mime: 'application/zip', mime: 'application/zip',
filePath: undefined filePath: undefined
}, capabilities)).toBe(true); }, 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', () => { it('receives browser-sized files in memory when disk streaming is unavailable', () => {

View File

@@ -67,21 +67,13 @@ export function shouldStreamAttachmentReceiveToDisk(
attachment: Pick<Attachment, 'size' | 'mime' | 'filePath'>, attachment: Pick<Attachment, 'size' | 'mime' | 'filePath'>,
capabilities: AttachmentReceiveCapabilities capabilities: AttachmentReceiveCapabilities
): boolean { ): boolean {
if (attachment.filePath?.trim()) {
return false;
}
if (!capabilities.canStreamToDisk || !capabilities.canPersistSize(attachment.size)) { if (!capabilities.canStreamToDisk || !capabilities.canPersistSize(attachment.size)) {
return false; return false;
} }
if (attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
return true; return true;
} }
return isAttachmentMedia(attachment);
}
export function canReceiveAttachmentInMemory( export function canReceiveAttachmentInMemory(
attachment: Pick<Attachment, 'size'>, attachment: Pick<Attachment, 'size'>,
capabilities: AttachmentReceiveCapabilities capabilities: AttachmentReceiveCapabilities

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,11 +5,12 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, computed,
ElementRef,
effect, effect,
ElementRef,
inject, inject,
input, input,
OnDestroy, OnDestroy,
AfterViewInit,
output, output,
signal, signal,
TemplateRef, TemplateRef,
@@ -44,9 +45,11 @@ import {
} from '../../../../../attachment'; } from '../../../../../attachment';
import { import {
dedupeImageAttachmentsForDisplay, dedupeImageAttachmentsForDisplay,
isAttachmentPendingInlineHydration,
isImageAttachment, isImageAttachment,
isInlineDisplayableImage isInlineDisplayableImage
} from '../../../../../attachment/domain/logic/attachment-image.rules'; } from '../../../../../attachment/domain/logic/attachment-image.rules';
import { ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN } from '../../../../../attachment/domain/logic/attachment-blob-eviction.rules';
import { PlatformService, ViewportService } from '../../../../../../core/platform'; import { PlatformService, ViewportService } from '../../../../../../core/platform';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { ExperimentalMediaSettingsService } from '../../../../../experimental-media'; import { ExperimentalMediaSettingsService } from '../../../../../experimental-media';
@@ -168,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);
@@ -188,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());
@@ -264,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) {
@@ -279,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);
} }
}); });
@@ -501,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();
} }
@@ -772,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);
} }
@@ -882,6 +961,21 @@ export class ChatMessageItemComponent implements OnDestroy {
}; };
} }
private handleMessageVisibilityChange(isVisible: boolean): void {
if (isVisible === this.isMessageVisible()) {
return;
}
this.isMessageVisible.set(isVisible);
const messageId = this.message().id;
if (isVisible) {
return;
}
this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(messageId);
}
private getLiveAttachment(attachmentId: string): Attachment | undefined { private getLiveAttachment(attachmentId: string): Attachment | undefined {
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId); return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
} }

View File

@@ -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,7 +22,11 @@ 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';
@@ -88,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);
@@ -485,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> {
@@ -599,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;

View File

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

View File

@@ -23,6 +23,7 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { PluginRequirementService, PluginStoreService } from '../../../plugins'; import { PluginRequirementService, PluginStoreService } from '../../../plugins';
import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing'; import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import type { ServerInfo } from '../../domain/models/server-directory.model'; import type { ServerInfo } from '../../domain/models/server-directory.model';
import type { User } from '../../../../shared-kernel'; import type { User } from '../../../../shared-kernel';
@@ -100,6 +101,9 @@ function createHarness(options: HarnessOptions = {}) {
sendRawMessageToSignalUrl: vi.fn() sendRawMessageToSignalUrl: vi.fn()
} as unknown as RealtimeSessionFacade; } as unknown as RealtimeSessionFacade;
const externalLinks = { open: vi.fn() } as unknown as ExternalLinkService; const externalLinks = { open: vi.fn() } as unknown as ExternalLinkService;
const signalServerAuthorize = {
ensureCredentialForServerUrl: vi.fn(() => Promise.resolve(true))
} as unknown as SignalServerAuthorizeService;
const injector = Injector.create({ const injector = Injector.create({
providers: [ providers: [
ServerBrowserComponent, ServerBrowserComponent,
@@ -111,6 +115,7 @@ function createHarness(options: HarnessOptions = {}) {
{ provide: RealtimeSessionFacade, useValue: webrtc }, { provide: RealtimeSessionFacade, useValue: webrtc },
{ provide: PluginRequirementService, useValue: pluginRequirements }, { provide: PluginRequirementService, useValue: pluginRequirements },
{ provide: PluginStoreService, useValue: pluginStore }, { provide: PluginStoreService, useValue: pluginStore },
{ provide: SignalServerAuthorizeService, useValue: signalServerAuthorize },
...provideAppI18nForTests() ...provideAppI18nForTests()
] ]
}); });

View File

@@ -43,6 +43,18 @@
</div> </div>
<span class="font-mono text-sm text-foreground">{{ ramLabel() ?? '-' }}</span> <span class="font-mono text-sm text-foreground">{{ ramLabel() ?? '-' }}</span>
</div> </div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
(click)="exportRamDiagnostics()"
[disabled]="isExportingRamDiagnostics()"
class="rounded-lg border border-border bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
{{ isExportingRamDiagnostics()
? ('settings.debugging.exportRamDiagnosticsWorking' | translate)
: ('settings.debugging.exportRamDiagnostics' | translate) }}
</button>
</div>
<p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.ramHint' | translate }}</p> <p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.ramHint' | translate }}</p>
</section> </section>
} }

View File

@@ -21,6 +21,7 @@ import { interval } from 'rxjs';
import { startWith, switchMap } from 'rxjs/operators'; import { startWith, switchMap } from 'rxjs/operators';
import { DebuggingService } from '../../../../core/services/debugging.service'; import { DebuggingService } from '../../../../core/services/debugging.service';
import { DesktopHighMemoryAlertService } from '../../../../core/services/desktop-high-memory-alert.service';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { formatAppRamLabel } from '../../../../core/platform/electron/electron-app-metrics.rules'; import { formatAppRamLabel } from '../../../../core/platform/electron/electron-app-metrics.rules';
import { PlatformService } from '../../../../core/platform'; import { PlatformService } from '../../../../core/platform';
@@ -53,10 +54,12 @@ export class DebuggingSettingsComponent {
private readonly platform = inject(PlatformService); private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
readonly debugging = inject(DebuggingService); readonly debugging = inject(DebuggingService);
private readonly highMemoryAlert = inject(DesktopHighMemoryAlertService);
private readonly appI18n = inject(AppI18nService); private readonly appI18n = inject(AppI18nService);
readonly isElectron = this.platform.isElectron; readonly isElectron = this.platform.isElectron;
readonly ramLabel = signal<string | null>(null); readonly ramLabel = signal<string | null>(null);
readonly isExportingRamDiagnostics = signal(false);
readonly enabled = this.debugging.enabled; readonly enabled = this.debugging.enabled;
readonly isConsoleOpen = this.debugging.isConsoleOpen; readonly isConsoleOpen = this.debugging.isConsoleOpen;
readonly entryCount = computed(() => { readonly entryCount = computed(() => {
@@ -97,6 +100,20 @@ export class DebuggingSettingsComponent {
this.debugging.clear(); this.debugging.clear();
} }
async exportRamDiagnostics(): Promise<void> {
if (!this.isElectron || this.isExportingRamDiagnostics()) {
return;
}
this.isExportingRamDiagnostics.set(true);
try {
await this.highMemoryAlert.exportDiagnostics();
} finally {
this.isExportingRamDiagnostics.set(false);
}
}
private startRamPolling(): void { private startRamPolling(): void {
const api = this.electronBridge.getApi(); const api = this.electronBridge.getApi();

View File

@@ -5,14 +5,20 @@
(dismissed)="dismiss()" (dismissed)="dismiss()"
/> />
<div
class="fixed inset-0 z-[121] flex items-center justify-center px-4 pointer-events-none"
>
<div <div
appThemeNode="highMemoryAlertDialog" appThemeNode="highMemoryAlertDialog"
class="fixed inset-0 z-[121] flex items-center justify-center px-4" class="pointer-events-auto relative w-full max-w-lg rounded-xl border border-border bg-card p-4 shadow-lg"
role="alertdialog" role="alertdialog"
aria-modal="true"
[attr.aria-labelledby]="'high-memory-alert-title'" [attr.aria-labelledby]="'high-memory-alert-title'"
[attr.aria-describedby]="'high-memory-alert-description'" [attr.aria-describedby]="'high-memory-alert-description'"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
> >
<div class="relative w-full max-w-lg rounded-xl border border-border bg-card p-4 shadow-lg">
<button <button
type="button" type="button"
(click)="dismiss()" (click)="dismiss()"
@@ -33,14 +39,14 @@
id="high-memory-alert-title" id="high-memory-alert-title"
class="mt-1 pr-10 text-base font-semibold text-foreground" class="mt-1 pr-10 text-base font-semibold text-foreground"
> >
{{ 'app.highMemoryAlert.title' | translate:{ usageGb: alertService.peakUsageGb() } }} {{ alertService.titleKey() | translate:{ usageGb: alertService.peakUsageGb() } }}
</h2> </h2>
<p <p
id="high-memory-alert-description" id="high-memory-alert-description"
class="mt-2 pr-2 text-sm leading-6 text-muted-foreground" class="mt-2 pr-2 text-sm leading-6 text-muted-foreground"
> >
{{ 'app.highMemoryAlert.message' | translate }} {{ alertService.messageKey() | translate }}
</p> </p>
<p class="mt-3 break-all rounded-lg border border-border/70 bg-secondary/40 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground"> <p class="mt-3 break-all rounded-lg border border-border/70 bg-secondary/40 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground">

View File

@@ -0,0 +1,108 @@
import '@angular/compiler';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { EnvironmentInjector, Injector } from '@angular/core';
import { PerfDiagnosticsCollector } from './diagnostics.collector';
import {
bootstrapPerfDiagnostics,
registerImmediatePerfDiagCollector,
resetDiagnosticsBootstrapStateForTests
} from './diagnostics.bootstrap';
import type { PerfDiagEntry } from './diagnostics.models';
describe('diagnostics.bootstrap', () => {
let injector: EnvironmentInjector;
let collector: {
collectSample: ReturnType<typeof vi.fn>;
buildEntries: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
resetDiagnosticsBootstrapStateForTests();
collector = {
collectSample: vi.fn(() => ({
storeDomains: { attachment: 1024 },
storeBytes: { attachment: 1024 },
components: { ChatMessageItemComponent: 12 },
componentDomains: { chat: 12 },
suspectedLeaks: [],
heap: { usedJsHeapMb: 256, totalJsHeapMb: 300 },
route: '/room/test',
durationMs: 2
})),
buildEntries: vi.fn(() => ([
{
collectedAt: 1,
source: 'renderer',
type: 'store',
payload: { domains: { attachment: 1024 } }
},
{
collectedAt: 1,
source: 'renderer',
type: 'components',
payload: { domains: { chat: 12 } }
},
{
collectedAt: 1,
source: 'renderer',
type: 'heap',
payload: { usedJsHeapMb: 256 }
}
] satisfies PerfDiagEntry[]))
};
injector = Injector.create({
providers: [{ provide: PerfDiagnosticsCollector, useValue: collector }]
}) as EnvironmentInjector;
});
afterEach(() => {
resetDiagnosticsBootstrapStateForTests();
});
it('registers immediate renderer samples without perf diagnostics being enabled', () => {
registerImmediatePerfDiagCollector(injector);
const entries = globalThis.__collectPerfDiagSample?.() ?? [];
expect(entries).toHaveLength(3);
expect(entries.find((entry) => entry.type === 'store')?.payload).toEqual({
domains: { attachment: 1024 }
});
expect(entries.find((entry) => entry.type === 'components')?.payload).toEqual({
domains: { chat: 12 }
});
});
it('keeps the immediate collector registered after periodic sampling stops', async () => {
vi.useFakeTimers();
vi.stubGlobal('window', { addEventListener: vi.fn() });
registerImmediatePerfDiagCollector(injector);
const reportSample = vi.fn(async () => {
throw new Error('writer unavailable');
});
await bootstrapPerfDiagnostics({
isPerfDiagEnabled: async () => true,
reportPerfDiagSample: reportSample
}, injector);
await vi.runOnlyPendingTimersAsync();
expect(globalThis.__collectPerfDiagSample?.()).toHaveLength(3);
vi.useRealTimers();
vi.unstubAllGlobals();
});
});

View File

@@ -16,11 +16,38 @@ const SAMPLE_INTERVAL_MS = 10_000;
let started = false; let started = false;
let sampleTimer: ReturnType<typeof setInterval> | null = null; let sampleTimer: ReturnType<typeof setInterval> | null = null;
let immediateCollectorRegistered = false;
export function registerImmediatePerfDiagCollector(injector: EnvironmentInjector): void {
if (immediateCollectorRegistered) {
return;
}
let immediateSampleCollector: PerfDiagnosticsCollector | null = null;
runInInjectionContext(injector, () => {
immediateSampleCollector = inject(PerfDiagnosticsCollector);
});
globalThis.__collectPerfDiagSample = () => {
if (!immediateSampleCollector) {
return [];
}
const sample = immediateSampleCollector.collectSample();
return sample ? immediateSampleCollector.buildEntries(sample) : [];
};
immediateCollectorRegistered = true;
}
export async function bootstrapPerfDiagnostics( export async function bootstrapPerfDiagnostics(
api: ElectronApi, api: ElectronApi,
injector: EnvironmentInjector injector: EnvironmentInjector
): Promise<void> { ): Promise<void> {
registerImmediatePerfDiagCollector(injector);
const reportSample = api.reportPerfDiagSample; const reportSample = api.reportPerfDiagSample;
if (started || !api.isPerfDiagEnabled || !reportSample) { if (started || !api.isPerfDiagEnabled || !reportSample) {
@@ -41,22 +68,6 @@ export async function bootstrapPerfDiagnostics(
started = true; started = true;
let immediateSampleCollector: PerfDiagnosticsCollector | null = null;
runInInjectionContext(injector, () => {
immediateSampleCollector = inject(PerfDiagnosticsCollector);
});
globalThis.__collectPerfDiagSample = () => {
if (!immediateSampleCollector) {
return [];
}
const sample = immediateSampleCollector.collectSample();
return sample ? immediateSampleCollector.buildEntries(sample) : [];
};
const reporter: PerfDiagReporter = { const reporter: PerfDiagReporter = {
report: (entry: PerfDiagEntry) => reportSample(entry) report: (entry: PerfDiagEntry) => reportSample(entry)
}; };
@@ -113,6 +124,12 @@ function stopPerfDiagnosticsSampling(): void {
sampleTimer = null; sampleTimer = null;
} }
delete globalThis.__collectPerfDiagSample;
started = false; started = false;
} }
/** @internal Resets module state between unit tests. */
export function resetDiagnosticsBootstrapStateForTests(): void {
stopPerfDiagnosticsSampling();
immediateCollectorRegistered = false;
delete globalThis.__collectPerfDiagSample;
}

View File

@@ -161,6 +161,13 @@ export interface FileChunkChatEvent extends ChatEventBase {
data: string; data: string;
} }
export interface FileChunkAckChatEvent extends ChatEventBase {
type: 'file-chunk-ack';
messageId: string;
fileId: string;
index: number;
}
export interface FileRequestChatEvent extends ChatEventBase { export interface FileRequestChatEvent extends ChatEventBase {
type: 'file-request'; type: 'file-request';
messageId: string; messageId: string;
@@ -498,6 +505,7 @@ export type ChatEvent =
| ReactionRemovedEvent | ReactionRemovedEvent
| FileAnnounceChatEvent | FileAnnounceChatEvent
| FileChunkChatEvent | FileChunkChatEvent
| FileChunkAckChatEvent
| FileRequestChatEvent | FileRequestChatEvent
| FileCancelChatEvent | FileCancelChatEvent
| FileNotFoundChatEvent | FileNotFoundChatEvent

View File

@@ -59,6 +59,7 @@ type IncomingMessageType =
| 'chat-sync-full' | 'chat-sync-full'
| 'file-announce' | 'file-announce'
| 'file-chunk' | 'file-chunk'
| 'file-chunk-ack'
| 'file-request' | 'file-request'
| 'file-cancel' | 'file-cancel'
| 'file-not-found'; | 'file-not-found';
@@ -583,10 +584,6 @@ function handleFileAnnounce(
): Observable<Action> { ): Observable<Action> {
attachments.handleFileAnnounce(event); attachments.handleFileAnnounce(event);
if (event.messageId) {
attachments.queueAutoDownloadsForMessage(event.messageId, event.file?.id);
}
return EMPTY; return EMPTY;
} }
@@ -598,6 +595,14 @@ function handleFileChunk(
return EMPTY; return EMPTY;
} }
function handleFileChunkAck(
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileChunkAck(event);
return EMPTY;
}
function handleFileRequest( function handleFileRequest(
event: IncomingMessageEvent, event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext { attachments }: IncomingMessageContext
@@ -736,6 +741,7 @@ const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
// Attachments // Attachments
'file-announce': handleFileAnnounce, 'file-announce': handleFileAnnounce,
'file-chunk': handleFileChunk, 'file-chunk': handleFileChunk,
'file-chunk-ack': handleFileChunkAck,
'file-request': handleFileRequest, 'file-request': handleFileRequest,
'file-cancel': handleFileCancel, 'file-cancel': handleFileCancel,
'file-not-found': handleFileNotFound, 'file-not-found': handleFileNotFound,

View File

@@ -30,12 +30,19 @@ bootstrapApplication(App, appConfig)
.then(async (appRef) => { .then(async (appRef) => {
const api = getElectronApi(); const api = getElectronApi();
if (!api?.isPerfDiagEnabled) { if (!api) {
return; return;
} }
const { bootstrapPerfDiagnostics } = await import('./app/infrastructure/diagnostics/diagnostics.bootstrap'); const {
registerImmediatePerfDiagCollector,
bootstrapPerfDiagnostics
} = await import('./app/infrastructure/diagnostics/diagnostics.bootstrap');
registerImmediatePerfDiagCollector(appRef.injector);
if (api.isPerfDiagEnabled) {
await bootstrapPerfDiagnostics(api, appRef.injector); await bootstrapPerfDiagnostics(api, appRef.injector);
}
}) })
.catch((err) => console.error(err)); .catch((err) => console.error(err));