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
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:
@@ -25,7 +25,9 @@ import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
||||
import {
|
||||
attachRendererDiagnosticsHooks,
|
||||
ensurePerfDiagIpcRegistered,
|
||||
shutdownHighMemoryMonitoring,
|
||||
shutdownPerfDiagnostics,
|
||||
startHighMemoryMonitoring,
|
||||
startPerfDiagnostics
|
||||
} from '../diagnostics';
|
||||
|
||||
@@ -39,6 +41,7 @@ function startLocalApiAfterWindowReady(): void {
|
||||
|
||||
export function registerAppLifecycle(): void {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
startHighMemoryMonitoring();
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const dockIconPath = getDockIconPath();
|
||||
@@ -83,6 +86,7 @@ export function registerAppLifecycle(): void {
|
||||
|
||||
app.on('before-quit', async (event) => {
|
||||
prepareWindowForAppQuit();
|
||||
shutdownHighMemoryMonitoring();
|
||||
await shutdownPerfDiagnostics();
|
||||
|
||||
if (getDataSource()?.isInitialized) {
|
||||
|
||||
@@ -10,19 +10,21 @@ import { resolveReadablePath } from '../path-jail';
|
||||
import { sumWorkingSetKb } from './process-metrics.rules';
|
||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||
import { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
|
||||
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
|
||||
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
|
||||
import { captureHighMemoryDiagnostics } from './high-memory-capture';
|
||||
import { collectSessionContext } from './session-context.collector';
|
||||
import {
|
||||
clearHighMemoryAlert,
|
||||
readHighMemoryAlert,
|
||||
writeHighMemoryAlert
|
||||
writeHighMemoryAlert,
|
||||
type HighMemoryAlertRecord
|
||||
} from './high-memory-alert.store';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import { PerfDiagWriter } from './diagnostics.writer';
|
||||
|
||||
const PROCESS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
export const HIGH_MEMORY_ALERT_PENDING_CHANNEL = 'high-memory-alert-pending';
|
||||
|
||||
let activeWriter: PerfDiagWriter | null = null;
|
||||
let processPollTimer: NodeJS.Timeout | null = null;
|
||||
let diagnosticsEnabled = false;
|
||||
@@ -67,6 +69,24 @@ export function ensurePerfDiagIpcRegistered(): void {
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('export-high-memory-diagnostics', async () => {
|
||||
const metrics = collectAppMetricsSnapshot();
|
||||
const totalKb = sumWorkingSetKb(metrics.processes) ?? 0;
|
||||
const record = await captureHighMemoryDiagnostics({
|
||||
userDataPath: app.getPath('userData'),
|
||||
sessionStartedAt,
|
||||
metrics,
|
||||
totalWorkingSetKb: totalKb,
|
||||
writer: activeWriter,
|
||||
mainWindow: getMainWindow(),
|
||||
reason: 'manual'
|
||||
});
|
||||
|
||||
await persistAndNotifyHighMemoryAlert(record);
|
||||
|
||||
return record;
|
||||
});
|
||||
|
||||
ipcMain.handle('show-log-file-in-folder', async (_event, filePath: string) => {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) {
|
||||
return {
|
||||
@@ -94,8 +114,48 @@ export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
||||
return activeWriter;
|
||||
}
|
||||
|
||||
export function startHighMemoryMonitoring(): void {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
|
||||
if (!sessionStartedAt) {
|
||||
sessionStartedAt = Date.now();
|
||||
highMemoryAlertTriggeredThisSession = false;
|
||||
}
|
||||
|
||||
if (processPollTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sample = (): void => {
|
||||
try {
|
||||
const metrics = collectAppMetricsSnapshot();
|
||||
const totalKb = sumWorkingSetKb(metrics.processes);
|
||||
|
||||
if (activeWriter && diagnosticsEnabled) {
|
||||
activeWriter.append({
|
||||
collectedAt: metrics.collectedAt,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: {
|
||||
totalWorkingSetKb: totalKb,
|
||||
processes: metrics.processes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void maybeTriggerHighMemoryAlert(metrics, totalKb);
|
||||
} catch {
|
||||
// Collector failures must never affect the app.
|
||||
}
|
||||
};
|
||||
|
||||
sample();
|
||||
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function startPerfDiagnostics(): PerfDiagWriter | null {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
startHighMemoryMonitoring();
|
||||
diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged);
|
||||
|
||||
if (!diagnosticsEnabled) {
|
||||
@@ -109,10 +169,7 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
|
||||
});
|
||||
|
||||
activeWriter = writer;
|
||||
highMemoryAlertTriggeredThisSession = false;
|
||||
sessionStartedAt = Date.now();
|
||||
registerProcessCrashHandlers(writer);
|
||||
startProcessMetricsPolling(writer);
|
||||
|
||||
const userDataPath = app.getPath('userData');
|
||||
|
||||
@@ -188,14 +245,15 @@ export async function shutdownPerfDiagnostics(): Promise<void> {
|
||||
}
|
||||
|
||||
await activeWriter.flushSnapshot('shutdown');
|
||||
activeWriter = null;
|
||||
diagnosticsEnabled = false;
|
||||
}
|
||||
|
||||
export function shutdownHighMemoryMonitoring(): void {
|
||||
if (processPollTimer) {
|
||||
clearInterval(processPollTimer);
|
||||
processPollTimer = null;
|
||||
}
|
||||
|
||||
activeWriter = null;
|
||||
diagnosticsEnabled = false;
|
||||
}
|
||||
|
||||
function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
|
||||
@@ -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(
|
||||
writer: PerfDiagWriter,
|
||||
metrics: AppMetricsSnapshot,
|
||||
totalWorkingSetKb: number | null
|
||||
): Promise<void> {
|
||||
@@ -278,51 +309,26 @@ async function maybeTriggerHighMemoryAlert(
|
||||
|
||||
highMemoryAlertTriggeredThisSession = true;
|
||||
|
||||
const detectedAt = Date.now();
|
||||
const userDataPath = app.getPath('userData');
|
||||
const immediateRendererEntries = await collectImmediateRendererSamples(getMainWindow());
|
||||
const environment = collectSessionContext({
|
||||
const record = await captureHighMemoryDiagnostics({
|
||||
userDataPath: app.getPath('userData'),
|
||||
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,
|
||||
environment,
|
||||
mainProcessMemory: process.memoryUsage(),
|
||||
ringEntries: writer.bufferedEntries,
|
||||
immediateRendererEntries,
|
||||
sessionId: writer.sessionId
|
||||
})
|
||||
totalWorkingSetKb: totalWorkingSetKb ?? 0,
|
||||
writer: activeWriter,
|
||||
mainWindow: getMainWindow(),
|
||||
reason: 'threshold'
|
||||
});
|
||||
|
||||
await writer.flushSnapshot('high-memory-threshold');
|
||||
await persistAndNotifyHighMemoryAlert(record);
|
||||
}
|
||||
|
||||
await writeHighMemoryAlert(userDataPath, {
|
||||
logFilePath: writer.snapshotFilePath,
|
||||
detectedAt,
|
||||
peakWorkingSetKb: totalWorkingSetKb ?? 0,
|
||||
sessionId: writer.sessionId
|
||||
});
|
||||
async function persistAndNotifyHighMemoryAlert(record: HighMemoryAlertRecord): Promise<void> {
|
||||
await writeHighMemoryAlert(app.getPath('userData'), record);
|
||||
notifyHighMemoryAlert(record);
|
||||
}
|
||||
|
||||
function notifyHighMemoryAlert(record: HighMemoryAlertRecord): void {
|
||||
getMainWindow()?.webContents.send(HIGH_MEMORY_ALERT_PENDING_CHANNEL, record);
|
||||
}
|
||||
|
||||
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
||||
|
||||
@@ -33,7 +33,8 @@ describe('high-memory-alert.store', () => {
|
||||
logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'),
|
||||
detectedAt: 1_700_000_000_000,
|
||||
peakWorkingSetKb: 2_200_000,
|
||||
sessionId: 'session-1'
|
||||
sessionId: 'session-1',
|
||||
reason: 'threshold' as const
|
||||
};
|
||||
|
||||
await writeHighMemoryAlert(userDataPath, record);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export type HighMemoryAlertReason = 'manual' | 'threshold';
|
||||
|
||||
export interface HighMemoryAlertRecord {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: HighMemoryAlertReason;
|
||||
}
|
||||
|
||||
export function resolveHighMemoryAlertPath(userDataPath: string): string {
|
||||
@@ -31,7 +34,10 @@ export async function readHighMemoryAlert(userDataPath: string): Promise<HighMem
|
||||
logFilePath: parsed.logFilePath,
|
||||
detectedAt: parsed.detectedAt,
|
||||
peakWorkingSetKb: parsed.peakWorkingSetKb,
|
||||
sessionId: parsed.sessionId
|
||||
sessionId: parsed.sessionId,
|
||||
...(parsed.reason === 'manual' || parsed.reason === 'threshold'
|
||||
? { reason: parsed.reason }
|
||||
: {})
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
57
electron/diagnostics/high-memory-capture.spec.ts
Normal file
57
electron/diagnostics/high-memory-capture.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as fsp from 'fs/promises';
|
||||
|
||||
import { captureHighMemoryDiagnostics } from './high-memory-capture';
|
||||
|
||||
vi.mock('./immediate-renderer-samples.collector', () => ({
|
||||
collectImmediateRendererSamples: vi.fn(async () => [])
|
||||
}));
|
||||
|
||||
vi.mock('./session-context.collector', () => ({
|
||||
collectSessionContext: vi.fn(() => ({
|
||||
platform: 'linux',
|
||||
userDataPath: '/tmp/user-data'
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('captureHighMemoryDiagnostics', () => {
|
||||
let userDataPath = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-capture-'));
|
||||
});
|
||||
|
||||
it('writes a diagnostics snapshot and returns an alert record', async () => {
|
||||
const record = await captureHighMemoryDiagnostics({
|
||||
userDataPath,
|
||||
sessionStartedAt: Date.now() - 60_000,
|
||||
metrics: {
|
||||
collectedAt: Date.now(),
|
||||
processes: [
|
||||
{
|
||||
pid: 1,
|
||||
type: 'Browser',
|
||||
workingSetKb: 2_200_000
|
||||
}
|
||||
]
|
||||
},
|
||||
totalWorkingSetKb: 2_200_000,
|
||||
writer: null,
|
||||
mainWindow: null,
|
||||
reason: 'manual'
|
||||
});
|
||||
|
||||
expect(record.peakWorkingSetKb).toBe(2_200_000);
|
||||
expect(record.reason).toBe('manual');
|
||||
expect(record.logFilePath).toContain(userDataPath);
|
||||
await expect(fsp.stat(record.logFilePath)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
80
electron/diagnostics/high-memory-capture.ts
Normal file
80
electron/diagnostics/high-memory-capture.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { AppMetricsSnapshot } from '../app-metrics';
|
||||
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
|
||||
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
|
||||
import { collectSessionContext } from './session-context.collector';
|
||||
import type { HighMemoryAlertRecord } from './high-memory-alert.store';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import { PerfDiagWriter } from './diagnostics.writer';
|
||||
|
||||
export type HighMemoryCaptureReason = 'manual' | 'threshold';
|
||||
|
||||
export interface CaptureHighMemoryDiagnosticsInput {
|
||||
userDataPath: string;
|
||||
sessionStartedAt: number;
|
||||
metrics: AppMetricsSnapshot;
|
||||
totalWorkingSetKb: number;
|
||||
writer: PerfDiagWriter | null;
|
||||
mainWindow: BrowserWindow | null;
|
||||
reason: HighMemoryCaptureReason;
|
||||
}
|
||||
|
||||
export async function captureHighMemoryDiagnostics(
|
||||
input: CaptureHighMemoryDiagnosticsInput
|
||||
): Promise<HighMemoryAlertRecord> {
|
||||
const detectedAt = Date.now();
|
||||
const writer = input.writer ?? new PerfDiagWriter({
|
||||
userDataPath: input.userDataPath,
|
||||
sessionId: `${input.reason}-${detectedAt.toString(36)}-${process.pid}`
|
||||
});
|
||||
const immediateRendererEntries = await collectImmediateRendererSamples(input.mainWindow);
|
||||
const environment = collectSessionContext({
|
||||
sessionStartedAt: input.sessionStartedAt,
|
||||
userDataPath: input.userDataPath
|
||||
});
|
||||
|
||||
appendEntries(writer, immediateRendererEntries);
|
||||
appendEntries(writer, [
|
||||
{
|
||||
collectedAt: detectedAt,
|
||||
source: 'main',
|
||||
type: 'environment',
|
||||
payload: {
|
||||
...environment
|
||||
}
|
||||
},
|
||||
{
|
||||
collectedAt: detectedAt,
|
||||
source: 'main',
|
||||
type: 'high-memory',
|
||||
payload: buildHighMemoryDiagnosticPayload({
|
||||
detectedAt,
|
||||
totalWorkingSetKb: input.totalWorkingSetKb,
|
||||
metrics: input.metrics,
|
||||
environment,
|
||||
mainProcessMemory: process.memoryUsage(),
|
||||
ringEntries: writer.bufferedEntries,
|
||||
immediateRendererEntries,
|
||||
sessionId: writer.sessionId
|
||||
})
|
||||
}
|
||||
]);
|
||||
|
||||
await writer.flushSnapshot(
|
||||
input.reason === 'manual' ? 'manual-export' : 'high-memory-threshold'
|
||||
);
|
||||
|
||||
return {
|
||||
logFilePath: writer.snapshotFilePath,
|
||||
detectedAt,
|
||||
peakWorkingSetKb: input.totalWorkingSetKb,
|
||||
sessionId: writer.sessionId,
|
||||
reason: input.reason
|
||||
};
|
||||
}
|
||||
|
||||
function appendEntries(writer: PerfDiagWriter, entries: readonly PerfDiagEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
writer.append(entry);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,11 @@ export {
|
||||
attachRendererDiagnosticsHooks,
|
||||
ensurePerfDiagIpcRegistered,
|
||||
getActivePerfDiagWriter,
|
||||
HIGH_MEMORY_ALERT_PENDING_CHANNEL,
|
||||
isPerfDiagActive,
|
||||
shutdownHighMemoryMonitoring,
|
||||
shutdownPerfDiagnostics,
|
||||
startHighMemoryMonitoring,
|
||||
startPerfDiagnostics
|
||||
} from './diagnostics.lifecycle';
|
||||
export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models';
|
||||
|
||||
16
electron/ipc/file-read.rules.spec.ts
Normal file
16
electron/ipc/file-read.rules.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { isReadableRegularFile } from './file-read.rules';
|
||||
|
||||
describe('file-read.rules', () => {
|
||||
it('accepts regular files', () => {
|
||||
expect(isReadableRegularFile({ isFile: () => true })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects directories and other non-file paths', () => {
|
||||
expect(isReadableRegularFile({ isFile: () => false })).toBe(false);
|
||||
});
|
||||
});
|
||||
6
electron/ipc/file-read.rules.ts
Normal file
6
electron/ipc/file-read.rules.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Stats } from 'fs';
|
||||
|
||||
/** Only regular files can be read through the read-file IPC surface. */
|
||||
export function isReadableRegularFile(stats: Pick<Stats, 'isFile'>): boolean {
|
||||
return stats.isFile();
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
grantPluginReadRoot,
|
||||
resolveReadablePath
|
||||
} from '../path-jail';
|
||||
import { isReadableRegularFile } from './file-read.rules';
|
||||
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
|
||||
@@ -654,9 +655,19 @@ export function setupSystemHandlers(): void {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fsp.stat(scopedPath);
|
||||
|
||||
if (!isReadableRegularFile(stats)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await fsp.readFile(scopedPath);
|
||||
|
||||
return data.toString('base64');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
|
||||
@@ -666,6 +677,13 @@ export function setupSystemHandlers(): void {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fsp.stat(scopedPath);
|
||||
|
||||
if (!isReadableRegularFile(stats)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileHandle = await fsp.open(scopedPath, 'r');
|
||||
|
||||
try {
|
||||
@@ -678,6 +696,9 @@ export function setupSystemHandlers(): void {
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
|
||||
@@ -728,6 +749,17 @@ export function setupSystemHandlers(): void {
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('append-file-bytes', async (_event, filePath: string, bytes: Uint8Array) => {
|
||||
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||
|
||||
if (!scopedPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await fsp.appendFile(scopedPath, Buffer.from(bytes));
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-file', async (_event, filePath: string) => {
|
||||
const scopedPath = await resolveWritableUserDataFilePath(filePath);
|
||||
|
||||
|
||||
@@ -35,6 +35,17 @@ describe('path-jail', () => {
|
||||
await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath);
|
||||
});
|
||||
|
||||
it('accepts diagnostics log paths under diagnostics', async () => {
|
||||
const diagnosticsDir = path.join(tempRoot, 'diagnostics');
|
||||
|
||||
fs.mkdirSync(diagnosticsDir, { recursive: true });
|
||||
const logPath = path.join(diagnosticsDir, 'perf-session.jsonl');
|
||||
|
||||
fs.writeFileSync(logPath, '{}');
|
||||
|
||||
await expect(assertPathUnderRoot(tempRoot, logPath)).resolves.toBe(logPath);
|
||||
});
|
||||
|
||||
it('accepts cached plugin bundle paths under plugin-bundles', async () => {
|
||||
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0');
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ export const DEFAULT_USER_DATA_SUBDIRS = [
|
||||
'plugin-bundles',
|
||||
'plugin-cache',
|
||||
'themes',
|
||||
'metoyou'
|
||||
'metoyou',
|
||||
'diagnostics'
|
||||
] as const;
|
||||
|
||||
export function isPathInside(parentPath: string, candidatePath: string): boolean {
|
||||
|
||||
@@ -11,6 +11,7 @@ const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
||||
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
|
||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
|
||||
const HIGH_MEMORY_ALERT_PENDING_CHANNEL = 'high-memory-alert-pending';
|
||||
|
||||
export interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
@@ -264,8 +265,23 @@ export interface ElectronAPI {
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: 'manual' | 'threshold';
|
||||
} | null>;
|
||||
acknowledgeHighMemoryAlert: () => Promise<boolean>;
|
||||
exportHighMemoryDiagnostics: () => Promise<{
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: 'manual' | 'threshold';
|
||||
}>;
|
||||
onHighMemoryAlertPending: (listener: (alert: {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: 'manual' | 'threshold';
|
||||
}) => void) => () => void;
|
||||
showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
@@ -335,6 +351,7 @@ export interface ElectronAPI {
|
||||
grantPluginReadRoot: (rootPath: string) => Promise<boolean>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFileBytes: (filePath: string, data: Uint8Array) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
saveExistingFileAs: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
openFilePath: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
|
||||
@@ -410,6 +427,23 @@ const electronAPI: ElectronAPI = {
|
||||
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
||||
getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'),
|
||||
acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-high-memory-alert'),
|
||||
exportHighMemoryDiagnostics: () => ipcRenderer.invoke('export-high-memory-diagnostics'),
|
||||
onHighMemoryAlertPending: (listener) => {
|
||||
const wrappedListener = (_event: Electron.IpcRendererEvent, alert: {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
}) => {
|
||||
listener(alert);
|
||||
};
|
||||
|
||||
ipcRenderer.on(HIGH_MEMORY_ALERT_PENDING_CHANNEL, wrappedListener);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(HIGH_MEMORY_ALERT_PENDING_CHANNEL, wrappedListener);
|
||||
};
|
||||
},
|
||||
showLogFileInFolder: (filePath) => ipcRenderer.invoke('show-log-file-in-folder', filePath),
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||
@@ -478,6 +512,7 @@ const electronAPI: ElectronAPI = {
|
||||
grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath),
|
||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
|
||||
appendFileBytes: (filePath, data) => ipcRenderer.invoke('append-file-bytes', filePath, data),
|
||||
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
|
||||
saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName),
|
||||
openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath),
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
},
|
||||
"highMemoryAlert": {
|
||||
"badge": "High memory usage",
|
||||
"title": "The app used {{usageGb}} GB of RAM last session",
|
||||
"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.",
|
||||
"thresholdTitle": "The app is using {{usageGb}} GB of RAM",
|
||||
"thresholdMessage": "MetoYou crossed the 2 GB memory threshold. A diagnostics log was saved so you can inspect what was using memory or share it with support.",
|
||||
"manualTitle": "RAM diagnostics exported ({{usageGb}} GB in use)",
|
||||
"manualMessage": "A snapshot of current memory usage was saved. Open the log, reveal it in your file manager, or copy the path to share with support.",
|
||||
"openLog": "Open log file",
|
||||
"showInFolder": "Show in folder",
|
||||
"copyPath": "Copy path",
|
||||
|
||||
@@ -441,7 +441,9 @@
|
||||
"title": "App-wide debugging",
|
||||
"description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.",
|
||||
"processRam": "Process RAM",
|
||||
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds.",
|
||||
"exportRamDiagnostics": "Export RAM diagnostics",
|
||||
"exportRamDiagnosticsWorking": "Exporting...",
|
||||
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds. Export saves a diagnostics log and opens the high-memory alert dialog.",
|
||||
"capturedEvents": "Captured events",
|
||||
"lastUpdate": "Last update: {{label}}",
|
||||
"noLogsYet": "No logs yet",
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
},
|
||||
"highMemoryAlert": {
|
||||
"badge": "High memory usage",
|
||||
"title": "The app used {{usageGb}} GB of RAM last session",
|
||||
"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.",
|
||||
"thresholdTitle": "The app is using {{usageGb}} GB of RAM",
|
||||
"thresholdMessage": "MetoYou crossed the 2 GB memory threshold. A diagnostics log was saved so you can inspect what was using memory or share it with support.",
|
||||
"manualTitle": "RAM diagnostics exported ({{usageGb}} GB in use)",
|
||||
"manualMessage": "A snapshot of current memory usage was saved. Open the log, reveal it in your file manager, or copy the path to share with support.",
|
||||
"openLog": "Open log file",
|
||||
"showInFolder": "Show in folder",
|
||||
"copyPath": "Copy path",
|
||||
@@ -1507,7 +1509,9 @@
|
||||
"title": "App-wide debugging",
|
||||
"description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.",
|
||||
"processRam": "Process RAM",
|
||||
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds.",
|
||||
"exportRamDiagnostics": "Export RAM diagnostics",
|
||||
"exportRamDiagnosticsWorking": "Exporting...",
|
||||
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds. Export saves a diagnostics log and opens the high-memory alert dialog.",
|
||||
"capturedEvents": "Captured events",
|
||||
"lastUpdate": "Last update: {{label}}",
|
||||
"noLogsYet": "No logs yet",
|
||||
|
||||
@@ -256,6 +256,7 @@ export interface ElectronHighMemoryAlertRecord {
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: 'manual' | 'threshold';
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
@@ -281,6 +282,8 @@ export interface ElectronApi {
|
||||
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
|
||||
getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>;
|
||||
acknowledgeHighMemoryAlert?: () => Promise<boolean>;
|
||||
exportHighMemoryDiagnostics?: () => Promise<ElectronHighMemoryAlertRecord>;
|
||||
onHighMemoryAlertPending?: (listener: (alert: ElectronHighMemoryAlertRecord) => void) => () => void;
|
||||
showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
@@ -319,6 +322,7 @@ export interface ElectronApi {
|
||||
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
appendFileBytes: (filePath: string, data: Uint8Array) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
|
||||
|
||||
@@ -4,20 +4,22 @@ import { ElectronBridgeService } from './electron/electron-bridge.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlatformService {
|
||||
readonly isElectron: boolean;
|
||||
readonly isCapacitor: boolean;
|
||||
readonly isBrowser: boolean;
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
constructor() {
|
||||
this.isElectron = this.electronBridge.isAvailable;
|
||||
|
||||
const isElectron = this.electronBridge.isAvailable;
|
||||
const runtime = detectRuntimePlatform({
|
||||
hasElectronApi: this.isElectron,
|
||||
hasElectronApi: isElectron,
|
||||
capacitorIsNative: isCapacitorNativeRuntime()
|
||||
});
|
||||
|
||||
this.isCapacitor = runtime === 'capacitor';
|
||||
this.isBrowser = runtime === 'browser';
|
||||
}
|
||||
|
||||
get isElectron(): boolean {
|
||||
return this.electronBridge.isAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import '@angular/compiler';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
|
||||
import { DesktopHighMemoryAlertService } from './desktop-high-memory-alert.service';
|
||||
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||
|
||||
describe('DesktopHighMemoryAlertService', () => {
|
||||
let electronBridge: {
|
||||
isAvailable: boolean;
|
||||
getApi: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let documentStub: Document;
|
||||
|
||||
beforeEach(() => {
|
||||
documentStub = {
|
||||
body: null,
|
||||
createElement: vi.fn(),
|
||||
execCommand: vi.fn(() => true)
|
||||
} as unknown as Document;
|
||||
|
||||
electronBridge = {
|
||||
isAvailable: true,
|
||||
getApi: vi.fn(() => ({
|
||||
getPendingHighMemoryAlert: vi.fn(async () => ({
|
||||
logFilePath: '/tmp/diagnostics/session.ndjson',
|
||||
detectedAt: 1,
|
||||
peakWorkingSetKb: 2_200_000,
|
||||
sessionId: 'session-1'
|
||||
})),
|
||||
onHighMemoryAlertPending: vi.fn(() => () => undefined),
|
||||
exportHighMemoryDiagnostics: vi.fn(async () => ({
|
||||
logFilePath: '/tmp/diagnostics/manual.ndjson',
|
||||
detectedAt: 2,
|
||||
peakWorkingSetKb: 1_800_000,
|
||||
sessionId: 'session-2',
|
||||
reason: 'manual' as const
|
||||
})),
|
||||
acknowledgeHighMemoryAlert: vi.fn(async () => true)
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
function createService(): DesktopHighMemoryAlertService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
DesktopHighMemoryAlertService,
|
||||
{ provide: ElectronBridgeService, useValue: electronBridge },
|
||||
{ provide: DOCUMENT, useValue: documentStub }
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(DesktopHighMemoryAlertService));
|
||||
}
|
||||
|
||||
it('loads a pending alert from disk on initialize', async () => {
|
||||
const service = createService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/session.ndjson');
|
||||
expect(service.peakUsageGb()).toBe('2.10');
|
||||
});
|
||||
|
||||
it('shows the modal when a live high-memory alert event arrives', async () => {
|
||||
let listener: ((alert: {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
}) => void) | undefined;
|
||||
|
||||
electronBridge.getApi = vi.fn(() => ({
|
||||
getPendingHighMemoryAlert: vi.fn(async () => null),
|
||||
onHighMemoryAlertPending: vi.fn((callback) => {
|
||||
listener = callback;
|
||||
return () => undefined;
|
||||
}),
|
||||
exportHighMemoryDiagnostics: vi.fn(async () => null),
|
||||
acknowledgeHighMemoryAlert: vi.fn(async () => true)
|
||||
}));
|
||||
|
||||
const service = createService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
listener?.({
|
||||
logFilePath: '/tmp/diagnostics/live.ndjson',
|
||||
detectedAt: 3,
|
||||
peakWorkingSetKb: 2_400_000,
|
||||
sessionId: 'session-3'
|
||||
});
|
||||
|
||||
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/live.ndjson');
|
||||
});
|
||||
|
||||
it('exports diagnostics manually and opens the modal with manual copy', async () => {
|
||||
const service = createService();
|
||||
|
||||
await expect(service.exportDiagnostics()).resolves.toBe(true);
|
||||
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/manual.ndjson');
|
||||
expect(service.pendingAlert()?.reason).toBe('manual');
|
||||
expect(service.titleKey()).toBe('app.highMemoryAlert.manualTitle');
|
||||
expect(service.messageKey()).toBe('app.highMemoryAlert.manualMessage');
|
||||
});
|
||||
|
||||
it('uses threshold copy for live high-memory alerts', async () => {
|
||||
let listener: ((alert: {
|
||||
logFilePath: string;
|
||||
detectedAt: number;
|
||||
peakWorkingSetKb: number;
|
||||
sessionId: string;
|
||||
reason?: 'manual' | 'threshold';
|
||||
}) => void) | undefined;
|
||||
|
||||
electronBridge.getApi = vi.fn(() => ({
|
||||
getPendingHighMemoryAlert: vi.fn(async () => null),
|
||||
onHighMemoryAlertPending: vi.fn((callback) => {
|
||||
listener = callback;
|
||||
return () => undefined;
|
||||
}),
|
||||
exportHighMemoryDiagnostics: vi.fn(async () => null),
|
||||
acknowledgeHighMemoryAlert: vi.fn(async () => true)
|
||||
}));
|
||||
|
||||
const service = createService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
listener?.({
|
||||
logFilePath: '/tmp/diagnostics/live.ndjson',
|
||||
detectedAt: 3,
|
||||
peakWorkingSetKb: 2_400_000,
|
||||
sessionId: 'session-3',
|
||||
reason: 'threshold'
|
||||
});
|
||||
|
||||
expect(service.titleKey()).toBe('app.highMemoryAlert.thresholdTitle');
|
||||
expect(service.messageKey()).toBe('app.highMemoryAlert.thresholdMessage');
|
||||
});
|
||||
|
||||
it('copies the diagnostics log path to the clipboard', async () => {
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText }
|
||||
});
|
||||
|
||||
const service = createService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
await expect(service.copyLogPath()).resolves.toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith('/tmp/diagnostics/session.ndjson');
|
||||
});
|
||||
});
|
||||
@@ -4,16 +4,21 @@ import {
|
||||
inject,
|
||||
signal
|
||||
} 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 type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
|
||||
import { formatKilobytesAsGigabytes } from '../platform/electron/electron-app-metrics.rules';
|
||||
import {
|
||||
resolveHighMemoryAlertCopyKind,
|
||||
resolveHighMemoryAlertMessageKey,
|
||||
resolveHighMemoryAlertTitleKey
|
||||
} from './high-memory-alert-copy.rules';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DesktopHighMemoryAlertService {
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
readonly pendingAlert = signal<ElectronHighMemoryAlertRecord | null>(null);
|
||||
|
||||
@@ -23,24 +28,55 @@ export class DesktopHighMemoryAlertService {
|
||||
return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null;
|
||||
});
|
||||
|
||||
readonly titleKey = computed(() => resolveHighMemoryAlertTitleKey(
|
||||
resolveHighMemoryAlertCopyKind(this.pendingAlert())
|
||||
));
|
||||
|
||||
readonly messageKey = computed(() => resolveHighMemoryAlertMessageKey(
|
||||
resolveHighMemoryAlertCopyKind(this.pendingAlert())
|
||||
));
|
||||
|
||||
private initialized = false;
|
||||
private removePendingListener: (() => void) | null = null;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.platform.isElectron) {
|
||||
if (!this.electronBridge.isAvailable || this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (!api?.getPendingHighMemoryAlert) {
|
||||
if (!api) {
|
||||
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) {
|
||||
this.pendingAlert.set(alert);
|
||||
}
|
||||
}
|
||||
|
||||
async exportDiagnostics(): Promise<boolean> {
|
||||
const api = this.electronBridge.getApi();
|
||||
const alert = await api?.exportHighMemoryDiagnostics?.();
|
||||
|
||||
if (!alert) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.pendingAlert.set(alert);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async dismiss(): Promise<void> {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
@@ -70,13 +106,49 @@ export class DesktopHighMemoryAlertService {
|
||||
await api.showLogFileInFolder(alert.logFilePath);
|
||||
}
|
||||
|
||||
async copyLogPath(): Promise<void> {
|
||||
async copyLogPath(): Promise<boolean> {
|
||||
const alert = this.pendingAlert();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
resolveHighMemoryAlertCopyKind,
|
||||
resolveHighMemoryAlertMessageKey,
|
||||
resolveHighMemoryAlertTitleKey
|
||||
} from './high-memory-alert-copy.rules';
|
||||
|
||||
describe('high-memory-alert-copy.rules', () => {
|
||||
it('uses threshold copy for live alerts and legacy records without a reason', () => {
|
||||
expect(resolveHighMemoryAlertCopyKind({
|
||||
logFilePath: '/tmp/log.jsonl',
|
||||
detectedAt: 1,
|
||||
peakWorkingSetKb: 2_100_000,
|
||||
sessionId: 'session-1'
|
||||
})).toBe('threshold');
|
||||
|
||||
expect(resolveHighMemoryAlertCopyKind({
|
||||
logFilePath: '/tmp/log.jsonl',
|
||||
detectedAt: 1,
|
||||
peakWorkingSetKb: 2_100_000,
|
||||
sessionId: 'session-1',
|
||||
reason: 'threshold'
|
||||
})).toBe('threshold');
|
||||
});
|
||||
|
||||
it('uses manual copy for exported diagnostics', () => {
|
||||
expect(resolveHighMemoryAlertCopyKind({
|
||||
logFilePath: '/tmp/log.jsonl',
|
||||
detectedAt: 1,
|
||||
peakWorkingSetKb: 1_800_000,
|
||||
sessionId: 'session-2',
|
||||
reason: 'manual'
|
||||
})).toBe('manual');
|
||||
});
|
||||
|
||||
it('maps copy kinds to translation keys', () => {
|
||||
expect(resolveHighMemoryAlertTitleKey('threshold')).toBe('app.highMemoryAlert.thresholdTitle');
|
||||
expect(resolveHighMemoryAlertTitleKey('manual')).toBe('app.highMemoryAlert.manualTitle');
|
||||
expect(resolveHighMemoryAlertMessageKey('threshold')).toBe('app.highMemoryAlert.thresholdMessage');
|
||||
expect(resolveHighMemoryAlertMessageKey('manual')).toBe('app.highMemoryAlert.manualMessage');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
|
||||
|
||||
export type HighMemoryAlertCopyKind = 'threshold' | 'manual';
|
||||
|
||||
export function resolveHighMemoryAlertCopyKind(
|
||||
alert: ElectronHighMemoryAlertRecord | null | undefined
|
||||
): HighMemoryAlertCopyKind {
|
||||
return alert?.reason === 'manual' ? 'manual' : 'threshold';
|
||||
}
|
||||
|
||||
export function resolveHighMemoryAlertTitleKey(kind: HighMemoryAlertCopyKind): string {
|
||||
return kind === 'manual'
|
||||
? 'app.highMemoryAlert.manualTitle'
|
||||
: 'app.highMemoryAlert.thresholdTitle';
|
||||
}
|
||||
|
||||
export function resolveHighMemoryAlertMessageKey(kind: HighMemoryAlertCopyKind): string {
|
||||
return kind === 'manual'
|
||||
? 'app.highMemoryAlert.manualMessage'
|
||||
: 'app.highMemoryAlert.thresholdMessage';
|
||||
}
|
||||
@@ -107,12 +107,15 @@ Concurrent triggers (file-announce, message sync, peer connect) can race to requ
|
||||
|
||||
- **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window.
|
||||
- **Sender:** `handleFileRequest` / `fulfillRequestWithFile` track active outbound streams per `(messageId, fileId, peerId)` and ignore duplicate requests while a stream is in flight. A fresh `file-request` clears any earlier `file-cancel` marker from that peer.
|
||||
- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped.
|
||||
- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped. When the active store supports streaming (`canStreamToDisk`), **all** persistable downloads append directly to disk — metadata `filePath` does not force an in-memory assembly fallback. Disk-streamed receives decode each chunk once, append bytes through Electron IPC (`append-file-bytes`), and acknowledge the sender with `file-chunk-ack` so only one chunk is in flight at a time (preventing unbounded base64 retention in the renderer). Completed media stays on `savedPath` until inline display hydration runs on demand.
|
||||
- **Sender:** after each `file-chunk` the transport awaits the matching `file-chunk-ack` before sending the next chunk, in addition to data-channel bufferedAmount back-pressure.
|
||||
|
||||
### Failure handling
|
||||
|
||||
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
|
||||
|
||||
Peers that finish downloading a file re-announce it and register themselves as mirror hosts. New download requests prefer mirror hosts over the original uploader so the sharer's device is not the only upload source. Repeat `file-announce` events for already-known attachments update the host list but do not re-trigger auto-download.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant R as Receiver
|
||||
@@ -155,6 +158,7 @@ An optional experimental VLC.js adapter can be enabled from General settings. Wh
|
||||
|
||||
- `isUploaderUser(attachment, currentUserId)` — the current user is the uploader (same user, any device).
|
||||
- `deviceHasLocalCopy(attachment)` — this device physically holds the bytes (`available` + a blob `objectUrl`, or a non-empty `savedPath`/`filePath`). Synced metadata alone does not count, because P2P/account sync strips local paths.
|
||||
- `canHostAttachment(attachment)` — alias of `deviceHasLocalCopy`; any peer with local bytes can serve downloads.
|
||||
- `isSharingFromThisDevice(attachment, currentUserId)` — `isUploaderUser && deviceHasLocalCopy`. Only this returns the "Shared from your device" state.
|
||||
|
||||
The chat message item renders "Shared from your device" (and hides the request/download affordance) **only** when `isSharingFromThisDevice` is true. A second device of the same user that merely synced the message metadata is the uploader-user but holds no local copy, so it falls back to the normal recipient flow (request/download) instead of falsely claiming ownership and blocking the file (regression: the old check used `uploaderPeerId === currentUserId` and so claimed ownership on every device of the uploader). The transfer service uses the same rule to decide whether a no-peers failure should read "your original upload is missing" (sharing device) or "no connected peers" (any other device).
|
||||
@@ -195,3 +199,14 @@ Room and conversation names are sanitised to remove filesystem-unsafe characters
|
||||
- **cancellations**: IDs of transfers the user cancelled
|
||||
|
||||
Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service.
|
||||
|
||||
### Display blob lifecycle (memory)
|
||||
|
||||
Image inline previews on Electron/desktop use renderer `blob:` URLs rebuilt from disk. To cap RAM in media-heavy channels:
|
||||
|
||||
- **Room restore** (`restoreLocalAttachmentsForRoom`) resolves `savedPath` for hosting only — it does not hydrate every image blob up front.
|
||||
- **Visibility** (`ChatMessageItemComponent` + `IntersectionObserver` on the chat scrollport) hydrates blobs when a message enters view (with `ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN`) and revokes them when it leaves, as long as a disk path can rehydrate later (`canRevokeAttachmentDisplayBlob`).
|
||||
- **Pinned overlays** (lightbox / image gallery) call `pinDisplayBlobs` so an open full-screen view is not revoked while its message scrolls off-screen.
|
||||
- **Serving** is unaffected: peers still download from `savedPath` / `filePath`; blob URLs are display-only.
|
||||
|
||||
While a revoked image waits to rehydrate, chat renders the existing image-grid spinner skeleton (`isAttachmentPendingInlineHydration`).
|
||||
|
||||
@@ -75,6 +75,24 @@ export class AttachmentFacade {
|
||||
return this.manager.tryRestoreAttachmentFromLocal(...args);
|
||||
}
|
||||
|
||||
pinDisplayBlobs(
|
||||
...args: Parameters<AttachmentManagerService['pinDisplayBlobs']>
|
||||
): ReturnType<AttachmentManagerService['pinDisplayBlobs']> {
|
||||
return this.manager.pinDisplayBlobs(...args);
|
||||
}
|
||||
|
||||
unpinDisplayBlobs(
|
||||
...args: Parameters<AttachmentManagerService['unpinDisplayBlobs']>
|
||||
): ReturnType<AttachmentManagerService['unpinDisplayBlobs']> {
|
||||
return this.manager.unpinDisplayBlobs(...args);
|
||||
}
|
||||
|
||||
revokeOffscreenDisplayBlobsForMessage(
|
||||
...args: Parameters<AttachmentManagerService['revokeOffscreenDisplayBlobsForMessage']>
|
||||
): ReturnType<AttachmentManagerService['revokeOffscreenDisplayBlobsForMessage']> {
|
||||
return this.manager.revokeOffscreenDisplayBlobsForMessage(...args);
|
||||
}
|
||||
|
||||
requestFile(
|
||||
...args: Parameters<AttachmentManagerService['requestFile']>
|
||||
): ReturnType<AttachmentManagerService['requestFile']> {
|
||||
@@ -99,6 +117,12 @@ export class AttachmentFacade {
|
||||
return this.manager.handleFileChunk(...args);
|
||||
}
|
||||
|
||||
handleFileChunkAck(
|
||||
...args: Parameters<AttachmentManagerService['handleFileChunkAck']>
|
||||
): ReturnType<AttachmentManagerService['handleFileChunkAck']> {
|
||||
return this.manager.handleFileChunkAck(...args);
|
||||
}
|
||||
|
||||
handleFileRequest(
|
||||
...args: Parameters<AttachmentManagerService['handleFileRequest']>
|
||||
): ReturnType<AttachmentManagerService['handleFileRequest']> {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
|
||||
|
||||
describe('AttachmentChunkAckService', () => {
|
||||
let service: AttachmentChunkAckService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new AttachmentChunkAckService();
|
||||
});
|
||||
|
||||
it('resolves a waiter when the matching chunk ack arrives', async () => {
|
||||
const waitPromise = service.waitForAck('msg-1', 'file-1', 0, 1_000);
|
||||
|
||||
service.resolveAck('msg-1', 'file-1', 0);
|
||||
|
||||
await expect(waitPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('times out when no ack arrives', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const waitPromise = service.waitForAck('msg-1', 'file-1', 1, 50);
|
||||
|
||||
vi.advanceTimersByTime(51);
|
||||
|
||||
await expect(waitPromise).rejects.toThrow('attachment chunk ack timeout');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { buildAttachmentChunkAckKey } from '../../domain/logic/attachment-chunk-ack.rules';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentChunkAckService {
|
||||
private readonly waiters = new Map<string, () => void>();
|
||||
|
||||
waitForAck(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
index: number,
|
||||
timeoutMs = 60_000
|
||||
): Promise<void> {
|
||||
const key = buildAttachmentChunkAckKey(messageId, fileId, index);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.waiters.delete(key);
|
||||
reject(new Error('attachment chunk ack timeout'));
|
||||
}, timeoutMs);
|
||||
|
||||
this.waiters.set(key, () => {
|
||||
clearTimeout(timer);
|
||||
this.waiters.delete(key);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
resolveAck(messageId: string, fileId: string, index: number): void {
|
||||
this.waiters.get(buildAttachmentChunkAckKey(messageId, fileId, index))?.();
|
||||
}
|
||||
|
||||
cancelPendingForFile(messageId: string, fileId: string): void {
|
||||
const prefix = `${messageId}:${fileId}:`;
|
||||
|
||||
for (const [key, resolve] of this.waiters) {
|
||||
if (!key.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolve();
|
||||
this.waiters.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import '@angular/compiler';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
|
||||
import { AttachmentDownloadService } from './attachment-download.service';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
|
||||
describe('AttachmentDownloadService', () => {
|
||||
let electronBridge: {
|
||||
isAvailable: boolean;
|
||||
getApi: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let documentStub: Document;
|
||||
let saveExistingFileAs: ReturnType<typeof vi.fn>;
|
||||
let saveFileAs: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
saveExistingFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
||||
saveFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
||||
|
||||
electronBridge = {
|
||||
isAvailable: true,
|
||||
getApi: vi.fn(() => ({
|
||||
saveExistingFileAs,
|
||||
saveFileAs
|
||||
}))
|
||||
};
|
||||
|
||||
documentStub = {
|
||||
body: {
|
||||
appendChild: vi.fn(),
|
||||
removeChild: vi.fn()
|
||||
},
|
||||
createElement: vi.fn(() => ({
|
||||
click: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
href: '',
|
||||
download: ''
|
||||
}))
|
||||
} as unknown as Document;
|
||||
});
|
||||
|
||||
function createService(): AttachmentDownloadService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
AttachmentDownloadService,
|
||||
{ provide: ElectronBridgeService, useValue: electronBridge },
|
||||
{ provide: DOCUMENT, useValue: documentStub }
|
||||
]
|
||||
});
|
||||
|
||||
return runInInjectionContext(injector, () => injector.get(AttachmentDownloadService));
|
||||
}
|
||||
|
||||
it('exports a completed disk-only attachment through Electron save dialog', async () => {
|
||||
const service = createService();
|
||||
const attachment: Attachment = {
|
||||
id: 'file-1',
|
||||
messageId: 'message-1',
|
||||
filename: 'large.bin',
|
||||
mime: 'application/octet-stream',
|
||||
size: 5_000_000_000,
|
||||
available: true,
|
||||
savedPath: '/appdata/server/room/files/large.bin'
|
||||
};
|
||||
|
||||
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(saveExistingFileAs).toHaveBeenCalledWith('/appdata/server/room/files/large.bin', 'large.bin');
|
||||
expect(saveFileAs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the attachment is not downloadable yet', async () => {
|
||||
const service = createService();
|
||||
const attachment: Attachment = {
|
||||
id: 'file-2',
|
||||
messageId: 'message-2',
|
||||
filename: 'large.bin',
|
||||
mime: 'application/octet-stream',
|
||||
size: 5_000_000_000,
|
||||
available: true
|
||||
};
|
||||
|
||||
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(false);
|
||||
|
||||
expect(saveExistingFileAs).not.toHaveBeenCalled();
|
||||
expect(saveFileAs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { canDownloadAttachment, resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentDownloadService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
async downloadToUserLocation(attachment: Attachment): Promise<boolean> {
|
||||
if (!canDownloadAttachment(attachment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
const diskPath = resolveAttachmentDiskPath(attachment);
|
||||
|
||||
if (electronApi) {
|
||||
if (diskPath && electronApi.saveExistingFileAs) {
|
||||
try {
|
||||
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
|
||||
|
||||
if (result.saved || result.cancelled) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* fall back to blob/browser download */
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await this.getAttachmentBlob(attachment);
|
||||
|
||||
if (blob) {
|
||||
try {
|
||||
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
|
||||
|
||||
if (result.saved || result.cancelled) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* fall back to browser download */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!attachment.objectUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const link = this.document.createElement('a');
|
||||
|
||||
link.href = attachment.objectUrl;
|
||||
link.download = attachment.filename;
|
||||
this.document.body?.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
|
||||
if (!attachment.objectUrl || attachment.objectUrl.startsWith('file:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
|
||||
return await response.blob();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('Failed to encode attachment'));
|
||||
return;
|
||||
}
|
||||
|
||||
const [, base64 = ''] = reader.result.split(',', 2);
|
||||
|
||||
resolve(base64);
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules';
|
||||
import { buildAttachmentDisplayPinKey, shouldRevokeDisplayBlobForAttachment } from '../../domain/logic/attachment-blob-eviction.rules';
|
||||
import {
|
||||
getWatchedAttachmentRoomIdFromUrl,
|
||||
isDirectMessageAttachmentRoomId,
|
||||
@@ -20,6 +21,7 @@ import type {
|
||||
FileAnnouncePayload,
|
||||
FileCancelPayload,
|
||||
FileChunkPayload,
|
||||
FileChunkAckPayload,
|
||||
FileNotFoundPayload,
|
||||
FileRequestPayload
|
||||
} from '../../domain/models/attachment-transfer.model';
|
||||
@@ -44,6 +46,7 @@ export class AttachmentManagerService {
|
||||
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
|
||||
private isDatabaseInitialised = false;
|
||||
private autoDownloadRequestsByRoom = new Map<string, Promise<void>>();
|
||||
private pinnedDisplayBlobKeys = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -160,6 +163,48 @@ export class AttachmentManagerService {
|
||||
return restored;
|
||||
}
|
||||
|
||||
pinDisplayBlobs(attachments: readonly Pick<Attachment, 'id' | 'messageId'>[]): void {
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.messageId || !attachment.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.pinnedDisplayBlobKeys.add(buildAttachmentDisplayPinKey(attachment.messageId, attachment.id));
|
||||
}
|
||||
}
|
||||
|
||||
unpinDisplayBlobs(attachments: readonly Pick<Attachment, 'id' | 'messageId'>[]): void {
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.messageId || !attachment.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.pinnedDisplayBlobKeys.delete(buildAttachmentDisplayPinKey(attachment.messageId, attachment.id));
|
||||
}
|
||||
}
|
||||
|
||||
revokeOffscreenDisplayBlobsForMessage(messageId: string): void {
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
|
||||
if (!shouldRevokeDisplayBlobForAttachment(messageId, attachment, this.pinnedDisplayBlobKeys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.persistence.revokeAttachmentDisplayBlob(attachment)) {
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
this.runtimeStore.touch();
|
||||
}
|
||||
}
|
||||
|
||||
requestFile(messageId: string, attachment: Attachment): Promise<void> {
|
||||
return this.transfer.requestFile(messageId, attachment);
|
||||
}
|
||||
@@ -173,9 +218,9 @@ export class AttachmentManagerService {
|
||||
}
|
||||
|
||||
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||
this.transfer.handleFileAnnounce(payload);
|
||||
const isNew = this.transfer.handleFileAnnounce(payload);
|
||||
|
||||
if (payload.messageId && payload.file?.id) {
|
||||
if (isNew && payload.messageId && payload.file?.id) {
|
||||
this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id);
|
||||
}
|
||||
}
|
||||
@@ -184,6 +229,10 @@ export class AttachmentManagerService {
|
||||
this.transfer.handleFileChunk(payload);
|
||||
}
|
||||
|
||||
handleFileChunkAck(payload: FileChunkAckPayload): void {
|
||||
this.transfer.handleFileChunkAck(payload);
|
||||
}
|
||||
|
||||
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||
await this.transfer.handleFileRequest(payload);
|
||||
}
|
||||
@@ -218,7 +267,7 @@ export class AttachmentManagerService {
|
||||
|
||||
for (const messageId of messageIds) {
|
||||
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
|
||||
if (await this.persistence.tryRestoreAttachmentFromLocal(attachment)) {
|
||||
if (await this.persistence.tryRestoreAttachmentHostOnly(attachment)) {
|
||||
hasChanges = true;
|
||||
await yieldToAttachmentHydrationLoop();
|
||||
}
|
||||
|
||||
@@ -99,7 +99,17 @@ describe('AttachmentPersistenceService', () => {
|
||||
});
|
||||
|
||||
it('hydrates blob URLs on demand for a single attachment', async () => {
|
||||
const service = createService();
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
AttachmentPersistenceService,
|
||||
AttachmentRuntimeStore,
|
||||
{ provide: DatabaseService, useValue: database },
|
||||
{ provide: AttachmentStorageService, useValue: attachmentStorage },
|
||||
{ provide: Store, useValue: { select: () => of('room-1') } }
|
||||
]
|
||||
});
|
||||
const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService));
|
||||
const runtimeStore = injector.get(AttachmentRuntimeStore);
|
||||
|
||||
await service.initFromDatabase();
|
||||
|
||||
@@ -113,10 +123,12 @@ describe('AttachmentPersistenceService', () => {
|
||||
savedPath: '/appdata/photo.png',
|
||||
available: false
|
||||
};
|
||||
const versionBefore = runtimeStore.updated();
|
||||
|
||||
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
|
||||
expect(attachment.available).toBe(true);
|
||||
expect(attachment.objectUrl).toMatch(/^blob:/);
|
||||
expect(runtimeStore.updated()).toBeGreaterThan(versionBefore);
|
||||
expect(attachmentStorage.getFileSize).toHaveBeenCalledWith('/appdata/photo.png');
|
||||
expect(attachmentStorage.readFileChunk).toHaveBeenCalled();
|
||||
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
||||
@@ -206,4 +218,49 @@ describe('AttachmentPersistenceService', () => {
|
||||
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
|
||||
expect(database.saveAttachment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores host metadata without hydrating media blobs when display hydration is disabled', async () => {
|
||||
const service = createService();
|
||||
const attachment = {
|
||||
id: 'att-1',
|
||||
messageId: 'msg-1',
|
||||
filename: 'photo.png',
|
||||
size: 3,
|
||||
mime: 'image/png',
|
||||
isImage: true,
|
||||
savedPath: '/appdata/photo.png',
|
||||
available: false
|
||||
};
|
||||
|
||||
await expect(service.tryRestoreAttachmentHostOnly(attachment)).resolves.toBe(true);
|
||||
|
||||
expect(attachment.savedPath).toBe('/appdata/photo.png');
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
expect(attachment.available).toBe(false);
|
||||
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
|
||||
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('revokes display blobs while keeping disk paths for later rehydration', () => {
|
||||
const service = createService();
|
||||
const attachment = {
|
||||
id: 'att-1',
|
||||
messageId: 'msg-1',
|
||||
filename: 'photo.png',
|
||||
size: 3,
|
||||
mime: 'image/png',
|
||||
isImage: true,
|
||||
savedPath: '/appdata/photo.png',
|
||||
available: true,
|
||||
objectUrl: 'blob:http://localhost/abc'
|
||||
};
|
||||
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||
|
||||
expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true);
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
expect(attachment.savedPath).toBe('/appdata/photo.png');
|
||||
expect(revokeSpy).toHaveBeenCalledWith('blob:http://localhost/abc');
|
||||
|
||||
revokeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
decodeBase64ToUint8Array,
|
||||
yieldToAttachmentHydrationLoop
|
||||
} from '../../domain/logic/attachment-blob.rules';
|
||||
import { canRevokeAttachmentDisplayBlob } from '../../domain/logic/attachment-blob-eviction.rules';
|
||||
import { isBlobObjectUrl, needsBlobObjectUrlForInlineDisplay } from '../../domain/logic/attachment-display-url.rules';
|
||||
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
|
||||
import { isAttachmentMedia } from '../../domain/logic/attachment.logic';
|
||||
@@ -119,7 +120,7 @@ export class AttachmentPersistenceService {
|
||||
}
|
||||
|
||||
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
|
||||
const restored = await this.ensurePersistedUploadHost(attachment);
|
||||
const restored = await this.ensurePersistedUploadHost(attachment, { hydrateMediaForDisplay: true });
|
||||
|
||||
if (restored) {
|
||||
attachment.requestError = undefined;
|
||||
@@ -128,11 +129,30 @@ export class AttachmentPersistenceService {
|
||||
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);
|
||||
|
||||
if (existingPath) {
|
||||
return this.hydrateAttachmentFromStoredPath(attachment, existingPath);
|
||||
return this.hydrateAttachmentFromStoredPath(attachment, existingPath, hydrateMediaForDisplay);
|
||||
}
|
||||
|
||||
if (!attachment.filePath?.trim() || !this.attachmentStorage.canCopyFiles()) {
|
||||
@@ -147,13 +167,22 @@ export class AttachmentPersistenceService {
|
||||
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;
|
||||
|
||||
if (isAttachmentMedia(attachment)) {
|
||||
if (!hydrateMediaForDisplay) {
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.ensureInlineDisplayObjectUrl(attachment);
|
||||
}
|
||||
|
||||
@@ -192,6 +221,7 @@ export class AttachmentPersistenceService {
|
||||
this.revokeAttachmentObjectUrl(attachment);
|
||||
attachment.objectUrl = nativeUrl;
|
||||
attachment.available = true;
|
||||
this.runtimeStore.touch();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -366,6 +396,8 @@ export class AttachmentPersistenceService {
|
||||
`${attachment.messageId}:${attachment.id}`,
|
||||
new File([blob], attachment.filename, { type: attachment.mime })
|
||||
);
|
||||
|
||||
this.runtimeStore.touch();
|
||||
}
|
||||
|
||||
private revokeAttachmentObjectUrl(attachment: Attachment): void {
|
||||
|
||||
@@ -12,6 +12,7 @@ export class AttachmentRuntimeStore {
|
||||
private pendingRequests = new Map<string, Set<string>>();
|
||||
private chunkBuffers = new Map<string, (ArrayBuffer | undefined)[]>();
|
||||
private chunkCounts = new Map<string, number>();
|
||||
private announcedHostsByAttachment = new Map<string, Set<string>>();
|
||||
|
||||
touch(): void {
|
||||
this.updated.set(this.updated() + 1);
|
||||
@@ -66,6 +67,25 @@ export class AttachmentRuntimeStore {
|
||||
return this.originalFiles.get(key);
|
||||
}
|
||||
|
||||
deleteOriginalFile(key: string): void {
|
||||
this.originalFiles.delete(key);
|
||||
}
|
||||
|
||||
addAnnouncedHost(requestKey: string, peerId: string): void {
|
||||
const hosts = this.announcedHostsByAttachment.get(requestKey) ?? new Set<string>();
|
||||
|
||||
hosts.add(peerId);
|
||||
this.announcedHostsByAttachment.set(requestKey, hosts);
|
||||
}
|
||||
|
||||
getAnnouncedHosts(requestKey: string): Set<string> {
|
||||
return this.announcedHostsByAttachment.get(requestKey) ?? new Set();
|
||||
}
|
||||
|
||||
deleteAnnouncedHosts(requestKey: string): void {
|
||||
this.announcedHostsByAttachment.delete(requestKey);
|
||||
}
|
||||
|
||||
findOriginalFileByFileId(fileId: string): File | null {
|
||||
for (const [key, file] of this.originalFiles) {
|
||||
if (key.endsWith(`:${fileId}`)) {
|
||||
@@ -160,5 +180,11 @@ export class AttachmentRuntimeStore {
|
||||
this.cancelledTransfers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Array.from(this.announcedHostsByAttachment.keys())) {
|
||||
if (key.startsWith(scopedPrefix)) {
|
||||
this.announcedHostsByAttachment.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
decodeBase64,
|
||||
iterateBlobChunks
|
||||
} from '../../../../shared-kernel';
|
||||
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentTransferTransportService {
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
private readonly chunkAcks = inject(AttachmentChunkAckService);
|
||||
|
||||
decodeBase64(base64: string): Uint8Array {
|
||||
return decodeBase64(base64);
|
||||
@@ -39,6 +41,7 @@ export class AttachmentTransferTransportService {
|
||||
};
|
||||
|
||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||
await this.chunkAcks.waitForAck(messageId, fileId, chunk.index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +87,7 @@ export class AttachmentTransferTransportService {
|
||||
};
|
||||
|
||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||
await this.chunkAcks.waitForAck(messageId, fileId, chunkIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +126,7 @@ export class AttachmentTransferTransportService {
|
||||
};
|
||||
|
||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||
await this.chunkAcks.waitForAck(messageId, fileId, chunkIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
import { AttachmentTransferService } from './attachment-transfer.service';
|
||||
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
|
||||
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
|
||||
|
||||
const MESSAGE_ID = 'msg-1';
|
||||
const FILE_ID = 'file-1';
|
||||
@@ -52,6 +53,7 @@ describe('AttachmentTransferService', () => {
|
||||
resolveExistingPath: ReturnType<typeof vi.fn>;
|
||||
resolveLegacyImagePath: ReturnType<typeof vi.fn>;
|
||||
appendBase64: ReturnType<typeof vi.fn>;
|
||||
appendBytes: ReturnType<typeof vi.fn>;
|
||||
createWritableFile: ReturnType<typeof vi.fn>;
|
||||
deleteFile: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
@@ -60,6 +62,11 @@ describe('AttachmentTransferService', () => {
|
||||
streamFileToPeer: ReturnType<typeof vi.fn>;
|
||||
streamFileFromDiskToPeer: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let chunkAcks: {
|
||||
resolveAck: ReturnType<typeof vi.fn>;
|
||||
waitForAck: ReturnType<typeof vi.fn>;
|
||||
cancelPendingForFile: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let webrtc: {
|
||||
getConnectedPeers: ReturnType<typeof vi.fn>;
|
||||
broadcastMessage: ReturnType<typeof vi.fn>;
|
||||
@@ -88,6 +95,7 @@ describe('AttachmentTransferService', () => {
|
||||
resolveExistingPath: vi.fn(async () => null),
|
||||
resolveLegacyImagePath: vi.fn(async () => null),
|
||||
appendBase64: vi.fn(async () => true),
|
||||
appendBytes: vi.fn(async () => true),
|
||||
createWritableFile: vi.fn(async () => '/appdata/server/room/files/file-1'),
|
||||
deleteFile: vi.fn(async () => true)
|
||||
};
|
||||
@@ -98,6 +106,12 @@ describe('AttachmentTransferService', () => {
|
||||
streamFileFromDiskToPeer: vi.fn(async () => undefined)
|
||||
};
|
||||
|
||||
chunkAcks = {
|
||||
resolveAck: vi.fn(),
|
||||
waitForAck: vi.fn(async () => undefined),
|
||||
cancelPendingForFile: vi.fn()
|
||||
};
|
||||
|
||||
webrtc = {
|
||||
getConnectedPeers: vi.fn(() => [PEER_ID]),
|
||||
broadcastMessage: vi.fn(),
|
||||
@@ -115,7 +129,8 @@ describe('AttachmentTransferService', () => {
|
||||
{ provide: AppI18nService, useValue: { instant: (key: string) => key } },
|
||||
{ provide: AttachmentStorageService, useValue: attachmentStorage },
|
||||
{ provide: AttachmentPersistenceService, useValue: persistence },
|
||||
{ provide: AttachmentTransferTransportService, useValue: transport }
|
||||
{ provide: AttachmentTransferTransportService, useValue: transport },
|
||||
{ provide: AttachmentChunkAckService, useValue: chunkAcks }
|
||||
]
|
||||
});
|
||||
const service = runInInjectionContext(injector, () => injector.get(AttachmentTransferService));
|
||||
@@ -294,17 +309,13 @@ describe('AttachmentTransferService', () => {
|
||||
});
|
||||
|
||||
it('streams a requested file only once while the same request is already in flight', async () => {
|
||||
attachmentStorage.resolveExistingPath.mockResolvedValue(null);
|
||||
|
||||
const service = createService();
|
||||
|
||||
registerIncomingAttachment(9);
|
||||
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File([new Uint8Array(9)], 'photo.png', { type: 'image/png' }));
|
||||
|
||||
let releaseStream: () => void = () => undefined;
|
||||
|
||||
transport.streamFileToPeer.mockImplementation(() => new Promise<void>((resolve) => {
|
||||
releaseStream = resolve;
|
||||
}));
|
||||
|
||||
const firstRequest = service.handleFileRequest({
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
@@ -316,7 +327,6 @@ describe('AttachmentTransferService', () => {
|
||||
fromPeerId: PEER_ID
|
||||
});
|
||||
|
||||
releaseStream();
|
||||
await Promise.all([firstRequest, duplicateRequest]);
|
||||
|
||||
expect(transport.streamFileToPeer).toHaveBeenCalledTimes(1);
|
||||
@@ -396,7 +406,14 @@ describe('AttachmentTransferService', () => {
|
||||
await vi.waitFor(() => expect(attachment.available).toBe(true));
|
||||
|
||||
expect(attachmentStorage.createWritableFile).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBase64).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBytes).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
|
||||
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, {
|
||||
type: 'file-chunk-ack',
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
index: 0
|
||||
});
|
||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -418,6 +435,18 @@ describe('AttachmentTransferService', () => {
|
||||
expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves chunk ack waiters from inbound ack events', () => {
|
||||
const service = createService();
|
||||
|
||||
service.handleFileChunkAck({
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
index: 2
|
||||
});
|
||||
|
||||
expect(chunkAcks.resolveAck).toHaveBeenCalledWith(MESSAGE_ID, FILE_ID, 2);
|
||||
});
|
||||
|
||||
it('marks a request as pending synchronously so concurrent auto-download triggers cannot double-request', () => {
|
||||
const service = createService();
|
||||
const attachment = registerIncomingAttachment(9);
|
||||
@@ -443,9 +472,65 @@ describe('AttachmentTransferService', () => {
|
||||
await vi.waitFor(() => expect(attachment.available).toBe(true));
|
||||
|
||||
expect(attachmentStorage.createWritableFile).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBase64).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBytes).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
|
||||
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, {
|
||||
type: 'file-chunk-ack',
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
index: 0
|
||||
});
|
||||
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
|
||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('streams large downloads to disk even when attachment metadata still carries a source filePath', async () => {
|
||||
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
||||
attachmentStorage.canPersistSize.mockReturnValue(true);
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
|
||||
|
||||
attachment.filePath = '/home/ludde/archive.zip';
|
||||
|
||||
service.handleFileChunk(chunkPayload(0, 1, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
await vi.waitFor(() => expect(attachment.available).toBe(true));
|
||||
|
||||
expect(attachmentStorage.appendBytes).toHaveBeenCalled();
|
||||
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
|
||||
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, {
|
||||
type: 'file-chunk-ack',
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
index: 0
|
||||
});
|
||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||
expect(runtimeStore.getChunkBuffer(`${MESSAGE_ID}:${FILE_ID}`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not hydrate media blobs after a disk-streamed download completes', async () => {
|
||||
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingVideo(3);
|
||||
|
||||
service.handleFileChunk(chunkPayload(0, 1, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
await vi.waitFor(() => expect(attachment.available).toBe(true));
|
||||
|
||||
expect(attachment.savedPath).toBeTruthy();
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects oversized browser downloads before requesting peers', async () => {
|
||||
@@ -483,7 +568,10 @@ describe('AttachmentTransferService', () => {
|
||||
it('copies oversized generic uploads with a source path into app data when publishing', async () => {
|
||||
attachmentStorage.canCopyFiles.mockReturnValue(true);
|
||||
attachmentStorage.canPersistSize.mockReturnValue(true);
|
||||
persistence.persistUploadCopyFromSourcePath.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 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 })
|
||||
}));
|
||||
});
|
||||
|
||||
it('requests a mirror host before the original uploader when both announced the file', async () => {
|
||||
const uploaderPeer = 'uploader-peer';
|
||||
const mirrorPeer = 'mirror-peer';
|
||||
|
||||
webrtc.getConnectedPeers.mockReturnValue([uploaderPeer, mirrorPeer]);
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingAttachment(3_000);
|
||||
|
||||
attachment.uploaderPeerId = uploaderPeer;
|
||||
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, uploaderPeer);
|
||||
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, mirrorPeer);
|
||||
|
||||
await service.requestFromAnyPeer(MESSAGE_ID, attachment);
|
||||
|
||||
expect(webrtc.sendToPeer).toHaveBeenCalledWith(mirrorPeer, expect.objectContaining({
|
||||
type: 'file-request',
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID
|
||||
}));
|
||||
});
|
||||
|
||||
it('records announced hosts from incoming file-announce payloads', () => {
|
||||
const service = createService();
|
||||
|
||||
service.handleFileAnnounce({
|
||||
messageId: MESSAGE_ID,
|
||||
fromPeerId: 'mirror-peer',
|
||||
file: {
|
||||
id: FILE_ID,
|
||||
filename: 'photo.png',
|
||||
size: 3,
|
||||
mime: 'image/png',
|
||||
isImage: true,
|
||||
uploaderPeerId: 'uploader-peer'
|
||||
}
|
||||
});
|
||||
|
||||
expect(runtimeStore.getAnnouncedHosts(`${MESSAGE_ID}:${FILE_ID}`).has('mirror-peer')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not register duplicate attachment metadata on repeat file-announce', () => {
|
||||
const service = createService();
|
||||
const announce = {
|
||||
messageId: MESSAGE_ID,
|
||||
fromPeerId: 'uploader-peer',
|
||||
file: {
|
||||
id: FILE_ID,
|
||||
filename: 'photo.png',
|
||||
size: 3,
|
||||
mime: 'image/png',
|
||||
isImage: true,
|
||||
uploaderPeerId: 'uploader-peer'
|
||||
}
|
||||
};
|
||||
|
||||
expect(service.handleFileAnnounce(announce)).toBe(true);
|
||||
expect(service.handleFileAnnounce(announce)).toBe(false);
|
||||
expect(runtimeStore.getAttachmentsForMessage(MESSAGE_ID)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prefers streaming from disk over an in-memory original file when both exist', async () => {
|
||||
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
|
||||
|
||||
attachment.savedPath = '/appdata/server/room/files/setup.exe';
|
||||
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File(['x'], 'setup.exe'));
|
||||
|
||||
await service.handleFileRequest({
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
fromPeerId: 'peer-2'
|
||||
});
|
||||
|
||||
expect(transport.streamFileFromDiskToPeer).toHaveBeenCalled();
|
||||
expect(transport.streamFileToPeer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases the in-memory upload copy after persisting a large generic file to disk', async () => {
|
||||
attachmentStorage.canCopyFiles.mockReturnValue(true);
|
||||
attachmentStorage.canPersistSize.mockReturnValue(true);
|
||||
persistence.persistUploadCopyFromSourcePath.mockImplementation(async (attachment) => {
|
||||
attachment.savedPath = '/appdata/server/room/files/setup.exe';
|
||||
return attachment.savedPath;
|
||||
});
|
||||
|
||||
const service = createService();
|
||||
const file = new File([new Uint8Array(11 * 1024 * 1024)], 'setup.exe', { type: 'application/octet-stream' });
|
||||
|
||||
Object.defineProperty(file, 'path', { value: '/home/ludde/setup.exe' });
|
||||
|
||||
await service.publishAttachments(MESSAGE_ID, [file], PEER_ID);
|
||||
|
||||
const attachment = runtimeStore.getAttachmentsForMessage(MESSAGE_ID)[0];
|
||||
|
||||
expect(runtimeStore.getOriginalFile(`${MESSAGE_ID}:${attachment.id}`)).toBeUndefined();
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
expect(attachment.available).toBe(true);
|
||||
expect(attachment.savedPath).toBe('/appdata/server/room/files/setup.exe');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,11 @@ import { selectCurrentUserId } from '../../../../store/users/users.selectors';
|
||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
|
||||
import { isImageAttachment, resolvePublishAttachmentIsImage } from '../../domain/logic/attachment-image.rules';
|
||||
import { isSharingFromThisDevice } from '../../domain/logic/attachment-sharing.rules';
|
||||
import { base64DecodedByteLength, decodeBase64ToUint8Array } from '../../domain/logic/attachment-blob.rules';
|
||||
import { isSharingFromThisDevice, canHostAttachment } from '../../domain/logic/attachment-sharing.rules';
|
||||
import { selectFileRequestPeer } from '../../domain/logic/attachment-request.rules';
|
||||
import {
|
||||
canReceiveAttachment,
|
||||
isAttachmentMedia,
|
||||
shouldCopyLargeUploaderFileToAppData,
|
||||
shouldPersistDownloadedAttachment,
|
||||
shouldStreamAttachmentReceiveToDisk
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
ATTACHMENT_DOWNLOAD_FAILED_KEY,
|
||||
ATTACHMENT_FILE_TOO_LARGE_KEY,
|
||||
ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY,
|
||||
ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY,
|
||||
ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY,
|
||||
ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY,
|
||||
FILE_NOT_FOUND_REQUEST_ERROR_KEY,
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
type FileCancelEvent,
|
||||
type FileCancelPayload,
|
||||
type FileChunkPayload,
|
||||
type FileChunkAckPayload,
|
||||
type FileChunkAckEvent,
|
||||
type FileNotFoundEvent,
|
||||
type FileNotFoundPayload,
|
||||
type FileRequestEvent,
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
|
||||
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
|
||||
|
||||
interface DiskReceiveAssembly {
|
||||
path: string;
|
||||
@@ -86,9 +89,10 @@ export class AttachmentTransferService {
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
private readonly persistence = inject(AttachmentPersistenceService);
|
||||
private readonly transport = inject(AttachmentTransferTransportService);
|
||||
private readonly chunkAcks = inject(AttachmentChunkAckService);
|
||||
|
||||
private readonly diskReceiveAssemblies = new Map<string, DiskReceiveAssembly>();
|
||||
private readonly diskReceiveChains = new Map<string, Promise<void>>();
|
||||
private readonly diskReceiveLocks = new Map<string, Promise<void>>();
|
||||
private readonly activeOutboundTransfers = new Set<string>();
|
||||
|
||||
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
|
||||
@@ -275,6 +279,7 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
await this.persistPublishedAttachment(attachment, file);
|
||||
this.releaseInMemoryUploadCopyIfPersisted(`${messageId}:${fileId}`, attachment);
|
||||
|
||||
const fileAnnounceEvent: FileAnnounceEvent = {
|
||||
type: 'file-announce',
|
||||
@@ -302,17 +307,23 @@ export class AttachmentTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||
handleFileAnnounce(payload: FileAnnouncePayload): boolean {
|
||||
const { messageId, file } = payload;
|
||||
|
||||
if (!messageId || !file)
|
||||
return;
|
||||
if (!messageId || !file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.fromPeerId) {
|
||||
this.runtimeStore.addAnnouncedHost(this.buildRequestKey(messageId, file.id), payload.fromPeerId);
|
||||
}
|
||||
|
||||
const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
|
||||
const alreadyKnown = list.find((entry) => entry.id === file.id);
|
||||
|
||||
if (alreadyKnown)
|
||||
return;
|
||||
if (alreadyKnown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: file.id,
|
||||
@@ -334,6 +345,8 @@ export class AttachmentTransferService {
|
||||
this.runtimeStore.setAttachmentsForMessage(messageId, list);
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handleFileChunk(payload: FileChunkPayload): void {
|
||||
@@ -365,7 +378,7 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
if (this.shouldReceiveToDisk(attachment)) {
|
||||
this.enqueueDiskFileChunk(attachment, {
|
||||
void this.receiveDiskChunk(attachment, {
|
||||
data,
|
||||
fileId,
|
||||
fromPeerId,
|
||||
@@ -377,6 +390,12 @@ export class AttachmentTransferService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY);
|
||||
this.runtimeStore.touch();
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedBytes = this.transport.decodeBase64(data);
|
||||
const assemblyKey = `${messageId}:${fileId}`;
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
@@ -394,10 +413,21 @@ export class AttachmentTransferService {
|
||||
|
||||
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
|
||||
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
|
||||
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
|
||||
this.updateTransferProgress(attachment, decodedBytes.byteLength, fromPeerId);
|
||||
|
||||
this.runtimeStore.touch();
|
||||
void this.finalizeTransferIfComplete(attachment, assemblyKey, total);
|
||||
this.emitChunkAck({ fileId, fromPeerId, index, messageId });
|
||||
}
|
||||
|
||||
handleFileChunkAck(payload: FileChunkAckPayload): void {
|
||||
const { messageId, fileId, index } = payload;
|
||||
|
||||
if (!messageId || !fileId || typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chunkAcks.resolveAck(messageId, fileId, index);
|
||||
}
|
||||
|
||||
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||
@@ -511,21 +541,6 @@ export class AttachmentTransferService {
|
||||
fromPeerId: string
|
||||
): Promise<void> {
|
||||
const exactKey = `${messageId}:${fileId}`;
|
||||
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
|
||||
?? this.runtimeStore.findOriginalFileByFileId(fileId);
|
||||
|
||||
if (originalFile) {
|
||||
await this.transport.streamFileToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
originalFile,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
const attachment = list.find((entry) => entry.id === fileId);
|
||||
const diskPath = attachment
|
||||
@@ -544,6 +559,21 @@ export class AttachmentTransferService {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
|
||||
?? this.runtimeStore.findOriginalFileByFileId(fileId);
|
||||
|
||||
if (originalFile) {
|
||||
await this.transport.streamFileToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
originalFile,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment?.isImage) {
|
||||
const roomName = await this.persistence.resolveCurrentRoomName();
|
||||
const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath(
|
||||
@@ -630,14 +660,13 @@ export class AttachmentTransferService {
|
||||
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>();
|
||||
|
||||
let targetPeerId: string | undefined;
|
||||
|
||||
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) {
|
||||
targetPeerId = preferredPeerId;
|
||||
} else {
|
||||
targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId));
|
||||
}
|
||||
const announcedHosts = this.runtimeStore.getAnnouncedHosts(requestKey);
|
||||
const targetPeerId = selectFileRequestPeer({
|
||||
connectedPeers,
|
||||
triedPeers,
|
||||
announcedHosts,
|
||||
uploaderPeerId: preferredPeerId
|
||||
});
|
||||
|
||||
if (!targetPeerId) {
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
@@ -677,16 +706,16 @@ export class AttachmentTransferService {
|
||||
|
||||
private updateTransferProgress(
|
||||
attachment: Attachment,
|
||||
decodedBytes: Uint8Array,
|
||||
chunkByteLength: number,
|
||||
fromPeerId?: string
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const previousReceived = attachment.receivedBytes ?? 0;
|
||||
|
||||
attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
|
||||
attachment.receivedBytes = previousReceived + chunkByteLength;
|
||||
|
||||
if (fromPeerId) {
|
||||
recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now);
|
||||
recordDebugNetworkFileChunk(fromPeerId, chunkByteLength, now);
|
||||
}
|
||||
|
||||
if (!attachment.startedAtMs)
|
||||
@@ -696,7 +725,7 @@ export class AttachmentTransferService {
|
||||
attachment.lastUpdateMs = now;
|
||||
|
||||
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
|
||||
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000;
|
||||
const instantaneousBps = (chunkByteLength / elapsedMs) * 1000;
|
||||
const previousSpeed = attachment.speedBps ?? instantaneousBps;
|
||||
|
||||
attachment.speedBps =
|
||||
@@ -745,6 +774,7 @@ export class AttachmentTransferService {
|
||||
|
||||
this.runtimeStore.touch();
|
||||
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 attachment of attachments) {
|
||||
if (!isSharingFromThisDevice(attachment, currentUserId)) {
|
||||
if (!canHostAttachment(attachment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -799,6 +829,48 @@ export class AttachmentTransferService {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.announceLocalHost(attachment, currentUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private releaseInMemoryUploadCopyIfPersisted(exactKey: string, attachment: Attachment): void {
|
||||
if (!attachment.savedPath?.trim() || attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runtimeStore.deleteOriginalFile(exactKey);
|
||||
|
||||
if (!attachment.objectUrl?.startsWith('blob:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
URL.revokeObjectURL(attachment.objectUrl);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!this.isPlayableMedia(attachment)) {
|
||||
attachment.objectUrl = undefined;
|
||||
attachment.available = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async announceLocalHost(attachment: Attachment, hostPeerId?: string | null): Promise<void> {
|
||||
if (!canHostAttachment(attachment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const announcingPeerId = hostPeerId ?? await this.resolveCurrentUserId();
|
||||
|
||||
if (!announcingPeerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runtimeStore.addAnnouncedHost(
|
||||
this.buildRequestKey(attachment.messageId, attachment.id),
|
||||
announcingPeerId
|
||||
);
|
||||
|
||||
const fileAnnounceEvent: FileAnnounceEvent = {
|
||||
type: 'file-announce',
|
||||
messageId: attachment.messageId,
|
||||
@@ -814,8 +886,6 @@ export class AttachmentTransferService {
|
||||
|
||||
this.webrtc.broadcastMessage(fileAnnounceEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async applySavedPathObjectUrl(attachment: Attachment, savedPath: string | null): Promise<void> {
|
||||
if (!savedPath) {
|
||||
@@ -845,31 +915,47 @@ export class AttachmentTransferService {
|
||||
};
|
||||
}
|
||||
|
||||
private enqueueDiskFileChunk(
|
||||
attachment: Attachment,
|
||||
payload: ValidFileChunkPayload
|
||||
): void {
|
||||
private receiveDiskChunk(attachment: Attachment, payload: ValidFileChunkPayload): void {
|
||||
const assemblyKey = `${payload.messageId}:${payload.fileId}`;
|
||||
const previous = this.diskReceiveChains.get(assemblyKey) ?? Promise.resolve();
|
||||
const previous = this.diskReceiveLocks.get(assemblyKey) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.handleDiskFileChunk(attachment, assemblyKey, payload))
|
||||
.then(async () => {
|
||||
await this.handleDiskFileChunk(attachment, assemblyKey, payload);
|
||||
this.emitChunkAck(payload);
|
||||
})
|
||||
.catch((error: unknown) => this.handleDiskReceiveFailure(attachment, assemblyKey, error));
|
||||
|
||||
this.diskReceiveChains.set(assemblyKey, next);
|
||||
this.diskReceiveLocks.set(assemblyKey, next);
|
||||
void next.finally(() => {
|
||||
if (this.diskReceiveChains.get(assemblyKey) === next) {
|
||||
this.diskReceiveChains.delete(assemblyKey);
|
||||
if (this.diskReceiveLocks.get(assemblyKey) === next) {
|
||||
this.diskReceiveLocks.delete(assemblyKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private emitChunkAck(payload: Pick<ValidFileChunkPayload, 'fileId' | 'fromPeerId' | 'index' | 'messageId'>): void {
|
||||
if (!payload.fromPeerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ack: FileChunkAckEvent = {
|
||||
type: 'file-chunk-ack',
|
||||
messageId: payload.messageId,
|
||||
fileId: payload.fileId,
|
||||
index: payload.index
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(payload.fromPeerId, ack);
|
||||
}
|
||||
|
||||
private async handleDiskFileChunk(
|
||||
attachment: Attachment,
|
||||
assemblyKey: string,
|
||||
payload: ValidFileChunkPayload
|
||||
): Promise<void> {
|
||||
const decodedBytes = this.transport.decodeBase64(payload.data);
|
||||
const chunkByteLength = base64DecodedByteLength(payload.data);
|
||||
const chunkBytes = decodeBase64ToUint8Array(payload.data);
|
||||
const requestKey = this.buildRequestKey(payload.messageId, payload.fileId);
|
||||
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
@@ -889,7 +975,7 @@ export class AttachmentTransferService {
|
||||
throw new Error(this.appI18n.instant(ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY));
|
||||
}
|
||||
|
||||
const didAppend = await this.attachmentStorage.appendBase64(assembly.path, payload.data);
|
||||
const didAppend = await this.attachmentStorage.appendBytes(assembly.path, chunkBytes);
|
||||
|
||||
if (!didAppend) {
|
||||
throw new Error(this.appI18n.instant(ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY));
|
||||
@@ -897,7 +983,7 @@ export class AttachmentTransferService {
|
||||
|
||||
assembly.receivedIndexes.add(payload.index);
|
||||
assembly.receivedCount += 1;
|
||||
this.updateTransferProgress(attachment, decodedBytes, payload.fromPeerId);
|
||||
this.updateTransferProgress(attachment, chunkByteLength, payload.fromPeerId);
|
||||
this.runtimeStore.touch();
|
||||
|
||||
if (assembly.receivedCount < assembly.total) {
|
||||
@@ -905,25 +991,12 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
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.objectUrl = undefined;
|
||||
this.diskReceiveAssemblies.delete(assemblyKey);
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
void this.announceLocalHost(attachment);
|
||||
}
|
||||
|
||||
private async getOrCreateDiskReceiveAssembly(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
buildAttachmentDisplayPinKey,
|
||||
canRevokeAttachmentDisplayBlob,
|
||||
shouldRevokeDisplayBlobForAttachment
|
||||
} from './attachment-blob-eviction.rules';
|
||||
|
||||
describe('attachment-blob-eviction rules', () => {
|
||||
it('builds a stable pin key from message and attachment ids', () => {
|
||||
expect(buildAttachmentDisplayPinKey('msg-1', 'att-1')).toBe('msg-1:att-1');
|
||||
});
|
||||
|
||||
it('allows revoking blob urls when a disk path can rehydrate the attachment', () => {
|
||||
expect(canRevokeAttachmentDisplayBlob({
|
||||
objectUrl: 'blob:http://localhost/abc',
|
||||
savedPath: '/appdata/photo.png',
|
||||
receivedBytes: 0,
|
||||
available: true
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to revoke blobs that are the only local copy', () => {
|
||||
expect(canRevokeAttachmentDisplayBlob({
|
||||
objectUrl: 'blob:http://localhost/abc',
|
||||
receivedBytes: 0,
|
||||
available: true
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to revoke blobs while a download is still in progress', () => {
|
||||
expect(canRevokeAttachmentDisplayBlob({
|
||||
objectUrl: 'blob:http://localhost/abc',
|
||||
savedPath: '/appdata/photo.png',
|
||||
receivedBytes: 1024,
|
||||
available: false
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('skips revocation for pinned attachments', () => {
|
||||
const attachment = {
|
||||
id: 'att-1',
|
||||
objectUrl: 'blob:http://localhost/abc',
|
||||
savedPath: '/appdata/photo.png',
|
||||
receivedBytes: 0,
|
||||
available: true
|
||||
};
|
||||
|
||||
expect(shouldRevokeDisplayBlobForAttachment(
|
||||
'msg-1',
|
||||
attachment,
|
||||
new Set([buildAttachmentDisplayPinKey('msg-1', 'att-1')])
|
||||
)).toBe(false);
|
||||
|
||||
expect(shouldRevokeDisplayBlobForAttachment('msg-1', attachment, new Set())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { isBlobObjectUrl } from './attachment-display-url.rules';
|
||||
|
||||
/** Margin around the chat scrollport used to hydrate blobs before they enter view. */
|
||||
export const ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN = '200px';
|
||||
|
||||
export interface AttachmentDisplayBlobCandidate {
|
||||
available?: boolean;
|
||||
filePath?: string;
|
||||
objectUrl?: string;
|
||||
receivedBytes?: number;
|
||||
savedPath?: string;
|
||||
}
|
||||
|
||||
export function buildAttachmentDisplayPinKey(messageId: string, attachmentId: string): string {
|
||||
return `${messageId}:${attachmentId}`;
|
||||
}
|
||||
|
||||
export function canRevokeAttachmentDisplayBlob(
|
||||
attachment: AttachmentDisplayBlobCandidate
|
||||
): boolean {
|
||||
if (!attachment.objectUrl || !isBlobObjectUrl(attachment.objectUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasNonEmptyString(attachment.savedPath) && !hasNonEmptyString(attachment.filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((attachment.receivedBytes ?? 0) > 0 && attachment.available !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldRevokeDisplayBlobForAttachment(
|
||||
messageId: string,
|
||||
attachment: AttachmentDisplayBlobCandidate & { id: string },
|
||||
pinnedKeys: ReadonlySet<string>
|
||||
): boolean {
|
||||
if (pinnedKeys.has(buildAttachmentDisplayPinKey(messageId, attachment.id))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return canRevokeAttachmentDisplayBlob(attachment);
|
||||
}
|
||||
|
||||
function hasNonEmptyString(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { decodeBase64ToUint8Array } from './attachment-blob.rules';
|
||||
import {
|
||||
base64DecodedByteLength,
|
||||
decodeBase64ToUint8Array
|
||||
} from './attachment-blob.rules';
|
||||
|
||||
describe('attachment blob rules', () => {
|
||||
it('decodes base64 payloads into byte arrays', () => {
|
||||
@@ -16,4 +19,9 @@ describe('attachment blob rules', () => {
|
||||
67
|
||||
]);
|
||||
});
|
||||
|
||||
it('estimates decoded base64 byte length without allocating bytes', () => {
|
||||
expect(base64DecodedByteLength('QUJD')).toBe(3);
|
||||
expect(base64DecodedByteLength('YQ==')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,13 @@ export function encodeUint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Returns the decoded byte length of a base64 payload without allocating the bytes. */
|
||||
export function base64DecodedByteLength(base64: string): number {
|
||||
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
||||
|
||||
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
|
||||
}
|
||||
|
||||
/** Yield control back to the browser so long attachment hydration cannot freeze Electron. */
|
||||
export function yieldToAttachmentHydrationLoop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { buildAttachmentChunkAckKey } from './attachment-chunk-ack.rules';
|
||||
|
||||
describe('attachment-chunk-ack rules', () => {
|
||||
it('builds a stable ack key from message, file, and chunk index', () => {
|
||||
expect(buildAttachmentChunkAckKey('msg-1', 'file-1', 42)).toBe('msg-1:file-1:42');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function buildAttachmentChunkAckKey(messageId: string, fileId: string, index: number): string {
|
||||
return `${messageId}:${fileId}:${index}`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
canDownloadAttachment,
|
||||
resolveAttachmentDiskPath
|
||||
} from './attachment-download.rules';
|
||||
|
||||
describe('attachment-download.rules', () => {
|
||||
it('allows download when a completed disk-only attachment has no object URL', () => {
|
||||
expect(canDownloadAttachment({
|
||||
available: true,
|
||||
savedPath: '/appdata/server/room/files/large.bin'
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('allows download when a blob object URL is available', () => {
|
||||
expect(canDownloadAttachment({
|
||||
available: true,
|
||||
objectUrl: 'blob:http://localhost/abc'
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects incomplete or empty local copies', () => {
|
||||
expect(canDownloadAttachment({
|
||||
available: false,
|
||||
savedPath: '/appdata/server/room/files/large.bin'
|
||||
})).toBe(false);
|
||||
|
||||
expect(canDownloadAttachment({
|
||||
available: true
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers savedPath over filePath for disk export', () => {
|
||||
expect(resolveAttachmentDiskPath({
|
||||
savedPath: '/appdata/copy.bin',
|
||||
filePath: '/home/me/original.bin'
|
||||
})).toBe('/appdata/copy.bin');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Attachment } from '../models/attachment.model';
|
||||
|
||||
export function canDownloadAttachment(
|
||||
attachment: Pick<Attachment, 'available' | 'objectUrl' | 'savedPath' | 'filePath'>
|
||||
): boolean {
|
||||
if (attachment.available !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasNonEmptyString(attachment.objectUrl) ||
|
||||
hasNonEmptyString(attachment.savedPath) ||
|
||||
hasNonEmptyString(attachment.filePath);
|
||||
}
|
||||
|
||||
export function resolveAttachmentDiskPath(
|
||||
attachment: Pick<Attachment, 'savedPath' | 'filePath'>
|
||||
): string | null {
|
||||
const diskPath = attachment.savedPath?.trim() || attachment.filePath?.trim();
|
||||
|
||||
return diskPath || null;
|
||||
}
|
||||
|
||||
function hasNonEmptyString(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
dedupeImageAttachmentsForDisplay,
|
||||
hasImageFilename,
|
||||
isAttachmentPendingInlineHydration,
|
||||
isImageAttachment,
|
||||
isInlineDisplayableImage,
|
||||
resolvePublishAttachmentIsImage
|
||||
@@ -38,6 +39,27 @@ describe('attachment-image rules', () => {
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('detects images waiting for on-demand blob hydration', () => {
|
||||
expect(isAttachmentPendingInlineHydration({
|
||||
id: '1',
|
||||
filename: 'photo.png',
|
||||
mime: 'image/png',
|
||||
isImage: true,
|
||||
available: false,
|
||||
savedPath: '/appdata/photo.png'
|
||||
})).toBe(true);
|
||||
|
||||
expect(isAttachmentPendingInlineHydration({
|
||||
id: '2',
|
||||
filename: 'photo.png',
|
||||
mime: 'image/png',
|
||||
isImage: true,
|
||||
available: true,
|
||||
objectUrl: 'blob:http://localhost/photo',
|
||||
savedPath: '/appdata/photo.png'
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('dedupes image attachments by filename and prefers displayable copies', () => {
|
||||
const deduped = dedupeImageAttachmentsForDisplay([
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ImageAttachmentCandidate {
|
||||
isImage: boolean;
|
||||
mime: string;
|
||||
objectUrl?: string;
|
||||
receivedBytes?: number;
|
||||
savedPath?: string;
|
||||
}
|
||||
|
||||
@@ -50,6 +51,27 @@ export function isInlineDisplayableImage(
|
||||
!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl);
|
||||
}
|
||||
|
||||
export function isAttachmentPendingInlineHydration(
|
||||
attachment: Pick<
|
||||
ImageAttachmentCandidate,
|
||||
'available' | 'filePath' | 'filename' | 'isImage' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath'
|
||||
>
|
||||
): boolean {
|
||||
if (isInlineDisplayableImage(attachment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isImageAttachment(attachment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((attachment.receivedBytes ?? 0) > 0 && attachment.available !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!(attachment.savedPath?.trim() || attachment.filePath?.trim());
|
||||
}
|
||||
|
||||
export function imageAttachmentDisplayRank(
|
||||
attachment: Pick<ImageAttachmentCandidate, 'available' | 'filePath' | 'isImage' | 'objectUrl' | 'savedPath'>
|
||||
): number {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { selectFileRequestPeer } from './attachment-request.rules';
|
||||
|
||||
describe('selectFileRequestPeer', () => {
|
||||
const uploader = 'uploader-peer';
|
||||
const mirror = 'mirror-peer';
|
||||
const other = 'other-peer';
|
||||
|
||||
it('prefers a mirror host over the original uploader when both are available', () => {
|
||||
expect(selectFileRequestPeer({
|
||||
connectedPeers: [
|
||||
uploader,
|
||||
mirror,
|
||||
other
|
||||
],
|
||||
triedPeers: new Set(),
|
||||
announcedHosts: new Set([uploader, mirror]),
|
||||
uploaderPeerId: uploader
|
||||
})).toBe(mirror);
|
||||
});
|
||||
|
||||
it('falls back to the uploader when no mirror hosts are announced', () => {
|
||||
expect(selectFileRequestPeer({
|
||||
connectedPeers: [uploader, other],
|
||||
triedPeers: new Set(),
|
||||
announcedHosts: new Set([uploader]),
|
||||
uploaderPeerId: uploader
|
||||
})).toBe(uploader);
|
||||
});
|
||||
|
||||
it('skips peers that were already tried', () => {
|
||||
expect(selectFileRequestPeer({
|
||||
connectedPeers: [mirror, uploader],
|
||||
triedPeers: new Set([mirror]),
|
||||
announcedHosts: new Set([mirror, uploader]),
|
||||
uploaderPeerId: uploader
|
||||
})).toBe(uploader);
|
||||
});
|
||||
|
||||
it('returns undefined when every connected peer was already tried', () => {
|
||||
expect(selectFileRequestPeer({
|
||||
connectedPeers: [mirror, uploader],
|
||||
triedPeers: new Set([mirror, uploader]),
|
||||
announcedHosts: new Set([mirror, uploader]),
|
||||
uploaderPeerId: uploader
|
||||
})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface FileRequestPeerSelectionInput {
|
||||
connectedPeers: string[];
|
||||
triedPeers: ReadonlySet<string>;
|
||||
announcedHosts: ReadonlySet<string>;
|
||||
uploaderPeerId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the next peer to request a file from. Mirror hosts (peers that announced
|
||||
* they hold the bytes) are preferred over the original uploader so the sharer's
|
||||
* device is not the only upload source.
|
||||
*/
|
||||
export function selectFileRequestPeer(input: FileRequestPeerSelectionInput): string | undefined {
|
||||
const candidates = input.connectedPeers.filter((peerId) => !input.triedPeers.has(peerId));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mirrorHosts = candidates.filter(
|
||||
(peerId) => input.announcedHosts.has(peerId) && peerId !== input.uploaderPeerId
|
||||
);
|
||||
|
||||
if (mirrorHosts.length > 0) {
|
||||
return mirrorHosts[0];
|
||||
}
|
||||
|
||||
if (input.uploaderPeerId && candidates.includes(input.uploaderPeerId)) {
|
||||
return input.uploaderPeerId;
|
||||
}
|
||||
|
||||
const announcedCandidate = candidates.find((peerId) => input.announcedHosts.has(peerId));
|
||||
|
||||
if (announcedCandidate) {
|
||||
return announcedCandidate;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
canHostAttachment,
|
||||
deviceHasLocalCopy,
|
||||
isSharingFromThisDevice,
|
||||
isUploaderUser
|
||||
@@ -66,4 +67,10 @@ describe('attachment sharing rules', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canHostAttachment', () => {
|
||||
it('is true for any device that holds the bytes locally', () => {
|
||||
expect(canHostAttachment({ available: false, savedPath: '/appdata/file.bin' })).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,13 @@ export function isSharingFromThisDevice(
|
||||
return isUploaderUser(attachment, currentUserId) && deviceHasLocalCopy(attachment);
|
||||
}
|
||||
|
||||
/** True when this device can serve the attachment bytes to other peers. */
|
||||
export function canHostAttachment(
|
||||
attachment: Pick<Attachment, 'available' | 'objectUrl' | 'savedPath' | 'filePath'>
|
||||
): boolean {
|
||||
return deviceHasLocalCopy(attachment);
|
||||
}
|
||||
|
||||
function hasNonEmptyString(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('attachment logic', () => {
|
||||
}, 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 = {
|
||||
canStreamToDisk: true,
|
||||
canPersistSize: (bytes: number) => bytes <= 256 * 1024 * 1024
|
||||
@@ -69,6 +69,18 @@ describe('attachment logic', () => {
|
||||
mime: 'application/zip',
|
||||
filePath: undefined
|
||||
}, capabilities)).toBe(true);
|
||||
|
||||
expect(shouldStreamAttachmentReceiveToDisk({
|
||||
size: 3,
|
||||
mime: 'application/zip',
|
||||
filePath: undefined
|
||||
}, capabilities)).toBe(true);
|
||||
|
||||
expect(shouldStreamAttachmentReceiveToDisk({
|
||||
size: 200 * 1024 * 1024,
|
||||
mime: 'application/zip',
|
||||
filePath: '/home/ludde/archive.zip'
|
||||
}, capabilities)).toBe(true);
|
||||
});
|
||||
|
||||
it('receives browser-sized files in memory when disk streaming is unavailable', () => {
|
||||
|
||||
@@ -67,19 +67,11 @@ export function shouldStreamAttachmentReceiveToDisk(
|
||||
attachment: Pick<Attachment, 'size' | 'mime' | 'filePath'>,
|
||||
capabilities: AttachmentReceiveCapabilities
|
||||
): boolean {
|
||||
if (attachment.filePath?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!capabilities.canStreamToDisk || !capabilities.canPersistSize(attachment.size)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isAttachmentMedia(attachment);
|
||||
}
|
||||
|
||||
export function canReceiveAttachmentInMemory(
|
||||
|
||||
@@ -17,6 +17,14 @@ export type FileChunkEvent = ChatEvent & {
|
||||
fromPeerId?: string;
|
||||
};
|
||||
|
||||
export type FileChunkAckEvent = ChatEvent & {
|
||||
type: 'file-chunk-ack';
|
||||
messageId: string;
|
||||
fileId: string;
|
||||
index: number;
|
||||
fromPeerId?: string;
|
||||
};
|
||||
|
||||
export type FileRequestEvent = ChatEvent & {
|
||||
type: 'file-request';
|
||||
messageId: string;
|
||||
@@ -37,7 +45,7 @@ export type FileNotFoundEvent = ChatEvent & {
|
||||
fileId: string;
|
||||
};
|
||||
|
||||
export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file'>;
|
||||
export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file' | 'fromPeerId'>;
|
||||
|
||||
export interface FileChunkPayload {
|
||||
messageId?: string;
|
||||
@@ -48,6 +56,13 @@ export interface FileChunkPayload {
|
||||
data?: ChatEvent['data'];
|
||||
}
|
||||
|
||||
export interface FileChunkAckPayload {
|
||||
messageId?: string;
|
||||
fileId?: string;
|
||||
fromPeerId?: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
|
||||
export type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
|
||||
export type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from './application/facades/attachment.facade';
|
||||
export * from './application/services/attachment-download.service';
|
||||
export * from './domain/constants/attachment.constants';
|
||||
export * from './domain/logic/attachment-download.rules';
|
||||
export * from './domain/logic/attachment-sharing.rules';
|
||||
export * from './domain/logic/local-file-path.rules';
|
||||
export * from './domain/models/attachment.model';
|
||||
|
||||
@@ -187,6 +187,18 @@ export class AttachmentStorageService {
|
||||
return this.store.appendFile(filePath, base64Data);
|
||||
}
|
||||
|
||||
async appendBytes(filePath: string, bytes: Uint8Array): Promise<boolean> {
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.platform.isElectron) {
|
||||
return this.electronStore.appendFileBytes(filePath, bytes);
|
||||
}
|
||||
|
||||
return this.appendBase64(filePath, encodeUint8ArrayToBase64(bytes));
|
||||
}
|
||||
|
||||
async deleteFile(filePath: string): Promise<void> {
|
||||
if (!filePath) {
|
||||
return;
|
||||
|
||||
@@ -74,6 +74,20 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore {
|
||||
}
|
||||
}
|
||||
|
||||
async appendFileBytes(filePath: string, bytes: Uint8Array): Promise<boolean> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi?.appendFileBytes || !filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await electronApi.appendFileBytes(filePath, bytes);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async readFile(filePath: string): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
|
||||
@@ -12,11 +12,14 @@ import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { BottomSheetComponent } from '../../../../shared';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentDownloadService,
|
||||
AttachmentFacade
|
||||
} from '../../../attachment';
|
||||
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||
import {
|
||||
@@ -69,10 +72,10 @@ export class ChatMessagesComponent {
|
||||
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
||||
@ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
|
||||
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
private readonly attachmentDownload = inject(AttachmentDownloadService);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
|
||||
@@ -300,6 +303,7 @@ export class ChatMessagesComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.attachmentsSvc.pinDisplayBlobs(attachments);
|
||||
this.lightboxState.set({
|
||||
attachments,
|
||||
index
|
||||
@@ -307,6 +311,12 @@ export class ChatMessagesComponent {
|
||||
}
|
||||
|
||||
closeLightbox(): void {
|
||||
const state = this.lightboxState();
|
||||
|
||||
if (state) {
|
||||
this.attachmentsSvc.unpinDisplayBlobs(state.attachments);
|
||||
}
|
||||
|
||||
this.lightboxState.set(null);
|
||||
}
|
||||
|
||||
@@ -336,10 +346,17 @@ export class ChatMessagesComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.attachmentsSvc.pinDisplayBlobs(availableImages);
|
||||
this.galleryAttachments.set(availableImages);
|
||||
}
|
||||
|
||||
closeImageGallery(): void {
|
||||
const gallery = this.galleryAttachments();
|
||||
|
||||
if (gallery) {
|
||||
this.attachmentsSvc.unpinDisplayBlobs(gallery);
|
||||
}
|
||||
|
||||
this.galleryAttachments.set(null);
|
||||
}
|
||||
|
||||
@@ -352,46 +369,7 @@ export class ChatMessagesComponent {
|
||||
}
|
||||
|
||||
async downloadAttachment(attachment: Attachment): Promise<void> {
|
||||
if (!attachment.available || !attachment.objectUrl)
|
||||
return;
|
||||
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (electronApi) {
|
||||
const diskPath = this.getAttachmentDiskPath(attachment);
|
||||
|
||||
if (diskPath && electronApi.saveExistingFileAs) {
|
||||
try {
|
||||
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
|
||||
|
||||
if (result.saved || result.cancelled)
|
||||
return;
|
||||
} catch {
|
||||
/* fall back to blob/browser download */
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await this.getAttachmentBlob(attachment);
|
||||
|
||||
if (blob) {
|
||||
try {
|
||||
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
|
||||
|
||||
if (result.saved || result.cancelled)
|
||||
return;
|
||||
} catch {
|
||||
/* fall back to browser download */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = attachment.objectUrl;
|
||||
link.download = attachment.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
await this.attachmentDownload.downloadToUserLocation(attachment);
|
||||
}
|
||||
|
||||
async copyImageToClipboard(attachment: Attachment): Promise<void> {
|
||||
@@ -415,46 +393,6 @@ export class ChatMessagesComponent {
|
||||
return message.senderId === this.currentUser()?.id;
|
||||
}
|
||||
|
||||
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
|
||||
if (!attachment.objectUrl)
|
||||
return null;
|
||||
|
||||
if (attachment.objectUrl.startsWith('file:'))
|
||||
return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
|
||||
return await response.blob();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getAttachmentDiskPath(attachment: Attachment): string | null {
|
||||
return attachment.savedPath || attachment.filePath || null;
|
||||
}
|
||||
|
||||
private blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('Failed to encode attachment'));
|
||||
return;
|
||||
}
|
||||
|
||||
const [, base64 = ''] = reader.result.split(',', 2);
|
||||
|
||||
resolve(base64);
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
private convertToPng(blob: Blob): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (blob.type === 'image/png') {
|
||||
|
||||
@@ -192,6 +192,10 @@
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
|
||||
</div>
|
||||
} @else if (isImagePendingHydration(gridImage)) {
|
||||
<div class="chat-image-grid-cell chat-image-grid-loading">
|
||||
<div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
</div>
|
||||
} @else if ((gridImage.receivedBytes || 0) > 0) {
|
||||
<div class="chat-image-grid-cell chat-image-grid-loading">
|
||||
<ng-icon
|
||||
@@ -234,7 +238,7 @@
|
||||
@for (att of attachmentsList; track att.id) {
|
||||
@if (shouldShowAttachmentInList(att)) {
|
||||
@if (isImageLikeAttachment(att) && !imageGridLayout().useGrid) {
|
||||
@if (att.available && att.objectUrl) {
|
||||
@if (isDisplayableImage(att)) {
|
||||
<div
|
||||
class="group/img relative inline-block"
|
||||
(contextmenu)="openImageContextMenu($event, att)"
|
||||
@@ -269,6 +273,13 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (isImagePendingHydration(att)) {
|
||||
<div
|
||||
appThemeNode="chatAttachmentCard"
|
||||
class="flex max-h-80 min-h-32 min-w-48 items-center justify-center rounded-md border border-border bg-secondary/40 p-6"
|
||||
>
|
||||
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
</div>
|
||||
} @else if ((att.receivedBytes || 0) > 0) {
|
||||
<div
|
||||
appThemeNode="chatAttachmentCard"
|
||||
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
OnDestroy,
|
||||
AfterViewInit,
|
||||
output,
|
||||
signal,
|
||||
TemplateRef,
|
||||
@@ -44,9 +45,11 @@ import {
|
||||
} from '../../../../../attachment';
|
||||
import {
|
||||
dedupeImageAttachmentsForDisplay,
|
||||
isAttachmentPendingInlineHydration,
|
||||
isImageAttachment,
|
||||
isInlineDisplayableImage
|
||||
} from '../../../../../attachment/domain/logic/attachment-image.rules';
|
||||
import { ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN } from '../../../../../attachment/domain/logic/attachment-blob-eviction.rules';
|
||||
import { PlatformService, ViewportService } from '../../../../../../core/platform';
|
||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||
import { ExperimentalMediaSettingsService } from '../../../../../experimental-media';
|
||||
@@ -168,10 +171,11 @@ interface MissingPluginEmbedFallback {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
})
|
||||
export class ChatMessageItemComponent implements OnDestroy {
|
||||
export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
|
||||
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
|
||||
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly pluginRequirements = inject(PluginRequirementStateService);
|
||||
@@ -188,6 +192,8 @@ export class ChatMessageItemComponent implements OnDestroy {
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private mobileSheetOverlayRef: OverlayRef | null = null;
|
||||
private longPressTimer: number | null = null;
|
||||
private visibilityObserver: IntersectionObserver | null = null;
|
||||
private readonly isMessageVisible = signal(false);
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
readonly mobileSheetOpen = signal(false);
|
||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||
@@ -264,12 +270,17 @@ export class ChatMessageItemComponent implements OnDestroy {
|
||||
const images = this.imageAttachments();
|
||||
|
||||
void this.attachmentVersion();
|
||||
const isVisible = this.isMessageVisible();
|
||||
|
||||
for (const image of images) {
|
||||
if (isInlineDisplayableImage(image)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isAttachmentPendingInlineHydration(image)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const liveAttachment = this.getLiveAttachment(image.id);
|
||||
|
||||
if (!liveAttachment) {
|
||||
@@ -279,7 +290,11 @@ export class ChatMessageItemComponent implements OnDestroy {
|
||||
void this.attachmentsSvc.tryRestoreAttachmentFromLocal(liveAttachment);
|
||||
}
|
||||
|
||||
if (images.some((image) => !isInlineDisplayableImage(image))) {
|
||||
if (!isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (images.some((image) => !isInlineDisplayableImage(image) && !isAttachmentPendingInlineHydration(image))) {
|
||||
void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId);
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
this.visibilityObserver?.disconnect();
|
||||
this.visibilityObserver = null;
|
||||
|
||||
if (this.isMessageVisible()) {
|
||||
this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(this.message().id);
|
||||
}
|
||||
|
||||
this.clearLongPressTimer();
|
||||
this.detachMobileSheet();
|
||||
}
|
||||
@@ -772,6 +847,10 @@ export class ChatMessageItemComponent implements OnDestroy {
|
||||
return isInlineDisplayableImage(attachment);
|
||||
}
|
||||
|
||||
isImagePendingHydration(attachment: ChatMessageAttachmentViewModel): boolean {
|
||||
return isAttachmentPendingInlineHydration(attachment);
|
||||
}
|
||||
|
||||
isImageLikeAttachment(attachment: ChatMessageAttachmentViewModel): boolean {
|
||||
return isImageAttachment(attachment);
|
||||
}
|
||||
@@ -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 {
|
||||
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Store } from '@ngrx/store';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { map } from 'rxjs';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import {
|
||||
BottomSheetComponent,
|
||||
@@ -23,7 +22,11 @@ import {
|
||||
UserAvatarComponent
|
||||
} from '../../../../shared';
|
||||
import { DirectCallService } from '../../../direct-call';
|
||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentDownloadService,
|
||||
AttachmentFacade
|
||||
} from '../../../attachment';
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { isConversationBound } from './dm-chat.rules';
|
||||
@@ -88,7 +91,7 @@ export class DmChatComponent {
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly store = inject(Store);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly attachmentDownload = inject(AttachmentDownloadService);
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly linkMetadata = inject(LinkMetadataService);
|
||||
@@ -485,49 +488,7 @@ export class DmChatComponent {
|
||||
}
|
||||
|
||||
async downloadAttachment(attachment: Attachment): Promise<void> {
|
||||
if (!attachment.available || !attachment.objectUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (electronApi) {
|
||||
const diskPath = this.getAttachmentDiskPath(attachment);
|
||||
|
||||
if (diskPath && electronApi.saveExistingFileAs) {
|
||||
try {
|
||||
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
|
||||
|
||||
if (result.saved || result.cancelled) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall back to blob/browser download */
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await this.getAttachmentBlob(attachment);
|
||||
|
||||
if (blob) {
|
||||
try {
|
||||
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
|
||||
|
||||
if (result.saved || result.cancelled) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall back to browser download */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = attachment.objectUrl;
|
||||
link.download = attachment.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
await this.attachmentDownload.downloadToUserLocation(attachment);
|
||||
}
|
||||
|
||||
async copyImageToClipboard(attachment: Attachment): Promise<void> {
|
||||
@@ -599,48 +560,6 @@ export class DmChatComponent {
|
||||
return `${messageId}:${url}`;
|
||||
}
|
||||
|
||||
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
|
||||
if (!attachment.objectUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (attachment.objectUrl.startsWith('file:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
|
||||
return await response.blob();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getAttachmentDiskPath(attachment: Attachment): string | null {
|
||||
return attachment.savedPath || attachment.filePath || null;
|
||||
}
|
||||
|
||||
private blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('Failed to encode attachment'));
|
||||
return;
|
||||
}
|
||||
|
||||
const [, base64 = ''] = reader.result.split(',', 2);
|
||||
|
||||
resolve(base64);
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
|
||||
if (conversation.kind === 'group' || conversation.participants.length > 2) {
|
||||
return null;
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('CreateServerDialogComponent', () => {
|
||||
|
||||
component.create();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/login']);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/login'], { queryParams: {} });
|
||||
expect(dispatch.mock.calls.some(([entry]) => entry.type === RoomsActions.createRoom.type)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
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 type { ServerInfo } from '../../domain/models/server-directory.model';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
@@ -100,6 +101,9 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
sendRawMessageToSignalUrl: vi.fn()
|
||||
} as unknown as RealtimeSessionFacade;
|
||||
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({
|
||||
providers: [
|
||||
ServerBrowserComponent,
|
||||
@@ -111,6 +115,7 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
{ provide: RealtimeSessionFacade, useValue: webrtc },
|
||||
{ provide: PluginRequirementService, useValue: pluginRequirements },
|
||||
{ provide: PluginStoreService, useValue: pluginStore },
|
||||
{ provide: SignalServerAuthorizeService, useValue: signalServerAuthorize },
|
||||
...provideAppI18nForTests()
|
||||
]
|
||||
});
|
||||
|
||||
@@ -43,6 +43,18 @@
|
||||
</div>
|
||||
<span class="font-mono text-sm text-foreground">{{ ramLabel() ?? '-' }}</span>
|
||||
</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>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { interval } from 'rxjs';
|
||||
import { startWith, switchMap } from 'rxjs/operators';
|
||||
|
||||
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 { formatAppRamLabel } from '../../../../core/platform/electron/electron-app-metrics.rules';
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
@@ -53,10 +54,12 @@ export class DebuggingSettingsComponent {
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
readonly debugging = inject(DebuggingService);
|
||||
private readonly highMemoryAlert = inject(DesktopHighMemoryAlertService);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
|
||||
readonly isElectron = this.platform.isElectron;
|
||||
readonly ramLabel = signal<string | null>(null);
|
||||
readonly isExportingRamDiagnostics = signal(false);
|
||||
readonly enabled = this.debugging.enabled;
|
||||
readonly isConsoleOpen = this.debugging.isConsoleOpen;
|
||||
readonly entryCount = computed(() => {
|
||||
@@ -97,6 +100,20 @@ export class DebuggingSettingsComponent {
|
||||
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 {
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
|
||||
@@ -5,14 +5,20 @@
|
||||
(dismissed)="dismiss()"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-[121] flex items-center justify-center px-4 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
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"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="'high-memory-alert-title'"
|
||||
[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
|
||||
type="button"
|
||||
(click)="dismiss()"
|
||||
@@ -33,14 +39,14 @@
|
||||
id="high-memory-alert-title"
|
||||
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>
|
||||
|
||||
<p
|
||||
id="high-memory-alert-description"
|
||||
class="mt-2 pr-2 text-sm leading-6 text-muted-foreground"
|
||||
>
|
||||
{{ 'app.highMemoryAlert.message' | translate }}
|
||||
{{ alertService.messageKey() | translate }}
|
||||
</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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,38 @@ const SAMPLE_INTERVAL_MS = 10_000;
|
||||
|
||||
let started = false;
|
||||
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(
|
||||
api: ElectronApi,
|
||||
injector: EnvironmentInjector
|
||||
): Promise<void> {
|
||||
registerImmediatePerfDiagCollector(injector);
|
||||
|
||||
const reportSample = api.reportPerfDiagSample;
|
||||
|
||||
if (started || !api.isPerfDiagEnabled || !reportSample) {
|
||||
@@ -41,22 +68,6 @@ export async function bootstrapPerfDiagnostics(
|
||||
|
||||
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 = {
|
||||
report: (entry: PerfDiagEntry) => reportSample(entry)
|
||||
};
|
||||
@@ -113,6 +124,12 @@ function stopPerfDiagnosticsSampling(): void {
|
||||
sampleTimer = null;
|
||||
}
|
||||
|
||||
delete globalThis.__collectPerfDiagSample;
|
||||
started = false;
|
||||
}
|
||||
|
||||
/** @internal Resets module state between unit tests. */
|
||||
export function resetDiagnosticsBootstrapStateForTests(): void {
|
||||
stopPerfDiagnosticsSampling();
|
||||
immediateCollectorRegistered = false;
|
||||
delete globalThis.__collectPerfDiagSample;
|
||||
}
|
||||
|
||||
@@ -161,6 +161,13 @@ export interface FileChunkChatEvent extends ChatEventBase {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface FileChunkAckChatEvent extends ChatEventBase {
|
||||
type: 'file-chunk-ack';
|
||||
messageId: string;
|
||||
fileId: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface FileRequestChatEvent extends ChatEventBase {
|
||||
type: 'file-request';
|
||||
messageId: string;
|
||||
@@ -498,6 +505,7 @@ export type ChatEvent =
|
||||
| ReactionRemovedEvent
|
||||
| FileAnnounceChatEvent
|
||||
| FileChunkChatEvent
|
||||
| FileChunkAckChatEvent
|
||||
| FileRequestChatEvent
|
||||
| FileCancelChatEvent
|
||||
| FileNotFoundChatEvent
|
||||
|
||||
@@ -59,6 +59,7 @@ type IncomingMessageType =
|
||||
| 'chat-sync-full'
|
||||
| 'file-announce'
|
||||
| 'file-chunk'
|
||||
| 'file-chunk-ack'
|
||||
| 'file-request'
|
||||
| 'file-cancel'
|
||||
| 'file-not-found';
|
||||
@@ -583,10 +584,6 @@ function handleFileAnnounce(
|
||||
): Observable<Action> {
|
||||
attachments.handleFileAnnounce(event);
|
||||
|
||||
if (event.messageId) {
|
||||
attachments.queueAutoDownloadsForMessage(event.messageId, event.file?.id);
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
@@ -598,6 +595,14 @@ function handleFileChunk(
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
function handleFileChunkAck(
|
||||
event: IncomingMessageEvent,
|
||||
{ attachments }: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
attachments.handleFileChunkAck(event);
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
function handleFileRequest(
|
||||
event: IncomingMessageEvent,
|
||||
{ attachments }: IncomingMessageContext
|
||||
@@ -736,6 +741,7 @@ const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
|
||||
// Attachments
|
||||
'file-announce': handleFileAnnounce,
|
||||
'file-chunk': handleFileChunk,
|
||||
'file-chunk-ack': handleFileChunkAck,
|
||||
'file-request': handleFileRequest,
|
||||
'file-cancel': handleFileCancel,
|
||||
'file-not-found': handleFileNotFound,
|
||||
|
||||
@@ -30,12 +30,19 @@ bootstrapApplication(App, appConfig)
|
||||
.then(async (appRef) => {
|
||||
const api = getElectronApi();
|
||||
|
||||
if (!api?.isPerfDiagEnabled) {
|
||||
if (!api) {
|
||||
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);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
|
||||
Reference in New Issue
Block a user