Harden audit findings: correctness, type-safety, Windows conventions & polish
Audit-pass over CODE-AUDIT.md (~48 items closed this pass; all verified — typecheck + 234 tests + eslint + prettier green). Correctness / bugs: - B3: match the release checksum to the asset's filename line (no wrong-hash verify) - B4: newline-safe metadata probe (one --print with a unit-separator delimiter) - B5 / L88: guard the meta event against canceled items; progress no longer promotes a queued item outside pump() - B7: cookie-login promise always resolves (handles destroy-without-close) - L146: trim parser rejects >2 colon-group times; M36: Library selection counts only actionable rows - L11 / L50 / L156 / L57 / L159 / L15 / L3: live queue count, empty-cookie message, schedule picker min, dead-code/comment cleanup Type safety: - Enable noUncheckedIndexedAccess + noFallthroughCasesInSwitch (15 real edge cases fixed) Resilience / Windows / metadata: - R5: settings write failure handled (no unhandled IPC rejection; reconciles to truth) - W1 / W5 / W6: min window size, seeded folder picker, parented sign-in window; L147 dead macOS branches removed - CL1: shared stdout markers; package/builder metadata (license, homepage, repository, copyright, tsbuildinfo glob) Copy / docs / tests: - M37 / SR9 dev-jargon cleanup in hints; M8 / M25 / M26 / L66 / L80 / L81 reconciled - New unit tests for L35 (isValidMediaItem) and L36 (compareVersions) This commit also checkpoints the previously-uncommitted feat/tray-background-clipboard work it builds on: background running + auto-download, library clipboard detection, tray, binary management & library scale, credential encryption at rest, the shared jsonStore and ui/ primitives, and the eslint/prettier tooling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+17
-12
@@ -1,14 +1,9 @@
|
||||
import { dialog, type BrowserWindow } from 'electron'
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { getSettings, setSettings } from './settings'
|
||||
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'
|
||||
import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc'
|
||||
|
||||
interface BackupFile {
|
||||
version: 1
|
||||
@@ -22,7 +17,12 @@ export async function exportBackup(win: BrowserWindow | undefined): Promise<Back
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }]
|
||||
})
|
||||
if (res.canceled || !res.filePath) return { ok: false }
|
||||
const payload: BackupFile = { version: 1, settings: getSettings(), templates: listTemplates() }
|
||||
// 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 }
|
||||
@@ -85,8 +85,7 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
|
||||
return `• ${name}: ${args}`
|
||||
})
|
||||
.join('\n')
|
||||
const more =
|
||||
commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
|
||||
const more = commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
|
||||
const choice = await dialog.showMessageBox(win!, {
|
||||
type: 'warning',
|
||||
buttons: ['Enable custom commands', 'Import without enabling', 'Cancel'],
|
||||
@@ -106,11 +105,17 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
|
||||
}
|
||||
|
||||
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
|
||||
? incomingSettings
|
||||
: { ...incomingSettings, customCommandEnabled: false }
|
||||
? restored
|
||||
: { ...restored, customCommandEnabled: false }
|
||||
setSettings(safeSettings)
|
||||
}
|
||||
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { deflateSync } from 'zlib'
|
||||
import { nativeImage, type NativeImage } from 'electron'
|
||||
|
||||
// CRC32 table polynomial used by PNG.
|
||||
function crc32(buf: Buffer): number {
|
||||
let crc = 0xffffffff
|
||||
for (const b of buf) {
|
||||
crc ^= b
|
||||
for (let i = 0; i < 8; i++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Buffer): Buffer {
|
||||
const t = Buffer.from(type, 'ascii')
|
||||
const len = Buffer.alloc(4)
|
||||
len.writeUInt32BE(data.length)
|
||||
const crc = Buffer.alloc(4)
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([t, data])))
|
||||
return Buffer.concat([len, t, data, crc])
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 16×16 RGBA PNG containing a solid-colour filled circle. Used for the
|
||||
* Windows taskbar overlay badge. No external deps — pure Node.js (Buffer + zlib).
|
||||
*/
|
||||
function makeDotPng(r: number, g: number, b: number): NativeImage {
|
||||
const W = 16,
|
||||
H = 16
|
||||
const px = Buffer.alloc(W * H * 4, 0) // transparent bg
|
||||
const cx = W / 2,
|
||||
cy = H / 2,
|
||||
rad = W / 2 - 1.5
|
||||
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const dx = x + 0.5 - cx,
|
||||
dy = y + 0.5 - cy
|
||||
if (dx * dx + dy * dy <= rad * rad) {
|
||||
const i = (y * W + x) * 4
|
||||
px[i] = r
|
||||
px[i + 1] = g
|
||||
px[i + 2] = b
|
||||
px[i + 3] = 255
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PNG: IHDR (8-bit RGBA, no interlace), filter-0 rows, deflated IDAT, IEND.
|
||||
const ihdr = Buffer.alloc(13)
|
||||
ihdr.writeUInt32BE(W, 0)
|
||||
ihdr.writeUInt32BE(H, 4)
|
||||
ihdr[8] = 8 // bit depth
|
||||
ihdr[9] = 6 // colour type: RGBA
|
||||
|
||||
const rows = Buffer.alloc(H * (1 + W * 4))
|
||||
for (let y = 0; y < H; y++) {
|
||||
rows[y * (1 + W * 4)] = 0 // filter type: None
|
||||
px.copy(rows, y * (1 + W * 4) + 1, y * W * 4, (y + 1) * W * 4)
|
||||
}
|
||||
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
const png = Buffer.concat([
|
||||
sig,
|
||||
pngChunk('IHDR', ihdr),
|
||||
pngChunk('IDAT', deflateSync(rows)),
|
||||
pngChunk('IEND', Buffer.alloc(0))
|
||||
])
|
||||
return nativeImage.createFromBuffer(png)
|
||||
}
|
||||
|
||||
// Lazy-initialised singletons — created on first use so this module is safe to
|
||||
// import before the app is ready (nativeImage.createFromBuffer is fine post-ready).
|
||||
let activeBadge: NativeImage | null = null
|
||||
let errorBadge: NativeImage | null = null
|
||||
|
||||
/** Green dot badge for the taskbar overlay — shown when downloads are active. */
|
||||
export function getActiveBadge(): NativeImage {
|
||||
if (!activeBadge) activeBadge = makeDotPng(58, 185, 108) // #3ab96c
|
||||
return activeBadge
|
||||
}
|
||||
|
||||
/** Red dot badge for the taskbar overlay — shown when a download has errored. */
|
||||
export function getErrorBadge(): NativeImage {
|
||||
if (!errorBadge) errorBadge = makeDotPng(217, 48, 37) // #d93025
|
||||
return errorBadge
|
||||
}
|
||||
+19
-2
@@ -1,4 +1,4 @@
|
||||
import { app } from 'electron'
|
||||
import { app, nativeImage, type NativeImage } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
|
||||
@@ -24,6 +24,23 @@ export function getAppIconPath(): string {
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
}
|
||||
|
||||
let appIconImage: NativeImage | null = null
|
||||
/**
|
||||
* The app icon as a cached NativeImage, for OS notifications (W13/L127). Loaded
|
||||
* once from getAppIconPath(); a missing icon yields an empty image, which
|
||||
* Notification treats as "no icon" (the OS default) rather than erroring. This
|
||||
* gives completion/background toasts the real brand glyph — notably on the
|
||||
* portable build, which has no installed AUMID shortcut icon for Windows to use.
|
||||
*/
|
||||
export function getAppIconImage(): NativeImage {
|
||||
// Cache only a valid image so a transient read miss isn't latched forever; a
|
||||
// genuinely-missing icon just re-reads (cheap — notifications are infrequent).
|
||||
if (!appIconImage || appIconImage.isEmpty()) {
|
||||
appIconImage = nativeImage.createFromPath(getAppIconPath())
|
||||
}
|
||||
return appIconImage
|
||||
}
|
||||
|
||||
/**
|
||||
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
|
||||
* from the read-only bundled seed in resources/bin. The managed copy is what
|
||||
@@ -35,7 +52,7 @@ export function getAppIconPath(): string {
|
||||
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
|
||||
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
|
||||
*/
|
||||
export function getManagedBinDir(): string {
|
||||
function getManagedBinDir(): string {
|
||||
return join(app.getPath('userData'), 'bin')
|
||||
}
|
||||
|
||||
|
||||
+47
-13
@@ -42,9 +42,7 @@ function videoFormat(quality: string): string {
|
||||
*/
|
||||
function videoSelector(opts: StartDownloadOptions): string {
|
||||
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
|
||||
return opts.formatHasAudio
|
||||
? opts.formatId
|
||||
: `${opts.formatId}+bestaudio/${opts.formatId}`
|
||||
return opts.formatHasAudio ? opts.formatId : `${opts.formatId}+bestaudio/${opts.formatId}`
|
||||
}
|
||||
return videoFormat(opts.quality)
|
||||
}
|
||||
@@ -62,13 +60,19 @@ function audioQuality(quality: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Stdout line markers, shared by the emit side (the --print templates here) and
|
||||
// the parse side (download.ts). Defined once so changing a marker can't silently
|
||||
// break the parser that splits on it (CL1).
|
||||
export const PROGRESS_MARKER = 'prog|'
|
||||
export const FILEPATH_MARKER = 'path|'
|
||||
|
||||
// The progress line yt-dlp emits (one per --newline tick). Note the leading
|
||||
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp
|
||||
// (it does NOT appear in the output). The literal `prog|` that follows is our
|
||||
// (it does NOT appear in the output). The PROGRESS_MARKER that follows is our
|
||||
// own marker, so we can tell progress lines apart from the after-move filepath
|
||||
// print on the same stdout stream.
|
||||
export const PROGRESS_TEMPLATE =
|
||||
'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' +
|
||||
const PROGRESS_TEMPLATE =
|
||||
`download:${PROGRESS_MARKER}%(progress.status)s|%(progress.downloaded_bytes)s|` +
|
||||
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
|
||||
'%(progress.speed)s|%(progress.eta)s'
|
||||
|
||||
@@ -77,8 +81,7 @@ export const PROGRESS_TEMPLATE =
|
||||
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
|
||||
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
|
||||
export const CROP_SQUARE_PPA =
|
||||
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
|
||||
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
|
||||
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' + "crop=\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\""
|
||||
|
||||
/**
|
||||
* Phase B "Access & networking" settings — not per-download options, always
|
||||
@@ -127,7 +130,9 @@ export function parseExtraArgs(raw: string): string[] {
|
||||
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(raw)) !== null) {
|
||||
args.push(m[1] ?? m[2] ?? m[3])
|
||||
// Exactly one of the three alternation groups matches; the '' fallback only
|
||||
// satisfies the type checker (noUncheckedIndexedAccess) and never fires.
|
||||
args.push(m[1] ?? m[2] ?? m[3] ?? '')
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -197,7 +202,10 @@ export function matchesUrl(pattern: string, url: string): boolean {
|
||||
*/
|
||||
export function parseTrimSections(raw: string | undefined): string[] {
|
||||
if (!raw) return []
|
||||
const TIME = String.raw`\d+(?::\d{1,2})*(?:\.\d+)?`
|
||||
// A time is SS, M:SS, or H:MM:SS — at most two colon-separated groups. The old
|
||||
// `*` quantifier accepted nonsense like `1:2:3:4`, which then failed inside
|
||||
// yt-dlp's --download-sections rather than being rejected up front (L146).
|
||||
const TIME = String.raw`\d+(?::\d{1,2}){0,2}(?:\.\d+)?`
|
||||
const RANGE = new RegExp(`^${TIME}-${TIME}$`)
|
||||
const out: string[] = []
|
||||
for (const piece of raw.split(/[,\n]/)) {
|
||||
@@ -244,7 +252,11 @@ export function sanitizeDirSegment(name: string): string {
|
||||
// byte ever appears in this source file.
|
||||
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
|
||||
s = s.replace(/[<>:"/\\|?*]/g, ' ')
|
||||
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
|
||||
s = s
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/[. ]+$/, '')
|
||||
.replace(/^[. ]+/, '')
|
||||
// Reserved device names are reserved even WITH an extension ('CON.txt' is still
|
||||
// the CON device), so match an optional trailing '.<ext>' too (audit T3).
|
||||
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(s)) s = `_${s}`
|
||||
@@ -328,7 +340,23 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
|
||||
// Split into one file per chapter. yt-dlp also keeps the full file; the
|
||||
// per-chapter files use yt-dlp's default `chapter:` output template.
|
||||
if (o.splitChapters) args.push('--split-chapters')
|
||||
if (o.embedMetadata) args.push('--embed-metadata')
|
||||
|
||||
// Metadata embedding + optional per-field overrides.
|
||||
// --replace-in-metadata FIELD REGEX REPLACE is used instead of --parse-metadata
|
||||
// FROM:TO because the replacement arg is a separate element (no colon-splitting
|
||||
// on values like "Foo: Bar"). ^.*$ matches any string including empty. The only
|
||||
// escaping Python's re.sub needs in the replacement string is backslash.
|
||||
const metaOverrides: [string, string | undefined][] = [
|
||||
['title', o.metadataTitle],
|
||||
['artist', o.metadataArtist],
|
||||
['album', o.metadataAlbum]
|
||||
]
|
||||
const hasOverrides = metaOverrides.some(([, v]) => v?.trim())
|
||||
if (o.embedMetadata || hasOverrides) args.push('--embed-metadata')
|
||||
const escRepl = (s: string): string => s.replace(/\\/g, '\\\\')
|
||||
for (const [field, val] of metaOverrides) {
|
||||
if (val?.trim()) args.push('--replace-in-metadata', field, '^.*$', escRepl(val.trim()))
|
||||
}
|
||||
|
||||
// Media-server sidecar files (Phase K) — written next to the output file so
|
||||
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
|
||||
@@ -351,6 +379,12 @@ export function buildArgs(
|
||||
'--newline',
|
||||
'--no-color',
|
||||
'--no-playlist',
|
||||
// Bound a dead/hung connection so yt-dlp aborts (and retries, then exits) a
|
||||
// stalled socket instead of hanging forever and pinning a concurrency slot
|
||||
// with no recovery. The app-side idle watchdog in download.ts is the backstop
|
||||
// for a fully-wedged process. (B1)
|
||||
'--socket-timeout',
|
||||
'30',
|
||||
// --print (below) implies --quiet, which would suppress progress; --progress
|
||||
// forces the progress template to emit anyway.
|
||||
'--progress',
|
||||
@@ -362,7 +396,7 @@ export function buildArgs(
|
||||
PROGRESS_TEMPLATE,
|
||||
// Print the final path after post-processing/move so we can open it later.
|
||||
'--print',
|
||||
'after_move:path|%(filepath)s',
|
||||
`after_move:${FILEPATH_MARKER}%(filepath)s`,
|
||||
'--no-simulate'
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Menu, type WebContents, type MenuItemConstructorOptions } from 'electron'
|
||||
|
||||
/**
|
||||
* Give editable fields (and any selected text) the standard Windows Cut / Copy /
|
||||
* Paste / Select All right-click menu that Electron does not provide by default
|
||||
* (W4) — plus spellcheck suggestions for text areas. The menu items use built-in
|
||||
* roles, so the editing works directly in the sandboxed, context-isolated
|
||||
* renderer without any extra IPC. Wired onto the main window only; the untrusted
|
||||
* cookie sign-in window deliberately keeps its minimal chrome.
|
||||
*/
|
||||
export function attachEditContextMenu(wc: WebContents): void {
|
||||
wc.on('context-menu', (_e, params) => {
|
||||
const { isEditable, editFlags, selectionText, dictionarySuggestions } = params
|
||||
// Only worth a menu over an editable field or a real text selection.
|
||||
if (!isEditable && !selectionText.trim()) return
|
||||
|
||||
const template: MenuItemConstructorOptions[] = []
|
||||
|
||||
// Spellcheck suggestions first, when the caret sits on a misspelling.
|
||||
if (isEditable && dictionarySuggestions.length > 0) {
|
||||
for (const suggestion of dictionarySuggestions) {
|
||||
template.push({ label: suggestion, click: () => wc.replaceMisspelling(suggestion) })
|
||||
}
|
||||
template.push({ type: 'separator' })
|
||||
}
|
||||
|
||||
if (isEditable) {
|
||||
template.push(
|
||||
{ role: 'cut', enabled: editFlags.canCut },
|
||||
{ role: 'copy', enabled: editFlags.canCopy },
|
||||
{ role: 'paste', enabled: editFlags.canPaste },
|
||||
{ type: 'separator' },
|
||||
{ role: 'selectAll', enabled: editFlags.canSelectAll }
|
||||
)
|
||||
} else {
|
||||
// Read-only text with a selection: offer Copy (and Select All).
|
||||
template.push(
|
||||
{ role: 'copy', enabled: editFlags.canCopy },
|
||||
{ role: 'selectAll', enabled: editFlags.canSelectAll }
|
||||
)
|
||||
}
|
||||
|
||||
Menu.buildFromTemplate(template).popup()
|
||||
})
|
||||
}
|
||||
+128
-18
@@ -1,5 +1,5 @@
|
||||
import { app, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
|
||||
import { existsSync, statSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { app, safeStorage, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
|
||||
import { existsSync, statSync, unlinkSync, writeFileSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { assertHttpUrl } from './url'
|
||||
import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
|
||||
@@ -7,9 +7,10 @@ import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
|
||||
/**
|
||||
* Persisted session AeroFetch's built-in sign-in window uses. Kept separate
|
||||
* from the main window's (default) session so a logged-in site can't see or
|
||||
* touch anything the app itself loads.
|
||||
* touch anything the app itself loads. Exported so the PO-token window can
|
||||
* share the same session (and thus the user's YouTube sign-in state).
|
||||
*/
|
||||
const PARTITION = 'persist:aerofetch-login'
|
||||
export const LOGIN_PARTITION = 'persist:aerofetch-login'
|
||||
|
||||
/**
|
||||
* The sign-in window renders untrusted remote content, so every navigation and
|
||||
@@ -43,7 +44,7 @@ function hardenLoginWebContents(wc: WebContents): void {
|
||||
overrideBrowserWindowOptions: {
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
partition: PARTITION,
|
||||
partition: LOGIN_PARTITION,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
@@ -60,20 +61,99 @@ function hardenLoginWebContents(wc: WebContents): void {
|
||||
wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents))
|
||||
}
|
||||
|
||||
export function getCookiesFilePath(): string {
|
||||
// The persisted cookie jar is encrypted at rest (H7). yt-dlp's `--cookies` needs
|
||||
// a plaintext file, so the stored form is encrypted via Electron safeStorage
|
||||
// (DPAPI on Windows — the same protection settings secrets get) and only ever
|
||||
// decrypted to a short-lived temp file for the duration of a download. This
|
||||
// matters for the portable build, where userData sits next to the exe (USB /
|
||||
// shared Downloads folder) and a plaintext jar would hand over a logged-in session.
|
||||
function cookieStorePath(): string {
|
||||
return join(app.getPath('userData'), 'cookies.dat')
|
||||
}
|
||||
|
||||
function legacyCookiePath(): string {
|
||||
return join(app.getPath('userData'), 'cookies.txt')
|
||||
}
|
||||
|
||||
// Marks ciphertext so a read can tell it from legacy/fallback plaintext (written
|
||||
// before encryption, or where safeStorage was unavailable). Mirrors settings.ts.
|
||||
const ENC_PREFIX = 'enc:v1:'
|
||||
|
||||
/** Persist the Netscape cookie text, encrypted where safeStorage is available. */
|
||||
function storeCookies(netscape: string): void {
|
||||
let out = netscape
|
||||
try {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
out = ENC_PREFIX + safeStorage.encryptString(netscape).toString('base64')
|
||||
}
|
||||
} catch {
|
||||
/* fall through — store plaintext, as it was before encryption existed */
|
||||
}
|
||||
writeFileSync(cookieStorePath(), out)
|
||||
}
|
||||
|
||||
/** Decrypt the persisted cookie text, or null if none / undecryptable here. */
|
||||
function loadCookies(): string | null {
|
||||
const p = cookieStorePath()
|
||||
if (!existsSync(p)) return null
|
||||
const stored = readFileSync(p, 'utf8')
|
||||
if (!stored.startsWith(ENC_PREFIX)) return stored // legacy / fallback plaintext
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64'))
|
||||
} catch {
|
||||
// A different Windows user/machine (portable jar copied elsewhere) or a
|
||||
// corrupt blob — unusable; treat as no cookies rather than feed ciphertext.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration of a pre-encryption plaintext `cookies.txt` into the
|
||||
* encrypted store, deleting the plaintext so a logged-in session no longer sits
|
||||
* in the open after an update (H7). Best-effort; safe to call on every launch.
|
||||
*/
|
||||
export function migrateLegacyCookies(): void {
|
||||
const legacy = legacyCookiePath()
|
||||
try {
|
||||
if (!existsSync(legacy)) return
|
||||
if (!existsSync(cookieStorePath())) storeCookies(readFileSync(legacy, 'utf8'))
|
||||
unlinkSync(legacy)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a stored cookie jar exists (regardless of decryptability here). */
|
||||
export function hasStoredCookies(): boolean {
|
||||
return existsSync(cookieStorePath())
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the jar to a plaintext file at `dest` for yt-dlp's `--cookies`. Returns
|
||||
* false when there's nothing usable to write. The caller deletes `dest` once the
|
||||
* download settles, keeping the plaintext window as short as possible.
|
||||
*/
|
||||
export function materializeCookies(dest: string): boolean {
|
||||
const text = loadCookies()
|
||||
if (text == null) return false
|
||||
try {
|
||||
writeFileSync(dest, text)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getCookiesStatus(): CookiesStatus {
|
||||
const p = getCookiesFilePath()
|
||||
const p = cookieStorePath()
|
||||
if (!existsSync(p)) return { exists: false }
|
||||
return { exists: true, savedAt: statSync(p).mtimeMs }
|
||||
}
|
||||
|
||||
export async function clearCookies(): Promise<void> {
|
||||
const p = getCookiesFilePath()
|
||||
const p = cookieStorePath()
|
||||
if (existsSync(p)) unlinkSync(p)
|
||||
await session.fromPartition(PARTITION).clearStorageData()
|
||||
await session.fromPartition(LOGIN_PARTITION).clearStorageData()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,9 +169,15 @@ function toNetscapeCookieFile(cookies: Cookie[]): string {
|
||||
const includeSubdomains = domain.startsWith('.') ? 'TRUE' : 'FALSE'
|
||||
const expiry = c.session || !c.expirationDate ? 0 : Math.round(c.expirationDate)
|
||||
lines.push(
|
||||
[domain, includeSubdomains, c.path || '/', c.secure ? 'TRUE' : 'FALSE', expiry, c.name, c.value].join(
|
||||
'\t'
|
||||
)
|
||||
[
|
||||
domain,
|
||||
includeSubdomains,
|
||||
c.path || '/',
|
||||
c.secure ? 'TRUE' : 'FALSE',
|
||||
expiry,
|
||||
c.name,
|
||||
c.value
|
||||
].join('\t')
|
||||
)
|
||||
}
|
||||
return lines.join('\n') + '\n'
|
||||
@@ -102,10 +188,13 @@ let pendingResolvers: Array<(r: CookiesLoginResult) => void> = []
|
||||
|
||||
function exportAndResolve(): void {
|
||||
session
|
||||
.fromPartition(PARTITION)
|
||||
.fromPartition(LOGIN_PARTITION)
|
||||
.cookies.get({})
|
||||
.then((cookies) => {
|
||||
writeFileSync(getCookiesFilePath(), toNetscapeCookieFile(cookies))
|
||||
// Don't persist an empty jar: closing the window without signing in would
|
||||
// otherwise leave a useless "saved" cookie file behind (L50). The renderer
|
||||
// turns cookieCount === 0 into a "did you sign in?" hint.
|
||||
if (cookies.length > 0) storeCookies(toNetscapeCookieFile(cookies))
|
||||
const result: CookiesLoginResult = { ok: true, cookieCount: cookies.length }
|
||||
pendingResolvers.forEach((resolve) => resolve(result))
|
||||
})
|
||||
@@ -124,7 +213,10 @@ function exportAndResolve(): void {
|
||||
* window — cookies are exported to a Netscape-format file at that point,
|
||||
* ready for yt-dlp's `--cookies`. Mirrors Seal's "log in via WebView" feature.
|
||||
*/
|
||||
export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult> {
|
||||
export function openCookieLoginWindow(
|
||||
url: string,
|
||||
parent?: BrowserWindow
|
||||
): Promise<CookiesLoginResult> {
|
||||
return new Promise((resolve) => {
|
||||
let validUrl: string
|
||||
try {
|
||||
@@ -136,7 +228,9 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
|
||||
|
||||
if (loginWindow && !loginWindow.isDestroyed()) {
|
||||
pendingResolvers.push(resolve)
|
||||
loginWindow.loadURL(validUrl).catch(() => {})
|
||||
loginWindow
|
||||
.loadURL(validUrl)
|
||||
.catch((e) => console.error('[AeroFetch] cookie login loadURL failed:', e))
|
||||
loginWindow.focus()
|
||||
return
|
||||
}
|
||||
@@ -149,8 +243,11 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
|
||||
height: 720,
|
||||
title: 'Sign in — AeroFetch',
|
||||
autoHideMenuBar: true,
|
||||
// Group under the app window instead of taking its own taskbar button
|
||||
// (W6); a child window stays above its parent without a modal block.
|
||||
parent: parent && !parent.isDestroyed() ? parent : undefined,
|
||||
webPreferences: {
|
||||
partition: PARTITION,
|
||||
partition: LOGIN_PARTITION,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
@@ -170,13 +267,26 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
|
||||
hardenLoginWebContents(win.webContents)
|
||||
win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false))
|
||||
|
||||
// 'close' fires on a normal user close; a programmatic destroy() fires only
|
||||
// 'closed'. Latch whichever runs first so the partition is exported exactly
|
||||
// once and the promise can never hang (B7) — without this, a destroy()
|
||||
// without a preceding 'close' would leave pendingResolvers pending forever.
|
||||
let exportStarted = false
|
||||
const exportOnce = (): void => {
|
||||
if (exportStarted) return
|
||||
exportStarted = true
|
||||
exportAndResolve()
|
||||
}
|
||||
win.on('closed', () => {
|
||||
loginWindow = null
|
||||
// Fallback for destroy()/closed-without-close: the partition outlives the
|
||||
// window, so a late export still captures whatever cookies were collected.
|
||||
exportOnce()
|
||||
})
|
||||
// Closing the window IS "I'm done" — export whatever the partition
|
||||
// collected. The partition itself outlives the window, so this is safe
|
||||
// even though cookies.get() resolves after 'close' has already fired.
|
||||
win.on('close', exportAndResolve)
|
||||
win.on('close', exportOnce)
|
||||
|
||||
win.loadURL(validUrl).catch(() => {
|
||||
/* navigation errors surface as Chromium's own error page */
|
||||
|
||||
@@ -30,7 +30,8 @@ function readUrlShortcut(path: string): string | null {
|
||||
const buf = Buffer.alloc(MAX_URL_FILE_BYTES)
|
||||
const bytes = readSync(fd, buf, 0, buf.length, 0)
|
||||
const match = /^URL=(.+)$/im.exec(buf.toString('utf8', 0, bytes))
|
||||
return match ? asHttpUrl(match[1].trim()) : null
|
||||
const url = match?.[1]?.trim()
|
||||
return url ? asHttpUrl(url) : null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
|
||||
+107
-32
@@ -1,5 +1,6 @@
|
||||
import { spawn, execFile, type ChildProcess } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { existsSync, unlinkSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { BrowserWindow, Notification, type WebContents } from 'electron'
|
||||
import {
|
||||
@@ -8,11 +9,12 @@ import {
|
||||
getAria2cPath,
|
||||
getFfmpegPath,
|
||||
getFfprobePath,
|
||||
getSystem32Path
|
||||
getSystem32Path,
|
||||
getAppIconImage
|
||||
} from './binaries'
|
||||
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
|
||||
import { ensureManagedYtdlp } from './ytdlp'
|
||||
import { getCookiesFilePath } from './cookies'
|
||||
import { materializeCookies, hasStoredCookies } from './cookies'
|
||||
import { listTemplates } from './templates'
|
||||
import { assertHttpUrl } from './url'
|
||||
import { isSafeOutputDir } from './validation'
|
||||
@@ -20,7 +22,9 @@ import {
|
||||
buildArgs,
|
||||
selectExtraArgs,
|
||||
formatCommandLine,
|
||||
collectionOutputTemplate
|
||||
collectionOutputTemplate,
|
||||
PROGRESS_MARKER,
|
||||
FILEPATH_MARKER
|
||||
} from './buildArgs'
|
||||
import { cleanError } from './log'
|
||||
import { addErrorLog } from './errorlog'
|
||||
@@ -43,6 +47,14 @@ interface ActiveDownload {
|
||||
|
||||
const active = new Map<string, ActiveDownload>()
|
||||
|
||||
// Backstop for B1: if a spawned yt-dlp goes completely silent — no stdout or
|
||||
// stderr — for this long, treat it as wedged, kill it, and error the item so the
|
||||
// concurrency slot frees instead of leaking forever. Generous on purpose so a
|
||||
// long, output-less post-processing step (e.g. a large ffmpeg merge) isn't
|
||||
// mistaken for a stall; --socket-timeout (buildArgs) handles the common
|
||||
// dead-connection case at the network layer.
|
||||
const STALL_TIMEOUT_MS = 5 * 60_000
|
||||
|
||||
/**
|
||||
* Whether any yt-dlp download is currently running. Used by the window's close
|
||||
* handler to keep the app alive in the tray (instead of quitting and killing the
|
||||
@@ -98,7 +110,10 @@ function parseProgress(rest: string): DownloadProgress | null {
|
||||
progress,
|
||||
speed: fmtSpeed(num(speed)),
|
||||
eta: fmtEta(num(eta)),
|
||||
sizeLabel: totalBytes ? fmtBytes(totalBytes) : undefined
|
||||
sizeLabel: totalBytes ? fmtBytes(totalBytes) : undefined,
|
||||
// No (estimated) total → the % can never advance; flag it so the renderer
|
||||
// shows an indeterminate bar rather than a stuck 0% (L137).
|
||||
sizeUnknown: !totalBytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +124,7 @@ function send(wc: WebContents, ev: DownloadEvent): void {
|
||||
/** Native OS notification on completion/failure, gated by Settings.notifyOnComplete. */
|
||||
function notify(wc: WebContents, title: string, body: string): void {
|
||||
if (!getSettings().notifyOnComplete || !Notification.isSupported()) return
|
||||
const n = new Notification({ title, body })
|
||||
const n = new Notification({ title, body, icon: getAppIconImage() })
|
||||
n.on('click', () => {
|
||||
if (wc.isDestroyed()) return
|
||||
const win = BrowserWindow.fromWebContents(wc)
|
||||
@@ -135,6 +150,12 @@ function logFailure(opts: StartDownloadOptions, title: string | undefined, error
|
||||
|
||||
// --- Best-effort metadata probe (runs alongside the download) ---------------
|
||||
|
||||
// Unit Separator (0x1F): a control char that can't appear in a title/uploader,
|
||||
// so it safely delimits the three fields in ONE --print template. Splitting on
|
||||
// newlines instead (B4) would mis-assign channel/duration whenever a title
|
||||
// itself contains a newline, since each --print field is emitted on its own line.
|
||||
const META_SEP = '\u001f'
|
||||
|
||||
function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
@@ -144,20 +165,15 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
|
||||
'--no-warnings',
|
||||
'--skip-download',
|
||||
'--print',
|
||||
'title',
|
||||
'--print',
|
||||
'uploader',
|
||||
'--print',
|
||||
'duration_string',
|
||||
`%(title)s${META_SEP}%(uploader)s${META_SEP}%(duration_string)s`,
|
||||
'--',
|
||||
url
|
||||
],
|
||||
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 30_000 },
|
||||
(err, stdout) => {
|
||||
if (err) return resolve(null)
|
||||
const [title, uploader, duration] = stdout.split('\n').map((l) => l.trim())
|
||||
const clean = (v?: string): string | undefined =>
|
||||
v && v !== 'NA' ? v : undefined
|
||||
const [title, uploader, duration] = stdout.split(META_SEP).map((l) => l.trim())
|
||||
const clean = (v?: string): string | undefined => (v && v !== 'NA' ? v : undefined)
|
||||
resolve({
|
||||
title: clean(title),
|
||||
channel: clean(uploader),
|
||||
@@ -175,6 +191,9 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
|
||||
// (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 []
|
||||
return selectExtraArgs({
|
||||
customCommandEnabled: settings.customCommandEnabled,
|
||||
perDownloadExtraArgs: opts.extraArgs,
|
||||
@@ -184,8 +203,10 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
|
||||
export function buildCommand(opts: StartDownloadOptions): string[] {
|
||||
/** Resolve settings + per-download overrides into the full yt-dlp argv.
|
||||
* `cookiesFile` is the transient decrypted jar the caller materialises for the
|
||||
* 'login' cookie source (H7); the 'browser' source uses --cookies-from-browser. */
|
||||
export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string): string[] {
|
||||
const settings = getSettings()
|
||||
// Output dir resolution: a per-download override wins, then the user's explicit
|
||||
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
|
||||
@@ -210,12 +231,6 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
|
||||
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
|
||||
// into resources/bin — the toggle shouldn't turn into a hard error.
|
||||
const aria2cPath = settings.useAria2c && existsSync(getAria2cPath()) ? getAria2cPath() : undefined
|
||||
// Same idea: 'login' cookies only apply once the sign-in window has actually
|
||||
// exported a file; otherwise the download proceeds cookie-less rather than failing.
|
||||
const cookiesFile =
|
||||
settings.cookieSource === 'login' && existsSync(getCookiesFilePath())
|
||||
? getCookiesFilePath()
|
||||
: undefined
|
||||
const access = {
|
||||
proxy: settings.proxy,
|
||||
rateLimit: settings.rateLimit,
|
||||
@@ -250,10 +265,7 @@ export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult
|
||||
|
||||
// --- Public API -------------------------------------------------------------
|
||||
|
||||
export function startDownload(
|
||||
wc: WebContents,
|
||||
opts: StartDownloadOptions
|
||||
): StartDownloadResult {
|
||||
export function startDownload(wc: WebContents, opts: StartDownloadOptions): StartDownloadResult {
|
||||
// Self-heal the managed copy from the bundled seed before spawning, so a
|
||||
// never-seeded or deleted yt-dlp.exe doesn't fail an otherwise-fine download.
|
||||
ensureManagedYtdlp()
|
||||
@@ -301,10 +313,30 @@ export function startDownload(
|
||||
return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' }
|
||||
}
|
||||
|
||||
// Decrypt the stored cookie jar to a short-lived per-download temp file (H7).
|
||||
// yt-dlp reads --cookies once at startup; we delete it the moment the download
|
||||
// settles, so the plaintext never lingers at rest.
|
||||
let cookiesFile: string | undefined
|
||||
if (getSettings().cookieSource === 'login' && hasStoredCookies()) {
|
||||
const tmp = join(tmpdir(), `aerofetch-cookies-${opts.id}.txt`)
|
||||
if (materializeCookies(tmp)) cookiesFile = tmp
|
||||
}
|
||||
function cleanupCookies(): void {
|
||||
if (cookiesFile) {
|
||||
try {
|
||||
unlinkSync(cookiesFile)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
cookiesFile = undefined
|
||||
}
|
||||
}
|
||||
|
||||
let child: ChildProcess
|
||||
try {
|
||||
child = spawn(ytdlp, buildCommand(opts), { windowsHide: true })
|
||||
child = spawn(ytdlp, buildCommand(opts, cookiesFile), { windowsHide: true })
|
||||
} catch (e) {
|
||||
cleanupCookies()
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
|
||||
@@ -331,31 +363,72 @@ export function startDownload(
|
||||
let stdoutBuf = ''
|
||||
let stderrTail = ''
|
||||
let filePath: string | undefined
|
||||
// Latched once the first download stream reports 'finished'; flags later
|
||||
// progress as the merge/post-processing "finishing" phase (SR7).
|
||||
let finishing = false
|
||||
// 'error' and 'close' can both fire for one process; only act on the first.
|
||||
let settled = false
|
||||
|
||||
// Idle watchdog (B1): reset on any output; if it ever fires, the child has been
|
||||
// silent for STALL_TIMEOUT_MS, so kill + error it and free the slot.
|
||||
let stallTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function clearWatchdog(): void {
|
||||
if (stallTimer) {
|
||||
clearTimeout(stallTimer)
|
||||
stallTimer = null
|
||||
}
|
||||
}
|
||||
function bumpWatchdog(): void {
|
||||
clearWatchdog()
|
||||
stallTimer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearWatchdog()
|
||||
cleanupCookies()
|
||||
active.delete(opts.id)
|
||||
killTree(rec)
|
||||
const msg = `Download stalled — no activity for ${Math.round(STALL_TIMEOUT_MS / 60_000)} min. Stopped; retry to resume.`
|
||||
send(wc, { type: 'error', id: opts.id, error: msg })
|
||||
logFailure(opts, resolvedTitle, msg)
|
||||
notify(wc, resolvedTitle ?? 'Download failed', msg)
|
||||
}, STALL_TIMEOUT_MS)
|
||||
}
|
||||
bumpWatchdog()
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
bumpWatchdog()
|
||||
stdoutBuf += chunk.toString()
|
||||
let nl: number
|
||||
while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
|
||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stdoutBuf = stdoutBuf.slice(nl + 1)
|
||||
if (line.startsWith('prog|')) {
|
||||
const p = parseProgress(line.slice('prog|'.length))
|
||||
if (p) send(wc, { type: 'progress', id: opts.id, progress: p })
|
||||
} else if (line.startsWith('path|')) {
|
||||
filePath = line.slice('path|'.length).trim()
|
||||
if (line.startsWith(PROGRESS_MARKER)) {
|
||||
const p = parseProgress(line.slice(PROGRESS_MARKER.length))
|
||||
if (p) {
|
||||
// SR7: yt-dlp reports a 'finished' status when each download stream
|
||||
// completes. A video+audio download has two streams, so the bar would
|
||||
// otherwise fill 0→100% twice. Latch on the first 'finished' and flag
|
||||
// every later tick as "finishing" so the renderer shows an indeterminate
|
||||
// merge state instead of a visible restart.
|
||||
if (p.status === 'finished') finishing = true
|
||||
send(wc, { type: 'progress', id: opts.id, progress: { ...p, finishing } })
|
||||
}
|
||||
} else if (line.startsWith(FILEPATH_MARKER)) {
|
||||
filePath = line.slice(FILEPATH_MARKER.length).trim()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
bumpWatchdog()
|
||||
stderrTail = (stderrTail + chunk.toString()).slice(-4000)
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearWatchdog()
|
||||
cleanupCookies()
|
||||
active.delete(opts.id)
|
||||
// A paused download was killed on purpose — stay silent, like a cancel.
|
||||
if (!rec.canceled && !rec.paused) {
|
||||
@@ -368,6 +441,8 @@ export function startDownload(
|
||||
child.on('close', (code) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearWatchdog()
|
||||
cleanupCookies()
|
||||
active.delete(opts.id)
|
||||
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
|
||||
// 'paused' and keeps the .part for a later resume. Either way, no event.
|
||||
|
||||
+9
-30
@@ -1,45 +1,24 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import type { ErrorLogEntry } from '@shared/ipc'
|
||||
import { isValidErrorLogEntry } from './validation'
|
||||
import { createJsonStore } from './jsonStore'
|
||||
|
||||
// Plain JSON in userData, same shape as history.ts. Persisted so a failure
|
||||
// report survives the queue item being cleared (Seal's "debug report").
|
||||
// report survives the queue item being cleared (Seal's "debug report"). Atomic
|
||||
// writes / corruption backup / caching come from the shared jsonStore (R1–R3).
|
||||
const MAX_ENTRIES = 200
|
||||
|
||||
function errorLogFile(): string {
|
||||
return join(app.getPath('userData'), 'errorlog.json')
|
||||
}
|
||||
// Per-entry validation (isValidErrorLogEntry) so a hand-edited or corrupted
|
||||
// errorlog.json can't feed the UI entries with the wrong shape. (audit S5)
|
||||
const store = createJsonStore('errorlog.json', isValidErrorLogEntry, MAX_ENTRIES)
|
||||
|
||||
// Per-entry validation (isValidErrorLogEntry, in validation.ts) so a hand-edited
|
||||
// or corrupted errorlog.json can't feed the UI entries with the wrong shape —
|
||||
// invalid rows are dropped. (audit S5)
|
||||
export function listErrorLog(): ErrorLogEntry[] {
|
||||
try {
|
||||
if (!existsSync(errorLogFile())) return []
|
||||
const data = JSON.parse(readFileSync(errorLogFile(), 'utf8'))
|
||||
return Array.isArray(data) ? data.filter(isValidErrorLogEntry) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function save(entries: ErrorLogEntry[]): void {
|
||||
try {
|
||||
writeFileSync(errorLogFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
|
||||
} catch {
|
||||
/* best-effort; a read-only data dir just means no persisted error log */
|
||||
}
|
||||
return store.read()
|
||||
}
|
||||
|
||||
export function addErrorLog(entry: ErrorLogEntry): ErrorLogEntry[] {
|
||||
const entries = [entry, ...listErrorLog()]
|
||||
save(entries)
|
||||
return entries
|
||||
return store.write([entry, ...listErrorLog()])
|
||||
}
|
||||
|
||||
export function clearErrorLog(): ErrorLogEntry[] {
|
||||
save([])
|
||||
return []
|
||||
return store.write([])
|
||||
}
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ function readToolVersion(path: string): Promise<string | null> {
|
||||
}
|
||||
const firstLine = stdout.split('\n', 1)[0] ?? ''
|
||||
const m = firstLine.match(/version\s+(\S+)/i)
|
||||
resolve(m ? m[1] : null)
|
||||
resolve(m?.[1] ?? null)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
+17
-37
@@ -1,59 +1,39 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import type { HistoryEntry } from '@shared/ipc'
|
||||
import { isValidHistoryEntry } from './validation'
|
||||
import { createJsonStore } from './jsonStore'
|
||||
|
||||
// Plain JSON in userData (portable build redirects userData next to the exe).
|
||||
// Kept simple per the build plan; can migrate to better-sqlite3 later.
|
||||
// Kept simple per the build plan; can migrate to better-sqlite3 later. Atomic
|
||||
// writes, corruption backup, and caching come from the shared jsonStore (R1–R3).
|
||||
const MAX_ENTRIES = 500
|
||||
|
||||
function historyFile(): string {
|
||||
return join(app.getPath('userData'), 'history.json')
|
||||
}
|
||||
// Per-entry validation (isValidHistoryEntry) so a hand-edited or corrupted
|
||||
// history.json can't feed the UI (or openPath) entries with the wrong shape —
|
||||
// invalid rows are dropped rather than trusted. (audit S5)
|
||||
const store = createJsonStore('history.json', isValidHistoryEntry, MAX_ENTRIES)
|
||||
|
||||
// Per-entry validation (isValidHistoryEntry, in validation.ts) so a hand-edited
|
||||
// or corrupted history.json can't feed the UI (or openPath) entries with the
|
||||
// wrong shape — invalid rows are dropped rather than trusted. (audit S5)
|
||||
export function listHistory(): HistoryEntry[] {
|
||||
try {
|
||||
if (!existsSync(historyFile())) return []
|
||||
const data = JSON.parse(readFileSync(historyFile(), 'utf8'))
|
||||
return Array.isArray(data) ? data.filter(isValidHistoryEntry) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function save(entries: HistoryEntry[]): void {
|
||||
try {
|
||||
writeFileSync(historyFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
|
||||
} catch {
|
||||
/* best-effort; a read-only data dir just means no persisted history */
|
||||
}
|
||||
return store.read()
|
||||
}
|
||||
|
||||
export function addHistory(entry: HistoryEntry): HistoryEntry[] {
|
||||
// De-dupe by id (a retry of the same item replaces its prior entry).
|
||||
const entries = [entry, ...listHistory().filter((e) => e.id !== entry.id)]
|
||||
save(entries)
|
||||
return entries
|
||||
// De-dupe by id AND url (M35): a History re-download re-queues via addFromUrl,
|
||||
// which mints a NEW id, so id-only de-dup would let the same video accumulate a
|
||||
// fresh row on every re-download. Dropping any prior entry with the same url
|
||||
// keeps one row per video, refreshed to the top.
|
||||
const prior = listHistory().filter((e) => e.id !== entry.id && e.url !== entry.url)
|
||||
return store.write([entry, ...prior])
|
||||
}
|
||||
|
||||
export function removeHistory(id: string): HistoryEntry[] {
|
||||
const entries = listHistory().filter((e) => e.id !== id)
|
||||
save(entries)
|
||||
return entries
|
||||
return store.write(listHistory().filter((e) => e.id !== id))
|
||||
}
|
||||
|
||||
export function removeManyHistory(ids: string[]): HistoryEntry[] {
|
||||
const remove = new Set(ids)
|
||||
const entries = listHistory().filter((e) => !remove.has(e.id))
|
||||
save(entries)
|
||||
return entries
|
||||
return store.write(listHistory().filter((e) => !remove.has(e.id)))
|
||||
}
|
||||
|
||||
export function clearHistory(): HistoryEntry[] {
|
||||
save([])
|
||||
return []
|
||||
return store.write([])
|
||||
}
|
||||
|
||||
+117
-34
@@ -1,5 +1,16 @@
|
||||
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Notification } from 'electron'
|
||||
import {
|
||||
app,
|
||||
shell,
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
dialog,
|
||||
clipboard,
|
||||
nativeTheme,
|
||||
Notification,
|
||||
Menu
|
||||
} from 'electron'
|
||||
import { join, resolve } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import {
|
||||
IpcChannels,
|
||||
@@ -15,6 +26,7 @@ import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp
|
||||
import { getFfmpegVersions } from './ffmpeg'
|
||||
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||
import { probeMedia } from './probe'
|
||||
import { getAppIconImage } from './binaries'
|
||||
import {
|
||||
startDownload,
|
||||
cancelDownload,
|
||||
@@ -34,8 +46,15 @@ import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory
|
||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||
import { setupPortableData } from './portable'
|
||||
import { safeOpenPath, safeShowInFolder } from './reveal'
|
||||
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
||||
import {
|
||||
openCookieLoginWindow,
|
||||
getCookiesStatus,
|
||||
clearCookies,
|
||||
migrateLegacyCookies
|
||||
} from './cookies'
|
||||
import { attachEditContextMenu } from './contextMenu'
|
||||
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
||||
import { flushAllStores } from './jsonStore'
|
||||
import { exportBackup, importBackup } from './backup'
|
||||
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
|
||||
import {
|
||||
@@ -50,6 +69,8 @@ import { indexSource } from './indexer'
|
||||
import { syncWatchedSources } from './sync'
|
||||
import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule'
|
||||
import { createTray, markQuitting, isQuitting } from './tray'
|
||||
import { getActiveBadge, getErrorBadge } from './badge'
|
||||
import { openPoTokenWindow } from './poToken'
|
||||
|
||||
// Only one instance ever runs. A second launch — e.g. the OS invoking us again
|
||||
// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
|
||||
@@ -79,8 +100,9 @@ setupPortableData()
|
||||
// installer also declares this scheme (electron-builder.yml's `protocols`)
|
||||
// so it's registered even before first launch; this call additionally covers
|
||||
// the portable build and dev, which have no installer step to do it for us.
|
||||
if (is.dev && process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(process.argv[1])])
|
||||
const devScript = process.argv[1]
|
||||
if (is.dev && devScript) {
|
||||
app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(devScript)])
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient('aerofetch')
|
||||
}
|
||||
@@ -93,13 +115,18 @@ let mainWindow: BrowserWindow | null = null
|
||||
// Windows) shows the app's current color instead of a mismatched white flash.
|
||||
const THEME_BACKGROUND = { light: '#f7f7f8', dark: '#161618' } as const
|
||||
|
||||
// 'system' isn't a real background — resolve it against the OS's current
|
||||
// preference (nativeTheme.themeSource defaults to 'system', so this tracks it
|
||||
// without AeroFetch ever touching themeSource itself).
|
||||
// Resolve 'system' against the OS preference so callers always get a concrete color.
|
||||
function resolveBackgroundMode(theme: Settings['theme']): 'light' | 'dark' {
|
||||
return theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : theme
|
||||
}
|
||||
|
||||
// Keep the OS-drawn title bar consistent with the in-app theme (W3). Setting
|
||||
// themeSource to 'light'/'dark' forces the caption/chrome to match; 'system'
|
||||
// restores OS control. Called at startup and on every theme change.
|
||||
function applyNativeTheme(theme: Settings['theme']): void {
|
||||
nativeTheme.themeSource = theme
|
||||
}
|
||||
|
||||
function getSystemThemeInfo(): SystemThemeInfo {
|
||||
return {
|
||||
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
|
||||
@@ -116,7 +143,8 @@ function notifyBackgroundOnce(): void {
|
||||
notifiedBackground = true
|
||||
new Notification({
|
||||
title: 'AeroFetch is still running',
|
||||
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.'
|
||||
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.',
|
||||
icon: getAppIconImage()
|
||||
}).show()
|
||||
}
|
||||
|
||||
@@ -143,6 +171,10 @@ function createWindow(): void {
|
||||
const win = new BrowserWindow({
|
||||
width: 920,
|
||||
height: 700,
|
||||
// Below this the 212px sidebar + content layout breaks; pin a sensible
|
||||
// floor so the window can't be dragged down to unusable widths (W1).
|
||||
minWidth: 640,
|
||||
minHeight: 480,
|
||||
show: false,
|
||||
backgroundColor: THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)],
|
||||
autoHideMenuBar: true,
|
||||
@@ -155,6 +187,9 @@ function createWindow(): void {
|
||||
})
|
||||
mainWindow = win
|
||||
|
||||
// Standard Cut/Copy/Paste/Select All right-click menu on editable fields (W4).
|
||||
attachEditContextMenu(win.webContents)
|
||||
|
||||
win.on('ready-to-show', () => {
|
||||
// A scheduled `--sync` launch starts unobtrusively (shown but not focused) so
|
||||
// the daily background sync doesn't steal focus; a normal launch shows + focuses.
|
||||
@@ -226,6 +261,16 @@ function createWindow(): void {
|
||||
}
|
||||
|
||||
function registerIpcHandlers(): void {
|
||||
// M30: a broken contextBridge causes the renderer to silently run in preview/mock
|
||||
// mode. Catch it here and show a hard error so the failure is never invisible.
|
||||
ipcMain.once(IpcChannels.preloadBridgeFailure, (_e, detail: unknown) => {
|
||||
dialog.showErrorBox(
|
||||
'AeroFetch could not start',
|
||||
`The renderer bridge failed to initialize. Please reinstall the application.\n\n${String(detail)}`
|
||||
)
|
||||
app.quit()
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
||||
|
||||
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
|
||||
@@ -262,13 +307,17 @@ function registerIpcHandlers(): void {
|
||||
)
|
||||
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
|
||||
|
||||
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
|
||||
|
||||
ipcMain.handle(IpcChannels.chooseFolder, async (e) => {
|
||||
ipcMain.handle(IpcChannels.chooseFolder, async (e, current?: string) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
||||
const res = await dialog.showOpenDialog(win!, {
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
// Seed the picker at the currently-configured folder so it opens where the
|
||||
// user already points, not a generic default (W5). 'createDirectory' is a
|
||||
// macOS-only property and a no-op on Windows, so it's dropped (L58).
|
||||
const defaultPath = current && existsSync(current) ? current : undefined
|
||||
// Use the parented overload only when we actually have a window — passing a
|
||||
// forced non-null window that's gone can throw (L53).
|
||||
const res = win
|
||||
? await dialog.showOpenDialog(win, { properties: ['openDirectory'], defaultPath })
|
||||
: await dialog.showOpenDialog({ properties: ['openDirectory'], defaultPath })
|
||||
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
|
||||
})
|
||||
|
||||
@@ -280,13 +329,14 @@ function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IpcChannels.settingsGet, () => getSettings())
|
||||
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
|
||||
const result = setSettings(partial)
|
||||
// Keep the window's native background in sync with the theme so a compositor
|
||||
// repaint never flashes a mismatched color behind an overlay. Use the
|
||||
// validated result, not the raw partial (which may hold a bogus value).
|
||||
// Keep the window's native background and title bar in sync with the theme
|
||||
// so a compositor repaint never flashes a mismatched color, and the OS-drawn
|
||||
// caption always matches the in-app theme (W3). Use the validated result.
|
||||
if (partial.theme) {
|
||||
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(
|
||||
THEME_BACKGROUND[resolveBackgroundMode(result.theme)]
|
||||
)
|
||||
applyNativeTheme(result.theme)
|
||||
}
|
||||
return result
|
||||
})
|
||||
@@ -303,12 +353,18 @@ function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IpcChannels.historyRemoveMany, (_e, ids: string[]) => removeManyHistory(ids))
|
||||
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
|
||||
|
||||
ipcMain.handle(IpcChannels.cookiesLogin, (_e, url: string) => openCookieLoginWindow(url))
|
||||
ipcMain.handle(IpcChannels.cookiesLogin, (e, url: string) =>
|
||||
// Parent the sign-in window to the app window (W6) so it groups under
|
||||
// AeroFetch instead of spawning a second taskbar button.
|
||||
openCookieLoginWindow(url, BrowserWindow.fromWebContents(e.sender) ?? undefined)
|
||||
)
|
||||
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
|
||||
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
|
||||
|
||||
ipcMain.handle(IpcChannels.templatesList, () => listTemplates())
|
||||
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) => saveTemplate(template))
|
||||
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) =>
|
||||
saveTemplate(template)
|
||||
)
|
||||
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
|
||||
|
||||
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
|
||||
@@ -327,9 +383,11 @@ function registerIpcHandlers(): void {
|
||||
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
||||
const result = await importBackup(win)
|
||||
// A restored backup may have changed the theme; keep the native window
|
||||
// background in sync the same way settingsSet does.
|
||||
// background and title bar in sync the same way settingsSet does (W3).
|
||||
if (result.ok) {
|
||||
win?.setBackgroundColor(THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)])
|
||||
const theme = getSettings().theme
|
||||
win?.setBackgroundColor(THEME_BACKGROUND[resolveBackgroundMode(theme)])
|
||||
applyNativeTheme(theme)
|
||||
}
|
||||
return result
|
||||
})
|
||||
@@ -369,8 +427,24 @@ function registerIpcHandlers(): void {
|
||||
// Reflect overall queue progress on the Windows taskbar (fire-and-forget).
|
||||
ipcMain.on(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
if (p.mode === 'none') mainWindow.setProgressBar(-1)
|
||||
else mainWindow.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
|
||||
if (p.mode === 'none') {
|
||||
mainWindow.setProgressBar(-1)
|
||||
mainWindow.setOverlayIcon(null, '')
|
||||
} else {
|
||||
mainWindow.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
|
||||
const badge = p.mode === 'error' ? getErrorBadge() : getActiveBadge()
|
||||
const n = p.badgeCount ?? 0
|
||||
const label =
|
||||
p.mode === 'error' ? 'Download error' : `${n} download${n !== 1 ? 's' : ''} active`
|
||||
mainWindow.setOverlayIcon(badge, label)
|
||||
}
|
||||
})
|
||||
|
||||
// Open a YouTube WebView and extract a PO token for bot-check bypass (Phase P).
|
||||
ipcMain.handle(IpcChannels.youtubePoTokenMint, async () => {
|
||||
const token = await openPoTokenWindow()
|
||||
if (token) await setSettings({ youtubePoToken: token })
|
||||
return token
|
||||
})
|
||||
}
|
||||
|
||||
@@ -400,6 +474,10 @@ if (isPrimaryInstance) {
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId('com.aerofetch.app')
|
||||
|
||||
// M31: suppress the default Electron menu (which exposes DevTools/Reload via
|
||||
// Alt) in production builds. In dev the menu is kept so DevTools are accessible.
|
||||
if (!is.dev) Menu.setApplicationMenu(null)
|
||||
|
||||
// Create the default Documents\Video and Documents\Audio destinations so they
|
||||
// exist from first launch (downloads are routed into them by kind).
|
||||
ensureMediaDirs()
|
||||
@@ -407,6 +485,9 @@ if (isPrimaryInstance) {
|
||||
// Encrypt any credential still stored as legacy plaintext (from before at-rest
|
||||
// encryption), once safeStorage is available post-ready.
|
||||
migrateSecretsAtRest()
|
||||
// Same for a pre-encryption plaintext cookies.txt — encrypt it and delete the
|
||||
// plaintext so a logged-in session no longer sits in the open (H7).
|
||||
migrateLegacyCookies()
|
||||
|
||||
// Sync the Windows "run at sign-in" entry with the persisted setting, so it
|
||||
// reflects the user's choice even if they changed it on another install.
|
||||
@@ -419,6 +500,8 @@ if (isPrimaryInstance) {
|
||||
registerIpcHandlers()
|
||||
registerSystemThemeBridge()
|
||||
registerSendToShortcut()
|
||||
// Apply the persisted theme to the OS title bar before the window opens (W3).
|
||||
applyNativeTheme(getSettings().theme)
|
||||
createWindow()
|
||||
createTray(() => mainWindow)
|
||||
|
||||
@@ -443,20 +526,20 @@ if (isPrimaryInstance) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
}).catch((e) => console.error('[AeroFetch] yt-dlp auto-update failed:', e))
|
||||
})
|
||||
|
||||
// Any real quit path (tray "Quit", OS shutdown, the updater's app.quit) must set
|
||||
// the quitting flag so the window's close handler exits instead of hiding to tray.
|
||||
app.on('before-quit', () => markQuitting())
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
// Also flush any debounced JSON-store writes synchronously so a quit mid-debounce
|
||||
// can't drop the last history/sources/template change (R3).
|
||||
app.on('before-quit', () => {
|
||||
markQuitting()
|
||||
flushAllStores()
|
||||
})
|
||||
|
||||
// Windows-only app: closing the last window quits (the tray/in-flight-download
|
||||
// paths hide rather than close, so this only fires on a real exit). The former
|
||||
// macOS 'activate' handler and darwin guard were dead branches here (L147).
|
||||
app.on('window-all-closed', () => app.quit())
|
||||
}
|
||||
|
||||
+2
-1
@@ -152,7 +152,8 @@ export async function indexSource(
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'That link is a single video, not a channel or playlist. Use the download bar for one video.'
|
||||
error:
|
||||
'That link is a single video, not a channel or playlist. Use the download bar for one video.'
|
||||
}
|
||||
}
|
||||
title = data.title || 'Playlist'
|
||||
|
||||
@@ -88,7 +88,10 @@ export function classifySource(raw: string): SourceClass | null {
|
||||
// A playlist is identified purely by its list= param (works on any youtube host).
|
||||
const list = u.searchParams.get('list')
|
||||
if (isYouTube && list) {
|
||||
return { kind: 'playlist', base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}` }
|
||||
return {
|
||||
kind: 'playlist',
|
||||
base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}`
|
||||
}
|
||||
}
|
||||
|
||||
if (!isYouTube) return null
|
||||
@@ -163,7 +166,7 @@ export function parseRssVideoIds(xml: string): string[] {
|
||||
const ids: string[] = []
|
||||
const re = /<yt:videoId>\s*([\w-]+)\s*<\/yt:videoId>/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(xml)) !== null) ids.push(m[1])
|
||||
while ((m = re.exec(xml)) !== null) if (m[1]) ids.push(m[1])
|
||||
return ids
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync } from 'fs'
|
||||
|
||||
/**
|
||||
* One shared persistence layer for the hand-rolled JSON array stores (history,
|
||||
* error log, templates, sources, media-items). Replaces the per-module
|
||||
* read/parse/write copies (SIMP1) and fixes three data-safety blockers:
|
||||
*
|
||||
* - R1 — atomic writes: write a temp file then `rename` over the target, so a
|
||||
* crash / power-cut mid-write can never truncate the real file. (`electron-store`,
|
||||
* used for settings, is already atomic; these stores were not.)
|
||||
* - R2 — corruption is no longer silent data loss: on a parse error the bad file
|
||||
* is copied aside (`<file>.corrupt-<ts>`) before we fall back to empty, so a
|
||||
* single bad byte can't wipe the user's history/sources from their perspective.
|
||||
* - R3 — an in-memory cache + debounced batched writes: the previous code
|
||||
* re-read + re-parsed the entire (multi-MB) file and rewrote it synchronously
|
||||
* on *every* download completion (≈O(n²) over a large channel, blocking the
|
||||
* main thread). Reads now hit the cache; writes coalesce into one atomic flush.
|
||||
*/
|
||||
|
||||
// Debounce window for batched writes. Long enough to coalesce a burst (e.g. many
|
||||
// items enqueued/marked at once), short enough that data lands on disk promptly.
|
||||
const FLUSH_DELAY_MS = 400
|
||||
|
||||
/**
|
||||
* Read + validate a JSON array from `path`. Invalid rows are dropped. On a parse
|
||||
* error the file is backed up to `<path>.corrupt-<timestamp>` before returning []
|
||||
* so corruption is recoverable rather than a silent wipe (R2). A missing file is
|
||||
* simply [] (first run), with no backup.
|
||||
*/
|
||||
export function readJsonArraySafe<T>(path: string, isValid: (o: unknown) => o is T): T[] {
|
||||
let raw: string
|
||||
try {
|
||||
if (!existsSync(path)) return []
|
||||
raw = readFileSync(path, 'utf8')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
return Array.isArray(data) ? data.filter(isValid) : []
|
||||
} catch {
|
||||
try {
|
||||
copyFileSync(path, `${path}.corrupt-${Date.now()}`)
|
||||
} catch {
|
||||
/* best-effort — if we can't back it up, still don't crash the read */
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write `value` as pretty JSON to `path`: serialise to `<path>.tmp`,
|
||||
* then `rename` it over the target (an atomic operation on the same volume), so a
|
||||
* crash mid-write leaves either the old file or the new one — never a truncated
|
||||
* one (R1). Best-effort: a read-only data dir just means nothing is persisted.
|
||||
*/
|
||||
export function writeJsonAtomic(path: string, value: unknown): void {
|
||||
const tmp = `${path}.tmp`
|
||||
try {
|
||||
writeFileSync(tmp, JSON.stringify(value, null, 2))
|
||||
renameSync(tmp, path)
|
||||
} catch {
|
||||
/* best-effort; e.g. a read-only data dir */
|
||||
}
|
||||
}
|
||||
|
||||
export interface JsonStore<T> {
|
||||
/** All rows, validated; cached after the first read. */
|
||||
read(): T[]
|
||||
/** Replace all rows (capped), update the cache, and schedule an atomic flush. Returns the stored rows. */
|
||||
write(items: T[]): T[]
|
||||
/** Force any pending debounced write to disk synchronously (e.g. on quit). */
|
||||
flush(): void
|
||||
}
|
||||
|
||||
// Every store registers itself so app-quit can flush pending writes in one call.
|
||||
const registry: JsonStore<unknown>[] = []
|
||||
|
||||
/**
|
||||
* Create a cached, atomic JSON array store backed by `<userData>/<filename>`.
|
||||
* `cap` bounds the row count (pass Infinity for none). The path is resolved
|
||||
* lazily so importing this module never touches `app` before it's ready.
|
||||
*/
|
||||
export function createJsonStore<T>(
|
||||
filename: string,
|
||||
isValid: (o: unknown) => o is T,
|
||||
cap: number
|
||||
): JsonStore<T> {
|
||||
let cache: T[] | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let dirty = false
|
||||
|
||||
const filePath = (): string => join(app.getPath('userData'), filename)
|
||||
|
||||
function read(): T[] {
|
||||
if (cache === null) cache = readJsonArraySafe(filePath(), isValid)
|
||||
return cache
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
if (!dirty) return
|
||||
dirty = false
|
||||
writeJsonAtomic(filePath(), cache ?? [])
|
||||
}
|
||||
|
||||
function write(items: T[]): T[] {
|
||||
// Avoid an extra copy in the common under-cap case so a per-completion write
|
||||
// stays O(1) rather than re-slicing the whole list each time (R3).
|
||||
cache = items.length > cap ? items.slice(0, cap) : items
|
||||
dirty = true
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(flush, FLUSH_DELAY_MS)
|
||||
return cache
|
||||
}
|
||||
|
||||
const store: JsonStore<T> = { read, write, flush }
|
||||
registry.push(store as JsonStore<unknown>)
|
||||
return store
|
||||
}
|
||||
|
||||
/** Synchronously flush every store's pending write — call on app quit. */
|
||||
export function flushAllStores(): void {
|
||||
for (const s of registry) s.flush()
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { BrowserWindow, type WebContents } from 'electron'
|
||||
import { LOGIN_PARTITION } from './cookies'
|
||||
|
||||
/** A public YouTube video used as the extraction target (stable, always accessible). */
|
||||
const YT_VIDEO = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
|
||||
|
||||
/**
|
||||
* Restrict a PO-token window to *.youtube.com and accounts.google.com (for
|
||||
* sign-in). Mirrors hardenLoginWebContents in cookies.ts.
|
||||
*/
|
||||
function isAllowedPoTokenUrl(target: string): boolean {
|
||||
try {
|
||||
const { protocol, hostname } = new URL(target)
|
||||
if (protocol !== 'http:' && protocol !== 'https:') return false
|
||||
return (
|
||||
hostname === 'youtube.com' ||
|
||||
hostname.endsWith('.youtube.com') ||
|
||||
hostname === 'accounts.google.com' ||
|
||||
hostname.endsWith('.accounts.google.com')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function hardenPoTokenWebContents(wc: WebContents): void {
|
||||
wc.setWindowOpenHandler((details) => {
|
||||
if (!isAllowedPoTokenUrl(details.url)) return { action: 'deny' }
|
||||
return {
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: {
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
partition: LOGIN_PARTITION,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
wc.on('will-navigate', (e, navUrl) => {
|
||||
if (!isAllowedPoTokenUrl(navUrl)) e.preventDefault()
|
||||
})
|
||||
wc.on('will-redirect', (e, navUrl) => {
|
||||
if (!isAllowedPoTokenUrl(navUrl)) e.preventDefault()
|
||||
})
|
||||
wc.on('did-create-window', (child) => hardenPoTokenWebContents(child.webContents))
|
||||
}
|
||||
|
||||
/**
|
||||
* Async IIFE injected into the YouTube page after load. Polls
|
||||
* ytInitialPlayerResponse.serviceIntegrityDimensions.poToken (the field
|
||||
* yt-dlp's youtube extractor reads) for up to 8 s, returning the token
|
||||
* string or null if it never appears. The poll loop is needed because the
|
||||
* player JS initialises asynchronously after the DOM is ready.
|
||||
*/
|
||||
const EXTRACT_SCRIPT = `
|
||||
(async function() {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
try {
|
||||
const pot = window.ytInitialPlayerResponse
|
||||
&& window.ytInitialPlayerResponse.serviceIntegrityDimensions
|
||||
&& window.ytInitialPlayerResponse.serviceIntegrityDimensions.poToken
|
||||
if (typeof pot === 'string' && pot.length > 0) return pot
|
||||
} catch (_) {}
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
return null
|
||||
})()
|
||||
`
|
||||
|
||||
let mintWindow: BrowserWindow | null = null
|
||||
|
||||
/**
|
||||
* Open a visible BrowserWindow on a YouTube video page and extract the
|
||||
* Proof-of-Origin token from the page runtime. The window uses the shared
|
||||
* login session so a signed-in user's credentials are available.
|
||||
*
|
||||
* Resolves with the token string on success, or null if the window was closed
|
||||
* by the user before extraction completed or if the field was not found.
|
||||
*/
|
||||
export function openPoTokenWindow(): Promise<string | null> {
|
||||
// If a minting window is already open, bring it to the front rather than
|
||||
// opening a second one.
|
||||
if (mintWindow && !mintWindow.isDestroyed()) {
|
||||
mintWindow.focus()
|
||||
// Return a promise that resolves when the existing window closes.
|
||||
return new Promise((resolve) => {
|
||||
mintWindow!.once('closed', () => resolve(null))
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
const finish = (token: string | null): void => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
resolve(token)
|
||||
}
|
||||
|
||||
let win: BrowserWindow
|
||||
try {
|
||||
win = new BrowserWindow({
|
||||
width: 960,
|
||||
height: 640,
|
||||
title: 'AeroFetch — Fetch YouTube token',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
partition: LOGIN_PARTITION,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
|
||||
mintWindow = win
|
||||
hardenPoTokenWebContents(win.webContents)
|
||||
win.webContents.session.setPermissionRequestHandler((_wc, _perm, cb) => cb(false))
|
||||
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
win.webContents
|
||||
.executeJavaScript(EXTRACT_SCRIPT, true)
|
||||
.then((result: unknown) => {
|
||||
const token = typeof result === 'string' && result.length > 0 ? result : null
|
||||
finish(token)
|
||||
win.close()
|
||||
})
|
||||
.catch(() => {
|
||||
finish(null)
|
||||
win.close()
|
||||
})
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
mintWindow = null
|
||||
finish(null)
|
||||
})
|
||||
|
||||
win.loadURL(YT_VIDEO).catch(() => {
|
||||
/* navigation errors surface as Chromium's own error page */
|
||||
})
|
||||
})
|
||||
}
|
||||
+21
-3
@@ -13,11 +13,29 @@ import { extname, isAbsolute } from 'path'
|
||||
*/
|
||||
const OPENABLE_EXTENSIONS = new Set([
|
||||
// video
|
||||
'.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.ts', '.m4v', '.3gp', '.ogv',
|
||||
'.mp4',
|
||||
'.mkv',
|
||||
'.webm',
|
||||
'.mov',
|
||||
'.avi',
|
||||
'.flv',
|
||||
'.ts',
|
||||
'.m4v',
|
||||
'.3gp',
|
||||
'.ogv',
|
||||
// audio
|
||||
'.mp3', '.m4a', '.opus', '.ogg', '.oga', '.aac', '.flac', '.wav', '.wma',
|
||||
'.mp3',
|
||||
'.m4a',
|
||||
'.opus',
|
||||
'.ogg',
|
||||
'.oga',
|
||||
'.aac',
|
||||
'.flac',
|
||||
'.wav',
|
||||
'.wma',
|
||||
// subtitle sidecars (plain text — safe to open)
|
||||
'.vtt', '.srt'
|
||||
'.vtt',
|
||||
'.srt'
|
||||
])
|
||||
|
||||
/** Open a downloaded media file with its default app. Returns '' on success,
|
||||
|
||||
@@ -21,10 +21,15 @@ function schtasks(args: string[]): Promise<{ code: number; stdout: string; stder
|
||||
return new Promise((resolve) => {
|
||||
// Resolve schtasks from System32 by absolute path, not the bare name, so a
|
||||
// planted schtasks.exe on PATH / in the CWD can't be invoked instead. (audit F3)
|
||||
execFile(getSystem32Path('schtasks.exe'), args, { windowsHide: true }, (err, stdout, stderr) => {
|
||||
const code = err ? ((err as { code?: number }).code ?? 1) : 0
|
||||
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
|
||||
})
|
||||
execFile(
|
||||
getSystem32Path('schtasks.exe'),
|
||||
args,
|
||||
{ windowsHide: true },
|
||||
(err, stdout, stderr) => {
|
||||
const code = err ? ((err as { code?: number }).code ?? 1) : 0
|
||||
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+69
-16
@@ -10,6 +10,8 @@ import {
|
||||
SPONSORBLOCK_CATEGORIES,
|
||||
COOKIE_BROWSERS,
|
||||
ACCENT_COLORS,
|
||||
VIDEO_QUALITY_OPTIONS,
|
||||
AUDIO_QUALITY_OPTIONS,
|
||||
DEFAULT_DOWNLOAD_OPTIONS,
|
||||
isYtdlpUpdateChannel,
|
||||
type Settings,
|
||||
@@ -27,7 +29,9 @@ const DEFAULTS: Settings = {
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
maxConcurrent: 2,
|
||||
filenameTemplate: '%(title)s.%(ext)s',
|
||||
theme: 'light',
|
||||
// Follow the OS theme on first launch (SR1) so a dark-mode Windows user isn't
|
||||
// greeted by a bright white window despite full system-theme support.
|
||||
theme: 'system',
|
||||
accentColor: 'teal',
|
||||
clipboardWatch: true,
|
||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||||
@@ -41,14 +45,19 @@ const DEFAULTS: Settings = {
|
||||
restrictFilenames: false,
|
||||
downloadArchive: false,
|
||||
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
|
||||
// nightly channel (fastest to follow YouTube changes that cause 403s).
|
||||
// configured channel so a stale binary can't silently cause YouTube 403s.
|
||||
autoUpdateYtdlp: true,
|
||||
ytdlpChannel: 'nightly',
|
||||
// Default to stable so a nightly regression doesn't break downloads for
|
||||
// everyone out of the box (SR2). Users who want the latest YouTube fixes
|
||||
// can switch to nightly in Settings → Software.
|
||||
ytdlpChannel: 'stable',
|
||||
ytdlpLastUpdateCheck: 0,
|
||||
customCommandEnabled: false,
|
||||
defaultTemplateId: null,
|
||||
notifyOnComplete: true,
|
||||
autoDownloadNew: true,
|
||||
// Default off so adding a watched channel doesn't silently fill the user's
|
||||
// disk on first use (SR3). Enable explicitly once they know what it does.
|
||||
autoDownloadNew: false,
|
||||
hasCompletedOnboarding: false,
|
||||
minimizeToTray: false,
|
||||
launchAtStartup: false,
|
||||
@@ -105,8 +114,7 @@ export function ensureMediaDirs(): void {
|
||||
function sanitizeOptions(input: unknown): DownloadOptions {
|
||||
const o = (input && typeof input === 'object' ? input : {}) as Partial<DownloadOptions>
|
||||
const d = DEFAULT_DOWNLOAD_OPTIONS
|
||||
const bool = (v: unknown, fallback: boolean): boolean =>
|
||||
typeof v === 'boolean' ? v : fallback
|
||||
const bool = (v: unknown, fallback: boolean): boolean => (typeof v === 'boolean' ? v : fallback)
|
||||
const cats = Array.isArray(o.sponsorBlockCategories)
|
||||
? (o.sponsorBlockCategories.filter((c) =>
|
||||
(SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c)
|
||||
@@ -133,6 +141,9 @@ function sanitizeOptions(input: unknown): DownloadOptions {
|
||||
embedChapters: bool(o.embedChapters, d.embedChapters),
|
||||
splitChapters: bool(o.splitChapters, d.splitChapters),
|
||||
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
|
||||
metadataTitle: typeof o.metadataTitle === 'string' ? o.metadataTitle : undefined,
|
||||
metadataArtist: typeof o.metadataArtist === 'string' ? o.metadataArtist : undefined,
|
||||
metadataAlbum: typeof o.metadataAlbum === 'string' ? o.metadataAlbum : undefined,
|
||||
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
|
||||
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
|
||||
writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson),
|
||||
@@ -149,13 +160,19 @@ function getStore(): Store<Settings> {
|
||||
return store
|
||||
}
|
||||
|
||||
// Decrypted-settings cache — avoids repeated DPAPI calls on hot paths such as
|
||||
// buildCommand, the maxConcurrent check, completion notify, and the system-theme
|
||||
// bridge (PERF1). Invalidated by every setSettings write and by the one-time
|
||||
// migrateSecretsAtRest so callers always see the current values.
|
||||
let cachedSettings: Settings | null = null
|
||||
|
||||
// --- Credential encryption at rest ------------------------------------------
|
||||
// proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API
|
||||
// tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows)
|
||||
// so a leaked settings.json doesn't expose them. They're still decrypted before
|
||||
// reaching the renderer and exported in clear by backup — this guards the file at
|
||||
// rest only.
|
||||
const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const
|
||||
// so a leaked settings.json doesn't expose them. They're decrypted before
|
||||
// reaching the renderer; backup export strips them entirely (see backup.ts, M22).
|
||||
// Exported so backup.ts shares this one list rather than keeping a parallel copy.
|
||||
export const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const
|
||||
|
||||
// Marks a value produced by encryptSecret, so a read can tell ciphertext from a
|
||||
// legacy plaintext value (written before encryption existed, or while safeStorage
|
||||
@@ -213,9 +230,11 @@ export function migrateSecretsAtRest(): void {
|
||||
const raw = s.get(key) ?? ''
|
||||
if (raw && !raw.startsWith(ENC_PREFIX)) s.set(key, encryptSecret(raw))
|
||||
}
|
||||
cachedSettings = null
|
||||
}
|
||||
|
||||
export function getSettings(): Settings {
|
||||
if (cachedSettings) return cachedSettings
|
||||
const s = getStore()
|
||||
// getSettings() is on hot paths (buildCommand, notification checks, the system-
|
||||
// theme bridge, several IPC handlers). electron-store writes to disk on every
|
||||
@@ -238,7 +257,8 @@ export function getSettings(): Settings {
|
||||
}
|
||||
// Hand callers (and, via IPC, the renderer) plaintext credentials — they're
|
||||
// only encrypted on disk (see withDecryptedSecrets / encryptSecret).
|
||||
return withDecryptedSecrets(s.store)
|
||||
cachedSettings = withDecryptedSecrets(s.store)
|
||||
return cachedSettings
|
||||
}
|
||||
|
||||
/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
|
||||
@@ -264,6 +284,20 @@ function downloadOptionsEqual(a: DownloadOptions, b: unknown): boolean {
|
||||
// background (theme), so an out-of-range or malformed value shouldn't get stored.
|
||||
export function setSettings(partial: Partial<Settings>): Settings {
|
||||
const s = getStore()
|
||||
try {
|
||||
applySettings(s, partial)
|
||||
} catch (e) {
|
||||
// R5: a write failure (disk full, read-only profile) must not crash the IPC
|
||||
// handler or surface as an unhandled rejection. Log it; getSettings() below
|
||||
// returns the store's ACTUAL persisted state, so the renderer's reconciliation
|
||||
// (M34) reflects what truly saved instead of the optimistic value.
|
||||
console.error('[AeroFetch] settings write failed:', e)
|
||||
}
|
||||
cachedSettings = null
|
||||
return getSettings()
|
||||
}
|
||||
|
||||
function applySettings(s: Store<Settings>, partial: Partial<Settings>): void {
|
||||
for (const key of Object.keys(partial) as (keyof Settings)[]) {
|
||||
const value = partial[key]
|
||||
if (value === undefined) continue
|
||||
@@ -343,23 +377,42 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
||||
}
|
||||
break
|
||||
case 'defaultVideoQuality':
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
(VIDEO_QUALITY_OPTIONS as readonly string[]).includes(value)
|
||||
) {
|
||||
s.set('defaultVideoQuality', value)
|
||||
}
|
||||
break
|
||||
case 'defaultAudioQuality':
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
(AUDIO_QUALITY_OPTIONS as readonly string[]).includes(value)
|
||||
) {
|
||||
s.set('defaultAudioQuality', value)
|
||||
}
|
||||
break
|
||||
case 'rateLimit':
|
||||
// yt-dlp rate-limit format: empty (disabled) or e.g. "500K", "2.5M", "1G".
|
||||
if (typeof value === 'string' && /^(\d+\.?\d*[KMGkmg]?B?)?$/.test(value.trim())) {
|
||||
s.set('rateLimit', value.trim())
|
||||
}
|
||||
break
|
||||
case 'youtubePlayerClient':
|
||||
if (typeof value === 'string') s.set(key, value)
|
||||
// Power-user field; yt-dlp's client list evolves. Accept any trimmed string.
|
||||
if (typeof value === 'string') s.set('youtubePlayerClient', value.trim())
|
||||
break
|
||||
// Credential-bearing fields — encrypted at rest (see encryptSecret). proxy
|
||||
// may embed user:pass@host; youtubePoToken is an access token. exportBackup
|
||||
// still writes them in clear (via the decrypted getSettings), as documented.
|
||||
// may embed user:pass@host; youtubePoToken is an access token. Both are
|
||||
// stripped from backup exports (see SECRET_KEYS / backup.ts).
|
||||
case 'proxy':
|
||||
case 'youtubePoToken':
|
||||
if (typeof value === 'string') s.set(key, encryptSecret(value))
|
||||
break
|
||||
case 'updateToken':
|
||||
// A Gitea token has no spaces; trim before encrypting (like proxy creds).
|
||||
// An access token has no spaces; trim before encrypting (like proxy creds).
|
||||
if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim()))
|
||||
break
|
||||
}
|
||||
}
|
||||
return getSettings()
|
||||
}
|
||||
|
||||
+15
-42
@@ -12,48 +12,27 @@
|
||||
* can't feed the UI malformed records (same approach as history/errorlog).
|
||||
*/
|
||||
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import type { Source, MediaItem } from '@shared/ipc'
|
||||
import { isValidSource, isValidMediaItem } from './validation'
|
||||
import { mergeItemsPreservingState } from './indexerCore'
|
||||
import { createJsonStore } from './jsonStore'
|
||||
|
||||
// A generous global cap so a runaway index can't grow the file unbounded; large
|
||||
// enough for several big channels. When exceeded, the most-recently-written
|
||||
// source's items are kept (they're placed first by replaceMediaItems).
|
||||
const MAX_ITEMS = 20000
|
||||
|
||||
function sourcesFile(): string {
|
||||
return join(app.getPath('userData'), 'sources.json')
|
||||
}
|
||||
|
||||
function itemsFile(): string {
|
||||
return join(app.getPath('userData'), 'media-items.json')
|
||||
}
|
||||
|
||||
function readJsonArray<T>(path: string, isValid: (o: unknown) => o is T): T[] {
|
||||
try {
|
||||
if (!existsSync(path)) return []
|
||||
const data = JSON.parse(readFileSync(path, 'utf8'))
|
||||
return Array.isArray(data) ? data.filter(isValid) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(path: string, value: unknown): void {
|
||||
try {
|
||||
writeFileSync(path, JSON.stringify(value, null, 2))
|
||||
} catch {
|
||||
/* best-effort; a read-only data dir just means no persisted index */
|
||||
}
|
||||
}
|
||||
// Two cached, atomically-written stores (R1–R3 via the shared jsonStore). The
|
||||
// media-items store is the hot path: setMediaItemDownloaded used to re-read and
|
||||
// rewrite the whole (≤MAX_ITEMS) file on every completion; now reads hit the
|
||||
// cache and writes are batched. Sources are few, so that store is uncapped.
|
||||
const sourcesStore = createJsonStore('sources.json', isValidSource, Infinity)
|
||||
const itemsStore = createJsonStore('media-items.json', isValidMediaItem, MAX_ITEMS)
|
||||
|
||||
// --- Sources ----------------------------------------------------------------
|
||||
|
||||
export function listSources(): Source[] {
|
||||
return readJsonArray(sourcesFile(), isValidSource)
|
||||
return sourcesStore.read()
|
||||
}
|
||||
|
||||
export function getSource(id: string): Source | undefined {
|
||||
@@ -62,31 +41,25 @@ export function getSource(id: string): Source | undefined {
|
||||
|
||||
/** Insert or replace a source by id (a re-index updates the existing record). */
|
||||
export function upsertSource(source: Source): Source[] {
|
||||
const sources = [source, ...listSources().filter((s) => s.id !== source.id)]
|
||||
writeJson(sourcesFile(), sources)
|
||||
return sources
|
||||
return sourcesStore.write([source, ...listSources().filter((s) => s.id !== source.id)])
|
||||
}
|
||||
|
||||
/** Toggle whether a source is watched for new uploads (Phase J). Returns all sources. */
|
||||
export function setSourceWatched(id: string, watched: boolean): Source[] {
|
||||
const sources = listSources().map((s) => (s.id === id ? { ...s, watched } : s))
|
||||
writeJson(sourcesFile(), sources)
|
||||
return sources
|
||||
return sourcesStore.write(listSources().map((s) => (s.id === id ? { ...s, watched } : s)))
|
||||
}
|
||||
|
||||
/** Remove a source and all of its media items. Returns the remaining sources. */
|
||||
export function removeSource(id: string): Source[] {
|
||||
const sources = listSources().filter((s) => s.id !== id)
|
||||
writeJson(sourcesFile(), sources)
|
||||
const items = listAllItems().filter((m) => m.sourceId !== id)
|
||||
writeJson(itemsFile(), items)
|
||||
const sources = sourcesStore.write(listSources().filter((s) => s.id !== id))
|
||||
itemsStore.write(listAllItems().filter((m) => m.sourceId !== id))
|
||||
return sources
|
||||
}
|
||||
|
||||
// --- Media items ------------------------------------------------------------
|
||||
|
||||
function listAllItems(): MediaItem[] {
|
||||
return readJsonArray(itemsFile(), isValidMediaItem)
|
||||
return itemsStore.read()
|
||||
}
|
||||
|
||||
export function listMediaItems(sourceId: string): MediaItem[] {
|
||||
@@ -100,7 +73,7 @@ export function listMediaItems(sourceId: string): MediaItem[] {
|
||||
*/
|
||||
export function replaceMediaItems(sourceId: string, items: MediaItem[]): void {
|
||||
const others = listAllItems().filter((m) => m.sourceId !== sourceId)
|
||||
writeJson(itemsFile(), [...items, ...others].slice(0, MAX_ITEMS))
|
||||
itemsStore.write([...items, ...others])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +103,6 @@ export function setMediaItemDownloaded(id: string, filePath?: string): MediaItem
|
||||
const updated = all.map((m) =>
|
||||
m.id === id ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
|
||||
)
|
||||
writeJson(itemsFile(), updated)
|
||||
itemsStore.write(updated)
|
||||
return updated.filter((m) => m.sourceId === target.sourceId)
|
||||
}
|
||||
|
||||
+11
-34
@@ -1,38 +1,21 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import type { CommandTemplate } from '@shared/ipc'
|
||||
import { isTemplateLike } from './validation'
|
||||
import { createJsonStore } from './jsonStore'
|
||||
|
||||
// Plain JSON in userData, same shape as history.ts.
|
||||
// Plain JSON in userData, same shape as history.ts. Atomic writes / corruption
|
||||
// backup / caching come from the shared jsonStore (R1–R3).
|
||||
const MAX_TEMPLATES = 100
|
||||
|
||||
function templatesFile(): string {
|
||||
return join(app.getPath('userData'), 'templates.json')
|
||||
}
|
||||
|
||||
// A persisted template entry must at least be an object carrying an id (see
|
||||
// isTemplateLike in validation.ts); everything else is coerced by sanitize().
|
||||
// Drop anything that isn't, so a hand-edited templates.json can't inject
|
||||
// malformed entries. (audit S5)
|
||||
export function listTemplates(): CommandTemplate[] {
|
||||
try {
|
||||
if (!existsSync(templatesFile())) return []
|
||||
const data = JSON.parse(readFileSync(templatesFile(), 'utf8'))
|
||||
// Validate shape, then normalise each surviving entry through sanitize() so
|
||||
// name/args are always well-formed strings regardless of what was on disk.
|
||||
return Array.isArray(data) ? data.filter(isTemplateLike).map(sanitize) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
const store = createJsonStore('templates.json', isTemplateLike, MAX_TEMPLATES)
|
||||
|
||||
function save(templates: CommandTemplate[]): void {
|
||||
try {
|
||||
writeFileSync(templatesFile(), JSON.stringify(templates.slice(0, MAX_TEMPLATES), null, 2))
|
||||
} catch {
|
||||
/* best-effort; a read-only data dir just means no persisted templates */
|
||||
}
|
||||
export function listTemplates(): CommandTemplate[] {
|
||||
// Normalise each surviving entry through sanitize() so name/args are always
|
||||
// well-formed strings regardless of what was on disk.
|
||||
return store.read().map(sanitize)
|
||||
}
|
||||
|
||||
function sanitize(t: CommandTemplate): CommandTemplate {
|
||||
@@ -49,20 +32,14 @@ function sanitize(t: CommandTemplate): CommandTemplate {
|
||||
/** Add a new template, or update an existing one (matched by id). */
|
||||
export function saveTemplate(template: CommandTemplate): CommandTemplate[] {
|
||||
const clean = sanitize(template)
|
||||
const templates = [clean, ...listTemplates().filter((t) => t.id !== clean.id)]
|
||||
save(templates)
|
||||
return templates
|
||||
return store.write([clean, ...listTemplates().filter((t) => t.id !== clean.id)])
|
||||
}
|
||||
|
||||
export function removeTemplate(id: string): CommandTemplate[] {
|
||||
const templates = listTemplates().filter((t) => t.id !== id)
|
||||
save(templates)
|
||||
return templates
|
||||
return store.write(listTemplates().filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
/** Replace the entire template list (backup restore) rather than merge-by-id. */
|
||||
export function replaceTemplates(templates: CommandTemplate[]): CommandTemplate[] {
|
||||
const clean = templates.map(sanitize)
|
||||
save(clean)
|
||||
return clean
|
||||
return store.write(templates.map(sanitize))
|
||||
}
|
||||
|
||||
+2
-1
@@ -44,7 +44,8 @@ export function createTray(getWindow: () => BrowserWindow | null): void {
|
||||
// Prefer the real app icon; fall back to the embedded glyph when no icon.ico
|
||||
// ships, so minimize-to-tray always has a tray to restore from.
|
||||
let icon = nativeImage.createFromPath(getAppIconPath())
|
||||
if (icon.isEmpty()) icon = nativeImage.createFromDataURL(`data:image/png;base64,${FALLBACK_TRAY_PNG}`)
|
||||
if (icon.isEmpty())
|
||||
icon = nativeImage.createFromDataURL(`data:image/png;base64,${FALLBACK_TRAY_PNG}`)
|
||||
if (icon.isEmpty()) return
|
||||
|
||||
tray = new Tray(icon)
|
||||
|
||||
+29
-7
@@ -68,9 +68,10 @@ function updateDir(): string {
|
||||
* an "upgrade" over the same shipped version.
|
||||
*/
|
||||
function parseVersion(v: string): number[] {
|
||||
return v
|
||||
.replace(/^v/i, '')
|
||||
.split(/[-+]/)[0]
|
||||
// split() always yields at least one element; `?? ''` only satisfies the type
|
||||
// checker (noUncheckedIndexedAccess) for the [0] access.
|
||||
const core = v.replace(/^v/i, '').split(/[-+]/)[0] ?? ''
|
||||
return core
|
||||
.split('.')
|
||||
.map((p) => parseInt(p, 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
@@ -105,9 +106,28 @@ interface GiteaRelease {
|
||||
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the
|
||||
* lowercase digest, or null if there's no standalone 64-char hex token (so a
|
||||
* longer run like a sha512 digest is ignored rather than sliced).
|
||||
*
|
||||
* When `fileName` is given (the installer asset's name), a multi-line / combined
|
||||
* checksum file is matched line-by-line and the hash on the line naming THIS
|
||||
* asset wins — so a `<asset>.sha256` that happens to list several files can't
|
||||
* verify the installer against the wrong line's hash (B3). Falls back to the
|
||||
* first standalone digest for bare single-hash files (no filename present).
|
||||
*/
|
||||
export function extractSha256(text: string): string | null {
|
||||
const m = text.match(/\b[a-f0-9]{64}\b/i)
|
||||
export function extractSha256(text: string, fileName?: string): string | null {
|
||||
const hashRe = /\b[a-f0-9]{64}\b/i
|
||||
if (fileName) {
|
||||
const base = fileName.toLowerCase()
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.match(hashRe)
|
||||
if (!m) continue
|
||||
// The filename is the last whitespace-delimited token on a sha256sum /
|
||||
// Get-FileHash line (optionally with a '*' binary-mode marker). Match it
|
||||
// exactly — a loose substring would let 'App.exe' match a 'MyApp.exe' line.
|
||||
const last = (line.trim().split(/\s+/).pop() ?? '').replace(/^\*/, '')
|
||||
if (last.toLowerCase() === base) return m[0].toLowerCase()
|
||||
}
|
||||
}
|
||||
const m = text.match(hashRe)
|
||||
return m ? m[0].toLowerCase() : null
|
||||
}
|
||||
|
||||
@@ -200,7 +220,9 @@ function fetchTrustedText(
|
||||
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const done = (r: { ok: true; text: string } | { ok: false; status?: number; error: string }): void => {
|
||||
const done = (
|
||||
r: { ok: true; text: string } | { ok: false; status?: number; error: string }
|
||||
): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
@@ -261,7 +283,7 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
|
||||
const sum = await fetchTrustedText(checksumUrl)
|
||||
let expectedSha: string | null = null
|
||||
if (sum.ok) {
|
||||
expectedSha = extractSha256(sum.text)
|
||||
expectedSha = extractSha256(sum.text, safeName)
|
||||
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
|
||||
} else if (sum.status === 404) {
|
||||
if (REQUIRE_CHECKSUM) {
|
||||
|
||||
+15
-13
@@ -32,10 +32,7 @@ export function ensureManagedYtdlp(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
|
||||
* Proves the main-process → bundled-binary → IPC path end to end.
|
||||
*/
|
||||
/** Spawn the bundled yt-dlp and read back its `--version` for the Settings panel. */
|
||||
export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
||||
const ytdlpPath = getYtdlpPath()
|
||||
|
||||
@@ -47,16 +44,21 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(ytdlpPath, ['--version'], { windowsHide: true, timeout: 15_000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const msg = (err as { killed?: boolean }).killed
|
||||
? 'Timed out running yt-dlp.'
|
||||
: (stderr || err.message).trim()
|
||||
resolve({ ok: false, error: msg })
|
||||
return
|
||||
execFile(
|
||||
ytdlpPath,
|
||||
['--version'],
|
||||
{ windowsHide: true, timeout: 15_000 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const msg = (err as { killed?: boolean }).killed
|
||||
? 'Timed out running yt-dlp.'
|
||||
: (stderr || err.message).trim()
|
||||
resolve({ ok: false, error: msg })
|
||||
return
|
||||
}
|
||||
resolve({ ok: true, version: stdout.trim() })
|
||||
}
|
||||
resolve({ ok: true, version: stdout.trim() })
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user