diff --git a/docs/RETENTION_BACKUP_PLAN.md b/docs/RETENTION_BACKUP_PLAN.md new file mode 100644 index 00000000..bd6225b9 --- /dev/null +++ b/docs/RETENTION_BACKUP_PLAN.md @@ -0,0 +1,62 @@ +# Retention backup: archive-before-delete + automatic history cleanup + +Status: Phase 1 and Phase 2 code-complete on branch `feat/retention-backup` (pending on-device verification + PR). + +## Problem + +Continuous screen capture writes full-resolution PNGs into `captures/` with no retention: +~170MB/day (~5GB/month) measured on a live profile (1,379 files / 838MB over ~5 days). Nothing +prunes automatically - the only lever is the manual "delete older than N days" button in +Settings > Data & Privacy, and that delete is permanent. There is no way to keep old history +before clearing it: the existing backup engine (`src/main/backup/`) exports projects + +conversations only, never the capture/meeting files that retention deletes. + +## Phase 1 - "Back up & delete" (manual, this branch) + +A fail-closed archive step in front of the existing category delete. + +User-visible behavior: + +1. Settings > Data & Privacy grows a "Back up & delete" action next to the existing delete and + retention buttons for the file-centric categories (captures, meetings, generated images). +2. Clicking it opens the normal save dialog. The user picks any destination (external SSD, NAS). +3. The app stages one ZIP - e.g. `offgrid-captures-before-2026-07-26.zip` - containing every file + the delete would remove, plus a `manifest.json` (category, cutoff, created-at, file count, + total bytes). +4. Only after the ZIP is confirmed delivered does the real delete run. Cancel or any archive + failure = nothing is deleted, ever. + +Design: + +- **One source of truth for "what a category deletes".** The per-category userData dir list moves + out of `clearCategory`'s switch into a pure module (`src/main/data-categories.ts`) that both the + delete path and the archive path read. Two lists would drift into "backed up X, deleted Y". +- **Collector** - `collectCategoryFiles(dirs, olderThanDays?)` returns exactly the files + `clearDirs`/`clearDirsOlderThan` would remove (same mtime cutoff). +- **Stager** - streams the files into a ZIP via JSZip with STORE compression (PNGs do not + compress; the corpus is ~1GB, so never buffer it in memory) + writes `manifest.json`. +- **Orchestrator** - `archiveThenClear(category, olderThanDays)`: collect -> stage -> deliver via + the existing `DesktopBackupSink` (save dialog) -> on confirmed delivery only, call the existing + `clearCategory`. Zero files to archive skips the dialog and clears directly. The destination is + injectable (sink today, fixed folder later) so Phase 2 reuses the same seam. +- **Untouched contracts** - `clearCategory` itself does not change; pro's + `clearRemovedCaptureProjections` keys off missing files and we copy before deleting, so the pro + side needs zero changes. +- **Tests in the same pass** - the dir-map DRY guard, age-cutoff selection, ZIP + manifest + contents, and the ordering contract (canceled/failed archive leaves every file in place), run + against real temp dirs with a fake sink. + +## Phase 2 - automatic history cleanup (next) + +One setting plus a nightly job, built on the Phase 1 seam: + +- Settings: "Keep screen history for 30 / 60 / 90 days / forever" + optional archive folder. +- A scheduled daily job runs the same archive-then-delete machinery with a fixed-folder + destination instead of a dialog: old frames archived (if a folder is set), then pruned. Disk + usage stays flat at roughly one retention window. +- Ships OFF by default; deleting history silently is an explicit opt-in. +- This matches the field standard: Microsoft Recall caps storage and deletes oldest-first; + Rewind asks once how long to keep history. + +Out of scope for both phases (tracked separately in the bloat notes): compressing captures at +write time (WebP/JPEG instead of PNG) and encoding frames into HEVC segments for Replay. diff --git a/e2e/retention-backup.spec.ts b/e2e/retention-backup.spec.ts new file mode 100644 index 00000000..6d69bb15 --- /dev/null +++ b/e2e/retention-backup.spec.ts @@ -0,0 +1,94 @@ +/** + * Archive-before-delete surface (Settings > Data & privacy): the "Back up first" + * toggle exists for exactly the file-centric categories, arms visibly, and the + * summary reflects seeded capture files. Fresh temp profile; UI-state clicks only - + * the actual archive flow opens a native save dialog, which is covered by the + * unit/integration tests (retention-archive.test.ts), not driven here. + */ +import { test, expect, type ElectronApplication, type Page } from '@playwright/test' +import { launchOffGrid } from './helpers/launch' +import os from 'os' +import path from 'path' +import fs from 'fs' +import { openSettingsSection } from './helpers/settings' +import { completeOnboarding } from './helpers/onboarding' + +let app: ElectronApplication +let page: Page +let userDataDir: string + +test.beforeAll(async () => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-retention-')) + // Seed a few old capture files so the captures row has data and enabled buttons. + const captures = path.join(userDataDir, 'captures') + fs.mkdirSync(captures, { recursive: true }) + const old = new Date(Date.now() - 10 * 86_400_000) + for (const name of ['capture-1.png', 'capture-2.png', 'capture-3.png']) { + const p = path.join(captures, name) + fs.writeFileSync(p, 'fake-png-bytes') + fs.utimesSync(p, old, old) + } + app = await launchOffGrid({ + env: { + ...process.env, + OFFGRID_USER_DATA: userDataDir, + OFFGRID_PRO: '0', + NODE_ENV: 'production' + } + }) + page = await app.firstWindow() + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.waitForLoadState('domcontentloaded') + await completeOnboarding(page) +}) + +test.afterAll(async () => { + await app?.close() + fs.rmSync(userDataDir, { recursive: true, force: true }) +}) + +test('Back up first is offered for file categories and arms visibly', async () => { + await page.getByRole('button', { name: 'Settings', exact: true }).first().click() + await openSettingsSection(page, 'Data & privacy') + await expect(page.getByText('Your data on this device')).toBeVisible() + + // Exactly the archivable categories offer the toggle; chats does not. + await expect( + page.getByRole('button', { name: 'Back up Screen captures before deleting' }) + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Back up Meetings before deleting' }) + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Back up Generated images & artifacts before deleting' }) + ).toBeVisible() + await expect(page.getByRole('button', { name: /Back up Chats/ })).toHaveCount(0) + + // Seeded captures show up in the summary (3 files). + await expect(page.getByText(/3 items/)).toBeVisible() + + // Arm the toggle for captures - pressed state flips (pure UI state, no delete). + const toggle = page.getByRole('button', { name: 'Back up Screen captures before deleting' }) + await expect(toggle).toHaveAttribute('aria-pressed', 'false') + await toggle.click() + await expect(toggle).toHaveAttribute('aria-pressed', 'true') + + await page.screenshot({ path: 'e2e/screenshots/retention-backup-panel.png' }) +}) + +test('Automatic cleanup arms from Off and reveals folder + Run now', async () => { + await expect(page.getByText('Automatic cleanup')).toBeVisible() + const off = page.getByRole('button', { name: 'Off', exact: true }) + await expect(off).toHaveAttribute('aria-pressed', 'true') + await expect(page.getByRole('button', { name: /run now/i })).toHaveCount(0) + + await page.getByRole('button', { name: '30 days', exact: true }).click() + await expect(page.getByRole('button', { name: '30 days', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + await expect(page.getByText(/no backup - choose a folder/i)).toBeVisible() + await expect(page.getByRole('button', { name: /run now/i })).toBeVisible() + + await page.screenshot({ path: 'e2e/screenshots/retention-auto-cleanup.png' }) +}) diff --git a/e2e/screenshots/retention-auto-cleanup.png b/e2e/screenshots/retention-auto-cleanup.png new file mode 100644 index 00000000..eb4086f0 Binary files /dev/null and b/e2e/screenshots/retention-auto-cleanup.png differ diff --git a/e2e/screenshots/retention-backup-panel.png b/e2e/screenshots/retention-backup-panel.png new file mode 100644 index 00000000..31fc4874 Binary files /dev/null and b/e2e/screenshots/retention-backup-panel.png differ diff --git a/src/main/__tests__/data-categories.test.ts b/src/main/__tests__/data-categories.test.ts new file mode 100644 index 00000000..97f2590b --- /dev/null +++ b/src/main/__tests__/data-categories.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { ARCHIVABLE_CATEGORIES, CATEGORY_DIRS, type DataCategoryId } from '../data-categories' + +describe('the data-category dir map (SSOT for delete + archive)', () => { + it('gives every category at least one userData-relative dir name', () => { + for (const [id, dirs] of Object.entries(CATEGORY_DIRS)) { + expect(dirs.length, `category ${id} has no dirs`).toBeGreaterThan(0) + for (const dir of dirs) { + // Relative names only - callers resolve against userData. A path separator or + // traversal here would silently point delete/archive somewhere else. + expect(dir).not.toMatch(/[/\\]|\.\./) + expect(dir.trim().length).toBeGreaterThan(0) + } + } + }) + + it('archivable categories are a subset of the map', () => { + const ids = Object.keys(CATEGORY_DIRS) as DataCategoryId[] + for (const id of ARCHIVABLE_CATEGORIES) { + expect(ids).toContain(id) + } + }) + + it('keeps the retention-critical mappings stable', () => { + // The retention flows (age-based delete + pre-delete archive) are wired to these + // two categories; renaming their dirs is a data-loss-shaped change - fail loudly. + expect(CATEGORY_DIRS.captures).toEqual(['captures']) + expect(CATEGORY_DIRS.meetings).toEqual(['meetings']) + }) +}) diff --git a/src/main/backup/__tests__/auto-cleanup.test.ts b/src/main/backup/__tests__/auto-cleanup.test.ts new file mode 100644 index 00000000..5a1a71d5 --- /dev/null +++ b/src/main/backup/__tests__/auto-cleanup.test.ts @@ -0,0 +1,145 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import JSZip from 'jszip' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AUTO_CLEANUP_INTERVAL_MS, + cleanupDue, + folderDeliver, + runAutoCleanup +} from '../auto-cleanup' + +let userData: string +let temp: string +let archiveDir: string + +const writeOldCapture = (name: string, content = 'png', ageDays = 40): void => { + const dir = path.join(userData, 'captures') + fs.mkdirSync(dir, { recursive: true }) + const p = path.join(dir, name) + fs.writeFileSync(p, content) + const t = new Date(Date.now() - ageDays * 86_400_000) + fs.utimesSync(p, t, t) +} + +beforeEach(() => { + userData = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-auto-ud-')) + temp = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-auto-tmp-')) + archiveDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-auto-arc-')) +}) +afterEach(() => { + for (const d of [userData, temp, archiveDir]) fs.rmSync(d, { recursive: true, force: true }) +}) + +describe('cleanupDue', () => { + it('is due on first ever run, after the interval, and not before', () => { + const now = 1_800_000_000_000 + expect(cleanupDue(null, now)).toBe(true) + expect(cleanupDue(now - AUTO_CLEANUP_INTERVAL_MS - 1, now)).toBe(true) + expect(cleanupDue(now - AUTO_CLEANUP_INTERVAL_MS + 60_000, now)).toBe(false) + }) +}) + +describe('folderDeliver', () => { + const stage = (content: string): string => { + const dir = fs.mkdtempSync(path.join(temp, 'stage-')) + const p = path.join(dir, 'a.zip') + fs.writeFileSync(p, content) + return p + } + + it('copies into the folder, verifies the size, and removes staging', async () => { + const staged = stage('ZIPBYTES') + const result = await folderDeliver(archiveDir)(staged, 'offgrid-captures.zip') + expect(result.canceled).toBe(false) + expect(fs.readFileSync(result.path!, 'utf8')).toBe('ZIPBYTES') + expect(fs.existsSync(staged)).toBe(false) + }) + + it('never overwrites an earlier archive - collisions get a suffix', async () => { + fs.writeFileSync(path.join(archiveDir, 'offgrid-captures.zip'), 'EARLIER') + const result = await folderDeliver(archiveDir)(stage('NEWER'), 'offgrid-captures.zip') + expect(path.basename(result.path!)).toBe('offgrid-captures-2.zip') + expect(fs.readFileSync(path.join(archiveDir, 'offgrid-captures.zip'), 'utf8')).toBe('EARLIER') + }) + + it('an unwritable destination throws (which the orchestration treats as: do not prune)', async () => { + const file = path.join(temp, 'not-a-dir') + fs.writeFileSync(file, 'x') + await expect(folderDeliver(file)(stage('Z'), 'a.zip')).rejects.toThrow() + }) +}) + +describe('runAutoCleanup', () => { + it('does nothing when retention is off', async () => { + const clear = vi.fn(async () => ({ success: true })) + const result = await runAutoCleanup({ + config: { retentionDays: 0, archiveDir: null }, + userDataDir: userData, + clear + }) + expect(result.status).toBe('off') + expect(clear).not.toHaveBeenCalled() + }) + + it('with no archive folder it is a plain rolling window - prune, no ZIP', async () => { + writeOldCapture('old.png') + const clear = vi.fn(async () => ({ success: true })) + const result = await runAutoCleanup({ + config: { retentionDays: 30, archiveDir: null }, + userDataDir: userData, + clear + }) + expect(result).toMatchObject({ status: 'cleared', archivedFiles: 0 }) + expect(clear).toHaveBeenCalledTimes(1) + expect(fs.readdirSync(archiveDir)).toEqual([]) + }) + + it('with a folder it archives the old captures, then prunes', async () => { + writeOldCapture('old.png', 'OLDPNG') + writeOldCapture('fresh.png', 'FRESH', 1) // inside the window - not archived + const clear = vi.fn(async () => ({ success: true })) + const result = await runAutoCleanup({ + config: { retentionDays: 30, archiveDir }, + userDataDir: userData, + tempDir: temp, + clear + }) + expect(result.status).toBe('cleared') + expect(result.archivedFiles).toBe(1) + const zip = await JSZip.loadAsync(fs.readFileSync(result.archivePath!)) + expect(await zip.file('captures/old.png')!.async('string')).toBe('OLDPNG') + expect(zip.file('captures/fresh.png')).toBeNull() + expect(clear).toHaveBeenCalledTimes(1) + }) + + it('a failed archive means nothing is pruned (fail closed)', async () => { + writeOldCapture('old.png') + const file = path.join(temp, 'blocked') + fs.writeFileSync(file, 'x') // archiveDir points at a FILE - copy will fail + const clear = vi.fn(async () => ({ success: true })) + const result = await runAutoCleanup({ + config: { retentionDays: 30, archiveDir: file }, + userDataDir: userData, + tempDir: temp, + clear + }) + expect(result.status).toBe('failed') + expect(clear).not.toHaveBeenCalled() + }) + + it('nothing older than the window archives nothing and still reports cleared', async () => { + writeOldCapture('fresh.png', 'F', 2) + const clear = vi.fn(async () => ({ success: true })) + const result = await runAutoCleanup({ + config: { retentionDays: 30, archiveDir }, + userDataDir: userData, + tempDir: temp, + clear + }) + expect(result).toMatchObject({ status: 'cleared', archivedFiles: 0 }) + expect(fs.readdirSync(archiveDir)).toEqual([]) // no empty ZIPs accumulating + expect(clear).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/backup/__tests__/retention-archive-ipc.test.ts b/src/main/backup/__tests__/retention-archive-ipc.test.ts new file mode 100644 index 00000000..ad770ba2 --- /dev/null +++ b/src/main/backup/__tests__/retention-archive-ipc.test.ts @@ -0,0 +1,189 @@ +// The Electron wiring layer, tested at its true boundaries: electron (app paths, +// dialog), the settings store, and clearCategory are mocked; everything between - +// config sanitization, state persistence, the concurrency guard, the category +// refusal, and the archive->clear ordering against REAL temp files - runs for real. +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const boundary = vi.hoisted(() => ({ + userDataDir: '', + settings: new Map(), + clearCalls: [] as { id: string; olderThanDays?: number }[], + clearResult: { success: true }, + dialogResult: { canceled: true, filePaths: [] as string[] } +})) + +vi.mock('electron', () => ({ + app: { getPath: () => boundary.userDataDir }, + BrowserWindow: { getFocusedWindow: () => null }, + dialog: { showOpenDialog: async () => boundary.dialogResult } +})) + +vi.mock('../../database', () => ({ + getSetting: (key: string, dflt: T): T => + boundary.settings.has(key) ? (boundary.settings.get(key) as T) : dflt, + saveSetting: (key: string, value: unknown): void => { + boundary.settings.set(key, value) + } +})) + +vi.mock('../../data-privacy', () => ({ + clearCategory: async (id: string, olderThanDays?: number) => { + boundary.clearCalls.push({ id, olderThanDays }) + return boundary.clearResult + } +})) + +import { + archiveThenClearCategory, + getAutoCleanupStatus, + maybeRunScheduledCleanup, + pickArchiveDir, + readAutoCleanupConfig, + registerRetentionIpc, + runAutoCleanupNow +} from '../retention-archive-ipc' + +beforeEach(() => { + boundary.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-ipc-ud-')) + boundary.settings.clear() + boundary.clearCalls = [] + boundary.clearResult = { success: true } + boundary.dialogResult = { canceled: true, filePaths: [] } +}) +afterEach(() => { + fs.rmSync(boundary.userDataDir, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('archiveThenClearCategory', () => { + it('refuses a category that is not archivable, touching nothing', async () => { + const result = await archiveThenClearCategory('chats', 7) + expect(result.status).toBe('failed') + expect(boundary.clearCalls).toEqual([]) + }) + + it('with nothing to archive it clears directly (no dialog involved)', async () => { + const result = await archiveThenClearCategory('captures', 7) + expect(result).toMatchObject({ status: 'cleared', archivedFiles: 0 }) + expect(boundary.clearCalls).toEqual([{ id: 'captures', olderThanDays: 7 }]) + }) +}) + +describe('readAutoCleanupConfig (sanitizes whatever the renderer wrote)', () => { + it('defaults to off with no folder', () => { + expect(readAutoCleanupConfig()).toEqual({ retentionDays: 0, archiveDir: null }) + }) + + it('rejects junk shapes instead of trusting them', () => { + boundary.settings.set('autoCleanup', { retentionDays: 'soon', archiveDir: 42 }) + expect(readAutoCleanupConfig()).toEqual({ retentionDays: 0, archiveDir: null }) + boundary.settings.set('autoCleanup', { retentionDays: -5, archiveDir: '' }) + expect(readAutoCleanupConfig()).toEqual({ retentionDays: 0, archiveDir: null }) + boundary.settings.set('autoCleanup', { retentionDays: 999999, archiveDir: null }) + expect(readAutoCleanupConfig()).toEqual({ retentionDays: 0, archiveDir: null }) + }) + + it('passes a valid config through', () => { + boundary.settings.set('autoCleanup', { retentionDays: 30, archiveDir: '/Volumes/SSD' }) + expect(readAutoCleanupConfig()).toEqual({ retentionDays: 30, archiveDir: '/Volumes/SSD' }) + }) +}) + +describe('runAutoCleanupNow', () => { + it('is a no-op when retention is off, and persists nothing', async () => { + const result = await runAutoCleanupNow() + expect(result.status).toBe('off') + expect(boundary.settings.has('autoCleanupLastRun')).toBe(false) + expect(boundary.clearCalls).toEqual([]) + }) + + it('runs the prune and persists the result as the last run', async () => { + boundary.settings.set('autoCleanup', { retentionDays: 30, archiveDir: null }) + const result = await runAutoCleanupNow() + expect(result.status).toBe('cleared') + expect(boundary.clearCalls).toEqual([{ id: 'captures', olderThanDays: 30 }]) + expect(getAutoCleanupStatus().lastRun).toMatchObject({ status: 'cleared' }) + }) + + it('archives real old captures into the configured folder before clearing', async () => { + const archiveDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-ipc-arc-')) + try { + const captures = path.join(boundary.userDataDir, 'captures') + fs.mkdirSync(captures, { recursive: true }) + const old = new Date(Date.now() - 60 * 86_400_000) + fs.writeFileSync(path.join(captures, 'old.png'), 'OLD') + fs.utimesSync(path.join(captures, 'old.png'), old, old) + boundary.settings.set('autoCleanup', { retentionDays: 30, archiveDir }) + + const result = await runAutoCleanupNow() + + expect(result).toMatchObject({ status: 'cleared', archivedFiles: 1 }) + expect(fs.readdirSync(archiveDir)).toHaveLength(1) + expect(boundary.clearCalls).toEqual([{ id: 'captures', olderThanDays: 30 }]) + } finally { + fs.rmSync(archiveDir, { recursive: true, force: true }) + } + }) + + it('never runs two cleanups at once', async () => { + boundary.settings.set('autoCleanup', { retentionDays: 30, archiveDir: null }) + const [first, second] = await Promise.all([runAutoCleanupNow(), runAutoCleanupNow()]) + const statuses = [first.status, second.status].sort() + expect(statuses).toEqual(['cleared', 'failed']) + expect(boundary.clearCalls).toHaveLength(1) + }) +}) + +describe('pickArchiveDir', () => { + it('returns null when the dialog is canceled', async () => { + expect(await pickArchiveDir()).toBeNull() + }) + + it('returns the chosen folder', async () => { + boundary.dialogResult = { canceled: false, filePaths: ['/Volumes/SSD/Archive'] } + expect(await pickArchiveDir()).toBe('/Volumes/SSD/Archive') + }) +}) + +describe('registerRetentionIpc', () => { + it('registers all four channels and dispatches through to the handlers', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirrors the boundary signature + const handlers = new Map unknown>() + registerRetentionIpc({ handle: (channel, handler) => void handlers.set(channel, handler) }) + expect([...handlers.keys()].sort()).toEqual([ + 'data:archive-clear', + 'data:auto-cleanup-run', + 'data:auto-cleanup-status', + 'data:pick-archive-dir' + ]) + // Dispatch smoke: the refusal branch proves args flow through the boundary. + const refused = (await handlers.get('data:archive-clear')!(null, 'chats')) as { + status: string + } + expect(refused.status).toBe('failed') + const status = (await handlers.get('data:auto-cleanup-status')!(null)) as { + config: { retentionDays: number } + } + expect(status.config.retentionDays).toBe(0) + expect(await handlers.get('data:pick-archive-dir')!(null)).toBeNull() + }) +}) + +describe('maybeRunScheduledCleanup', () => { + it('does nothing while retention is off', async () => { + await maybeRunScheduledCleanup() + expect(boundary.clearCalls).toEqual([]) + expect(boundary.settings.has('autoCleanupLastRun')).toBe(false) + }) + + it('runs once when due, then holds until the next day', async () => { + boundary.settings.set('autoCleanup', { retentionDays: 30, archiveDir: null }) + await maybeRunScheduledCleanup() // never ran -> due + expect(boundary.clearCalls).toHaveLength(1) + await maybeRunScheduledCleanup() // just ran -> not due + expect(boundary.clearCalls).toHaveLength(1) + }) +}) diff --git a/src/main/backup/__tests__/retention-archive.test.ts b/src/main/backup/__tests__/retention-archive.test.ts new file mode 100644 index 00000000..d9d8b817 --- /dev/null +++ b/src/main/backup/__tests__/retention-archive.test.ts @@ -0,0 +1,171 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import JSZip from 'jszip' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + archiveThenClear, + collectCategoryFiles, + stageRetentionArchive, + type ArchiveClearDeps, + type DoomedFile +} from '../retention-archive' + +let userData: string +let temp: string + +const write = (rel: string, content = 'x', ageDays = 0): string => { + const abs = path.join(userData, rel) + fs.mkdirSync(path.dirname(abs), { recursive: true }) + fs.writeFileSync(abs, content) + if (ageDays > 0) { + const t = new Date(Date.now() - ageDays * 86_400_000) + fs.utimesSync(abs, t, t) + } + return abs +} + +/** Age a directory itself (clearDirsOlderThan cuts on TOP-LEVEL entry mtime). */ +const ageDir = (rel: string, ageDays: number): void => { + const t = new Date(Date.now() - ageDays * 86_400_000) + fs.utimesSync(path.join(userData, rel), t, t) +} + +beforeEach(() => { + userData = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-ret-ud-')) + temp = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-ret-tmp-')) +}) +afterEach(() => { + fs.rmSync(userData, { recursive: true, force: true }) + fs.rmSync(temp, { recursive: true, force: true }) +}) + +describe('collectCategoryFiles', () => { + it('collects everything for a full clear, across all of the category dirs', () => { + write('generated-images/a.png') + write('artifacts-library/b.html') + write('style-thumbs/c.png') + const files = collectCategoryFiles(userData, 'images') + expect(files.map((f) => f.zipKey).sort()).toEqual([ + 'artifacts-library/b.html', + 'generated-images/a.png', + 'style-thumbs/c.png' + ]) + }) + + it('mirrors the retention delete: only top-level entries older than the cutoff', () => { + write('captures/old.png', 'old', 10) + write('captures/new.png', 'new', 1) + const files = collectCategoryFiles(userData, 'captures', 7) + expect(files.map((f) => f.zipKey)).toEqual(['captures/old.png']) + }) + + it('an old day-directory contributes every file under it, keyed by relative path', () => { + write('meetings/2026-01-01/audio.m4a', 'a', 1) // fresh file INSIDE an old dir + ageDir('meetings/2026-01-01', 30) + const files = collectCategoryFiles(userData, 'meetings', 7) + expect(files.map((f) => f.zipKey)).toEqual(['meetings/2026-01-01/audio.m4a']) + }) + + it('never follows symlinks and survives a missing category dir', () => { + const real = write('captures/real.png', 'r', 10) + fs.symlinkSync(real, path.join(userData, 'captures', 'link.png')) + const files = collectCategoryFiles(userData, 'captures', 7) + expect(files.map((f) => f.zipKey)).toEqual(['captures/real.png']) + expect(collectCategoryFiles(userData, 'images')).toEqual([]) // dirs never created + }) +}) + +describe('stageRetentionArchive', () => { + it('stages a ZIP holding the files plus an accurate manifest', async () => { + write('captures/old.png', 'PNGDATA', 10) + const files = collectCategoryFiles(userData, 'captures', 7) + const staged = await stageRetentionArchive(files, { + category: 'captures', + olderThanDays: 7, + tempDir: temp + }) + expect(staged.suggestedName).toMatch(/^offgrid-captures-before-\d{4}-\d{2}-\d{2}\.zip$/) + + const zip = await JSZip.loadAsync(fs.readFileSync(staged.zipPath)) + expect(await zip.file('captures/old.png')!.async('string')).toBe('PNGDATA') + const manifest = JSON.parse(await zip.file('manifest.json')!.async('string')) + expect(manifest).toMatchObject({ + surface: 'offgrid-desktop-retention', + category: 'captures', + olderThanDays: 7, + fileCount: 1, + totalBytes: 'PNGDATA'.length + }) + expect(Date.parse(manifest.cutoffIso)).toBeLessThan(Date.now()) + }) + + it('names a full clear archive by the day it was made', async () => { + const staged = await stageRetentionArchive([], { category: 'images', tempDir: temp }) + expect(staged.suggestedName).toMatch(/^offgrid-images-all-\d{4}-\d{2}-\d{2}\.zip$/) + expect(staged.manifest.cutoffIso).toBeNull() + }) +}) + +describe('archiveThenClear (the ordering contract)', () => { + const doomed: DoomedFile[] = [{ absPath: '/tmp/x', zipKey: 'captures/x', bytes: 1 }] + const staged = { zipPath: '/tmp/z.zip', suggestedName: 'z.zip', manifest: {} as never } + + const deps = (over: Partial): ArchiveClearDeps => ({ + collect: () => doomed, + stage: vi.fn(async () => staged), + deliver: vi.fn(async () => ({ canceled: false, path: '/ssd/z.zip' })), + clear: vi.fn(async () => ({ success: true })), + ...over + }) + + it('clears only after a confirmed delivery, reporting where the archive went', async () => { + const d = deps({}) + const result = await archiveThenClear(d) + expect(result).toEqual({ status: 'cleared', archivedFiles: 1, archivePath: '/ssd/z.zip' }) + expect(d.clear).toHaveBeenCalledTimes(1) + }) + + it('a canceled save dialog deletes nothing', async () => { + const d = deps({ deliver: vi.fn(async () => ({ canceled: true })) }) + expect(await archiveThenClear(d)).toEqual({ status: 'canceled' }) + expect(d.clear).not.toHaveBeenCalled() + }) + + it('an archive failure deletes nothing', async () => { + const d = deps({ + stage: vi.fn(async () => { + throw new Error('disk full') + }) + }) + expect(await archiveThenClear(d)).toEqual({ status: 'failed', error: 'disk full' }) + expect(d.clear).not.toHaveBeenCalled() + }) + + it('a delivery failure deletes nothing', async () => { + const d = deps({ + deliver: vi.fn(async () => { + throw new Error('destination unwritable') + }) + }) + expect(await archiveThenClear(d)).toEqual({ + status: 'failed', + error: 'destination unwritable' + }) + expect(d.clear).not.toHaveBeenCalled() + }) + + it('zero doomed files skips the dialog and clears directly', async () => { + const d = deps({ collect: () => [] }) + expect(await archiveThenClear(d)).toEqual({ status: 'cleared', archivedFiles: 0 }) + expect(d.stage).not.toHaveBeenCalled() + expect(d.deliver).not.toHaveBeenCalled() + expect(d.clear).toHaveBeenCalledTimes(1) + }) + + it('reports honestly when the archive saved but the delete failed', async () => { + const d = deps({ clear: vi.fn(async () => ({ success: false })) }) + const result = await archiveThenClear(d) + expect(result.status).toBe('failed') + }) +}) diff --git a/src/main/backup/auto-cleanup.ts b/src/main/backup/auto-cleanup.ts new file mode 100644 index 00000000..08ebdf7b --- /dev/null +++ b/src/main/backup/auto-cleanup.ts @@ -0,0 +1,109 @@ +// Automatic history cleanup: the scheduled (Phase 2) reuse of the archive-then-clear +// seam. Instead of the manual save dialog, delivery is a verified copy into a fixed +// archive folder - or, with no folder configured, a plain prune with no archive. +// Same fail-closed rule: when a backup IS requested, a failed or unverifiable copy +// means nothing gets deleted. +// +// Electron-free: paths and the clear callback are injected, so the due-ness math, +// the folder delivery, and the ordering are all unit-testable against temp dirs. +// The Electron wiring (settings, scheduler, IPC) lives in retention-archive-ipc.ts. +import fs from 'node:fs' +import path from 'node:path' +import type { + AutoCleanupConfigContract, + AutoCleanupRunContract +} from '../../shared/backup-contracts' +import { + archiveThenClear, + collectCategoryFiles, + stageRetentionArchive, + type ArchiveDelivery +} from './retention-archive' + +/** Once a day, with a margin so a drifting timer can't skip a whole day. */ +export const AUTO_CLEANUP_INTERVAL_MS = 23.5 * 60 * 60 * 1000 + +export function cleanupDue(lastRunAt: number | null, now: number): boolean { + return lastRunAt === null || now - lastRunAt >= AUTO_CLEANUP_INTERVAL_MS +} + +/** + * Deliver a staged ZIP into a fixed folder, verified: the copy must exist at the + * staged size before we report success (success is what authorizes the prune). + * A name collision gets a numeric suffix instead of overwriting an earlier archive. + * Staging is removed on every path, mirroring DesktopBackupSink. + */ +export function folderDeliver(archiveDir: string) { + return async (zipPath: string, suggestedName: string): Promise => { + try { + await fs.promises.mkdir(archiveDir, { recursive: true }) + const parsed = path.parse(suggestedName) + let dest = path.join(archiveDir, suggestedName) + for (let n = 2; fs.existsSync(dest); n++) { + dest = path.join(archiveDir, `${parsed.name}-${n}${parsed.ext}`) + } + await fs.promises.copyFile(zipPath, dest) + const staged = await fs.promises.stat(zipPath) + const copied = await fs.promises.stat(dest) + if (copied.size !== staged.size) { + await fs.promises.rm(dest, { force: true }) + throw new Error('The archive copy did not match the staged file.') + } + return { canceled: false, path: dest } + } finally { + await fs.promises.rm(zipPath, { force: true }) + await fs.promises.rmdir(path.dirname(zipPath)).catch(() => undefined) + } + } +} + +export interface AutoCleanupRunOptions { + config: AutoCleanupConfigContract + userDataDir: string + tempDir?: string + /** The real category delete (clearCategory('captures', retentionDays)). */ + clear: () => Promise<{ success: boolean }> + now?: number +} + +/** One cleanup pass over screen captures. Pure orchestration - callers persist the result. */ +export async function runAutoCleanup(opts: AutoCleanupRunOptions): Promise { + const ranAt = opts.now ?? Date.now() + const days = opts.config.retentionDays + if (!Number.isInteger(days) || days <= 0) return { status: 'off', ranAt } + + if (opts.config.archiveDir === null) { + // Plain rolling window - the user chose no backup, so prune directly. + const cleared = await opts.clear() + return cleared.success + ? { status: 'cleared', ranAt, archivedFiles: 0 } + : { status: 'failed', ranAt, error: 'The prune failed.' } + } + + const archiveDir = opts.config.archiveDir + const result = await archiveThenClear({ + collect: () => collectCategoryFiles(opts.userDataDir, 'captures', days), + stage: (files) => + stageRetentionArchive(files, { + category: 'captures', + olderThanDays: days, + tempDir: opts.tempDir + }), + deliver: folderDeliver(archiveDir), + clear: opts.clear + }) + if (result.status === 'cleared') { + return { + status: 'cleared', + ranAt, + archivedFiles: result.archivedFiles, + archivePath: result.archivePath + } + } + // folderDeliver never cancels; normalize everything else to failed. + return { + status: 'failed', + ranAt, + error: result.status === 'failed' ? result.error : 'The archive was canceled.' + } +} diff --git a/src/main/backup/retention-archive-ipc.ts b/src/main/backup/retention-archive-ipc.ts new file mode 100644 index 00000000..c01ba8d3 --- /dev/null +++ b/src/main/backup/retention-archive-ipc.ts @@ -0,0 +1,137 @@ +// Electron wiring for archive-before-delete and the scheduled automatic cleanup: +// binds the pure orchestrations to the real userData dir, the save-dialog sink / +// archive folder, the settings store, and clearCategory. +import os from 'node:os' +import { app, BrowserWindow, dialog } from 'electron' +import { + AUTO_CLEANUP_DEFAULTS, + AUTO_CLEANUP_SETTING_KEY, + type AutoCleanupConfigContract, + type AutoCleanupRunContract, + type AutoCleanupStatusContract +} from '../../shared/backup-contracts' +import { ARCHIVABLE_CATEGORIES, type DataCategoryId } from '../data-categories' +import { clearCategory } from '../data-privacy' +import { getSetting, saveSetting } from '../database' +import { cleanupDue, runAutoCleanup } from './auto-cleanup' +import { + archiveThenClear, + collectCategoryFiles, + stageRetentionArchive, + type ArchiveClearResult +} from './retention-archive' +import { DesktopBackupSink } from './sink' + +export async function archiveThenClearCategory( + id: string, + olderThanDays?: number +): Promise { + if (!(ARCHIVABLE_CATEGORIES as readonly string[]).includes(id)) { + return { status: 'failed', error: `Category "${id}" cannot be archived before delete.` } + } + const category = id as DataCategoryId + const sink = new DesktopBackupSink() + return archiveThenClear({ + collect: () => collectCategoryFiles(app.getPath('userData'), category, olderThanDays), + stage: (files) => + stageRetentionArchive(files, { category, olderThanDays, tempDir: os.tmpdir() }), + deliver: (zipPath, suggestedName) => sink.deliverFile(zipPath, suggestedName), + clear: () => clearCategory(category, olderThanDays) + }) +} + +// --- Automatic history cleanup (Phase 2) ----------------------------------- + +const AUTO_CLEANUP_STATE_KEY = 'autoCleanupLastRun' + +/** Sanitize whatever is in settings into a valid config - the renderer writes this + * key directly via settings:save, so never trust its shape. */ +export function readAutoCleanupConfig(): AutoCleanupConfigContract { + const raw = getSetting>( + AUTO_CLEANUP_SETTING_KEY, + AUTO_CLEANUP_DEFAULTS + ) + const days = Number(raw?.retentionDays) + return { + retentionDays: Number.isInteger(days) && days > 0 && days <= 3650 ? days : 0, + archiveDir: + typeof raw?.archiveDir === 'string' && raw.archiveDir.length > 0 ? raw.archiveDir : null + } +} + +export function getAutoCleanupStatus(): AutoCleanupStatusContract { + return { + config: readAutoCleanupConfig(), + lastRun: getSetting(AUTO_CLEANUP_STATE_KEY, null) + } +} + +let cleanupRunning = false + +/** One cleanup pass now (manual "Run now" or the scheduler). Persists the result. */ +export async function runAutoCleanupNow(): Promise { + if (cleanupRunning) + return { status: 'failed', ranAt: Date.now(), error: 'A cleanup is already running.' } + cleanupRunning = true + try { + const config = readAutoCleanupConfig() + const result = await runAutoCleanup({ + config, + userDataDir: app.getPath('userData'), + tempDir: os.tmpdir(), + clear: () => clearCategory('captures', config.retentionDays) + }) + if (result.status !== 'off') saveSetting(AUTO_CLEANUP_STATE_KEY, result) + return result + } finally { + cleanupRunning = false + } +} + +export async function maybeRunScheduledCleanup(): Promise { + try { + const { config, lastRun } = getAutoCleanupStatus() + if (config.retentionDays <= 0) return + if (!cleanupDue(lastRun?.ranAt ?? null, Date.now())) return + const result = await runAutoCleanupNow() + if (result.status === 'failed') console.error('[auto-cleanup] run failed:', result.error) + } catch (e) { + console.error('[auto-cleanup] scheduled run crashed:', e) + } +} + +/** Daily cadence via an hourly due-check, plus one check shortly after startup so a + * Mac that sleeps through the timer still cleans up on the next launch. */ +export function setupAutoCleanupScheduler(): void { + setTimeout(() => void maybeRunScheduledCleanup(), 90_000) + setInterval(() => void maybeRunScheduledCleanup(), 60 * 60_000) +} + +/** IPC registration over an injectable boundary (same seam as backup/ipc.ts), so the + * channel->handler map is testable without Electron's real ipcMain. */ +export interface RetentionIpcBoundary { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirrors Electron's ipcMain.handle signature + handle(channel: string, handler: (event: unknown, ...args: any[]) => unknown): void +} + +export function registerRetentionIpc(ipc: RetentionIpcBoundary): void { + ipc.handle('data:archive-clear', (_e, id: string, olderThanDays?: number) => + archiveThenClearCategory(id, olderThanDays) + ) + ipc.handle('data:auto-cleanup-status', () => getAutoCleanupStatus()) + ipc.handle('data:auto-cleanup-run', () => runAutoCleanupNow()) + ipc.handle('data:pick-archive-dir', () => pickArchiveDir()) +} + +/** Native folder picker for the archive destination. Returns null when canceled. */ +export async function pickArchiveDir(): Promise { + const options: Electron.OpenDialogOptions = { + title: 'Choose a folder for capture archives', + properties: ['openDirectory', 'createDirectory'] + } + const owner = BrowserWindow.getFocusedWindow() + const result = owner + ? await dialog.showOpenDialog(owner, options) + : await dialog.showOpenDialog(options) + return result.canceled ? null : (result.filePaths[0] ?? null) +} diff --git a/src/main/backup/retention-archive.ts b/src/main/backup/retention-archive.ts new file mode 100644 index 00000000..0a036b66 --- /dev/null +++ b/src/main/backup/retention-archive.ts @@ -0,0 +1,190 @@ +// Archive-before-delete for the file-centric data categories (captures, meetings, +// images): collect exactly the files the category delete would remove, stage them +// into one ZIP with a manifest, deliver it to a user-chosen destination, and only +// then let the real delete run. Fail closed - a canceled or failed archive means +// nothing is deleted. +// +// Electron-free on purpose: everything here takes plain paths and injected deps +// (the save-dialog sink, clearCategory) so the ordering contract is unit-testable +// against real temp dirs. The Electron wiring lives in retention-archive-ipc.ts. +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import JSZip from 'jszip' +import type { RetentionArchiveClearContract } from '../../shared/backup-contracts' +import { CATEGORY_DIRS, type DataCategoryId } from '../data-categories' + +export interface DoomedFile { + absPath: string + /** Path inside the ZIP, `/` so multi-dir categories stay distinct. */ + zipKey: string + bytes: number +} + +/** + * The files `clearCategory(category, olderThanDays)` would remove. Mirrors the delete's + * exact semantics (data-privacy clearDirs/clearDirsOlderThan): the age cutoff applies to + * TOP-LEVEL entries by mtime, and an old top-level directory is removed whole - so an old + * day-directory contributes every file under it. Symlinks are never followed (same rule + * as the backup archive: no symlinks in an archive). + */ +export function collectCategoryFiles( + userDataDir: string, + category: DataCategoryId, + olderThanDays?: number +): DoomedFile[] { + const cutoff = olderThanDays && olderThanDays > 0 ? Date.now() - olderThanDays * 86_400_000 : null + const out: DoomedFile[] = [] + for (const dirName of CATEGORY_DIRS[category]) { + const root = path.join(userDataDir, dirName) + let entries: string[] + try { + entries = fs.readdirSync(root) + } catch { + continue // missing dir - nothing to archive there + } + for (const name of entries) { + const fp = path.join(root, name) + let st: fs.Stats + try { + st = fs.lstatSync(fp) + } catch { + continue + } + if (st.isSymbolicLink()) continue + if (cutoff !== null && st.mtimeMs >= cutoff) continue + if (st.isDirectory()) collectUnder(fp, `${dirName}/${name}`, out) + else if (st.isFile()) out.push({ absPath: fp, zipKey: `${dirName}/${name}`, bytes: st.size }) + } + } + return out +} + +function collectUnder(dir: string, keyPrefix: string, out: DoomedFile[]): void { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const fp = path.join(dir, entry.name) + const key = `${keyPrefix}/${entry.name}` + if (entry.isSymbolicLink()) continue + if (entry.isDirectory()) collectUnder(fp, key, out) + else if (entry.isFile()) { + try { + out.push({ absPath: fp, zipKey: key, bytes: fs.statSync(fp).size }) + } catch { + /* vanished between readdir and stat */ + } + } + } +} + +export interface RetentionManifest { + surface: 'offgrid-desktop-retention' + category: DataCategoryId + olderThanDays: number | null + /** Everything in the archive is older than this instant (null = full clear). */ + cutoffIso: string | null + createdIso: string + fileCount: number + totalBytes: number + note: string +} + +export interface StagedArchive { + zipPath: string + suggestedName: string + manifest: RetentionManifest +} + +/** + * Stream the doomed files into one ZIP (STORE - the corpus is mostly PNG/media that + * does not recompress, and it can be ~1GB, so never buffer it) plus a manifest.json. + */ +export async function stageRetentionArchive( + files: DoomedFile[], + opts: { category: DataCategoryId; olderThanDays?: number; tempDir?: string } +): Promise { + const now = new Date() + const cutoff = + opts.olderThanDays && opts.olderThanDays > 0 + ? new Date(now.getTime() - opts.olderThanDays * 86_400_000) + : null + const manifest: RetentionManifest = { + surface: 'offgrid-desktop-retention', + category: opts.category, + olderThanDays: opts.olderThanDays && opts.olderThanDays > 0 ? opts.olderThanDays : null, + cutoffIso: cutoff ? cutoff.toISOString() : null, + createdIso: now.toISOString(), + fileCount: files.length, + totalBytes: files.reduce((sum, f) => sum + f.bytes, 0), + note: 'Files archived by Off Grid AI Desktop before a category delete. Database rows and search vectors for this category were deleted, not archived.' + } + const zip = new JSZip() + zip.file('manifest.json', JSON.stringify(manifest, null, 2)) + for (const file of files) { + zip.file(file.zipKey, fs.createReadStream(file.absPath)) + } + const stageDir = await fs.promises.mkdtemp( + path.join(opts.tempDir ?? os.tmpdir(), 'offgrid-retention-') + ) + const day = now.toISOString().slice(0, 10) + const suggestedName = cutoff + ? `offgrid-${opts.category}-before-${cutoff.toISOString().slice(0, 10)}.zip` + : `offgrid-${opts.category}-all-${day}.zip` + const zipPath = path.join(stageDir, suggestedName) + await new Promise((resolve, reject) => { + zip + .generateNodeStream({ type: 'nodebuffer', streamFiles: true, compression: 'STORE' }) + .pipe(fs.createWriteStream(zipPath)) + .on('finish', () => resolve()) + .on('error', reject) + }) + return { zipPath, suggestedName, manifest } +} + +export interface ArchiveDelivery { + canceled: boolean + path?: string +} + +/** The injected seams: today's sink is the save dialog; Phase 2's scheduled cleanup + * swaps `deliver` for a fixed-folder copy with no dialog - same orchestration. */ +export interface ArchiveClearDeps { + collect: () => DoomedFile[] + stage: (files: DoomedFile[]) => Promise + deliver: (zipPath: string, suggestedName: string) => Promise + clear: () => Promise<{ success: boolean }> +} + +// The renderer-facing shape is the shared contract - one definition, both sides. +export type ArchiveClearResult = RetentionArchiveClearContract + +/** + * The ordering contract: delete runs ONLY after the archive is confirmed delivered. + * Cancel or any archive failure leaves every file in place. Zero doomed files skips + * the archive (nothing file-based to lose) and clears directly. + */ +export async function archiveThenClear(deps: ArchiveClearDeps): Promise { + try { + const files = deps.collect() + if (files.length === 0) { + const cleared = await deps.clear() + return cleared.success + ? { status: 'cleared', archivedFiles: 0 } + : { status: 'failed', error: 'Nothing to archive, but the delete failed.' } + } + const staged = await deps.stage(files) + const delivery = await deps.deliver(staged.zipPath, staged.suggestedName) + if (delivery.canceled) return { status: 'canceled' } + const cleared = await deps.clear() + return cleared.success + ? { status: 'cleared', archivedFiles: files.length, archivePath: delivery.path } + : { status: 'failed', error: 'The archive was saved, but the delete failed.' } + } catch (e) { + return { status: 'failed', error: e instanceof Error ? e.message : String(e) } + } +} diff --git a/src/main/data-categories.ts b/src/main/data-categories.ts new file mode 100644 index 00000000..ab9ee7a6 --- /dev/null +++ b/src/main/data-categories.ts @@ -0,0 +1,21 @@ +// The single source of truth for which userData directories each data category owns. +// Pure data (no imports) so it is unit-testable and shared by every consumer: +// - data-privacy's delete paths (clearCategory, getDataSummary), and +// - the retention archive (backup/retention-archive.ts), which must ZIP exactly the +// files the delete would remove. +// Two lists here would drift into "backed up X, deleted Y" - that is the bug class +// this module exists to prevent. Dir names are userData-relative; callers resolve +// them against app.getPath('userData'). + +import type { DataCategoryId } from '../shared/backup-contracts' + +export { ARCHIVABLE_CATEGORIES } from '../shared/backup-contracts' +export type { DataCategoryId } + +export const CATEGORY_DIRS: Record = { + chats: ['uploads'], + memories: ['entity-photos'], + captures: ['captures'], + meetings: ['meetings'], + images: ['generated-images', 'artifacts-library', 'style-thumbs'] +} diff --git a/src/main/data-privacy.ts b/src/main/data-privacy.ts index 16dd04a6..c475af33 100644 --- a/src/main/data-privacy.ts +++ b/src/main/data-privacy.ts @@ -7,9 +7,10 @@ import path from 'path' import { app } from 'electron' import { getDB } from './database' import { deleteByKinds, deleteByKindsOlderThan, resetVectors } from './vectors' +import { CATEGORY_DIRS, type DataCategoryId } from './data-categories' export interface DataCategory { - id: 'chats' | 'memories' | 'captures' | 'meetings' | 'images' + id: DataCategoryId label: string detail: string count?: number @@ -18,6 +19,9 @@ export interface DataCategory { const ud = (...p: string[]): string => path.join(app.getPath('userData'), ...p) +/** The category's userData dirs, absolute - resolved from the shared SSOT map. */ +const categoryDirs = (id: DataCategoryId): string[] => CATEGORY_DIRS[id].map((d) => ud(d)) + function dirSize(p: string): { bytes: number; files: number } { let bytes = 0, files = 0 @@ -279,14 +283,17 @@ function clearFiles(...files: string[]): void { /** Summary of what's stored, per category, for the Delete-my-data screen. */ export function getDataSummary(): DataCategory[] { - const captures = dirSize(ud('captures')) - const meetings = dirSize(ud('meetings')) - const images = (() => { - const a = dirSize(ud('generated-images')), - b = dirSize(ud('artifacts-library')), - c = dirSize(ud('style-thumbs')) - return { bytes: a.bytes + b.bytes + c.bytes, files: a.files + b.files + c.files } - })() + const sumDirs = (id: DataCategoryId): { bytes: number; files: number } => + categoryDirs(id).reduce( + (acc, dir) => { + const s = dirSize(dir) + return { bytes: acc.bytes + s.bytes, files: acc.files + s.files } + }, + { bytes: 0, files: 0 } + ) + const captures = sumDirs('captures') + const meetings = sumDirs('meetings') + const images = sumDirs('images') return [ { id: 'chats', @@ -335,21 +342,21 @@ export async function clearCategory( switch (id) { case 'chats': clearTables(...CHAT_TABLES) - clearDirs(ud('uploads')) + clearDirs(...categoryDirs('chats')) break case 'memories': clearTables(...MEMORY_TABLES) - clearDirs(ud('entity-photos')) + clearDirs(...categoryDirs('memories')) // Delete ONLY the memory-side vectors (not the shared lancedb dir — that // would wipe capture/meeting/chat vectors and dangle the live handle). await deleteByKinds(['memory', 'entity', 'fact']) break case 'captures': if (olderThanDays && olderThanDays > 0) { - clearDirsOlderThan(olderThanDays, ud('captures')) + clearDirsOlderThan(olderThanDays, ...categoryDirs('captures')) await deleteByKindsOlderThan(['screen'], Date.now() - olderThanDays * 86_400_000) // prune stale capture vectors too } else { - clearDirs(ud('captures')) + clearDirs(...categoryDirs('captures')) await deleteByKinds(['screen']) // full clear → drop capture vectors too // Registered cleaners below remove the semantic source rows. Drop their indexing // receipts here as well so a future capture can never inherit a stale marker. @@ -364,16 +371,16 @@ export async function clearCategory( break case 'meetings': if (olderThanDays && olderThanDays > 0) { - clearDirsOlderThan(olderThanDays, ud('meetings')) + clearDirsOlderThan(olderThanDays, ...categoryDirs('meetings')) await deleteByKindsOlderThan(['meeting'], Date.now() - olderThanDays * 86_400_000) // prune stale meeting vectors too } else { - clearDirs(ud('meetings')) + clearDirs(...categoryDirs('meetings')) await deleteByKinds(['meeting']) // full clear → drop meeting vectors too } pruneDanglingMeetings() // drop rows whose media we just deleted (no ghosts) break case 'images': - clearDirs(ud('generated-images'), ud('artifacts-library'), ud('style-thumbs')) + clearDirs(...categoryDirs('images')) break } for (const cleaner of categoryCleaners.get(id)?.values() ?? []) { diff --git a/src/main/index.ts b/src/main/index.ts index 2249a0c9..16e737a7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -401,6 +401,8 @@ app.whenReady().then(async () => { setupRagIPC() setupMcpIpc() // basic MCP connectors (management + chat tool extension) setupDesktopBackupIPC() + // Automatic history cleanup (archive-then-prune old captures) - daily, opt-in. + import('./backup/retention-archive-ipc').then((m) => m.setupAutoCleanupScheduler()) // one OpenAI-compatible local gateway (LLM + STT); auto-picks a free port. Async, so handle a // rejection on the promise (a try/catch around a fire-and-forget async call can't catch it). startModelServer().catch((e) => console.error('[model-server] start failed', e)) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7dd84793..3d9ba975 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1706,6 +1706,9 @@ export function setupIPC() { ) ) ipcMain.handle('data:delete-all', () => import('./data-privacy').then((m) => m.deleteAllData())) + // Archive-before-delete + automatic history cleanup - the channel map lives with + // its handlers (backup/retention-archive-ipc.ts) behind an injectable boundary. + void import('./backup/retention-archive-ipc').then((m) => m.registerRetentionIpc(ipcMain)) // --- Image generation (stable-diffusion.cpp) ---------------------------- ipcMain.handle('imagegen:status', async () => { diff --git a/src/preload/index.ts b/src/preload/index.ts index c4ce7977..88886888 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,8 +14,12 @@ import type { import { BACKUP_EXPORT_ALL_CHANNEL, BACKUP_IMPORT_CHANNEL, + RETENTION_ARCHIVE_CLEAR_CHANNEL, + type AutoCleanupRunContract, + type AutoCleanupStatusContract, type BackupDeliveryContract, - type BackupRestoreSummaryContract + type BackupRestoreSummaryContract, + type RetentionArchiveClearContract } from '../shared/backup-contracts' console.log('PRELOAD SCRIPT LOADED') @@ -406,6 +410,17 @@ const offGridApi = { getDataSummary: () => ipcRenderer.invoke('data:summary'), clearDataCategory: (id: string, olderThanDays?: number) => ipcRenderer.invoke('data:clear', id, olderThanDays), + archiveDataCategory: (id: string, olderThanDays?: number) => + ipcRenderer.invoke( + RETENTION_ARCHIVE_CLEAR_CHANNEL, + id, + olderThanDays + ) as Promise, + getAutoCleanupStatus: () => + ipcRenderer.invoke('data:auto-cleanup-status') as Promise, + runAutoCleanupNow: () => + ipcRenderer.invoke('data:auto-cleanup-run') as Promise, + pickArchiveDir: () => ipcRenderer.invoke('data:pick-archive-dir') as Promise, deleteAllData: () => ipcRenderer.invoke('data:delete-all'), exportBackup: () => ipcRenderer.invoke(BACKUP_EXPORT_ALL_CHANNEL) as Promise, diff --git a/src/renderer/src/components/setup/DataPrivacyPanel.tsx b/src/renderer/src/components/setup/DataPrivacyPanel.tsx index cf2af14b..81c01add 100644 --- a/src/renderer/src/components/setup/DataPrivacyPanel.tsx +++ b/src/renderer/src/components/setup/DataPrivacyPanel.tsx @@ -1,8 +1,22 @@ import { useCallback, useEffect, useState } from 'react' -import { Trash, Warning } from '@phosphor-icons/react' +import { Archive, ArrowsClockwise, FolderOpen, Trash, Warning, X } from '@phosphor-icons/react' +import { + ARCHIVABLE_CATEGORIES, + AUTO_CLEANUP_SETTING_KEY, + type AutoCleanupConfigContract, + type AutoCleanupStatusContract, + type DataCategoryId +} from '../../../../shared/backup-contracts' + +const RETENTION_CHOICES = [ + { days: 0, label: 'Off' }, + { days: 30, label: '30 days' }, + { days: 60, label: '60 days' }, + { days: 90, label: '90 days' } +] interface DataCategory { - id: 'chats' | 'memories' | 'captures' | 'meetings' | 'images' + id: DataCategoryId label: string detail: string count?: number @@ -22,6 +36,20 @@ export function DataPrivacyPanel(): React.ReactElement { const api = window.api const [cats, setCats] = useState([]) const [busy, setBusy] = useState(null) + // Per-category "Back up first": when on, the delete buttons archive to a + // user-picked ZIP before clearing (fail closed - cancel deletes nothing). + const [backupFirst, setBackupFirst] = useState>(new Set()) + // Automatic history cleanup (Phase 2): config + last run, owned by the main process. + const [auto, setAuto] = useState(null) + + const toggleBackupFirst = (id: DataCategoryId): void => { + setBackupFirst((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } const refresh = useCallback(async () => { try { @@ -32,9 +60,35 @@ export function DataPrivacyPanel(): React.ReactElement { } }, [api]) + const refreshAuto = useCallback(async () => { + try { + setAuto(await api.getAutoCleanupStatus()) + } catch { + /* keep last */ + } + }, [api]) + useEffect(() => { refresh() - }, [refresh]) + refreshAuto() + }, [refresh, refreshAuto]) + + // Save, then re-read from main - it sanitizes the config, so main stays the SSOT. + const saveAutoCleanup = async (config: AutoCleanupConfigContract): Promise => { + await api.saveSetting(AUTO_CLEANUP_SETTING_KEY, config) + await refreshAuto() + } + + const runCleanupNow = async (): Promise => { + setBusy('auto-cleanup') + try { + await api.runAutoCleanupNow() + await refresh() + await refreshAuto() + } finally { + setBusy(null) + } + } // Time-based retention is offered for captures + meetings (they accumulate). const RETENTION: Record = { @@ -54,6 +108,24 @@ export function DataPrivacyPanel(): React.ReactElement { const what = olderThanDays ? `${c.label.toLowerCase()} older than ${olderThanDays} days` : `all ${c.label.toLowerCase()}` + if (backupFirst.has(c.id)) { + if ( + !window.confirm( + `Back up ${what} to a ZIP, then delete? You'll pick where the backup is saved - canceling that deletes nothing.` + ) + ) + return + setBusy(c.id) + try { + const result = await api.archiveDataCategory(c.id, olderThanDays) + if (result.status === 'failed') + window.alert(`Backup failed - nothing was deleted. ${result.error}`) + await refresh() + } finally { + setBusy(null) + } + return + } if ( !window.confirm( `Delete ${what}? This permanently removes it from this device and can't be undone.` @@ -118,6 +190,22 @@ export function DataPrivacyPanel(): React.ReactElement {
+ {ARCHIVABLE_CATEGORIES.includes(c.id) ? ( + + ) : null} {RETENTION[c.id]?.map((r) => (
@@ -146,6 +240,89 @@ export function DataPrivacyPanel(): React.ReactElement { )} + {/* Automatic history cleanup */} + {auto ? ( +
+
+
+
Automatic cleanup
+
+ Keep screen captures for a window - older ones are archived to a folder you pick + (optional), then removed. Runs daily. +
+
+
+ {RETENTION_CHOICES.map((choice) => ( + + ))} +
+
+ {auto.config.retentionDays > 0 ? ( +
+
+ + {auto.config.archiveDir ? ( + + ) : null} +
+
+ {auto.lastRun ? ( + + {auto.lastRun.status === 'failed' + ? `Last run failed - nothing was deleted. ${auto.lastRun.error ?? ''}` + : `Last run ${new Date(auto.lastRun.ranAt).toLocaleString()} - ${auto.lastRun.archivedFiles ?? 0} file${(auto.lastRun.archivedFiles ?? 0) === 1 ? '' : 's'} archived`} + + ) : null} + +
+
+ ) : null} +
+ ) : null} + {/* Full reset */}
diff --git a/src/renderer/src/components/setup/__tests__/DataPrivacyPanel.test.tsx b/src/renderer/src/components/setup/__tests__/DataPrivacyPanel.test.tsx new file mode 100644 index 00000000..42509d31 --- /dev/null +++ b/src/renderer/src/components/setup/__tests__/DataPrivacyPanel.test.tsx @@ -0,0 +1,176 @@ +// @vitest-environment jsdom +// The archive-before-delete UI contract: "Back up first" appears only for archivable +// categories, and when it is on the delete buttons route to archiveDataCategory (the +// fail-closed IPC) instead of the plain delete. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { DataPrivacyPanel } from '../DataPrivacyPanel' +import { ARCHIVABLE_CATEGORIES } from '../../../../../shared/backup-contracts' + +const SUMMARY = [ + { id: 'chats', label: 'Chats', detail: 'Conversations and messages', count: 3 }, + { + id: 'captures', + label: 'Screen captures', + detail: 'Captured frames and OCR', + count: 10, + bytes: 5e6 + }, + { id: 'meetings', label: 'Meetings', detail: 'Recordings and transcripts', count: 2, bytes: 1e6 }, + { id: 'images', label: 'Generated images & artifacts', detail: 'Images', count: 1, bytes: 1e6 } +] + +const api = { + getDataSummary: vi.fn(async () => SUMMARY), + clearDataCategory: vi.fn(async () => ({ success: true })), + archiveDataCategory: vi.fn(async () => ({ status: 'cleared', archivedFiles: 10 })), + deleteAllData: vi.fn(async () => ({ success: true })), + getAutoCleanupStatus: vi.fn(async () => ({ + config: { retentionDays: 0, archiveDir: null }, + lastRun: null + })), + saveSetting: vi.fn(async () => true), + runAutoCleanupNow: vi.fn(async () => ({ status: 'cleared', ranAt: 1, archivedFiles: 4 })), + pickArchiveDir: vi.fn(async () => '/Volumes/SSD/Archive') +} + +beforeEach(() => { + ;(window as unknown as { api: unknown }).api = api + vi.spyOn(window, 'confirm').mockReturnValue(true) + vi.spyOn(window, 'alert').mockImplementation(() => {}) +}) +afterEach(() => { + cleanup() + vi.restoreAllMocks() + api.clearDataCategory.mockClear() + api.archiveDataCategory.mockClear() + api.saveSetting.mockClear() + api.runAutoCleanupNow.mockClear() + api.getAutoCleanupStatus.mockClear() +}) + +const backupToggle = (label: string): HTMLElement => + screen.getByRole('button', { name: `Back up ${label} before deleting` }) + +describe(' archive-before-delete', () => { + it('offers Back up first for exactly the archivable categories', async () => { + render() + await waitFor(() => expect(screen.getByText('Screen captures')).toBeTruthy()) + expect(screen.getAllByRole('button', { name: /back up .* before deleting/i })).toHaveLength( + ARCHIVABLE_CATEGORIES.length + ) + expect(screen.queryByRole('button', { name: /back up chats/i })).toBeNull() + }) + + it('with Back up first ON, a retention chip archives instead of plain-deleting', async () => { + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByText('Screen captures')).toBeTruthy()) + + await user.click(backupToggle('Screen captures')) + await user.click(screen.getAllByRole('button', { name: '> 30 days' })[0]!) + + expect(api.archiveDataCategory).toHaveBeenCalledWith('captures', 30) + expect(api.clearDataCategory).not.toHaveBeenCalled() + }) + + it('with Back up first OFF, the chip plain-deletes as before', async () => { + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByText('Screen captures')).toBeTruthy()) + + await user.click(screen.getAllByRole('button', { name: '> 30 days' })[0]!) + + expect(api.clearDataCategory).toHaveBeenCalledWith('captures', 30) + expect(api.archiveDataCategory).not.toHaveBeenCalled() + }) + + it('a failed archive tells the user nothing was deleted', async () => { + api.archiveDataCategory.mockResolvedValueOnce({ + status: 'failed', + error: 'disk full' + } as never) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByText('Screen captures')).toBeTruthy()) + + await user.click(backupToggle('Screen captures')) + await user.click(screen.getAllByRole('button', { name: '> 3 days' })[0]!) + + await waitFor(() => + expect(window.alert).toHaveBeenCalledWith(expect.stringContaining('nothing was deleted')) + ) + }) + + it('a declined confirm never reaches the archive IPC', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByText('Screen captures')).toBeTruthy()) + + await user.click(backupToggle('Screen captures')) + await user.click(screen.getAllByRole('button', { name: '> 30 days' })[0]!) + + expect(api.archiveDataCategory).not.toHaveBeenCalled() + expect(api.clearDataCategory).not.toHaveBeenCalled() + }) +}) + +describe(' automatic cleanup', () => { + it('defaults to Off, hiding the folder/run controls', async () => { + render() + await waitFor(() => expect(screen.getByText('Automatic cleanup')).toBeTruthy()) + expect(screen.getByRole('button', { name: 'Off' }).getAttribute('aria-pressed')).toBe('true') + expect(screen.queryByRole('button', { name: /run now/i })).toBeNull() + }) + + it('choosing a window saves the config and re-reads main-sanitized status', async () => { + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByText('Automatic cleanup')).toBeTruthy()) + + await user.click(screen.getByRole('button', { name: '30 days' })) + + expect(api.saveSetting).toHaveBeenCalledWith('autoCleanup', { + retentionDays: 30, + archiveDir: null + }) + // Saved, then re-fetched - main's sanitized copy is the source of truth. + await waitFor(() => expect(api.getAutoCleanupStatus.mock.calls.length).toBeGreaterThan(1)) + }) + + it('with retention on, offers the folder picker and Run now, and reports the last run', async () => { + api.getAutoCleanupStatus.mockResolvedValue({ + config: { retentionDays: 30, archiveDir: null }, + lastRun: { status: 'cleared', ranAt: 1756100000000, archivedFiles: 12 } + } as never) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByText(/no backup - choose a folder/i)).toBeTruthy()) + expect(screen.getByText(/12 files archived/i)).toBeTruthy() + + await user.click(screen.getByText(/no backup - choose a folder/i)) + expect(api.pickArchiveDir).toHaveBeenCalled() + await waitFor(() => + expect(api.saveSetting).toHaveBeenCalledWith('autoCleanup', { + retentionDays: 30, + archiveDir: '/Volumes/SSD/Archive' + }) + ) + + await user.click(screen.getByRole('button', { name: /run now/i })) + expect(api.runAutoCleanupNow).toHaveBeenCalledTimes(1) + }) + + it('a failed last run says nothing was deleted', async () => { + api.getAutoCleanupStatus.mockResolvedValue({ + config: { retentionDays: 30, archiveDir: '/gone' }, + lastRun: { status: 'failed', ranAt: 1756100000000, error: 'drive unplugged' } + } as never) + render() + await waitFor(() => + expect(screen.getByText(/last run failed - nothing was deleted/i)).toBeTruthy() + ) + }) +}) diff --git a/src/shared/backup-contracts.ts b/src/shared/backup-contracts.ts index 074b492c..4e947a0f 100644 --- a/src/shared/backup-contracts.ts +++ b/src/shared/backup-contracts.ts @@ -12,3 +12,53 @@ export interface BackupRestoreSummaryContract { messagesAdded: number documentsAdded: number } + +// Retention archive (archive-before-delete) - see src/main/backup/retention-archive.ts. +export const RETENTION_ARCHIVE_CLEAR_CHANNEL = 'data:archive-clear' + +export type DataCategoryId = 'chats' | 'memories' | 'captures' | 'meetings' | 'images' + +/** Categories whose deletable payload is files on disk - the ones worth archiving + * before a delete. Shared so the renderer offers "Back up & delete" for exactly the + * categories the main-process handler accepts. */ +export const ARCHIVABLE_CATEGORIES: readonly DataCategoryId[] = ['captures', 'meetings', 'images'] + +/** Result of an archive-then-clear run. `canceled` = user closed the save dialog; + * nothing was deleted. `failed` = archive or delete failed; on an archive failure + * nothing was deleted (fail closed). */ +export type RetentionArchiveClearContract = + | { status: 'cleared'; archivedFiles: number; archivePath?: string } + | { status: 'canceled' } + | { status: 'failed'; error: string } + +// Automatic history cleanup (Phase 2) - a daily job that archives-then-prunes old +// screen captures using the same fail-closed machinery as the manual flow. +// Config is a single app_settings key so the renderer saves it via settings:save. +export const AUTO_CLEANUP_SETTING_KEY = 'autoCleanup' + +export interface AutoCleanupConfigContract { + /** Keep screen history for this many days; 0 = automatic cleanup off. */ + retentionDays: number + /** Archive old captures to this folder before pruning; null = prune without backup. */ + archiveDir: string | null +} + +export const AUTO_CLEANUP_DEFAULTS: AutoCleanupConfigContract = { + retentionDays: 0, + archiveDir: null +} + +export interface AutoCleanupRunContract { + /** 'off' = retention disabled, nothing ran. 'failed' = archive or prune failed; + * on an archive failure nothing was pruned (fail closed). */ + status: 'off' | 'cleared' | 'failed' + ranAt: number + archivedFiles?: number + archivePath?: string + error?: string +} + +export interface AutoCleanupStatusContract { + config: AutoCleanupConfigContract + lastRun: AutoCleanupRunContract | null +}