diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index 7eb4a86..6b88129 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -755,9 +755,15 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n *Round 6 (2026-06-29) — behavioral/logic edge cases:* -- [ ] **L136 — Incognito leaks outside history.** Even if the (unreachable, M6) private flag were set, +- [x] **L136 — Incognito leaks outside history.** Even if the (unreachable, M6) private flag were set, `download.ts` still writes failures to `errorlog.json` (title + URL) and shows the title in completion - notifications — the "private" promise covers only history. + notifications — the "private" promise covers only history. *Fixed: `incognito` now flows through + `StartDownloadOptions` to main (the renderer's launch path passes `item.incognito`), where main enforces + the full "no logging, no history, no cookies" promise: `logFailure` skips the errorlog write (and the + pre-spawn errorlog in [ipc.ts](src/main/ipc.ts) too), `notify` uses a generic outcome title and drops the + detail body so no title/URL lands in the Windows Action Center, and both cookie sources + (`--cookies`/`--cookies-from-browser`) are withheld so a private download can't be tied to the signed-in + identity. (M6 wired the toggle; this makes the promise true in main, not just the renderer.)* - [x] **L137 — Stuck 0% for unknown-size downloads.** `parseProgress` returns `progress: 0` when `total_bytes`/`_estimate` are absent (livestreams, some sites), so the bar reads 0% the whole time instead of an indeterminate state. *Fixed: `parseProgress` now sets `sizeUnknown: !totalBytes` on diff --git a/src/main/download.ts b/src/main/download.ts index 9a6ffe6..da70d93 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -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 + ) } }) } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 101def9..a279b66 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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, diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index c8e6549..decd534 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -114,6 +114,9 @@ export const useDownloads = create((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 }) diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 938b16c..014022e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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 /**