Add Phase B: cookies, aria2c, proxy/rate-limit, restrict-filenames & archive
Implements the remaining Phase B (Access & networking) items from ROADMAP.md: cookie sources (browser cookie-store import, or a built-in sign-in window that exports a Netscape cookie file via a persisted Electron session partition), the aria2c external downloader, proxy/rate-limit, restrict-filenames, and a download-archive to skip repeats. Wires download.ts to the buildArgs() module added in the previous commit (it wasn't hooked up yet) and renames its options param NetworkOptions -> AccessOptions to reflect the wider scope now that cookies and filename/archive flags joined proxy/rate-limit/aria2c. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { app, session, BrowserWindow, type Cookie } from 'electron'
|
||||
import { existsSync, statSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { assertHttpUrl } from './url'
|
||||
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.
|
||||
*/
|
||||
const PARTITION = 'persist:aerofetch-login'
|
||||
|
||||
export function getCookiesFilePath(): string {
|
||||
return join(app.getPath('userData'), 'cookies.txt')
|
||||
}
|
||||
|
||||
export function getCookiesStatus(): CookiesStatus {
|
||||
const p = getCookiesFilePath()
|
||||
if (!existsSync(p)) return { exists: false }
|
||||
return { exists: true, savedAt: statSync(p).mtimeMs }
|
||||
}
|
||||
|
||||
export async function clearCookies(): Promise<void> {
|
||||
const p = getCookiesFilePath()
|
||||
if (existsSync(p)) unlinkSync(p)
|
||||
await session.fromPartition(PARTITION).clearStorageData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Render cookies in the Netscape cookie-jar format yt-dlp's `--cookies` reads.
|
||||
* Columns: domain, includeSubdomains, path, secure, expiry (unix seconds, 0
|
||||
* for session cookies), name, value.
|
||||
*/
|
||||
function toNetscapeCookieFile(cookies: Cookie[]): string {
|
||||
const lines = ['# Netscape HTTP Cookie File', '# Generated by AeroFetch — do not edit.', '']
|
||||
for (const c of cookies) {
|
||||
if (!c.domain || !c.name) continue
|
||||
const domain = c.hostOnly || c.domain.startsWith('.') ? c.domain : `.${c.domain}`
|
||||
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'
|
||||
)
|
||||
)
|
||||
}
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
let loginWindow: BrowserWindow | null = null
|
||||
let pendingResolvers: Array<(r: CookiesLoginResult) => void> = []
|
||||
|
||||
function exportAndResolve(): void {
|
||||
session
|
||||
.fromPartition(PARTITION)
|
||||
.cookies.get({})
|
||||
.then((cookies) => {
|
||||
writeFileSync(getCookiesFilePath(), toNetscapeCookieFile(cookies))
|
||||
const result: CookiesLoginResult = { ok: true, cookieCount: cookies.length }
|
||||
pendingResolvers.forEach((resolve) => resolve(result))
|
||||
})
|
||||
.catch((e) => {
|
||||
const result: CookiesLoginResult = { ok: false, error: (e as Error).message }
|
||||
pendingResolvers.forEach((resolve) => resolve(result))
|
||||
})
|
||||
.finally(() => {
|
||||
pendingResolvers = []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (or focus + navigate) a sign-in window backed by a persisted session
|
||||
* partition. The user logs into whatever site they need, then closes the
|
||||
* 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> {
|
||||
return new Promise((resolve) => {
|
||||
let validUrl: string
|
||||
try {
|
||||
validUrl = assertHttpUrl(url)
|
||||
} catch (e) {
|
||||
resolve({ ok: false, error: (e as Error).message })
|
||||
return
|
||||
}
|
||||
|
||||
if (loginWindow && !loginWindow.isDestroyed()) {
|
||||
pendingResolvers.push(resolve)
|
||||
loginWindow.loadURL(validUrl).catch(() => {})
|
||||
loginWindow.focus()
|
||||
return
|
||||
}
|
||||
|
||||
pendingResolvers = [resolve]
|
||||
let win: BrowserWindow
|
||||
try {
|
||||
win = new BrowserWindow({
|
||||
width: 480,
|
||||
height: 720,
|
||||
title: 'Sign in — AeroFetch',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
partition: PARTITION,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
pendingResolvers = []
|
||||
resolve({ ok: false, error: (e as Error).message })
|
||||
return
|
||||
}
|
||||
loginWindow = win
|
||||
|
||||
// Some sites log in via an OAuth/SSO popup. Let those open as real windows
|
||||
// sharing the same partition, rather than silently swallowing the click.
|
||||
win.webContents.setWindowOpenHandler(() => ({
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: {
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: { partition: PARTITION, sandbox: true, contextIsolation: true, nodeIntegration: false }
|
||||
}
|
||||
}))
|
||||
|
||||
win.on('closed', () => {
|
||||
loginWindow = null
|
||||
})
|
||||
// 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.loadURL(validUrl).catch(() => {
|
||||
/* navigation errors surface as Chromium's own error page */
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user