import { app, session, BrowserWindow, type Cookie, type WebContents } 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' /** * The sign-in window renders untrusted remote content, so every navigation and * popup is confined to web URLs — http(s), plus about:blank. This stops a * logged-in (or malicious) page from steering the window to a file:// URL, to * the app's own aerofetch:// protocol handler, or to any other external URI * scheme used as a pivot. (audit T4) */ export function isAllowedLoginUrl(target: string): boolean { try { const { protocol } = new URL(target) return protocol === 'http:' || protocol === 'https:' || protocol === 'about:' } catch { return false } } /** * Apply the web-only confinement to a sign-in webContents: block top-frame * navigations and server redirects to non-web URLs, restrict popups to web URLs * (reusing the same secure, partitioned webPreferences), and recurse into any * popup the page opens so a nested window can't escape the policy either. The * window-open handler alone only governs NEW windows, not navigations of an * existing one — both vectors are covered here. (audit T4) */ function hardenLoginWebContents(wc: WebContents): void { wc.setWindowOpenHandler((details) => { if (!isAllowedLoginUrl(details.url)) return { action: 'deny' } return { action: 'allow', overrideBrowserWindowOptions: { autoHideMenuBar: true, webPreferences: { partition: PARTITION, sandbox: true, contextIsolation: true, nodeIntegration: false } } } }) wc.on('will-navigate', (e, navUrl) => { if (!isAllowedLoginUrl(navUrl)) e.preventDefault() }) wc.on('will-redirect', (e, navUrl) => { if (!isAllowedLoginUrl(navUrl)) e.preventDefault() }) wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents)) } 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 { 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 { 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 // Confine every navigation/popup to web URLs (recursively, so OAuth/SSO // popups sharing the cookie partition are covered too), and deny all gated // web permissions — signing in needs no camera/mic/geolocation/etc., and the // page is untrusted. (audit T4) hardenLoginWebContents(win.webContents) win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(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 */ }) }) }