feat(audit): Batch 23 — settings key renames with migration (CC1) + config.ts (CC11)
CC1: customCommandEnabled→enableCustomCommands, downloadArchive→ useDownloadArchive, clipboardWatch→watchClipboard, sidebarCollapsed→ isSidebarCollapsed, hardwareAcceleration→useHardwareAcceleration, videoDir/audioDir→videoFolder/audioFolder (+ chooseDir/clearDir store actions → chooseFolder/clearFolder). Rename map in settingsMigration.ts (pure, unit-tested incl. pre-rename backup fixture), applied as an idempotent on-disk shim at store open and on backup import so old settings.json and old backups both keep working. CC11: update host/owner/repo + release API moved to src/main/config.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+9
-4
@@ -1,6 +1,7 @@
|
||||
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 './validation'
|
||||
import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc'
|
||||
@@ -54,9 +55,13 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
|
||||
// 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'
|
||||
? (file.settings as Partial<Settings>)
|
||||
? (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
|
||||
@@ -67,7 +72,7 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
|
||||
: []
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
@@ -75,7 +80,7 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
|
||||
(t) => t && typeof t === 'object' && typeof t.args === 'string' && t.args.trim() !== ''
|
||||
)
|
||||
const wouldEnableCustomCommands =
|
||||
incomingSettings?.customCommandEnabled === true && commandTemplates.length > 0
|
||||
incomingSettings?.enableCustomCommands === true && commandTemplates.length > 0
|
||||
|
||||
let applyCustomCommands = true
|
||||
if (wouldEnableCustomCommands) {
|
||||
@@ -120,7 +125,7 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
|
||||
// enable), force the toggle off so an imported defaultTemplateId can't auto-run.
|
||||
const safeSettings = applyCustomCommands
|
||||
? restored
|
||||
: { ...restored, customCommandEnabled: false }
|
||||
: { ...restored, enableCustomCommands: false }
|
||||
setSettings(safeSettings)
|
||||
}
|
||||
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
|
||||
|
||||
@@ -172,24 +172,24 @@ export function parseExtraArgs(raw: string): string[] {
|
||||
* per-download override wins over the persisted default template, but NEITHER is
|
||||
* applied while the gate is off. This keeps a compromised renderer from smuggling
|
||||
* code-exec flags through a lone `startDownload({ extraArgs })` call: it would
|
||||
* first have to flip the visible `customCommandEnabled` setting, leaving a trace
|
||||
* first have to flip the visible `enableCustomCommands` setting, leaving a trace
|
||||
* (the same defence-in-depth posture as the main-side maxConcurrent cap).
|
||||
*
|
||||
* - customCommandEnabled off → [] (always)
|
||||
* - enableCustomCommands off → [] (always)
|
||||
* - perDownloadExtraArgs defined (even '') → those args
|
||||
* - else a template whose urlPattern matches → that template's args (most specific)
|
||||
* - else a matching defaultTemplateId → that template's args
|
||||
* - else → []
|
||||
*/
|
||||
export function selectExtraArgs(params: {
|
||||
customCommandEnabled: boolean
|
||||
enableCustomCommands: boolean
|
||||
perDownloadExtraArgs: string | undefined
|
||||
defaultTemplateId: string | null
|
||||
templates: Pick<CommandTemplate, 'id' | 'args' | 'urlPattern'>[]
|
||||
/** the download URL, for urlPattern auto-matching */
|
||||
url?: string
|
||||
}): string[] {
|
||||
if (!params.customCommandEnabled) return []
|
||||
if (!params.enableCustomCommands) return []
|
||||
if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs)
|
||||
// A template whose urlPattern matches this URL auto-applies, ahead of the global
|
||||
// default — it's the more specific choice.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Build/host configuration (CC11): the deploy-specific constants that identify
|
||||
* WHERE this build talks to, separated from runtime tunables (constants.ts —
|
||||
* timeouts, caps, tickers) and from user settings (settings.ts). Retarget a
|
||||
* fork's update source by editing these three values.
|
||||
*
|
||||
* IMPORTANT: the update repo's releases must be ANONYMOUSLY readable — i.e. a
|
||||
* public repo on a Gitea instance that permits anonymous API + downloads.
|
||||
* AeroFetch never ships a token; on a sign-in-required instance the update
|
||||
* check simply reports that it couldn't reach the server.
|
||||
*/
|
||||
export const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
|
||||
export const UPDATE_OWNER = 'debont80'
|
||||
export const UPDATE_REPO = 'AeroFetch'
|
||||
export const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
|
||||
@@ -166,15 +166,15 @@ async function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | nul
|
||||
// --- Argv construction (shared by startDownload and the command preview) ---
|
||||
|
||||
// A per-download override (opts.extraArgs, even '') wins over the persisted
|
||||
// default template — but BOTH are gated on the customCommandEnabled consent flag
|
||||
// default template — but BOTH are gated on the enableCustomCommands consent flag
|
||||
// (see selectExtraArgs / audit F2). The gate is enforced here in main, not just
|
||||
// in the renderer UI, so the renderer can't be trusted to apply it.
|
||||
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
|
||||
// Gate the file read: listTemplates() parses templates.json on every call, so
|
||||
// skip it entirely when the feature is off (PERF2).
|
||||
if (!settings.customCommandEnabled) return []
|
||||
if (!settings.enableCustomCommands) return []
|
||||
return selectExtraArgs({
|
||||
customCommandEnabled: settings.customCommandEnabled,
|
||||
enableCustomCommands: settings.enableCustomCommands,
|
||||
perDownloadExtraArgs: opts.extraArgs,
|
||||
defaultTemplateId: settings.defaultTemplateId,
|
||||
templates: listTemplates(),
|
||||
@@ -197,7 +197,7 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
|
||||
// ignored in favour of the per-kind folder, then the per-kind default. (audit F4)
|
||||
const override = opts.outputDir?.trim()
|
||||
const safeOverride = override && isSafeOutputDir(override) ? override : ''
|
||||
const perKindDir = (opts.kind === 'audio' ? settings.audioDir : settings.videoDir)?.trim()
|
||||
const perKindDir = (opts.kind === 'audio' ? settings.audioFolder : settings.videoFolder)?.trim()
|
||||
const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind)
|
||||
// A collection (media-manager) download is filed into <channel>/<playlist>/
|
||||
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
|
||||
@@ -220,7 +220,7 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
|
||||
!opts.incognito && settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
|
||||
cookiesFile,
|
||||
restrictFilenames: settings.restrictFilenames,
|
||||
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
|
||||
downloadArchivePath: settings.useDownloadArchive ? getDownloadArchivePath() : undefined,
|
||||
youtubePlayerClient: settings.youtubePlayerClient,
|
||||
youtubePoToken: settings.youtubePoToken
|
||||
}
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ setupPortableData()
|
||||
// safe default; users on modern GPUs can flip Settings → Appearance →
|
||||
// "Hardware acceleration" (applies on next launch — this must run before the
|
||||
// app is ready). The window backgroundColor is theme-matched (see createWindow).
|
||||
if (!getSettings().hardwareAcceleration) {
|
||||
if (!getSettings().useHardwareAcceleration) {
|
||||
app.disableHardwareAcceleration()
|
||||
}
|
||||
// Native window-occlusion tracking stays off in BOTH modes — it's a separate
|
||||
|
||||
+29
-11
@@ -1,6 +1,7 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
||||
import { RENAMED_SETTINGS_KEYS } from './settingsMigration'
|
||||
import { logger } from './logger'
|
||||
import {
|
||||
AUDIO_FORMATS,
|
||||
@@ -91,11 +92,28 @@ function sanitizeOptions(input: unknown): DownloadOptions {
|
||||
}
|
||||
}
|
||||
|
||||
// Constructed lazily — electron-store needs app paths, which exist only after
|
||||
// the app is ready (all callers run post-ready).
|
||||
// Constructed lazily — electron-store needs app paths. (`userData` resolves
|
||||
// pre-ready too, which the W15 hardware-acceleration gate in index.ts relies on.)
|
||||
let store: Store<Settings> | null = null
|
||||
function getStore(): Store<Settings> {
|
||||
if (!store) store = new Store<Settings>({ name: 'settings', defaults: DEFAULTS })
|
||||
if (!store) {
|
||||
store = new Store<Settings>({ name: 'settings', defaults: DEFAULTS })
|
||||
// CC1 key-rename migration: move any legacy key still on disk to its new
|
||||
// name, once. `has()` reports defaults as present, so probe the raw store
|
||||
// object instead; idempotent because the old key is deleted afterwards.
|
||||
const raw = store.store as unknown as Record<string, unknown>
|
||||
const legacy = RENAMED_SETTINGS_KEYS.filter(([oldKey]) => oldKey in raw)
|
||||
if (legacy.length > 0) {
|
||||
const untyped = store as unknown as {
|
||||
set(k: string, v: unknown): void
|
||||
delete(k: string): void
|
||||
}
|
||||
for (const [oldKey, newKey] of legacy) {
|
||||
untyped.set(newKey, raw[oldKey])
|
||||
untyped.delete(oldKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
@@ -185,7 +203,7 @@ export function getSettings(): Settings {
|
||||
// `set`, so only write when something actually changed — otherwise this churns
|
||||
// the settings file on every read. (audit P1)
|
||||
const cur = s.store
|
||||
// videoDir/audioDir are intentionally left blank by default — an empty value
|
||||
// videoFolder/audioFolder are intentionally left blank by default — an empty value
|
||||
// routes that kind into Documents\Video / Documents\Audio (see buildCommand).
|
||||
// Only an explicit user choice (Settings → folders) overrides that.
|
||||
// Migrate settings files that predate downloadOptions (or hold a partial one),
|
||||
@@ -278,18 +296,18 @@ function applySettings(s: Store<Settings>, partial: Partial<Settings>): void {
|
||||
// well-formed 24h HH:MM is ever stored (L51).
|
||||
if (isValidSyncTime(value)) s.set('syncTime', value)
|
||||
break
|
||||
case 'clipboardWatch':
|
||||
case 'watchClipboard':
|
||||
case 'useAria2c':
|
||||
case 'restrictFilenames':
|
||||
case 'downloadArchive':
|
||||
case 'useDownloadArchive':
|
||||
case 'autoUpdateYtdlp':
|
||||
case 'customCommandEnabled':
|
||||
case 'enableCustomCommands':
|
||||
case 'notifyOnComplete':
|
||||
case 'autoDownloadNew':
|
||||
case 'hasCompletedOnboarding':
|
||||
case 'minimizeToTray':
|
||||
case 'sidebarCollapsed':
|
||||
case 'hardwareAcceleration':
|
||||
case 'isSidebarCollapsed':
|
||||
case 'useHardwareAcceleration':
|
||||
if (typeof value === 'boolean') s.set(key, value)
|
||||
break
|
||||
case 'launchAtStartup':
|
||||
@@ -325,8 +343,8 @@ function applySettings(s: Store<Settings>, partial: Partial<Settings>): void {
|
||||
// group (it merges field changes locally before calling setSettings).
|
||||
s.set('downloadOptions', sanitizeOptions(value))
|
||||
break
|
||||
case 'videoDir':
|
||||
case 'audioDir':
|
||||
case 'videoFolder':
|
||||
case 'audioFolder':
|
||||
// An empty string is allowed — it clears the override and restores the
|
||||
// Documents\Video / Documents\Audio default for that kind.
|
||||
if (typeof value === 'string' && (value.trim() === '' || isSafeOutputDir(value.trim()))) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Settings key renames (CC1): boolean keys follow the is/has/should/<verb>
|
||||
* convention and folder keys say "folder" (matching the UI) instead of "dir".
|
||||
* The rename is a breaking on-disk change for `settings.json`, so it ships with
|
||||
* this migration, applied in two places:
|
||||
*
|
||||
* - the electron-store shim in settings.ts, which moves any old key found on
|
||||
* disk to its new name once (idempotent — the old key is deleted after);
|
||||
* - backup import (backup.ts), so a backup exported by an older AeroFetch
|
||||
* still restores cleanly.
|
||||
*
|
||||
* Pure and unit-tested (test/settingsMigration.test.ts). New settings keys must
|
||||
* follow the convention up front — this list should only ever grow when a key
|
||||
* is deliberately renamed.
|
||||
*/
|
||||
export const RENAMED_SETTINGS_KEYS: ReadonlyArray<readonly [string, string]> = [
|
||||
['customCommandEnabled', 'enableCustomCommands'],
|
||||
['downloadArchive', 'useDownloadArchive'],
|
||||
['clipboardWatch', 'watchClipboard'],
|
||||
['sidebarCollapsed', 'isSidebarCollapsed'],
|
||||
['hardwareAcceleration', 'useHardwareAcceleration'],
|
||||
['videoDir', 'videoFolder'],
|
||||
['audioDir', 'audioFolder']
|
||||
]
|
||||
|
||||
/**
|
||||
* Return a copy of `obj` with every legacy key moved to its new name. A value
|
||||
* already present under the NEW name wins (the old key is simply dropped), so
|
||||
* a half-migrated or hand-merged file can't regress a newer value.
|
||||
*/
|
||||
export function migrateSettingsKeys(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...obj }
|
||||
for (const [oldKey, newKey] of RENAMED_SETTINGS_KEYS) {
|
||||
if (oldKey in out) {
|
||||
if (!(newKey in out)) out[newKey] = out[oldKey]
|
||||
delete out[oldKey]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* arguments and streams stdout/stderr back to the renderer line-by-line.
|
||||
*
|
||||
* SECURITY: the executable is fixed to the managed yt-dlp (never an arbitrary exe),
|
||||
* and the whole feature is gated on Settings.customCommandEnabled — the same consent
|
||||
* and the whole feature is gated on Settings.enableCustomCommands — the same consent
|
||||
* flag that guards per-download extra args, because yt-dlp args can run arbitrary
|
||||
* code (`--exec`). The gate is enforced here in main, not just in the renderer UI.
|
||||
*/
|
||||
@@ -25,7 +25,7 @@ function send(wc: WebContents, ev: TerminalEvent): void {
|
||||
}
|
||||
|
||||
export function runTerminal(wc: WebContents, id: string, argsRaw: string): TerminalRunResult {
|
||||
if (!getSettings().customCommandEnabled) {
|
||||
if (!getSettings().enableCustomCommands) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.'
|
||||
|
||||
+4
-11
@@ -4,6 +4,7 @@ import { stat, unlink } from 'fs/promises'
|
||||
import { join, normalize, dirname } from 'path'
|
||||
import { createHash } from 'crypto'
|
||||
import { getSettings } from './settings'
|
||||
import { UPDATE_HOST, RELEASE_API } from './config'
|
||||
import {
|
||||
IpcChannels,
|
||||
type AppUpdateInfo,
|
||||
@@ -28,18 +29,10 @@ function authHeader(): Record<string, string> {
|
||||
}
|
||||
|
||||
// --- Update source -----------------------------------------------------------
|
||||
// The Gitea repo whose Releases host the AeroFetch installers. The updater reads
|
||||
// the repo's latest release over the public REST API and downloads the installer
|
||||
// The Gitea repo whose Releases host the AeroFetch installers lives in
|
||||
// config.ts (CC11) with the other build/host constants. The updater reads the
|
||||
// repo's latest release over the public REST API and downloads the installer
|
||||
// asset attached to it.
|
||||
//
|
||||
// IMPORTANT: this points at a repo whose releases must be ANONYMOUSLY readable —
|
||||
// i.e. a public repo on a Gitea instance that permits anonymous API + downloads.
|
||||
// AeroFetch never ships a token; on a sign-in-required instance the check simply
|
||||
// reports that it couldn't reach the server. Change OWNER/REPO to retarget.
|
||||
const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
|
||||
const UPDATE_OWNER = 'debont80'
|
||||
const UPDATE_REPO = 'AeroFetch'
|
||||
const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
|
||||
|
||||
// Security: only ever download or execute a file served by the trusted update
|
||||
// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects
|
||||
|
||||
Reference in New Issue
Block a user