fix(main): L136 — enforce incognito in main (no errorlog / toast / cookie leak)

Incognito lived only in the renderer (history skip), so a "private" download
still leaked in main: errorlog entries (title+URL), OS toasts showing the title,
and the signed-in cookies attached — the UI promised "no logging, no history, no
cookies" but only history held.

Plumb `incognito` through StartDownloadOptions and enforce it in main:
- logFailure skips the errorlog write (and the pre-spawn errorlog in ipc.ts).
- notify uses a generic outcome title and drops the detail body, so no title/URL
  lands in the Windows Action Center.
- both cookie sources (--cookies login jar, --cookies-from-browser) are withheld,
  so a private download can't be tied to the user's identity.

typecheck + 262 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 09:14:49 -04:00
parent 3baba3b7bc
commit 701c73a889
5 changed files with 59 additions and 12 deletions
+38 -8
View File
@@ -90,9 +90,12 @@ 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 {
function notify(wc: WebContents, title: string, body: string, incognito = false): void {
if (!getSettings().notifyOnComplete || !Notification.isSupported()) return
const n = new Notification({ title, body, icon: getAppIconImage() })
// L136: an incognito ("private") download must not leak its title/URL into an OS
// toast (it persists in the Windows Action Center). Callers pass a generic outcome
// title for the private case; here we additionally drop the detail body.
const n = new Notification({ title, body: incognito ? '' : body, icon: getAppIconImage() })
n.on('click', () => {
if (wc.isDestroyed()) return
const win = BrowserWindow.fromWebContents(wc)
@@ -106,6 +109,9 @@ function notify(wc: WebContents, title: string, body: string): void {
}
function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void {
// L136: incognito downloads are never recorded — not even a failure entry, which
// would persist the title + URL in the user-facing diagnostics log.
if (opts.incognito) return
addErrorLog({
id: opts.id,
title,
@@ -199,7 +205,9 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
proxy: settings.proxy,
rateLimit: settings.rateLimit,
aria2cPath,
cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
// L136/M6: incognito attaches no cookies from the browser either.
cookiesFromBrowser:
!opts.incognito && settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
cookiesFile,
restrictFilenames: settings.restrictFilenames,
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
@@ -279,7 +287,9 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
// 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()) {
// L136/M6: an incognito download attaches no saved login cookies (the UI promises
// "no cookies"), so it can't be tied to the user's signed-in identity.
if (!opts.incognito && getSettings().cookieSource === 'login' && hasStoredCookies()) {
// Unique per spawn (not just per id): a retry/resume reuses opts.id, and the
// superseded download's cleanupCookies() must not unlink the new spawn's jar.
const tmp = join(tmpdir(), `aerofetch-cookies-${opts.id}-${++spawnSeq}.txt`)
@@ -390,7 +400,12 @@ function wireChildProcess(params: {
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, getTitle(), msg)
notify(wc, getTitle() ?? 'Download failed', msg)
notify(
wc,
opts.incognito ? 'Download stopped' : (getTitle() ?? 'Download failed'),
msg,
opts.incognito
)
}, STALL_TIMEOUT_MS)
}
bumpWatchdog()
@@ -434,7 +449,12 @@ function wireChildProcess(params: {
if (!rec.canceled && !rec.paused) {
send(wc, { type: 'error', id: opts.id, error: err.message })
logFailure(opts, getTitle(), err.message)
notify(wc, getTitle() ?? 'Download failed', err.message)
notify(
wc,
opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
err.message,
opts.incognito
)
}
})
@@ -449,12 +469,22 @@ function wireChildProcess(params: {
if (rec.canceled || rec.paused) return
if (code === 0) {
send(wc, { type: 'done', id: opts.id, filePath })
notify(wc, getTitle() ?? 'Download complete', 'Finished downloading.')
notify(
wc,
opts.incognito ? 'Download complete' : (getTitle() ?? 'Download complete'),
'Finished downloading.',
opts.incognito
)
} else {
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
send(wc, { type: 'error', id: opts.id, error: msg })
logFailure(opts, getTitle(), msg)
notify(wc, getTitle() ?? 'Download failed', msg)
notify(
wc,
opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
msg,
opts.incognito
)
}
})
}
+3 -2
View File
@@ -111,8 +111,9 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
const result = startDownload(e.sender, opts)
// Pre-spawn failures (missing yt-dlp.exe, bad URL, duplicate id) never reach
// download.ts's own close/error handlers, so log them here instead.
if (!result.ok) {
// download.ts's own close/error handlers, so log them here instead — unless the
// download is incognito, which is never recorded (L136).
if (!result.ok && !opts.incognito) {
addErrorLog({
id: opts.id,
url: opts.url,
+3
View File
@@ -114,6 +114,9 @@ export const useDownloads = create<DownloadState>((set, get) => {
options: item.options,
extraArgs: item.extraArgs,
trim: item.trim,
// Carry the private flag to main so it also skips errorlog, notification
// detail, and cookies — not just the renderer's history skip (L136).
incognito: item.incognito,
meta: item.probedMeta,
collection: item.collection
})
+7
View File
@@ -423,6 +423,13 @@ export interface StartDownloadOptions {
formatId?: string
/** whether the chosen format already includes audio (skips +bestaudio) */
formatHasAudio?: boolean
/**
* Incognito ("private") download. Main enforces the promise the UI makes ("no
* logging, no history, no cookies"): it writes no errorlog entry, reveals no
* title/URL in an OS notification, and attaches no saved login / browser
* cookies — not just the renderer's history skip (L136 / M6).
*/
incognito?: boolean
/** per-download post-processing override; main falls back to settings defaults */
options?: DownloadOptions
/**