3b0d668f6c
UI23: cookie sign-in + PO-token windows open with the app's resolved theme background (passed from the IPC layer), no more white flash. W12: multi-resolution fallback tray icon (16/24/32/48 via addRepresentation dataURL, embedded in trayFallbackIcons.ts); decode + scale factors verified in an Electron probe. W15: hardwareAcceleration setting (default false = software rendering); index.ts gates disableHardwareAcceleration on it after the portable redirect; Appearance card switch with restart note; boot path verified. L131: resolved by-design — clearing the queue acknowledges failures; flashFrame (W10) covers attention, Diagnostics persists the record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
import { app, safeStorage, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
|
|
import { existsSync, statSync, unlinkSync, writeFileSync, readFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { assertHttpUrl } from './url'
|
|
import { logger } from './logger'
|
|
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. Exported so the PO-token window can
|
|
* share the same session (and thus the user's YouTube sign-in state).
|
|
*/
|
|
export const LOGIN_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: LOGIN_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))
|
|
}
|
|
|
|
// The persisted cookie jar is encrypted at rest (H7). yt-dlp's `--cookies` needs
|
|
// a plaintext file, so the stored form is encrypted via Electron safeStorage
|
|
// (DPAPI on Windows — the same protection settings secrets get) and only ever
|
|
// decrypted to a short-lived temp file for the duration of a download. This
|
|
// matters for the portable build, where userData sits next to the exe (USB /
|
|
// shared Downloads folder) and a plaintext jar would hand over a logged-in session.
|
|
function cookieStorePath(): string {
|
|
return join(app.getPath('userData'), 'cookies.dat')
|
|
}
|
|
|
|
function legacyCookiePath(): string {
|
|
return join(app.getPath('userData'), 'cookies.txt')
|
|
}
|
|
|
|
// Marks ciphertext so a read can tell it from legacy/fallback plaintext (written
|
|
// before encryption, or where safeStorage was unavailable). Mirrors settings.ts.
|
|
const ENC_PREFIX = 'enc:v1:'
|
|
|
|
/** Persist the Netscape cookie text, encrypted where safeStorage is available. */
|
|
function storeCookies(netscape: string): void {
|
|
let out = netscape
|
|
try {
|
|
if (safeStorage.isEncryptionAvailable()) {
|
|
out = ENC_PREFIX + safeStorage.encryptString(netscape).toString('base64')
|
|
}
|
|
} catch {
|
|
/* fall through — store plaintext, as it was before encryption existed */
|
|
}
|
|
writeFileSync(cookieStorePath(), out)
|
|
}
|
|
|
|
/** Decrypt the persisted cookie text, or null if none / undecryptable here. */
|
|
function loadCookies(): string | null {
|
|
const p = cookieStorePath()
|
|
if (!existsSync(p)) return null
|
|
const stored = readFileSync(p, 'utf8')
|
|
if (!stored.startsWith(ENC_PREFIX)) return stored // legacy / fallback plaintext
|
|
try {
|
|
return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64'))
|
|
} catch {
|
|
// A different Windows user/machine (portable jar copied elsewhere) or a
|
|
// corrupt blob — unusable; treat as no cookies rather than feed ciphertext.
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One-time migration of a pre-encryption plaintext `cookies.txt` into the
|
|
* encrypted store, deleting the plaintext so a logged-in session no longer sits
|
|
* in the open after an update (H7). Best-effort; safe to call on every launch.
|
|
*/
|
|
export function migrateLegacyCookies(): void {
|
|
const legacy = legacyCookiePath()
|
|
try {
|
|
if (!existsSync(legacy)) return
|
|
if (!existsSync(cookieStorePath())) storeCookies(readFileSync(legacy, 'utf8'))
|
|
unlinkSync(legacy)
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
/** Whether a stored cookie jar exists (regardless of decryptability here). */
|
|
export function hasStoredCookies(): boolean {
|
|
return existsSync(cookieStorePath())
|
|
}
|
|
|
|
/**
|
|
* Decrypt the jar to a plaintext file at `dest` for yt-dlp's `--cookies`. Returns
|
|
* false when there's nothing usable to write. The caller deletes `dest` once the
|
|
* download settles, keeping the plaintext window as short as possible.
|
|
*/
|
|
export function materializeCookies(dest: string): boolean {
|
|
const text = loadCookies()
|
|
if (text == null) return false
|
|
try {
|
|
writeFileSync(dest, text)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function getCookiesStatus(): CookiesStatus {
|
|
const p = cookieStorePath()
|
|
if (!existsSync(p)) return { exists: false }
|
|
return { exists: true, savedAt: statSync(p).mtimeMs }
|
|
}
|
|
|
|
export async function clearCookies(): Promise<void> {
|
|
const p = cookieStorePath()
|
|
if (existsSync(p)) unlinkSync(p)
|
|
await session.fromPartition(LOGIN_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(LOGIN_PARTITION)
|
|
.cookies.get({})
|
|
.then((cookies) => {
|
|
// Don't persist an empty jar: closing the window without signing in would
|
|
// otherwise leave a useless "saved" cookie file behind (L50). The renderer
|
|
// turns cookieCount === 0 into a "did you sign in?" hint.
|
|
if (cookies.length > 0) storeCookies(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,
|
|
parent?: BrowserWindow,
|
|
/** app-theme window background (UI23) — supplied by the IPC layer so the
|
|
* sign-in chrome matches the app's Light/Dark instead of flashing white. */
|
|
backgroundColor?: 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((e) => logger.error('cookie login loadURL failed', e))
|
|
loginWindow.focus()
|
|
return
|
|
}
|
|
|
|
pendingResolvers = [resolve]
|
|
let win: BrowserWindow
|
|
try {
|
|
win = new BrowserWindow({
|
|
width: 480,
|
|
height: 720,
|
|
title: 'Sign in — AeroFetch',
|
|
// Match the app's resolved theme (UI23) so the window doesn't flash a
|
|
// default-white background before the remote page paints.
|
|
backgroundColor,
|
|
autoHideMenuBar: true,
|
|
// Group under the app window instead of taking its own taskbar button
|
|
// (W6); a child window stays above its parent without a modal block.
|
|
parent: parent && !parent.isDestroyed() ? parent : undefined,
|
|
webPreferences: {
|
|
partition: LOGIN_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))
|
|
|
|
// 'close' fires on a normal user close; a programmatic destroy() fires only
|
|
// 'closed'. Latch whichever runs first so the partition is exported exactly
|
|
// once and the promise can never hang (B7) — without this, a destroy()
|
|
// without a preceding 'close' would leave pendingResolvers pending forever.
|
|
let exportStarted = false
|
|
const exportOnce = (): void => {
|
|
if (exportStarted) return
|
|
exportStarted = true
|
|
exportAndResolve()
|
|
}
|
|
win.on('closed', () => {
|
|
loginWindow = null
|
|
// Fallback for destroy()/closed-without-close: the partition outlives the
|
|
// window, so a late export still captures whatever cookies were collected.
|
|
exportOnce()
|
|
})
|
|
// 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', exportOnce)
|
|
|
|
win.loadURL(validUrl).catch(() => {
|
|
/* navigation errors surface as Chromium's own error page */
|
|
})
|
|
})
|
|
}
|