From bb0ac930add8fd2629df64cad984eb5d8351b9dd Mon Sep 17 00:00:00 2001 From: Myx Date: Sun, 14 Jun 2026 00:25:22 +0200 Subject: [PATCH] Improve attachment memory safety, downloads, and high-memory alert UX. 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 --- electron/app/lifecycle.ts | 4 + electron/diagnostics/diagnostics.lifecycle.ts | 158 ++++++------ .../high-memory-alert.store.spec.ts | 3 +- .../diagnostics/high-memory-alert.store.ts | 8 +- .../diagnostics/high-memory-capture.spec.ts | 57 +++++ electron/diagnostics/high-memory-capture.ts | 80 ++++++ electron/diagnostics/index.ts | 3 + electron/ipc/file-read.rules.spec.ts | 16 ++ electron/ipc/file-read.rules.ts | 6 + electron/ipc/system.ts | 54 +++- electron/path-jail.spec.ts | 11 + electron/path-jail.ts | 3 +- electron/preload.ts | 35 +++ toju-app/public/i18n/catalog/app.json | 6 +- toju-app/public/i18n/catalog/settings.json | 4 +- toju-app/public/i18n/en.json | 10 +- .../platform/electron/electron-api.models.ts | 4 + .../src/app/core/platform/platform.service.ts | 10 +- .../desktop-high-memory-alert.service.spec.ts | 164 ++++++++++++ .../desktop-high-memory-alert.service.ts | 90 ++++++- .../high-memory-alert-copy.rules.spec.ts | 47 ++++ .../services/high-memory-alert-copy.rules.ts | 21 ++ toju-app/src/app/domains/attachment/README.md | 17 +- .../application/facades/attachment.facade.ts | 24 ++ .../attachment-chunk-ack.service.spec.ts | 37 +++ .../services/attachment-chunk-ack.service.ts | 47 ++++ .../attachment-download.service.spec.ts | 97 ++++++++ .../services/attachment-download.service.ts | 97 ++++++++ .../services/attachment-manager.service.ts | 55 ++++- .../attachment-persistence.service.spec.ts | 59 ++++- .../attachment-persistence.service.ts | 42 +++- .../services/attachment-runtime.store.ts | 26 ++ .../attachment-transfer-transport.service.ts | 5 + .../attachment-transfer.service.spec.ts | 213 +++++++++++++++- .../services/attachment-transfer.service.ts | 233 ++++++++++++------ .../attachment-blob-eviction.rules.spec.ts | 61 +++++ .../logic/attachment-blob-eviction.rules.ts | 50 ++++ .../logic/attachment-blob.rules.spec.ts | 10 +- .../domain/logic/attachment-blob.rules.ts | 7 + .../logic/attachment-chunk-ack.rules.spec.ts | 13 + .../logic/attachment-chunk-ack.rules.ts | 3 + .../logic/attachment-download.rules.spec.ts | 44 ++++ .../domain/logic/attachment-download.rules.ts | 25 ++ .../logic/attachment-image.rules.spec.ts | 22 ++ .../domain/logic/attachment-image.rules.ts | 22 ++ .../logic/attachment-request.rules.spec.ts | 47 ++++ .../domain/logic/attachment-request.rules.ts | 39 +++ .../logic/attachment-sharing.rules.spec.ts | 7 + .../domain/logic/attachment-sharing.rules.ts | 7 + .../domain/logic/attachment.logic.spec.ts | 14 +- .../domain/logic/attachment.logic.ts | 10 +- .../models/attachment-transfer.model.ts | 17 +- toju-app/src/app/domains/attachment/index.ts | 2 + .../services/attachment-storage.service.ts | 12 + .../electron-attachment-file-store.ts | 14 ++ .../chat-messages/chat-messages.component.ts | 104 ++------ .../chat-message-item.component.html | 13 +- .../chat-message-item.component.ts | 100 +++++++- .../feature/dm-chat/dm-chat.component.ts | 95 +------ .../create-server-dialog.component.spec.ts | 2 +- .../server-browser.component.spec.ts | 5 + .../debugging-settings.component.html | 12 + .../debugging-settings.component.ts | 17 ++ .../high-memory-alert-modal.component.html | 22 +- .../diagnostics/diagnostics.bootstrap.spec.ts | 108 ++++++++ .../diagnostics/diagnostics.bootstrap.ts | 51 ++-- toju-app/src/app/shared-kernel/chat-events.ts | 8 + .../messages/messages-incoming.handlers.ts | 14 +- toju-app/src/main.ts | 13 +- 69 files changed, 2306 insertions(+), 430 deletions(-) create mode 100644 electron/diagnostics/high-memory-capture.spec.ts create mode 100644 electron/diagnostics/high-memory-capture.ts create mode 100644 electron/ipc/file-read.rules.spec.ts create mode 100644 electron/ipc/file-read.rules.ts create mode 100644 toju-app/src/app/core/services/desktop-high-memory-alert.service.spec.ts create mode 100644 toju-app/src/app/core/services/high-memory-alert-copy.rules.spec.ts create mode 100644 toju-app/src/app/core/services/high-memory-alert-copy.rules.ts create mode 100644 toju-app/src/app/domains/attachment/application/services/attachment-chunk-ack.service.spec.ts create mode 100644 toju-app/src/app/domains/attachment/application/services/attachment-chunk-ack.service.ts create mode 100644 toju-app/src/app/domains/attachment/application/services/attachment-download.service.spec.ts create mode 100644 toju-app/src/app/domains/attachment/application/services/attachment-download.service.ts create mode 100644 toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.spec.ts create mode 100644 toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.ts create mode 100644 toju-app/src/app/domains/attachment/domain/logic/attachment-chunk-ack.rules.spec.ts create mode 100644 toju-app/src/app/domains/attachment/domain/logic/attachment-chunk-ack.rules.ts create mode 100644 toju-app/src/app/domains/attachment/domain/logic/attachment-download.rules.spec.ts create mode 100644 toju-app/src/app/domains/attachment/domain/logic/attachment-download.rules.ts create mode 100644 toju-app/src/app/domains/attachment/domain/logic/attachment-request.rules.spec.ts create mode 100644 toju-app/src/app/domains/attachment/domain/logic/attachment-request.rules.ts create mode 100644 toju-app/src/app/infrastructure/diagnostics/diagnostics.bootstrap.spec.ts diff --git a/electron/app/lifecycle.ts b/electron/app/lifecycle.ts index 3b0b372..d95b294 100644 --- a/electron/app/lifecycle.ts +++ b/electron/app/lifecycle.ts @@ -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) { diff --git a/electron/diagnostics/diagnostics.lifecycle.ts b/electron/diagnostics/diagnostics.lifecycle.ts index 8b65807..edfaf5a 100644 --- a/electron/diagnostics/diagnostics.lifecycle.ts +++ b/electron/diagnostics/diagnostics.lifecycle.ts @@ -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 { } 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 { @@ -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 + metrics, + totalWorkingSetKb: totalWorkingSetKb ?? 0, + writer: activeWriter, + mainWindow: getMainWindow(), + reason: 'threshold' }); - for (const entry of immediateRendererEntries) { - writer.append(entry); - } + await persistAndNotifyHighMemoryAlert(record); +} - writer.append({ - collectedAt: detectedAt, - source: 'main', - type: 'environment', - payload: { - ...environment - } - }); +async function persistAndNotifyHighMemoryAlert(record: HighMemoryAlertRecord): Promise { + await writeHighMemoryAlert(app.getPath('userData'), record); + notifyHighMemoryAlert(record); +} - 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 - }) - }); - - await writer.flushSnapshot('high-memory-threshold'); - - await writeHighMemoryAlert(userDataPath, { - logFilePath: writer.snapshotFilePath, - detectedAt, - peakWorkingSetKb: totalWorkingSetKb ?? 0, - sessionId: writer.sessionId - }); +function notifyHighMemoryAlert(record: HighMemoryAlertRecord): void { + getMainWindow()?.webContents.send(HIGH_MEMORY_ALERT_PENDING_CHANNEL, record); } function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry { diff --git a/electron/diagnostics/high-memory-alert.store.spec.ts b/electron/diagnostics/high-memory-alert.store.spec.ts index ed27478..dcffe02 100644 --- a/electron/diagnostics/high-memory-alert.store.spec.ts +++ b/electron/diagnostics/high-memory-alert.store.spec.ts @@ -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); diff --git a/electron/diagnostics/high-memory-alert.store.ts b/electron/diagnostics/high-memory-alert.store.ts index 3f5cdfd..b7bffd5 100644 --- a/electron/diagnostics/high-memory-alert.store.ts +++ b/electron/diagnostics/high-memory-alert.store.ts @@ -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 ({ + 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(); + }); +}); diff --git a/electron/diagnostics/high-memory-capture.ts b/electron/diagnostics/high-memory-capture.ts new file mode 100644 index 0000000..a55fc53 --- /dev/null +++ b/electron/diagnostics/high-memory-capture.ts @@ -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 { + 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); + } +} diff --git a/electron/diagnostics/index.ts b/electron/diagnostics/index.ts index 9c68d28..79f98ac 100644 --- a/electron/diagnostics/index.ts +++ b/electron/diagnostics/index.ts @@ -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'; diff --git a/electron/ipc/file-read.rules.spec.ts b/electron/ipc/file-read.rules.spec.ts new file mode 100644 index 0000000..88c8b83 --- /dev/null +++ b/electron/ipc/file-read.rules.spec.ts @@ -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); + }); +}); diff --git a/electron/ipc/file-read.rules.ts b/electron/ipc/file-read.rules.ts new file mode 100644 index 0000000..45a9a80 --- /dev/null +++ b/electron/ipc/file-read.rules.ts @@ -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): boolean { + return stats.isFile(); +} diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 327502c..96fd4ac 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -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; } - const data = await fsp.readFile(scopedPath); + try { + const stats = await fsp.stat(scopedPath); - return data.toString('base64'); + if (!isReadableRegularFile(stats)) { + return null; + } + + const data = await fsp.readFile(scopedPath); + + return data.toString('base64'); + } catch { + return null; + } }); ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => { @@ -666,17 +677,27 @@ export function setupSystemHandlers(): void { return null; } - const fileHandle = await fsp.open(scopedPath, 'r'); - try { - const safeStart = Math.max(0, Math.trunc(start)); - const safeEnd = Math.max(safeStart, Math.trunc(end)); - const buffer = Buffer.alloc(safeEnd - safeStart); - const result = await fileHandle.read(buffer, 0, buffer.length, safeStart); + const stats = await fsp.stat(scopedPath); - return buffer.subarray(0, result.bytesRead).toString('base64'); - } finally { - await fileHandle.close(); + if (!isReadableRegularFile(stats)) { + return null; + } + + const fileHandle = await fsp.open(scopedPath, 'r'); + + try { + const safeStart = Math.max(0, Math.trunc(start)); + const safeEnd = Math.max(safeStart, Math.trunc(end)); + const buffer = Buffer.alloc(safeEnd - safeStart); + const result = await fileHandle.read(buffer, 0, buffer.length, safeStart); + + return buffer.subarray(0, result.bytesRead).toString('base64'); + } finally { + await fileHandle.close(); + } + } catch { + return null; } }); @@ -728,6 +749,17 @@ export function setupSystemHandlers(): void { return true; }); + 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); diff --git a/electron/path-jail.spec.ts b/electron/path-jail.spec.ts index 1ed6a29..e4e7b46 100644 --- a/electron/path-jail.spec.ts +++ b/electron/path-jail.spec.ts @@ -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'); diff --git a/electron/path-jail.ts b/electron/path-jail.ts index 2d6358d..ca1980e 100644 --- a/electron/path-jail.ts +++ b/electron/path-jail.ts @@ -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 { diff --git a/electron/preload.ts b/electron/preload.ts index 6259ced..fec9856 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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; + 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; openCurrentDataFolder: () => Promise; @@ -335,6 +351,7 @@ export interface ElectronAPI { grantPluginReadRoot: (rootPath: string) => Promise; writeFile: (filePath: string, data: string) => Promise; appendFile: (filePath: string, data: string) => Promise; + appendFileBytes: (filePath: string, data: Uint8Array) => Promise; 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), diff --git a/toju-app/public/i18n/catalog/app.json b/toju-app/public/i18n/catalog/app.json index 63d5027..179ee78 100644 --- a/toju-app/public/i18n/catalog/app.json +++ b/toju-app/public/i18n/catalog/app.json @@ -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", diff --git a/toju-app/public/i18n/catalog/settings.json b/toju-app/public/i18n/catalog/settings.json index a42e812..29440fb 100644 --- a/toju-app/public/i18n/catalog/settings.json +++ b/toju-app/public/i18n/catalog/settings.json @@ -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", diff --git a/toju-app/public/i18n/en.json b/toju-app/public/i18n/en.json index cd1acec..efbdac5 100644 --- a/toju-app/public/i18n/en.json +++ b/toju-app/public/i18n/en.json @@ -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", diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index fad87a7..803b331 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -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; getPendingHighMemoryAlert?: () => Promise; acknowledgeHighMemoryAlert?: () => Promise; + exportHighMemoryDiagnostics?: () => Promise; + onHighMemoryAlertPending?: (listener: (alert: ElectronHighMemoryAlertRecord) => void) => () => void; showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>; getAppDataPath: () => Promise; openCurrentDataFolder: () => Promise; @@ -319,6 +322,7 @@ export interface ElectronApi { grantPluginReadRoot?: (rootPath: string) => Promise; writeFile: (filePath: string, data: string) => Promise; appendFile: (filePath: string, data: string) => Promise; + appendFileBytes: (filePath: string, data: Uint8Array) => Promise; 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 }>; diff --git a/toju-app/src/app/core/platform/platform.service.ts b/toju-app/src/app/core/platform/platform.service.ts index 42bbbdc..b6322b7 100644 --- a/toju-app/src/app/core/platform/platform.service.ts +++ b/toju-app/src/app/core/platform/platform.service.ts @@ -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; + } } diff --git a/toju-app/src/app/core/services/desktop-high-memory-alert.service.spec.ts b/toju-app/src/app/core/services/desktop-high-memory-alert.service.spec.ts new file mode 100644 index 0000000..f2b975d --- /dev/null +++ b/toju-app/src/app/core/services/desktop-high-memory-alert.service.spec.ts @@ -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; + }; + 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'); + }); +}); diff --git a/toju-app/src/app/core/services/desktop-high-memory-alert.service.ts b/toju-app/src/app/core/services/desktop-high-memory-alert.service.ts index aa2359a..09524ba 100644 --- a/toju-app/src/app/core/services/desktop-high-memory-alert.service.ts +++ b/toju-app/src/app/core/services/desktop-high-memory-alert.service.ts @@ -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(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 { - 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 { + const api = this.electronBridge.getApi(); + const alert = await api?.exportHighMemoryDiagnostics?.(); + + if (!alert) { + return false; + } + + this.pendingAlert.set(alert); + + return true; + } + async dismiss(): Promise { const api = this.electronBridge.getApi(); @@ -70,13 +106,49 @@ export class DesktopHighMemoryAlertService { await api.showLogFileInFolder(alert.logFilePath); } - async copyLogPath(): Promise { + async copyLogPath(): Promise { 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 { + 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; } } diff --git a/toju-app/src/app/core/services/high-memory-alert-copy.rules.spec.ts b/toju-app/src/app/core/services/high-memory-alert-copy.rules.spec.ts new file mode 100644 index 0000000..8c71f27 --- /dev/null +++ b/toju-app/src/app/core/services/high-memory-alert-copy.rules.spec.ts @@ -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'); + }); +}); diff --git a/toju-app/src/app/core/services/high-memory-alert-copy.rules.ts b/toju-app/src/app/core/services/high-memory-alert-copy.rules.ts new file mode 100644 index 0000000..ec89810 --- /dev/null +++ b/toju-app/src/app/core/services/high-memory-alert-copy.rules.ts @@ -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'; +} diff --git a/toju-app/src/app/domains/attachment/README.md b/toju-app/src/app/domains/attachment/README.md index 57b6d25..8ada65c 100644 --- a/toju-app/src/app/domains/attachment/README.md +++ b/toju-app/src/app/domains/attachment/README.md @@ -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`). diff --git a/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts b/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts index e4a0cae..a9cede9 100644 --- a/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts +++ b/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts @@ -75,6 +75,24 @@ export class AttachmentFacade { return this.manager.tryRestoreAttachmentFromLocal(...args); } + pinDisplayBlobs( + ...args: Parameters + ): ReturnType { + return this.manager.pinDisplayBlobs(...args); + } + + unpinDisplayBlobs( + ...args: Parameters + ): ReturnType { + return this.manager.unpinDisplayBlobs(...args); + } + + revokeOffscreenDisplayBlobsForMessage( + ...args: Parameters + ): ReturnType { + return this.manager.revokeOffscreenDisplayBlobsForMessage(...args); + } + requestFile( ...args: Parameters ): ReturnType { @@ -99,6 +117,12 @@ export class AttachmentFacade { return this.manager.handleFileChunk(...args); } + handleFileChunkAck( + ...args: Parameters + ): ReturnType { + return this.manager.handleFileChunkAck(...args); + } + handleFileRequest( ...args: Parameters ): ReturnType { diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-chunk-ack.service.spec.ts b/toju-app/src/app/domains/attachment/application/services/attachment-chunk-ack.service.spec.ts new file mode 100644 index 0000000..929fa4e --- /dev/null +++ b/toju-app/src/app/domains/attachment/application/services/attachment-chunk-ack.service.spec.ts @@ -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(); + }); +}); diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-chunk-ack.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-chunk-ack.service.ts new file mode 100644 index 0000000..a1c5bf1 --- /dev/null +++ b/toju-app/src/app/domains/attachment/application/services/attachment-chunk-ack.service.ts @@ -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 void>(); + + waitForAck( + messageId: string, + fileId: string, + index: number, + timeoutMs = 60_000 + ): Promise { + 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); + } + } +} diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-download.service.spec.ts b/toju-app/src/app/domains/attachment/application/services/attachment-download.service.spec.ts new file mode 100644 index 0000000..2bd4da0 --- /dev/null +++ b/toju-app/src/app/domains/attachment/application/services/attachment-download.service.spec.ts @@ -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; + }; + let documentStub: Document; + let saveExistingFileAs: ReturnType; + let saveFileAs: ReturnType; + + 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(); + }); +}); diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-download.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-download.service.ts new file mode 100644 index 0000000..4e7f7d3 --- /dev/null +++ b/toju-app/src/app/domains/attachment/application/services/attachment-download.service.ts @@ -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 { + 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 { + 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 { + 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); + }); + } +} diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts index 296a432..682253a 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts @@ -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>(); + private pinnedDisplayBlobKeys = new Set(); constructor() { effect(() => { @@ -160,6 +163,48 @@ export class AttachmentManagerService { return restored; } + pinDisplayBlobs(attachments: readonly Pick[]): void { + for (const attachment of attachments) { + if (!attachment.messageId || !attachment.id) { + continue; + } + + this.pinnedDisplayBlobKeys.add(buildAttachmentDisplayPinKey(attachment.messageId, attachment.id)); + } + } + + unpinDisplayBlobs(attachments: readonly Pick[]): 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 { 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 { 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(); } diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.spec.ts b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.spec.ts index 2363a87..6476ce4 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.spec.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.spec.ts @@ -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(); + }); }); diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts index d9cd06a..d95b5db 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts @@ -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 { - 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 { + async tryRestoreAttachmentHostOnly(attachment: Attachment): Promise { + 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 { + 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 { + private async hydrateAttachmentFromStoredPath( + attachment: Attachment, + diskPath: string, + hydrateMediaForDisplay = true + ): Promise { 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 { diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-runtime.store.ts b/toju-app/src/app/domains/attachment/application/services/attachment-runtime.store.ts index 3644b8e..852fd02 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-runtime.store.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-runtime.store.ts @@ -12,6 +12,7 @@ export class AttachmentRuntimeStore { private pendingRequests = new Map>(); private chunkBuffers = new Map(); private chunkCounts = new Map(); + private announcedHostsByAttachment = new Map>(); 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(); + + hosts.add(peerId); + this.announcedHostsByAttachment.set(requestKey, hosts); + } + + getAnnouncedHosts(requestKey: string): Set { + 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); + } + } } } diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer-transport.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer-transport.service.ts index 2d6752e..ec8145c 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer-transport.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer-transport.service.ts @@ -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); } } } diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts index c62fcd7..86b35ed 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts @@ -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; resolveLegacyImagePath: ReturnType; appendBase64: ReturnType; + appendBytes: ReturnType; createWritableFile: ReturnType; deleteFile: ReturnType; }; @@ -60,6 +62,11 @@ describe('AttachmentTransferService', () => { streamFileToPeer: ReturnType; streamFileFromDiskToPeer: ReturnType; }; + let chunkAcks: { + resolveAck: ReturnType; + waitForAck: ReturnType; + cancelPendingForFile: ReturnType; + }; let webrtc: { getConnectedPeers: ReturnType; broadcastMessage: ReturnType; @@ -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((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'); + }); }); diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts index a936d97..1f77afd 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts @@ -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(); - private readonly diskReceiveChains = new Map>(); + private readonly diskReceiveLocks = new Map>(); private readonly activeOutboundTransfers = new Set(); getAttachmentMetasForMessages(messageIds: string[]): Record { @@ -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 { @@ -511,21 +541,6 @@ export class AttachmentTransferService { fromPeerId: string ): Promise { 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(); - - 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,24 +829,64 @@ export class AttachmentTransferService { continue; } - const fileAnnounceEvent: FileAnnounceEvent = { - type: 'file-announce', - messageId: attachment.messageId, - file: { - id: attachment.id, - filename: attachment.filename, - size: attachment.size, - mime: attachment.mime, - isImage: attachment.isImage, - uploaderPeerId: attachment.uploaderPeerId - } - }; - - this.webrtc.broadcastMessage(fileAnnounceEvent); + 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 { + if (!canHostAttachment(attachment)) { + return; + } + + const announcingPeerId = hostPeerId ?? await this.resolveCurrentUserId(); + + if (!announcingPeerId) { + return; + } + + this.runtimeStore.addAnnouncedHost( + this.buildRequestKey(attachment.messageId, attachment.id), + announcingPeerId + ); + + const fileAnnounceEvent: FileAnnounceEvent = { + type: 'file-announce', + messageId: attachment.messageId, + file: { + id: attachment.id, + filename: attachment.filename, + size: attachment.size, + mime: attachment.mime, + isImage: attachment.isImage, + uploaderPeerId: attachment.uploaderPeerId + } + }; + + this.webrtc.broadcastMessage(fileAnnounceEvent); + } + private async applySavedPathObjectUrl(attachment: Attachment, savedPath: string | null): Promise { if (!savedPath) { return; @@ -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): 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 { - 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( diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.spec.ts new file mode 100644 index 0000000..8d47f43 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.spec.ts @@ -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); + }); +}); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.ts new file mode 100644 index 0000000..dddfe50 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.ts @@ -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 +): 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; +} diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts index ffaa95f..37b9634 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.spec.ts @@ -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); + }); }); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.ts index 2864c86..9fb0daf 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob.rules.ts @@ -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 { return new Promise((resolve) => { diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-chunk-ack.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-chunk-ack.rules.spec.ts new file mode 100644 index 0000000..69cfaf8 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-chunk-ack.rules.spec.ts @@ -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'); + }); +}); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-chunk-ack.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-chunk-ack.rules.ts new file mode 100644 index 0000000..9360cbb --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-chunk-ack.rules.ts @@ -0,0 +1,3 @@ +export function buildAttachmentChunkAckKey(messageId: string, fileId: string, index: number): string { + return `${messageId}:${fileId}:${index}`; +} diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-download.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-download.rules.spec.ts new file mode 100644 index 0000000..40e5d14 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-download.rules.spec.ts @@ -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'); + }); +}); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-download.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-download.rules.ts new file mode 100644 index 0000000..631b5c9 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-download.rules.ts @@ -0,0 +1,25 @@ +import type { Attachment } from '../models/attachment.model'; + +export function canDownloadAttachment( + attachment: Pick +): boolean { + if (attachment.available !== true) { + return false; + } + + return hasNonEmptyString(attachment.objectUrl) || + hasNonEmptyString(attachment.savedPath) || + hasNonEmptyString(attachment.filePath); +} + +export function resolveAttachmentDiskPath( + attachment: Pick +): 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; +} diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.spec.ts index f134f57..79b94c5 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.spec.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.spec.ts @@ -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([ { diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.ts index bcf01b0..89b7090 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-image.rules.ts @@ -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 ): number { diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-request.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-request.rules.spec.ts new file mode 100644 index 0000000..8689327 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-request.rules.spec.ts @@ -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(); + }); +}); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-request.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-request.rules.ts new file mode 100644 index 0000000..97873a3 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-request.rules.ts @@ -0,0 +1,39 @@ +export interface FileRequestPeerSelectionInput { + connectedPeers: string[]; + triedPeers: ReadonlySet; + announcedHosts: ReadonlySet; + 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]; +} diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-sharing.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-sharing.rules.spec.ts index 814cf8d..f33ce91 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment-sharing.rules.spec.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-sharing.rules.spec.ts @@ -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); + }); + }); }); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-sharing.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-sharing.rules.ts index 3b30c4c..38bbea4 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment-sharing.rules.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-sharing.rules.ts @@ -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 +): boolean { + return deviceHasLocalCopy(attachment); +} + function hasNonEmptyString(value: string | null | undefined): boolean { return typeof value === 'string' && value.trim().length > 0; } diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts index 7d08590..98e4e4d 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts @@ -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', () => { diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts index d6c5ccf..fb0ac09 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts @@ -67,19 +67,11 @@ export function shouldStreamAttachmentReceiveToDisk( attachment: Pick, 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); + return true; } export function canReceiveAttachmentInMemory( diff --git a/toju-app/src/app/domains/attachment/domain/models/attachment-transfer.model.ts b/toju-app/src/app/domains/attachment/domain/models/attachment-transfer.model.ts index 9ac4ef5..a1f77f0 100644 --- a/toju-app/src/app/domains/attachment/domain/models/attachment-transfer.model.ts +++ b/toju-app/src/app/domains/attachment/domain/models/attachment-transfer.model.ts @@ -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; +export type FileAnnouncePayload = Pick; 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; export type FileCancelPayload = Pick; export type FileNotFoundPayload = Pick; diff --git a/toju-app/src/app/domains/attachment/index.ts b/toju-app/src/app/domains/attachment/index.ts index e85ee9f..752fc4c 100644 --- a/toju-app/src/app/domains/attachment/index.ts +++ b/toju-app/src/app/domains/attachment/index.ts @@ -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'; diff --git a/toju-app/src/app/domains/attachment/infrastructure/services/attachment-storage.service.ts b/toju-app/src/app/domains/attachment/infrastructure/services/attachment-storage.service.ts index f8a1052..57a8796 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/services/attachment-storage.service.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/services/attachment-storage.service.ts @@ -187,6 +187,18 @@ export class AttachmentStorageService { return this.store.appendFile(filePath, base64Data); } + async appendBytes(filePath: string, bytes: Uint8Array): Promise { + 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 { if (!filePath) { return; diff --git a/toju-app/src/app/domains/attachment/infrastructure/services/electron-attachment-file-store.ts b/toju-app/src/app/domains/attachment/infrastructure/services/electron-attachment-file-store.ts index 4efbe83..aef34d8 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/services/electron-attachment-file-store.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/services/electron-attachment-file-store.ts @@ -74,6 +74,20 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore { } } + async appendFileBytes(filePath: string, bytes: Uint8Array): Promise { + 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 { const electronApi = this.electronBridge.getApi(); diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts index b405a4d..37cfb3f 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts @@ -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 { - 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 { @@ -415,46 +393,6 @@ export class ChatMessagesComponent { return message.senderId === this.currentUser()?.id; } - private async getAttachmentBlob(attachment: Attachment): Promise { - 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 { - 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 { return new Promise((resolve, reject) => { if (blob.type === 'image/png') { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html index 2a4809e..a5d18a3 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html @@ -192,6 +192,10 @@ />
+ } @else if (isImagePendingHydration(gridImage)) { +
+
+
} @else if ((gridImage.receivedBytes || 0) > 0) {
+ } @else if (isImagePendingHydration(att)) { +
+
+
} @else if ((att.receivedBytes || 0) > 0) {
; @ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef; + private readonly elementRef = inject(ElementRef); 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); } diff --git a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts index ac3700d..7781f5d 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts @@ -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 { - 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 { @@ -599,48 +560,6 @@ export class DmChatComponent { return `${messageId}:${url}`; } - private async getAttachmentBlob(attachment: Attachment): Promise { - 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 { - 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>): User | null { if (conversation.kind === 'group' || conversation.participants.length > 2) { return null; diff --git a/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.spec.ts b/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.spec.ts index c3b8d5e..94d8a63 100644 --- a/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.spec.ts +++ b/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.spec.ts @@ -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); }); }); diff --git a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts index 6647b9a..2ad0b52 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts @@ -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() ] }); diff --git a/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.html b/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.html index 2620cf4..a48786c 100644 --- a/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.html +++ b/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.html @@ -43,6 +43,18 @@
{{ ramLabel() ?? '-' }} +
+ +

{{ 'settings.debugging.ramHint' | translate }}

} diff --git a/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.ts b/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.ts index 976ed60..d06df2c 100644 --- a/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.ts @@ -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(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 { + 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(); diff --git a/toju-app/src/app/features/shell/high-memory-alert-modal/high-memory-alert-modal.component.html b/toju-app/src/app/features/shell/high-memory-alert-modal/high-memory-alert-modal.component.html index a0a7cfe..230da21 100644 --- a/toju-app/src/app/features/shell/high-memory-alert-modal/high-memory-alert-modal.component.html +++ b/toju-app/src/app/features/shell/high-memory-alert-modal/high-memory-alert-modal.component.html @@ -6,13 +6,19 @@ />
-
+