Files
AeroFetch/src/main/backup.ts
T
debont80 9134e7d216 feat(audit): Batch 25 — project reorg (CC12), leading DI args (CC7), CC10 by-design
CC12: renderer views/ (5 screens + Onboarding + settings cards) +
lib/ (theme/thumb/datetime/id/qualityOptions/useClipboardLink/queueStats);
main core/ (buildArgs/validation/indexerCore/ytdlpPolicy). git mv preserved
history; all src+test imports updated. Build emits view chunks from views/,
344 tests + typecheck green, live probe rendered all screens.
CC7: wc/onProgress is the leading arg everywhere — downloadAppUpdate(wc,url),
indexSource(onProgress,url,signal), indexSourceCancelable(onProgress,url).
CC10: closed by-design — jsonStore backs all records; settings stay in
electron-store for DPAPI secret encryption (a deliberate two-store split).
Also closes the UI24/W4 context-menu cross-reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:37:28 -04:00

136 lines
6.0 KiB
TypeScript

import { dialog, type BrowserWindow } from 'electron'
import { readFileSync, writeFileSync } from 'fs'
import { getSettings, setSettings, SECRET_KEYS } from './settings'
import { migrateSettingsKeys } from './settingsMigration'
import { listTemplates, replaceTemplates } from './templates'
import { isTemplateLike } from './core/validation'
import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc'
interface BackupFile {
version: 1
settings: Settings
templates: CommandTemplate[]
}
export async function exportBackup(win: BrowserWindow | undefined): Promise<BackupExportResult> {
const opts = {
defaultPath: 'aerofetch-backup.json',
filters: [{ name: 'JSON', extensions: ['json'] }]
}
const res = await (win ? dialog.showSaveDialog(win, opts) : dialog.showSaveDialog(opts))
if (res.canceled || !res.filePath) return { ok: false }
// Strip credentials (proxy creds, API tokens) so a backup file shared or synced
// to the cloud doesn't leak secrets in plaintext (M22). The user re-enters them
// after import. Shares SECRET_KEYS with settings.ts so the lists can't drift.
const settings = { ...getSettings() }
for (const key of SECRET_KEYS) (settings as Record<string, unknown>)[key] = ''
const payload: BackupFile = { version: 1, settings, templates: listTemplates() }
try {
writeFileSync(res.filePath, JSON.stringify(payload, null, 2))
return { ok: true, path: res.filePath }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
}
export async function importBackup(win: BrowserWindow | undefined): Promise<BackupImportResult> {
const openOpts = {
properties: ['openFile' as const],
filters: [{ name: 'JSON', extensions: ['json'] }]
}
const res = await (win ? dialog.showOpenDialog(win, openOpts) : dialog.showOpenDialog(openOpts))
if (res.canceled || !res.filePaths[0]) return { ok: false }
let parsed: unknown
try {
parsed = JSON.parse(readFileSync(res.filePaths[0], 'utf8'))
} catch (e) {
return { ok: false, error: `Could not read backup file: ${(e as Error).message}` }
}
if (!parsed || typeof parsed !== 'object') {
return { ok: false, error: 'Not a valid AeroFetch backup file.' }
}
// setSettings/replaceTemplates validate and sanitize every field, so a
// hand-edited or partial backup file degrades gracefully rather than
// corrupting the store.
const file = parsed as Partial<BackupFile>
// Backups exported by an older AeroFetch carry pre-rename keys (CC1) — map
// them forward so old backup files keep restoring cleanly.
const incomingSettings =
file.settings && typeof file.settings === 'object'
? (migrateSettingsKeys(
file.settings as unknown as Record<string, unknown>
) as Partial<Settings>)
: undefined
// Drop non-object / id-less entries up front (audit T3) so both the consent
// check below and replaceTemplates see only well-shaped rows. Without this a
// backup with a null/garbage templates entry throws in sanitize() instead of
// degrading gracefully as this function promises.
const incomingTemplates = Array.isArray(file.templates)
? (file.templates as CommandTemplate[]).filter(isTemplateLike)
: []
// A custom-command template's `args` are extra yt-dlp flags that get spawned on
// the next download. A backup that arrives with enableCustomCommands + a default
// template carrying args would silently enable code execution the moment a
// download starts — so require an explicit, informed confirmation before
// honouring that, and import the templates in a *disabled* state if declined.
const commandTemplates = incomingTemplates.filter(
(t) => t && typeof t === 'object' && typeof t.args === 'string' && t.args.trim() !== ''
)
const wouldEnableCustomCommands =
incomingSettings?.enableCustomCommands === true && commandTemplates.length > 0
let applyCustomCommands = true
if (wouldEnableCustomCommands) {
const preview = commandTemplates
.slice(0, 10)
.map((t) => {
const name = (t.name ?? '').trim() || 'Untitled command'
const args = t.args.length > 100 ? `${t.args.slice(0, 100)}…` : t.args
return `• ${name}: ${args}`
})
.join('\n')
const more = commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
const msgOpts = {
type: 'warning' as const,
buttons: ['Enable custom commands', 'Import without enabling', 'Cancel'],
defaultId: 1,
cancelId: 2,
title: 'Backup contains custom commands',
message: `This backup enables ${commandTemplates.length} custom-command template${
commandTemplates.length === 1 ? '' : 's'
}.`,
detail:
'Custom commands run extra yt-dlp flags on every download. Only enable them if you trust this backup file.\n\n' +
preview +
more
}
const choice = await (win
? dialog.showMessageBox(win, msgOpts)
: dialog.showMessageBox(msgOpts))
if (choice.response === 2) return { ok: false } // Cancel — change nothing
applyCustomCommands = choice.response === 0
}
if (incomingSettings) {
// Never restore credential fields from a backup. Exports strip them (M22), so an
// imported '' would otherwise wipe a proxy/token already configured on this
// machine; honoring a hand-edited one would reintroduce the leak vector. Either
// way, import leaves the user's existing secrets untouched — they re-enter as needed.
const restored: Partial<Settings> = { ...incomingSettings }
for (const key of SECRET_KEYS) delete restored[key]
// When the user declined to enable custom commands (or there were none to
// enable), force the toggle off so an imported defaultTemplateId can't auto-run.
const safeSettings = applyCustomCommands
? restored
: { ...restored, enableCustomCommands: false }
setSettings(safeSettings)
}
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
replaceTemplates(incomingTemplates)
}
return { ok: true }
}