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