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:
+8
-2
@@ -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
|
||||
|
||||
+38
-8
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user