9d816f0c86
L49: Remove newline from missing-binary error message in download.ts; inline
error spans render it as literal newline, now one sentence with period.
L53: backup.ts exportBackup/importBackup/showMessageBox no longer use win!
non-null assertions -- each uses a guarded ternary (same pattern index.ts
already used for chooseFolder).
L59: Extract PAGE_BACKGROUND to src/shared/theme.ts; main/index.ts and
renderer/src/theme.ts both import from it -- no more "keep in sync" comment.
typecheck + 242 tests + eslint + prettier green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
131 lines
5.7 KiB
TypeScript
131 lines
5.7 KiB
TypeScript
import { dialog, type BrowserWindow } from 'electron'
|
|
import { readFileSync, writeFileSync } from 'fs'
|
|
import { getSettings, setSettings, SECRET_KEYS } from './settings'
|
|
import { listTemplates, replaceTemplates } from './templates'
|
|
import { isTemplateLike } from './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>
|
|
const incomingSettings =
|
|
file.settings && typeof file.settings === 'object'
|
|
? (file.settings 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 customCommandEnabled + 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?.customCommandEnabled === 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, customCommandEnabled: false }
|
|
setSettings(safeSettings)
|
|
}
|
|
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
|
|
replaceTemplates(incomingTemplates)
|
|
}
|
|
return { ok: true }
|
|
}
|